device.service.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. import { DeviceService, NARROW_PX } from './device.service';
  2. /**
  3. * 端判定与路由归一单测
  4. *
  5. * 依据 SPEC FR-01(端分流)、FR-16(旧版替换迁移)。
  6. * 🔴 `normalize()` 是**旧路径重定向的唯一事实源**(AC-16.2),
  7. * 存量曾因 6 处重复实现导致重定向不一致,故必须有回归保护。
  8. *
  9. * ⚠️ `DeviceService` 在**构造时**读取 UA/视口并一次性确定端(ADR-004:
  10. * 端判定不随窗口缩放变化),故测试必须 `new` 出独立实例,
  11. * 且环境需在构造**之前**就位。
  12. */
  13. describe('DeviceService', () => {
  14. const origUA = Object.getOwnPropertyDescriptor(navigator, 'userAgent');
  15. const origMatch = window.matchMedia;
  16. const origWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
  17. afterEach(() => {
  18. if (origUA) Object.defineProperty(navigator, 'userAgent', origUA);
  19. if (origWidth) Object.defineProperty(window, 'innerWidth', origWidth);
  20. window.matchMedia = origMatch;
  21. localStorage.clear();
  22. });
  23. beforeEach(() => localStorage.clear());
  24. /** 在指定 UA / 视口下构造实例(环境在构造前生效,构造后即还原) */
  25. function make(
  26. ua: string,
  27. opts?: { coarse?: boolean; width?: number; keepStorage?: boolean },
  28. ): DeviceService {
  29. // 端偏好会覆盖自动判定 → 默认构造前清掉,避免上个用例的 force() 泄漏
  30. if (!opts?.keepStorage) localStorage.clear();
  31. Object.defineProperty(navigator, 'userAgent', { value: ua, configurable: true });
  32. Object.defineProperty(window, 'innerWidth', { value: opts?.width ?? 1440, configurable: true });
  33. window.matchMedia = ((q: string) =>
  34. ({
  35. matches: q.includes('coarse') ? !!opts?.coarse : false,
  36. media: q,
  37. addEventListener: () => undefined,
  38. removeEventListener: () => undefined,
  39. }) as unknown as MediaQueryList) as typeof window.matchMedia;
  40. const svc = new DeviceService();
  41. // 还原环境,避免污染后续用例
  42. if (origUA) Object.defineProperty(navigator, 'userAgent', origUA);
  43. if (origWidth) Object.defineProperty(window, 'innerWidth', origWidth);
  44. window.matchMedia = origMatch;
  45. return svc;
  46. }
  47. const DESKTOP_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
  48. const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)';
  49. describe('normalize(旧路径 → 当前端路径,FR-16 唯一事实源)', () => {
  50. it('/messages 与 /tabs 按端落点(桌面进会话、移动进消息列表)', () => {
  51. expect(make(DESKTOP_UA).normalize('/messages')).toBe('/desktop/chat');
  52. expect(make(DESKTOP_UA).normalize('/tabs')).toBe('/desktop/chat');
  53. expect(make(IPHONE_UA, { width: 390 }).normalize('/messages')).toBe('/mobile/messages');
  54. expect(make(IPHONE_UA, { width: 390 }).normalize('/tabs')).toBe('/mobile/messages');
  55. });
  56. it('/chat/:id 保留 id 并加端前缀', () => {
  57. expect(make(DESKTOP_UA).normalize('/chat/agent-node-xinting')).toBe(
  58. '/desktop/chat/agent-node-xinting',
  59. );
  60. expect(make(IPHONE_UA, { width: 390 }).normalize('/chat/agent-node-xinting')).toBe(
  61. '/mobile/chat/agent-node-xinting',
  62. );
  63. });
  64. it('/chat 无 id 时落端会话页', () => {
  65. expect(make(DESKTOP_UA).normalize('/chat')).toBe('/desktop/chat');
  66. expect(make(IPHONE_UA, { width: 390 }).normalize('/chat')).toBe('/mobile/chat');
  67. });
  68. it('/agent/:id 保留 id', () => {
  69. expect(make(DESKTOP_UA).normalize('/agent/a1')).toBe('/desktop/agent/a1');
  70. });
  71. it('/call/:agentId 落详情页(FR-11 裁决:语音暂不实现,禁建通话路由)', () => {
  72. expect(make(DESKTOP_UA).normalize('/call/agent-a')).toBe('/desktop/agent/agent-a');
  73. expect(make(IPHONE_UA, { width: 390 }).normalize('/call/agent-a')).toBe(
  74. '/mobile/agent/agent-a',
  75. );
  76. });
  77. it('简单旧路径按端加前缀(含 tasks/home)', () => {
  78. const mob = make(IPHONE_UA, { width: 390 });
  79. for (const p of [
  80. 'workbench',
  81. 'deliverables',
  82. 'mine',
  83. 'contacts',
  84. 'projects',
  85. 'events',
  86. 'home',
  87. 'tasks',
  88. ]) {
  89. expect(mob.normalize(`/${p}`)).toBe(`/mobile/${p}`);
  90. }
  91. });
  92. it('已带端前缀的路径原样返回(幂等)', () => {
  93. const svc = make(DESKTOP_UA);
  94. expect(svc.normalize('/desktop/chat')).toBe('/desktop/chat');
  95. // 2026-11-03:移动端按 UI Demo 重建,会话入口路由为 /mobile/messages
  96. expect(svc.normalize('/mobile/messages')).toBe('/mobile/messages');
  97. });
  98. it('未知路径 / 空值回落到端默认页', () => {
  99. expect(make(DESKTOP_UA).normalize('/unknown-thing')).toBe('/desktop/chat');
  100. expect(make(IPHONE_UA, { width: 390 }).normalize('/unknown-thing')).toBe('/mobile/messages');
  101. expect(make(DESKTOP_UA).normalize('')).toBe('/desktop/chat');
  102. });
  103. it('查询串被保留(登录回跳等场景需要)', () => {
  104. expect(make(DESKTOP_UA).normalize('/chat/abc?x=1')).toBe('/desktop/chat/abc?x=1');
  105. expect(make(IPHONE_UA, { width: 390 }).normalize('/messages?tab=unread')).toBe(
  106. '/mobile/messages?tab=unread',
  107. );
  108. });
  109. });
  110. describe('detect(UA / 视口判定)', () => {
  111. it('iPhone UA → mobile', () => {
  112. expect(make(IPHONE_UA, { width: 390 }).mode()).toBe('mobile');
  113. });
  114. it('Android 手机 UA(含 Mobile)→ mobile', () => {
  115. expect(
  116. make('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Mobile').mode(),
  117. ).toBe('mobile');
  118. });
  119. it('Android 平板(无 Mobile 标记)→ desktop', () => {
  120. expect(make('Mozilla/5.0 (Linux; Android 14; SM-X700) AppleWebKit/537.36').mode()).toBe(
  121. 'desktop',
  122. );
  123. });
  124. it('iPad UA → desktop', () => {
  125. expect(make('Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)').mode()).toBe('desktop');
  126. });
  127. it('桌面 UA → desktop', () => {
  128. expect(make(DESKTOP_UA).mode()).toBe('desktop');
  129. });
  130. it('粗指针 + 窄视口 → mobile', () => {
  131. expect(
  132. make('Mozilla/5.0 (X11; Linux x86_64)', { coarse: true, width: NARROW_PX - 1 }).mode(),
  133. ).toBe('mobile');
  134. });
  135. it('粗指针 + 宽视口 → desktop', () => {
  136. expect(
  137. make('Mozilla/5.0 (X11; Linux x86_64)', { coarse: true, width: 1440 }).mode(),
  138. ).toBe('desktop');
  139. });
  140. });
  141. describe('端切换与恢复自动识别(FR-10 AC-10.1)', () => {
  142. it('force 写偏好并覆盖自动判定;normalize 跟随', () => {
  143. const svc = make(DESKTOP_UA);
  144. expect(svc.mode()).toBe('desktop');
  145. svc.force('mobile');
  146. expect(svc.mode()).toBe('mobile');
  147. expect(svc.normalize('/chat/a')).toBe('/mobile/chat/a');
  148. });
  149. /*
  150. * 🔴 2026-11-10 用户裁决:去掉手动切换,纯自动识别屏幕大小。
  151. * 以下三条用例随之改写 —— 原断言的是「手动偏好持久化」,现已废弃。
  152. */
  153. it('resetToAuto 重新按屏幕判定(不再读偏好)', () => {
  154. const svc = make(DESKTOP_UA);
  155. /*
  156. * ⚠️ `make()` 构造完会还原 window.innerWidth,所以这里不能断言具体是哪一端,
  157. * 只能断言「回到了自动判定」—— 即 isForced 被清掉。
  158. */
  159. svc.force('mobile');
  160. expect(svc.mode()).toBe('mobile');
  161. expect(svc.isForced()).toBe(true);
  162. svc.resetToAuto();
  163. expect(svc.isForced()).toBe(false);
  164. });
  165. it('🔴 不再写入手动偏好键(自动识别模式)', () => {
  166. const svc = make(DESKTOP_UA);
  167. svc.force('mobile');
  168. expect(localStorage.getItem('fmode_ui_mode_override')).toBeNull();
  169. svc.resetToAuto();
  170. expect(localStorage.getItem('fmode_ui_mode_override')).toBeNull();
  171. });
  172. it('🔴 旧的偏好键被忽略(屏幕尺寸说了算)', () => {
  173. localStorage.setItem('fmode_ui_mode_override', 'mobile');
  174. const svc = make(DESKTOP_UA, { keepStorage: true });
  175. // 宽屏 + 桌面 UA → 桌面端,不被旧偏好带偏
  176. expect(svc.mode()).toBe('desktop');
  177. expect(svc.isForced()).toBe(false);
  178. });
  179. it('非法偏好值被安全忽略(回落自动判定)', () => {
  180. localStorage.setItem('fmode_ui_mode_override', 'garbage');
  181. expect(make(DESKTOP_UA).mode()).toBe('desktop');
  182. });
  183. });
  184. describe('entryPath(AC-17.1 两端默认落地页)', () => {
  185. it('桌面落 /desktop/chat、移动落 /mobile/messages(会话入口)', () => {
  186. expect(make(DESKTOP_UA).entryPath()).toBe('/desktop/chat');
  187. expect(make(IPHONE_UA, { width: 390 }).entryPath()).toBe('/mobile/messages');
  188. });
  189. });
  190. describe('path(逻辑路径 → 带端前缀)', () => {
  191. it('按当前端拼前缀', () => {
  192. expect(make(DESKTOP_UA).path('/chat/a')).toBe('/desktop/chat/a');
  193. expect(make(IPHONE_UA, { width: 390 }).path('/chat/a')).toBe('/mobile/chat/a');
  194. });
  195. it('🔴 回归:不得重复拼端前缀(normalize 返回的已带前缀)', () => {
  196. // 曾出现 /desktop/desktop/chat/a —— normalize 对旧路径已加前缀,
  197. // path() 再拼一次导致双前缀
  198. const svc = make(DESKTOP_UA);
  199. expect(svc.path('/chat/a')).not.toContain('/desktop/desktop');
  200. expect(svc.path('/chat/a')).toBe('/desktop/chat/a');
  201. expect(svc.path('/desktop/chat/a')).toBe('/desktop/chat/a');
  202. expect(svc.path('/messages')).toBe('/desktop/chat');
  203. expect(svc.path('/messages')).not.toContain('/desktop/desktop');
  204. });
  205. });
  206. });
  207. /**
  208. * 纯自动识别(2026-11-10 用户裁决,2026-11-11 合并确认)
  209. *
  210. * 「不需要手动切换,自动识别比例和屏幕大小」→ `detect()` **视口优先**:
  211. * 电脑上把窗口拖窄就是移动端,横屏手机宽度够仍算移动端。
  212. * 跨端提示 / 手动兜底已随裁决移除,这里锁住新口径的边界。
  213. */
  214. describe('DeviceService · 纯自动识别(视口优先)', () => {
  215. const origUA = Object.getOwnPropertyDescriptor(navigator, 'userAgent');
  216. const origWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
  217. const origMatch = window.matchMedia;
  218. afterEach(() => {
  219. if (origUA) Object.defineProperty(navigator, 'userAgent', origUA);
  220. if (origWidth) Object.defineProperty(window, 'innerWidth', origWidth);
  221. window.matchMedia = origMatch;
  222. localStorage.clear();
  223. });
  224. function make(ua: string, width: number): DeviceService {
  225. localStorage.clear();
  226. Object.defineProperty(navigator, 'userAgent', { value: ua, configurable: true });
  227. Object.defineProperty(window, 'innerWidth', { value: width, configurable: true });
  228. window.matchMedia = ((q: string) =>
  229. ({ matches: false, media: q, addEventListener: () => undefined, removeEventListener: () => undefined }) as unknown as MediaQueryList) as typeof window.matchMedia;
  230. return new DeviceService();
  231. }
  232. const DESKTOP_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
  233. const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148';
  234. it('🔴 桌面 UA + 窄窗口 → 移动端(UA 描述设备,不描述这个窗口)', () => {
  235. const svc = make(DESKTOP_UA, NARROW_PX - 1);
  236. expect(svc.detect()).toBe('mobile');
  237. expect(svc.mode()).toBe('mobile');
  238. expect(svc.entryPath()).toBe('/mobile/messages');
  239. });
  240. it('桌面 UA + 宽窗口 → 桌面端', () => {
  241. const svc = make(DESKTOP_UA, NARROW_PX);
  242. expect(svc.detect()).toBe('desktop');
  243. });
  244. it('🔴 手机 UA 横屏(宽度已够)→ 仍是移动端', () => {
  245. expect(make(IPHONE_UA, 844).detect()).toBe('mobile');
  246. });
  247. it('移动端把窗口拖宽 → 重新判定为桌面端', () => {
  248. const svc = make(DESKTOP_UA, NARROW_PX - 1);
  249. expect(svc.mode()).toBe('mobile');
  250. Object.defineProperty(window, 'innerWidth', { value: 1440, configurable: true });
  251. const r = svc.recheck();
  252. expect(r.changed).toBe(true);
  253. expect(r.mode).toBe('desktop');
  254. expect(svc.entryPath()).toBe('/desktop/chat');
  255. });
  256. it('🔴 不再读写手动偏好键', () => {
  257. const svc = make(DESKTOP_UA, 1440);
  258. svc.force('mobile');
  259. expect(localStorage.getItem('fmode_ui_mode_override')).toBeNull();
  260. svc.resetToAuto();
  261. expect(localStorage.getItem('fmode_ui_mode_override')).toBeNull();
  262. expect(svc.mode()).toBe('desktop');
  263. });
  264. });