import { DeviceService, NARROW_PX } from './device.service'; /** * 端判定与路由归一单测 * * 依据 SPEC FR-01(端分流)、FR-16(旧版替换迁移)。 * 🔴 `normalize()` 是**旧路径重定向的唯一事实源**(AC-16.2), * 存量曾因 6 处重复实现导致重定向不一致,故必须有回归保护。 * * ⚠️ `DeviceService` 在**构造时**读取 UA/视口并一次性确定端(ADR-004: * 端判定不随窗口缩放变化),故测试必须 `new` 出独立实例, * 且环境需在构造**之前**就位。 */ describe('DeviceService', () => { const origUA = Object.getOwnPropertyDescriptor(navigator, 'userAgent'); const origMatch = window.matchMedia; const origWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth'); afterEach(() => { if (origUA) Object.defineProperty(navigator, 'userAgent', origUA); if (origWidth) Object.defineProperty(window, 'innerWidth', origWidth); window.matchMedia = origMatch; localStorage.clear(); }); beforeEach(() => localStorage.clear()); /** 在指定 UA / 视口下构造实例(环境在构造前生效,构造后即还原) */ function make( ua: string, opts?: { coarse?: boolean; width?: number; keepStorage?: boolean }, ): DeviceService { // 端偏好会覆盖自动判定 → 默认构造前清掉,避免上个用例的 force() 泄漏 if (!opts?.keepStorage) localStorage.clear(); Object.defineProperty(navigator, 'userAgent', { value: ua, configurable: true }); Object.defineProperty(window, 'innerWidth', { value: opts?.width ?? 1440, configurable: true }); window.matchMedia = ((q: string) => ({ matches: q.includes('coarse') ? !!opts?.coarse : false, media: q, addEventListener: () => undefined, removeEventListener: () => undefined, }) as unknown as MediaQueryList) as typeof window.matchMedia; const svc = new DeviceService(); // 还原环境,避免污染后续用例 if (origUA) Object.defineProperty(navigator, 'userAgent', origUA); if (origWidth) Object.defineProperty(window, 'innerWidth', origWidth); window.matchMedia = origMatch; return svc; } const DESKTOP_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'; const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)'; describe('normalize(旧路径 → 当前端路径,FR-16 唯一事实源)', () => { it('/messages 与 /tabs 按端落点(桌面进会话、移动进消息列表)', () => { expect(make(DESKTOP_UA).normalize('/messages')).toBe('/desktop/chat'); expect(make(DESKTOP_UA).normalize('/tabs')).toBe('/desktop/chat'); expect(make(IPHONE_UA, { width: 390 }).normalize('/messages')).toBe('/mobile/messages'); expect(make(IPHONE_UA, { width: 390 }).normalize('/tabs')).toBe('/mobile/messages'); }); it('/chat/:id 保留 id 并加端前缀', () => { expect(make(DESKTOP_UA).normalize('/chat/agent-node-xinting')).toBe( '/desktop/chat/agent-node-xinting', ); expect(make(IPHONE_UA, { width: 390 }).normalize('/chat/agent-node-xinting')).toBe( '/mobile/chat/agent-node-xinting', ); }); it('/chat 无 id 时落端会话页', () => { expect(make(DESKTOP_UA).normalize('/chat')).toBe('/desktop/chat'); expect(make(IPHONE_UA, { width: 390 }).normalize('/chat')).toBe('/mobile/chat'); }); it('/agent/:id 保留 id', () => { expect(make(DESKTOP_UA).normalize('/agent/a1')).toBe('/desktop/agent/a1'); }); it('/call/:agentId 落详情页(FR-11 裁决:语音暂不实现,禁建通话路由)', () => { expect(make(DESKTOP_UA).normalize('/call/agent-a')).toBe('/desktop/agent/agent-a'); expect(make(IPHONE_UA, { width: 390 }).normalize('/call/agent-a')).toBe( '/mobile/agent/agent-a', ); }); it('简单旧路径按端加前缀(含 tasks/home)', () => { const mob = make(IPHONE_UA, { width: 390 }); for (const p of [ 'workbench', 'deliverables', 'mine', 'contacts', 'projects', 'events', 'home', 'tasks', ]) { expect(mob.normalize(`/${p}`)).toBe(`/mobile/${p}`); } }); it('已带端前缀的路径原样返回(幂等)', () => { const svc = make(DESKTOP_UA); expect(svc.normalize('/desktop/chat')).toBe('/desktop/chat'); // 2026-11-03:移动端按 UI Demo 重建,会话入口路由为 /mobile/messages expect(svc.normalize('/mobile/messages')).toBe('/mobile/messages'); }); it('未知路径 / 空值回落到端默认页', () => { expect(make(DESKTOP_UA).normalize('/unknown-thing')).toBe('/desktop/chat'); expect(make(IPHONE_UA, { width: 390 }).normalize('/unknown-thing')).toBe('/mobile/messages'); expect(make(DESKTOP_UA).normalize('')).toBe('/desktop/chat'); }); it('查询串被保留(登录回跳等场景需要)', () => { expect(make(DESKTOP_UA).normalize('/chat/abc?x=1')).toBe('/desktop/chat/abc?x=1'); expect(make(IPHONE_UA, { width: 390 }).normalize('/messages?tab=unread')).toBe( '/mobile/messages?tab=unread', ); }); }); describe('detect(UA / 视口判定)', () => { it('iPhone UA → mobile', () => { expect(make(IPHONE_UA, { width: 390 }).mode()).toBe('mobile'); }); it('Android 手机 UA(含 Mobile)→ mobile', () => { expect( make('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Mobile').mode(), ).toBe('mobile'); }); it('Android 平板(无 Mobile 标记)→ desktop', () => { expect(make('Mozilla/5.0 (Linux; Android 14; SM-X700) AppleWebKit/537.36').mode()).toBe( 'desktop', ); }); it('iPad UA → desktop', () => { expect(make('Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)').mode()).toBe('desktop'); }); it('桌面 UA → desktop', () => { expect(make(DESKTOP_UA).mode()).toBe('desktop'); }); it('粗指针 + 窄视口 → mobile', () => { expect( make('Mozilla/5.0 (X11; Linux x86_64)', { coarse: true, width: NARROW_PX - 1 }).mode(), ).toBe('mobile'); }); it('粗指针 + 宽视口 → desktop', () => { expect( make('Mozilla/5.0 (X11; Linux x86_64)', { coarse: true, width: 1440 }).mode(), ).toBe('desktop'); }); }); describe('端切换与恢复自动识别(FR-10 AC-10.1)', () => { it('force 写偏好并覆盖自动判定;normalize 跟随', () => { const svc = make(DESKTOP_UA); expect(svc.mode()).toBe('desktop'); svc.force('mobile'); expect(svc.mode()).toBe('mobile'); expect(svc.normalize('/chat/a')).toBe('/mobile/chat/a'); }); /* * 🔴 2026-11-10 用户裁决:去掉手动切换,纯自动识别屏幕大小。 * 以下三条用例随之改写 —— 原断言的是「手动偏好持久化」,现已废弃。 */ it('resetToAuto 重新按屏幕判定(不再读偏好)', () => { const svc = make(DESKTOP_UA); /* * ⚠️ `make()` 构造完会还原 window.innerWidth,所以这里不能断言具体是哪一端, * 只能断言「回到了自动判定」—— 即 isForced 被清掉。 */ svc.force('mobile'); expect(svc.mode()).toBe('mobile'); expect(svc.isForced()).toBe(true); svc.resetToAuto(); expect(svc.isForced()).toBe(false); }); it('🔴 不再写入手动偏好键(自动识别模式)', () => { const svc = make(DESKTOP_UA); svc.force('mobile'); expect(localStorage.getItem('fmode_ui_mode_override')).toBeNull(); svc.resetToAuto(); expect(localStorage.getItem('fmode_ui_mode_override')).toBeNull(); }); it('🔴 旧的偏好键被忽略(屏幕尺寸说了算)', () => { localStorage.setItem('fmode_ui_mode_override', 'mobile'); const svc = make(DESKTOP_UA, { keepStorage: true }); // 宽屏 + 桌面 UA → 桌面端,不被旧偏好带偏 expect(svc.mode()).toBe('desktop'); expect(svc.isForced()).toBe(false); }); it('非法偏好值被安全忽略(回落自动判定)', () => { localStorage.setItem('fmode_ui_mode_override', 'garbage'); expect(make(DESKTOP_UA).mode()).toBe('desktop'); }); }); describe('entryPath(AC-17.1 两端默认落地页)', () => { it('桌面落 /desktop/chat、移动落 /mobile/messages(会话入口)', () => { expect(make(DESKTOP_UA).entryPath()).toBe('/desktop/chat'); expect(make(IPHONE_UA, { width: 390 }).entryPath()).toBe('/mobile/messages'); }); }); describe('path(逻辑路径 → 带端前缀)', () => { it('按当前端拼前缀', () => { expect(make(DESKTOP_UA).path('/chat/a')).toBe('/desktop/chat/a'); expect(make(IPHONE_UA, { width: 390 }).path('/chat/a')).toBe('/mobile/chat/a'); }); it('🔴 回归:不得重复拼端前缀(normalize 返回的已带前缀)', () => { // 曾出现 /desktop/desktop/chat/a —— normalize 对旧路径已加前缀, // path() 再拼一次导致双前缀 const svc = make(DESKTOP_UA); expect(svc.path('/chat/a')).not.toContain('/desktop/desktop'); expect(svc.path('/chat/a')).toBe('/desktop/chat/a'); expect(svc.path('/desktop/chat/a')).toBe('/desktop/chat/a'); expect(svc.path('/messages')).toBe('/desktop/chat'); expect(svc.path('/messages')).not.toContain('/desktop/desktop'); }); }); }); /** * 纯自动识别(2026-11-10 用户裁决,2026-11-11 合并确认) * * 「不需要手动切换,自动识别比例和屏幕大小」→ `detect()` **视口优先**: * 电脑上把窗口拖窄就是移动端,横屏手机宽度够仍算移动端。 * 跨端提示 / 手动兜底已随裁决移除,这里锁住新口径的边界。 */ describe('DeviceService · 纯自动识别(视口优先)', () => { const origUA = Object.getOwnPropertyDescriptor(navigator, 'userAgent'); const origWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth'); const origMatch = window.matchMedia; afterEach(() => { if (origUA) Object.defineProperty(navigator, 'userAgent', origUA); if (origWidth) Object.defineProperty(window, 'innerWidth', origWidth); window.matchMedia = origMatch; localStorage.clear(); }); function make(ua: string, width: number): DeviceService { localStorage.clear(); Object.defineProperty(navigator, 'userAgent', { value: ua, configurable: true }); Object.defineProperty(window, 'innerWidth', { value: width, configurable: true }); window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener: () => undefined, removeEventListener: () => undefined }) as unknown as MediaQueryList) as typeof window.matchMedia; return new DeviceService(); } const DESKTOP_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'; const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148'; it('🔴 桌面 UA + 窄窗口 → 移动端(UA 描述设备,不描述这个窗口)', () => { const svc = make(DESKTOP_UA, NARROW_PX - 1); expect(svc.detect()).toBe('mobile'); expect(svc.mode()).toBe('mobile'); expect(svc.entryPath()).toBe('/mobile/messages'); }); it('桌面 UA + 宽窗口 → 桌面端', () => { const svc = make(DESKTOP_UA, NARROW_PX); expect(svc.detect()).toBe('desktop'); }); it('🔴 手机 UA 横屏(宽度已够)→ 仍是移动端', () => { expect(make(IPHONE_UA, 844).detect()).toBe('mobile'); }); it('移动端把窗口拖宽 → 重新判定为桌面端', () => { const svc = make(DESKTOP_UA, NARROW_PX - 1); expect(svc.mode()).toBe('mobile'); Object.defineProperty(window, 'innerWidth', { value: 1440, configurable: true }); const r = svc.recheck(); expect(r.changed).toBe(true); expect(r.mode).toBe('desktop'); expect(svc.entryPath()).toBe('/desktop/chat'); }); it('🔴 不再读写手动偏好键', () => { const svc = make(DESKTOP_UA, 1440); svc.force('mobile'); expect(localStorage.getItem('fmode_ui_mode_override')).toBeNull(); svc.resetToAuto(); expect(localStorage.getItem('fmode_ui_mode_override')).toBeNull(); expect(svc.mode()).toBe('desktop'); }); });