"""Local Reader behavior regressions; all document/network fixtures stay offline.""" import io import base64 import gzip import hashlib import json import os import time import unittest import urllib.parse import zipfile import wave from tests import test_reader_performance as support @unittest.skipIf(support.sync_playwright is None, "Playwright is unavailable") class ReaderRefactorTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.server = support.static_server() cls.origin = cls.server.__enter__() cls.playwright = support.sync_playwright().start() try: cls.browser = cls.playwright.chromium.launch(headless=True, args=["--no-sandbox"]) except support.PlaywrightError as error: cls.playwright.stop() cls.server.__exit__(None, None, None) raise unittest.SkipTest(str(error)) @classmethod def tearDownClass(cls): cls.browser.close() cls.playwright.stop() cls.server.__exit__(None, None, None) def setUp(self): self.context = self.browser.new_context(viewport={"width": 1100, "height": 800}) self.context.route("**/*", lambda route: route.continue_() if route.request.url.startswith(self.origin + "/") else route.abort()) self.page = self.context.new_page() self.errors = [] self.page.on("pageerror", lambda error: self.errors.append(str(error))) self.page.set_default_timeout(int(os.environ.get("READER_TEST_TIMEOUT_MS", "6000"))) def tearDown(self): self.context.close() self.assertEqual(self.errors, []) def reader_url(self, extension="txt", name="refactor", **params): source = f"https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/{name}.{extension}" values = {"url": source, "ext": extension, "title": name, **params} return self.origin + "/static/reader.html?" + urllib.parse.urlencode(values) def serve(self, body, mime="text/plain"): self.page.route("**/api/reader-content**", lambda route: route.fulfill( status=200, content_type=mime, body=body)) def open(self, url): self.page.goto(url) self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") def test_zoom_changed_during_preparation_survives_late_restoration(self): store = support.STORE_SCRIPT.replace( "get: () => new Promise((resolve) => setTimeout(() => resolve(null), 300)),", "get: () => new Promise(resolve => { window.__releaseRestore = () => resolve({url: location.href, page: 1, zoom: 220}); }),", ) self.page.route("**/static/reader-store.js", lambda route: route.fulfill( content_type="text/javascript", body=store)) self.serve(support.minimal_pdf(), "application/pdf") self.page.goto(self.reader_url("pdf"), wait_until="domcontentloaded") self.page.wait_for_function("() => !!window.__releaseRestore && !!document.querySelector('.reader-page canvas.ready')") self.page.locator("#zoom-in").click(click_count=3) self.assertEqual(self.page.locator("#zoom").input_value(), "130") self.page.evaluate("window.__releaseRestore()") self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") self.assertEqual(self.page.locator("#zoom").input_value(), "130") self.assertEqual(self.page.locator("#content").evaluate( "node => node.style.getPropertyValue('--reader-zoom')"), "1.3") self.assertEqual(self.page.evaluate("localStorage.getItem('reader-zoom')"), "130") self.page.wait_for_function("() => window.__savedReaderProgress?.zoom === 130") def test_book_restoration_does_not_change_new_books_global_zoom(self): store = support.STORE_SCRIPT.replace( "get: () => new Promise((resolve) => setTimeout(() => resolve(null), 300)),", "get: (url) => Promise.resolve(url.includes('refactor.pdf') ? {url, page: 1, zoom: 175} : null),", ) self.page.route("**/static/reader-store.js", lambda route: route.fulfill( content_type="text/javascript", body=store)) self.serve(support.minimal_pdf(), "application/pdf") self.page.goto(self.reader_url("pdf")) self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") self.page.locator("#zoom").fill("125") self.page.locator("#zoom").press("Enter") self.page.reload() self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") self.assertEqual(self.page.locator("#zoom").input_value(), "175") self.assertEqual(self.page.locator("#content").evaluate( "node => node.style.getPropertyValue('--reader-zoom')"), "1.75") self.assertEqual(self.page.evaluate("localStorage.getItem('reader-zoom')"), "125") self.page.goto(self.reader_url("pdf", "another")) self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") self.assertEqual(self.page.locator("#zoom").input_value(), "125") self.assertEqual(self.page.locator("#content").evaluate( "node => node.style.getPropertyValue('--reader-zoom')"), "1.25") def test_pdf_page_sources_preserve_version_and_navigate(self): manifest = {"version": 2, "kind": "pdf-pages", "page_count": 3} root = "https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/objects/aa/" + "a" * 64 self.serve(json.dumps(manifest), "application/json") self.page.route("**/page-manifest.json", lambda route: route.fulfill(json=manifest)) self.page.route("**/pages/page-*.webp", lambda route: route.fulfill( content_type="image/webp", body=support.IMAGE_FIXTURES["webp"][1])) for version in ("", "/1234567890abcdef"): with self.subTest(version=version): source = root + version self.open(self.reader_url("pdf-pages", url=source + "/page-manifest.json")) self.page.locator(".reader-page img.ready").first.wait_for() self.page.locator("#page-number").fill("3") self.page.locator("#page-number").dispatch_event("change") image = self.page.locator('.reader-page[data-page="3"] img.ready') image.wait_for() self.assertEqual(image.get_attribute("src"), source + "/pages/page-000003.webp") self.assertTrue(image.evaluate("image => image.complete && image.naturalWidth > 0")) self.assertTrue(self.page.locator("#full-search-toggle").evaluate("node => node.hidden")) def test_native_spreadsheet_html_keeps_cells_selectable_and_zoomable(self): chart = support.IMAGE_FIXTURES["webp"][1] chart_data = "data:image/webp;base64," + base64.b64encode(chart).decode() document = ( '
' '

Summary

' f'
完整单元格文字
' ) self.serve(document, "text/html") self.open(self.reader_url("html", "table")) frame_element = self.page.locator("iframe.spreadsheet-html-frame") frame_element.wait_for() frame = frame_element.element_handle().content_frame() self.assertEqual(frame.locator("#cell").text_content().strip(), "完整单元格文字") self.assertGreater(frame.locator("img").evaluate("image => image.naturalWidth"), 0) before = frame.locator("table").evaluate("node => node.getBoundingClientRect().width") self.page.locator("#zoom-in").click(click_count=5) self.assertGreater( frame.locator("table").evaluate("node => node.getBoundingClientRect().width"), before, ) def test_saved_native_pdf_opens_target_page_first(self): store = support.STORE_SCRIPT.replace( "get: () => new Promise((resolve) => setTimeout(() => resolve(null), 300)),", "get: () => Promise.resolve({page: 12, pageOffset: 20, zoom: 1}),", ) module = support.PDF_MODULE.replace( "getPage: () => Promise.resolve(page)", "getPage: (number) => { (window.__pdfPageRequests ||= []).push(number); return Promise.resolve(page); }", ) self.page.route("**/static/reader-store.js", lambda route: route.fulfill(content_type="text/javascript", body=store)) self.page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill(content_type="text/javascript", body=module)) self.serve(b"pdf", "application/pdf") self.open(self.reader_url("pdf")) self.assertEqual(self.page.evaluate("window.__pdfPageRequests[0]"), 12) self.assertEqual(self.page.locator("#page-number").input_value(), "12") self.page.locator('.reader-page[data-page="12"] canvas.ready').wait_for() def test_saved_pdf_pages_requests_target_image_first(self): store = support.STORE_SCRIPT.replace( "get: () => new Promise((resolve) => setTimeout(() => resolve(null), 300)),", "get: () => Promise.resolve({page: 12, pageOffset: 0, zoom: 1}),", ) manifest = {"version": 2, "kind": "pdf-pages", "page_count": 24} root = "https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/objects/aa/" + "a" * 64 requests = [] self.page.route("**/static/reader-store.js", lambda route: route.fulfill(content_type="text/javascript", body=store)) self.serve(json.dumps(manifest), "application/json") self.page.route("**/page-manifest.json", lambda route: route.fulfill(json=manifest)) def image(route): requests.append(route.request.url) route.fulfill(content_type="image/webp", body=support.IMAGE_FIXTURES["webp"][1]) self.page.route("**/pages/page-*.webp", image) self.open(self.reader_url("pdf-pages", url=root + "/page-manifest.json")) self.assertTrue(requests, "saved-page image was not requested") self.assertTrue(requests[0].endswith("page-000012.webp"), requests) self.assertEqual(self.page.locator("#page-number").input_value(), "12") def test_native_pdf_reuses_text_after_layer_is_cleared(self): module = support.PDF_MODULE.replace( "getTextContent() { return Promise.resolve", "getTextContent() { window.__pdfTextCalls = (window.__pdfTextCalls || 0) + 1; return Promise.resolve", ) self.page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill(content_type="text/javascript", body=module)) def instrument(route): response = route.fetch() route.fulfill(response=response, body=response.text() + "\nwindow.__reloadPdfText = async () => { const shell = document.querySelector('.reader-page[data-page=\\\"1\\\"]'); shell.querySelector('.reader-pdf-text').replaceChildren(); shell.dataset.textReady = '0'; await renderPdfText(await pdfDocument.getPage(1), shell); };\n") self.page.route("**/static/reader.js?*", instrument) self.serve(b"pdf", "application/pdf") self.open(self.reader_url("pdf")) self.page.wait_for_function("() => document.querySelector('.reader-page[data-page=\\\"1\\\"]')?.dataset.textReady === '1'") before = self.page.evaluate("window.__pdfTextCalls") self.page.evaluate("window.__reloadPdfText()") self.assertEqual(self.page.evaluate("window.__pdfTextCalls"), before) self.assertIn("Accessible PDF text", self.page.locator('.reader-page[data-page="1"] .reader-pdf-text').text_content()) def test_saved_chapter_book_fetches_target_first(self): store = support.STORE_SCRIPT.replace( "get: () => new Promise((resolve) => setTimeout(() => resolve(null), 300)),", "get: () => Promise.resolve({chapterIndex: 8, chapterOffset: 0}),", ) root = "https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/objects/aa/" + "a" * 64 manifest = {"version": 1, "kind": "epub-chapters", "chapters": [ {"index": i, "path": f"chapter-{i}.xhtml", "bytes": 100} for i in range(1, 13) ]} requested = [] self.page.route("**/static/reader-store.js", lambda route: route.fulfill(content_type="text/javascript", body=store)) def resource(route): url = urllib.parse.parse_qs(urllib.parse.urlsplit(route.request.url).query).get("url", [""])[0] if "chapter-" in url and url.endswith(".xhtml"): number = int(url.rsplit("chapter-", 1)[1].split(".", 1)[0]) requested.append(number) route.fulfill(content_type="text/html", body=f"

Chapter {number}

Content

") else: route.fulfill(json=manifest) self.page.route("**/api/reader-content**", resource) self.open(self.reader_url("epub-chapters", url=root + "/chapter-manifest.json")) self.assertEqual(requested[0], 8) self.assertNotIn(1, requested) self.assertTrue(self.page.locator('.reader-epub-chapter[data-chapter="8"]').is_visible()) self.page.locator('#history').click() self.page.locator('#toc-list .panel-item-main').nth(6).click() self.page.locator('.reader-epub-chapter[data-chapter="7"]').wait_for(state="attached") self.page.wait_for_function("() => document.querySelector('.reader-chapter-sentinel[data-chapter=\\\"6\\\"]') || document.querySelector('.reader-epub-chapter[data-chapter=\\\"6\\\"]')") self.page.evaluate("() => document.querySelector('.reader-chapter-sentinel[data-chapter=\\\"6\\\"]')?.click()") self.page.locator('.reader-epub-chapter[data-chapter="6"]').wait_for(state="attached") self.assertNotIn(1, requested) def test_saved_foliate_builds_target_section_first(self): store = support.STORE_SCRIPT.replace( "get: () => new Promise((resolve) => setTimeout(() => resolve(null), 300)),", "get: () => Promise.resolve({foliateSection: 10, foliateOffset: 0, foliateTocIndex: 9}),", ) self.page.route("**/static/reader-store.js", lambda route: route.fulfill(content_type="text/javascript", body=store)) def instrument(route): response = route.fetch() body = response.text().replace( "async function createFoliateSection(section, index) {", "async function createFoliateSection(section, index) { (window.__openedSections ||= []).push(index);", ) route.fulfill(response=response, body=body) self.page.route("**/static/reader.js?*", instrument) self.serve(support.epub_with_many_chapters(), "application/epub+zip") self.open(self.reader_url("epub")) self.assertEqual(self.page.evaluate("window.__openedSections[0]"), 10) self.assertTrue(self.page.locator('.foliate-continuous article[data-section="10"]').is_visible()) def test_pdf_pages_recovers_ocr_text_and_searches_valid_index(self): def instrument(route): response = route.fetch() route.fulfill(response=response, body=response.text() + "\nwindow.__reloadPdfOcr = async () => { const shell = document.querySelector('.reader-page[data-page=\\\"1\\\"]'); shell.querySelector('.reader-pdf-text').replaceChildren(); shell.dataset.textReady = '0'; await renderPdfOcrText(shell); };\n") self.page.route("**/static/reader.js?*", instrument) root = "objects/aa/" + "a" * 64 + "/" + "b" * 16 source = "https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/" + root ocr_path = root + "/ocr-manifest.json" book_path = root + "/ocr/book-text.json.gz" book = gzip.compress(json.dumps({"version": 2, "kind": "pdf-book-text", "complete": True, "offset_unit": "unicode-codepoint", "source_sha256": "a" * 64, "page_count": 2, "pages": [{"page": page, "text": "indexed text" if page == 1 else "", "layout": {"offset_unit": "unicode-codepoint"}, "text_spans": []} for page in (1, 2)]}).encode()) manifest = {"version": 1, "kind": "pdf-ocr", "complete": True, "source_sha256": "a" * 64, "profile": "test-layout-v1-retry", "page_count": 2, "pages": [{"p": page, "o": root + f"/ocr/page-{page:06d}.json.gz"} for page in (1, 2)], "book_text": {"path": book_path, "bytes": len(book), "sha256": hashlib.sha256(book).hexdigest()}} attempts, manifest_attempts, image_requests = [], [], [] self.serve(json.dumps({"version": 2, "kind": "pdf-pages", "page_count": 2}), "application/json") def image(route): image_requests.append(route.request.url) route.fulfill(content_type="image/webp", body=support.IMAGE_FIXTURES["webp"][1]) def resource(route): path = urllib.parse.parse_qs(urllib.parse.urlsplit(route.request.url).query)["path"][0] if path == ocr_path: manifest_attempts.append(path) if len(manifest_attempts) == 1: route.fulfill(status=503, body="temporarily unavailable") return route.fulfill(json=manifest) return if path == book_path: route.fulfill(content_type="application/gzip", body=book) return if path.endswith("page-000001.json.gz"): attempts.append(path) if len(attempts) == 1: route.fulfill(status=503, body="temporarily unavailable") return number = int(path.rsplit("page-", 1)[1][:6]) payload = {"version": 1, "kind": "pdf-ocr-page", "page": number, "width": 100, "height": 140, "blocks": [{"t": "Recovered text", "b": [0.1, 0.2, 0.8, 0.3]}]} route.fulfill(content_type="application/gzip", body=gzip.compress(json.dumps(payload).encode())) self.page.route(source + "/pages/**", image) self.page.route("**/api/reader-bucket-resource**", resource) self.open(self.reader_url("pdf-pages", url=source + "/page-manifest.json", ocr_manifest=self.origin + "/api/reader-bucket-resource?path=" + ocr_path)) self.assertFalse(self.page.locator("#full-search-toggle").evaluate("node => node.hidden")) self.page.wait_for_function("() => document.querySelector('.reader-page[data-page=\"1\"]')?.dataset.renderState === 'rendered' && !document.querySelector('.reader-page[data-page=\"1\"]')._ocrPromise") self.page.evaluate("""async () => { const shell = document.querySelector('.reader-page[data-page="1"]'); for (let attempt = 0; attempt < 3 && shell.dataset.textReady !== '1'; attempt++) await window.__reloadPdfOcr(); }""") self.assertGreaterEqual(len(manifest_attempts), 1) self.assertEqual(len(attempts), 2) first_image_requests = image_requests.count(source + "/pages/page-000001.webp") self.page.locator("#page-number").fill("2") self.page.locator("#page-number").dispatch_event("change") self.page.wait_for_function("() => document.querySelector('#page-number').value === '2'") self.page.locator("#page-number").fill("1") self.page.locator("#page-number").dispatch_event("change") self.page.locator('.reader-page[data-page="1"][data-text-ready="1"]').wait_for() self.assertEqual(len(attempts), 2) self.assertEqual(image_requests.count(source + "/pages/page-000001.webp"), first_image_requests) self.page.evaluate("window.__reloadPdfOcr()") self.assertEqual(len(attempts), 2) self.assertIn("Recovered text", self.page.locator('.reader-page[data-page="1"] .reader-pdf-text').text_content()) self.page.locator("#history").click() self.page.locator("#full-search-toggle").click() self.page.locator("#full-search-input").fill("indexed") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'") self.assertEqual(self.page.locator(".full-search-result").count(), 1) def test_pdf_pages_ocr_uses_versioned_manifest_paths_after_image(self): self.page.set_viewport_size({"width": 390, "height": 844}) root = "objects/aa/" + "a" * 64 image_root = root + "/" + "1" * 16 ocr_root = root + "/" + "2" * 16 text_root = root + "/" + "3" * 16 source = "https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/" + image_root ocr_path = ocr_root + "/ocr-manifest.json" payloads = [gzip.compress(json.dumps({"version": 1, "kind": "pdf-ocr-page", "page": number, "width": 100, "height": 140, "blocks": [ {"t": f"Page {number} text", "b": [0.2, 0.2, 0.7, 0.3]}, {"t": f"Page {number} middle", "b": [0.2, 0.4, 0.7, 0.5]}, {"t": f"Page {number} end", "b": [0.2, 0.7, 0.7, 0.8]}]}).encode()) for number in (1, 2)] manifest = {"version": 1, "kind": "pdf-ocr", "complete": True, "source_sha256": "a" * 64, "profile": "test-layout-v1-1234567890abcdef", "page_count": 2, "pages": [ {"p": number, "o": text_root + f"/ocr/page-{number:06d}.json.gz", "os": hashlib.sha256(payloads[number - 1]).hexdigest(), "ob": len(payloads[number - 1])} for number in (1, 2)], "book_text": {"path": ocr_root + "/ocr/book-text.json.gz", "bytes": 1, "sha256": "a" * 64}, "page_manifest": {"path": image_root + "/page-manifest.json"}} held, requested, held_images = [], [], [] self.serve(json.dumps({"version": 2, "kind": "pdf-pages", "page_count": 2}), "application/json") def serve_ocr(route): path = urllib.parse.parse_qs(urllib.parse.urlsplit(route.request.url).query)["path"][0] requested.append(path) if path == ocr_path: held.append(route) elif path in [entry["o"] for entry in manifest["pages"]]: number = int(path.rsplit("page-", 1)[1][:6]) route.fulfill(content_type="application/gzip", body=payloads[number - 1]) else: route.fulfill(status=404) self.page.route("**/api/reader-bucket-resource**", serve_ocr) def serve_image(route): if route.request.url.endswith("page-000001.webp"): held_images.append(route) else: route.fulfill(content_type="image/webp", body=support.IMAGE_FIXTURES["webp"][1]) self.page.route(source + "/pages/**", serve_image) with self.page.expect_request(self.origin + "/api/reader-bucket-resource?path=" + ocr_path): self.page.goto(self.reader_url("pdf-pages", url=source + "/page-manifest.json", ocr_manifest=self.origin + "/api/reader-bucket-resource?path=" + ocr_path), wait_until="domcontentloaded") self.page.wait_for_timeout(100) self.assertEqual(len(held_images), 1) self.assertEqual(self.page.locator('.reader-page[data-page="1"] img.ready').count(), 0) self.assertEqual(self.page.locator('.reader-loading-indicator').count(), 0) self.assertTrue(self.page.locator('.reader-page-state').first.is_hidden()) held_images.pop().fulfill(content_type="image/webp", body=support.IMAGE_FIXTURES["webp"][1]) self.page.locator('html[data-reader-phase="ready"]').wait_for() self.page.locator('.reader-page[data-page="1"] img.ready').wait_for() self.assertEqual(self.page.locator('.reader-loading-indicator').count(), 0) self.assertEqual(len(held), 1) self.assertNotEqual(self.page.locator('.reader-page[data-page="1"]').get_attribute("data-text-ready"), "1") held.pop().fulfill(json=manifest) self.page.locator('.reader-page[data-page="1"][data-text-ready="1"]').wait_for() self.assertIn("Page 1 text", self.page.locator('.reader-page[data-page="1"] .reader-pdf-text').text_content()) self.page.locator("#page-number").fill("2") self.page.locator("#page-number").dispatch_event("change") self.page.locator('.reader-page[data-page="2"][data-text-ready="1"]').wait_for() self.assertIn("Page 2 text", self.page.locator('.reader-page[data-page="2"] .reader-pdf-text').text_content()) self.page.evaluate("document.querySelector('#viewport').scrollTop = 0") points = self.page.evaluate("""() => [1, 2].map(number => { const run = document.querySelectorAll(`.reader-page[data-page="${number}"] .reader-pdf-text-run`)[1]; const rect = run.getBoundingClientRect(); return { x: rect.left + 2, y: rect.top + rect.height / 2 }; })""") hit = self.page.evaluate("""() => { const shell = document.querySelector('.reader-page[data-page="2"]'); const box = shell.getBoundingClientRect(); const image = shell.querySelector('img'), run = shell.querySelector('.reader-pdf-text-run'); const text = run.getBoundingClientRect(); return { image: document.elementFromPoint(box.left + box.width * .9, box.top + box.height * .5) === image, text: document.elementFromPoint(text.left + text.width / 2, text.top + text.height / 2) === run }; }""") self.assertEqual(hit, {"image": True, "text": True}) self.page.evaluate("""() => { window.__pdfImageMenu = false; document.querySelector('.reader-page[data-page="2"] img').addEventListener('contextmenu', () => { window.__pdfImageMenu = true; }); }""") self.page.locator('.reader-page[data-page="2"] img').click(button="right", position={"x": 10, "y": 10}) self.assertTrue(self.page.evaluate("window.__pdfImageMenu")) gap = self.page.evaluate("""() => { const runs = document.querySelectorAll('.reader-page[data-page="2"] .reader-pdf-text-run'); const first = runs[0].getBoundingClientRect(), second = runs[1].getBoundingClientRect(); return { x: first.left + 4, y: (first.bottom + second.top) / 2 }; }""") self.page.mouse.move(points[0]["x"], points[0]["y"]) self.page.mouse.down() self.page.mouse.move(gap["x"], gap["y"], steps=24) during = self.page.evaluate("""() => ({ imageSelect: getComputedStyle(document.querySelector('.reader-page[data-page="1"] img')).userSelect, anchor: getSelection().anchorNode?.parentElement?.closest('.reader-page')?.dataset.page, focus: getSelection().focusNode?.parentElement?.closest('.reader-page')?.dataset.page, text: getSelection().toString() })""") self.assertEqual(during["imageSelect"], "none") self.assertEqual(during["anchor"], "1") self.assertEqual(during["focus"], "2") self.assertNotIn("Page 2 end", during["text"]) self.page.mouse.up() self.page.wait_for_function("() => !getSelection().toString().includes('Page 2 end')") selection = self.page.evaluate("getSelection().toString()") self.assertIn("Page 1 end", selection) self.assertIn("Page 2 text", selection) self.assertNotIn("Page 1 text", selection) self.assertNotIn("Page 2 end", selection) self.page.set_viewport_size({"width": 390, "height": 1500}) self.page.evaluate("() => { getSelection().removeAllRanges(); document.querySelector('#viewport').scrollTop = 0; }") bottom = self.page.evaluate("""() => { const start = document.querySelectorAll('.reader-page[data-page="1"] .reader-pdf-text-run')[2].getBoundingClientRect(); const end = document.querySelector('.reader-page[data-page="2"]').getBoundingClientRect(); return { x: start.left + 2, y: start.top + start.height / 2, endX: end.left + end.width / 2, endY: end.bottom + 8 }; }""") self.page.mouse.move(bottom["x"], bottom["y"]) self.page.mouse.down() self.page.mouse.move(bottom["endX"], bottom["endY"], steps=36) self.page.wait_for_function("() => getSelection().focusNode?.parentElement?.closest('.reader-page')?.dataset.page === '2'") moving = self.page.evaluate("""() => ({ anchor: getSelection().anchorNode.parentElement.closest('.reader-page').dataset.page, text: getSelection().toString() })""") self.assertEqual(moving["anchor"], "1") self.assertIn("Page 1 end", moving["text"]) self.assertIn("Page 2 end", moving["text"]) self.assertNotIn("Page 1 text", moving["text"]) self.page.mouse.up() columns = self.page.evaluate("""() => { getSelection().removeAllRanges(); const layer = document.querySelector('.reader-page[data-page="2"] .reader-pdf-text'); const right = layer.querySelector('.reader-pdf-text-position').cloneNode(true); right.style.left = '75%'; right.dataset.column = 'right'; right.querySelector('.reader-pdf-text-run').textContent = 'Right column'; right.querySelector('.reader-pdf-text-run').style.transform = 'scaleX(.35)'; layer.append(right); const start = document.querySelectorAll('.reader-page[data-page="1"] .reader-pdf-text-run')[2].getBoundingClientRect(); const end = right.querySelector('.reader-pdf-text-run').getBoundingClientRect(); return { start: { x: start.left + 2, y: start.top + start.height / 2 }, end: { x: end.left + end.width / 2, y: end.top + end.height / 2 } }; }""") self.page.mouse.move(**columns["start"]) self.page.mouse.down() self.page.mouse.move(**columns["end"], steps=24) self.page.mouse.up() self.page.wait_for_function("() => getSelection().focusNode?.parentElement?.closest('[data-column]')?.dataset.column === 'right'") self.page.wait_for_timeout(100) touch = self.page.evaluate("""() => { const run = document.querySelector('.reader-page[data-page="1"] .reader-pdf-text-run'); const node = run.firstChild, range = document.createRange(); range.setStart(node, 0); range.setEnd(node, node.length); getSelection().removeAllRanges(); getSelection().addRange(range); window.__pdfTouchWrites = 0; const original = Selection.prototype.setBaseAndExtent; Selection.prototype.setBaseAndExtent = function(...args) { window.__pdfTouchWrites++; return original.apply(this, args); }; const start = run.getBoundingClientRect(); const end = document.querySelector('.reader-page[data-page="2"] .reader-pdf-text-run').getBoundingClientRect(); const target = document.elementFromPoint(end.left + 4, end.top + 4) ?.closest('.reader-page')?.dataset.page; run.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, pointerId: 91, pointerType: 'touch', clientX: start.left + 4, clientY: start.top + 4})); document.dispatchEvent(new PointerEvent('pointermove', {bubbles: true, pointerId: 91, pointerType: 'touch', clientX: end.left + 4, clientY: end.top + 4})); return target; }""") self.assertEqual(touch, "2") self.page.wait_for_timeout(100) self.assertEqual(self.page.evaluate("window.__pdfTouchWrites"), 0) self.page.evaluate("document.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, pointerId: 91, pointerType: 'touch'}))") self.assertEqual(requested, [ocr_path, text_root + "/ocr/page-000001.json.gz", text_root + "/ocr/page-000002.json.gz"]) def test_download_during_prepare_does_not_interrupt_reader(self): pending_content = [] checks = [] downloads = [] def slow_content(route): pending_content.append(route) def check_download(route): checks.append(route.request.url) if len(checks) == 1: route.fulfill(status=503, content_type="application/json", body=json.dumps({"error": "下载检查失败,请重试"})) else: route.fulfill(content_type="application/json", body=json.dumps({"ok": True})) def serve_download(route): downloads.append(route.request.url) route.fulfill(content_type="application/octet-stream", body=b"download", headers={"Content-Disposition": "attachment; filename*=UTF-8''refactor.txt"}) self.page.route("**/api/reader-content**", slow_content) self.page.route("**/api/download/check**", check_download) self.page.route("**/api/download?**", serve_download) self.page.goto(self.reader_url("txt"), wait_until="domcontentloaded") self.page.wait_for_function("() => document.querySelector('#download').hasAttribute('href')") self.assertNotEqual(self.page.locator("html").get_attribute("data-reader-phase"), "ready") self.page.locator("#download").click() self.page.wait_for_function("() => document.querySelector('#download-feedback').textContent === '下载检查失败,请重试'") self.assertEqual(len(checks), 1) self.assertEqual(downloads, []) self.assertNotIn("原文件", self.page.locator("#status").text_content()) self.page.set_viewport_size({"width": 390, "height": 844}) self.assertTrue(self.page.locator("#download-feedback").evaluate("""node => { const message = node.getBoundingClientRect(); const button = document.querySelector('#download').getBoundingClientRect(); return message.left >= 0 && message.right <= innerWidth && message.top >= button.bottom; }""")) with self.page.expect_download() as event: self.page.locator("#download").click() self.assertEqual(event.value.suggested_filename, "refactor.txt") self.assertEqual(self.page.locator("html").get_attribute("data-reader-phase"), "prepare") self.assertEqual(len(checks), 2) self.assertEqual(len(downloads), 1) pending_content[0].fulfill(status=200, content_type="text/plain", body="Reader download") self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") self.assertIn("Reader download", self.page.locator("#content").text_content()) def test_id_only_download_joins_reader_resolution(self): resolved = [] content = [] checks = [] source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/shared.txt" self.page.route("**/api/reader-resolve?**", lambda route: resolved.append(route)) self.page.route("**/api/reader-content?**", lambda route: content.append(route)) self.page.route("**/api/download/check?**", lambda route: ( checks.append(route.request.url), route.fulfill(json={"ok": True}))) self.page.route("**/api/download?**", lambda route: route.fulfill( body=b"original", headers={"Content-Disposition": "attachment; filename=shared.txt"})) self.page.goto(self.origin + "/static/reader.html?id=" + "a" * 13 + "&ext=txt", wait_until="domcontentloaded") self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'resolve'") self.page.locator("#download").click() self.page.locator("#download").click() self.assertEqual(len(resolved), 1) self.assertEqual(checks, []) with self.page.expect_download() as event: resolved[0].fulfill(json={"url": source, "download": source, "title": "shared", "extension": "txt"}) self.assertEqual(event.value.suggested_filename, "shared.txt") self.assertEqual(len(checks), 1) self.assertEqual(self.page.locator("html").get_attribute("data-reader-phase"), "prepare") content[0].fulfill(body="Resolved text") self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") def test_pdf_can_finish_preparing_after_download_starts(self): pending_content = [] self.page.route("**/api/reader-content**", lambda route: pending_content.append(route)) self.page.route("**/api/download/check**", lambda route: route.fulfill(json={"ok": True})) self.page.route("**/api/download?**", lambda route: route.fulfill( content_type="application/octet-stream", body=b"original", headers={"Content-Disposition": "attachment; filename=book.pdf"})) self.page.goto(self.reader_url("pdf"), wait_until="domcontentloaded") self.page.wait_for_function("() => document.querySelector('#download').hasAttribute('href')") with self.page.expect_download() as event: self.page.locator("#download").click() self.assertEqual(event.value.suggested_filename, "book.pdf") self.assertEqual(self.page.locator("html").get_attribute("data-reader-phase"), "prepare") self.page.wait_for_timeout(200) self.assertTrue(pending_content) pending_content[0].fulfill(content_type="application/pdf", body=support.minimal_pdf()) self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") self.page.locator('.reader-page[data-page="1"] canvas.ready').wait_for(state="attached") def test_pdf_retries_transient_proxy_failure_while_download_starts(self): requests, held = [], [] direct = [] def reader_content(route): requests.append(route) if len(requests) > 1: route.fulfill(content_type="application/pdf", body=support.minimal_pdf()) self.page.route("**/api/reader-content**", reader_content) self.page.route("https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/refactor.pdf", lambda route: (direct.append(route.request.url), route.abort())) self.page.route("**/api/download/check**", lambda route: route.fulfill(json={"ok": True})) self.page.route("**/api/download?**", lambda route: route.fulfill( body=b"original", headers={"Content-Disposition": "attachment; filename=refactor.pdf"})) for fault in ("http", "transport"): with self.subTest(fault=fault): requests.clear() with self.page.expect_request("**/api/reader-content**"): self.page.goto(self.reader_url("pdf"), wait_until="domcontentloaded") self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'prepare'") with self.page.expect_download() as event: self.page.locator("#download").click() self.assertEqual(event.value.suggested_filename, "refactor.pdf") self.page.wait_for_function("() => document.querySelector('#download-feedback').textContent === '已发起下载'") self.assertEqual(len(requests), 1) if fault == "http": requests[0].fulfill(status=503, body="temporary") else: requests[0].abort("failed") self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") self.page.locator('.reader-page[data-page="1"] canvas.ready').wait_for(state="attached") self.assertEqual(len(requests), 2) self.assertEqual(direct, []) def test_failed_reader_still_allows_original_download(self): checks = [] def check(route): checks.append(route.request.url) if len(checks) == 1: route.fulfill(status=503, json={"error": "暂时无法检查下载"}) else: route.fulfill(json={"ok": True}) self.page.route("**/api/download/check**", check) self.page.route("**/api/download?**", lambda route: route.fulfill( body=b"original", headers={"Content-Disposition": "attachment; filename=original.xyz"})) self.page.goto(self.reader_url("xyz")) self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'failed'") self.assertIn("请下载原文件", self.page.locator(".reader-error").text_content()) self.page.locator("#download").click() self.page.wait_for_function("() => document.querySelector('#download-feedback').textContent === '暂时无法检查下载'") with self.page.expect_download() as event: self.page.locator("#download").click() self.assertEqual(event.value.suggested_filename, "original.xyz") self.assertEqual(len(checks), 2) def test_pdf_embedded_ocr_selection_search_and_pagination(self): module = support.PDF_MODULE.replace("numPages: 30", "numPages: 3").replace( "return { width: 600 * scale, height: 800 * scale };", "return { width: 600 * scale, height: 800 * scale, scale, transform: [scale,0,0,-scale,0,800*scale], convertToViewportPoint:(x,y)=>[x*scale,(800-y)*scale] };") module = module.replace( "getTextContent() { return Promise.resolve", "async getTextContent() { await new Promise(resolve => setTimeout(resolve, 30)); return Promise.resolve" ) items = [dict(str=s, transform=[20, 0, 0, 20, x, 700], width=w, height=20, fontName="f") for s, x, w in [("毛 ", 50, 30), ("泽", 80, 20), (" 东", 100, 30), (" 手。机 hello world", 130, 200)]] items += [dict(str="毛 泽 东", transform=[20, 0, 0, 20, 50, 650-i*10], width=80, height=20, fontName="f", hasEOL=True) for i in range(40)] items.append(dict(str="竖 排 正 文", transform=[20, 0, 0, 20, 500, 700], width=20, height=140, dir="ttb", fontName="f")) text = json.dumps(dict(items=items, styles={"f": dict(fontFamily="sans-serif", ascent=0.8)}), ensure_ascii=False) module = module.replace("{ items: [{ str: 'Accessible PDF text', hasEOL: false }] }", text) self.page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill(content_type="text/javascript", body=module)) self.open(self.reader_url("pdf")) run = self.page.locator('.reader-page[data-page="1"] .reader-pdf-text-run').first def bounds(): return run.evaluate("""node => {const r=node.getBoundingClientRect(), p=node.closest('.reader-page').getBoundingClientRect(); return {x:(r.x-p.x)/p.width,y:(r.y-p.y)/p.height,width:r.width/p.width,height:r.height/p.width, hit:document.elementFromPoint(r.x+2,r.y+2)===node};} """) before = bounds() self.assertAlmostEqual(before['x'], 50/600, delta=0.002) self.assertAlmostEqual(before['y'], 84/800, delta=0.002) self.assertAlmostEqual(before['width'], 30/600, delta=0.002) self.assertTrue(before['hit']) vertical = self.page.locator('.reader-page[data-page="1"] .reader-pdf-text-run').last self.assertAlmostEqual(vertical.evaluate("e=>e.getBoundingClientRect().height/e.closest('.reader-page').getBoundingClientRect().height"), 140/800, delta=0.003) self.page.set_viewport_size({"width": 390, "height": 844}) after = bounds() for key in ('x', 'y', 'width', 'height'): self.assertAlmostEqual(before[key], after[key], delta=0.003) selected = run.evaluate("""node => {const r=document.createRange();r.selectNodeContents(node); const s=getSelection();s.removeAllRanges();s.addRange(r);return s.toString();}""") self.assertEqual(selected, '毛 ') self.page.evaluate('getSelection().removeAllRanges()') self.page.locator('#history').click() self.page.locator('#full-search-toggle').click() self.page.locator('#full-search-input').fill('毛泽东') self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent.includes('正在搜索') && document.querySelectorAll('.full-search-result').length > 0") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '123 个结果'") self.assertEqual(self.page.locator('.full-search-result').count(), 50) self.assertEqual(self.page.locator('.reader-page[data-page="1"] mark').first.text_content(), '毛 ') self.page.locator('#full-search-page-next').click() self.page.wait_for_function("() => document.querySelector('#full-search-page').value === '2'") self.page.locator('#full-search-page-next').click() self.page.wait_for_function("() => document.querySelector('#full-search-page').value === '3'") self.assertEqual(self.page.locator('.full-search-result').count(), 23) self.page.locator('.full-search-result').last.click() self.assertEqual(self.page.locator('#page-number').input_value(), '3') self.page.locator('#history').click() self.assertFalse(self.page.locator('#full-search-view').is_hidden()) self.page.locator('#full-search-input').fill('手机') self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '未找到正文匹配'") self.page.locator('#full-search-input').fill('hello world') self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '3 个结果'") def test_pdf_second_query_reuses_extracted_page_text(self): module = support.PDF_MODULE.replace( "getTextContent() { return Promise.resolve", "getTextContent() { window.__pdfTextCalls = (window.__pdfTextCalls || 0) + 1; return Promise.resolve" ) self.page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill( content_type="text/javascript", body=module)) self.open(self.reader_url("pdf")) self.page.locator("#history").click() self.page.locator("#full-search-toggle").click() self.page.locator("#full-search-input").fill("Accessible") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '30 个结果'") first = self.page.evaluate("window.__pdfTextCalls") self.page.locator("#full-search-input").fill("PDF") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '30 个结果'") self.assertEqual(self.page.evaluate("window.__pdfTextCalls"), first) def test_pdf_search_cache_cleanup_resets_byte_budget(self): def instrument(route): response = route.fetch() route.fulfill(response=response, body=response.text() + "\nwindow.__pdfCacheProbe = () => { cachePdfSearchText(1, 'cached'); readerRuntime.dispose(); return [pdfSearchTextCache.size, pdfSearchTextCacheBytes]; };\n") self.page.route("**/static/reader.js?*", instrument) self.serve(support.minimal_pdf(), "application/pdf") self.open(self.reader_url("pdf")) self.assertEqual(self.page.evaluate("window.__pdfCacheProbe()"), [0, 0]) def test_pdf_search_pages_reextract_evicted_text_and_ignore_cancelled_load(self): def instrument(route): response = route.fetch() route.fulfill(response=response, body=response.text() + "\nwindow.__dropPdfSearchCache = () => { pdfSearchTextCache.clear(); pdfSearchTextCacheBytes = 0; };\n") self.page.route("**/static/reader.js?*", instrument) module = support.PDF_MODULE.replace("numPages: 30", "numPages: 3").replace( "getPage: () => Promise.resolve(page)", """getPage: (number) => Promise.resolve({ ...page, getTextContent() { window.__pdfTextCalls = (window.__pdfTextCalls || 0) + 1; if (number === 1 && window.__holdPdfPage) { window.__holdPdfPage = false; return new Promise(resolve => { window.__releasePdfPage = () => resolve({ items: [{ str: 'needle '.repeat(60), hasEOL: false }] }); }); } return Promise.resolve({ items: [{ str: 'needle '.repeat(60), hasEOL: false }] }); } })""") self.page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill( content_type="text/javascript", body=module)) self.open(self.reader_url("pdf")) self.search("needle") self.assertEqual(self.page.locator("#full-search-status").text_content(), "180 个结果") self.assertEqual(self.page.locator(".full-search-result").count(), 50) self.page.evaluate("window.__dropPdfSearchCache()") calls = self.page.evaluate("window.__pdfTextCalls") self.page.locator("#full-search-page-next").click() self.page.wait_for_function("() => document.querySelector('.full-search-rank')?.textContent === '51.'") self.assertGreater(self.page.evaluate("window.__pdfTextCalls"), calls) self.page.locator("#full-search-page").fill("4") self.page.locator("#full-search-page").dispatch_event("change") self.page.wait_for_function("() => document.querySelector('.full-search-rank')?.textContent === '151.'") self.assertEqual(self.page.locator(".full-search-result").count(), 30) self.assertEqual(self.page.locator(".full-search-result").first.locator("small").text_content(), "第 3 页") self.page.evaluate("() => { window.__dropPdfSearchCache(); window.__holdPdfPage = true; }") self.page.locator("#full-search-page").fill("2") self.page.locator("#full-search-page").dispatch_event("change") self.page.wait_for_function("() => !!window.__releasePdfPage && document.querySelector('#full-search-status').textContent === '180 个结果'") self.page.locator("#full-search-cancel").click() self.page.evaluate("window.__releasePdfPage()") self.page.wait_for_timeout(100) self.assertEqual(self.page.locator("#full-search-status").text_content(), "搜索已取消") self.assertEqual(self.page.locator(".full-search-result").count(), 0) self.page.locator("#full-search-retry").click() self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '180 个结果'") self.page.locator("#full-search-page").fill("4") self.page.locator("#full-search-page").dispatch_event("change") self.page.wait_for_function("() => document.querySelector('.full-search-rank')?.textContent === '151.'") self.page.locator(".full-search-result").first.click() self.page.wait_for_function("() => document.querySelector('#page-number').value === '3'") def test_pdf_deep_result_page_can_cancel_while_skipping_hits(self): def instrument(route): response = route.fetch() script = response.text().replace( "function waitForReader(delay = 0, animationFrame = false) {", """function waitForReader(delay = 0, animationFrame = false) { if (window.__holdSearchPage && delay === 0 && !animationFrame) { window.__holdSearchPage = false; return new Promise(resolve => { window.__releaseSearchPage = resolve; }); }""" ) route.fulfill(response=response, body=script) self.page.route("**/static/reader.js?*", instrument) module = support.PDF_MODULE.replace("numPages: 30", "numPages: 2").replace( "getPage: () => Promise.resolve(page)", """getPage: () => Promise.resolve({ ...page, getTextContent() { return Promise.resolve({ items: [{ str: 'needle '.repeat(6000), hasEOL: false }] }); } })""") self.page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill( content_type="text/javascript", body=module)) self.open(self.reader_url("pdf")) self.search("needle") self.assertEqual(self.page.locator("#full-search-status").text_content(), "12000 个结果") self.page.evaluate("window.__holdSearchPage = true") self.page.locator("#full-search-page").fill("200") self.page.locator("#full-search-page").dispatch_event("change") self.page.wait_for_function("() => !!window.__releaseSearchPage && document.querySelector('#full-search-status').textContent === '12000 个结果'") self.page.locator("#full-search-cancel").click() self.page.evaluate("window.__releaseSearchPage()") self.page.wait_for_timeout(100) self.assertEqual(self.page.locator("#full-search-status").text_content(), "搜索已取消") self.assertEqual(self.page.locator(".full-search-result").count(), 0) def test_failed_pdf_search_page_retry_keeps_target_page(self): def instrument(route): response = route.fetch() needle = "async function loadPdfSearchText(pdf, page, generation) {" script = response.text() self.assertIn(needle, script) route.fulfill(response=response, body=script.replace(needle, needle + """ if (page === 2 && window.__failSearchPage) { window.__failSearchPage = false; throw new Error('temporary page load'); }""")) self.page.route("**/static/reader.js?*", instrument) module = support.PDF_MODULE.replace("numPages: 30", "numPages: 3").replace( "getPage: () => Promise.resolve(page)", """getPage: () => Promise.resolve({ ...page, getTextContent() { return Promise.resolve({ items: [{ str: 'needle '.repeat(60), hasEOL: false }] }); } })""") self.page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill( content_type="text/javascript", body=module)) self.open(self.reader_url("pdf")) self.search("needle") self.page.locator("#full-search-page-next").click() self.page.wait_for_function("() => document.querySelector('.full-search-rank')?.textContent === '51.'") self.page.evaluate("window.__failSearchPage = true") self.page.locator("#full-search-page-next").click() self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === 'temporary page load'") self.assertEqual(self.page.locator("#full-search-retry").text_content(), "重试本页") self.assertEqual(self.page.locator(".full-search-rank").first.text_content(), "51.") self.page.locator("#full-search-toggle").click() self.page.locator("#full-search-toggle").click() self.assertTrue(self.page.locator("#full-search-retry").is_visible()) self.page.locator("#full-search-retry").click() self.page.wait_for_function("() => document.querySelector('.full-search-rank')?.textContent === '101.'") self.assertEqual(self.page.locator("#full-search-page").input_value(), "3") self.assertEqual(self.page.locator("#full-search-status").text_content(), "180 个结果") def test_pending_foliate_result_does_not_close_reopened_search(self): self.serve(support.epub_with_many_chapters(3), "application/epub+zip") self.open(self.reader_url("epub")) self.search("正文") self.page.evaluate("""() => { const view = document.querySelector('foliate-view'); const original = view.resolveNavigation.bind(view); view.resolveNavigation = cfi => new Promise(resolve => { window.__releaseSearchNavigation = () => resolve(original(cfi)); }); }""") self.page.locator(".full-search-result").first.click() self.page.wait_for_function("() => !!window.__releaseSearchNavigation") self.page.locator("#history").click() self.page.locator("#history").click() self.page.locator("#full-search-input").focus() self.page.evaluate("window.__releaseSearchNavigation()") self.page.locator(".foliate-continuous mark.full-search-highlight").first.wait_for() self.assertTrue(self.page.locator("#full-search-input").is_visible()) self.assertTrue(self.page.locator("#history-panel").evaluate("node => node.classList.contains('is-open')")) def test_unresolvable_foliate_result_keeps_search_open_for_retry(self): self.serve(support.epub_with_many_chapters(3), "application/epub+zip") self.open(self.reader_url("epub")) self.search("正文") self.page.evaluate("""() => { const view = document.querySelector('foliate-view'); window.__resolveSearchResult = view.resolveNavigation.bind(view); view.resolveNavigation = async () => null; }""") self.page.locator(".full-search-result").first.click() self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '搜索结果定位失败,请重试'") self.assertTrue(self.page.locator("#history-panel").evaluate("node => node.classList.contains('is-open')")) self.assertEqual(self.page.locator(".foliate-continuous mark.full-search-highlight").count(), 0) self.page.evaluate("document.querySelector('foliate-view').resolveNavigation = window.__resolveSearchResult") self.page.locator(".full-search-result").first.click() self.page.wait_for_function("() => !document.querySelector('#history-panel').classList.contains('is-open')") self.assertEqual(self.page.locator("#full-search-status").text_content(), "3 个结果") self.assertEqual(self.page.locator(".foliate-continuous mark.full-search-highlight").count(), 1) def test_foliate_navigation_exception_reports_failure_and_retries(self): self.serve(support.epub_with_many_chapters(3), "application/epub+zip") self.open(self.reader_url("epub")) self.search("正文") self.page.evaluate("""() => { const view = document.querySelector('foliate-view'); const original = view.resolveNavigation.bind(view); view.resolveNavigation = async cfi => { view.resolveNavigation = original; throw new Error('temporary navigation failure'); }; }""") self.page.locator(".full-search-result").first.click() self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '搜索结果定位失败,请重试'") self.assertTrue(self.page.locator("#history-panel").evaluate("node => node.classList.contains('is-open')")) self.page.locator(".full-search-result").first.click() self.page.wait_for_function("() => !document.querySelector('#history-panel').classList.contains('is-open')") self.assertEqual(self.page.locator("#full-search-status").text_content(), "3 个结果") def test_stale_foliate_fallback_cannot_highlight_after_navigation(self): def hold_fallback(route): response = route.fetch() needle = "const nodes = await fullSearchTextNodes(root, generation);" script = response.text() self.assertIn(needle, script) script = script.replace(needle, """if (occurrence !== null && !window.__fallbackHeld) { window.__fallbackHeld = true; await new Promise(resolve => { window.__releaseFallback = resolve; }); } """ + needle) route.fulfill(response=response, body=script.replace( "target = matches[0]?.target;", "target = matches[0]?.target; window.__fallbackDone = true;")) self.page.route("**/static/reader.js?*", hold_fallback) self.serve(support.epub_with_many_chapters(3), "application/epub+zip") self.open(self.reader_url("epub")) self.search("正文") self.page.evaluate("""() => { const view = document.querySelector('foliate-view'); const original = view.resolveNavigation.bind(view); view.resolveNavigation = async cfi => ({ ...(await original(cfi)), anchor: null }); }""") self.page.locator(".full-search-result").first.click() self.page.wait_for_function("() => !!window.__releaseFallback") self.page.evaluate("document.querySelector('#viewport').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))") self.page.evaluate("window.__releaseFallback()") self.page.wait_for_function("() => !!window.__fallbackDone") self.assertEqual(self.page.locator(".foliate-continuous mark.full-search-highlight").count(), 0) def test_foliate_search_reuses_rows_and_bounds_visited_pages(self): with zipfile.ZipFile(io.BytesIO(support.epub_with_many_chapters(6))) as archive: files = {name: archive.read(name) for name in archive.namelist()} for number in range(1, 7): files[f'OEBPS/chapter-{number}.xhtml'] = ( '' + ''.join(f'

needle {number}-{hit}

' for hit in range(100)) + '').encode() self.serve(support.zip_bytes(files), "application/epub+zip") self.open(self.reader_url("epub")) self.page.evaluate("""() => { window.__searchProbe = {rows: 0, calls: 0}; const view = document.querySelector('foliate-view'), search = view.search.bind(view); view.search = (...args) => { __searchProbe.calls++; return search(...args); }; new MutationObserver(records => { for (const record of records) __searchProbe.rows += record.addedNodes.length; }).observe(document.querySelector('#full-search-results'), {childList: true}); }""") self.search("needle") self.assertEqual(self.page.locator("#full-search-status").text_content(), "600 个结果") self.assertEqual(self.page.evaluate("__searchProbe.rows"), 50) def jump(number): self.page.locator("#full-search-page").fill(str(number)) self.page.locator("#full-search-page").dispatch_event("change") self.page.wait_for_function("rank => document.querySelector('.full-search-rank')?.textContent === rank", arg=f"{(number - 1) * 50 + 1}.") return self.page.locator(".full-search-result").all_text_contents() original = jump(2) jump(3) before = self.page.evaluate("__searchProbe.calls") self.assertEqual(jump(2), original) self.assertEqual(self.page.evaluate("__searchProbe.calls"), before) for number in (4, 5, 6, 7): jump(number) before = self.page.evaluate("__searchProbe.calls") self.assertEqual(jump(2), original) self.assertGreater(self.page.evaluate("__searchProbe.calls"), before) self.page.locator("#full-search-input").fill("absent") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '未找到正文匹配'") self.page.locator("#full-search-input").fill("needle") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '600 个结果'") before = self.page.evaluate("__searchProbe.calls") self.assertEqual(jump(2), original) self.assertGreater(self.page.evaluate("__searchProbe.calls"), before) def test_foliate_many_hits_paginate_and_cancel(self): with zipfile.ZipFile(io.BytesIO(support.epub_with_many_chapters(3))) as archive: files = {name: archive.read(name) for name in archive.namelist()} for number in (1, 2, 3): files[f'OEBPS/chapter-{number}.xhtml'] = ( '' + ''.join(f'

needle {number}-{hit}

' for hit in range(70)) + '' ).encode() self.serve(support.zip_bytes(files), "application/epub+zip") self.open(self.reader_url("epub")) self.search("needle") self.assertEqual(self.page.locator("#full-search-status").text_content(), "210 个结果") self.assertEqual(self.page.locator(".full-search-result").count(), 50) self.assertTrue(self.page.locator("#full-search-cancel").is_hidden()) self.page.evaluate("""() => { window.__cancelPagingChanges = []; const cancel = document.querySelector('#full-search-cancel'); new MutationObserver(() => { __cancelPagingChanges.push(cancel.hidden); }).observe(document.querySelector('#full-search-cancel'), { attributes: true, attributeFilter: ['hidden'] }); }""") self.page.evaluate("""() => { const status = document.querySelector('#full-search-status'); window.__pagingStatuses = [status.textContent]; window.__pagingObserver = new MutationObserver(records => { for (const record of records) { for (const node of record.removedNodes) window.__pagingStatuses.push(node.textContent); } window.__pagingStatuses.push(status.textContent); }); window.__pagingObserver.observe(status, {childList: true}); const view = document.querySelector('foliate-view'); const original = view.search.bind(view); view.search = async function* (options) { if (options.index != null && !window.__heldResultPage) { window.__heldResultPage = true; await new Promise(resolve => { window.__releaseResultPage = resolve; }); } yield* original(options); }; }""") self.page.locator("#full-search-page-next").click() self.page.wait_for_function("() => !!window.__releaseResultPage") self.assertTrue(self.page.locator("#full-search-cancel").is_hidden()) self.page.evaluate("window.__releaseResultPage()") self.page.wait_for_function("() => document.querySelector('.full-search-rank')?.textContent === '51.'") self.page.locator("#full-search-page").fill("3") self.page.locator("#full-search-page").dispatch_event("change") self.page.wait_for_function("() => document.querySelector('#full-search-page').value === '3' && document.querySelector('.full-search-result .full-search-rank')?.textContent === '101.'") self.assertIn("needle 2-30", self.page.locator(".full-search-result").first.text_content()) self.assertTrue(self.page.locator("#full-search-cancel").is_hidden()) self.assertNotIn(False, self.page.evaluate("__cancelPagingChanges")) statuses = self.page.evaluate("() => { window.__pagingObserver.disconnect(); return window.__pagingStatuses; }") self.assertEqual(set(statuses), {"210 个结果"}) self.page.locator(".full-search-result").first.click() self.page.wait_for_function("() => !document.querySelector('#history-panel').classList.contains('is-open')") self.page.locator("#history").click() self.page.wait_for_function("() => document.querySelector('#history-panel').classList.contains('is-open') && !document.querySelector('#full-search-view').hidden") self.assertEqual(self.page.locator("#full-search-page").input_value(), "3") self.page.locator("#full-search-input").fill("missing") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '未找到正文匹配'") self.page.evaluate("""() => { const sections = document.querySelector('foliate-view').book.sections; for (const section of sections) if (section.createDocument) { const original = section.createDocument.bind(section); section.createDocument = async () => { await new Promise(resolve => setTimeout(resolve, 200)); return original(); }; } }""") self.page.locator("#full-search-input").fill("needle") self.page.locator("#full-search-cancel").click() self.page.wait_for_timeout(700) self.assertEqual(self.page.locator("#full-search-status").text_content(), "搜索已取消") self.assertEqual(self.page.locator(".full-search-result").count(), 0) self.page.locator("#full-search-retry").click() self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '210 个结果'") def test_pdf_with_unavailable_ocr_index_does_not_fall_back(self): self.page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill( content_type="text/javascript", body=support.PDF_MODULE)) manifest = self.origin + "/api/reader-bucket-resource?path=objects/aa/" + "a" * 64 + "/" + "b" * 16 + "/ocr-manifest.json" self.page.route("**/api/reader-bucket-resource**", lambda route: route.fulfill(status=503, body="unavailable")) self.open(self.reader_url("pdf", ocr_manifest=manifest)) self.page.locator("#history").click() self.page.locator("#full-search-toggle").click() self.page.locator("#full-search-input").fill("Accessible") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent.includes('503')") self.assertEqual(self.page.locator(".full-search-result").count(), 0) def test_pdf_with_valid_ocr_index_searches_index_not_native_text(self): self.page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill( content_type="text/javascript", body=support.PDF_MODULE.replace("numPages: 30", "numPages: 1"))) root = "objects/aa/" + "a" * 64 + "/" + "b" * 16 manifest_path = root + "/ocr-manifest.json" book_path = root + "/ocr/book-text.json.gz" text = "索引独有文字" book = gzip.compress(json.dumps({"version": 2, "kind": "pdf-book-text", "complete": True, "offset_unit": "unicode-codepoint", "source_sha256": "a" * 64, "page_count": 1, "pages": [{"page": 1, "text": text, "layout": {"offset_unit": "unicode-codepoint"}, "text_spans": [{"start": 0, "end": len(text), "box": [0.1, 0.2, 0.4, 0.3]}]}]}).encode()) manifest = {"version": 1, "kind": "pdf-ocr", "complete": True, "source_sha256": "a" * 64, "profile": "test-layout-v1-index", "page_count": 1, "pages": [{"p": 1, "o": root + "/ocr/page-000001.json.gz"}], "book_text": {"path": book_path, "bytes": len(book), "sha256": hashlib.sha256(book).hexdigest()}} requests, held = [], [] def resource(route): path = urllib.parse.parse_qs(urllib.parse.urlsplit(route.request.url).query)["path"][0] requests.append(path) if path == manifest_path: route.fulfill(json=manifest) elif path == book_path: route.fulfill(content_type="application/gzip", body=book) else: route.fulfill(status=404) self.page.route("**/api/reader-bucket-resource**", resource) manifest_url = self.origin + "/api/reader-bucket-resource?path=" + manifest_path self.open(self.reader_url("pdf", ocr_manifest=manifest_url)) self.page.wait_for_timeout(7000) self.assertEqual(requests, [manifest_path, book_path]) self.page.locator("#history").click() self.page.locator("#full-search-toggle").click() self.page.locator("#full-search-input").fill("索引独有") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'") self.assertEqual(self.page.locator(".full-search-result").count(), 1) self.assertEqual(requests, [manifest_path, book_path]) def test_pdf_ocr_progress_preserves_full_first_page_and_focus(self): def pause_after_first_page(route): response = route.fetch() needle = "scanned, pages, ...partial.page(0) });" script = response.text() self.assertIn(needle, script) script = script.replace("progress: (partial, scanned, pages) => {", "progress: async (partial, scanned, pages) => {") route.fulfill(response=response, body=script.replace(needle, needle + """ if (scanned === 1) await new Promise(resolve => { const release = event => { if (event.data.type !== 'test-release') return; self.removeEventListener('message', release); resolve(); }; self.addEventListener('message', release); });""")) self.page.route("**/static/reader-pdf-book-search-worker.mjs*", pause_after_first_page) self.page.add_init_script("""(() => { const NativeWorker = window.Worker; window.Worker = class extends NativeWorker { constructor(url, options) { super(url, options); if (String(url).includes('reader-pdf-book-search-worker')) window.__pdfSearchWorker = this; } }; })()""") self.page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill( content_type="text/javascript", body=support.PDF_MODULE.replace("numPages: 30", "numPages: 3"))) root = "objects/aa/" + "a" * 64 + "/" + "b" * 16 manifest_path = root + "/ocr-manifest.json" book_path = root + "/ocr/book-text.json.gz" texts = ["needle " * 60, "needle", "end"] book = gzip.compress(json.dumps({"version": 2, "kind": "pdf-book-text", "complete": True, "offset_unit": "unicode-codepoint", "source_sha256": "a" * 64, "page_count": 3, "pages": [{"page": i + 1, "text": text, "layout": {"offset_unit": "unicode-codepoint"}, "text_spans": []} for i, text in enumerate(texts)]}).encode()) manifest = {"version": 1, "kind": "pdf-ocr", "complete": True, "source_sha256": "a" * 64, "profile": "test-layout-v1-index", "page_count": 3, "pages": [{"p": i + 1, "o": root + f"/ocr/page-{i + 1:06d}.json.gz"} for i in range(3)], "book_text": {"path": book_path, "bytes": len(book), "sha256": hashlib.sha256(book).hexdigest()}} def resource(route): path = urllib.parse.parse_qs(urllib.parse.urlsplit(route.request.url).query)["path"][0] if path == manifest_path: route.fulfill(json=manifest) elif path == book_path: route.fulfill(content_type="application/gzip", body=book) else: route.fulfill(status=404) self.page.route("**/api/reader-bucket-resource**", resource) self.open(self.reader_url("pdf", ocr_manifest=self.origin + "/api/reader-bucket-resource?path=" + manifest_path)) self.page.locator("#history").click() self.page.locator("#full-search-toggle").click() self.page.locator("#full-search-input").fill("needle") self.page.wait_for_function("() => !!window.__pdfSearchWorker && document.querySelectorAll('.full-search-result').length === 50") self.page.evaluate("() => { window.__firstOcrRow = document.querySelector('.full-search-result'); __firstOcrRow.focus(); window.__pdfSearchWorker.postMessage({type: 'test-release'}); }") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '61 个结果'") self.assertTrue(self.page.evaluate("() => document.querySelector('.full-search-result') === window.__firstOcrRow && document.activeElement === window.__firstOcrRow")) def test_pdf_ocr_worker_pages_deep_results_and_reuses_book(self): self.page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill( content_type="text/javascript", body=support.PDF_MODULE.replace("numPages: 30", "numPages: 2"))) root = "objects/aa/" + "a" * 64 + "/" + "b" * 16 manifest_path = root + "/ocr-manifest.json" book_path = root + "/ocr/book-text.json.gz" book = gzip.compress(json.dumps({"version": 2, "kind": "pdf-book-text", "complete": True, "offset_unit": "unicode-codepoint", "source_sha256": "a" * 64, "page_count": 2, "pages": [{"page": page, "text": text, "layout": {"offset_unit": "unicode-codepoint"}, "text_spans": []} for page, text in ((1, "needle " * 620), (2, "tail"))]}).encode()) manifest = {"version": 1, "kind": "pdf-ocr", "complete": True, "source_sha256": "a" * 64, "profile": "test-layout-v1-index", "page_count": 2, "pages": [{"p": page, "o": root + f"/ocr/page-{page:06d}.json.gz"} for page in (1, 2)], "book_text": {"path": book_path, "bytes": len(book), "sha256": hashlib.sha256(book).hexdigest()}} book_requests = [] def resource(route): path = urllib.parse.parse_qs(urllib.parse.urlsplit(route.request.url).query)["path"][0] if path == manifest_path: route.fulfill(json=manifest) elif path == book_path: book_requests.append(path) route.fulfill(content_type="application/gzip", body=book) else: route.fulfill(status=404) self.page.route("**/api/reader-bucket-resource**", resource) self.open(self.reader_url("pdf", ocr_manifest=self.origin + "/api/reader-bucket-resource?path=" + manifest_path)) self.page.locator("#history").click() self.page.locator("#full-search-toggle").click() self.page.locator("#full-search-input").fill("needle") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '620 个结果'") self.page.locator("#full-search-page").fill("11") self.page.locator("#full-search-page").dispatch_event("change") self.page.wait_for_function("() => document.querySelector('.full-search-rank')?.textContent === '501.'") self.assertEqual(self.page.locator(".full-search-result").count(), 50) self.page.locator("#full-search-input").fill("tail") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'") self.assertIn("第 2 页", self.page.locator(".full-search-result").first.text_content()) self.assertEqual(book_requests, [book_path]) def test_pdf_ocr_worker_retries_corrupt_book_and_can_cancel(self): self.page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill( content_type="text/javascript", body=support.PDF_MODULE.replace("numPages: 30", "numPages: 1"))) root = "objects/aa/" + "a" * 64 + "/" + "b" * 16 manifest_path, book_path = root + "/ocr-manifest.json", root + "/ocr/book-text.json.gz" book = gzip.compress(json.dumps({"version": 2, "kind": "pdf-book-text", "complete": True, "offset_unit": "unicode-codepoint", "source_sha256": "a" * 64, "page_count": 1, "pages": [{"page": 1, "text": "needle", "layout": {"offset_unit": "unicode-codepoint"}, "text_spans": []}]}).encode()) manifest = {"version": 1, "kind": "pdf-ocr", "complete": True, "source_sha256": "a" * 64, "profile": "test-layout-v1-index", "page_count": 1, "pages": [{"p": 1, "o": root + "/ocr/page-000001.json.gz"}], "book_text": {"path": book_path, "bytes": len(book), "sha256": hashlib.sha256(book).hexdigest()}} requests, held = [], [] def resource(route): path = urllib.parse.parse_qs(urllib.parse.urlsplit(route.request.url).query)["path"][0] if path == manifest_path: route.fulfill(json=manifest) elif path == book_path: requests.append(path) if len(requests) == 1: held.append(route) return payload = b"x" * len(book) if len(requests) == 2 else book route.fulfill(content_type="application/gzip", body=payload) else: route.fulfill(status=404) self.page.route("**/api/reader-bucket-resource**", resource) self.open(self.reader_url("pdf", ocr_manifest=self.origin + "/api/reader-bucket-resource?path=" + manifest_path)) self.page.locator("#history").click() self.page.locator("#full-search-toggle").click() with self.page.expect_request(lambda request: urllib.parse.parse_qs( urllib.parse.urlsplit(request.url).query).get("path") == [book_path]): self.page.locator("#full-search-input").fill("needle") self.page.locator("#full-search-cancel").click() self.assertEqual(self.page.locator("#full-search-status").text_content(), "搜索已取消") for route in held: try: route.abort() except support.PlaywrightError: pass self.page.locator("#full-search-retry").click() self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent.includes('PDF_OCR_HASH_MISMATCH')") self.page.locator("#full-search-retry").click() self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'") self.assertEqual(requests, [book_path] * 3) def test_media_proxy_failure_retries_original_once(self): buffer = io.BytesIO() with wave.open(buffer, 'wb') as audio: audio.setparams((1, 2, 8000, 8000, 'NONE', 'not compressed')) audio.writeframes(b'\0\0' * 8000) requests = [] self.page.route('**/api/reader-content**', lambda route: route.fulfill(status=503, body='unavailable')) def original(route): requests.append(route.request.url) data = buffer.getvalue() start = int(route.request.headers.get('range', 'bytes=0-').split('=')[1].split('-')[0]) route.fulfill(status=206, content_type='audio/wav', body=data[start:], headers={'Accept-Ranges': 'bytes', 'Content-Range': f'bytes {start}-{len(data)-1}/{len(data)}'}) self.page.route('https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/refactor.wav', original) self.open(self.reader_url('wav')) self.page.wait_for_function("() => document.querySelector('audio').readyState >= 1") self.assertEqual(len(requests), 1) self.assertEqual(self.page.locator('audio').evaluate('node => node.duration'), 1) self.page.evaluate("async () => { const media = document.querySelector('audio'); media.currentTime = 0.5; await VoiceOfMLReaderStore.put({url: media.src, mediaTime: 0.5}); }") self.page.reload() self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready' && document.querySelector('audio').currentTime === 0.5") self.assertEqual(len(requests), 2) self.page.locator('audio').evaluate("node => node.dispatchEvent(new Event('error'))") self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'failed'") self.assertEqual(len(requests), 2) def test_chapter_links_load_target_without_leaving_reader(self): base = 'https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/objects/aa/' + 'b' * 64 + '/foliate-original-v1-epub-chapters-v5/epub-chapters/' bodies = {i: f'

Chapter {i}

正文

' for i in range(1, 13)} bodies[1] = '跨章节跳转章节首页失效旧链接' + bodies[1] + '' bodies[12] = 'Chapter 12返回目录朝露zhāo lù正文' manifest = dict(version=1, kind='epub-chapters', chapters=[dict(index=i, path=f'chapters/chapter-{i:04d}.xhtml', title=str(i), bytes=len(bodies[i].encode())) for i in bodies]) def serve(route): url = urllib.parse.parse_qs(urllib.parse.urlsplit(route.request.url).query)['url'][0] body = json.dumps(manifest) if url.endswith('chapter-manifest.json') else bodies[int(url.rsplit('-', 1)[1].split('.')[0])] route.fulfill(content_type='application/json' if url.endswith('.json') else 'application/xhtml+xml', body=body) self.page.route('**/api/reader-content**', serve) self.open(self.reader_url('epub-chapters', url=base + 'chapter-manifest.json')) before = self.page.url self.page.get_by_text('跨章节跳转', exact=True).click() self.page.wait_for_function("() => !!document.querySelector('.reader-epub-chapter[data-chapter=\"12\"] #target')") self.page.wait_for_function("() => document.querySelector('#viewport').scrollTop > 1000") self.assertEqual(self.page.url, before) self.assertLess(abs(self.page.locator('.reader-epub-chapter[data-chapter="12"] #target').evaluate("node => node.getBoundingClientRect().top - document.querySelector('#viewport').getBoundingClientRect().top")), 5) self.page.get_by_text('返回目录', exact=True).click() self.assertLess(abs(self.page.locator('.reader-epub-chapter[data-chapter="1"]').evaluate("node => node.getBoundingClientRect().top - document.querySelector('#viewport').getBoundingClientRect().top")), 5) self.page.get_by_text('章节首页', exact=True).click() self.assertEqual(self.page.locator('.reader-epub-chapter[data-chapter="12"]').get_attribute('id'), 'chapter-root') self.assertEqual(self.page.locator('#chapter-root rb').inner_text(), '朝露') self.assertEqual(self.page.locator('#chapter-root rtc rt').inner_text(), 'zhāo lù') self.assertEqual(self.page.locator('#chapter-root rb #w3').count(), 1) self.assertLess(abs(self.page.locator('#chapter-root').evaluate("node => node.getBoundingClientRect().top - document.querySelector('#viewport').getBoundingClientRect().top")), 5) self.page.get_by_text('返回目录', exact=True).click() self.page.get_by_text('失效旧链接', exact=True).click() self.page.get_by_text('失效旧链接', exact=True).click() self.assertEqual(self.page.locator('.reader-chapter-link-error').count(), 1) self.assertTrue(self.page.get_by_role('status').filter(has_text='无法定位此书内链接').is_visible()) mapped = self.page.evaluate("""async () => { const {chapterTextMap} = await import('/static/reader-chapter-search.mjs'); return chapterTextMap([document.querySelector('.reader-epub-chapter[data-chapter="1"]')]).text; }""") self.assertNotIn('无法定位', mapped) self.assertIn('正文', mapped) self.assertEqual(self.page.url, before) source = base + 'chapter-manifest.json' self.page.evaluate("""async source => { window.dispatchEvent(new PageTransitionEvent('pagehide', {persisted:false})); await new Promise(resolve => setTimeout(resolve, 0)); await VoiceOfMLReaderStore.put({url:source, chapterIndex:9999, chapterOffset:123, lastReadAt:Date.now()+1}); }""", source) self.page.reload() self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") self.page.get_by_text('跨章节跳转', exact=True).click() self.wait_for_store("async source => {const item=await VoiceOfMLReaderStore.get(source);return item?.chapterIndex===12 && item?.chapterManifest===source;}", source) self.page.reload() self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") self.assertTrue(self.page.locator('#chapter-root #target').is_visible()) self.assertLess(abs(self.page.locator('#chapter-root #target').evaluate("node => node.getBoundingClientRect().top - document.querySelector('#viewport').getBoundingClientRect().top")), 10) def test_bucket_chapter_stream_loads_manifest_chapter_and_resource(self): prefix = ("ebook-chapters/objects/aa/" + "c" * 64 + "/bundle-v1/profile-epub-chapters-v7-bucket/epub-chapters/") chapter = '

Bucket chapter

' chapter_path = prefix + "chapters/chapter-0001.xhtml" manifest_path = prefix + "chapter-manifest.json" resource_path = prefix + "resources/shared/cover.png" manifest = {"version": 1, "kind": "epub-chapters", "chapters": [ {"index": 1, "path": "chapters/chapter-0001.xhtml", "title": "Bucket chapter", "bytes": len(chapter.encode()), "sha256": hashlib.sha256(chapter.encode()).hexdigest()} ]} requested = [] def serve(route): path = urllib.parse.parse_qs(urllib.parse.urlsplit(route.request.url).query)["path"][0] requested.append(path) if path == manifest_path: route.fulfill(content_type="application/json", body=json.dumps(manifest)) elif path == chapter_path: route.fulfill(content_type="application/xhtml+xml", body=chapter) elif path == resource_path: route.fulfill(content_type="image/png", body=b"not-a-real-png") else: route.fulfill(status=404, body="missing") self.page.route("https://voiceofml-search.hf.space/api/reader-bucket-resource**", serve) url = "https://voiceofml-search.hf.space/api/reader-bucket-resource?path=" + urllib.parse.quote(manifest_path, safe="") self.open(self.reader_url("epub-chapters", url=url)) self.assertIn("Bucket chapter", self.page.locator(".reader-epub-chapter").inner_text()) self.page.wait_for_function("() => document.querySelector('.reader-epub-chapter img')?.src.includes('reader-bucket-resource')") self.assertIn(manifest_path, requested) self.assertIn(chapter_path, requested) self.assertIn(resource_path, requested) def test_bucket_chapter_internal_links_keep_reader_navigation(self): prefix = ("ebook-chapters/objects/aa/" + "d" * 64 + "/bundle-v2/profile-epub-chapters-v8-bucket/epub-chapters/") bodies = { 1: '跨章节目录

目录

', 2: '

目标章节

正文

', } chapter_paths = {index: prefix + f"chapters/chapter-{index:04d}.xhtml" for index in bodies} manifest_path = prefix + "chapter-manifest.json" manifest = {"version": 1, "kind": "epub-chapters", "chapters": [ {"index": index, "path": f"chapters/chapter-{index:04d}.xhtml", "title": f"Chapter {index}", "bytes": len(body.encode()), "sha256": hashlib.sha256(body.encode()).hexdigest()} for index, body in bodies.items() ]} requests = [] def serve(route): path = urllib.parse.parse_qs(urllib.parse.urlsplit(route.request.url).query)["path"][0] requests.append(path) if path == manifest_path: route.fulfill(content_type="application/json", body=json.dumps(manifest)) else: index = next((i for i, item in chapter_paths.items() if item == path), None) route.fulfill(content_type="application/xhtml+xml", body=bodies[index]) if index else route.fulfill(status=404) self.page.route("https://voiceofml-search.hf.space/api/reader-bucket-resource**", serve) url = "https://voiceofml-search.hf.space/api/reader-bucket-resource?path=" + urllib.parse.quote(manifest_path, safe="") self.open(self.reader_url("epub-chapters", url=url)) before = self.page.url self.page.get_by_text("跨章节目录", exact=True).click() self.page.wait_for_function("() => !!document.querySelector('.reader-epub-chapter[data-chapter=\"2\"] #target')") self.assertEqual(self.page.url, before) self.assertIn(chapter_paths[2], requests) def search(self, query): self.page.locator("#history").click() self.page.locator("#full-search-toggle").click() self.page.locator("#full-search-input").fill(query) self.page.wait_for_function("() => /^(?:\d+ 个结果|未找到正文匹配)$/.test(document.querySelector('#full-search-status').textContent)") def wait_for_store(self, predicate, arg=None): self.page.evaluate("""async arg => { const check = """ + predicate + """; const deadline = performance.now() + 6000; while (performance.now() < deadline) { if (await check(arg)) return; await new Promise(resolve => setTimeout(resolve, 30)); } throw new Error('Store condition did not become true'); }""", arg) def test_failed_chapter_restoration_preserves_saved_position(self): requests, base = self.serve_chapter_search() source = base + "chapter-manifest.json" self.page.evaluate("""async source => { window.dispatchEvent(new PageTransitionEvent('pagehide', {persisted:false})); await new Promise(resolve => setTimeout(resolve, 0)); await VoiceOfMLReaderStore.put({url:source, title:'Saved chapter', chapterIndex:12, chapterOffset:345, lastReadAt:Date.now()+1}); }""", source) self.context.route("**/api/reader-content**chapter-0012.xhtml*", lambda route: route.fulfill(status=503, body="temporary")) self.page.reload() self.page.wait_for_timeout(1200) saved = self.page.evaluate("source => VoiceOfMLReaderStore.get(source)", source) self.assertEqual(saved["chapterIndex"], 12) self.assertEqual(saved["chapterOffset"], 345) self.assertEqual(self.page.locator("#content").get_attribute("data-error-code"), "READER_RESTORE") self.assertTrue(self.page.get_by_role("button", name="重试加载").is_visible()) self.context.unroute("**/api/reader-content**chapter-0012.xhtml*") self.page.get_by_role("button", name="重试加载").click() self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") self.wait_for_store("async source => (await VoiceOfMLReaderStore.get(source))?.chapterIndex === 12", source) offset = self.page.locator('.reader-epub-chapter[data-chapter="12"]').evaluate( "node => document.querySelector('#viewport').getBoundingClientRect().top + 8 - node.getBoundingClientRect().top") self.assertAlmostEqual(offset, 345, delta=3) def test_parent_abort_saves_last_position_before_disposal(self): self.serve("reading\n" * 3000) self.open(self.reader_url()) self.page.wait_for_timeout(650) self.page.evaluate("""() => { const viewport=document.querySelector('#viewport'); viewport.scrollTop=900;viewport.dispatchEvent(new Event('scroll')); window.dispatchEvent(new MessageEvent('message', {origin:location.origin, source:window.parent,data:{type:'voice-reader-abort'}})); }""") source = urllib.parse.parse_qs(urllib.parse.urlsplit(self.page.url).query)["url"][0] self.page.wait_for_timeout(100) saved = self.page.evaluate("source => VoiceOfMLReaderStore.get(source)", source) self.assertEqual(saved["scrollTop"], 900) def test_store_lists_preserve_limits_order_and_reject_noncurrent_records(self): self.page.goto(self.reader_url().split('?')[0]) result = self.page.evaluate('''async () => { const store=VoiceOfMLReaderStore; await store.put({url:'book-a',lastReadAt:10}); await store.put({url:'book-b',lastReadAt:20}); await store.put({url:'book-c',lastReadAt:30}); await store.put({url:'book-c',lastReadAt:1}); const db=await new Promise((resolve,reject)=>{ const request=indexedDB.open(store.DB_NAME,store.DB_VERSION); request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error); }); await new Promise((resolve,reject)=>{ const tx=db.transaction('bookmarks','readwrite'), bookmarks=tx.objectStore('bookmarks'); for(const entry of [ {id:'z',url:'book-a',createdAt:1,schemaVersion:1}, {id:'a',url:'book-a',createdAt:3,schemaVersion:1}, {id:'b',url:'book-b',createdAt:2,schemaVersion:1}, {id:'bad',url:42,createdAt:4}, {id:'legacy',url:'book-a',createdAt:6}, {id:'future',url:'book-a',createdAt:5,schemaVersion:2} ]) bookmarks.put(entry); tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error); }); const history=(await store.list(2)).map(entry=>entry.url); const empty=(await store.list(0)).length; const all=(await store.listAllBookmarks()).map(entry=>entry.id); const one=(await store.listBookmarks('book-a')).map(entry=>entry.id); const raw=await new Promise((resolve,reject)=>{ const tx=db.transaction('bookmarks'), request=tx.objectStore('bookmarks').getAll(); tx.oncomplete=()=>resolve(request.result);tx.onerror=()=>reject(tx.error); }); db.close();await store.removeBookmark('a'); return {history,empty,all,one,remaining:(await store.listBookmarks('book-a')).map(entry=>entry.id), current:raw.every(entry=>entry.schemaVersion===1), legacy:raw.some(entry=>entry.id==='legacy'), corruptRemoved:!raw.some(entry=>entry.id==='bad')}; }''') self.assertEqual(result, dict(history=['book-c', 'book-b'], empty=0, all=['a', 'b', 'z'], one=['a', 'z'], remaining=['z'], current=True, legacy=False, corruptRemoved=True)) def test_cross_book_bookmark_restores_selected_position_and_rejects_mismatch(self): self.serve("start\n" + "ordinary text\n" * 3000) book_b = self.reader_url(name="book-b") source_b = urllib.parse.parse_qs(urllib.parse.urlsplit(book_b).query)["url"][0] self.open(book_b) self.page.evaluate("""async ({url, readerUrl}) => { await VoiceOfMLReaderStore.putBookmark({id:'selected-bookmark', url, readerUrl, title:'Book B', label:'Selected passage', scrollTop:900, createdAt:1}); await VoiceOfMLReaderStore.put({url, readerUrl, title:'Book B', scrollTop:2500, lastReadAt:Date.now()}); }""", {"url": source_b, "readerUrl": book_b}) self.open(self.reader_url(name="book-a")) # Override any pagehide save from the old B page before opening its bookmark. self.page.evaluate("""async url => VoiceOfMLReaderStore.put({url, title:'Book B', scrollTop:2500, lastReadAt:Date.now()+1})""", source_b) self.page.locator("#history").click() self.page.locator("#bookmarks-tab").click() self.page.locator("#bookmarks-all").click() self.page.locator("#bookmarks-list .panel-item-main", has_text="Selected passage").click() self.page.wait_for_url(book_b) self.page.wait_for_function("() => Math.abs(document.querySelector('#viewport').scrollTop - 900) < 2") self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") self.page.evaluate("""() => { document.querySelector('#viewport').scrollTop = 2500; document.dispatchEvent(new Event('visibilitychange')); }""") self.page.wait_for_timeout(650) self.page.reload() self.page.wait_for_function("() => Math.abs(document.querySelector('#viewport').scrollTop - 2500) < 2") self.open(self.reader_url(name="book-a")) self.page.locator("#history").click() self.page.locator("#bookmarks-tab").click() self.page.locator("#bookmarks-all").click() self.page.locator("#bookmarks-list .panel-item-main", has_text="Selected passage").click() self.page.wait_for_url(book_b) self.page.wait_for_function("() => Math.abs(document.querySelector('#viewport').scrollTop - 900) < 2") self.page.locator("#viewport").evaluate("node => { node.scrollTop = 2500; node.dispatchEvent(new Event('scroll')); }") self.page.wait_for_timeout(650) invalid = book_b + "&bookmark=selected-bookmark&bookmark_source=wrong-source" self.open(invalid) self.page.wait_for_function("() => Math.abs(document.querySelector('#viewport').scrollTop - 2500) < 2") saved = self.page.evaluate("url => VoiceOfMLReaderStore.get(url)", source_b) self.assertNotIn("bookmark=", saved["readerUrl"]) def test_overlay_bookmark_consumes_parent_navigation_records_and_reloads_history(self): self.serve("reading\n" * 3000) book = self.reader_url(name="overlay-book") source = urllib.parse.parse_qs(urllib.parse.urlsplit(book).query)["url"][0] self.open(book) self.page.evaluate("""async ({source, book}) => { await VoiceOfMLReaderStore.putBookmark({id:'overlay-bookmark', url:source, readerUrl:book, label:'Overlay', scrollTop:900, createdAt:1}); }""", {"source": source, "book": book}) handoff = book + "&" + urllib.parse.urlencode({ "bookmark": "overlay-bookmark", "bookmark_source": source, "return": self.origin + "/Test?q=retained", "nav": "return-token" }) shell = """""".replace("HANDOFF", json.dumps(handoff)) def overlay(route): if route.request.is_navigation_request() and route.request.frame == self.page.main_frame: route.fulfill(content_type="text/html", body=shell) else: route.fallback() self.page.route("**/static/reader.html?**", overlay) self.page.goto(handoff) frame = self.page.frame_locator("iframe.reader-overlay") frame.locator("#viewport").evaluate("node => node.scrollTop") self.page.wait_for_function("() => !new URL(location.href).searchParams.has('bookmark')") state = self.page.evaluate("({state:history.state, saved:JSON.parse(sessionStorage.getItem('reader-navigation-current'))})") self.assertNotIn("bookmark=", state["state"]["readerUrl"]) self.assertNotIn("bookmark=", state["saved"]["readerUrl"]) self.assertNotIn("bookmark=", state["saved"]["shareUrl"]) self.assertEqual(state["state"]["extra"], "keep") self.assertEqual(state["saved"]["returnScroll"], {"top": 123}) self.assertIn("nav=return-token", state["state"]["readerUrl"]) self.assertEqual(frame.locator("#viewport").evaluate("node => node.scrollTop"), 900) frame.locator("#viewport").evaluate("node => {node.scrollTop=2500; node.dispatchEvent(new Event('scroll'));}") self.wait_for_store("async source => (await document.querySelector('iframe').contentWindow.VoiceOfMLReaderStore.get(source))?.scrollTop === 2500", source) for session_only in (False, True): if session_only: self.page.evaluate("history.replaceState(null, '', location.href)") self.page.reload() self.page.wait_for_function("() => document.querySelector('iframe')?.contentDocument?.querySelector('#viewport')?.scrollTop === 2500") self.page.evaluate("url => openReader(url)", handoff) self.page.wait_for_function("() => document.querySelector('iframe')?.contentDocument?.querySelector('#viewport')?.scrollTop === 900 && !new URL(location.href).searchParams.has('bookmark')") def test_remote_history_deletion_blocks_queued_and_future_saves_until_reopen(self): self.serve("reading\n" * 3000) url = self.reader_url(name="remote-history") source = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)["url"][0] peer = self.context.new_page() store_path = urllib.parse.urlsplit(url).path.replace("reader.html", "reader-store.js") peer.route("**/store-peer", lambda route: route.fulfill(content_type="text/html", body=f'')) peer.goto(self.origin + "/store-peer") for event_type in ("history-remove", "history-clear"): with self.subTest(event_type=event_type): self.open(url) self.wait_for_store("async source => !!(await VoiceOfMLReaderStore.get(source))", source) self.page.evaluate("""() => { const store=VoiceOfMLReaderStore; window.__writes=0; window.__changes=[]; store.subscribe(change => { if(change.remote) window.__changes.push(change.type); }); window.VoiceOfMLReaderStore={...store, put:async entry => { ++window.__writes; await store.put(entry); if(window.__writes===1) await new Promise(resolve => window.__releaseWrite=resolve); }}; document.querySelector('#viewport').scrollTop=900; }""") self.page.wait_for_function("() => !!window.__releaseWrite") self.page.locator("#viewport").evaluate("node => {node.scrollTop=1500; node.dispatchEvent(new Event('scroll'));}") self.page.wait_for_timeout(650) await_event = {"type": event_type, "source": source} peer.evaluate("async ({type,source}) => type==='history-clear' ? VoiceOfMLReaderStore.clearHistory() : VoiceOfMLReaderStore.remove(source)", await_event) self.page.wait_for_function("type => window.__changes.includes(type)", arg=event_type) self.page.evaluate("window.__releaseWrite()") self.page.locator("#viewport").evaluate("node => {node.scrollTop=2200; node.dispatchEvent(new Event('scroll'));}") self.page.wait_for_timeout(750) self.assertEqual(self.page.evaluate("window.__writes"), 1) self.assertIsNone(peer.evaluate("source => VoiceOfMLReaderStore.get(source)", source)) self.page.reload() self.wait_for_store("async source => !!(await VoiceOfMLReaderStore.get(source))", source) peer.close() def test_bookmark_cleanup_preserves_unrelated_parent_and_session_navigation(self): self.serve("reading\n" * 3000) book = self.reader_url(name="cleanup-target") source = urllib.parse.parse_qs(urllib.parse.urlsplit(book).query)["url"][0] self.open(book) self.page.evaluate("async ({source,book}) => VoiceOfMLReaderStore.putBookmark({id:'cleanup-mark',url:source,readerUrl:book,label:'Target',scrollTop:900,createdAt:1})", {"source": source, "book": book}) handoff = book + "&" + urllib.parse.urlencode({"bookmark": "cleanup-mark", "bookmark_source": source}) unrelated = self.reader_url(name="different", bookmark="other", bookmark_source="other") record = {"readerUrl": unrelated, "shareUrl": unrelated, "extra": "preserve"} shell = """""".replace("OTHER", json.dumps(unrelated)).replace("RECORD", json.dumps(record)).replace("HANDOFF", json.dumps(handoff)) self.page.route("**/unrelated-overlay", lambda route: route.fulfill(content_type="text/html", body=shell)) self.page.goto(self.origin + "/unrelated-overlay") self.page.wait_for_function("() => {const w=document.querySelector('iframe')?.contentWindow; return w?.document.querySelector('#viewport')?.scrollTop===900 && !new URL(w.location.href).searchParams.has('bookmark');}") self.assertEqual(self.page.url, unrelated) self.assertEqual(self.page.evaluate("history.state.readerUrl"), unrelated) self.assertEqual(self.page.evaluate("JSON.parse(sessionStorage.getItem('reader-navigation-current'))"), record) def test_remote_unrelated_history_removal_does_not_suppress_current_media(self): self.serve(support.minimal_wav(), "audio/wav") self.open(self.reader_url("wav", name="remote-media")) self.page.wait_for_function("() => document.querySelector('audio').readyState >= 1") self.page.evaluate("""() => { window.__changes=[]; VoiceOfMLReaderStore.subscribe(change => {if(change.remote) window.__changes.push(change.type);}); const media=document.querySelector('audio'); Object.defineProperty(media,'currentTime',{value:45,writable:true}); }""") peer = self.context.new_page() peer.goto(self.origin + "/static/reader.html?ext=unsupported") peer.evaluate("() => VoiceOfMLReaderStore.remove('unrelated')") self.page.wait_for_function("() => window.__changes.includes('history-remove')") self.page.locator("audio").evaluate("media => media.dispatchEvent(new Event('timeupdate'))") self.wait_for_store("async () => (await VoiceOfMLReaderStore.get(new URL(location.href).searchParams.get('url')))?.mediaTime===45") peer.evaluate("() => VoiceOfMLReaderStore.clearHistory()") self.page.wait_for_function("() => window.__changes.includes('history-clear')") self.page.locator("audio").evaluate("media => {media.currentTime=60; for(const type of ['timeupdate','pause','seeked']) media.dispatchEvent(new Event(type));}") self.page.wait_for_timeout(750) self.assertIsNone(self.page.evaluate("() => VoiceOfMLReaderStore.get(new URL(location.href).searchParams.get('url'))")) peer.close() def test_search_reopen_retains_live_targets(self): self.serve("prefix\n" * 500 + "needle" + "\nending" * 500) self.open(self.reader_url()) self.search("needle") self.page.locator("#full-search-toggle").click() self.assertEqual(self.page.locator("#content .full-search-highlight").count(), 1) self.page.locator("#full-search-toggle").click() self.page.locator("#full-search-results .full-search-result").click() self.page.wait_for_function("() => document.querySelector('#viewport').scrollTop > 1000") self.assertEqual(self.page.locator("#content .full-search-highlight").text_content(), "needle") def test_search_reopen_keeps_results_and_clear_control_state(self): self.serve("begin " + "middle " * 400 + "needle at the end") self.open(self.reader_url()) self.page.locator("#history").click() self.assertTrue(self.page.locator("#full-search-clear").is_hidden()) self.page.locator("#full-search-toggle").click() self.assertEqual(self.page.locator("#full-search-toggle").text_content(), "关闭全文搜索") self.page.locator("#full-search-input").fill("needle") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'") self.assertEqual(self.page.locator(".full-search-rank").text_content(), "1.") self.assertIn("正文 ", self.page.locator(".full-search-location").text_content()) self.assertTrue(self.page.locator("#full-search-clear").is_visible()) self.page.locator(".full-search-result").click() self.page.locator("#history").click() self.assertFalse(self.page.locator("#full-search-view").is_hidden()) self.assertEqual(self.page.locator("#full-search-status").text_content(), "1 个结果") self.page.locator("#full-search-toggle").click() self.assertEqual(self.page.locator("#full-search-toggle").text_content(), "全文搜索") self.page.locator("#full-search-toggle").click() self.assertEqual(self.page.locator("#full-search-status").text_content(), "1 个结果") self.page.locator("#full-search-clear").click() self.assertTrue(self.page.locator("#full-search-clear").is_hidden()) def test_top_level_system_back_steps_out_without_reopen_race(self): self.serve("needle " * 300) url = self.reader_url() self.open(url) self.page.locator("#history").click() self.page.locator("#full-search-toggle").click() self.page.locator("#full-search-input").fill("needle") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '300 个结果'") self.page.locator(".full-search-result").first.click() self.page.locator("#history").click() self.page.wait_for_function("() => !document.querySelector('#full-search-view').hidden") self.page.evaluate("history.back()") self.page.wait_for_function("() => document.querySelector('#full-search-view').hidden && document.querySelector('#history-panel').classList.contains('is-open') && history.state.voiceReaderGuard") self.assertEqual(self.page.url, url) self.page.evaluate("history.back()") self.page.wait_for_function("() => !document.querySelector('#history-panel').classList.contains('is-open') && history.state.voiceReaderGuard") self.assertEqual(self.page.url, url) def test_text_search_publishes_partial_results_before_full_scan(self): self.page.add_init_script("""const native = window.setTimeout.bind(window); window.setTimeout = (fn, ms, ...args) => native(fn, ms === 0 ? 20 : ms, ...args);""") self.serve("needle " + "x" * 600000 + " needle") self.open(self.reader_url()) self.page.locator("#history").click() self.page.locator("#full-search-toggle").click() self.page.locator("#full-search-input").fill("needle") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent.includes('正在搜索') && document.querySelectorAll('.full-search-result').length === 1") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '2 个结果'") self.assertEqual(self.page.locator(".full-search-rank").all_text_contents(), ["1.", "2."]) def test_typing_new_full_search_disables_old_navigation_immediately(self): self.serve("oldword " * 65 + "freshword") self.open(self.reader_url()) self.search("oldword") self.assertEqual(self.page.locator("#full-search-status").text_content(), "65 个结果") self.assertTrue(self.page.locator(".full-search-pagination").is_visible()) self.page.locator("#full-search-input").fill("freshword") self.assertEqual(self.page.locator(".full-search-result").count(), 0) self.assertTrue(self.page.locator(".full-search-pagination").is_hidden()) self.assertTrue(self.page.locator("#full-search-next").is_disabled()) self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'") def test_pending_text_result_cannot_highlight_after_new_search(self): def hold_activation(route): response = route.fetch() needle = "const currentNodes = await fullSearchTextNodes(root, searchGeneration);" script = response.text() self.assertIn(needle, script) route.fulfill(response=response, body=script.replace(needle, "await new Promise(resolve => { window.__releaseActivation = resolve; }); " + needle + " window.__activationSettled = true;")) self.page.route("**/static/reader.js?*", hold_activation) self.serve("oldword " * 70 + "freshword") self.open(self.reader_url()) self.search("oldword") self.page.locator(".full-search-result").first.click() self.page.wait_for_function("() => !!window.__releaseActivation") self.page.locator("#full-search-input").fill("freshword") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'") self.page.evaluate("window.__releaseActivation()") self.page.wait_for_function("() => window.__activationSettled") self.assertEqual(self.page.locator("#content mark.full-search-highlight").all_text_contents(), ["freshword"]) def test_search_scans_beyond_20000_nodes_and_cancels_old_query(self): self.serve("
" + "ordinary " * 21000 + "

unique-tail

", "text/html") self.open(self.reader_url("html")) self.search("unique-tail") self.assertEqual(self.page.locator(".full-search-result").count(), 1) self.page.locator("#full-search-input").evaluate("""input => { input.value='ordinary'; input.dispatchEvent(new Event('input')); input.value='unique-tail'; input.dispatchEvent(new Event('input')); }""") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'") self.assertEqual(self.page.frame_locator(".html-frame").locator(".full-search-highlight").all_text_contents(), ["unique-tail"]) def test_search_retains_matches_across_character_batches_and_pages_all_results(self): text = "x" * 65533 + "boundary-needle" + " y" * 100 self.serve(text) self.open(self.reader_url()) self.search("boundary-needle") self.assertEqual(self.page.locator(".full-search-result").count(), 1) self.page.locator("#full-search-input").fill("x") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '65533 个结果'") self.assertEqual(self.page.locator("#content mark").count(), 100) self.page.locator("#full-search-page").fill("3") self.page.locator("#full-search-page").press("Enter") self.page.wait_for_function("() => document.querySelector('#full-search-page').value === '3' && document.querySelector('.full-search-rank')?.textContent === '101.'") self.page.locator(".full-search-result").first.click() self.assertEqual(self.page.locator("#content mark.full-search-highlight").all_text_contents(), ["x"]) def test_html_preserves_css_prose_and_tracks_progress_and_headings(self): html = "

Examples: url(icon.png) and @import theme.css;

" + "".join( f"

Chapter {i}

text

" for i in range(4)) self.serve(html, "text/html") self.open(self.reader_url("html")) frame = self.page.frame_locator(".html-frame") self.assertIn("url(icon.png) and @import theme.css;", frame.locator("body").inner_text()) self.assertTrue(self.page.locator(".html-frame").get_attribute("title")) frame.locator("#chapter-2").evaluate("node => node.scrollIntoView()") self.page.locator("#history").click() self.page.wait_for_function("() => Number(document.querySelector('.reader-progress-range').value) > 40") self.page.wait_for_function("() => document.querySelectorAll('.toc-item')[2].classList.contains('is-current')") self.page.locator(".reader-chapter-next").click() self.page.wait_for_function("() => document.querySelectorAll('.toc-item')[3].classList.contains('is-current')") def test_discourse_html_extracts_posts_without_forum_shell(self): html = """论坛标题
论坛顶部
论坛标题
时间线

第一段正文

删除的文字

第二段正文

推荐主题
""" self.serve(html, "text/html") self.open(self.reader_url("html", name="forum")) frame = self.page.frame_locator(".html-frame") self.assertEqual(frame.locator(".reader-forum-post").count(), 2) self.assertIn("第一段正文", frame.locator("body").inner_text()) self.assertIn("第二段正文", frame.locator("body").inner_text()) self.assertEqual(frame.locator("s").inner_text(), "删除的文字") self.assertEqual(frame.locator(".d-header, .topic-timeline, .suggested-topics").count(), 0) def test_html_keyboard_turns_use_iframe_and_reduced_motion(self): self.context.clear_permissions() self.page.emulate_media(reduced_motion="reduce") self.serve("

Long document

", "text/html") self.open(self.reader_url("html")) self.page.locator(".html-frame").evaluate("""frame => { const original = frame.contentWindow.scrollBy.bind(frame.contentWindow); frame.contentWindow.scrollBy = options => { window.__turn = options; original(options); }; }""") self.page.evaluate("document.body.dispatchEvent(new KeyboardEvent('keydown', {key:'PageDown', bubbles:true, cancelable:true}))") self.assertEqual(self.page.evaluate("window.__turn.behavior"), "instant") self.assertGreater(self.page.locator(".html-frame").evaluate("frame => frame.contentWindow.scrollY"), 100) self.page.frame_locator(".html-frame").locator("body").evaluate("node => node.dispatchEvent(new KeyboardEvent('keydown', {key:'PageUp', bubbles:true, cancelable:true}))") self.assertEqual(self.page.locator(".html-frame").evaluate("frame => frame.contentWindow.scrollY"), 0) def test_bookmark_dialog_focus_inert_and_invoker_restoration(self): self.serve("A document\n" * 200) self.open(self.reader_url()) self.page.evaluate("document.querySelector('#download').inert = true") self.page.locator("#history").click() invoker = self.page.locator(".reader-progress-bookmark") invoker.click() self.assertEqual(self.page.locator("#bookmark-popover").get_attribute("aria-labelledby"), "bookmark-prompt") self.assertTrue(self.page.locator("#history-panel").evaluate("node => node.inert")) self.page.keyboard.press("Shift+Tab") self.assertEqual(self.page.evaluate("document.activeElement.id"), "bookmark-cancel") self.page.keyboard.press("Tab") self.assertEqual(self.page.evaluate("document.activeElement.id"), "bookmark-label") self.page.keyboard.press("Tab") self.assertEqual(self.page.evaluate("document.activeElement.id"), "bookmark-excerpt-input") self.page.keyboard.press("Escape") self.assertTrue(invoker.evaluate("node => node === document.activeElement")) self.assertEqual(invoker.get_attribute("aria-expanded"), "false") self.assertFalse(self.page.locator("#history-panel").evaluate("node => node.inert")) self.assertTrue(self.page.locator("#download").evaluate("node => node.inert")) def test_media_updates_percentage_and_saves_during_continuous_updates(self): self.serve(support.minimal_wav(), "audio/wav") self.open(self.reader_url("wav")) self.page.wait_for_function("() => document.querySelector('audio').readyState >= 1") self.page.evaluate("""() => { const media = document.querySelector('audio'); Object.defineProperty(media, 'duration', {value:100}); Object.defineProperty(media, 'currentTime', {value:45, writable:true}); media.dispatchEvent(new Event('timeupdate')); window.__tick = setInterval(() => media.dispatchEvent(new Event('timeupdate')), 100); }""") self.page.locator("#history").click() self.page.wait_for_function("() => document.querySelector('.reader-progress-range').value === '45'") self.assertEqual(self.page.locator("#media-tab").get_attribute("aria-controls"), "media-panel") self.assertEqual(self.page.locator("#media-panel").get_attribute("role"), "tabpanel") self.assertTrue(self.page.locator("audio").get_attribute("aria-label")) self.wait_for_store("""async () => { const url = new URL(location.href).searchParams.get('url'); return (await VoiceOfMLReaderStore.get(url))?.mediaTime === 45; }""") self.page.evaluate("clearInterval(window.__tick)") def test_image_disposal_removes_handlers_before_fallback(self): self.page.add_init_script("""(() => { const NativeImage = window.Image; window.Image = function(...args) { const image = new NativeImage(...args); window.__image = image; return image; }; })()""") held = [] self.page.route("**/api/reader-content**", lambda route: held.append(route)) self.page.goto(self.reader_url("png"), wait_until="domcontentloaded") self.page.wait_for_function("() => window.__image?.getAttribute('src')") self.page.evaluate("window.dispatchEvent(new PageTransitionEvent('pagehide', {persisted:false}))") self.assertEqual(self.page.evaluate("[window.__image.onload, window.__image.onerror, window.__image.getAttribute('src')]"), [None, None, None]) for route in held: route.abort() self.assertEqual(self.page.locator("html").get_attribute("data-reader-phase"), "disposed") def test_folder_navigation_has_one_handler_and_preserves_explicit_target(self): source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/folder.txt" folder = self.origin + "/Test?folder_self=explicit" self.page.add_init_script("sessionStorage.setItem('reader-resolve:folder-id', " + json.dumps(json.dumps({ "url": source, "extension": "txt", "title": "Folder", "repo": "Test", "folder": ["metadata"] })) + ")") self.serve("Folder document") self.page.goto(self.origin + "/static/reader.html?" + urllib.parse.urlencode({ "id": "folder-id", "ext": "txt", "path": "Test/explicit", "folder_url": folder })) self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") # With a top-level Reader, the explicit folder URL must win after metadata resolves. self.page.route("**/Test?**", lambda route: route.fulfill(content_type="text/html", body="Folder")) self.page.locator("#reader-path").click() self.page.wait_for_url(folder) self.assertEqual(self.page.url, folder) def test_chapter_manifest_restores_structured_history_and_bookmark(self): source = "https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/objects/aa/" + "a" * 64 + "/chapter-manifest.json" manifest = {"version": 1, "kind": "epub-chapters", "chapters": [ {"index": i, "path": f"chapter-{i}.xhtml", "title": f"Chapter {i}", "bytes": 100} for i in range(1, 5) ]} def serve(route): raw = urllib.parse.parse_qs(urllib.parse.urlsplit(route.request.url).query)["url"][0] if raw.endswith("chapter-manifest.json"): route.fulfill(content_type="application/json", body=json.dumps(manifest)) else: number = raw.rsplit("chapter-", 1)[-1].split(".")[0] route.fulfill(content_type="text/html", body=f"

Chapter {number}

Body

") self.page.route("**/api/reader-content**", serve) url = self.reader_url("epub-chapters", url=source) self.open(url) self.page.locator("#history").click() self.page.locator("#toc-list .panel-item-main").nth(2).click() self.page.locator('.reader-epub-chapter[data-chapter="3"]').evaluate("node => { const v=document.querySelector('#viewport'); v.scrollTop += node.getBoundingClientRect().top - v.getBoundingClientRect().top + 250; }") self.page.locator("#bookmark-ribbon").click() self.page.locator("#bookmark-add").click() self.wait_for_store("async source => (await VoiceOfMLReaderStore.listBookmarks(source)).length === 1", source) bookmark = self.page.evaluate("async source => (await VoiceOfMLReaderStore.listBookmarks(source))[0]", source) self.assertEqual(bookmark["chapterIndex"], 3) self.page.wait_for_timeout(700) self.open(url) self.page.wait_for_function("() => document.querySelector('.reader-epub-chapter[data-chapter=\"3\"]')") offset = self.page.locator('.reader-epub-chapter[data-chapter="3"]').evaluate("node => document.querySelector('#viewport').getBoundingClientRect().top + 8 - node.getBoundingClientRect().top") self.assertAlmostEqual(offset, bookmark["chapterOffset"], delta=3) self.open(url + "&" + urllib.parse.urlencode({"bookmark": bookmark["id"], "bookmark_source": source})) self.page.wait_for_function("() => document.querySelector('.reader-epub-chapter[data-chapter=\"3\"]')") def serve_chapter_search(self, index_failure=None, namespaced=False): base = "https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/objects/aa/" + "b" * 64 + "/foliate-original-v1/epub-chapters/" chapters, texts, bodies = [], [], {} for index in range(1, 13): path = f"chapters/chapter-{index:04d}.xhtml" title = f"Chapter {index}" if index == 12: body = ("

最后一章

前言 &amp; 独特词语 尾声

" + "".join(f"

第{i}处needle结束

" for i in range(155)) + "

手机 手。机

") text = "最后一章 前言 & 独特 词语 尾声 " + " ".join(f"第{i}处needle结束" for i in range(155)) + " 手机 手。机" if namespaced: body = ('' '原始标题' + body + '') text = "原始标题 " + text else: body = f"

{title}

普通正文

" text = title + " 普通正文" bodies[base + path] = body chapters.append({"index": index, "path": path, "title": title, "bytes": len(body.encode())}) texts.append({"index": index, "path": path, "title": title, "text": text}) packed = gzip.compress(json.dumps({"version": 1, "kind": "epub-search-index", "chapters": texts}, ensure_ascii=False).encode(), mtime=0) manifest = {"version": 1, "kind": "epub-chapters", "chapters": chapters, "search_index": {"path": "epub-search-index.json.gz", "bytes": len(packed), "sha256": hashlib.sha256(packed).hexdigest()}} requests = [] def serve(route): raw = urllib.parse.parse_qs(urllib.parse.urlsplit(route.request.url).query)["url"][0] requests.append(raw) if raw.endswith("chapter-manifest.json"): route.fulfill(content_type="application/json", body=json.dumps(manifest), headers={"Access-Control-Allow-Origin": "*"}) elif raw.endswith("epub-search-index.json.gz"): attempt = sum(item.endswith("epub-search-index.json.gz") for item in requests) if index_failure and index_failure(route, attempt, packed): return route.fulfill(content_type="application/gzip", body=packed, headers={"Access-Control-Allow-Origin": "*"}) else: route.fulfill(content_type="text/html", body=bodies[raw], headers={"Access-Control-Allow-Origin": "*"}) # Worker fetches belong to the context, not to the page's route table. self.context.route("**/api/reader-content**", serve) self.open(self.reader_url("epub-chapters", url=base + "chapter-manifest.json")) return requests, base def test_chapter_search_is_lazy_complete_paged_and_navigates_unloaded_repeated_hits(self): requests, base = self.serve_chapter_search() self.assertFalse(any(item.endswith("epub-search-index.json.gz") for item in requests)) self.assertNotIn(base + "chapters/chapter-0012.xhtml", requests) self.search("needle") self.assertEqual(self.page.locator("#full-search-status").text_content(), "155 个结果") self.assertEqual(self.page.locator(".full-search-result").count(), 50) self.assertTrue(self.page.locator("#full-search-cancel").is_hidden()) self.page.evaluate("""() => { window.__cancelPagingChanges = []; const cancel = document.querySelector('#full-search-cancel'); new MutationObserver(() => { __cancelPagingChanges.push(cancel.hidden); }).observe(document.querySelector('#full-search-cancel'), { attributes: true, attributeFilter: ['hidden'] }); }""") self.assertNotIn(base + "chapters/chapter-0012.xhtml", requests) page_input = self.page.locator("#full-search-page") page_input.fill("4") page_input.press("Enter") page_input.blur() self.page.wait_for_function("() => document.querySelectorAll('.full-search-result').length === 5") self.assertTrue(self.page.locator("#full-search-cancel").is_hidden()) self.assertNotIn(False, self.page.evaluate("__cancelPagingChanges")) self.page.locator(".full-search-result").last.click() self.page.wait_for_function("() => document.querySelector('.reader-epub-chapter[data-chapter=\"12\"] mark.full-search-highlight')") mark = self.page.locator(".reader-epub-chapter mark.full-search-highlight") self.assertEqual(mark.text_content(), "needle") self.assertEqual(mark.locator("..").text_content(), "第154处needle结束") # Reopening preserves the current result page and navigation position. self.page.locator("#history").click() self.assertFalse(self.page.locator("#full-search-view").is_hidden()) self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '155 个结果'") self.assertEqual(self.page.locator("#full-search-page").input_value(), "4") self.page.locator("#full-search-prev").click() self.page.wait_for_function("() => document.querySelector('#full-search-page').value === '4'") self.page.locator("#full-search-next").click() self.page.wait_for_function("() => document.querySelector('.reader-epub-chapter mark')?.parentElement.textContent === '第154处needle结束'") self.assertEqual(self.page.locator("#full-search-page").input_value(), "4") self.assertEqual(sum(item.endswith("epub-search-index.json.gz") for item in requests), 1) def test_failed_chapter_page_restarts_worker_and_restores_target_page(self): def expose_worker_failure(route): response = route.fetch() needle = 'worker.postMessage({ type: "init", configuration });' script = response.text() self.assertIn(needle, script) route.fulfill(response=response, body=script.replace(needle, "window.__breakChapterWorker = () => worker.onerror({ preventDefault() {} }); " + needle)) self.page.route("**/static/reader-chapter-search.mjs*", expose_worker_failure) self.serve_chapter_search() self.search("needle") self.page.evaluate("window.__breakChapterWorker()") self.page.locator("#full-search-page").fill("3") self.page.locator("#full-search-page").dispatch_event("change") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent.includes('全文搜索组件加载失败')") self.page.locator("#full-search-retry").click() self.page.wait_for_function("() => document.querySelector('.full-search-rank')?.textContent === '101.'") self.assertEqual(self.page.locator("#full-search-page").input_value(), "3") self.assertEqual(self.page.locator("#full-search-status").text_content(), "155 个结果") def test_chapter_search_emits_partial_results_before_exact_total(self): self.serve_chapter_search() self.page.locator("#history").click() self.page.locator("#full-search-toggle").click() self.page.evaluate("""() => { window.__chapterStatuses = []; new MutationObserver(() => __chapterStatuses.push(document.querySelector('#full-search-status').textContent)) .observe(document.querySelector('#full-search-status'), { childList: true, subtree: true }); }""") self.page.locator("#full-search-input").fill("needle") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '155 个结果'") self.assertTrue(any("155 个结果(正在搜索" in status for status in self.page.evaluate("window.__chapterStatuses"))) def test_chapter_search_maps_inline_nodes_entities_and_repeated_searches(self): requests, _ = self.serve_chapter_search(namespaced=True) self.search("独特 词语") self.assertEqual(self.page.locator("#full-search-status").text_content(), "1 个结果") self.page.locator(".full-search-result").click() self.page.wait_for_function("() => document.querySelectorAll('.reader-epub-chapter mark.full-search-highlight').length === 2") self.assertEqual(self.page.locator(".reader-epub-chapter mark.full-search-highlight").all_text_contents(), ["独特", "词语"]) self.page.locator("#history").click() self.assertFalse(self.page.locator("#full-search-view").is_hidden()) self.page.locator("#full-search-input").fill("手机") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果' && document.querySelector('.full-search-result')?.textContent.includes('手机')") self.page.locator(".full-search-result").click() self.page.wait_for_function("() => document.querySelector('.reader-epub-chapter mark.full-search-highlight')?.textContent === '手机'") self.assertEqual(sum(item.endswith("epub-search-index.json.gz") for item in requests), 1) def test_chapter_search_retries_a_failed_index_and_rejects_corrupt_bytes(self): def failure(route, attempt, packed): if attempt == 1: route.fulfill(status=503, body="temporarily unavailable") return True if attempt == 2: route.fulfill(content_type="application/gzip", body=packed[:-1] + bytes([packed[-1] ^ 1])) return True requests, _ = self.serve_chapter_search(failure) self.page.locator("#history").click() self.page.locator("#full-search-toggle").click() self.page.locator("#full-search-input").fill("needle") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent.includes('503')") self.page.locator("#full-search-retry").click() self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent.includes('校验失败')") self.page.locator("#full-search-retry").click() self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '155 个结果'") self.assertEqual(sum(item.endswith("epub-search-index.json.gz") for item in requests), 3) def test_chapter_search_cancelled_download_cannot_publish_stale_results(self): held = [] def hold(route, attempt, packed): if attempt == 1: held.append((route, packed)) return True requests, _ = self.serve_chapter_search(hold) self.page.locator("#history").click() self.page.locator("#full-search-toggle").click() with self.context.expect_event("request", predicate=lambda request: "epub-search-index" in request.url): self.page.locator("#full-search-input").fill("needle") self.page.locator("#full-search-cancel").click() self.assertEqual(self.page.locator("#full-search-status").text_content(), "搜索已取消") self.page.locator("#full-search-input").fill("手机") self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'") self.assertEqual(self.page.locator(".full-search-result").count(), 1) self.assertEqual(sum(item.endswith("epub-search-index.json.gz") for item in requests), 2) for route, packed in held: route.fulfill(content_type="application/gzip", body=packed) self.assertEqual(self.page.locator("#full-search-status").text_content(), "1 个结果") def test_chapter_manifest_does_not_translate_foliate_positions(self): source = "https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/objects/aa/" + "b" * 64 + "/chapter-manifest.json" manifest = {"version": 1, "kind": "epub-chapters", "chapters": [ {"index": i, "path": f"chapter-{i}.xhtml", "title": f"Chapter {i}", "bytes": 100} for i in range(1, 5) ]} def serve(route): raw = urllib.parse.parse_qs(urllib.parse.urlsplit(route.request.url).query)["url"][0] if raw.endswith("chapter-manifest.json"): route.fulfill(content_type="application/json", body=json.dumps(manifest)) else: number = raw.rsplit("chapter-", 1)[-1].split(".")[0] route.fulfill(content_type="text/html", body=f"

Chapter {number}

Body

") self.page.route("**/api/reader-content**", serve) url = self.reader_url("epub-chapters", url=source) old_entry = {"url": source, "foliateSection": 2, "foliateOffset": 275, "foliateTocIndex": 2, "lastReadAt": 1} self.open(url) self.page.wait_for_function("() => document.querySelector('.reader-epub-chapter[data-chapter=\"3\"]')") self.page.wait_for_timeout(2500) self.page.evaluate("source => VoiceOfMLReaderStore.remove(source)", source) self.page.evaluate("entry => VoiceOfMLReaderStore.put(entry)", old_entry) self.page.reload(wait_until="domcontentloaded") self.page.wait_for_function("() => document.querySelector('.reader-epub-chapter[data-chapter=\"3\"]')") self.page.wait_for_timeout(3000) saved = self.page.evaluate("source => VoiceOfMLReaderStore.get(source)", source) self.assertEqual(saved["chapterIndex"], 1) def test_foliate_cfi_selects_accented_and_inline_ranges_after_repeated_search(self): with zipfile.ZipFile(io.BytesIO(support.epub_with_many_chapters())) as archive: files = {name: archive.read(name) for name in archive.namelist()} chapter = next(name for name in files if name.endswith("chapter-1.xhtml")) files[chapter] = b''' Search

Search

Before

café

Between

cafe

international

After

''' self.serve(support.zip_bytes(files), "application/epub+zip") self.open(self.reader_url("epub")) self.search("cafe") self.assertEqual(self.page.locator(".full-search-result").count(), 2) self.page.locator(".full-search-result").first.click() self.assertEqual(self.page.locator("#accent .full-search-highlight").text_content(), "caf\u00e9") self.page.locator("#history").click() self.assertFalse(self.page.locator("#full-search-view").is_hidden()) self.page.locator(".full-search-result").nth(1).click() self.assertEqual(self.page.locator("#plain .full-search-highlight").text_content(), "cafe") self.page.locator("#history").click() self.assertFalse(self.page.locator("#full-search-view").is_hidden()) self.page.locator("#full-search-input").fill("international") self.page.locator(".full-search-result").click() self.page.locator("#inline .full-search-highlight").first.wait_for() self.assertEqual("".join(self.page.locator("#inline .full-search-highlight").all_text_contents()), "international") def test_chm_self_closing_anchors_keep_text_order_and_search_after_many_entries(self): with zipfile.ZipFile(io.BytesIO(support.epub_with_legacy_chm_markup())) as archive: files = {name: archive.read(name) for name in archive.namelist()} # Hundreds of empty XML anchors become nested containers under HTML # parsing, which can reorder inline citations near the parser depth cap. entries = ''.join(f'Entry {i}: ' f'Citation {i}; End {i}.' for i in range(600)) files['OEBPS/chapter.xhtml'] = ( '' 'Order' + entries + '' ).encode() self.serve(support.zip_bytes(files), "application/epub+zip") self.open(self.reader_url("epub")) body = self.page.locator('.foliate-continuous .reader-section-body') self.assertEqual(body.text_content(), ''.join(f'Entry {i}: Citation {i}; End {i}.' for i in range(600))) self.search('Citation 599') self.page.locator('.full-search-result').first.click() self.assertEqual(self.page.locator('.foliate-continuous .full-search-highlight').text_content(), 'Citation 599') def test_chapter_resources_allow_siblings_within_manifest_family_only(self): object_root = "https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/objects/aa/" + "a" * 64 + "/" for variant in ("", "epub-chapters/"): with self.subTest(variant=variant): base = object_root + variant source = base + "chapter-manifest.json" manifest = {"version": 1, "kind": "epub-chapters", "chapters": [ {"index": 1, "path": "chapters/chapter-1.xhtml", "bytes": 100} ]} self.page.route("**/api/reader-content**", lambda route: route.fulfill( content_type="application/json" if "chapter-manifest.json" in route.request.url else "text/html", body=json.dumps(manifest) if "chapter-manifest.json" in route.request.url else """

Chapter

""")) self.open(self.reader_url("epub-chapters", url=source)) self.assertEqual(self.page.locator("#cover").get_attribute("src"), base + "resources/cover.svg") self.assertIsNone(self.page.locator("#escape").get_attribute("src")) self.assertIsNone(self.page.locator("#external").get_attribute("src")) if __name__ == "__main__": unittest.main()