Files
PathEditor/tests/unit/app-store.test.ts
T
Serendipity cbf99f12fd v5.1: 全面代码审查修复 — 安全加固 + 功能修复 + 测试补全 + 工程化
安全修复 (CRITICAL):
- 启用 CSP (default-src 'self')
- read_text_file 限制文件扩展名白名单 (.json/.csv/.txt)
- capabilities 显式声明窗口权限
- profile 名校验增强 (null 字节/控制字符/长度限制)

功能修复 (HIGH):
- AnalyzeDialog 重新打开时正确刷新数据
- UndoRedoButtons 订阅路径长度变化确保响应性
- 禁用状态持久化错误处理 (.catch → console.warn)
- 硬编码中文全部迁移到 i18n (6 处)
- PATH 长度检查改用 UTF-16 字符计数
- PATH 写入前 null 字节校验
- CLI export 拒绝写入系统目录
- savePaths 职责分离: window.confirm → Tauri ask() 对话框

代码质量 (MEDIUM):
- 导入路径统一过滤 (sanitize_paths: null 字节/分号/空白)
- 原子写入 (atomic_write: disabled.json + profiles)
- 验证缓存自动清理 (PathTable useEffect)
- Scanner 线程错误处理改进 (.unwrap → .map_err)
- Ctrl+F 去重 (移除 use-keyboard 重复处理)
- Profile 路径列表 key 修复 (index → path)
- 生产构建启用日志插件 (Warn 级别)
- export_paths JSON 序列化改 expect

测试:
- Rust: 35 → 48 测试 (+13)
- Frontend: 80 → 85 测试 (+5)
- Vitest 全局 jsdom + 覆盖率阈值 (80%)
- 安装 @vitest/coverage-v8 + test:coverage 脚本
- 移除未使用的 @testing-library/jest-dom

工程化:
- CI 添加 Cargo 缓存 (Swatinem/rust-cache@v2)
- CI 添加 cargo fmt --check
- tsconfig.test.json 覆盖测试文件类型检查
- cargo fmt 全量格式化

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:17:27 +08:00

308 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock @tauri-apps/api/core
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn().mockResolvedValue(undefined),
}));
// Mock i18n
vi.mock('@/i18n', () => ({
default: { t: vi.fn((key: string, opts?: Record<string, unknown>) => {
if (key === 'status.loaded') return `已加载 ${opts?.sysCount} 条系统 PATH${opts?.userCount} 条用户 PATH`;
if (key === 'status.error') return '加载失败';
if (key === 'status.saving') return '正在保存...';
if (key === 'status.saved') return '保存成功';
if (key === 'status.warning_backup') return '备份失败,但保存继续';
if (key === 'status.readonly') return '只读模式';
if (key === 'status.deleted') return `已删除 ${opts?.count} 条路径`;
return key;
}) },
}));
import type { PathEntry } from '../../src/core/path-entry';
function pe(s: string, enabled: boolean = true): PathEntry {
return { path: s, enabled };
}
import { useAppStore } from '@/store/app-store';
import { UndoRedoManager, TargetType } from '@/core/undo-redo';
import { invoke } from '@tauri-apps/api/core';
const mockedInvoke = vi.mocked(invoke);
function resetStore() {
useAppStore.setState({
sysPaths: [],
userPaths: [],
undoRedo: new UndoRedoManager(50),
_savedSys: [],
_savedUser: [],
isModified: false,
isLoading: false,
isSaving: false,
selectedIndices: [],
searchQuery: '',
statusMessage: '',
});
}
describe('app-store CRUD', () => {
beforeEach(() => {
vi.clearAllMocks();
resetStore();
});
it('addPath 追加到 sysPaths', () => {
useAppStore.getState().addPath('C:\\test', TargetType.SYSTEM);
const s = useAppStore.getState();
expect(s.sysPaths.map(e => e.path)).toEqual(['C:\\test']);
expect(s.isModified).toBe(true);
expect(s.undoRedo.historyLength).toBe(1);
});
it('addPath 追加到 userPaths', () => {
useAppStore.getState().addPath('D:\\user', TargetType.USER);
const s = useAppStore.getState();
expect(s.userPaths.map(e => e.path)).toEqual(['D:\\user']);
expect(s.sysPaths).toEqual([]);
});
it('editPath 替换正确位置', () => {
const store = useAppStore.getState();
store.addPath('C:\\old', TargetType.SYSTEM);
store.editPath(0, 'C:\\new', TargetType.SYSTEM);
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\new']);
});
it('editPath 越界 index 无崩溃', () => {
expect(() => {
useAppStore.getState().editPath(99, 'X', TargetType.SYSTEM);
}).not.toThrow();
});
it('deletePaths 单选删除', () => {
const store = useAppStore.getState();
store.addPath('A', TargetType.SYSTEM);
store.addPath('B', TargetType.SYSTEM);
store.addPath('C', TargetType.SYSTEM);
store.deletePaths([1], TargetType.SYSTEM);
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A', 'C']);
expect(useAppStore.getState().selectedIndices).toEqual([]);
});
it('deletePaths 多选删除(逆序排序一次 undo 覆盖)', () => {
const store = useAppStore.getState();
store.addPath('A', TargetType.USER);
store.addPath('B', TargetType.USER);
store.addPath('C', TargetType.USER);
store.addPath('D', TargetType.USER);
store.deletePaths([1, 3], TargetType.USER);
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['A', 'C']);
});
it('deletePaths 非连续多选删除后可 undo 恢复到正确位置', () => {
const store = useAppStore.getState();
store.addPath('A', TargetType.SYSTEM);
store.addPath('B', TargetType.SYSTEM);
store.addPath('C', TargetType.SYSTEM);
store.addPath('D', TargetType.SYSTEM);
store.deletePaths([1, 3], TargetType.SYSTEM);
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A', 'C']);
useAppStore.getState().undo();
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A', 'B', 'C', 'D']);
});
it('moveUp index=0 无操作', () => {
const store = useAppStore.getState();
store.addPath('A', TargetType.SYSTEM);
store.moveUp(0, TargetType.SYSTEM);
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A']);
});
it('moveUp 正常交换位置', () => {
const store = useAppStore.getState();
store.addPath('A', TargetType.SYSTEM);
store.addPath('B', TargetType.SYSTEM);
store.moveUp(1, TargetType.SYSTEM);
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['B', 'A']);
expect(useAppStore.getState().selectedIndices).toEqual([0]);
});
it('moveDown 末位无操作', () => {
const store = useAppStore.getState();
store.addPath('A', TargetType.USER);
store.moveDown(0, TargetType.USER);
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['A']);
});
it('cleanPaths 移除无效路径并返回 removed', () => {
const store = useAppStore.getState();
store.addPath('C:\\valid', TargetType.SYSTEM);
store.addPath(':::invalid:::', TargetType.SYSTEM);
// is_valid_path_format 拒绝全标点路径
const removed = store.cleanPaths(TargetType.SYSTEM, (p) => !p.includes(':::'));
expect(removed).toEqual([':::invalid:::']);
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\valid']);
});
it('replacePaths 整体替换列表', () => {
const store = useAppStore.getState();
store.addPath('old1', TargetType.USER);
store.addPath('old2', TargetType.USER);
store.replacePaths(TargetType.USER, ['new1', 'new2', 'new3']);
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['new1', 'new2', 'new3']);
});
it('clearPaths 清空列表', () => {
const store = useAppStore.getState();
store.addPath('A', TargetType.SYSTEM);
store.addPath('B', TargetType.SYSTEM);
store.clearPaths(TargetType.SYSTEM);
expect(useAppStore.getState().sysPaths).toEqual([]);
});
it('clearPaths 空列表无操作', () => {
const store = useAppStore.getState();
store.clearPaths(TargetType.USER);
expect(useAppStore.getState().undoRedo.historyLength).toBe(0);
});
});
describe('undo/redo', () => {
beforeEach(() => {
vi.clearAllMocks();
resetStore();
});
it('undo 恢复操作前状态', () => {
useAppStore.getState().addPath('test', TargetType.SYSTEM);
expect(useAppStore.getState().sysPaths.length).toBe(1);
useAppStore.getState().undo();
expect(useAppStore.getState().sysPaths).toEqual([]);
});
it('redo 回到操作后状态', () => {
const store = useAppStore.getState();
store.addPath('test', TargetType.SYSTEM);
store.undo();
store.redo();
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['test']);
});
it('undo/redo 正确更新 isModified', () => {
const store = useAppStore.getState();
// 设置已保存快照
useAppStore.setState({ _savedSys: [], _savedUser: [] });
store.addPath('test', TargetType.SYSTEM);
expect(useAppStore.getState().isModified).toBe(true);
store.undo();
expect(useAppStore.getState().isModified).toBe(false);
store.redo();
expect(useAppStore.getState().isModified).toBe(true);
});
});
describe('loadPaths', () => {
beforeEach(() => {
vi.clearAllMocks();
resetStore();
});
it('成功加载', async () => {
mockedInvoke.mockResolvedValueOnce(['C:\\sys1', 'C:\\sys2']);
mockedInvoke.mockResolvedValueOnce(['D:\\usr1']);
await useAppStore.getState().loadPaths();
const s = useAppStore.getState();
expect(s.sysPaths.map(e => e.path)).toEqual(['C:\\sys1', 'C:\\sys2']);
expect(s.userPaths.map(e => e.path)).toEqual(['D:\\usr1']);
expect(s.isLoading).toBe(false);
expect(s.isModified).toBe(false);
});
it('加载失败时 isLoading 重置', async () => {
mockedInvoke.mockRejectedValueOnce(new Error('reg error'));
mockedInvoke.mockResolvedValueOnce([]);
await useAppStore.getState().loadPaths();
const s = useAppStore.getState();
expect(s.isLoading).toBe(false);
expect(s.statusMessage).toContain('加载失败');
});
});
describe('savePaths', () => {
beforeEach(() => {
vi.clearAllMocks();
resetStore();
useAppStore.setState({ sysPaths: [pe('A')], userPaths: [pe('B')] });
});
it('保存成功', async () => {
mockedInvoke.mockResolvedValue(undefined);
await useAppStore.getState().savePaths();
const s = useAppStore.getState();
expect(s.isSaving).toBe(false);
expect(s.isModified).toBe(false);
expect(s.statusMessage).toBe('保存成功');
});
it('部分失败时报告具体 hive', async () => {
mockedInvoke
.mockResolvedValueOnce(undefined) // backup_registry
.mockResolvedValueOnce(undefined) // save_system_paths
.mockRejectedValueOnce('权限不足'); // save_user_paths
await useAppStore.getState().savePaths();
const s = useAppStore.getState();
expect(s.isSaving).toBe(false);
expect(s.statusMessage).toContain('用户 PATH 保存失败');
});
it('isSaving 守卫:并发第二次调用直接返回', async () => {
let resolveAll: (v: unknown) => void;
const pending = new Promise((r) => { resolveAll = r; });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockedInvoke.mockReturnValue(pending as any);
// 第一次调用(不等它完成,停在 Promise.allSettled
const p1 = useAppStore.getState().savePaths();
// 第二次调用应被 isSaving 守卫拦截(此时 isSaving=true
const r2 = useAppStore.getState().savePaths();
// 第二次调用同步返回 false(被守卫拦截)
await expect(r2).resolves.toBe(false);
// 放行第一次调用的所有 invoke
resolveAll!(undefined);
await p1;
});
});
describe('initialize', () => {
beforeEach(() => {
vi.clearAllMocks();
resetStore();
});
it('管理员模式初始化', async () => {
mockedInvoke
.mockResolvedValueOnce(true) // check_admin
.mockResolvedValueOnce(['S1']) // load_system_paths
.mockResolvedValueOnce(['U1']); // load_user_paths
await useAppStore.getState().initialize();
const s = useAppStore.getState();
expect(s.isAdmin).toBe(true);
expect(s.sysPaths.map(e => e.path)).toEqual(['S1']);
expect(s.userPaths.map(e => e.path)).toEqual(['U1']);
});
it('非管理员初始化进入只读模式', async () => {
mockedInvoke
.mockResolvedValueOnce(false) // check_admin
.mockResolvedValueOnce([]) // load_system_paths
.mockResolvedValueOnce([]); // load_user_paths
await useAppStore.getState().initialize();
expect(useAppStore.getState().isAdmin).toBe(false);
// statusMessage 被后续 loadPaths 覆盖为加载完成消息,但 isAdmin=false 不变
});
});