diff --git a/__mocks__/lucide-react.tsx b/__mocks__/lucide-react.tsx index e123b235..9842b93b 100644 --- a/__mocks__/lucide-react.tsx +++ b/__mocks__/lucide-react.tsx @@ -84,3 +84,33 @@ export function Settings(props: Readonly<{ size?: number; className?: string }>) export function Plus(props: Readonly<{ size?: number; className?: string }>): ReactElement { return ; } +/** + * Stub for the FoldVertical icon used by the row-gap merge control (join two stacked rows). + * + * @param props - SVG props forwarded from the component. + * @returns A ReactElement SVG element used as a vertical-merge icon stub in tests. + */ +export function FoldVertical(props: Readonly<{ className?: string }>): ReactElement { + return ; +} + +/** + * Stub for the FoldHorizontal icon used by the continuous-strip merge control (join two adjacent + * segments). + * + * @param props - SVG props forwarded from the component. + * @returns A ReactElement SVG element used as a horizontal-merge icon stub in tests. + */ +export function FoldHorizontal(props: Readonly<{ className?: string }>): ReactElement { + return ; +} + +/** + * 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 ; +} diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 58aecbc0..4ea89927 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -43,7 +43,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 a free edge token of an adjacent segment can be linked across a boundary.", + "%interlinearizer_boundaryControl_merge%": "Merge segments", + "%interlinearizer_boundaryControl_split%": "Split segment here", + "%interlinearizer_boundaryControl_formerBoundary%": "Former verse boundary", "%interlinearizer_modal_create_title%": "Create Interlinear Project", "%interlinearizer_modal_create_name_label%": "Name (optional)", diff --git a/cspell.json b/cspell.json index 3046472d..965968b9 100644 --- a/cspell.json +++ b/cspell.json @@ -55,6 +55,7 @@ "recentering", "recenters", "relayout", + "resegment", "resnap", "sandboxed", "scriptio", diff --git a/src/__tests__/components/AnalysisStore.test.tsx b/src/__tests__/components/AnalysisStore.test.tsx index 926487be..1c43328b 100644 --- a/src/__tests__/components/AnalysisStore.test.tsx +++ b/src/__tests__/components/AnalysisStore.test.tsx @@ -16,6 +16,7 @@ import { useMorphemeDeleteDispatch, useMorphemeGlossDispatch, useMorphemes, + usePhraseLinkByIdGetter, usePhraseLinkByIdMap, usePhraseLinkForToken, usePhraseLinkMap, @@ -684,6 +685,45 @@ describe('usePhraseLinkByIdMap', () => { }); }); +/** + * Renders a component that reads the phrase-link map through `usePhraseLinkByIdGetter` at render + * time and displays its size, used to assert the getter resolves current store state. + * + * @returns JSX element suitable for passing to `render`. + */ +function PhraseLinkByIdGetterReader() { + const getPhraseLinkById = usePhraseLinkByIdGetter(); + return {getPhraseLinkById().size}; +} + +/** + * Renders a component that calls `usePhraseLinkByIdGetter` without a provider, to assert it throws. + * + * @returns Nothing — only mounted to trigger the throw. + */ +function PhraseLinkByIdGetterUser() { + usePhraseLinkByIdGetter(); + return undefined; +} + +describe('usePhraseLinkByIdGetter', () => { + it('returns a getter resolving the current phrase-link-by-id map', () => { + render( + + + , + ); + expect(screen.getByTestId('getter-map-size')).toHaveTextContent('1'); + }); + + it('throws when called outside an AnalysisStoreProvider', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + 'usePhraseLinkByIdGetter must be used inside an AnalysisStoreProvider', + ); + }); +}); + describe('usePhraseDispatch', () => { it('createPhrase adds a new phrase and calls onSave', async () => { const onSave = jest.fn(); diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index dff035e8..4cbdef88 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -7,6 +7,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event'; import type { Book, PhraseAnalysisLink, Token } from 'interlinearizer'; import { useState, type ReactNode } from 'react'; +import { resegmentBook } from 'parsers/papi/resegmentBook'; import type { PhraseDispatch } from '../../components/AnalysisStore'; import ContinuousView from '../../components/ContinuousView'; import { isWordToken } from '../../types/type-guards'; @@ -863,6 +864,54 @@ describe('ContinuousView scroll behavior', () => { expect(scrollIntoViewMock).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'auto' })); }); + it('holds the group centered on animation frames after an external jump within the active segment', () => { + // An external jump landing in the already-active segment never flips committedActiveSegmentId, + // so the layout-effect re-center loop does not run for it; the scroll effect's own hold loop + // must keep the group pinned while late layout (arc padding settling on the slid window) + // shifts the strip after the instant snap. + const book = makeBook(); + const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); + const { rerender } = render(, withAnalysisStore); + + act(() => { + jest.useFakeTimers(); + }); + try { + // tok-1 shares GEN 1:1 with tok-0, so the active segment is unchanged by this jump. + rerender(); + // Complete the fade-out (RECENTER_FADE_MS) so the displayed focus updates and the instant + // snap fires. + scrollIntoViewMock.mockClear(); + act(() => { + jest.advanceTimersByTime(510); + }); + expect(scrollIntoViewMock).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'auto', inline: 'center' }), + ); + + // Frames within the hold window keep re-centering after the snap. + scrollIntoViewMock.mockClear(); + act(() => { + jest.advanceTimersByTime(50); + }); + expect(scrollIntoViewMock).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'auto', inline: 'center' }), + ); + + // Past the deadline the loop stops scheduling further frames. + act(() => { + jest.advanceTimersByTime(500); + }); + scrollIntoViewMock.mockClear(); + act(() => { + jest.advanceTimersByTime(500); + }); + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + it('snaps the link slots (no transition) during an external jump so they do not slide after the fade-in', () => { const book = makeBook(); const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); @@ -1157,6 +1206,49 @@ describe('ContinuousView scroll behavior', () => { }); }); +// --------------------------------------------------------------------------- +// Segmentation edits +// --------------------------------------------------------------------------- + +describe('ContinuousView segmentation edits', () => { + /** + * Reads the inline opacity of the link-slot wrapper between `prevRef` and `nextRef`, the style + * `PhraseSlot` uses to suppress link buttons outside the active segment. + * + * @param container - The render container to query. + * @param prevRef - Token ref on the start side of the slot. + * @param nextRef - Token ref on the end side of the slot. + * @returns The wrapper's inline `opacity` value. + */ + function slotOpacity(container: HTMLElement, prevRef: string, nextRef: string): string { + const icon = container.querySelector( + `[data-prev-ref="${prevRef}"][data-next-ref="${nextRef}"]`, + ); + const wrapper = icon?.parentElement; + if (!(wrapper instanceof HTMLElement)) throw new Error('Expected a link-slot wrapper span'); + return wrapper.style.opacity; + } + + it('keeps the focused segment link buttons active when a merge changes the focused token segment id', () => { + const book = makeBook(); + const props = requiredProps(book, { focusedTokenRef: 'tok-2' }); + props.viewOptions = { ...allFalseViewOptions, hideInactiveLinkButtons: true }; + const { container, rerender } = render(, withAnalysisStore); + + // Focus sits in GEN 1:2, so the slot between its two tokens is active and visible. + expect(slotOpacity(container, 'tok-2', 'tok-3')).toBe('1'); + + // Merge GEN 1:2 into GEN 1:1 — the exact transform the loader applies on a boundary edit. + // Token refs survive, so focus does not move, but the focused token's segment id changes. + const merged = resegmentBook(book, { removedVerseStarts: ['tok-2'], addedStarts: [] }); + rerender(); + + // The committed active segment must follow the merge; a stale id names a segment that no + // longer exists, which would suppress every link button until the next navigation. + expect(slotOpacity(container, 'tok-2', 'tok-3')).toBe('1'); + }); +}); + // --------------------------------------------------------------------------- // RTL layout // --------------------------------------------------------------------------- diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx index fde0eb24..770d702d 100644 --- a/src/__tests__/components/Interlinearizer.test.tsx +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -2,16 +2,24 @@ /// /// +import { useLocalizedStrings } from '@papi/frontend/react'; import type { SerializedVerseRef } from '@sillsdev/scripture'; -import { act, render, screen } from '@testing-library/react'; -import type { Book, ScriptureRef, Segment, Token } from 'interlinearizer'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import type { Book, PhraseAnalysisLink, ScriptureRef, Segment, Token } from 'interlinearizer'; import type { ReactNode } from 'react'; import { useState } from 'react'; +import { resegmentBook } from 'parsers/papi/resegmentBook'; import Interlinearizer from '../../components/Interlinearizer'; import { InterlinearNavProvider } from '../../components/InterlinearNavContext'; +import { + useSegmentation, + type SegmentationContextValue, + type SegmentationDispatch, +} from '../../components/SegmentationStore'; import type { SegmentDisplayMode } from '../../components/SegmentView'; +import type { SegmentLabel } from '../../utils/segment-labels'; import { RECENTER_FADE_MS } from '../../components/recenter-fade'; -import { defaultScrRef, GEN_1_1_BOOK } from '../test-helpers'; +import { defaultScrRef, GEN_1_1_BOOK, makePhraseLink } from '../test-helpers'; import { allFalseViewOptions } from './test-helpers'; jest.mock('lucide-react', () => ({ @@ -22,6 +30,12 @@ jest.mock('lucide-react', () => ({ * @returns An SVG element with `data-testid="locate-fixed-icon"`. */ LocateFixed: () => , + /** + * Stub for the FoldVertical icon used by the between-rows merge control. + * + * @returns An SVG element with `data-testid="fold-vertical-icon"`. + */ + FoldVertical: () => , })); /** @@ -42,10 +56,18 @@ type CapturedContinuousViewProps = { }; let capturedContinuousViewProps: CapturedContinuousViewProps | undefined; +/** + * The segmentation context value seen by the (stubbed) ContinuousView — carries the force-break + * wrapped dispatch built by InterlinearizerInner, so tests can invoke its methods directly. + */ +let capturedSegmentation: SegmentationContextValue | undefined; + /** Props captured from SegmentView renders so tests can assert on what Interlinearizer passes down. */ type CapturedSegmentViewProps = { /** The segment the component is asked to render. */ segment: Segment; + /** The segment's display label — a verse number, lettered portion, or en-dash range. */ + label?: SegmentLabel; /** Controls whether tokens are rendered as chips or as raw baseline text. */ displayMode: SegmentDisplayMode; /** The `Token.ref` string of the currently focused token, if any. */ @@ -64,6 +86,16 @@ let capturedSegmentViewPropsList: CapturedSegmentViewProps[] = []; /** Stable spy for `updatePhrase` — reset between tests via resetMocks. */ const mockUpdatePhrase = jest.fn(); +/** Stable spies for `createPhrase` / `deletePhrase`, asserted on by the force-break tests. */ +const mockCreatePhrase = jest.fn(); +const mockDeletePhrase = jest.fn(); + +/** + * Phrase-link-by-id map served by the mocked `usePhraseLinkByIdGetter`. Force-break tests seed it + * with phrases that straddle a boundary; cleared in the top-level `beforeEach`. + */ +const mockPhraseLinkById = new Map(); + jest.mock('../../components/AnalysisStore', () => ({ __esModule: true, /** @@ -95,18 +127,39 @@ jest.mock('../../components/AnalysisStore', () => ({ * @returns An empty `Map`. */ usePhraseLinkMap: () => new Map(), - usePhraseLinkByIdMap: () => new Map(), + /** + * Returns the test-owned phrase-link map so straddled-boundary tests can seed phrases the + * component's `straddledBoundaryRefs` memo sees. + * + * @returns The current phrase-link-by-id map. + */ + usePhraseLinkByIdMap: () => mockPhraseLinkById, + /** + * Returns a getter over the test-owned phrase-link map so force-break tests can seed straddling + * phrases. + * + * @returns A function returning the current map. + */ + usePhraseLinkByIdGetter: () => () => mockPhraseLinkById, usePhraseDispatch: () => ({ - createPhrase: () => {}, + createPhrase: (...args: Parameters) => mockCreatePhrase(...args), updatePhrase: (...args: Parameters) => mockUpdatePhrase(...args), - deletePhrase: () => {}, + deletePhrase: (...args: Parameters) => mockDeletePhrase(...args), }), })); jest.mock('../../components/ContinuousView', () => ({ __esModule: true, - default: (props: CapturedContinuousViewProps) => { + /** + * ContinuousView stub; captures its props and the segmentation context (the wrapped, + * force-breaking dispatch) so tests can invoke the dispatch directly. + * + * @param props - The props passed by Interlinearizer. + * @returns A minimal div carrying the focused token ref. + */ + default: function ContinuousViewStub(props: CapturedContinuousViewProps) { capturedContinuousViewProps = props; + capturedSegmentation = useSegmentation(); return (
); @@ -397,6 +450,8 @@ function renderInterlinearizer({ chapterLabelInVerse = false, showMorphology = false, showFreeTranslation = false, + segmentationDispatch, + formerBoundaries, }: { book?: Book; continuousScroll?: boolean; @@ -407,12 +462,16 @@ function renderInterlinearizer({ chapterLabelInVerse?: boolean; showMorphology?: boolean; showFreeTranslation?: boolean; + segmentationDispatch?: SegmentationDispatch; + formerBoundaries?: ReadonlyMap; } = {}) { return render( withNav( { // jsdom does not implement scrollIntoView; stub it globally so components that call it don't throw. Element.prototype.scrollIntoView = jest.fn(); + // The phrase-link map is a plain Map (not a jest mock), so resetMocks does not clear it. + mockPhraseLinkById.clear(); + capturedSegmentation = undefined; + // resetMocks clears the shared useLocalizedStrings implementation the between-rows merge + // control's label relies on; re-establish the key-to-itself mapping. + jest + .mocked(useLocalizedStrings) + .mockImplementation((keys: readonly string[]) => [ + keys.reduce>((acc, k) => ({ ...acc, [k]: k }), {}), + false, + ]); }); describe('Interlinearizer', () => { @@ -453,6 +523,24 @@ describe('Interlinearizer', () => { expect(screen.getByText(/no verse data for gen 1\./i)).toBeInTheDocument(); }); + it('passes verse-based segment labels to the segment views', () => { + renderInterlinearizer({ book: GEN_1_MULTI_BOOK }); + + expect(capturedSegmentViewPropsList[0].label).toBe('1'); + expect(capturedSegmentViewPropsList[1].label).toBe('2'); + }); + + it('labels a merged segment with its merged verse range', () => { + const merged = resegmentBook(GEN_1_MULTI_BOOK, { + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + renderInterlinearizer({ book: merged }); + + expect(screen.getAllByTestId('segment-view')).toHaveLength(1); + expect(capturedSegmentViewPropsList[0].label).toBe('1–2'); + }); + it('renders a SegmentView for every segment in the current chapter', () => { renderInterlinearizer({ book: GEN_1_MULTI_BOOK }); @@ -1221,3 +1309,383 @@ describe('Interlinearizer', () => { } }); }); + +// --------------------------------------------------------------------------- +// Segmentation dispatch force-break wrapper + between-rows merge control +// --------------------------------------------------------------------------- + +describe('segmentation dispatch force-break', () => { + /** + * Builds a raw segmentation-dispatch spy; the wrapped dispatch the views receive must delegate + * every call to it. + * + * @returns A dispatch whose methods are jest spies. + */ + function makeRawDispatch(): SegmentationDispatch { + return { merge: jest.fn(), split: jest.fn(), move: jest.fn() }; + } + + /** + * Renders with continuous scroll on (so the stubbed ContinuousView captures the segmentation + * context) and returns the wrapped dispatch. + * + * @param raw - The raw dispatch handed to the component. + * @param book - The book fixture to render. + * @returns The wrapped dispatch captured from the segmentation context. + */ + function renderAndCaptureDispatch(raw: SegmentationDispatch, book: Book): SegmentationDispatch { + renderInterlinearizer({ book, continuousScroll: true, segmentationDispatch: raw }); + const dispatch = capturedSegmentation?.dispatch; + if (!dispatch) throw new Error('expected a captured segmentation dispatch'); + return dispatch; + } + + it('passes merge straight through without touching phrases', () => { + const raw = makeRawDispatch(); + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0'])); + const dispatch = renderAndCaptureDispatch(raw, GEN_1_MULTI_BOOK); + dispatch.merge('GEN 1:2:0'); + expect(raw.merge).toHaveBeenCalledWith('GEN 1:2:0'); + expect(mockUpdatePhrase).not.toHaveBeenCalled(); + expect(mockCreatePhrase).not.toHaveBeenCalled(); + expect(mockDeletePhrase).not.toHaveBeenCalled(); + }); + + it('force-breaks a two-token phrase straddling a split boundary (deletes it), then splits', () => { + const raw = makeRawDispatch(); + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0'])); + const dispatch = renderAndCaptureDispatch(raw, GEN_1_MULTI_BOOK); + dispatch.split('GEN 1:2:0'); + // Both halves of the straddled two-token phrase are single tokens, so the phrase is deleted. + expect(mockDeletePhrase).toHaveBeenCalledWith('p1'); + expect(raw.split).toHaveBeenCalledWith('GEN 1:2:0'); + // The break lands before the boundary write so no consumer observes a straddling phrase. + expect(mockDeletePhrase.mock.invocationCallOrder[0]).toBeLessThan( + jest.mocked(raw.split).mock.invocationCallOrder[0], + ); + }); + + it('force-breaks a longer straddling phrase into its two sides at the boundary', () => { + const raw = makeRawDispatch(); + mockPhraseLinkById.set( + 'p1', + makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0', 'GEN 1:3:0', 'GEN 1:4:0']), + ); + const dispatch = renderAndCaptureDispatch(raw, makeLargeBook(4)); + dispatch.split('GEN 1:3:0'); + expect(mockUpdatePhrase).toHaveBeenCalledWith('p1', [ + { tokenRef: 'GEN 1:1:0', surfaceText: 'GEN 1:1:0' }, + { tokenRef: 'GEN 1:2:0', surfaceText: 'GEN 1:2:0' }, + ]); + expect(mockCreatePhrase).toHaveBeenCalledWith([ + { tokenRef: 'GEN 1:3:0', surfaceText: 'GEN 1:3:0' }, + { tokenRef: 'GEN 1:4:0', surfaceText: 'GEN 1:4:0' }, + ]); + expect(raw.split).toHaveBeenCalledWith('GEN 1:3:0'); + }); + + it('leaves phrases alone when the split boundary cuts none of them', () => { + const raw = makeRawDispatch(); + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:3:0', 'GEN 1:4:0'])); + const dispatch = renderAndCaptureDispatch(raw, makeLargeBook(4)); + dispatch.split('GEN 1:3:0'); + expect(mockUpdatePhrase).not.toHaveBeenCalled(); + expect(mockCreatePhrase).not.toHaveBeenCalled(); + expect(mockDeletePhrase).not.toHaveBeenCalled(); + expect(raw.split).toHaveBeenCalledWith('GEN 1:3:0'); + }); + + it('force-breaks at the destination boundary of a move', () => { + const raw = makeRawDispatch(); + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0'])); + const dispatch = renderAndCaptureDispatch(raw, GEN_1_MULTI_BOOK); + dispatch.move('GEN 1:1:0', 'GEN 1:2:0'); + expect(mockDeletePhrase).toHaveBeenCalledWith('p1'); + expect(raw.move).toHaveBeenCalledWith('GEN 1:1:0', 'GEN 1:2:0'); + }); + + it('skips the force-break when the boundary ref is unknown to the book', () => { + const raw = makeRawDispatch(); + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0'])); + const dispatch = renderAndCaptureDispatch(raw, GEN_1_MULTI_BOOK); + dispatch.split('EXO 9:9:9'); + expect(mockDeletePhrase).not.toHaveBeenCalled(); + expect(raw.split).toHaveBeenCalledWith('EXO 9:9:9'); + }); +}); + +describe('between-rows merge control', () => { + it('renders a merge button between adjacent segment rows and merges on click', () => { + const raw: SegmentationDispatch = { merge: jest.fn(), split: jest.fn(), move: jest.fn() }; + renderInterlinearizer({ book: GEN_1_MULTI_BOOK, segmentationDispatch: raw }); + const buttons = screen.getAllByTestId('segment-merge-btn'); + // Two rows -> exactly one gap between them. + expect(buttons).toHaveLength(1); + fireEvent.click(buttons[0]); + // Merging removes the boundary at the lower segment's first token. + expect(raw.merge).toHaveBeenCalledWith('GEN 1:2:0'); + }); + + it('renders no merge button when only one segment row is mounted', () => { + renderInterlinearizer({ book: GEN_1_1_BOOK }); + expect(screen.queryByTestId('segment-merge-btn')).not.toBeInTheDocument(); + }); + + it('outlines and tints the two adjacent rows while the merge button is hovered', () => { + const { container } = renderInterlinearizer({ book: GEN_1_MULTI_BOOK }); + const button = screen.getByTestId('segment-merge-btn'); + expect(container.getElementsByClassName('tw:ring-ring/60')).toHaveLength(0); + fireEvent.mouseEnter(button); + // Both rows around the hovered gap carry the preview outline and tint. + expect(container.getElementsByClassName('tw:ring-ring/60')).toHaveLength(2); + fireEvent.mouseLeave(button); + expect(container.getElementsByClassName('tw:ring-ring/60')).toHaveLength(0); + }); + + it('clears the merge preview synchronously when the merge button is clicked', () => { + const raw: SegmentationDispatch = { merge: jest.fn(), split: jest.fn(), move: jest.fn() }; + const { container } = renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + segmentationDispatch: raw, + }); + const button = screen.getByTestId('segment-merge-btn'); + fireEvent.mouseEnter(button); + fireEvent.click(button); + expect(container.getElementsByClassName('tw:ring-ring/60')).toHaveLength(0); + expect(raw.merge).toHaveBeenCalledWith('GEN 1:2:0'); + }); +}); + +describe('focus preservation across segmentation edits', () => { + /** GEN book whose verse 1 has two word tokens and verse 2 one — lets focus sit off a verse start. */ + const GEN_TWO_TOKEN_V1_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: 'In beginning.', + tokens: [ + { + ref: 'GEN 1:1:0', + surfaceText: 'In', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 2, + }, + { + ref: 'GEN 1:1:3', + surfaceText: 'beginning', + writingSystem: 'en', + type: 'word', + charStart: 3, + charEnd: 12, + }, + ], + }, + { + id: 'GEN 1:2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: 'And the earth.', + tokens: [ + { + ref: 'GEN 1:2:0', + surfaceText: 'And', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 3, + }, + ], + }, + ], + }; + + /** + * Builds the wrapped `` element used by these tests, so the initial render and + * the post-edit rerender share identical props apart from the book. + * + * @param book - The (re)segmented book to render. + * @param scrRef - The active scripture reference. + * @returns The element wrapped in a nav provider. + */ + function interlinearizerEl(book: Book, scrRef: SerializedVerseRef): ReactNode { + return withNav( + {}} + viewOptions={allFalseViewOptions} + />, + ); + } + + it('keeps the focused token when a merge removes the active verse segment start', () => { + const scrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 2 }; + const { rerender } = render(interlinearizerEl(GEN_1_MULTI_BOOK, scrRef)); + expect(capturedContinuousViewProps?.focusedTokenRef).toBe('GEN 1:2:0'); + // Merge verse 2 into verse 1 — the exact transform the loader applies on a boundary edit. No + // segment starts at the active verse afterwards, but the focused token still exists. + const merged = resegmentBook(GEN_1_MULTI_BOOK, { + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + rerender(interlinearizerEl(merged, scrRef)); + expect(capturedContinuousViewProps?.focusedTokenRef).toBe('GEN 1:2:0'); + }); + + it('keeps a deliberately-focused token across a merge into the active verse', () => { + const scrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + const { rerender } = render(interlinearizerEl(GEN_TWO_TOKEN_V1_BOOK, scrRef)); + act(() => capturedContinuousViewProps?.onFocusedTokenRefChange('GEN 1:1:3')); + expect(capturedContinuousViewProps?.focusedTokenRef).toBe('GEN 1:1:3'); + // Merge verse 2 into verse 1; the active verse's segment survives (same start), so the + // deliberately-focused token must not be clobbered back to the verse's first word. + const merged = resegmentBook(GEN_TWO_TOKEN_V1_BOOK, { + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + rerender(interlinearizerEl(merged, scrRef)); + expect(capturedContinuousViewProps?.focusedTokenRef).toBe('GEN 1:1:3'); + }); +}); + +describe('former boundaries', () => { + it('provides the supplied formerBoundaries map to the views through the segmentation context', () => { + // Anchor word ref → removed default start ref (distinct when the verse opens with punctuation). + const formerBoundaries: ReadonlyMap = new Map([['GEN 1:2:1', 'GEN 1:2:0']]); + renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + continuousScroll: true, + formerBoundaries, + }); + expect(capturedSegmentation?.formerBoundaries).toBe(formerBoundaries); + }); + + it('defaults formerBoundaries to an empty map when the loader supplies none', () => { + renderInterlinearizer({ book: GEN_1_MULTI_BOOK, continuousScroll: true }); + expect(capturedSegmentation?.formerBoundaries?.size).toBe(0); + }); +}); + +describe('straddled boundary refs', () => { + /** + * Renders with continuous scroll on (so the stubbed ContinuousView captures the segmentation + * context) and returns the straddled-boundary set the component computed. Phrase links must be + * seeded into `mockPhraseLinkById` before calling. + * + * @param book - The book fixture to render. + * @returns The `straddledBoundaryRefs` set captured from the segmentation context. + */ + function renderAndCaptureStraddled(book: Book): ReadonlySet { + renderInterlinearizer({ book, continuousScroll: true }); + const straddled = capturedSegmentation?.straddledBoundaryRefs; + if (!straddled) throw new Error('expected a captured straddled-boundary set'); + return straddled; + } + + it('blocks the word refs strictly inside a contiguous three-word phrase', () => { + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0', 'GEN 1:3:0'])); + const straddled = renderAndCaptureStraddled(makeLargeBook(4)); + // A boundary before the 2nd or 3rd word would cut the phrase. + expect(straddled.has('GEN 1:2:0')).toBe(true); + expect(straddled.has('GEN 1:3:0')).toBe(true); + }); + + it('does not block the phrase-leading word ref or the word after the phrase', () => { + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0', 'GEN 1:3:0'])); + const straddled = renderAndCaptureStraddled(makeLargeBook(4)); + // A boundary before the phrase's first word or after its last word leaves it intact. + expect(straddled.has('GEN 1:1:0')).toBe(false); + expect(straddled.has('GEN 1:4:0')).toBe(false); + }); + + it('blocks the gap word ref inside a discontiguous phrase', () => { + // The phrase spans words 1 and 3; word 2 sits in the gap, but a boundary before it would still + // put the phrase's halves in different segments. + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:3:0'])); + const straddled = renderAndCaptureStraddled(makeLargeBook(4)); + expect(straddled.has('GEN 1:2:0')).toBe(true); + }); + + it('exposes an empty set when no phrase links exist', () => { + const straddled = renderAndCaptureStraddled(makeLargeBook(4)); + expect(straddled.size).toBe(0); + }); +}); + +describe('segmentationVersion pass-through', () => { + /** + * Builds the wrapped `` element for the pass-through tests, varying only the + * book and segmentation version between renders. + * + * @param book - The (re)segmented book to render. + * @param segmentationVersion - The boundary-edit counter forwarded to the segment window. + * @returns The element wrapped in a nav provider. + */ + function versionedEl(book: Book, segmentationVersion: number): ReactNode { + return withNav( + {}} + viewOptions={{ ...allFalseViewOptions }} + />, + ); + } + + it('redraws in place without a recenter fade when a boundary edit bumps segmentationVersion', () => { + const { container, rerender } = render(versionedEl(GEN_1_MULTI_BOOK, 0)); + const merged = resegmentBook(GEN_1_MULTI_BOOK, { + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + act(() => { + rerender(versionedEl(merged, 1)); + }); + // Were the prop dropped on the way to the segment window, the segments-identity change would be + // classified as a re-tokenization and fade the list out. + expect(container.querySelector('.tw\\:gap-2.tw\\:transition-opacity')).toHaveStyle({ + opacity: '1', + }); + }); + + it('fades the list for a segments change without a segmentationVersion bump', () => { + jest.useFakeTimers(); + try { + const { container, rerender } = render(versionedEl(GEN_1_MULTI_BOOK, 0)); + const merged = resegmentBook(GEN_1_MULTI_BOOK, { + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + act(() => { + rerender(versionedEl(merged, 0)); + }); + // Same segments change, same version: a re-tokenization, so the recenter fade runs. This + // guards the positive case above against passing vacuously in a setup where fades never fire. + expect(container.querySelector('.tw\\:gap-2.tw\\:transition-opacity')).toHaveStyle({ + opacity: '0', + }); + act(() => { + jest.advanceTimersByTime(RECENTER_FADE_MS); + }); + expect(container.querySelector('.tw\\:gap-2.tw\\:transition-opacity')).toHaveStyle({ + opacity: '1', + }); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index 6b28da37..6e5d0414 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -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'); @@ -154,6 +155,9 @@ type CapturedInterlinearizerProps = { phraseMode: PhraseMode; setPhraseMode: Dispatch>; viewOptions: ViewOptions; + segmentationDispatch: SegmentationDispatch; + formerBoundaries: ReadonlyMap; + segmentationVersion: number; }; let capturedInterlinearizerProps: CapturedInterlinearizerProps | undefined; let interlinearizerMountCount = 0; @@ -579,10 +583,54 @@ describe('InterlinearizerLoader', () => { }); }); - it('resolves a mid-segment verse to the owning segment start', async () => { - // A merged segment can span several verses; navigating to a verse inside the span has no - // segment starting at that verse, so the loader resolves to the nearest preceding segment - // start — the segment that contains the verse, never a later segment in the chapter. + it('resolves a verse missing from the text to the nearest preceding segment start, never a later one', async () => { + // A verse absent from the book's content (e.g. bridged away in the source) is contained in no + // segment, so the loader resolves it to the nearest preceding segment start in the chapter — + // skipping the later segment that also sits in the chapter. + const gappedBook: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [ + { + id: 'GEN 3:1', + startRef: { book: 'GEN', chapter: 3, verse: 1 }, + endRef: { book: 'GEN', chapter: 3, verse: 1 }, + baselineText: 'First verse.', + tokens: [], + }, + { + id: 'GEN 3:3', + startRef: { book: 'GEN', chapter: 3, verse: 3 }, + endRef: { book: 'GEN', chapter: 3, verse: 3 }, + baselineText: 'Verse after the gap.', + tokens: [], + }, + ], + }; + mockBookData({ book: gappedBook }); + + await act(async () => { + renderLoader({ + useWebViewScrollGroupScrRef: makeScrollGroupHook({ + book: 'GEN', + chapterNum: 3, + verseNum: 2, + }), + }); + }); + + expect(capturedInterlinearizerProps?.scrRef).toEqual({ + book: 'GEN', + chapterNum: 3, + verseNum: 1, + }); + }); + + it('passes a mid-segment verse through unchanged', async () => { + // A merged segment can span several verses; a verse inside the span is contained in that + // segment even though no segment starts at it, so the loader passes the reference through + // unchanged and the views resolve it to the containing segment. const mergedSegmentBook: Book = { id: 'GEN', bookRef: 'GEN', @@ -619,7 +667,7 @@ describe('InterlinearizerLoader', () => { expect(capturedInterlinearizerProps?.scrRef).toEqual({ book: 'GEN', chapterNum: 3, - verseNum: 1, + verseNum: 2, }); }); @@ -1133,6 +1181,380 @@ 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; + } + + /** + * Returns the segmentation dispatch captured from the rendered interlinearizer, failing the + * test if none was captured. + * + * @returns The captured `segmentationDispatch`. + * @throws If the interlinearizer did not render and capture a dispatch. + */ + function getSegmentationDispatch(): SegmentationDispatch { + const dispatch = capturedInterlinearizerProps?.segmentationDispatch; + if (!dispatch) throw new Error('expected a captured segmentationDispatch'); + return dispatch; + } + + it('persists split, merge, and move boundary edits made through the dispatch', async () => { + mockBookData({ book: TWO_VERSE_BOOK }); + await act(async () => { + renderLoader(); + }); + const dispatch = getSegmentationDispatch(); + + 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". "beta" (GEN 1:1:6) already begins a segment from + // the split above, so the move removes the (already-removed) verse-2 default start and re-adds + // the existing "beta" start: the normalized delta is unchanged — verse 1's split boundary and + // verse 2's merged boundary both persist. + act(() => dispatch.move('GEN 1:2:0', 'GEN 1:1:6')); + act(() => jest.advanceTimersByTime(300)); + jest.useRealTimers(); + expect(lastPersistedSegmentation()).toEqual({ + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: ['GEN 1:1:6'], + }); + }); + + it('re-renders the interlinearizer with the new segments in place after a boundary edit', async () => { + // The reported bug: clicking merge/split did not update the view. The resegmented book is + // derived from the draft's ref-held segmentation, and the auto-save's `setDirty(true)` bails + // out of the re-render once the draft is dirty — so the new `book` prop only reaches the + // interlinearizer because `autosaveSegmentation` bumps a dedicated version. Assert the split + // actually reaches the rendered `book` (verse 1 becomes two segments) without a remount. + mockBookData({ book: TWO_VERSE_BOOK }); + await act(async () => { + renderLoader(); + }); + const dispatch = getSegmentationDispatch(); + expect(capturedInterlinearizerProps?.book.segments).toHaveLength(2); + const mountsBefore = interlinearizerMountCount; + + act(() => dispatch.split('GEN 1:1:6')); + + // Verse 1 is now two segments (before/after "beta"), so the book has three segments total, and + // the interlinearizer was updated in place rather than remounted. + expect(capturedInterlinearizerProps?.book.segments).toHaveLength(3); + expect(interlinearizerMountCount).toBe(mountsBefore); + }); + + it('sends the boundary delta on Save and clears the unsaved marker', async () => { + mockBookData({ book: TWO_VERSE_BOOK }); + let result: ReturnType | undefined; + await act(async () => { + result = renderLoader({ + useWebViewState: makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT }), + }); + }); + const dispatch = getSegmentationDispatch(); + + // A boundary edit dirties the draft, so the tab marker lights up. + act(() => dispatch.merge('GEN 1:2:0')); + expect(result?.updateWebViewDefinition).toHaveBeenCalledWith({ title: 'Interlinearizer ●' }); + + result?.updateWebViewDefinition.mockClear(); + await userEvent.click(screen.getByTestId('tab-toolbar-save')); + + // Save sends the draft's boundary delta (not the "null" clear sentinel) alongside the + // analysis, so the project's boundaries match the analysis just written. + expect(mockSendCommand).toHaveBeenCalledWith( + 'interlinearizer.saveAnalysis', + 'proj-1', + JSON.stringify(emptyAnalysis()), + JSON.stringify({ removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }), + ); + // markSynced received the draft's exact analysis and segmentation references, so its + // identity guard matched and the unsaved marker cleared. + expect(result?.updateWebViewDefinition).toHaveBeenCalledWith({ title: 'Interlinearizer' }); + }); + + it('clears the segmentation field when an edit restores the default segmentation', async () => { + mockBookData({ book: TWO_VERSE_BOOK }); + await act(async () => { + renderLoader(); + }); + const dispatch = getSegmentationDispatch(); + + 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('former boundaries', () => { + /** + * A four-verse book covering every former-boundary shape: a word-initial verse (1:3), a verse + * that begins with punctuation so its first word token differs from its first token (1:2), a + * verse with no word token at all (1:4), and a token-less verse (1:5). + */ + const BOUNDARY_SHAPES_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.', + tokens: [ + { + ref: 'GEN 1:1:0', + surfaceText: 'Alpha', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 5, + }, + ], + }, + { + 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: '“', + writingSystem: 'en', + type: 'punctuation', + charStart: 0, + charEnd: 1, + }, + { + ref: 'GEN 1:2:1', + surfaceText: 'Gamma', + writingSystem: 'en', + type: 'word', + charStart: 1, + charEnd: 6, + }, + ], + }, + { + id: 'GEN 1:3', + startRef: { book: 'GEN', chapter: 1, verse: 3 }, + endRef: { book: 'GEN', chapter: 1, verse: 3 }, + baselineText: 'Delta.', + tokens: [ + { + ref: 'GEN 1:3:0', + surfaceText: 'Delta', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 5, + }, + ], + }, + { + id: 'GEN 1:4', + startRef: { book: 'GEN', chapter: 1, verse: 4 }, + endRef: { book: 'GEN', chapter: 1, verse: 4 }, + baselineText: '—', + tokens: [ + { + ref: 'GEN 1:4:0', + surfaceText: '—', + writingSystem: 'en', + type: 'punctuation', + charStart: 0, + charEnd: 1, + }, + ], + }, + { + id: 'GEN 1:5', + startRef: { book: 'GEN', chapter: 1, verse: 5 }, + endRef: { book: 'GEN', chapter: 1, verse: 5 }, + baselineText: '', + tokens: [], + }, + ], + }; + + /** + * Renders the loader on {@link BOUNDARY_SHAPES_BOOK} and returns the captured segmentation + * dispatch so a test can drive boundary edits. + * + * @returns The captured `segmentationDispatch`. + * @throws If the interlinearizer did not render and capture a dispatch. + */ + async function renderBoundaryBook(): Promise { + mockBookData({ book: BOUNDARY_SHAPES_BOOK }); + await act(async () => { + renderLoader(); + }); + const dispatch = capturedInterlinearizerProps?.segmentationDispatch; + if (!dispatch) throw new Error('expected a captured segmentationDispatch'); + return dispatch; + } + + it('passes an empty formerBoundaries map and segmentationVersion 0 before any boundary edit', async () => { + await renderBoundaryBook(); + + expect(capturedInterlinearizerProps?.formerBoundaries.size).toBe(0); + expect(capturedInterlinearizerProps?.segmentationVersion).toBe(0); + }); + + it('maps a merged word-initial verse start to itself and bumps segmentationVersion', async () => { + const dispatch = await renderBoundaryBook(); + + // Merge verse 3 into verse 2: the verse begins with a word token, so the split anchor (its + // first word token) and the removed default start are the same ref. + act(() => dispatch.merge('GEN 1:3:0')); + + expect(capturedInterlinearizerProps?.formerBoundaries.get('GEN 1:3:0')).toBe('GEN 1:3:0'); + expect(capturedInterlinearizerProps?.segmentationVersion).toBe(1); + }); + + it('keys a punct-initial merged verse by its first word token, mapped to the removed start', async () => { + const dispatch = await renderBoundaryBook(); + + // Merge verse 2 into verse 1: the verse opens with a quote mark, so the boundary slot's word + // anchor (the first word token) differs from the removed default start (the punct token). + act(() => dispatch.merge('GEN 1:2:0')); + + expect(capturedInterlinearizerProps?.formerBoundaries.get('GEN 1:2:1')).toBe('GEN 1:2:0'); + expect(capturedInterlinearizerProps?.formerBoundaries.has('GEN 1:2:0')).toBe(false); + }); + + it('skips a merged-away verse that has no word token', async () => { + const dispatch = await renderBoundaryBook(); + + // Merge verse 4 (punctuation only) into verse 3: with no word token there is no split anchor + // to key the former boundary by, so the verse contributes no map entry. + act(() => dispatch.merge('GEN 1:4:0')); + + expect(capturedInterlinearizerProps?.formerBoundaries.size).toBe(0); + }); + + it('does not map verses whose default start was not merged away', async () => { + const dispatch = await renderBoundaryBook(); + + // Only verse 3 is merged; the other verses keep their default boundaries and must stay out + // of the map so their slots do not render former-boundary ticks. + act(() => dispatch.merge('GEN 1:3:0')); + + expect(capturedInterlinearizerProps?.formerBoundaries.size).toBe(1); + expect(capturedInterlinearizerProps?.formerBoundaries.has('GEN 1:1:0')).toBe(false); + expect(capturedInterlinearizerProps?.formerBoundaries.has('GEN 1:2:1')).toBe(false); + }); + + it('shows the loading state when the book unloads while the draft holds removals', async () => { + // Render with a rebuildable element so the same loader instance can be re-invoked after the + // book-data mock changes (the mock mutates hook output, not React state). + mockBookData({ book: BOUNDARY_SHAPES_BOOK }); + const updateWebViewDefinition = jest.fn(() => true); + const scrollGroupHook = makeScrollGroupHook(); + const webViewState = makeWebViewState(); + const buildUi = () => ( + + ); + let view: ReturnType | undefined; + await act(async () => { + view = render(buildUi()); + }); + const dispatch = capturedInterlinearizerProps?.segmentationDispatch; + if (!dispatch) throw new Error('expected a captured segmentationDispatch'); + // Merge so the draft's delta has a removed verse start. + act(() => dispatch.merge('GEN 1:3:0')); + expect(capturedInterlinearizerProps?.formerBoundaries.size).toBe(1); + + // The book unloads (e.g. a cross-book swap): with no verse book to resolve refs against, the + // former boundaries cannot be derived and the loader falls back to the loading curtain. + mockBookData({ book: undefined, isLoading: true }); + view?.rerender(buildUi()); + + expect(screen.getByText('Loading…')).toBeInTheDocument(); + expect(screen.queryByTestId('interlinearizer')).not.toBeInTheDocument(); + }); + }); + describe('save command', () => { it('saves the draft analysis to the active project when Save is clicked with an active project', async () => { const draftAnalysis = emptyAnalysis(); @@ -1150,6 +1572,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', ); }); diff --git a/src/__tests__/components/PhraseBox.test.tsx b/src/__tests__/components/PhraseBox.test.tsx index bd7fcafc..9491b77f 100644 --- a/src/__tests__/components/PhraseBox.test.tsx +++ b/src/__tests__/components/PhraseBox.test.tsx @@ -365,6 +365,23 @@ describe('PhraseBox', () => { expect(phraseBox).toHaveClass('tw:phrase-dimmed'); }); + it('applies the candidate outline when isCandidate is true', () => { + renderBox(); + + const phraseBox = document.querySelector('[data-phrase-box="true"]'); + expect(phraseBox).toHaveClass('tw:phrase-candidate'); + }); + + it('gives the candidate outline precedence over the focused style', () => { + renderBox(); + + // While an operation preview is hovered, showing what it affects matters more than showing + // what is focused. + const phraseBox = document.querySelector('[data-phrase-box="true"]'); + expect(phraseBox).toHaveClass('tw:phrase-candidate'); + expect(phraseBox).not.toHaveClass('tw:phrase-focused'); + }); + it('reddens only the chips whose refs are in splitFreeTokenRefs, leaving the box border neutral', () => { renderBox( /// +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, @@ -12,7 +13,12 @@ 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 { PhraseMode } from '../../types/phrase-mode'; import type { TokenGroup, LinkSlot, FocusContext } from '../../types/token-layout'; import { makePhraseLink, makePhraseStripContext, makeWordToken } from '../test-helpers'; @@ -38,6 +44,7 @@ jest.mock('../../components/PhraseBox', () => ({ tokens, isFocused, isHighlighted, + isCandidate, showControls, showGlossInput, splitFreeTokenRefs, @@ -47,6 +54,7 @@ jest.mock('../../components/PhraseBox', () => ({ tokens: (Token & { type: 'word' })[]; isFocused: boolean; isHighlighted: boolean; + isCandidate: boolean; phraseLink: PhraseAnalysisLink | undefined; groupKey: string; onFocusPhrase: (groupKey: string) => void; @@ -58,6 +66,7 @@ jest.mock('../../components/PhraseBox', () => ({ type="button" data-focused={isFocused ? 'true' : 'false'} data-highlighted={isHighlighted ? 'true' : 'false'} + data-candidate={isCandidate ? 'true' : 'false'} data-controls={showControls ? 'true' : 'false'} data-gloss={showGlossInput ? 'true' : 'false'} data-split-free={[...splitFreeTokenRefs].join(',')} @@ -86,6 +95,17 @@ function mkPunct(ref: string, surfaceText = '.'): Token { /** A minimal no-focus context. */ const NO_FOCUS: FocusContext = emptyFocusContext(); +// Every PhraseSlot render mounts BoundaryControl, whose labels come from useLocalizedStrings; +// resetMocks clears the shared implementation, so re-establish the key-to-itself mapping globally. +beforeEach(() => { + jest + .mocked(useLocalizedStrings) + .mockImplementation((keys: readonly string[]) => [ + keys.reduce>((acc, k) => ({ ...acc, [k]: k }), {}), + false, + ]); +}); + /** Default props shared by PhraseSlot tests. */ function slotProps(slot: LinkSlot): Parameters[0] { return { @@ -298,6 +318,262 @@ describe('PhraseSlot', () => { }); }); +// --------------------------------------------------------------------------- +// PhraseSlot boundary controls +// --------------------------------------------------------------------------- + +describe('PhraseSlot boundary controls', () => { + 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: [] }; + + /** The segment holding the intra-segment fixture tokens `a` and `b`. */ + const prevSegment: Segment = { + id: 'seg-1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'a b', + tokens: [makeWordToken('a'), makeWordToken('b')], + }; + + /** 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. + * + * @param props - Overrides for the slot props (e.g. prev/next segment ids). + * @param options - Optional fixture overrides: merged-away boundaries, straddled boundary refs, + * the phrase mode, and the candidate-token hover spy. + * @returns The dispatch, for asserting on its calls. + */ + function renderBoundary( + props: Partial[0]>, + options: { + formerBoundaries?: ReadonlyMap; + straddledBoundaryRefs?: ReadonlySet; + phraseMode?: PhraseMode; + onHoverCandidateTokens?: (refs: readonly string[] | undefined) => void; + } = {}, + ) { + const dispatch = { + merge: jest.fn(), + split: jest.fn(), + move: jest.fn(), + }; + const value: SegmentationContextValue = { + dispatch, + segmentById: new Map([ + ['seg-1', prevSegment], + ['seg-2', nextSegment], + ]), + segmentOrder: new Map([ + ['seg-1', 0], + ['seg-2', 1], + ]), + formerBoundaries: options.formerBoundaries ?? new Map(), + straddledBoundaryRefs: options.straddledBoundaryRefs ?? new Set(), + }; + render( + + + + + , + ); + 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 the control always visible, with no hover gating', () => { + renderBoundary({ prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }); + const wrapper = screen.getByTestId('boundary-split-btn').parentElement; + expect(wrapper?.className).not.toContain('tw:opacity-0'); + expect(wrapper?.className).not.toContain('tw:group-hover/slot:opacity-100'); + }); + + it('renders no split control at a leading slot inside a segment (the boundary already exists)', () => { + // A SegmentView strip's leading slot has no group before it but carries the segment's id on + // both sides; splitting at the segment's first token would be a no-op, so no control renders. + const leadingSlot: LinkSlot = { prevGroup: undefined, nextGroup: groupB, punctuation: [] }; + renderBoundary({ slot: leadingSlot, prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }); + expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument(); + }); + + it('hides the split control at a straddled boundary ref (not-mid-phrase UI guard)', () => { + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, + { straddledBoundaryRefs: new Set(['b']) }, + ); + expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument(); + }); + + it('keeps the split control when the anchor is not among the straddled boundary refs', () => { + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, + { straddledBoundaryRefs: new Set(['other-ref']) }, + ); + expect(screen.getByTestId('boundary-split-btn')).toBeInTheDocument(); + }); + + it('keeps the merge control at a straddled boundary ref (merge never cuts a phrase)', () => { + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' }, + { straddledBoundaryRefs: new Set(['b']) }, + ); + expect(screen.getByTestId('boundary-merge-btn')).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(); + }); + + it('previews a merge on hover by highlighting the word tokens of both segments', () => { + const onHoverCandidateTokens = jest.fn(); + renderBoundary({ prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' }, { onHoverCandidateTokens }); + fireEvent.mouseEnter(screen.getByTestId('boundary-merge-btn')); + expect(onHoverCandidateTokens).toHaveBeenCalledWith(['a', 'b', 'seg2-start']); + fireEvent.mouseLeave(screen.getByTestId('boundary-merge-btn')); + expect(onHoverCandidateTokens).toHaveBeenLastCalledWith(undefined); + }); + + it('previews a split on hover by highlighting the word tokens that would break off', () => { + const onHoverCandidateTokens = jest.fn(); + renderBoundary({ prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, { onHoverCandidateTokens }); + fireEvent.mouseEnter(screen.getByTestId('boundary-split-btn')); + // The split anchor is 'b' (the next group's first token); only the run from it to the segment + // end becomes the new segment. + expect(onHoverCandidateTokens).toHaveBeenCalledWith(['b']); + fireEvent.mouseLeave(screen.getByTestId('boundary-split-btn')); + expect(onHoverCandidateTokens).toHaveBeenLastCalledWith(undefined); + }); + + it('previews nothing when the split slot segment id is not in the segment lookup', () => { + const onHoverCandidateTokens = jest.fn(); + renderBoundary({ prevSegmentId: 'seg-x', nextSegmentId: 'seg-x' }, { onHoverCandidateTokens }); + fireEvent.mouseEnter(screen.getByTestId('boundary-split-btn')); + expect(onHoverCandidateTokens).toHaveBeenCalledWith([]); + }); + + it('clears the hover preview synchronously when the control is clicked', () => { + const onHoverCandidateTokens = jest.fn(); + const dispatch = renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, + { onHoverCandidateTokens }, + ); + const button = screen.getByTestId('boundary-split-btn'); + fireEvent.mouseEnter(button); + fireEvent.click(button); + expect(onHoverCandidateTokens).toHaveBeenLastCalledWith(undefined); + expect(dispatch.split).toHaveBeenCalledWith('b'); + }); + + it('dispatches the original removed ref when splitting at a former boundary', () => { + // The merged-away verse began with punctuation: the word anchor 'b' maps to the removed + // punctuation start ref, and the restore must dispatch that original ref. + const dispatch = renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, + { formerBoundaries: new Map([['b', 'punct-start']]) }, + ); + fireEvent.click(screen.getByTestId('boundary-split-btn')); + expect(dispatch.split).toHaveBeenCalledWith('punct-start'); + }); + + it('disables the merge control while a phrase edit is active', () => { + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' }, + { phraseMode: { kind: 'edit', phraseId: 'p1', originalTokens: [] } }, + ); + expect(screen.getByTestId('boundary-merge-btn')).toBeDisabled(); + }); + + it('disables the split control while a confirm-unlink prompt is active', () => { + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, + { phraseMode: { kind: 'confirm-unlink', phraseId: 'p1' } }, + ); + expect(screen.getByTestId('boundary-split-btn')).toBeDisabled(); + }); + + it('renders a former-boundary tick on an intra-segment slot at a merged-away verse start', () => { + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, + { formerBoundaries: new Map([['b', 'b']]) }, + ); + expect(screen.getByTestId('former-boundary-marker')).toBeInTheDocument(); + expect(screen.getByTestId('boundary-split-btn')).toBeInTheDocument(); + }); + + it('renders no former-boundary tick when the slot is not on a merged-away verse start', () => { + renderBoundary({ prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }); + expect(screen.queryByTestId('former-boundary-marker')).not.toBeInTheDocument(); + }); + + it('keeps the former-boundary tick when the split control is suppressed by the mid-phrase guard', () => { + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, + { formerBoundaries: new Map([['b', 'b']]), straddledBoundaryRefs: new Set(['b']) }, + ); + expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument(); + expect(screen.getByTestId('former-boundary-marker')).toBeInTheDocument(); + }); + + it('renders no former-boundary tick on a cross-segment slot', () => { + // A cross-segment slot sits on a live boundary; the tick marks only merged-away ones. + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' }, + { formerBoundaries: new Map([['b', 'b']]) }, + ); + expect(screen.getByTestId('boundary-merge-btn')).toBeInTheDocument(); + expect(screen.queryByTestId('former-boundary-marker')).not.toBeInTheDocument(); + }); +}); + // --------------------------------------------------------------------------- // PhraseGroup // --------------------------------------------------------------------------- @@ -314,6 +590,7 @@ describe('MemoizedPhraseGroup', () => { group, isFocused: false, isHighlighted: false, + isCandidate: false, splitFreeTokenRefs: new Set(), showControls: false, showGlossInput: true, @@ -340,6 +617,11 @@ describe('MemoizedPhraseGroup', () => { expect(document.querySelector('[data-highlighted="true"]')).toBeInTheDocument(); }); + it('passes isCandidate=true to PhraseBox when set', () => { + render(); + expect(document.querySelector('[data-candidate="true"]')).toBeInTheDocument(); + }); + it('does not attach hover handlers when allowHover is false', () => { const onHoverPhrase = jest.fn(); const setHoveredGroupKey = jest.fn(); @@ -439,14 +721,32 @@ describe('PhraseStrip', () => { expect(boxes[1]).toHaveAttribute('data-gloss', 'false'); }); - it('highlights a group whose token is a link candidate', () => { + it('marks a group whose token is a hovered-preview candidate as candidate, not highlighted', () => { const items = [groupItem(undefined, ['tok-a'])]; render( withProvider( , ), ); - expect(document.querySelector('[data-highlighted="true"]')).toBeInTheDocument(); + // The candidate preview renders through its own dedicated tier — it must not double as the + // hover highlight, which also reveals phrase edit controls. + expect(document.querySelector('[data-candidate="true"]')).toBeInTheDocument(); + expect(document.querySelector('[data-highlighted="true"]')).not.toBeInTheDocument(); + }); + + it('does not mark candidate groups outside view mode', () => { + const items = [groupItem(undefined, ['tok-a'])]; + render( + withProvider( + , + ), + ); + expect(document.querySelector('[data-candidate="true"]')).not.toBeInTheDocument(); }); it('highlights a group whose phraseId matches the focused phrase', () => { diff --git a/src/__tests__/components/SegmentView.test.tsx b/src/__tests__/components/SegmentView.test.tsx index a669ea3e..3c12ef0a 100644 --- a/src/__tests__/components/SegmentView.test.tsx +++ b/src/__tests__/components/SegmentView.test.tsx @@ -223,6 +223,7 @@ function requiredProps(): { onHoverPhrase: jest.Mock; onSelect: (ref: ScriptureRef, tokenRef?: string) => void; segment: Segment; + label: string; phraseMode: { kind: 'view' }; setPhraseMode: jest.Mock; tokenSegmentMap: ReadonlyMap; @@ -239,6 +240,7 @@ function requiredProps(): { onHoverPhrase: jest.fn(), onSelect: jest.fn(), segment: WORD_SEGMENT, + label: '1', phraseMode: { kind: 'view' }, setPhraseMode: jest.fn(), tokenSegmentMap: new Map(), @@ -293,20 +295,26 @@ describe('SegmentView', () => { expect(screen.queryByText('the')).not.toBeInTheDocument(); }); - it('shows the verse number label', () => { + it('shows the verse-based label', () => { render(, withAnalysisStore); expect(screen.getByText('1')).toBeInTheDocument(); }); - it('shows a bare verse number by default', () => { + it('shows a multi-verse range label for a merged segment', () => { + render(, withAnalysisStore); + + expect(screen.getByText('2–3')).toBeInTheDocument(); + }); + + it('shows a bare verse label by default', () => { render(, withAnalysisStore); expect(screen.getByText('1')).toBeInTheDocument(); expect(screen.queryByText('1:1')).not.toBeInTheDocument(); }); - it('folds the chapter into the verse label when chapterLabelInVerse is set', () => { + it('folds the chapter into the segment label when chapterLabelInVerse is set', () => { render( +/// + +import { render, screen } from '@testing-library/react'; +import type { Segment } from 'interlinearizer'; +import { + NO_OP_SEGMENTATION_DISPATCH, + SegmentationProvider, + useSegmentation, + type SegmentationContextValue, +} from '../../components/SegmentationStore'; + +/** A test consumer that renders the resolved context as text so tests can assert on it. */ +function Probe() { + const { segmentById, segmentOrder } = useSegmentation(); + return ( + + {segmentById.size}:{segmentOrder.size} + + ); +} + +describe('SegmentationStore', () => { + it('returns an inert default when no provider is present', () => { + render(); + expect(screen.getByTestId('probe')).toHaveTextContent('0:0'); + }); + + it('provides the supplied value to consumers within a provider', () => { + const segment: Segment = { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'Hi.', + tokens: [], + }; + const value: SegmentationContextValue = { + dispatch: NO_OP_SEGMENTATION_DISPATCH, + segmentById: new Map([['GEN 1:1', segment]]), + segmentOrder: new Map([['GEN 1:1', 0]]), + formerBoundaries: new Map(), + straddledBoundaryRefs: new Set(), + }; + render( + + + , + ); + expect(screen.getByTestId('probe')).toHaveTextContent('1:1'); + }); + + it('exposes an inert no-op dispatch that does nothing when invoked', () => { + // Calling each method must not throw; this also exercises the no-op function bodies. + expect(() => { + NO_OP_SEGMENTATION_DISPATCH.merge('GEN 1:1:0'); + NO_OP_SEGMENTATION_DISPATCH.split('GEN 1:1:6'); + NO_OP_SEGMENTATION_DISPATCH.move('GEN 1:1:0', 'GEN 1:1:6'); + }).not.toThrow(); + }); +}); diff --git a/src/__tests__/components/TokenLinkIcon.test.tsx b/src/__tests__/components/TokenLinkIcon.test.tsx index bbe5b6dc..89d54d8a 100644 --- a/src/__tests__/components/TokenLinkIcon.test.tsx +++ b/src/__tests__/components/TokenLinkIcon.test.tsx @@ -4,12 +4,18 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { Segment, Token } from 'interlinearizer'; import type { ComponentProps, ReactElement } from 'react'; import { TokenLinkIcon } from '../../components/TokenLinkIcon'; import { PhraseStripProvider, type PhraseStripContextValue, } from '../../components/PhraseStripContext'; +import { + SegmentationProvider, + type SegmentationContextValue, + type SegmentationDispatch, +} from '../../components/SegmentationStore'; import type { SlotFocusInfo } from '../../types/token-layout'; import { makePhraseLink, makePhraseStripContext, makeWordToken } from '../test-helpers'; @@ -48,6 +54,7 @@ function slotFocus(overrides: Partial = {}): SlotFocusInfo { return { focusedSideIsPrev: undefined, isSameSegmentAsFocus: true, + isAdjacentEdgeOfFocus: false, focusedPhraseLink: undefined, focusedFreeToken: undefined, ...overrides, @@ -531,4 +538,250 @@ describe('TokenLinkIcon', () => { await userEvent.hover(screen.getByTestId('token-unlink-btn')); expect(onHoverSplitFreeTokens).not.toHaveBeenCalled(); }); + + // --------------------------------------------------------------------------- + // Cross-segment edge link (pulls an adjacent segment's edge token + moves the boundary) + // --------------------------------------------------------------------------- + + describe('cross-segment edge link', () => { + /** A fresh segmentation dispatch whose calls the tests assert on. */ + function makeDispatch(): jest.Mocked { + return { merge: jest.fn(), split: jest.fn(), move: jest.fn() }; + } + + /** + * Builds a punctuation token fixture for adjacent-segment layouts that interleave punctuation + * with the word tokens. + * + * @param ref - Token ref. + * @returns A punctuation token. + */ + function mkPunct(ref: string): Token { + return { + ref, + surfaceText: ',', + writingSystem: 'en', + type: 'punctuation', + charStart: 0, + charEnd: 1, + }; + } + + /** + * Builds an adjacent segment ("seg-B") containing the given tokens in order. + * + * @param tokens - The segment's tokens, in document order. + * @returns A segment with id `seg-B`. + */ + function segB(tokens: readonly Token[]): Segment { + return { + id: 'seg-B', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: tokens.map((t) => t.surfaceText).join(' '), + tokens: [...tokens], + }; + } + + /** + * Renders a cross-segment edge `TokenLinkIcon` inside both providers. + * + * @param dispatch - The segmentation dispatch to capture calls on. + * @param opts - `focusedSideIsPrev` and the adjacent segment's tokens. + * @returns The render result. + */ + function renderEdge( + dispatch: SegmentationDispatch, + opts: { focusedSideIsPrev: boolean; segmentTokens: readonly Token[]; mapToken?: boolean }, + ) { + const segmentation: SegmentationContextValue = { + dispatch, + segmentById: new Map([['seg-B', segB(opts.segmentTokens)]]), + segmentOrder: new Map([ + ['seg-A', 0], + ['seg-B', 1], + ]), + formerBoundaries: new Map(), + straddledBoundaryRefs: new Set(), + }; + const tokenSegmentMap = + opts.mapToken === false ? new Map() : new Map([['tok-b', 'seg-B']]); + return render( + + + + + , + ); + } + + it('activates the link button at an adjacent edge even across a segment boundary', () => { + renderEdge(makeDispatch(), { + focusedSideIsPrev: true, + segmentTokens: [makeWordToken('tok-b'), makeWordToken('tok-c')], + }); + expect(screen.getByTestId('token-link-btn')).toBeEnabled(); + }); + + it('pulls one token forward (move) when focus is the previous segment', async () => { + const dispatch = makeDispatch(); + renderEdge(dispatch, { + focusedSideIsPrev: true, + segmentTokens: [makeWordToken('tok-b'), makeWordToken('tok-c')], + }); + await userEvent.click(screen.getByTestId('token-link-btn')); + // The adjacent segment B starts at tok-b; pulling tok-b leaves tok-c as B's new start. + expect(dispatch.move).toHaveBeenCalledWith('tok-b', 'tok-c'); + expect(mockCreatePhrase).toHaveBeenCalled(); + }); + + it('moves the boundary to the next word token, skipping punctuation after the pulled word', async () => { + // Segment B is [tok-b, punct, tok-c]: the boundary must land on a word token, so the comma + // after the pulled tok-b travels with it and tok-c becomes B's new start. + const dispatch = makeDispatch(); + renderEdge(dispatch, { + focusedSideIsPrev: true, + segmentTokens: [makeWordToken('tok-b'), mkPunct('punct-1'), makeWordToken('tok-c')], + }); + await userEvent.click(screen.getByTestId('token-link-btn')); + expect(dispatch.move).toHaveBeenCalledWith('tok-b', 'tok-c'); + }); + + it('merges the whole adjacent segment when it has only the pulled token', async () => { + const dispatch = makeDispatch(); + renderEdge(dispatch, { focusedSideIsPrev: true, segmentTokens: [makeWordToken('tok-b')] }); + await userEvent.click(screen.getByTestId('token-link-btn')); + expect(dispatch.merge).toHaveBeenCalledWith('tok-b'); + }); + + it('merges the whole adjacent segment when only punctuation follows the pulled token', async () => { + // Segment B is [tok-b, punct]: no word token remains past tok-b, so a move would strand a + // word-less remainder — the segment merges wholly into the focused one instead. + const dispatch = makeDispatch(); + renderEdge(dispatch, { + focusedSideIsPrev: true, + segmentTokens: [makeWordToken('tok-b'), mkPunct('punct-1')], + }); + await userEvent.click(screen.getByTestId('token-link-btn')); + expect(dispatch.merge).toHaveBeenCalledWith('tok-b'); + expect(dispatch.move).not.toHaveBeenCalled(); + }); + + it('moves the boundary back to the pulled token when focus is the next segment', async () => { + const dispatch = makeDispatch(); + renderEdge(dispatch, { + focusedSideIsPrev: false, + segmentTokens: [makeWordToken('tok-b'), makeWordToken('tok-c')], + }); + await userEvent.click(screen.getByTestId('token-link-btn')); + // Focus is segment B (starting at tok-b); pulling the previous segment's tok-a moves B's start to tok-a. + expect(dispatch.move).toHaveBeenCalledWith('tok-b', 'tok-a'); + }); + + it('skips the boundary move but still phrases when the pulled token maps to no segment', async () => { + const dispatch = makeDispatch(); + renderEdge(dispatch, { + focusedSideIsPrev: true, + segmentTokens: [makeWordToken('tok-b'), makeWordToken('tok-c')], + mapToken: false, + }); + await userEvent.click(screen.getByTestId('token-link-btn')); + expect(dispatch.move).not.toHaveBeenCalled(); + expect(dispatch.merge).not.toHaveBeenCalled(); + expect(mockCreatePhrase).toHaveBeenCalled(); + }); + + it('leaves the link button inactive (with tooltip) for a non-edge cross-segment slot', () => { + render( + + + , + ); + const button = screen.getByTestId('token-link-btn'); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('title', 'nope'); + }); + + // ------------------------------------------------------------------------- + // The not-mid-phrase UI guard: a pull moves the boundary by one token, so a pulled edge token + // that belongs to a phrase would leave that phrase straddling the boundary — the icon disables. + // ------------------------------------------------------------------------- + + describe('pulled edge token in a phrase', () => { + /** + * Renders a cross-segment edge slot whose pulled edge token belongs to a phrase in the + * adjacent segment. `focusedSideIsPrev` picks which side holds focus; the pulled token is on + * the opposite side (`tok-b` when focus is prev-ward, `tok-a` when focus is next-ward). + * + * @param opts - Which side holds focus. + * @returns The render result. + */ + function renderPhrasePull(opts: { focusedSideIsPrev: boolean }) { + const segmentation: SegmentationContextValue = { + dispatch: makeDispatch(), + segmentById: new Map([['seg-B', segB([makeWordToken('tok-b'), makeWordToken('tok-c')])]]), + segmentOrder: new Map([ + ['seg-A', 0], + ['seg-B', 1], + ]), + formerBoundaries: new Map(), + straddledBoundaryRefs: new Set(), + }; + const tokenSegmentMap = new Map([ + ['tok-a', 'seg-A'], + ['tok-b', 'seg-B'], + ]); + const pulledLink = opts.focusedSideIsPrev + ? { nextPhraseLink: makePhraseLink('pB', ['tok-b', 'tok-c']) } + : { prevPhraseLink: makePhraseLink('pA', ['tok-z', 'tok-a']) }; + return render( + + + + + , + ); + } + + it('disables the link when the pulled next-side token belongs to a phrase', () => { + renderPhrasePull({ focusedSideIsPrev: true }); + const button = screen.getByTestId('token-link-btn'); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('title', 'nope'); + }); + + it('disables the link when the pulled prev-side token belongs to a phrase', () => { + renderPhrasePull({ focusedSideIsPrev: false }); + const button = screen.getByTestId('token-link-btn'); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('title', 'nope'); + }); + }); + }); }); diff --git a/src/__tests__/components/modals/ProjectModals.test.tsx b/src/__tests__/components/modals/ProjectModals.test.tsx index 2d8e60b0..1b1ed96e 100644 --- a/src/__tests__/components/modals/ProjectModals.test.tsx +++ b/src/__tests__/components/modals/ProjectModals.test.tsx @@ -39,6 +39,16 @@ const MOCK_FULL_PROJECT_WITH_TARGET = { analysis: emptyAnalysis(), }; +/** A custom boundary delta used to exercise the segmentation branches of open and Save As. */ +const MOCK_SEGMENTATION = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + +/** The same project carrying stored segment boundaries, for the openProject segmentation branch. */ +const MOCK_FULL_PROJECT_WITH_SEGMENTATION = { + ...MOCK_PROJECT, + analysis: emptyAnalysis(), + segmentation: MOCK_SEGMENTATION, +}; + /** Draft snapshot returned by the default `getDraftSnapshot`. */ const MOCK_DRAFT: DraftProject = { sourceProjectId: 'source-proj', @@ -49,6 +59,12 @@ const MOCK_DRAFT: DraftProject = { suggestedDescription: 'Suggested Desc', }; +/** The same draft carrying a custom boundary delta, for the Save As segmentation branches. */ +const MOCK_DRAFT_WITH_SEGMENTATION: DraftProject = { + ...MOCK_DRAFT, + segmentation: MOCK_SEGMENTATION, +}; + jest.mock('../../../components/modals/SelectInterlinearProjectModal', () => ({ __esModule: true, SelectInterlinearProjectModal: ({ @@ -387,6 +403,39 @@ describe('ProjectModals', () => { ); }); + it('passes a stored segmentation delta into the draft when the project has one', async () => { + jest + .mocked(papi.commands.sendCommand) + .mockResolvedValueOnce(JSON.stringify(MOCK_FULL_PROJECT_WITH_SEGMENTATION)); + const loadFromProject = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('select-select')); + + await waitFor(() => + expect(loadFromProject).toHaveBeenCalledWith({ + analysisLanguages: ['en'], + segmentation: MOCK_SEGMENTATION, + analysis: emptyAnalysis(), + }), + ); + }); + + it('notifies and does not load when the stored segmentation is malformed', async () => { + jest + .mocked(papi.commands.sendCommand) + .mockResolvedValueOnce( + JSON.stringify({ ...MOCK_PROJECT, analysis: emptyAnalysis(), segmentation: { bad: 1 } }), + ); + const loadFromProject = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('select-select')); + + await waitFor(() => expect(papi.notifications.send).toHaveBeenCalledTimes(1)); + expect(loadFromProject).not.toHaveBeenCalled(); + }); + it('notifies and does not load when getProject returns a non-project shape', async () => { jest .mocked(papi.commands.sendCommand) @@ -706,15 +755,48 @@ describe('ProjectModals', () => { 'NewDesc', ), ); + // The draft has no custom boundaries, so the boundary argument is the "null" clear sentinel. expect(papi.commands.sendCommand).toHaveBeenCalledWith( 'interlinearizer.saveAnalysis', 'proj-1', JSON.stringify(emptyAnalysis()), + 'null', ); expect(markSynced).toHaveBeenCalledTimes(1); + expect(markSynced).toHaveBeenCalledWith(MOCK_DRAFT.analysis, undefined); expect(setModal).toHaveBeenCalledWith('none'); }); + it('sends the draft segmentation delta when saving as a new project', async () => { + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); + const markSynced = jest.fn(); + render( + MOCK_DRAFT_WITH_SEGMENTATION, + })} + />, + ); + + await userEvent.click(screen.getByTestId('saveas-new')); + + await waitFor(() => + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.saveAnalysis', + 'proj-1', + JSON.stringify(emptyAnalysis()), + JSON.stringify(MOCK_SEGMENTATION), + ), + ); + // markSynced receives the exact persisted references so its identity guard can match. + expect(markSynced).toHaveBeenCalledWith( + MOCK_DRAFT_WITH_SEGMENTATION.analysis, + MOCK_SEGMENTATION, + ); + }); + it('notifies and does not mark synced when create returns a non-project shape', async () => { jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify({ bad: true })); const markSynced = jest.fn(); @@ -744,17 +826,49 @@ describe('ProjectModals', () => { await userEvent.click(screen.getByTestId('saveas-overwrite')); + // The draft has no custom boundaries, so "null" clears any the target had stored. await waitFor(() => expect(papi.commands.sendCommand).toHaveBeenCalledWith( 'interlinearizer.saveAnalysis', 'proj-1', JSON.stringify(emptyAnalysis()), + 'null', ), ); expect(markSynced).toHaveBeenCalledTimes(1); + expect(markSynced).toHaveBeenCalledWith(MOCK_DRAFT.analysis, undefined); expect(setModal).toHaveBeenCalledWith('none'); }); + it('sends the draft segmentation delta when overwriting an existing project', async () => { + const markSynced = jest.fn(); + render( + MOCK_DRAFT_WITH_SEGMENTATION, + })} + />, + ); + + await userEvent.click(screen.getByTestId('saveas-overwrite')); + + await waitFor(() => + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.saveAnalysis', + 'proj-1', + JSON.stringify(emptyAnalysis()), + JSON.stringify(MOCK_SEGMENTATION), + ), + ); + // markSynced receives the exact persisted references so its identity guard can match. + expect(markSynced).toHaveBeenCalledWith( + MOCK_DRAFT_WITH_SEGMENTATION.analysis, + MOCK_SEGMENTATION, + ); + }); + it('reconciles the overwritten project metadata with the draft config', async () => { const draftWithConfig: DraftProject = { sourceProjectId: 'source-proj', diff --git a/src/__tests__/hooks/useDraftProject.test.ts b/src/__tests__/hooks/useDraftProject.test.ts index 04e07030..ff09ade4 100644 --- a/src/__tests__/hooks/useDraftProject.test.ts +++ b/src/__tests__/hooks/useDraftProject.test.ts @@ -194,7 +194,111 @@ describe('useDraftProject', () => { }); }); + describe('autosaveSegmentation', () => { + it('stores the boundary delta on the draft, marks it dirty, and persists it', async () => { + const { result } = await renderLoaded(); + + jest.useFakeTimers(); + const delta = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + act(() => { + result.current.autosaveSegmentation(delta); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + jest.useRealTimers(); + + expect(result.current.dirty).toBe(true); + expect(result.current.getDraftSnapshot()?.segmentation).toEqual(delta); + expect(lastSavedDraft().segmentation).toEqual(delta); + }); + + it('bumps segmentationVersion on every edit, including when the draft was already dirty', async () => { + // The resegmented book is derived from the draft's segmentation, which lives in a ref, and + // `setDirty(true)` bails out of the re-render once the draft is already dirty — so without the + // version bump a second boundary edit would silently fail to update the view (the reported + // "clicking merge/split doesn't re-render"). Assert the counter advances on each call. + const { result } = await renderLoaded(); + + jest.useFakeTimers(); + const versionBefore = result.current.segmentationVersion; + act(() => { + result.current.autosaveSegmentation({ removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); + }); + expect(result.current.dirty).toBe(true); + expect(result.current.segmentationVersion).toBe(versionBefore + 1); + + // A second edit while already dirty must still bump the version so the view recomputes. + act(() => { + result.current.autosaveSegmentation({ removedVerseStarts: [], addedStarts: ['GEN 1:1:6'] }); + }); + expect(result.current.segmentationVersion).toBe(versionBefore + 2); + + act(() => { + jest.advanceTimersByTime(300); + }); + jest.useRealTimers(); + }); + + it('replaces a pending debounced write when called again before it flushes', async () => { + const { result } = await renderLoaded(); + + jest.useFakeTimers(); + act(() => { + result.current.autosaveSegmentation({ removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); + }); + // A second call before the debounce fires clears the pending timer and schedules a new write. + act(() => { + result.current.autosaveSegmentation({ removedVerseStarts: [], addedStarts: ['GEN 1:1:6'] }); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + jest.useRealTimers(); + + expect(lastSavedDraft().segmentation).toEqual({ + removedVerseStarts: [], + addedStarts: ['GEN 1:1:6'], + }); + }); + + it('clears the segmentation field when passed undefined (back to default segmentation)', async () => { + // Seed a draft that already has custom boundaries so clearing them is observable. + mockGetDraftResolves( + makeDraft({ segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] } }), + ); + const { result } = await renderLoaded(); + + jest.useFakeTimers(); + act(() => { + result.current.autosaveSegmentation(undefined); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + jest.useRealTimers(); + + expect(result.current.getDraftSnapshot()?.segmentation).toBeUndefined(); + expect(lastSavedDraft().segmentation).toBeUndefined(); + }); + }); + describe('loadFromProject', () => { + it('copies a project segmentation delta into the draft when present', async () => { + const { result } = await renderLoaded(); + + const delta = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: ['GEN 1:1:6'] }; + act(() => { + result.current.loadFromProject({ + analysis: analysisWithToken('tok-open'), + analysisLanguages: ['de'], + segmentation: delta, + }); + }); + + expect(result.current.draft?.segmentation).toEqual(delta); + }); + it('copies analysis, analysis languages, and target, clears dirty, and bumps the version', async () => { const { result } = await renderLoaded(); const versionBefore = result.current.draftVersion; @@ -351,6 +455,24 @@ describe('useDraftProject', () => { expect(result.current.draftVersion).toBe(versionBefore + 1); expect(lastSavedDraft().dirty).toBe(false); }); + + it('clears any custom segment boundaries as part of the clean baseline', async () => { + mockGetDraftResolves( + makeDraft({ + analysis: analysisWithToken('tok-wipe-all'), + dirty: true, + segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, + }), + ); + const { result } = await renderLoaded(); + + act(() => { + result.current.wipeAll(); + }); + + expect(result.current.draft?.segmentation).toBeUndefined(); + expect(lastSavedDraft().segmentation).toBeUndefined(); + }); }); describe('markSynced', () => { @@ -365,7 +487,7 @@ describe('useDraftProject', () => { const versionBefore = result.current.draftVersion; act(() => { - result.current.markSynced(synced); + result.current.markSynced(synced, undefined); }); expect(result.current.dirty).toBe(false); @@ -388,11 +510,55 @@ describe('useDraftProject', () => { // Syncing against the now-stale snapshot must not clear the unsaved-changes flag. act(() => { - result.current.markSynced(savedSnapshot); + result.current.markSynced(savedSnapshot, undefined); }); expect(result.current.dirty).toBe(true); }); + + it('leaves the draft dirty when a segmentation edit landed since the saved snapshot', async () => { + const { result } = await renderLoaded(); + // Dirty the draft with the analysis a Save would capture and persist. + const savedAnalysis = analysisWithToken('tok-saved'); + act(() => { + result.current.autosaveAnalysis(savedAnalysis); + }); + // A boundary edit lands during the save round-trip. It carries the analysis over by + // reference, so only the segmentation identity distinguishes the ref from the snapshot. + act(() => { + result.current.autosaveSegmentation({ removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); + }); + expect(result.current.dirty).toBe(true); + + // The snapshot was taken before the boundary edit (default segmentation), so syncing against + // it must not clear the unsaved-changes flag over the un-persisted boundary change. + act(() => { + result.current.markSynced(savedAnalysis, undefined); + }); + + expect(result.current.dirty).toBe(true); + }); + + it('clears dirty when synced with the matching segmentation reference', async () => { + const { result } = await renderLoaded(); + const delta = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + act(() => { + result.current.autosaveSegmentation(delta); + }); + expect(result.current.dirty).toBe(true); + const snapshot = result.current.getDraftSnapshot(); + if (!snapshot) throw new Error('expected the draft to be loaded'); + + // Save persisted exactly what the ref holds, so the sync clears the unsaved-changes flag. + act(() => { + result.current.markSynced(snapshot.analysis, snapshot.segmentation); + }); + + expect(result.current.dirty).toBe(false); + const saved = lastSavedDraft(); + expect(saved.dirty).toBe(false); + expect(saved.segmentation).toEqual(delta); + }); }); describe('getDraftSnapshot', () => { diff --git a/src/__tests__/hooks/useSegmentWindow.test.ts b/src/__tests__/hooks/useSegmentWindow.test.ts index b7135dd5..00ea8a7d 100644 --- a/src/__tests__/hooks/useSegmentWindow.test.ts +++ b/src/__tests__/hooks/useSegmentWindow.test.ts @@ -23,17 +23,18 @@ declare global { * * @param chapter - Chapter number for the segment's refs. * @param verse - Verse number for the segment's refs. + * @param book - Book code for the segment's refs; defaults to `GEN`. * @returns A minimal {@link Segment}. */ -function makeSegment(chapter: number, verse: number): Segment { +function makeSegment(chapter: number, verse: number, book = 'GEN'): Segment { return { - id: `GEN ${chapter}:${verse}`, - startRef: { book: 'GEN', chapter, verse }, - endRef: { book: 'GEN', chapter, verse }, + id: `${book} ${chapter}:${verse}`, + startRef: { book, chapter, verse }, + endRef: { book, chapter, verse }, baselineText: 'word', tokens: [ { - ref: `GEN ${chapter}:${verse}:0`, + ref: `${book} ${chapter}:${verse}:0`, surfaceText: 'word', writingSystem: 'en', type: 'word', @@ -50,13 +51,14 @@ function makeSegment(chapter: number, verse: number): Segment { * * @param chapter1Count - Number of verses in chapter 1. * @param chapter2Count - Number of verses in chapter 2. + * @param book - Book code for the book and its segments; defaults to `GEN`. * @returns A {@link Book} with the combined flat segment list. */ -function makeBook(chapter1Count: number, chapter2Count: number): Book { +function makeBook(chapter1Count: number, chapter2Count: number, book = 'GEN'): Book { const segments: Segment[] = []; - for (let v = 1; v <= chapter1Count; v += 1) segments.push(makeSegment(1, v)); - for (let v = 1; v <= chapter2Count; v += 1) segments.push(makeSegment(2, v)); - return { id: 'GEN', bookRef: 'GEN', textVersion: 'v1', segments }; + for (let v = 1; v <= chapter1Count; v += 1) segments.push(makeSegment(1, v, book)); + for (let v = 1; v <= chapter2Count; v += 1) segments.push(makeSegment(2, v, book)); + return { id: book, bookRef: book, textVersion: 'v1', segments }; } /** @@ -92,13 +94,20 @@ function renderSegmentWindow( const onDisplayContinuousScrollChange = (v: boolean) => displayContinuousScrollReports.push(v); const hook = renderHook< ReturnType, - { b: Book; ref: SerializedVerseRef; focus?: string | undefined; cont?: boolean } + { + b: Book; + ref: SerializedVerseRef; + focus?: string | undefined; + cont?: boolean; + segVersion?: number; + } >( - ({ b, ref, focus, cont }) => { + ({ b, ref, focus, cont, segVersion }) => { const scrollContainerRef = useRef(container); return useSegmentWindow({ book: b, scrRef: ref, + segmentationVersion: segVersion ?? 0, focusedTokenRef: focus, continuousScroll: cont ?? false, scrollContainerRef, @@ -293,6 +302,25 @@ describe('useSegmentWindow', () => { expect(ids).toContain('GEN 2:1'); }); + it('anchors on the segment whose verse range contains the reference', () => { + // Verse 10 is split and its second half merged with verses 11–12; navigating to verse 11 + // must center the window on that containing segment, not fall back to the chapter start. + const segments = [ + ...Array.from({ length: 9 }, (_, i) => makeSegment(1, i + 1)), + { ...makeSegment(1, 10), id: 'GEN 1:10a' }, + { ...makeSegment(1, 10), id: 'GEN 1:10b-12', endRef: { book: 'GEN', chapter: 1, verse: 12 } }, + ...Array.from({ length: 8 }, (_, i) => makeSegment(1, i + 13)), + ]; + const book: Book = { id: 'GEN', bookRef: 'GEN', textVersion: 'v1', segments }; + const { result } = renderSegmentWindow(book, { book: 'GEN', chapterNum: 1, verseNum: 11 }); + + // The merged segment sits at flat index 10, so the centered window runs [2, 19) — anchoring + // at the chapter start instead would have produced [0, 9). + const ids = result.current.windowSegments.map((s) => s.id); + expect(ids).toContain('GEN 1:10b-12'); + expect(ids[0]).toBe('GEN 1:3'); + }); + it('falls back to the first segment of the chapter when no exact verse matches', () => { const book = makeBook(10, 10); const { result } = renderSegmentWindow(book, { book: 'GEN', chapterNum: 2, verseNum: 999 }); @@ -578,10 +606,10 @@ describe('useSegmentWindow', () => { expect(result.current.windowSegments.map((s) => s.id)).toContain('GEN 1:50'); }); - it('fades and recenters when the segments identity changes at the same anchor index', () => { - // A book swap (or a re-tokenized book) hands the hook a new `segments` array whose anchor can - // resolve to the same index as before; the identity check must still detect the change and - // recenter rather than leaving the window on stale segment objects. + it('fades and recenters when a book swap changes the segments identity at the same anchor index', () => { + // A book swap hands the hook a new `segments` array whose anchor can resolve to the same index + // as before; the identity check must still detect the change and recenter rather than leaving + // the window on stale segment objects. const book = makeBook(10, 0); const { result, rerender } = renderSegmentWindow(book, { book: 'GEN', @@ -590,11 +618,94 @@ describe('useSegmentWindow', () => { }); expect(result.current.isFaded).toBe(false); - act(() => rerender({ b: makeBook(10, 0), ref: { book: 'GEN', chapterNum: 1, verseNum: 1 } })); + act(() => + rerender({ b: makeBook(10, 0, 'EXO'), ref: { book: 'EXO', chapterNum: 1, verseNum: 1 } }), + ); + + expect(result.current.isFaded).toBe(true); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.isFaded).toBe(false); + }); + + it('redraws in place without fading on a boundary edit (segments change with a version bump)', () => { + // A boundary edit (merge/split) re-segments the loaded book: new `segments` identity carrying a + // `segmentationVersion` bump. The new slice already rendered in the same commit, so a fade + // afterwards would flash content the user is looking at and snap it away from the point they + // clicked — the window must leave the redraw alone. + const book = makeBook(10, 0); + const scrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + const { result, rerender } = renderSegmentWindow(book, scrRef); + const editedBook = makeBook(9, 0); + + act(() => rerender({ b: editedBook, ref: scrRef, segVersion: 1 })); + + expect(result.current.isFaded).toBe(false); + // The window slices the edited book's segments immediately — no midpoint swap to wait for. + expect(result.current.windowSegments[0]).toBe(editedBook.segments[0]); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.isFaded).toBe(false); + }); + + it('does not fade on a boundary edit even when the anchor verse changes', () => { + // A merge that absorbs the active verse's segment start (GEN 1:2 merged into GEN 1:1) makes the + // loader re-resolve the scrRef to the surviving segment's verse in the same commit. The verse + // changed, but the version bump identifies this as a boundary edit, not a navigation — no fade. + const book = makeBook(10, 0); + const { result, rerender } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 2, + }); + const editedBook = makeBook(9, 0); + + act(() => + rerender({ b: editedBook, ref: { book: 'GEN', chapterNum: 1, verseNum: 1 }, segVersion: 1 }), + ); + + expect(result.current.isFaded).toBe(false); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.isFaded).toBe(false); + }); + + it('syncs the display refs immediately on a boundary edit that changes the anchor verse', () => { + // The redraw stands without a recenter midpoint, so the display refs would otherwise be + // stranded on the pre-edit verse/token; the boundary-edit branch must sync them in place. + const book = makeBook(10, 0); + const { result, rerender } = renderSegmentWindow( + book, + { book: 'GEN', chapterNum: 1, verseNum: 2 }, + 'tok-old', + ); + const editedBook = makeBook(9, 0); + + act(() => + rerender({ + b: editedBook, + ref: { book: 'GEN', chapterNum: 1, verseNum: 1 }, + focus: 'tok-new', + segVersion: 1, + }), + ); + + expect(result.current.displayScrRef.verseNum).toBe(1); + expect(result.current.displayFocusedTokenRef).toBe('tok-new'); + }); + + it('fades and recenters when the segments change without a version bump at the same anchor verse', () => { + // A re-tokenization of the loaded book hands the hook a new `segments` identity with the same + // `segmentationVersion` and can keep the anchor verse. The mounted index range no longer + // matches the content, so this must recenter with the fade like any external change. + const book = makeBook(10, 0); + const scrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + const { result, rerender } = renderSegmentWindow(book, scrRef); + const retokenizedBook = makeBook(10, 0); + + act(() => rerender({ b: retokenizedBook, ref: scrRef })); expect(result.current.isFaded).toBe(true); act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); expect(result.current.isFaded).toBe(false); + expect(result.current.windowSegments[0]).toBe(retokenizedBook.segments[0]); }); it('lags displayScrRef through the fade so the highlight moves only with the window swap', () => { diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 0fc3fd64..b3349cb6 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -170,9 +170,9 @@ const getGetProjectHandler = () => /** Activates the extension and returns the `interlinearizer.saveAnalysis` handler. */ const getSaveAnalysisHandler = () => - activateAndGetHandler<(id: string, analysisJson: string) => Promise>( - 'interlinearizer.saveAnalysis', - ); + activateAndGetHandler< + (id: string, analysisJson: string, segmentationJson?: string) => Promise + >('interlinearizer.saveAnalysis'); /** Activates the extension and returns the `interlinearizer.getDraft` handler. */ const getGetDraftHandler = () => @@ -910,7 +910,13 @@ describe('main', () => { await handler('proj-id', JSON.stringify(stubAnalysis)); - expect(mockUpdateAnalysis).toHaveBeenCalledWith(expect.anything(), 'proj-id', stubAnalysis); + // No segmentationJson passed ⇒ the 4th arg is undefined (leave stored boundaries unchanged). + expect(mockUpdateAnalysis).toHaveBeenCalledWith( + expect.anything(), + 'proj-id', + stubAnalysis, + undefined, + ); }); it('logs the error, sends an error notification, and rethrows when storage throws', async () => { @@ -956,6 +962,46 @@ describe('main', () => { ); expect(mockUpdateAnalysis).not.toHaveBeenCalled(); }); + + it('passes a parsed segmentation delta through to updateAnalysis', async () => { + mockUpdateAnalysis.mockResolvedValue(undefined); + const handler = await getSaveAnalysisHandler(); + const segmentation = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + + await handler('proj-id', JSON.stringify(stubAnalysis), JSON.stringify(segmentation)); + + expect(mockUpdateAnalysis).toHaveBeenCalledWith( + expect.anything(), + 'proj-id', + stubAnalysis, + segmentation, + ); + }); + + it('passes null through to updateAnalysis to clear boundaries when segmentationJson is "null"', async () => { + mockUpdateAnalysis.mockResolvedValue(undefined); + const handler = await getSaveAnalysisHandler(); + + await handler('proj-id', JSON.stringify(stubAnalysis), 'null'); + + // eslint-disable-next-line no-null/no-null -- asserting the clear-boundaries sentinel is forwarded + expect(mockUpdateAnalysis).toHaveBeenCalledWith( + expect.anything(), + 'proj-id', + stubAnalysis, + // eslint-disable-next-line no-null/no-null -- asserting the clear-boundaries sentinel is forwarded + null, + ); + }); + + it('rethrows when segmentationJson does not conform to SegmentationDelta', async () => { + const handler = await getSaveAnalysisHandler(); + + await expect( + handler('proj-id', JSON.stringify(stubAnalysis), JSON.stringify({ bogus: true })), + ).rejects.toThrow(TypeError); + expect(mockUpdateAnalysis).not.toHaveBeenCalled(); + }); }); describe('interlinearizer.getDraft command', () => { diff --git a/src/__tests__/parsers/papi/resegmentBook.test.ts b/src/__tests__/parsers/papi/resegmentBook.test.ts new file mode 100644 index 00000000..76ade277 --- /dev/null +++ b/src/__tests__/parsers/papi/resegmentBook.test.ts @@ -0,0 +1,103 @@ +/** @file Unit tests for {@link resegmentBook}. */ +/// + +import type { Book } from 'interlinearizer'; +import { tokenizeBook } from 'parsers/papi/bookTokenizer'; +import { resegmentBook } from 'parsers/papi/resegmentBook'; + +/** + * Builds a verse-tokenized GEN book from the given verses. + * + * @param verses - Verse SID + text pairs. + * @returns The tokenized book. + */ +function makeBook(verses: { sid: string; text: string }[]): Book { + return tokenizeBook({ bookCode: 'GEN', writingSystem: 'en', contentHash: 'h', verses }); +} + +const BOOK = makeBook([ + { sid: 'GEN 1:1', text: 'Alpha beta.' }, + { sid: 'GEN 1:2', text: 'Gamma delta.' }, + { sid: 'GEN 1:3', text: 'Epsilon.' }, +]); + +/** Asserts the segment-baseline/offset invariant for every token of every segment. */ +function expectInvariant(book: Book): void { + book.segments.forEach((seg) => { + seg.tokens.forEach((t) => { + expect(seg.baselineText.slice(t.charStart, t.charEnd)).toBe(t.surfaceText); + }); + }); +} + +describe('resegmentBook', () => { + it('returns the same book reference for an undefined delta', () => { + expect(resegmentBook(BOOK, undefined)).toBe(BOOK); + }); + + it('returns the same book reference for an empty delta', () => { + expect(resegmentBook(BOOK, { removedVerseStarts: [], addedStarts: [] })).toBe(BOOK); + }); + + it('reuses untouched verse Segment objects by reference when a delta is active elsewhere', () => { + // Merge verses 1+2; verse 3 is untouched and should be the same object. + const result = resegmentBook(BOOK, { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); + expect(result.segments[1]).toBe(BOOK.segments[2]); + }); + + it('merges two verses into one segment with concatenated baseline and shifted offsets', () => { + const result = resegmentBook(BOOK, { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); + expect(result.segments).toHaveLength(2); + const merged = result.segments[0]; + expect(merged.baselineText).toBe('Alpha beta. Gamma delta.'); + // Token refs are preserved unchanged across the merge. + expect(merged.tokens.map((t) => t.ref)).toEqual([ + 'GEN 1:1:0', + 'GEN 1:1:6', + 'GEN 1:1:10', + 'GEN 1:2:0', + 'GEN 1:2:6', + 'GEN 1:2:11', + ]); + expectInvariant(result); + }); + + it('keeps the leading verse SID as the merged segment id', () => { + const result = resegmentBook(BOOK, { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); + expect(result.segments[0].id).toBe('GEN 1:1'); + }); + + it('spans the merged range in startRef/endRef', () => { + const result = resegmentBook(BOOK, { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); + expect(result.segments[0].startRef).toEqual({ book: 'GEN', chapter: 1, verse: 1 }); + expect(result.segments[0].endRef).toEqual({ book: 'GEN', chapter: 1, verse: 2 }); + }); + + it('splits a verse before a mid-verse token', () => { + // Split verse 1 before "beta" (charStart 6). + const result = resegmentBook(BOOK, { removedVerseStarts: [], addedStarts: ['GEN 1:1:6'] }); + expect(result.segments).toHaveLength(4); + const [firstHalf, secondHalf] = result.segments; + expect(firstHalf.id).toBe('GEN 1:1'); + expect(firstHalf.tokens.map((t) => t.ref)).toEqual(['GEN 1:1:0']); + // The second half begins mid-verse, so it takes its first token's ref as a fresh id. + expect(secondHalf.id).toBe('GEN 1:1:6'); + expect(secondHalf.tokens.map((t) => t.ref)).toEqual(['GEN 1:1:6', 'GEN 1:1:10']); + expectInvariant(result); + }); + + it('carries a sub-verse charIndex on a split piece that begins mid-verse', () => { + const result = resegmentBook(BOOK, { removedVerseStarts: [], addedStarts: ['GEN 1:1:6'] }); + expect(result.segments[1].startRef).toEqual({ + book: 'GEN', + chapter: 1, + verse: 1, + charIndex: 6, + }); + }); + + it('ignores a drifted (nonexistent) anchor and yields the default grouping', () => { + const result = resegmentBook(BOOK, { removedVerseStarts: ['GEN 9:9:9'], addedStarts: [] }); + expect(result.segments.map((s) => s.id)).toEqual(['GEN 1:1', 'GEN 1:2', 'GEN 1:3']); + }); +}); diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index 713a611c..98e537e5 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -454,6 +454,45 @@ describe('projectStorage', () => { await expect(updateAnalysis(token, 'proj-id', newAnalysis)).rejects.toThrow('disk full'); }); + + it('writes a provided segmentation delta onto the project', async () => { + __mockReadUserData.mockResolvedValue(JSON.stringify(storedProject)); + const segmentation = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + + const result = await updateAnalysis(token, 'proj-id', newAnalysis, segmentation); + + expect(result).toMatchObject({ analysis: newAnalysis, segmentation }); + expect(__mockWriteUserData).toHaveBeenCalledWith( + token, + 'project:proj-id', + JSON.stringify({ ...storedProject, analysis: newAnalysis, segmentation }), + ); + }); + + it('clears stored boundaries when segmentation is null', async () => { + const projectWithBoundaries = { + ...storedProject, + segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, + }; + __mockReadUserData.mockResolvedValue(JSON.stringify(projectWithBoundaries)); + + // eslint-disable-next-line no-null/no-null -- explicit "clear boundaries" sentinel under test + const result = await updateAnalysis(token, 'proj-id', newAnalysis, null); + + expect(result && 'segmentation' in result).toBe(false); + }); + + it('leaves existing boundaries unchanged when segmentation is undefined', async () => { + const projectWithBoundaries = { + ...storedProject, + segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, + }; + __mockReadUserData.mockResolvedValue(JSON.stringify(projectWithBoundaries)); + + const result = await updateAnalysis(token, 'proj-id', newAnalysis); + + expect(result).toMatchObject({ segmentation: projectWithBoundaries.segmentation }); + }); }); describe('getProjectsForSource', () => { diff --git a/src/__tests__/utils/segment-labels.test.ts b/src/__tests__/utils/segment-labels.test.ts new file mode 100644 index 00000000..2eec2a84 --- /dev/null +++ b/src/__tests__/utils/segment-labels.test.ts @@ -0,0 +1,133 @@ +/** @file Unit tests for utils/segment-labels.ts. */ +/// + +import type { Segment } from 'interlinearizer'; +import { buildSegmentLabels } from '../../utils/segment-labels'; + +/** + * Builds a minimal token-less {@link Segment} spanning the given verse coordinates. Label derivation + * only reads `id`, `startRef`, and `endRef`, so tokens and baseline text stay empty. + * + * @param id - The segment id. + * @param startChapter - Chapter of the segment's start. + * @param startVerse - Verse of the segment's start. + * @param endChapter - Chapter of the segment's end; defaults to `startChapter`. + * @param endVerse - Verse of the segment's end; defaults to `startVerse`. + * @returns The assembled segment. + */ +function makeSegment( + id: string, + startChapter: number, + startVerse: number, + endChapter: number = startChapter, + endVerse: number = startVerse, +): Segment { + return { + id, + startRef: { book: 'GEN', chapter: startChapter, verse: startVerse }, + endRef: { book: 'GEN', chapter: endChapter, verse: endVerse }, + baselineText: '', + tokens: [], + }; +} + +describe('buildSegmentLabels', () => { + it('labels default verse segments with their bare verse numbers', () => { + const labels = buildSegmentLabels([ + makeSegment('s1', 1, 1), + makeSegment('s2', 1, 2), + makeSegment('s3', 1, 3), + ]); + + expect(labels.get('s1')).toBe('1'); + expect(labels.get('s2')).toBe('2'); + expect(labels.get('s3')).toBe('3'); + }); + + it('labels a verse-0 superscription segment as 0', () => { + const labels = buildSegmentLabels([makeSegment('s0', 1, 0), makeSegment('s1', 1, 1)]); + + expect(labels.get('s0')).toBe('0'); + expect(labels.get('s1')).toBe('1'); + }); + + it('shows a merged segment as the contained verse range', () => { + const labels = buildSegmentLabels([ + makeSegment('s1', 1, 1), + makeSegment('merged', 1, 2, 1, 3), + makeSegment('s4', 1, 4), + ]); + + expect(labels.get('merged')).toBe('2–3'); + // Neighboring whole-verse segments keep their bare numbers. + expect(labels.get('s4')).toBe('4'); + }); + + it('letters the portions of a split verse in document order', () => { + const labels = buildSegmentLabels([ + makeSegment('s1', 1, 1), + makeSegment('split-a', 1, 2), + makeSegment('split-b', 1, 2), + makeSegment('s3', 1, 3), + ]); + + expect(labels.get('split-a')).toBe('2a'); + expect(labels.get('split-b')).toBe('2b'); + // The verse after the split is unaffected. + expect(labels.get('s3')).toBe('3'); + }); + + it('letters only the split end of a range when a split portion is merged with following verses', () => { + // Split verse 1 into three portions, the third merged with verse 2: 1a, 1b, 1c–2, 3. + const labels = buildSegmentLabels([ + makeSegment('p1', 1, 1), + makeSegment('p2', 1, 1), + makeSegment('p3', 1, 1, 1, 2), + makeSegment('s3', 1, 3), + ]); + + expect(labels.get('p1')).toBe('1a'); + expect(labels.get('p2')).toBe('1b'); + expect(labels.get('p3')).toBe('1c–2'); + expect(labels.get('s3')).toBe('3'); + }); + + it('letters a range end when the segment ends mid-verse', () => { + // Verse 1 merged with the first portion of a split verse 2: 1–2a, then 2b. + const labels = buildSegmentLabels([makeSegment('head', 1, 1, 1, 2), makeSegment('tail', 1, 2)]); + + expect(labels.get('head')).toBe('1–2a'); + expect(labels.get('tail')).toBe('2b'); + }); + + it('qualifies the range end with its chapter when a segment crosses a chapter boundary', () => { + const labels = buildSegmentLabels([ + makeSegment('c1s1', 1, 1), + makeSegment('crossing', 1, 2, 2, 1), + makeSegment('c2s2', 2, 2), + ]); + + expect(labels.get('crossing')).toBe('2–2:1'); + expect(labels.get('c2s2')).toBe('2'); + }); + + it('letters a chapter-crossing end when its verse is split', () => { + const labels = buildSegmentLabels([ + makeSegment('crossing', 1, 29, 2, 1), + makeSegment('tail', 2, 1), + ]); + + expect(labels.get('crossing')).toBe('29–2:1a'); + expect(labels.get('tail')).toBe('1b'); + }); + + it('continues lettering bijectively past z for a verse split into more than 26 portions', () => { + const portions = Array.from({ length: 28 }, (_, i) => makeSegment(`p${i}`, 1, 1)); + const labels = buildSegmentLabels(portions); + + expect(labels.get('p0')).toBe('1a'); + expect(labels.get('p25')).toBe('1z'); + expect(labels.get('p26')).toBe('1aa'); + expect(labels.get('p27')).toBe('1ab'); + }); +}); diff --git a/src/__tests__/utils/segmentation.test.ts b/src/__tests__/utils/segmentation.test.ts new file mode 100644 index 00000000..96141ea8 --- /dev/null +++ b/src/__tests__/utils/segmentation.test.ts @@ -0,0 +1,257 @@ +/** @file Unit tests for the pure segmentation-delta transforms. */ +/// + +import type { Book, SegmentationDelta } from 'interlinearizer'; +import { tokenizeBook } from 'parsers/papi/bookTokenizer'; +import { + addBoundaryBefore, + defaultVerseStarts, + effectiveStarts, + isDefaultSegmentation, + mergeSegments, + moveBoundary, + removeBoundaryAt, + splitSegmentBefore, +} from '../../utils/segmentation'; + +/** + * Builds a verse-tokenized GEN book from the given verses for use as the `verseBook` argument. + * + * @param verses - Verse SID + text pairs. + * @returns The tokenized book. + */ +function makeBook(verses: { sid: string; text: string }[]): Book { + return tokenizeBook({ bookCode: 'GEN', writingSystem: 'en', contentHash: 'h', verses }); +} + +/** A three-verse fixture: "Alpha beta." / "Gamma delta." / "Epsilon." */ +const THREE_VERSES = makeBook([ + { sid: 'GEN 1:1', text: 'Alpha beta.' }, + { sid: 'GEN 1:2', text: 'Gamma delta.' }, + { sid: 'GEN 1:3', text: 'Epsilon.' }, +]); + +// First token refs of each verse (charStart 0): "GEN 1:1:0", "GEN 1:2:0", "GEN 1:3:0". +const V1_START = 'GEN 1:1:0'; +const V2_START = 'GEN 1:2:0'; +const V3_START = 'GEN 1:3:0'; +// Second word of verse 1 ("beta" at charStart 6). +const V1_BETA = 'GEN 1:1:6'; + +/** + * A fixture with a mid-book verse-0 superscription: GEN 1:1, then GEN 2:0 (the superscription), + * then GEN 2:1. Pins the decision that verse 0 is an ordinary segment for boundary editing — no + * guard makes its boundaries special (only the always-present book-first lock applies). + */ +const MID_VERSE_ZERO = makeBook([ + { sid: 'GEN 1:1', text: 'Alpha beta.' }, + { sid: 'GEN 2:0', text: 'Sup tee.' }, + { sid: 'GEN 2:1', text: 'Gamma.' }, +]); +// Verse-0 start, an interior verse-0 word ("tee" at charStart 4), and the start of the verse right +// after verse 0. +const VZ0_START = 'GEN 2:0:0'; +const VZ0_INTERIOR = 'GEN 2:0:4'; +const VZ_NEXT_START = 'GEN 2:1:0'; + +describe('defaultVerseStarts', () => { + it('returns the first-token ref of every verse', () => { + expect(defaultVerseStarts(THREE_VERSES)).toEqual(new Set([V1_START, V2_START, V3_START])); + }); + + it('skips verses with no tokens', () => { + const book = makeBook([ + { sid: 'GEN 1:1', text: ' ' }, + { sid: 'GEN 1:2', text: 'Word.' }, + ]); + expect(defaultVerseStarts(book)).toEqual(new Set(['GEN 1:2:0'])); + }); +}); + +describe('isDefaultSegmentation', () => { + it('is true for undefined', () => { + expect(isDefaultSegmentation(undefined)).toBe(true); + }); + + it('is true for empty arrays', () => { + expect(isDefaultSegmentation({ removedVerseStarts: [], addedStarts: [] })).toBe(true); + }); + + it('is false when a boundary is removed', () => { + expect(isDefaultSegmentation({ removedVerseStarts: [V2_START], addedStarts: [] })).toBe(false); + }); + + it('is false when a boundary is added', () => { + expect(isDefaultSegmentation({ removedVerseStarts: [], addedStarts: [V1_BETA] })).toBe(false); + }); +}); + +describe('effectiveStarts', () => { + it('returns all default verse starts for the default segmentation', () => { + expect(effectiveStarts(THREE_VERSES, undefined)).toEqual( + new Set([V1_START, V2_START, V3_START]), + ); + }); + + it('drops a removed verse start (merge)', () => { + const starts = effectiveStarts(THREE_VERSES, { + removedVerseStarts: [V2_START], + addedStarts: [], + }); + expect(starts).toEqual(new Set([V1_START, V3_START])); + }); + + it('adds a split start', () => { + const starts = effectiveStarts(THREE_VERSES, { + removedVerseStarts: [], + addedStarts: [V1_BETA], + }); + expect(starts).toEqual(new Set([V1_START, V1_BETA, V2_START, V3_START])); + }); + + it('ignores an added start whose token no longer exists (drift)', () => { + const starts = effectiveStarts(THREE_VERSES, { + removedVerseStarts: [], + addedStarts: ['GEN 9:9:9'], + }); + expect(starts).toEqual(new Set([V1_START, V2_START, V3_START])); + }); + + it('always keeps the book-first token as a start even if asked to remove it', () => { + const starts = effectiveStarts(THREE_VERSES, { + removedVerseStarts: [V1_START], + addedStarts: [], + }); + expect(starts.has(V1_START)).toBe(true); + }); +}); + +describe('addBoundaryBefore', () => { + it('records a mid-verse split as an added start', () => { + expect(addBoundaryBefore(THREE_VERSES, undefined, V1_BETA)).toEqual({ + removedVerseStarts: [], + addedStarts: [V1_BETA], + }); + }); + + it('un-merges a default verse start by dropping it from removedVerseStarts', () => { + const merged: SegmentationDelta = { removedVerseStarts: [V2_START], addedStarts: [] }; + expect(addBoundaryBefore(THREE_VERSES, merged, V2_START)).toEqual({ + removedVerseStarts: [], + addedStarts: [], + }); + }); + + it('is idempotent on an already-added start', () => { + const once = addBoundaryBefore(THREE_VERSES, undefined, V1_BETA); + expect(addBoundaryBefore(THREE_VERSES, once, V1_BETA)).toEqual(once); + }); + + it('splits inside a verse-0 superscription like any other verse', () => { + expect(addBoundaryBefore(MID_VERSE_ZERO, undefined, VZ0_INTERIOR)).toEqual({ + removedVerseStarts: [], + addedStarts: [VZ0_INTERIOR], + }); + }); +}); + +describe('removeBoundaryAt', () => { + it('records a default verse start as removed (merge)', () => { + expect(removeBoundaryAt(THREE_VERSES, undefined, V2_START)).toEqual({ + removedVerseStarts: [V2_START], + addedStarts: [], + }); + }); + + it('drops an added split rather than recording a removal', () => { + const split: SegmentationDelta = { removedVerseStarts: [], addedStarts: [V1_BETA] }; + expect(removeBoundaryAt(THREE_VERSES, split, V1_BETA)).toEqual({ + removedVerseStarts: [], + addedStarts: [], + }); + }); + + it('is a no-op for the book-first token', () => { + expect(removeBoundaryAt(THREE_VERSES, undefined, V1_START)).toEqual({ + removedVerseStarts: [], + addedStarts: [], + }); + }); + + it('merges a verse-0 superscription into the previous segment like any verse start', () => { + expect(removeBoundaryAt(MID_VERSE_ZERO, undefined, VZ0_START)).toEqual({ + removedVerseStarts: [VZ0_START], + addedStarts: [], + }); + }); + + it('merges the verse after a verse-0 superscription into it like any verse start', () => { + expect(removeBoundaryAt(MID_VERSE_ZERO, undefined, VZ_NEXT_START)).toEqual({ + removedVerseStarts: [VZ_NEXT_START], + addedStarts: [], + }); + }); +}); + +describe('moveBoundary', () => { + it('removes the old start and adds the new one', () => { + expect(moveBoundary(THREE_VERSES, undefined, V2_START, V1_BETA)).toEqual({ + removedVerseStarts: [V2_START], + addedStarts: [V1_BETA], + }); + }); + + it('moves a boundary across a verse-0 superscription like any other segment', () => { + expect(moveBoundary(MID_VERSE_ZERO, undefined, VZ_NEXT_START, VZ0_INTERIOR)).toEqual({ + removedVerseStarts: [VZ_NEXT_START], + addedStarts: [VZ0_INTERIOR], + }); + }); +}); + +describe('mergeSegments / splitSegmentBefore aliases', () => { + it('mergeSegments removes the second segment start', () => { + expect(mergeSegments(THREE_VERSES, undefined, V2_START)).toEqual( + removeBoundaryAt(THREE_VERSES, undefined, V2_START), + ); + }); + + it('splitSegmentBefore adds a start', () => { + expect(splitSegmentBefore(THREE_VERSES, undefined, V1_BETA)).toEqual( + addBoundaryBefore(THREE_VERSES, undefined, V1_BETA), + ); + }); +}); + +describe('normalization', () => { + it('dedupes and sorts removed/added arrays by document order', () => { + const messy: SegmentationDelta = { + removedVerseStarts: [V3_START, V2_START, V2_START], + addedStarts: [], + }; + // Re-adding V1_BETA twice plus the messy removals exercises dedupe + sort. + const result = addBoundaryBefore( + THREE_VERSES, + addBoundaryBefore(THREE_VERSES, messy, V1_BETA), + V1_BETA, + ); + expect(result).toEqual({ removedVerseStarts: [V2_START, V3_START], addedStarts: [V1_BETA] }); + }); + + it('strips a removed ref that is not a default verse start', () => { + const bogus: SegmentationDelta = { removedVerseStarts: [V1_BETA], addedStarts: [] }; + // V1_BETA is mid-verse, not a default start, so it is not a valid removal. + expect(removeBoundaryAt(THREE_VERSES, bogus, V3_START)).toEqual({ + removedVerseStarts: [V3_START], + addedStarts: [], + }); + }); + + it('strips an added ref that is actually a default verse start', () => { + const bogus: SegmentationDelta = { removedVerseStarts: [], addedStarts: [V2_START] }; + expect(addBoundaryBefore(THREE_VERSES, bogus, V1_BETA)).toEqual({ + removedVerseStarts: [], + addedStarts: [V1_BETA], + }); + }); +}); diff --git a/src/__tests__/utils/token-layout.test.ts b/src/__tests__/utils/token-layout.test.ts index 318f37f4..64fe0f76 100644 --- a/src/__tests__/utils/token-layout.test.ts +++ b/src/__tests__/utils/token-layout.test.ts @@ -148,6 +148,53 @@ describe('resolveSlotFocus', () => { expect(result.focusedPhraseLink).toBe(link); expect(result.focusedFreeToken).toBe(freeToken); }); + + /** Segment document order for the adjacent-edge tests: seg-0 < seg-1 < seg-2. */ + const order = new Map([ + ['seg-0', 0], + ['seg-1', 1], + ['seg-2', 2], + ]); + + it('marks isAdjacentEdgeOfFocus true when the focused segment borders the next one', () => { + const result = resolveSlotFocus('seg-1', 'seg-2', focusWithSegment('seg-1'), true, order); + expect(result.isAdjacentEdgeOfFocus).toBe(true); + }); + + it('marks isAdjacentEdgeOfFocus true when the focused segment borders the previous one', () => { + const result = resolveSlotFocus('seg-0', 'seg-1', focusWithSegment('seg-1'), false, order); + expect(result.isAdjacentEdgeOfFocus).toBe(true); + }); + + it('marks isAdjacentEdgeOfFocus false within one segment', () => { + const result = resolveSlotFocus('seg-1', 'seg-1', focusWithSegment('seg-1'), true, order); + expect(result.isAdjacentEdgeOfFocus).toBe(false); + }); + + it('marks isAdjacentEdgeOfFocus false when nothing is focused', () => { + const result = resolveSlotFocus('seg-1', 'seg-2', focusWithSegment(undefined), true, order); + expect(result.isAdjacentEdgeOfFocus).toBe(false); + }); + + it('marks isAdjacentEdgeOfFocus false when neither neighbor is the focused segment', () => { + const result = resolveSlotFocus('seg-0', 'seg-2', focusWithSegment('seg-1'), true, order); + expect(result.isAdjacentEdgeOfFocus).toBe(false); + }); + + it('marks isAdjacentEdgeOfFocus false when the two segments are not adjacent', () => { + const result = resolveSlotFocus('seg-0', 'seg-2', focusWithSegment('seg-0'), true, order); + expect(result.isAdjacentEdgeOfFocus).toBe(false); + }); + + it('marks isAdjacentEdgeOfFocus false when a leading slot has no previous segment', () => { + const result = resolveSlotFocus(undefined, 'seg-1', focusWithSegment('seg-1'), false, order); + expect(result.isAdjacentEdgeOfFocus).toBe(false); + }); + + it('marks isAdjacentEdgeOfFocus false when segment order is unknown (default empty map)', () => { + const result = resolveSlotFocus('seg-1', 'seg-2', focusWithSegment('seg-1'), true); + expect(result.isAdjacentEdgeOfFocus).toBe(false); + }); }); // --------------------------------------------------------------------------- @@ -159,6 +206,7 @@ describe('NO_SLOT_FOCUS', () => { expect(NO_SLOT_FOCUS).toEqual({ focusedSideIsPrev: undefined, isSameSegmentAsFocus: false, + isAdjacentEdgeOfFocus: false, focusedPhraseLink: undefined, focusedFreeToken: undefined, }); diff --git a/src/__tests__/utils/verse-ref.test.ts b/src/__tests__/utils/verse-ref.test.ts new file mode 100644 index 00000000..30bef88d --- /dev/null +++ b/src/__tests__/utils/verse-ref.test.ts @@ -0,0 +1,85 @@ +/** @file Unit tests for utils/verse-ref.ts. */ +/// + +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import type { Segment } from 'interlinearizer'; +import { segmentContainsVerse } from '../../utils/verse-ref'; + +/** + * Builds a minimal token-less {@link Segment} spanning the given verse coordinates. Containment only + * reads `startRef` and `endRef`, so tokens and baseline text stay empty. + * + * @param startChapter - Chapter of the segment's start. + * @param startVerse - Verse of the segment's start. + * @param endChapter - Chapter of the segment's end; defaults to `startChapter`. + * @param endVerse - Verse of the segment's end; defaults to `startVerse`. + * @returns The assembled segment. + */ +function makeSegment( + startChapter: number, + startVerse: number, + endChapter: number = startChapter, + endVerse: number = startVerse, +): Segment { + return { + id: 'seg', + startRef: { book: 'GEN', chapter: startChapter, verse: startVerse }, + endRef: { book: 'GEN', chapter: endChapter, verse: endVerse }, + baselineText: '', + tokens: [], + }; +} + +/** + * Builds a `SerializedVerseRef` in the test book. + * + * @param chapterNum - The chapter number. + * @param verseNum - The verse number. + * @param book - The book id; defaults to the segments' book. + * @returns The assembled reference. + */ +function makeRef(chapterNum: number, verseNum: number, book: string = 'GEN'): SerializedVerseRef { + return { book, chapterNum, verseNum }; +} + +describe('segmentContainsVerse', () => { + it('contains the verse a single-verse segment names', () => { + expect(segmentContainsVerse(makeSegment(1, 2), makeRef(1, 2))).toBe(true); + }); + + it('does not contain a different verse of the same chapter', () => { + expect(segmentContainsVerse(makeSegment(1, 2), makeRef(1, 3))).toBe(false); + }); + + it('contains an interior verse of a merged multi-verse segment', () => { + expect(segmentContainsVerse(makeSegment(1, 1, 1, 3), makeRef(1, 2))).toBe(true); + }); + + it('contains both end verses of a multi-verse segment inclusively', () => { + const seg = makeSegment(1, 2, 1, 4); + expect(segmentContainsVerse(seg, makeRef(1, 2))).toBe(true); + expect(segmentContainsVerse(seg, makeRef(1, 4))).toBe(true); + }); + + it('does not contain verses outside the range', () => { + const seg = makeSegment(1, 2, 1, 4); + expect(segmentContainsVerse(seg, makeRef(1, 1))).toBe(false); + expect(segmentContainsVerse(seg, makeRef(1, 5))).toBe(false); + }); + + it('contains verses across a chapter-crossing range', () => { + const seg = makeSegment(1, 29, 2, 2); + expect(segmentContainsVerse(seg, makeRef(1, 30))).toBe(true); + expect(segmentContainsVerse(seg, makeRef(2, 1))).toBe(true); + }); + + it('does not contain verses beyond a chapter-crossing range', () => { + const seg = makeSegment(1, 29, 2, 2); + expect(segmentContainsVerse(seg, makeRef(1, 28))).toBe(false); + expect(segmentContainsVerse(seg, makeRef(2, 3))).toBe(false); + }); + + it('never contains a verse from another book', () => { + expect(segmentContainsVerse(makeSegment(1, 1, 1, 3), makeRef(1, 2, 'EXO'))).toBe(false); + }); +}); diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx index dfa4239e..4fb3b04e 100644 --- a/src/components/AnalysisStore.tsx +++ b/src/components/AnalysisStore.tsx @@ -544,6 +544,22 @@ export function usePhraseLinkByIdMap(): Map { return useSelector((state: AnalysisRootState) => selectPhraseLinkByAnalysisId(state.analysis)); } +/** + * Returns a stable getter for the current phrase-link-by-id map, for event-time reads that must not + * subscribe the caller to store updates — e.g. the segmentation dispatch's force-break, which reads + * the phrases only when a boundary edit fires and would otherwise re-render the whole view tree on + * every phrase change. + * + * @returns A stable function returning the phrase-link-by-id map at call time. + * @throws When called outside an {@link AnalysisStoreProvider}. + */ +export function usePhraseLinkByIdGetter(): () => Map { + useRequiredCallbacks('usePhraseLinkByIdGetter'); + const store = useStore(); + + return useCallback(() => selectPhraseLinkByAnalysisId(store.getState().analysis), [store]); +} + /** * Returns the approved `PhraseAnalysisLink` whose token list contains `tokenRef`, or `undefined` * when the token is not part of any phrase. Re-renders only when the phrase membership of this diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index a230e212..8f1a1b9a 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -298,6 +298,28 @@ export default function ContinuousView({ }); }, []); + /** + * Holds the group at `groupIndex` centered by re-centering it each animation frame for + * {@link LINK_SLOT_TRANSITION_MS} — the shared clock of the link-slot fade, whose async layout + * settling (arcs, morpheme rows) is what the hold outlasts. Used after an instant jump's reveal + * and after the committed-active-segment flip, so both holds run one loop implementation on one + * clock. + * + * @param groupIndex - Index of the group to keep centered. + * @returns A cancel function that stops the loop; call it from the owning effect's cleanup. + */ + const holdCentered = useCallback( + (groupIndex: number) => { + const deadline = performance.now() + LINK_SLOT_TRANSITION_MS; + let rafId = requestAnimationFrame(function holdFrame() { + centerGroup(groupIndex, 'auto'); + if (performance.now() < deadline) rafId = requestAnimationFrame(holdFrame); + }); + return () => cancelAnimationFrame(rafId); + }, + [centerGroup], + ); + /** Ref to the token-strip row; the content row and mouse-leave target. */ // eslint-disable-next-line no-null/no-null const stripRowRef = useRef(null); @@ -338,6 +360,28 @@ export default function ContinuousView({ setCommittedActiveSegmentId(targetActiveSegmentIdRef.current); }, [targetActiveSegmentIdRef]); + /** + * `focusedTokenRef` last seen by the segmentation-reconcile effect below, so it can distinguish + * "the focused token's segment id changed because the segmentation changed" (commit immediately) + * from "focus moved" (the focus-change machinery owns the commit timing). + */ + const prevFocusForSegmentationRef = useRef(focusedTokenRef); + + // Reconcile the committed active segment when a segmentation edit (merge/split) changes the + // focused token's segment id without moving focus. Token refs survive re-segmentation, so no + // focus-change effect fires for such an edit; without this the committed id would keep naming a + // segment that no longer exists, deactivating every link button until the next navigation. Only + // fires while focus is unchanged — a focus move commits through its own paths (deferred to the + // scroll settle for internal nav, behind the fade for external jumps), which this must not + // preempt. A real commit also flips the active-segment recenter effect below, re-pinning the + // focused group against the edit's relayout. + useEffect(() => { + const focusUnchanged = prevFocusForSegmentationRef.current === focusedTokenRef; + prevFocusForSegmentationRef.current = focusedTokenRef; + if (!focusUnchanged) return; + commitPendingActiveSegment(); + }, [tokenSegmentMap, focusedTokenRef, commitPendingActiveSegment]); + /** Ref mirror of `onFocusedTokenRefChange` so callbacks never need it as a dep. */ const onFocusedTokenRefChangeRef = useLatestRef(onFocusedTokenRefChange); @@ -537,36 +581,39 @@ export default function ContinuousView({ // The snapped-slot paint has happened; re-enable the transition for later in-view toggles. setSkipSlotTransitionForJump(false); }); + // Hold the group centered through the reveal. The window slide mounts/unmounts groups whose + // arcs and morpheme rows finish laying out asynchronously over the next frames, shifting the + // strip after the instant snap above. The committed-active-segment layout effect re-centers + // against that drift, but only when the segment id actually flips — a jump landing in the + // already-active segment (a token click in the active verse) gets no correction there and was + // revealed off-center. Hold on the shared clock so every instant jump stays pinned regardless + // of whether the active segment changed. + const cancelHold = holdCentered(focusPhraseIndex); return () => { cancelAnimationFrame(rafId); + cancelHold(); setIsVisible(true); }; - }, [focusPhraseIndex, commitPendingActiveSegment, centerGroup]); + }, [focusPhraseIndex, commitPendingActiveSegment, centerGroup, holdCentered]); // Keep the focused group pinned dead-center after the deferred active-segment flip. When // `committedActiveSegmentId` flips (after an internal-nav scroll settles), inactive link icons // fade in/out over `LINK_SLOT_TRANSITION_MS`. Because they are hidden via `opacity: 0` their // layout space is preserved, so boxes do not shift — but any residual sub-pixel drift from the - // preceding smooth scroll is corrected by re-centering once before paint. The rAF loop holds the - // group centered for the full fade duration as a conservative guard against any future layout - // changes that could re-introduce drift. The first run is skipped because the initial center is - // established by the scroll effect's instant jump. A `useLayoutEffect` seeds the loop so the very - // first re-center lands before paint (no initial flash), then `rAF` carries it through the fade. + // preceding smooth scroll is corrected by re-centering once before paint. The shared hold loop + // keeps the group centered for the full fade duration as a conservative guard against any future + // layout changes that could re-introduce drift. The first run is skipped because the initial + // center is established by the scroll effect's instant jump. A `useLayoutEffect` seeds the loop + // with a synchronous re-center so the very first correction lands before paint (no initial + // flash), then the hold's `rAF` carries it through the fade. const skipActiveSegmentRecenterRef = useRef(true); useLayoutEffect(() => { if (skipActiveSegmentRecenterRef.current) { skipActiveSegmentRecenterRef.current = false; return undefined; } - /** Re-centers the focused group; called synchronously now and each `rAF` until the deadline. */ - const recenter = () => centerGroup(focusPhraseIndex, 'auto'); - recenter(); - const deadline = performance.now() + LINK_SLOT_TRANSITION_MS; - let rafId = requestAnimationFrame(function recenterFrame() { - recenter(); - if (performance.now() < deadline) rafId = requestAnimationFrame(recenterFrame); - }); - return () => cancelAnimationFrame(rafId); + centerGroup(focusPhraseIndex, 'auto'); + return holdCentered(focusPhraseIndex); // Only the active-segment flip should trigger this re-anchor; focusPhraseIndex has its own scroll // effect. Reading it here is a snapshot, not a trigger. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/components/Interlinearizer.tsx b/src/components/Interlinearizer.tsx index 5bf1c6b7..105e9233 100644 --- a/src/components/Interlinearizer.tsx +++ b/src/components/Interlinearizer.tsx @@ -2,7 +2,18 @@ import type { SerializedVerseRef } from '@sillsdev/scripture'; import type { Book, ScriptureRef, Segment, TextAnalysis } from 'interlinearizer'; import { useCallback, useEffect, useMemo, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; -import { AnalysisStoreProvider, usePhraseDispatch } from './AnalysisStore'; +import { + AnalysisStoreProvider, + usePhraseDispatch, + usePhraseLinkByIdGetter, + usePhraseLinkByIdMap, +} from './AnalysisStore'; +import { + NO_OP_SEGMENTATION_DISPATCH, + SegmentationProvider, + type SegmentationContextValue, + type SegmentationDispatch, +} from './SegmentationStore'; import ContinuousView from './ContinuousView'; import EditPhraseControls from './controls/EditPhraseControls'; import useBookIndexes from '../hooks/useBookIndexes'; @@ -10,7 +21,8 @@ import useLatestRef from '../hooks/useLatestRef'; import type { PhraseMode } from '../types/phrase-mode'; import type { ViewOptions } from '../types/view-options'; import { isWordToken } from '../types/type-guards'; -import { isSameVerse, toSerializedVerseRef } from '../utils/verse-ref'; +import { phrasesStraddlingBoundary, splitPhraseAtBoundary } from '../utils/phrase-arc'; +import { isSameVerse, segmentContainsVerse, toSerializedVerseRef } from '../utils/verse-ref'; import SegmentListView from './SegmentListView'; import UnlinkPhraseConfirm from './modals/UnlinkPhraseConfirm'; import { useInterlinearNav } from './InterlinearNavContext'; @@ -28,6 +40,9 @@ function firstWordTokenRefOf(segment: Segment | undefined): string | undefined { return segment?.tokens.find(isWordToken)?.ref; } +/** Stable empty map used as the `formerBoundaries` default so memoization isn't broken. */ +const EMPTY_FORMER_BOUNDARIES: ReadonlyMap = new Map(); + /** Props for {@link Interlinearizer}. */ type InterlinearizerProps = Readonly<{ /** Tokenized book whose segments are rendered. */ @@ -35,14 +50,15 @@ type InterlinearizerProps = Readonly<{ /** When true, the horizontal token strip is shown above the segment list. */ continuousScroll: boolean; /** - * Current scripture reference used to highlight the active verse. Always names a verse some - * segment in `book` starts at, provided the referenced chapter has segments: the loader keeps + * Current scripture reference used to highlight the active verse. Always names a verse contained + * in some segment of `book`, provided the referenced chapter has segments: the loader keeps * `verseNum: 0` when the active chapter has a verse-0 superscription segment (so the * superscription becomes the active verse), resolves a whole-chapter (verse 0) selection to the - * chapter's first numbered verse, and resolves any other unmatched verse — the host's next-verse - * button past the chapter's end, or a verse inside a multi-verse segment — to the nearest - * preceding segment start in the chapter. So this is normally verse >= 1, and is verse 0 only - * when a matching verse-0 segment exists. + * chapter's first numbered verse, and resolves a verse no segment contains — the host's + * next-verse button past the chapter's end — to the nearest preceding segment start in the + * chapter. A verse absorbed mid-segment by a merge passes through unchanged and resolves here by + * containment. So this is normally verse >= 1, and is verse 0 only when a matching verse-0 + * segment exists. */ scrRef: SerializedVerseRef; /** @@ -70,6 +86,25 @@ type InterlinearizerProps = Readonly<{ * affordances. Forwarded straight to {@link AnalysisStoreProvider}. Defaults to `false`. */ showSuggestions?: boolean; + /** + * Boundary-editing operations provided to the views via {@link SegmentationProvider}. Optional so + * isolated tests can omit it; the real loader always supplies it. Defaults to an inert no-op. + */ + segmentationDispatch?: SegmentationDispatch; + /** + * Maps each merged-away default verse boundary's word-token split anchor (the verse's first word + * token) to the removed default start ref, provided to the views via {@link SegmentationProvider} + * so slots on those anchors render the former-boundary tick and a split there restores the + * original boundary exactly. Optional so isolated tests can omit it; defaults to an empty map. + */ + formerBoundaries?: ReadonlyMap; + /** + * Monotonic counter the loader bumps on every boundary edit. Threaded to the segment window so it + * can tell a boundary edit (redraw in place) apart from a re-tokenization of the loaded book + * (recenter with a fade) when the segments identity changes. Optional so isolated tests can omit + * it; defaults to `0`. + */ + segmentationVersion?: number; }>; /** @@ -95,6 +130,9 @@ function InterlinearizerInner({ phraseMode, setPhraseMode, viewOptions, + segmentationDispatch = NO_OP_SEGMENTATION_DISPATCH, + formerBoundaries = EMPTY_FORMER_BOUNDARIES, + segmentationVersion = 0, }: Omit< InterlinearizerProps, 'initialAnalysis' | 'analysisLanguage' | 'onSaveAnalysis' | 'showSuggestions' @@ -105,18 +143,21 @@ function InterlinearizerInner({ const { navigate, consumeInternalNav, reportSettled } = useInterlinearNav(); /** - * Finds the book segment that owns the active verse named by `scrRef`, matching on book, chapter, - * and verse. Book must be matched too: during an external navigation the new `scrRef.book` is set - * before its book data finishes loading, so the still-mounted `book` belongs to the previous - * reference. A chapter+verse-only match would then resolve to the wrong book's verse (e.g. - * Genesis 15 while navigating to Matthew 15), seed `focusedTokenRef` from it, and the echo-back - * effect would fire that wrong-book verse back as `scrRef`, corrupting the global reference. - * Returning `undefined` until the matching book is mounted keeps focus unset rather than wrong. + * Finds the book segment that owns the active verse named by `scrRef`: the first segment in + * document order whose verse range contains it. Containment (rather than an exact start-verse + * match) matters after boundary edits — a verse absorbed into a multi-verse segment, or named by + * a later portion of a split verse, still resolves to the segment that holds its text. Book must + * be matched too: during an external navigation the new `scrRef.book` is set before its book data + * finishes loading, so the still-mounted `book` belongs to the previous reference. A + * chapter+verse-only match would then resolve to the wrong book's verse (e.g. Genesis 15 while + * navigating to Matthew 15), seed `focusedTokenRef` from it, and the echo-back effect would fire + * that wrong-book verse back as `scrRef`, corrupting the global reference. Returning `undefined` + * until the matching book is mounted keeps focus unset rather than wrong. * * @returns The active verse's segment, or `undefined` when no segment matches. */ const findActiveSegment = useCallback( - () => book.segments.find((seg) => isSameVerse(seg.startRef, scrRef)), + () => book.segments.find((seg) => segmentContainsVerse(seg, scrRef)), [book.segments, scrRef], ); @@ -127,16 +168,116 @@ function InterlinearizerInner({ firstWordTokenRefOf(findActiveSegment()), ); - // Reseed when the book changes — the previous focusedTokenRef refers to a token from another - // book and would never resolve in the new book's maps. + // Book-wide lookup indexes the views share, built in one pass over the segment list. + const { + segmentById, + segmentOrder, + tokenDocOrder, + fullTokenOrder, + tokenSegmentMap, + wordTokenByRef, + wordRefByOrder, + } = useBookIndexes(book); + + // Reseed when the new book no longer resolves the focused token — a real book change (the + // previous focusedTokenRef names a token from another book) or a re-tokenization that dropped the + // token. A boundary edit (merge/split) also produces a fresh `book` object, but token refs + // survive re-segmentation, so a still-resolving focus must be kept: reseeding would snap the + // continuous strip to the active verse's first word — or park it on the book's very first phrase + // when a merge removed the active verse's segment start (no segment startRef names that verse + // anymore, so findActiveSegment returns undefined). useEffect(() => { - setFocusedTokenRef(firstWordTokenRefOf(findActiveSegment())); - // findActiveSegment changes with scrRef too; only re-seed on book change. + setFocusedTokenRef((current) => + current !== undefined && wordTokenByRef.has(current) + ? current + : firstWordTokenRefOf(findActiveSegment()), + ); + // findActiveSegment changes with scrRef too, and wordTokenByRef derives from book; only re-seed + // on book change. // eslint-disable-next-line react-hooks/exhaustive-deps }, [book]); - // Book-wide lookup indexes the views share, built in one pass over the segment list. - const { segmentById, tokenDocOrder, tokenSegmentMap, wordTokenByRef } = useBookIndexes(book); + const phraseDispatch = usePhraseDispatch(); + const getPhraseLinkById = usePhraseLinkByIdGetter(); + const phraseLinkById = usePhraseLinkByIdMap(); + + /** + * Word-token refs where placing a segment boundary would cut a phrase — the not-mid-phrase UI + * guard, precomputed once per phrase-link change. A boundary before ref `W` cuts a phrase when + * the phrase has tokens both strictly before and at-or-after `W`, i.e. every word ref strictly + * inside the phrase's document-order span `(min, max]` — including refs in the gaps of a + * discontiguous phrase. Computed here (one store subscription, one pass over the links) instead + * of per boundary slot, so hover-driven strip re-renders do O(1) set lookups rather than each + * slot rescanning every phrase link. + */ + const straddledBoundaryRefs = useMemo>(() => { + const blocked = new Set(); + phraseLinkById.forEach((link) => { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + link.tokens.forEach((t) => { + const order = tokenDocOrder.get(t.tokenRef); + /* v8 ignore next -- phrase tokens are word tokens, which the doc-order map always contains */ + if (order === undefined) return; + if (order < min) min = order; + if (order > max) max = order; + }); + for (let i = min + 1; i <= max; i += 1) blocked.add(wordRefByOrder[i]); + }); + return blocked; + }, [phraseLinkById, tokenDocOrder, wordRefByOrder]); + + /** + * Splits every phrase that a new segment boundary before `boundaryRef` would cut, so no phrase + * ever spans two segments. Reads the phrase links at call time (not via subscription) and applies + * {@link splitPhraseAtBoundary} at each straddled phrase's boundary-side split point. + * + * @param boundaryRef - Token ref the new segment will begin at. + */ + const forceBreakStraddledPhrases = useCallback( + (boundaryRef: string) => { + phrasesStraddlingBoundary(boundaryRef, getPhraseLinkById().values(), fullTokenOrder).forEach( + ({ link, splitAfterTokenRef }) => + splitPhraseAtBoundary(link, splitAfterTokenRef, phraseDispatch, tokenDocOrder), + ); + }, + [getPhraseLinkById, fullTokenOrder, phraseDispatch, tokenDocOrder], + ); + + /** + * The dispatch the views receive: wraps the raw boundary writer so any operation that adds a + * boundary (split, and the add-half of move) first force-breaks the phrases the new boundary + * would cut. Merge only removes a boundary, which can never leave a phrase straddling segments, + * so it passes through. The token-chip views suppress their controls at straddling boundaries via + * the same predicate, so the force-break normally fires only for callers that cannot see + * phrases. + */ + const dispatch = useMemo( + () => ({ + merge: segmentationDispatch.merge, + split: (tokenRef) => { + forceBreakStraddledPhrases(tokenRef); + segmentationDispatch.split(tokenRef); + }, + move: (fromRef, toRef) => { + forceBreakStraddledPhrases(toRef); + segmentationDispatch.move(fromRef, toRef); + }, + }), + [segmentationDispatch, forceBreakStraddledPhrases], + ); + + /** Segmentation context shared by the views — the dispatch plus the lookups its call sites need. */ + const segmentationValue = useMemo( + () => ({ + dispatch, + segmentById, + segmentOrder, + formerBoundaries, + straddledBoundaryRefs, + }), + [dispatch, segmentById, segmentOrder, formerBoundaries, straddledBoundaryRefs], + ); /** PhraseId currently hovered anywhere in the interlinearizer; shared across all SegmentViews. */ const [hoveredPhraseId, setHoveredPhraseId] = useState(); @@ -238,7 +379,10 @@ function InterlinearizerInner({ if (!seg) return; const { current } = scrRefRef; if (seg.startRef.book !== current.book) return; - if (isSameVerse(seg.startRef, current)) return; + // Already-contained check (not exact start-verse match): when the active verse sits inside a + // multi-verse segment, focusing another of that segment's tokens must not renavigate to the + // segment's start verse — the reference is already within the focused segment. + if (segmentContainsVerse(seg, current)) return; navigate(toSerializedVerseRef(seg.startRef), 'internal'); }, [segmentById, tokenSegmentMap, navigate, scrRefRef], @@ -266,59 +410,62 @@ function InterlinearizerInner({ ); return ( -
- {(phraseMode.kind === 'confirm-unlink' || phraseMode.kind === 'edit') && ( -
- {phraseMode.kind === 'confirm-unlink' ? ( - - ) : ( - - )} -
- )} -
- {displayContinuousScroll && ( -
- + +
+ {(phraseMode.kind === 'confirm-unlink' || phraseMode.kind === 'edit') && ( +
+ {phraseMode.kind === 'confirm-unlink' ? ( + + ) : ( + + )}
)} +
+ {displayContinuousScroll && ( +
+ +
+ )} - + +
-
+ ); } diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index a1b78792..6d45577e 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -10,9 +10,18 @@ import type { SelectMenuItemHandler } from 'platform-bible-react'; import type { ScriptureRef } from 'interlinearizer'; import { isPlatformError } from 'platform-bible-utils'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { resegmentBook } from 'parsers/papi/resegmentBook'; import useDraftProject from '../hooks/useDraftProject'; import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData'; import useOptimisticBooleanSetting from '../hooks/useOptimisticBooleanSetting'; +import { + isDefaultSegmentation, + mergeSegments, + moveBoundary, + splitSegmentBefore, +} from '../utils/segmentation'; +import { isWordToken } from '../types/type-guards'; +import type { SegmentationDispatch } from './SegmentationStore'; import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; import Interlinearizer from './Interlinearizer'; import ViewOptionsDropdown from './controls/ViewOptionsDropdown'; @@ -22,7 +31,7 @@ import { WipeModal, type WipeScope } from './modals/WipeModal'; import ScriptureNavControls from './controls/ScriptureNavControls'; import { InterlinearNavProvider, useInterlinearNav } from './InterlinearNavContext'; import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade'; -import { isSameVerse, toSerializedVerseRef } from '../utils/verse-ref'; +import { segmentContainsVerse, toSerializedVerseRef } from '../utils/verse-ref'; /** Host-injected callback to update this WebView's definition (used to toggle the tab title). */ type UpdateWebViewDefinition = WebViewProps['updateWebViewDefinition']; @@ -135,8 +144,10 @@ function InterlinearizerLoaderInner({ isDraftLoading, draft, draftVersion, + segmentationVersion, dirty, autosaveAnalysis, + autosaveSegmentation, loadFromProject, newDraft, getDraftSnapshot, @@ -232,29 +243,105 @@ function InterlinearizerLoaderInner({ ], ); - const { book, isLoading, bookError, tokenizeError } = useInterlinearizerBookData({ + const { + book: verseBook, + isLoading, + bookError, + tokenizeError, + } = useInterlinearizerBookData({ projectId, scrRef, }); - // The active reference handed to the interlinearizer, resolved so it always names a verse some - // segment starts at (when the requested chapter has segments at all). A reference whose verse is - // a segment start passes through unchanged. Otherwise: - // - // - Verse 0 with no verse-0 segment is a plain whole-chapter selection (the host emits - // `verseNum: 0` for those as well as for superscriptions), so it falls back to the chapter's - // first numbered verse rather than leaving nothing highlighted. - // - Any other unmatched verse resolves to the nearest preceding segment start in the same - // chapter. This covers the host's next-verse button, which emits `verseNum + 1` without - // clamping — bumping forward from a chapter's last verse delivers a verse past the chapter's - // end, which must land on the chapter's last segment, not fall through to the views' anchor - // fallback (the start of the book). It also covers verses inside a multi-verse segment, which - // resolve to the segment that contains them. - // - A reference to a chapter with no segments (including any book mismatch during a cross-book - // swap) passes through unchanged; the views' own fallbacks handle that transient. + /** + * The book the views render: the verse-tokenized book re-grouped into the user's custom segments. + * Identical (by reference) to `verseBook` when no custom boundaries are set, so the common case + * incurs no extra work. `verseBook` is retained separately because the segmentation operations + * need the default verse boundaries it carries. + * + * `draft.segmentation` is read fresh from the ref-held draft at recompute time; the deps are the + * two version counters that cover every path that can change it — `segmentationVersion` for + * boundary edits and `draftVersion` for wholesale replacements (New / Open / Wipe). The draft + * object itself is deliberately NOT a dep: gloss auto-saves replace the draft identity on every + * write without touching the boundaries, and keying on it would re-run the full-book + * re-segmentation (and cascade a fresh `book` identity through every downstream index and memo) + * after every gloss edit. `isDraftLoading` covers the one replacement that bumps neither counter: + * the initial draft load, which must invalidate a book computed while the stored boundaries were + * still in flight. + */ + const book = useMemo( + () => (verseBook ? resegmentBook(verseBook, draft?.segmentation) : undefined), + // eslint-disable-next-line react-hooks/exhaustive-deps -- the version counters track draft?.segmentation, a ref value + [verseBook, segmentationVersion, draftVersion, isDraftLoading], + ); + + /** + * Maps each merged-away default verse boundary's word-token split anchor — the verse's first word + * token, the ref the boundary slots are keyed by — to the removed default start ref (the verse's + * first token of any type, per the delta's `removedVerseStarts`). The two differ when a verse + * begins with punctuation (e.g. an opening quote); mapping through the word anchor lets the slot + * render the former-boundary tick and dispatch a split that cancels the removal exactly. Deps + * mirror the `book` memo above: the version counters cover every path that changes the + * segmentation, `isDraftLoading` covers the initial draft load, and the draft identity is + * deliberately not a dep. + */ + const formerBoundaries = useMemo>(() => { + const map = new Map(); + const removed = draft?.segmentation?.removedVerseStarts ?? []; + if (removed.length === 0 || !verseBook) return map; + const removedSet = new Set(removed); + verseBook.segments.forEach((verse) => { + const startRef = verse.tokens[0]?.ref; + if (startRef === undefined || !removedSet.has(startRef)) return; + const wordAnchorRef = verse.tokens.find(isWordToken)?.ref; + if (wordAnchorRef !== undefined) map.set(wordAnchorRef, startRef); + }); + return map; + // eslint-disable-next-line react-hooks/exhaustive-deps -- the version counters track draft?.segmentation, a ref value + }, [verseBook, segmentationVersion, draftVersion, isDraftLoading]); + + /** + * Boundary-editing operations passed down to the views. Each reads the draft's latest boundary + * delta synchronously (so rapid edits compose correctly), applies the relevant pure transform + * against the original verse book, and auto-saves the normalized result — clearing the field back + * to `undefined` when the edit restores the default verse segmentation. + */ + const segmentationDispatch = useMemo(() => { + const apply = (next: ReturnType) => { + autosaveSegmentation(isDefaultSegmentation(next) ? undefined : next); + }; + return { + merge: (secondSegmentStartRef) => { + /* v8 ignore next -- boundary controls only render once the book has loaded */ + if (!verseBook) return; + apply(mergeSegments(verseBook, getDraftSnapshot()?.segmentation, secondSegmentStartRef)); + }, + split: (tokenRef) => { + /* v8 ignore next -- boundary controls only render once the book has loaded */ + if (!verseBook) return; + apply(splitSegmentBefore(verseBook, getDraftSnapshot()?.segmentation, tokenRef)); + }, + move: (fromRef, toRef) => { + /* v8 ignore next -- the cross-segment link only renders once the book has loaded */ + if (!verseBook) return; + apply(moveBoundary(verseBook, getDraftSnapshot()?.segmentation, fromRef, toRef)); + }, + }; + }, [verseBook, getDraftSnapshot, autosaveSegmentation]); + + // The active reference handed to the interlinearizer. The host emits `verseNum: 0` both for a + // chapter's verse-0 superscription (which has its own segment) and for a plain whole-chapter + // selection (which does not). Keep verse 0 when the loaded book actually has a verse-0 segment for + // that chapter — so a Psalm superscription becomes the active verse — and otherwise fall back to + // the chapter's first numbered verse, so an ordinary chapter selection still lands on verse 1 + // rather than leaving nothing highlighted. A reference contained in any segment's verse range — + // including a verse absorbed mid-segment by a merge — passes through unchanged; the views resolve + // it to its containing segment. Only a reference no segment contains at all (the host's + // next-verse over-shooting the chapter's end) resolves to the nearest preceding segment start in + // the same chapter (falling through unchanged when the chapter has none). const activeScrRef = useMemo(() => { if (!book) return scrRef; - if (book.segments.some((segment) => isSameVerse(segment.startRef, scrRef))) return scrRef; + if (book.segments.some((segment) => segmentContainsVerse(segment, scrRef))) return scrRef; if (scrRef.verseNum === 0) return { ...scrRef, verseNum: 1 }; let preceding: ScriptureRef | undefined; book.segments.forEach(({ startRef }) => { @@ -330,8 +417,12 @@ function InterlinearizerLoaderInner({ 'interlinearizer.saveAnalysis', activeProject.id, JSON.stringify(snapshot.analysis), + // Send the draft's boundary state on every Save; `null` clears any stored boundaries so a + // reverted segmentation propagates to the project rather than leaving it stale. + // eslint-disable-next-line no-null/no-null -- "null" is the JSON sentinel that clears boundaries + JSON.stringify(snapshot.segmentation ?? null), ); - markSynced(snapshot.analysis); + markSynced(snapshot.analysis, snapshot.segmentation); } catch (e) { logger.error('Interlinearizer: failed to save draft to project', e); } finally { @@ -514,6 +605,9 @@ function InterlinearizerLoaderInner({ setPhraseMode={setPhraseMode} viewOptions={viewOptions} showSuggestions={showSuggestions} + segmentationDispatch={segmentationDispatch} + formerBoundaries={formerBoundaries} + segmentationVersion={segmentationVersion} /> )}
diff --git a/src/components/PhraseBox.tsx b/src/components/PhraseBox.tsx index 98b01c3b..03ea80ce 100644 --- a/src/components/PhraseBox.tsx +++ b/src/components/PhraseBox.tsx @@ -104,6 +104,13 @@ type PhraseBoxProps = Readonly<{ * the view. All fragments of that phrase receive the highlighted style simultaneously. */ isHighlighted?: boolean; + /** + * When `true`, this box's tokens are part of a hovered operation preview — a link icon or a + * boundary merge/split control whose candidate tokens would join into one phrase/segment or break + * off into a new segment. Renders the strong `phrase-candidate` outline, visually distinct from + * (and taking precedence over) the ordinary hover and focus styles. + */ + isCandidate?: boolean; /** * Token refs that would become free (solo) if the currently hovered split/unlink button were * clicked. Each matching chip renders with a destructive border as a preview; when every token in @@ -152,6 +159,8 @@ type PhraseBoxProps = Readonly<{ * sets for hovered fragment only) * @param props.isHighlighted - When true, all fragments of the hovered/focused phrase receive the * highlighted border style simultaneously + * @param props.isCandidate - When true, this box's tokens are part of a hovered operation preview + * (link or boundary merge/split) and render the strong candidate outline * @param props.splitFreeTokenRefs - Token refs that would become free after a hovered split; each * matching chip renders with a destructive border as a preview * @param props.punctuationBetween - Punctuation tokens between adjacent word tokens, in document @@ -161,6 +170,7 @@ type PhraseBoxProps = Readonly<{ export function PhraseBox({ isFocused = false, isHighlighted = false, + isCandidate = false, splitFreeTokenRefs, punctuationBetween, groupKey, @@ -333,6 +343,9 @@ export function PhraseBox({ if (phraseMode.kind === 'view') { const viewBorderClass = (() => { if (isBoxSplitFree) return 'tw:phrase-destructive'; + // Candidate previews outrank focus/hover: while a preview is hovered, showing what the + // operation affects matters more than showing what is focused. + if (isCandidate) return 'tw:phrase-candidate'; if (isFocused) return 'tw:phrase-focused'; if (isHighlighted) return 'tw:phrase-hovered'; return 'tw:phrase-dimmed'; diff --git a/src/components/PhraseStripContext.tsx b/src/components/PhraseStripContext.tsx index e0a7fb05..7e2d4fc6 100644 --- a/src/components/PhraseStripContext.tsx +++ b/src/components/PhraseStripContext.tsx @@ -40,7 +40,10 @@ export type PhraseStripContextValue = Readonly<{ * function for them. */ onHoverPhrase: (phraseId: string | undefined) => void; - /** Called with the candidate token refs (or `undefined`) when a link icon is hovered. */ + /** + * Called with the candidate token refs (or `undefined`) when a link icon or a boundary + * merge/split control is hovered; the affected groups render the strong candidate preview style. + */ onHoverCandidateTokens: (refs: readonly string[] | undefined) => void; /** Called with the would-be-free token refs (or `undefined`) when a split/unlink icon is hovered. */ onHoverSplitFreeTokens: (refs: readonly string[] | undefined) => void; diff --git a/src/components/PhraseStripParts.tsx b/src/components/PhraseStripParts.tsx index e1efc91a..2c91de70 100644 --- a/src/components/PhraseStripParts.tsx +++ b/src/components/PhraseStripParts.tsx @@ -1,13 +1,238 @@ /** @file Shared render parts for the two phrase strips (SegmentView and ContinuousView). */ +import { useLocalizedStrings } from '@papi/frontend/react'; +import { FoldHorizontal, Scissors } from 'lucide-react'; import { memo } from 'react'; +import type { ReactNode } from 'react'; import MemoizedPhraseBox from './PhraseBox'; import type { PhraseMode } from '../types/phrase-mode'; import { usePhraseStripContext } from './PhraseStripContext'; +import { useSegmentation } from './SegmentationStore'; import { InertTokenChip } from './TokenChip'; import MemoizedTokenLinkIcon from './TokenLinkIcon'; import type { FocusContext, LinkSlot, TokenGroup } from '../types/token-layout'; +import { isWordToken } from '../types/type-guards'; import { resolveSlotFocus } from '../utils/token-layout'; +/** Localized labels for the merge/split boundary controls; hoisted so the array reference is stable. */ +const BOUNDARY_STRING_KEYS = [ + '%interlinearizer_boundaryControl_merge%', + '%interlinearizer_boundaryControl_split%', + '%interlinearizer_boundaryControl_formerBoundary%', +] as const satisfies `%${string}%`[]; + +/** Props for {@link BoundaryButton}. */ +type BoundaryButtonProps = Readonly<{ + /** Accessible label, doubling as the tooltip. */ + label: string; + /** `data-testid` for the button element. */ + testId: string; + /** The icon rendered inside the button. */ + icon: ReactNode; + /** When `true` the control renders inert; used while a phrase mode (edit / unlink) is active. */ + disabled: boolean; + /** + * Lazily computes the word-token refs the operation would affect. Called on hover (not render), + * so mounting hundreds of slots does no per-render array building. + * + * @returns The refs to highlight as the operation's preview. + */ + getPreviewRefs: () => readonly string[]; + /** The boundary edit to run on click. */ + action: () => void; +}>; + +/** + * One boundary-edit button (merge or split): shared markup, hover-preview wiring, and click + * cleanup. Entering highlights the refs from `getPreviewRefs` through the shared candidate-token + * channel (the same one the link icon uses), leaving clears, and click clears synchronously before + * running the edit so the highlight can't linger over the re-segmented content. + * + * @param props - Component props. + * @param props.label - Accessible label and tooltip. + * @param props.testId - `data-testid` for the button element. + * @param props.icon - The icon rendered inside the button. + * @param props.disabled - Renders the control inert while a phrase mode is active. + * @param props.getPreviewRefs - Lazily computes the refs the operation would affect. + * @param props.action - The boundary edit to run on click. + * @returns The styled boundary button. + */ +function BoundaryButton({ + label, + testId, + icon, + disabled, + getPreviewRefs, + action, +}: BoundaryButtonProps) { + const { onHoverCandidateTokens } = usePhraseStripContext(); + return ( + + ); +} + +/** Props for {@link BoundaryControl}. */ +type BoundaryControlProps = Readonly<{ + /** Segment id of the group before the slot, or `undefined` for the leading slot. */ + prevSegmentId: string | undefined; + /** Segment id of the group after the slot, or `undefined` for the trailing slot. */ + nextSegmentId: string | undefined; + /** + * Last word token before the slot, or `undefined` for a leading slot. A slot with no word token + * before it sits on an existing segment start, where a split would be a no-op, so no control + * renders there. + */ + prevTokenRef: string | undefined; + /** First word token after the slot, used as the split anchor. */ + nextTokenRef: string | undefined; +}>; + +/** + * Renders the boundary-edit control for one slot, always visible (not hover-gated). A slot + * straddling two different segments shows a merge control (combine the next segment into the + * previous one); a slot inside one segment shows a split control (start a new segment at the next + * token). Leading/trailing slots (a word token missing on either side) render nothing — a leading + * slot sits on an existing segment start, where a split would be a no-op. + * + * Hovering either control previews its effect through the shared candidate-token highlight (the + * same channel the link icon uses): merge highlights every word token of both segments (they become + * one segment); split highlights the word tokens from the split anchor to the segment end (the run + * that breaks off into the new segment). The preview is cleared on leave and synchronously on click + * so it can't linger over the re-segmented content. + * + * An intra-segment slot sitting on a merged-away default verse start additionally renders a faint + * dashed tick — the former verse boundary — so the user can see where a split would restore the + * original segmentation. The tick shows even when the split control itself is suppressed by the + * not-mid-phrase guard, since it is informational rather than interactive. + * + * The not-mid-phrase rule is a UI guard applied here: no split control renders at a boundary that + * would cut a phrase — including the gap between two fragments of a discontiguous phrase. (The + * segmentation dispatch itself accepts such boundaries and force-breaks the straddled phrases; only + * callers that cannot see token chunks take that path.) Merge needs no such guard: removing a + * boundary can never leave a phrase straddling one. + * + * Both controls render disabled while a phrase mode (edit / confirm-unlink) is active, matching the + * link icons: a boundary edit mid-mode could re-segment the phrase the mode UI is operating on + * (e.g. canceling an edit would then restore a phrase spanning the new boundary). + * + * @param props - Component props. + * @param props.prevSegmentId - Segment id before the slot. + * @param props.nextSegmentId - Segment id after the slot. + * @param props.prevTokenRef - Last word token before the slot. + * @param props.nextTokenRef - First word token after the slot (split anchor). + * @returns A merge or split button (with an optional former-boundary tick), or `undefined` when the + * slot is at a segment or book edge or nothing applies at this boundary. + */ +function BoundaryControl({ + prevSegmentId, + nextSegmentId, + prevTokenRef, + nextTokenRef, +}: BoundaryControlProps) { + const { dispatch, segmentById, formerBoundaries, straddledBoundaryRefs } = useSegmentation(); + const { phraseMode } = usePhraseStripContext(); + const [localizedStrings] = useLocalizedStrings(BOUNDARY_STRING_KEYS); + if ( + prevSegmentId === undefined || + nextSegmentId === undefined || + prevTokenRef === undefined || + nextTokenRef === undefined + ) { + return undefined; + } + + const boundaryEditsDisabled = phraseMode.kind !== 'view'; + + const control = (() => { + if (prevSegmentId !== nextSegmentId) { + const nextSegment = segmentById.get(nextSegmentId); + const secondStart = nextSegment?.tokens[0]?.ref; + /* v8 ignore next -- a rendered cross-segment slot always resolves the next segment's start */ + if (nextSegment === undefined || secondStart === undefined) return undefined; + return ( + } + disabled={boundaryEditsDisabled} + // Merging joins every token of both segments into one; preview exactly that. + getPreviewRefs={() => + [ + /* v8 ignore next -- a rendered cross-segment slot's previous segment is always mapped */ + ...(segmentById.get(prevSegmentId)?.tokens ?? []), + ...nextSegment.tokens, + ] + .filter(isWordToken) + .map((t) => t.ref) + } + action={() => dispatch.merge(secondStart)} + /> + ); + } + // The not-mid-phrase UI guard: no split control at a boundary that would cut a phrase. + if (straddledBoundaryRefs.has(nextTokenRef)) return undefined; + // A split on a former boundary dispatches the original removed default start — which may be a + // leading punctuation token no word-anchored slot could name — so the restore cancels the + // removal exactly and the delta can normalize back to the default segmentation. + const splitRef = formerBoundaries.get(nextTokenRef) ?? nextTokenRef; + return ( + } + disabled={boundaryEditsDisabled} + // Splitting breaks the run from the anchor to the segment end off into a new segment; + // preview exactly that run. + getPreviewRefs={() => { + const segmentTokens = segmentById.get(nextSegmentId)?.tokens ?? []; + const anchorIndex = segmentTokens.findIndex((t) => t.ref === nextTokenRef); + /* v8 ignore next -- an intra-segment slot's anchor is always a token of its segment */ + if (anchorIndex === -1) return []; + return segmentTokens + .slice(anchorIndex) + .filter(isWordToken) + .map((t) => t.ref); + }} + action={() => dispatch.split(splitRef)} + /> + ); + })(); + + // The former-boundary tick: an intra-segment slot sitting on a merged-away default verse start. + const formerBoundaryMarker = + prevSegmentId === nextSegmentId && formerBoundaries.has(nextTokenRef) ? ( +