Files
Literature-Management/tests/unit/renderer/store/lit-cut-paste.test.ts
2026-10-21 14:42:59 +08:00

259 lines
11 KiB
TypeScript

// tests/unit/renderer/store/lit-cut-paste.test.ts
//
// 守卫:literature store 的 cut + paste(cut) + moveToFolder 在 r167 必须
// 走 api.litMove IPC,失败累计 + 首个 throw。以及 r167 落地的 selectedId
// 重映射 —— 跨目录搬移改 id 后若编辑器选中该资源,selectedId 必须跳到
// 新 id,否则后续读 absPath / 重开 PDF 会撞 NOT_FOUND。
//
// 与 notes-cut-paste.test.ts 同源,只换 api.litMove / literature store。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../../src/renderer/src/store/workspace', () => ({
useWorkspaceStore: {
getState: () => ({
pendingFocusNote: { hasPending: false, payload: null },
consumeFocusNoteRequest: () => {},
}),
},
}));
vi.mock('../../../../src/renderer/src/lib/toast', () => ({
useToast: {
getState: () => ({ show: () => {} }),
},
}));
import { useLiteratureStore } from '../../../../src/renderer/src/store/literature';
import type { LiteratureResource } from '../../../../src/shared/types';
interface MockApi {
litMove: ReturnType<typeof vi.fn>;
listLiterature: ReturnType<typeof vi.fn>;
importLiteratureFromFile: ReturnType<typeof vi.fn>;
onLiteratureChanged: ReturnType<typeof vi.fn>;
}
function makeItem(id: string, absPath: string): LiteratureResource {
return {
id: id as unknown as LiteratureResource['id'],
kind: 'literature',
absPath,
rootId: null,
extractStatus: 'ok',
title: '',
mtime: 0,
size: 0,
indexedAt: 0,
};
}
function setupMockApi(): MockApi {
const api: MockApi = {
litMove: vi.fn(),
listLiterature: vi.fn().mockResolvedValue({ ok: true, data: [] }),
importLiteratureFromFile: vi.fn(),
onLiteratureChanged: vi.fn().mockReturnValue(() => {}),
};
(globalThis as unknown as { window: { api: MockApi } }).window = { api };
useLiteratureStore.setState({
items: [],
selectedIds: new Set<string>(),
selectedId: null,
anchorId: null,
clipboard: null,
lastError: null,
displayOrderById: {},
});
return api;
}
describe('literature store cut/paste/moveToFolder', () => {
beforeEach(() => setupMockApi());
afterEach(() => vi.clearAllMocks());
it('cut 写剪贴板,不立即调 IPC', () => {
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
useLiteratureStore.setState({
items: [makeItem('l1', 'l1.pdf'), makeItem('l2', 'l2.pdf')],
selectedIds: new Set(['l1', 'l2']),
});
useLiteratureStore.getState().cut();
expect(useLiteratureStore.getState().clipboard).toEqual({
ids: ['l1', 'l2'],
mode: 'cut',
});
expect(api.litMove).not.toHaveBeenCalled();
});
it('paste(cut) 调 litMove 每个 id 用目标 relPath', async () => {
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
useLiteratureStore.setState({
items: [makeItem('l1', 'l1.pdf'), makeItem('l2', 'l2.pdf')],
selectedIds: new Set(['l1', 'l2']),
});
useLiteratureStore.getState().cut();
api.litMove.mockResolvedValue({ ok: true, data: { id: 'l1', relPath: 'foo/l1.pdf' } });
await useLiteratureStore.getState().paste('foo');
expect(api.litMove).toHaveBeenCalledTimes(2);
expect(api.litMove).toHaveBeenNthCalledWith(1, 'l1', 'foo');
expect(api.litMove).toHaveBeenNthCalledWith(2, 'l2', 'foo');
});
it('paste(cut) 部分失败 throw,剪贴板保留可重试', async () => {
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
useLiteratureStore.setState({
items: [makeItem('l1', 'l1.pdf'), makeItem('l2', 'l2.pdf')],
selectedIds: new Set(['l1', 'l2']),
});
useLiteratureStore.getState().cut();
api.litMove.mockImplementation((id: string) => {
if (id === 'l2') {
return Promise.resolve({ ok: false, code: 'IO_ERROR', message: 'disk fail' });
}
return Promise.resolve({ ok: true, data: { id, relPath: 'foo/' + id + '.pdf' } });
});
await expect(useLiteratureStore.getState().paste('foo')).rejects.toThrowError(/failed 1\/2/);
expect(useLiteratureStore.getState().clipboard).toEqual({
ids: ['l1', 'l2'],
mode: 'cut',
});
});
it('paste(cut) 成功后被搬移的 selectedId 重映射到 IPC 响应的新 id', async () => {
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
const NEW_ID = 'new-l1';
const NEW_REL = 'foo/l1.pdf';
useLiteratureStore.setState({
items: [makeItem('l1', 'l1.pdf')],
selectedId: 'l1' as unknown as null,
selectedIds: new Set(['l1']),
});
useLiteratureStore.getState().cut();
api.litMove.mockResolvedValue({ ok: true, data: { id: NEW_ID, relPath: NEW_REL } });
await useLiteratureStore.getState().paste('foo');
expect(useLiteratureStore.getState().selectedId).toBe(NEW_ID);
});
it('moveToFolder 0 ids 是 no-op', async () => {
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
await useLiteratureStore.getState().moveToFolder([], 'x');
expect(api.litMove).not.toHaveBeenCalled();
});
it('moveToFolder 失败 throw,剪贴板不动', async () => {
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
useLiteratureStore.setState({
items: [makeItem('l1', 'l1.pdf')],
selectedIds: new Set(['l1']),
});
useLiteratureStore.getState().cut();
api.litMove.mockResolvedValue({ ok: false, code: 'NOT_FOUND', message: 'gone' });
await expect(useLiteratureStore.getState().moveToFolder(['l1'], 'x')).rejects.toThrowError(
/failed/,
);
expect(useLiteratureStore.getState().clipboard).toEqual({ ids: ['l1'], mode: 'cut' });
});
it('r168:applyMoveRemap 是 store public action —— LiteratureList 回调回写', () => {
useLiteratureStore.setState({
selectedId: 'l1' as unknown as null,
});
const remap = new Map<string, { id: string; relPath?: string; absPath?: string }>();
remap.set('l1', { id: 'new-l1', absPath: 'foo/l1.pdf' });
useLiteratureStore.getState().applyMoveRemap(remap);
expect(useLiteratureStore.getState().selectedId).toBe('new-l1');
});
it('r168:applyMoveRemap 空 map / id 不匹配 都是 no-op', () => {
useLiteratureStore.setState({ selectedId: 'l1' as unknown as null });
useLiteratureStore.getState().applyMoveRemap(new Map());
expect(useLiteratureStore.getState().selectedId).toBe('l1');
const remap = new Map<string, { id: string; relPath?: string; absPath?: string }>();
remap.set('other', { id: 'new-other', absPath: 'foo/other.pdf' });
useLiteratureStore.getState().applyMoveRemap(remap);
expect(useLiteratureStore.getState().selectedId).toBe('l1');
});
});
// r170:literature copy 分支两处修正。
//
// 1. **搬移后 id 会变** —— lit id = 'lit:'+toPosix(absPath),搬进子目录
// absPath 变 → id 变。旧实现只判 `r.ok` 把 r.data.id 丢掉,末尾
// `set({ selectedIds: new Set(newIds) })` 存的是**搬移前**的 id →
// 选中的行在 UI 里不存在(点不开 / Reader 拿不到)。这是「复制粘贴进
// 文件夹后选不中」的直接原因。
// 2. 失败可见 —— 旧实现在 store 内部调 `api.pushToast` 弹部分失败 toast,
// 但 **preload 的 Api 接口里没有 pushToast**(grep 零命中)→ 生产恒走
// else 分支只 log.warn,用户零感知。改成累计 errors 后抛聚合错,由
// LiteratureList.pasteTo 的 catch 统一 toast(literature.pasteFailedToast)。
describe('literature store paste(copy) —— 搬移后的 id 与失败可见(r170)', () => {
beforeEach(() => setupMockApi());
afterEach(() => vi.clearAllMocks());
it('搬进目标目录后 selectedIds 用搬后的 id(不是 import 时的 id)', async () => {
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
useLiteratureStore.setState({
items: [makeItem('l1', 'C:/lib/l1.pdf')],
selectedIds: new Set(['l1']),
});
useLiteratureStore.getState().copy();
// import 落在 literature 根,id = 'lit:'+根下绝对路径
api.importLiteratureFromFile.mockResolvedValue({
ok: true,
data: { id: 'lit:root/l1.pdf', absPath: 'C:/lib/l1.pdf' },
});
// 搬进子目录后 absPath 变 → id 变
api.litMove.mockResolvedValue({
ok: true,
data: { id: 'lit:root/foo/l1.pdf', absPath: 'C:/lib/foo/l1.pdf' },
});
await useLiteratureStore.getState().paste('foo');
expect(api.litMove).toHaveBeenCalledWith('lit:root/l1.pdf', 'foo');
expect([...useLiteratureStore.getState().selectedIds]).toEqual(['lit:root/foo/l1.pdf']);
});
it('目标 = 根(null)时不调 litMove(import 本来就落根),选中 import id', async () => {
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
useLiteratureStore.setState({
items: [makeItem('l1', 'C:/lib/l1.pdf')],
selectedIds: new Set(['l1']),
});
useLiteratureStore.getState().copy();
api.importLiteratureFromFile.mockResolvedValue({
ok: true,
data: { id: 'lit:root/l1.pdf', absPath: 'C:/lib/l1.pdf' },
});
await useLiteratureStore.getState().paste(null);
expect(api.litMove).not.toHaveBeenCalled();
expect([...useLiteratureStore.getState().selectedIds]).toEqual(['lit:root/l1.pdf']);
});
it('import 失败 → 聚合错抛出,不调 litMove,剪贴板保留可重试', async () => {
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
useLiteratureStore.setState({
items: [makeItem('l1', 'C:/lib/l1.pdf')],
selectedIds: new Set(['l1']),
});
useLiteratureStore.getState().copy();
api.importLiteratureFromFile.mockRejectedValue(new Error('io boom'));
await expect(useLiteratureStore.getState().paste('foo')).rejects.toThrowError(/failed 1\/1/);
expect(api.litMove).not.toHaveBeenCalled();
expect(useLiteratureStore.getState().clipboard).toEqual({ ids: ['l1'], mode: 'copy' });
});
it('搬移失败 → 聚合错抛出,副本仍留在选中集(它还在根,id 未变)', async () => {
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
useLiteratureStore.setState({
items: [makeItem('l1', 'C:/lib/l1.pdf')],
selectedIds: new Set(['l1']),
});
useLiteratureStore.getState().copy();
api.importLiteratureFromFile.mockResolvedValue({
ok: true,
data: { id: 'lit:root/l1.pdf', absPath: 'C:/lib/l1.pdf' },
});
api.litMove.mockResolvedValue({ ok: false, code: 'VALIDATION', message: 'target exists' });
await expect(useLiteratureStore.getState().paste('foo')).rejects.toThrowError(/failed 1\/1/);
expect([...useLiteratureStore.getState().selectedIds]).toEqual(['lit:root/l1.pdf']);
});
});