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