import { test, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { match, resolveUrl, navigate, subscribe, currentUrl, opportunityUrl, reportUrl, detailUrl, safeReturnTo, returnToRoute, setQuery, onLinkClick } from '../src/utils/router.js'; let base, entries, cursor; test('详情的来源和证据链接保留筛选,替换上一种详情,并兼容旧链接', () => { const source = resolveUrl(detailUrl('source', '原文 1', '/reports/voc?ev=高&stage=S3&artifact=a1&page=2')); assert.equal(source.searchParams.get('source'), '原文 1'); assert.equal(source.searchParams.get('ev'), '高'); assert.equal(source.searchParams.get('artifact'), null); assert.equal(source.searchParams.get('page'), '2'); const evidence = resolveUrl(detailUrl('evidence', 'ev:1', '/reports/voc?ev=高&source=s1', 'report1')); assert.equal(evidence.searchParams.get('ev'), '高'); assert.equal(evidence.searchParams.get('evidence'), 'ev:1'); assert.equal(evidence.searchParams.get('evidenceReport'), 'report1'); assert.equal(evidence.searchParams.get('source'), null); const legacy = resolveUrl(detailUrl('', '', '/pool/o1?ev=old&report=r1&returnTo=%2Fmembers')); assert.equal(legacy.searchParams.get('ev'), null); assert.equal(legacy.searchParams.get('report'), null); assert.equal(legacy.searchParams.get('returnTo'), '/members'); }); beforeEach(() => { base = '/preview/'; entries = [{ url: new URL('https://board.test/preview/pool?stage=S3&sort=latest'), state: null }]; cursor = 0; globalThis.location = entries[0].url; globalThis.document = { querySelector: () => ({ getAttribute: () => base }) }; globalThis.window = new EventTarget(); window.scrollY = 350; globalThis.PopStateEvent = class extends Event { constructor(type, options) { super(type); this.state = options.state; } }; globalThis.history = { get state() { return entries[cursor].state; }, replaceState(state, _, url) { entries[cursor] = { state, url: url ? new URL(url, location.href) : location }; globalThis.location = entries[cursor].url; }, pushState(state, _, url) { entries.splice(++cursor, entries.length, { state, url: new URL(url, location.href) }); globalThis.location = entries[cursor].url; }, back() { if (!cursor) return; globalThis.location = entries[--cursor].url; window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); }, }; }); test('机会卡深链接保留筛选、排序和关闭返回地址;报告携带回卡片地址', () => { const opp = opportunityUrl('机会 1'); const url = resolveUrl(opp); assert.equal(url.pathname, '/preview/pool/%E6%9C%BA%E4%BC%9A%201'); assert.equal(url.searchParams.get('stage'), 'S3'); assert.equal(url.searchParams.get('sort'), 'latest'); assert.equal(url.searchParams.get('returnTo'), '/pool?stage=S3&sort=latest'); const report = resolveUrl(reportUrl('r 1', opp)); assert.equal(report.searchParams.get('returnTo'), opp); assert.equal(match(url.pathname.slice('/preview'.length)).params.id, '机会 1'); }); test('来自其他页面的机会卡返回原页,不将来源页参数误用为卡池筛选', () => { const url = resolveUrl(opportunityUrl('o1', '/members?member=小智')); assert.equal(url.searchParams.get('member'), null); assert.equal(url.searchParams.get('returnTo'), '/members?member=小智'); }); test('推入历史保存离开页的位置,换卡 replace 保留返回凭据且不多压一层', () => { const back = currentUrl(); navigate(opportunityUrl('o1'), { state: { kfOverlay: { returnTo: back } } }); assert.equal(entries[0].state.kfScrollY, 350); assert.equal(history.state.kfFrom, back); navigate(opportunityUrl('o2', back), { replace: true }); assert.equal(entries.length, 2); assert.equal(history.state.kfOverlay.returnTo, back); assert.equal(match().params.id, 'o2'); history.back(); assert.equal(currentUrl(), back); }); test('订阅分别识别站内导航与浏览器返回,返回读取当前实际路径和位置', () => { const changes = []; const off = subscribe(value => changes.push(value)); navigate('/report/r1?ch=limits'); history.back(); off(); assert.deepEqual(changes.map(c => c.navigation), ['initial', 'push', 'traverse']); assert.equal(changes.at(-1).name, 'pool'); assert.equal(changes.at(-1).historyState.kfScrollY, 350); }); test('章节和浮层查询串更新不丢返回地址和历史状态', () => { navigate(reportUrl('r1', '/pool/o1?stage=S3')); setQuery({ ch: 'limits', ev: 'e1', report: 'r1' }); setQuery({ ev: null, report: null }); const q = new URLSearchParams(location.search); assert.equal(q.get('returnTo'), '/pool/o1?stage=S3'); assert.equal(q.get('ch'), 'limits'); assert.equal(q.get('ev'), null); assert.equal(history.state.kfFrom, '/pool?stage=S3&sort=latest'); }); test('非法或站外返回地址落到应用内兜底,损坏编码匹配为不存在', () => { for (const path of ['https://evil.test/', '//evil.test/', '/\\evil.test/', '/missing', '/pool\n']) { assert.equal(safeReturnTo(path, '/reports'), '/reports'); } assert.equal(match('/pool/%E0%A4%A').name, 'notfound'); assert.equal(match('/reports/competitor/r1').name, 'report'); }); test('根部署与子路径导航均保留查询和章节,不重复压入相同 URL', () => { base = '/'; assert.equal(resolveUrl('/report/r1?ch=2#top').href, 'https://board.test/report/r1?ch=2#top'); base = '/preview/'; navigate('/report/r1?ch=2#top'); navigate('/report/r1?ch=2#top'); assert.equal(entries.length, 2); assert.equal(location.pathname, '/preview/report/r1'); assert.equal(currentUrl(), '/report/r1?ch=2#top'); }); test('报告返回链接使用浏览器历史;新标签与下载链接保留浏览器行为', () => { globalThis.HTMLAnchorElement = class { constructor(path, attrs = {}) { this.href = resolveUrl(path).href; this.attrs = attrs; this.target = attrs.target || ''; } matches() { return true; } hasAttribute(name) { return Object.hasOwn(this.attrs, name); } }; globalThis.Element = HTMLAnchorElement; const click = anchor => { const e = { button: 0, composedPath: () => [anchor], preventDefault() { this.prevented = true; }, stopPropagation() {} }; onLinkClick(e); return e; }; const back = currentUrl(); navigate('/report/r1'); assert.ok(click(new HTMLAnchorElement(back, { 'data-back': '' })).prevented); assert.equal(currentUrl(), back); assert.equal(cursor, 0); assert.equal(click(new HTMLAnchorElement('/pool/o1', { target: '_blank' })).prevented, undefined); assert.equal(click(new HTMLAnchorElement('/report/r1', { download: '' })).prevented, undefined); }); test('历史达到浏览器上限、来源条目被裁剪时,关闭仍回指定页', () => { const origin = currentUrl(); navigate(opportunityUrl('o1')); window.navigation = { currentEntry: { index: 4 }, entries: () => [{ index: 3, url: hrefForTest('/members') }, { index: 4, url: location.href }], }; returnToRoute(origin, { canGoBack: true }); assert.equal(cursor, 1); assert.equal(currentUrl(), origin); }); function hrefForTest(path) { return resolveUrl(path).href; }