From 8ffc152e0a08cdbb9c5f6b211818dd8255412835 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Fri, 16 Oct 2026 12:41:25 -0600 Subject: [PATCH 1/5] Serve book outlines and segment pages from the host (#436) A Scripture edit now reloads the book at the view's next page fetch; --- AGENTS.md | 2 +- e2e-tests/tests/perf/app-perf.spec.ts | 20 +- e2e-tests/tests/perf/view-driver.ts | 5 + perf/summary/cli.ts | 5 + .../components/Interlinearizer.test.tsx | 141 ++++- .../components/InterlinearizerLoader.test.tsx | 137 ++++- .../hooks/useInterlinearizerBookData.test.ts | 12 + src/__tests__/hooks/useSegmentHeights.test.ts | 8 +- src/__tests__/hooks/useSegmentPages.test.ts | 513 ++++++++++++++++++ src/__tests__/hooks/useSegmentWindow.test.ts | 23 +- src/__tests__/main.test.ts | 76 +++ src/__tests__/services/bookService.test.ts | 219 +++++++- src/__tests__/test-helpers.ts | 49 ++ src/__tests__/utils/book-outline.test.ts | 62 +++ src/__tests__/utils/segment-heights.test.ts | 52 +- src/__tests__/utils/segmentation.test.ts | 40 ++ src/components/Interlinearizer.tsx | 16 +- src/components/InterlinearizerLoader.tsx | 46 +- src/components/SegmentListView.tsx | 192 ++++--- src/hooks/useInterlinearizerBookData.ts | 12 +- src/hooks/useSegmentHeights.ts | 16 +- src/hooks/useSegmentPages.ts | 293 ++++++++++ src/hooks/useSegmentWindow.ts | 34 +- src/main.ts | 125 +++++ src/services/bookService.ts | 159 +++++- src/types/interlinearizer.d.ts | 30 + src/types/segment-pages.ts | 31 ++ src/utils/book-outline.ts | 32 ++ src/utils/segment-heights.ts | 25 +- src/utils/segment-labels.ts | 7 +- src/utils/segmentation.ts | 13 + src/utils/verse-ref.ts | 8 +- src/utils/verse-superscripts.ts | 5 +- 33 files changed, 2194 insertions(+), 214 deletions(-) create mode 100644 src/__tests__/hooks/useSegmentPages.test.ts create mode 100644 src/__tests__/utils/book-outline.test.ts create mode 100644 src/hooks/useSegmentPages.ts create mode 100644 src/types/segment-pages.ts create mode 100644 src/utils/book-outline.ts diff --git a/AGENTS.md b/AGENTS.md index b672a471e..75fa322c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ This is a **Platform.Bible extension** for interlinear Bible text alignment. Pla [src/interlinearizer.web-view.tsx](src/interlinearizer.web-view.tsx) — entry point rendered inside Platform.Bible's WebView iframe; delegates to [InterlinearizerLoader](src/components/InterlinearizerLoader.tsx) when a `projectId` is present. `useWebViewScrollGroupScrRef` and `useWebViewState` are **props injected by the PAPI host** (not hook imports). -[InterlinearizerLoader](src/components/InterlinearizerLoader.tsx) — real top of the React tree: owns modal state, persists the active interlinear project, fetches the tokenized book, and routes top-menu commands to the appropriate modal. +[InterlinearizerLoader](src/components/InterlinearizerLoader.tsx) — real top of the React tree: owns modal state, persists the active interlinear project, fetches the tokenized book and its segment pages, and routes top-menu commands to the appropriate modal. [Interlinearizer](src/components/Interlinearizer.tsx) — renders the interlinear view from the loaded book data. diff --git a/e2e-tests/tests/perf/app-perf.spec.ts b/e2e-tests/tests/perf/app-perf.spec.ts index 1375f1d09..7afd746dd 100644 --- a/e2e-tests/tests/perf/app-perf.spec.ts +++ b/e2e-tests/tests/perf/app-perf.spec.ts @@ -143,8 +143,8 @@ async function deleteLeftoverProjects(page: Page, projectId: string): Promise name === 'getSegments') + .forEach(({ duration }) => open.host.addValue('page-fetch', duration)); const tokenizes = hostEntries.filter(({ name }) => name === 'getTokenizedBook:tokenize').length; if (tokenizes !== 1) console.warn(`Opening ${book} tokenized it ${tokenizes} times, not once`); await closeView(page); @@ -272,7 +277,16 @@ async function measureTier( switchBook.add( // eslint-disable-next-line no-await-in-loop await waitForMeasure(frame, 'reanchor', tier.viewBook), - ['book-fetch', 'book-parse', 'reanchor', 'book-render'], + [ + 'book-fetch', + 'book-parse', + 'outline-fetch', + 'outline-parse', + 'page-fetch', + 'page-parse', + 'reanchor', + 'book-render', + ], ); } result.switchBook = switchBook.summary(); diff --git a/e2e-tests/tests/perf/view-driver.ts b/e2e-tests/tests/perf/view-driver.ts index 772227202..488d7bc93 100644 --- a/e2e-tests/tests/perf/view-driver.ts +++ b/e2e-tests/tests/perf/view-driver.ts @@ -32,6 +32,11 @@ export const HOST_CALLS = { command: 'getTokenizedBook', phasePrefixes: ['getTokenizedBook'], }, + getBookOutline: { + measure: 'outline-fetch', + command: 'getBookOutline', + phasePrefixes: ['getBookOutline'], + }, saveDraft: { measure: 'autosave', command: 'saveDraft', phasePrefixes: ['saveDraft'] }, getProjectsForSource: { measure: 'projects-fetch', diff --git a/perf/summary/cli.ts b/perf/summary/cli.ts index a0436b39e..8732c07fe 100644 --- a/perf/summary/cli.ts +++ b/perf/summary/cli.ts @@ -92,6 +92,10 @@ const METRIC_LABELS: Record = { tokenize: 'Tokenize book', 'book-fetch': 'Fetch tokenized book', 'book-parse': 'Parse tokenized book', + 'outline-fetch': 'Fetch book outline', + 'outline-parse': 'Parse book outline', + 'page-fetch': 'Fetch a page of segments', + 'page-parse': 'Parse a page of segments', 'book-render': 'Book change to rendered', 'draft-fetch': 'Fetch draft', 'draft-parse': 'Parse draft', @@ -122,6 +126,7 @@ const PHASE_LABELS: Record = { read: 'read storage', usj: 'fetch USJ', tokenize: 'extract and tokenize', + resegment: 'segment by boundaries', parse: 'parse and validate', compare: 'split and compare shards', serialize: 'serialize reply', diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx index fed871e45..cd9a699ae 100644 --- a/src/__tests__/components/Interlinearizer.test.tsx +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -20,6 +20,8 @@ import { } from '../../components/SegmentationStore'; import type { SegmentDisplayMode } from '../../components/SegmentView'; import { RECENTER_FADE_MS } from '../../components/recenter-fade'; +import type { SegmentPages } from '../../hooks/useSegmentPages'; +import { EXTEND_CHUNK, INITIAL_WINDOW_HALF } from '../../hooks/useSegmentWindow'; import { defaultScrRef, GEN_1_1_BOOK, @@ -28,6 +30,7 @@ import { makeVerseBook, makeWordToken, type ScrollGroupTuple, + segmentPagesOf, } from '../test-helpers'; import { allFalseViewOptions, mockKeyAsValueLocalizedStrings } from './test-helpers'; import type { StaleFreeTranslation } from '../../utils/stale-free-translations'; @@ -434,6 +437,7 @@ function withNav( */ function renderInterlinearizer({ book = GEN_1_1_BOOK, + pages = segmentPagesOf(book), continuousScroll = false, scrRef = defaultScrRef, navigate = () => {}, @@ -445,6 +449,7 @@ function renderInterlinearizer({ asOneStep, }: { book?: Book; + pages?: SegmentPages; continuousScroll?: boolean; scrRef?: SerializedVerseRef; navigate?: (r: SerializedVerseRef) => void; @@ -459,6 +464,7 @@ function renderInterlinearizer({ withNav( { withNav( { withNav( { withNav( { withNav( { withNav( { withNav( { withNav( { withNav( { withNav( { withNav( { withNav( { withNav( { withNav( { withNav( { withNav( { withNav( { const book = makeLargeBook(60); const props = { book, + pages: segmentPagesOf(book), continuousScroll: false, phraseMode: { kind: 'view' } as const, setPhraseMode: () => {}, @@ -2043,6 +2066,7 @@ describe('Interlinearizer', () => { return ( { const book = makeLargeBook(60); const props = { book, + pages: segmentPagesOf(book), scrRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, phraseMode: { kind: 'view' } as const, setPhraseMode: () => {}, @@ -2251,6 +2276,115 @@ describe('segmentation dispatch force-break', () => { }); }); +describe('segment pages', () => { + /** `book`'s pages, with only its first `loaded` segments loaded. */ + function partlyLoaded(book: Book, loaded: number): SegmentPages { + return { + ...segmentPagesOf(book), + segmentAt: (index) => (index < loaded ? book.segments[index] : undefined), + }; + } + + it('renders a segment once its page loads', () => { + let loaded = 1; + let version = 0; + const listeners = new Set<() => void>(); + const pages: SegmentPages = { + ...segmentPagesOf(GEN_1_MULTI_BOOK), + segmentAt: (index) => (index < loaded ? GEN_1_MULTI_BOOK.segments[index] : undefined), + subscribe: (onChange) => { + listeners.add(onChange); + return () => listeners.delete(onChange); + }, + loadedVersion: () => version, + }; + renderInterlinearizer({ book: GEN_1_MULTI_BOOK, pages }); + + act(() => { + loaded = 2; + version += 1; + listeners.forEach((listener) => listener()); + }); + + expect(screen.getAllByTestId('segment-view')).toHaveLength(2); + expect(screen.queryByTestId('segment-placeholder')).not.toBeInTheDocument(); + }); + + it('holds the place of a segment whose page has not loaded at its predicted height', () => { + renderInterlinearizer({ book: GEN_1_MULTI_BOOK, pages: partlyLoaded(GEN_1_MULTI_BOOK, 1) }); + + expect(screen.getAllByTestId('segment-view')).toHaveLength(1); + const placeholder = screen.getByTestId('segment-placeholder'); + expect(placeholder).toHaveAttribute('data-segment-id', 'GEN 1:2'); + expect(placeholder).toHaveAttribute('aria-busy', 'true'); + expect(placeholder.style.height).not.toBe(''); + }); + + it('marks the place of an active segment whose page has not loaded', () => { + renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + pages: partlyLoaded(GEN_1_MULTI_BOOK, 0), + scrRef: { book: 'GEN', chapterNum: 1, verseNum: 2 }, + }); + + expect( + screen + .getAllByTestId('segment-placeholder') + .map((el) => el.getAttribute('aria-current') ?? undefined), + ).toEqual([undefined, 'true']); + }); + + it('keeps the merge gap above a segment whose page has not loaded, without its button', () => { + renderInterlinearizer({ book: GEN_1_MULTI_BOOK, pages: partlyLoaded(GEN_1_MULTI_BOOK, 1) }); + + expect(screen.queryByTestId('segment-merge-btn')).not.toBeInTheDocument(); + expect(screen.getByTestId('segment-placeholder').previousElementSibling).toHaveClass('tw:h-4'); + }); + + it('asks for the pages of the mounted window, a chunk beyond each edge', () => { + const book = makeLargeBook(60); + const pages = segmentPagesOf(book); + + renderInterlinearizer({ book, pages, scrRef: { book: 'GEN', chapterNum: 1, verseNum: 30 } }); + + expect(pages.load).toHaveBeenCalledWith( + 29 - INITIAL_WINDOW_HALF - EXTEND_CHUNK, + 30 + INITIAL_WINDOW_HALF + EXTEND_CHUNK, + ); + }); + + it('asks for the pages around a verse navigated to', () => { + const book = makeLargeBook(60); + const pages = segmentPagesOf(book); + const props = { book, pages, phraseMode: { kind: 'view' } as const }; + const { rerender } = render( + withNav( + {}} + viewOptions={allFalseViewOptions} + scrRef={{ book: 'GEN', chapterNum: 1, verseNum: 1 }} + />, + ), + ); + + rerender( + withNav( + {}} + viewOptions={allFalseViewOptions} + scrRef={{ book: 'GEN', chapterNum: 1, verseNum: 50 }} + />, + ), + ); + + expect(pages.load).toHaveBeenCalledWith(49 - EXTEND_CHUNK, 50 + EXTEND_CHUNK); + }); +}); + describe('between-rows merge control', () => { it('shows an always-visible, always-enabled merge button between rows even while Alt is not held', () => { const raw: SegmentationDispatch = { merge: jest.fn(), split: jest.fn(), move: jest.fn() }; @@ -2321,6 +2455,7 @@ describe('between-rows merge control', () => { withNav( { return withNav( { describe('segmentationVersion pass-through', () => { /** * Builds the wrapped `` element for the pass-through tests, varying only the - * book and segmentation version between renders. + * book and the segmentation version its pages carry between renders. */ function versionedEl(book: Book, segmentationVersion: number): ReactNode { return withNav( {}} viewOptions={{ ...allFalseViewOptions }} @@ -2591,6 +2727,7 @@ describe('cross-book focus requests', () => { ; unmergeableStarts?: ReadonlySet; - segmentationVersion: number; + pages: SegmentPages; asOneStep: (action: () => void) => void; }; let capturedInterlinearizerProps: CapturedInterlinearizerProps | undefined; @@ -628,6 +631,7 @@ function mockBookData( isLoading: boolean; bookError: string | undefined; writingSystem: string; + reload: () => void; }> = {}, ): void { jest.mocked(useInterlinearizerBookData).mockReturnValue({ @@ -635,10 +639,26 @@ function mockBookData( isLoading: false, bookError: undefined, writingSystem: 'und', + reload: jest.fn(), ...overrides, }); } +/** + * Serves the page client's book whole, as the host would with no custom boundaries, unless + * `overrides` say otherwise. + */ +function mockSegmentPages(overrides: Partial> = {}): void { + jest.mocked(useSegmentPages).mockImplementation(({ verseBook, segmentationVersion }) => ({ + pages: verseBook && segmentPagesOf(verseBook, segmentationVersion), + isLoading: false, + error: undefined, + ...overrides, + })); +} + +beforeEach(() => mockSegmentPages()); + /** * Configures useOptimisticBooleanSetting to return the given state. Each setting key gets its own * distinct `onChange` mock so wiring tests can verify that a given toggle is connected to the @@ -1130,6 +1150,101 @@ describe('InterlinearizerLoader', () => { expect(screen.getByText(/project not found/i)).toBeInTheDocument(); }); + describe('segment pages', () => { + /** A two-verse Genesis whose second verse can merge into its first. */ + const TWO_VERSE_GENESIS: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + duplicateVerseIds: [], + segments: [ + makeSegment('GEN 1:1', 'Alpha.', [makeWordToken('GEN 1:1:0', 'Alpha')]), + makeSegment('GEN 1:2', 'Beta.', [makeWordToken('GEN 1:2:0', 'Beta')]), + ], + }; + + /** The arguments the loader last gave the page client. */ + function lastPagesArgs() { + return jest.mocked(useSegmentPages).mock.lastCall?.[0]; + } + + it("hands the page client the loaded book and the draft's boundaries in it", async () => { + mockSendCommand.mockResolvedValue( + JSON.stringify({ + ...emptyDraft(testProjectId), + segmentation: { removedVerseStarts: ['GEN 1:2:0', 'EXO 1:2:0'], addedStarts: [] }, + }), + ); + mockBookData({ book: TWO_VERSE_GENESIS }); + await act(async () => { + renderLoader(); + }); + + expect(lastPagesArgs()).toMatchObject({ + projectId: testProjectId, + verseBook: TWO_VERSE_GENESIS, + segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, + }); + }); + + it('holds the book back from the page client while the draft loads', async () => { + mockSendCommand.mockReturnValue(new Promise(() => {})); + await act(async () => { + renderLoader(); + }); + + expect(lastPagesArgs()?.verseBook).toBeUndefined(); + }); + + it('reloads the book when the page client finds its text has changed', async () => { + const reload = jest.fn(); + mockBookData({ reload }); + await act(async () => { + renderLoader(); + }); + + act(() => lastPagesArgs()?.onTextChanged()); + + expect(reload).toHaveBeenCalled(); + }); + + it('hands the view the pages the page client serves', async () => { + await act(async () => { + renderLoader(); + }); + + expect(capturedInterlinearizerProps?.pages).toBe(segmentPagesOf(GEN_1_1_BOOK)); + }); + + it('shows the loading curtain while the pages load', async () => { + mockSegmentPages({ isLoading: true }); + await act(async () => { + renderLoader(); + }); + + expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); + expect(screen.queryByTestId('interlinearizer')).not.toBeInTheDocument(); + }); + + it("shows the loading curtain while the pages are another book's", async () => { + mockSegmentPages({ pages: segmentPagesOf({ ...GEN_1_1_BOOK, bookRef: 'EXO' }) }); + await act(async () => { + renderLoader(); + }); + + expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); + }); + + it('shows why the pages could not be loaded', async () => { + mockSegmentPages({ pages: undefined, error: 'Cannot segment' }); + await act(async () => { + renderLoader(); + }); + + expect(screen.getByText('Cannot segment')).toBeInTheDocument(); + }); + }); + describe('when the source project lacks the book', () => { beforeEach(() => { mockProjectBookIds(['PHP']); @@ -1531,6 +1646,22 @@ describe('InterlinearizerLoader', () => { return screen.findByTestId('pt9-import-banner'); } + it("hands the page client none of the draft's boundaries", async () => { + mockSendCommand.mockImplementation(async (...args) => + JSON.stringify( + args[0] === 'interlinearizer.getProject' + ? { ...FRESH_IMPORT_SUMMARY, analysis: emptyAnalysis() } + : { + ...emptyDraft(testProjectId), + segmentation: { removedVerseStarts: ['GEN 1:1:0'], addedStarts: [] }, + }, + ), + ); + await renderImportView(); + + expect(jest.mocked(useSegmentPages).mock.lastCall?.[0].segmentation).toBeUndefined(); + }); + it('defaults free translation to on for an import already holding one', async () => { // The draft beside it holds none, so only the import's own analysis can turn it on. mockSendCommand.mockImplementation(async (...args) => @@ -3151,7 +3282,7 @@ describe('InterlinearizerLoader', () => { await renderBoundaryBook(); expect(capturedInterlinearizerProps?.formerBoundaries.size).toBe(0); - expect(capturedInterlinearizerProps?.segmentationVersion).toBe(0); + expect(capturedInterlinearizerProps?.pages.segmentationVersion).toBe(0); }); it("passes the verse book's unmergeable starts", async () => { @@ -3168,7 +3299,7 @@ describe('InterlinearizerLoader', () => { 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); + expect(capturedInterlinearizerProps?.pages.segmentationVersion).toBe(1); }); it('keys a punct-initial merged verse by its first word token, mapped to the removed start', async () => { diff --git a/src/__tests__/hooks/useInterlinearizerBookData.test.ts b/src/__tests__/hooks/useInterlinearizerBookData.test.ts index bdb6c3b47..e55019208 100644 --- a/src/__tests__/hooks/useInterlinearizerBookData.test.ts +++ b/src/__tests__/hooks/useInterlinearizerBookData.test.ts @@ -120,6 +120,18 @@ describe('useInterlinearizerBookData', () => { expect(mockSendCommand).toHaveBeenCalledTimes(1); }); + it('fetches the book again when asked to reload it', async () => { + const { result } = renderBookData(); + await waitFor(() => expect(result.current.book).toEqual(TEST_BOOK)); + const revised: Book = { ...TEST_BOOK, textVersion: 'v2' }; + serveBooks(revised); + + act(() => result.current.reload()); + + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.book).toEqual(revised)); + }); + it('keeps the previous book while the next one loads', async () => { const { result, rerender } = renderBookData(); await waitFor(() => expect(result.current.book).toEqual(TEST_BOOK)); diff --git a/src/__tests__/hooks/useSegmentHeights.test.ts b/src/__tests__/hooks/useSegmentHeights.test.ts index 26a3521f3..c437c917d 100644 --- a/src/__tests__/hooks/useSegmentHeights.test.ts +++ b/src/__tests__/hooks/useSegmentHeights.test.ts @@ -5,7 +5,7 @@ import type { Book } from 'interlinearizer'; import { useRef } from 'react'; import useSegmentHeights from '../../hooks/useSegmentHeights'; import type { HeightConfig } from '../../utils/segment-heights'; -import { makeSegment, makeWordToken } from '../test-helpers'; +import { makeSegment, makeWordToken, outlineOf } from '../test-helpers'; /** Builds a book whose segments each carry one word token. */ function makeBook(count: number): Book { @@ -70,7 +70,11 @@ function renderSegmentHeights(book: Book, wrapWidth: number, config: HeightConfi return renderHook( (props: RenderProps) => { const containerRef = useRef(container); - return useSegmentHeights({ ...props, containerRef }); + return useSegmentHeights({ + segments: outlineOf(props.book.segments), + config: props.config, + containerRef, + }); }, { initialProps }, ); diff --git a/src/__tests__/hooks/useSegmentPages.test.ts b/src/__tests__/hooks/useSegmentPages.test.ts new file mode 100644 index 000000000..e38dbecce --- /dev/null +++ b/src/__tests__/hooks/useSegmentPages.test.ts @@ -0,0 +1,513 @@ +/// + +import papi, { logger } from '@papi/frontend'; +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { Book, Segment, SegmentationDelta } from 'interlinearizer'; +import useSegmentPages, { PAGE_SIZE, type UseSegmentPagesArgs } from '../../hooks/useSegmentPages'; +import type { BookOutline, SegmentPage } from '../../types/segment-pages'; +import { getMockedSendCommand, makeSegment, makeWordToken, outlineOf } from '../test-helpers'; + +const mockSendCommand = getMockedSendCommand(papi); + +/** A book of `count` one-word verses in chapter 1. */ +function makeBook(count: number, textVersion = 'v1'): Book { + return { + id: 'GEN', + bookRef: 'GEN', + textVersion, + duplicateVerseIds: [], + segments: Array.from({ length: count }, (_unused, i) => + makeSegment(`GEN 1:${i + 1}`, 'word', [makeWordToken(`GEN 1:${i + 1}:0`, 'word')]), + ), + }; +} + +const BOOK = makeBook(100); + +/** Merges verse 2 into verse 1. */ +const MERGE_1_2: SegmentationDelta = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + +const VERSE_1: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + +/** One request the host was sent, held open until the test answers it. */ +interface Request { + command: string; + args: unknown[]; + respond: () => void; + reject: (error: Error) => void; +} + +/** Answers `requests` from the host's book, together. */ +async function answer(...requests: Request[]): Promise { + await act(async () => requests.forEach((request) => request.respond())); +} + +async function fail(request: Request, error: Error): Promise { + await act(async () => request.reject(error)); +} + +/** + * Segments `book` as the host would: one segment per verse, or verses 1 and 2 as one when a request + * names {@link MERGE_1_2}, the only boundaries these tests name. + */ +function segmentsFor(book: Book, segmentationJson: unknown): readonly Segment[] { + if (segmentationJson === undefined) return book.segments; + const [first, second, ...rest] = book.segments; + return [ + { ...first, endRef: second.endRef, tokens: [...first.tokens, ...second.tokens] }, + ...rest, + ]; +} + +/** What the host answers a request with when it serves `book`. */ +function hostAnswer(book: Book, command: string, args: unknown[]): string { + if (command === 'interlinearizer.getBookOutline') { + const outline: BookOutline = { + bookRef: book.bookRef, + textVersion: book.textVersion, + segments: outlineOf(segmentsFor(book, args[2])), + }; + return JSON.stringify(outline); + } + const [, , start, end, segmentationJson] = args; + if (typeof start !== 'number' || typeof end !== 'number') throw new Error('Expected a range'); + const page: SegmentPage = { + textVersion: book.textVersion, + start, + segments: segmentsFor(book, segmentationJson).slice(start, end), + }; + return JSON.stringify(page); +} + +/** + * Has the host answer every request at once from `book`, or hold each open in the returned list + * when `held`. + */ +function serveHost(book: Book | (() => Book), { held = false } = {}): Request[] { + const requests: Request[] = []; + const bookNow = typeof book === 'function' ? book : () => book; + mockSendCommand.mockImplementation((command: string, ...args: unknown[]) => { + if (!held) return Promise.resolve(hostAnswer(bookNow(), command, args)); + return new Promise((resolve, reject) => { + requests.push({ + command, + args, + respond: () => resolve(hostAnswer(bookNow(), command, args)), + reject, + }); + }); + }); + return requests; +} + +/** The `[start, end)` ranges of segments asked of the host so far, a request apiece. */ +function rangesAskedFor(): [number, number][] { + return mockSendCommand.mock.calls + .filter(([command]) => command === 'interlinearizer.getSegments') + .map(([, , , start, end]) => [Number(start), Number(end)]); +} + +/** The pages asked of the host so far, by index. */ +function pagesAskedFor(): number[] { + return rangesAskedFor().flatMap(([start, end]) => + Array.from({ length: (end - start) / PAGE_SIZE }, (_unused, i) => start / PAGE_SIZE + i), + ); +} + +/** The arguments {@link renderPages} renders with, but for `overrides`. */ +function pageArgs(overrides: Partial = {}): UseSegmentPagesArgs { + return { + projectId: 'src', + verseBook: BOOK, + segmentation: undefined, + segmentationVersion: 0, + scrRef: VERSE_1, + onTextChanged: jest.fn(), + ...overrides, + }; +} + +function renderPages(overrides: Partial = {}) { + return renderHook((props: UseSegmentPagesArgs) => useSegmentPages(props), { + initialProps: pageArgs(overrides), + }); +} + +describe('useSegmentPages', () => { + it('asks for nothing before the book has loaded', () => { + serveHost(BOOK); + + const { result } = renderPages({ verseBook: undefined }); + + expect(result.current).toEqual({ pages: undefined, isLoading: false, error: undefined }); + expect(mockSendCommand).not.toHaveBeenCalled(); + }); + + it("outlines the book by the project's boundaries", async () => { + serveHost(BOOK); + + const { result } = renderPages({ segmentation: MERGE_1_2 }); + + await waitFor(() => expect(result.current.pages).toBeDefined()); + expect(mockSendCommand).toHaveBeenCalledWith( + 'interlinearizer.getBookOutline', + 'src', + 'GEN', + JSON.stringify(MERGE_1_2), + ); + expect(result.current.pages?.bookRef).toBe('GEN'); + expect(result.current.pages?.outline.map((seg) => seg.id).slice(0, 2)).toEqual([ + 'GEN 1:1', + 'GEN 1:3', + ]); + }); + + it('outlines one segment per verse when the project has no boundaries', async () => { + serveHost(BOOK); + + const { result } = renderPages(); + + await waitFor(() => expect(result.current.pages?.outline).toHaveLength(100)); + expect(mockSendCommand).toHaveBeenCalledWith( + 'interlinearizer.getBookOutline', + 'src', + 'GEN', + undefined, + ); + }); + + it('stays loading until the pages around the verse opened on have loaded', async () => { + const requests = serveHost(BOOK, { held: true }); + const { result } = renderPages({ scrRef: { ...VERSE_1, verseNum: 50 } }); + + await answer(requests[0]); + expect(result.current.isLoading).toBe(true); + expect(pagesAskedFor()).toEqual([0, 1, 2]); + await answer(...requests.slice(1)); + + expect(result.current.isLoading).toBe(false); + expect(result.current.pages?.segmentAt(49)?.id).toBe('GEN 1:50'); + }); + + it('serves no segment whose page has not loaded', async () => { + serveHost(BOOK); + + const { result } = renderPages(); + + await waitFor(() => expect(result.current.pages).toBeDefined()); + expect(result.current.pages?.segmentAt(0)?.id).toBe('GEN 1:1'); + expect(result.current.pages?.segmentAt(99)).toBeUndefined(); + }); + + it('loads the pages asked for', async () => { + serveHost(BOOK); + const { result } = renderPages(); + await waitFor(() => expect(result.current.pages).toBeDefined()); + + act(() => result.current.pages?.load(90, 100)); + + await waitFor(() => expect(result.current.pages?.segmentAt(99)?.id).toBe('GEN 1:100')); + expect(result.current.pages?.segmentAt(2 * PAGE_SIZE)?.id).toBe(`GEN 1:${2 * PAGE_SIZE + 1}`); + }); + + it('asks for a run of missing pages in one request', async () => { + serveHost(BOOK); + const { result } = renderPages(); + await waitFor(() => expect(result.current.pages).toBeDefined()); + + act(() => result.current.pages?.load(90, 100)); + + await waitFor(() => expect(result.current.pages?.segmentAt(99)).toBeDefined()); + expect(rangesAskedFor()).toEqual([ + [0, 2 * PAGE_SIZE], + [2 * PAGE_SIZE, 4 * PAGE_SIZE], + ]); + }); + + it('asks for each run of missing pages in a request of its own', async () => { + serveHost(makeBook(200)); + const { result } = renderPages({ + verseBook: makeBook(200), + scrRef: { ...VERSE_1, verseNum: 100 }, + }); + await waitFor(() => expect(result.current.pages).toBeDefined()); + mockSendCommand.mockClear(); + + act(() => result.current.pages?.load(0, 200)); + + await waitFor(() => expect(result.current.pages?.segmentAt(199)).toBeDefined()); + expect(rangesAskedFor()).toEqual([ + [0, PAGE_SIZE], + [5 * PAGE_SIZE, 7 * PAGE_SIZE], + ]); + }); + + it('lets subscribers know when pages load', async () => { + serveHost(BOOK); + const { result } = renderPages(); + await waitFor(() => expect(result.current.pages).toBeDefined()); + const onChange = jest.fn(); + result.current.pages?.subscribe(onChange); + const version = result.current.pages?.loadedVersion(); + + act(() => result.current.pages?.load(90, 100)); + + await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)); + expect(result.current.pages?.loadedVersion()).not.toBe(version); + }); + + it('stops letting a subscriber know once it unsubscribes', async () => { + serveHost(BOOK); + const { result } = renderPages(); + await waitFor(() => expect(result.current.pages).toBeDefined()); + const onChange = jest.fn(); + result.current.pages?.subscribe(onChange)(); + + act(() => result.current.pages?.load(90, 100)); + + await waitFor(() => expect(result.current.pages?.segmentAt(99)).toBeDefined()); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('asks for a page once while it loads, and not again once it has', async () => { + const requests = serveHost(BOOK, { held: true }); + const { result } = renderPages(); + await answer(requests[0]); + await answer(...requests.slice(1)); + + act(() => result.current.pages?.load(70, 80)); + act(() => result.current.pages?.load(70, 80)); + await answer(requests[requests.length - 1]); + act(() => result.current.pages?.load(70, 80)); + + expect(pagesAskedFor()).toEqual([0, 1, 2]); + }); + + it('asks for no page beyond the ends of the book', async () => { + serveHost(BOOK); + const { result } = renderPages(); + await waitFor(() => expect(result.current.pages).toBeDefined()); + + act(() => result.current.pages?.load(-50, 500)); + + await waitFor(() => expect(pagesAskedFor()).toEqual([0, 1, 2, 3])); + }); + + it('keeps the outline it has while the outline for new boundaries loads', async () => { + const requests = serveHost(BOOK, { held: true }); + const { result, rerender } = renderPages(); + await answer(requests[0]); + await answer(...requests.slice(1)); + const before = result.current.pages; + + rerender(pageArgs({ segmentation: MERGE_1_2, segmentationVersion: 1 })); + await answer(requests[2]); + + expect(result.current.pages).toBe(before); + expect(result.current.isLoading).toBe(false); + await answer(...requests.slice(3)); + expect(result.current.pages?.outline[1].id).toBe('GEN 1:3'); + expect(result.current.pages?.segmentationVersion).toBe(1); + }); + + it('loads the pages last asked for with the outline for new boundaries', async () => { + serveHost(BOOK); + const { result, rerender } = renderPages(); + await waitFor(() => expect(result.current.pages).toBeDefined()); + act(() => result.current.pages?.load(90, 100)); + await waitFor(() => expect(result.current.pages?.segmentAt(99)).toBeDefined()); + mockSendCommand.mockClear(); + + rerender(pageArgs({ segmentation: MERGE_1_2, segmentationVersion: 1 })); + + await waitFor(() => expect(result.current.pages?.segmentationVersion).toBe(1)); + expect(result.current.pages?.segmentAt(98)?.id).toBe('GEN 1:100'); + expect(pagesAskedFor()).toEqual([0, 1, 2, 3]); + }); + + it('carries the boundary-edit count the outline was asked for at', async () => { + const requests = serveHost(BOOK, { held: true }); + const { result, rerender } = renderPages({ segmentationVersion: 4 }); + + rerender(pageArgs({ segmentationVersion: 5 })); + await answer(requests[0]); + await answer(...requests.slice(1)); + + expect(result.current.pages?.segmentationVersion).toBe(4); + }); + + it('ignores an outline asked for by boundaries since replaced', async () => { + const requests = serveHost(BOOK, { held: true }); + const { result, rerender } = renderPages(); + + rerender(pageArgs({ segmentation: MERGE_1_2 })); + await answer(requests[0]); + await answer(requests[1]); + await answer(...requests.slice(2)); + + expect(result.current.pages?.outline[1].id).toBe('GEN 1:3'); + expect(pagesAskedFor()).toEqual([0, 1]); + }); + + it('ignores pages of an outline asked for by boundaries since replaced', async () => { + const requests = serveHost(BOOK, { held: true }); + const { result, rerender } = renderPages(); + await answer(requests[0]); + + rerender(pageArgs({ segmentation: MERGE_1_2 })); + await answer(...requests.slice(1, 3)); + + expect(result.current.pages).toBeUndefined(); + }); + + it('ignores a page loaded for an outline since replaced', async () => { + const requests = serveHost(BOOK, { held: true }); + const { result, rerender } = renderPages(); + await answer(requests[0]); + await answer(...requests.slice(1)); + act(() => result.current.pages?.load(96, 100)); + rerender(pageArgs({ segmentation: MERGE_1_2 })); + await answer(requests[3]); + await answer(...requests.slice(4)); + + await answer(requests[2]); + + expect(result.current.pages?.segmentAt(97)?.id).toBe('GEN 1:99'); + }); + + it('reports a book whose text has changed since it loaded', async () => { + serveHost(makeBook(100, 'v2')); + const onTextChanged = jest.fn(); + + const { result } = renderPages({ onTextChanged }); + + await waitFor(() => expect(onTextChanged).toHaveBeenCalled()); + expect(result.current.isLoading).toBe(true); + }); + + it('reports a book whose text has gone', async () => { + mockSendCommand.mockResolvedValue(undefined); + const onTextChanged = jest.fn(); + + renderPages({ onTextChanged }); + + await waitFor(() => expect(onTextChanged).toHaveBeenCalled()); + }); + + it('reports a book whose text changed before its first pages were cut', async () => { + let book = BOOK; + const requests = serveHost(() => book, { held: true }); + const onTextChanged = jest.fn(); + const { result } = renderPages({ onTextChanged }); + await answer(requests[0]); + + book = makeBook(100, 'v2'); + await answer(...requests.slice(1)); + + expect(onTextChanged).toHaveBeenCalled(); + expect(result.current.pages).toBeUndefined(); + }); + + it('reports a book whose text changed before a page asked for later was cut', async () => { + let book = BOOK; + serveHost(() => book); + const onTextChanged = jest.fn(); + const { result } = renderPages({ onTextChanged }); + await waitFor(() => expect(result.current.pages).toBeDefined()); + + book = makeBook(100, 'v2'); + act(() => result.current.pages?.load(90, 100)); + + await waitFor(() => expect(onTextChanged).toHaveBeenCalled()); + expect(result.current.pages?.segmentAt(99)).toBeUndefined(); + }); + + it('reports a book whose text went before a page asked for later was cut', async () => { + serveHost(BOOK); + const onTextChanged = jest.fn(); + const { result } = renderPages({ onTextChanged }); + await waitFor(() => expect(result.current.pages).toBeDefined()); + + mockSendCommand.mockResolvedValue(undefined); + act(() => result.current.pages?.load(96, 100)); + + await waitFor(() => expect(onTextChanged).toHaveBeenCalled()); + }); + + it('reports why the outline could not be loaded', async () => { + mockSendCommand.mockRejectedValue(new Error('no such book')); + + const { result } = renderPages(); + + await waitFor(() => expect(result.current.error).toBe('no such book')); + expect(result.current.isLoading).toBe(false); + }); + + it('reports a failure that is not an Error by its text', async () => { + mockSendCommand.mockRejectedValue('offline'); + + const { result } = renderPages(); + + await waitFor(() => expect(result.current.error).toBe('offline')); + }); + + it('drops a failure once an outline loads', async () => { + mockSendCommand.mockRejectedValue(new Error('busy')); + const { result, rerender } = renderPages(); + await waitFor(() => expect(result.current.error).toBe('busy')); + + serveHost(BOOK); + rerender(pageArgs({ segmentation: MERGE_1_2 })); + + await waitFor(() => expect(result.current.pages).toBeDefined()); + expect(result.current.error).toBeUndefined(); + }); + + it('ignores a failure of an outline since replaced', async () => { + const requests = serveHost(BOOK, { held: true }); + const { result, rerender } = renderPages(); + + rerender(pageArgs({ segmentation: MERGE_1_2 })); + await fail(requests[0], new Error('busy')); + + expect(result.current.error).toBeUndefined(); + }); + + it('asks again for a page that failed to load', async () => { + const requests = serveHost(BOOK, { held: true }); + const { result } = renderPages(); + await answer(requests[0]); + await answer(...requests.slice(1)); + act(() => result.current.pages?.load(96, 100)); + + await fail(requests[2], new Error('busy')); + act(() => result.current.pages?.load(96, 100)); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('busy')); + expect(pagesAskedFor()).toEqual([0, 1, 3, 3]); + }); + + it('stays loading for another book until its outline loads', async () => { + serveHost(BOOK); + const { result, rerender } = renderPages(); + await waitFor(() => expect(result.current.pages).toBeDefined()); + + rerender(pageArgs({ verseBook: { ...BOOK } })); + + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + }); + + it('asks for nothing once the book has been unloaded', async () => { + serveHost(BOOK); + const { result, rerender } = renderPages(); + await waitFor(() => expect(result.current.pages).toBeDefined()); + mockSendCommand.mockClear(); + + rerender(pageArgs({ verseBook: undefined })); + + expect(result.current.isLoading).toBe(false); + expect(mockSendCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/hooks/useSegmentWindow.test.ts b/src/__tests__/hooks/useSegmentWindow.test.ts index 91f846336..d30e7c57a 100644 --- a/src/__tests__/hooks/useSegmentWindow.test.ts +++ b/src/__tests__/hooks/useSegmentWindow.test.ts @@ -17,7 +17,7 @@ import useSegmentWindow, { import { verseKey, type RecenterRequest } from '../../components/InterlinearNavContext'; import { RECENTER_FADE_MS } from '../../components/recenter-fade'; import type { HeightTable } from '../../utils/segment-heights'; -import { makeWordToken } from '../test-helpers'; +import { makeWordToken, outlineOf } from '../test-helpers'; /** Height the test table gives every segment, so a jump target reads as a round offset. */ const UNIFORM_SEGMENT_PX = 1000; @@ -81,6 +81,18 @@ function makeBook(chapter1Count: number, chapter2Count: number, book = 'GEN'): B return { id: book, bookRef: book, textVersion: 'v1', segments, duplicateVerseIds: [] }; } +const tokenSegmentMaps = new WeakMap>(); + +/** Maps each token of `segments` to its segment's id, the same map for the same segments. */ +function tokenSegmentMapOf(segments: readonly Segment[]): ReadonlyMap { + let map = tokenSegmentMaps.get(segments); + if (!map) { + map = new Map(segments.flatMap((seg) => seg.tokens.map((token) => [token.ref, seg.id]))); + tokenSegmentMaps.set(segments, map); + } + return map; +} + /** * Renders {@link useSegmentWindow} with a real, attached scroll container so sentinel ref callbacks * register with the stubbed observer and `scrollHeight`/`scrollTop` are writable for assertions. @@ -121,7 +133,8 @@ function renderSegmentWindow( ({ b, ref, focus, cont, segVersion, recenter }) => { const scrollContainerRef = useRef(container); return useSegmentWindow({ - book: b, + segments: outlineOf(b.segments), + tokenSegmentMap: tokenSegmentMapOf(b.segments), scrRef: ref, segmentationVersion: segVersion ?? 0, focusedTokenRef: focus, @@ -298,7 +311,7 @@ describe('useSegmentWindow', () => { const { result } = renderSegmentWindow(book, { book: 'GEN', chapterNum: 1, verseNum: 12 }); const { range, windowSegments } = result.current; - expect(book.segments.slice(range.start, range.end)).toEqual(windowSegments); + expect(outlineOf(book.segments).slice(range.start, range.end)).toEqual(windowSegments); }); it('re-seats the window when the scroll position jumps below the mounted segments', () => { @@ -1698,7 +1711,7 @@ describe('useSegmentWindow', () => { expect(result.current.isFaded).toBe(false); // The window slices the edited book's segments immediately, with no midpoint swap to wait for. - expect(result.current.windowSegments[0]).toBe(editedBook.segments[0]); + expect(result.current.windowSegments[0]).toBe(outlineOf(editedBook.segments)[0]); act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); expect(result.current.isFaded).toBe(false); }); @@ -1862,7 +1875,7 @@ describe('useSegmentWindow', () => { 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]); + expect(result.current.windowSegments[0]).toBe(outlineOf(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 8ee68f4ae..f47663ce2 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -319,6 +319,8 @@ describe('main', () => { 'interlinearizer.getProjectsForSource', 'interlinearizer.getDraft', 'interlinearizer.getTokenizedBook', + 'interlinearizer.getBookOutline', + 'interlinearizer.getSegments', 'interlinearizer.saveDraft', 'interlinearizer.openSelectProjectModal', 'interlinearizer.openNewProjectModal', @@ -1372,6 +1374,80 @@ describe('main', () => { }); }); + describe('interlinearizer.getBookOutline command', () => { + const mockGetBookOutline = jest.mocked(bookService.getBookOutline); + const getHandler = () => + activateAndGetHandler< + (sourceProjectId: string, bookId: string, segmentationJson?: string) => Promise + >('interlinearizer.getBookOutline'); + + it('returns the outline the service serves for the boundaries asked for', async () => { + mockGetBookOutline.mockResolvedValue('{"bookRef":"GEN"}'); + const handler = await getHandler(); + + const result = await handler('src-project', 'GEN', '{"removedVerseStarts":[]}'); + + expect(mockGetBookOutline).toHaveBeenCalledWith( + 'src-project', + 'GEN', + '{"removedVerseStarts":[]}', + ); + expect(result).toBe('{"bookRef":"GEN"}'); + }); + + it('logs the error and rethrows when the book cannot be outlined', async () => { + mockGetBookOutline.mockRejectedValue(new Error('bad text')); + const handler = await getHandler(); + + await expect(handler('src-project', 'GEN')).rejects.toThrow('bad text'); + expect(__mockLogger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to outline book GEN of project src-project', + expect.any(Error), + ); + }); + }); + + describe('interlinearizer.getSegments command', () => { + const mockGetSegments = jest.mocked(bookService.getSegments); + const getHandler = () => + activateAndGetHandler< + ( + sourceProjectId: string, + bookId: string, + start: number, + end: number, + segmentationJson?: string, + ) => Promise + >('interlinearizer.getSegments'); + + it('returns the page the service serves for the range and boundaries asked for', async () => { + mockGetSegments.mockResolvedValue('{"start":32}'); + const handler = await getHandler(); + + const result = await handler('src-project', 'GEN', 32, 64, '{"removedVerseStarts":[]}'); + + expect(mockGetSegments).toHaveBeenCalledWith( + 'src-project', + 'GEN', + 32, + 64, + '{"removedVerseStarts":[]}', + ); + expect(result).toBe('{"start":32}'); + }); + + it('logs the error and rethrows when the segments cannot be served', async () => { + mockGetSegments.mockRejectedValue(new Error('bad text')); + const handler = await getHandler(); + + await expect(handler('src-project', 'GEN', 0, 32)).rejects.toThrow('bad text'); + expect(__mockLogger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to serve segments 0-32 of book GEN of project src-project', + expect.any(Error), + ); + }); + }); + describe('interlinearizer.setPerfMarks command', () => { it("turns the host's timing entries on or off as asked", async () => { const handler = await activateAndGetHandler<(enabled: boolean) => Promise>( diff --git a/src/__tests__/services/bookService.test.ts b/src/__tests__/services/bookService.test.ts index b6b5abb5e..71ac7cbb0 100644 --- a/src/__tests__/services/bookService.test.ts +++ b/src/__tests__/services/bookService.test.ts @@ -1,19 +1,30 @@ /// import papiBackendMock from '@papi/backend'; -import type { Book } from 'interlinearizer'; +import type { Book, SegmentationDelta } from 'interlinearizer'; import { tokenizeBook } from 'parsers/papi/bookTokenizer'; +import { resegmentBook } from 'parsers/papi/resegmentBook'; import { extractBookFromUsj, type UsjDocument } from 'parsers/papi/usjBookExtractor'; import { closeAllProjects, closeProject, + getBookOutline, + getSegments, getTokenizedBook, getWritingSystem, } from '../../services/bookService'; +import type { BookOutline, SegmentPage } from '../../types/segment-pages'; +import { buildBookOutline } from '../../utils/book-outline'; +import { reanchorSegmentation } from '../../utils/segmentation'; +import { makeSegment, makeWordToken } from '../test-helpers'; // Tokenizing is the parsers' concern; these stand-ins name the book, text and tag they came from. jest.mock('parsers/papi/bookTokenizer'); jest.mock('parsers/papi/usjBookExtractor'); +// Segmenting and outlining have suites of their own; these stand-ins record what they were given. +jest.mock('parsers/papi/resegmentBook'); +jest.mock('../../utils/book-outline'); +jest.mock('../../utils/segmentation'); function isBackendMock(m: unknown): m is { __mockProjectDataProvidersGet: jest.Mock } { return !!m && typeof m === 'object' && '__mockProjectDataProvidersGet' in m; @@ -77,6 +88,62 @@ function parseBook(json: string | undefined): Book { return JSON.parse(json); } +function parseOutline(json: string | undefined): BookOutline { + if (!json) throw new Error('Expected an outline'); + return JSON.parse(json); +} + +function parsePage(json: string | undefined): SegmentPage { + if (!json) throw new Error('Expected a page'); + return JSON.parse(json); +} + +/** A tokenized Genesis 1:1–3, standing in for whatever text the project holds. */ +const VERSE_BOOK: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1@en', + duplicateVerseIds: [], + segments: [1, 2, 3].map((verse) => + makeSegment(`GEN 1:${verse}`, 'word', [makeWordToken(`GEN 1:${verse}:0`, 'word')]), + ), +}; + +/** Has the project's text tokenize to {@link VERSE_BOOK}. */ +function tokenizeToVerseBook() { + jest.mocked(tokenizeBook).mockReturnValue(VERSE_BOOK); +} + +/** Merges verse 2 into verse 1. */ +const DELTA: SegmentationDelta = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + +/** {@link DELTA} as re-anchoring it to the book's text gives it. */ +const REANCHORED: SegmentationDelta = { + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [{ tokenRef: 'GEN 1:3:0', surfaceText: 'word' }], +}; + +/** The book's segments under {@link REANCHORED}, as segmenting gives them. */ +const SEGMENTED = [VERSE_BOOK.segments[0], VERSE_BOOK.segments[2]]; + +/** Has the book segment to {@link SEGMENTED} under any boundaries, and outline to a stand-in. */ +function segmentStandIns() { + jest.mocked(reanchorSegmentation).mockReturnValue(REANCHORED); + jest.mocked(resegmentBook).mockImplementation((book) => ({ ...book, segments: SEGMENTED })); + jest.mocked(buildBookOutline).mockImplementation((book, segments) => ({ + bookRef: book.bookRef, + textVersion: book.textVersion, + segments: segments.map((segment) => ({ + id: segment.id, + startRef: segment.startRef, + verseStarts: segment.verseStarts, + tokenCount: 0, + wordTokenCount: 0, + textLength: 0, + })), + })); +} + beforeEach(() => { jest.mocked(extractBookFromUsj).mockImplementation((doc, writingSystem) => ({ bookCode: String(doc.content[0]), @@ -240,6 +307,156 @@ describe('getTokenizedBook', () => { }); }); +describe('getBookOutline', () => { + beforeEach(() => { + tokenizeToVerseBook(); + segmentStandIns(); + }); + + it('outlines the book segmented by the boundaries named, re-anchored to its text', async () => { + serveProject((bookId) => usj(bookId)); + + const outline = parseOutline(await getBookOutline('src', 'GEN', JSON.stringify(DELTA))); + + expect(reanchorSegmentation).toHaveBeenCalledWith(VERSE_BOOK, DELTA); + expect(resegmentBook).toHaveBeenCalledWith(VERSE_BOOK, REANCHORED); + expect(buildBookOutline).toHaveBeenCalledWith(VERSE_BOOK, SEGMENTED); + expect(outline.segments.map((seg) => seg.id)).toEqual(['GEN 1:1', 'GEN 1:3']); + }); + + it('names no boundaries to segment by when none are given', async () => { + serveProject((bookId) => usj(bookId)); + + await getBookOutline('src', 'GEN'); + + expect(reanchorSegmentation).toHaveBeenCalledWith(VERSE_BOOK, undefined); + }); + + it('names no boundaries to segment by when they arrive as null', async () => { + serveProject((bookId) => usj(bookId)); + // Over PAPI an omitted argument arrives as null, which the signature has no room for. + // eslint-disable-next-line no-null/no-null + const args = ['src', 'GEN', null]; + + await Reflect.apply(getBookOutline, undefined, args); + + expect(reanchorSegmentation).toHaveBeenCalledWith(VERSE_BOOK, undefined); + }); + + it('resolves undefined when the project has no text for the book', async () => { + serveProject(() => undefined); + + await expect(getBookOutline('src', 'GEN')).resolves.toBeUndefined(); + }); + + it('rejects boundaries that are not a SegmentationDelta', async () => { + serveProject((bookId) => usj(bookId)); + + await expect(getBookOutline('src', 'GEN', '{"removed":[]}')).rejects.toThrow( + 'not a SegmentationDelta', + ); + }); + + it('rejects boundaries that are not JSON', async () => { + serveProject((bookId) => usj(bookId)); + + await expect(getBookOutline('src', 'GEN', '{')).rejects.toThrow(SyntaxError); + }); + + it('tokenizes the book once for its outline and for the whole book', async () => { + serveProject((bookId) => usj(bookId)); + + await Promise.all([getTokenizedBook('src', 'GEN'), getBookOutline('src', 'GEN')]); + + expect(tokenizeBook).toHaveBeenCalledTimes(1); + }); + + it('segments the book once for its outline and its pages under the same boundaries', async () => { + serveProject((bookId) => usj(bookId)); + const json = JSON.stringify(DELTA); + + await getBookOutline('src', 'GEN', json); + await getSegments('src', 'GEN', 0, 2, json); + + expect(resegmentBook).toHaveBeenCalledTimes(1); + }); + + it('segments the book again for other boundaries', async () => { + serveProject((bookId) => usj(bookId)); + + await getBookOutline('src', 'GEN', JSON.stringify(DELTA)); + await getBookOutline('src', 'GEN'); + + expect(resegmentBook).toHaveBeenCalledTimes(2); + }); +}); + +describe('getSegments', () => { + beforeEach(() => { + tokenizeToVerseBook(); + segmentStandIns(); + }); + + it('serves the segments at the outline indexes asked for', async () => { + serveProject((bookId) => usj(bookId)); + + const page = parsePage(await getSegments('src', 'GEN', 1, 2, JSON.stringify(DELTA))); + + expect(resegmentBook).toHaveBeenCalledWith(VERSE_BOOK, REANCHORED); + expect(page).toEqual({ textVersion: 'v1@en', start: 1, segments: [SEGMENTED[1]] }); + }); + + it('serves the segments the book has of a range running past its end', async () => { + serveProject((bookId) => usj(bookId)); + + const page = parsePage(await getSegments('src', 'GEN', 1, 50)); + + expect(page.segments).toEqual([SEGMENTED[1]]); + }); + + it('starts a range running before the book at its first segment', async () => { + serveProject((bookId) => usj(bookId)); + + const page = parsePage(await getSegments('src', 'GEN', -5, 1)); + + expect(page).toMatchObject({ start: 0, segments: [SEGMENTED[0]] }); + }); + + it('serves no segments for a range that ends before it starts', async () => { + serveProject((bookId) => usj(bookId)); + + const page = parsePage(await getSegments('src', 'GEN', 2, 1)); + + expect(page.segments).toEqual([]); + }); + + it('resolves undefined when the project has no text for the book', async () => { + serveProject(() => undefined); + + await expect(getSegments('src', 'GEN', 0, 1)).resolves.toBeUndefined(); + }); + + it("serves a kept book's segments without reading the language tag again", async () => { + serveProject((bookId) => usj(bookId)); + await getBookOutline('src', 'GEN'); + mockPdpGet.mockClear(); + + await getSegments('src', 'GEN', 0, 1); + + expect(mockPdpGet).not.toHaveBeenCalledWith('platform.base', 'src'); + }); + + it("reads the language tag for a book it doesn't keep", async () => { + serveProject((bookId) => usj(bookId)); + await getBookOutline('src', 'GEN'); + mockPdpGet.mockClear(); + + await getSegments('src', 'EXO', 0, 1); + + expect(mockPdpGet).toHaveBeenCalledWith('platform.base', 'src'); + }); +}); + describe('closeProject', () => { it("lets go of the project's book", async () => { const project = serveProject((bookId) => usj(bookId)); diff --git a/src/__tests__/test-helpers.ts b/src/__tests__/test-helpers.ts index 5be9c6a79..4dc98de72 100644 --- a/src/__tests__/test-helpers.ts +++ b/src/__tests__/test-helpers.ts @@ -9,9 +9,11 @@ import { TOKEN_CHIP_LABEL_KEYS, type PhraseStripContextValue, } from '../components/PhraseStripContext'; +import type { SegmentPages } from '../hooks/useSegmentPages'; import { emptyAnalysis } from '../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../types/model-version'; import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; +import type { OutlineSegment } from '../types/segment-pages'; /** Minimal execution token-shaped object for tests (structural match for ExecutionToken). */ const mockExecutionToken: { @@ -159,6 +161,53 @@ export function makeScrollGroupHook( return () => [ref, setScrRef, scrollGroupId, setScrollGroupId, sourceProjectId]; } +const outlinesBySegments = new WeakMap(); + +/** The outline of `segments`, the same array for the same segments. */ +export function outlineOf(segments: readonly Segment[]): readonly OutlineSegment[] { + let outline = outlinesBySegments.get(segments); + if (!outline) { + // Not built by the outline util, which only its own suite runs. + outline = segments.map(({ id, startRef, verseStarts, heading, tokens, baselineText }) => ({ + id, + startRef, + verseStarts, + ...(heading && { heading }), + tokenCount: tokens.length, + wordTokenCount: tokens.filter((token) => token.type === 'word').length, + textLength: baselineText.length, + })); + outlinesBySegments.set(segments, outline); + } + return outline; +} + +const pagesBySegments = new WeakMap>(); + +/** + * Serves `book`'s segments as pages that have all loaded, the same pages for the same segments, + * book and version, so a rerender with them changes nothing. `load` is a mock. + */ +export function segmentPagesOf(book: Book, segmentationVersion = 0): SegmentPages { + const byKey = pagesBySegments.get(book.segments) ?? new Map(); + pagesBySegments.set(book.segments, byKey); + const key = `${book.bookRef}:${segmentationVersion}`; + let pages = byKey.get(key); + if (!pages) { + pages = { + bookRef: book.bookRef, + outline: outlineOf(book.segments), + segmentationVersion, + segmentAt: (index) => book.segments[index], + load: jest.fn(), + subscribe: () => () => {}, + loadedVersion: () => 0, + }; + byKey.set(key, pages); + } + return pages; +} + /** Pre-built Book with one GEN 1:1 segment and a single word token. */ export const GEN_1_1_BOOK: Book = { id: 'GEN', diff --git a/src/__tests__/utils/book-outline.test.ts b/src/__tests__/utils/book-outline.test.ts new file mode 100644 index 000000000..ec7523caa --- /dev/null +++ b/src/__tests__/utils/book-outline.test.ts @@ -0,0 +1,62 @@ +/// + +import type { Book } from 'interlinearizer'; +import { buildBookOutline, outlineSegment } from '../../utils/book-outline'; +import { makePunctToken, makeSegment, makeWordToken } from '../test-helpers'; + +describe('outlineSegment', () => { + it('keeps where the segment falls', () => { + const segment = makeSegment('GEN 1:1', 'In the beginning', [makeWordToken('GEN 1:1:0', 'In')]); + + expect(outlineSegment(segment)).toMatchObject({ + id: 'GEN 1:1', + startRef: segment.startRef, + verseStarts: segment.verseStarts, + }); + }); + + it('counts every token, and words apart, without carrying them', () => { + const outline = outlineSegment( + makeSegment('GEN 1:1', 'a, b.', [ + makeWordToken('GEN 1:1:0', 'a'), + makePunctToken('GEN 1:1:1', ','), + makeWordToken('GEN 1:1:3', 'b', 3), + makePunctToken('GEN 1:1:4', '.'), + ]), + ); + + expect(outline).toMatchObject({ tokenCount: 4, wordTokenCount: 2, textLength: 5 }); + expect(outline).not.toHaveProperty('tokens'); + expect(outline).not.toHaveProperty('baselineText'); + }); + + it("keeps a heading's marker", () => { + const heading = { marker: 's1', verseId: 'GEN 1:0', verseNumber: '0' }; + const segment = { ...makeSegment('GEN 1:0', '', []), id: 'GEN 1:0/s1', heading }; + + expect(outlineSegment(segment).heading).toBe(heading); + }); + + it('leaves a verse without a heading', () => { + expect(outlineSegment(makeSegment('GEN 1:1', '', []))).not.toHaveProperty('heading'); + }); +}); + +describe('buildBookOutline', () => { + it('outlines the segments given, under the book and its text version', () => { + const segments = [makeSegment('GEN 1:1', 'a', [makeWordToken('GEN 1:1:0', 'a')])]; + const book: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [], + duplicateVerseIds: [], + }; + + expect(buildBookOutline(book, segments)).toEqual({ + bookRef: 'GEN', + textVersion: 'v1', + segments: segments.map(outlineSegment), + }); + }); +}); diff --git a/src/__tests__/utils/segment-heights.test.ts b/src/__tests__/utils/segment-heights.test.ts index 11d978689..250a5e31e 100644 --- a/src/__tests__/utils/segment-heights.test.ts +++ b/src/__tests__/utils/segment-heights.test.ts @@ -1,6 +1,5 @@ /// -import { makePunctToken, makeSegment, makeWordToken } from '../test-helpers'; import { buildHeightTable, heightForRows, @@ -66,15 +65,15 @@ describe('predictRowCount', () => { describe('predictLineCount', () => { it('reports one line for empty text', () => { - expect(predictLineCount('', 300)).toBe(1); + expect(predictLineCount(0, 300)).toBe(1); }); it('keeps text that exactly fills the wrap width on one line', () => { - expect(predictLineCount('a'.repeat(100), 840)).toBe(1); + expect(predictLineCount(100, 840)).toBe(1); }); it('wraps when the text runs past the wrap width', () => { - expect(predictLineCount('a'.repeat(101), 840)).toBe(2); + expect(predictLineCount(101, 840)).toBe(2); }); }); @@ -207,59 +206,40 @@ describe('heightForRows', () => { describe('predictSegmentHeights', () => { it('predicts a one-row height for a segment whose chips fit the wrap width', () => { - const segment = makeSegment('PSA 1:1', 'a b', [ - makeWordToken('PSA 1:1:0', 'a'), - makeWordToken('PSA 1:1:1', 'b'), + expect(predictSegmentHeights([{ wordTokenCount: 2, textLength: 3 }], CONFIG, 300)).toEqual([ + 132, ]); - expect(predictSegmentHeights([segment], CONFIG, 300)).toEqual([132]); }); it('charges a taller height to a segment whose chips wrap', () => { - const segment = makeSegment( - 'PSA 1:1', - 'a b c d', - ['a', 'b', 'c', 'd'].map((t, i) => makeWordToken(`PSA 1:1:${i}`, t)), - ); - expect(predictSegmentHeights([segment], CONFIG, 300)).toEqual([256]); - }); - - it('excludes punctuation tokens, which render inside a chip rather than as one', () => { - // Three words and three punctuation marks fit the width only if the marks are not chips. - const segment = makeSegment('PSA 1:1', 'a, b, c,', [ - makeWordToken('PSA 1:1:0', 'a'), - makePunctToken('PSA 1:1:1', ','), - makeWordToken('PSA 1:1:2', 'b'), - makePunctToken('PSA 1:1:3', ','), - makeWordToken('PSA 1:1:4', 'c'), - makePunctToken('PSA 1:1:5', ','), + expect(predictSegmentHeights([{ wordTokenCount: 4, textLength: 7 }], CONFIG, 300)).toEqual([ + 256, ]); - expect(predictSegmentHeights([segment], CONFIG, 300)).toEqual([132]); }); it('counts wrapped text lines, not chip rows, in baseline-text mode', () => { // One word token, but a baseline text too long for one line of the wrap width. - const segment = makeSegment('PSA 1:1', 'a'.repeat(50), [makeWordToken('PSA 1:1:0', 'a')]); expect( - predictSegmentHeights([segment], { ...CONFIG, displayMode: 'baseline-text' }, 300), + predictSegmentHeights( + [{ wordTokenCount: 1, textLength: 50 }], + { ...CONFIG, displayMode: 'baseline-text' }, + 300, + ), ).toEqual([58]); }); it('predicts every segment in document order', () => { const segments = [ - makeSegment('PSA 1:1', 'a', [makeWordToken('PSA 1:1:0', 'a')]), - makeSegment( - 'PSA 1:2', - 'a b c d', - ['a', 'b', 'c', 'd'].map((t, i) => makeWordToken(`PSA 1:2:${i}`, t)), - ), + { wordTokenCount: 1, textLength: 1 }, + { wordTokenCount: 4, textLength: 7 }, ]; expect(predictSegmentHeights(segments, CONFIG, 300)).toEqual([132, 256]); }); it('charges the free-translation row only to the segments the view renders one for', () => { const segments = [ - makeSegment('PSA 1:1', 'a', [makeWordToken('PSA 1:1:0', 'a')]), - makeSegment('PSA 1:2', 'b', [makeWordToken('PSA 1:2:0', 'b')]), + { wordTokenCount: 1, textLength: 1 }, + { wordTokenCount: 1, textLength: 1 }, ]; const config = { ...CONFIG, diff --git a/src/__tests__/utils/segmentation.test.ts b/src/__tests__/utils/segmentation.test.ts index 976d5ac13..44f00b800 100644 --- a/src/__tests__/utils/segmentation.test.ts +++ b/src/__tests__/utils/segmentation.test.ts @@ -1,6 +1,7 @@ /// import type { SegmentationDelta, TokenSnapshot } from 'interlinearizer'; +import { resegmentBook } from 'parsers/papi/resegmentBook'; import { addBoundaryBefore, defaultVerseStarts, @@ -12,6 +13,7 @@ import { moveBoundary, reanchorSegmentation, removeBoundaryAt, + segmentationForBook, splitSegmentBefore, unmergeableVerseStarts, } from '../../utils/segmentation'; @@ -132,6 +134,44 @@ describe('isEmptyDelta', () => { }); }); +describe('segmentationForBook', () => { + it("keeps only the boundaries that name the book's tokens", () => { + const delta: SegmentationDelta = { + removedVerseStarts: ['GEN 1:2:0', 'EXO 1:2:0'], + addedStarts: [ + { tokenRef: 'EXO 1:1:6', surfaceText: 'beta' }, + { tokenRef: 'GEN 1:1:6', surfaceText: 'beta' }, + ], + }; + + expect(segmentationForBook(delta, 'GEN')).toEqual({ + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [{ tokenRef: 'GEN 1:1:6', surfaceText: 'beta' }], + }); + }); + + it('gives no boundaries for a book the delta does not name', () => { + const delta: SegmentationDelta = { removedVerseStarts: ['EXO 1:2:0'], addedStarts: [] }; + + expect(segmentationForBook(delta, 'GEN')).toBeUndefined(); + }); + + it('gives no boundaries for no delta', () => { + expect(segmentationForBook(undefined, 'GEN')).toBeUndefined(); + }); + + it('segments the book as the whole delta does', () => { + const delta: SegmentationDelta = { + removedVerseStarts: [V2_START, 'EXO 1:2:0'], + addedStarts: [{ tokenRef: 'GEN 1:1:6', surfaceText: 'beta' }], + }; + const segmentBy = (boundaries: SegmentationDelta | undefined) => + resegmentBook(THREE_VERSES, reanchorSegmentation(THREE_VERSES, boundaries)).segments; + + expect(segmentBy(segmentationForBook(delta, 'GEN'))).toEqual(segmentBy(delta)); + }); +}); + describe('isDefaultSegmentationForBook', () => { it('is true for undefined', () => { expect(isDefaultSegmentationForBook(THREE_VERSES, undefined)).toBe(true); diff --git a/src/components/Interlinearizer.tsx b/src/components/Interlinearizer.tsx index 07c2b3de5..7ef98505a 100644 --- a/src/components/Interlinearizer.tsx +++ b/src/components/Interlinearizer.tsx @@ -13,6 +13,7 @@ import { import ContinuousView from './ContinuousView'; import EditPhraseControls from './controls/EditPhraseControls'; import useBookIndexes from '../hooks/useBookIndexes'; +import type { SegmentPages } from '../hooks/useSegmentPages'; import { useAltHeldAttribute } from '../hooks/useAltHeldAttribute'; import type { PhraseMode } from '../types/phrase-mode'; import { TOOLTIP_DELAY_MS } from './tooltip-delay'; @@ -36,8 +37,10 @@ const RUN_UNGROUPED = (action: () => void) => action(); /** Props for {@link Interlinearizer}. */ type InterlinearizerProps = Readonly<{ - /** Tokenized book whose segments are rendered. */ + /** The whole tokenized book, segmented as `pages` segments it. */ book: Book; + /** The book's segments as the segment list renders them. */ + pages: SegmentPages; /** When true, the horizontal token strip is shown above the segment list. */ continuousScroll: boolean; /** @@ -65,13 +68,6 @@ type InterlinearizerProps = Readonly<{ formerBoundaries?: ReadonlyMap; /** Segment-start refs no merge can remove; when absent, none are. */ unmergeableStarts?: ReadonlySet; - /** - * Monotonic counter the loader bumps on every boundary edit. Lets the segment window 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; without it, every such change reads as a - * re-tokenization. - */ - segmentationVersion?: number; /** Runs an action so every edit it makes undoes as one step; ungrouped when omitted. */ asOneStep?: (action: () => void) => void; }>; @@ -83,6 +79,7 @@ type InterlinearizerProps = Readonly<{ */ export default function Interlinearizer({ book, + pages, continuousScroll, scrRef, phraseMode, @@ -91,7 +88,6 @@ export default function Interlinearizer({ segmentationDispatch = NO_OP_SEGMENTATION_DISPATCH, formerBoundaries = EMPTY_FORMER_BOUNDARIES, unmergeableStarts = EMPTY_UNMERGEABLE_STARTS, - segmentationVersion = 0, asOneStep = RUN_UNGROUPED, }: InterlinearizerProps) { // Navigation surface from the context: `consumeInternalNav` lets the segment window suppress the @@ -284,9 +280,9 @@ export default function Interlinearizer({ )} + isImportView || !verseBook + ? undefined + : segmentationForBook(draft?.segmentation, verseBook.bookRef), + // eslint-disable-next-line react-hooks/exhaustive-deps -- the version counters track draft?.segmentation, a ref value + [verseBook, segmentationVersion, draftVersion, isDraftLoading, isImportView], + ); + + const { + pages, + isLoading: isPagesLoading, + error: pagesError, + } = useSegmentPages({ + projectId, + // Held back until the draft has loaded, so the first outline carries its boundaries. + verseBook: isDraftLoading ? undefined : verseBook, + segmentation: pageSegmentation, + segmentationVersion, + scrRef, + onTextChanged: reloadBook, + }); + const headingPlacements = useMemo(() => placeHeadings(verseBook?.segments ?? []), [verseBook]); const { undismissedLostBoundaries, onDismiss: handleDismissLostBoundaries } = @@ -851,7 +878,8 @@ function InterlinearizerLoaderInner({ return precedingVerse !== undefined ? { ...scrRef, verseNum: precedingVerse } : scrRef; }, [scrRef, book]); - const hasError = isBookMissing || !!bookError; + const loadError = bookError ?? pagesError; + const hasError = isBookMissing || !!loadError; const isSettingLoading = isContinuousScrollLoading || isInterfaceModeLoading || @@ -862,7 +890,13 @@ function InterlinearizerLoaderInner({ // swaps the old view for the Loading… curtain immediately, so nothing of either book shows until // the new one has mounted and fades in. const isCrossBookSwap = !!book && scrRef.book !== book.bookRef; - const showLoading = isLoading || isDraftLoading || isSettingLoading || isCrossBookSwap; + const showLoading = + isLoading || + isDraftLoading || + isSettingLoading || + isCrossBookSwap || + isPagesLoading || + pages?.bookRef !== book?.bookRef; const isLoaded = !hasError && !showLoading && !!book; // Abort any in-flight cross-book fade when the new book fails to load, so the error is revealed @@ -1629,7 +1663,7 @@ function InterlinearizerLoaderInner({ /> ) : (
- {bookError && ( + {loadError && (
{/* The error text is not localized, so it shows here and below without waiting. */} {!stringsLoading && ( @@ -1637,7 +1671,7 @@ function InterlinearizerLoaderInner({ {localizedStrings['%interlinearizer_error_load_book_heading%']} )} -
{bookError}
+
{loadError}
)} @@ -1657,13 +1691,14 @@ function InterlinearizerLoaderInner({ ); const bookArea = - hasError || showLoading || !book ? ( + hasError || showLoading || !book || !pages ? ( loadingOrErrorPanel ) : ( diff --git a/src/components/SegmentListView.tsx b/src/components/SegmentListView.tsx index 141a6bd01..89539b088 100644 --- a/src/components/SegmentListView.tsx +++ b/src/components/SegmentListView.tsx @@ -1,18 +1,28 @@ import { useLocalizedStrings } from '@papi/frontend/react'; import { Canon, type SerializedVerseRef } from '@sillsdev/scripture'; -import type { Book, Segment, Token } from 'interlinearizer'; +import type { Book, Token } from 'interlinearizer'; import { LocateFixed, Merge } from 'lucide-react'; import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'platform-bible-react'; import { formatReplacementString, type LanguageStrings } from 'platform-bible-utils'; -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; import type { Dispatch, SetStateAction } from 'react'; import useHydrationRange from '../hooks/useHydrationRange'; import useSegmentHeights from '../hooks/useSegmentHeights'; -import useSegmentWindow from '../hooks/useSegmentWindow'; +import type { SegmentPages } from '../hooks/useSegmentPages'; +import useSegmentWindow, { EXTEND_CHUNK, findAnchorIndex } from '../hooks/useSegmentWindow'; import type { HeightConfig } from '../utils/segment-heights'; import { offsetOfSegment, segmentIndexAtOffset } from '../utils/segment-heights'; import useLatestRef from '../hooks/useLatestRef'; import type { PhraseMode } from '../types/phrase-mode'; +import type { OutlineSegment } from '../types/segment-pages'; import type { ViewOptions } from '../types/view-options'; import { resolvedOrEmpty, tooltipContentOrUndefined } from '../utils/localized-strings'; import { altHeldSwap } from './alt-key-hint'; @@ -109,8 +119,11 @@ const LIST_STRING_KEYS = [ /** Props for {@link MergeRowButton}. */ type MergeRowButtonProps = Readonly<{ - /** The segment below the gap this button sits in — the one a click joins to its predecessor. */ - segment: Segment; + /** + * First token of the segment below the gap this button sits in — the one a click joins to its + * predecessor. + */ + secondSegmentStartRef: string; /** Resolved {@link LIST_STRING_KEYS}, supplied rather than subscribed to per gap. */ localizedStrings: LanguageStrings; }>; @@ -130,11 +143,8 @@ type MergeRowButtonProps = Readonly<{ * * @returns The fixed-height row gap with its rail and always-enabled merge button. */ -function MergeRowButton({ segment, localizedStrings }: MergeRowButtonProps) { +function MergeRowButton({ secondSegmentStartRef, localizedStrings }: MergeRowButtonProps) { const { dispatch } = useSegmentation(); - const secondSegmentStartRef = segment.tokens[0]?.ref; - /* v8 ignore next -- the caller renders this only for a segment eligible to merge, which requires a token */ - if (secondSegmentStartRef === undefined) return undefined; // Only the tooltip is resolved-or-empty: an unresolved `%…%` localize key would otherwise be // visible hover text. The `aria-label` below keeps the raw value — emptying it would leave the // button with no accessible name at all. @@ -179,16 +189,12 @@ function MergeRowButton({ segment, localizedStrings }: MergeRowButtonProps) { /** Props for {@link SegmentListView}. */ type SegmentListViewProps = Readonly<{ - /** Tokenized book whose segments are windowed and rendered. */ + /** The book's segments, windowed and rendered as their pages load. */ + pages: SegmentPages; + /** The whole segmented book, which the book's stale free translations are placed against. */ book: Book; /** Current scripture reference; its verse is the recenter anchor and active-verse highlight. */ scrRef: SerializedVerseRef; - /** - * Monotonic counter bumped on every boundary edit, so that when the segments identity changes a - * boundary edit (redraw in place) can be told apart from a re-tokenization of the loaded book - * (recenter with a fade). - */ - segmentationVersion: number; /** When true, the horizontal token strip is shown above this list (changes display mode). */ continuousScroll: boolean; /** @@ -241,9 +247,9 @@ type SegmentListViewProps = Readonly<{ * list — which carries the bulk of the scroll/fade/window machinery — in one focused component. */ export default function SegmentListView({ + pages, book, scrRef, - segmentationVersion, continuousScroll, displayContinuousScroll, onDisplayContinuousScrollChange, @@ -269,33 +275,30 @@ export default function SegmentListView({ const recenterTooltip = tooltipContentOrUndefined( resolvedOrEmpty(localizedStrings['%interlinearizer_segmentList_scrollToActiveVerse%']), ); + const { outline } = pages; + // Re-renders the list, and only the list, as the segments' pages load. + useSyncExternalStore(pages.subscribe, pages.loadedVersion); + /** * Inline verse-superscript labels for every segment (chapter-qualified where a verse start opens - * a new chapter), keyed by segment id. Computed over the whole `book.segments` list (not just the - * mounted window) so the qualification is stable regardless of which slice happens to be - * mounted. + * a new chapter), keyed by segment id. Computed over the whole outline (not just the mounted + * window) so the qualification is stable regardless of which slice happens to be mounted. */ - const verseStartLabelsBySegmentId = useMemo( - () => buildVerseStartLabels(book.segments), - [book.segments], - ); + const verseStartLabelsBySegmentId = useMemo(() => buildVerseStartLabels(outline), [outline]); /** * Verse-range gutter label for every segment (`5`, `2–3`, `29–2:1`), keyed by segment id. - * Computed over the whole `book.segments` list (not just the mounted window) so cross-chapter - * ranges resolve the same regardless of which slice is mounted. + * Computed over the whole outline (not just the mounted window) so cross-chapter ranges resolve + * the same regardless of which slice is mounted. */ - const gutterLabelsBySegmentId = useMemo(() => buildSegmentLabels(book.segments), [book.segments]); + const gutterLabelsBySegmentId = useMemo(() => buildSegmentLabels(outline), [outline]); // English book name for the sticky chapter header, e.g. "John" (USJ verse markers carry no book // name; a platform-localized name would need PAPI wiring this view does not yet have). - const bookName = useMemo(() => Canon.bookIdToEnglishName(book.bookRef), [book.bookRef]); + const bookName = useMemo(() => Canon.bookIdToEnglishName(pages.bookRef), [pages.bookRef]); /** Chapter each segment starts in, index-aligned with the book, for the pinned header. */ - const chapterByIndex = useMemo( - () => book.segments.map((seg) => seg.startRef.chapter), - [book.segments], - ); + const chapterByIndex = useMemo(() => outline.map((seg) => seg.startRef.chapter), [outline]); const chapterByIndexRef = useLatestRef(chapterByIndex); /** @@ -310,15 +313,15 @@ export default function SegmentListView({ const { mergeableSegmentIds, mergeableSegmentIndexes } = useMemo(() => { const ids = new Set(); const indexes = new Set(); - const joinsVerseText = (seg: Segment) => seg.tokens.length > 0 && !seg.heading; - book.segments.forEach((seg, i) => { - if (i > 0 && joinsVerseText(book.segments[i - 1]) && joinsVerseText(seg)) { + const joinsVerseText = (seg: OutlineSegment) => seg.tokenCount > 0 && !seg.heading; + outline.forEach((seg, i) => { + if (i > 0 && joinsVerseText(outline[i - 1]) && joinsVerseText(seg)) { ids.add(seg.id); indexes.add(i); } }); return { mergeableSegmentIds: ids, mergeableSegmentIndexes: indexes }; - }, [book.segments]); + }, [outline]); const scrollContainerRef = useRef(undefined); @@ -357,8 +360,8 @@ export default function SegmentListView({ * the editable view renders a one-line input for every segment whatever it holds. */ const freeTranslationText = useCallback( - (index: number) => freeTranslationsBySegment.get(book.segments[index].id), - [freeTranslationsBySegment, book.segments], + (index: number) => freeTranslationsBySegment.get(outline[index].id), + [freeTranslationsBySegment, outline], ); const staleNoText = localizedStrings['%interlinearizer_freeTranslationInput_staleNoText%']; @@ -370,28 +373,28 @@ export default function SegmentListView({ */ const staleReviewTexts = useCallback( (index: number) => { - const segmentId = book.segments[index].id; + const segmentId = outline[index].id; const stale = staleFreeTranslationsBySegment.get(segmentId) ?? []; if (adoptedStaleTranslation(stale, segmentsWithApprovedTranslation.has(segmentId))) { return ['']; } return stale.map((translation) => translation.text || staleNoText); }, - [staleFreeTranslationsBySegment, segmentsWithApprovedTranslation, book.segments, staleNoText], + [staleFreeTranslationsBySegment, segmentsWithApprovedTranslation, outline, staleNoText], ); /** How many orphaned-translation cards render beside a segment, ahead of it or after. */ const orphanedCardCount = useCallback( (index: number) => (index === 0 ? orphanedAhead.length : 0) + - (orphanedAfter.get(book.segments[index].id)?.length ?? 0), - [orphanedAhead, orphanedAfter, book.segments], + (orphanedAfter.get(outline[index].id)?.length ?? 0), + [orphanedAhead, orphanedAfter, outline], ); /** Whether a segment's review rows offer Keep, which they do only while it has no approval. */ const staleReviewOffersKeep = useCallback( - (index: number) => !segmentsWithApprovedTranslation.has(book.segments[index].id), - [segmentsWithApprovedTranslation, book.segments], + (index: number) => !segmentsWithApprovedTranslation.has(outline[index].id), + [segmentsWithApprovedTranslation, outline], ); /** @@ -404,11 +407,11 @@ export default function SegmentListView({ const activeSegmentIndex = useMemo(() => { const focusedSegmentId = focusedTokenRef ? tokenSegmentMap.get(focusedTokenRef) : undefined; const focusedIndex = focusedSegmentId - ? book.segments.findIndex((seg) => seg.id === focusedSegmentId) + ? outline.findIndex((seg) => seg.id === focusedSegmentId) : -1; if (focusedIndex !== -1) return focusedIndex; - return book.segments.findIndex((seg) => segmentContainsVerse(seg, scrRef)); - }, [book.segments, scrRef, focusedTokenRef, tokenSegmentMap]); + return outline.findIndex((seg) => segmentContainsVerse(seg, scrRef)); + }, [outline, scrRef, focusedTokenRef, tokenSegmentMap]); // Heights model the settled layout — every segment at its chip height — never the transient // hydration state. Charging an unhydrated segment its plain-text height instead would move the @@ -439,7 +442,7 @@ export default function SegmentListView({ ); const heightTable = useSegmentHeights({ - book, + segments: outline, config: heightConfig, containerRef: scrollContainerRef, }); @@ -460,9 +463,10 @@ export default function SegmentListView({ contentRef, recenterOnActive, } = useSegmentWindow({ - book, + segments: outline, + tokenSegmentMap, scrRef, - segmentationVersion, + segmentationVersion: pages.segmentationVersion, focusedTokenRef, continuousScroll, scrollContainerRef, @@ -473,6 +477,20 @@ export default function SegmentListView({ onSettled: reportSettled, }); + // Pages load a chunk beyond each edge, so an extend finds its segments already there. + const { load } = pages; + useEffect( + () => load(range.start - EXTEND_CHUNK, range.end + EXTEND_CHUNK), + [load, range.start, range.end], + ); + + // Pages around a verse navigated to load while the list fades out to recenter on it. + const anchorIndex = useMemo(() => findAnchorIndex(outline, scrRef), [outline, scrRef]); + useEffect( + () => load(anchorIndex - EXTEND_CHUNK, anchorIndex + EXTEND_CHUNK + 1), + [load, anchorIndex], + ); + const { hydrated } = useHydrationRange({ table: heightTable, scrollContainerRef, @@ -660,8 +678,9 @@ export default function SegmentListView({ {windowSegments.map((seg, windowIndex) => { /** Index of this segment in the full book, which hydration and heights are keyed on. */ const bookIndex = range.start + windowIndex; + const segment = pages.segmentAt(bookIndex); /* v8 ignore next 2 -- the ?? arm is a defensive fallback for the Map.get type: every - windowed segment comes from book.segments, so the lookup always resolves */ + windowed segment comes from the outline, so the lookup always resolves */ const verseStartLabels = verseStartLabelsBySegmentId.get(seg.id) ?? []; // Merge control renders above every segment whose merge would take effect (see // mergeableSegmentIds). Eligibility is computed over the FULL book, not the mounted @@ -691,33 +710,52 @@ export default function SegmentListView({ {orphanedCards(orphanedAhead)}
)} - {showMergeControl && ( - + {showMergeControl && + (segment ? ( + + ) : ( +