Skip to content

Commit d24b35d

Browse files
Render analyzed-token breakdown as a boxed morpheme grid (#132)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 73cf42b commit d24b35d

8 files changed

Lines changed: 544 additions & 214 deletions

File tree

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/** @file Unit tests for components/MorphemeBox.tsx. */
2+
/// <reference types="jest" />
3+
/// <reference types="@testing-library/jest-dom" />
4+
5+
import { useLocalizedStrings } from '@papi/frontend/react';
6+
import { fireEvent, render, screen } from '@testing-library/react';
7+
import userEvent from '@testing-library/user-event';
8+
import type { MorphemeAnalysis, Token } from 'interlinearizer';
9+
import * as AnalysisStore from '../../components/AnalysisStore';
10+
import { MorphemeBox, MorphemeGlossInput } from '../../components/MorphemeBox';
11+
12+
jest.mock('../../components/AnalysisStore');
13+
14+
const LOCALIZED = {
15+
'%interlinearizer_tokenChip_editMorphemes%': 'Edit morpheme breakdown for {token}',
16+
'%interlinearizer_morphemeGloss_label%': 'Gloss for morpheme {form}',
17+
};
18+
19+
beforeEach(() => {
20+
jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]);
21+
});
22+
23+
const WORD_TOKEN = {
24+
ref: 'GEN 1:1:0',
25+
surfaceText: 'hello',
26+
writingSystem: 'en',
27+
type: 'word',
28+
charStart: 0,
29+
charEnd: 5,
30+
} satisfies Token;
31+
32+
const MORPHEMES: MorphemeAnalysis[] = [
33+
{ id: 'm-1', form: 'hel', writingSystem: 'en' },
34+
{ id: 'm-2', form: '-lo', writingSystem: 'en' },
35+
];
36+
37+
/**
38+
* Renders {@link MorphemeBox} with required props defaulted so each test overrides only what it
39+
* asserts on.
40+
*
41+
* @param props - Overrides merged over the defaults.
42+
* @returns The render result.
43+
*/
44+
function renderBox(props: Partial<Parameters<typeof MorphemeBox>[0]> = {}) {
45+
return render(
46+
<MorphemeBox
47+
analysisLanguage="en"
48+
disabled={false}
49+
morphemes={MORPHEMES}
50+
onEditBreakdown={jest.fn()}
51+
popoverOpen={false}
52+
token={WORD_TOKEN}
53+
{...props}
54+
/>,
55+
);
56+
}
57+
58+
describe('MorphemeBox', () => {
59+
it('renders one form cell per morpheme', () => {
60+
renderBox();
61+
expect(screen.getByText('hel')).toBeInTheDocument();
62+
expect(screen.getByText('-lo')).toBeInTheDocument();
63+
});
64+
65+
it('renders one gloss input per morpheme', () => {
66+
renderBox();
67+
expect(screen.getAllByRole('textbox')).toHaveLength(2);
68+
});
69+
70+
it('exposes a single "edit breakdown" control for the whole forms row', () => {
71+
renderBox();
72+
expect(
73+
screen.getByRole('button', { name: 'Edit morpheme breakdown for hello' }),
74+
).toBeInTheDocument();
75+
});
76+
77+
it('places each form directly above its gloss in the same grid column', () => {
78+
renderBox();
79+
const firstForm = screen.getByText('hel');
80+
const firstGloss = screen.getByRole('textbox', { name: 'Gloss for morpheme hel' });
81+
// A morpheme and its gloss must share a column; the form sits on row 1, its gloss on row 2.
82+
expect(firstForm).toHaveStyle({ gridColumn: '1', gridRow: '1' });
83+
expect(firstGloss).toHaveStyle({ gridColumn: '1', gridRow: '2' });
84+
});
85+
86+
it('orders columns left-to-right by morpheme order', () => {
87+
renderBox();
88+
expect(screen.getByText('hel')).toHaveStyle({ gridColumn: '1' });
89+
expect(screen.getByText('-lo')).toHaveStyle({ gridColumn: '2' });
90+
});
91+
92+
it('preserves morpheme order under right-to-left document direction', () => {
93+
// RTL is first-class: the grid honors the document `dir` for column flow (column 1 lands on the
94+
// right), but DOM/source order — and thus the form-over-gloss column pairing — is unchanged.
95+
document.documentElement.dir = 'rtl';
96+
try {
97+
renderBox();
98+
expect(screen.getByText('hel')).toHaveStyle({ gridColumn: '1' });
99+
expect(screen.getByText('-lo')).toHaveStyle({ gridColumn: '2' });
100+
expect(screen.getByRole('textbox', { name: 'Gloss for morpheme hel' })).toHaveStyle({
101+
gridColumn: '1',
102+
});
103+
} finally {
104+
document.documentElement.dir = '';
105+
}
106+
});
107+
108+
it('sizes the column template to the morpheme count', () => {
109+
const { container } = renderBox();
110+
const box = container.querySelector('[style*="grid-template-columns"]');
111+
expect(box).toHaveStyle({ gridTemplateColumns: 'repeat(2, minmax(1ch, auto))' });
112+
});
113+
114+
it('calls onEditBreakdown when a form cell is clicked', async () => {
115+
const onEditBreakdown = jest.fn();
116+
renderBox({ onEditBreakdown });
117+
await userEvent.click(screen.getByText('hel'));
118+
expect(onEditBreakdown).toHaveBeenCalledTimes(1);
119+
});
120+
121+
it('calls onEditBreakdown when a non-first form cell is clicked', async () => {
122+
// Non-first cells are spans, not buttons, so they need their own coverage.
123+
const onEditBreakdown = jest.fn();
124+
renderBox({ onEditBreakdown });
125+
await userEvent.click(screen.getByText('-lo'));
126+
expect(onEditBreakdown).toHaveBeenCalledTimes(1);
127+
});
128+
129+
it('stops a non-first form cell mousedown from bubbling to an ancestor handler', () => {
130+
// Regression test: an ancestor onMouseDown (e.g. TokenChip's label) would otherwise focus the
131+
// gloss input, since a span (unlike a button) doesn't match its input/button guard.
132+
const onAncestorMouseDown = jest.fn();
133+
render(
134+
// Stand-in for an ancestor React handler, not real UI.
135+
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
136+
<div onMouseDown={onAncestorMouseDown}>
137+
<MorphemeBox
138+
analysisLanguage="en"
139+
disabled={false}
140+
morphemes={MORPHEMES}
141+
onEditBreakdown={jest.fn()}
142+
popoverOpen={false}
143+
token={WORD_TOKEN}
144+
/>
145+
</div>,
146+
);
147+
fireEvent.mouseDown(screen.getByText('-lo'));
148+
expect(onAncestorMouseDown).not.toHaveBeenCalled();
149+
});
150+
151+
it('does not call onEditBreakdown when disabled', async () => {
152+
const onEditBreakdown = jest.fn();
153+
renderBox({ disabled: true, onEditBreakdown });
154+
await userEvent.click(screen.getByText('hel'));
155+
expect(onEditBreakdown).not.toHaveBeenCalled();
156+
});
157+
158+
it('disables the gloss inputs when disabled', () => {
159+
renderBox({ disabled: true });
160+
expect(screen.getByRole('textbox', { name: 'Gloss for morpheme hel' })).toBeDisabled();
161+
});
162+
163+
it('tints the forms row on hover and clears it on leave', async () => {
164+
// Hovering any form cell tints the whole forms row (the edit action is breakdown-wide). The
165+
// tint class itself would be brittle to assert; this exercises the hover handlers and the
166+
// state they drive, leaving the box intact through enter/leave.
167+
renderBox();
168+
const form = screen.getByText('hel');
169+
await userEvent.hover(form);
170+
expect(form).toBeInTheDocument();
171+
await userEvent.unhover(form);
172+
expect(form).toBeInTheDocument();
173+
});
174+
175+
it('still renders its cells while the breakdown editor is open (active look)', () => {
176+
// The box takes an accent ring while `popoverOpen` (asserted via class would be brittle); what
177+
// matters behaviorally is that the box stays intact and editable while the editor is open.
178+
renderBox({ popoverOpen: true });
179+
expect(screen.getByText('hel')).toBeInTheDocument();
180+
expect(screen.getByRole('textbox', { name: 'Gloss for morpheme hel' })).toBeInTheDocument();
181+
});
182+
});
183+
184+
describe('MorphemeGlossInput', () => {
185+
it('renders an empty input when no gloss exists', () => {
186+
render(
187+
<MorphemeGlossInput
188+
analysisLanguage="und"
189+
column={1}
190+
disabled={false}
191+
morpheme={{ id: 'm-1', form: 'un-', writingSystem: 'und' }}
192+
tokenRef="tok-1"
193+
/>,
194+
);
195+
expect(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' })).toHaveValue('');
196+
});
197+
198+
it('renders the existing gloss value', () => {
199+
render(
200+
<MorphemeGlossInput
201+
analysisLanguage="und"
202+
column={1}
203+
disabled={false}
204+
morpheme={{ id: 'm-1', form: 'un-', writingSystem: 'und', gloss: { und: 'not' } }}
205+
tokenRef="tok-1"
206+
/>,
207+
);
208+
expect(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' })).toHaveValue('not');
209+
});
210+
211+
it('does not dispatch when blurring without changes', async () => {
212+
const dispatchMock = jest.fn();
213+
jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(dispatchMock);
214+
215+
render(
216+
<MorphemeGlossInput
217+
analysisLanguage="und"
218+
column={1}
219+
disabled={false}
220+
morpheme={{ id: 'm-1', form: 'un-', writingSystem: 'und', gloss: { und: 'not' } }}
221+
tokenRef="tok-1"
222+
/>,
223+
);
224+
await userEvent.click(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' }));
225+
await userEvent.tab();
226+
expect(dispatchMock).not.toHaveBeenCalled();
227+
});
228+
229+
it('dispatches the gloss on blur when the draft differs', async () => {
230+
const dispatchMock = jest.fn();
231+
jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(dispatchMock);
232+
233+
render(
234+
<MorphemeGlossInput
235+
analysisLanguage="und"
236+
column={1}
237+
disabled={false}
238+
morpheme={{ id: 'm-1', form: 'un-', writingSystem: 'und' }}
239+
tokenRef="tok-1"
240+
/>,
241+
);
242+
await userEvent.type(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' }), 'not');
243+
await userEvent.tab();
244+
expect(dispatchMock).toHaveBeenCalledWith('tok-1', 'm-1', 'not');
245+
});
246+
247+
it('does not dispatch when disabled', () => {
248+
const dispatchMock = jest.fn();
249+
jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(dispatchMock);
250+
251+
render(
252+
<MorphemeGlossInput
253+
analysisLanguage="und"
254+
column={1}
255+
disabled
256+
morpheme={{ id: 'm-1', form: 'un-', writingSystem: 'und' }}
257+
tokenRef="tok-1"
258+
/>,
259+
);
260+
expect(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' })).toBeDisabled();
261+
});
262+
});

src/__tests__/components/MorphemeEditor.test.tsx

Lines changed: 1 addition & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import { useLocalizedStrings } from '@papi/frontend/react';
66
import { fireEvent, render, screen } from '@testing-library/react';
77
import userEvent from '@testing-library/user-event';
88
import type { ComponentProps } from 'react';
9-
import * as AnalysisStore from '../../components/AnalysisStore';
10-
import { MorphemeBreakdownPopover, MorphemeGlossInput } from '../../components/MorphemeEditor';
9+
import { MorphemeBreakdownPopover } from '../../components/MorphemeEditor';
1110

1211
jest.mock('../../components/AnalysisStore');
1312

@@ -303,79 +302,3 @@ describe('MorphemeBreakdownPopover', () => {
303302
expect(screen.getByRole('textbox', { name: 'token gloss' })).toHaveFocus();
304303
});
305304
});
306-
307-
describe('MorphemeGlossInput', () => {
308-
it('renders an empty input when no gloss exists', () => {
309-
render(
310-
<MorphemeGlossInput
311-
morpheme={{ id: 'm-1', form: 'un-', writingSystem: 'und' }}
312-
tokenRef="tok-1"
313-
analysisLanguage="und"
314-
disabled={false}
315-
/>,
316-
);
317-
expect(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' })).toHaveValue('');
318-
});
319-
320-
it('renders the existing gloss value', () => {
321-
render(
322-
<MorphemeGlossInput
323-
morpheme={{ id: 'm-1', form: 'un-', writingSystem: 'und', gloss: { und: 'not' } }}
324-
tokenRef="tok-1"
325-
analysisLanguage="und"
326-
disabled={false}
327-
/>,
328-
);
329-
expect(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' })).toHaveValue('not');
330-
});
331-
332-
it('does not dispatch when blurring without changes', async () => {
333-
const dispatchMock = jest.fn();
334-
jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(dispatchMock);
335-
336-
render(
337-
<MorphemeGlossInput
338-
morpheme={{ id: 'm-1', form: 'un-', writingSystem: 'und', gloss: { und: 'not' } }}
339-
tokenRef="tok-1"
340-
analysisLanguage="und"
341-
disabled={false}
342-
/>,
343-
);
344-
await userEvent.click(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' }));
345-
await userEvent.tab();
346-
expect(dispatchMock).not.toHaveBeenCalled();
347-
});
348-
349-
it('dispatches the gloss on blur when the draft differs', async () => {
350-
const dispatchMock = jest.fn();
351-
jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(dispatchMock);
352-
353-
render(
354-
<MorphemeGlossInput
355-
morpheme={{ id: 'm-1', form: 'un-', writingSystem: 'und' }}
356-
tokenRef="tok-1"
357-
analysisLanguage="und"
358-
disabled={false}
359-
/>,
360-
);
361-
await userEvent.type(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' }), 'not');
362-
await userEvent.tab();
363-
expect(dispatchMock).toHaveBeenCalledWith('tok-1', 'm-1', 'not');
364-
});
365-
366-
it('does not dispatch when disabled', async () => {
367-
const dispatchMock = jest.fn();
368-
jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(dispatchMock);
369-
370-
render(
371-
<MorphemeGlossInput
372-
morpheme={{ id: 'm-1', form: 'un-', writingSystem: 'und' }}
373-
tokenRef="tok-1"
374-
analysisLanguage="und"
375-
disabled
376-
/>,
377-
);
378-
const input = screen.getByRole('textbox', { name: 'Gloss for morpheme un-' });
379-
expect(input).toBeDisabled();
380-
});
381-
});

0 commit comments

Comments
 (0)