| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304 |
- 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');
- });
- });
|