From e6dd6f4ec7a39de18f35294811804c411279cdb1 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Thu, 18 Jun 2026 12:36:54 -0600 Subject: [PATCH 1/6] Add free translation input to segment view --- contributions/localizedStrings.json | 5 + contributions/projectSettings.json | 5 + .../components/AnalysisStore.test.tsx | 140 ++++++++++++ .../components/ContinuousView.test.tsx | 3 + .../components/Interlinearizer.test.tsx | 18 ++ .../components/InterlinearizerLoader.test.tsx | 30 +++ src/__tests__/components/PhraseBox.test.tsx | 8 + src/__tests__/components/SegmentView.test.tsx | 104 +++++++++ .../controls/ViewOptionsDropdown.test.tsx | 39 +++- src/__tests__/main.test.ts | 2 +- src/__tests__/store/analysisSlice.test.ts | 206 ++++++++++++++++++ src/__tests__/utils/localized-strings.test.ts | 18 ++ src/components/AnalysisStore.tsx | 47 ++++ src/components/InterlinearizerLoader.tsx | 27 ++- src/components/PhraseBox.tsx | 12 +- .../SegmentFreeTranslationInput.tsx | 67 ++++++ src/components/SegmentView.tsx | 67 +++++- src/components/TokenChip.tsx | 6 +- .../controls/ViewOptionsDropdown.tsx | 20 +- src/main.ts | 6 + src/store/analysisSlice.ts | 164 ++++++++++++++ src/types/interlinearizer.d.ts | 5 + src/types/view-options.ts | 2 + src/utils/localized-strings.ts | 18 ++ 24 files changed, 996 insertions(+), 23 deletions(-) create mode 100644 src/__tests__/utils/localized-strings.test.ts create mode 100644 src/components/SegmentFreeTranslationInput.tsx create mode 100644 src/utils/localized-strings.ts diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 27b27edb..cd921cea 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -30,6 +30,11 @@ "%interlinearizer_viewOption_showMorphology%": "Show morphology", "%interlinearizer_projectSettings_showMorphology%": "Show Morphology", "%interlinearizer_projectSettings_showMorphologyDescription%": "Display morpheme breakdown and per-morpheme glosses beneath each word token", + "%interlinearizer_viewOption_showFreeTranslation%": "Show free translation", + "%interlinearizer_projectSettings_showFreeTranslation%": "Show Free Translation", + "%interlinearizer_projectSettings_showFreeTranslationDescription%": "Display a free-translation input beneath each segment's tokens or baseline text", + "%interlinearizer_glossInput_placeholder%": "gloss", + "%interlinearizer_freeTranslationInput_placeholder%": "Free translation", "%interlinearizer_morphemeEditor_splitLabel%": "Split into morphemes", "%interlinearizer_morphemeEditor_delete%": "Delete", "%interlinearizer_morphemeEditor_cancel%": "Cancel", diff --git a/contributions/projectSettings.json b/contributions/projectSettings.json index 16f6a64d..3ff292e3 100644 --- a/contributions/projectSettings.json +++ b/contributions/projectSettings.json @@ -26,6 +26,11 @@ "label": "%interlinearizer_projectSettings_showMorphology%", "description": "%interlinearizer_projectSettings_showMorphologyDescription%", "default": false + }, + "interlinearizer.showFreeTranslation": { + "label": "%interlinearizer_projectSettings_showFreeTranslation%", + "description": "%interlinearizer_projectSettings_showFreeTranslationDescription%", + "default": false } } } diff --git a/src/__tests__/components/AnalysisStore.test.tsx b/src/__tests__/components/AnalysisStore.test.tsx index 08fb9bb5..6985d606 100644 --- a/src/__tests__/components/AnalysisStore.test.tsx +++ b/src/__tests__/components/AnalysisStore.test.tsx @@ -22,6 +22,8 @@ import { usePhraseGloss, usePhraseGlossDispatch, useReportGlossEditing, + useSegmentFreeTranslation, + useSegmentFreeTranslationDispatch, } from '../../components/AnalysisStore'; // --------------------------------------------------------------------------- @@ -819,6 +821,144 @@ describe('usePhraseGlossDispatch', () => { }); }); +// --------------------------------------------------------------------------- +// useSegmentFreeTranslation +// --------------------------------------------------------------------------- + +/** A `TextAnalysis` with an approved segment analysis carrying a free translation in `'und'`. */ +const SEGMENT_ANALYSIS_WITH_TRANSLATION: TextAnalysis = { + segmentAnalyses: [ + { id: 'sa-1', surfaceText: 'In the beginning', freeTranslation: { und: 'au commencement' } }, + ], + segmentAnalysisLinks: [{ analysisId: 'sa-1', status: 'approved', segmentId: 'seg-1' }], + tokenAnalyses: [], + tokenAnalysisLinks: [], + phraseAnalyses: [], + phraseAnalysisLinks: [], +}; + +/** + * Renders the free translation for a given segmentId, used to assert on + * `useSegmentFreeTranslation`. + * + * @param props - Component props. + * @param props.segmentId - Segment id to look up. + * @returns JSX element. + */ +function SegmentTranslationReader({ segmentId }: Readonly<{ segmentId: string }>) { + const value = useSegmentFreeTranslation(segmentId); + return {value}; +} + +/** + * Renders a component that calls `useSegmentFreeTranslation` without a provider, to assert it + * throws. + * + * @returns Nothing — only mounted to trigger the throw. + */ +function SegmentTranslationUser() { + useSegmentFreeTranslation('seg-1'); + return undefined; +} + +describe('useSegmentFreeTranslation', () => { + it('returns empty string when the segment has no approved analysis', () => { + render( + + + , + ); + expect(screen.getByTestId('segment-translation')).toHaveTextContent(''); + }); + + it('returns the free translation for the active analysis language', () => { + render( + + + , + ); + expect(screen.getByTestId('segment-translation')).toHaveTextContent('au commencement'); + }); + + it('throws when called outside an AnalysisStoreProvider', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + 'useSegmentFreeTranslation must be used inside an AnalysisStoreProvider', + ); + }); +}); + +// --------------------------------------------------------------------------- +// 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 () => { + const onSave = jest.fn(); + render( + + + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'write' })); + + expect(onSave).toHaveBeenCalledTimes(1); + const saved: TextAnalysis = onSave.mock.calls[0][0]; + expect(saved.segmentAnalyses[0]).toMatchObject({ + surfaceText: 'In the beginning', + freeTranslation: { und: 'au commencement' }, + }); + }); + + it('throws when called outside an AnalysisStoreProvider', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + 'useSegmentFreeTranslationDispatch must be used inside an AnalysisStoreProvider', + ); + }); +}); + // --------------------------------------------------------------------------- // Morpheme hooks // --------------------------------------------------------------------------- diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index 698d359f..30111c00 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -436,6 +436,7 @@ function requiredProps( simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }, }; } @@ -906,6 +907,7 @@ describe('ContinuousView scroll behavior', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} /> ); @@ -956,6 +958,7 @@ describe('ContinuousView scroll behavior', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} /> ); diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx index 4c2d7bd5..4a1634ac 100644 --- a/src/__tests__/components/Interlinearizer.test.tsx +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -354,6 +354,7 @@ function renderInterlinearizer({ simplifyPhrases = false, chapterLabelInVerse = false, showMorphology = false, + showFreeTranslation = false, }: { book?: Book; continuousScroll?: boolean; @@ -363,6 +364,7 @@ function renderInterlinearizer({ simplifyPhrases?: boolean; chapterLabelInVerse?: boolean; showMorphology?: boolean; + showFreeTranslation?: boolean; } = {}) { return render( withNav( @@ -378,6 +380,7 @@ function renderInterlinearizer({ simplifyPhrases, chapterLabelInVerse, showMorphology, + showFreeTranslation, }} />, navigate, @@ -521,6 +524,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} />, ), @@ -636,6 +640,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} />, ), @@ -675,6 +680,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} />, ), @@ -719,6 +725,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} />, ), @@ -740,6 +747,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} />, ), @@ -786,6 +794,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} />, ), @@ -826,6 +835,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} />, ), @@ -925,6 +935,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} />, ), @@ -953,6 +964,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} />, ), @@ -975,6 +987,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} />, ), @@ -999,6 +1012,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} />, ), @@ -1023,6 +1037,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} />, ), @@ -1046,6 +1061,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }, }; const { container, rerender } = render( @@ -1117,6 +1133,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }} /> ); @@ -1151,6 +1168,7 @@ describe('Interlinearizer', () => { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: false, }, }; const { container, rerender } = render( diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index 452b679e..d6ee2782 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -34,6 +34,8 @@ jest.mock('../../components/controls/ViewOptionsDropdown', () => ({ onChapterLabelInVerseChange, showMorphology, onShowMorphologyChange, + showFreeTranslation, + onShowFreeTranslationChange, }: { continuousScroll: boolean; onContinuousScrollChange: (v: boolean) => void; @@ -45,6 +47,8 @@ jest.mock('../../components/controls/ViewOptionsDropdown', () => ({ onChapterLabelInVerseChange: (v: boolean) => void; showMorphology: boolean; onShowMorphologyChange: (v: boolean) => void; + showFreeTranslation: boolean; + onShowFreeTranslationChange: (v: boolean) => void; }) => (
), })); @@ -623,6 +634,25 @@ describe('InterlinearizerLoader', () => { expect(onChangeByKey.get('interlinearizer.showMorphology')).toHaveBeenCalledWith(true); }); + it('passes showFreeTranslation through to Interlinearizer from useOptimisticBooleanSetting', async () => { + mockOptimisticSetting(true); + await act(async () => { + renderLoader(); + }); + + expect(capturedInterlinearizerProps?.viewOptions.showFreeTranslation).toBe(true); + }); + + it('wires ViewOptionsDropdown show-free-translation to onChange from useOptimisticBooleanSetting', async () => { + const onChangeByKey = mockOptimisticSetting(); + await act(async () => { + renderLoader(); + }); + + await userEvent.click(screen.getByTestId('show-free-translation-toggle')); + expect(onChangeByKey.get('interlinearizer.showFreeTranslation')).toHaveBeenCalledWith(true); + }); + it('passes continuousScroll=true to Interlinearizer when the setting is true', async () => { mockOptimisticSetting(true); await act(async () => { diff --git a/src/__tests__/components/PhraseBox.test.tsx b/src/__tests__/components/PhraseBox.test.tsx index bc3a3222..9414b6df 100644 --- a/src/__tests__/components/PhraseBox.test.tsx +++ b/src/__tests__/components/PhraseBox.test.tsx @@ -2,6 +2,7 @@ /// /// +import { useLocalizedStrings } from '@papi/frontend/react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ReactElement } from 'react'; @@ -238,6 +239,13 @@ function renderBox(ui: ReactElement, context: Partial = describe('PhraseBox', () => { beforeEach(() => { + // Restore key-as-value behavior cleared by resetMocks: true, so the gloss placeholder resolves. + jest + .mocked(useLocalizedStrings) + .mockImplementation((keys: readonly string[]) => [ + Object.fromEntries(keys.map((k) => [k, k])), + false, + ]); mockUseGloss.mockReturnValue(''); mockUseGlossDispatch.mockReturnValue(jest.fn()); mockUsePhraseGloss.mockReturnValue(''); diff --git a/src/__tests__/components/SegmentView.test.tsx b/src/__tests__/components/SegmentView.test.tsx index a68f719f..6984eeab 100644 --- a/src/__tests__/components/SegmentView.test.tsx +++ b/src/__tests__/components/SegmentView.test.tsx @@ -30,6 +30,9 @@ const mockUsePhraseDispatch = jest.fn, []>().m mergePhrases: jest.fn(), }); +/** Stable mock fn capturing `useSegmentFreeTranslationDispatch` calls so tests can assert on them. */ +const mockSegmentFreeTranslationDispatch = jest.fn(); + jest.mock('../../components/AnalysisStore', () => ({ __esModule: true, AnalysisStoreProvider({ children }: Readonly<{ children: ReactNode; analysisLanguage: string }>) { @@ -46,6 +49,9 @@ jest.mock('../../components/AnalysisStore', () => ({ usePhraseDispatch: () => mockUsePhraseDispatch(), usePhraseGloss: () => '', usePhraseGlossDispatch: () => () => {}, + useReportGlossEditing: () => {}, + useSegmentFreeTranslation: () => '', + useSegmentFreeTranslationDispatch: () => mockSegmentFreeTranslationDispatch, })); // The shared hover-preview state is covered in full by usePhraseHoverState.test.ts. Stub it here so @@ -212,6 +218,7 @@ function requiredProps(): { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, + showFreeTranslation: true, }, }; } @@ -332,6 +339,27 @@ describe('SegmentView', () => { expect(handleSelect).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 1 }); }); + it('renders a free-translation input below the plain text in baseline-text mode', () => { + render(, withAnalysisStore); + + expect(screen.getByTestId('segment-free-translation-input')).toBeInTheDocument(); + }); + + it('selects the segment once (via focus) when the baseline free-translation input is clicked', async () => { + const handleSelect = jest.fn(); + render( + , + withAnalysisStore, + ); + + // Focusing the input selects the verse via its token ref; the container's click handler must + // not also fire a bare-ref select, so onSelect lands exactly once. + await userEvent.click(screen.getByTestId('segment-free-translation-input')); + + expect(handleSelect).toHaveBeenCalledTimes(1); + expect(handleSelect).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 1 }, 'tok-0'); + }); + it('calls onSelect with the verse ref and token id when a word token is clicked', async () => { const handleSelect = jest.fn(); render(, withAnalysisStore); @@ -627,4 +655,80 @@ describe('SegmentView', () => { // ArcOverlay receives candidatePhraseIds; it renders so no assertion needed beyond no crash expect(screen.getByTestId('arc-split-btn')).toBeInTheDocument(); }); + + it('renders a free-translation input below the segment tokens', () => { + render(, withAnalysisStore); + + expect(screen.getByTestId('segment-free-translation-input')).toBeInTheDocument(); + }); + + it('hides the free-translation input when showFreeTranslation is false (token-chip mode)', () => { + render( + , + withAnalysisStore, + ); + + expect(screen.queryByTestId('segment-free-translation-input')).not.toBeInTheDocument(); + }); + + it('hides the free-translation input when showFreeTranslation is false (baseline-text mode)', () => { + render( + , + withAnalysisStore, + ); + + expect(screen.queryByTestId('segment-free-translation-input')).not.toBeInTheDocument(); + }); + + it('commits the free translation on blur when the draft changed', async () => { + render(, withAnalysisStore); + + const input = screen.getByTestId('segment-free-translation-input'); + await userEvent.type(input, 'au commencement'); + await userEvent.tab(); + + expect(mockSegmentFreeTranslationDispatch).toHaveBeenCalledWith( + 'GEN 1:1', + 'In the beginning.', + 'au commencement', + ); + }); + + it('does not commit on blur when the draft is unchanged', async () => { + render(, withAnalysisStore); + + const input = screen.getByTestId('segment-free-translation-input'); + await userEvent.click(input); + await userEvent.tab(); + + expect(mockSegmentFreeTranslationDispatch).not.toHaveBeenCalled(); + }); + + it('makes the segment active when the free-translation input is focused', async () => { + const handleSelect = jest.fn(); + render(, withAnalysisStore); + + await userEvent.click(screen.getByTestId('segment-free-translation-input')); + + expect(handleSelect).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 1 }, 'tok-0'); + }); + + it('does not select on free-translation focus when the segment has no word token', async () => { + const handleSelect = jest.fn(); + render( + , + withAnalysisStore, + ); + + await userEvent.click(screen.getByTestId('segment-free-translation-input')); + + expect(handleSelect).not.toHaveBeenCalled(); + }); }); diff --git a/src/__tests__/components/controls/ViewOptionsDropdown.test.tsx b/src/__tests__/components/controls/ViewOptionsDropdown.test.tsx index 55ca467d..2995be9c 100644 --- a/src/__tests__/components/controls/ViewOptionsDropdown.test.tsx +++ b/src/__tests__/components/controls/ViewOptionsDropdown.test.tsx @@ -29,6 +29,8 @@ const DEFAULT_PROPS = { onChapterLabelInVerseChange: jest.fn(), showMorphology: false, onShowMorphologyChange: jest.fn(), + showFreeTranslation: false, + onShowFreeTranslationChange: jest.fn(), }; describe('ViewOptionsDropdown', () => { @@ -171,13 +173,38 @@ describe('ViewOptionsDropdown', () => { }); }); + describe('show free translation toggle', () => { + it('reflects the checked value', async () => { + render(); + await userEvent.click(screen.getByTestId('view-options-button')); + + expect(screen.getByRole('checkbox', { name: /freeTranslation/i })).toBeChecked(); + }); + + it('calls onShowFreeTranslationChange when toggled', async () => { + const onShowFreeTranslationChange = jest.fn(); + render( + , + ); + await userEvent.click(screen.getByTestId('view-options-button')); + + await userEvent.click(screen.getByRole('checkbox', { name: /freeTranslation/i })); + + expect(onShowFreeTranslationChange).toHaveBeenCalledWith(true); + }); + }); + describe('hide inactive link buttons toggle', () => { it('reflects the checked value', async () => { render(); await userEvent.click(screen.getByTestId('view-options-button')); const checkboxes = screen.getAllByRole('checkbox'); - expect(checkboxes[2]).toBeChecked(); + expect(checkboxes[3]).toBeChecked(); }); it('calls onHideInactiveLinkButtonsChange when toggled', async () => { @@ -192,7 +219,7 @@ describe('ViewOptionsDropdown', () => { await userEvent.click(screen.getByTestId('view-options-button')); const checkboxes = screen.getAllByRole('checkbox'); - await userEvent.click(checkboxes[2]); + await userEvent.click(checkboxes[3]); expect(onHideInactiveLinkButtonsChange).toHaveBeenCalledWith(true); }); @@ -204,7 +231,7 @@ describe('ViewOptionsDropdown', () => { await userEvent.click(screen.getByTestId('view-options-button')); const checkboxes = screen.getAllByRole('checkbox'); - expect(checkboxes[3]).toBeChecked(); + expect(checkboxes[4]).toBeChecked(); }); it('calls onSimplifyPhrasesChange when toggled', async () => { @@ -219,7 +246,7 @@ describe('ViewOptionsDropdown', () => { await userEvent.click(screen.getByTestId('view-options-button')); const checkboxes = screen.getAllByRole('checkbox'); - await userEvent.click(checkboxes[3]); + await userEvent.click(checkboxes[4]); expect(onSimplifyPhrasesChange).toHaveBeenCalledWith(true); }); @@ -231,7 +258,7 @@ describe('ViewOptionsDropdown', () => { await userEvent.click(screen.getByTestId('view-options-button')); const checkboxes = screen.getAllByRole('checkbox'); - expect(checkboxes[4]).toBeChecked(); + expect(checkboxes[5]).toBeChecked(); }); it('calls onChapterLabelInVerseChange when toggled', async () => { @@ -246,7 +273,7 @@ describe('ViewOptionsDropdown', () => { await userEvent.click(screen.getByTestId('view-options-button')); const checkboxes = screen.getAllByRole('checkbox'); - await userEvent.click(checkboxes[4]); + await userEvent.click(checkboxes[5]); expect(onChapterLabelInVerseChange).toHaveBeenCalledWith(true); }); diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index a1a8a6a7..2c348b65 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -276,7 +276,7 @@ describe('main', () => { await activate(context); - expect(context.registrations.unsubscribers.size).toBe(23); + expect(context.registrations.unsubscribers.size).toBe(24); }); it('logs activation start and finish', async () => { diff --git a/src/__tests__/store/analysisSlice.test.ts b/src/__tests__/store/analysisSlice.test.ts index 02cf794b..d94a5079 100644 --- a/src/__tests__/store/analysisSlice.test.ts +++ b/src/__tests__/store/analysisSlice.test.ts @@ -3,6 +3,8 @@ import type { PhraseAnalysisLink, + SegmentAnalysis, + SegmentAnalysisLink, TextAnalysis, TokenAnalysis, TokenAnalysisLink, @@ -24,11 +26,13 @@ import { selectPhraseGloss, selectPhraseLinks, setAnalysis, + selectSegmentFreeTranslation, updatePhrase, writeGloss, writeMorphemeGloss, writeMorphemes, writePhraseGloss, + writeSegmentFreeTranslation, } from '../../store/analysisSlice'; /** @@ -631,6 +635,208 @@ describe('selectPhraseGloss', () => { }); }); +/** + * Builds a `TextAnalysis` seeded with a single approved `SegmentAnalysis` for `seg-1`. + * + * @param analysis - The `SegmentAnalysis` payload to include. + * @param link - Optional override for the link; defaults to an approved link for `seg-1`. + * @returns A `TextAnalysis` with the segment analysis and its link. + */ +function makeAnalysisWithSegment( + analysis: SegmentAnalysis, + link?: SegmentAnalysisLink, +): TextAnalysis { + return { + ...emptyAnalysis(), + segmentAnalyses: [analysis], + segmentAnalysisLinks: [ + link ?? { analysisId: analysis.id, status: 'approved', segmentId: 'seg-1' }, + ], + }; +} + +describe('writeSegmentFreeTranslation', () => { + it('creates a new approved segment analysis and link when none exists', () => { + const store = createAnalysisStore(); + + store.dispatch(writeSegmentFreeTranslation('seg-1', 'In the beginning', 'au commencement')); + + const { segmentAnalyses, segmentAnalysisLinks } = store.getState().analysis.analysis; + expect(segmentAnalyses).toHaveLength(1); + expect(segmentAnalyses[0]).toMatchObject({ + surfaceText: 'In the beginning', + freeTranslation: { und: 'au commencement' }, + }); + expect(segmentAnalysisLinks).toEqual([ + { analysisId: segmentAnalyses[0].id, status: 'approved', segmentId: 'seg-1' }, + ]); + }); + + it('no-ops on a blank write when the segment has no approved analysis', () => { + const store = createAnalysisStore(); + + store.dispatch(writeSegmentFreeTranslation('seg-1', 'In the beginning', ' ')); + + expect(store.getState().analysis.analysis.segmentAnalyses).toHaveLength(0); + expect(store.getState().analysis.analysis.segmentAnalysisLinks).toHaveLength(0); + }); + + it('updates an existing analysis in place and refreshes the surface text', () => { + const seeded = makeAnalysisWithSegment({ + id: 'sa-1', + surfaceText: 'old surface', + freeTranslation: { und: 'old' }, + }); + const store = createAnalysisStore({ analysis: { analysis: seeded, analysisLanguage: 'und' } }); + + store.dispatch(writeSegmentFreeTranslation('seg-1', 'new surface', 'new')); + + const { segmentAnalyses } = store.getState().analysis.analysis; + expect(segmentAnalyses).toHaveLength(1); + expect(segmentAnalyses[0]).toMatchObject({ + id: 'sa-1', + surfaceText: 'new surface', + freeTranslation: { und: 'new' }, + }); + }); + + it('initializes the free translation on an existing analysis that has none', () => { + const seeded = makeAnalysisWithSegment({ + id: 'sa-1', + surfaceText: 'surface', + literalTranslation: { und: 'word for word' }, + }); + const store = createAnalysisStore({ analysis: { analysis: seeded, analysisLanguage: 'und' } }); + + store.dispatch(writeSegmentFreeTranslation('seg-1', 'surface', 'idiomatic')); + + const sa = store.getState().analysis.analysis.segmentAnalyses[0]; + expect(sa.freeTranslation).toStrictEqual({ und: 'idiomatic' }); + expect(sa.literalTranslation).toStrictEqual({ und: 'word for word' }); + }); + + it('removes the record and its link when a blank write empties the analysis', () => { + const seeded = makeAnalysisWithSegment({ + id: 'sa-1', + surfaceText: 'surface', + freeTranslation: { und: 'value' }, + }); + const store = createAnalysisStore({ analysis: { analysis: seeded, analysisLanguage: 'und' } }); + + store.dispatch(writeSegmentFreeTranslation('seg-1', 'surface', '')); + + expect(store.getState().analysis.analysis.segmentAnalyses).toHaveLength(0); + expect(store.getState().analysis.analysis.segmentAnalysisLinks).toHaveLength(0); + }); + + it('keeps the record when another language still has a free translation', () => { + const seeded = makeAnalysisWithSegment({ + id: 'sa-1', + surfaceText: 'surface', + freeTranslation: { und: 'value', fr: 'valeur' }, + }); + const store = createAnalysisStore({ analysis: { analysis: seeded, analysisLanguage: 'und' } }); + + store.dispatch(writeSegmentFreeTranslation('seg-1', 'surface', '')); + + expect(store.getState().analysis.analysis.segmentAnalyses[0].freeTranslation).toStrictEqual({ + fr: 'valeur', + }); + }); + + it('keeps the record when a literal translation remains after clearing the free translation', () => { + const seeded = makeAnalysisWithSegment({ + id: 'sa-1', + surfaceText: 'surface', + freeTranslation: { und: 'value' }, + literalTranslation: { und: 'word for word' }, + }); + const store = createAnalysisStore({ analysis: { analysis: seeded, analysisLanguage: 'und' } }); + + store.dispatch(writeSegmentFreeTranslation('seg-1', 'surface', '')); + + const { segmentAnalyses } = store.getState().analysis.analysis; + expect(segmentAnalyses).toHaveLength(1); + expect(segmentAnalyses[0].freeTranslation).toBeUndefined(); + expect(segmentAnalyses[0].literalTranslation).toStrictEqual({ und: 'word for word' }); + }); + + it('treats whitespace-only entries in other languages as empty and removes the record', () => { + const seeded = makeAnalysisWithSegment({ + id: 'sa-1', + surfaceText: 'surface', + freeTranslation: { und: ' ', fr: 'valeur' }, + }); + const store = createAnalysisStore({ analysis: { analysis: seeded, analysisLanguage: 'fr' } }); + + store.dispatch(writeSegmentFreeTranslation('seg-1', 'surface', '')); + + expect(store.getState().analysis.analysis.segmentAnalyses).toHaveLength(0); + expect(store.getState().analysis.analysis.segmentAnalysisLinks).toHaveLength(0); + }); + + it('repairs an orphaned approved link and creates a fresh analysis', () => { + const orphanLink: SegmentAnalysisLink = { + analysisId: 'old-uuid', + status: 'approved', + segmentId: 'seg-1', + }; + const store = createAnalysisStore({ + analysis: { + analysis: { ...emptyAnalysis(), segmentAnalysisLinks: [orphanLink] }, + analysisLanguage: 'und', + }, + }); + + store.dispatch(writeSegmentFreeTranslation('seg-1', 'surface', 'fresh')); + + const { segmentAnalyses, segmentAnalysisLinks } = store.getState().analysis.analysis; + expect(segmentAnalyses).toHaveLength(1); + expect(segmentAnalysisLinks).toHaveLength(1); + expect(segmentAnalysisLinks[0].analysisId).not.toBe('old-uuid'); + expect(segmentAnalysisLinks[0].analysisId).toBe(segmentAnalyses[0].id); + }); +}); + +describe('selectSegmentFreeTranslation', () => { + it('returns the free translation for the active language', () => { + const seeded = makeAnalysisWithSegment({ + id: 'sa-1', + surfaceText: 'surface', + freeTranslation: { und: 'value' }, + }); + const store = createAnalysisStore({ analysis: { analysis: seeded, analysisLanguage: 'und' } }); + + expect(selectSegmentFreeTranslation(store.getState().analysis, 'seg-1')).toBe('value'); + }); + + it('returns empty string when the segment has no approved link', () => { + const store = createAnalysisStore(); + expect(selectSegmentFreeTranslation(store.getState().analysis, 'seg-1')).toBe(''); + }); + + it('returns empty string when the approved link references a missing analysis', () => { + const store = createAnalysisStore({ + analysis: { + analysis: { + ...emptyAnalysis(), + segmentAnalysisLinks: [{ analysisId: 'gone', status: 'approved', segmentId: 'seg-1' }], + }, + analysisLanguage: 'und', + }, + }); + + expect(selectSegmentFreeTranslation(store.getState().analysis, 'seg-1')).toBe(''); + }); + + it('returns empty string when the analysis has no free translation for the active language', () => { + const seeded = makeAnalysisWithSegment({ id: 'sa-1', surfaceText: 'surface' }); + const store = createAnalysisStore({ analysis: { analysis: seeded, analysisLanguage: 'und' } }); + + expect(selectSegmentFreeTranslation(store.getState().analysis, 'seg-1')).toBe(''); + }); +}); + describe('writeMorphemes', () => { it('creates a new approved analysis with morphemes when none exists', () => { const store = createAnalysisStore(); diff --git a/src/__tests__/utils/localized-strings.test.ts b/src/__tests__/utils/localized-strings.test.ts new file mode 100644 index 00000000..35937982 --- /dev/null +++ b/src/__tests__/utils/localized-strings.test.ts @@ -0,0 +1,18 @@ +/** @file Unit tests for utils/localized-strings.ts. */ +/// + +import { resolvedOrEmpty } from '../../utils/localized-strings'; + +describe('resolvedOrEmpty', () => { + it('returns an empty string for an unresolved %…% key', () => { + expect(resolvedOrEmpty('%interlinearizer_glossInput_placeholder%')).toBe(''); + }); + + it('returns a resolved localized string unchanged', () => { + expect(resolvedOrEmpty('gloss')).toBe('gloss'); + }); + + it('does not treat a string that merely contains a percent sign as a key', () => { + expect(resolvedOrEmpty('50% complete')).toBe('50% complete'); + }); +}); diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx index ae7f410b..d483d30f 100644 --- a/src/components/AnalysisStore.tsx +++ b/src/components/AnalysisStore.tsx @@ -20,11 +20,13 @@ import { selectPhraseLinkByAnalysisId, selectPhraseLinkByTokenRef, selectPhraseGloss, + selectSegmentFreeTranslation, updatePhrase, writeGloss, writeMorphemeGloss, writeMorphemes, writePhraseGloss, + writeSegmentFreeTranslation, } from '../store/analysisSlice'; import { createAnalysisStore, type AnalysisDispatch, type AnalysisRootState } from '../store'; import { emptyAnalysis } from '../types/empty-factories'; @@ -536,3 +538,48 @@ export function usePhraseDispatch(): PhraseDispatch { } // #endregion + +// #region Segment hooks + +/** + * Returns the approved free-translation string for the given segment in the store's active analysis + * language, re-rendering only when that segment's free translation changes. + * + * @param segmentId - The `Segment.id` whose free translation to read. + * @returns The current free-translation string, or `''` when absent. + * @throws When called outside an {@link AnalysisStoreProvider}. + */ +export function useSegmentFreeTranslation(segmentId: string): string { + useRequiredCallbacks('useSegmentFreeTranslation'); + + return useSelector((state: AnalysisRootState) => + selectSegmentFreeTranslation(state.analysis, segmentId), + ); +} + +/** + * Returns a stable callback that writes a free-translation value for the given segment, then calls + * `onSave`. Mirrors {@link useGlossDispatch}: a blank value clears the translation and may drop the + * now-empty `SegmentAnalysis` record. + * + * @returns A function `(segmentId, surfaceText, value) => void`, where `surfaceText` is the + * segment's current baseline text, stored on the `SegmentAnalysis` record. + * @throws When called outside an {@link AnalysisStoreProvider}. + */ +export function useSegmentFreeTranslationDispatch(): ( + segmentId: string, + surfaceText: string, + value: string, +) => void { + const { dispatch, save } = useAnalysisSave('useSegmentFreeTranslationDispatch'); + + return useCallback( + (segmentId: string, surfaceText: string, value: string) => { + dispatch(writeSegmentFreeTranslation(segmentId, surfaceText, value)); + save(); + }, + [dispatch, save], + ); +} + +// #endregion diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index 04a3714e..dbe140cf 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -203,13 +203,31 @@ function InterlinearizerLoaderInner({ value: showMorphology, } = useOptimisticBooleanSetting(projectId, 'interlinearizer.showMorphology', false); + const { + isLoading: isShowFreeTranslationLoading, + onChange: handleShowFreeTranslationChange, + value: showFreeTranslation, + } = useOptimisticBooleanSetting(projectId, 'interlinearizer.showFreeTranslation', false); + // Bundle the display toggles into one stable object. Memoizing on the primitive values keeps // the reference identical across the loader's frequent re-renders (driven by `useData`, // `useSetting`, etc.), so the `memo()` wrapping `SegmentView` can shallow-compare it away instead // of re-rendering every windowed segment when no toggle actually changed. const viewOptions = useMemo( - () => ({ hideInactiveLinkButtons, simplifyPhrases, chapterLabelInVerse, showMorphology }), - [hideInactiveLinkButtons, simplifyPhrases, chapterLabelInVerse, showMorphology], + () => ({ + hideInactiveLinkButtons, + simplifyPhrases, + chapterLabelInVerse, + showMorphology, + showFreeTranslation, + }), + [ + hideInactiveLinkButtons, + simplifyPhrases, + chapterLabelInVerse, + showMorphology, + showFreeTranslation, + ], ); const { book, isLoading, bookError, tokenizeError } = useInterlinearizerBookData({ @@ -223,7 +241,8 @@ function InterlinearizerLoaderInner({ isHideInactiveLinkButtonsLoading || isSimplifyPhrasesLoading || isChapterLabelInVerseLoading || - isShowMorphologyLoading; + isShowMorphologyLoading || + isShowFreeTranslationLoading; // True during a cross-book swap: the live `scrRef` already names the new book but the loaded `book` // is still the previous one (its USJ hasn't arrived yet). The old `Interlinearizer` is still // mounted here; showing it (even frozen on its last in-book reference) lets the previous book's @@ -400,6 +419,8 @@ function InterlinearizerLoaderInner({ onChapterLabelInVerseChange={handleChapterLabelInVerseChange} showMorphology={showMorphology} onShowMorphologyChange={handleShowMorphologyChange} + showFreeTranslation={showFreeTranslation} + onShowFreeTranslationChange={handleShowFreeTranslationChange} /> ) : undefined } diff --git a/src/components/PhraseBox.tsx b/src/components/PhraseBox.tsx index bacc1e7f..fbc51da4 100644 --- a/src/components/PhraseBox.tsx +++ b/src/components/PhraseBox.tsx @@ -1,4 +1,5 @@ /** @file Shared phrase-box wrapper used around word tokens. */ +import { useLocalizedStrings } from '@papi/frontend/react'; import type { PhraseAnalysisLink, Token } from 'interlinearizer'; import { Trash2 } from 'lucide-react'; import { memo, useCallback, useEffect, useState } from 'react'; @@ -14,8 +15,16 @@ import { usePhraseStripContext } from './PhraseStripContext'; import MemoizedTokenChip, { InertTokenChip } from './TokenChip'; import MemoizedTokenLinkIcon from './TokenLinkIcon'; import { sortByDocOrder } from '../utils/phrase-arc'; +import { resolvedOrEmpty } from '../utils/localized-strings'; import { NO_SLOT_FOCUS } from '../utils/token-layout'; +/** + * Localized string keys this module needs. Hoisted to module scope so the reference passed to + * `useLocalizedStrings` is stable across renders (a fresh array literal each render makes the PAPI + * hook re-fetch and re-set state every render). + */ +const STRING_KEYS = ['%interlinearizer_glossInput_placeholder%'] as const satisfies `%${string}%`[]; + /** * Inline gloss input for a phrase. Reads and writes the phrase-level gloss from the analysis store. * Separated into its own component so hooks are always called unconditionally. @@ -34,6 +43,7 @@ function PhraseGlossInput({ }: Readonly<{ phraseId: string; disabled?: boolean; onFocus?: () => void }>) { const committed = usePhraseGloss(phraseId); const dispatchPhraseGloss = usePhraseGlossDispatch(); + const [localizedStrings] = useLocalizedStrings(STRING_KEYS); const [draft, setDraft] = useState(committed); useEffect(() => { @@ -49,7 +59,7 @@ function PhraseGlossInput({ className="tw:gloss-input" data-testid="phrase-gloss-input" disabled={disabled} - placeholder="gloss" + placeholder={resolvedOrEmpty(localizedStrings['%interlinearizer_glossInput_placeholder%'])} style={{ fieldSizing: 'content' }} type="text" value={draft} diff --git a/src/components/SegmentFreeTranslationInput.tsx b/src/components/SegmentFreeTranslationInput.tsx new file mode 100644 index 00000000..fe35358c --- /dev/null +++ b/src/components/SegmentFreeTranslationInput.tsx @@ -0,0 +1,67 @@ +/** @file Segment-level free-translation input rendered by the segment view. */ +import { useLocalizedStrings } from '@papi/frontend/react'; +import { useEffect, useState } from 'react'; +import { + useReportGlossEditing, + useSegmentFreeTranslation, + useSegmentFreeTranslationDispatch, +} from './AnalysisStore'; +import { resolvedOrEmpty } from '../utils/localized-strings'; + +/** + * Localized string keys this component needs. Hoisted to module scope so the reference passed to + * `useLocalizedStrings` is stable across renders (a fresh array literal each render makes the PAPI + * hook re-fetch and re-set state every render). + */ +const STRING_KEYS = [ + '%interlinearizer_freeTranslationInput_placeholder%', +] as const satisfies `%${string}%`[]; + +/** + * Free-translation input for a segment. Reads and writes the segment-level free translation from + * the analysis store. Rendered below a segment's tokens in `SegmentView`. Kept in its own component + * so the analysis-store hooks are always called unconditionally. + * + * @param props - Component props + * @param props.segmentId - `Segment.id` of the segment to read/write. + * @param props.surfaceText - Current baseline text of the segment, stored on the `SegmentAnalysis` + * record so it can detect drift if the baseline changes later. + * @param props.onFocus - Called when the input receives focus; used by `SegmentView` to make the + * segment active. + * @returns A full-width text input. + */ +export default function SegmentFreeTranslationInput({ + segmentId, + surfaceText, + onFocus, +}: Readonly<{ segmentId: string; surfaceText: string; onFocus?: () => void }>) { + const committed = useSegmentFreeTranslation(segmentId); + const dispatchFreeTranslation = useSegmentFreeTranslationDispatch(); + const [localizedStrings] = useLocalizedStrings(STRING_KEYS); + const [draft, setDraft] = useState(committed); + + useEffect(() => { + setDraft(committed); + }, [committed]); + + // Surface uncommitted typing to the unsaved indicator before the translation commits on blur. + useReportGlossEditing(draft !== committed); + + return ( + { + if (draft !== committed) dispatchFreeTranslation(segmentId, surfaceText, draft); + }} + onChange={(e) => setDraft(e.target.value)} + onFocus={onFocus} + /> + ); +} diff --git a/src/components/SegmentView.tsx b/src/components/SegmentView.tsx index e4e0c14e..ffede152 100644 --- a/src/components/SegmentView.tsx +++ b/src/components/SegmentView.tsx @@ -16,6 +16,7 @@ import type { RenderUnit } from '../types/token-layout'; import { buildRenderUnits, groupTokens, resolveFocusContext } from '../utils/token-layout'; import { usePhraseLinkByIdMap, usePhraseLinkMap } from './AnalysisStore'; import MemoizedArcOverlay from './ArcOverlay'; +import SegmentFreeTranslationInput from './SegmentFreeTranslationInput'; import { PhraseStripProvider } from './PhraseStripContext'; import { PhraseStrip, type StripItem } from './PhraseStripParts'; @@ -128,8 +129,13 @@ export function SegmentView({ wordTokenByRef, viewOptions, }: SegmentViewProps) { - const { hideInactiveLinkButtons, simplifyPhrases, chapterLabelInVerse, showMorphology } = - viewOptions; + const { + hideInactiveLinkButtons, + simplifyPhrases, + chapterLabelInVerse, + showMorphology, + showFreeTranslation, + } = viewOptions; const { book, chapter, verse } = segment.startRef; const ref: ScriptureRef = useMemo(() => ({ book, chapter, verse }), [book, chapter, verse]); @@ -360,6 +366,30 @@ export function SegmentView({ [firstWordTokenRef, onSelect, ref], ); + /** + * Makes this segment the active verse when the free-translation input gains focus, so editing a + * segment's translation behaves like selecting it. A no-op when the segment has no word token to + * anchor the selection on. + */ + const handleFreeTranslationFocus = useCallback(() => { + if (firstWordTokenRef !== undefined) onSelect(ref, firstWordTokenRef); + }, [firstWordTokenRef, onSelect, ref]); + + /** + * Selects this segment when its baseline-text body is clicked. Clicks that originate inside the + * free-translation input are ignored: that input fires its own {@link handleFreeTranslationFocus} + * on focus, so letting the container also fire would double-select the verse. + * + * @param event - The click event on the baseline-text container. + */ + const handleBaselineClick = useCallback( + (event: MouseEvent) => { + if (event.target instanceof Element && event.target.closest('input')) return; + onSelect(ref); + }, + [onSelect, ref], + ); + // Measure phrase boxes inside this segment and compute arcs. Disabled in baseline-text mode, // where the arc container is unmounted, so the result resets to empty. const { @@ -377,19 +407,33 @@ export function SegmentView({ ]); if (displayMode === 'baseline-text') { + // Intentional: baseline-text mode renders a clickable div, not a button, so the + // free-translation input can sit inside the same box (an input may not be nested in a button). + // The free-translation input is the only interactive child and handles its own focus, so the + // container only needs a click handler; a redundant key handler / role / tabIndex would add a + // non-functional tab stop, so the relevant a11y rules are disabled here (matching token-chip + // mode below). return ( - + + {segment.baselineText} + + {showFreeTranslation && ( + + )} + ); } @@ -448,6 +492,13 @@ export function SegmentView({ + {showFreeTranslation && ( + + )} ); } diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx index 1219b249..55d490a9 100644 --- a/src/components/TokenChip.tsx +++ b/src/components/TokenChip.tsx @@ -13,10 +13,12 @@ import { useReportGlossEditing, } from './AnalysisStore'; import { MorphemeBreakdownPopover, MorphemeGlossInput } from './MorphemeEditor'; +import { resolvedOrEmpty } from '../utils/localized-strings'; const STRING_KEYS = [ '%interlinearizer_tokenChip_editMorphemes%', '%interlinearizer_tokenChip_defineMorphemes%', + '%interlinearizer_glossInput_placeholder%', ] as const satisfies `%${string}%`[]; /** @@ -241,7 +243,9 @@ export function TokenChip({ className="tw:gloss-input" disabled={disabled} id={glossInputId} - placeholder="gloss" + placeholder={resolvedOrEmpty( + localizedStrings['%interlinearizer_glossInput_placeholder%'], + )} style={{ fieldSizing: 'content', minWidth: '5ch' }} value={draft} onChange={(e) => setDraft(e.target.value)} diff --git a/src/components/controls/ViewOptionsDropdown.tsx b/src/components/controls/ViewOptionsDropdown.tsx index 51010bed..27b51f19 100644 --- a/src/components/controls/ViewOptionsDropdown.tsx +++ b/src/components/controls/ViewOptionsDropdown.tsx @@ -11,6 +11,7 @@ const STRING_KEYS = [ '%interlinearizer_viewOption_simplifyPhrases%', '%interlinearizer_viewOption_chapterLabelInVerse%', '%interlinearizer_viewOption_showMorphology%', + '%interlinearizer_viewOption_showFreeTranslation%', ] as const satisfies `%${string}%`[]; /** @@ -67,12 +68,16 @@ type ViewOptionsDropdownProps = Readonly<{ showMorphology: boolean; /** Called when the show-morphology toggle changes. */ onShowMorphologyChange: (checked: boolean) => void; + /** Current value of the show-free-translation toggle. */ + showFreeTranslation: boolean; + /** Called when the show-free-translation toggle changes. */ + onShowFreeTranslationChange: (checked: boolean) => void; }>; /** - * Toolbar dropdown that groups the continuous-scroll toggle and four view-mode toggles (show - * morphology, hide inactive link buttons, simplify phrases, chapter label in verse). Opens and - * closes via a gear icon button. + * Toolbar dropdown that groups the continuous-scroll toggle and five view-mode toggles (show + * morphology, show free translation, hide inactive link buttons, simplify phrases, chapter label in + * verse). Opens and closes via a gear icon button. * * @param props - Component props * @param props.continuousScroll - Current continuous-scroll value. @@ -85,6 +90,8 @@ type ViewOptionsDropdownProps = Readonly<{ * @param props.onChapterLabelInVerseChange - Show-chapter-in-verse-label change callback. * @param props.showMorphology - Current show-morphology value. * @param props.onShowMorphologyChange - Show-morphology change callback. + * @param props.showFreeTranslation - Current show-free-translation value. + * @param props.onShowFreeTranslationChange - Show-free-translation change callback. * @returns A gear button that opens a dropdown panel of view toggles. */ export default function ViewOptionsDropdown({ @@ -98,6 +105,8 @@ export default function ViewOptionsDropdown({ onChapterLabelInVerseChange, showMorphology, onShowMorphologyChange, + showFreeTranslation, + onShowFreeTranslationChange, }: ViewOptionsDropdownProps) { const [localizedStrings] = useLocalizedStrings(STRING_KEYS); const [open, setOpen] = useState(false); @@ -191,6 +200,11 @@ export default function ViewOptionsDropdown({ label={localizedStrings['%interlinearizer_viewOption_showMorphology%']} onCheckedChange={onShowMorphologyChange} /> + l.status === 'approved' && l.segmentId === segmentId, + ); + if (!link) return undefined; + const analysis = state.analysis.segmentAnalyses.find((sa) => sa.id === link.analysisId); + if (!analysis) { + state.analysis.segmentAnalysisLinks = state.analysis.segmentAnalysisLinks.filter( + (l) => l !== link, + ); + return undefined; + } + return { link, analysis }; +} + +/** + * Determines whether a `SegmentAnalysis` carries no content worth keeping, so a reducer that just + * emptied the free translation can drop the whole record instead of accumulating empty records in + * storage. A `freeTranslation` counts as empty when it has no entries or every entry is blank. + * `literalTranslation` is treated as content so an imported word-for-word translation survives a + * free-translation clear, mirroring how {@link isEmptyTokenAnalysis} preserves morphemes/pos. + * + * @param analysis - The `SegmentAnalysis` to inspect. + * @returns `true` when the record holds no content worth keeping. + */ +function isEmptySegmentAnalysis(analysis: SegmentAnalysis): boolean { + return ( + (!analysis.freeTranslation || + Object.values(analysis.freeTranslation).every((t) => t.trim() === '')) && + analysis.literalTranslation === undefined + ); +} + +/** + * Removes a `SegmentAnalysis` record and its `SegmentAnalysisLink` from the draft in a single step, + * keeping the two collections in sync. Called when an edit empties an analysis of all content. + * + * @param state - Current slice state (Immer draft). + * @param analysis - The `SegmentAnalysis` record to remove. + * @param link - The `SegmentAnalysisLink` referencing it. + */ +function removeSegmentAnalysis( + state: AnalysisState, + analysis: SegmentAnalysis, + link: SegmentAnalysisLink, +): void { + state.analysis.segmentAnalyses = state.analysis.segmentAnalyses.filter((sa) => sa !== analysis); + state.analysis.segmentAnalysisLinks = state.analysis.segmentAnalysisLinks.filter( + (l) => l !== link, + ); +} + /** * Finds the approved `TokenAnalysisLink` for `tokenRef` together with the `TokenAnalysis` it * references. When the approved link references a missing analysis (an orphaned link from @@ -522,6 +602,70 @@ const analysisSlice = createSlice({ if (!pa.gloss) pa.gloss = {}; pa.gloss[lang] = value; }, + writeSegmentFreeTranslation: { + /** + * Generates a UUID for a potential new `SegmentAnalysis` record before the action reaches the + * reducer, keeping the reducer pure. + * + * @param segmentId - `Segment.id` of the segment being translated. + * @param surfaceText - Baseline text of the segment. + * @param value - New free-translation string. + * @returns The prepared action payload including a pre-generated `id`. + */ + prepare(segmentId: string, surfaceText: string, value: string) { + return { payload: { segmentId, surfaceText, value, id: crypto.randomUUID() } }; + }, + /** + * Creates or updates the approved `SegmentAnalysis` carrying a segment's free translation. If + * an approved link already exists for `segmentId`, its analysis is updated in place and the + * stored surface text is refreshed, so it never goes stale when the baseline text changed + * since the analysis was first written. Otherwise a new `SegmentAnalysis` and approved + * `SegmentAnalysisLink` are appended (an orphaned approved link is repaired first; see + * {@link resolveApprovedSegmentAnalysis}). + * + * A blank `value` (empty or whitespace) clears the free translation rather than writing junk: + * the active language's entry is removed, and when that leaves the analysis with no content + * ({@link isEmptySegmentAnalysis}) the record and its link are removed entirely. A blank write + * to a segment with no approved analysis is a no-op, so a focus/blur cycle on an empty input + * never creates a record. + * + * @param state - Current slice state (Immer draft). + * @param action - Action carrying the `WriteSegmentFreeTranslationPayload`. + */ + reducer(state, action: PayloadAction) { + const { segmentId, surfaceText, value, id } = action.payload; + const lang = state.analysisLanguage; + const isBlank = value.trim() === ''; + + const resolved = resolveApprovedSegmentAnalysis(state, segmentId); + if (resolved) { + const { link, analysis } = resolved; + analysis.surfaceText = surfaceText; + if (isBlank) { + if (analysis.freeTranslation) { + delete analysis.freeTranslation[lang]; + if (Object.keys(analysis.freeTranslation).length === 0) + delete analysis.freeTranslation; + } + if (isEmptySegmentAnalysis(analysis)) removeSegmentAnalysis(state, analysis, link); + return; + } + if (!analysis.freeTranslation) analysis.freeTranslation = {}; + analysis.freeTranslation[lang] = value; + return; + } + + if (isBlank) return; + const newAnalysis: SegmentAnalysis = { + id, + surfaceText, + freeTranslation: { [lang]: value }, + }; + const newLink: SegmentAnalysisLink = { analysisId: id, status: 'approved', segmentId }; + state.analysis.segmentAnalyses.push(newAnalysis); + state.analysis.segmentAnalysisLinks.push(newLink); + }, + }, }, }); @@ -536,6 +680,7 @@ export const { deletePhrase, mergePhrases, writePhraseGloss, + writeSegmentFreeTranslation, } = analysisSlice.actions; export default analysisSlice.reducer; @@ -700,4 +845,23 @@ export function selectPhraseGloss(state: AnalysisState, phraseId: string): strin return pa?.gloss?.[state.analysisLanguage] ?? ''; } +/** + * Returns the approved free-translation string for the given segment in the active analysis + * language, or `''` when no approved analysis exists or it has no free translation for the active + * language. An approved link referencing a missing analysis is treated as absent (read-only here; + * the orphan is repaired on the next write through {@link resolveApprovedSegmentAnalysis}). + * + * @param state - The analysis slice state. + * @param segmentId - The `Segment.id` to look up. + * @returns The free-translation string, or `''` when absent. + */ +export function selectSegmentFreeTranslation(state: AnalysisState, segmentId: string): string { + const link = state.analysis.segmentAnalysisLinks.find( + (l) => l.status === 'approved' && l.segmentId === segmentId, + ); + if (!link) return ''; + const sa = state.analysis.segmentAnalyses.find((a) => a.id === link.analysisId); + return sa?.freeTranslation?.[state.analysisLanguage] ?? ''; +} + // #endregion diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index 42c36e47..8ed9194e 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -34,6 +34,11 @@ declare module 'papi-shared-types' { * the token-level gloss input. */ 'interlinearizer.showMorphology': boolean; + /** + * When true, each segment displays a free-translation input beneath its tokens (token-chip + * mode) or its baseline text (continuous-scroll mode). + */ + 'interlinearizer.showFreeTranslation': boolean; } /** diff --git a/src/types/view-options.ts b/src/types/view-options.ts index 483a4c0a..57a02496 100644 --- a/src/types/view-options.ts +++ b/src/types/view-options.ts @@ -17,4 +17,6 @@ export type ViewOptions = Readonly<{ chapterLabelInVerse: boolean; /** When true, morpheme rows and per-morpheme glosses are shown beneath each word token. */ showMorphology: boolean; + /** When true, a free-translation input is shown beneath each segment's tokens or baseline text. */ + showFreeTranslation: boolean; }>; diff --git a/src/utils/localized-strings.ts b/src/utils/localized-strings.ts new file mode 100644 index 00000000..b5c770fb --- /dev/null +++ b/src/utils/localized-strings.ts @@ -0,0 +1,18 @@ +/** @file Helpers for working with values returned by PAPI's `useLocalizedStrings` hook. */ + +/** + * Returns a localized value, or an empty string while it is still an unresolved key. + * + * `useLocalizedStrings` resolves asynchronously: until the lookup completes it returns the raw + * localize key (e.g. `%interlinearizer_glossInput_placeholder%`) as the value. Rendering that + * directly flashes the bare `%…%` key in user-visible text — most noticeable in input placeholders, + * which paint immediately on mount/toggle. Substituting an empty string for an unresolved key + * replaces that flash with a momentarily-empty field, which then fills in once localization + * resolves. A resolved localized string is returned unchanged. + * + * @param value - A value from a `useLocalizedStrings` result record. + * @returns The value, or `''` when it is still an unresolved `%…%` key. + */ +export function resolvedOrEmpty(value: string): string { + return /^%.*%$/.test(value) ? '' : value; +} From a50d6cde35972a51d650d6c2e8545e6349e40352 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Fri, 19 Jun 2026 13:54:19 -0600 Subject: [PATCH 2/6] Minor adjustments --- .../controls/ViewOptionsDropdown.test.tsx | 24 +++++++------------ src/__tests__/main.test.ts | 14 +++++++++-- src/components/SegmentView.tsx | 3 +-- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/__tests__/components/controls/ViewOptionsDropdown.test.tsx b/src/__tests__/components/controls/ViewOptionsDropdown.test.tsx index 2995be9c..9f86c077 100644 --- a/src/__tests__/components/controls/ViewOptionsDropdown.test.tsx +++ b/src/__tests__/components/controls/ViewOptionsDropdown.test.tsx @@ -126,8 +126,7 @@ describe('ViewOptionsDropdown', () => { render(); await userEvent.click(screen.getByTestId('view-options-button')); - const checkboxes = screen.getAllByRole('checkbox'); - expect(checkboxes[0]).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /continuousScroll/i })).toBeChecked(); }); it('calls onContinuousScrollChange when toggled', async () => { @@ -141,8 +140,7 @@ describe('ViewOptionsDropdown', () => { ); await userEvent.click(screen.getByTestId('view-options-button')); - const checkboxes = screen.getAllByRole('checkbox'); - await userEvent.click(checkboxes[0]); + await userEvent.click(screen.getByRole('checkbox', { name: /continuousScroll/i })); expect(onContinuousScrollChange).toHaveBeenCalledWith(true); }); @@ -203,8 +201,7 @@ describe('ViewOptionsDropdown', () => { render(); await userEvent.click(screen.getByTestId('view-options-button')); - const checkboxes = screen.getAllByRole('checkbox'); - expect(checkboxes[3]).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /hideInactiveLinkButtons/i })).toBeChecked(); }); it('calls onHideInactiveLinkButtonsChange when toggled', async () => { @@ -218,8 +215,7 @@ describe('ViewOptionsDropdown', () => { ); await userEvent.click(screen.getByTestId('view-options-button')); - const checkboxes = screen.getAllByRole('checkbox'); - await userEvent.click(checkboxes[3]); + await userEvent.click(screen.getByRole('checkbox', { name: /hideInactiveLinkButtons/i })); expect(onHideInactiveLinkButtonsChange).toHaveBeenCalledWith(true); }); @@ -230,8 +226,7 @@ describe('ViewOptionsDropdown', () => { render(); await userEvent.click(screen.getByTestId('view-options-button')); - const checkboxes = screen.getAllByRole('checkbox'); - expect(checkboxes[4]).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /simplifyPhrases/i })).toBeChecked(); }); it('calls onSimplifyPhrasesChange when toggled', async () => { @@ -245,8 +240,7 @@ describe('ViewOptionsDropdown', () => { ); await userEvent.click(screen.getByTestId('view-options-button')); - const checkboxes = screen.getAllByRole('checkbox'); - await userEvent.click(checkboxes[4]); + await userEvent.click(screen.getByRole('checkbox', { name: /simplifyPhrases/i })); expect(onSimplifyPhrasesChange).toHaveBeenCalledWith(true); }); @@ -257,8 +251,7 @@ describe('ViewOptionsDropdown', () => { render(); await userEvent.click(screen.getByTestId('view-options-button')); - const checkboxes = screen.getAllByRole('checkbox'); - expect(checkboxes[5]).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /chapterLabelInVerse/i })).toBeChecked(); }); it('calls onChapterLabelInVerseChange when toggled', async () => { @@ -272,8 +265,7 @@ describe('ViewOptionsDropdown', () => { ); await userEvent.click(screen.getByTestId('view-options-button')); - const checkboxes = screen.getAllByRole('checkbox'); - await userEvent.click(checkboxes[5]); + await userEvent.click(screen.getByRole('checkbox', { name: /chapterLabelInVerse/i })); expect(onChapterLabelInVerseChange).toHaveBeenCalledWith(true); }); diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 2c348b65..0fc3fd64 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -271,12 +271,22 @@ describe('main', () => { ); }); - it('adds all registrations to the activation context', async () => { + it('adds every created registration to the activation context for disposal', async () => { const context = createTestActivationContext(); await activate(context); - expect(context.registrations.unsubscribers.size).toBe(24); + // Every registration produced during activation must be handed to the context so the platform + // disposes it on deactivation: the WebView provider, one per command and validator, plus the + // two WebView lifecycle subscriptions. Deriving the count from the mock calls keeps this + // resilient when commands or validators are added or removed. + const expectedRegistrationCount = + __mockRegisterWebViewProvider.mock.calls.length + + __mockRegisterCommand.mock.calls.length + + __mockRegisterValidator.mock.calls.length + + __mockOnDidOpenWebView.mock.calls.length + + __mockOnDidCloseWebView.mock.calls.length; + expect(context.registrations.unsubscribers.size).toBe(expectedRegistrationCount); }); it('logs activation start and finish', async () => { diff --git a/src/components/SegmentView.tsx b/src/components/SegmentView.tsx index ffede152..db2747e3 100644 --- a/src/components/SegmentView.tsx +++ b/src/components/SegmentView.tsx @@ -110,8 +110,7 @@ type SegmentViewProps = Readonly<{ * @param props.wordTokenByRef - Word token ref → token lookup; used to resolve focus context. * @param props.viewOptions - Bundled display toggles; `chapterLabelInVerse` sets the verse label, * the rest pass through to the phrase strip context. - * @returns A button (baseline-text mode) or div (token-chip mode) containing a verse label and - * segment content + * @returns A div containing a verse label and the segment content (baseline text or token chips) */ export function SegmentView({ displayMode, From caa2dceba7953d63c4eabb138ac2fb937bd1c8f9 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Fri, 19 Jun 2026 16:14:40 -0600 Subject: [PATCH 3/6] Minor adjustment --- src/__tests__/components/SegmentView.test.tsx | 58 ++++++++++++++++--- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/src/__tests__/components/SegmentView.test.tsx b/src/__tests__/components/SegmentView.test.tsx index 6984eeab..5adba2af 100644 --- a/src/__tests__/components/SegmentView.test.tsx +++ b/src/__tests__/components/SegmentView.test.tsx @@ -218,7 +218,7 @@ function requiredProps(): { simplifyPhrases: false, chapterLabelInVerse: false, showMorphology: false, - showFreeTranslation: true, + showFreeTranslation: false, }, }; } @@ -340,7 +340,14 @@ describe('SegmentView', () => { }); it('renders a free-translation input below the plain text in baseline-text mode', () => { - render(, withAnalysisStore); + render( + , + withAnalysisStore, + ); expect(screen.getByTestId('segment-free-translation-input')).toBeInTheDocument(); }); @@ -348,7 +355,12 @@ describe('SegmentView', () => { it('selects the segment once (via focus) when the baseline free-translation input is clicked', async () => { const handleSelect = jest.fn(); render( - , + , withAnalysisStore, ); @@ -657,7 +669,13 @@ describe('SegmentView', () => { }); it('renders a free-translation input below the segment tokens', () => { - render(, withAnalysisStore); + render( + , + withAnalysisStore, + ); expect(screen.getByTestId('segment-free-translation-input')).toBeInTheDocument(); }); @@ -688,7 +706,13 @@ describe('SegmentView', () => { }); it('commits the free translation on blur when the draft changed', async () => { - render(, withAnalysisStore); + render( + , + withAnalysisStore, + ); const input = screen.getByTestId('segment-free-translation-input'); await userEvent.type(input, 'au commencement'); @@ -702,7 +726,13 @@ describe('SegmentView', () => { }); it('does not commit on blur when the draft is unchanged', async () => { - render(, withAnalysisStore); + render( + , + withAnalysisStore, + ); const input = screen.getByTestId('segment-free-translation-input'); await userEvent.click(input); @@ -713,7 +743,14 @@ describe('SegmentView', () => { it('makes the segment active when the free-translation input is focused', async () => { const handleSelect = jest.fn(); - render(, withAnalysisStore); + render( + , + withAnalysisStore, + ); await userEvent.click(screen.getByTestId('segment-free-translation-input')); @@ -723,7 +760,12 @@ describe('SegmentView', () => { it('does not select on free-translation focus when the segment has no word token', async () => { const handleSelect = jest.fn(); render( - , + , withAnalysisStore, ); From 6cd780104163f516a411d5c2ff7513788ff6eeb0 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 22 Jun 2026 11:07:59 -0400 Subject: [PATCH 4/6] Reduce test --- .../components/InterlinearizerLoader.test.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index d6ee2782..b50c53ef 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -592,6 +592,7 @@ describe('InterlinearizerLoader', () => { expect(capturedInterlinearizerProps?.viewOptions.simplifyPhrases).toBe(false); expect(capturedInterlinearizerProps?.viewOptions.chapterLabelInVerse).toBe(false); expect(capturedInterlinearizerProps?.viewOptions.showMorphology).toBe(false); + expect(capturedInterlinearizerProps?.viewOptions.showFreeTranslation).toBe(false); }); it('wires ViewOptionsDropdown hide-inactive-link-buttons to onChange from useOptimisticBooleanSetting', async () => { @@ -634,15 +635,6 @@ describe('InterlinearizerLoader', () => { expect(onChangeByKey.get('interlinearizer.showMorphology')).toHaveBeenCalledWith(true); }); - it('passes showFreeTranslation through to Interlinearizer from useOptimisticBooleanSetting', async () => { - mockOptimisticSetting(true); - await act(async () => { - renderLoader(); - }); - - expect(capturedInterlinearizerProps?.viewOptions.showFreeTranslation).toBe(true); - }); - it('wires ViewOptionsDropdown show-free-translation to onChange from useOptimisticBooleanSetting', async () => { const onChangeByKey = mockOptimisticSetting(); await act(async () => { From e8d45d2f9527c6a0177b977a34fcf7f0b6fa4401 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 22 Jun 2026 11:26:07 -0400 Subject: [PATCH 5/6] Refine localized-strings tests --- src/__tests__/utils/localized-strings.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/__tests__/utils/localized-strings.test.ts b/src/__tests__/utils/localized-strings.test.ts index 35937982..f393346a 100644 --- a/src/__tests__/utils/localized-strings.test.ts +++ b/src/__tests__/utils/localized-strings.test.ts @@ -8,11 +8,11 @@ describe('resolvedOrEmpty', () => { expect(resolvedOrEmpty('%interlinearizer_glossInput_placeholder%')).toBe(''); }); - it('returns a resolved localized string unchanged', () => { - expect(resolvedOrEmpty('gloss')).toBe('gloss'); + it('does not treat a string with only an initial percent sign as a key', () => { + expect(resolvedOrEmpty('% interest?')).toBe('% interest?'); }); - it('does not treat a string that merely contains a percent sign as a key', () => { - expect(resolvedOrEmpty('50% complete')).toBe('50% complete'); + it('does not treat a string with only a final percent sign as a key', () => { + expect(resolvedOrEmpty('complete: 50%')).toBe('complete: 50%'); }); }); From 759196f01e7207371f13dddff412ec57451871ef Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 22 Jun 2026 11:20:15 -0400 Subject: [PATCH 6/6] Add all-false ViewOptions test object --- .../components/ContinuousView.test.tsx | 18 +-- .../components/Interlinearizer.test.tsx | 121 +++--------------- src/__tests__/components/SegmentView.test.tsx | 10 +- src/__tests__/components/test-helpers.tsx | 10 ++ 4 files changed, 31 insertions(+), 128 deletions(-) diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index 30111c00..6226abbd 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -11,7 +11,7 @@ import type { PhraseDispatch } from '../../components/AnalysisStore'; import ContinuousView from '../../components/ContinuousView'; import { isWordToken } from '../../types/type-guards'; import type { ViewOptions } from '../../types/view-options'; -import { withAnalysisStore } from './test-helpers'; +import { allFalseViewOptions, withAnalysisStore } from './test-helpers'; // --------------------------------------------------------------------------- // AnalysisStore mock — pass-through provider so AnalysisStore.tsx stays out of scope @@ -431,13 +431,7 @@ function requiredProps( tokenSegmentMap, tokenDocOrder, wordTokenByRef, - viewOptions: { - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }, + viewOptions: { ...allFalseViewOptions }, }; } @@ -902,13 +896,7 @@ describe('ContinuousView scroll behavior', () => { tokenSegmentMap={tokenSegmentMap} tokenDocOrder={tokenDocOrder} wordTokenByRef={wordTokenByRef} - viewOptions={{ - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }} + viewOptions={{ ...allFalseViewOptions }} /> ); } diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx index 4a1634ac..c5405832 100644 --- a/src/__tests__/components/Interlinearizer.test.tsx +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -12,6 +12,7 @@ import { InterlinearNavProvider } from '../../components/InterlinearNavContext'; import type { SegmentDisplayMode } from '../../components/SegmentView'; import { RECENTER_FADE_MS } from '../../components/recenter-fade'; import { defaultScrRef, GEN_1_1_BOOK } from '../test-helpers'; +import { allFalseViewOptions } from './test-helpers'; jest.mock('lucide-react', () => ({ __esModule: true, @@ -519,13 +520,7 @@ describe('Interlinearizer', () => { analysisLanguage="und" phraseMode={{ kind: 'view' }} setPhraseMode={() => {}} - viewOptions={{ - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }} + viewOptions={{ ...allFalseViewOptions }} />, ), ); @@ -635,13 +630,7 @@ describe('Interlinearizer', () => { analysisLanguage="und" phraseMode={{ kind: 'view' }} setPhraseMode={() => {}} - viewOptions={{ - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }} + viewOptions={{ ...allFalseViewOptions }} />, ), ); @@ -675,13 +664,7 @@ describe('Interlinearizer', () => { analysisLanguage="und" phraseMode={{ kind: 'view' }} setPhraseMode={() => {}} - viewOptions={{ - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }} + viewOptions={{ ...allFalseViewOptions }} />, ), ); @@ -720,13 +703,7 @@ describe('Interlinearizer', () => { analysisLanguage="und" phraseMode={{ kind: 'view' }} setPhraseMode={() => {}} - viewOptions={{ - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }} + viewOptions={{ ...allFalseViewOptions }} />, ), ); @@ -742,13 +719,7 @@ describe('Interlinearizer', () => { analysisLanguage="und" phraseMode={{ kind: 'view' }} setPhraseMode={() => {}} - viewOptions={{ - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }} + viewOptions={{ ...allFalseViewOptions }} />, ), ); @@ -789,13 +760,7 @@ describe('Interlinearizer', () => { analysisLanguage="und" phraseMode={{ kind: 'view' }} setPhraseMode={() => {}} - viewOptions={{ - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }} + viewOptions={{ ...allFalseViewOptions }} />, ), ); @@ -830,13 +795,7 @@ describe('Interlinearizer', () => { analysisLanguage="und" phraseMode={{ kind: 'view' }} setPhraseMode={() => {}} - viewOptions={{ - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }} + viewOptions={{ ...allFalseViewOptions }} />, ), ); @@ -930,13 +889,7 @@ describe('Interlinearizer', () => { analysisLanguage="und" phraseMode={{ kind: 'view' }} setPhraseMode={() => {}} - viewOptions={{ - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }} + viewOptions={{ ...allFalseViewOptions }} />, ), ); @@ -959,13 +912,7 @@ describe('Interlinearizer', () => { originalTokens: [{ tokenRef: 'GEN 1:1:0', surfaceText: 'In' }], }} setPhraseMode={() => {}} - viewOptions={{ - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }} + viewOptions={{ ...allFalseViewOptions }} />, ), ); @@ -982,13 +929,7 @@ describe('Interlinearizer', () => { analysisLanguage="und" phraseMode={{ kind: 'confirm-unlink', phraseId: 'phrase-1' }} setPhraseMode={() => {}} - viewOptions={{ - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }} + viewOptions={{ ...allFalseViewOptions }} />, ), ); @@ -1007,13 +948,7 @@ describe('Interlinearizer', () => { analysisLanguage="und" phraseMode={{ kind: 'edit', phraseId: 'phrase-1', originalTokens, revert: true }} setPhraseMode={setPhraseMode} - viewOptions={{ - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }} + viewOptions={{ ...allFalseViewOptions }} />, ), ); @@ -1032,13 +967,7 @@ describe('Interlinearizer', () => { analysisLanguage="und" phraseMode={{ kind: 'edit', phraseId: 'phrase-1', originalTokens: [], revert: true }} setPhraseMode={setPhraseMode} - viewOptions={{ - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }} + viewOptions={{ ...allFalseViewOptions }} />, ), ); @@ -1056,13 +985,7 @@ describe('Interlinearizer', () => { analysisLanguage: 'und', phraseMode: { kind: 'view' } as const, setPhraseMode: () => {}, - viewOptions: { - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }, + viewOptions: { ...allFalseViewOptions }, }; const { container, rerender } = render( withNav( @@ -1128,13 +1051,7 @@ describe('Interlinearizer', () => { analysisLanguage="und" phraseMode={{ kind: 'view' }} setPhraseMode={() => {}} - viewOptions={{ - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }} + viewOptions={{ ...allFalseViewOptions }} /> ); } @@ -1163,13 +1080,7 @@ describe('Interlinearizer', () => { scrRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, phraseMode: { kind: 'view' } as const, setPhraseMode: () => {}, - viewOptions: { - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }, + viewOptions: { ...allFalseViewOptions }, }; const { container, rerender } = render( withNav(), diff --git a/src/__tests__/components/SegmentView.test.tsx b/src/__tests__/components/SegmentView.test.tsx index 5adba2af..597cd0d0 100644 --- a/src/__tests__/components/SegmentView.test.tsx +++ b/src/__tests__/components/SegmentView.test.tsx @@ -12,7 +12,7 @@ import { LINK_SLOT_TRANSITION_MS } from '../../components/PhraseStripParts'; import { SegmentView } from '../../components/SegmentView'; import type { ViewOptions } from '../../types/view-options'; import { makePhraseLink } from '../test-helpers'; -import { withAnalysisStore } from './test-helpers'; +import { allFalseViewOptions, withAnalysisStore } from './test-helpers'; // --------------------------------------------------------------------------- // AnalysisStore mock — pass-through provider so AnalysisStore.tsx stays out of scope @@ -213,13 +213,7 @@ function requiredProps(): { tokenSegmentMap: new Map(), tokenDocOrder: new Map(), wordTokenByRef: new Map(), - viewOptions: { - hideInactiveLinkButtons: false, - simplifyPhrases: false, - chapterLabelInVerse: false, - showMorphology: false, - showFreeTranslation: false, - }, + viewOptions: { ...allFalseViewOptions }, }; } diff --git a/src/__tests__/components/test-helpers.tsx b/src/__tests__/components/test-helpers.tsx index ba803715..0fcae20a 100644 --- a/src/__tests__/components/test-helpers.tsx +++ b/src/__tests__/components/test-helpers.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { AnalysisStoreProvider } from '../../components/AnalysisStore'; +import { ViewOptions } from '../../types/view-options'; /** * Testing Library render options that wrap a subject in `AnalysisStoreProvider` with the default @@ -15,3 +16,12 @@ export const withAnalysisStore = { return {children}; }, }; + +/** A {@link ViewOptions} object with every toggle set to `false`, for use as a test baseline. */ +export const allFalseViewOptions: ViewOptions = { + hideInactiveLinkButtons: false, + simplifyPhrases: false, + chapterLabelInVerse: false, + showMorphology: false, + showFreeTranslation: false, +};