Files
mnote/wolai-frontend/src/components/sidebar/tree-shell-surface.test.tsx
T

484 lines
18 KiB
TypeScript
Raw Normal View History

2026-04-24 06:10:18 +08:00
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2026-04-28 16:30:51 +08:00
import type { Mock } from "vitest";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
2026-04-24 06:10:18 +08:00
import { SidebarTreeSurface, TreePickerSurface, type TreeRendererFamily } from "./tree-shell-surface";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
2026-04-28 16:30:51 +08:00
type RuntimeRequest = {
requestId: string;
mode: "page" | "fileTree" | "picker";
environment?: Record<string, unknown>;
state?: Record<string, unknown>;
action?: Record<string, unknown>;
};
type TreeShellRuntimeTestGlobal = typeof globalThis & {
__MNOTE_TREE_SHELL_RUNTIME__?: {
reduceTreeShellRuntime: Mock;
};
};
function reduceTreeRuntimeForTest(request: RuntimeRequest) {
if (request.mode === "fileTree") {
const state = request.state ?? {};
const action = request.action ?? {};
const env = request.environment ?? {};
const rows = Array.isArray(env.rows) ? env.rows as Array<Record<string, unknown>> : [];
const rowId = typeof action.targetRowId === "string" ? action.targetRowId : null;
const targetRow = rows.find((row) => row.rowId === rowId) ?? {};
const target = {
kind: targetRow.rowKind ?? "doc",
documentId: targetRow.documentId ?? null,
assetId: targetRow.assetId ?? null,
};
const hostEvents =
action.kind === "dispatchInternalDrop"
? [{
kind: "fileTreeInternalDrop",
targetRowId: rowId,
target,
rowIds: Array.isArray(action.rowIds) ? action.rowIds : [],
copy: action.copy === true,
}]
: action.kind === "dispatchExternalDrop"
? [{
kind: "fileTreeExternalDrop",
targetRowId: rowId,
target,
fileCount: action.fileCount ?? 0,
}]
: [];
return {
requestId: request.requestId,
mode: "fileTree",
state: { mode: "fileTree", state },
domPatches: [{ kind: "fileTreeState" }],
hostEvents,
commandEvents: [],
};
}
return {
requestId: request.requestId,
mode: request.mode,
state: { mode: request.mode, state: request.state ?? {} },
domPatches: [],
hostEvents: [],
commandEvents: [],
};
}
function mockTreeRuntimeReducer() {
const reduceTreeShellRuntime = vi.fn(async (request: RuntimeRequest) =>
reduceTreeRuntimeForTest(request),
);
(globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__ = {
reduceTreeShellRuntime,
};
return reduceTreeShellRuntime;
}
async function waitForRuntimeReducer(reducerMock: Mock) {
for (let index = 0; index < 10; index += 1) {
if (reducerMock.mock.calls.length > 0) {
return;
}
await act(async () => {
await Promise.resolve();
});
}
}
2026-04-24 06:10:18 +08:00
describe("tree-shell-surface", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
2026-04-28 16:30:51 +08:00
delete (globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__;
2026-04-24 06:10:18 +08:00
});
2026-04-26 04:29:23 +08:00
function renderPageSurface(rendererFamily: TreeRendererFamily, focusedDocumentId?: string) {
2026-04-24 06:10:18 +08:00
act(() => {
root.render(
<SidebarTreeSurface
mode="page"
rendererFamily={rendererFamily}
workspaceId="ws_1"
treeShellEnabled
rows={[]}
expanded={new Set<string>()}
activeId=""
2026-04-26 04:29:23 +08:00
focusedDocumentId={focusedDocumentId}
2026-04-24 06:10:18 +08:00
onToggleExpand={() => undefined}
onMove={() => undefined}
onCreateChild={() => undefined}
onContextMenu={() => undefined}
/>,
);
});
}
2026-04-28 16:30:51 +08:00
it("page tree surface 在 rust_family 下默认切到 Rust/WASM DOM shell host", () => {
2026-04-24 06:10:18 +08:00
renderPageSurface("rust_family");
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
const rustHost = container.querySelector(
'[data-testid="sidebar-page-tree-shell-rust-host"]',
);
2026-04-28 16:30:51 +08:00
const domHost = container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]');
2026-04-24 06:10:18 +08:00
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
2026-04-28 16:30:51 +08:00
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
2026-04-26 19:35:52 +08:00
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
2026-04-24 06:10:18 +08:00
expect(rustHost).not.toBeNull();
2026-04-28 16:30:51 +08:00
expect(domHost).not.toBeNull();
expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1");
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
2026-04-26 04:29:23 +08:00
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
2026-04-24 06:10:18 +08:00
});
2026-04-28 16:30:51 +08:00
it("page tree surface 在 rust_family 下应把 focusedDocumentId 暴露到宿主 DOM 状态", () => {
2026-04-26 04:29:23 +08:00
renderPageSurface("rust_family", "doc_focus");
2026-04-26 19:35:52 +08:00
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
2026-04-28 16:30:51 +08:00
const domHost = container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]');
2026-04-26 04:29:23 +08:00
2026-04-26 19:35:52 +08:00
expect(surface?.getAttribute("data-page-tree-focused-id")).toBe("doc_focus");
2026-04-28 16:30:51 +08:00
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull();
2026-04-26 04:29:23 +08:00
});
2026-04-28 16:30:51 +08:00
it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => {
2026-04-26 04:29:23 +08:00
await act(async () => {
2026-04-24 06:10:18 +08:00
root.render(
<SidebarTreeSurface
mode="page"
rendererFamily="rust_family"
workspaceId="ws_1"
treeShellEnabled={false}
rows={[]}
expanded={new Set<string>()}
activeId=""
onToggleExpand={() => undefined}
onMove={() => undefined}
onCreateChild={() => undefined}
onContextMenu={() => undefined}
/>,
);
});
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
2026-04-28 16:30:51 +08:00
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]')).not.toBeNull();
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull();
2026-04-26 04:29:23 +08:00
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
});
it("page tree 在缺少 workspaceId 时应显示 Rust 宿主占位,而不是回退旧 React renderer", () => {
act(() => {
root.render(
<SidebarTreeSurface
mode="page"
rendererFamily="rust_family"
workspaceId={null}
treeShellEnabled
rows={[]}
expanded={new Set<string>()}
activeId=""
onToggleExpand={() => undefined}
onMove={() => undefined}
onCreateChild={() => undefined}
onContextMenu={() => undefined}
/>,
);
});
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("page_tree_renderer_removed");
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).not.toBeNull();
2026-04-24 06:10:18 +08:00
});
2026-04-28 16:30:51 +08:00
it("file tree surface 在 rust_family 下也应走默认 Rust/WASM DOM shell host", () => {
2026-04-24 06:10:18 +08:00
act(() => {
root.render(
<SidebarTreeSurface
mode="filetree"
rendererFamily="rust_family"
workspaceId="ws_1"
treeShellEnabled
rows={[]}
activeId=""
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
onRowContextMenu={() => undefined}
onToggleExpand={() => undefined}
onCreateChild={() => undefined}
/>,
);
});
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
const rustHost = container.querySelector(
'[data-testid="sidebar-file-tree-shell-rust-host"]',
);
2026-04-28 16:30:51 +08:00
const domHost = container.querySelector('[data-testid="sidebar-file-tree-shell-dom-host"]');
2026-04-24 06:10:18 +08:00
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
2026-04-28 16:30:51 +08:00
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
2026-04-26 19:35:52 +08:00
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
2026-04-24 06:10:18 +08:00
expect(rustHost).not.toBeNull();
2026-04-28 16:30:51 +08:00
expect(domHost).not.toBeNull();
expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1");
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
2026-04-26 04:29:23 +08:00
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
});
2026-04-28 16:30:51 +08:00
it("file tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => {
2026-04-26 04:29:23 +08:00
await act(async () => {
root.render(
<SidebarTreeSurface
mode="filetree"
rendererFamily="rust_family"
workspaceId="ws_1"
treeShellEnabled={false}
rows={[]}
activeId=""
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
onRowContextMenu={() => undefined}
onToggleExpand={() => undefined}
onCreateChild={() => undefined}
/>,
);
});
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
2026-04-28 16:30:51 +08:00
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-dom-host"]')).not.toBeNull();
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).toBeNull();
2026-04-26 04:29:23 +08:00
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
});
it("file tree 在缺少 workspaceId 时应显示 Rust 宿主占位,而不是回退旧 React renderer", () => {
act(() => {
root.render(
<SidebarTreeSurface
mode="filetree"
rendererFamily="rust_family"
workspaceId={null}
treeShellEnabled
rows={[]}
activeId=""
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
onRowContextMenu={() => undefined}
onToggleExpand={() => undefined}
onCreateChild={() => undefined}
/>,
);
});
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("filetree_renderer_removed");
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).not.toBeNull();
2026-04-24 06:10:18 +08:00
});
2026-04-28 16:30:51 +08:00
it("file tree surface 应把 DOM shell 内部拖放与外部文件拖放桥接回宿主回调", async () => {
const runtimeReducerMock = mockTreeRuntimeReducer();
2026-04-24 06:10:18 +08:00
const onInternalDrop = vi.fn();
const onDropFiles = vi.fn();
const droppedFile = new File(["bridge"], "bridge.txt", { type: "text/plain" });
2026-04-28 16:30:51 +08:00
const dataTransfer = {
files: [] as File[],
types: ["application/x-mnote-file-tree", "text/plain"],
dropEffect: "move",
getData: (type: string) =>
type === "application/x-mnote-file-tree" || type === "text/plain"
? JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds: ["doc:doc_source"] })
: "",
setData: vi.fn(),
} as unknown as DataTransfer;
const externalDataTransfer = {
files: [droppedFile],
types: ["Files"],
dropEffect: "copy",
getData: () => "",
setData: vi.fn(),
} as unknown as DataTransfer;
const targetProjectionItem: KernelFileTreeProjectionItem = {
rowId: "doc:doc_target",
nodeId: "doc_target",
parentNodeId: null,
projectionKind: "file_tree",
title: "目标页面",
depth: 0,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
nodeType: "page",
rowKind: "document",
iconHint: "page",
capabilities: ["open", "select", "context-menu"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_target",
workspaceId: "ws_1",
iconHint: "page",
},
};
2026-04-24 06:10:18 +08:00
await act(async () => {
root.render(
<SidebarTreeSurface
mode="filetree"
rendererFamily="rust_family"
workspaceId="ws_1"
treeShellEnabled
2026-04-26 04:29:23 +08:00
rows={[]}
2026-04-28 16:30:51 +08:00
treeShellItems={[targetProjectionItem]}
2026-04-24 06:10:18 +08:00
activeId=""
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
onRowContextMenu={() => undefined}
onToggleExpand={() => undefined}
onCreateChild={() => undefined}
onInternalDrop={onInternalDrop}
onDropFiles={onDropFiles}
/>,
);
});
2026-04-28 16:30:51 +08:00
const targetRow = container.querySelector(
'[data-testid="filetree-doc-row"][data-document-id="doc_target"]',
2026-04-24 06:10:18 +08:00
);
2026-04-28 16:30:51 +08:00
expect(targetRow).not.toBeNull();
2026-04-24 06:10:18 +08:00
await act(async () => {
2026-04-28 16:30:51 +08:00
const internalDropEvent = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(internalDropEvent, "dataTransfer", {
configurable: true,
value: dataTransfer,
});
targetRow?.dispatchEvent(internalDropEvent);
const externalDropEvent = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(externalDropEvent, "dataTransfer", {
configurable: true,
value: externalDataTransfer,
});
targetRow?.dispatchEvent(externalDropEvent);
2026-04-24 06:10:18 +08:00
});
2026-04-28 16:30:51 +08:00
await waitForRuntimeReducer(runtimeReducerMock);
2026-04-24 06:10:18 +08:00
2026-04-28 16:30:51 +08:00
expect(runtimeReducerMock).toHaveBeenCalledWith(
expect.objectContaining({
mode: "fileTree",
action: expect.objectContaining({ kind: "dispatchInternalDrop" }),
}),
);
2026-04-24 06:10:18 +08:00
expect(onInternalDrop).toHaveBeenCalledWith({
2026-04-26 04:29:23 +08:00
targetDocumentId: "doc_target",
targetRowId: "doc:doc_target",
targetRowKind: "doc",
targetAssetId: null,
2026-04-24 06:10:18 +08:00
rowIds: ["doc:doc_source"],
copy: false,
});
2026-04-26 04:29:23 +08:00
expect(onDropFiles).toHaveBeenCalledWith({
targetDocumentId: "doc_target",
targetRowId: "doc:doc_target",
targetRowKind: "doc",
targetAssetId: null,
files: [droppedFile],
});
2026-04-24 06:10:18 +08:00
});
2026-04-28 16:30:51 +08:00
it("picker surface 在 rust_family 下也应挂到默认 Rust/WASM DOM shell host", async () => {
2026-04-24 06:10:18 +08:00
const onPick = vi.fn();
await act(async () => {
root.render(
<TreePickerSurface
rendererFamily="rust_family"
workspaceId="ws_1"
treeShellEnabled
items={[{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 }]}
highlighted={0}
onHighlight={() => undefined}
onPick={onPick}
/>,
);
});
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
2026-04-28 16:30:51 +08:00
const domHost = container.querySelector('[data-testid="tree-picker-surface-dom-host"]');
2026-04-24 06:10:18 +08:00
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
2026-04-28 16:30:51 +08:00
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
2026-04-26 19:35:52 +08:00
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
2026-04-24 06:10:18 +08:00
expect(rustHost).not.toBeNull();
2026-04-28 16:30:51 +08:00
expect(domHost).not.toBeNull();
expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1");
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
expect(container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_1"]')).not.toBeNull();
2026-04-24 06:10:18 +08:00
expect(onPick).not.toHaveBeenCalled();
});
2026-04-28 16:30:51 +08:00
it("picker 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => {
2026-04-26 04:29:23 +08:00
await act(async () => {
2026-04-24 06:10:18 +08:00
root.render(
<TreePickerSurface
rendererFamily="rust_family"
workspaceId="ws_1"
treeShellEnabled={false}
items={[{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 }]}
highlighted={0}
emptyText="没有匹配结果"
onHighlight={() => undefined}
onPick={vi.fn()}
/>,
);
});
2026-04-26 04:29:23 +08:00
await act(async () => {
await Promise.resolve();
});
2026-04-24 06:10:18 +08:00
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
2026-04-28 16:30:51 +08:00
const domHost = container.querySelector('[data-testid="tree-picker-surface-dom-host"]');
2026-04-24 06:10:18 +08:00
2026-04-28 16:30:51 +08:00
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
2026-04-24 06:10:18 +08:00
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(rustHost).not.toBeNull();
2026-04-28 16:30:51 +08:00
expect(domHost).not.toBeNull();
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
2026-04-24 06:10:18 +08:00
});
});