mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-29 01:45:54 +08:00
feat: 导入改用原生对话框、新增 app-store 单元测试、修复窗口滚动
- handleImport 从 DOM <input> hack 改为 @tauri-apps/plugin-dialog 原生文件选择 - 新增 Rust read_text_file 命令读取文件内容,零外部依赖 - 新增 tests/unit/app-store.test.ts,25 个测试覆盖 CRUD/undo-redo/loadPaths/savePaths - 修复 AppShell overflow-hidden 导致无法滚动窗口的问题 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
/// 读取文本文件内容(供前端原生对话框选择文件后使用)
|
||||
#[tauri::command]
|
||||
pub fn read_text_file(path: &str) -> Result<String, String> {
|
||||
std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod registry;
|
||||
pub mod system;
|
||||
pub mod backup;
|
||||
pub mod fs;
|
||||
|
||||
@@ -26,6 +26,7 @@ pub fn run() {
|
||||
commands::system::broadcast_env_change,
|
||||
commands::backup::backup_registry,
|
||||
commands::backup::get_appdata_dir,
|
||||
commands::fs::read_text_file,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -84,7 +84,7 @@ export function AppShell() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
className="flex-1 overflow-auto"
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'link'; }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { TargetType } from '@/core/undo-redo';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { importFromContent, exportToJson, exportToCsv, flattenImportResult } from '@/core/import-export';
|
||||
import { is_valid_path_format } from '@/core/validation';
|
||||
import { useKeyboard } from './use-keyboard';
|
||||
@@ -80,25 +81,21 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
||||
|
||||
// ── 导入导出 ──
|
||||
|
||||
const handleImport = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json,.csv,.txt';
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) { input.remove(); return; }
|
||||
const content = await file.text();
|
||||
const result = importFromContent(content, file.name);
|
||||
input.remove();
|
||||
if (result.system.length > 0 && result.user.length > 0) {
|
||||
setImportDialog({ open: true, system: result.system, user: result.user });
|
||||
} else if (result.system.length > 0) {
|
||||
useAppStore.getState().importPaths(TargetType.SYSTEM, result.system);
|
||||
} else if (result.user.length > 0) {
|
||||
useAppStore.getState().importPaths(TargetType.USER, result.user);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
const handleImport = useCallback(async () => {
|
||||
const selected = await open({
|
||||
filters: [{ name: '受支持格式', extensions: ['json', 'csv', 'txt'] }],
|
||||
multiple: false,
|
||||
});
|
||||
if (!selected || typeof selected !== 'string') return;
|
||||
const content = await invoke<string>('read_text_file', { path: selected });
|
||||
const result = importFromContent(content, selected);
|
||||
if (result.system.length > 0 && result.user.length > 0) {
|
||||
setImportDialog({ open: true, system: result.system, user: result.user });
|
||||
} else if (result.system.length > 0) {
|
||||
useAppStore.getState().importPaths(TargetType.SYSTEM, result.system);
|
||||
} else if (result.user.length > 0) {
|
||||
useAppStore.getState().importPaths(TargetType.USER, result.user);
|
||||
}
|
||||
}, [setImportDialog]);
|
||||
|
||||
const handleExport = useCallback((format: 'json' | 'csv' = 'json') => {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock @tauri-apps/api/core
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
// 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 { 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).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).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).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).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).toEqual(['A', 'C']);
|
||||
});
|
||||
|
||||
it('moveUp index=0 无操作', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('A', TargetType.SYSTEM);
|
||||
store.moveUp(0, TargetType.SYSTEM);
|
||||
expect(useAppStore.getState().sysPaths).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).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).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).toEqual(['C:\\valid']);
|
||||
});
|
||||
|
||||
it('importPaths 整体替换列表', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('old1', TargetType.USER);
|
||||
store.addPath('old2', TargetType.USER);
|
||||
store.importPaths(TargetType.USER, ['new1', 'new2', 'new3']);
|
||||
expect(useAppStore.getState().userPaths).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).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('_markDirty', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetStore();
|
||||
});
|
||||
|
||||
it('修改后 isModified 为 true', () => {
|
||||
useAppStore.setState({ _savedSys: [], sysPaths: [], _savedUser: [], userPaths: [] });
|
||||
useAppStore.getState()._markDirty();
|
||||
expect(useAppStore.getState().isModified).toBe(false); // 相等
|
||||
});
|
||||
|
||||
it('路径变化时 isModified 为 true', () => {
|
||||
useAppStore.setState({
|
||||
_savedSys: ['A'], sysPaths: ['B'],
|
||||
_savedUser: [], userPaths: [],
|
||||
});
|
||||
useAppStore.getState()._markDirty();
|
||||
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).toEqual(['C:\\sys1', 'C:\\sys2']);
|
||||
expect(s.userPaths).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: ['A'], userPaths: ['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; });
|
||||
mockedInvoke.mockReturnValue(pending as any);
|
||||
|
||||
// 第一次调用(不等它完成,停在 Promise.allSettled)
|
||||
const p1 = useAppStore.getState().savePaths();
|
||||
// 第二次调用应被 isSaving 守卫拦截(此时 isSaving=true)
|
||||
const r2 = useAppStore.getState().savePaths();
|
||||
|
||||
// 第二次调用同步返回 undefined(被守卫拦截)
|
||||
await expect(r2).resolves.toBeUndefined();
|
||||
|
||||
// 放行第一次调用的所有 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).toEqual(['S1']);
|
||||
expect(s.userPaths).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 不变
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user