20260513 mindmap优化01
This commit is contained in:
@@ -1,14 +1,23 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TreeShellRustDomShellHost } from "./tree-shell-dom-host";
|
||||
import { TreeShellHost } from "./tree-shell-host";
|
||||
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type PendingRuntimeRequest = {
|
||||
request: Record<string, unknown>;
|
||||
resolve: (runtimeResult?: Record<string, unknown> | null) => void;
|
||||
};
|
||||
|
||||
describe("tree-shell-host", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let previousLegacyFlag: string | undefined;
|
||||
let originalFetch: typeof fetch | undefined;
|
||||
let pendingRuntimeRequests: PendingRuntimeRequest[];
|
||||
|
||||
beforeEach(() => {
|
||||
previousLegacyFlag = process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
|
||||
@@ -16,13 +25,35 @@ describe("tree-shell-host", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
originalFetch = global.fetch;
|
||||
pendingRuntimeRequests = [];
|
||||
global.fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
||||
return new Promise((resolve) => {
|
||||
pendingRuntimeRequests.push({
|
||||
request,
|
||||
resolve: (runtimeResult = null) => {
|
||||
resolve({
|
||||
ok: true,
|
||||
json: async () => runtimeResult,
|
||||
} as Response);
|
||||
},
|
||||
});
|
||||
});
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
pendingRuntimeRequests.splice(0).forEach(({ resolve }) => resolve(null));
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
if (originalFetch) {
|
||||
global.fetch = originalFetch;
|
||||
} else {
|
||||
delete (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch;
|
||||
}
|
||||
if (previousLegacyFlag === undefined) {
|
||||
delete process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
|
||||
} else {
|
||||
@@ -30,6 +61,109 @@ describe("tree-shell-host", () => {
|
||||
}
|
||||
});
|
||||
|
||||
const fileTreeItems: KernelFileTreeProjectionItem[] = [
|
||||
{
|
||||
projectionKind: "file_tree",
|
||||
rowId: "index:doc_1",
|
||||
rowKind: "index",
|
||||
nodeId: "doc_1:index",
|
||||
parentNodeId: null,
|
||||
title: "Doc 1 索引",
|
||||
depth: 0,
|
||||
childCount: 0,
|
||||
position: 0,
|
||||
expandedByDefault: false,
|
||||
iconHint: "page",
|
||||
capabilities: ["select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
},
|
||||
{
|
||||
projectionKind: "file_tree",
|
||||
rowId: "index:doc_2",
|
||||
rowKind: "index",
|
||||
nodeId: "doc_2:index",
|
||||
parentNodeId: null,
|
||||
title: "Doc 2 索引",
|
||||
depth: 0,
|
||||
childCount: 0,
|
||||
position: 1,
|
||||
expandedByDefault: false,
|
||||
iconHint: "page",
|
||||
capabilities: ["select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_2",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
},
|
||||
{
|
||||
projectionKind: "file_tree",
|
||||
rowId: "index:doc_3",
|
||||
rowKind: "index",
|
||||
nodeId: "doc_3:index",
|
||||
parentNodeId: null,
|
||||
title: "Doc 3 索引",
|
||||
depth: 0,
|
||||
childCount: 0,
|
||||
position: 2,
|
||||
expandedByDefault: false,
|
||||
iconHint: "page",
|
||||
capabilities: ["select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_3",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function queryFileTreeRow(rowId: string) {
|
||||
return container.querySelector(`[data-row-id="${rowId}"]`) as HTMLElement | null;
|
||||
}
|
||||
|
||||
function expectSelectedRowIds(rowIds: string[]) {
|
||||
const selected = Array.from(container.querySelectorAll('[data-shell-mode="filetree"][data-selected="true"]'))
|
||||
.map((element) => element.getAttribute("data-row-id"))
|
||||
.filter((rowId): rowId is string => Boolean(rowId));
|
||||
expect(selected).toEqual(rowIds);
|
||||
}
|
||||
|
||||
function renderDomHost(input: {
|
||||
activeDocumentId?: string | null;
|
||||
onFileTreeDeleteSelection?: (payload: {
|
||||
selectedRowIds: string[];
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => void;
|
||||
} = {}) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TreeShellRustDomShellHost
|
||||
mode="filetree"
|
||||
surfaceTestId="sidebar-file-tree-shell"
|
||||
workspaceId="ws_1"
|
||||
activeDocumentId={input.activeDocumentId ?? null}
|
||||
inlineFileTreeItems={fileTreeItems}
|
||||
onFileTreeDeleteSelection={input.onFileTreeDeleteSelection}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function flushRuntimeDispatch() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function renderHost() {
|
||||
act(() => {
|
||||
root.render(
|
||||
@@ -79,4 +213,158 @@ describe("tree-shell-host", () => {
|
||||
expect(iframe?.getAttribute("data-tree-browser-bridge")).toBe("iframe_srcdoc");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"browserBridge":"iframe_srcdoc"');
|
||||
});
|
||||
|
||||
it("filetree DOM host 应先本地提交 Ctrl/Shift selection,再异步等 runtime 对账", async () => {
|
||||
renderDomHost();
|
||||
|
||||
const row2 = queryFileTreeRow("index:doc_2");
|
||||
const row3 = queryFileTreeRow("index:doc_3");
|
||||
expect(row2).not.toBeNull();
|
||||
expect(row3).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
expectSelectedRowIds(["index:doc_2"]);
|
||||
|
||||
act(() => {
|
||||
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, shiftKey: true }));
|
||||
});
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
});
|
||||
|
||||
it("旧 runtime selection 回写不应覆盖更新后的多选,Delete 应使用当前稳定 selection", async () => {
|
||||
const handleDeleteSelection = vi.fn();
|
||||
renderDomHost({ onFileTreeDeleteSelection: handleDeleteSelection });
|
||||
|
||||
const row2 = queryFileTreeRow("index:doc_2");
|
||||
const row3 = queryFileTreeRow("index:doc_3");
|
||||
expect(row2).not.toBeNull();
|
||||
expect(row3).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
act(() => {
|
||||
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
await flushRuntimeDispatch();
|
||||
|
||||
act(() => {
|
||||
pendingRuntimeRequests[0]?.resolve({
|
||||
requestId: String(pendingRuntimeRequests[0]?.request.requestId ?? "filetree-stale"),
|
||||
mode: "fileTree",
|
||||
state: {
|
||||
mode: "fileTree",
|
||||
state: {
|
||||
activeRowId: null,
|
||||
selection: {
|
||||
selectedRowIds: ["index:doc_2"],
|
||||
anchorRowId: "index:doc_2",
|
||||
focusedRowId: "index:doc_2",
|
||||
},
|
||||
dragRowIds: [],
|
||||
dragEffect: null,
|
||||
dropTargetRowId: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
|
||||
act(() => {
|
||||
row3?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Delete" }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
|
||||
expect(handleDeleteSelection).toHaveBeenCalledTimes(1);
|
||||
expect(handleDeleteSelection.mock.calls[0]?.[0]).toMatchObject({
|
||||
selectedRowIds: ["index:doc_2", "index:doc_3"],
|
||||
anchorRowId: "index:doc_3",
|
||||
focusedRowId: "index:doc_3",
|
||||
});
|
||||
});
|
||||
|
||||
it("Backspace 也应复用当前稳定多选并触发删除回调", async () => {
|
||||
const handleDeleteSelection = vi.fn();
|
||||
renderDomHost({ onFileTreeDeleteSelection: handleDeleteSelection });
|
||||
|
||||
const row2 = queryFileTreeRow("index:doc_2");
|
||||
const row3 = queryFileTreeRow("index:doc_3");
|
||||
expect(row2).not.toBeNull();
|
||||
expect(row3).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
act(() => {
|
||||
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
|
||||
act(() => {
|
||||
row3?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Backspace" }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
|
||||
expect(handleDeleteSelection).toHaveBeenCalledTimes(1);
|
||||
expect(handleDeleteSelection.mock.calls[0]?.[0]).toMatchObject({
|
||||
selectedRowIds: ["index:doc_2", "index:doc_3"],
|
||||
anchorRowId: "index:doc_3",
|
||||
focusedRowId: "index:doc_3",
|
||||
});
|
||||
});
|
||||
|
||||
it("点击行内更多操作时不应因为事件冒泡把多选压成单选", async () => {
|
||||
renderDomHost();
|
||||
|
||||
const row2 = queryFileTreeRow("index:doc_2");
|
||||
const row3 = queryFileTreeRow("index:doc_3");
|
||||
const row3Menu = container.querySelector(
|
||||
'[data-row-id="index:doc_3"] [data-testid="filetree-action-menu"]',
|
||||
) as HTMLElement | null;
|
||||
expect(row2).not.toBeNull();
|
||||
expect(row3).not.toBeNull();
|
||||
expect(row3Menu).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
act(() => {
|
||||
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
|
||||
act(() => {
|
||||
row3Menu?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
});
|
||||
|
||||
it("activeDocumentId 变化时,已有 filetree selection 不应被无条件重置", () => {
|
||||
renderDomHost();
|
||||
|
||||
const row2 = queryFileTreeRow("index:doc_2");
|
||||
const row3 = queryFileTreeRow("index:doc_3");
|
||||
expect(row2).not.toBeNull();
|
||||
expect(row3).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
act(() => {
|
||||
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
|
||||
renderDomHost({ activeDocumentId: "doc_1" });
|
||||
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user