From 4b560ba96ac94c5dbd8a6653e457eaf38605f1f1 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 28 Jul 2026 09:24:43 -0400 Subject: [PATCH 1/3] Rename morpheme-editor Reset string key from delete to reset The button labeled "Reset" and its behavior reset the morpheme breakdown rather than deleting it; align the localized string key and doc wording. Co-Authored-By: Claude Opus 4.8 --- contributions/localizedStrings.json | 2 +- src/__tests__/components/MorphemeEditor.test.tsx | 2 +- src/components/MorphemeEditor.tsx | 4 ++-- src/components/TokenChip.tsx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index e3f31b32..0f3f0e44 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -37,7 +37,7 @@ "%interlinearizer_glossInput_placeholder%": "gloss", "%interlinearizer_freeTranslationInput_placeholder%": "Free translation", "%interlinearizer_morphemeEditor_splitLabel%": "Split into morphemes", - "%interlinearizer_morphemeEditor_delete%": "Reset", + "%interlinearizer_morphemeEditor_reset%": "Reset", "%interlinearizer_morphemeEditor_cancel%": "Cancel", "%interlinearizer_morphemeEditor_done%": "Done", "%interlinearizer_morphemeEditor_emptyHint%": "Enter morpheme forms separated by spaces", diff --git a/src/__tests__/components/MorphemeEditor.test.tsx b/src/__tests__/components/MorphemeEditor.test.tsx index bfb00705..60b021bd 100644 --- a/src/__tests__/components/MorphemeEditor.test.tsx +++ b/src/__tests__/components/MorphemeEditor.test.tsx @@ -12,7 +12,7 @@ jest.mock('../../components/AnalysisStore'); const LOCALIZED = { '%interlinearizer_morphemeEditor_splitLabel%': 'Split into morphemes', - '%interlinearizer_morphemeEditor_delete%': 'Reset', + '%interlinearizer_morphemeEditor_reset%': 'Reset', '%interlinearizer_morphemeEditor_cancel%': 'Cancel', '%interlinearizer_morphemeEditor_done%': 'Done', '%interlinearizer_morphemeEditor_emptyHint%': 'Enter morpheme forms separated by spaces', diff --git a/src/components/MorphemeEditor.tsx b/src/components/MorphemeEditor.tsx index 3591a161..2103a2eb 100644 --- a/src/components/MorphemeEditor.tsx +++ b/src/components/MorphemeEditor.tsx @@ -11,7 +11,7 @@ import type { KeyboardEvent, MouseEvent } from 'react'; const POPOVER_STRING_KEYS = [ '%interlinearizer_morphemeEditor_splitLabel%', - '%interlinearizer_morphemeEditor_delete%', + '%interlinearizer_morphemeEditor_reset%', '%interlinearizer_morphemeEditor_cancel%', '%interlinearizer_morphemeEditor_done%', '%interlinearizer_morphemeEditor_emptyHint%', @@ -296,7 +296,7 @@ export function MorphemeBreakdownPopover({ type="button" variant="outline" > - {localizedStrings['%interlinearizer_morphemeEditor_delete%']} + {localizedStrings['%interlinearizer_morphemeEditor_reset%']} )} - - - - - ); -} - /** * 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', ); }); From 6b194632d31ccf8bb517dd28561b96cd73b6ac0f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 28 Jul 2026 10:05:35 -0400 Subject: [PATCH 3/3] Reopen the morpheme editor when any breakdown cell is clicked The forms row renders the first morpheme as a