from __future__ import annotations import json import time import unittest try: from .real_server import get_shared_server except ImportError: from real_server import get_shared_server try: from playwright.sync_api import Error as PlaywrightError from playwright.sync_api import sync_playwright except ImportError: PlaywrightError = Exception sync_playwright = None @unittest.skipUnless(sync_playwright, "install requirements-test.txt and run: playwright install chromium") class BrowserInteractionTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.server = get_shared_server() cls.base_url = cls.server.base_url cls.playwright = sync_playwright().start() try: cls.browser = cls.playwright.chromium.launch(headless=True, args=["--no-sandbox"]) except PlaywrightError as error: cls.playwright.stop() raise unittest.SkipTest(f"Chromium is unavailable: {error}") @classmethod def tearDownClass(cls): cls.browser.close() cls.playwright.stop() def open_page(self, viewport=None, search_handler=None): context = self.browser.new_context(viewport=viewport or {"width": 1440, "height": 900}) page = context.new_page() errors = [] page.on("pageerror", lambda error: errors.append(str(error))) result = { "Repo": "VoiceOfML/Test", "File": "手机资料", "Extension": "txt", "Folder": ["测试目录"], "Size": 1024, "HasTxt": True, "Link": "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/test.txt", "Path": "https://huggingface.co/datasets/VoiceOfML/Test/blob/main/test.txt", } def route_api(route, request): path = request.url.split(self.base_url, 1)[-1] if path.startswith("/api/search"): body = request.post_data_json or {} if search_handler: return search_handler(route, request, body, result) payload = {"results": [result], "total": 1, "page": body.get("page", 1), "page_size": body.get("page_size", 100)} elif path.startswith("/api/repos"): payload = [{"name": "VoiceOfML/Test", "count": 1}] elif path.startswith("/api/extensions"): payload = [{"name": "txt", "count": 1}] elif path.startswith("/api/folders/Test/contents"): payload = {"folders": [], "files": [], "current_path": ""} elif path.startswith("/api/folders/Test"): payload = [] elif path.startswith("/api/random-txt/status"): payload = {"available": True, "count": 1} elif path.startswith("/api/random"): payload = result else: payload = {} route.fulfill(status=200, content_type="application/json", body=json.dumps(payload, ensure_ascii=False)) page.route(self.base_url + "/api/**", route_api) page.route( "https://vomebook-hitokoto.hf.space/**", lambda route: route.fulfill(status=200, content_type="application/json", body='{"hitokoto":"test"}'), ) page.goto(self.base_url + "/", wait_until="domcontentloaded") page.locator("#search-input").wait_for(state="visible") return context, page, errors def test_multi_term_highlight_never_marks_inside_tags_or_entities(self): context, page, errors = self.open_page() try: page.locator("#search-input").fill("手机 a") title = page.locator("#results-list .result-item .result-title").filter(has_text="手机资料").first title.wait_for(timeout=15000) self.assertEqual(title.evaluate("n => [...n.querySelectorAll('mark')].map(m => m.textContent)"), ["手机"]) self.assertNotIn("rk>", title.inner_text()) self.assertEqual(page.evaluate("highlightText('Tom & Jerry', 'tom amp')"), "Tom & Jerry") self.assertEqual(page.evaluate("highlightText('abcab', 'ab abc')"), "abcab") self.assertEqual(errors, []) finally: context.close() def test_path_only_navigation_does_not_strand_pending_filter_render(self): context, page, errors = self.open_page() tree = [{"name": "Test", "path": "", "isRoot": True, "count": 2, "hasDirectFiles": False, "hasChildren": True, "children": [{"name": n, "path": n, "count": 1, "hasDirectFiles": True, "hasChildren": False, "children": []} for n in ("docs", "notes")]}] held = [] def folders(route): if not held: held.append(route) else: route.fulfill(status=200, content_type="application/json", body=json.dumps(tree)) try: page.route(self.base_url + "/api/folders/Test", folders) page.evaluate("() => { STATE.rightSidebarOpen = true; ROUTER.navigate('repo', 'Test'); }") deadline = time.time() + 15 while not held and time.time() < deadline: page.wait_for_timeout(50) self.assertTrue(held, "folder tree request never started") self.assertIn("加载中", page.locator("#filter-folder-tree").inner_text()) # A sidebar folder click changes only the path while the tree is pending. page.evaluate("() => ROUTER.navigate('repo', 'Test', 'docs')") page.wait_for_function("STATE.browserPath === 'docs'") page.locator('#filter-folder-tree .filter-folder-item[data-path="docs"]').wait_for(timeout=10000) held[0].fulfill(status=200, content_type="application/json", body=json.dumps(tree)) page.wait_for_function("!folderTreeRenderQueue.length") paths = page.locator("#filter-folder-tree .filter-folder-item").evaluate_all("rows => rows.map(r => r.dataset.path)") self.assertEqual(paths, ["docs", "notes"]) self.assertIn("txt", page.locator("#filter-ext-list").inner_text()) # A later cache hit shows the same normalized roots, without the raw repository root. page.evaluate("() => ROUTER.navigate('global')") page.wait_for_function("STATE.mode === 'global'") page.evaluate("() => ROUTER.navigate('repo', 'Test')") page.wait_for_function("STATE.mode === 'repo' && STATE.folderTree && STATE.folderTree.length") page.wait_for_function("!folderTreeRenderQueue.length") paths = page.locator("#filter-folder-tree .filter-folder-item").evaluate_all("rows => rows.map(r => r.dataset.path)") self.assertEqual(paths, ["docs", "notes"]) self.assertEqual(errors, []) finally: context.close() def test_late_initial_payload_fallback_does_not_restart_a_newer_query(self): context, page, errors = self.open_page() try: page.locator("#results-list .result-item").first.wait_for(timeout=15000) result = page.evaluate('''() => new Promise(async done => { const original = {fetchJsonWithTimeout, doSearch}; let release, searches = 0; fetchJsonWithTimeout = () => new Promise(resolve => { release = resolve; }); doSearch = () => { searches++; }; if (typeof readInitialSearchPayload === "function") { original.readInitialSearchPayload = readInitialSearchPayload; readInitialSearchPayload = () => null; } initialPayloadCache.clear(); STATE.query = ""; STATE.page = 1; const pending = searchWithInitialFallbackFresh(); await new Promise(resolve => setTimeout(resolve, 50)); const held = typeof release === "function"; STATE.query = "late-payload-query"; if (held) release(null); await pending; Object.assign(window, original); done({held, searches}); })''') self.assertEqual(result, {"held": True, "searches": 0}) self.assertEqual(errors, []) finally: context.close() def test_desktop_search_theme_filters_and_keyboard(self): context, page, errors = self.open_page() try: page.locator("#search-input").fill("手机") page.locator("#results-list .result-item").filter(has_text="手机资料").wait_for(timeout=15000) self.assertIn("q=", page.url) original_class = page.locator("body").get_attribute("class") page.locator("#theme-btn").click() self.assertNotEqual(original_class, page.locator("body").get_attribute("class")) page.locator("#settings-btn").click() page.locator("#sort-select").select_option("name") page.locator("#sort-select").evaluate("node => node.blur()") page.keyboard.press("Escape") page.keyboard.press("/") self.assertTrue(page.locator("#search-input").evaluate("node => document.activeElement === node")) self.assertEqual(errors, []) finally: context.close() def test_search_clear_control_tracks_query_state(self): context, page, errors = self.open_page() try: search = page.locator("#search-input") self.assertEqual(search.get_attribute("data-has-query"), "false") self.assertFalse(page.locator("#search-box").evaluate("el => el.classList.contains('has-query')")) search.fill("手机") page.wait_for_function("document.getElementById('search-input').dataset.hasQuery === 'true'") self.assertTrue(page.locator("#search-box").evaluate("el => el.classList.contains('has-query')")) search.fill("") page.wait_for_function("document.getElementById('search-input').dataset.hasQuery === 'false'") self.assertFalse(page.locator("#search-box").evaluate("el => el.classList.contains('has-query')")) self.assertEqual(errors, []) finally: context.close() def test_malformed_api_envelopes_and_folder_errors_are_rejected(self): context, page, errors = self.open_page() try: self.assertEqual(page.evaluate("""() => ({ nullRecord: isValidSearchResponse({results:[null],total:1,page:1,page_size:100},1,100), wrongTotal: isValidSearchResponse({results:[],total:1,page:1,page_size:100},1,100), wrongPageSize: isValidSearchResponse({results:[],total:0,page:1,page_size:50},1,100), oversized: isValidSearchResponse({results:Array(101).fill({}),total:101,page:1,page_size:100},1,100) })"""), { "nullRecord": False, "wrongTotal": False, "wrongPageSize": False, "oversized": False, }) page.route( self.base_url + "/api/folders/Test/contents*", lambda route: route.fulfill(status=500, content_type="application/json", body='{"error":"temporary"}'), ) result = page.evaluate("""async () => { try { await API.getContents('Test', ''); return 'resolved'; } catch (error) { return error.message; } }""") self.assertIn("Invalid folder contents response", result) self.assertEqual(errors, []) finally: context.close() def test_section_filter_cancel_buttons_follow_selected_state(self): context, page, errors = self.open_page() try: page.locator("#settings-btn").click() self.assertTrue(page.locator("#repo-filter-cancel").is_hidden()) page.locator("#filter-ext-list input").first.check() page.locator("#ext-filter-cancel").wait_for(state="visible") page.locator("#filter-repo-list input").first.check() page.locator("#repo-filter-cancel").wait_for(state="visible") page.locator("#repo-filter-cancel").click() self.assertEqual(page.evaluate("STATE.filterRepos"), []) self.assertNotEqual(page.evaluate("STATE.filterExtensions"), []) page.locator("#ext-filter-cancel").click() self.assertEqual(page.evaluate("STATE.filterExtensions"), []) page.goto(self.base_url + "/MLMRL-Hub", wait_until="domcontentloaded") page.locator("#search-input").wait_for(state="visible") page.locator("#settings-btn").click() page.evaluate("STATE.filterFolderSubtrees = ['archive']; updateFilterCancelButtons()") page.locator("#folder-filter-cancel").click() self.assertEqual(page.evaluate("[STATE.filterFolderSelfs, STATE.filterFolderSubtrees]"), [[], []]) self.assertNotIn("folder_subtree=", page.url) self.assertEqual(errors, []) finally: context.close() def test_repository_and_extension_filters_refresh_results(self): search_bodies = [] def search_handler(route, request, body, result): search_bodies.append(body) filtered = dict(result) if body.get("repos"): filtered["File"] = "仓库筛选结果" elif body.get("extensions"): filtered["File"] = "类型筛选结果" route.fulfill( status=200, content_type="application/json", body=json.dumps({ "results": [filtered], "total": 1, "page": body.get("page", 1), "page_size": body.get("page_size", 100), }, ensure_ascii=False), ) context, page, errors = self.open_page(search_handler=search_handler) try: page.locator("#settings-btn").click() extension = page.locator("#filter-ext-list input").first extension.check() page.wait_for_function("STATE.results[0] && STATE.results[0].File === '类型筛选结果'", timeout=5000) self.assertTrue(extension.is_checked()) self.assertTrue(any(body.get("extensions") == ["txt"] for body in search_bodies)) page.locator("#ext-filter-cancel").click() page.wait_for_function("STATE.filterExtensions.length === 0", timeout=5000) repository = page.locator("#filter-repo-list input").first repository.check() page.wait_for_function("STATE.results[0] && STATE.results[0].File === '仓库筛选结果'", timeout=5000) self.assertTrue(repository.is_checked()) self.assertTrue(any(body.get("repos") == ["VoiceOfML/Test"] for body in search_bodies)) self.assertEqual(errors, []) finally: context.close() def test_rapid_repository_filter_changes_are_coalesced(self): search_bodies = [] def search_handler(route, request, body, result): search_bodies.append(body) filtered = dict(result) if body.get("repos"): filtered["File"] = "批量仓库筛选结果" route.fulfill( status=200, content_type="application/json", body=json.dumps({ "results": [filtered], "total": 1, "page": body.get("page", 1), "page_size": body.get("page_size", 100), }, ensure_ascii=False), ) context, page, errors = self.open_page(search_handler=search_handler) try: page.locator("#settings-btn").click() page.evaluate("""() => { renderCheckboxList(DOM.filterRepoList, [ {key: 'VoiceOfML/Test', label: 'Test', count: 1}, {key: 'VoiceOfML/Other', label: 'Other', count: 1}, ], [], vals => { STATE.filterRepos = vals; STATE.page = 1; updateFilterCancelButtons(); scheduleFilterSearch(); }); const inputs = DOM.filterRepoList.querySelectorAll('input'); for (const input of inputs) { input.checked = true; input.dispatchEvent(new Event('change', {bubbles: true})); } }""") page.wait_for_function("STATE.results[0] && STATE.results[0].File === '批量仓库筛选结果'", timeout=5000) filtered_requests = [body for body in search_bodies if body.get("repos")] self.assertEqual(len(filtered_requests), 1) self.assertEqual(filtered_requests[0]["repos"], ["VoiceOfML/Test", "VoiceOfML/Other"]) self.assertEqual(errors, []) finally: context.close() def test_folder_filter_checkbox_updates_and_searches(self): context, page, errors = self.open_page() try: page.goto(self.base_url + "/MLMRL-Hub", wait_until="domcontentloaded") page.locator("#search-input").wait_for(state="visible") page.locator("#settings-btn").click() page.locator("#filter-folder-section").wait_for(state="visible") page.evaluate("""() => { STATE.folderTree = [{ name: "根", path: "", count: 1, isRoot: true, hasDirectFiles: false, hasChildren: true, children: [{ name: "一级/二级", path: "一级/二级", count: 1, hasDirectFiles: true, hasChildren: false, children: [] }] }]; STATE.folderTreeCollapsed = {}; initializeFolderTreeCollapsed(STATE.folderTree); renderFilterFolderTree(); }""") checkbox = page.locator("#filter-folder-tree .filter-folder-item input").last checkbox.click() page.wait_for_function("STATE.filterFolderSubtrees.includes('一级/二级')", timeout=5000) self.assertTrue(checkbox.is_checked(), page.evaluate("""() => ({ state: [STATE.filterFolderSelfs, STATE.filterFolderSubtrees], inputs: [...document.querySelectorAll('#filter-folder-tree input')].map(input => [input.value, input.checked, input.indeterminate]) })""")) self.assertEqual(errors, []) finally: context.close() def test_repository_directory_navigation_keeps_path_filters(self): context, page, errors = self.open_page() try: page.goto(self.base_url + "/MLMRL-Hub?folder_self=docs&folder_subtree=archive%2F2026", wait_until="domcontentloaded") page.wait_for_function("() => STATE.mode === 'repo' && STATE.filterFolderSelfs.includes('docs')") for path in ("docs/nested", "docs", ""): page.evaluate("path => ROUTER.navigate('repo', STATE.repo, path)", path) params = page.evaluate("() => Object.fromEntries(new URL(location.href).searchParams)") self.assertEqual(params.get("path", ""), path) self.assertEqual(params.get("folder_self"), "docs") self.assertEqual(params.get("folder_subtree"), "archive/2026") self.assertEqual(page.evaluate("STATE.filterFolderSelfs"), ["docs"]) self.assertEqual(page.evaluate("STATE.filterFolderSubtrees"), ["archive/2026"]) page.evaluate("ROUTER.navigate('repo', 'VOMEBOOK', '')") self.assertEqual(page.evaluate("STATE.filterFolderSelfs"), []) self.assertNotIn("folder_self=", page.url) self.assertEqual(errors, []) finally: context.close() def test_directory_tree_preview_stays_usable_during_slow_contents(self): context, page, errors = self.open_page() try: page.evaluate("ROUTER.navigate('repo', 'Test', '')") page.wait_for_function("() => STATE.mode === 'repo' && STATE.repo === 'Test'") page.locator("#filter-ext-list input").first.wait_for(state="attached") page.evaluate("""() => { STATE.folderTree = [{name:'根目录', path:'', children:[ {name:'docs', path:'docs', count:2, children:[ {name:'nested', path:'docs/nested', count:1, children:[]} ]} ]}]; }""") pending = [] page.route(self.base_url + "/api/folders/Test/contents?path=docs", lambda route: pending.append(route)) page.evaluate("ROUTER.navigate('repo', 'Test', 'docs')") page.wait_for_function("""() => STATE.browserPath === 'docs' && !!document.querySelector('.browser-item[data-type="folder"][data-path="docs/nested"]')""") self.assertEqual(page.locator(".browser-list .sidebar-loading").text_content(), "文件加载中...") self.assertTrue(pending) pending[0].fulfill(status=200, content_type="application/json", body=json.dumps({ "folders": [{"name": "nested", "path": "docs/nested", "count": 1}], "files": [{"name": "readme.txt", "ext": "txt", "size": 100, "hasTxt": False}] })) page.locator('.browser-item[data-type="file"] .browser-name').wait_for() self.assertEqual(page.locator('.browser-item[data-type="file"] .browser-name').text_content(), "readme.txt") warming = [] page.route(self.base_url + "/api/folders/Test/contents?path=docs%2Fnested", lambda route: warming.append(route)) page.wait_for_function("() => API._browserPending.size === 0") page.locator('.browser-item[data-type="folder"][data-path="docs/nested"]').dispatch_event("pointerover") page.wait_for_function("() => API._browserPending.has('Test|docs/nested')", timeout=3000) self.assertEqual(len(warming), 1) page.evaluate("ROUTER.navigate('repo', 'Test', 'docs/nested')") self.assertEqual(len(warming), 1) warming[0].fulfill(status=200, content_type="application/json", body=json.dumps({ "folders": [], "files": [{"name": "nested.txt", "ext": "txt", "size": 1}] })) page.locator('.browser-item[data-type="file"] .browser-name').wait_for() self.assertEqual(page.locator('.browser-item[data-type="file"] .browser-name').text_content(), "nested.txt") page.evaluate("API._browserCache.delete('Test|docs')") page.route(self.base_url + "/api/folders/Test/contents?path=docs", lambda route: route.fulfill(status=500, body='{}')) page.evaluate("ROUTER.navigate('repo', 'Test', 'docs')") page.locator('.browser-list .sidebar-loading').filter(has_text="正在重试").wait_for(timeout=5000) page.locator('.browser-item[data-type="folder"][data-path="docs/nested"]').click() page.locator('.browser-item[data-type="file"] .browser-name').filter(has_text="nested.txt").wait_for() self.assertEqual(errors, []) finally: context.close() def test_large_directory_navigation_keeps_search_page_and_filter_tree(self): context, page, errors = self.open_page() try: page.goto(self.base_url + "/MLMRL-Hub", wait_until="domcontentloaded") page.wait_for_function("() => STATE.mode === 'repo'") page.locator("#filter-ext-list input").first.wait_for(state="attached") page.evaluate("""() => { STATE.folderTree = Array.from({length: 600}, (_, i) => ({ name: `folder-${i}`, path: `folder-${i}`, count: 1, hasDirectFiles: true, hasChildren: false, children: [] })); renderFilterFolderTree(); window.__originalFilterRow = DOM.filterFolderTree.firstElementChild; window.__filterRuns = 0; window.__searchRuns = 0; const render = renderFilters, search = searchWithInitialFallback; renderFilters = (...args) => { window.__filterRuns++; return render(...args); }; searchWithInitialFallback = (...args) => { window.__searchRuns++; return search(...args); }; STATE.page = 3; ROUTER.navigate('repo', STATE.repo, 'folder-300'); }""") self.assertTrue(page.evaluate("window.__originalFilterRow.isConnected")) self.assertEqual(page.evaluate("[STATE.page, window.__searchRuns, window.__filterRuns]"), [3, 0, 0]) page.evaluate("""() => { const url = new URL(location.href); url.searchParams.set('q', 'different query'); history.pushState(null, '', url.href); ROUTER.apply(); }""") self.assertFalse(page.evaluate("window.__originalFilterRow.isConnected")) self.assertGreater(page.evaluate("window.__searchRuns"), 0) self.assertGreater(page.evaluate("window.__filterRuns"), 0) self.assertEqual(errors, []) finally: context.close() def test_deselected_self_files_button_has_neutral_hover_border(self): context, page, errors = self.open_page() try: page.goto(self.base_url + "/MLMRL-Hub", wait_until="domcontentloaded") page.locator("#filter-folder-section").wait_for(state="visible") page.locator("#settings-btn").click() page.locator("#right-sidebar.open").wait_for(state="visible") page.evaluate("""() => { STATE.folderTree = [{name:'根', path:'', count:2, isRoot:true, showSelfToggle:true, hasDirectFiles:true, hasChildren:true, children:[{name:'docs', path:'docs', count:1, hasDirectFiles:true, hasChildren:false, children:[]}]}]; STATE.folderTreeCollapsed = {}; renderFilterFolderTree(); }""") button = page.locator("#filter-folder-tree .folder-self-toggle") button.click() self.assertEqual(button.get_attribute("aria-pressed"), "true") page.wait_for_timeout(220) selected_border = button.evaluate("node => getComputedStyle(node).borderTopColor") button.click() self.assertEqual(button.get_attribute("aria-pressed"), "false") page.wait_for_function("selected => getComputedStyle(document.querySelector('#filter-folder-tree .folder-self-toggle')).borderTopColor !== selected", arg=selected_border) self.assertFalse(button.evaluate("node => node.classList.contains('active')")) self.assertEqual(errors, []) finally: context.close() def test_desktop_result_order_and_action_heights(self): context, page, errors = self.open_page() try: page.locator("#search-input").fill("手机") row = page.locator("#results-list .result-item").filter(has_text="手机资料") row.wait_for(timeout=15000) layout = row.evaluate("""node => { const rect = selector => node.querySelector(selector).getBoundingClientRect(); const title = rect('.result-title'); const actions = rect('.result-actions'); const path = rect('.result-path'); const meta = rect('.result-meta'); const buttons = [...node.querySelectorAll('.result-action-btn')].map(item => item.getBoundingClientRect().height); return { titleTop: title.top, actionsTop: actions.top, actionsBottom: actions.bottom, pathTop: path.top, pathBottom: path.bottom, pathRight: path.right, actionsLeft: actions.left, metaTop: meta.top, buttons, }; }""") self.assertAlmostEqual(layout["titleTop"], layout["actionsTop"], delta=1) self.assertGreaterEqual(layout["pathTop"], layout["actionsBottom"] - 1) self.assertGreaterEqual(layout["metaTop"], layout["pathBottom"]) self.assertGreater(layout["pathRight"], layout["actionsLeft"]) self.assertEqual(len(layout["buttons"]), 4) self.assertTrue(all(abs(height - 24) < 0.1 for height in layout["buttons"])) self.assertEqual(errors, []) finally: context.close() def test_mobile_result_actions_use_compact_column(self): context, page, errors = self.open_page({"width": 390, "height": 844}) try: page.locator("#search-input").fill("手机") row = page.locator("#results-list .result-item").filter(has_text="手机资料") row.wait_for(timeout=15000) layout = row.evaluate("""node => { const actions = node.querySelector('.result-actions').getBoundingClientRect(); const buttons = [...node.querySelectorAll('.result-action-btn')].map(item => { const rect = item.getBoundingClientRect(); return {height: rect.height, top: rect.top}; }); return {height: actions.height, buttons}; }""") self.assertEqual(len(layout["buttons"]), 4) self.assertTrue(all(abs(button["height"] - 20) < 0.1 for button in layout["buttons"])) self.assertLessEqual(layout["height"], 83) self.assertEqual(len({round(button["top"], 1) for button in layout["buttons"]}), 4) self.assertEqual(errors, []) finally: context.close() def test_mobile_long_folder_uses_remaining_path_line(self): context, page, errors = self.open_page({"width": 390, "height": 844}) try: layout = page.evaluate("""async () => { STATE.results = [{ Repo: "VoiceOfML/VOMEBOOK", File: "路径换行测试", Extension: "pdf", Folder: [ "马列之声ebook小组第七批成果集成(2018.8.17)", "马列之声ebook小组第七批成果集成(2018.8.17)part 1a", "2018.8 part 1", ], Size: 1, }]; STATE.total = 1; STATE.hasMore = false; resetVirtualScrollState(); renderResults(); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); const path = document.querySelector('.result-path'); const folders = [...path.querySelectorAll('.path-folder')]; const first = document.createRange(); first.selectNodeContents(folders[1]); const repo = folders[0].getBoundingClientRect(); const firstRect = first.getClientRects()[0]; return { repoTop: repo.top, firstTop: firstRect.top, firstLeft: firstRect.left, separatorsNested: [...path.querySelectorAll('.path-sep')].every(sep => sep.parentElement.classList.contains('path-folder')), }; }""") self.assertAlmostEqual(layout["firstTop"], layout["repoTop"], delta=1) self.assertGreater(layout["firstLeft"], 0) self.assertTrue(layout["separatorsNested"]) self.assertEqual(errors, []) finally: context.close() def test_mobile_sidebar_and_view_toggle(self): context, page, errors = self.open_page({"width": 390, "height": 844}) try: self.assertTrue(page.locator("body").evaluate("node => node.classList.contains('mobile')")) was_open = page.locator("#left-sidebar").evaluate("node => node.classList.contains('open')") page.locator("#hamburger-btn").click() self.assertNotEqual(was_open, page.locator("#left-sidebar").evaluate("node => node.classList.contains('open')")) page.locator("#mobile-toggle-btn").click() self.assertFalse(page.locator("body").evaluate("node => node.classList.contains('mobile')")) self.assertEqual(errors, []) finally: context.close() def test_mobile_sidebars_do_not_add_history_guards(self): context, page, errors = self.open_page({"width": 390, "height": 844}) try: page.goto(self.base_url + "/?sidebar=0", wait_until="domcontentloaded") page.locator("#search-input").wait_for(state="visible") self.assertFalse(page.evaluate("history.state?.voiceSidebarGuard || history.state?.voiceSidebarOverlay")) page.locator("#hamburger-btn").click() page.wait_for_function("STATE.leftSidebarOpen") self.assertFalse(page.evaluate("history.state?.voiceSidebarGuard || history.state?.voiceSidebarOverlay")) page.locator("#settings-btn").click() page.wait_for_function("STATE.rightSidebarOpen && !STATE.leftSidebarOpen") self.assertFalse(page.evaluate("history.state?.voiceSidebarGuard || history.state?.voiceSidebarOverlay")) self.assertEqual(errors, []) finally: context.close() def test_mobile_reader_return_does_not_restore_closed_sidebar(self): context, page, errors = self.open_page({"width": 390, "height": 844}) try: page.route("**/api/reader-content**", lambda route: route.fulfill( content_type="text/plain", body="Reader navigation")) page.wait_for_function("() => STATE.leftSidebarOpen") url = "/static/reader.html?url=https%3A%2F%2Fhuggingface.co%2Fdatasets%2FVoiceOfML%2FTest%2Fresolve%2Fmain%2Fback.txt&ext=txt" self.assertTrue(page.evaluate("url => navigateToReader(url)", url)) reader = page.frame_locator("iframe.reader-overlay") reader.locator(".reader-text").wait_for() page.evaluate("history.back()") page.locator("iframe.reader-overlay").wait_for(state="detached") self.assertFalse(page.evaluate("STATE.leftSidebarOpen || STATE.rightSidebarOpen")) self.assertIn("sidebar=0", page.url) self.assertEqual(errors, []) finally: context.close() def test_sidebar_expand_button_keeps_svg_state(self): context, page, errors = self.open_page({"width": 1440, "height": 900}) try: page.locator("#sidebar-content .repo-list-item").first.wait_for(state="visible") page.locator("#sidebar-content .repo-list-item").first.click() page.wait_for_function("STATE.mode === 'repo'") button = page.locator("#sidebar-expand-btn") self.assertEqual(button.locator("svg").count(), 1) self.assertNotIn("→", button.inner_text()) button.click() self.assertTrue(page.locator("#left-sidebar").evaluate("node => node.classList.contains('expanded-wide')")) self.assertTrue(button.evaluate("node => node.classList.contains('expanded')")) self.assertEqual(button.locator("svg").count(), 1) button.click() self.assertFalse(page.locator("#left-sidebar").evaluate("node => node.classList.contains('expanded-wide')")) self.assertFalse(button.evaluate("node => node.classList.contains('expanded')")) self.assertEqual(button.locator("svg").count(), 1) self.assertEqual(errors, []) finally: context.close() def test_mobile_sidebar_returns_by_directory_level(self): context, page, errors = self.open_page({"width": 390, "height": 844}) try: heights = page.evaluate("[document.querySelector('.sidebar-header').getBoundingClientRect().height, document.querySelector('.status-bar').getBoundingClientRect().height]") self.assertEqual(heights[0], heights[1]) if page.locator("#left-sidebar").evaluate("node => !node.classList.contains('open')"): page.locator("#hamburger-btn").click() page.locator("#sidebar-content .repo-list-item").first.wait_for(state="visible") page.locator("#sidebar-content .repo-list-item").first.click() page.wait_for_function("STATE.mode === 'repo' && STATE.browserPath === ''") self.assertTrue(page.locator("#sidebar-back-btn").is_visible()) self.assertEqual(page.locator(".sidebar-breadcrumb").inner_text(), "根目录") page.evaluate("ROUTER.navigate('repo', STATE.repo, 'docs')") page.wait_for_function("STATE.browserPath === 'docs'") page.evaluate("ROUTER.navigate('repo', STATE.repo, 'docs/part')") page.wait_for_function("STATE.browserPath === 'docs/part'") page.evaluate("history.back()") page.wait_for_function("STATE.browserPath === 'docs'") self.assertTrue(page.locator("#left-sidebar").evaluate("node => node.classList.contains('open')")) page.locator("#sidebar-back-btn").click() page.wait_for_function("STATE.browserPath === ''") self.assertEqual(page.locator(".sidebar-breadcrumb").inner_text(), "根目录") self.assertEqual(errors, []) finally: context.close() def test_ime_composition_defers_search_until_composition_end(self): requests = [] def record_search(route, request, body, result): requests.append(body.get("q", "")) payload = {"results": [result], "total": 1, "page": 1, "page_size": 100} route.fulfill(status=200, content_type="application/json", body=json.dumps(payload, ensure_ascii=False)) context, page, errors = self.open_page(search_handler=record_search) try: # The visible input precedes asynchronous startup search/prefetch. # Observe composition only after those initial requests settle. page.wait_for_function("STATE._loadedPage >= 1 && !STATE.isLoading && pendingSearchPages.size === 0") requests.clear() page.locator("#search-input").dispatch_event("compositionstart") page.locator("#search-input").fill("手") page.wait_for_timeout(300) self.assertEqual(requests, []) page.locator("#search-input").dispatch_event("compositionend") page.wait_for_function("() => new URL(location.href).searchParams.get('q') === '手'") self.assertIn("手", requests) self.assertEqual(errors, []) finally: context.close() def test_newer_search_result_wins_when_older_request_is_delayed(self): context, page, errors = self.open_page() try: page.unroute(self.base_url + "/api/**") def ordered(route, request): path = request.url.split(self.base_url, 1)[-1] if not path.startswith("/api/search"): return route.fulfill(status=200, content_type="application/json", body="[]") body = request.post_data_json or {} query = body.get("q", "") if query == "old": time.sleep(0.4) item = {"Repo": "VoiceOfML/Test", "File": query or "initial", "Extension": "txt", "Folder": [], "Size": 1, "HasTxt": False} payload = {"results": [item], "total": 1, "page": 1, "page_size": 100} route.fulfill(status=200, content_type="application/json", body=json.dumps(payload)) page.route(self.base_url + "/api/**", ordered) page.locator("#search-input").fill("old") page.wait_for_timeout(130) page.locator("#search-input").fill("new") page.locator("#results-list .result-item").filter(has_text="new").wait_for(timeout=10000) self.assertEqual(page.locator("#results-list .result-title").first.text_content(), "new.txt") self.assertEqual(errors, []) finally: context.close() def test_virtual_scroll_settles_and_thumb_drag_stays_covered(self): context, page, errors = self.open_page() try: result = page.evaluate("""async () => { const repeat = (value, count) => Array(count).fill(value).join(""); STATE.results = Array.from({length: 800}, (_, index) => ({ Repo: "VoiceOfML/Test", File: index % 4 === 1 ? `virtual-${index}-` + repeat("长标题", 45) : `virtual-${index}`, Extension: "txt", Folder: index % 4 >= 2 ? Array.from({length: 10}, (_, part) => `很长的目录-${index}-${part}`) : [], Size: index + 1, HasTxt: index % 4 === 3, })); STATE.total = STATE.results.length; STATE.hasMore = false; STATE.isLoading = false; DOM.resultsContainer.scrollTop = 0; resetVirtualScrollState(); renderResultsSkeleton(); renderResults(); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); const skeletonCount = DOM.resultsList.querySelectorAll(".result-skeleton-item").length; const samples = []; const container = DOM.resultsContainer; for (const ratio of [0.82, 0.17, 0.94, 0.33, 0.7, 0.05, 1]) { container.scrollTop = ratio * (container.scrollHeight - container.clientHeight); container.dispatchEvent(new Event("scroll")); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); const rect = container.getBoundingClientRect(); const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); const row = hit && hit.closest(".result-item"); samples.push(row && row.querySelector(".result-title") ? Number(row.dataset.index) : null); } container.scrollTop = 0.4 * (container.scrollHeight - container.clientHeight); container.dispatchEvent(new Event("scroll")); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); const appendRect = container.getBoundingClientRect(); const preservedRow = document.elementFromPoint( appendRect.left + appendRect.width / 2, appendRect.top + appendRect.height / 2, ).closest(".result-item"); const preservedIndex = preservedRow.dataset.index; const heightBeforeAppend = container.scrollHeight; const oldLength = STATE.results.length; ensureHeightTree(); const heightTreeBeforeAppend = VSCROLL.heightTree; STATE.results = STATE.results.concat(Array.from({length: 100}, (_, index) => ({ Repo: "VoiceOfML/Test", File: `appended-${index}`, Extension: "txt", Folder: [], Size: oldLength + index, }))); refreshVirtualAfterAppend(); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); let naiveHeight = 0; let prefixMatches = true; for (let index = 0; index < VSCROLL.heights.length; index++) { naiveHeight += VSCROLL.heights[index]; if (Math.abs(fenwickSum(VSCROLL.heightTree, index + 1) - naiveHeight) > 0.01) prefixMatches = false; } const measuredRow = DOM.resultsList.querySelector(`.result-item[data-index="${preservedIndex}"]`); let repeatedReads = 0; const originalRect = measuredRow.getBoundingClientRect.bind(measuredRow); measuredRow.getBoundingClientRect = () => { repeatedReads++; return originalRect(); }; VSCROLL.measuredWindowKey = ""; measureHeights(VSCROLL.renderStart, VSCROLL.renderEnd); const readsAtSameWidth = repeatedReads; const originalWidth = DOM.resultsContainer.style.width; DOM.resultsContainer.style.width = Math.max(200, container.clientWidth - 1) + "px"; VSCROLL.measuredWindowKey = ""; measureHeights(VSCROLL.renderStart, VSCROLL.renderEnd); DOM.resultsContainer.style.width = originalWidth; const preservedAfterAppend = preservedRow === DOM.resultsList.querySelector(`.result-item[data-index="${preservedIndex}"]`); const treeExtendedInPlace = heightTreeBeforeAppend === VSCROLL.heightTree; const heightAfterAppend = container.scrollHeight; updateScrollTrack(); container.scrollTop = 0; renderVisible(); const thumbRect = DOM.scrollThumb.getBoundingClientRect(); const trackRect = DOM.scrollTrack.getBoundingClientRect(); DOM.scrollThumb.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientY: thumbRect.top + thumbRect.height / 2, })); for (let step = 1; step <= 40; step++) { document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientY: thumbRect.top + thumbRect.height / 2 + (trackRect.height - thumbRect.height) * step / 40, })); } const scrollTopBeforeDragFrame = container.scrollTop; await new Promise(resolve => requestAnimationFrame(resolve)); const scrollTopAfterDragFrame = container.scrollTop; const dragRect = container.getBoundingClientRect(); const dragHit = document.elementFromPoint( dragRect.left + dragRect.width / 2, dragRect.top + dragRect.height / 2, ); const dragHitIndex = dragHit && dragHit.closest(".result-item") ? Number(dragHit.closest(".result-item").dataset.index) : null; const dragDomCount = DOM.resultsList.querySelectorAll(".result-item").length; document.dispatchEvent(new MouseEvent("mouseup", {bubbles: true})); return { samples, skeletonCount, preservedAfterAppend, heightBeforeAppend, heightAfterAppend, treeExtendedInPlace, prefixMatches, readsAtSameWidth, readsAfterWidthChange: repeatedReads, scrollTopBeforeDragFrame, scrollTopAfterDragFrame, dragHitIndex, dragDomCount, draggingAfterMouseUp: VSCROLL.isDraggingThumb, }; }""") self.assertTrue(all(index is not None for index in result["samples"])) self.assertEqual(result["skeletonCount"], 0) self.assertTrue(result["preservedAfterAppend"]) self.assertGreater(result["heightAfterAppend"], result["heightBeforeAppend"]) self.assertTrue(result["treeExtendedInPlace"]) self.assertTrue(result["prefixMatches"]) self.assertEqual(result["readsAtSameWidth"], 0) self.assertGreater(result["readsAfterWidthChange"], result["readsAtSameWidth"]) self.assertEqual(result["scrollTopBeforeDragFrame"], 0) self.assertGreater(result["scrollTopAfterDragFrame"], result["scrollTopBeforeDragFrame"]) self.assertIsNotNone(result["dragHitIndex"]) self.assertLess(result["dragDomCount"], 50) self.assertFalse(result["draggingAfterMouseUp"]) self.assertEqual(errors, []) finally: context.close() def test_url_filter_roundtrip_restores_query_sort_and_size(self): context, page, errors = self.open_page() try: page.goto(self.base_url + "/?q=phone&sort=name&min_size=1KB&max_size=2MB&search_folders=false&exact=0", wait_until="domcontentloaded") page.locator("#search-input").wait_for(state="visible") self.assertEqual(page.locator("#search-input").input_value(), "phone") self.assertEqual(page.locator("#sort-select").input_value(), "name") self.assertEqual(page.locator("#filter-min-size").input_value(), "1") self.assertEqual(page.locator("#filter-min-unit").input_value(), "KB") self.assertFalse(page.locator("#search-folders-toggle").is_checked()) self.assertFalse(page.locator("#exact-search-toggle").is_checked()) self.assertEqual(errors, []) finally: context.close() def test_repository_folder_filters_remain_in_url_after_reload(self): context, page, errors = self.open_page() try: url = self.base_url + "/MLMRL-Hub?folder_self=docs&folder_subtree=archive%2F2026" page.goto(url, wait_until="domcontentloaded") page.locator("#search-input").wait_for(state="visible") page.wait_for_function("() => STATE.filterFolderSelfs.includes('docs') && STATE.filterFolderSubtrees.includes('archive/2026')") self.assertEqual(page.url.count("folder_self=docs"), 1) self.assertEqual(page.url.count("folder_subtree=archive%2F2026"), 1) page.reload(wait_until="domcontentloaded") page.locator("#search-input").wait_for(state="visible") page.wait_for_function("() => STATE.filterFolderSelfs.includes('docs') && STATE.filterFolderSubtrees.includes('archive/2026')") self.assertIn("folder_self=docs", page.url) self.assertIn("folder_subtree=archive%2F2026", page.url) self.assertEqual(errors, []) finally: context.close() def test_random_book_uses_deterministic_api_result(self): context, page, errors = self.open_page() try: page.locator("#random-book-btn").click() page.locator("#toast").filter(has_text="已发起下载").wait_for(timeout=5000) iframe = page.locator('iframe[src^="/api/download?"]') iframe.wait_for(state="attached") self.assertIn("file=", iframe.get_attribute("src")) self.assertEqual(errors, []) finally: context.close() def test_reader_path_returns_to_matching_folder_filter(self): context, page, errors = self.open_page() try: reader_url = page.evaluate("""() => getReaderLink({ Repo: "VoiceOfML/Test", File: "路径测试", Extension: "txt", Folder: ["一级目录", "二级目录"], HasTxt: true, Link: "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/reader.txt", })""") page.goto(self.base_url + reader_url, wait_until="domcontentloaded") path = page.locator("#reader-path") path.wait_for(state="visible", timeout=10000) self.assertEqual(path.text_content(), "Test/一级目录/二级目录") # Only the path text is a target, not the whole title column. geometry = path.evaluate("""n => { const r = n.getBoundingClientRect(), h = n.parentElement.getBoundingClientRect(); const range = document.createRange(); range.selectNodeContents(n); return {button: r.width, heading: h.width, text: range.getBoundingClientRect().width}; }""") self.assertLess(geometry["button"], geometry["heading"] - 40, geometry) self.assertLessEqual(geometry["button"], geometry["text"] + 2, geometry) self.assertTrue(page.locator("#status").is_hidden()) path.click() page.wait_for_url("**/Test?**", timeout=10000) target = page.evaluate("() => ({pathname: location.pathname, folders: new URL(location.href).searchParams.getAll('folder_self')})") self.assertEqual(target["pathname"], "/Test") self.assertEqual(target["folders"], ["一级目录/二级目录"]) self.assertEqual(errors, []) finally: context.close() def test_cancelled_extension_filter_is_not_restored_by_stale_reader_link(self): context, page, errors = self.open_page() try: page.evaluate("STATE.filterExtensions = ['pdf']") stale_reader_url = page.evaluate("""() => getReaderLink({ Repo: "VoiceOfML/Test", File: "路径测试", Extension: "txt", Folder: ["docs"], HasTxt: true, Link: "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/path.txt", })""") page.evaluate("STATE.filterExtensions = []; saveStoredExtensionFilters()") folder_url = page.evaluate("url => new URL(syncReaderFolderFilter(url), location.origin).searchParams.get('folder_url') || ''", stale_reader_url) self.assertNotIn("ext=", folder_url) self.assertEqual(errors, []) finally: context.close() def test_reader_back_closes_overlay_without_reloading_search(self): context, page, errors = self.open_page() try: page.locator("#search-input").fill("手机") page.wait_for_function("() => STATE.query === '手机' && new URL(location.href).searchParams.get('q') === '手机'") marker = page.evaluate("() => { window.__readerReturnMarker = Math.random(); return window.__readerReturnMarker; }") reader_url = page.evaluate("""() => getReaderLink({ Repo: "VoiceOfML/Test", File: "返回测试", Extension: "txt", Folder: [], HasTxt: true, Link: "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/return.txt", })""") search_url = page.url page.locator("#search-input").focus() page.evaluate("""() => { const stale = new URL('/static/reader.html?nav=stale', location.origin).href; sessionStorage.setItem('reader-return:stale', location.href); sessionStorage.setItem('reader-navigation-current', JSON.stringify({readerUrl: stale, shareUrl: stale})); }""") page.evaluate("url => navigateToReader(url)", reader_url) self.assertEqual(page.evaluate("() => location.pathname"), "/static/reader.html") self.assertNotIn("return=", page.url) self.assertNotIn("nav=", page.url) self.assertIsNone(page.evaluate("() => sessionStorage.getItem('reader-return:stale')")) self.assertEqual(page.evaluate("() => Object.keys(sessionStorage).filter(key => key.startsWith('reader-return:')).length"), 1) reader = page.frame_locator("iframe.reader-overlay") reader.locator("#back").wait_for(state="visible") reader.locator("html[data-reader-phase]").wait_for(state="attached") initial_dark = page.evaluate("() => STATE.isDark") reader.locator("#history").click() reader.locator("#history-panel.is-open").wait_for(state="visible") reader.locator("#theme-toggle").click() page.wait_for_function("initial => STATE.isDark !== initial", arg=initial_dark) self.assertEqual(reader.locator("html").get_attribute("data-theme"), "light" if initial_dark else "dark") reader.locator("#theme-toggle").click() page.wait_for_function("initial => STATE.isDark === initial", arg=initial_dark) reader.locator("#history-close").click() page.wait_for_function("() => document.activeElement === document.querySelector('iframe.reader-overlay')") self.assertGreater(page.locator("body > [inert]").count(), 0) reader.locator("#back").click() page.locator("iframe.reader-overlay").wait_for(state="detached") self.assertEqual(page.url, search_url) self.assertEqual(page.evaluate("() => window.__readerReturnMarker"), marker) self.assertTrue(page.locator("#search-input").evaluate("el => el === document.activeElement")) self.assertEqual(page.locator("body > [inert]").count(), 0) self.assertEqual(page.evaluate("() => performance.getEntriesByType('navigation')[0].type"), "navigate") page.go_forward(wait_until="commit") page.locator("iframe.reader-overlay").wait_for(state="attached") self.assertEqual(page.evaluate("() => location.pathname"), "/static/reader.html") page.evaluate("() => history.replaceState(null, '', location.href)") page.reload(wait_until="domcontentloaded") page.locator("#back").click() page.locator("#search-input").wait_for(state="visible") self.assertEqual(page.url, search_url) page.wait_for_function("() => STATE.query === '手机'") self.assertEqual(page.evaluate("() => Object.keys(sessionStorage).filter(key => key.startsWith('reader-return:')).length"), 0) self.assertIsNone(page.evaluate("() => sessionStorage.getItem('reader-navigation-current')")) self.assertEqual(errors, []) finally: context.close() def test_reader_back_restores_virtual_search_scroll(self): context, page, errors = self.open_page() try: before = page.evaluate("""async () => { STATE.results = Array.from({length: 240}, (_, index) => ({ Repo: "VoiceOfML/Test", File: `返回滚动-${index}`, Extension: "txt", Folder: index % 3 === 0 ? ["目录", `分组-${index}`] : [], Size: index + 1, HasTxt: true, })); STATE.total = STATE.results.length; STATE.hasMore = false; STATE.isLoading = false; resetVirtualScrollState(); renderResults(); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); DOM.resultsContainer.scrollTop = 520; DOM.resultsContainer.dispatchEvent(new Event("scroll")); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); return DOM.resultsContainer.scrollTop; }""") self.assertGreater(before, 0) page.evaluate("url => navigateToReader(url)", "/static/reader.html?title=返回滚动") page.locator("iframe.reader-overlay").wait_for(state="attached") page.evaluate("history.back()") page.locator("iframe.reader-overlay").wait_for(state="detached") page.wait_for_function( "expected => Math.abs(DOM.resultsContainer.scrollTop - expected) <= 1", arg=before, timeout=10000, ) self.assertEqual(page.evaluate("DOM.resultsContainer.scrollTop"), before) self.assertEqual(errors, []) finally: context.close() def test_system_back_exits_reader_in_one_step_with_panel_or_search_open(self): # Like the Back button, one system back leaves the Reader. It must not # re-push a history entry (Chromium would mark the search page skippable). context, page, errors = self.open_page() try: page.route("**/api/reader-content**", lambda route: route.fulfill( content_type="text/plain", body="find-me in the book")) url = "/static/reader.html?url=https%3A%2F%2Fhuggingface.co%2Fdatasets%2FVoiceOfML%2FTest%2Fresolve%2Fmain%2Fback.txt&ext=txt" for view in ("panel", "full-search"): before = page.evaluate("() => [location.href, history.length]") page.evaluate("url => navigateToReader(url)", url) reader = page.frame_locator("iframe.reader-overlay") reader.locator(".reader-text").wait_for() reader.locator("#history").click() if view == "full-search": reader.locator("#full-search-toggle").click() reader.locator("#full-search-input").fill("find-me") reader.locator(".full-search-result").wait_for() page.wait_for_function("view => readerUiState === view", arg=view) page.evaluate("""() => { window.__pushes = 0; const push = history.pushState.bind(history); history.pushState = (...args) => { window.__pushes++; return push(...args); }; }""") page.evaluate("history.back()") page.locator("iframe.reader-overlay").wait_for(state="detached") self.assertEqual(page.evaluate("window.__pushes"), 0, view) self.assertEqual(page.evaluate("() => location.href"), before[0], view) page.evaluate("() => { delete history.pushState; }") self.assertEqual(errors, []) finally: context.close() def test_search_snapshot_restores_results_and_page_cache(self): context, page, errors = self.open_page() try: restored = page.evaluate("""async () => { const first = [ {Repo: "VoiceOfML/Test", File: "快照一", Extension: "txt", Folder: ["目录"], Size: 1}, {Repo: "VoiceOfML/Test", File: "快照二", Extension: "txt", Folder: [], Size: 2}, ]; STATE.query = "快照查询"; DOM.searchInput.value = STATE.query; STATE.results = first; STATE.total = 2; STATE.page = 2; STATE._loadedPage = 2; STATE._pageCache = {1: first.slice(0, 1), 2: first.slice(1)}; STATE.hasMore = true; STATE.isLoading = false; resetVirtualScrollState(); renderResults(); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); const key = getSearchViewKey(); saveSearchViewSnapshot(key); STATE.query = "另一个查询"; DOM.searchInput.value = STATE.query; STATE.results = []; STATE.total = 0; STATE.page = 1; STATE._loadedPage = 0; STATE._pageCache = {}; renderResults(); STATE.query = "快照查询"; DOM.searchInput.value = STATE.query; const didRestore = restoreSearchViewSnapshot(getSearchViewKey(), false); return { didRestore, files: STATE.results.map(result => result.File), total: STATE.total, page: STATE.page, loadedPage: STATE._loadedPage, cachedPages: Object.keys(STATE._pageCache).sort(), }; }""") self.assertTrue(restored["didRestore"]) self.assertEqual(restored["files"], ["快照一", "快照二"]) self.assertEqual(restored["total"], 2) self.assertEqual(restored["page"], 2) self.assertEqual(restored["loadedPage"], 2) self.assertEqual(restored["cachedPages"], ["1", "2"]) self.assertEqual(errors, []) finally: context.close() def test_reader_history_switch_updates_parent_url_and_state(self): context, page, errors = self.open_page() try: page.locator("#search-input").fill("历史切书") first_url = page.evaluate("""() => getReaderLink({ Repo: "VoiceOfML/Test", File: "第一本", Extension: "txt", Folder: [], HasTxt: true, Link: "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/first.txt", })""") second_url = page.evaluate("""() => new URL(getReaderLink({ Repo: "VoiceOfML/Test", File: "第二本", Extension: "txt", Folder: [], HasTxt: true, Link: "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/second.txt", }), location.origin).href""") search_url = page.url page.evaluate("url => navigateToReader(url)", first_url) page.frame_locator("iframe.reader-overlay").locator("#back").wait_for() reader_frame = next(frame for frame in page.frames if frame.parent_frame is not None and "/static/reader.html" in frame.url) page.wait_for_function("() => !!document.querySelector('iframe.reader-overlay')?.contentWindow?.VoiceOfMLReaderStore") reader_frame.evaluate("""async (readerUrl) => VoiceOfMLReaderStore.put({ url: 'https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/second.txt', title: '第二本.txt', extension: 'txt', readerUrl, page: 1, pageCount: 0, lastReadAt: Date.now() })""", second_url) # The panel can open by itself after loading; open it idempotently # instead of toggling it closed again. reader_frame.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready'") if reader_frame.locator("#history").get_attribute("aria-expanded") != "true": reader_frame.locator("#history").click() reader_frame.wait_for_function("() => document.querySelector('#history-panel').classList.contains('is-open')") reader_frame.locator('.reader-panel-tabs button[data-panel="history"]').click() reader_frame.locator("#history-list .panel-item-main").filter(has_text="第二本.txt").click() page.wait_for_function("() => new URL(location.href).searchParams.get('title') === '第二本.txt'") self.assertEqual(page.evaluate("() => new URL(history.state.readerUrl).searchParams.get('title')"), "第二本.txt") page.frame_locator("iframe.reader-overlay").locator("#title").filter(has_text="第二本.txt").wait_for() page.frame_locator("iframe.reader-overlay").locator("#back").click() page.locator("iframe.reader-overlay").wait_for(state="detached") self.assertEqual(page.url, search_url) self.assertEqual(errors, []) finally: context.close() def test_indexeddb_restores_last_search_but_explicit_url_wins(self): context, page, errors = self.open_page() try: page.goto(self.base_url + "/?q=持久搜索&ext=pdf&filters=1", wait_until="domcontentloaded") page.locator("#search-input").wait_for(state="visible") page.wait_for_function("() => STATE.query === '持久搜索' && STATE.filterExtensions.includes('pdf')") page.wait_for_function("""async () => { const db = await openSearchSessionDB(); const tx = db.transaction('session', 'readonly'); const request = tx.objectStore('session').get('last-search'); const saved = await new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = reject; }); db.close(); return saved && saved.url.includes(encodeURIComponent('持久搜索')); }""") page.close() page = context.new_page() page.on("pageerror", lambda error: errors.append(str(error))) page.goto(self.base_url + "/", wait_until="domcontentloaded") page.locator("#search-input").wait_for(state="visible") page.wait_for_function("() => STATE.query === '持久搜索' && STATE.filterExtensions.includes('pdf') && STATE.rightSidebarOpen") page.goto(self.base_url + "/?q=显式搜索&ext=txt", wait_until="domcontentloaded") page.locator("#search-input").wait_for(state="visible") page.wait_for_function("() => STATE.query === '显式搜索' && STATE.filterExtensions.length === 1 && STATE.filterExtensions[0] === 'txt'") self.assertEqual(errors, []) finally: context.close() if __name__ == "__main__": unittest.main()