163 lines
6.8 KiB
JavaScript
163 lines
6.8 KiB
JavaScript
// src/file-ops.js —— moveEntry / copyEntry / _applyMoveToOpenFile 单测
|
||
//
|
||
// 覆盖:
|
||
// - moveEntry 成功:toast「已移动到」 + 当前打开文件被移动后 path/name/mtime/size 同步
|
||
// (state.currentFile、lastSavedMtimeMs、fileList.setActive、viewer.setFileMeta、
|
||
// updateWindowTitle、callbacks.onFileChanged 都收口到新路径)
|
||
// - moveEntry 失败:toast「移动失败」且不篡改 state.currentFile
|
||
// - copyEntry 成功:toast「已复制到」且**不**动当前打开文件 / 不 setActive
|
||
// - copyEntry 失败:toast「复制失败」
|
||
//
|
||
// 渲染端模块带 DOM 依赖,这里用 vi.mock 把 feedback / prompt-dialog /
|
||
// confirm-dialog / utils / rescue / dirty-sync 全部打桩,只验证 move/copy 分支。
|
||
|
||
/* @vitest-environment jsdom */
|
||
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
|
||
// 打桩渲染端依赖,避免拉入 DOM-heavy 模块
|
||
vi.mock('../../src/feedback.js', () => ({ showToast: vi.fn() }));
|
||
vi.mock('../../src/prompt-dialog.js', () => ({ showPromptDialog: vi.fn() }));
|
||
vi.mock('../../src/confirm-dialog.js', () => ({ showConfirmDialog: vi.fn() }));
|
||
vi.mock('../../src/utils.js', () => ({ isMarkdownFile: () => true }));
|
||
vi.mock('../../src/rescue.js', () => ({ rescueDirtyContent: vi.fn() }));
|
||
vi.mock('../../src/dirty-sync.js', () => ({ syncDirtyFromEditor: vi.fn() }));
|
||
|
||
const { createFileOps } = await import('../../src/file-ops.js');
|
||
const { showToast } = await import('../../src/feedback.js');
|
||
|
||
function makeCtx(overrides = {}) {
|
||
const mockApi = {
|
||
moveFile: vi.fn(async () => ({ ok: true, path: '/d/sub/note.md', name: 'note.md', entryType: 'file', mtimeMs: 123, size: 10 })),
|
||
copyFile: vi.fn(async () => ({ ok: true, path: '/d/sub/note.md', name: 'note.md', entryType: 'file', mtimeMs: 123, size: 10 })),
|
||
friendlyFsError: (code, msg) => (msg ? `${msg}(${code})` : String(code)),
|
||
};
|
||
const ctx = {
|
||
state: {
|
||
currentFile: { path: '/d/note.md', name: 'note.md', mtimeMs: 1, size: 5 },
|
||
currentFileIsMarkdown: true,
|
||
lastSavedMtimeMs: 1,
|
||
clipboard: null,
|
||
currentDir: '/d',
|
||
},
|
||
controls: {
|
||
contextMenu: { show: vi.fn() },
|
||
viewer: { setFileMeta: vi.fn() },
|
||
fileList: { setActive: vi.fn() },
|
||
},
|
||
api: mockApi,
|
||
callbacks: {
|
||
openFile: vi.fn(),
|
||
updateWindowTitle: vi.fn(),
|
||
updateStatusStats: vi.fn(),
|
||
syncSaveButton: vi.fn(),
|
||
clearCurrentFile: vi.fn(),
|
||
applyEditorMode: vi.fn(),
|
||
onFileChanged: vi.fn(),
|
||
onCut: vi.fn(),
|
||
onCopy: vi.fn(),
|
||
onPaste: vi.fn(),
|
||
},
|
||
};
|
||
// file-ops 同时依赖全局 window.api.friendlyFsError
|
||
globalThis.window = { api: mockApi };
|
||
Object.assign(ctx, overrides);
|
||
return ctx;
|
||
}
|
||
|
||
describe('moveEntry / copyEntry', () => {
|
||
beforeEach(() => {
|
||
showToast.mockClear();
|
||
});
|
||
|
||
it('moveEntry 成功:toast + 当前打开文件 path 同步到新目录', async () => {
|
||
const ctx = makeCtx();
|
||
const ops = createFileOps(ctx);
|
||
const entry = { path: '/d/note.md', name: 'note.md', entryType: 'file' };
|
||
|
||
const result = await ops.moveEntry(entry, '/d/sub');
|
||
|
||
expect(result.ok).toBe(true);
|
||
expect(showToast).toHaveBeenCalledWith('已移动到:note.md', 'success', 1500);
|
||
// 当前打开文件即被移动的文件 → state 同步
|
||
expect(ctx.state.currentFile.path).toBe('/d/sub/note.md');
|
||
expect(ctx.state.currentFile.name).toBe('note.md');
|
||
expect(ctx.state.currentFile.mtimeMs).toBe(123);
|
||
expect(ctx.state.currentFile.size).toBe(10);
|
||
expect(ctx.state.lastSavedMtimeMs).toBe(123);
|
||
expect(ctx.controls.fileList.setActive).toHaveBeenCalledWith('/d/sub/note.md');
|
||
expect(ctx.controls.viewer.setFileMeta).toHaveBeenCalled();
|
||
expect(ctx.callbacks.updateWindowTitle).toHaveBeenCalled();
|
||
expect(ctx.callbacks.onFileChanged).toHaveBeenCalledWith(ctx.state.currentFile);
|
||
});
|
||
|
||
it('moveEntry 成功:被移动的不是当前打开文件 → 不触发 setActive / onFileChanged', async () => {
|
||
const ctx = makeCtx();
|
||
const ops = createFileOps(ctx);
|
||
const entry = { path: '/d/other.md', name: 'other.md', entryType: 'file' };
|
||
|
||
await ops.moveEntry(entry, '/d/sub');
|
||
|
||
expect(ctx.controls.fileList.setActive).not.toHaveBeenCalled();
|
||
expect(ctx.callbacks.onFileChanged).not.toHaveBeenCalled();
|
||
// 当前打开文件保持不变
|
||
expect(ctx.state.currentFile.path).toBe('/d/note.md');
|
||
expect(ctx.callbacks.updateWindowTitle).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('moveEntry 失败:toast「移动失败」且不动 state.currentFile', async () => {
|
||
const ctx = makeCtx();
|
||
ctx.api.moveFile = vi.fn(async () => ({ ok: false, code: 'EACCES', message: '没有访问权限' }));
|
||
globalThis.window = { api: ctx.api };
|
||
const ops = createFileOps(ctx);
|
||
const entry = { path: '/d/note.md', name: 'note.md', entryType: 'file' };
|
||
|
||
const result = await ops.moveEntry(entry, '/d/sub');
|
||
|
||
expect(result.ok).toBe(false);
|
||
expect(showToast).toHaveBeenCalledWith('移动失败:没有访问权限(EACCES)', 'error');
|
||
expect(ctx.state.currentFile.path).toBe('/d/note.md');
|
||
expect(ctx.controls.fileList.setActive).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('copyEntry 成功:toast + 不改动当前打开文件 / 不 setActive', async () => {
|
||
const ctx = makeCtx();
|
||
const ops = createFileOps(ctx);
|
||
const entry = { path: '/d/note.md', name: 'note.md', entryType: 'file' };
|
||
|
||
const result = await ops.copyEntry(entry, '/d/sub');
|
||
|
||
expect(result.ok).toBe(true);
|
||
expect(showToast).toHaveBeenCalledWith('已复制到:note.md', 'success', 1500);
|
||
// 复制源不变
|
||
expect(ctx.state.currentFile.path).toBe('/d/note.md');
|
||
expect(ctx.controls.fileList.setActive).not.toHaveBeenCalled();
|
||
expect(ctx.callbacks.updateWindowTitle).not.toHaveBeenCalled();
|
||
expect(ctx.callbacks.onFileChanged).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('copyEntry 失败:toast「复制失败」', async () => {
|
||
const ctx = makeCtx();
|
||
ctx.api.copyFile = vi.fn(async () => ({ ok: false, code: 'ENOSPC', message: '磁盘空间不足' }));
|
||
globalThis.window = { api: ctx.api };
|
||
const ops = createFileOps(ctx);
|
||
const entry = { path: '/d/note.md', name: 'note.md', entryType: 'file' };
|
||
|
||
const result = await ops.copyEntry(entry, '/d/sub');
|
||
|
||
expect(result.ok).toBe(false);
|
||
expect(showToast).toHaveBeenCalledWith('复制失败:磁盘空间不足(ENOSPC)', 'error');
|
||
expect(ctx.state.currentFile.path).toBe('/d/note.md');
|
||
});
|
||
|
||
it('moveEntry / copyEntry 入参缺字段时直接返回 null', async () => {
|
||
const ctx = makeCtx();
|
||
const ops = createFileOps(ctx);
|
||
expect(await ops.moveEntry(null, '/d/sub')).toBeNull();
|
||
expect(await ops.moveEntry({ path: '/d/note.md' }, '')).toBeNull();
|
||
expect(await ops.copyEntry(null, '/d/sub')).toBeNull();
|
||
expect(ctx.api.moveFile).not.toHaveBeenCalled();
|
||
expect(ctx.api.copyFile).not.toHaveBeenCalled();
|
||
});
|
||
});
|