mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-29 18:15:55 +08:00
71b98e308a
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1075 lines
29 KiB
Markdown
1075 lines
29 KiB
Markdown
# v4.3 路径启用/禁用 + E2E 测试 — 实现计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 实现路径软开关 + Playwright E2E 测试,数据模型从 `string[]` 迁移到 `PathEntry[]`
|
||
|
||
**Architecture:** 分两大阶段。Phase 1:数据模型迁移 + 路径启用/禁用(15 个 Task)。Phase 2:E2E 测试(4 个 Task)。Phase 1 必须完整完成且测试通过后才能开始 Phase 2。
|
||
|
||
**Tech Stack:** TypeScript strict + React 19 + Zustand + Rust + Tauri IPC + @playwright/test
|
||
|
||
---
|
||
|
||
# Phase 1: 路径启用/禁用
|
||
|
||
## 文件清单
|
||
|
||
| 操作 | 文件 |
|
||
|------|------|
|
||
| **新增** | `src/core/path-entry.ts` |
|
||
| **新增** | `src-tauri/src/commands/disabled.rs` |
|
||
| **修改** | `src/core/undo-redo.ts` |
|
||
| **修改** | `src/core/path-manager.ts` |
|
||
| **修改** | `src/core/import-export.ts` |
|
||
| **修改** | `src/core/validation.ts` |
|
||
| **修改** | `src/store/app-store.ts` |
|
||
| **修改** | `src/components/path-list/PathTable.tsx` |
|
||
| **修改** | `src/components/path-list/MergePreview.tsx` |
|
||
| **修改** | `src/hooks/use-app-actions.ts` |
|
||
| **修改** | `src/components/layout/AppShell.tsx` |
|
||
| **修改** | `src-tauri/src/commands/mod.rs` |
|
||
| **修改** | `src-tauri/src/lib.rs` |
|
||
| **修改** | `src-tauri/src/commands/registry.rs` |
|
||
| **修改** | `tests/unit/undo-redo.test.ts` |
|
||
| **修改** | `tests/unit/app-store.test.ts` |
|
||
| **修改** | `tests/unit/import-export.test.ts` |
|
||
| **修改** | `tests/unit/path-manager.test.ts` |
|
||
| **修改** | `tests/unit/validation.test.ts` |
|
||
|
||
---
|
||
|
||
### Task 1: 创建 PathEntry 类型 + TOGGLE 操作类型
|
||
|
||
**Files:**
|
||
- Create: `src/core/path-entry.ts`
|
||
- Modify: `src/core/undo-redo.ts:5-7, 13-22`
|
||
|
||
- [ ] **Step 1: 创建 PathEntry 模块**
|
||
|
||
```typescript
|
||
// src/core/path-entry.ts
|
||
export interface PathEntry {
|
||
path: string;
|
||
enabled: boolean;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: undo-redo.ts 新增 TOGGLE,更新类型**
|
||
|
||
```typescript
|
||
// src/core/undo-redo.ts
|
||
import type { PathEntry } from './path-entry';
|
||
|
||
export const OperationType = {
|
||
ADD: 0, DELETE: 1, EDIT: 2, MOVE_UP: 3, MOVE_DOWN: 4, CLEAN: 5, CLEAR: 6, IMPORT: 7, TOGGLE: 8,
|
||
} as const;
|
||
export type OperationType = (typeof OperationType)[keyof typeof OperationType];
|
||
|
||
export interface OpRecord {
|
||
type: OperationType;
|
||
target: TargetType;
|
||
index: number;
|
||
count: number;
|
||
oldPaths: PathEntry[];
|
||
newPaths: PathEntry[];
|
||
/** DELETE 操作专用:被删除的各路径的原始 index(升序) */
|
||
indices?: number[];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: undo/redo 新增 TOGGLE case**
|
||
|
||
在 `undo()` 的 switch 中新增:
|
||
```typescript
|
||
case OperationType.TOGGLE:
|
||
target[rec.index] = rec.oldPaths[0];
|
||
break;
|
||
```
|
||
|
||
在 `redo()` 的 switch 中新增:
|
||
```typescript
|
||
case OperationType.TOGGLE:
|
||
target[rec.index] = rec.newPaths[0];
|
||
break;
|
||
```
|
||
|
||
- [ ] **Step 4: 运行 undo-redo 测试确认类型兼容**
|
||
|
||
```bash
|
||
npx vitest run tests/unit/undo-redo.test.ts
|
||
```
|
||
|
||
预期:编译错误,因为测试用 `string[]` 构建 OpRecord 而现在是 `PathEntry[]`。下一步 Task 修测试。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/core/path-entry.ts src/core/undo-redo.ts
|
||
git commit -m "feat: 新增 PathEntry 类型 + TOGGLE 操作类型,undo-redo 用 PathEntry[] 替代 string[]"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 更新所有测试文件适配 PathEntry
|
||
|
||
**Files:**
|
||
- Modify: `tests/unit/undo-redo.test.ts`
|
||
- Modify: `tests/unit/app-store.test.ts`
|
||
- Modify: `tests/unit/import-export.test.ts`
|
||
- Modify: `tests/unit/path-manager.test.ts`
|
||
- Modify: `tests/unit/validation.test.ts`
|
||
|
||
- [ ] **Step 1: 在测试文件顶部添加辅助函数**
|
||
|
||
```typescript
|
||
// 每个测试文件顶部添加
|
||
import type { PathEntry } from '../../src/core/path-entry';
|
||
|
||
function pe(s: string, enabled: boolean = true): PathEntry {
|
||
return { path: s, enabled };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 更新所有 `string[]` → `PathEntry[]`**
|
||
|
||
每个测试文件中:
|
||
- 所有 `['C:\\Windows', 'C:\\Program Files']` → `[pe('C:\\Windows'), pe('C:\\Program Files')]`
|
||
- 所有 `['C:\\']` → `[pe('C:\\')]`
|
||
- 所有 `[]` → `[]` (空数组不变)
|
||
- `makeRecord` 调用中的 `oldPaths`/`newPaths` 参数用 `pe()` 包裹
|
||
- undo/redo 断言改为比较 `.path` 和 `.enabled` 字段
|
||
|
||
示例(undo-redo.test.ts):
|
||
```typescript
|
||
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('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']);
|
||
});
|
||
// ... 其余测试类似适配
|
||
});
|
||
```
|
||
|
||
**关键**: app-store.test.ts 中的 `resetStore` 无需改动(store state 类型改成 PathEntry[] 后初始值 `[]` 不变)。`addPath` 调用改为 `addPath('C:\\test', TargetType.SYSTEM)` — store 内部会将 string 转成 PathEntry。
|
||
|
||
- [ ] **Step 3: 新增 TOGGLE undo/redo 测试**
|
||
|
||
在 `undo-redo.test.ts` 末尾添加:
|
||
```typescript
|
||
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)]));
|
||
sys[0] = 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);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: 运行所有测试确认编译错误全部消除**
|
||
|
||
```bash
|
||
npx tsc --noEmit && npx vitest run
|
||
```
|
||
|
||
预期:测试失败(store 和 core 逻辑还没改),但编译通过。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/
|
||
git commit -m "test: 所有测试适配 PathEntry[] 类型,新增 TOGGLE undo/redo 测试"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: 更新 core 模块适配 PathEntry
|
||
|
||
**Files:**
|
||
- Modify: `src/core/path-manager.ts`
|
||
- Modify: `src/core/import-export.ts`
|
||
- Modify: `src/core/validation.ts`
|
||
|
||
- [ ] **Step 1: path-manager.ts 适配**
|
||
|
||
```typescript
|
||
// src/core/path-manager.ts
|
||
import type { PathEntry } from './path-entry';
|
||
|
||
export interface PathValidation {
|
||
isValid: boolean;
|
||
isDuplicate: boolean;
|
||
isEnvVar: boolean;
|
||
}
|
||
|
||
export function analyzePaths(
|
||
paths: readonly PathEntry[],
|
||
validateFn: (path: string) => boolean,
|
||
): PathValidation[] {
|
||
const result: PathValidation[] = [];
|
||
const seen = new Set<string>();
|
||
|
||
for (const entry of paths) {
|
||
const lower = entry.path.toLowerCase();
|
||
const isDuplicate = seen.has(lower);
|
||
seen.add(lower);
|
||
result.push({ isValid: validateFn(entry.path), isDuplicate, isEnvVar: entry.path.includes('%') });
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
export function pathClean(
|
||
paths: readonly PathEntry[],
|
||
validateFn: (path: string) => boolean,
|
||
): [PathEntry[], PathEntry[]] {
|
||
const analysis = analyzePaths(paths, validateFn);
|
||
const kept: PathEntry[] = [];
|
||
const removed: PathEntry[] = [];
|
||
|
||
for (let i = 0; i < paths.length; i++) {
|
||
const a = analysis[i];
|
||
if (!a.isValid || a.isDuplicate) {
|
||
removed.push(paths[i]);
|
||
} else {
|
||
kept.push(paths[i]);
|
||
}
|
||
}
|
||
|
||
return [kept, removed];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: import-export.ts 适配**
|
||
|
||
`ExportData` 接口改为:
|
||
```typescript
|
||
export interface ExportData {
|
||
system: PathEntry[];
|
||
user: PathEntry[];
|
||
}
|
||
```
|
||
|
||
`exportToJson` / `exportToCsv` 只取 `entry.path`(导出不包含 enabled 状态,因为 JSON/CSV 是跨系统的交换格式):
|
||
```typescript
|
||
export function exportToJson(data: ExportData): string {
|
||
const obj = {
|
||
version: '1.0',
|
||
type: 'PathEditor',
|
||
exported: new Date().toISOString(),
|
||
system: data.system.map(e => e.path),
|
||
user: data.user.map(e => e.path),
|
||
};
|
||
return JSON.stringify(obj, null, 2);
|
||
}
|
||
```
|
||
|
||
`importFromJson` / `importFromCsv` 返回的 `ImportResult` 中路径全标记 `enabled: true`:
|
||
```typescript
|
||
export interface ImportResult {
|
||
system: PathEntry[];
|
||
user: PathEntry[];
|
||
}
|
||
|
||
export function importFromJson(content: string): ImportResult {
|
||
const result: ImportResult = { system: [], user: [] };
|
||
let obj: Record<string, unknown>;
|
||
try {
|
||
obj = JSON.parse(content);
|
||
} catch {
|
||
return result;
|
||
}
|
||
if (typeof obj !== 'object' || obj === null) return result;
|
||
if (Array.isArray(obj.system)) {
|
||
result.system = obj.system
|
||
.filter((p: unknown) => typeof p === 'string' && p.trim().length > 0)
|
||
.map((p: string) => ({ path: p.trim(), enabled: true }));
|
||
}
|
||
if (Array.isArray(obj.user)) {
|
||
result.user = obj.user
|
||
.filter((p: unknown) => typeof p === 'string' && p.trim().length > 0)
|
||
.map((p: string) => ({ path: p.trim(), enabled: true }));
|
||
}
|
||
return result;
|
||
}
|
||
```
|
||
|
||
`flattenImportResult` 返回 `ExportData`(类型自动适配)。
|
||
|
||
- [ ] **Step 3: validation.ts — 无需改动**
|
||
|
||
`is_valid_path_format` 接收 `string`,不受影响。`split_path` 和 `join_path` 操作纯路径字符串,也不受影响。
|
||
|
||
- [ ] **Step 4: 运行测试**
|
||
|
||
```bash
|
||
npx tsc --noEmit && npx vitest run
|
||
```
|
||
|
||
预期:import-export 和 path-manager 相关测试失败(返回类型变了)。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/core/
|
||
git commit -m "refactor: core 模块适配 PathEntry — path-manager、import-export 类型迁移"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Rust 后端新增 disabled.rs + 更新 registry.rs
|
||
|
||
**Files:**
|
||
- Create: `src-tauri/src/commands/disabled.rs`
|
||
- Modify: `src-tauri/src/commands/registry.rs`
|
||
- Modify: `src-tauri/src/commands/mod.rs`
|
||
- Modify: `src-tauri/src/lib.rs`
|
||
|
||
- [ ] **Step 1: 创建 disabled.rs**
|
||
|
||
```rust
|
||
// src-tauri/src/commands/disabled.rs
|
||
use serde::{Deserialize, Serialize};
|
||
use std::fs;
|
||
use std::path::PathBuf;
|
||
|
||
fn disabled_file_path() -> PathBuf {
|
||
dirs::data_dir()
|
||
.or_else(dirs::home_dir)
|
||
.unwrap_or_else(|| PathBuf::from("."))
|
||
.join("PathEditor")
|
||
.join("disabled.json")
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Default)]
|
||
struct DisabledState {
|
||
#[serde(default)]
|
||
system: Vec<String>,
|
||
#[serde(default)]
|
||
user: Vec<String>,
|
||
}
|
||
|
||
/// 保存禁用路径列表
|
||
#[tauri::command]
|
||
pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String> {
|
||
let state = DisabledState { system, user };
|
||
let path = disabled_file_path();
|
||
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent)
|
||
.map_err(|e| format!("无法创建配置目录: {}", e))?;
|
||
}
|
||
|
||
let json = serde_json::to_string_pretty(&state)
|
||
.map_err(|e| format!("JSON 序列化失败: {}", e))?;
|
||
|
||
fs::write(&path, &json)
|
||
.map_err(|e| format!("无法写入 disabled.json: {}", e))?;
|
||
|
||
log::info!("已保存禁用状态到: {}", path.display());
|
||
Ok(())
|
||
}
|
||
|
||
/// 加载禁用路径列表,返回 (system_disabled, user_disabled)
|
||
#[tauri::command]
|
||
pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> {
|
||
let path = disabled_file_path();
|
||
|
||
if !path.exists() {
|
||
return Ok((vec![], vec![]));
|
||
}
|
||
|
||
let content = fs::read_to_string(&path)
|
||
.map_err(|e| format!("无法读取 disabled.json: {}", e))?;
|
||
|
||
if content.trim().is_empty() {
|
||
return Ok((vec![], vec![]));
|
||
}
|
||
|
||
let state: DisabledState = serde_json::from_str(&content)
|
||
.map_err(|e| format!("JSON 解析失败: {}", e))?;
|
||
|
||
Ok((state.system, state.user))
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 更新 registry.rs**
|
||
|
||
`load_paths` 返回 `Vec<String>`(不变——注册表只有字符串)。`save_paths` 接收 `Vec<String>`(不变——store 层传入前过滤 disabled)。
|
||
|
||
无需改动 registry.rs。
|
||
|
||
- [ ] **Step 3: 更新 mod.rs**
|
||
|
||
```rust
|
||
// src-tauri/src/commands/mod.rs
|
||
pub mod registry;
|
||
pub mod system;
|
||
pub mod backup;
|
||
pub mod fs;
|
||
pub mod disabled;
|
||
```
|
||
|
||
- [ ] **Step 4: 更新 lib.rs**
|
||
|
||
在 invoke_handler 宏中新增:
|
||
```rust
|
||
commands::disabled::save_disabled_state,
|
||
commands::disabled::load_disabled_state,
|
||
```
|
||
|
||
- [ ] **Step 5: 编译 + 测试**
|
||
|
||
```bash
|
||
cd src-tauri && cargo check && cargo test
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src-tauri/
|
||
git commit -m "feat: 新增 disabled.rs — 禁用路径 JSON 文件读写"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: 更新 app-store 适配 PathEntry + 新增 togglePath
|
||
|
||
**Files:**
|
||
- Modify: `src/store/app-store.ts`
|
||
|
||
这是 Phase 1 最核心的改动,涉及约 20 处修改。
|
||
|
||
- [ ] **Step 1: 接口类型更新**
|
||
|
||
```typescript
|
||
// app-store.ts
|
||
import type { PathEntry } from '@/core/path-entry';
|
||
|
||
interface AppState {
|
||
sysPaths: PathEntry[];
|
||
userPaths: PathEntry[];
|
||
// ... 其余不变
|
||
|
||
togglePath: (index: number, target: TargetType) => void;
|
||
addPath: (path: string, target: TargetType) => void; // 接收 string,内部转 PathEntry
|
||
// ... 其余方法签名按需调整
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: addPath 改为接收 string,内部转 PathEntry**
|
||
|
||
```typescript
|
||
addPath: (path, target) => {
|
||
const state = get();
|
||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||
const entry: PathEntry = { path, enabled: true };
|
||
const newList = [...list, entry];
|
||
state.undoRedo.push({
|
||
type: OperationType.ADD, target, index: newList.length - 1, count: 1,
|
||
oldPaths: [], newPaths: [entry],
|
||
});
|
||
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||
else set({ userPaths: newList });
|
||
markDirty();
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 3: editPath 适配**
|
||
|
||
```typescript
|
||
editPath: (index, newPath, target) => {
|
||
const state = get();
|
||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||
const oldEntry = list[index];
|
||
if (!oldEntry) return;
|
||
const newEntry: PathEntry = { path: newPath, enabled: oldEntry.enabled };
|
||
state.undoRedo.push({
|
||
type: OperationType.EDIT, target, index, count: 1,
|
||
oldPaths: [oldEntry], newPaths: [newEntry],
|
||
});
|
||
const newList = [...list];
|
||
newList[index] = newEntry;
|
||
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||
else set({ userPaths: newList });
|
||
markDirty();
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 4: deletePaths 适配**
|
||
|
||
```typescript
|
||
deletePaths: (indices, target) => {
|
||
// ... 同上,oldPaths = sortedAsc.map(i => list[i])
|
||
// list[i] 现在返回 PathEntry,oldPaths 自动成为 PathEntry[]
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 5: 新增 togglePath**
|
||
|
||
```typescript
|
||
togglePath: (index, target) => {
|
||
const state = get();
|
||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||
const oldEntry = list[index];
|
||
if (!oldEntry) return;
|
||
const newEntry: PathEntry = { path: oldEntry.path, enabled: !oldEntry.enabled };
|
||
|
||
state.undoRedo.push({
|
||
type: OperationType.TOGGLE, target, index, count: 1,
|
||
oldPaths: [oldEntry], newPaths: [newEntry],
|
||
});
|
||
|
||
const newList = [...list];
|
||
newList[index] = newEntry;
|
||
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||
else set({ userPaths: newList });
|
||
markDirty();
|
||
|
||
// 即时保存禁用状态
|
||
const { sysPaths: sys, userPaths: usr } = get();
|
||
const sysDisabled = sys.filter(e => !e.enabled).map(e => e.path);
|
||
const usrDisabled = usr.filter(e => !e.enabled).map(e => e.path);
|
||
invoke('save_disabled_state', { system: sysDisabled, user: usrDisabled })
|
||
.catch(() => {});
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 6: loadPaths 合并禁用状态**
|
||
|
||
```typescript
|
||
loadPaths: async () => {
|
||
try {
|
||
set({ isLoading: true });
|
||
const [sysArr, userArr, disabledState] = await Promise.all([
|
||
invoke<string[]>('load_system_paths'),
|
||
invoke<string[]>('load_user_paths'),
|
||
invoke<[string[], string[]]>('load_disabled_state').catch(() => [[], []] as [string[], string[]]),
|
||
]);
|
||
|
||
const sysDisabled = new Set(disabledState[0]);
|
||
const usrDisabled = new Set(disabledState[1]);
|
||
|
||
const sysEntries: PathEntry[] = sysArr.map(p => ({ path: p, enabled: !sysDisabled.has(p) }));
|
||
const usrEntries: PathEntry[] = userArr.map(p => ({ path: p, enabled: !usrDisabled.has(p) }));
|
||
|
||
set({
|
||
sysPaths: sysEntries, userPaths: usrEntries,
|
||
_savedSys: [...sysEntries], _savedUser: [...usrEntries],
|
||
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
||
isLoading: false, isModified: false,
|
||
statusMessage: i18n.t('status.loaded', { sysCount: sysArr.length, userCount: userArr.length }),
|
||
});
|
||
} catch (e) {
|
||
set({ isLoading: false, statusMessage: `${i18n.t('status.error')}: ${String(e)}` });
|
||
}
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 7: savePaths 过滤 disabled 路径**
|
||
|
||
```typescript
|
||
savePaths: async () => {
|
||
const state = get();
|
||
if (state.isSaving) return;
|
||
set({ isSaving: true, statusMessage: i18n.t('status.saving') });
|
||
|
||
// 只保存 enabled 的路径
|
||
const sysPaths = state.sysPaths.filter(e => e.enabled).map(e => e.path);
|
||
const userPaths = state.userPaths.filter(e => e.enabled).map(e => e.path);
|
||
// ... 其余逻辑不变,sysPaths/userPaths 是 string[]
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 8: moveUp/moveDown/cleanPaths/replacePaths/clearPaths 适配**
|
||
|
||
这些操作的内部逻辑不变,只是因为 `PathEntry` 是对象,展开/赋值行为与 `string` 一致(都是值语义)。每个操作内部的 `[...list]` 仍然创建浅拷贝,`PathEntry` 是只读的。无需特殊处理。
|
||
|
||
- [ ] **Step 9: undo/redo 适配**
|
||
|
||
undo/redo 接收的参数类型从 `readonly string[]` 改为 `readonly PathEntry[]`,内部逻辑不变。
|
||
|
||
- [ ] **Step 10: 编译 + 类型检查**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
预期:编译通过。
|
||
|
||
- [ ] **Step 11: Commit**
|
||
|
||
```bash
|
||
git add src/store/app-store.ts
|
||
git commit -m "feat: app-store 适配 PathEntry — 新增 togglePath、loadPaths 合并禁用状态、savePaths 过滤 disabled"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 更新 UI 组件(PathTable + MergePreview + AppShell)
|
||
|
||
**Files:**
|
||
- Modify: `src/components/path-list/PathTable.tsx`
|
||
- Modify: `src/components/path-list/MergePreview.tsx`
|
||
- Modify: `src/hooks/use-app-actions.ts`
|
||
- Modify: `src/components/layout/AppShell.tsx`
|
||
|
||
- [ ] **Step 1: PathTable — 新增复选框列**
|
||
|
||
把表头 `#` 替换为复选框:
|
||
```tsx
|
||
// 表头
|
||
<th className="w-10 px-2 py-1">
|
||
<span className="text-xs opacity-50">#</span>
|
||
</th>
|
||
```
|
||
|
||
每行渲染(在 `<td>` 序号列之后、路径列之前,新增复选框列):
|
||
```tsx
|
||
// 序号列改为更窄
|
||
<td className="w-6 px-1 py-0.5 text-xs opacity-50 text-right" style={{ color: 'var(--app-fg)' }}>
|
||
{index + 1}
|
||
</td>
|
||
// 新增复选框列
|
||
<td className="w-6 px-1 py-0.5">
|
||
<input
|
||
type="checkbox"
|
||
checked={pathEntry.enabled}
|
||
onChange={() => {
|
||
const target = tabId === 'system' ? TargetType.SYSTEM : TargetType.USER;
|
||
useAppStore.getState().togglePath(index, target);
|
||
}}
|
||
className="cursor-pointer"
|
||
/>
|
||
</td>
|
||
```
|
||
|
||
路径文字根据 `enabled` 状态添加样式:
|
||
```tsx
|
||
let textColor = 'var(--app-fg)';
|
||
let textDecoration = 'none';
|
||
if (v.state === 'invalid') textColor = '#dc3545';
|
||
else if (v.isDuplicate) textColor = '#fd7e14';
|
||
if (!pathEntry.enabled) {
|
||
textColor = 'var(--app-fg)';
|
||
// 覆盖为灰色
|
||
textColor = '#6b7280';
|
||
textDecoration = 'line-through';
|
||
}
|
||
```
|
||
|
||
注意:`pathEntry` 即 `paths[index]`,需要从 `filtered` 的 `PathRow` 中能访问到 `enabled` 字段。更新 `PathRow` 接口:
|
||
```typescript
|
||
interface PathRow {
|
||
path: string;
|
||
index: number;
|
||
enabled: boolean;
|
||
}
|
||
```
|
||
|
||
`filtered` useMemo:
|
||
```typescript
|
||
const filtered = useMemo<PathRow[]>(() => {
|
||
if (!searchQuery) return paths.map((p, i) => ({ path: p.path, index: i, enabled: p.enabled }));
|
||
const q = searchQuery.toLowerCase();
|
||
const result: PathRow[] = [];
|
||
for (let i = 0; i < paths.length; i++) {
|
||
const p = paths[i];
|
||
if (p.path.toLowerCase().includes(q)) result.push({ path: p.path, index: i, enabled: p.enabled });
|
||
}
|
||
return result;
|
||
}, [paths, searchQuery]);
|
||
```
|
||
|
||
- [ ] **Step 2: MergePreview 适配**
|
||
|
||
`MergePreview` 合并显示 sysPaths + userPaths。改为 `PathEntry[]` 后,只显示 `enabled` 的路径或全部显示(含灰显禁用的)。先保持简单:全部显示,禁用路径同样灰显。
|
||
|
||
```typescript
|
||
// MergePreview.tsx — 所有 sysPaths/userPaths 引用改为 .path 和 .enabled
|
||
```
|
||
|
||
- [ ] **Step 3: use-app-actions 适配**
|
||
|
||
- `handleBrowse` 中 `addPath` 不变(传 string)
|
||
- `handleEdit` 中获取 `list[idx].path` 改为 `list[idx]?.path`
|
||
- `handleClean` 不变(cleanPaths 接收 validateFn)
|
||
- `handleImport` / `handleImportSelect` 中 `replacePaths` 接收 `PathEntry[]`
|
||
- `handleSave` 不变
|
||
|
||
```typescript
|
||
const handleEdit = useCallback(() => {
|
||
const idx = useAppStore.getState().selectedIndices[0];
|
||
if (idx === undefined) return;
|
||
const target = activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM;
|
||
const list = target === TargetType.SYSTEM
|
||
? useAppStore.getState().sysPaths
|
||
: useAppStore.getState().userPaths;
|
||
const entry = list[idx];
|
||
if (entry) setEditDialog({ open: true, index: idx, value: entry.path, target });
|
||
}, [activeTab, setEditDialog]);
|
||
```
|
||
|
||
- [ ] **Step 4: AppShell 适配**
|
||
|
||
拖拽路径创建时 `addPath` 不变(传 string)。其余不变。
|
||
|
||
- [ ] **Step 5: 编译检查**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/components/ src/hooks/
|
||
git commit -m "feat: UI 组件适配 PathEntry — 复选框列、禁用行灰显删除线"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 整合测试 + 修正所有失败
|
||
|
||
**Files:**
|
||
- Modify: 所有之前修改过的测试文件(微调)
|
||
|
||
- [ ] **Step 1: 运行全部测试**
|
||
|
||
```bash
|
||
npx vitest run
|
||
```
|
||
|
||
预期:部分测试失败,因为测试中的期望值与实现不一致。逐个修复。
|
||
|
||
- [ ] **Step 2: 修复 import-export 测试**
|
||
|
||
导出测试需改为 `ExportData.system.map(e => e.path)` 断言:
|
||
```typescript
|
||
it('导出结构化 JSON', () => {
|
||
const json = exportToJson({ system: [pe('C:\\Windows'), pe('C:\\Program Files')], user: [] });
|
||
const parsed = JSON.parse(json);
|
||
expect(parsed.system).toEqual(['C:\\Windows', 'C:\\Program Files']); // JSON 中不含 enabled
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 修复 app-store 测试**
|
||
|
||
- `savePaths` 测试:mock 的 `invoke` 调用增加 `load_disabled_state`
|
||
- `addPath` 测试:断言 `sysPaths[0].path` 而非 `sysPaths[0]`
|
||
- `togglePath` 测试:新增
|
||
```typescript
|
||
it('togglePath 切换启用状态', () => {
|
||
useAppStore.getState().addPath('test', TargetType.SYSTEM);
|
||
expect(useAppStore.getState().sysPaths[0].enabled).toBe(true);
|
||
useAppStore.getState().togglePath(0, TargetType.SYSTEM);
|
||
expect(useAppStore.getState().sysPaths[0].enabled).toBe(false);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: 全部通过**
|
||
|
||
```bash
|
||
npx vitest run
|
||
```
|
||
|
||
预期:所有测试通过。
|
||
|
||
- [ ] **Step 5: 编译 + Rust 检查**
|
||
|
||
```bash
|
||
npx tsc --noEmit && cd src-tauri && cargo check && cargo clippy -- -D warnings
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "test: 修复所有测试适配 PathEntry,togglePath 测试,全部通过"
|
||
```
|
||
|
||
---
|
||
|
||
# Phase 2: E2E 测试
|
||
|
||
### Task 8: 安装 Playwright + 配置
|
||
|
||
**Files:**
|
||
- Modify: `package.json`
|
||
- Create: `e2e/playwright.config.ts`
|
||
- Create: `e2e/mocks/ipc.ts`
|
||
|
||
- [ ] **Step 1: 安装 @playwright/test**
|
||
|
||
```bash
|
||
npm install -D @playwright/test
|
||
npx playwright install chromium
|
||
```
|
||
|
||
- [ ] **Step 2: package.json 新增 script**
|
||
|
||
```json
|
||
"test:e2e": "playwright test --config e2e/playwright.config.ts"
|
||
```
|
||
|
||
- [ ] **Step 3: 创建 playwright.config.ts**
|
||
|
||
```typescript
|
||
// e2e/playwright.config.ts
|
||
import { defineConfig } from '@playwright/test';
|
||
|
||
export default defineConfig({
|
||
testDir: './tests',
|
||
timeout: 10000,
|
||
use: {
|
||
baseURL: 'http://localhost:5173',
|
||
},
|
||
webServer: {
|
||
command: 'npm run dev',
|
||
url: 'http://localhost:5173',
|
||
reuseExistingServer: true,
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: 创建 IPC mock**
|
||
|
||
```typescript
|
||
// e2e/mocks/ipc.ts
|
||
export function createIpcMock() {
|
||
return `
|
||
window.__TAURI_INTERNALS__ = {
|
||
invoke: async (cmd, args) => {
|
||
switch (cmd) {
|
||
case 'check_admin': return true;
|
||
case 'load_system_paths': return ['C:\\\\Windows', 'C:\\\\Program Files'];
|
||
case 'load_user_paths': return ['C:\\\\Users\\\\me\\\\AppData'];
|
||
case 'load_disabled_state': return { system: [], user: [] };
|
||
case 'save_system_paths': return undefined;
|
||
case 'save_user_paths': return undefined;
|
||
case 'save_disabled_state': return undefined;
|
||
case 'backup_registry': return 'C:\\\\backup\\\\path.txt';
|
||
case 'broadcast_env_change': return undefined;
|
||
case 'validate_path': return true;
|
||
case 'expand_env_vars': return 'C:\\\\Expanded';
|
||
case 'read_text_file': return '';
|
||
case 'get_appdata_dir': return 'C:\\\\appdata';
|
||
default: throw new Error('Unexpected invoke: ' + cmd);
|
||
}
|
||
}
|
||
};
|
||
`;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add e2e/ package.json
|
||
git commit -m "chore: 安装 Playwright + 配置 E2E 基础框架"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: 编写 4 条 E2E 测试
|
||
|
||
**Files:**
|
||
- Create: `e2e/tests/startup.spec.ts`
|
||
- Create: `e2e/tests/crud-undo.spec.ts`
|
||
- Create: `e2e/tests/toggle-save.spec.ts`
|
||
- Create: `e2e/tests/search-clean.spec.ts`
|
||
|
||
- [ ] **Step 1: 编写启动加载测试**
|
||
|
||
```typescript
|
||
// e2e/tests/startup.spec.ts
|
||
import { test, expect } from '@playwright/test';
|
||
import { createIpcMock } from '../mocks/ipc';
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.addInitScript(createIpcMock());
|
||
await page.goto('/');
|
||
});
|
||
|
||
test('启动后加载系统 PATH 和用户 PATH', async ({ page }) => {
|
||
// 系统 tab 显示 2 条
|
||
await expect(page.locator('table tbody tr')).toHaveCount(2);
|
||
await expect(page.locator('text=C:\\\\Windows')).toBeVisible();
|
||
await expect(page.locator('text=C:\\\\Program Files')).toBeVisible();
|
||
|
||
// 切换到用户 tab
|
||
await page.click('text=用户');
|
||
await expect(page.locator('table tbody tr')).toHaveCount(1);
|
||
await expect(page.locator('text=C:\\\\Users\\\\me\\\\AppData')).toBeVisible();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 编写 CRUD + 撤销测试**
|
||
|
||
```typescript
|
||
// e2e/tests/crud-undo.spec.ts
|
||
import { test, expect } from '@playwright/test';
|
||
import { createIpcMock } from '../mocks/ipc';
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.addInitScript(createIpcMock());
|
||
await page.goto('/');
|
||
});
|
||
|
||
test('添加路径后可撤销和重做', async ({ page }) => {
|
||
// 点击新建按钮
|
||
await page.click('text=新建');
|
||
// 输入路径
|
||
await page.fill('input[type="text"]', 'C:\\\\NewPath');
|
||
// 确认
|
||
await page.click('text=确定');
|
||
// 路径出现在列表
|
||
await expect(page.locator('text=C:\\\\NewPath')).toBeVisible();
|
||
|
||
// Ctrl+Z 撤销
|
||
await page.keyboard.press('Control+z');
|
||
await expect(page.locator('text=C:\\\\NewPath')).not.toBeVisible();
|
||
|
||
// Ctrl+Y 重做
|
||
await page.keyboard.press('Control+y');
|
||
await expect(page.locator('text=C:\\\\NewPath')).toBeVisible();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 编写禁用 + 保存测试**
|
||
|
||
```typescript
|
||
// e2e/tests/toggle-save.spec.ts
|
||
import { test, expect } from '@playwright/test';
|
||
import { createIpcMock } from '../mocks/ipc';
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.addInitScript(createIpcMock());
|
||
await page.goto('/');
|
||
});
|
||
|
||
test('禁用路径后灰显 + 保存只传 enabled 路径', async ({ page }) => {
|
||
// 点击第一个复选框禁用
|
||
await page.click('input[type="checkbox"]:first-of-type');
|
||
// 灰显+删除线
|
||
const row = page.locator('table tbody tr').first();
|
||
await expect(row.locator('td').nth(2)).toHaveCSS('text-decoration-line', 'line-through');
|
||
|
||
// 点击保存
|
||
await page.click('text=保存');
|
||
// 无需验证 IPC 参数(mock 不检查),确认无报错即可
|
||
await expect(page.locator('text=保存成功')).toBeVisible();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: 编写搜索 + 清理测试**
|
||
|
||
```typescript
|
||
// e2e/tests/search-clean.spec.ts
|
||
import { test, expect } from '@playwright/test';
|
||
import { createIpcMock } from '../mocks/ipc';
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
// 返回含无效路径的数据
|
||
await page.addInitScript(() => {
|
||
window.__TAURI_INTERNALS__ = {
|
||
invoke: async (cmd, args) => {
|
||
switch (cmd) {
|
||
case 'check_admin': return true;
|
||
case 'load_system_paths': return ['C:\\\\Windows', 'invalid_path', 'C:\\\\Temp'];
|
||
case 'load_user_paths': return [];
|
||
case 'load_disabled_state': return { system: [], user: [] };
|
||
case 'validate_path': return false; // 所有路径无效 → 触发红色
|
||
default: return undefined;
|
||
}
|
||
}
|
||
};
|
||
});
|
||
await page.goto('/');
|
||
});
|
||
|
||
test('搜索过滤 + 清理无效路径', async ({ page }) => {
|
||
// 输入搜索词
|
||
await page.fill('input[placeholder]', 'Windows');
|
||
await expect(page.locator('table tbody tr')).toHaveCount(1);
|
||
|
||
// 清空搜索
|
||
await page.fill('input[placeholder]', '');
|
||
await expect(page.locator('table tbody tr')).toHaveCount(3);
|
||
|
||
// 点击清理
|
||
await page.click('text=清理');
|
||
// 无效路径消失
|
||
await expect(page.locator('table tbody tr')).toHaveCount(0);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: 运行 E2E**
|
||
|
||
```bash
|
||
npx playwright test --config e2e/playwright.config.ts
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add e2e/
|
||
git commit -m "test: 新增 4 条 E2E 测试 — 启动加载、CRUD撤销、禁用保存、搜索清理"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: 最终验证
|
||
|
||
- [ ] **Step 1: 全栈检查**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
npx vitest run
|
||
npx playwright test --config e2e/playwright.config.ts
|
||
cd src-tauri && cargo check && cargo clippy -- -D warnings && cargo test
|
||
```
|
||
|
||
- [ ] **Step 2: Push + CI**
|
||
|
||
```bash
|
||
git push origin v4.2
|
||
```
|
||
|
||
确认 GitHub Actions CI 全部通过。
|
||
|
||
- [ ] **Step 3: Tag release (可选)**
|
||
|
||
如果一切就绪:
|
||
```bash
|
||
git tag -a v4.3.0 -m "v4.3.0: 路径启用/禁用 + E2E 测试"
|
||
git push origin v4.3.0
|
||
```
|
||
|
||
---
|
||
|
||
## 执行顺序
|
||
|
||
Phase 1: Task 1 → 2 → 3 → 4 → 5 → 6 → 7(严格按序,数据模型依赖关系)
|
||
Phase 2: Task 8 → 9 → 10(依赖 Phase 1 完成)
|