Files
readest/apps/readest-app/src/__tests__/paragraph-mode.test.tsx
T
Huang Xin bfb85c2f68 feat(reader): sync paragraph mode & speed reader with TTS read-along (#3235) (#4576)
* docs(reader): TTS-sync design spec for paragraph mode + RSVP (#3235)

Hardened via brainstorming + /autoplan (CEO/Design/Eng dual-voice review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tts): emit canonical tts-position event from TTSController (#3235)

Controller emits { cfi, kind, sectionIndex, sequence } alongside the existing
tts-highlight-mark/-word events. Monotonic sequence lets downstream consumers
(paragraph mode, RSVP — later slices) drop out-of-order positions. Additive;
existing events untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): containment+cursor CFI->index mappers for TTS sync (#3235)

RSVPController.syncToCfi + setExternallyDriven: containment match (fixes
mid-token skip), monotonic cursor + binary search (avoids O(N)-per-word jank,
no per-word getCFI), -1/no-op on no match (no silent jump to word 0), timer
suspension while externally driven.

ParagraphIterator.findIndexByRange: hinted + binary-search containment mapper
returning -1 on no match (never first()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tts): forward tts-position + tts-playback-state onto the app bus (#3235)

useTTSControl republishes the controller's canonical tts-position (tagged with
bookKey) via a dedicated listener — NOT inside the suppression-gated highlight
handlers, so page-follow suppression can't silently desync the modes. Adds
tts-playback-state (playing/paused/stopped) so RSVP can track playback without
the hook-local isPlaying. Verified by extending the real-foliate-view browser
harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): paragraph mode follows TTS playback (#3235)

When paragraph mode + TTS are both active, the focused paragraph follows the
spoken position (sentence granularity, all engines). Section-generation contract
(stash cross-section position, apply after the iterator re-inits); sync-focus
path that does NOT arm isFocusingRef (avoids the relocate-eaten wrong-section
paragraph-0 bug); stale-sequence drop; decouple on manual nav, re-engage on next
playing. Start-alignment + visible indicator deferred to later slices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(rsvp): speed reader follows TTS playback (#3235)

Edge word-boundary voices: RSVP shows the spoken word via syncToCfi. Non-Edge
(sentence-only) voices: sentence-paced estimator (clamp 60..600 wpm from voice
rate, hold at +60 words cap, snap to first word on each new sentence mark).
RSVP auto-advance suspended while TTS-driven. Decouple on manual nav via a
rsvp-manual-nav signal; re-engage on next playing. Cross-section positions
re-extract then apply. Pure decideRsvpTtsPosition helper unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): fixed-layout gate + ttsSyncStatus for TTS sync (#3235)

Gate sync to reflowable books (D7): fixed-layout reports 'unsupported' and
never engages. Both modes expose ttsSyncStatus (idle/following/syncing/
decoupled/unsupported) as the data source for the upcoming indicator. RSVPControl
now forwardRef-exposes the status via an imperative handle. Cross-bookKey events
ignored (regression-tested).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): 'following audio' indicator for TTS sync (#3235)

5-state pill (following/syncing/decoupled, idle+unsupported render null) shown
top-center in the paragraph overlay and as a status row in the RSVP overlay.
Decoupled state is the tap-to-resume control; first decouple fires a one-time
toast. eink-bordered, glyph+text (no color-only), RTL logical props, touch
targets, safe-area top inset. RSVP 'plain' variant matches its themed surface;
non-Edge shows '· estimated'. New i18n keys need extraction before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(rsvp): in-overlay TTS toggle + audio-paced speed control (#3235)

Voice-glyph audio toggle in the RSVP control row (trailing, by the gear) starts/
stops read-along from inside the full-screen overlay, start-aligned to the current
word (range validated against the live doc). While TTS-driven, the WPM control
shows a locked 'Audio pace' affordance that opens a compact rate picker; rate
changes go through a new tts-set-rate bus event reusing the existing throttled
setRate path. Pure buildRsvpTtsSpeakDetail helper unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(reader): e2e paragraph mode follows TTS across a section boundary (#3235)

Real <foliate-view> browser e2e: with paragraph mode active, the focused
paragraph follows the TTS walk and re-targets to the new section after a Ch4->Ch5
boundary (proves no stuck wrong-section paragraph-0 / isFocusingRef trap).
Asserts on the owning section of the current range. Test-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n(reader): translate TTS-sync strings across 33 locales (#3235)

Following audio / · estimated / Resume audio / Stopped following audio /
Play audio / Pause audio / Audio pace / Speed follows audio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reader): resolve TTS CFI anchors across iframe realms (#3235)

RSVP and paragraph follow silently failed to track the spoken word: the CFI
anchor from view.resolveCFI(...).anchor(doc) is a Range created in the book
iframe's realm, so 'anchor instanceof Range' (top realm) was always false
(cross-realm instanceof) -> resolveCfiToRange/applySyncCfi returned null ->
syncToCfi never advanced. Add isRangeLike() duck-type (cloneRange is unique to
Range) and use it at all 4 CFI-resolution sites. Confirmed live via CDP: before
= syncToCfi false (frozen); after = exact word map + RSVP follows Edge TTS at
~171 wpm (audio pace). Unit tests reproduce the cross-realm anchor (jsdom is
single-realm so the old code passed there but died in the app).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rsvp): stop estimator/word fight + map transport to TTS play/pause (#3235)

Two read-along refinements (verified live via CDP with Edge TTS):

1. No more jump-ahead-then-snap-back flashing. Word-boundary engines (Edge) emit
   BOTH sentence marks and word boundaries; RSVP was routing sentence -> the
   estimator (self-paces ~190xrate, up to +60 words ahead) while word positions
   snapped it back. Now once a word position is seen, sentence positions are
   ignored and any running estimator is stopped, so words alone drive RSVP.

2. The RSVP transport (center play/pause, Space, center-tap) maps to TTS
   play/pause while read-along is engaged (tts-toggle-play), instead of RSVP's
   own suspended timer. Pausing TTS keeps RSVP suspended (no runaway); a full
   stop releases it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rsvp): keep indicator on pause + reach dict management from RSVP (#3235)

- Pausing read-along no longer dismisses the 'following audio' indicator / 'Audio
  pace' lock (layout shift). New 'paused' sync status keeps the indicator row and
  WPM lock present while TTS is engaged-but-paused; only a full stop clears them.
  Verified live via CDP: pause keeps the layout, no shift.
- Dict management is reachable from RSVP: the settings dialog is z-50, far below
  the full-screen RSVP overlay (z-[10000]), so it opened invisibly behind it.
  handleManageDictionary now exits RSVP first (position saved/resumable) so
  management shows over the reader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rsvp): show dict management over RSVP instead of exiting it (#3235)

Per feedback: opening dictionary management from the RSVP lookup popup no longer
closes the speed reader. The settings dialog is raised above the full-screen RSVP
overlay (z-[10000] -> SettingsDialog !z-[10050]) so it shows on top, and RSVP's
capture-phase keyboard handler bails while the settings dialog is open so its
inputs accept Space and Escape closes settings (not RSVP). Verified live via CDP:
management opens over RSVP, RSVP stays active behind it, Escape returns to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dict): only apply drag-handle margin compensation when the handle shows (#3235)

The dictionary sheet header used -mt-4 to compensate for Dialog's drag handle,
but that handle is sm:hidden (shown only below sm). On sm+ the handle is
display:none, so -mt-4 pulled the header up into the top edge (broken layout
when the lookup renders as a sheet on a short/wide window). Mirror the handle's
breakpoint: -mt-4 sm:mt-0. Verified live via CDP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): in-mode TTS audio toggle for paragraph mode (#3235)

Paragraph mode already follows TTS, but there was no way to start read-along
from inside it. Add an audio toggle to the ParagraphBar (mirroring RSVP's): it
starts TTS start-aligned to the focused paragraph (range validated live, +
section index) and stops it. Track session-active vs playing so a pause keeps
the indicator ('paused' status) instead of collapsing to idle. Pure
buildParagraphTtsSpeakDetail helper unit-tested. Verified live via CDP: tapping
the icon starts audio from the focused paragraph and the focus follows speech.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): highlight current TTS word/sentence in paragraph mode (#3235)

Paragraph mode follows TTS by advancing the focused paragraph, but the spoken
word wasn't highlighted within it like normal mode. The overlay renders a CLONE
of the paragraph, so the iframe's TTS highlight isn't visible there — reproduce
it on the clone with the CSS Custom Highlight API (no DOM mutation, spans inline
boundaries natively, leaves the fade-in animation untouched).

- TTSController already tags tts-position with kind word|sentence. The hook
  decides granularity: word boundaries (Edge) drive a per-word highlight; once
  seen, the coarse sentence event is skipped so the whole sentence doesn't
  flicker over the current word. Engines without word boundaries
  (WebSpeech/Native) fall back to the sentence highlight.
- Offsets are computed relative to the paragraph start (so they map 1:1 onto the
  clone's text) and tagged with the paragraph index so a stale highlight never
  paints the wrong paragraph. Cleared on stop / section change / disabled.
- The ::highlight() style mirrors the user's ttsHighlightOptions color+style.

Pure helpers (offset math, word/sentence decision, css builder) unit-tested.
Verified live via CDP: word highlight tracks Edge word-by-word and follows
across paragraph boundaries (news -> ... -> ladies), matching the TTS color.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:46:18 +02:00

891 lines
29 KiB
TypeScript

import React from 'react';
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ParagraphOverlay from '@/app/reader/components/paragraph/ParagraphOverlay';
import { useParagraphMode } from '@/app/reader/hooks/useParagraphMode';
import type { FoliateView } from '@/types/view';
import { eventDispatcher } from '@/utils/event';
import {
getParagraphActionForKey,
getParagraphActionForZone,
getParagraphPresentation,
} from '@/utils/paragraphPresentation';
const currentViewSettings = {
paragraphMode: { enabled: true },
writingMode: 'horizontal-tb',
vertical: false,
rtl: false,
};
const mockGetViewSettings = vi.fn(() => currentViewSettings);
const mockSetViewSettings = vi.fn();
const mockGetProgress = vi.fn(() => null);
let mockIsFixedLayout = false;
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: () => ({
getBookData: () => ({ isFixedLayout: mockIsFixedLayout }),
}),
}));
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ envConfig: {}, appService: { hasSafeAreaInset: false } }),
}));
vi.mock('@/helpers/settings', () => ({
saveViewSettings: vi.fn(),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({
getViewSettings: mockGetViewSettings,
setViewSettings: mockSetViewSettings,
getProgress: mockGetProgress,
}),
}));
global.ResizeObserver = class ResizeObserver {
constructor(private readonly callback: ResizeObserverCallback) {}
observe(target: Element) {
this.callback([{ target } as ResizeObserverEntry], this);
}
disconnect() {}
unobserve() {}
} as typeof ResizeObserver;
const createDoc = (body: string): Document =>
new DOMParser().parseFromString(`<html><body>${body}</body></html>`, 'text/html');
const attachDefaultView = (
doc: Document,
getComputedStyle: (element: Element) => CSSStyleDeclaration,
) => {
Object.defineProperty(doc, 'defaultView', {
value: { getComputedStyle },
configurable: true,
});
};
function createMockView(docs: Document[], initialPrimaryIndex: number) {
const contents = docs.map((doc, index) => ({ doc, index }));
const renderer = {
primaryIndex: initialPrimaryIndex,
getContents: vi.fn(() => contents),
nextSection: vi.fn(async () => {
renderer.primaryIndex = Math.min(renderer.primaryIndex + 1, contents.length - 1);
}),
prevSection: vi.fn(async () => {
renderer.primaryIndex = Math.max(renderer.primaryIndex - 1, 0);
}),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
goTo: vi.fn(),
scrollToAnchor: vi.fn(),
};
const view = {
renderer,
resolveCFI: vi.fn(),
getCFI: vi.fn(() => 'epubcfi(/6/4!/4/2/1:0)'),
} as unknown as FoliateView;
return { view, renderer };
}
let hookApi: ReturnType<typeof useParagraphMode> | null = null;
const HookHarness = ({ view }: { view: React.RefObject<FoliateView | null> }) => {
hookApi = useParagraphMode({ bookKey: 'book-1', viewRef: view });
return null;
};
describe('paragraph mode', () => {
beforeEach(() => {
vi.clearAllMocks();
hookApi = null;
mockIsFixedLayout = false;
currentViewSettings.writingMode = 'horizontal-tb';
currentViewSettings.vertical = false;
currentViewSettings.rtl = false;
});
afterEach(() => {
cleanup();
});
it('preserves source presentation and navigation rules', () => {
const verticalDoc = createDoc('<p lang="ja">縦書きの段落です。</p>');
const verticalParagraph = verticalDoc.querySelector('p')!;
const verticalRange = verticalDoc.createRange();
verticalRange.selectNodeContents(verticalParagraph);
attachDefaultView(verticalDoc, (element: Element) => {
if (element === verticalParagraph || element === verticalDoc.body) {
return {
writingMode: 'vertical-rl',
direction: 'ltr',
textOrientation: 'upright',
unicodeBidi: 'plaintext',
textAlign: 'start',
} as CSSStyleDeclaration;
}
return {
writingMode: 'horizontal-tb',
direction: 'ltr',
} as CSSStyleDeclaration;
});
const arabicDoc = createDoc('<p dir="rtl">هذا نص عربي</p>');
const arabicParagraph = arabicDoc.querySelector('p')!;
const arabicRange = arabicDoc.createRange();
arabicRange.selectNodeContents(arabicParagraph);
attachDefaultView(
arabicDoc,
() =>
({
writingMode: 'horizontal-tb',
direction: 'rtl',
textAlign: 'start',
}) as CSSStyleDeclaration,
);
expect(getParagraphPresentation(verticalDoc, verticalRange)).toEqual(
expect.objectContaining({
lang: 'ja',
dir: 'ltr',
writingMode: 'vertical-rl',
vertical: true,
}),
);
expect(getParagraphPresentation(arabicDoc, arabicRange)).toEqual(
expect.objectContaining({
dir: 'rtl',
rtl: true,
}),
);
expect(getParagraphActionForZone('left', { rtl: true, vertical: false })).toBe('next');
expect(getParagraphActionForZone('top', { vertical: true, writingMode: 'vertical-rl' })).toBe(
'prev',
);
expect(getParagraphActionForKey('ArrowLeft', { rtl: true, vertical: false })).toBe('next');
expect(
getParagraphActionForKey('ArrowLeft', { vertical: true, writingMode: 'vertical-rl' }),
).toBe('next');
});
it('uses the active primary section when moving across chapter boundaries', async () => {
const previousChapterDoc = createDoc('<p>Old chapter ending</p>');
const nextChapterDoc = createDoc('<h1>Chapter 2</h1><p>First paragraph</p>');
const { view, renderer } = createMockView([previousChapterDoc, nextChapterDoc], 0);
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
render(<HookHarness view={viewRef} />);
await waitFor(() => {
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Old chapter ending');
});
await act(async () => {
await hookApi?.goToNextParagraph();
});
await waitFor(() => {
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Chapter 2');
});
expect(renderer.nextSection).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(renderer.goTo).toHaveBeenLastCalledWith(expect.objectContaining({ index: 1 }));
});
});
it('renders preserved presentation and layout-aware click zones in the overlay', async () => {
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
const overlayBookKey = 'overlay-book';
const doc = createDoc('<p>مرحبا بالعالم</p>');
const paragraph = doc.querySelector('p')!;
const range = doc.createRange();
range.selectNodeContents(paragraph);
const { container } = render(
<ParagraphOverlay
bookKey={overlayBookKey}
dimOpacity={0.3}
viewSettings={{ writingMode: 'horizontal-tb', vertical: false, rtl: true } as never}
/>,
);
await act(async () => {
await eventDispatcher.dispatch('paragraph-focus', {
bookKey: overlayBookKey,
range,
presentation: {
lang: 'ja',
dir: 'ltr',
writingMode: 'vertical-rl',
textOrientation: 'upright',
vertical: true,
rtl: true,
},
});
});
const paragraphContent = await waitFor(() => {
const node = container.querySelector('.paragraph-content') as HTMLDivElement | null;
expect(node).not.toBeNull();
return node!;
});
expect(paragraphContent.getAttribute('lang')).toBe('ja');
expect(paragraphContent.style.writingMode).toBe('vertical-rl');
dispatchSpy.mockClear();
const contentArea = container.querySelector('.relative.flex') as HTMLDivElement;
vi.spyOn(contentArea, 'getBoundingClientRect').mockReturnValue({
width: 300,
height: 300,
top: 0,
left: 0,
right: 300,
bottom: 300,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect);
fireEvent.click(contentArea, { clientX: 150, clientY: 20 });
await waitFor(() => {
expect(dispatchSpy).toHaveBeenCalledWith('paragraph-prev', { bookKey: overlayBookKey });
});
await act(async () => {
await eventDispatcher.dispatch('paragraph-focus', {
bookKey: overlayBookKey,
range,
presentation: {
dir: 'rtl',
writingMode: 'horizontal-tb',
vertical: false,
rtl: true,
},
});
});
dispatchSpy.mockClear();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 320));
});
fireEvent.click(contentArea, { clientX: 40, clientY: 150 });
await waitFor(() => {
expect(dispatchSpy).toHaveBeenCalledWith('paragraph-next', { bookKey: overlayBookKey });
});
});
const renderVisibleOverlay = async (onClose: () => void) => {
const overlayBookKey = 'overlay-book';
const doc = createDoc('<p>Hello world</p>');
const paragraph = doc.querySelector('p')!;
const range = doc.createRange();
range.selectNodeContents(paragraph);
const { container } = render(
<ParagraphOverlay
bookKey={overlayBookKey}
dimOpacity={0.3}
viewSettings={{ writingMode: 'horizontal-tb', vertical: false, rtl: false } as never}
onClose={onClose}
/>,
);
await act(async () => {
await eventDispatcher.dispatch('paragraph-focus', {
bookKey: overlayBookKey,
range,
presentation: { dir: 'ltr', writingMode: 'horizontal-tb', vertical: false, rtl: false },
});
});
return { container, overlayBookKey };
};
const mockContentRect = (contentArea: HTMLElement) =>
vi.spyOn(contentArea, 'getBoundingClientRect').mockReturnValue({
width: 300,
height: 300,
top: 0,
left: 0,
right: 300,
bottom: 300,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect);
it('reveals the controls instead of exiting when the backdrop is tapped', async () => {
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
const onClose = vi.fn();
const { container, overlayBookKey } = await renderVisibleOverlay(onClose);
const dialog = await waitFor(() => {
const node = container.querySelector('[role="dialog"]') as HTMLDivElement | null;
expect(node).not.toBeNull();
return node!;
});
dispatchSpy.mockClear();
fireEvent.click(dialog);
expect(onClose).not.toHaveBeenCalled();
expect(dispatchSpy).toHaveBeenCalledWith('paragraph-show-controls', {
bookKey: overlayBookKey,
});
});
it('reveals the controls instead of exiting when the center zone is tapped', async () => {
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
const onClose = vi.fn();
const { container, overlayBookKey } = await renderVisibleOverlay(onClose);
const contentArea = container.querySelector('.relative.flex') as HTMLDivElement;
mockContentRect(contentArea);
dispatchSpy.mockClear();
fireEvent.click(contentArea, { clientX: 150, clientY: 150 });
expect(onClose).not.toHaveBeenCalled();
expect(dispatchSpy).toHaveBeenCalledWith('paragraph-show-controls', {
bookKey: overlayBookKey,
});
expect(dispatchSpy).not.toHaveBeenCalledWith('paragraph-next', { bookKey: overlayBookKey });
expect(dispatchSpy).not.toHaveBeenCalledWith('paragraph-prev', { bookKey: overlayBookKey });
});
it('still exits on a double-tap of the paragraph', async () => {
const onClose = vi.fn();
const { container } = await renderVisibleOverlay(onClose);
const contentArea = container.querySelector('.relative.flex') as HTMLDivElement;
mockContentRect(contentArea);
fireEvent.click(contentArea, { clientX: 150, clientY: 150 });
fireEvent.click(contentArea, { clientX: 150, clientY: 150 });
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe('paragraph mode TTS sync', () => {
beforeEach(() => {
vi.clearAllMocks();
hookApi = null;
mockIsFixedLayout = false;
currentViewSettings.writingMode = 'horizontal-tb';
currentViewSettings.vertical = false;
currentViewSettings.rtl = false;
});
afterEach(() => {
cleanup();
});
// Multi-paragraph doc: ParagraphIterator turns each <p> into one block, so
// block N corresponds to the Nth <p>.
const createMultiParagraphDoc = () =>
createDoc('<p>Block zero</p><p>Block one</p><p>Block two</p>');
// Mock view.resolveCFI so a given cfi resolves into the Nth <p> of the doc at
// `sectionIndex`. The hook anchors the current section's doc, so `anchor(doc)`
// returns a Range selecting the target paragraph's contents.
const stubResolveCFI = (
view: FoliateView,
mapping: Record<string, { sectionIndex: number; paragraphIndex: number }>,
) => {
(view.resolveCFI as ReturnType<typeof vi.fn>).mockImplementation((cfi: string) => {
const target = mapping[cfi];
if (!target) return null;
return {
index: target.sectionIndex,
anchor: (doc: Document) => {
const paragraph = doc.querySelectorAll('p')[target.paragraphIndex];
if (!paragraph) return null;
const range = doc.createRange();
range.selectNodeContents(paragraph);
return range;
},
};
});
};
const dispatchPlaying = async (bookKey: string) => {
await act(async () => {
await eventDispatcher.dispatch('tts-playback-state', { bookKey, state: 'playing' });
});
};
const dispatchPosition = async (detail: {
bookKey: string;
cfi: string;
kind: 'word' | 'sentence';
sectionIndex: number;
sequence: number;
}) => {
await act(async () => {
await eventDispatcher.dispatch('tts-position', detail);
});
};
it('follows TTS to the spoken paragraph in the same section', async () => {
const doc = createMultiParagraphDoc();
const { view } = createMockView([doc], 0);
stubResolveCFI(view, { 'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 } });
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
render(<HookHarness view={viewRef} />);
await waitFor(() => {
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Block zero');
});
expect(hookApi?.paragraphState.currentIndex).toBe(0);
await dispatchPlaying('book-1');
await dispatchPosition({
bookKey: 'book-1',
cfi: 'cfi-block-2',
kind: 'sentence',
sectionIndex: 0,
sequence: 1,
});
await waitFor(() => {
expect(hookApi?.paragraphState.currentIndex).toBe(2);
});
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Block two');
});
it('ignores tts-position events with a stale (<=) sequence', async () => {
const doc = createMultiParagraphDoc();
const { view } = createMockView([doc], 0);
stubResolveCFI(view, {
'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 },
'cfi-block-1': { sectionIndex: 0, paragraphIndex: 1 },
});
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
render(<HookHarness view={viewRef} />);
await waitFor(() => {
expect(hookApi?.paragraphState.currentIndex).toBe(0);
});
await dispatchPlaying('book-1');
await dispatchPosition({
bookKey: 'book-1',
cfi: 'cfi-block-2',
kind: 'sentence',
sectionIndex: 0,
sequence: 5,
});
await waitFor(() => {
expect(hookApi?.paragraphState.currentIndex).toBe(2);
});
// A later-arriving but older-sequence event must be dropped.
await dispatchPosition({
bookKey: 'book-1',
cfi: 'cfi-block-1',
kind: 'sentence',
sectionIndex: 0,
sequence: 3,
});
expect(hookApi?.paragraphState.currentIndex).toBe(2);
// An equal sequence is also stale.
await dispatchPosition({
bookKey: 'book-1',
cfi: 'cfi-block-1',
kind: 'sentence',
sectionIndex: 0,
sequence: 5,
});
expect(hookApi?.paragraphState.currentIndex).toBe(2);
});
it('decouples on manual nav and re-engages on the next playing state', async () => {
const doc = createMultiParagraphDoc();
const { view } = createMockView([doc], 0);
stubResolveCFI(view, {
'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 },
'cfi-block-0': { sectionIndex: 0, paragraphIndex: 0 },
});
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
render(<HookHarness view={viewRef} />);
await waitFor(() => {
expect(hookApi?.paragraphState.currentIndex).toBe(0);
});
await dispatchPlaying('book-1');
// Manual nav decouples: paragraph stops following TTS.
await act(async () => {
await hookApi?.goToNextParagraph();
});
expect(hookApi?.paragraphState.currentIndex).toBe(1);
// While decoupled, tts-position is ignored (no focus change).
await dispatchPosition({
bookKey: 'book-1',
cfi: 'cfi-block-2',
kind: 'sentence',
sectionIndex: 0,
sequence: 10,
});
expect(hookApi?.paragraphState.currentIndex).toBe(1);
// Re-engage via a fresh 'playing' state, then follow again.
await dispatchPlaying('book-1');
await dispatchPosition({
bookKey: 'book-1',
cfi: 'cfi-block-2',
kind: 'sentence',
sectionIndex: 0,
sequence: 11,
});
await waitFor(() => {
expect(hookApi?.paragraphState.currentIndex).toBe(2);
});
});
it('stashes a cross-section tts-position and applies it after the iterator re-inits', async () => {
const sectionZeroDoc = createDoc('<p>S0 first</p><p>S0 second</p>');
const sectionOneDoc = createDoc('<p>S1 first</p><p>S1 second</p><p>S1 third</p>');
const { view, renderer } = createMockView([sectionZeroDoc, sectionOneDoc], 0);
stubResolveCFI(view, { 'cfi-s1-block-2': { sectionIndex: 1, paragraphIndex: 2 } });
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
render(<HookHarness view={viewRef} />);
await waitFor(() => {
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('S0 first');
});
// Capture the relocate handler the hook registered with the renderer.
const relocateCall = (renderer.addEventListener as ReturnType<typeof vi.fn>).mock.calls.find(
([eventName]) => eventName === 'relocate',
);
const handleRelocate = relocateCall?.[1] as () => void;
expect(handleRelocate).toBeTypeOf('function');
await dispatchPlaying('book-1');
// A tts-position for section 1 while we are on section 0: must NOT map yet.
await dispatchPosition({
bookKey: 'book-1',
cfi: 'cfi-s1-block-2',
kind: 'sentence',
sectionIndex: 1,
sequence: 20,
});
// Still on section 0, focus unchanged (no cross-section mapping).
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('S0 first');
// Let the initial mount-focus isFocusingRef window (200ms) expire so the
// relocate below isn't eaten by an unrelated guard.
await act(async () => {
await new Promise((r) => setTimeout(r, 250));
});
// TTS drives the view into section 1; the existing relocate handler re-inits
// the iterator for the new section, after which the stashed CFI applies.
renderer.primaryIndex = 1;
await act(async () => {
handleRelocate();
await new Promise((r) => setTimeout(r, 250));
});
await waitFor(() => {
expect(hookApi?.paragraphState.currentIndex).toBe(2);
});
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('S1 third');
});
it('does not arm the isFocusingRef guard on a TTS-driven sync focus', async () => {
const sectionZeroDoc = createDoc('<p>S0 first</p><p>S0 second</p><p>S0 third</p>');
const sectionOneDoc = createDoc('<p>S1 first</p><p>S1 second</p>');
const { view, renderer } = createMockView([sectionZeroDoc, sectionOneDoc], 0);
stubResolveCFI(view, {
'cfi-s0-block-2': { sectionIndex: 0, paragraphIndex: 2 },
'cfi-s1-block-1': { sectionIndex: 1, paragraphIndex: 1 },
});
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
render(<HookHarness view={viewRef} />);
await waitFor(() => {
expect(hookApi?.paragraphState.currentIndex).toBe(0);
});
const relocateCall = (renderer.addEventListener as ReturnType<typeof vi.fn>).mock.calls.find(
([eventName]) => eventName === 'relocate',
);
const handleRelocate = relocateCall?.[1] as () => void;
// Drain the initial mount-focus isFocusingRef window (200ms) so only the
// sync focus under test can possibly arm the guard.
await act(async () => {
await new Promise((r) => setTimeout(r, 250));
});
await dispatchPlaying('book-1');
// A TTS-driven sync focus within the same section must NOT arm the focusing
// guard; otherwise the next relocate (a TTS-driven section change) would be
// swallowed and the iterator would never re-init for the new section.
await dispatchPosition({
bookKey: 'book-1',
cfi: 'cfi-s0-block-2',
kind: 'sentence',
sectionIndex: 0,
sequence: 30,
});
await waitFor(() => {
expect(hookApi?.paragraphState.currentIndex).toBe(2);
});
// Let the sync focus fully settle (its scroll runs after a rAF) so that IF
// it (wrongly) armed isFocusingRef, the guard would be set and persist by
// the time the relocate below fires.
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});
// A cross-section tts-position stashes; the following relocate for section 1
// must still re-init. If the sync focus above had armed isFocusingRef, this
// relocate would be eaten and the iterator would never re-init -> still
// section 0.
await dispatchPosition({
bookKey: 'book-1',
cfi: 'cfi-s1-block-1',
kind: 'sentence',
sectionIndex: 1,
sequence: 31,
});
renderer.primaryIndex = 1;
await act(async () => {
handleRelocate();
await new Promise((r) => setTimeout(r, 250));
});
await waitFor(() => {
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('S1 second');
});
expect(hookApi?.paragraphState.currentIndex).toBe(1);
});
it('does not follow TTS and reports unsupported for a fixed-layout book', async () => {
mockIsFixedLayout = true;
const doc = createMultiParagraphDoc();
const { view } = createMockView([doc], 0);
stubResolveCFI(view, { 'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 } });
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
render(<HookHarness view={viewRef} />);
await waitFor(() => {
expect(hookApi?.paragraphState.currentIndex).toBe(0);
});
expect(hookApi?.ttsSyncStatus).toBe('unsupported');
await dispatchPlaying('book-1');
await dispatchPosition({
bookKey: 'book-1',
cfi: 'cfi-block-2',
kind: 'sentence',
sectionIndex: 0,
sequence: 1,
});
// Fixed-layout never follows: focus stays on the first paragraph.
expect(hookApi?.paragraphState.currentIndex).toBe(0);
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Block zero');
expect(hookApi?.ttsSyncStatus).toBe('unsupported');
});
it('derives ttsSyncStatus through the follow lifecycle (reflowable)', async () => {
const doc = createMultiParagraphDoc();
const { view } = createMockView([doc, createMultiParagraphDoc()], 0);
stubResolveCFI(view, {
'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 },
'cfi-s1-block-1': { sectionIndex: 1, paragraphIndex: 1 },
});
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
render(<HookHarness view={viewRef} />);
await waitFor(() => {
expect(hookApi?.paragraphState.currentIndex).toBe(0);
});
// Initial: idle (TTS not engaged).
expect(hookApi?.ttsSyncStatus).toBe('idle');
// Playing -> following.
await dispatchPlaying('book-1');
await waitFor(() => {
expect(hookApi?.ttsSyncStatus).toBe('following');
});
// Manual nav -> decoupled (TTS still playing).
await act(async () => {
await hookApi?.goToNextParagraph();
});
await waitFor(() => {
expect(hookApi?.ttsSyncStatus).toBe('decoupled');
});
// Re-engage, then a cross-section position (before re-init) -> syncing.
await dispatchPlaying('book-1');
await waitFor(() => {
expect(hookApi?.ttsSyncStatus).toBe('following');
});
await dispatchPosition({
bookKey: 'book-1',
cfi: 'cfi-s1-block-1',
kind: 'sentence',
sectionIndex: 1,
sequence: 40,
});
await waitFor(() => {
expect(hookApi?.ttsSyncStatus).toBe('syncing');
});
// Stopped -> idle.
await act(async () => {
await eventDispatcher.dispatch('tts-playback-state', {
bookKey: 'book-1',
state: 'stopped',
});
});
await waitFor(() => {
expect(hookApi?.ttsSyncStatus).toBe('idle');
});
});
it('toggleTtsAudio starts TTS aligned to the focused paragraph when idle', async () => {
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
const doc = createMultiParagraphDoc();
const { view } = createMockView([doc], 0);
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
render(<HookHarness view={viewRef} />);
await waitFor(() => {
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Block zero');
});
expect(hookApi?.ttsActive).toBe(false);
dispatchSpy.mockClear();
act(() => {
hookApi?.toggleTtsAudio();
});
const speakCall = dispatchSpy.mock.calls.find(([name]) => name === 'tts-speak');
expect(speakCall).toBeDefined();
const detail = speakCall![1] as { bookKey: string; index?: number; range?: Range };
expect(detail.bookKey).toBe('book-1');
// Start-aligned to the focused paragraph: section index + live range.
expect(detail.index).toBe(0);
expect(detail.range?.toString()).toContain('Block zero');
});
it('toggleTtsAudio stops TTS when a session is active', async () => {
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
const doc = createMultiParagraphDoc();
const { view } = createMockView([doc], 0);
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
render(<HookHarness view={viewRef} />);
await waitFor(() => {
expect(hookApi?.paragraphState.currentIndex).toBe(0);
});
// A playing session makes the toggle a stop.
await dispatchPlaying('book-1');
await waitFor(() => {
expect(hookApi?.ttsActive).toBe(true);
});
dispatchSpy.mockClear();
act(() => {
hookApi?.toggleTtsAudio();
});
expect(dispatchSpy).toHaveBeenCalledWith('tts-stop', { bookKey: 'book-1' });
expect(dispatchSpy).not.toHaveBeenCalledWith('tts-speak', expect.anything());
});
it('keeps ttsActive and reports paused on a TTS pause; clears on stop', async () => {
const doc = createMultiParagraphDoc();
const { view } = createMockView([doc], 0);
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
render(<HookHarness view={viewRef} />);
await waitFor(() => {
expect(hookApi?.paragraphState.currentIndex).toBe(0);
});
await dispatchPlaying('book-1');
await waitFor(() => {
expect(hookApi?.ttsActive).toBe(true);
expect(hookApi?.ttsSyncStatus).toBe('following');
});
// Pause keeps the session active and persists the indicator as 'paused'.
await act(async () => {
await eventDispatcher.dispatch('tts-playback-state', { bookKey: 'book-1', state: 'paused' });
});
await waitFor(() => {
expect(hookApi?.ttsActive).toBe(true);
expect(hookApi?.ttsSyncStatus).toBe('paused');
});
// A full stop clears the session and returns to idle.
await act(async () => {
await eventDispatcher.dispatch('tts-playback-state', { bookKey: 'book-1', state: 'stopped' });
});
await waitFor(() => {
expect(hookApi?.ttsActive).toBe(false);
expect(hookApi?.ttsSyncStatus).toBe('idle');
});
});
it('ignores tts events for a different bookKey and keeps status unchanged', async () => {
const doc = createMultiParagraphDoc();
const { view } = createMockView([doc], 0);
stubResolveCFI(view, { 'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 } });
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
render(<HookHarness view={viewRef} />);
await waitFor(() => {
expect(hookApi?.paragraphState.currentIndex).toBe(0);
});
expect(hookApi?.ttsSyncStatus).toBe('idle');
// Playback + position for a DIFFERENT book: must be ignored.
await dispatchPlaying('other-book');
await dispatchPosition({
bookKey: 'other-book',
cfi: 'cfi-block-2',
kind: 'sentence',
sectionIndex: 0,
sequence: 1,
});
expect(hookApi?.paragraphState.currentIndex).toBe(0);
expect(hookApi?.ttsSyncStatus).toBe('idle');
});
});