refactor(pdf-layout): streamline viewer layout settling and harden pdf document loading
Unify and improve layout settling logic across EPUB, HTML, and PDF viewers by adding delayed recomputation and guarding against zero-height container during transient states. Refactor PDF document loading to include retry logic with success detection, ensuring robust handling of transient failures. Update Playwright test helpers to provide more reliable PDF viewer readiness checks and introduce request retry utilities for backend API calls. Add targeted unit tests for single-section chapter fallback in the PDF audiobook adapter. Update test files and helpers to align with new layout and export logic.
This commit is contained in:
parent
01cb95d8e7
commit
84c3b22e5a
8 changed files with 217 additions and 56 deletions
|
|
@ -111,7 +111,9 @@ export default function EPUBPage() {
|
||||||
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
||||||
const vh = window.innerHeight;
|
const vh = window.innerHeight;
|
||||||
const h = Math.max(0, vh - headerH - ttsH);
|
const h = Math.max(0, vh - headerH - ttsH);
|
||||||
setContainerHeight(`${h}px`);
|
if (h > 0) {
|
||||||
|
setContainerHeight(`${h}px`);
|
||||||
|
}
|
||||||
|
|
||||||
// compute max horizontal padding while preserving a minimum readable width,
|
// compute max horizontal padding while preserving a minimum readable width,
|
||||||
// but still allow some padding on small screens
|
// but still allow some padding on small screens
|
||||||
|
|
@ -122,9 +124,15 @@ export default function EPUBPage() {
|
||||||
setMaxPadPx(maxPad);
|
setMaxPadPx(maxPad);
|
||||||
};
|
};
|
||||||
compute();
|
compute();
|
||||||
|
const settleT1 = window.setTimeout(compute, 0);
|
||||||
|
const settleT2 = window.setTimeout(compute, 120);
|
||||||
window.addEventListener('resize', compute);
|
window.addEventListener('resize', compute);
|
||||||
return () => window.removeEventListener('resize', compute);
|
return () => {
|
||||||
}, []);
|
window.removeEventListener('resize', compute);
|
||||||
|
window.clearTimeout(settleT1);
|
||||||
|
window.clearTimeout(settleT2);
|
||||||
|
};
|
||||||
|
}, [isLoading, activeSidebar]);
|
||||||
|
|
||||||
// Nudge EPUB renderer to reflow on horizontal padding changes
|
// Nudge EPUB renderer to reflow on horizontal padding changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,9 @@ export default function HTMLPage() {
|
||||||
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
||||||
const vh = window.innerHeight;
|
const vh = window.innerHeight;
|
||||||
const h = Math.max(0, vh - headerH - ttsH);
|
const h = Math.max(0, vh - headerH - ttsH);
|
||||||
setContainerHeight(`${h}px`);
|
if (h > 0) {
|
||||||
|
setContainerHeight(`${h}px`);
|
||||||
|
}
|
||||||
|
|
||||||
// Adaptive minimum content width: allow some padding on narrow screens
|
// Adaptive minimum content width: allow some padding on narrow screens
|
||||||
const vw = window.innerWidth;
|
const vw = window.innerWidth;
|
||||||
|
|
@ -121,9 +123,15 @@ export default function HTMLPage() {
|
||||||
setMaxPadPx(maxPad);
|
setMaxPadPx(maxPad);
|
||||||
};
|
};
|
||||||
compute();
|
compute();
|
||||||
|
const settleT1 = window.setTimeout(compute, 0);
|
||||||
|
const settleT2 = window.setTimeout(compute, 120);
|
||||||
window.addEventListener('resize', compute);
|
window.addEventListener('resize', compute);
|
||||||
return () => window.removeEventListener('resize', compute);
|
return () => {
|
||||||
}, []);
|
window.removeEventListener('resize', compute);
|
||||||
|
window.clearTimeout(settleT1);
|
||||||
|
window.clearTimeout(settleT2);
|
||||||
|
};
|
||||||
|
}, [isLoading, activeSidebar]);
|
||||||
|
|
||||||
const handleGenerateAudiobook = useCallback(async (
|
const handleGenerateAudiobook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ export default function PDFViewerPage() {
|
||||||
console.log('Loading new document (from page.tsx)');
|
console.log('Loading new document (from page.tsx)');
|
||||||
let didRedirect = false;
|
let didRedirect = false;
|
||||||
let startedLoad = false;
|
let startedLoad = false;
|
||||||
|
let loadSucceeded = false;
|
||||||
try {
|
try {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
setError('Document not found');
|
setError('Document not found');
|
||||||
|
|
@ -101,8 +102,20 @@ export default function PDFViewerPage() {
|
||||||
startedLoad = true;
|
startedLoad = true;
|
||||||
inFlightDocIdRef.current = resolved;
|
inFlightDocIdRef.current = resolved;
|
||||||
stop(); // Reset TTS when loading new document
|
stop(); // Reset TTS when loading new document
|
||||||
await setCurrentDocument(resolved);
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
loadedDocIdRef.current = resolved;
|
const loaded = await setCurrentDocument(resolved);
|
||||||
|
if (loaded) {
|
||||||
|
loadSucceeded = true;
|
||||||
|
loadedDocIdRef.current = resolved;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (attempt === 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!loadSucceeded) {
|
||||||
|
throw new Error(`Failed to load PDF document ${resolved}`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading document:', err);
|
console.error('Error loading document:', err);
|
||||||
setError('Failed to load document');
|
setError('Failed to load document');
|
||||||
|
|
@ -110,7 +123,7 @@ export default function PDFViewerPage() {
|
||||||
if (startedLoad) {
|
if (startedLoad) {
|
||||||
inFlightDocIdRef.current = null;
|
inFlightDocIdRef.current = null;
|
||||||
}
|
}
|
||||||
if (!didRedirect && startedLoad) {
|
if (!didRedirect && startedLoad && loadSucceeded) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -148,12 +161,21 @@ export default function PDFViewerPage() {
|
||||||
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
||||||
const vh = window.innerHeight;
|
const vh = window.innerHeight;
|
||||||
const h = Math.max(0, vh - headerH - ttsH);
|
const h = Math.max(0, vh - headerH - ttsH);
|
||||||
setContainerHeight(`${h}px`);
|
// Avoid locking the reader at 0px during transient startup layout states.
|
||||||
|
if (h > 0) {
|
||||||
|
setContainerHeight(`${h}px`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
compute();
|
compute();
|
||||||
|
const settleT1 = window.setTimeout(compute, 0);
|
||||||
|
const settleT2 = window.setTimeout(compute, 120);
|
||||||
window.addEventListener('resize', compute);
|
window.addEventListener('resize', compute);
|
||||||
return () => window.removeEventListener('resize', compute);
|
return () => {
|
||||||
}, []);
|
window.removeEventListener('resize', compute);
|
||||||
|
window.clearTimeout(settleT1);
|
||||||
|
window.clearTimeout(settleT2);
|
||||||
|
};
|
||||||
|
}, [isLoading, isParseReady, isAtLimit, activeSidebar]);
|
||||||
|
|
||||||
const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 300));
|
const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 300));
|
||||||
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50));
|
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50));
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ import {
|
||||||
highlightWordIndex,
|
highlightWordIndex,
|
||||||
} from '@/lib/client/pdf';
|
} from '@/lib/client/pdf';
|
||||||
import { buildPageTextFromBlocks } from '@/lib/client/pdf-block-text';
|
import { buildPageTextFromBlocks } from '@/lib/client/pdf-block-text';
|
||||||
import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan';
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_DOCUMENT_SETTINGS,
|
DEFAULT_DOCUMENT_SETTINGS,
|
||||||
type DocumentSettings,
|
type DocumentSettings,
|
||||||
|
|
@ -72,7 +71,7 @@ export interface PdfDocumentState {
|
||||||
parsedOverlayEnabled: boolean;
|
parsedOverlayEnabled: boolean;
|
||||||
setParsedOverlayEnabled: (enabled: boolean) => void;
|
setParsedOverlayEnabled: (enabled: boolean) => void;
|
||||||
forceReparseParsedPdf: () => Promise<void>;
|
forceReparseParsedPdf: () => Promise<void>;
|
||||||
setCurrentDocument: (id: string) => Promise<void>;
|
setCurrentDocument: (id: string) => Promise<boolean>;
|
||||||
clearCurrDoc: () => void;
|
clearCurrDoc: () => void;
|
||||||
|
|
||||||
// PDF functionality
|
// PDF functionality
|
||||||
|
|
@ -264,24 +263,6 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceUnitsFromParsedPage = (pageNum: number): CanonicalTtsSourceUnit[] => {
|
|
||||||
const page = pageFromParsed(pageNum);
|
|
||||||
if (!page) return [];
|
|
||||||
const skipKinds = new Set(documentSettings.pdf?.skipBlockKinds ?? []);
|
|
||||||
return page.blocks
|
|
||||||
.filter((block) => !skipKinds.has(block.kind))
|
|
||||||
.map((block) => ({
|
|
||||||
sourceKey: `pdf:${pageNum}:${block.id}`,
|
|
||||||
text: block.text,
|
|
||||||
locator: {
|
|
||||||
readerType: 'pdf',
|
|
||||||
page: block.fragments[0]?.page ?? pageNum,
|
|
||||||
blockId: block.id,
|
|
||||||
} as TTSSegmentLocator,
|
|
||||||
}))
|
|
||||||
.filter((unit) => unit.text.trim().length > 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPageText = async (pageNumber: number, shouldCache = false): Promise<string> => {
|
const getPageText = async (pageNumber: number, shouldCache = false): Promise<string> => {
|
||||||
// Ignore stale/in-flight work if the document or worker changed.
|
// Ignore stale/in-flight work if the document or worker changed.
|
||||||
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
||||||
|
|
@ -345,14 +326,12 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
|
|
||||||
if (text !== currDocText || text === '') {
|
if (text !== currDocText || text === '') {
|
||||||
setCurrDocText(text);
|
setCurrDocText(text);
|
||||||
const sourceUnits = sourceUnitsFromParsedPage(currDocPageNumber);
|
|
||||||
setTTSText(text, {
|
setTTSText(text, {
|
||||||
location: currDocPageNumber,
|
location: currDocPageNumber,
|
||||||
previousText: prevText,
|
previousText: prevText,
|
||||||
nextLocation: nextPageNumber,
|
nextLocation: nextPageNumber,
|
||||||
nextText: nextText,
|
nextText: nextText,
|
||||||
upcomingLocations: additionalUpcoming,
|
upcomingLocations: additionalUpcoming,
|
||||||
...(sourceUnits.length > 0 ? { sourceUnits } : {}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -389,7 +368,7 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
* @param {string} id - The unique identifier of the document to set
|
* @param {string} id - The unique identifier of the document to set
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
const setCurrentDocument = useCallback(async (id: string): Promise<boolean> => {
|
||||||
// --- race-condition guard ---
|
// --- race-condition guard ---
|
||||||
const seq = ++docLoadSeqRef.current;
|
const seq = ++docLoadSeqRef.current;
|
||||||
docLoadAbortRef.current?.abort();
|
docLoadAbortRef.current?.abort();
|
||||||
|
|
@ -416,10 +395,10 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
|
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
|
||||||
|
|
||||||
const meta = await getDocumentMetadata(id, { signal: controller.signal });
|
const meta = await getDocumentMetadata(id, { signal: controller.signal });
|
||||||
if (seq !== docLoadSeqRef.current) return; // stale
|
if (seq !== docLoadSeqRef.current) return false; // stale
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
console.error('Document not found on server');
|
console.error('Document not found on server');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (meta.type === 'pdf') {
|
if (meta.type === 'pdf') {
|
||||||
startParsedPolling(id, (meta.parseStatus ?? null) as PdfParseStatus | null);
|
startParsedPolling(id, (meta.parseStatus ?? null) as PdfParseStatus | null);
|
||||||
|
|
@ -427,25 +406,28 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await ensureCachedDocument(meta, { signal: controller.signal });
|
const doc = await ensureCachedDocument(meta, { signal: controller.signal });
|
||||||
if (seq !== docLoadSeqRef.current) return; // stale
|
if (seq !== docLoadSeqRef.current) return false; // stale
|
||||||
if (doc.type !== 'pdf') {
|
if (doc.type !== 'pdf') {
|
||||||
console.error('Document is not a PDF');
|
console.error('Document is not a PDF');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrDocName(doc.name);
|
setCurrDocName(doc.name);
|
||||||
// IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the
|
// IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the
|
||||||
// buffer passed into the worker; we always pass clones to react-pdf.
|
// buffer passed into the worker; we always pass clones to react-pdf.
|
||||||
setCurrDocData(doc.data.slice(0));
|
setCurrDocData(doc.data.slice(0));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
if (error instanceof DOMException && error.name === 'AbortError') return false;
|
||||||
console.error('Failed to get document:', error);
|
console.error('Failed to get document:', error);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up the controller only if it's still ours (a newer call hasn't replaced it).
|
// Clean up the controller only if it's still ours (a newer call hasn't replaced it).
|
||||||
if (docLoadAbortRef.current === controller) {
|
if (docLoadAbortRef.current === controller) {
|
||||||
docLoadAbortRef.current = null;
|
docLoadAbortRef.current = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}, [
|
}, [
|
||||||
setCurrDocId,
|
setCurrDocId,
|
||||||
setCurrDocName,
|
setCurrDocName,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { test, expect, Page } from '@playwright/test';
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import type { APIResponse } from '@playwright/test';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
@ -8,6 +9,35 @@ import { setupTest, uploadAndDisplay } from './helpers';
|
||||||
|
|
||||||
const execFileAsync = util.promisify(execFile);
|
const execFileAsync = util.promisify(execFile);
|
||||||
|
|
||||||
|
function isTransientRequestError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) return false;
|
||||||
|
const msg = error.message || '';
|
||||||
|
return (
|
||||||
|
msg.includes('ECONNRESET') ||
|
||||||
|
msg.includes('socket hang up') ||
|
||||||
|
msg.includes('ERR_SOCKET_CLOSED') ||
|
||||||
|
msg.includes('ETIMEDOUT') ||
|
||||||
|
msg.includes('fetch failed')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestWithRetry(
|
||||||
|
fn: () => Promise<APIResponse>,
|
||||||
|
{ attempts = 4, backoffMs = 200 }: { attempts?: number; backoffMs?: number } = {},
|
||||||
|
): Promise<APIResponse> {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (!isTransientRequestError(error) || attempt === attempts) throw error;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, backoffMs * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error('Request failed');
|
||||||
|
}
|
||||||
|
|
||||||
async function getBookIdFromUrl(page: Page, expectedPrefix: 'pdf' | 'epub') {
|
async function getBookIdFromUrl(page: Page, expectedPrefix: 'pdf' | 'epub') {
|
||||||
const url = new URL(page.url());
|
const url = new URL(page.url());
|
||||||
const segments = url.pathname.split('/').filter(Boolean);
|
const segments = url.pathname.split('/').filter(Boolean);
|
||||||
|
|
@ -103,7 +133,7 @@ async function getAudioDurationSeconds(filePath: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectChaptersBackendState(page: Page, bookId: string) {
|
async function expectChaptersBackendState(page: Page, bookId: string) {
|
||||||
const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`);
|
const res = await requestWithRetry(() => page.request.get(`/api/audiobook/status?bookId=${bookId}`));
|
||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
return json;
|
return json;
|
||||||
|
|
@ -184,11 +214,20 @@ async function cancelGenerationIfVisible(page: Page): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetAudiobookById(page: Page, bookId: string) {
|
async function resetAudiobookById(page: Page, bookId: string) {
|
||||||
const res = await page.request.delete(`/api/audiobook?bookId=${bookId}`);
|
const res = await requestWithRetry(() => page.request.delete(`/api/audiobook?bookId=${bookId}`));
|
||||||
expect(res.ok() || res.status() === 404).toBeTruthy();
|
expect(res.ok() || res.status() === 404).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetAudiobookIfPresent(page: Page) {
|
async function resetAudiobookIfPresent(page: Page, bookId?: string) {
|
||||||
|
// Prefer backend reset when bookId is available: deterministic and independent of modal timing.
|
||||||
|
if (bookId) {
|
||||||
|
await resetAudiobookById(page, bookId);
|
||||||
|
await page.reload();
|
||||||
|
await openExportModal(page);
|
||||||
|
await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const resetButtons = page.getByRole('button', { name: 'Reset' });
|
const resetButtons = page.getByRole('button', { name: 'Reset' });
|
||||||
const count = await resetButtons.count();
|
const count = await resetButtons.count();
|
||||||
|
|
||||||
|
|
@ -199,8 +238,12 @@ async function resetAudiobookIfPresent(page: Page) {
|
||||||
const resetButton = resetButtons.first();
|
const resetButton = resetButtons.first();
|
||||||
await resetButton.click();
|
await resetButton.click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15_000 });
|
const resetDialog = page
|
||||||
const confirmReset = page.getByRole('button', { name: 'Reset' }).last();
|
.getByRole('dialog')
|
||||||
|
.filter({ has: page.getByRole('heading', { name: 'Reset Audiobook' }) })
|
||||||
|
.first();
|
||||||
|
await expect(resetDialog).toBeVisible({ timeout: 15_000 });
|
||||||
|
const confirmReset = resetDialog.getByRole('button', { name: 'Reset' });
|
||||||
await confirmReset.click();
|
await confirmReset.click();
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 });
|
await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
@ -256,7 +299,7 @@ test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({
|
||||||
expect(ch.duration).toBeGreaterThan(0);
|
expect(ch.duration).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
await resetAudiobookIfPresent(page);
|
await resetAudiobookIfPresent(page, bookId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ({ page }, testInfo) => {
|
test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ({ page }, testInfo) => {
|
||||||
|
|
@ -312,7 +355,7 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async
|
||||||
expect(durationSeconds).toBeLessThan(300);
|
expect(durationSeconds).toBeLessThan(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
await resetAudiobookIfPresent(page);
|
await resetAudiobookIfPresent(page, bookId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }, testInfo) => {
|
test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }, testInfo) => {
|
||||||
|
|
@ -343,7 +386,7 @@ test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page
|
||||||
expect(durationSeconds).toBeLessThan(300);
|
expect(durationSeconds).toBeLessThan(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
await resetAudiobookIfPresent(page);
|
await resetAudiobookIfPresent(page, bookId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resets all MP3 audiobook PDF pages', async ({ page }, testInfo) => {
|
test('resets all MP3 audiobook PDF pages', async ({ page }, testInfo) => {
|
||||||
|
|
@ -455,7 +498,7 @@ test('regenerates a single MP3 audiobook PDF page and exports full audiobook', a
|
||||||
expect(ch.duration).toBeGreaterThan(0);
|
expect(ch.duration).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
await resetAudiobookIfPresent(page);
|
await resetAudiobookIfPresent(page, bookId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resumes audiobook when a chapter is missing and full download succeeds (PDF)', async ({ page }, testInfo) => {
|
test('resumes audiobook when a chapter is missing and full download succeeds (PDF)', async ({ page }, testInfo) => {
|
||||||
|
|
@ -476,7 +519,9 @@ test('resumes audiobook when a chapter is missing and full download succeeds (PD
|
||||||
|
|
||||||
// Delete the first chapter via the backend API so the audiobook has a missing index (0).
|
// Delete the first chapter via the backend API so the audiobook has a missing index (0).
|
||||||
// This is more reliable than clicking through the chapter actions menu in headless runs.
|
// This is more reliable than clicking through the chapter actions menu in headless runs.
|
||||||
const deleteRes = await page.request.delete(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=0`);
|
const deleteRes = await requestWithRetry(() =>
|
||||||
|
page.request.delete(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=0`)
|
||||||
|
);
|
||||||
expect(deleteRes.ok()).toBeTruthy();
|
expect(deleteRes.ok()).toBeTruthy();
|
||||||
|
|
||||||
// Wait for backend to reflect only one remaining chapter (index 1).
|
// Wait for backend to reflect only one remaining chapter (index 1).
|
||||||
|
|
@ -534,5 +579,5 @@ test('resumes audiobook when a chapter is missing and full download succeeds (PD
|
||||||
expect(ch.duration).toBeGreaterThan(0);
|
expect(ch.duration).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
await resetAudiobookIfPresent(page);
|
await resetAudiobookIfPresent(page, bookId);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -22,6 +22,21 @@ function sha256HexOfFile(filePath: string) {
|
||||||
return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForPdfViewerReady(page: Page, timeout = 60000) {
|
||||||
|
await expect(page).toHaveURL(/\/pdf\/[A-Za-z0-9._%-]+$/, { timeout: Math.min(timeout, 20000) });
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const documentVisible = await page.locator('.react-pdf__Document').first().isVisible().catch(() => false);
|
||||||
|
if (documentVisible) return true;
|
||||||
|
const pageVisible = await page.locator('.react-pdf__Page').first().isVisible().catch(() => false);
|
||||||
|
if (pageVisible) return true;
|
||||||
|
const canvasVisible = await page.locator('.react-pdf__Page canvas').first().isVisible().catch(() => false);
|
||||||
|
if (canvasVisible) return true;
|
||||||
|
return false;
|
||||||
|
}, { timeout })
|
||||||
|
.toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a sample epub or pdf
|
* Upload a sample epub or pdf
|
||||||
*/
|
*/
|
||||||
|
|
@ -63,7 +78,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
|
||||||
await expect(targetLink).toBeVisible({ timeout: 15000 });
|
await expect(targetLink).toBeVisible({ timeout: 15000 });
|
||||||
await dismissOnboardingModals(page);
|
await dismissOnboardingModals(page);
|
||||||
await targetLink.click();
|
await targetLink.click();
|
||||||
await page.waitForSelector('.react-pdf__Document', { timeout: 15000 });
|
await waitForPdfViewerReady(page, 60000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,7 +95,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lower.endsWith('.pdf')) {
|
if (lower.endsWith('.pdf')) {
|
||||||
await page.waitForSelector('.react-pdf__Document', { timeout: 10000 });
|
await waitForPdfViewerReady(page, 60000);
|
||||||
} else if (lower.endsWith('.epub')) {
|
} else if (lower.endsWith('.epub')) {
|
||||||
await page.waitForSelector('.epub-container', { timeout: 10000 });
|
await page.waitForSelector('.epub-container', { timeout: 10000 });
|
||||||
} else if (lower.endsWith('.txt') || lower.endsWith('.md')) {
|
} else if (lower.endsWith('.txt') || lower.endsWith('.md')) {
|
||||||
|
|
@ -350,10 +365,28 @@ export async function ensureDocumentsListed(page: Page, fileNames: string[]) {
|
||||||
|
|
||||||
// Click the document link row by filename
|
// Click the document link row by filename
|
||||||
export async function clickDocumentLink(page: Page, fileName: string) {
|
export async function clickDocumentLink(page: Page, fileName: string) {
|
||||||
await page
|
const link = page
|
||||||
.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') })
|
.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') })
|
||||||
.first()
|
.first();
|
||||||
.click();
|
await expect(link).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const href = await link.getAttribute('href');
|
||||||
|
if (!href) {
|
||||||
|
await link.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigatedByClick = await Promise.all([
|
||||||
|
page
|
||||||
|
.waitForURL((url) => url.pathname === href, { timeout: 8_000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
link.click(),
|
||||||
|
]).then(([ok]) => ok);
|
||||||
|
|
||||||
|
if (!navigatedByClick) {
|
||||||
|
await page.goto(href);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expect correct URL and viewer to be visible for a given file by extension
|
// Expect correct URL and viewer to be visible for a given file by extension
|
||||||
|
|
|
||||||
|
|
@ -73,4 +73,67 @@ test.describe('pdf audiobook adapter', () => {
|
||||||
expect(chapters[1].title).toBe('Second');
|
expect(chapters[1].title).toBe('Second');
|
||||||
expect(chapters[1].text).toContain('More body.');
|
expect(chapters[1].text).toContain('More body.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps a single section chapter when only one heading is present', async () => {
|
||||||
|
const parsed: ParsedPdfDocument = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
documentId: 'doc-2',
|
||||||
|
parserVersion: 'test',
|
||||||
|
parsedAt: Date.now(),
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
pageNumber: 1,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'p1-title',
|
||||||
|
kind: 'doc_title',
|
||||||
|
text: 'Sample PDF',
|
||||||
|
fragments: [{ page: 1, bbox: [0, 80, 100, 90], text: 'Sample PDF', readingOrder: 0 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p1-text',
|
||||||
|
kind: 'text',
|
||||||
|
text: 'First page body.',
|
||||||
|
fragments: [{ page: 1, bbox: [0, 50, 100, 79], text: 'First page body.', readingOrder: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pageNumber: 2,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'p2-text',
|
||||||
|
kind: 'text',
|
||||||
|
text: 'Second page body.',
|
||||||
|
fragments: [{ page: 2, bbox: [0, 50, 100, 79], text: 'Second page body.', readingOrder: 0 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const settings: DocumentSettings = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
pdf: {
|
||||||
|
skipBlockKinds: [],
|
||||||
|
chaptersFromSections: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const adapter = createPdfAudiobookSourceAdapter({
|
||||||
|
parsed,
|
||||||
|
settings,
|
||||||
|
smartSentenceSplitting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const chapters = await adapter.prepareChapters();
|
||||||
|
expect(chapters).toHaveLength(1);
|
||||||
|
expect(chapters[0].title).toBe('Sample PDF');
|
||||||
|
expect(chapters[0].text).toContain('First page body.');
|
||||||
|
expect(chapters[0].text).toContain('Second page body.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue