diff --git a/AGENTS.md b/AGENTS.md index 318d1119..b4452f34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,10 @@ Jest with ts-jest, jsdom environment. PAPI is fully mocked in `__mocks__/`. Cove `@papi/backend` and `@papi/frontend` mocks are mutually exclusive: backend tests use `papi-backend.ts`, WebView tests use `papi-frontend.ts` + `papi-frontend-react.ts`. Each mock file ends with `export {}` so TypeScript treats it as a module. +### Testing hooks + +Test context hooks and dispatchers directly with `renderHook` + a `wrapper`, not a throwaway button-and-click component. See `renderStoreHook` in [AnalysisStore.test.tsx](src/__tests__/components/AnalysisStore.test.tsx) for the provider-wrapping pattern: return the hooks under test from the callback, drive dispatchers inside `act(...)`, and assert on state read off `result.current`. Reserve rendered components for behavior that genuinely needs the DOM — re-render / render-count assertions and mount/unmount lifecycle. + ### Branches not worth testing 100% coverage is enforced, but coverage is a floor, not a goal — chasing it produces low-value tests that bloat the suite and slow it down. Do **not** write a dedicated test for a branch whose only purpose is to satisfy the coverage gate when the branch carries no real behavior. Such branches should instead be excluded from coverage (via `/* v8 ignore next */` with a one-line reason, or the explicit exclusions in the Jest config) rather than tested. Branches not worth a dedicated test: diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index bb043b5b..0f3f0e44 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -37,9 +37,12 @@ "%interlinearizer_glossInput_placeholder%": "gloss", "%interlinearizer_freeTranslationInput_placeholder%": "Free translation", "%interlinearizer_morphemeEditor_splitLabel%": "Split into morphemes", - "%interlinearizer_morphemeEditor_delete%": "Delete", + "%interlinearizer_morphemeEditor_reset%": "Reset", "%interlinearizer_morphemeEditor_cancel%": "Cancel", "%interlinearizer_morphemeEditor_done%": "Done", + "%interlinearizer_morphemeEditor_emptyHint%": "Enter morpheme forms separated by spaces", + "%interlinearizer_morphemeEditor_confirmResetPrompt%": "Discard this breakdown and its glosses?", + "%interlinearizer_morphemeEditor_confirmResetAction%": "Reset", "%interlinearizer_morphemeGloss_label%": "Gloss for morpheme {form}", "%interlinearizer_tokenChip_editMorphemes%": "Edit morpheme breakdown for {token}", "%interlinearizer_tokenChip_defineMorphemes%": "Define morpheme breakdown for {token}", diff --git a/src/__tests__/components/AnalysisStore.test.tsx b/src/__tests__/components/AnalysisStore.test.tsx index ee211df8..00c3632e 100644 --- a/src/__tests__/components/AnalysisStore.test.tsx +++ b/src/__tests__/components/AnalysisStore.test.tsx @@ -2,9 +2,10 @@ /// /// -import { render, screen } from '@testing-library/react'; +import { act, render, renderHook, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { TextAnalysis, TokenAnalysis, TokenAnalysisLink } from 'interlinearizer'; +import type { ReactNode } from 'react'; import { AnalysisStoreProvider, useAnalysis, @@ -113,17 +114,6 @@ function AnalysisReader() { return {JSON.stringify(analysis)}; } -/** - * Renders a component that calls `useGlossDispatch` without a provider, used to assert the hook - * throws outside an {@link AnalysisStoreProvider}. - * - * @returns Nothing — only mounted to trigger the throw. - */ -function DispatchUser() { - useGlossDispatch(); - return undefined; -} - /** * Renders a button that calls `useGlossDispatch` to write a gloss, used to test dispatch. * @@ -145,6 +135,33 @@ function GlossWriter({ ); } +/** + * Renders `useHook` inside an {@link AnalysisStoreProvider} so a test can call the store's dispatch + * callbacks and read its selectors directly off `result.current`, without a throwaway button-and- + * click component. Mirrors the `renderHook` + `wrapper` idiom used elsewhere in the suite. + * + * @param useHook - Callback invoking the store hooks under test; its return is `result.current`. + * @param options - Provider props; `analysisLanguage` defaults to `'und'`. + * @returns The `renderHook` result. + */ +function renderStoreHook( + useHook: () => T, + options: Readonly<{ + analysisLanguage?: string; + initialAnalysis?: TextAnalysis; + onSave?: (analysis: TextAnalysis) => void; + onGlossChange?: (tokenRef: string, value: string) => void; + }> = {}, +) { + const { analysisLanguage = 'und', ...rest } = options; + const wrapper = ({ children }: Readonly<{ children: ReactNode }>) => ( + + {children} + + ); + return renderHook(useHook, { wrapper }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -313,37 +330,32 @@ describe('useAnalysis', () => { }); describe('useGlossDispatch', () => { - it('replaces the existing approved analysis on subsequent writes for the same token', async () => { - render( - - - - , - ); - await userEvent.click(screen.getByRole('button', { name: 'write' })); - await userEvent.click(screen.getByRole('button', { name: 'write' })); - const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? ''); + it('replaces the existing approved analysis on subsequent writes for the same token', () => { + const { result } = renderStoreHook(() => ({ + write: useGlossDispatch(), + analysis: useAnalysis(), + })); + act(() => result.current.write('tok-1', 'word', 'hi')); + act(() => result.current.write('tok-1', 'word', 'hi')); + const { analysis } = result.current; expect(analysis.tokenAnalyses).toHaveLength(1); expect(analysis.tokenAnalysisLinks).toHaveLength(1); expect(analysis.tokenAnalysisLinks[0].status).toBe('approved'); }); - it('creates a new approved analysis when writing to a different token', async () => { - render( - - - - - , - ); - await userEvent.click(screen.getAllByRole('button', { name: 'write' })[0]); - await userEvent.click(screen.getAllByRole('button', { name: 'write' })[1]); - const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? ''); + it('creates a new approved analysis when writing to a different token', () => { + const { result } = renderStoreHook(() => ({ + write: useGlossDispatch(), + analysis: useAnalysis(), + })); + act(() => result.current.write('tok-1', 'word', 'hi')); + act(() => result.current.write('tok-2', 'other', 'bye')); + const { analysis } = result.current; expect(analysis.tokenAnalyses).toHaveLength(2); expect(analysis.tokenAnalysisLinks).toHaveLength(2); }); - it('does not touch existing suggested analyses on write', async () => { + it('does not touch existing suggested analyses on write', () => { const suggested: TokenAnalysis = { id: 'suggested-1', surfaceText: 'word', @@ -362,14 +374,12 @@ describe('useGlossDispatch', () => { phraseAnalyses: [], phraseAnalysisLinks: [], }; - render( - - - - , + const { result } = renderStoreHook( + () => ({ write: useGlossDispatch(), analysis: useAnalysis() }), + { initialAnalysis: seed }, ); - await userEvent.click(screen.getByRole('button', { name: 'write' })); - const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? ''); + act(() => result.current.write('tok-1', 'word', 'new')); + const { analysis } = result.current; expect(analysis.tokenAnalyses).toHaveLength(2); const suggestedEntry = analysis.tokenAnalysisLinks.find((l) => l.status === 'suggested'); const approvedEntry = analysis.tokenAnalysisLinks.find((l) => l.status === 'approved'); @@ -377,49 +387,35 @@ describe('useGlossDispatch', () => { expect(approvedEntry?.analysisId).not.toBe('suggested-1'); }); - it('calls the onGlossChange spy with tokenRef and value', async () => { + it('calls the onGlossChange spy with tokenRef and value', () => { const spy = jest.fn(); - render( - - - , - ); - await userEvent.click(screen.getByRole('button', { name: 'write' })); + const { result } = renderStoreHook(() => useGlossDispatch(), { onGlossChange: spy }); + act(() => result.current('tok-1', 'word', 'hi')); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith('tok-1', 'hi'); }); - it('calls onSave with the updated TextAnalysis', async () => { + it('calls onSave with the updated TextAnalysis', () => { const onSave = jest.fn(); - render( - - - , - ); - await userEvent.click(screen.getByRole('button', { name: 'write' })); + const { result } = renderStoreHook(() => useGlossDispatch(), { onSave }); + act(() => result.current('tok-1', 'word', 'hi')); expect(onSave).toHaveBeenCalledTimes(1); const saved: TextAnalysis = onSave.mock.calls[0][0]; expect(saved.tokenAnalyses[0].gloss).toStrictEqual({ und: 'hi' }); }); - it('edits only this token when the payload is shared, forking the sibling off', async () => { + it('edits only this token when the payload is shared, forking the sibling off', () => { const onSave = jest.fn(); - render( - - - - , + const { result } = renderStoreHook( + () => ({ write: useGlossDispatch(), gloss: useGloss('tok-1') }), + { initialAnalysis: makeSharedAnalysis(), onSave }, ); - expect(screen.getByTestId('gloss')).toHaveTextContent('a'); + expect(result.current.gloss).toBe('a'); - await userEvent.click(screen.getByRole('button', { name: 'write' })); + act(() => result.current.write('tok-1', 'word', 'b')); // tok-1 reads the new 'b'; tok-2 keeps 'a' because editing forked the shared payload. - expect(screen.getByTestId('gloss')).toHaveTextContent('b'); + expect(result.current.gloss).toBe('b'); const saved: TextAnalysis = onSave.mock.calls[0][0]; expect(saved.tokenAnalyses).toHaveLength(2); const tok1Link = saved.tokenAnalysisLinks.find((l) => l.token.tokenRef === 'tok-1'); @@ -431,23 +427,17 @@ describe('useGlossDispatch', () => { expect(tok1Link?.analysisId).not.toBe(tok2Link?.analysisId); }); - it('clears a shared gloss for only this token, leaving the sibling untouched', async () => { + it('clears a shared gloss for only this token, leaving the sibling untouched', () => { const onSave = jest.fn(); - render( - - - - , + const { result } = renderStoreHook( + () => ({ write: useGlossDispatch(), gloss: useGloss('tok-1') }), + { initialAnalysis: makeSharedAnalysis(), onSave }, ); - await userEvent.click(screen.getByRole('button', { name: 'write' })); + act(() => result.current.write('tok-1', 'word', '')); // Clearing forks the shared payload so only tok-1's link goes away; tok-2 keeps the shared 'a'. - expect(screen.getByTestId('gloss')).toBeEmptyDOMElement(); + expect(result.current.gloss).toBe(''); const saved: TextAnalysis = onSave.mock.calls[0][0]; expect(saved.tokenAnalysisLinks.find((l) => l.token.tokenRef === 'tok-1')).toBeUndefined(); const tok2Link = saved.tokenAnalysisLinks.find((l) => l.token.tokenRef === 'tok-2'); @@ -458,7 +448,7 @@ describe('useGlossDispatch', () => { it('throws when called outside an AnalysisStoreProvider', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render()).toThrow( + expect(() => renderHook(() => useGlossDispatch())).toThrow( 'useGlossDispatch must be used inside an AnalysisStoreProvider', ); }); @@ -510,48 +500,6 @@ function PhraseLinkReader({ tokenRef }: Readonly<{ tokenRef: string }>) { return {link?.analysisId ?? 'none'}; } -/** - * Renders buttons that exercise `usePhraseDispatch`, used to assert phrase dispatch callbacks. - * - * @param props - Component props. - * @param props.phraseId - The phrase id to use for update and delete operations. - * @returns JSX element suitable for passing to `render`. - */ -function PhraseDispatchUser({ phraseId }: Readonly<{ phraseId: string }>) { - const { createPhrase, updatePhrase, deletePhrase, mergePhrases } = usePhraseDispatch(); - return ( - <> - - - - - - ); -} - /** * Renders a component that calls `usePhraseLinkMap` without a provider, to assert it throws. * @@ -572,16 +520,6 @@ function PhraseLinkForTokenUser() { return undefined; } -/** - * Renders a component that calls `usePhraseDispatch` without a provider, to assert it throws. - * - * @returns Nothing — only mounted to trigger the throw. - */ -function PhraseDispatchOutsideProvider() { - usePhraseDispatch(); - return undefined; -} - describe('usePhraseLinkMap', () => { it('returns an empty map when no approved phrase links exist', () => { render( @@ -724,15 +662,11 @@ describe('usePhraseLinkByIdGetter', () => { }); describe('usePhraseDispatch', () => { - it('createPhrase adds a new phrase and calls onSave', async () => { + it('createPhrase adds a new phrase and calls onSave', () => { const onSave = jest.fn(); - render( - - - , - ); + const { result } = renderStoreHook(() => usePhraseDispatch(), { onSave }); - await userEvent.click(screen.getByRole('button', { name: 'create' })); + act(() => result.current.createPhrase([{ tokenRef: 'tok-x', surfaceText: 'X' }])); expect(onSave).toHaveBeenCalledTimes(1); const saved: TextAnalysis = onSave.mock.calls[0][0]; @@ -740,19 +674,14 @@ describe('usePhraseDispatch', () => { expect(saved.phraseAnalysisLinks).toHaveLength(1); }); - it('updatePhrase modifies the token list and calls onSave', async () => { + it('updatePhrase modifies the token list and calls onSave', () => { const onSave = jest.fn(); - render( - - - , - ); + const { result } = renderStoreHook(() => usePhraseDispatch(), { + initialAnalysis: PHRASE_ANALYSIS, + onSave, + }); - await userEvent.click(screen.getByRole('button', { name: 'update' })); + act(() => result.current.updatePhrase('phrase-1', [{ tokenRef: 'tok-a', surfaceText: 'A' }])); expect(onSave).toHaveBeenCalledTimes(1); const saved: TextAnalysis = onSave.mock.calls[0][0]; @@ -760,19 +689,14 @@ describe('usePhraseDispatch', () => { expect(saved.phraseAnalysisLinks[0].tokens[0].tokenRef).toBe('tok-a'); }); - it('deletePhrase removes the phrase and calls onSave', async () => { + it('deletePhrase removes the phrase and calls onSave', () => { const onSave = jest.fn(); - render( - - - , - ); + const { result } = renderStoreHook(() => usePhraseDispatch(), { + initialAnalysis: PHRASE_ANALYSIS, + onSave, + }); - await userEvent.click(screen.getByRole('button', { name: 'delete' })); + act(() => result.current.deletePhrase('phrase-1')); expect(onSave).toHaveBeenCalledTimes(1); const saved: TextAnalysis = onSave.mock.calls[0][0]; @@ -780,19 +704,23 @@ describe('usePhraseDispatch', () => { expect(saved.phraseAnalysisLinks).toHaveLength(0); }); - it('mergePhrases grows the target phrase in one dispatch and calls onSave once', async () => { + it('mergePhrases grows the target phrase in one dispatch and calls onSave once', () => { const onSave = jest.fn(); - render( - - - , - ); + const { result } = renderStoreHook(() => usePhraseDispatch(), { + initialAnalysis: PHRASE_ANALYSIS, + onSave, + }); - await userEvent.click(screen.getByRole('button', { name: 'merge' })); + act(() => + result.current.mergePhrases( + 'phrase-1', + [ + { tokenRef: 'tok-a', surfaceText: 'A' }, + { tokenRef: 'tok-b', surfaceText: 'B' }, + ], + 'phrase-2', + ), + ); // A single dispatch means a single save — the intermediate two-phrase state is never observed. expect(onSave).toHaveBeenCalledTimes(1); @@ -806,7 +734,7 @@ describe('usePhraseDispatch', () => { it('throws when called outside an AnalysisStoreProvider', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render()).toThrow( + expect(() => renderHook(() => usePhraseDispatch())).toThrow( 'usePhraseDispatch must be used inside an AnalysisStoreProvider', ); }); @@ -887,47 +815,15 @@ describe('usePhraseGloss', () => { // usePhraseGlossDispatch // --------------------------------------------------------------------------- -/** - * Renders a button that writes a phrase gloss via `usePhraseGlossDispatch`. - * - * @param props - Component props. - * @param props.phraseId - Phrase id to write. - * @param props.value - Gloss value to write. - * @returns JSX element. - */ -function PhraseGlossWriter({ phraseId, value }: Readonly<{ phraseId: string; value: string }>) { - const dispatch = usePhraseGlossDispatch(); - return ( - - ); -} - -/** - * Renders a component that calls `usePhraseGlossDispatch` without a provider, to assert it throws. - * - * @returns Nothing — only mounted to trigger the throw. - */ -function PhraseGlossDispatchUser() { - usePhraseGlossDispatch(); - return undefined; -} - describe('usePhraseGlossDispatch', () => { - it('writes the phrase gloss and triggers onSave', async () => { + it('writes the phrase gloss and triggers onSave', () => { const onSave = jest.fn(); - render( - - - , - ); + const { result } = renderStoreHook(() => usePhraseGlossDispatch(), { + initialAnalysis: PHRASE_ANALYSIS, + onSave, + }); - await userEvent.click(screen.getByRole('button', { name: 'write' })); + act(() => result.current('phrase-1', 'beginning')); expect(onSave).toHaveBeenCalledTimes(1); const saved: TextAnalysis = onSave.mock.calls[0][0]; @@ -936,7 +832,7 @@ describe('usePhraseGlossDispatch', () => { it('throws when called outside an AnalysisStoreProvider', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render()).toThrow( + expect(() => renderHook(() => usePhraseGlossDispatch())).toThrow( 'usePhraseGlossDispatch must be used inside an AnalysisStoreProvider', ); }); @@ -1016,53 +912,12 @@ describe('useSegmentFreeTranslation', () => { // useSegmentFreeTranslationDispatch // --------------------------------------------------------------------------- -/** - * Renders a button that writes a segment free translation via `useSegmentFreeTranslationDispatch`. - * - * @param props - Component props. - * @param props.segmentId - Segment id to write. - * @param props.surfaceText - Segment baseline text to store. - * @param props.value - Free-translation value to write. - * @returns JSX element. - */ -function SegmentTranslationWriter({ - segmentId, - surfaceText, - value, -}: Readonly<{ segmentId: string; surfaceText: string; value: string }>) { - const dispatch = useSegmentFreeTranslationDispatch(); - return ( - - ); -} - -/** - * Renders a component that calls `useSegmentFreeTranslationDispatch` without a provider, to assert - * it throws. - * - * @returns Nothing — only mounted to trigger the throw. - */ -function SegmentTranslationDispatchUser() { - useSegmentFreeTranslationDispatch(); - return undefined; -} - describe('useSegmentFreeTranslationDispatch', () => { - it('writes the segment free translation and triggers onSave', async () => { + it('writes the segment free translation and triggers onSave', () => { const onSave = jest.fn(); - render( - - - , - ); + const { result } = renderStoreHook(() => useSegmentFreeTranslationDispatch(), { onSave }); - await userEvent.click(screen.getByRole('button', { name: 'write' })); + act(() => result.current('seg-1', 'In the beginning', 'au commencement')); expect(onSave).toHaveBeenCalledTimes(1); const saved: TextAnalysis = onSave.mock.calls[0][0]; @@ -1074,7 +929,7 @@ describe('useSegmentFreeTranslationDispatch', () => { it('throws when called outside an AnalysisStoreProvider', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render()).toThrow( + expect(() => renderHook(() => useSegmentFreeTranslationDispatch())).toThrow( 'useSegmentFreeTranslationDispatch must be used inside an AnalysisStoreProvider', ); }); @@ -1105,67 +960,6 @@ function LanguageReader() { return {lang}; } -/** - * Renders a button that dispatches a morpheme breakdown, used to test - * `useMorphemeBreakdownDispatch`. - * - * @param props.tokenRef - Token ref to write. - * @param props.surfaceText - Surface text of the token. - * @param props.forms - Morpheme forms to write. - * @param props.writingSystem - Writing system of the token's surface text. - * @returns JSX element suitable for passing to `render`. - */ -function MorphemeWriter({ - tokenRef, - surfaceText, - forms, - writingSystem, -}: Readonly<{ tokenRef: string; surfaceText: string; forms: string[]; writingSystem: string }>) { - const dispatch = useMorphemeBreakdownDispatch(); - return ( - - ); -} - -/** - * Renders a button that dispatches a morpheme breakdown deletion, used to test - * `useMorphemeDeleteDispatch`. - * - * @param props.tokenRef - Token ref whose breakdown to delete. - * @returns JSX element suitable for passing to `render`. - */ -function MorphemeDeleter({ tokenRef }: Readonly<{ tokenRef: string }>) { - const dispatch = useMorphemeDeleteDispatch(); - return ( - - ); -} - -/** - * Renders a button that dispatches a morpheme gloss, used to test `useMorphemeGlossDispatch`. - * - * @param props.tokenRef - Token ref to write. - * @param props.morphemeId - Morpheme id to gloss. - * @param props.value - Gloss value. - * @returns JSX element suitable for passing to `render`. - */ -function MorphemeGlossWriter({ - tokenRef, - morphemeId, - value, -}: Readonly<{ tokenRef: string; morphemeId: string; value: string }>) { - const dispatch = useMorphemeGlossDispatch(); - return ( - - ); -} - /** * Renders a component that calls `useMorphemes` without a provider, used to test the error. * @@ -1186,36 +980,6 @@ function LanguageUser() { return undefined; } -/** - * Renders a component that calls `useMorphemeBreakdownDispatch` without a provider. - * - * @returns Nothing — only mounted to trigger the throw. - */ -function MorphemeBreakdownDispatchUser() { - useMorphemeBreakdownDispatch(); - return undefined; -} - -/** - * Renders a component that calls `useMorphemeGlossDispatch` without a provider. - * - * @returns Nothing — only mounted to trigger the throw. - */ -function MorphemeGlossDispatchUser() { - useMorphemeGlossDispatch(); - return undefined; -} - -/** - * Renders a component that calls `useMorphemeDeleteDispatch` without a provider. - * - * @returns Nothing — only mounted to trigger the throw. - */ -function MorphemeDeleteDispatchUser() { - useMorphemeDeleteDispatch(); - return undefined; -} - describe('useMorphemes', () => { it('returns empty array when no morphemes exist', () => { render( @@ -1283,23 +1047,16 @@ describe('useAnalysisLanguage', () => { }); describe('useMorphemeBreakdownDispatch', () => { - it('writes morphemes and calls onSave', async () => { + it('writes morphemes and calls onSave', () => { const onSave = jest.fn(); - render( - - - - , + const { result } = renderStoreHook( + () => ({ breakdown: useMorphemeBreakdownDispatch(), morphemes: useMorphemes('tok-1') }), + { onSave }, ); - await userEvent.click(screen.getByRole('button', { name: 'break' })); + act(() => result.current.breakdown('tok-1', 'cat', ['ca', '-t'], 'en')); - expect(screen.getByTestId('morphemes')).toHaveTextContent('ca,-t'); + expect(result.current.morphemes.map((m) => m.form)).toStrictEqual(['ca', '-t']); expect(onSave).toHaveBeenCalledTimes(1); const saved: TextAnalysis = onSave.mock.calls[0][0]; expect(saved.tokenAnalyses).toHaveLength(1); @@ -1307,24 +1064,14 @@ describe('useMorphemeBreakdownDispatch', () => { expect(saved.tokenAnalyses[0].morphemes?.[0].writingSystem).toBe('en'); }); - it('forks a shared payload so a breakdown edit touches only this token', async () => { + it('forks a shared payload so a breakdown edit touches only this token', () => { const onSave = jest.fn(); - render( - - - , - ); + const { result } = renderStoreHook(() => useMorphemeBreakdownDispatch(), { + initialAnalysis: makeSharedAnalysis(), + onSave, + }); - await userEvent.click(screen.getByRole('button', { name: 'break' })); + act(() => result.current('tok-1', 'word', ['wor', 'd'], 'en')); // tok-1's forked copy gains the breakdown; tok-2's original keeps just the gloss. const saved: TextAnalysis = onSave.mock.calls[0][0]; @@ -1340,14 +1087,14 @@ describe('useMorphemeBreakdownDispatch', () => { it('throws when called outside an AnalysisStoreProvider', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render()).toThrow( + expect(() => renderHook(() => useMorphemeBreakdownDispatch())).toThrow( 'useMorphemeBreakdownDispatch must be used inside an AnalysisStoreProvider', ); }); }); describe('useMorphemeDeleteDispatch', () => { - it('removes the morpheme breakdown and calls onSave', async () => { + it('deletes the morpheme breakdown and calls onSave', async () => { const onSave = jest.fn(); const ta: TokenAnalysis = { id: 'ta-1', @@ -1370,16 +1117,23 @@ describe('useMorphemeDeleteDispatch', () => { phraseAnalyses: [], phraseAnalysisLinks: [], }; - render( + const wrapper = ({ children }: { children: ReactNode }) => ( - - - , + {children} + + ); + const { result } = renderHook( + () => ({ + deleteBreakdown: useMorphemeDeleteDispatch(), + morphemes: useMorphemes('tok-1'), + }), + { wrapper }, ); + expect(result.current.morphemes).toHaveLength(2); - await userEvent.click(screen.getByRole('button', { name: 'delete-morphemes' })); + act(() => result.current.deleteBreakdown('tok-1')); - expect(screen.getByTestId('morphemes')).toHaveTextContent(''); + expect(result.current.morphemes).toHaveLength(0); expect(onSave).toHaveBeenCalledTimes(1); const saved: TextAnalysis = onSave.mock.calls[0][0]; // The analysis carried no gloss, so the now-empty record and its link are removed entirely. @@ -1389,14 +1143,14 @@ describe('useMorphemeDeleteDispatch', () => { it('throws when called outside an AnalysisStoreProvider', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render()).toThrow( + expect(() => renderHook(() => useMorphemeDeleteDispatch())).toThrow( 'useMorphemeDeleteDispatch must be used inside an AnalysisStoreProvider', ); }); }); describe('useMorphemeGlossDispatch', () => { - it('writes a morpheme gloss and calls onSave', async () => { + it('writes a morpheme gloss and calls onSave', () => { const onSave = jest.fn(); const ta: TokenAnalysis = { id: 'ta-1', @@ -1419,20 +1173,19 @@ describe('useMorphemeGlossDispatch', () => { phraseAnalyses: [], phraseAnalysisLinks: [], }; - render( - - - , - ); + const { result } = renderStoreHook(() => useMorphemeGlossDispatch(), { + initialAnalysis: analysis, + onSave, + }); - await userEvent.click(screen.getByRole('button', { name: 'gloss' })); + act(() => result.current('tok-1', 'm-1', 'prefix')); expect(onSave).toHaveBeenCalledTimes(1); const saved: TextAnalysis = onSave.mock.calls[0][0]; expect(saved.tokenAnalyses[0].morphemes?.[0].gloss).toStrictEqual({ und: 'prefix' }); }); - it('forks a shared payload so a morpheme-gloss edit touches only this token', async () => { + it('forks a shared payload so a morpheme-gloss edit touches only this token', () => { const onSave = jest.fn(); const sharedMorpheme: TextAnalysis = { segmentAnalyses: [], @@ -1459,17 +1212,12 @@ describe('useMorphemeGlossDispatch', () => { phraseAnalyses: [], phraseAnalysisLinks: [], }; - render( - - - , - ); + const { result } = renderStoreHook(() => useMorphemeGlossDispatch(), { + initialAnalysis: sharedMorpheme, + onSave, + }); - await userEvent.click(screen.getByRole('button', { name: 'gloss' })); + act(() => result.current('tok-1', 'm1', 'root')); // tok-1's forked copy gets the morpheme gloss; tok-2's original morpheme stays bare. const saved: TextAnalysis = onSave.mock.calls[0][0]; @@ -1484,7 +1232,7 @@ describe('useMorphemeGlossDispatch', () => { it('throws when called outside an AnalysisStoreProvider', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render()).toThrow( + expect(() => renderHook(() => useMorphemeGlossDispatch())).toThrow( 'useMorphemeGlossDispatch must be used inside an AnalysisStoreProvider', ); }); @@ -1625,28 +1373,6 @@ function ResolvedReader({ return {resolved?.status ?? 'none'}; } -/** - * Renders a button that approves a chosen analysis for a token, used to test - * `useApproveAnalysisDispatch`. - * - * @param props.tokenRef - Token ref to approve for. - * @param props.surfaceText - Surface text snapshotted on the new link. - * @param props.analysisId - Payload id to approve. - * @returns JSX element suitable for passing to `render`. - */ -function Approver({ - tokenRef, - surfaceText, - analysisId, -}: Readonly<{ tokenRef: string; surfaceText: string; analysisId: string }>) { - const approve = useApproveAnalysisDispatch(); - return ( - - ); -} - describe('useShowSuggestions', () => { /** * Renders the active show-suggestions flag, used to assert on `useShowSuggestions`. @@ -1812,23 +1538,20 @@ describe('useSuggestionAfterClearing', () => { }); describe('useApproveAnalysisDispatch', () => { - it('approves the chosen payload for the token, flipping it from suggested to approved', async () => { + it('approves the chosen payload for the token, flipping it from suggested to approved', () => { const onSave = jest.fn(); - render( - - - - , + const { result } = renderStoreHook( + () => ({ + approve: useApproveAnalysisDispatch(), + resolved: useResolvedTokenAnalysis('tok-2', 'logos', true), + }), + { initialAnalysis: makeAnalysisWithGloss('tok-1', 'w', 'logos'), onSave }, ); - expect(screen.getByTestId('resolved')).toHaveTextContent('suggested'); + expect(result.current.resolved?.status).toBe('suggested'); - await userEvent.click(screen.getByRole('button', { name: 'approve' })); + act(() => result.current.approve('tok-2', 'logos', 'tok-1-analysis')); - expect(screen.getByTestId('resolved')).toHaveTextContent('approved'); + expect(result.current.resolved?.status).toBe('approved'); const saved: TextAnalysis = onSave.mock.calls[0][0]; // No new payload — tok-2 links to the existing shared analysis (frequency now 2). expect(saved.tokenAnalyses).toHaveLength(1); @@ -1839,7 +1562,7 @@ describe('useApproveAnalysisDispatch', () => { it('throws when called outside an AnalysisStoreProvider', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render()).toThrow( + expect(() => renderHook(() => useApproveAnalysisDispatch())).toThrow( 'useApproveAnalysisDispatch must be used inside an AnalysisStoreProvider', ); }); diff --git a/src/__tests__/components/MorphemeBox.test.tsx b/src/__tests__/components/MorphemeBox.test.tsx index f108d819..a31b3ff7 100644 --- a/src/__tests__/components/MorphemeBox.test.tsx +++ b/src/__tests__/components/MorphemeBox.test.tsx @@ -126,6 +126,17 @@ describe('MorphemeBox', () => { expect(onEditBreakdown).toHaveBeenCalledTimes(1); }); + it.each([ + ['first', 'hel'], + ['non-first', '-lo'], + ])('cancels the default click action on the %s form cell', (_label, form) => { + // Regression: the box sits in TokenChip's