* 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>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import {
|
||||
computeParagraphHighlightOffsets,
|
||||
decideParagraphTtsHighlight,
|
||||
buildTtsHighlightCssText,
|
||||
} from '@/app/reader/components/paragraph/paragraphTts';
|
||||
|
||||
// Build a paragraph element and return both the element and its
|
||||
// selectNodeContents range (what the iterator hands paragraph mode).
|
||||
const makeParagraph = (html: string) => {
|
||||
const p = document.createElement('p');
|
||||
p.innerHTML = html;
|
||||
document.body.appendChild(p);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(p);
|
||||
return { p, range };
|
||||
};
|
||||
|
||||
const rangeInNode = (node: Node, start: number, end: number) => {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, start);
|
||||
range.setEnd(node, end);
|
||||
return range;
|
||||
};
|
||||
|
||||
describe('computeParagraphHighlightOffsets (#3235)', () => {
|
||||
test('word in a single text node → character offsets relative to paragraph start', () => {
|
||||
const { p, range } = makeParagraph('Hello world foo');
|
||||
const text = p.firstChild!; // "Hello world foo"
|
||||
const target = rangeInNode(text, 6, 11); // "world"
|
||||
|
||||
expect(computeParagraphHighlightOffsets(range, target)).toEqual({ start: 6, end: 11 });
|
||||
});
|
||||
|
||||
test('word at the very start of the paragraph → start 0', () => {
|
||||
const { p, range } = makeParagraph('Hello world');
|
||||
const target = rangeInNode(p.firstChild!, 0, 5); // "Hello"
|
||||
|
||||
expect(computeParagraphHighlightOffsets(range, target)).toEqual({ start: 0, end: 5 });
|
||||
});
|
||||
|
||||
test('word inside a nested inline element → offsets across node boundaries', () => {
|
||||
// "Hello " (6) + "brave" (in <b>) + " world"
|
||||
const { p, range } = makeParagraph('Hello <b>brave</b> world');
|
||||
const bold = p.querySelector('b')!;
|
||||
const target = rangeInNode(bold.firstChild!, 0, 5); // "brave"
|
||||
|
||||
expect(computeParagraphHighlightOffsets(range, target)).toEqual({ start: 6, end: 11 });
|
||||
});
|
||||
|
||||
test('word after a nested inline element counts the inline text', () => {
|
||||
const { p, range } = makeParagraph('Hello <b>brave</b> world');
|
||||
const tail = p.childNodes[2]!; // " world" text node
|
||||
const target = rangeInNode(tail, 1, 6); // "world"
|
||||
|
||||
expect(computeParagraphHighlightOffsets(range, target)).toEqual({ start: 12, end: 17 });
|
||||
});
|
||||
|
||||
test('target outside the paragraph → null', () => {
|
||||
const { range } = makeParagraph('Hello world');
|
||||
const other = makeParagraph('Different paragraph');
|
||||
const target = rangeInNode(other.p.firstChild!, 0, 9); // "Different"
|
||||
|
||||
expect(computeParagraphHighlightOffsets(range, target)).toBeNull();
|
||||
});
|
||||
|
||||
test('collapsed / empty target → null', () => {
|
||||
const { p, range } = makeParagraph('Hello world');
|
||||
const target = rangeInNode(p.firstChild!, 3, 3); // empty
|
||||
|
||||
expect(computeParagraphHighlightOffsets(range, target)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideParagraphTtsHighlight (#3235)', () => {
|
||||
test('word boundary event → highlight the word', () => {
|
||||
expect(decideParagraphTtsHighlight({ kind: 'word', hasWordPositions: false })).toBe('word');
|
||||
expect(decideParagraphTtsHighlight({ kind: 'word', hasWordPositions: true })).toBe('word');
|
||||
});
|
||||
|
||||
test('sentence event with no word boundaries seen → highlight the sentence', () => {
|
||||
expect(decideParagraphTtsHighlight({ kind: 'sentence', hasWordPositions: false })).toBe(
|
||||
'sentence',
|
||||
);
|
||||
});
|
||||
|
||||
test('sentence event after words seen → skip (words drive the fine-grained highlight)', () => {
|
||||
expect(decideParagraphTtsHighlight({ kind: 'sentence', hasWordPositions: true })).toBe('skip');
|
||||
});
|
||||
|
||||
test('unknown kind falls back to sentence granularity', () => {
|
||||
expect(decideParagraphTtsHighlight({ hasWordPositions: false })).toBe('sentence');
|
||||
expect(decideParagraphTtsHighlight({ hasWordPositions: true })).toBe('skip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTtsHighlightCssText (#3235)', () => {
|
||||
test('defaults to a translucent background using the default color', () => {
|
||||
const css = buildTtsHighlightCssText(undefined);
|
||||
expect(css).toContain('background-color');
|
||||
expect(css).toContain('#808080');
|
||||
});
|
||||
|
||||
test('highlight style uses a translucent background of the configured color', () => {
|
||||
const css = buildTtsHighlightCssText({ style: 'highlight', color: '#ffcc00' });
|
||||
expect(css).toContain('color-mix');
|
||||
expect(css).toContain('#ffcc00');
|
||||
});
|
||||
|
||||
test('underline style uses text-decoration underline', () => {
|
||||
const css = buildTtsHighlightCssText({ style: 'underline', color: '#3366ff' });
|
||||
expect(css).toContain('text-decoration');
|
||||
expect(css).toContain('underline');
|
||||
expect(css).toContain('#3366ff');
|
||||
});
|
||||
|
||||
test('squiggly style uses a wavy underline', () => {
|
||||
expect(buildTtsHighlightCssText({ style: 'squiggly', color: '#ff0000' })).toContain('wavy');
|
||||
});
|
||||
|
||||
test('strikethrough style uses line-through', () => {
|
||||
expect(buildTtsHighlightCssText({ style: 'strikethrough', color: '#ff0000' })).toContain(
|
||||
'line-through',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { buildParagraphTtsSpeakDetail } from '@/app/reader/components/paragraph/paragraphTts';
|
||||
|
||||
const BOOK_KEY = 'hash123-session456';
|
||||
|
||||
// Build a real DOM range inside a given document so ownerDocument validation is
|
||||
// exercised against an actual node (not a mock).
|
||||
const makeRange = (doc: Document, text = 'paragraph'): Range => {
|
||||
const p = doc.createElement('p');
|
||||
p.textContent = text;
|
||||
doc.body.appendChild(p);
|
||||
const range = doc.createRange();
|
||||
range.selectNodeContents(p.firstChild!);
|
||||
return range;
|
||||
};
|
||||
|
||||
describe('buildParagraphTtsSpeakDetail (#3235)', () => {
|
||||
test('focused paragraph with live range → detail with range + index (start-alignment)', () => {
|
||||
const range = makeRange(document);
|
||||
|
||||
const detail = buildParagraphTtsSpeakDetail(range, 3, BOOK_KEY, document);
|
||||
|
||||
expect(detail.bookKey).toBe(BOOK_KEY);
|
||||
expect(detail.index).toBe(3);
|
||||
expect(detail.range).toBe(range);
|
||||
});
|
||||
|
||||
test('stale / cross-document range → detail keeps index but omits range', () => {
|
||||
// Range built in a different document than the one paragraph mode renders.
|
||||
const otherDoc = document.implementation.createHTMLDocument('other');
|
||||
const staleRange = makeRange(otherDoc);
|
||||
|
||||
const detail = buildParagraphTtsSpeakDetail(staleRange, 3, BOOK_KEY, document);
|
||||
|
||||
expect(detail.index).toBe(3);
|
||||
expect(detail.range).toBeUndefined();
|
||||
});
|
||||
|
||||
test('no range → detail keeps index, no range', () => {
|
||||
const detail = buildParagraphTtsSpeakDetail(null, 2, BOOK_KEY, document);
|
||||
|
||||
expect(detail.index).toBe(2);
|
||||
expect(detail.range).toBeUndefined();
|
||||
});
|
||||
|
||||
test('no range and no docIndex → bookKey only (TTS falls back to progress)', () => {
|
||||
const detail = buildParagraphTtsSpeakDetail(null, undefined, BOOK_KEY, document);
|
||||
|
||||
expect(detail.bookKey).toBe(BOOK_KEY);
|
||||
expect(detail.index).toBeUndefined();
|
||||
expect(detail.range).toBeUndefined();
|
||||
});
|
||||
|
||||
test('range present but docIndex unknown → range only, no index', () => {
|
||||
const range = makeRange(document);
|
||||
|
||||
const detail = buildParagraphTtsSpeakDetail(range, undefined, BOOK_KEY, document);
|
||||
|
||||
expect(detail.index).toBeUndefined();
|
||||
expect(detail.range).toBe(range);
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,11 @@ vi.mock('@/services/rsvp', () => ({
|
||||
loadNextPageContent: vi.fn(() => {
|
||||
loadedSections.push(primaryIndex);
|
||||
}),
|
||||
// Slice 5 (#3235) TTS-sync surface used by RSVPControl's subscription.
|
||||
setExternallyDriven: vi.fn(),
|
||||
stopEstimator: vi.fn(),
|
||||
syncToCfi: vi.fn(() => false),
|
||||
driveEstimatedFromCfi: vi.fn(() => false),
|
||||
getStoredPosition: vi.fn(() => null),
|
||||
get currentState() {
|
||||
return { active: true };
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { buildRsvpTtsSpeakDetail } from '@/app/reader/components/rsvp/rsvpTts';
|
||||
import type { RsvpWord } from '@/services/rsvp';
|
||||
|
||||
const BOOK_KEY = 'hash123-session456';
|
||||
|
||||
// Build a real DOM range inside a given document so ownerDocument validation is
|
||||
// exercised against an actual node (not a mock).
|
||||
const makeRange = (doc: Document, text = 'word'): Range => {
|
||||
const p = doc.createElement('p');
|
||||
p.textContent = text;
|
||||
doc.body.appendChild(p);
|
||||
const range = doc.createRange();
|
||||
range.selectNodeContents(p.firstChild!);
|
||||
return range;
|
||||
};
|
||||
|
||||
describe('buildRsvpTtsSpeakDetail (slice 7, #3235)', () => {
|
||||
test('no current word → null', () => {
|
||||
expect(buildRsvpTtsSpeakDetail(null, BOOK_KEY, document)).toBeNull();
|
||||
expect(buildRsvpTtsSpeakDetail(undefined, BOOK_KEY, document)).toBeNull();
|
||||
});
|
||||
|
||||
test('valid word with live range → detail with range + index (start-alignment)', () => {
|
||||
const range = makeRange(document);
|
||||
const word: RsvpWord = { text: 'word', orpIndex: 1, pauseMultiplier: 1, range, docIndex: 3 };
|
||||
|
||||
const detail = buildRsvpTtsSpeakDetail(word, BOOK_KEY, document);
|
||||
|
||||
expect(detail).not.toBeNull();
|
||||
expect(detail!.bookKey).toBe(BOOK_KEY);
|
||||
expect(detail!.index).toBe(3);
|
||||
expect(detail!.range).toBe(range);
|
||||
});
|
||||
|
||||
test('stale / cross-document range → detail keeps index but omits range', () => {
|
||||
// Range built in a different document than the one RSVP is rendering.
|
||||
const otherDoc = document.implementation.createHTMLDocument('other');
|
||||
const staleRange = makeRange(otherDoc);
|
||||
const word: RsvpWord = {
|
||||
text: 'word',
|
||||
orpIndex: 1,
|
||||
pauseMultiplier: 1,
|
||||
range: staleRange,
|
||||
docIndex: 3,
|
||||
};
|
||||
|
||||
const detail = buildRsvpTtsSpeakDetail(word, BOOK_KEY, document);
|
||||
|
||||
expect(detail).not.toBeNull();
|
||||
expect(detail!.index).toBe(3);
|
||||
expect(detail!.range).toBeUndefined();
|
||||
});
|
||||
|
||||
test('word without a range → detail keeps index, no range', () => {
|
||||
const word: RsvpWord = { text: 'word', orpIndex: 1, pauseMultiplier: 1, docIndex: 2 };
|
||||
|
||||
const detail = buildRsvpTtsSpeakDetail(word, BOOK_KEY, document);
|
||||
|
||||
expect(detail!.index).toBe(2);
|
||||
expect(detail!.range).toBeUndefined();
|
||||
});
|
||||
|
||||
test('word without docIndex → detail omits index (TTS falls back to progress)', () => {
|
||||
const range = makeRange(document);
|
||||
const word: RsvpWord = { text: 'word', orpIndex: 1, pauseMultiplier: 1, range };
|
||||
|
||||
const detail = buildRsvpTtsSpeakDetail(word, BOOK_KEY, document);
|
||||
|
||||
expect(detail!.index).toBeUndefined();
|
||||
expect(detail!.range).toBe(range);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import {
|
||||
decideRsvpTtsPosition,
|
||||
type RsvpTtsSyncState,
|
||||
type RsvpTtsPositionDetail,
|
||||
} from '@/app/reader/components/rsvp/RSVPControl';
|
||||
|
||||
const BOOK_KEY = 'hash123-session456';
|
||||
|
||||
const freshState = (over: Partial<RsvpTtsSyncState> = {}): RsvpTtsSyncState => ({
|
||||
following: true,
|
||||
lastSequenceSeen: -Infinity,
|
||||
currentSectionIndex: 2,
|
||||
hasWordPositions: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const detail = (over: Partial<RsvpTtsPositionDetail> = {}): RsvpTtsPositionDetail => ({
|
||||
bookKey: BOOK_KEY,
|
||||
cfi: 'epubcfi(/6/6!/4/2/1:0)',
|
||||
kind: 'word',
|
||||
sectionIndex: 2,
|
||||
sequence: 1,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('decideRsvpTtsPosition (slice 5, #3235)', () => {
|
||||
test('word + following + same section → sync (Edge word-level), advances lastSequenceSeen', () => {
|
||||
const result = decideRsvpTtsPosition(
|
||||
freshState(),
|
||||
detail({ kind: 'word', sequence: 5 }),
|
||||
BOOK_KEY,
|
||||
);
|
||||
expect(result.action).toBe('sync');
|
||||
expect(result.cfi).toBe('epubcfi(/6/6!/4/2/1:0)');
|
||||
expect(result.nextState.lastSequenceSeen).toBe(5);
|
||||
});
|
||||
|
||||
test('word position marks the engine word-capable (hasWordPositions)', () => {
|
||||
const result = decideRsvpTtsPosition(
|
||||
freshState(),
|
||||
detail({ kind: 'word', sequence: 1 }),
|
||||
BOOK_KEY,
|
||||
);
|
||||
expect(result.nextState.hasWordPositions).toBe(true);
|
||||
});
|
||||
|
||||
test('sentence + following + same section → drive-estimator (non-Edge, no words seen)', () => {
|
||||
const result = decideRsvpTtsPosition(
|
||||
freshState(),
|
||||
detail({ kind: 'sentence', sequence: 3 }),
|
||||
BOOK_KEY,
|
||||
);
|
||||
expect(result.action).toBe('drive-estimator');
|
||||
expect(result.cfi).toBe('epubcfi(/6/6!/4/2/1:0)');
|
||||
expect(result.nextState.lastSequenceSeen).toBe(3);
|
||||
});
|
||||
|
||||
test('sentence is IGNORED once a word has been seen (Edge: estimator must not fight words)', () => {
|
||||
// Word-boundary engines emit sentence marks too; driving the estimator on
|
||||
// them makes RSVP outrun the audio and the words snap it back → flashing.
|
||||
const result = decideRsvpTtsPosition(
|
||||
freshState({ hasWordPositions: true }),
|
||||
detail({ kind: 'sentence', sequence: 7 }),
|
||||
BOOK_KEY,
|
||||
);
|
||||
expect(result.action).toBe('ignore');
|
||||
expect(result.nextState.lastSequenceSeen).toBe(7);
|
||||
});
|
||||
|
||||
test('drops events for a different bookKey (no state change)', () => {
|
||||
const state = freshState({ lastSequenceSeen: 1 });
|
||||
const result = decideRsvpTtsPosition(state, detail({ bookKey: 'other-book' }), BOOK_KEY);
|
||||
expect(result.action).toBe('ignore');
|
||||
expect(result.nextState).toBe(state);
|
||||
});
|
||||
|
||||
test('drops stale / out-of-order sequence (<= lastSequenceSeen)', () => {
|
||||
const state = freshState({ lastSequenceSeen: 10 });
|
||||
const result = decideRsvpTtsPosition(state, detail({ sequence: 10 }), BOOK_KEY);
|
||||
expect(result.action).toBe('ignore');
|
||||
expect(result.nextState).toBe(state);
|
||||
|
||||
const older = decideRsvpTtsPosition(state, detail({ sequence: 4 }), BOOK_KEY);
|
||||
expect(older.action).toBe('ignore');
|
||||
});
|
||||
|
||||
test('does nothing while decoupled (following=false) and does NOT advance the sequence', () => {
|
||||
const state = freshState({ following: false, lastSequenceSeen: 2 });
|
||||
const result = decideRsvpTtsPosition(state, detail({ sequence: 7 }), BOOK_KEY);
|
||||
expect(result.action).toBe('ignore');
|
||||
// Sequence not bumped: when we re-engage we want the next live position.
|
||||
expect(result.nextState.lastSequenceSeen).toBe(2);
|
||||
});
|
||||
|
||||
test('different section → re-extract + stash latest (no mapping)', () => {
|
||||
const result = decideRsvpTtsPosition(
|
||||
freshState({ currentSectionIndex: 2 }),
|
||||
detail({ sectionIndex: 5, sequence: 9, cfi: 'epubcfi(/6/12!/4/2/1:0)' }),
|
||||
BOOK_KEY,
|
||||
);
|
||||
expect(result.action).toBe('reextract');
|
||||
expect(result.nextState.lastSequenceSeen).toBe(9);
|
||||
expect(result.nextState.pendingSync).toEqual({
|
||||
cfi: 'epubcfi(/6/12!/4/2/1:0)',
|
||||
sequence: 9,
|
||||
sectionIndex: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test('guards malformed details (missing cfi / sectionIndex) → ignore', () => {
|
||||
const state = freshState();
|
||||
expect(decideRsvpTtsPosition(state, detail({ cfi: undefined }), BOOK_KEY).action).toBe(
|
||||
'ignore',
|
||||
);
|
||||
expect(decideRsvpTtsPosition(state, detail({ sectionIndex: undefined }), BOOK_KEY).action).toBe(
|
||||
'ignore',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Fixed-layout gate (decision D7, slice 8b, #3235) ──────────────────
|
||||
describe('fixed-layout / unsupported gate (D7)', () => {
|
||||
test('word + fixed-layout → never sync; ignore (no state change)', () => {
|
||||
const state = freshState();
|
||||
const result = decideRsvpTtsPosition(state, detail({ kind: 'word', sequence: 5 }), BOOK_KEY, {
|
||||
unsupported: true,
|
||||
});
|
||||
expect(result.action).toBe('ignore');
|
||||
expect(result.cfi).toBeUndefined();
|
||||
// The gate is checked first; it must not advance the sequence.
|
||||
expect(result.nextState).toBe(state);
|
||||
});
|
||||
|
||||
test('sentence + fixed-layout → never drive-estimator; ignore', () => {
|
||||
const state = freshState();
|
||||
const result = decideRsvpTtsPosition(
|
||||
state,
|
||||
detail({ kind: 'sentence', sequence: 3 }),
|
||||
BOOK_KEY,
|
||||
{ unsupported: true },
|
||||
);
|
||||
expect(result.action).toBe('ignore');
|
||||
expect(result.nextState).toBe(state);
|
||||
});
|
||||
|
||||
test('different section + fixed-layout → ignore (no reextract / no stash)', () => {
|
||||
const state = freshState({ currentSectionIndex: 2 });
|
||||
const result = decideRsvpTtsPosition(
|
||||
state,
|
||||
detail({ sectionIndex: 5, sequence: 9 }),
|
||||
BOOK_KEY,
|
||||
{ unsupported: true },
|
||||
);
|
||||
expect(result.action).toBe('ignore');
|
||||
expect(result.nextState).toBe(state);
|
||||
expect(result.nextState.pendingSync).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unsupported=false behaves exactly like the default (word → sync)', () => {
|
||||
const result = decideRsvpTtsPosition(
|
||||
freshState(),
|
||||
detail({ kind: 'word', sequence: 5 }),
|
||||
BOOK_KEY,
|
||||
{ unsupported: false },
|
||||
);
|
||||
expect(result.action).toBe('sync');
|
||||
expect(result.nextState.lastSequenceSeen).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
'use client';
|
||||
|
||||
import { createRef } from 'react';
|
||||
import { render, act, cleanup } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import RSVPControl, { type RSVPControlHandle } from '@/app/reader/components/rsvp/RSVPControl';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
// Mounts the real RSVPControl with a mocked RSVPController + stores and asserts
|
||||
// that the slice-5 subscription routes tts-position / tts-playback-state to the
|
||||
// controller correctly (engage, word-sync, decouple, re-engage).
|
||||
|
||||
const BOOK_KEY = 'hash123-session456';
|
||||
|
||||
let primaryIndex = 2;
|
||||
let isFixedLayout = false;
|
||||
const controllerEventListeners = new Map<string, EventListener[]>();
|
||||
let controllerMock: ReturnType<typeof makeControllerMock>;
|
||||
|
||||
function makeControllerMock() {
|
||||
return {
|
||||
seedPosition: vi.fn(),
|
||||
setCurrentCfi: vi.fn(),
|
||||
requestStart: vi.fn(() => {
|
||||
const event = new CustomEvent('rsvp-start-choice', {
|
||||
detail: { hasSavedPosition: false, hasSelection: false },
|
||||
});
|
||||
(controllerEventListeners.get('rsvp-start-choice') ?? []).forEach((h) => h(event));
|
||||
}),
|
||||
startFromCurrentPosition: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
loadNextPageContent: vi.fn(),
|
||||
setExternallyDriven: vi.fn(),
|
||||
stopEstimator: vi.fn(),
|
||||
togglePlayPause: vi.fn(),
|
||||
syncToCfi: vi.fn(() => true),
|
||||
driveEstimatedFromCfi: vi.fn(() => true),
|
||||
getStoredPosition: vi.fn(() => null),
|
||||
get currentState() {
|
||||
return { active: true };
|
||||
},
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (!controllerEventListeners.has(type)) controllerEventListeners.set(type, []);
|
||||
controllerEventListeners.get(type)!.push(listener);
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
const arr = controllerEventListeners.get(type) ?? [];
|
||||
controllerEventListeners.set(
|
||||
type,
|
||||
arr.filter((l) => l !== listener),
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('@/app/reader/components/rsvp/RSVPOverlay', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
vi.mock('@/app/reader/components/rsvp/RSVPStartDialog', () => ({ default: () => null }));
|
||||
vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (s: string) => s }));
|
||||
vi.mock('@/context/EnvContext', () => ({ useEnv: () => ({ envConfig: {} }) }));
|
||||
|
||||
vi.mock('@/store/readerStore', () => {
|
||||
const state = {
|
||||
getView: () => mockView,
|
||||
getProgress: () => ({ location: 'epubcfi(/6/6!/4/1:0)', sectionHref: 'ch2.xhtml' }),
|
||||
getViewSettings: () => ({
|
||||
ttsRate: 1.0,
|
||||
defaultFont: 'serif',
|
||||
serifFont: 'Georgia',
|
||||
sansSerifFont: 'Arial',
|
||||
monospaceFont: 'Menlo',
|
||||
defaultCJKFont: 'Noto',
|
||||
}),
|
||||
};
|
||||
return {
|
||||
useReaderStore: <R,>(selector?: (s: typeof state) => R) => (selector ? selector(state) : state),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/store/bookDataStore', () => {
|
||||
const state = {
|
||||
getBookData: () => ({ book: { format: 'EPUB' }, bookDoc: { toc: [] }, isFixedLayout }),
|
||||
getConfig: () => null,
|
||||
setConfig: vi.fn(),
|
||||
saveConfig: vi.fn(),
|
||||
};
|
||||
return {
|
||||
useBookDataStore: <R,>(selector?: (s: typeof state) => R) =>
|
||||
selector ? selector(state) : state,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/store/settingsStore', () => ({ useSettingsStore: () => ({ settings: {} }) }));
|
||||
vi.mock('@/store/themeStore', () => ({
|
||||
useThemeStore: () => ({ themeCode: { primary: '#000' } }),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/rsvp', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/services/rsvp')>();
|
||||
return {
|
||||
...actual,
|
||||
// eslint-disable-next-line prefer-arrow-callback
|
||||
RSVPController: Object.assign(
|
||||
vi.fn(function RSVPControllerMock() {
|
||||
return controllerMock;
|
||||
}),
|
||||
{ estimatedWpmFromRate: vi.fn(() => 190) },
|
||||
),
|
||||
buildRsvpExitConfigUpdate: vi.fn(() => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
const mockView = {
|
||||
renderer: {
|
||||
get primaryIndex() {
|
||||
return primaryIndex;
|
||||
},
|
||||
get atEnd() {
|
||||
return false;
|
||||
},
|
||||
goTo: vi.fn(),
|
||||
getContents: vi.fn(() => []),
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
book: { format: 'EPUB' },
|
||||
getCFI: vi.fn(() => null),
|
||||
addAnnotation: vi.fn(),
|
||||
resolveCFI: vi.fn(),
|
||||
};
|
||||
|
||||
const wordPos = (over: Record<string, unknown> = {}) => ({
|
||||
bookKey: BOOK_KEY,
|
||||
cfi: 'epubcfi(/6/6!/4/2/1:0)',
|
||||
kind: 'word',
|
||||
sectionIndex: 2,
|
||||
...over,
|
||||
});
|
||||
|
||||
async function startSession() {
|
||||
await act(async () => {
|
||||
eventDispatcher.dispatch('rsvp-start', { bookKey: BOOK_KEY });
|
||||
await new Promise<void>((r) => setTimeout(r, 20));
|
||||
});
|
||||
}
|
||||
|
||||
describe('RSVPControl — TTS sync wiring (slice 5, #3235)', () => {
|
||||
beforeEach(() => {
|
||||
primaryIndex = 2;
|
||||
isFixedLayout = false;
|
||||
controllerEventListeners.clear();
|
||||
controllerMock = makeControllerMock();
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
test('engages following on playing and syncs Edge word positions', async () => {
|
||||
render(
|
||||
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
|
||||
);
|
||||
await startSession();
|
||||
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'playing' });
|
||||
});
|
||||
expect(controllerMock.setExternallyDriven).toHaveBeenLastCalledWith(true);
|
||||
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 1 }));
|
||||
});
|
||||
expect(controllerMock.syncToCfi).toHaveBeenCalledWith('epubcfi(/6/6!/4/2/1:0)');
|
||||
});
|
||||
|
||||
test('drops stale sequences and positions for other books', async () => {
|
||||
render(
|
||||
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
|
||||
);
|
||||
await startSession();
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'playing' });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 5 }));
|
||||
await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 3 })); // stale
|
||||
await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 5, bookKey: 'other' }));
|
||||
});
|
||||
// Only the first (seq 5) maps; the stale and other-book events are dropped.
|
||||
expect(controllerMock.syncToCfi).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('sentence positions drive the estimator', async () => {
|
||||
render(
|
||||
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
|
||||
);
|
||||
await startSession();
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'playing' });
|
||||
await eventDispatcher.dispatch('tts-position', wordPos({ kind: 'sentence', sequence: 1 }));
|
||||
});
|
||||
expect(controllerMock.driveEstimatedFromCfi).toHaveBeenCalledWith(
|
||||
'epubcfi(/6/6!/4/2/1:0)',
|
||||
190,
|
||||
);
|
||||
});
|
||||
|
||||
test('paused stops the estimator + ignores positions but KEEPS RSVP suspended (#3235)', async () => {
|
||||
render(
|
||||
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
|
||||
);
|
||||
await startSession();
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'playing' });
|
||||
});
|
||||
|
||||
controllerMock.setExternallyDriven.mockClear();
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'paused' });
|
||||
});
|
||||
expect(controllerMock.stopEstimator).toHaveBeenCalled();
|
||||
// Pause must NOT restore the RSVP timer, or RSVP would run away on its own
|
||||
// while audio is paused. Only a full stop releases it (asserted below).
|
||||
expect(controllerMock.setExternallyDriven).not.toHaveBeenCalledWith(false);
|
||||
|
||||
// While paused, positions are ignored (following dropped).
|
||||
controllerMock.syncToCfi.mockClear();
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 99 }));
|
||||
});
|
||||
expect(controllerMock.syncToCfi).not.toHaveBeenCalled();
|
||||
|
||||
// A full STOP releases RSVP back to its own control.
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'stopped' });
|
||||
});
|
||||
expect(controllerMock.setExternallyDriven).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
test('a manual nav decouples; following resumes on the next playing', async () => {
|
||||
render(
|
||||
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
|
||||
);
|
||||
await startSession();
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'playing' });
|
||||
});
|
||||
|
||||
// User skips: controller emits rsvp-manual-nav -> decouple.
|
||||
await act(async () => {
|
||||
(controllerEventListeners.get('rsvp-manual-nav') ?? []).forEach((h) =>
|
||||
h(new CustomEvent('rsvp-manual-nav')),
|
||||
);
|
||||
});
|
||||
|
||||
controllerMock.syncToCfi.mockClear();
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 50 }));
|
||||
});
|
||||
expect(controllerMock.syncToCfi).not.toHaveBeenCalled(); // decoupled
|
||||
|
||||
// Re-engage on next playing, then a fresh position syncs again.
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'playing' });
|
||||
await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 60 }));
|
||||
});
|
||||
expect(controllerMock.syncToCfi).toHaveBeenCalledWith('epubcfi(/6/6!/4/2/1:0)');
|
||||
});
|
||||
|
||||
test('cleans up subscriptions on unmount', async () => {
|
||||
const { unmount } = render(
|
||||
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
|
||||
);
|
||||
await startSession();
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'playing' });
|
||||
});
|
||||
|
||||
act(() => unmount());
|
||||
|
||||
controllerMock.syncToCfi.mockClear();
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 200 }));
|
||||
});
|
||||
expect(controllerMock.syncToCfi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ─── Fixed-layout gate + ttsSyncStatus (slice 8b, #3235) ───────────────
|
||||
describe('fixed-layout gate (D7)', () => {
|
||||
test('playing does NOT engage and positions do NOT drive the controller', async () => {
|
||||
isFixedLayout = true;
|
||||
render(
|
||||
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
|
||||
);
|
||||
await startSession();
|
||||
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-playback-state', {
|
||||
bookKey: BOOK_KEY,
|
||||
state: 'playing',
|
||||
});
|
||||
});
|
||||
// Never engage external driving for fixed-layout.
|
||||
expect(controllerMock.setExternallyDriven).not.toHaveBeenCalledWith(true);
|
||||
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 1 }));
|
||||
await eventDispatcher.dispatch('tts-position', wordPos({ kind: 'sentence', sequence: 2 }));
|
||||
});
|
||||
expect(controllerMock.syncToCfi).not.toHaveBeenCalled();
|
||||
expect(controllerMock.driveEstimatedFromCfi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exposes ttsSyncStatus="unsupported" via the imperative handle', async () => {
|
||||
isFixedLayout = true;
|
||||
const handle = createRef<RSVPControlHandle>();
|
||||
render(
|
||||
<RSVPControl
|
||||
ref={handle}
|
||||
bookKey={BOOK_KEY}
|
||||
gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }}
|
||||
/>,
|
||||
);
|
||||
await startSession();
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-playback-state', {
|
||||
bookKey: BOOK_KEY,
|
||||
state: 'playing',
|
||||
});
|
||||
});
|
||||
expect(handle.current?.ttsSyncStatus).toBe('unsupported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ttsSyncStatus transitions (reflowable)', () => {
|
||||
test('idle → following on playing → idle on stopped', async () => {
|
||||
const handle = createRef<RSVPControlHandle>();
|
||||
render(
|
||||
<RSVPControl
|
||||
ref={handle}
|
||||
bookKey={BOOK_KEY}
|
||||
gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }}
|
||||
/>,
|
||||
);
|
||||
await startSession();
|
||||
// Not playing yet.
|
||||
expect(handle.current?.ttsSyncStatus).toBe('idle');
|
||||
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-playback-state', {
|
||||
bookKey: BOOK_KEY,
|
||||
state: 'playing',
|
||||
});
|
||||
});
|
||||
expect(handle.current?.ttsSyncStatus).toBe('following');
|
||||
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-playback-state', {
|
||||
bookKey: BOOK_KEY,
|
||||
state: 'stopped',
|
||||
});
|
||||
});
|
||||
expect(handle.current?.ttsSyncStatus).toBe('idle');
|
||||
});
|
||||
|
||||
test('decoupled on a manual nav while playing', async () => {
|
||||
const handle = createRef<RSVPControlHandle>();
|
||||
render(
|
||||
<RSVPControl
|
||||
ref={handle}
|
||||
bookKey={BOOK_KEY}
|
||||
gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }}
|
||||
/>,
|
||||
);
|
||||
await startSession();
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-playback-state', {
|
||||
bookKey: BOOK_KEY,
|
||||
state: 'playing',
|
||||
});
|
||||
});
|
||||
expect(handle.current?.ttsSyncStatus).toBe('following');
|
||||
|
||||
await act(async () => {
|
||||
(controllerEventListeners.get('rsvp-manual-nav') ?? []).forEach((h) =>
|
||||
h(new CustomEvent('rsvp-manual-nav')),
|
||||
);
|
||||
});
|
||||
expect(handle.current?.ttsSyncStatus).toBe('decoupled');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user