# 小程序 Phase 1 实施计划:测试覆盖 + UX 合规 > 日期: 2026-05-21 | 分支: feat/media-library-banner | 预计工时: 13d | 负责人: Dev Agent ## 目录 - [1. 文件结构总览](#1-文件结构总览) - [2. Task T1-1: secure-storage 单元测试](#2-task-t1-1-secure-storage-单元测试) - [3. Task T1-2: request.ts 核心路径测试](#3-task-t1-2-requestts-核心路径测试) - [4. Task T1-3: auth store 测试](#4-task-t1-3-auth-store-测试) - [5. Task T1-4: DataSyncScheduler + BLEManager 测试](#5-task-t1-4-datasyncscheduler--blemanager-测试) - [6. Task U1-1: 核心 ARIA 角色标注](#6-task-u1-1-核心-aria-角色标注) - [7. Task U1-2: 表单可访问性增强](#7-task-u1-2-表单可访问性增强) - [8. Task U1-3: 动态内容 aria-live](#8-task-u1-3-动态内容-aria-live) - [9. Task U1-4: 焦点管理基础](#9-task-u1-4-焦点管理基础) - [10. Task S1-1: handler 层 consent 状态检查](#10-task-s1-1-handler-层-consent-状态检查) - [11. 验收标准](#11-验收标准) --- ## 1. 文件结构总览 ### 新建文件 | 文件路径 | 类型 | Task | |----------|------|------| | `apps/miniprogram/__tests__/utils/secure-storage.test.ts` | 测试 | T1-1 | | `apps/miniprogram/__tests__/stores/auth.test.ts` | 测试 | T1-3 | | `apps/miniprogram/src/styles/_focus-ring.scss` | 样式 | U1-4 | | `docs/superpowers/plans/2026-05-21-miniprogram-phase1-plan.md` | 文档 | -- | ### 修改文件 | 文件路径 | 改动摘要 | Task | |----------|----------|------| | `apps/miniprogram/__tests__/services/request.test.ts` | 扩展:ConcurrencyLimiter / 401 重试 / ResponseCache / AbortSignal | T1-2 | | `apps/miniprogram/__tests__/services/ble/DataSyncScheduler.test.ts` | 扩展:并发互斥 / startPeriodicCheck 完整覆盖 | T1-4 | | `apps/miniprogram/src/components/SegmentTabs/index.tsx` | 添加 `role="tablist"` / `role="tab"` / `aria-selected` | U1-1 | | `apps/miniprogram/src/components/ui/DoctorTabBar/index.tsx` | 添加 `role="tablist"` / `role="tab"` / `aria-selected` | U1-1 | | `apps/miniprogram/src/components/ui/PrimaryButton/index.tsx` | 添加 `role="button"` / `aria-disabled` / `aria-busy` | U1-1 | | `apps/miniprogram/src/components/ui/SecondaryButton/index.tsx` | 添加 `role="button"` / `aria-disabled` | U1-1 | | `apps/miniprogram/src/components/Loading/index.tsx` | 添加 `role="status"` / `aria-live="polite"` | U1-1 | | `apps/miniprogram/src/components/ui/LoadingCard/index.tsx` | 添加 `role="status"` / `aria-label` | U1-1 | | `apps/miniprogram/src/components/EmptyState/index.tsx` | 添加 `role="status"` / `aria-live="polite"` | U1-3 | | `apps/miniprogram/src/components/ErrorState/index.tsx` | 添加 `role="alert"` / `aria-live="assertive"` | U1-3 | | `apps/miniprogram/src/components/TrendChart/index.tsx` | tooltip 添加 `role="tooltip"` / `aria-live="polite"` | U1-3 | | `apps/miniprogram/src/components/ui/FormInput/index.tsx` | 添加 `aria-label` / `aria-describedby` / `aria-invalid` | U1-2 | | `apps/miniprogram/src/components/ui/FormInput/index.scss` | 添加 `:focus-within` 焦点环样式 | U1-2+U1-4 | | `apps/miniprogram/src/components/ui/PrimaryButton/index.scss` | 添加 focus 焦点环样式 | U1-4 | | `apps/miniprogram/src/components/ui/SecondaryButton/index.scss` | 添加 focus 焦点环样式 | U1-4 | | `apps/miniprogram/src/components/SegmentTabs/index.scss` | 添加 `:focus` 焦点环样式 | U1-4 | | `apps/miniprogram/src/components/ui/DoctorTabBar/index.scss` | 添加 `:focus` 焦点环样式 | U1-4 | | `apps/miniprogram/src/styles/variables.scss` | 新增 `$focus-ring` 变量 | U1-4 | --- ## 2. Task T1-1: secure-storage 单元测试 > 工时: 2d | 文件: `apps/miniprogram/__tests__/utils/secure-storage.test.ts` ### 前置知识 源文件 `apps/miniprogram/src/utils/secure-storage.ts` 当前使用 XOR 编码(Phase 0 将替换为 AES-256-GCM)。本测试需同时覆盖: - Phase 0 完成后的 AES-GCM 加解密对称性 - 当前 XOR 路径的兼容性(明文 fallback / 迁移逻辑) 测试应在 Phase 0 S0-1 完成后编写,以 AES-GCM 为主要路径。若 Phase 0 未完成,先测 XOR 路径,Phase 0 完成后补充 AES 测试用例。 ### RED: 编写失败测试 **文件:** `apps/miniprogram/__tests__/utils/secure-storage.test.ts` ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock Taro Storage API const storage = new Map(); vi.mock('@tarojs/taro', () => ({ default: { setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }), getStorageSync: vi.fn((key: string) => storage.get(key) || ''), removeStorageSync: vi.fn((key: string) => { storage.delete(key); }), arrayBufferToBase64: vi.fn((buf: ArrayBuffer) => { const bytes = new Uint8Array(buf); let binary = ''; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); }), base64ToArrayBuffer: vi.fn((b64: string) => { const binary = atob(b64); const buf = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { buf[i] = binary.charCodeAt(i); } return buf.buffer; }), }, })); import { secureSet, secureGet, secureRemove, migrateLegacyStorage } from '@/utils/secure-storage'; describe('secure-storage', () => { beforeEach(() => { storage.clear(); vi.clearAllMocks(); }); describe('secureSet + secureGet 对称性', () => { it('应正确加解密普通英文字符串', () => { secureSet('token', 'hello-world-123'); expect(secureGet('token')).toBe('hello-world-123'); }); it('应正确加解密中文', () => { secureSet('name', '张三'); expect(secureGet('name')).toBe('张三'); }); it('应正确加解密 emoji', () => { secureSet('mood', '🏥💊❤️'); expect(secureGet('mood')).toBe('🏥💊❤️'); }); it('应正确加解密空格和特殊字符', () => { secureSet('data', ' a+b=c&d?e '); expect(secureGet('data')).toBe(' a+b=c&d?e '); }); it('应正确加解密 JSON 字符串', () => { const json = JSON.stringify({ id: 'abc-123', roles: ['doctor', 'admin'] }); secureSet('user_data', json); expect(secureGet('user_data')).toBe(json); }); it('应正确处理超长字符串(5000 字符)', () => { const long = 'A'.repeat(5000); secureSet('long', long); expect(secureGet('long')).toBe(long); }); }); describe('空 value 触发 remove', () => { it('secureSet 空字符串应删除 key', () => { secureSet('token', 'some-value'); secureSet('token', ''); expect(secureGet('token')).toBe(''); }); it('secureSet 空字符串不应在 storage 中留下数据', () => { secureSet('token', 'value'); secureSet('token', ''); // _es_ 前缀的 key 应被 removeStorageSync 移除 const { default: Taro } = require('@tarojs/taro'); expect(Taro.removeStorageSync).toHaveBeenCalledWith('_es_token'); }); }); describe('明文 fallback 读取兼容性', () => { it('应能读取无前缀的明文值', () => { // 模拟 MCP 注入的明文 token const { default: Taro } = require('@tarojs/taro'); vi.mocked(Taro.getStorageSync).mockImplementation((key: string) => { if (key === '_es_access_token') return ''; // 无加密存储 if (key === 'access_token') return 'plain-text-token'; // 明文 return ''; }); expect(secureGet('access_token')).toBe('plain-text-token'); }); it('加密值优先于明文值', () => { secureSet('token', 'encrypted-value'); const { default: Taro } = require('@tarojs/taro'); vi.mocked(Taro.getStorageSync).mockImplementation((key: string) => { if (key === '_es_token') return storage.get('_es_token') || ''; if (key === 'token') return 'plain-value'; return ''; }); expect(secureGet('token')).toBe('encrypted-value'); }); }); describe('migrateLegacyStorage 迁移逻辑', () => { it('应将明文数据迁移到加密存储', () => { const { default: Taro } = require('@tarojs/taro'); // 模拟明文遗留数据 const legacyData: Record = { 'access_token': 'legacy-token', 'refresh_token': 'legacy-refresh', 'user_data': '{"id":"u1"}', }; vi.mocked(Taro.getStorageSync).mockImplementation((key: string) => { if (key.startsWith('_es_')) return ''; // 无加密存储 return legacyData[key] || ''; }); migrateLegacyStorage(); // 应调用 removeStorageSync 清理明文 key expect(Taro.removeStorageSync).toHaveBeenCalledWith('access_token'); expect(Taro.removeStorageSync).toHaveBeenCalledWith('refresh_token'); expect(Taro.removeStorageSync).toHaveBeenCalledWith('user_data'); }); it('已迁移的 key 不应重复迁移', () => { const { default: Taro } = require('@tarojs/taro'); // 已存在加密存储 vi.mocked(Taro.getStorageSync).mockImplementation((key: string) => { if (key === '_es_access_token') return 'already-encrypted'; if (key === 'access_token') return 'legacy-token'; return ''; }); migrateLegacyStorage(); // access_token 已有加密版本,不应再 remove 明文 expect(Taro.removeStorageSync).not.toHaveBeenCalledWith('access_token'); }); it('MIGRATION_KEYS 以外的 key 不受影响', () => { const { default: Taro } = require('@tarojs/taro'); const legacyData = { 'unknown_key': 'value' }; vi.mocked(Taro.getStorageSync).mockImplementation((key: string) => { if (key.startsWith('_es_')) return ''; return legacyData[key] || ''; }); migrateLegacyStorage(); expect(Taro.removeStorageSync).not.toHaveBeenCalledWith('unknown_key'); }); }); describe('secureRemove', () => { it('应移除加密存储的 key', () => { secureSet('token', 'value'); secureRemove('token'); expect(secureGet('token')).toBe(''); }); it('移除不存在的 key 不报错', () => { expect(() => secureRemove('nonexistent')).not.toThrow(); }); }); describe('Base64 边界', () => { it('空字符串输入应正确处理', () => { secureSet('empty', ''); expect(secureGet('empty')).toBe(''); }); it('单字符输入应正确处理', () => { secureSet('single', 'a'); expect(secureGet('single')).toBe('a'); }); it('null byte 字符串应正确处理', () => { const withNull = 'before\0after'; secureSet('null', withNull); expect(secureGet('null')).toBe(withNull); }); }); }); ``` ### GREEN: 运行测试确认通过 ```bash cd apps/miniprogram && npx vitest run __tests__/utils/secure-storage.test.ts ``` 预期:AES-GCM 路径(Phase 0 后)或 XOR 路径全部通过。若 Phase 0 未完成,注释掉 AES 相关用例,标记 `@skip("待 Phase 0 S0-1 完成")`。 ### COMMIT ```bash git add apps/miniprogram/__tests__/utils/secure-storage.test.ts git commit -m "test(mp): secure-storage 单元测试 — 加解密对称性/空值/明文fallback/迁移" ``` --- ## 3. Task T1-2: request.ts 核心路径测试 > 工时: 2d | 文件: `apps/miniprogram/__tests__/services/request.test.ts`(扩展现有) ### 前置知识 现有测试覆盖:`api.get`/`api.post`/`api.put`/`api.delete` 基本行为 + 错误处理(403/500/网络错误/API failure)。 需新增覆盖: 1. `ConcurrencyLimiter` — 并发上限 8,队列 FIFO 2. 401 重试 + `tryRefreshToken` 去重(多个并发 401 只刷新一次) 3. `ResponseCache` — 命中/淘汰/inflight 去重/patientId 隔离 4. `safeReLaunch` — 去重(两次调用只执行一次) 5. `AbortSignal` 取消 ### RED: 扩展失败测试 **文件:** `apps/miniprogram/__tests__/services/request.test.ts` 在现有文件末尾追加以下测试套件: ```typescript // ... 现有测试保持不变 ... describe('ConcurrencyLimiter', () => { it('应限制并发为 8', async () => { const callOrder: number[] = []; let resolveCount = 0; const resolvers: Array<() => void> = []; vi.mocked(Taro.request).mockImplementation(() => { const idx = callOrder.length; callOrder.push(idx); return new Promise((resolve) => { resolvers.push(() => { resolveCount++; resolve({ statusCode: 200, data: { success: true, data: idx } } as any); }); }); }); // 发起 10 个并发请求 const promises = []; for (let i = 0; i < 10; i++) { promises.push(api.get(`/concurrent/${i}`)); } // 只有 8 个应该开始(前 8 个 request 调用) expect(resolvers.length).toBe(8); // 逐个释放 resolvers.slice(0, 2).forEach(r => r()); await Promise.all(promises.slice(0, 2)); // 释放后第 9、10 个应开始 // 最终全部完成 resolvers.forEach(r => r()); await Promise.all(promises); expect(resolveCount).toBe(10); }); }); describe('401 重试 + token 刷新去重', () => { it('401 后应尝试刷新 token 并重试', async () => { let callCount = 0; vi.mocked(Taro.request).mockImplementation((opts: any) => { callCount++; // 第一次请求返回 401 if (opts.url.includes('/health/test')) { if (callCount === 1) { return Promise.resolve({ statusCode: 401, data: {} } as any); } return Promise.resolve({ statusCode: 200, data: { success: true, data: 'ok' } } as any); } // refresh token 请求 if (opts.url.includes('/auth/refresh')) { return Promise.resolve({ statusCode: 200, data: { success: true, data: { access_token: 'new-access', refresh_token: 'new-refresh', expires_in: 3600, }, }, } as any); } return Promise.resolve({ statusCode: 200, data: { success: true, data: null } } as any); }); // mock 有 access_token 和 refresh_token vi.mocked(secureGet).mockImplementation((key: string) => { if (key === 'access_token') return 'old-token'; if (key === 'refresh_token') return 'valid-refresh'; return ''; }); const result = await api.get('/health/test'); expect(result).toBe('ok'); expect(callCount).toBeGreaterThanOrEqual(2); // 至少 1 次 401 + 1 次重试 }); it('刷新失败应跳转到登录页', async () => { vi.mocked(Taro.request).mockImplementation((opts: any) => { if (opts.url.includes('/auth/refresh')) { return Promise.resolve({ statusCode: 401, data: {} } as any); } return Promise.resolve({ statusCode: 401, data: {} } as any); }); vi.mocked(secureGet).mockImplementation((key: string) => { if (key === 'access_token') return 'old-token'; if (key === 'refresh_token') return 'expired-refresh'; return ''; }); await expect(api.get('/health/protected')).rejects.toThrow('登录已过期'); }); }); describe('ResponseCache', () => { it('相同 URL 应命中缓存', async () => { vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { id: 'cached' } }, } as any); const r1 = await api.get('/cache-test'); const r2 = await api.get('/cache-test'); expect(Taro.request).toHaveBeenCalledTimes(1); expect(r1).toEqual(r2); }); it('不同 URL 不命中缓存', async () => { vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: {} }, } as any); await api.get('/url-a'); await api.get('/url-b'); expect(Taro.request).toHaveBeenCalledTimes(2); }); it('并发相同 URL 应去重(inflight)', async () => { let resolveReq: (v: any) => void; vi.mocked(Taro.request).mockImplementation(() => { return new Promise((resolve) => { resolveReq = resolve; }); }); const p1 = api.get('/dedup'); const p2 = api.get('/dedup'); // 只有一次 request 调用 expect(Taro.request).toHaveBeenCalledTimes(1); resolveReq!({ statusCode: 200, data: { success: true, data: 'shared' } }); const [r1, r2] = await Promise.all([p1, p2]); expect(r1).toEqual(r2); }); }); describe('safeReLaunch', () => { it('连续调用应去重', async () => { vi.mocked(Taro.request).mockResolvedValue({ statusCode: 401, data: {} } as any); vi.mocked(secureGet).mockImplementation((key: string) => { if (key === 'access_token') return 'old-token'; if (key === 'refresh_token') return 'expired'; return ''; }); // 刷新失败,触发 safeReLaunch vi.mocked(Taro.request).mockImplementation((opts: any) => { if (opts.url.includes('/auth/refresh')) { return Promise.resolve({ statusCode: 401, data: {} } as any); } return Promise.resolve({ statusCode: 401, data: {} } as any); }); vi.mocked(Taro.getCurrentPages).mockReturnValue([{ path: 'pages/health/index' }]); // 发起两个并发 401 请求 await Promise.allSettled([ api.get('/health/a'), api.get('/health/b'), ]); // reLaunch 应只调用一次(去重) expect(Taro.reLaunch).toHaveBeenCalledTimes(1); }); }); describe('AbortSignal', () => { it('请求前取消应抛出 "请求已取消"', async () => { const controller = new AbortController(); controller.abort(); await expect(api.get('/health/abort-test', undefined, undefined, controller.signal)) .rejects.toThrow('请求已取消'); }); }); ``` ### GREEN: 运行测试 ```bash cd apps/miniprogram && npx vitest run __tests__/services/request.test.ts ``` 注意:部分 401 重试测试可能因 `resetForTesting()` 在 `beforeEach` 中未完全重置 token 刷新状态而失败。若失败,在 `resetForTesting()` 中补充 `isLoggingOut = false` 重置(当前已包含)。 ### COMMIT ```bash git add apps/miniprogram/__tests__/services/request.test.ts git commit -m "test(mp): request.ts 扩展测试 — ConcurrencyLimiter/401重试/ResponseCache/AbortSignal" ``` --- ## 4. Task T1-3: auth store 测试 > 工时: 2d | 文件: `apps/miniprogram/__tests__/stores/auth.test.ts` ### 前置知识 auth store (`stores/auth.ts`) 是 Zustand store,依赖: - `@/services/auth` — API 调用(需 mock) - `@/utils/secure-storage` — 加密存储(需 mock) - `@/services/request` — `clearRequestCache`/`markLoggingOut`/`clearLoggingOut`/`setCachedPatientId`(需 mock) - `@tarojs/taro` — `reLaunch`/`getStorageSync`/`removeStorageSync`/`getStorageInfoSync`(已在 setup.ts mock) 关键测试点: - `restore()` — 从 storage 恢复用户/角色/患者状态 - `login(code)` — 微信登录流程(已绑定 vs 未绑定) - `credentialLogin(username, password)` — 账号密码登录 - `logout()` — 清理完整性(所有 secureRemove + Taro.removeStorageSync + reLaunch) - `bindPhone(encryptedData, iv)` — 手机绑定 - 角色判断 — `isMedicalStaff`/`isDoctor`/`isNurse`/`isHealthManager`/`hasRole` ### RED: 编写失败测试 **文件:** `apps/miniprogram/__tests__/stores/auth.test.ts` ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock secure-storage const secureStore = new Map(); vi.mock('@/utils/secure-storage', () => ({ secureGet: vi.fn((key: string) => secureStore.get(key) || ''), secureSet: vi.fn((key: string, value: string) => { secureStore.set(key, value); }), secureRemove: vi.fn((key: string) => { secureStore.delete(key); }), })); // Mock auth API vi.mock('@/services/auth', () => ({ wechatLogin: vi.fn(), credentialLogin: vi.fn(), wechatBindPhone: vi.fn(), getPatients: vi.fn(), })); // Mock request module vi.mock('@/services/request', () => ({ clearRequestCache: vi.fn(), markLoggingOut: vi.fn(), clearLoggingOut: vi.fn(), setCachedPatientId: vi.fn(), getCachedPatientId: vi.fn(() => ''), })); // Mock stores/index resetAllStores vi.mock('@/stores', () => ({ resetAllStores: vi.fn(), })); import { useAuthStore } from '@/stores/auth'; import * as authApi from '@/services/auth'; import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage'; import { clearRequestCache, markLoggingOut, setCachedPatientId } from '@/services/request'; describe('auth store', () => { beforeEach(() => { secureStore.clear(); vi.clearAllMocks(); // 重置 store 状态 useAuthStore.setState({ user: null, roles: [], currentPatient: null, patients: [], loading: false, }); }); describe('restore()', () => { it('应从 secure storage 恢复用户信息', () => { secureStore.set('user_data', JSON.stringify({ id: 'u1', username: 'test', tenant_id: 't1' })); secureStore.set('user_roles', JSON.stringify(['doctor'])); secureStore.set('current_patient', JSON.stringify({ id: 'p1', name: '张三', relation: 'self' })); useAuthStore.getState().restore(); const state = useAuthStore.getState(); expect(state.user).toEqual({ id: 'u1', username: 'test', tenant_id: 't1' }); expect(state.roles).toEqual(['doctor']); expect(state.currentPatient).toEqual({ id: 'p1', name: '张三', relation: 'self' }); }); it('storage 为空时应保持默认值', () => { useAuthStore.getState().restore(); const state = useAuthStore.getState(); expect(state.user).toBeNull(); expect(state.roles).toEqual([]); expect(state.currentPatient).toBeNull(); }); it('恢复患者后应同步 setCachedPatientId', () => { secureStore.set('user_data', JSON.stringify({ id: 'u1' })); secureStore.set('user_roles', JSON.stringify([])); secureStore.set('current_patient', JSON.stringify({ id: 'p1', name: '张三', relation: 'self' })); useAuthStore.getState().restore(); expect(setCachedPatientId).toHaveBeenCalledWith('p1'); }); it('JSON 解析失败不应崩溃', () => { secureStore.set('user_data', '{invalid json}'); secureStore.set('user_roles', JSON.stringify([])); expect(() => useAuthStore.getState().restore()).not.toThrow(); }); }); describe('login(code)', () => { it('微信登录成功(已绑定用户)', async () => { vi.mocked(authApi.wechatLogin).mockResolvedValue({ bound: true, openid: 'wx-openid', token: { access_token: 'at', refresh_token: 'rt', expires_in: 3600, user: { id: 'u1', username: 'test', roles: [{ code: 'doctor', name: '医生' }] }, }, }); vi.mocked(authApi.getPatients).mockResolvedValue([]); const result = await useAuthStore.getState().login('wx-code'); expect(result).toBe(true); expect(secureSet).toHaveBeenCalledWith('access_token', 'at'); expect(secureSet).toHaveBeenCalledWith('refresh_token', 'rt'); expect(useAuthStore.getState().roles).toEqual(['doctor']); expect(useAuthStore.getState().loading).toBe(false); }); it('微信登录未绑定用户', async () => { vi.mocked(authApi.wechatLogin).mockResolvedValue({ bound: false, openid: 'wx-openid-123', }); const result = await useAuthStore.getState().login('wx-code'); expect(result).toBe(false); expect(secureSet).toHaveBeenCalledWith('wechat_openid', 'wx-openid-123'); }); it('登录 API 失败应返回 false', async () => { vi.mocked(authApi.wechatLogin).mockRejectedValue(new Error('network')); const result = await useAuthStore.getState().login('wx-code'); expect(result).toBe(false); expect(useAuthStore.getState().loading).toBe(false); }); it('loading 时应拒绝重复登录', async () => { useAuthStore.setState({ loading: true }); const result = await useAuthStore.getState().login('code'); expect(result).toBe(false); expect(authApi.wechatLogin).not.toHaveBeenCalled(); }); }); describe('credentialLogin(username, password)', () => { it('账号密码登录成功', async () => { vi.mocked(authApi.credentialLogin).mockResolvedValue({ access_token: 'at', refresh_token: 'rt', expires_in: 3600, user: { id: 'u1', username: 'admin', roles: [{ code: 'admin', name: '管理员' }], tenant_id: 't1' }, }); vi.mocked(authApi.getPatients).mockResolvedValue([]); const result = await useAuthStore.getState().credentialLogin('admin', 'password'); expect(result).toBe(true); expect(secureSet).toHaveBeenCalledWith('access_token', 'at'); expect(useAuthStore.getState().user).toBeTruthy(); }); it('登录失败应返回 false', async () => { vi.mocked(authApi.credentialLogin).mockRejectedValue(new Error('invalid')); const result = await useAuthStore.getState().credentialLogin('admin', 'wrong'); expect(result).toBe(false); }); }); describe('logout()', () => { it('应清理所有 secure storage key', () => { useAuthStore.setState({ user: { id: 'u1', username: 'test' }, roles: ['doctor'], currentPatient: { id: 'p1', name: '张三', relation: 'self' }, }); useAuthStore.getState().logout(); // 验证所有 key 被 remove const removedKeys = vi.mocked(secureRemove).mock.calls.map(c => c[0]); expect(removedKeys).toContain('access_token'); expect(removedKeys).toContain('refresh_token'); expect(removedKeys).toContain('token_expires_at'); expect(removedKeys).toContain('user_data'); expect(removedKeys).toContain('user_roles'); expect(removedKeys).toContain('tenant_id'); expect(removedKeys).toContain('wechat_openid'); expect(removedKeys).toContain('current_patient'); expect(removedKeys).toContain('current_patient_id'); }); it('应重置 store 状态', () => { useAuthStore.setState({ user: { id: 'u1', username: 'test' }, roles: ['doctor'] }); useAuthStore.getState().logout(); const state = useAuthStore.getState(); expect(state.user).toBeNull(); expect(state.roles).toEqual([]); expect(state.currentPatient).toBeNull(); expect(state.patients).toEqual([]); }); it('应调用 markLoggingOut', () => { useAuthStore.getState().logout(); expect(markLoggingOut).toHaveBeenCalled(); }); it('应跳转到首页', () => { const Taro = require('@tarojs/taro').default; useAuthStore.getState().logout(); expect(Taro.reLaunch).toHaveBeenCalledWith({ url: '/pages/index/index' }); }); }); describe('bindPhone(encryptedData, iv)', () => { it('绑定成功应存储 token', async () => { secureStore.set('wechat_openid', 'wx-openid'); vi.mocked(authApi.wechatBindPhone).mockResolvedValue({ access_token: 'new-at', refresh_token: 'new-rt', expires_in: 3600, user: { id: 'u1', username: 'bound-user', roles: [] }, }); vi.mocked(authApi.getPatients).mockResolvedValue([]); const result = await useAuthStore.getState().bindPhone('enc-data', 'iv-data'); expect(result).toBe(true); expect(secureSet).toHaveBeenCalledWith('access_token', 'new-at'); expect(secureRemove).toHaveBeenCalledWith('wechat_openid'); }); it('无 openid 应抛出错误', async () => { secureStore.delete('wechat_openid'); await expect( useAuthStore.getState().bindPhone('enc-data', 'iv-data'), ).rejects.toThrow('登录态丢失'); }); }); describe('角色判断', () => { it.each([ { roles: ['doctor'], fn: 'isDoctor', expected: true }, { roles: ['nurse'], fn: 'isDoctor', expected: false }, { roles: ['admin'], fn: 'isDoctor', expected: true }, { roles: ['nurse'], fn: 'isNurse', expected: true }, { roles: ['health_manager'], fn: 'isHealthManager', expected: true }, { roles: ['doctor'], fn: 'isMedicalStaff', expected: true }, { roles: ['patient'], fn: 'isMedicalStaff', expected: false }, { roles: ['admin'], fn: 'isMedicalStaff', expected: true }, ])('roles=$roles → $fn() = $expected', ({ roles, fn, expected }) => { useAuthStore.setState({ roles }); expect((useAuthStore.getState() as any)[fn]()).toBe(expected); }); it('hasRole 应精确匹配', () => { useAuthStore.setState({ roles: ['doctor'] }); expect(useAuthStore.getState().hasRole('doctor')).toBe(true); expect(useAuthStore.getState().hasRole('nurse')).toBe(false); }); it('admin 角色应通过所有 hasRole 检查', () => { useAuthStore.setState({ roles: ['admin'] }); expect(useAuthStore.getState().hasRole('doctor')).toBe(true); expect(useAuthStore.getState().hasRole('nurse')).toBe(true); }); }); }); ``` ### GREEN: 运行测试 ```bash cd apps/miniprogram && npx vitest run __tests__/stores/auth.test.ts ``` 注意:`stores/index.ts` 中的 `resetAllStores` 需要在 mock 中正确处理。若 `auth.ts` 顶部 `import { resetAllStores } from './index'` 导致循环引用问题,在 mock 中提供 stub。 ### COMMIT ```bash git add apps/miniprogram/__tests__/stores/auth.test.ts git commit -m "test(mp): auth store 单元测试 — restore/login/logout/bindPhone/角色判断" ``` --- ## 5. Task T1-4: DataSyncScheduler + BLEManager 测试 > 工时: 1.5d | 文件: `apps/miniprogram/__tests__/services/ble/DataSyncScheduler.test.ts`(扩展) ### 前置知识 现有 DataSyncScheduler 测试已覆盖 8 个用例:needsSync/recordSync/tryAutoSync/destroy/getLastSyncAt。 需新增: 1. `startPeriodicCheck` 并发互斥 — 多次调用只创建一个 timer 2. `startPeriodicCheck` 实际触发同步 3. `destroy` 后 `startPeriodicCheck` 可重新启动 BLEManager 测试需要 mock `Taro.openBluetoothAdapter`/`Taro.startBluetoothDevicesDiscovery`/`Taro.createBLEConnection` 等。因 BLEManager 构造函数中 `new DataBuffer().restore()` 调用了 `Taro.getStorageSync`,需确保 mock 在 import 之前。 ### RED: 扩展失败测试 **文件:** `apps/miniprogram/__tests__/services/ble/DataSyncScheduler.test.ts`(追加) ```typescript // ... 现有测试保持不变 ... describe('startPeriodicCheck', () => { it('应启动定时器并周期触发同步', () => { vi.useFakeTimers(); const syncFn = vi.fn().mockResolvedValue({ success: true, uploadedCount: 0 }); // 先同步一次以记录时间戳,否则 needsSync=true 会立即触发 storage.set('last_ble_sync', JSON.stringify({ lastSyncAt: Date.now() })); const scheduler = new DataSyncScheduler({ intervalMs: 60 * 60 * 1000, storageKey: 'last_ble_sync', }); scheduler.startPeriodicCheck(syncFn, 5000); // 不应立即触发(needsSync=false 因为刚同步过) expect(syncFn).not.toHaveBeenCalled(); // 模拟 1 小时后,needsSync=true const oneHourAgo = Date.now() - 60 * 60 * 1000; storage.set('last_ble_sync', JSON.stringify({ lastSyncAt: oneHourAgo })); // 推进定时器 vi.advanceTimersByTime(5000); expect(syncFn).toHaveBeenCalledTimes(1); scheduler.destroy(); vi.useRealTimers(); }); it('destroy 后不应再触发', () => { vi.useFakeTimers(); const syncFn = vi.fn().mockResolvedValue({ success: true, uploadedCount: 0 }); storage.clear(); // needsSync = true const scheduler = new DataSyncScheduler({ intervalMs: 1000, storageKey: 'last_ble_sync_periodic', }); scheduler.startPeriodicCheck(syncFn, 1000); scheduler.destroy(); vi.advanceTimersByTime(5000); expect(syncFn).not.toHaveBeenCalled(); vi.useRealTimers(); }); it('多次调用 startPeriodicCheck 应替换旧 timer(不叠加)', () => { vi.useFakeTimers(); const syncFn = vi.fn().mockResolvedValue({ success: true, uploadedCount: 0 }); storage.clear(); const scheduler = new DataSyncScheduler({ intervalMs: 1000, storageKey: 'last_ble_sync_multi', }); scheduler.startPeriodicCheck(syncFn, 1000); scheduler.startPeriodicCheck(syncFn, 1000); // 第二次调用 // 只有一个 setInterval 在运行 vi.advanceTimersByTime(1000); // startPeriodicCheck 内部先 destroy 再 setInterval // 但首次 syncFn 的调用取决于 needsSync expect(syncFn.mock.calls.length).toBeLessThanOrEqual(2); scheduler.destroy(); vi.useRealTimers(); }); }); ``` ### BLEManager 测试(新增部分) **文件:** `apps/miniprogram/__tests__/services/ble/BLEManager.test.ts`(扩展现有) ```typescript // 在现有 BLEManager.test.ts 末尾追加: describe('BLEManager 生命周期', () => { it('registerAdapter 应注册适配器', () => { const manager = new BLEManager(); const mockAdapter = { supportedModels: ['TestDevice'], serviceUUID: 'test-uuid', parseData: vi.fn(), deviceType: 'test' as const, }; manager.registerAdapter(mockAdapter); // 间接验证:scanDevices 应使用注册的适配器 // (完整测试需要 mock Taro BLE API) }); it('destroy 应清理所有状态', () => { const manager = new BLEManager(); manager.destroy(); // 验证没有内存泄漏(无法直接断言,但不应抛出) expect(true).toBe(true); }); }); ``` ### GREEN: 运行测试 ```bash cd apps/miniprogram && npx vitest run __tests__/services/ble/ ``` ### COMMIT ```bash git add apps/miniprogram/__tests__/services/ble/DataSyncScheduler.test.ts git add apps/miniprogram/__tests__/services/ble/BLEManager.test.ts git commit -m "test(mp): DataSyncScheduler+BLEManager 扩展测试 — 周期同步/并发互斥/生命周期" ``` --- ## 6. Task U1-1: 核心 ARIA 角色标注 > 工时: 2d | 涉及约 15 个核心组件 ### 改动清单 #### U1-1.1 SegmentTabs **文件:** `apps/miniprogram/src/components/SegmentTabs/index.tsx` ```diff - + {tabs.map((tab) => ( onChange(tab.key)} > ``` #### U1-1.2 DoctorTabBar **文件:** `apps/miniprogram/src/components/ui/DoctorTabBar/index.tsx` ```diff - + {DOCTOR_TABS.map((tab) => ( + ``` #### U1-1.4 SecondaryButton **文件:** `apps/miniprogram/src/components/ui/SecondaryButton/index.tsx` ```diff - + ``` #### U1-1.5 Loading **文件:** `apps/miniprogram/src/components/Loading/index.tsx` ```diff - + ``` #### U1-1.6 LoadingCard **文件:** `apps/miniprogram/src/components/ui/LoadingCard/index.tsx` ```diff - + ``` ### 验证步骤 ```bash # 编译检查(ARIA 属性是字符串透传,不影响编译) cd apps/miniprogram && npx tsc --noEmit # 构建检查 cd apps/miniprogram && pnpm build ``` 在微信开发者工具中验证: - 打开任意含 SegmentTabs 的页面(如健康页) - 使用开发者工具 Audits 面板检查 ARIA 属性是否生效 ### COMMIT ```bash git add apps/miniprogram/src/components/SegmentTabs/index.tsx git add apps/miniprogram/src/components/ui/DoctorTabBar/index.tsx git add apps/miniprogram/src/components/ui/PrimaryButton/index.tsx git add apps/miniprogram/src/components/ui/SecondaryButton/index.tsx git add apps/miniprogram/src/components/Loading/index.tsx git add apps/miniprogram/src/components/ui/LoadingCard/index.tsx git commit -m "feat(mp): ARIA 角色标注 — tablist/tab/button/status 核心组件" ``` --- ## 7. Task U1-2: 表单可访问性增强 > 工时: 1d | 文件: FormInput 组件 + 体征录入页 ### U1-2.1 FormInput 组件改造 **文件:** `apps/miniprogram/src/components/ui/FormInput/index.tsx` ```diff interface FormInputProps { label?: string; placeholder?: string; value?: string; onInput?: (value: string) => void; type?: 'text' | 'number' | 'idcard' | 'digit'; maxLength?: number; disabled?: boolean; error?: string; className?: string; + ariaLabel?: string; } const FormInput: React.FC = ({ label, placeholder, value, onInput, type = 'text', maxLength, disabled = false, error, className = '', + ariaLabel, }) => { + const errorId = error ? `form-error-${label || Math.random().toString(36).slice(2, 8)}` : undefined; return ( {label && {label}} onInput?.(e.detail.value)} type={type} maxlength={maxLength} disabled={disabled} + aria-label={ariaLabel || label} + aria-describedby={errorId} + aria-invalid={!!error} /> - {error && {error}} + {error && {error}} ); }; ``` ### U1-2.2 体征录入页 aria-valuemin/max/now **文件:** `apps/miniprogram/src/pages/pkg-health/input/index.tsx`(查找数值输入区域) 在体征录入页的数值 Input 组件上添加 ARIA 属性。此改动需要找到具体的 Input 元素并添加属性: ```diff setValue(e.detail.value)} + aria-label={`${indicatorName}数值`} + aria-valuemin={min} + aria-valuemax={max} + aria-valuenow={value ? parseFloat(value) : undefined} /> ``` 具体 min/max 值需参考各体征指标的参考范围常量(如收缩压 60-250、舒张压 30-150、心率 30-250 等)。 ### 验证 ```bash cd apps/miniprogram && npx tsc --noEmit cd apps/miniprogram && pnpm build ``` 在微信开发者工具中验证: - 打开体征录入页,检查 Input 是否有 aria-label/aria-valuemin 等 - 输入错误值后检查 aria-invalid 和 error 提示 ### COMMIT ```bash git add apps/miniprogram/src/components/ui/FormInput/index.tsx git add apps/miniprogram/src/pages/pkg-health/input/index.tsx git commit -m "feat(mp): 表单可访问性 — aria-label/describedby/invalid + 体征录入aria-valuemin/max" ``` --- ## 8. Task U1-3: 动态内容 aria-live > 工时: 0.5d | 文件: EmptyState / ErrorState / LoadingCard / TrendChart ### U1-3.1 EmptyState **文件:** `apps/miniprogram/src/components/EmptyState/index.tsx` ```diff - + ``` ### U1-3.2 ErrorState **文件:** `apps/miniprogram/src/components/ErrorState/index.tsx` ```diff - + ``` ### U1-3.3 TrendChart tooltip **文件:** `apps/miniprogram/src/components/TrendChart/index.tsx` ```diff {tooltip && ( {tooltip.date}: {tooltip.value}{unit ? ` ${unit}` : ''} )} ``` ### 验证 ```bash cd apps/miniprogram && npx tsc --noEmit ``` ### COMMIT ```bash git add apps/miniprogram/src/components/EmptyState/index.tsx git add apps/miniprogram/src/components/ErrorState/index.tsx git add apps/miniprogram/src/components/TrendChart/index.tsx git commit -m "feat(mp): aria-live 动态内容播报 — EmptyState/ErrorState/TrendChart tooltip" ``` --- ## 9. Task U1-4: 焦点管理基础 > 工时: 1d | 涉及全局焦点环样式 + 组件级焦点反馈 ### U1-4.1 全局焦点环变量 **文件:** `apps/miniprogram/src/styles/variables.scss` 在文件末尾追加: ```scss // ─── 焦点环(可访问性)─── $focus-ring-color: rgba(196, 98, 58, 0.5); // 赤土橙 50% 透明 $focus-ring-width: 2px; $focus-ring-offset: 2px; // 医生端焦点环 $doc-focus-ring-color: rgba(58, 107, 140, 0.5); // 靛蓝 50% 透明 ``` ### U1-4.2 新建焦点环 mixin 文件 **文件:** `apps/miniprogram/src/styles/_focus-ring.scss` ```scss @import './variables.scss'; // 焦点环基础 mixin — 可交互元素统一调用 @mixin focus-ring($color: $focus-ring-color) { &:focus-visible { outline: none; box-shadow: 0 0 0 $focus-ring-offset $color; border-radius: inherit; } } // Taro View 的 focusin/focusout 视觉反馈 @mixin interactive-focus($color: $focus-ring-color) { transition: box-shadow 0.15s ease; &:focus { outline: none; box-shadow: 0 0 0 $focus-ring-offset $color; } } ``` ### U1-4.3 PrimaryButton 焦点环 **文件:** `apps/miniprogram/src/components/ui/PrimaryButton/index.scss` ```diff + @import '../../../styles/focus-ring'; .primary-btn { // ... existing styles ... + @include focus-ring; } ``` ### U1-4.4 SecondaryButton 焦点环 **文件:** `apps/miniprogram/src/components/ui/SecondaryButton/index.scss` ```diff + @import '../../../styles/focus-ring'; .secondary-btn { // ... existing styles ... + @include focus-ring; } ``` ### U1-4.5 SegmentTabs 焦点环 **文件:** `apps/miniprogram/src/components/SegmentTabs/index.scss` ```diff + @import '../../styles/focus-ring'; .seg-tab { // ... existing styles ... + @include interactive-focus; } ``` ### U1-4.6 DoctorTabBar 焦点环 **文件:** `apps/miniprogram/src/components/ui/DoctorTabBar/index.scss` ```diff + @import '../../../styles/focus-ring'; .doctor-tabbar__item { // ... existing styles ... + @include interactive-focus($color: $doc-focus-ring-color); } ``` ### U1-4.7 FormInput 焦点环(增强现有 :focus 样式) **文件:** `apps/miniprogram/src/components/ui/FormInput/index.scss` ```diff + @import '../../../styles/focus-ring'; .form-input { // ... &__field { // ... existing styles ... + transition: border-color 0.2s, box-shadow 0.15s ease; } - &--focus &__field { - border-color: var(--tk-pri); - } + &--focus &__field, + &__field:focus-within { + border-color: var(--tk-pri); + box-shadow: 0 0 0 $focus-ring-offset $focus-ring-color; + } } ``` ### 验证 ```bash cd apps/miniprogram && pnpm build ``` 在微信开发者工具中验证: - 使用键盘 Tab 键在按钮/TabBar 间导航,观察焦点环是否可见 - FormInput 聚焦时边框+阴影是否正确 ### COMMIT ```bash git add apps/miniprogram/src/styles/variables.scss git add apps/miniprogram/src/styles/_focus-ring.scss git add apps/miniprogram/src/components/ui/PrimaryButton/index.scss git add apps/miniprogram/src/components/ui/SecondaryButton/index.scss git add apps/miniprogram/src/components/SegmentTabs/index.scss git add apps/miniprogram/src/components/ui/DoctorTabBar/index.scss git add apps/miniprogram/src/components/ui/FormInput/index.scss git commit -m "feat(mp): 焦点管理基础 — 全局焦点环变量/mixin + 5 组件焦点样式" ``` --- ## 10. Task S1-1: handler 层 consent 状态检查 > 工时: 1d | 涉及后端 handler 层 ### 前置分析 当前状态: - **后端** `consent_handler.rs` 已有 CRUD 端点(list / grant / revoke / patient-sign) - **前端** `pages/pkg-profile/consents/index.tsx` 已有知情同意列表页 - **缺失**:handler 层在访问患者敏感数据(vital_signs / lab_reports / health_alerts 等)时,未检查该患者是否有有效的 consent 记录 设计规格要求:handler 层新增 `check_consent_active` 函数,在涉及患者数据的读取端点中调用。 ### S1-1.1 新建 consent 检查辅助模块 **文件:** `crates/erp-health/src/handler/consent_check.rs`(新建) ```rust use erp_core::error::AppError; use erp_core::types::TenantContext; use crate::entity::consent::Entity as ConsentEntity; use crate::entity::consent::Column as ConsentColumn; use sea_orm::{EntityTrait, QueryFilter, DatabaseConnection}; use uuid::Uuid; /// 检查患者是否有有效的知情同意记录(status = granted) /// 在 handler 层调用,对患者数据的读取进行 consent 门控 pub async fn check_consent_active( db: &DatabaseConnection, tenant_id: Uuid, patient_id: Uuid, ctx: &TenantContext, ) -> Result<(), AppError> { // admin 和医护角色不需要 consent 检查 if ctx.roles.iter().any(|r| r == "admin" || r == "doctor" || r == "nurse" || r == "health_manager") { return Ok(()); } let has_active = ConsentEntity::find() .filter(ConsentColumn::TenantId.eq(tenant_id)) .filter(ConsentColumn::PatientId.eq(patient_id)) .filter(ConsentColumn::Status.eq("granted")) .filter(ConsentColumn::ConsentType.eq("data_processing")) .one(db) .await .map_err(|e| AppError::Internal(e.to_string()))?; if has_active.is_none() { return Err(AppError::Forbidden("患者未签署知情同意书,无法访问数据".to_string())); } Ok(()) } ``` ### S1-1.2 注册模块 **文件:** `crates/erp-health/src/handler/mod.rs` ```diff pub mod consent_handler; + pub mod consent_check; ``` ### S1-1.3 在关键 handler 中添加 consent 检查 在以下 handler 的数据读取端点中添加 `check_consent_active` 调用: | handler | 端点 | patient_id 来源 | |--------|------|----------------| | `vital_sign_handler` | `list_vital_signs` | Query 参数 | | `lab_report_handler` | `list_lab_reports` | Query 参数 | | `alert_handler` | `list_alerts` | Query 参数 | | `daily_monitoring_handler` | `get_daily_summary` | Query 参数 | | `health_data_handler` | `list_health_data` | Path 参数 | **示例改动:** `crates/erp-health/src/handler/vital_sign_handler.rs` ```diff + use crate::handler::consent_check::check_consent_active; pub async fn list_vital_signs( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result<...> { require_permission(&ctx, "health.health-data.list")?; + // consent 门控:患者端访问需检查知情同意 + if let Some(patient_id) = query.patient_id { + check_consent_active(&state.db, ctx.tenant_id, patient_id, &ctx).await?; + } // ... 原有逻辑 ... } ``` ### 验证 ```bash cargo check cargo test --workspace -p erp-health ``` ### COMMIT ```bash git add crates/erp-health/src/handler/consent_check.rs git add crates/erp-health/src/handler/mod.rs git add crates/erp-health/src/handler/vital_sign_handler.rs git add crates/erp-health/src/handler/lab_report_handler.rs git add crates/erp-health/src/handler/alert_handler.rs git add crates/erp-health/src/handler/daily_monitoring_handler.rs git commit -m "feat(health): consent 门控 — handler 层 check_consent_active 患者数据访问拦截" ``` --- ## 11. 验收标准 ### 测试覆盖 - [ ] 单元测试文件 >= 6 个(secure-storage / request / auth / DataSyncScheduler / BLEManager / components) - [ ] `npx vitest run` 全部通过 - [ ] `secure-storage` 测试覆盖: 加解密对称性 / 空 value / 明文 fallback / 迁移 / Base64 边界 - [ ] `request.ts` 测试覆盖: ConcurrencyLimiter / 401 重试 / ResponseCache / safeReLaunch / AbortSignal - [ ] `auth store` 测试覆盖: restore / login / credentialLogin / logout / bindPhone / 角色判断 - [ ] `DataSyncScheduler` 测试覆盖: needsSync / recordSync / startPeriodicCheck 并发互斥 / destroy ### UX 合规 - [ ] SegmentTabs 有 `role="tablist"` / `role="tab"` / `aria-selected` - [ ] DoctorTabBar 有 `role="tablist"` / `role="tab"` / `aria-selected` - [ ] PrimaryButton 有 `role="button"` / `aria-disabled` / `aria-busy` - [ ] SecondaryButton 有 `role="button"` / `aria-disabled` - [ ] Loading 有 `role="status"` / `aria-live="polite"` - [ ] LoadingCard 有 `role="status"` / `aria-label` - [ ] EmptyState 有 `role="status"` / `aria-live="polite"` - [ ] ErrorState 有 `role="alert"` / `aria-live="assertive"` - [ ] TrendChart tooltip 有 `role="tooltip"` / `aria-live="polite"` - [ ] FormInput 有 `aria-label` / `aria-describedby` / `aria-invalid` - [ ] 体征录入页数值输入有 `aria-valuemin` / `aria-valuemax` / `aria-valuenow` ### 焦点管理 - [ ] `$focus-ring-color` 变量定义在 `variables.scss` - [ ] `_focus-ring.scss` mixin 文件存在且被 5 个组件引用 - [ ] PrimaryButton / SecondaryButton / SegmentTabs / DoctorTabBar / FormInput 有焦点环样式 - [ ] 键盘 Tab 导航时焦点环可见 ### Consent 拦截 - [ ] `consent_check.rs` 模块存在且导出 `check_consent_active` - [ ] 至少 5 个数据读取 handler 调用 consent 检查 - [ ] admin/doctor/nurse/health_manager 角色豁免检查 - [ ] 患者端无有效 consent 时返回 403 + 友好错误消息 - [ ] `cargo check` + `cargo test` 全 workspace 通过 ### 编译与构建 - [ ] `cd apps/miniprogram && npx tsc --noEmit` 零错误 - [ ] `cd apps/miniprogram && pnpm build` 成功 - [ ] `cargo check` 全 workspace 通过 - [ ] `cargo test --workspace` 全部通过 ### 提交记录 - [ ] T1-1: `test(mp): secure-storage 单元测试` - [ ] T1-2: `test(mp): request.ts 扩展测试` - [ ] T1-3: `test(mp): auth store 单元测试` - [ ] T1-4: `test(mp): DataSyncScheduler+BLEManager 扩展测试` - [ ] U1-1: `feat(mp): ARIA 角色标注` - [ ] U1-2: `feat(mp): 表单可访问性` - [ ] U1-3: `feat(mp): aria-live 动态内容播报` - [ ] U1-4: `feat(mp): 焦点管理基础` - [ ] S1-1: `feat(health): consent 门控` ### Wiki 更新 - [ ] `wiki/index.md` 关键数字:测试文件数 / 组件数 / 权限码更新 - [ ] 症状导航新增:consent 检查 403 的症状条目