import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Mock } from "vitest"; import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree"; import { SidebarTreeSurface, TreePickerSurface, type TreeRendererFamily } from "./tree-shell-surface"; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; type RuntimeRequest = { requestId: string; mode: "page" | "fileTree" | "picker"; environment?: Record; state?: Record; action?: Record; }; 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 selection = (state.selection ?? {}) as Record; const rows = Array.isArray(env.rows) ? env.rows as Array> : []; 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, }] : []; const selectedRowId = typeof action.rowId === "string" ? action.rowId : null; const nextSelection = action.kind === "selectRow" && selectedRowId ? { selectedRowIds: [selectedRowId], anchorRowId: selectedRowId, focusedRowId: selectedRowId } : action.kind === "clearSelection" ? { selectedRowIds: [], anchorRowId: null, focusedRowId: null } : selection; return { requestId: request.requestId, mode: "fileTree", state: { mode: "fileTree", state: { activeRowId: (state as Record).activeRowId ?? null, selection: nextSelection, dragRowIds: (state as Record).dragRowIds ?? [], dragEffect: (state as Record).dragEffect ?? null, dropTargetRowId: (state as Record).dropTargetRowId ?? null, }, }, 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(); }); } } 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(); delete (globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__; }); function renderPageSurface(rendererFamily: TreeRendererFamily, focusedDocumentId?: string) { act(() => { root.render( ()} activeId="" focusedDocumentId={focusedDocumentId} onToggleExpand={() => undefined} onMove={() => undefined} onCreateChild={() => undefined} onContextMenu={() => undefined} />, ); }); } it("page tree surface 在 rust_family 下默认切到 Rust/WASM DOM shell host", () => { 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"]', ); const domHost = container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]'); expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family"); expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family"); expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host"); expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(rustHost).not.toBeNull(); 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(); expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull(); }); it("page tree surface 在 rust_family 下应把 focusedDocumentId 暴露到宿主 DOM 状态", () => { renderPageSurface("rust_family", "doc_focus"); const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]'); const domHost = container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]'); expect(surface?.getAttribute("data-page-tree-focused-id")).toBe("doc_focus"); expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm"); expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull(); }); it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => { await act(async () => { root.render( ()} 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("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(); expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull(); }); it("page tree 在缺少 workspaceId 时应显示 Rust 宿主占位,而不是回退旧 React renderer", () => { act(() => { root.render( ()} 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(); }); it("file tree surface 在 rust_family 下也应走默认 Rust/WASM DOM shell host", () => { act(() => { root.render( 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"]', ); const domHost = container.querySelector('[data-testid="sidebar-file-tree-shell-dom-host"]'); expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family"); expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family"); expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host"); expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(rustHost).not.toBeNull(); 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(); expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull(); }); it("file tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => { await act(async () => { root.render( 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("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(); expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull(); }); it("file tree 在缺少 workspaceId 时应显示 Rust 宿主占位,而不是回退旧 React renderer", () => { act(() => { root.render( 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(); }); it("file tree surface 应把 DOM shell 内部拖放与外部文件拖放桥接回宿主回调", async () => { const runtimeReducerMock = mockTreeRuntimeReducer(); const onInternalDrop = vi.fn(); const onDropFiles = vi.fn(); const droppedFile = new File(["bridge"], "bridge.txt", { type: "text/plain" }); 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", }, }; await act(async () => { root.render( undefined} onRowDoubleClick={() => undefined} onRowContextMenu={() => undefined} onToggleExpand={() => undefined} onCreateChild={() => undefined} onInternalDrop={onInternalDrop} onDropFiles={onDropFiles} />, ); }); const targetRow = container.querySelector( '[data-testid="filetree-doc-row"][data-document-id="doc_target"]', ); expect(targetRow).not.toBeNull(); await act(async () => { 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); }); await waitForRuntimeReducer(runtimeReducerMock); expect(runtimeReducerMock).toHaveBeenCalledWith( expect.objectContaining({ mode: "fileTree", action: expect.objectContaining({ kind: "dispatchInternalDrop" }), }), ); expect(onInternalDrop).toHaveBeenCalledWith({ targetDocumentId: "doc_target", targetRowId: "doc:doc_target", targetRowKind: "doc", targetAssetId: null, rowIds: ["doc:doc_source"], copy: false, }); expect(onDropFiles).toHaveBeenCalledWith({ targetDocumentId: "doc_target", targetRowId: "doc:doc_target", targetRowKind: "doc", targetAssetId: null, files: [droppedFile], }); }); it("file tree surface 应支持空白区清空选择", async () => { const runtimeReducerMock = mockTreeRuntimeReducer(); 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", }, }; await act(async () => { root.render( undefined} onRowDoubleClick={() => undefined} onRowContextMenu={() => undefined} onToggleExpand={() => undefined} onCreateChild={() => undefined} />, ); }); const targetRow = container.querySelector('[data-testid="filetree-doc-row"][data-row-id="doc:doc_target"]') as HTMLElement | null; const treeRoot = container.querySelector('[data-rust-filetree-renderer="dom_v1"]') as HTMLElement | null; expect(targetRow).not.toBeNull(); expect(treeRoot).not.toBeNull(); await act(async () => { targetRow?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); }); await waitForRuntimeReducer(runtimeReducerMock); expect(targetRow?.getAttribute("data-selected")).toBe("true"); await act(async () => { treeRoot?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); await Promise.resolve(); }); expect(targetRow?.getAttribute("data-selected")).toBe("false"); }); it("picker surface 在 rust_family 下也应挂到默认 Rust/WASM DOM shell host", async () => { const onPick = vi.fn(); await act(async () => { root.render( undefined} onPick={onPick} />, ); }); const surface = container.querySelector('[data-testid="tree-picker-surface"]'); const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]'); const domHost = container.querySelector('[data-testid="tree-picker-surface-dom-host"]'); expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family"); expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family"); expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host"); expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(rustHost).not.toBeNull(); 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(); expect(onPick).not.toHaveBeenCalled(); }); it("picker 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => { await act(async () => { root.render( undefined} onPick={vi.fn()} />, ); }); await act(async () => { await Promise.resolve(); }); const surface = container.querySelector('[data-testid="tree-picker-surface"]'); const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]'); const domHost = container.querySelector('[data-testid="tree-picker-surface-dom-host"]'); expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host"); expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family"); expect(rustHost).not.toBeNull(); expect(domHost).not.toBeNull(); expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull(); }); });