Skip to content

Commit 17a7ecd

Browse files
Replace GlossStore with AnalysisStore; migrate token.id → token.ref
- `GlossStore` stored a flat string map keyed by `token.id`. `AnalysisStore` replaces it with a `TextAnalysis`-backed store: each gloss write creates a new approved `TokenAnalysis` + `TokenAnalysisLink`, the full analysis snapshot is accessible via `useAnalysis`, and `onSave` propagates changes to the caller for persistence. The store also accepts an `analysisLanguage` prop to support non-English glosses. - Token identity keys are migrated from token.id to token.ref throughout (`TokenChip`, `PhraseBox`, `SegmentView`, `ContinuousView`, `Interlinearizer`, and all corresponding tests). - Some unneeded tests removed and some adjustments to documents made
1 parent ffd5725 commit 17a7ecd

21 files changed

Lines changed: 943 additions & 597 deletions

__mocks__/lucide-react.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,23 @@ import type { ReactElement } from 'react';
1414
export function LocateFixed(props: Readonly<{ className?: string }>): ReactElement {
1515
return <svg data-testid="locate-fixed-icon" {...props} />;
1616
}
17+
18+
/**
19+
* Stub for the Info icon.
20+
*
21+
* @param props - SVG props forwarded from the component.
22+
* @returns A ReactElement SVG element used as an info icon stub in tests.
23+
*/
24+
export function Info(props: Readonly<{ size?: number; className?: string }>): ReactElement {
25+
return <svg data-testid="info-icon" {...props} />;
26+
}
27+
28+
/**
29+
* Stub for the Trash2 icon.
30+
*
31+
* @param props - SVG props forwarded from the component.
32+
* @returns A ReactElement SVG element used as a trash icon stub in tests.
33+
*/
34+
export function Trash2(props: Readonly<{ size?: number; className?: string }>): ReactElement {
35+
return <svg data-testid="trash2-icon" {...props} />;
36+
}
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
/** @file Unit tests for components/AnalysisStore.tsx. */
2+
/// <reference types="jest" />
3+
/// <reference types="@testing-library/jest-dom" />
4+
5+
import { render, screen } from '@testing-library/react';
6+
import userEvent from '@testing-library/user-event';
7+
import type { TextAnalysis, TokenAnalysis, TokenAnalysisLink } from 'interlinearizer';
8+
import {
9+
AnalysisStoreProvider,
10+
useAnalysis,
11+
useGloss,
12+
useGlossDispatch,
13+
} from '../../components/AnalysisStore';
14+
15+
// ---------------------------------------------------------------------------
16+
// Helpers
17+
// ---------------------------------------------------------------------------
18+
19+
/**
20+
* Builds a minimal `TextAnalysis` with a single approved `TokenAnalysis` for the given token.
21+
*
22+
* @param tokenRef - Token reference string.
23+
* @param gloss - Gloss value for the `'en'` language key.
24+
* @param surfaceText - Surface text of the token.
25+
* @returns A `TextAnalysis` seeded with one approved token analysis.
26+
*/
27+
function makeAnalysisWithGloss(
28+
tokenRef: string,
29+
gloss: string,
30+
surfaceText = 'word',
31+
): TextAnalysis {
32+
const ta: TokenAnalysis = {
33+
id: `${tokenRef}-analysis`,
34+
surfaceText,
35+
gloss: { en: gloss },
36+
};
37+
const link: TokenAnalysisLink = {
38+
analysisId: ta.id,
39+
status: 'approved',
40+
token: { tokenRef, surfaceText },
41+
};
42+
return {
43+
segmentAnalyses: [],
44+
segmentAnalysisLinks: [],
45+
tokenAnalyses: [ta],
46+
tokenAnalysisLinks: [link],
47+
phraseAnalyses: [],
48+
phraseAnalysisLinks: [],
49+
};
50+
}
51+
52+
/**
53+
* Renders a component that displays the gloss for a single token, used to assert on `useGloss`.
54+
*
55+
* @param tokenRef - Token ref to subscribe to.
56+
* @returns JSX element suitable for passing to `render`.
57+
*/
58+
function GlossReader({ tokenRef }: Readonly<{ tokenRef: string }>) {
59+
const gloss = useGloss(tokenRef);
60+
return <span data-testid="gloss">{gloss}</span>;
61+
}
62+
63+
/**
64+
* Renders a component that displays the full analysis as JSON, used to assert on `useAnalysis`.
65+
*
66+
* @returns JSX element suitable for passing to `render`.
67+
*/
68+
function AnalysisReader() {
69+
const analysis = useAnalysis();
70+
return <span data-testid="analysis">{JSON.stringify(analysis)}</span>;
71+
}
72+
73+
/**
74+
* Renders a component that calls `useGlossDispatch` without a provider, used to assert the hook
75+
* throws outside an {@link AnalysisStoreProvider}.
76+
*
77+
* @returns Nothing — only mounted to trigger the throw.
78+
*/
79+
function DispatchUser() {
80+
useGlossDispatch();
81+
return undefined;
82+
}
83+
84+
/**
85+
* Renders a button that calls `useGlossDispatch` to write a gloss, used to test dispatch.
86+
*
87+
* @param props.tokenRef - Token ref to write.
88+
* @param props.surfaceText - Surface text of the token.
89+
* @param props.value - Gloss value to write.
90+
* @returns JSX element suitable for passing to `render`.
91+
*/
92+
function GlossWriter({
93+
tokenRef,
94+
surfaceText,
95+
value,
96+
}: Readonly<{ tokenRef: string; surfaceText: string; value: string }>) {
97+
const dispatch = useGlossDispatch();
98+
return (
99+
<button onClick={() => dispatch(tokenRef, surfaceText, value)} type="button">
100+
write
101+
</button>
102+
);
103+
}
104+
105+
// ---------------------------------------------------------------------------
106+
// Tests
107+
// ---------------------------------------------------------------------------
108+
109+
describe('useGloss', () => {
110+
it('returns an empty string for an unknown token', () => {
111+
render(
112+
<AnalysisStoreProvider>
113+
<GlossReader tokenRef="tok-1" />
114+
</AnalysisStoreProvider>,
115+
);
116+
expect(screen.getByTestId('gloss')).toHaveTextContent('');
117+
});
118+
119+
it('returns the approved gloss from initialAnalysis', () => {
120+
render(
121+
<AnalysisStoreProvider initialAnalysis={makeAnalysisWithGloss('tok-1', 'hello')}>
122+
<GlossReader tokenRef="tok-1" />
123+
</AnalysisStoreProvider>,
124+
);
125+
expect(screen.getByTestId('gloss')).toHaveTextContent('hello');
126+
});
127+
128+
it('returns empty string for a token with a non-approved link in initialAnalysis', () => {
129+
const ta: TokenAnalysis = { id: 'ta-1', surfaceText: 'word', gloss: { en: 'hi' } };
130+
const link: TokenAnalysisLink = {
131+
analysisId: 'ta-1',
132+
status: 'suggested',
133+
token: { tokenRef: 'tok-1', surfaceText: 'word' },
134+
};
135+
const analysis: TextAnalysis = {
136+
segmentAnalyses: [],
137+
segmentAnalysisLinks: [],
138+
tokenAnalyses: [ta],
139+
tokenAnalysisLinks: [link],
140+
phraseAnalyses: [],
141+
phraseAnalysisLinks: [],
142+
};
143+
render(
144+
<AnalysisStoreProvider initialAnalysis={analysis}>
145+
<GlossReader tokenRef="tok-1" />
146+
</AnalysisStoreProvider>,
147+
);
148+
expect(screen.getByTestId('gloss')).toHaveTextContent('');
149+
});
150+
151+
it('updates when the subscribed token is glossed via dispatch', async () => {
152+
render(
153+
<AnalysisStoreProvider>
154+
<GlossReader tokenRef="tok-1" />
155+
<GlossWriter tokenRef="tok-1" surfaceText="word" value="world" />
156+
</AnalysisStoreProvider>,
157+
);
158+
expect(screen.getByTestId('gloss')).toHaveTextContent('');
159+
await userEvent.click(screen.getByRole('button', { name: 'write' }));
160+
expect(screen.getByTestId('gloss')).toHaveTextContent('world');
161+
});
162+
163+
it('does not re-render when a different token is glossed', async () => {
164+
let renderCount = 0;
165+
166+
function CountingGlossReader({ tokenRef }: Readonly<{ tokenRef: string }>) {
167+
renderCount += 1;
168+
const gloss = useGloss(tokenRef);
169+
return <span data-testid="gloss">{gloss}</span>;
170+
}
171+
172+
render(
173+
<AnalysisStoreProvider>
174+
<CountingGlossReader tokenRef="tok-1" />
175+
<GlossWriter tokenRef="tok-2" surfaceText="other" value="other" />
176+
</AnalysisStoreProvider>,
177+
);
178+
const initialRenderCount = renderCount;
179+
await userEvent.click(screen.getByRole('button', { name: 'write' }));
180+
expect(renderCount).toBe(initialRenderCount);
181+
});
182+
183+
it('uses the analysisLanguage prop to resolve the gloss', () => {
184+
const ta: TokenAnalysis = { id: 'ta-1', surfaceText: 'mot', gloss: { fr: 'bonjour' } };
185+
const link: TokenAnalysisLink = {
186+
analysisId: 'ta-1',
187+
status: 'approved',
188+
token: { tokenRef: 'tok-1', surfaceText: 'mot' },
189+
};
190+
const analysis: TextAnalysis = {
191+
segmentAnalyses: [],
192+
segmentAnalysisLinks: [],
193+
tokenAnalyses: [ta],
194+
tokenAnalysisLinks: [link],
195+
phraseAnalyses: [],
196+
phraseAnalysisLinks: [],
197+
};
198+
render(
199+
<AnalysisStoreProvider initialAnalysis={analysis} analysisLanguage="fr">
200+
<GlossReader tokenRef="tok-1" />
201+
</AnalysisStoreProvider>,
202+
);
203+
expect(screen.getByTestId('gloss')).toHaveTextContent('bonjour');
204+
});
205+
206+
it('throws when called outside an AnalysisStoreProvider', () => {
207+
jest.spyOn(console, 'error').mockImplementation(() => {});
208+
expect(() => render(<GlossReader tokenRef="tok-1" />)).toThrow(
209+
'useGloss must be used inside an AnalysisStoreProvider',
210+
);
211+
});
212+
});
213+
214+
describe('useAnalysis', () => {
215+
it('returns an empty analysis when no initialAnalysis is provided', () => {
216+
render(
217+
<AnalysisStoreProvider>
218+
<AnalysisReader />
219+
</AnalysisStoreProvider>,
220+
);
221+
const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? '');
222+
expect(analysis.tokenAnalyses).toHaveLength(0);
223+
expect(analysis.tokenAnalysisLinks).toHaveLength(0);
224+
});
225+
226+
it('returns seeded analyses from initialAnalysis', () => {
227+
const seed = makeAnalysisWithGloss('tok-1', 'hi');
228+
render(
229+
<AnalysisStoreProvider initialAnalysis={seed}>
230+
<AnalysisReader />
231+
</AnalysisStoreProvider>,
232+
);
233+
const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? '');
234+
expect(analysis.tokenAnalyses).toHaveLength(1);
235+
expect(analysis.tokenAnalysisLinks).toHaveLength(1);
236+
});
237+
238+
it('updates after a gloss write', async () => {
239+
render(
240+
<AnalysisStoreProvider>
241+
<AnalysisReader />
242+
<GlossWriter tokenRef="tok-1" surfaceText="word" value="world" />
243+
</AnalysisStoreProvider>,
244+
);
245+
await userEvent.click(screen.getByRole('button', { name: 'write' }));
246+
const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? '');
247+
expect(analysis.tokenAnalyses).toHaveLength(1);
248+
expect(analysis.tokenAnalyses[0].gloss).toStrictEqual({ en: 'world' });
249+
expect(analysis.tokenAnalysisLinks[0].status).toBe('approved');
250+
});
251+
252+
it('throws when called outside an AnalysisStoreProvider', () => {
253+
jest.spyOn(console, 'error').mockImplementation(() => {});
254+
expect(() => render(<AnalysisReader />)).toThrow(
255+
'useAnalysis must be used inside an AnalysisStoreProvider',
256+
);
257+
});
258+
});
259+
260+
describe('useGlossDispatch', () => {
261+
it('creates a new approved TokenAnalysis on each write', async () => {
262+
render(
263+
<AnalysisStoreProvider>
264+
<AnalysisReader />
265+
<GlossWriter tokenRef="tok-1" surfaceText="word" value="hi" />
266+
</AnalysisStoreProvider>,
267+
);
268+
await userEvent.click(screen.getByRole('button', { name: 'write' }));
269+
await userEvent.click(screen.getByRole('button', { name: 'write' }));
270+
const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? '');
271+
expect(analysis.tokenAnalyses).toHaveLength(2);
272+
expect(analysis.tokenAnalysisLinks).toHaveLength(2);
273+
analysis.tokenAnalysisLinks.forEach((link) => expect(link.status).toBe('approved'));
274+
});
275+
276+
it('does not touch existing suggested analyses on write', async () => {
277+
const suggested: TokenAnalysis = {
278+
id: 'suggested-1',
279+
surfaceText: 'word',
280+
gloss: { en: 'old' },
281+
};
282+
const suggestedLink: TokenAnalysisLink = {
283+
analysisId: 'suggested-1',
284+
status: 'suggested',
285+
token: { tokenRef: 'tok-1', surfaceText: 'word' },
286+
};
287+
const seed: TextAnalysis = {
288+
segmentAnalyses: [],
289+
segmentAnalysisLinks: [],
290+
tokenAnalyses: [suggested],
291+
tokenAnalysisLinks: [suggestedLink],
292+
phraseAnalyses: [],
293+
phraseAnalysisLinks: [],
294+
};
295+
render(
296+
<AnalysisStoreProvider initialAnalysis={seed}>
297+
<AnalysisReader />
298+
<GlossWriter tokenRef="tok-1" surfaceText="word" value="new" />
299+
</AnalysisStoreProvider>,
300+
);
301+
await userEvent.click(screen.getByRole('button', { name: 'write' }));
302+
const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? '');
303+
expect(analysis.tokenAnalyses).toHaveLength(2);
304+
const suggestedEntry = analysis.tokenAnalysisLinks.find((l) => l.status === 'suggested');
305+
const approvedEntry = analysis.tokenAnalysisLinks.find((l) => l.status === 'approved');
306+
expect(suggestedEntry?.analysisId).toBe('suggested-1');
307+
expect(approvedEntry?.analysisId).not.toBe('suggested-1');
308+
});
309+
310+
it('calls the onGlossChange spy with tokenRef and value', async () => {
311+
const spy = jest.fn();
312+
render(
313+
<AnalysisStoreProvider onGlossChange={spy}>
314+
<GlossWriter tokenRef="tok-1" surfaceText="word" value="hi" />
315+
</AnalysisStoreProvider>,
316+
);
317+
await userEvent.click(screen.getByRole('button', { name: 'write' }));
318+
expect(spy).toHaveBeenCalledTimes(1);
319+
expect(spy).toHaveBeenCalledWith('tok-1', 'hi');
320+
});
321+
322+
it('calls onSave with the updated TextAnalysis', async () => {
323+
const onSave = jest.fn();
324+
render(
325+
<AnalysisStoreProvider onSave={onSave}>
326+
<GlossWriter tokenRef="tok-1" surfaceText="word" value="hi" />
327+
</AnalysisStoreProvider>,
328+
);
329+
await userEvent.click(screen.getByRole('button', { name: 'write' }));
330+
expect(onSave).toHaveBeenCalledTimes(1);
331+
const saved: TextAnalysis = onSave.mock.calls[0][0];
332+
expect(saved.tokenAnalyses[0].gloss).toStrictEqual({ en: 'hi' });
333+
});
334+
335+
it('throws when called outside an AnalysisStoreProvider', () => {
336+
jest.spyOn(console, 'error').mockImplementation(() => {});
337+
expect(() => render(<DispatchUser />)).toThrow(
338+
'useGlossDispatch must be used inside an AnalysisStoreProvider',
339+
);
340+
});
341+
});

0 commit comments

Comments
 (0)