router.test.js 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. import { test, beforeEach } from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { match, resolveUrl, navigate, subscribe, currentUrl, opportunityUrl, reportUrl, detailUrl, safeReturnTo, returnToRoute, setQuery, onLinkClick } from '../src/utils/router.js';
  4. let base, entries, cursor;
  5. test('详情的来源和证据链接保留筛选,替换上一种详情,并兼容旧链接', () => {
  6. const source = resolveUrl(detailUrl('source', '原文 1', '/reports/voc?ev=高&stage=S3&artifact=a1&page=2'));
  7. assert.equal(source.searchParams.get('source'), '原文 1');
  8. assert.equal(source.searchParams.get('ev'), '高');
  9. assert.equal(source.searchParams.get('artifact'), null);
  10. assert.equal(source.searchParams.get('page'), '2');
  11. const evidence = resolveUrl(detailUrl('evidence', 'ev:1', '/reports/voc?ev=高&source=s1', 'report1'));
  12. assert.equal(evidence.searchParams.get('ev'), '高');
  13. assert.equal(evidence.searchParams.get('evidence'), 'ev:1');
  14. assert.equal(evidence.searchParams.get('evidenceReport'), 'report1');
  15. assert.equal(evidence.searchParams.get('source'), null);
  16. const legacy = resolveUrl(detailUrl('', '', '/pool/o1?ev=old&report=r1&returnTo=%2Fmembers'));
  17. assert.equal(legacy.searchParams.get('ev'), null);
  18. assert.equal(legacy.searchParams.get('report'), null);
  19. assert.equal(legacy.searchParams.get('returnTo'), '/members');
  20. });
  21. beforeEach(() => {
  22. base = '/preview/';
  23. entries = [{ url: new URL('https://board.test/preview/pool?stage=S3&sort=latest'), state: null }];
  24. cursor = 0;
  25. globalThis.location = entries[0].url;
  26. globalThis.document = { querySelector: () => ({ getAttribute: () => base }) };
  27. globalThis.window = new EventTarget();
  28. window.scrollY = 350;
  29. globalThis.PopStateEvent = class extends Event {
  30. constructor(type, options) { super(type); this.state = options.state; }
  31. };
  32. globalThis.history = {
  33. get state() { return entries[cursor].state; },
  34. replaceState(state, _, url) {
  35. entries[cursor] = { state, url: url ? new URL(url, location.href) : location };
  36. globalThis.location = entries[cursor].url;
  37. },
  38. pushState(state, _, url) {
  39. entries.splice(++cursor, entries.length, { state, url: new URL(url, location.href) });
  40. globalThis.location = entries[cursor].url;
  41. },
  42. back() {
  43. if (!cursor) return;
  44. globalThis.location = entries[--cursor].url;
  45. window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }));
  46. },
  47. };
  48. });
  49. test('机会卡深链接保留筛选、排序和关闭返回地址;报告携带回卡片地址', () => {
  50. const opp = opportunityUrl('机会 1');
  51. const url = resolveUrl(opp);
  52. assert.equal(url.pathname, '/preview/pool/%E6%9C%BA%E4%BC%9A%201');
  53. assert.equal(url.searchParams.get('stage'), 'S3');
  54. assert.equal(url.searchParams.get('sort'), 'latest');
  55. assert.equal(url.searchParams.get('returnTo'), '/pool?stage=S3&sort=latest');
  56. const report = resolveUrl(reportUrl('r 1', opp));
  57. assert.equal(report.searchParams.get('returnTo'), opp);
  58. assert.equal(match(url.pathname.slice('/preview'.length)).params.id, '机会 1');
  59. });
  60. test('来自其他页面的机会卡返回原页,不将来源页参数误用为卡池筛选', () => {
  61. const url = resolveUrl(opportunityUrl('o1', '/members?member=小智'));
  62. assert.equal(url.searchParams.get('member'), null);
  63. assert.equal(url.searchParams.get('returnTo'), '/members?member=小智');
  64. });
  65. test('推入历史保存离开页的位置,换卡 replace 保留返回凭据且不多压一层', () => {
  66. const back = currentUrl();
  67. navigate(opportunityUrl('o1'), { state: { kfOverlay: { returnTo: back } } });
  68. assert.equal(entries[0].state.kfScrollY, 350);
  69. assert.equal(history.state.kfFrom, back);
  70. navigate(opportunityUrl('o2', back), { replace: true });
  71. assert.equal(entries.length, 2);
  72. assert.equal(history.state.kfOverlay.returnTo, back);
  73. assert.equal(match().params.id, 'o2');
  74. history.back();
  75. assert.equal(currentUrl(), back);
  76. });
  77. test('订阅分别识别站内导航与浏览器返回,返回读取当前实际路径和位置', () => {
  78. const changes = [];
  79. const off = subscribe(value => changes.push(value));
  80. navigate('/report/r1?ch=limits');
  81. history.back();
  82. off();
  83. assert.deepEqual(changes.map(c => c.navigation), ['initial', 'push', 'traverse']);
  84. assert.equal(changes.at(-1).name, 'pool');
  85. assert.equal(changes.at(-1).historyState.kfScrollY, 350);
  86. });
  87. test('章节和浮层查询串更新不丢返回地址和历史状态', () => {
  88. navigate(reportUrl('r1', '/pool/o1?stage=S3'));
  89. setQuery({ ch: 'limits', ev: 'e1', report: 'r1' });
  90. setQuery({ ev: null, report: null });
  91. const q = new URLSearchParams(location.search);
  92. assert.equal(q.get('returnTo'), '/pool/o1?stage=S3');
  93. assert.equal(q.get('ch'), 'limits');
  94. assert.equal(q.get('ev'), null);
  95. assert.equal(history.state.kfFrom, '/pool?stage=S3&sort=latest');
  96. });
  97. test('非法或站外返回地址落到应用内兜底,损坏编码匹配为不存在', () => {
  98. for (const path of ['https://evil.test/', '//evil.test/', '/\\evil.test/', '/missing', '/pool\n']) {
  99. assert.equal(safeReturnTo(path, '/reports'), '/reports');
  100. }
  101. assert.equal(match('/pool/%E0%A4%A').name, 'notfound');
  102. assert.equal(match('/reports/competitor/r1').name, 'report');
  103. });
  104. test('根部署与子路径导航均保留查询和章节,不重复压入相同 URL', () => {
  105. base = '/';
  106. assert.equal(resolveUrl('/report/r1?ch=2#top').href, 'https://board.test/report/r1?ch=2#top');
  107. base = '/preview/';
  108. navigate('/report/r1?ch=2#top');
  109. navigate('/report/r1?ch=2#top');
  110. assert.equal(entries.length, 2);
  111. assert.equal(location.pathname, '/preview/report/r1');
  112. assert.equal(currentUrl(), '/report/r1?ch=2#top');
  113. });
  114. test('报告返回链接使用浏览器历史;新标签与下载链接保留浏览器行为', () => {
  115. globalThis.HTMLAnchorElement = class {
  116. constructor(path, attrs = {}) { this.href = resolveUrl(path).href; this.attrs = attrs; this.target = attrs.target || ''; }
  117. matches() { return true; }
  118. hasAttribute(name) { return Object.hasOwn(this.attrs, name); }
  119. };
  120. globalThis.Element = HTMLAnchorElement;
  121. const click = anchor => {
  122. const e = { button: 0, composedPath: () => [anchor], preventDefault() { this.prevented = true; }, stopPropagation() {} };
  123. onLinkClick(e);
  124. return e;
  125. };
  126. const back = currentUrl();
  127. navigate('/report/r1');
  128. assert.ok(click(new HTMLAnchorElement(back, { 'data-back': '' })).prevented);
  129. assert.equal(currentUrl(), back);
  130. assert.equal(cursor, 0);
  131. assert.equal(click(new HTMLAnchorElement('/pool/o1', { target: '_blank' })).prevented, undefined);
  132. assert.equal(click(new HTMLAnchorElement('/report/r1', { download: '' })).prevented, undefined);
  133. });
  134. test('历史达到浏览器上限、来源条目被裁剪时,关闭仍回指定页', () => {
  135. const origin = currentUrl();
  136. navigate(opportunityUrl('o1'));
  137. window.navigation = {
  138. currentEntry: { index: 4 },
  139. entries: () => [{ index: 3, url: hrefForTest('/members') }, { index: 4, url: location.href }],
  140. };
  141. returnToRoute(origin, { canGoBack: true });
  142. assert.equal(cursor, 1);
  143. assert.equal(currentUrl(), origin);
  144. });
  145. function hrefForTest(path) { return resolveUrl(path).href; }