297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
// tests/unit/renderer/components/standalone-editor-note-moved.test.tsx
|
|
//
|
|
// 守 audit 2026-10-21(P0 数据丢失):「笔记被搬走 / 改名」时,编辑器里**未落盘**
|
|
// 的内容必须落到**新 id** 上,而不是主进程已经删掉的旧 id。
|
|
//
|
|
// 旧行为(两条路都丢字,且**永久**丢):
|
|
// 1. 主进程的笔记 id = relPath 的哈希(standalone.ts repin 规则)⇒ 搬移/改名
|
|
// 后同一个文件换了 id,旧 id 在 byId 里被 delete ⇒ 成了**死键**;
|
|
// 2. 编辑器判「id 变了 = 换了另一篇笔记」用的是 relPath(r192 的漂移判据)
|
|
// ⇒ 搬移/改名**必然**改 relPath ⇒ 必然被判成「用户切了笔记」;
|
|
// 3. 于是按**旧 id** flush ⇒ 主进程 NOT_FOUND ⇒ 内容只进 failedDrafts
|
|
// (**旧 id 键**)⇒ 而笔记现在的新 id 取不到那份草稿 ⇒ 这段字永久消失。
|
|
//
|
|
// 本测锁(行为级,只锁用户可见的行为):
|
|
// M1. 搬移:flush 必须打到**新 id**;本地正文不得被搬移前的快照回滚。
|
|
// M2. 改名(full 为 null ⇒ 编辑器整条链卸载):flush 同样打到**新 id**。
|
|
// M3. 对照:真的切到**另一篇**笔记(没有重映射)⇒ 仍然照旧 flush 旧 id。
|
|
// M4. store 层:搬移后 notes[] 里不得留着死 id(否则右侧渲染「笔记已不可用」,
|
|
// 而且编辑器被卸载 ⇒ 未落盘内容只能走卸载 flush)。
|
|
// M5. store 层:搬移后的保存不得带 expectedMtime —— 主进程 repin 重写了文件,
|
|
// 磁盘 mtime 必然变,带搬移前的旧 mtime 必报**假 CONFLICT**(「文件被外部
|
|
// 修改」,其实是我们自己搬的)。
|
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
import { render, fireEvent, screen, act, cleanup } from '@testing-library/react';
|
|
|
|
// i18n:只关心交互,不关心文案 —— key 原样返回
|
|
vi.mock('react-i18next', () => ({
|
|
useTranslation: () => ({ t: (k: string) => k }),
|
|
}));
|
|
|
|
// workspace store:编辑器只用到 pendingSaveActiveNote.version(⌘S 通道)
|
|
// + consumeSaveActiveNoteRequest();本测不走 ⌘S,后者恒 false。
|
|
// 注意:编辑器是**当 hook 用**的(useWorkspaceStore(selector))⇒ mock 必须是
|
|
// 可调用的,不能只给 getState。
|
|
vi.mock('../../../../src/renderer/src/store/workspace', () => {
|
|
const state = {
|
|
pendingSaveActiveNote: { version: 0 },
|
|
consumeSaveActiveNoteRequest: () => false,
|
|
};
|
|
const useWorkspaceStore = ((selector?: (s: unknown) => unknown) =>
|
|
selector ? selector(state) : state) as unknown as {
|
|
(selector?: (s: unknown) => unknown): unknown;
|
|
getState: () => unknown;
|
|
};
|
|
useWorkspaceStore.getState = () => state;
|
|
return { useWorkspaceStore };
|
|
});
|
|
|
|
// toast:store 的 CONFLICT 分支会弹 toast,本测不让它真的弹。
|
|
vi.mock('../../../../src/renderer/src/lib/toast', () => ({
|
|
useToast: { getState: () => ({ show: vi.fn() }) },
|
|
}));
|
|
|
|
// MarkdownEditor 是 CodeMirror(jsdom 里不可测)⇒ 换成普通 textarea,
|
|
// 行为等价(受控 value + onChange(string))。
|
|
vi.mock('../../../../src/renderer/src/components/editor/MarkdownEditor', () => ({
|
|
MarkdownEditor: (props: { value: string; onChange: (v: string) => void }) => (
|
|
<textarea
|
|
data-testid="body-editor"
|
|
value={props.value}
|
|
onChange={(e) => props.onChange(e.target.value)}
|
|
/>
|
|
),
|
|
}));
|
|
|
|
import { useNotesStore } from '../../../../src/renderer/src/store/notes';
|
|
import { resetNoteIdRemap } from '../../../../src/renderer/src/lib/note-id-remap';
|
|
import { StandaloneNoteEditorCore } from '../../../../src/renderer/src/components/notes/StandaloneNoteEditorCore';
|
|
import type { StandaloneNote } from '../../../../src/shared/types';
|
|
|
|
const OLD_ID = 'old-note';
|
|
const NEW_ID = 'new-note';
|
|
|
|
interface MockApi {
|
|
writeNote: ReturnType<typeof vi.fn>;
|
|
readNote: ReturnType<typeof vi.fn>;
|
|
listNotes: ReturnType<typeof vi.fn>;
|
|
renameNoteFile: ReturnType<typeof vi.fn>;
|
|
onNotesChanged: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
function note(id: string, relPath: string, body: string, title = 'T1', mtime = 111): StandaloneNote {
|
|
return {
|
|
id,
|
|
relPath,
|
|
filename: relPath.split('/').pop() ?? relPath,
|
|
title,
|
|
tags: null,
|
|
mtime,
|
|
body,
|
|
};
|
|
}
|
|
|
|
let api: MockApi;
|
|
|
|
function setupApi(): void {
|
|
api = {
|
|
writeNote: vi.fn((id: string, body: string, title: string) =>
|
|
Promise.resolve({ ok: true, data: note(id, 'a.md', body, title, 222) }),
|
|
),
|
|
readNote: vi.fn(),
|
|
listNotes: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
|
renameNoteFile: vi.fn(),
|
|
onNotesChanged: vi.fn().mockReturnValue(() => {}),
|
|
};
|
|
(window as unknown as { api: MockApi }).api = api;
|
|
}
|
|
|
|
function seedStore(n: StandaloneNote): void {
|
|
useNotesStore.setState({
|
|
notes: [
|
|
{
|
|
id: n.id,
|
|
relPath: n.relPath,
|
|
filename: n.filename,
|
|
title: n.title,
|
|
tags: n.tags,
|
|
mtime: n.mtime,
|
|
},
|
|
],
|
|
selectedId: n.id,
|
|
selectedIds: new Set<string>([n.id]),
|
|
anchorId: n.id,
|
|
activeNoteFull: n,
|
|
loadingFull: false,
|
|
clipboard: null,
|
|
lastError: null,
|
|
displayOrderById: {},
|
|
});
|
|
}
|
|
|
|
describe('笔记被搬走/改名:未落盘内容必须落到新 id(audit 2026-10-21 P0)', () => {
|
|
beforeEach(() => {
|
|
cleanup();
|
|
// 登记表是模块级的 ⇒ 每个用例重来,别让上一个用例的映射串进来。
|
|
resetNoteIdRemap();
|
|
setupApi();
|
|
vi.useFakeTimers();
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('M1. 搬移:未落盘内容必须按新 id 落盘,且本地正文不被搬移前的快照回滚', () => {
|
|
const target = note(OLD_ID, 'a.md', 'B0');
|
|
seedStore(target);
|
|
|
|
const onSave = (id: string, body: string, title: string): Promise<void> =>
|
|
useNotesStore.getState().save(id, body, title).then(() => undefined);
|
|
|
|
const { rerender } = render(
|
|
<StandaloneNoteEditorCore note={target} onSave={onSave} titleEditable />,
|
|
);
|
|
|
|
// 用户在搬移前敲了字(还没落盘)
|
|
fireEvent.change(screen.getByTestId('body-editor'), { target: { value: 'B1' } });
|
|
|
|
// 搬移:主进程 fs.rename + repin ⇒ id 变了;store 收到 idRemap
|
|
act(() => {
|
|
useNotesStore.getState().applyMoveRemap(
|
|
new Map([[OLD_ID, { id: NEW_ID, relPath: 'sub/a.md' }]]),
|
|
);
|
|
});
|
|
|
|
// 父组件按新 store 状态重渲(真实路径:selectedId / activeNoteFull 都换了)
|
|
const st = useNotesStore.getState();
|
|
expect(st.activeNoteFull?.id, 'store 必须把 activeNoteFull 换到新 id').toBe(NEW_ID);
|
|
rerender(
|
|
<StandaloneNoteEditorCore note={st.activeNoteFull!} onSave={onSave} titleEditable />,
|
|
);
|
|
|
|
// 断言 1:flush 必须打到**新 id**(旧 id 已是主进程死键)
|
|
const calls = api.writeNote.mock.calls;
|
|
expect(calls.length, '搬移后必须补落盘一次').toBeGreaterThan(0);
|
|
expect(
|
|
calls.every((c) => c[0] !== OLD_ID),
|
|
'绝不能按旧 id 写 —— 那是主进程的死键(必撞 NOT_FOUND ⇒ 永久丢字)',
|
|
).toBe(true);
|
|
expect(calls[0]![0], '必须按新 id 写').toBe(NEW_ID);
|
|
expect(calls[0]![1], '必须带上用户刚敲的正文').toBe('B1');
|
|
|
|
// 断言 2:本地正文不得被「搬移前」的 note.body 回滚(否则用户刚敲的字消失)
|
|
expect((screen.getByTestId('body-editor') as HTMLTextAreaElement).value).toBe('B1');
|
|
});
|
|
|
|
it('M2. 改名(full 清空 ⇒ 编辑器卸载):未落盘内容同样必须按新 id 落盘', async () => {
|
|
const target = note(OLD_ID, 'a.md', 'B0');
|
|
seedStore(target);
|
|
|
|
const renamed = note(NEW_ID, 'renamed.md', 'B0');
|
|
api.renameNoteFile.mockResolvedValue({ ok: true, data: renamed });
|
|
api.listNotes.mockResolvedValue({
|
|
ok: true,
|
|
data: [
|
|
{
|
|
id: NEW_ID,
|
|
relPath: 'renamed.md',
|
|
filename: 'renamed.md',
|
|
title: renamed.title,
|
|
tags: null,
|
|
mtime: 222,
|
|
},
|
|
],
|
|
});
|
|
|
|
const onSave = (id: string, body: string, title: string): Promise<void> =>
|
|
useNotesStore.getState().save(id, body, title).then(() => undefined);
|
|
|
|
const { rerender } = render(
|
|
<StandaloneNoteEditorCore note={target} onSave={onSave} titleEditable />,
|
|
);
|
|
|
|
fireEvent.change(screen.getByTestId('body-editor'), { target: { value: 'B1' } });
|
|
|
|
// 改名:主进程 repin frontmatter id ⇒ 旧 id 成死键
|
|
await act(async () => {
|
|
await useNotesStore.getState().renameFile(OLD_ID, 'renamed.md');
|
|
});
|
|
|
|
// 真实路径:activeNoteFull 被清空 ⇒ NotesView 渲染 Skeleton ⇒ 编辑器卸载
|
|
rerender(<div />);
|
|
|
|
const calls = api.writeNote.mock.calls;
|
|
expect(calls.length, '改名后必须补落盘一次').toBeGreaterThan(0);
|
|
expect(calls[0]![0], '必须按新 id 写').toBe(NEW_ID);
|
|
expect(calls[0]![1], '必须带上用户刚敲的正文').toBe('B1');
|
|
});
|
|
|
|
it('M3. 对照:真的切到另一篇笔记(无重映射)⇒ 仍然照旧 flush 旧 id', () => {
|
|
const target = note(OLD_ID, 'a.md', 'B0');
|
|
seedStore(target);
|
|
|
|
const onSave = (id: string, body: string, title: string): Promise<void> =>
|
|
useNotesStore.getState().save(id, body, title).then(() => undefined);
|
|
|
|
const { rerender } = render(
|
|
<StandaloneNoteEditorCore note={target} onSave={onSave} titleEditable />,
|
|
);
|
|
|
|
fireEvent.change(screen.getByTestId('body-editor'), { target: { value: 'B1' } });
|
|
|
|
// 切到**另一篇**笔记(没有重映射登记)
|
|
rerender(
|
|
<StandaloneNoteEditorCore note={note('other', 'c.md', 'X0')} onSave={onSave} titleEditable />,
|
|
);
|
|
|
|
const calls = api.writeNote.mock.calls;
|
|
expect(calls.length, '切笔记时旧笔记必须被 flush').toBeGreaterThan(0);
|
|
expect(calls[0]![0], '切笔记 ⇒ 照旧写回旧 id(旧笔记还活着)').toBe(OLD_ID);
|
|
expect(calls[0]![1]).toBe('B1');
|
|
});
|
|
|
|
it('M4. 搬移后 notes[] 里不得留着死 id(否则右侧渲染「笔记已不可用」)', () => {
|
|
const target = note(OLD_ID, 'a.md', 'B0');
|
|
seedStore(target);
|
|
|
|
act(() => {
|
|
useNotesStore.getState().applyMoveRemap(
|
|
new Map([[OLD_ID, { id: NEW_ID, relPath: 'sub/a.md' }]]),
|
|
);
|
|
});
|
|
|
|
const st = useNotesStore.getState();
|
|
expect(
|
|
st.notes.some((n) => n.id === OLD_ID),
|
|
'列表里不得留着死 id(点它必然 NOT_FOUND)',
|
|
).toBe(false);
|
|
const moved = st.notes.find((n) => n.id === NEW_ID);
|
|
expect(moved, '列表里必须有新 id 那一行').toBeTruthy();
|
|
expect(moved?.relPath, '新行的 relPath 必须跟着搬').toBe('sub/a.md');
|
|
});
|
|
|
|
it('M5. 搬移后的保存不得带 expectedMtime(否则必报假 CONFLICT)', async () => {
|
|
const target = note(OLD_ID, 'a.md', 'B0', 'T1', 111);
|
|
seedStore(target);
|
|
|
|
act(() => {
|
|
useNotesStore.getState().applyMoveRemap(
|
|
new Map([[OLD_ID, { id: NEW_ID, relPath: 'sub/a.md' }]]),
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
await useNotesStore.getState().save(NEW_ID, 'B1', 'T1');
|
|
});
|
|
|
|
const call = api.writeNote.mock.calls.at(-1)!;
|
|
expect(call[0]).toBe(NEW_ID);
|
|
expect(
|
|
call[3],
|
|
'搬移后磁盘 mtime 必然变(repin 重写了文件)⇒ 不得再拿旧 mtime 比对',
|
|
).toBeUndefined();
|
|
});
|
|
});
|