Download tests/test_reader_refactor.py from VoiceOfML/Search: direct link, hf CLI and curl.
- Browser
- Download file 151 kB
-
https://huggingface.co/spaces/VoiceOfML/Search/resolve/main/tests/test_reader_refactor.py
- Command line
-
hf download hf://spaces/VoiceOfML/Search/tests/test_reader_refactor.py
-
curl -L -o test_reader_refactor.py https://huggingface.co/spaces/VoiceOfML/Search/resolve/main/tests/test_reader_refactor.py
151 kB
| """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 | |
| class ReaderRefactorTest(unittest.TestCase): | |
| 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)) | |
| 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 = ( | |
| '<!doctype html><html><head><meta charset="utf-8"><style>' | |
| 'body{width:max-content;min-width:100%;margin:0;padding:12px}' | |
| '.reader-spreadsheet-sheet{width:max-content;min-width:100%}' | |
| '</style></head><body><section class="reader-spreadsheet-sheet">' | |
| '<h2>Summary</h2><table><tr><td id="cell">完整单元格文字</td>' | |
| f'<td><img src="{chart_data}"></td></tr></table></section></body></html>' | |
| ) | |
| 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"<h1>Chapter {number}</h1><p>Content</p>") | |
| 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'] = ( | |
| '<html xmlns="http://www.w3.org/1999/xhtml"><body>' + | |
| ''.join(f'<p>needle {number}-{hit}</p>' for hit in range(100)) + | |
| '</body></html>').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'] = ( | |
| '<html xmlns="http://www.w3.org/1999/xhtml"><body>' + | |
| ''.join(f'<p>needle {number}-{hit}</p>' for hit in range(70)) + | |
| '</body></html>' | |
| ).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'<h1>Chapter {i}</h1><p id="target" style="height:12000px">正文</p>' for i in range(1, 13)} | |
| bodies[1] = '<html><body id="contents"><a href="chapter-0012.xhtml#target">跨章节跳转</a><a href="chapter-0012.xhtml#chapter-root">章节首页</a><a href="part0010.html#missing">失效旧链接</a>' + bodies[1] + '</body></html>' | |
| bodies[12] = '<html:html xmlns:html="http://www.w3.org/1999/xhtml"><html:body id="chapter-root"><html:h1>Chapter 12</html:h1><html:p id="target"><html:a href="chapter-0001.xhtml#contents">返回目录</html:a></html:p><html:ruby><html:rb>朝露<html:a id="w3"/></html:rb><html:rtc><html:rt>zhāo lù</html:rt></html:rtc></html:ruby><html:p style="height:12000px">正文</html:p></html:body></html:html>' | |
| 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 = '<html><body><h1>Bucket chapter</h1><img src="../resources/shared/cover.png"></body></html>' | |
| 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: '<html><body><a href="chapter-0002.xhtml#target">跨章节目录</a><h1>目录</h1></body></html>', | |
| 2: '<html><body><h1 id="target">目标章节</h1><p style="height:12000px">正文</p></body></html>', | |
| } | |
| 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 = """<!doctype html><body><script> | |
| window.openReader = readerUrl => { | |
| const share = new URL(readerUrl); share.searchParams.delete('return'); share.searchParams.delete('nav'); | |
| history.replaceState({voiceReaderOverlay:true, readerUrl, extra:'keep'}, '', share.href); | |
| sessionStorage.setItem('reader-navigation-current', JSON.stringify({readerUrl, shareUrl:share.href, | |
| returnScroll:{top:123}, extra:'keep'})); | |
| document.querySelector('iframe')?.remove(); | |
| const frame = document.createElement('iframe'); frame.className='reader-overlay'; | |
| frame.style='width:100%;height:750px'; frame.src=readerUrl; document.body.append(frame); | |
| }; | |
| openReader(history.state?.readerUrl || JSON.parse(sessionStorage.getItem('reader-navigation-current') || 'null')?.readerUrl || HANDOFF); | |
| </script>""".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'<script src="{store_path}"></script>')) | |
| 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 = """<!doctype html><body><script> | |
| history.replaceState({voiceReaderOverlay:true, readerUrl:OTHER}, '', OTHER); | |
| sessionStorage.setItem('reader-navigation-current', JSON.stringify(RECORD)); | |
| const frame=document.createElement('iframe'); frame.className='reader-overlay'; | |
| frame.style='height:750px;width:100%'; frame.src=HANDOFF; document.body.append(frame); | |
| </script>""".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("<main>" + "<span>ordinary </span>" * 21000 + "<p>unique-tail</p></main>", "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 = "<p>Examples: url(icon.png) and @import theme.css;</p>" + "".join( | |
| f"<h1 id='chapter-{i}'>Chapter {i}</h1><p style='height:1100px'>text</p>" 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 = """<!doctype html><html><head><title>论坛标题</title></head><body> | |
| <header class="d-header">论坛顶部</header> | |
| <div id="topic-title"><a class="fancy-title">论坛标题</a></div> | |
| <div class="topic-timeline">时间线</div> | |
| <div class="post-stream"> | |
| <article class="topic-post"><div class="cooked"><p>第一段正文</p><s>删除的文字</s></div></article> | |
| <article class="topic-post"><div class="cooked"><p>第二段正文</p></div></article> | |
| </div> | |
| <div class="suggested-topics">推荐主题</div> | |
| </body></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("<p style='height:8000px'>Long document</p>", "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"<h1>Chapter {number}</h1><p style='height:2000px'>Body</p>") | |
| 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 = ("<h1>最后一章</h1><p>前言 &amp; <span>独特</span><em>词语</em> 尾声</p>" | |
| + "".join(f"<p>第{i}处needle结束</p>" for i in range(155)) | |
| + "<p>手机 手。机</p>") | |
| text = "最后一章 前言 & 独特 词语 尾声 " + " ".join(f"第{i}处needle结束" for i in range(155)) + " 手机 手。机" | |
| if namespaced: | |
| body = ('<html:html xmlns:html="http://www.w3.org/1999/xhtml"><html:head>' | |
| '<html:meta/><html:title>原始标题</html:title></html:head><html:body>' | |
| + body + '</html:body></html:html>') | |
| text = "原始标题 " + text | |
| else: | |
| body = f"<h1>{title}</h1><p style='height:12000px'>普通正文</p>" | |
| 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"<h1>Chapter {number}</h1><p style='height:2000px'>Body</p>") | |
| 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'''<?xml version="1.0" encoding="UTF-8"?> | |
| <html xmlns="http://www.w3.org/1999/xhtml"><head><title>Search</title></head><body> | |
| <h1>Search</h1><p style="height:1300px">Before</p> | |
| <p id="accent">café</p><p style="height:1300px">Between</p> | |
| <p id="plain">cafe</p><p id="inline">inter<b>national</b></p><p style="height:1300px">After</p> | |
| </body></html>''' | |
| 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'<html:a id="entry-{i}"/><html:p>Entry {i}: ' | |
| f'<html:span>Citation {i}</html:span>; End {i}.</html:p>' for i in range(600)) | |
| files['OEBPS/chapter.xhtml'] = ( | |
| '<html:html xmlns:html="http://www.w3.org/1999/xhtml"><html:head><html:meta/>' | |
| '<html:title>Order</html:title></html:head><html:body>' + entries + '</html:body></html:html>' | |
| ).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 """ | |
| <h1>Chapter</h1><img id='cover' src='../resources/cover.svg'> | |
| <img id='escape' src='../../resources/outside.svg'> | |
| <img id='external' src='https://evil.test/cover.svg'> | |
| """)) | |
| 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() | |