Skip to content

Commit fba0220

Browse files
Allow user to define their own segment boundaries
1 parent 18beaa3 commit fba0220

32 files changed

Lines changed: 2074 additions & 103 deletions

__mocks__/lucide-react.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,23 @@ export function Link2Off(props: Readonly<{ size?: number; className?: string }>)
7474
export function Settings(props: Readonly<{ size?: number; className?: string }>): ReactElement {
7575
return <svg data-testid="settings-icon" {...props} />;
7676
}
77+
78+
/**
79+
* Stub for the Combine icon used by the merge boundary control.
80+
*
81+
* @param props - SVG props forwarded from the component.
82+
* @returns A ReactElement SVG element used as a merge icon stub in tests.
83+
*/
84+
export function Combine(props: Readonly<{ size?: number; className?: string }>): ReactElement {
85+
return <svg data-testid="combine-icon" {...props} />;
86+
}
87+
88+
/**
89+
* Stub for the Scissors icon used by the split boundary control.
90+
*
91+
* @param props - SVG props forwarded from the component.
92+
* @returns A ReactElement SVG element used as a split icon stub in tests.
93+
*/
94+
export function Scissors(props: Readonly<{ size?: number; className?: string }>): ReactElement {
95+
return <svg data-testid="scissors-icon" {...props} />;
96+
}

contributions/localizedStrings.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@
4242
"%interlinearizer_morphemeGloss_label%": "Gloss for morpheme {form}",
4343
"%interlinearizer_tokenChip_editMorphemes%": "Edit morpheme breakdown for {token}",
4444
"%interlinearizer_tokenChip_defineMorphemes%": "Define morpheme breakdown for {token}",
45-
"%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Cross-segment phrases are not supported. This link button is outside the current segment.",
45+
"%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Only the edge token of an adjacent segment can be linked across a boundary.",
46+
"%interlinearizer_viewOption_boundaryEditMode%": "Edit segment boundaries",
47+
"%interlinearizer_boundaryControl_merge%": "Merge with previous segment",
48+
"%interlinearizer_boundaryControl_split%": "Split segment here",
4649

4750
"%interlinearizer_modal_create_title%": "Create Interlinear Project",
4851
"%interlinearizer_modal_create_name_label%": "Name (optional)",

src/__tests__/components/Interlinearizer.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,7 @@ function renderInterlinearizer({
423423
chapterLabelInVerse,
424424
showMorphology,
425425
showFreeTranslation,
426+
boundaryEditMode: false,
426427
}}
427428
/>,
428429
navigate,

src/__tests__/components/InterlinearizerLoader.test.tsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting
1616
import { emptyAnalysis, emptyDraft } from '../../types/empty-factories';
1717
import type { PhraseMode } from '../../types/phrase-mode';
1818
import type { ViewOptions } from '../../types/view-options';
19+
import type { SegmentationDispatch } from '../../components/SegmentationStore';
1920
import { GEN_1_1_BOOK, makeScrollGroupHook, makeWebViewState } from '../test-helpers';
2021

2122
jest.mock('../../hooks/useInterlinearizerBookData');
@@ -154,6 +155,7 @@ type CapturedInterlinearizerProps = {
154155
phraseMode: PhraseMode;
155156
setPhraseMode: Dispatch<SetStateAction<PhraseMode>>;
156157
viewOptions: ViewOptions;
158+
segmentationDispatch: SegmentationDispatch;
157159
};
158160
let capturedInterlinearizerProps: CapturedInterlinearizerProps | undefined;
159161
let interlinearizerMountCount = 0;
@@ -1024,6 +1026,115 @@ describe('InterlinearizerLoader', () => {
10241026
});
10251027
});
10261028

1029+
describe('segmentation dispatch', () => {
1030+
/** A two-verse book so boundary edits produce real, non-default deltas. */
1031+
const TWO_VERSE_BOOK: Book = {
1032+
id: 'GEN',
1033+
bookRef: 'GEN',
1034+
textVersion: 'v1',
1035+
segments: [
1036+
{
1037+
id: 'GEN 1:1',
1038+
startRef: { book: 'GEN', chapter: 1, verse: 1 },
1039+
endRef: { book: 'GEN', chapter: 1, verse: 1 },
1040+
baselineText: 'Alpha beta.',
1041+
tokens: [
1042+
{
1043+
ref: 'GEN 1:1:0',
1044+
surfaceText: 'Alpha',
1045+
writingSystem: 'en',
1046+
type: 'word',
1047+
charStart: 0,
1048+
charEnd: 5,
1049+
},
1050+
{
1051+
ref: 'GEN 1:1:6',
1052+
surfaceText: 'beta',
1053+
writingSystem: 'en',
1054+
type: 'word',
1055+
charStart: 6,
1056+
charEnd: 10,
1057+
},
1058+
],
1059+
},
1060+
{
1061+
id: 'GEN 1:2',
1062+
startRef: { book: 'GEN', chapter: 1, verse: 2 },
1063+
endRef: { book: 'GEN', chapter: 1, verse: 2 },
1064+
baselineText: 'Gamma.',
1065+
tokens: [
1066+
{
1067+
ref: 'GEN 1:2:0',
1068+
surfaceText: 'Gamma',
1069+
writingSystem: 'en',
1070+
type: 'word',
1071+
charStart: 0,
1072+
charEnd: 5,
1073+
},
1074+
],
1075+
},
1076+
],
1077+
};
1078+
1079+
/**
1080+
* Returns the segmentation delta from the most recent saveDraft call.
1081+
*
1082+
* @returns The persisted draft's `segmentation`, or `undefined` when not set / no call.
1083+
*/
1084+
function lastPersistedSegmentation(): DraftProject['segmentation'] {
1085+
const calls = mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveDraft');
1086+
const last = calls[calls.length - 1];
1087+
const json = last?.[2];
1088+
return typeof json === 'string' ? JSON.parse(json).segmentation : undefined;
1089+
}
1090+
1091+
it('persists split, merge, and move boundary edits made through the dispatch', async () => {
1092+
mockBookData({ book: TWO_VERSE_BOOK });
1093+
await act(async () => {
1094+
renderLoader();
1095+
});
1096+
const dispatch = capturedInterlinearizerProps?.segmentationDispatch;
1097+
if (!dispatch) throw new Error('expected a captured segmentationDispatch');
1098+
1099+
jest.useFakeTimers();
1100+
// Split verse 1 before "beta" — a non-default delta is persisted.
1101+
act(() => dispatch.split('GEN 1:1:6'));
1102+
act(() => jest.advanceTimersByTime(300));
1103+
expect(lastPersistedSegmentation()).toEqual({
1104+
removedVerseStarts: [],
1105+
addedStarts: ['GEN 1:1:6'],
1106+
});
1107+
1108+
// Merge verse 2 into its predecessor — adds a removed verse start.
1109+
act(() => dispatch.merge('GEN 1:2:0'));
1110+
act(() => jest.advanceTimersByTime(300));
1111+
expect(lastPersistedSegmentation()?.removedVerseStarts).toContain('GEN 1:2:0');
1112+
1113+
// Move the verse-2 boundary back onto "beta".
1114+
act(() => dispatch.move('GEN 1:2:0', 'GEN 1:1:6'));
1115+
act(() => jest.advanceTimersByTime(300));
1116+
jest.useRealTimers();
1117+
expect(lastPersistedSegmentation()).toBeDefined();
1118+
});
1119+
1120+
it('clears the segmentation field when an edit restores the default segmentation', async () => {
1121+
mockBookData({ book: TWO_VERSE_BOOK });
1122+
await act(async () => {
1123+
renderLoader();
1124+
});
1125+
const dispatch = capturedInterlinearizerProps?.segmentationDispatch;
1126+
if (!dispatch) throw new Error('expected a captured segmentationDispatch');
1127+
1128+
jest.useFakeTimers();
1129+
// Merging the book's first token is a no-op, so the result is the default segmentation and the
1130+
// persisted field is cleared to undefined.
1131+
act(() => dispatch.merge('GEN 1:1:0'));
1132+
act(() => jest.advanceTimersByTime(300));
1133+
jest.useRealTimers();
1134+
expect(lastPersistedSegmentation()).toBeUndefined();
1135+
});
1136+
});
1137+
10271138
describe('save command', () => {
10281139
it('saves the draft analysis to the active project when Save is clicked with an active project', async () => {
10291140
const draftAnalysis = emptyAnalysis();
@@ -1041,6 +1152,8 @@ describe('InterlinearizerLoader', () => {
10411152
'interlinearizer.saveAnalysis',
10421153
'proj-1',
10431154
JSON.stringify(draftAnalysis),
1155+
// The draft has no custom boundaries, so Save sends "null" to clear any stored ones.
1156+
'null',
10441157
);
10451158
});
10461159

src/__tests__/components/PhraseStripParts.test.tsx

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
/// <reference types="jest" />
33
/// <reference types="@testing-library/jest-dom" />
44

5+
import { useLocalizedStrings } from '@papi/frontend/react';
56
import { fireEvent, render, screen } from '@testing-library/react';
6-
import type { PhraseAnalysisLink, Token } from 'interlinearizer';
7+
import type { PhraseAnalysisLink, Segment, Token } from 'interlinearizer';
78
import type { ReactElement } from 'react';
89
import {
910
PhraseSlot,
@@ -12,6 +13,10 @@ import {
1213
type StripItem,
1314
} from '../../components/PhraseStripParts';
1415
import { PhraseStripProvider } from '../../components/PhraseStripContext';
16+
import {
17+
SegmentationProvider,
18+
type SegmentationContextValue,
19+
} from '../../components/SegmentationStore';
1520
import { emptyFocusContext } from '../../types/empty-factories';
1621
import type { TokenGroup, LinkSlot, FocusContext } from '../../types/token-layout';
1722
import { makePhraseLink, makePhraseStripContext, makeWordToken } from '../test-helpers';
@@ -279,6 +284,106 @@ describe('PhraseSlot', () => {
279284
});
280285
});
281286

287+
// ---------------------------------------------------------------------------
288+
// PhraseSlot boundary controls (boundary-edit mode)
289+
// ---------------------------------------------------------------------------
290+
291+
describe('PhraseSlot boundary controls', () => {
292+
// resetMocks clears the shared useLocalizedStrings implementation, so re-establish the
293+
// key-to-itself mapping the BoundaryControl labels rely on.
294+
beforeEach(() => {
295+
jest
296+
.mocked(useLocalizedStrings)
297+
.mockImplementation((keys: readonly string[]) => [
298+
keys.reduce<Record<string, string>>((acc, k) => ({ ...acc, [k]: k }), {}),
299+
false,
300+
]);
301+
});
302+
303+
const groupA: TokenGroup = {
304+
tokens: [makeWordToken('a')],
305+
phraseLink: undefined,
306+
firstIndex: 0,
307+
punctuationBetween: [],
308+
};
309+
const groupB: TokenGroup = {
310+
tokens: [makeWordToken('b')],
311+
phraseLink: undefined,
312+
firstIndex: 1,
313+
punctuationBetween: [],
314+
};
315+
const slot: LinkSlot = { prevGroup: groupA, nextGroup: groupB, punctuation: [] };
316+
317+
/** A segment whose first token ref identifies the boundary the merge control removes. */
318+
const nextSegment: Segment = {
319+
id: 'seg-2',
320+
startRef: { book: 'GEN', chapter: 1, verse: 2 },
321+
endRef: { book: 'GEN', chapter: 1, verse: 2 },
322+
baselineText: 'b',
323+
tokens: [makeWordToken('seg2-start')],
324+
};
325+
326+
/**
327+
* Renders a PhraseSlot inside both providers with boundary-edit mode on.
328+
*
329+
* @param props - Overrides for the slot props (e.g. prev/next segment ids).
330+
* @param dispatch - The segmentation dispatch to capture calls on.
331+
* @returns The render result.
332+
*/
333+
function renderBoundary(
334+
props: Partial<Parameters<typeof PhraseSlot>[0]>,
335+
dispatch = {
336+
merge: jest.fn(),
337+
split: jest.fn(),
338+
move: jest.fn(),
339+
},
340+
) {
341+
const value: SegmentationContextValue = {
342+
dispatch,
343+
boundaryEditMode: true,
344+
segmentById: new Map([['seg-2', nextSegment]]),
345+
segmentOrder: new Map([
346+
['seg-1', 0],
347+
['seg-2', 1],
348+
]),
349+
};
350+
render(
351+
<SegmentationProvider value={value}>
352+
<PhraseStripProvider value={makePhraseStripContext()}>
353+
<PhraseSlot {...slotProps(slot)} {...props} />
354+
</PhraseStripProvider>
355+
</SegmentationProvider>,
356+
);
357+
return dispatch;
358+
}
359+
360+
it('shows a merge control on a cross-segment slot and merges on click', () => {
361+
const dispatch = renderBoundary({ prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' });
362+
const button = screen.getByTestId('boundary-merge-btn');
363+
fireEvent.click(button);
364+
expect(dispatch.merge).toHaveBeenCalledWith('seg2-start');
365+
expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument();
366+
});
367+
368+
it('shows a split control on an intra-segment slot and splits on click', () => {
369+
const dispatch = renderBoundary({ prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' });
370+
const button = screen.getByTestId('boundary-split-btn');
371+
fireEvent.click(button);
372+
// The next group's first token ref is the split anchor.
373+
expect(dispatch.split).toHaveBeenCalledWith('b');
374+
expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument();
375+
});
376+
377+
it('renders no control at a leading slot with no previous segment', () => {
378+
renderBoundary({
379+
prevSegmentId: undefined,
380+
nextSegmentId: 'seg-1',
381+
});
382+
expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument();
383+
expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument();
384+
});
385+
});
386+
282387
// ---------------------------------------------------------------------------
283388
// PhraseGroup
284389
// ---------------------------------------------------------------------------
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/** @file Unit tests for components/SegmentationStore.tsx. */
2+
/// <reference types="jest" />
3+
/// <reference types="@testing-library/jest-dom" />
4+
5+
import { render, screen } from '@testing-library/react';
6+
import type { Segment } from 'interlinearizer';
7+
import {
8+
NO_OP_SEGMENTATION_DISPATCH,
9+
SegmentationProvider,
10+
useSegmentation,
11+
type SegmentationContextValue,
12+
} from '../../components/SegmentationStore';
13+
14+
/** A test consumer that renders the resolved context as text so tests can assert on it. */
15+
function Probe() {
16+
const { boundaryEditMode, segmentById, segmentOrder } = useSegmentation();
17+
return (
18+
<span data-testid="probe">
19+
{String(boundaryEditMode)}:{segmentById.size}:{segmentOrder.size}
20+
</span>
21+
);
22+
}
23+
24+
describe('SegmentationStore', () => {
25+
it('returns an inert default when no provider is present', () => {
26+
render(<Probe />);
27+
expect(screen.getByTestId('probe')).toHaveTextContent('false:0:0');
28+
});
29+
30+
it('provides the supplied value to consumers within a provider', () => {
31+
const segment: Segment = {
32+
id: 'GEN 1:1',
33+
startRef: { book: 'GEN', chapter: 1, verse: 1 },
34+
endRef: { book: 'GEN', chapter: 1, verse: 1 },
35+
baselineText: 'Hi.',
36+
tokens: [],
37+
};
38+
const value: SegmentationContextValue = {
39+
dispatch: NO_OP_SEGMENTATION_DISPATCH,
40+
boundaryEditMode: true,
41+
segmentById: new Map([['GEN 1:1', segment]]),
42+
segmentOrder: new Map([['GEN 1:1', 0]]),
43+
};
44+
render(
45+
<SegmentationProvider value={value}>
46+
<Probe />
47+
</SegmentationProvider>,
48+
);
49+
expect(screen.getByTestId('probe')).toHaveTextContent('true:1:1');
50+
});
51+
52+
it('exposes an inert no-op dispatch that does nothing when invoked', () => {
53+
// Calling each method must not throw; this also exercises the no-op function bodies.
54+
expect(() => {
55+
NO_OP_SEGMENTATION_DISPATCH.merge('GEN 1:1:0');
56+
NO_OP_SEGMENTATION_DISPATCH.split('GEN 1:1:6');
57+
NO_OP_SEGMENTATION_DISPATCH.move('GEN 1:1:0', 'GEN 1:1:6');
58+
}).not.toThrow();
59+
});
60+
});

0 commit comments

Comments
 (0)