Files
hms/docs/superpowers/plans/2026-05-21-miniprogram-phase1-plan.md
iven 9c61156ab3 docs(mp): Phase 1 实施计划 — 测试覆盖 + UX 合规(9 Tasks)
测试覆盖: secure-storage/request/auth/DataSyncScheduler 测试扩展
UX 合规: ARIA 角色标注 + 表单可访问性 + aria-live + 焦点管理
安全: 后端 consent 拦截器
2026-05-21 23:46:06 +08:00

50 KiB
Raw Blame History

小程序 Phase 1 实施计划:测试覆盖 + UX 合规

日期: 2026-05-21 | 分支: feat/media-library-banner | 预计工时: 13d | 负责人: Dev Agent

目录


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

import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock Taro Storage API
const storage = new Map<string, string>();
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<string, string> = {
        '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: 运行测试确认通过

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

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

在现有文件末尾追加以下测试套件:

// ... 现有测试保持不变 ...

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: 运行测试

cd apps/miniprogram && npx vitest run __tests__/services/request.test.ts

注意:部分 401 重试测试可能因 resetForTesting()beforeEach 中未完全重置 token 刷新状态而失败。若失败,在 resetForTesting() 中补充 isLoggingOut = false 重置(当前已包含)。

COMMIT

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/requestclearRequestCache/markLoggingOut/clearLoggingOut/setCachedPatientId(需 mock
  • @tarojs/taroreLaunch/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

import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock secure-storage
const secureStore = new Map<string, string>();
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: 运行测试

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

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. destroystartPeriodicCheck 可重新启动

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(追加)

// ... 现有测试保持不变 ...

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(扩展现有)

// 在现有 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: 运行测试

cd apps/miniprogram && npx vitest run __tests__/services/ble/

COMMIT

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

- <View className={`seg-tabs seg-tabs--${variant}`}>
+ <View className={`seg-tabs seg-tabs--${variant}`} role="tablist">
    {tabs.map((tab) => (
      <View
        key={tab.key}
+       role="tab"
+       aria-selected={activeKey === tab.key}
        className={`seg-tab ${activeKey === tab.key ? 'seg-tab--active' : ''}`}
        onClick={() => onChange(tab.key)}
      >

U1-1.2 DoctorTabBar

文件: apps/miniprogram/src/components/ui/DoctorTabBar/index.tsx

- <View className="doctor-tabbar">
+ <View className="doctor-tabbar" role="tablist">
    {DOCTOR_TABS.map((tab) => (
      <View
        key={tab.key}
+       role="tab"
+       aria-selected={tab.key === activeKey}
        className={`doctor-tabbar__item ${tab.key === activeKey ? 'doctor-tabbar__item--active' : ''}`}

U1-1.3 PrimaryButton

文件: apps/miniprogram/src/components/ui/PrimaryButton/index.tsx

  return (
-   <View className={cls} onClick={!disabled && !loading ? onClick : undefined}>
+   <View
+     className={cls}
+     role="button"
+     aria-disabled={disabled || loading}
+     aria-busy={loading}
+     onClick={!disabled && !loading ? onClick : undefined}
+   >

U1-1.4 SecondaryButton

文件: apps/miniprogram/src/components/ui/SecondaryButton/index.tsx

- <View className={cls} onClick={!disabled ? onClick : undefined}>
+ <View
+   className={cls}
+   role="button"
+   aria-disabled={disabled}
+   onClick={!disabled ? onClick : undefined}
+ >

U1-1.5 Loading

文件: apps/miniprogram/src/components/Loading/index.tsx

- <View className={`loading-state ${isListEnd ? 'loading-state--end' : ''}`}>
+ <View
+   className={`loading-state ${isListEnd ? 'loading-state--end' : ''}`}
+   role="status"
+   aria-live="polite"
+ >

U1-1.6 LoadingCard

文件: apps/miniprogram/src/components/ui/LoadingCard/index.tsx

- <View className={`loading-card-group loading-card-group--${layout}`}>
+ <View
+   className={`loading-card-group loading-card-group--${layout}`}
+   role="status"
+   aria-label="正在加载"
+ >

验证步骤

# 编译检查ARIA 属性是字符串透传,不影响编译)
cd apps/miniprogram && npx tsc --noEmit

# 构建检查
cd apps/miniprogram && pnpm build

在微信开发者工具中验证:

  • 打开任意含 SegmentTabs 的页面(如健康页)
  • 使用开发者工具 Audits 面板检查 ARIA 属性是否生效

COMMIT

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

  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<FormInputProps> = ({
    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 (
      <View className={cls}>
        {label && <Text className='form-input__label' id={errorId ? `label-${label}` : undefined}>{label}</Text>}
        <View className='form-input__field'>
          <Input
            className='form-input__control'
            placeholder={placeholder}
            placeholderClass='form-input__placeholder'
            value={value}
            onInput={e => onInput?.(e.detail.value)}
            type={type}
            maxlength={maxLength}
            disabled={disabled}
+           aria-label={ariaLabel || label}
+           aria-describedby={errorId}
+           aria-invalid={!!error}
          />
        </View>
-       {error && <Text className='form-input__error'>{error}</Text>}
+       {error && <Text className='form-input__error' id={errorId} role='alert'>{error}</Text>}
      </View>
    );
  };

U1-2.2 体征录入页 aria-valuemin/max/now

文件: apps/miniprogram/src/pages/pkg-health/input/index.tsx(查找数值输入区域)

在体征录入页的数值 Input 组件上添加 ARIA 属性。此改动需要找到具体的 Input 元素并添加属性:

  <Input
    type='digit'
    value={value}
    onInput={e => 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 等)。

验证

cd apps/miniprogram && npx tsc --noEmit
cd apps/miniprogram && pnpm build

在微信开发者工具中验证:

  • 打开体征录入页,检查 Input 是否有 aria-label/aria-valuemin 等
  • 输入错误值后检查 aria-invalid 和 error 提示

COMMIT

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

- <View className='empty-state'>
+ <View className='empty-state' role='status' aria-live='polite'>

U1-3.2 ErrorState

文件: apps/miniprogram/src/components/ErrorState/index.tsx

- <View className='error-state'>
+ <View className='error-state' role='alert' aria-live='assertive'>

U1-3.3 TrendChart tooltip

文件: apps/miniprogram/src/components/TrendChart/index.tsx

  {tooltip && (
    <View
      className='trend-tooltip'
      style={{ left: `${tooltip.x}px`, top: '8px' }}
+     role='tooltip'
+     aria-live='polite'
    >
      <Text className='trend-tooltip-text'>
        {tooltip.date}: {tooltip.value}{unit ? ` ${unit}` : ''}
      </Text>
    </View>
  )}

验证

cd apps/miniprogram && npx tsc --noEmit

COMMIT

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

在文件末尾追加:

// ─── 焦点环(可访问性)───
$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

@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

+ @import '../../../styles/focus-ring';

  .primary-btn {
    // ... existing styles ...
+   @include focus-ring;
  }

U1-4.4 SecondaryButton 焦点环

文件: apps/miniprogram/src/components/ui/SecondaryButton/index.scss

+ @import '../../../styles/focus-ring';

  .secondary-btn {
    // ... existing styles ...
+   @include focus-ring;
  }

U1-4.5 SegmentTabs 焦点环

文件: apps/miniprogram/src/components/SegmentTabs/index.scss

+ @import '../../styles/focus-ring';

  .seg-tab {
    // ... existing styles ...
+   @include interactive-focus;
  }

U1-4.6 DoctorTabBar 焦点环

文件: apps/miniprogram/src/components/ui/DoctorTabBar/index.scss

+ @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

+ @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;
+   }
  }

验证

cd apps/miniprogram && pnpm build

在微信开发者工具中验证:

  • 使用键盘 Tab 键在按钮/TabBar 间导航,观察焦点环是否可见
  • FormInput 聚焦时边框+阴影是否正确

COMMIT

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 组件焦点样式"

工时: 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 函数,在涉及患者数据的读取端点中调用。

文件: crates/erp-health/src/handler/consent_check.rs(新建)

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

  pub mod consent_handler;
+ pub mod consent_check;

在以下 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

+ use crate::handler::consent_check::check_consent_active;

  pub async fn list_vital_signs<S>(
      State(state): State<HealthState>,
      Extension(ctx): Extension<TenantContext>,
      Query(query): Query<VitalSignListQuery>,
  ) -> 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?;
+     }

      // ... 原有逻辑 ...
  }

验证

cargo check
cargo test --workspace -p erp-health

COMMIT

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_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 的症状条目