Files
mnote/wolai-frontend/src/components/documents/move-embed-picker-dialog.test.tsx
T
lix-2026 e564dfde02 feat: continue tree rust family cutover
- add rust renderer/state-family scaffolds and inline compat host thinning for page tree, file tree, and picker
- route tree/filetree preflight, file projection, resource artifact, and stream delta contracts through rust plans
- preserve canonical move-order validation, file-tree search projection, and related frontend/runtime regression coverage
2026-04-26 19:35:52 +08:00

667 lines
19 KiB
TypeScript

import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import React, { type ReactNode } from "react";
import { MoveEmbedPickerDialog } from "./move-embed-picker-dialog";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const sidebarData = {
activeWorkspaceId: "ws_test",
workspaces: [],
documents: [],
kernelSidebarProjection: {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projection: "sidebar_tree",
rootNodeId: null,
items: [],
edges: [],
},
kernelSidebarTree: [],
trashedDocuments: [],
};
function buildSidebarNode(input: {
id: string;
title: string;
children?: Array<ReturnType<typeof buildSidebarNode>>;
}) {
return {
access_scope: "private",
id: input.id,
workspace_id: "ws_test",
title: input.title,
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-24T00:00:00Z",
updated_at: "2026-04-24T00:00:00Z",
children: input.children ?? [],
kernel: {
nodeType: "page" as const,
depth: 0,
position: 0,
childCount: input.children?.length ?? 0,
expandedByDefault: true,
},
};
}
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
const key = Array.isArray(queryKey) ? queryKey[0] : queryKey;
if (key === "move-embed-picker-sidebar") {
return {
data: sidebarData,
isLoading: false,
error: null,
};
}
return {
data: null,
isLoading: false,
error: null,
};
},
}));
const mockUseDocumentSearch = vi.fn(() => ({
data: null,
isLoading: false,
error: null,
}));
vi.mock("@/hooks/use-document-search", () => ({
useDocumentSearch: (...args: unknown[]) => mockUseDocumentSearch(...args),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
DialogTitle: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
vi.mock("@/components/ui/input", () => ({
Input: React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>((props, ref) => (
<input ref={ref} {...props} />
)),
}));
vi.mock("@/components/ui/tabs", () => ({
Tabs: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabsList: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
TabsTrigger: ({
children,
className,
value,
}: {
children: ReactNode;
className?: string;
value: string;
}) => (
<button type="button" className={className} data-value={value}>
{children}
</button>
),
TabsContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
describe("MoveEmbedPickerDialog", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
window.__MNOTE_RUNTIME_CONFIG__ = {
treeRendererFamily: "react",
};
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
vi.restoreAllMocks();
vi.clearAllMocks();
mockUseDocumentSearch.mockReset();
mockUseDocumentSearch.mockReturnValue({
data: null,
isLoading: false,
error: null,
});
sidebarData.kernelSidebarTree = [];
delete window.__MNOTE_RUNTIME_CONFIG__;
});
it("空查询时会使用统一 picker surface 并透传根目录选择", async () => {
const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn();
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={onOpenChange}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={["doc_hidden"]}
onPick={onPick}
/>,
);
});
const shellPickButton = container.querySelector('[data-testid="tree-picker-root"]');
expect(shellPickButton).not.toBeNull();
await act(async () => {
shellPickButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onPick).toHaveBeenCalledTimes(1);
expect(onPick).toHaveBeenCalledWith("move", null);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("空查询时应保留根节点并过滤 excludeIds", async () => {
sidebarData.kernelSidebarTree = [
buildSidebarNode({ id: "doc_hidden", title: "隐藏页面" }),
buildSidebarNode({ id: "doc_visible", title: "保留页面" }),
];
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={vi.fn()}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={["doc_hidden"]}
onPick={vi.fn(async () => undefined)}
/>,
);
});
expect(container.querySelector('[data-testid="tree-picker-root"]')).not.toBeNull();
expect(container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_hidden"]')).toBeNull();
expect(container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_visible"]')).not.toBeNull();
});
it("空查询态应支持键盘高亮切换并按当前高亮项选中", async () => {
sidebarData.kernelSidebarTree = [
buildSidebarNode({ id: "doc_first", title: "第一页" }),
buildSidebarNode({ id: "doc_second", title: "第二页" }),
];
const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn();
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={onOpenChange}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={[]}
onPick={onPick}
/>,
);
});
const input = container.querySelector("input");
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
expect(input).not.toBeNull();
expect(pickerRows).toHaveLength(2);
await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
});
expect(pickerRows[0]?.className ?? "").toContain("bg-[#e3ecff]");
await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onPick).toHaveBeenCalledWith("move", "doc_first");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("搜索结果也应继续复用统一 picker surface", async () => {
mockUseDocumentSearch.mockReturnValue({
data: {
results: [
{
id: "doc_target",
title: "目标页面",
matchField: "title",
},
],
},
isLoading: false,
error: null,
});
const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn();
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={onOpenChange}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={[]}
onPick={onPick}
/>,
);
});
const input = container.querySelector("input");
expect(input).not.toBeNull();
await act(async () => {
input?.dispatchEvent(new Event("input", { bubbles: true }));
Object.defineProperty(input as HTMLInputElement, "value", {
configurable: true,
value: "目标",
});
input?.dispatchEvent(new Event("change", { bubbles: true }));
});
const pickerSurface = container.querySelector('[data-testid="tree-picker-surface"]');
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
expect(pickerSurface).not.toBeNull();
expect(pickerRows).toHaveLength(1);
await act(async () => {
pickerRows[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onPick).toHaveBeenCalledWith("move", "doc_target");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("搜索结果态应支持高亮切换并按当前高亮项选中", async () => {
mockUseDocumentSearch.mockReturnValue({
data: {
results: [
{
id: "doc_first",
title: "第一页",
matchField: "title",
},
{
id: "doc_second",
title: "第二页",
matchField: "title",
},
],
},
isLoading: false,
error: null,
});
const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn();
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={onOpenChange}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={[]}
onPick={onPick}
/>,
);
});
const input = container.querySelector("input");
expect(input).not.toBeNull();
await act(async () => {
Object.defineProperty(input as HTMLInputElement, "value", {
configurable: true,
value: "第",
});
input?.dispatchEvent(new Event("input", { bubbles: true }));
input?.dispatchEvent(new Event("change", { bubbles: true }));
});
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
expect(pickerRows).toHaveLength(2);
await act(async () => {
pickerRows[1]?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
});
expect(pickerRows[1]?.className ?? "").toContain("bg-[#e3ecff]");
await act(async () => {
pickerRows[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onPick).toHaveBeenCalledWith("move", "doc_second");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("搜索结果态不应再注入根节点,且仍应支持多次 ArrowDown 后选中后续结果", async () => {
mockUseDocumentSearch.mockReturnValue({
data: {
results: [
{
id: "doc_first",
title: "第一页",
matchField: "title",
},
{
id: "doc_second",
title: "第二页",
matchField: "title",
},
],
},
isLoading: false,
error: null,
});
const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn();
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={onOpenChange}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={[]}
onPick={onPick}
/>,
);
});
const input = container.querySelector("input");
expect(input).not.toBeNull();
await act(async () => {
Object.defineProperty(input as HTMLInputElement, "value", {
configurable: true,
value: "第",
});
input?.dispatchEvent(new Event("input", { bubbles: true }));
input?.dispatchEvent(new Event("change", { bubbles: true }));
});
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
expect(container.querySelector('[data-testid="tree-picker-root"]')).toBeNull();
expect(pickerRows).toHaveLength(2);
await act(async () => {
input?.focus();
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
});
await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
});
await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onPick).toHaveBeenCalledWith("move", "doc_second");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("rust_family 配置下,picker 空态与结果态都应进入统一 host", async () => {
window.__MNOTE_RUNTIME_CONFIG__ = {
treeRendererFamily: "rust_family",
};
const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn();
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={onOpenChange}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={[]}
onPick={onPick}
/>,
);
});
const emptySurface = container.querySelector('[data-testid="tree-picker-surface"]');
expect(emptySurface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(emptySurface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'),
).not.toBeNull();
mockUseDocumentSearch.mockReturnValue({
data: {
results: [
{
id: "doc_target",
title: "目标页面",
matchField: "title",
},
],
},
isLoading: false,
error: null,
});
const input = container.querySelector("input");
expect(input).not.toBeNull();
await act(async () => {
Object.defineProperty(input as HTMLInputElement, "value", {
configurable: true,
value: "目标",
});
input?.dispatchEvent(new Event("input", { bubbles: true }));
input?.dispatchEvent(new Event("change", { bubbles: true }));
});
const resultSurface = container.querySelector('[data-testid="tree-picker-surface"]');
expect(resultSurface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(resultSurface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'),
).not.toBeNull();
});
it("rust_family 配置下,输入框键盘命令应转发给 iframe,并用焦点回传更新 shell 状态", async () => {
window.__MNOTE_RUNTIME_CONFIG__ = {
treeRendererFamily: "rust_family",
};
mockUseDocumentSearch.mockReturnValue({
data: {
results: [
{
id: "doc_first",
title: "第一页",
matchField: "title",
},
{
id: "doc_second",
title: "第二页",
matchField: "title",
},
],
},
isLoading: false,
error: null,
});
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
{ status: 200, headers: { "content-type": "text/html" } },
),
);
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={vi.fn()}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={[]}
onPick={vi.fn(async () => undefined)}
/>,
);
});
const input = container.querySelector("input");
expect(input).not.toBeNull();
await act(async () => {
Object.defineProperty(input as HTMLInputElement, "value", {
configurable: true,
value: "第",
});
input?.dispatchEvent(new Event("input", { bubbles: true }));
input?.dispatchEvent(new Event("change", { bubbles: true }));
});
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
expect(iframe).not.toBeNull();
Object.defineProperty(iframe, "contentWindow", {
configurable: true,
value: window,
});
const postMessageMock = vi.spyOn(window, "postMessage").mockImplementation(() => undefined);
postMessageMock.mockClear();
await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
});
expect(postMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "tree-picker-surface",
type: "tree.picker.command",
command: "next",
}),
"*",
);
postMessageMock.mockClear();
await act(async () => {
window.dispatchEvent(
new MessageEvent("message", {
data: {
channel: "tree-picker-surface",
type: "tree.picker.focus.changed",
documentId: "doc_second",
itemKey: "doc_second",
},
source: window,
}),
);
});
expect(postMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "tree-picker-surface",
type: "tree.shell.state.patch",
activeDocumentId: "doc_second",
activePickerItemKey: "doc_second",
}),
"*",
);
postMessageMock.mockClear();
await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(postMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "tree-picker-surface",
type: "tree.picker.command",
command: "pick",
}),
"*",
);
});
it("rust_family 配置下,无根目录且无结果时也应保持 same-origin host 空态", async () => {
window.__MNOTE_RUNTIME_CONFIG__ = {
treeRendererFamily: "rust_family",
};
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
{ status: 200, headers: { "content-type": "text/html" } },
),
);
const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn();
await act(async () => {
root.render(
<MoveEmbedPickerDialog
open
onOpenChange={onOpenChange}
workspaceId="ws_test"
defaultMode="move"
allowRoot={false}
excludeIds={[]}
onPick={onPick}
/>,
);
});
await act(async () => {
await Promise.resolve();
});
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
});
});