memos/web/tests/use-auto-save.test.tsx
boojack 1e3ec38fa1 refactor(editor): modularize formatting and simplify editor state
Behavior-preserving restructure of the MemoEditor subsystem for simplicity
and extensibility:

- Add a data-driven command catalog (editorCommands.ts) as the single source
  for formatting verbs; the toolbar, active-state hook, and WYSIWYG handle all
  derive from it, so adding a verb is a one-file change.
- Add a createSuggestionExtension factory; TagSuggestion consumes it, so new
  `/` or `@` triggers reuse the shared popup in ~10 lines.
- Collapse FormattingController into a single EditorController with an optional
  `formatting` capability; PlainEditor is an honest textarea fallback (no faked
  formatting); replace stringly-typed isActive with typed getActiveFormats.
- Move audio-recorder state out of the reducer into useAudioRecorder, keeping a
  single recorderBusy flag in the store.
- Convert the editor context to an external store (useSyncExternalStore) with
  per-slice subscriptions, so typing no longer re-renders the toolbar, insert
  menu, or metadata.
- Extract the shared #tag lexing grammar (tag-grammar.ts) used by both the
  editor tokenizer and the remark renderer.
- Remove dead reducer actions/cases and fix a preview blob-URL leak on the
  upload path.

Add tests for the command catalog, suggestion factory, and autosave.
2026-06-22 23:51:59 +08:00

85 lines
3 KiB
TypeScript

import { act, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoSave } from "@/components/MemoEditor/hooks/useAutoSave";
import { cacheService } from "@/components/MemoEditor/services/cacheService";
import { EditorProvider, useEditorContext } from "@/components/MemoEditor/state";
// Probe surfaces the store's dispatch/actions plus the autosave API so tests can
// drive content changes the way the editor does and assert on cache writes.
let api: {
dispatch: ReturnType<typeof useEditorContext>["dispatch"];
actions: ReturnType<typeof useEditorContext>["actions"];
discardDraft: () => void;
};
function Probe({ username, cacheKey, enabled }: { username: string; cacheKey?: string; enabled?: boolean }) {
const { dispatch, actions } = useEditorContext();
const { discardDraft } = useAutoSave(username, cacheKey, enabled);
api = { dispatch, actions, discardDraft };
return null;
}
describe("useAutoSave (store-subscribed)", () => {
let saveSpy: ReturnType<typeof vi.spyOn>;
let saveNowSpy: ReturnType<typeof vi.spyOn>;
let clearSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
saveSpy = vi.spyOn(cacheService, "save").mockImplementation(() => {});
saveNowSpy = vi.spyOn(cacheService, "saveNow").mockImplementation(() => {});
clearSpy = vi.spyOn(cacheService, "clear").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("persists content to the draft cache when content changes", () => {
render(
<EditorProvider>
<Probe username="users/steven" cacheKey="k" enabled />
</EditorProvider>,
);
const key = cacheService.key("users/steven", "k");
saveSpy.mockClear(); // ignore the mount-time persist of the initial empty content
act(() => {
api.dispatch(api.actions.updateContent("hello world"));
});
expect(saveSpy).toHaveBeenCalledWith(key, "hello world");
});
it("does not persist when disabled", () => {
render(
<EditorProvider>
<Probe username="users/steven" cacheKey="k" enabled={false} />
</EditorProvider>,
);
saveSpy.mockClear();
act(() => {
api.dispatch(api.actions.updateContent("ignored"));
});
expect(saveSpy).not.toHaveBeenCalled();
});
it("discardDraft clears the cache and suppresses the unmount flush", () => {
const { unmount } = render(
<EditorProvider>
<Probe username="users/steven" cacheKey="k2" enabled />
</EditorProvider>,
);
const key = cacheService.key("users/steven", "k2");
act(() => {
api.dispatch(api.actions.updateContent("draft body"));
});
act(() => {
api.discardDraft();
});
expect(clearSpy).toHaveBeenCalledWith(key);
saveNowSpy.mockClear();
unmount();
// The just-discarded content equals the latest content, so the unmount
// flush must NOT re-persist it as a stale draft.
expect(saveNowSpy).not.toHaveBeenCalled();
});
});