- 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
667 lines
19 KiB
TypeScript
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");
|
|
});
|
|
|
|
});
|