multica/packages/views/editor/title-editor.test.tsx

139 lines
4.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
const mockFocus = vi.hoisted(() => vi.fn());
const mockSetContent = vi.hoisted(() => vi.fn());
const mockBlur = vi.hoisted(() => vi.fn());
const editorState = vi.hoisted(() => ({
isFocused: false,
isDestroyed: false,
text: "",
}));
vi.mock("../i18n", () => ({
useT: () => ({ t: (fn: unknown) => (typeof fn === "function" ? "" : "") }),
}));
const editorRef = vi.hoisted<{ current: unknown }>(() => ({ current: null }));
vi.mock("@tiptap/react", () => ({
useEditor: () => {
if (!editorRef.current) {
editorRef.current = {
get isFocused() {
return editorState.isFocused;
},
get isDestroyed() {
return editorState.isDestroyed;
},
commands: {
focus: mockFocus,
blur: mockBlur,
setContent: mockSetContent,
},
getText: () => editorState.text,
};
}
return editorRef.current;
},
EditorContent: () => <div data-testid="editor-content" />,
}));
import { TitleEditor } from "./title-editor";
describe("TitleEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
editorState.isFocused = false;
editorState.isDestroyed = false;
editorState.text = "";
editorRef.current = null;
});
it("syncs editor content when defaultValue changes externally and editor is unfocused", () => {
editorState.text = "old title";
const { rerender } = render(<TitleEditor defaultValue="old title" />);
expect(mockSetContent).not.toHaveBeenCalled();
rerender(<TitleEditor defaultValue="new title from server" />);
expect(mockSetContent).toHaveBeenCalledTimes(1);
expect(mockSetContent).toHaveBeenCalledWith(
{
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "new title from server" }],
},
],
},
{ emitUpdate: false },
);
});
it("does not overwrite the user's in-flight edits when the editor is focused and dirty", () => {
editorState.text = "old title";
const { rerender } = render(<TitleEditor defaultValue="old title" />);
editorState.isFocused = true;
editorState.text = "user typed but not yet blurred";
rerender(<TitleEditor defaultValue="external update" />);
expect(mockSetContent).not.toHaveBeenCalled();
});
// Regression: a focused but clean editor (user clicked in but never typed)
// must still accept external updates, otherwise the subsequent blur would
// compare stale editor text to the new server value and silently roll the
// external update back.
it("syncs to new defaultValue when editor is focused but clean", () => {
editorState.text = "old title";
const { rerender } = render(<TitleEditor defaultValue="old title" />);
// User clicked into the title field but has not typed anything yet:
// editor text still equals the previous defaultValue.
editorState.isFocused = true;
editorState.text = "old title";
rerender(<TitleEditor defaultValue="new title from server" />);
expect(mockSetContent).toHaveBeenCalledTimes(1);
expect(mockSetContent).toHaveBeenCalledWith(
{
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "new title from server" }],
},
],
},
{ emitUpdate: false },
);
});
it("short-circuits when editor text already equals incoming defaultValue", () => {
editorState.text = "same title";
const { rerender } = render(<TitleEditor defaultValue="same title" />);
// Force the effect to re-run by rendering with a different prop, then
// back to the same value. Even an identity-equal prop should be skipped.
rerender(<TitleEditor defaultValue="same title" />);
expect(mockSetContent).not.toHaveBeenCalled();
});
it("clears the editor when defaultValue transitions to empty", () => {
editorState.text = "old title";
const { rerender } = render(<TitleEditor defaultValue="old title" />);
rerender(<TitleEditor defaultValue="" />);
expect(mockSetContent).toHaveBeenCalledTimes(1);
expect(mockSetContent).toHaveBeenCalledWith("", { emitUpdate: false });
});
});