diff --git a/src/screens/novel/components/EditInfoModal.tsx b/src/screens/novel/components/EditInfoModal.tsx index 868e8396..b424cb45 100644 --- a/src/screens/novel/components/EditInfoModal.tsx +++ b/src/screens/novel/components/EditInfoModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { FlatList, Pressable, @@ -19,6 +19,7 @@ import { NovelInfo } from '@database/types'; import { NovelStatus } from '@plugins/types'; import { translateNovelStatus } from '@utils/translateEnum'; import { showToast } from '@utils/showToast'; +import { parseGenres } from '../utils/genres'; interface EditInfoModalProps { theme: ThemeColors; @@ -55,12 +56,15 @@ const EditInfoModalContent = ({ const [saving, setSaving] = useState(false); const [newGenre, setNewGenre] = useState(''); + const genres = useMemo( + () => parseGenres(novelInfo.genres), + [novelInfo.genres], + ); const removeTag = (t: string) => { setNovelInfo(current => ({ ...current, - genres: current.genres - ?.split(',') + genres: parseGenres(current.genres) .filter(item => item !== t) .join(','), })); @@ -199,9 +203,9 @@ const EditInfoModalContent = ({ setNovelInfo(prevVal => ({ ...prevVal, - genres: prevVal.genres - ? `${prevVal.genres},` + newGenreTrimmed - : newGenreTrimmed, + genres: [...parseGenres(prevVal.genres), newGenreTrimmed].join( + ',', + ), })); setNewGenre(''); }} @@ -210,11 +214,11 @@ const EditInfoModalContent = ({ style={styles.inputWrapper} /> - {novelInfo.genres !== undefined && novelInfo.genres !== '' ? ( + {genres.length > 0 ? ( 'novelTag' + index} renderItem={({ item }) => ( removeTag(item)}> diff --git a/src/screens/novel/components/Info/NovelInfoComponents.tsx b/src/screens/novel/components/Info/NovelInfoComponents.tsx index 56d4a493..75a75439 100644 --- a/src/screens/novel/components/Info/NovelInfoComponents.tsx +++ b/src/screens/novel/components/Info/NovelInfoComponents.tsx @@ -9,6 +9,7 @@ import { isMissingNovelCover } from '../../../../components/NovelCoverImage'; import { ThemeColors } from '@theme/types'; import { getString } from '@i18n/translations'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { parseGenres } from '../../utils/genres'; interface CoverImageProps { children: React.ReactNode; @@ -269,26 +270,29 @@ const TrackerButton = ({ const genreKeyExtractor = (_item: string, index: number) => 'genre' + index; -const NovelGenres = memo( - ({ theme, genres }: { theme: ThemeColors; genres: string }) => { - const data = useMemo(() => genres.split(/,\s*/), [genres]); - const renderGenre = useCallback( - ({ item }: { item: string }) => , - [theme], - ); +interface NovelGenresProps { + theme: ThemeColors; + genres?: string | null; +} - return ( - - ); - }, -); +const NovelGenres = memo(({ theme, genres }: NovelGenresProps) => { + const data = useMemo(() => parseGenres(genres), [genres]); + const renderGenre = useCallback( + ({ item }: { item: string }) => , + [theme], + ); + + return ( + + ); +}); export { NovelInfoContainer, diff --git a/src/screens/novel/components/Info/__tests__/NovelInfoComponents.test.tsx b/src/screens/novel/components/Info/__tests__/NovelInfoComponents.test.tsx new file mode 100644 index 00000000..9a23ae88 --- /dev/null +++ b/src/screens/novel/components/Info/__tests__/NovelInfoComponents.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react-native'; +import type { ThemeColors } from '@theme/types'; +import { NovelGenres } from '../NovelInfoComponents'; + +jest.mock('../../../../../components', () => { + const { Text } = require('react-native'); + + return { + Chip: ({ label }: { label: string }) => {label}, + NovelCoverImage: () => null, + }; +}); + +const theme = {} as ThemeColors; + +describe('NovelGenres', () => { + it('renders normalized genres', () => { + render( + , + ); + + expect(screen.getByText('Fantasy')).toBeTruthy(); + expect(screen.getByText('Adventure')).toBeTruthy(); + expect(screen.getByText('Romance')).toBeTruthy(); + }); + + it.each([ + ['missing', undefined], + ['null', null], + ['malformed', ['Fantasy'] as unknown as string], + ])('does not crash for %s genres', (_case, genres) => { + expect(() => + render(), + ).not.toThrow(); + }); +}); diff --git a/src/screens/novel/utils/__tests__/genres.test.ts b/src/screens/novel/utils/__tests__/genres.test.ts new file mode 100644 index 00000000..7d47b369 --- /dev/null +++ b/src/screens/novel/utils/__tests__/genres.test.ts @@ -0,0 +1,18 @@ +import { parseGenres } from '../genres'; + +describe('parseGenres', () => { + it('normalizes comma-separated genres', () => { + expect(parseGenres('Fantasy, Adventure, , Romance ')).toEqual([ + 'Fantasy', + 'Adventure', + 'Romance', + ]); + }); + + it.each([undefined, null, ['Fantasy'], { genre: 'Fantasy' }])( + 'returns an empty list for malformed input: %p', + genres => { + expect(parseGenres(genres)).toEqual([]); + }, + ); +}); diff --git a/src/screens/novel/utils/genres.ts b/src/screens/novel/utils/genres.ts new file mode 100644 index 00000000..7f727a6b --- /dev/null +++ b/src/screens/novel/utils/genres.ts @@ -0,0 +1,7 @@ +export const parseGenres = (genres: unknown): string[] => + typeof genres === 'string' + ? genres + .split(',') + .map(genre => genre.trim()) + .filter(Boolean) + : [];