Skip to content

Commit ba4ba3f

Browse files
Audit docs, clean up tests, minor adjustments
1 parent 5010335 commit ba4ba3f

32 files changed

Lines changed: 477 additions & 171 deletions

src/__tests__/components/ArcOverlay.test.tsx

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -367,9 +367,24 @@ describe('ArcOverlay', () => {
367367
/>,
368368
);
369369
const btns = screen.getAllByTestId('split-arc-btn');
370-
// Hover the first split button — tok-b's arc goes through focusedPhraseId branch (returns 2).
370+
// Hover the first split button — tok-b's arc goes through focusedPhraseId branch.
371371
await userEvent.hover(btns[0]);
372-
expect(document.querySelectorAll('path')).toHaveLength(2);
372+
373+
// The free-split-hovered tok-a arc is promoted to the z-5 (hovered) layer.
374+
const hoveredLayer = document.querySelector('svg.tw\\:z-5');
375+
const hoveredPaths = hoveredLayer?.querySelectorAll('path') ?? [];
376+
expect(hoveredPaths).toHaveLength(1);
377+
expect(hoveredPaths[0].getAttribute('d')).toContain('tok-a');
378+
379+
// tok-b's arc is classified focused via the focusedPhraseId branch, so it sits in the z-3
380+
// (focused) layer — not the z-1 unfocused layer it would fall to if that branch regressed.
381+
const focusedLayer = document.querySelector('svg.tw\\:z-3');
382+
const focusedPaths = focusedLayer?.querySelectorAll('path') ?? [];
383+
expect(focusedPaths).toHaveLength(1);
384+
expect(focusedPaths[0].getAttribute('d')).toContain('tok-b');
385+
386+
// Nothing falls through to the unfocused (z-1) layer.
387+
expect(document.querySelector('svg.tw\\:z-1')?.querySelectorAll('path')).toHaveLength(0);
373388
});
374389

375390
it('assigns candidate priority to an arc via candidatePhraseIds when split-hover misses its splitAfterTokenRef and focusedPhraseId does not match', async () => {
@@ -395,7 +410,21 @@ describe('ArcOverlay', () => {
395410
);
396411
const btns = screen.getAllByTestId('split-arc-btn');
397412
await userEvent.hover(btns[0]);
398-
expect(document.querySelectorAll('path')).toHaveLength(2);
413+
414+
// Both arcs land in the z-5 (hovered) layer: tok-a via the free-split promotion, and tok-b via
415+
// the candidatePhraseIds.has('p1') branch. If that candidate clause regressed, tok-b would drop
416+
// to the z-1 (unfocused) layer and would no longer appear here.
417+
const hoveredLayer = document.querySelector('svg.tw\\:z-5');
418+
const hoveredDs = Array.from(hoveredLayer?.querySelectorAll('path') ?? []).map((p) =>
419+
p.getAttribute('d'),
420+
);
421+
expect(hoveredDs).toHaveLength(2);
422+
expect(hoveredDs).toEqual(
423+
expect.arrayContaining([expect.stringContaining('tok-a'), expect.stringContaining('tok-b')]),
424+
);
425+
426+
// No arc is demoted to the unfocused (z-1) layer.
427+
expect(document.querySelector('svg.tw\\:z-1')?.querySelectorAll('path')).toHaveLength(0);
399428
});
400429

401430
it('highlights the phrase via onHoverPhrase on enter when the split would not free any token', async () => {

src/__tests__/components/ContinuousView.test.tsx

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,22 @@ jest.mock('../../components/TokenLinkIcon', () => ({
8888

8989
jest.mock('../../components/ArcOverlay', () => ({
9090
__esModule: true,
91+
// Surface the props ContinuousView derives and forwards (hoveredPhraseId, candidatePhraseIds) as
92+
// data attributes so DOM queries can assert on values that otherwise only live inside ArcOverlay.
9193
default: ({
9294
onArcSplit,
93-
}: Readonly<{ onArcSplit: (phraseId: string, splitAfterTokenRef: string) => void }>) => (
95+
hoveredPhraseId,
96+
candidatePhraseIds,
97+
}: Readonly<{
98+
onArcSplit: (phraseId: string, splitAfterTokenRef: string) => void;
99+
hoveredPhraseId: string | undefined;
100+
candidatePhraseIds: ReadonlySet<string>;
101+
}>) => (
94102
<button
95103
type="button"
96104
data-testid="arc-split-btn"
105+
data-hovered-phrase-id={hoveredPhraseId ?? ''}
106+
data-candidate-phrase-ids={[...candidatePhraseIds].join(',')}
97107
onClick={() => onArcSplit('phrase-1', 'tok-0')}
98108
>
99109
split
@@ -1267,26 +1277,69 @@ describe('ContinuousView phrase grouping', () => {
12671277
expect(phraseBoxes[1]).toHaveAttribute('data-show-gloss', 'false');
12681278
});
12691279

1270-
it('fires mouse-leave on the token strip without throwing', async () => {
1280+
it('clears the hovered phrase highlight when the pointer leaves the token strip', async () => {
1281+
// Group tok-0/tok-1 into one hoverable phrase so hovering it sets hoveredPhraseId, which
1282+
// ContinuousView forwards to ArcOverlay. Leaving the strip runs clearAllHoverState, which must
1283+
// reset hoveredPhraseId to undefined.
1284+
const phraseLink: PhraseAnalysisLink = {
1285+
analysisId: 'phrase-1',
1286+
status: 'approved',
1287+
tokens: [
1288+
{ tokenRef: 'tok-0', surfaceText: 'In' },
1289+
{ tokenRef: 'tok-1', surfaceText: 'the' },
1290+
],
1291+
};
1292+
phraseLinkMap.set('tok-0', phraseLink);
1293+
phraseLinkMap.set('tok-1', phraseLink);
12711294
const book = makeBook();
12721295
render(<ContinuousView {...requiredProps(book)} />, withAnalysisStore);
1273-
const strip = screen.getByTestId('token-strip');
1274-
await userEvent.unhover(strip);
1275-
// No throw = pass
1296+
1297+
// Hover the phrase group to set hoveredPhraseId='phrase-1'.
1298+
const phraseGroupSpan = document.querySelector('[data-phrase-box="true"]')?.parentElement;
1299+
if (!phraseGroupSpan) throw new Error('Expected a phrase group wrapper span');
1300+
await userEvent.hover(phraseGroupSpan);
1301+
expect(screen.getByTestId('arc-split-btn')).toHaveAttribute(
1302+
'data-hovered-phrase-id',
1303+
'phrase-1',
1304+
);
1305+
1306+
// Leaving the strip itself (not the group) must clear the highlight via clearAllHoverState.
1307+
fireEvent.mouseLeave(screen.getByTestId('token-strip'));
1308+
expect(screen.getByTestId('arc-split-btn')).toHaveAttribute('data-hovered-phrase-id', '');
12761309
});
12771310

12781311
it('applies the internal focus transition when the parent reflects a click-driven ref change', async () => {
12791312
// Simulate: ContinuousView clicks Next, sets internalFocusedTokenRefRef, calls
12801313
// onFocusedTokenRefChange. The parent then passes the new focusedTokenRef back. This exercises
1281-
// the isInternal=true path (lines 306-308) of the pending-jump effect.
1314+
// the isInternal=true branch of the focus-change effect, which applies the new ref *immediately*
1315+
// (setDisplayFocusedTokenRef) without fading the strip out. The external (non-internal) branch
1316+
// would instead defer the display update behind a fade timeout, so the focused box would still
1317+
// be 'In' (tok-0) right after the rerender.
12821318
const book = makeBook();
12831319
const props = requiredProps(book, { focusedTokenRef: 'tok-0' });
12841320
const { rerender } = render(<ContinuousView {...props} />, withAnalysisStore);
12851321

1322+
// Sanity: tok-0's box ('In') is focused before the click.
1323+
expect(screen.getByText('In').closest('[data-phrase-box="true"]')).toHaveAttribute(
1324+
'data-focus-state',
1325+
'focused',
1326+
);
1327+
12861328
await userEvent.click(screen.getByRole('button', { name: 'Next token' }));
1287-
// Now reflect the new ref back as a prop change (as a real parent would do).
1329+
// Now reflect the new ref back as a prop change (as a real parent would do). Because the click
1330+
// stamped tok-1 as internally-originated, the echo is recognized as internal and applied at once.
12881331
rerender(<ContinuousView {...props} focusedTokenRef="tok-1" />);
1289-
// No throw = the isInternal path ran successfully.
1332+
1333+
// The displayed focus moved synchronously to tok-1's box ('the') — the internal path, not the
1334+
// fade-then-snap external path (which would leave 'In' focused until the fade timeout fires).
1335+
expect(screen.getByText('the').closest('[data-phrase-box="true"]')).toHaveAttribute(
1336+
'data-focus-state',
1337+
'focused',
1338+
);
1339+
expect(screen.getByText('In').closest('[data-phrase-box="true"]')).toHaveAttribute(
1340+
'data-focus-state',
1341+
'default',
1342+
);
12901343
});
12911344

12921345
it('scrolls to the first token of the active phrase when entering edit mode', async () => {
@@ -1400,6 +1453,25 @@ describe('ContinuousView phrase grouping', () => {
14001453
mockCandidateTokenRefs.current = new Set(['tok-0']);
14011454
const book = makeBook();
14021455
render(<ContinuousView {...requiredProps(book)} />, withAnalysisStore);
1403-
expect(screen.getByTestId('arc-split-btn')).toBeInTheDocument();
1456+
// useCandidatePhraseIds resolves the hovered candidate ref (tok-0) to the phrase that contains
1457+
// it, and ContinuousView forwards the set to ArcOverlay. The mock surfaces it as a data attr.
1458+
expect(screen.getByTestId('arc-split-btn')).toHaveAttribute(
1459+
'data-candidate-phrase-ids',
1460+
'phrase-1',
1461+
);
1462+
});
1463+
1464+
it('computes an empty candidatePhraseIds set when no candidate tokens are hovered', () => {
1465+
const phraseLink: PhraseAnalysisLink = {
1466+
analysisId: 'phrase-1',
1467+
status: 'approved',
1468+
tokens: [{ tokenRef: 'tok-0', surfaceText: 'In' }],
1469+
};
1470+
phraseLinkMap.set('tok-0', phraseLink);
1471+
// No hovered candidate refs: the phrase exists, but nothing should resolve to it.
1472+
mockCandidateTokenRefs.current = new Set();
1473+
const book = makeBook();
1474+
render(<ContinuousView {...requiredProps(book)} />, withAnalysisStore);
1475+
expect(screen.getByTestId('arc-split-btn')).toHaveAttribute('data-candidate-phrase-ids', '');
14041476
});
14051477
});

src/__tests__/components/Interlinearizer.test.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1064,7 +1064,13 @@ describe('Interlinearizer', () => {
10641064
throw new Error('Expected GEN 1:7 onSelect to be a function');
10651065
act(() => select({ book: 'GEN', chapter: 1, verse: 7 }, 'GEN 1:7:0'));
10661066

1067-
expect(container.querySelector('.tw\\:transition-opacity')).toHaveStyle({ opacity: '1' });
1067+
// Target the inner list wrapper (tw:gap-2) — the one that fades on external nav via isFaded —
1068+
// matching the positive-fade test above. The bare .tw:transition-opacity selector would return
1069+
// the outer mode-toggle wrapper instead, whose opacity is never driven here, masking a regressed
1070+
// (non-suppressed) recenter fade of the list.
1071+
expect(container.querySelector('.tw\\:gap-2.tw\\:transition-opacity')).toHaveStyle({
1072+
opacity: '1',
1073+
});
10681074
} finally {
10691075
jest.useRealTimers();
10701076
}

src/__tests__/components/InterlinearizerLoader.test.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ jest.mock('../../components/modals/ProjectModals', () => ({
241241
data-testid="project-modals"
242242
data-modal={modal}
243243
data-default-lang={defaultAnalysisLanguage}
244+
data-active-project-name={activeProject?.name}
244245
>
245246
{modal === 'select' && (
246247
<div data-testid="select-modal">
@@ -313,7 +314,10 @@ jest.mock('../../components/modals/ProjectModals', () => ({
313314
<button
314315
type="button"
315316
data-testid="metadata-modal-saved"
316-
onClick={() => setModal('none')}
317+
onClick={() => {
318+
setActiveProject({ ...STUB_ACTIVE_PROJECT, name: 'Renamed Project' });
319+
setModal('none');
320+
}}
317321
>
318322
Save
319323
</button>
@@ -834,6 +838,9 @@ describe('InterlinearizerLoader', () => {
834838
await userEvent.click(screen.getByTestId('metadata-modal-deleted'));
835839

836840
expect(screen.queryByTestId('metadata-modal')).not.toBeInTheDocument();
841+
// The deleted project was the active one, so the loader should now pass `activeProject:
842+
// undefined` down to ProjectModals (the stub omits the attribute when there is no name).
843+
expect(screen.getByTestId('project-modals')).not.toHaveAttribute('data-active-project-name');
837844
});
838845

839846
it('updates the active project name when its metadata is saved', async () => {
@@ -847,6 +854,12 @@ describe('InterlinearizerLoader', () => {
847854
await userEvent.click(screen.getByTestId('metadata-modal-saved'));
848855

849856
expect(screen.queryByTestId('metadata-modal')).not.toBeInTheDocument();
857+
// Saving renamed the active project; the loader must reflect the new name it reads back from
858+
// WebView state by passing it down to ProjectModals.
859+
expect(screen.getByTestId('project-modals')).toHaveAttribute(
860+
'data-active-project-name',
861+
'Renamed Project',
862+
);
850863
});
851864

852865
it('renders without error when useData provides a topMenu with items', async () => {

src/__tests__/components/MorphemeEditor.test.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,12 @@ describe('MorphemeBreakdownPopover', () => {
5353

5454
it('auto-focuses and selects the input on mount', () => {
5555
renderPopover({ initialValue: 'word' });
56-
expect(screen.getByRole('textbox')).toHaveFocus();
56+
const input = screen.getByRole('textbox');
57+
expect(input).toHaveFocus();
58+
// The mount effect calls .select(), so the whole value is selected and a fresh keystroke
59+
// replaces it. Asserting the selection range catches a regression that drops the .select() call.
60+
expect(input).toHaveProperty('selectionStart', 0);
61+
expect(input).toHaveProperty('selectionEnd', 'word'.length);
5762
});
5863

5964
it('calls onSave and onClose when Done button is clicked', async () => {
@@ -150,7 +155,13 @@ describe('MorphemeBreakdownPopover', () => {
150155

151156
it('does not save on outside interaction when the input is only whitespace', async () => {
152157
const onSave = jest.fn();
153-
renderPopover({ initialValue: ' ', onSave });
158+
// Start from a real word and edit it down to whitespace so the draft differs from initialValue
159+
// (isUnedited is false). This forces handleInteractOutside past the unedited guard into
160+
// handleSave, where the isMeaningless check is what rejects the empty breakdown — the behavior
161+
// this test names. If isMeaningless were removed, handleSave would call onSave and this fails.
162+
renderPopover({ initialValue: 'word', onSave, surfaceText: 'whole' });
163+
await userEvent.clear(screen.getByRole('textbox'));
164+
await userEvent.type(screen.getByRole('textbox'), ' ');
154165
await userEvent.click(screen.getByTestId('popover-outside'));
155166
expect(onSave).not.toHaveBeenCalled();
156167
});

src/__tests__/components/PhraseBox.test.tsx

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -933,10 +933,13 @@ describe('PhraseBox', () => {
933933
renderBox(<PhraseBox {...requiredProps()} />);
934934
const box = document.querySelector('[data-phrase-box="true"]');
935935
expect(box).not.toBeNull();
936-
// Focus the box container, then press Enter → should focus the first input.
936+
// Focus the box container, then press Enter → the keydown handler forwards focus to the first
937+
// gloss input. Asserting toHaveFocus makes the test fail if the Enter branch of
938+
// focusFirstGlossOnSelfKeyDown is broken or removed.
937939
if (box instanceof HTMLElement) box.focus();
938940
await userEvent.keyboard('{Enter}');
939-
// No throw = pass (jsdom doesn't fire input focus in this setup, but the handler runs).
941+
942+
expect(screen.getByRole('textbox', { name: 'Gloss for Hello' })).toHaveFocus();
940943
});
941944

942945
it('pops out a middle token from a 3+ token phrase in view mode (updatePhrase)', async () => {
@@ -1049,19 +1052,6 @@ describe('PhraseBox', () => {
10491052
expect(screen.getByRole('button', { name: 'Remove World' })).toBeInTheDocument();
10501053
});
10511054

1052-
it('pops out a token from a 3-token phrase by deleting when only 1 would remain (edge case)', async () => {
1053-
// Actually a 3-token phrase removes a middle token to leave 2 (≥2), so updatePhrase is called.
1054-
// For deletePhrase to be called, we need to go from 2 tokens to ≤1.
1055-
// The onRemove is only wired for middle tokens when length > 2. So we simulate handleViewPopOut
1056-
// via the phraseLink having exactly 2 tokens (which means the mock won't show Remove button).
1057-
// Instead, let's test the deletePhrase path by using a 3-token phrase removing to leave 1 token
1058-
// by mocking phraseLink.tokens to have 2 entries while tokens prop has 3, and removing the first.
1059-
// Actually, the easiest way is to test through phraseLink with 2 tokens where the condition
1060-
// doesn't show the Remove button. Skip this path via v8 ignore instead.
1061-
// This test just documents the behavior.
1062-
expect(true).toBe(true);
1063-
});
1064-
10651055
it('writes phrase gloss on blur when draft differs from committed', async () => {
10661056
mockUsePhraseGloss.mockReturnValue('');
10671057
const dispatchSpy = jest.fn();
@@ -1075,12 +1065,14 @@ describe('PhraseBox', () => {
10751065
});
10761066

10771067
it('ignores non-Enter/Space keys on the box container', async () => {
1078-
const setPhraseMode = jest.fn();
1079-
renderBox(<PhraseBox {...requiredProps()} />, { setPhraseMode });
1068+
renderBox(<PhraseBox {...requiredProps()} />);
10801069
const box = document.querySelector('[data-phrase-box="true"]');
10811070
if (box instanceof HTMLElement) box.focus();
1082-
await userEvent.keyboard('{Tab}');
1083-
expect(setPhraseMode).not.toHaveBeenCalled();
1071+
// ArrowRight is neither Enter nor Space and does not move focus by default, so
1072+
// focusFirstGlossOnSelfKeyDown must return early without focusing the first gloss input. If the
1073+
// Enter/Space guard were dropped, this key would forward focus to the gloss input and fail here.
1074+
await userEvent.keyboard('{ArrowRight}');
1075+
expect(screen.getByRole('textbox', { name: 'Gloss for Hello' })).not.toHaveFocus();
10841076
});
10851077

10861078
it('renders disabled style for a token in the wrong segment during edit mode', () => {

src/__tests__/components/PhraseStripParts.test.tsx

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import { makePhraseLink, makePhraseStripContext, makeWordToken } from '../test-h
2222

2323
jest.mock('../../components/TokenLinkIcon', () => ({
2424
__esModule: true,
25-
default: () => <span data-testid="link-icon" />,
25+
default: ({ isPhraseRevealed }: Readonly<{ isPhraseRevealed: boolean }>) => (
26+
<span data-testid="link-icon" data-phrase-revealed={String(isPhraseRevealed)} />
27+
),
2628
}));
2729

2830
jest.mock('../../components/TokenChip', () => ({
@@ -155,12 +157,29 @@ describe('PhraseSlot', () => {
155157
punctuationBetween: [],
156158
};
157159
const slot: LinkSlot = { prevGroup, nextGroup, punctuation: [] };
158-
// PhrasedRevealed means the unlink button is shown — but TokenLinkIcon is mocked to undefined,
159-
// so just check no errors are thrown when hoveredPhraseId matches.
160-
const { container } = render(
161-
withProvider(<PhraseSlot {...slotProps(slot)} hoveredPhraseId="p1" />),
162-
);
163-
expect(container.firstChild).not.toBeNull();
160+
// phraseRevealed (source lines 70-73) flows into TokenLinkIcon as isPhraseRevealed; the mock
161+
// surfaces it as data-phrase-revealed so we can assert the computation directly.
162+
render(withProvider(<PhraseSlot {...slotProps(slot)} hoveredPhraseId="p1" />));
163+
expect(screen.getByTestId('link-icon')).toHaveAttribute('data-phrase-revealed', 'true');
164+
});
165+
166+
it('does not set phraseRevealed when the hovered phrase differs from the neighbors', () => {
167+
const link = makePhraseLink('p1', ['tok-a', 'tok-b']);
168+
const prevGroup: TokenGroup = {
169+
tokens: [makeWordToken('tok-a')],
170+
phraseLink: link,
171+
firstIndex: 0,
172+
punctuationBetween: [],
173+
};
174+
const nextGroup: TokenGroup = {
175+
tokens: [makeWordToken('tok-b')],
176+
phraseLink: link,
177+
firstIndex: 1,
178+
punctuationBetween: [],
179+
};
180+
const slot: LinkSlot = { prevGroup, nextGroup, punctuation: [] };
181+
render(withProvider(<PhraseSlot {...slotProps(slot)} hoveredPhraseId="other-phrase" />));
182+
expect(screen.getByTestId('link-icon')).toHaveAttribute('data-phrase-revealed', 'false');
164183
});
165184

166185
it('sets phraseRevealed via focusedPhraseId when both neighbors are in the same focused phrase', () => {
@@ -185,12 +204,14 @@ describe('PhraseSlot', () => {
185204
focusedSegmentId: 'seg-1',
186205
focusedPhraseId: 'p1',
187206
};
188-
const { container } = render(
207+
// With no hover, the only way phraseRevealed becomes true is the focus.focusedPhraseId branch
208+
// (source line 73); the mock exposes it via data-phrase-revealed.
209+
render(
189210
withProvider(
190211
<PhraseSlot {...slotProps(slot)} focus={focusedContext} hoveredPhraseId={undefined} />,
191212
),
192213
);
193-
expect(container.firstChild).not.toBeNull();
214+
expect(screen.getByTestId('link-icon')).toHaveAttribute('data-phrase-revealed', 'true');
194215
});
195216

196217
it('renders the link icon when hideInactiveLinkButtons is off', () => {
@@ -228,7 +249,6 @@ describe('PhraseSlot', () => {
228249
);
229250
// Icon stays mounted but invisible (opacity:0 hides it while the min-height preserves layout space).
230251
const icon = screen.getByTestId('link-icon');
231-
expect(icon.parentElement?.style.visibility).toBeFalsy();
232252
expect(icon.parentElement?.style.opacity).toBe('0');
233253
});
234254

@@ -274,7 +294,6 @@ describe('PhraseSlot', () => {
274294
);
275295
// Icon stays mounted but invisible (opacity:0 hides it while the min-height preserves layout space).
276296
const icon = screen.getByTestId('link-icon');
277-
expect(icon.parentElement?.style.visibility).toBeFalsy();
278297
expect(icon.parentElement?.style.opacity).toBe('0');
279298
});
280299
});

0 commit comments

Comments
 (0)