feat(mp): Phase 1 测试覆盖 + UX 无障碍 — 106 tests PASS + ARIA + focus ring

测试:
- secure-storage: 26 tests (AES 加解密/明文 fallback/迁移/Base64 边界)
- request.ts: 16 tests (扩展 ResponseCache/patientId 隔离/requestUnlimited)
- mock-api: 修复 getCachedPatientId 缺失导致 health 测试失败

UX 无障碍 (10 组件):
- SegmentTabs/DoctorTabBar: role=tablist/tab + aria-selected
- PrimaryButton/SecondaryButton: role=button + aria-disabled/aria-busy
- Loading/LoadingCard: role=status + aria-live=polite
- EmptyState: role=status + aria-live=polite
- ErrorState: role=alert + aria-live=assertive
- TrendChart tooltip: role=tooltip + aria-live=polite
- FormInput: aria-invalid + aria-label

焦点管理:
- 新增 _focus-ring.scss mixin (focus + focus-visible)
- 5 组件 SCSS 应用 focus-ring
This commit is contained in:
iven
2026-05-22 00:24:06 +08:00
parent 02a96682f6
commit 898e22c715
20 changed files with 363 additions and 9 deletions

View File

@@ -11,6 +11,9 @@ vi.mock('@/services/request', () => ({
clearRequestCache: vi.fn(),
markLoggingOut: vi.fn(),
clearLoggingOut: vi.fn(),
getCachedPatientId: vi.fn(() => ''),
setCachedPatientId: vi.fn(),
resetForTesting: vi.fn(),
}));
/** 创建一个成功的 API 响应 */

View File

@@ -23,7 +23,7 @@ vi.mock('@/utils/secure-storage', () => ({
}));
import Taro from '@tarojs/taro';
import { api, clearRequestCache, resetForTesting } from '@/services/request';
import { api, clearRequestCache, resetForTesting, setCachedPatientId } from '@/services/request';
describe('request module', () => {
beforeEach(() => {
@@ -148,4 +148,61 @@ describe('request module', () => {
await expect(api.get('/bad-params')).rejects.toThrow('参数错误');
});
});
describe('ResponseCache', () => {
it('should cache GET responses and return cached on second call', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { id: '1' } } } as any);
await api.get('/cached-test');
await api.get('/cached-test');
expect(Taro.request).toHaveBeenCalledTimes(1);
});
it('should not cache POST requests', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: {} } } as any);
await api.post('/no-cache', { a: 1 });
await api.post('/no-cache', { a: 1 });
expect(Taro.request).toHaveBeenCalledTimes(2);
});
it('clearRequestCache should clear cached entries', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { v: 1 } } } as any);
await api.get('/clear-test');
clearRequestCache();
await api.get('/clear-test');
expect(Taro.request).toHaveBeenCalledTimes(2);
});
});
describe('setCachedPatientId', () => {
it('should isolate cache entries by patient ID', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { v: 1 } } } as any);
setCachedPatientId('patient-A');
await api.get('/health/data');
setCachedPatientId('patient-B');
await api.get('/health/data');
// 不同 patient ID 应各自发请求(缓存隔离)
expect(Taro.request).toHaveBeenCalledTimes(2);
});
});
describe('requestUnlimited', () => {
it('should bypass concurrency limiter', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: 'ok' } } as any);
const { requestUnlimited } = await import('@/services/request');
await requestUnlimited('GET', '/health/test');
expect(Taro.request).toHaveBeenCalledWith(
expect.objectContaining({ method: 'GET' }),
);
});
});
});

View File

@@ -0,0 +1,252 @@
/**
* secure-storage AES-256-GCM 测试
* 覆盖加解密对称性、空值删除、明文兼容、迁移、Base64 边界
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
// --- crypto.getRandomValues polyfill ---
if (!globalThis.crypto?.getRandomValues) {
globalThis.crypto = {
getRandomValues: (arr: any) => {
for (let i = 0; i < arr.length; i++) arr[i] = (Math.random() * 256) | 0;
return arr;
},
} as any;
}
// --- Mock @tarojs/taro (覆盖 setup.ts 中的默认 mock添加 base64 方法) ---
const storage = new Map<string, string>();
vi.mock('@tarojs/taro', () => ({
default: {
getStorageSync: vi.fn((key: string) => storage.get(key) ?? ''),
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
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 bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}),
},
}));
// --- Mock 加密密钥 ---
process.env.TARO_APP_ENCRYPTION_KEY =
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
// --- 导入被测模块(在 mock 之后) ---
import { secureSet, secureGet, secureRemove, migrateLegacyStorage } from '@/utils/secure-storage';
// --- 辅助:直接访问 mock 函数 ---
import Taro from '@tarojs/taro';
const mockGet = Taro.getStorageSync as ReturnType<typeof vi.fn>;
const mockSet = Taro.setStorageSync as ReturnType<typeof vi.fn>;
const mockRemove = Taro.removeStorageSync as ReturnType<typeof vi.fn>;
describe('secure-storage AES-256-GCM', () => {
beforeEach(() => {
storage.clear();
vi.clearAllMocks();
});
// ================================================================
// 1. AES 加解密对称性
// ================================================================
describe('AES 加解密对称性', () => {
const cases: Array<[string, string]> = [
['英文', 'hello world'],
['中文', '你好世界'],
['emoji', '\u{1F600}\u{1F680}\u{1F4A9}'],
['特殊字符', '<script>alert("xss")</script>&"\''],
['JSON', '{"name":"张三","age":30,"nested":{"key":"val"}}'],
['超长字符串', 'A'.repeat(10000)],
['混合内容', 'Hello 你好 \u{1F600} !@#$%^&*()'],
['空格和换行', ' line1\nline2\ttabbed '],
['Unicode 补充', '\u{1F1E8}\u{1F1F3}\u{1F1FA}\u{1F1F8}'],
];
cases.forEach(([label, value]) => {
it(`roundtrip: ${label}`, () => {
secureSet('test_key', value);
const result = secureGet('test_key');
expect(result).toBe(value);
});
});
it('多次写入同一 key 产生不同密文nonce 随机)', () => {
secureSet('dup', 'same-value');
const first = storage.get('_es_dup')!;
secureSet('dup', 'same-value');
const second = storage.get('_es_dup')!;
// 密文应该不同nonce 不同),但都能解密
expect(first).not.toBe(second);
expect(secureGet('dup')).toBe('same-value');
});
it('不同 key 互不干扰', () => {
secureSet('key_a', 'value_a');
secureSet('key_b', 'value_b');
expect(secureGet('key_a')).toBe('value_a');
expect(secureGet('key_b')).toBe('value_b');
});
});
// ================================================================
// 2. 空 value 触发 remove
// ================================================================
describe('空 value 触发 remove', () => {
it('空字符串触发 removeStorageSync', () => {
secureSet('empty', '');
expect(mockRemove).toHaveBeenCalledWith('_es_empty');
expect(secureGet('empty')).toBe('');
});
it('先写入再清空应删除存储', () => {
secureSet('temp', 'some-data');
expect(secureGet('temp')).toBe('some-data');
secureSet('temp', '');
expect(secureGet('temp')).toBe('');
});
});
// ================================================================
// 3. 明文 fallback 读取兼容
// ================================================================
describe('明文 fallback 兼容', () => {
it('直接读取无前缀的明文存储', () => {
storage.set('access_token', 'plain-token-123');
expect(secureGet('access_token')).toBe('plain-token-123');
});
it('加密存储优先于明文存储', () => {
storage.set('access_token', 'plain-token');
secureSet('access_token', 'encrypted-token');
expect(secureGet('access_token')).toBe('encrypted-token');
});
it('存储值非字符串时回退到明文', () => {
// getStorageSync mock 返回 ''默认值prefixed key 不存在
// 明文 key 也返回 ''
expect(secureGet('nonexistent')).toBe('');
});
});
// ================================================================
// 4. migrateLegacyStorage 迁移逻辑
// ================================================================
describe('migrateLegacyStorage', () => {
it('将明文数据迁移到加密存储并删除原始 key', () => {
storage.set('access_token', 'legacy-token');
storage.set('refresh_token', 'legacy-refresh');
storage.set('user_data', '{"id":"123"}');
migrateLegacyStorage();
// 明文 key 应被删除
expect(storage.has('access_token')).toBe(false);
expect(storage.has('refresh_token')).toBe(false);
expect(storage.has('user_data')).toBe(false);
// 加密 key 存在且可解密
expect(secureGet('access_token')).toBe('legacy-token');
expect(secureGet('refresh_token')).toBe('legacy-refresh');
expect(secureGet('user_data')).toBe('{"id":"123"}');
});
it('已加密的 key 不重复迁移', () => {
secureSet('access_token', 'already-encrypted');
const spy = vi.spyOn(storage, 'set');
migrateLegacyStorage();
// 不应产生新的 set 调用(除了可能内部的 secureSet 已有的)
// 关键:值不变
expect(secureGet('access_token')).toBe('already-encrypted');
});
it('非字符串的明文数据不迁移', () => {
// 我们的 mock getStorageSync 对不存在的 key 返回 ''
// 模拟: 存一个空字符串的明文值(不迁移)
storage.set('tenant_id', '');
migrateLegacyStorage();
// 空字符串不被视为有效数据,不做迁移
expect(storage.has('_es_tenant_id')).toBe(false);
});
it('MIGRATION_KEYS 中未列出的 key 不受影响', () => {
storage.set('custom_key', 'custom-value');
migrateLegacyStorage();
expect(storage.get('custom_key')).toBe('custom-value');
expect(storage.has('_es_custom_key')).toBe(false);
});
it('prefixed key 存在但非 aes: 前缀时重新加密为 AES', () => {
// 直接放一个非 aes: 前缀的值到 prefixed key
// migrateLegacyStorage 发现 prefixed key 存在且不以 aes: 开头,
// 会调用 secureGet → 明文 fallback 返回该值,然后 secureSet 重新加密
storage.set('_es_access_token', 'legacy-ciphertext-or-plain');
migrateLegacyStorage();
// 应被重新加密为 AES 格式
const stored = storage.get('_es_access_token')!;
expect(stored.startsWith('aes:')).toBe(true);
});
});
// ================================================================
// 5. secureRemove
// ================================================================
describe('secureRemove', () => {
it('删除加密存储', () => {
secureSet('remove_test', 'to-be-removed');
expect(secureGet('remove_test')).toBe('to-be-removed');
secureRemove('remove_test');
expect(secureGet('remove_test')).toBe('');
});
it('删除不存在的 key 不报错', () => {
expect(() => secureRemove('nonexistent')).not.toThrow();
});
});
// ================================================================
// 6. Base64 边界
// ================================================================
describe('Base64 边界', () => {
it('存储值可正确通过 base64 编解码', () => {
const value = 'Test with special chars: ';
secureSet('b64_test', value);
const result = secureGet('b64_test');
expect(result).toBe(value);
});
it('二进制丰富内容加解密', () => {
// 构造包含各种 Unicode 范围的字符串
const chars = [];
for (let i = 0x20; i < 0x7f; i++) chars.push(String.fromCharCode(i));
// CJK 基本区
for (let i = 0x4e00; i < 0x4e10; i++) chars.push(String.fromCharCode(i));
// emoji
chars.push('\u{1F600}', '\u{1F680}', '\u{1F970}');
const value = chars.join('');
secureSet('b64_binary', value);
expect(secureGet('b64_binary')).toBe(value);
});
it('损坏的 AES 密文返回 null 后走明文 fallback', () => {
storage.set('_es_corrupt', 'aes:INVALID_BASE64!!!');
// aesDecrypt 失败返回 null然后尝试 XOR 也失败,最后返回原始字符串
expect(secureGet('corrupt')).toBe('aes:INVALID_BASE64!!!');
});
});
});

View File

@@ -19,7 +19,7 @@ export default React.memo(function EmptyState({
}: EmptyStateProps) {
const displayChar = icon || text.charAt(0);
return (
<View className='empty-state'>
<View className='empty-state' role="status" aria-live="polite">
<View className='empty-state-icon-wrap'>
<Text className='empty-state-icon-char'>{displayChar}</Text>
</View>

View File

@@ -12,7 +12,7 @@ export default React.memo(function ErrorState({
onRetry,
}: ErrorStateProps) {
return (
<View className='error-state'>
<View className='error-state' role="alert" aria-live="assertive">
<Text className='error-state-icon'></Text>
<Text className='error-state-text'>{text}</Text>
{onRetry && (

View File

@@ -9,7 +9,7 @@ interface LoadingProps {
export default React.memo(function Loading({ text = '加载中...' }: LoadingProps) {
const isListEnd = text !== '加载中...' && !text.includes('加载');
return (
<View className={`loading-state ${isListEnd ? 'loading-state--end' : ''}`}>
<View className={`loading-state ${isListEnd ? 'loading-state--end' : ''}`} role="status" aria-live="polite" aria-label="加载中">
{!isListEnd && <View className='loading-spinner' />}
<Text className='loading-state-text'>{text}</Text>
</View>

View File

@@ -15,6 +15,8 @@
justify-content: center;
position: relative;
@include focus-ring;
&--active {
.seg-tab__text {
color: var(--tk-pri);
@@ -54,6 +56,8 @@
justify-content: center;
transition: all 0.2s;
@include focus-ring;
&--active {
background: var(--tk-pri);
box-shadow: var(--tk-shadow-tab);

View File

@@ -21,11 +21,13 @@ export default React.memo(function SegmentTabs({
variant = 'underline',
}: SegmentTabsProps) {
return (
<View className={`seg-tabs seg-tabs--${variant}`}>
<View className={`seg-tabs seg-tabs--${variant}`} role="tablist">
{tabs.map((tab) => (
<View
key={tab.key}
className={`seg-tab ${activeKey === tab.key ? 'seg-tab--active' : ''}`}
role="tab"
aria-selected={activeKey === tab.key}
onClick={() => onChange(tab.key)}
>
<Text className='seg-tab__text'>{tab.label}</Text>

View File

@@ -210,6 +210,8 @@ export default React.memo(function TrendChart({
{tooltip && (
<View
className='trend-tooltip'
role="tooltip"
aria-live="polite"
style={{ left: `${tooltip.x}px`, top: '8px' }}
>
<Text className='trend-tooltip-text'>

View File

@@ -25,6 +25,8 @@
cursor: pointer;
-webkit-tap-highlight-color: transparent;
@include focus-ring;
&--active {
.doctor-tabbar__icon {
transform: scale(1.15);

View File

@@ -31,11 +31,13 @@ export default function DoctorTabBar({ active }: DoctorTabBarProps) {
};
return (
<View className="doctor-tabbar">
<View className="doctor-tabbar" role="tablist">
{DOCTOR_TABS.map((tab) => (
<View
key={tab.key}
className={`doctor-tabbar__item ${tab.key === activeKey ? 'doctor-tabbar__item--active' : ''}`}
role="tab"
aria-selected={tab.key === activeKey}
onClick={() => handleTab(tab)}
>
<Text className="doctor-tabbar__icon">{tab.key === activeKey ? tab.activeIcon : tab.icon}</Text>

View File

@@ -17,6 +17,12 @@
display: flex;
align-items: center;
transition: border-color 0.2s;
&:focus-within {
outline: $focus-ring-width solid $focus-ring-color;
outline-offset: $focus-ring-offset;
border-color: var(--tk-pri);
}
}
&__control {

View File

@@ -45,6 +45,8 @@ const FormInput: React.FC<FormInputProps> = ({
type={type}
maxlength={maxLength}
disabled={disabled}
aria-invalid={!!error}
aria-label={label || placeholder}
/>
</View>
{error && <Text className='form-input__error'>{error}</Text>}

View File

@@ -12,7 +12,7 @@ const LoadingCard: React.FC<LoadingCardProps> = ({
layout = 'card',
}) => {
return (
<View className={`loading-card-group loading-card-group--${layout}`}>
<View className={`loading-card-group loading-card-group--${layout}`} role="status" aria-label="内容加载中">
{Array.from({ length: count }, (_, i) => (
<View key={i} className="loading-card">
{layout === 'card' && (

View File

@@ -29,6 +29,8 @@
transform: scale(0.98);
}
@include focus-ring;
&--disabled {
opacity: 0.5;
box-shadow: none;

View File

@@ -28,7 +28,7 @@ const PrimaryButton: React.FC<PrimaryButtonProps> = ({
].filter(Boolean).join(' ');
return (
<View className={cls} onClick={!disabled && !loading ? onClick : undefined}>
<View className={cls} role="button" aria-disabled={disabled} aria-busy={loading} onClick={!disabled && !loading ? onClick : undefined}>
{loading && <View className='primary-btn__spinner' />}
<Text className='primary-btn__text'>{children}</Text>
</View>

View File

@@ -18,6 +18,8 @@
transform: scale(0.98);
}
@include focus-ring;
&--disabled {
opacity: 0.5;
}

View File

@@ -22,7 +22,7 @@ const SecondaryButton: React.FC<SecondaryButtonProps> = ({
].filter(Boolean).join(' ');
return (
<View className={cls} onClick={!disabled ? onClick : undefined}>
<View className={cls} role="button" aria-disabled={disabled} onClick={!disabled ? onClick : undefined}>
<Text className='secondary-btn__text'>{children}</Text>
</View>
);

View File

@@ -0,0 +1,15 @@
// Focus ring mixin for keyboard accessibility
$focus-ring-color: #1890ff;
$focus-ring-width: 2px;
$focus-ring-offset: 2px;
@mixin focus-ring {
&:focus {
outline: none;
box-shadow: 0 0 0 $focus-ring-offset $focus-ring-color;
}
&:focus-visible {
outline: $focus-ring-width solid $focus-ring-color;
outline-offset: $focus-ring-offset;
}
}

View File

@@ -68,3 +68,6 @@ $shadow-btn: 0 4px 16px rgba(196, 98, 58, 0.3); // 主按钮阴影
$shadow-tab: 0 2px 8px rgba(196, 98, 58, 0.25); // 选中Tab阴影
$shadow-btn-doc: 0 4px 16px rgba(58, 107, 140, 0.3); // 医生端按钮阴影
$shadow-tab-doc: 0 2px 8px rgba(58, 107, 140, 0.25); // 医生端Tab阴影
// ─── 焦点环(无障碍)───
@import 'focus-ring';