Files
PathEditor/tests/unit/undo-redo.test.ts
Serendipity 9453006310
CI / 前端检查 (格式 + 类型 + Lint + 测试 + 覆盖率) (push) Has been cancelled
CI / Rust 检查 (格式 + Check + Clippy + Test) (push) Has been cancelled
chore: 同步 v5.0 基础设施完善到 v5.1
从 v5.0 cherry-pick 的开源项目基础设施改进:

新增配置文件:
- .editorconfig, .gitattributes, .prettierrc, .markdownlint.json
- commitlint.config.js

新增 GitHub 社区文件:
- .github/dependabot.yml — 依赖自动更新
- .github/CODEOWNERS — 自动 PR 审查分配
- .github/FUNDING.yml — 开源赞助入口

新增文档:
- ROADMAP.md — 路线图
- SUPPORT.md — 帮助指南
- docs/screenshots/ — 应用截图

新增 Git Hooks:
- .husky/pre-commit — lint-staged 自动格式化+修复
- .husky/commit-msg — commitlint 校验

CI 强化:
- 新增 Prettier 格式检查
- 新增 Vitest 覆盖率 + Codecov 上报
- 保留 v5.1 已有的 rust-cache + jsdom 全局环境

修复:
- index.html 标题 v4.0 → v5.1
- PathEditDialog set-state-in-effect 改用 useRef prevOpen 守卫
- merge-preview.test.tsx no-explicit-any 修复
- 所有 TS/TSX 文件 Prettier 格式化统一

v5.1 保留特性:
- @tanstack/react-virtual 虚拟滚动
- jsdom 全局测试环境
- Swatinem/rust-cache CI 加速
- 105 测试全部通过
2026-06-19 19:24:03 +08:00

258 lines
7.1 KiB
TypeScript
Raw Permalink 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, beforeEach } from 'vitest';
import {
UndoRedoManager,
OperationType,
TargetType,
type OpRecord,
} from '../../src/core/undo-redo';
import type { PathEntry } from '../../src/core/path-entry';
function pe(s: string, enabled: boolean = true): PathEntry {
return { path: s, enabled };
}
function makeRecord(
type: OperationType,
target: TargetType,
index: number,
count: number,
oldPaths: PathEntry[],
newPaths: PathEntry[],
): OpRecord {
return { type, target, index, count, oldPaths, newPaths };
}
describe('UndoRedoManager', () => {
let mgr: UndoRedoManager;
let sys: PathEntry[];
let user: PathEntry[];
beforeEach(() => {
mgr = new UndoRedoManager(50);
sys = [pe('C:\\Windows'), pe('C:\\Program Files')];
user = [pe('C:\\Users\\me\\AppData')];
});
it('初始不可撤销不可重做', () => {
expect(mgr.canUndo()).toBe(false);
expect(mgr.canRedo()).toBe(false);
});
it('ADD 撤销/重做', () => {
sys.push(pe('C:\\NewPath'));
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], [pe('C:\\NewPath')]));
const u = mgr.undo(sys, user)!;
expect(u[0].map((e) => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
const r = mgr.redo(...u)!;
expect(r[0].map((e) => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files', 'C:\\NewPath']);
});
it('DELETE 撤销/重做', () => {
const removed = sys[0];
mgr.push(makeRecord(OperationType.DELETE, TargetType.SYSTEM, 0, 1, [removed], []));
sys.splice(0, 1);
const u = mgr.undo(sys, user)!;
expect(u[0][0].path).toBe(removed.path);
const r = mgr.redo(...u)!;
expect(r[0].map((e) => e.path)).toEqual(['C:\\Program Files']);
});
it('EDIT 撤销/重做', () => {
mgr.push(
makeRecord(
OperationType.EDIT,
TargetType.SYSTEM,
0,
1,
[pe('C:\\Windows')],
[pe('C:\\Edited')],
),
);
sys[0] = pe('C:\\Edited');
const u = mgr.undo(sys, user)!;
expect(u[0][0].path).toBe('C:\\Windows');
const r = mgr.redo(...u)!;
expect(r[0][0].path).toBe('C:\\Edited');
});
it('MOVE_UP 撤销/重做', () => {
mgr.push(makeRecord(OperationType.MOVE_UP, TargetType.SYSTEM, 1, 1, [], []));
[sys[0], sys[1]] = [sys[1], sys[0]];
const u = mgr.undo(sys, user)!;
expect(u[0].map((e) => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
const r = mgr.redo(...u)!;
expect(r[0].map((e) => e.path)).toEqual(['C:\\Program Files', 'C:\\Windows']);
});
it('MOVE_DOWN 撤销/重做', () => {
mgr.push(makeRecord(OperationType.MOVE_DOWN, TargetType.SYSTEM, 0, 1, [], []));
[sys[0], sys[1]] = [sys[1], sys[0]];
const u = mgr.undo(sys, user)!;
expect(u[0].map((e) => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
});
it('CLEAN 撤销/重做', () => {
const old = [...sys];
const cleaned = [pe('C:\\Windows')];
mgr.push(makeRecord(OperationType.CLEAN, TargetType.SYSTEM, 0, 2, old, cleaned));
sys = cleaned;
const u = mgr.undo(sys, user)!;
expect(u[0]).toEqual(old);
const r = mgr.redo(...u)!;
expect(r[0]).toEqual(cleaned);
});
it('CLEAR 撤销/重做', () => {
const old = [...sys];
mgr.push(makeRecord(OperationType.CLEAR, TargetType.SYSTEM, 0, 2, old, []));
sys = [];
const u = mgr.undo(sys, user)!;
expect(u[0]).toEqual(old);
const r = mgr.redo(...u)!;
expect(r[0]).toEqual([]);
});
it('IMPORT 撤销/重做', () => {
const old = [...sys];
const imported = [pe('C:\\New1'), pe('C:\\New2')];
mgr.push(makeRecord(OperationType.IMPORT, TargetType.SYSTEM, 0, 2, old, imported));
sys = imported;
const u = mgr.undo(sys, user)!;
expect(u[0]).toEqual(old);
const r = mgr.redo(...u)!;
expect(r[0]).toEqual(imported);
});
it('新操作后截断重做分支', () => {
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], [pe('first')]));
mgr.undo(sys, user);
expect(mgr.canRedo()).toBe(true);
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], [pe('second')]));
expect(mgr.canRedo()).toBe(false);
});
it('空历史栈的撤销与重做', () => {
expect(mgr.undo(sys, user)).toBeNull();
expect(mgr.redo(sys, user)).toBeNull();
});
it('超出栈底/栈顶的安全处理', () => {
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], [pe('C:\\NewPath')]));
sys.push(pe('C:\\NewPath'));
// undo一次
mgr.undo(sys, user);
// 再次undo,此时应到达底部返回null
expect(mgr.undo(sys, user)).toBeNull();
// redo一次
mgr.redo(sys, user);
// 再次redo,应到达顶部返回null
expect(mgr.redo(sys, user)).toBeNull();
});
it('超出最大历史容量时移除最旧记录', () => {
const small = new UndoRedoManager(3);
for (let i = 0; i < 5; i++) {
small.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], [pe(`path_${i}`)]));
}
expect(small.historyLength).toBe(3);
});
it('非连续多选 DELETE 撤销恢复到原始位置', () => {
// 扩展初始数组
sys.push(pe('C:\\Extra1'), pe('C:\\Extra2'));
const old = [...sys];
// 删除 indices [1, 3]C:\Program Files 和 C:\Extra2
const removed = [sys[1], sys[3]];
mgr.push({
type: OperationType.DELETE,
target: TargetType.SYSTEM,
index: 1,
count: 2,
oldPaths: removed,
newPaths: [],
indices: [1, 3],
});
sys.splice(3, 1);
sys.splice(1, 1);
const u = mgr.undo(sys, user)!;
expect(u[0]).toEqual(old);
const r = mgr.redo(...u)!;
expect(r[0].map((e) => e.path)).toEqual(['C:\\Windows', 'C:\\Extra1']);
});
it('操作 USER 路径', () => {
user.push(pe('C:\\NewUserPath'));
mgr.push(makeRecord(OperationType.ADD, TargetType.USER, 1, 1, [], [pe('C:\\NewUserPath')]));
const u = mgr.undo(sys, user)!;
expect(u[1].map((e) => e.path)).toEqual(['C:\\Users\\me\\AppData']);
expect(u[0].map((e) => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
});
it('TOGGLE 撤销/重做', () => {
sys[0] = pe('C:\\Windows', false);
mgr.push(
makeRecord(
OperationType.TOGGLE,
TargetType.SYSTEM,
0,
1,
[pe('C:\\Windows', true)],
[pe('C:\\Windows', false)],
),
);
const u = mgr.undo(sys, user)!;
expect(u[0][0].enabled).toBe(true);
const r = mgr.redo(...u)!;
expect(r[0][0].enabled).toBe(false);
});
it('IMPORT_BOTH 撤销/重做(同时修改系统和用户路径)', () => {
const oldSys = [...sys];
const oldUser = [...user];
const newSys = [pe('C:\\ImportedSys')];
const newUser = [pe('C:\\ImportedUser')];
mgr.push({
type: OperationType.IMPORT_BOTH,
target: TargetType.SYSTEM,
index: 0,
count: 0,
oldPaths: oldSys,
newPaths: newSys,
oldPathsOther: oldUser,
newPathsOther: newUser,
});
sys = newSys;
user = newUser;
const u = mgr.undo(sys, user)!;
expect(u[0]).toEqual(oldSys);
expect(u[1]).toEqual(oldUser);
const r = mgr.redo(...u)!;
expect(r[0]).toEqual(newSys);
expect(r[1]).toEqual(newUser);
});
});