# 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 完成）
