memos/web/tests/suggestion-menu.test.tsx

176 lines
5.7 KiB
TypeScript

import { act, fireEvent, render, screen } from "@testing-library/react";
import { createRef } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import { createSuggestionRenderer, SuggestionMenu, type SuggestionMenuHandle } from "@/components/MemoEditor/Editor/suggestionMenu";
function setup(items = ["alpha", "beta", "gamma"]) {
const command = vi.fn();
const ref = createRef<SuggestionMenuHandle>();
render(
<SuggestionMenu
ref={ref}
items={items}
command={command}
getItemKey={(item: string) => item}
renderItem={(item: string) => <span>#{item}</span>}
/>,
);
return { command, ref };
}
const keyDown = (key: string) => ({ event: new KeyboardEvent("keydown", { key }) }) as never;
describe("SuggestionMenu", () => {
it("renders all items", () => {
setup();
expect(screen.getByText("#alpha")).toBeInTheDocument();
expect(screen.getByText("#gamma")).toBeInTheDocument();
});
it("navigates with arrows and selects with Enter", () => {
const { command, ref } = setup();
expect(ref.current?.onKeyDown(keyDown("ArrowDown"))).toBe(true);
expect(ref.current?.onKeyDown(keyDown("Enter"))).toBe(true);
expect(command).toHaveBeenCalledWith("beta");
});
it("selects on mouse down", () => {
const { command } = setup();
fireEvent.mouseDown(screen.getByText("#gamma"));
expect(command).toHaveBeenCalledWith("gamma");
});
it("renders nothing for an empty list and lets keys pass through", () => {
const { ref } = setup([]);
expect(document.querySelector("[data-suggestion-menu]")).toBeNull();
expect(ref.current?.onKeyDown(keyDown("Enter"))).toBe(false);
});
it("scrolls the selected item into view on arrow navigation", async () => {
const { ref } = setup(["a", "b", "c"]);
// Assign scrollIntoView spy on HTMLElement.prototype so it survives re-renders.
const spy = vi.fn();
const original = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = spy;
try {
await act(async () => {
ref.current?.onKeyDown(keyDown("ArrowDown"));
});
expect(spy).toHaveBeenCalled();
} finally {
HTMLElement.prototype.scrollIntoView = original;
}
});
});
describe("createSuggestionRenderer blur handling", () => {
/**
* Build a minimal fake editor that satisfies the ReactRenderer constructor:
* - isEditorContentInitialized=true causes ReactRenderer to call flushSync,
* so the component renders synchronously.
* - contentComponent.setRenderer renders the React element into renderer.element
* via createPortal, making [data-suggestion-menu] visible in the DOM.
*/
function makeFakeEditor() {
const handlers: Record<string, Array<() => void>> = {};
const fakeEditor = {
isEditorContentInitialized: true,
contentComponent: {
setRenderer: vi.fn((_id: string, renderer: { reactElement: React.ReactNode; element: HTMLElement }) => {
// Mimic the real portal: render the element directly into renderer.element
// via React so it shows up in the DOM when appended to document.body.
const root = createRoot(renderer.element);
act(() => {
root.render(renderer.reactElement as React.ReactElement);
});
}),
removeRenderer: vi.fn(),
},
on: vi.fn((event: string, handler: () => void) => {
(handlers[event] ??= []).push(handler);
}),
off: vi.fn(),
handlers,
};
return fakeEditor;
}
it("destroys the popup container and unsubscribes on editor blur", () => {
const fakeEditor = makeFakeEditor();
const renderFn = createSuggestionRenderer<string>({
renderItem: (item: string) => <span>{item}</span>,
getItemKey: (item: string) => item,
});
const lifecycle = renderFn!();
act(() => {
lifecycle!.onStart!({
clientRect: () => null,
editor: fakeEditor as never,
items: ["x"],
command: vi.fn(),
query: "",
text: "",
decorationNode: null,
} as never);
});
// The outer container div must be attached to document.body.
const container = document.body.lastElementChild as HTMLElement;
expect(container).not.toBeNull();
expect(container?.style.position).toBe("absolute");
expect(container?.isConnected).toBe(true);
expect(fakeEditor.on).toHaveBeenCalledWith("blur", expect.any(Function));
// Simulate the editor emitting "blur".
const blurHandler = fakeEditor.handlers["blur"]?.[0];
expect(blurHandler).toBeDefined();
act(() => {
blurHandler?.();
});
// Container must be detached from the document.
expect(container.isConnected).toBe(false);
// off must have been called to avoid leaking the listener.
expect(fakeEditor.off).toHaveBeenCalledWith("blur", expect.any(Function));
});
it("is idempotent — calling destroy twice does not throw", () => {
const fakeEditor = {
isEditorContentInitialized: false,
on: vi.fn(),
off: vi.fn(),
};
const renderFn = createSuggestionRenderer<string>({
renderItem: (item: string) => <span>{item}</span>,
getItemKey: (item: string) => item,
});
const lifecycle = renderFn!();
act(() => {
lifecycle!.onStart!({
clientRect: () => null,
editor: fakeEditor as never,
items: ["x"],
command: vi.fn(),
query: "",
text: "",
decorationNode: null,
} as never);
});
act(() => {
lifecycle!.onExit!({} as never);
// Second call (e.g. from Escape after onExit) must not throw.
lifecycle!.onExit!({} as never);
});
expect(fakeEditor.off).toHaveBeenCalledTimes(1);
});
});