forked from akai/readest
test(tts): add browser e2e for auto-advance across a chapter boundary (#4573)
Mounts the real foliate <foliate-view> with sample-alice.epub, renders the real useTTSControl hook with the real stores, and mocks only the speech client. Starts TTS at the last paragraph of chapter 4 and verifies the reading auto-advances into chapter 5, the page turns, and the "Back to TTS Location" badge never appears (the TTS location stays in view). The mock client's speak() only needs to yield `end` — the real TTSController drives forward() and the real view.tts walks the document across the section boundary, so the cross-chapter navigation and badge suppression are genuinely exercised rather than re-implemented in the test. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { DocumentLoader } from '@/libs/document';
|
||||
import type { BookDoc, TOCItem } from '@/libs/document';
|
||||
import { FoliateView, wrappedFoliateView } from '@/types/view';
|
||||
import type { Book, BookConfig, PageInfo, ViewSettings } from '@/types/book';
|
||||
import type { FileSystem } from '@/types/system';
|
||||
import type { TTSClient, TTSMessageEvent } from '@/services/tts/TTSClient';
|
||||
import type { SystemSettings } from '@/types/settings';
|
||||
import { getDefaultViewSettings } from '@/services/settingsService';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { useTTSControl } from '@/app/reader/hooks/useTTSControl';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock ONLY the speech client. Everything else is real: the foliate
|
||||
// <foliate-view> renders Alice and lays out pages, the real TTSController
|
||||
// walks the real `view.tts` over the real document, and the real
|
||||
// `useTTSControl` hook (with the real Zustand stores) drives the page turn
|
||||
// and the "Back to TTS Location" badge. The client is the single seam — the
|
||||
// thing that would otherwise talk to a speech engine / network.
|
||||
//
|
||||
// The controller's #speak() loop only inspects the event `code`: when the
|
||||
// last event is `end`, it calls forward(), which advances `view.tts` to the
|
||||
// next sentence (and, at a section boundary, into the next chapter). So the
|
||||
// mock just needs to emit `end` after a short delay. Marks/SSML/highlights
|
||||
// all come from the real document.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SPEAK_DELAY_MS = 25;
|
||||
|
||||
function makeMockTTSClient(name: string): TTSClient {
|
||||
return {
|
||||
name,
|
||||
initialized: false,
|
||||
init: () => Promise.resolve(true),
|
||||
shutdown: () => Promise.resolve(),
|
||||
speak: async function* (
|
||||
_ssml: string,
|
||||
signal: AbortSignal,
|
||||
preload?: boolean,
|
||||
): AsyncGenerator<TTSMessageEvent> {
|
||||
// The preload path only warms a cache in real clients — emit nothing.
|
||||
if (preload) return;
|
||||
await new Promise((r) => setTimeout(r, SPEAK_DELAY_MS));
|
||||
if (signal.aborted) return;
|
||||
yield { code: 'end' };
|
||||
},
|
||||
pause: () => Promise.resolve(true),
|
||||
resume: () => Promise.resolve(true),
|
||||
stop: () => Promise.resolve(),
|
||||
setPrimaryLang: () => {},
|
||||
setRate: () => Promise.resolve(),
|
||||
setPitch: () => Promise.resolve(),
|
||||
setVoice: () => Promise.resolve(),
|
||||
getAllVoices: () => Promise.resolve([]),
|
||||
getVoices: () => Promise.resolve([]),
|
||||
getGranularities: () => ['sentence'],
|
||||
supportsWordBoundaries: () => false,
|
||||
getVoiceId: () => 'mock-voice',
|
||||
getSpeakingLang: () => 'en',
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('@/services/tts/WebSpeechClient', () => ({
|
||||
WebSpeechClient: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
|
||||
Object.assign(this, makeMockTTSClient('web'));
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/services/tts/EdgeTTSClient', () => ({
|
||||
EdgeTTSClient: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
|
||||
Object.assign(this, makeMockTTSClient('edge'));
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/services/tts/NativeTTSClient', () => ({
|
||||
NativeTTSClient: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
|
||||
Object.assign(this, makeMockTTSClient('native'));
|
||||
}),
|
||||
}));
|
||||
|
||||
// useEnv/useAuth throw outside their providers; stub them (test-side module
|
||||
// mocks, not a production seam). A null appService exercises the web/desktop
|
||||
// code paths in useTTSControl (no mobile/iOS branches).
|
||||
vi.mock('@/context/EnvContext', () => ({
|
||||
useEnv: () => ({ envConfig: {}, appService: null }),
|
||||
EnvProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ user: null }),
|
||||
AuthProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture: sample-alice.epub spine (all linear), 0-based section indices:
|
||||
// 0 cover · 1 title · 2 about · 3 main0=Ch1 · 4 main1=Ch2 · 5 main2=Ch3
|
||||
// 6 main3=Ch4 (The Rabbit Sends in a Little Bill)
|
||||
// 7 main4=Ch5 (Advice from a Caterpillar)
|
||||
// Chapters 4 and 5 are adjacent sections — exactly the boundary we cross.
|
||||
// ---------------------------------------------------------------------------
|
||||
const EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href;
|
||||
const CH4_SECTION_INDEX = 6;
|
||||
const CH5_SECTION_INDEX = 7;
|
||||
const BOOK_ID = 'alice';
|
||||
const BOOK_KEY = 'alice'; // id = key.split('-')[0]
|
||||
|
||||
let bookDoc: BookDoc;
|
||||
|
||||
interface RelocateDetail {
|
||||
cfi: string;
|
||||
tocItem?: TOCItem;
|
||||
pageItem?: BookProgressPageItem;
|
||||
section: PageInfo;
|
||||
location: PageInfo;
|
||||
time: { section: number; total: number };
|
||||
range: Range;
|
||||
}
|
||||
type BookProgressPageItem = { label?: string; href?: string } | null;
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
beforeAll(async () => {
|
||||
const resp = await fetch(EPUB_URL);
|
||||
const buffer = await resp.arrayBuffer();
|
||||
const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
|
||||
bookDoc = (await new DocumentLoader(file).open()).book;
|
||||
// Hydrate TOC so relocate's tocItem carries chapter labels ("Chapter 4 …").
|
||||
const { computeBookNav, hydrateBookNav } = await import('@/services/nav');
|
||||
hydrateBookNav(bookDoc, await computeBookNav(bookDoc));
|
||||
await import('foliate-js/view.js');
|
||||
}, 60000);
|
||||
|
||||
const createView = (viewSettings: ViewSettings) => {
|
||||
const container = document.createElement('div');
|
||||
Object.assign(container.style, {
|
||||
position: 'fixed',
|
||||
left: '0',
|
||||
top: '0',
|
||||
width: '800px',
|
||||
height: '600px',
|
||||
});
|
||||
document.body.appendChild(container);
|
||||
|
||||
const view = wrappedFoliateView(document.createElement('foliate-view') as FoliateView);
|
||||
container.appendChild(view);
|
||||
return { container, view, viewSettings };
|
||||
};
|
||||
|
||||
const seedStores = (view: FoliateView, viewSettings: ViewSettings) => {
|
||||
const book = {
|
||||
hash: BOOK_ID,
|
||||
format: 'EPUB',
|
||||
title: "Alice's Adventures in Wonderland",
|
||||
author: 'Lewis Carroll',
|
||||
coverImageUrl: '',
|
||||
primaryLanguage: 'en',
|
||||
} as unknown as Book;
|
||||
const config = {
|
||||
updatedAt: Date.now(),
|
||||
booknotes: [],
|
||||
progress: [1, 1],
|
||||
location: '',
|
||||
viewSettings,
|
||||
} as unknown as BookConfig;
|
||||
|
||||
// proofreadStore.getMergedRules reads settings.globalViewSettings.* during
|
||||
// SSML preprocessing; without it the speak chain rejects and TTS stops.
|
||||
useSettingsStore.setState((s) => ({
|
||||
settings: { ...s.settings, globalViewSettings: viewSettings } as SystemSettings,
|
||||
}));
|
||||
|
||||
useBookDataStore.setState((s) => ({
|
||||
booksData: {
|
||||
...s.booksData,
|
||||
[BOOK_ID]: { id: BOOK_ID, book, file: null, config, bookDoc, isFixedLayout: false },
|
||||
},
|
||||
}));
|
||||
useReaderStore.setState((s) => ({
|
||||
viewStates: {
|
||||
...s.viewStates,
|
||||
[BOOK_KEY]: {
|
||||
key: BOOK_KEY,
|
||||
view,
|
||||
viewerKey: BOOK_KEY,
|
||||
isPrimary: true,
|
||||
loading: false,
|
||||
inited: true,
|
||||
error: null,
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
syncing: false,
|
||||
gridInsets: null,
|
||||
previewMode: false,
|
||||
viewSettings,
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Reproduce FoliateViewer's relocate → setProgress glue (the one production
|
||||
// bit not under test here) so useBookProgress updates on every page turn —
|
||||
// which is what drives the badge effect.
|
||||
const wireRelocate = (view: FoliateView) => {
|
||||
const { setProgress } = useReaderStore.getState();
|
||||
view.addEventListener('relocate', (e: Event) => {
|
||||
const detail = (e as CustomEvent<RelocateDetail>).detail;
|
||||
const atEnd = view.renderer.atEnd || false;
|
||||
const { current, next, total } = detail.location;
|
||||
const currentPage = atEnd && total > 0 ? total - 1 : current;
|
||||
const pageInfo: PageInfo = { current: currentPage, next, total };
|
||||
setProgress(
|
||||
BOOK_KEY,
|
||||
detail.cfi,
|
||||
(detail.tocItem ?? {}) as TOCItem,
|
||||
detail.pageItem ?? null,
|
||||
detail.section,
|
||||
pageInfo,
|
||||
detail.time,
|
||||
detail.range,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Page forward from the start of Ch4 to its LAST page, so TTS starts at the
|
||||
// last paragraph of Ch4 and only a sentence or two remain before the boundary.
|
||||
const goToLastPageOfCh4 = async (view: FoliateView) => {
|
||||
await view.renderer.goTo({ index: CH4_SECTION_INDEX });
|
||||
let guard = 0;
|
||||
while (view.renderer.primaryIndex === CH4_SECTION_INDEX && guard++ < 300) {
|
||||
await view.renderer.next();
|
||||
}
|
||||
// next() at the end of Ch4 loads Ch5; step back onto Ch4's final page.
|
||||
if (view.renderer.primaryIndex !== CH4_SECTION_INDEX) {
|
||||
await view.renderer.prev();
|
||||
}
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await eventDispatcher.dispatch('tts-stop', { bookKey: BOOK_KEY });
|
||||
document.querySelectorAll('foliate-view').forEach((el) => el.parentElement?.remove());
|
||||
useBookDataStore.setState(() => ({ booksData: {} }));
|
||||
useReaderStore.setState(() => ({ viewStates: {} }));
|
||||
});
|
||||
|
||||
describe('TTS auto-advance across a chapter boundary (browser e2e)', () => {
|
||||
it('reads from the last paragraph of Ch4 into Ch5, turns the page, and shows no "Back to TTS Location" badge', async () => {
|
||||
const viewSettings: ViewSettings = {
|
||||
...getDefaultViewSettings({
|
||||
fs: {} as FileSystem,
|
||||
isMobile: false,
|
||||
isEink: false,
|
||||
isAppDataSandbox: false,
|
||||
}),
|
||||
maxColumnCount: 1,
|
||||
scrolled: false,
|
||||
};
|
||||
|
||||
const { view } = createView(viewSettings);
|
||||
await view.open(bookDoc);
|
||||
view.renderer.setAttribute('max-column-count', '1');
|
||||
view.renderer.setAttribute('max-inline-size', '800px');
|
||||
view.renderer.setAttribute('max-block-size', '1000px');
|
||||
view.renderer.setAttribute('margin-top', '0px');
|
||||
view.renderer.setAttribute('margin-bottom', '0px');
|
||||
view.renderer.setAttribute('margin-left', '0px');
|
||||
view.renderer.setAttribute('margin-right', '0px');
|
||||
view.renderer.setAttribute('gap', '0%');
|
||||
await view.goToFraction(0);
|
||||
|
||||
seedStores(view, viewSettings);
|
||||
wireRelocate(view);
|
||||
await goToLastPageOfCh4(view);
|
||||
await sleep(50); // let the relocate → setProgress settle
|
||||
|
||||
// Precondition: we are on Ch4, and progress reflects it.
|
||||
expect(view.renderer.primaryIndex).toBe(CH4_SECTION_INDEX);
|
||||
expect(useReaderStore.getState().getProgress(BOOK_KEY)?.sectionLabel).toMatch(/Chapter 4/);
|
||||
|
||||
const { result, unmount } = renderHook(() => useTTSControl({ bookKey: BOOK_KEY }));
|
||||
|
||||
// Start TTS at the last paragraph of Ch4 (index pins the start section).
|
||||
await act(async () => {
|
||||
await eventDispatcher.dispatch('tts-speak', { bookKey: BOOK_KEY, index: CH4_SECTION_INDEX });
|
||||
});
|
||||
|
||||
// Drive the mock 'end' events; the controller walks to the end of Ch4 and
|
||||
// auto-advances into Ch5. Watch the badge the whole time — under normal
|
||||
// TTS following it must never appear.
|
||||
let badgeEverAppeared = false;
|
||||
await act(async () => {
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline) {
|
||||
if (result.current.showBackToCurrentTTSLocation) badgeEverAppeared = true;
|
||||
if (view.renderer.primaryIndex === CH5_SECTION_INDEX) break;
|
||||
await sleep(50);
|
||||
}
|
||||
});
|
||||
|
||||
// Let the badge effect settle against the Ch5 progress (past any grace).
|
||||
await act(async () => {
|
||||
await sleep(700);
|
||||
if (result.current.showBackToCurrentTTSLocation) badgeEverAppeared = true;
|
||||
});
|
||||
|
||||
// 1. The page turned into Ch5.
|
||||
expect(view.renderer.primaryIndex).toBe(CH5_SECTION_INDEX);
|
||||
expect(useReaderStore.getState().getProgress(BOOK_KEY)?.sectionLabel).toMatch(/Chapter 5/);
|
||||
|
||||
// 2. TTS actually read into Ch5 (its highlighted location is in Ch5).
|
||||
const ttsLocation = useReaderStore.getState().getViewSettings(BOOK_KEY)?.ttsLocation;
|
||||
expect(ttsLocation).toBeTruthy();
|
||||
expect(view.resolveCFI(ttsLocation!).index).toBe(CH5_SECTION_INDEX);
|
||||
|
||||
// 3. No "Back to TTS Location" badge — the TTS location is already in view,
|
||||
// so the gating flag is false now and never flipped true while crossing.
|
||||
expect(result.current.showBackToCurrentTTSLocation).toBe(false);
|
||||
expect(badgeEverAppeared).toBe(false);
|
||||
|
||||
unmount();
|
||||
}, 60000);
|
||||
});
|
||||
Reference in New Issue
Block a user