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";
|
|
|
|
|
import { SidebarTreeSurface, TreePickerSurface, type TreeRendererFamily } from "./tree-shell-surface";
|
|
|
|
|
|
|
|
|
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
|
|
|
|
|
|
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-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}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("page tree surface 在 rust_family 下可切到同源 iframe 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 iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
|
|
|
|
|
|
|
|
|
|
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("mnote_web_iframe_proxy");
|
|
|
|
|
expect(rustHost).not.toBeNull();
|
|
|
|
|
expect(iframe).not.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-26 04:29:23 +08:00
|
|
|
it("page tree surface 在 rust_family 下应把 focusedDocumentId 透传到 iframe", () => {
|
|
|
|
|
renderPageSurface("rust_family", "doc_focus");
|
|
|
|
|
|
|
|
|
|
const iframe = container.querySelector(
|
|
|
|
|
'[data-testid="sidebar-page-tree-shell-rust-iframe"]',
|
|
|
|
|
) as HTMLIFrameElement | null;
|
|
|
|
|
|
|
|
|
|
expect(iframe?.getAttribute("src")).toContain("focusedDocumentId=doc_focus");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
|
|
|
|
|
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-26 04:29:23 +08:00
|
|
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
|
|
|
|
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).not.toBeNull();
|
|
|
|
|
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
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("file tree surface 在 rust_family 下也应走同一 host 选择契约", () => {
|
|
|
|
|
act(() => {
|
|
|
|
|
root.render(
|
|
|
|
|
<SidebarTreeSurface
|
|
|
|
|
mode="filetree"
|
|
|
|
|
rendererFamily="rust_family"
|
|
|
|
|
workspaceId="ws_1"
|
|
|
|
|
treeShellEnabled
|
|
|
|
|
rows={[]}
|
|
|
|
|
activeId=""
|
|
|
|
|
selectedRowIds={new Set<string>()}
|
|
|
|
|
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"]',
|
|
|
|
|
);
|
|
|
|
|
const iframe = container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]');
|
|
|
|
|
|
|
|
|
|
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("mnote_web_iframe_proxy");
|
|
|
|
|
expect(rustHost).not.toBeNull();
|
|
|
|
|
expect(iframe).not.toBeNull();
|
2026-04-26 04:29:23 +08:00
|
|
|
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("file tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
|
|
|
|
|
await act(async () => {
|
|
|
|
|
root.render(
|
|
|
|
|
<SidebarTreeSurface
|
|
|
|
|
mode="filetree"
|
|
|
|
|
rendererFamily="rust_family"
|
|
|
|
|
workspaceId="ws_1"
|
|
|
|
|
treeShellEnabled={false}
|
|
|
|
|
rows={[]}
|
|
|
|
|
activeId=""
|
|
|
|
|
selectedRowIds={new Set<string>()}
|
|
|
|
|
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("mnote_web_iframe_proxy");
|
|
|
|
|
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).not.toBeNull();
|
|
|
|
|
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=""
|
|
|
|
|
selectedRowIds={new Set<string>()}
|
|
|
|
|
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
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("file tree surface 应把 iframe 内部拖放与外部文件拖放桥接回宿主回调", async () => {
|
|
|
|
|
const onInternalDrop = vi.fn();
|
|
|
|
|
const onDropFiles = vi.fn();
|
|
|
|
|
const droppedFile = new File(["bridge"], "bridge.txt", { type: "text/plain" });
|
|
|
|
|
|
|
|
|
|
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-24 06:10:18 +08:00
|
|
|
activeId=""
|
|
|
|
|
selectedRowIds={new Set<string>()}
|
|
|
|
|
onRowClick={() => undefined}
|
|
|
|
|
onRowDoubleClick={() => undefined}
|
|
|
|
|
onRowContextMenu={() => undefined}
|
|
|
|
|
onToggleExpand={() => undefined}
|
|
|
|
|
onCreateChild={() => undefined}
|
|
|
|
|
onInternalDrop={onInternalDrop}
|
|
|
|
|
onDropFiles={onDropFiles}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const iframe = container.querySelector(
|
|
|
|
|
'[data-testid="sidebar-file-tree-shell-rust-iframe"]',
|
|
|
|
|
);
|
|
|
|
|
expect(iframe).not.toBeNull();
|
|
|
|
|
Object.defineProperty(iframe, "contentWindow", {
|
|
|
|
|
configurable: true,
|
|
|
|
|
value: window,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
window.dispatchEvent(
|
|
|
|
|
new MessageEvent("message", {
|
|
|
|
|
data: {
|
|
|
|
|
channel: "sidebar-file-tree-shell",
|
|
|
|
|
type: "tree.filetree.internal-drop",
|
|
|
|
|
rowIds: ["doc:doc_source"],
|
|
|
|
|
copy: false,
|
2026-04-26 04:29:23 +08:00
|
|
|
rowId: "doc:doc_target",
|
|
|
|
|
rowKind: "doc",
|
|
|
|
|
documentId: "doc_target",
|
2026-04-24 06:10:18 +08:00
|
|
|
},
|
|
|
|
|
source: window,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
window.dispatchEvent(
|
|
|
|
|
new MessageEvent("message", {
|
|
|
|
|
data: {
|
|
|
|
|
channel: "sidebar-file-tree-shell",
|
|
|
|
|
type: "tree.filetree.external-drop",
|
|
|
|
|
documentId: "doc_target",
|
2026-04-26 04:29:23 +08:00
|
|
|
rowId: "doc:doc_target",
|
|
|
|
|
rowKind: "doc",
|
2026-04-24 06:10:18 +08:00
|
|
|
files: [droppedFile],
|
|
|
|
|
},
|
|
|
|
|
source: window,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("picker surface 在 rust_family 下也应挂到同一 host 边界", async () => {
|
|
|
|
|
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"]');
|
|
|
|
|
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
|
|
|
|
|
|
|
|
|
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("mnote_web_iframe_proxy");
|
|
|
|
|
expect(rustHost).not.toBeNull();
|
|
|
|
|
expect(iframe).not.toBeNull();
|
|
|
|
|
expect(onPick).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-26 04:29:23 +08:00
|
|
|
it("picker 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
|
|
|
|
|
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-26 04:29:23 +08:00
|
|
|
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
2026-04-24 06:10:18 +08:00
|
|
|
|
2026-04-26 04:29:23 +08:00
|
|
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
2026-04-24 06:10:18 +08:00
|
|
|
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
|
|
|
|
expect(rustHost).not.toBeNull();
|
2026-04-26 04:29:23 +08:00
|
|
|
expect(iframe).not.toBeNull();
|
2026-04-24 06:10:18 +08:00
|
|
|
});
|
|
|
|
|
});
|