Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions __mocks__/lucide-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,23 @@ export function Link2Off(props: Readonly<{ size?: number; className?: string }>)
export function Settings(props: Readonly<{ size?: number; className?: string }>): ReactElement {
return <svg data-testid="settings-icon" {...props} />;
}

/**
* Stub for the Combine icon used by the merge boundary control.
*
* @param props - SVG props forwarded from the component.
* @returns A ReactElement SVG element used as a merge icon stub in tests.
*/
export function Combine(props: Readonly<{ size?: number; className?: string }>): ReactElement {
return <svg data-testid="combine-icon" {...props} />;
}

/**
* Stub for the Scissors icon used by the split boundary control.
*
* @param props - SVG props forwarded from the component.
* @returns A ReactElement SVG element used as a split icon stub in tests.
*/
export function Scissors(props: Readonly<{ size?: number; className?: string }>): ReactElement {
return <svg data-testid="scissors-icon" {...props} />;
}
5 changes: 4 additions & 1 deletion contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@
"%interlinearizer_morphemeGloss_label%": "Gloss for morpheme {form}",
"%interlinearizer_tokenChip_editMorphemes%": "Edit morpheme breakdown for {token}",
"%interlinearizer_tokenChip_defineMorphemes%": "Define morpheme breakdown for {token}",
"%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Cross-segment phrases are not supported. This link button is outside the current segment.",
"%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Only the edge token of an adjacent segment can be linked across a boundary.",
"%interlinearizer_viewOption_boundaryEditMode%": "Edit segment boundaries",
"%interlinearizer_boundaryControl_merge%": "Merge with previous segment",
"%interlinearizer_boundaryControl_split%": "Split segment here",

"%interlinearizer_modal_create_title%": "Create Interlinear Project",
"%interlinearizer_modal_create_name_label%": "Name (optional)",
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/components/Interlinearizer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ function renderInterlinearizer({
chapterLabelInVerse,
showMorphology,
showFreeTranslation,
boundaryEditMode: false,
}}
/>,
navigate,
Expand Down
113 changes: 113 additions & 0 deletions src/__tests__/components/InterlinearizerLoader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting
import { emptyAnalysis, emptyDraft } from '../../types/empty-factories';
import type { PhraseMode } from '../../types/phrase-mode';
import type { ViewOptions } from '../../types/view-options';
import type { SegmentationDispatch } from '../../components/SegmentationStore';
import { GEN_1_1_BOOK, makeScrollGroupHook, makeWebViewState } from '../test-helpers';

jest.mock('../../hooks/useInterlinearizerBookData');
Expand Down Expand Up @@ -154,6 +155,7 @@ type CapturedInterlinearizerProps = {
phraseMode: PhraseMode;
setPhraseMode: Dispatch<SetStateAction<PhraseMode>>;
viewOptions: ViewOptions;
segmentationDispatch: SegmentationDispatch;
};
let capturedInterlinearizerProps: CapturedInterlinearizerProps | undefined;
let interlinearizerMountCount = 0;
Expand Down Expand Up @@ -1024,6 +1026,115 @@ describe('InterlinearizerLoader', () => {
});
});

describe('segmentation dispatch', () => {
/** A two-verse book so boundary edits produce real, non-default deltas. */
const TWO_VERSE_BOOK: Book = {
id: 'GEN',
bookRef: 'GEN',
textVersion: 'v1',
segments: [
{
id: 'GEN 1:1',
startRef: { book: 'GEN', chapter: 1, verse: 1 },
endRef: { book: 'GEN', chapter: 1, verse: 1 },
baselineText: 'Alpha beta.',
tokens: [
{
ref: 'GEN 1:1:0',
surfaceText: 'Alpha',
writingSystem: 'en',
type: 'word',
charStart: 0,
charEnd: 5,
},
{
ref: 'GEN 1:1:6',
surfaceText: 'beta',
writingSystem: 'en',
type: 'word',
charStart: 6,
charEnd: 10,
},
],
},
{
id: 'GEN 1:2',
startRef: { book: 'GEN', chapter: 1, verse: 2 },
endRef: { book: 'GEN', chapter: 1, verse: 2 },
baselineText: 'Gamma.',
tokens: [
{
ref: 'GEN 1:2:0',
surfaceText: 'Gamma',
writingSystem: 'en',
type: 'word',
charStart: 0,
charEnd: 5,
},
],
},
],
};

/**
* Returns the segmentation delta from the most recent saveDraft call.
*
* @returns The persisted draft's `segmentation`, or `undefined` when not set / no call.
*/
function lastPersistedSegmentation(): DraftProject['segmentation'] {
const calls = mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveDraft');
const last = calls[calls.length - 1];
const json = last?.[2];
return typeof json === 'string' ? JSON.parse(json).segmentation : undefined;
}

it('persists split, merge, and move boundary edits made through the dispatch', async () => {
mockBookData({ book: TWO_VERSE_BOOK });
await act(async () => {
renderLoader();
});
const dispatch = capturedInterlinearizerProps?.segmentationDispatch;
if (!dispatch) throw new Error('expected a captured segmentationDispatch');

jest.useFakeTimers();
// Split verse 1 before "beta" — a non-default delta is persisted.
act(() => dispatch.split('GEN 1:1:6'));
act(() => jest.advanceTimersByTime(300));
expect(lastPersistedSegmentation()).toEqual({
removedVerseStarts: [],
addedStarts: ['GEN 1:1:6'],
});

// Merge verse 2 into its predecessor — adds a removed verse start.
act(() => dispatch.merge('GEN 1:2:0'));
act(() => jest.advanceTimersByTime(300));
expect(lastPersistedSegmentation()?.removedVerseStarts).toContain('GEN 1:2:0');

// Move the verse-2 boundary back onto "beta".
act(() => dispatch.move('GEN 1:2:0', 'GEN 1:1:6'));
act(() => jest.advanceTimersByTime(300));
jest.useRealTimers();
expect(lastPersistedSegmentation()).toBeDefined();
});

it('clears the segmentation field when an edit restores the default segmentation', async () => {
mockBookData({ book: TWO_VERSE_BOOK });
await act(async () => {
renderLoader();
});
const dispatch = capturedInterlinearizerProps?.segmentationDispatch;
if (!dispatch) throw new Error('expected a captured segmentationDispatch');

jest.useFakeTimers();
// Merging the book's first token is a no-op, so the result is the default segmentation and the
// persisted field is cleared to undefined.
act(() => dispatch.merge('GEN 1:1:0'));
act(() => jest.advanceTimersByTime(300));
jest.useRealTimers();
expect(lastPersistedSegmentation()).toBeUndefined();
});
});

describe('save command', () => {
it('saves the draft analysis to the active project when Save is clicked with an active project', async () => {
const draftAnalysis = emptyAnalysis();
Expand All @@ -1041,6 +1152,8 @@ describe('InterlinearizerLoader', () => {
'interlinearizer.saveAnalysis',
'proj-1',
JSON.stringify(draftAnalysis),
// The draft has no custom boundaries, so Save sends "null" to clear any stored ones.
'null',
);
});

Expand Down
139 changes: 138 additions & 1 deletion src/__tests__/components/PhraseStripParts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
/// <reference types="jest" />
/// <reference types="@testing-library/jest-dom" />

import { useLocalizedStrings } from '@papi/frontend/react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { PhraseAnalysisLink, Token } from 'interlinearizer';
import type { PhraseAnalysisLink, Segment, Token } from 'interlinearizer';
import type { ReactElement } from 'react';
import {
PhraseSlot,
Expand All @@ -12,6 +13,10 @@ import {
type StripItem,
} from '../../components/PhraseStripParts';
import { PhraseStripProvider } from '../../components/PhraseStripContext';
import {
SegmentationProvider,
type SegmentationContextValue,
} from '../../components/SegmentationStore';
import { emptyFocusContext } from '../../types/empty-factories';
import type { TokenGroup, LinkSlot, FocusContext } from '../../types/token-layout';
import { makePhraseLink, makePhraseStripContext, makeWordToken } from '../test-helpers';
Expand Down Expand Up @@ -279,6 +284,138 @@ describe('PhraseSlot', () => {
});
});

// ---------------------------------------------------------------------------
// PhraseSlot boundary controls (boundary-edit mode)
// ---------------------------------------------------------------------------

describe('PhraseSlot boundary controls', () => {
// resetMocks clears the shared useLocalizedStrings implementation, so re-establish the
// key-to-itself mapping the BoundaryControl labels rely on.
beforeEach(() => {
jest
.mocked(useLocalizedStrings)
.mockImplementation((keys: readonly string[]) => [
keys.reduce<Record<string, string>>((acc, k) => ({ ...acc, [k]: k }), {}),
false,
]);
});

const groupA: TokenGroup = {
tokens: [makeWordToken('a')],
phraseLink: undefined,
firstIndex: 0,
punctuationBetween: [],
};
const groupB: TokenGroup = {
tokens: [makeWordToken('b')],
phraseLink: undefined,
firstIndex: 1,
punctuationBetween: [],
};
const slot: LinkSlot = { prevGroup: groupA, nextGroup: groupB, punctuation: [] };

/** A segment whose first token ref identifies the boundary the merge control removes. */
const nextSegment: Segment = {
id: 'seg-2',
startRef: { book: 'GEN', chapter: 1, verse: 2 },
endRef: { book: 'GEN', chapter: 1, verse: 2 },
baselineText: 'b',
tokens: [makeWordToken('seg2-start')],
};

/**
* Renders a PhraseSlot inside both providers with boundary-edit mode on.
*
* @param props - Overrides for the slot props (e.g. prev/next segment ids).
* @param dispatch - The segmentation dispatch to capture calls on.
* @returns The render result.
*/
function renderBoundary(
props: Partial<Parameters<typeof PhraseSlot>[0]>,
dispatch = {
merge: jest.fn(),
split: jest.fn(),
move: jest.fn(),
},
verseZeroSegmentIds: ReadonlySet<string> = new Set(),
) {
const value: SegmentationContextValue = {
dispatch,
boundaryEditMode: true,
segmentById: new Map([['seg-2', nextSegment]]),
segmentOrder: new Map([
['seg-1', 0],
['seg-2', 1],
]),
verseZeroSegmentIds,
};
render(
<SegmentationProvider value={value}>
<PhraseStripProvider value={makePhraseStripContext()}>
<PhraseSlot {...slotProps(slot)} {...props} />
</PhraseStripProvider>
</SegmentationProvider>,
);
return dispatch;
}

it('shows a merge control on a cross-segment slot and merges on click', () => {
const dispatch = renderBoundary({ prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' });
const button = screen.getByTestId('boundary-merge-btn');
fireEvent.click(button);
expect(dispatch.merge).toHaveBeenCalledWith('seg2-start');
expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument();
});

it('shows a split control on an intra-segment slot and splits on click', () => {
const dispatch = renderBoundary({ prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' });
const button = screen.getByTestId('boundary-split-btn');
fireEvent.click(button);
// The next group's first token ref is the split anchor.
expect(dispatch.split).toHaveBeenCalledWith('b');
expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument();
});

it('renders no merge control when the next segment is a verse-0 superscription', () => {
renderBoundary(
{ prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' },
undefined,
new Set(['seg-2']),
);
expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument();
});

it('renders no merge control when the previous segment is a verse-0 superscription', () => {
renderBoundary(
{ prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' },
undefined,
new Set(['seg-1']),
);
expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument();
});

it('renders no split control inside a verse-0 superscription segment', () => {
renderBoundary(
{ prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' },
undefined,
new Set(['seg-1']),
);
expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument();
});

it('renders no control at a leading slot with no previous segment', () => {
renderBoundary({
prevSegmentId: undefined,
nextSegmentId: 'seg-1',
});
expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument();
});
});

// ---------------------------------------------------------------------------
// PhraseGroup
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading