vomebook commited on
Commit
7ceca57
·
verified ·
1 Parent(s): 2b5d164

Open result breadcrumb in the sidebar directory

Browse files
static/app.js CHANGED
@@ -546,12 +546,11 @@ async function navigateToSourceFolder(sourceSlug, folderPath) {
546
  STATE.folderSelections = folderPath ? [folderPath] : [];
547
  syncRoute();
548
  const routeId = ++routeRenderId;
549
- sidebarCurrentPath = "";
550
- await renderSidebar(routeId);
551
- if (routeId !== routeRenderId) return;
552
- await renderFolderTree(false, routeId);
553
- if (routeId !== routeRenderId) return;
554
- refreshFolderTreeSelectionState();
555
  syncUrl();
556
  doSearch();
557
  }
@@ -1889,6 +1888,11 @@ async function applySidebarInitial(routeId, requestSequence) {
1889
 
1890
  async function renderSidebar(routeId = routeRenderId) {
1891
  if (routeId !== routeRenderId) return;
 
 
 
 
 
1892
  activateSidebarScrollKey(sidebarScrollKeyFor(STATE.source, ""));
1893
  const requestSequence = ++sidebarRequestSequence;
1894
  sidebarCurrentPath = "";
@@ -2333,6 +2337,8 @@ function attachEvents() {
2333
  refreshFolderTreeSelectionState();
2334
  syncUrl();
2335
  doSearch();
 
 
2336
  }
2337
  });
2338
  DOM.resultsList.addEventListener("keydown", (event) => {
 
546
  STATE.folderSelections = folderPath ? [folderPath] : [];
547
  syncRoute();
548
  const routeId = ++routeRenderId;
549
+ // Open the clicked result path directly; avoid a slow root request racing
550
+ // the desired directory. Filtering must not wait for either sidebar fetch.
551
+ if (folderPath) openFolder(folderPath, routeId);
552
+ else renderSidebar(routeId);
553
+ renderFolderTree(false, routeId);
 
554
  syncUrl();
555
  doSearch();
556
  }
 
1888
 
1889
  async function renderSidebar(routeId = routeRenderId) {
1890
  if (routeId !== routeRenderId) return;
1891
+ // A result breadcrumb stores the filter in the URL. On a fresh load there
1892
+ // is no previous sidebar position, so restore a single selected directory.
1893
+ // Existing in-page navigation remains independent of filter changes.
1894
+ if (!initComplete && STATE.source && STATE.folderSelections.length === 1)
1895
+ return openFolder(STATE.folderSelections[0], routeId);
1896
  activateSidebarScrollKey(sidebarScrollKeyFor(STATE.source, ""));
1897
  const requestSequence = ++sidebarRequestSequence;
1898
  sidebarCurrentPath = "";
 
2337
  refreshFolderTreeSelectionState();
2338
  syncUrl();
2339
  doSearch();
2340
+ if (path) openFolder(path, routeRenderId);
2341
+ else renderSidebar(routeRenderId);
2342
  }
2343
  });
2344
  DOM.resultsList.addEventListener("keydown", (event) => {
static/index.html CHANGED
@@ -159,6 +159,6 @@
159
 
160
  <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
161
  <script src="/static/tts-client.js?v=clips-11"></script>
162
- <script src="/static/app.js?v=phrases-4"></script>
163
  </body>
164
  </html>
 
159
 
160
  <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
161
  <script src="/static/tts-client.js?v=clips-11"></script>
162
+ <script src="/static/app.js?v=result-path-1"></script>
163
  </body>
164
  </html>
tests/path_navigation_fixture.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small isolated metadata fixture for real-backend path navigation tests.
2
+
3
+ Uses checked-out base metadata only; does not activate OCR or read any source
4
+ text. The original payload remains untouched and production startup is unchanged.
5
+ """
6
+ from contextlib import asynccontextmanager
7
+ from pathlib import Path
8
+ import app
9
+
10
+
11
+ @asynccontextmanager
12
+ async def fixture_lifespan(_application):
13
+ payload = app.load_json_gz(app.DATA_PATH)
14
+ app.records = [record for record in payload['records'] if record['source'] == 'CCRD'][:5000]
15
+ app.sources = [{'slug':'CCRD','name':'CCRD','count':len(app.records)}]
16
+ app.source_counts = {'CCRD':len(app.records)}
17
+ app.source_physical_map = {'CCRD':'CCRD'}
18
+ app.physical_source_counts = {'CCRD':len(app.records)}
19
+ app.record_map = {row['doc_id']:row for row in app.records}
20
+ app.record_map_index = {row['doc_id']:i for i,row in enumerate(app.records)}
21
+ app.folder_tree_data = {'CCRD':app.load_json_gz(app.FOLDER_TREE_PATH)['CCRD']}
22
+ app.folder_browser_data = {'CCRD':app.load_json_gz(app.FOLDER_BROWSER_PATH)['CCRD']}
23
+ app.active_ocr_release = None
24
+ app.fulltext_databases = None
25
+ app.build_indexes()
26
+ yield
27
+
28
+
29
+ app.app.router.lifespan_context = fixture_lifespan
30
+ application = app.app
tests/test_browser.py CHANGED
@@ -308,24 +308,124 @@ class BrowserInteractionTests(unittest.TestCase):
308
  finally:
309
  context.close()
310
 
311
- def test_old_folder_navigation_does_not_restart_search_after_going_home(self):
312
  context, page, errors = self.page()
313
  try:
314
  result = page.evaluate("""async () => {
315
  let release;
316
  renderSidebar = () => STATE.source
317
  ? new Promise(resolve => { release = resolve; }) : Promise.resolve();
 
318
  const searches = [];
319
  const trees = [];
320
  renderFolderTree = async () => trees.push(STATE.source);
321
  doSearch = () => searches.push(STATE.source);
322
  const oldNavigation = navigateToSourceFolder('CCRD', 'old');
 
323
  navigateHome();
324
  release();
325
  await oldNavigation;
326
- return {searches, trees, source: STATE.source};
327
  }""")
328
- self.assertEqual(result, {"searches": [None], "trees": [None], "source": None})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  self.assertEqual(errors, [])
330
  finally:
331
  context.close()
 
308
  finally:
309
  context.close()
310
 
311
+ def test_result_folder_search_does_not_wait_for_sidebar_or_restart_after_going_home(self):
312
  context, page, errors = self.page()
313
  try:
314
  result = page.evaluate("""async () => {
315
  let release;
316
  renderSidebar = () => STATE.source
317
  ? new Promise(resolve => { release = resolve; }) : Promise.resolve();
318
+ openFolder = () => new Promise(resolve => { release = resolve; });
319
  const searches = [];
320
  const trees = [];
321
  renderFolderTree = async () => trees.push(STATE.source);
322
  doSearch = () => searches.push(STATE.source);
323
  const oldNavigation = navigateToSourceFolder('CCRD', 'old');
324
+ const beforeGoingHome = searches.slice();
325
  navigateHome();
326
  release();
327
  await oldNavigation;
328
+ return {beforeGoingHome, searches, trees, source: STATE.source};
329
  }""")
330
+ self.assertEqual(result, {"beforeGoingHome": ['CCRD'], "searches": ['CCRD', None],
331
+ "trees": ['CCRD', None], "source": None})
332
+ self.assertEqual(errors, [])
333
+ finally:
334
+ context.close()
335
+
336
+ def test_result_path_filters_subtree_and_opens_the_same_sidebar_directory(self):
337
+ context, page, errors = self.page()
338
+ try:
339
+ page.evaluate("""() => {
340
+ const node=(name,path,children=[])=>({name,path,count:1,children});
341
+ const tree=[node('docs','docs',[node('子','docs/子')]),node('docs2','docs2')];
342
+ API.getFolders=async()=>tree;
343
+ API.getContents=async (_source,path)=>({folders:[],files:[{doc_id:'local',name:path||'ROOT'}]});
344
+ loadSidebarInitial=async()=>null;
345
+ window.__pathSearches=[];
346
+ doSearch=()=>{window.__pathSearches.push({source:STATE.source,folders:STATE.folderSelections.slice()});};
347
+ window.__resultPath=(source,folder)=>{
348
+ const link=document.createElement('span');link.className='path-folder';
349
+ link.dataset.source=source;link.dataset.folder=folder;DOM.resultsList.append(link);link.click();};
350
+ }""")
351
+ page.evaluate("__resultPath('CCRD','docs')")
352
+ page.wait_for_function("""() => STATE.source==='CCRD' && sidebarCurrentPath==='docs' &&
353
+ STATE.folderSelections.join()==='docs' &&
354
+ document.querySelector('.sidebar-breadcrumb [data-folder-nav="docs"]') &&
355
+ window.__pathSearches.length===1""")
356
+ page.wait_for_function("document.querySelector('#filter-folder-tree input[data-folder-path=\"docs\"]')?.checked")
357
+ self.assertTrue(page.locator('#filter-folder-tree input[data-folder-path="docs/子"]').is_checked())
358
+ self.assertEqual(page.evaluate('__pathSearches[0]'), {'source':'CCRD','folders':['docs']})
359
+
360
+ page.evaluate("__resultPath('CCRD','docs/子')")
361
+ page.wait_for_function("""() => sidebarCurrentPath==='docs/子' &&
362
+ STATE.folderSelections.join()==='docs/子' &&
363
+ document.querySelector('.sidebar-breadcrumb [data-folder-nav="docs/子"]')""")
364
+ self.assertEqual(page.evaluate('__pathSearches.at(-1)'), {'source':'CCRD','folders':['docs/子']})
365
+ page.evaluate("__resultPath('CCRD','')")
366
+ page.wait_for_function("""() => sidebarCurrentPath==='' && !STATE.folderSelections.length &&
367
+ DOM.sidebarContent.textContent.includes('ROOT.txt')""")
368
+ self.assertEqual(page.evaluate('__pathSearches.at(-1)'), {'source':'CCRD','folders':[]})
369
+ self.assertEqual(errors, [])
370
+ finally:
371
+ context.close()
372
+
373
+ def test_result_path_keyboard_activation_preserves_subtree_and_sidebar(self):
374
+ context, page, errors = self.page()
375
+ try:
376
+ page.evaluate("""() => {
377
+ history.replaceState(null,'','/CCRD');syncRoute();
378
+ API.getContents=async (_source,path)=>({folders:[],files:[{doc_id:'id',name:path}]});
379
+ doSearch=()=>{};refreshFolderTreeSelectionState=()=>{};
380
+ DOM.resultsList.innerHTML='<span role="button" tabindex="0" class="path-folder" data-source="CCRD" data-folder="docs/子">子</span>';
381
+ }""")
382
+ link = page.locator('.path-folder[role="button"]')
383
+ link.focus()
384
+ page.keyboard.press('Enter')
385
+ page.wait_for_function("sidebarCurrentPath==='docs/子' && STATE.folderSelections.join()==='docs/子'")
386
+ self.assertEqual(page.locator('.sidebar-breadcrumb [data-folder-nav="docs/子"]').count(), 1)
387
+ self.assertEqual(errors, [])
388
+ finally:
389
+ context.close()
390
+
391
+ def test_rapid_result_paths_keep_latest_sidebar_and_filter(self):
392
+ context, page, errors = self.page()
393
+ try:
394
+ result = page.evaluate("""async () => {
395
+ history.replaceState(null,'','/CCRD');syncRoute();
396
+ STATE.folderSelections=[];routeRenderId++;
397
+ const pending={};
398
+ API.getContents=(_source,path)=>new Promise(resolve=>{pending[path]=resolve;});
399
+ doSearch=()=>{};refreshFolderTreeSelectionState=()=>{};
400
+ const click=path=>{const link=document.createElement('span');link.className='path-folder';
401
+ link.dataset.source='CCRD';link.dataset.folder=path;DOM.resultsList.append(link);link.click();};
402
+ click('old');click('new');
403
+ pending.new({folders:[],files:[{doc_id:'new',name:'NEW'}]});
404
+ await new Promise(resolve=>setTimeout(resolve,0));
405
+ pending.old({folders:[],files:[{doc_id:'old',name:'OLD'}]});
406
+ await new Promise(resolve=>setTimeout(resolve,0));
407
+ return {path:sidebarCurrentPath, selection:STATE.folderSelections.slice(),
408
+ visible:DOM.sidebarContent.textContent.includes('NEW.txt'),
409
+ stale:DOM.sidebarContent.textContent.includes('OLD.txt')};
410
+ }""")
411
+ self.assertEqual(result, {'path':'new','selection':['new'],'visible':True,'stale':False})
412
+ self.assertEqual(errors, [])
413
+ finally:
414
+ context.close()
415
+
416
+ def test_sidebar_root_render_does_not_automatically_follow_manual_filter_change(self):
417
+ context, page, errors = self.page()
418
+ try:
419
+ result = page.evaluate("""async () => {
420
+ STATE.source='CCRD';STATE.folderSelections=['manual-filter'];initComplete=true;
421
+ loadSidebarInitial=async()=>null;
422
+ API.getContents=async (_source,path)=>({folders:[],files:[{doc_id:'x',name:path||'ROOT'}]});
423
+ await openFolder('manual-location',routeRenderId);
424
+ await renderSidebar(routeRenderId);
425
+ return {filter:STATE.folderSelections.slice(),path:sidebarCurrentPath,
426
+ rootVisible:DOM.sidebarContent.textContent.includes('ROOT.txt')};
427
+ }""")
428
+ self.assertEqual(result, {'filter':['manual-filter'],'path':'','rootVisible':True})
429
  self.assertEqual(errors, [])
430
  finally:
431
  context.close()
tests/test_path_navigation_browser.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Result breadcrumbs against a real, small CCRD metadata search backend."""
2
+ from __future__ import annotations
3
+ import json
4
+ import socket
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ import unittest
9
+ from pathlib import Path
10
+ from urllib.request import urlopen
11
+
12
+ from playwright.sync_api import sync_playwright
13
+
14
+ ROOT=Path(__file__).resolve().parents[1]
15
+
16
+
17
+ class ResultPathNavigationTests(unittest.TestCase):
18
+ @classmethod
19
+ def setUpClass(cls):
20
+ with socket.socket() as sock:
21
+ sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
22
+ cls.origin=f'http://127.0.0.1:{port}'
23
+ cls.server=subprocess.Popen([sys.executable,'-m','uvicorn','tests.path_navigation_fixture:application',
24
+ '--host','127.0.0.1','--port',str(port),'--log-level','warning'],cwd=ROOT,
25
+ stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
26
+ for _ in range(180):
27
+ if cls.server.poll() is not None:raise RuntimeError('fixture backend exited')
28
+ try:
29
+ with urlopen(cls.origin+'/api/sources',timeout=1) as response:
30
+ if response.status==200:break
31
+ except Exception:time.sleep(.25)
32
+ else:raise RuntimeError('fixture backend not ready')
33
+ cls.playwright=sync_playwright().start()
34
+ cls.browser=cls.playwright.chromium.launch(headless=True,args=['--no-sandbox'])
35
+
36
+ @classmethod
37
+ def tearDownClass(cls):
38
+ cls.browser.close();cls.playwright.stop();cls.server.terminate();cls.server.wait(timeout=10)
39
+
40
+ def test_parent_and_child_result_paths_filter_real_records_and_open_sidebar(self):
41
+ context=self.browser.new_context(viewport={'width':1280,'height':800},service_workers='block')
42
+ page=context.new_page();errors=[];page.on('pageerror',lambda error:errors.append(str(error)))
43
+ try:
44
+ page.goto(self.origin+'/?fulltext=0',wait_until='domcontentloaded')
45
+ parent=page.locator('#results-list .result-item').first.locator('.path-folder').nth(2)
46
+ parent.wait_for(timeout=30000)
47
+ path=parent.get_attribute('data-folder')
48
+ parent.click()
49
+ page.wait_for_function('''path=>STATE.source==='CCRD' && sidebarCurrentPath===path &&
50
+ STATE.folderSelections.length===1 && STATE.folderSelections[0]===path &&
51
+ !STATE.isLoading && STATE.results.length>0 &&
52
+ [...document.querySelectorAll('.sidebar-breadcrumb [data-folder-nav]')].some(x=>x.dataset.folderNav===path)''',
53
+ arg=path,timeout=30000)
54
+ records=page.evaluate('STATE.results.map(x=>x.Folder.join("/"))')
55
+ self.assertTrue(records and all(p==path or p.startswith(path+'/') for p in records))
56
+ self.assertTrue(any(p.startswith(path+'/') for p in records),'parent must include descendants')
57
+ self.assertEqual(page.evaluate('STATE.total'),1958)
58
+ child=page.locator('#results-list .result-item').first.locator('.path-folder').nth(3)
59
+ child_path=child.get_attribute('data-folder');child.click()
60
+ page.wait_for_function('''path=>sidebarCurrentPath===path && STATE.folderSelections[0]===path &&
61
+ !STATE.isLoading && [...document.querySelectorAll('.sidebar-breadcrumb [data-folder-nav]')]
62
+ .some(x=>x.dataset.folderNav===path)''',arg=child_path,timeout=30000)
63
+ self.assertTrue(all(p==child_path or p.startswith(child_path+'/')
64
+ for p in page.evaluate('STATE.results.map(x=>x.Folder.join("/"))')))
65
+ page.reload(wait_until='domcontentloaded')
66
+ page.wait_for_function('''path=>STATE.source==='CCRD' && STATE.folderSelections[0]===path &&
67
+ sidebarCurrentPath===path && !STATE.isLoading &&
68
+ [...document.querySelectorAll('.sidebar-breadcrumb [data-folder-nav]')]
69
+ .some(x=>x.dataset.folderNav===path)''',arg=child_path,timeout=30000)
70
+ self.assertEqual(errors,[])
71
+ finally:context.close()
tests/test_search.py CHANGED
@@ -1121,6 +1121,19 @@ class RequestValidationTests(unittest.TestCase):
1121
 
1122
 
1123
  class FrontendContractTests(unittest.TestCase):
 
 
 
 
 
 
 
 
 
 
 
 
 
1124
  def test_every_static_control_exists_is_cached_and_has_behavior(self):
1125
  html = (Path(app.BASE_DIR) / "static" / "index.html").read_text(encoding="utf-8")
1126
  source = (Path(app.BASE_DIR) / "static" / "app.js").read_text(encoding="utf-8")
 
1121
 
1122
 
1123
  class FrontendContractTests(unittest.TestCase):
1124
+ def test_result_path_navigation_keeps_filter_and_sidebar_in_sync(self):
1125
+ source = (Path(__file__).resolve().parents[1] / 'static/app.js').read_text(encoding='utf-8')
1126
+ handler = source.split('const folder = event.target.closest(".path-folder[data-folder]");', 1)[1].split(
1127
+ 'DOM.resultsList.addEventListener("keydown"', 1)[0]
1128
+ self.assertIn('STATE.folderSelections = path ? [path] : []', handler)
1129
+ self.assertIn('if (path) openFolder(path, routeRenderId)', handler)
1130
+ self.assertIn('else renderSidebar(routeRenderId)', handler)
1131
+ source_navigation = source.split('async function navigateToSourceFolder(', 1)[1].split(
1132
+ 'function navigateHome()', 1)[0]
1133
+ self.assertIn('if (folderPath) openFolder(folderPath, routeId)', source_navigation)
1134
+ self.assertIn('renderFolderTree(false, routeId)', source_navigation)
1135
+ self.assertIn('doSearch()', source_navigation)
1136
+
1137
  def test_every_static_control_exists_is_cached_and_has_behavior(self):
1138
  html = (Path(app.BASE_DIR) / "static" / "index.html").read_text(encoding="utf-8")
1139
  source = (Path(app.BASE_DIR) / "static" / "app.js").read_text(encoding="utf-8")