415 lines
16 KiB
TypeScript
415 lines
16 KiB
TypeScript
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import React from "react";
|
|
import { fireEvent } from "@testing-library/react";
|
|
|
|
import { RightDrawer } from "../src/components/RightDrawer";
|
|
import { SearchPanel } from "../src/components/SearchPanel";
|
|
import { artifactDrawer, graphCommunitySummaryDrawer, graphNodeSummaryDrawer, graphSelectionDrawer, wikiDrawer, type DrawerState } from "../src/lib/drawer-state";
|
|
import type { ArtifactManifest } from "../src/lib/api";
|
|
import type { GraphCommunitySummaryPayload, GraphNodeSummaryPayload, GraphSummaryCommand, Selection } from "@llm-wiki/graph-engine";
|
|
import { click, pressKey, render, screen } from "./render";
|
|
|
|
describe("RightDrawer interactions", () => {
|
|
it("marks the drawer as open for shell layout", () => {
|
|
renderDrawer(wikiDrawer("wiki/paper.md", { content: "Paper body" }));
|
|
|
|
const drawer = document.querySelector(".drawer-panel-open");
|
|
assert.ok(drawer);
|
|
assert.equal(drawer?.getAttribute("data-drawer-open"), "true");
|
|
});
|
|
|
|
it("resizes by dragging, keyboard shortcuts, and double click reset", async () => {
|
|
const resizeCalls: number[] = [];
|
|
renderDrawer(wikiDrawer("wiki/paper.md", { content: "Paper body" }), {
|
|
width: 420,
|
|
defaultWidth: 480,
|
|
onResize: (width) => resizeCalls.push(width),
|
|
});
|
|
|
|
const handle = screen.getByRole("separator", { name: "调整预览区宽度" });
|
|
stubPointerCapture(handle);
|
|
|
|
fireEvent.pointerDown(handle, { clientX: 900, pointerId: 1 });
|
|
assert.equal(document.body.classList.contains("drawer-resizing"), true);
|
|
fireEvent.pointerMove(handle, { clientX: 852, pointerId: 1 });
|
|
fireEvent.pointerUp(handle, { pointerId: 1 });
|
|
assert.equal(document.body.classList.contains("drawer-resizing"), false);
|
|
|
|
await pressKey(handle, "ArrowLeft");
|
|
await pressKey(handle, "ArrowRight");
|
|
await pressKey(handle, "Home");
|
|
fireEvent.doubleClick(handle);
|
|
|
|
assert.deepEqual(resizeCalls, [468, 444, 396, 480, 480]);
|
|
});
|
|
|
|
it("hides the resize handle while fullscreen and keeps the fullscreen toggle wired", async () => {
|
|
const fullscreenCalls: string[] = [];
|
|
const { rerender } = renderDrawer(wikiDrawer("wiki/paper.md", { content: "Paper body" }), {
|
|
fullscreen: false,
|
|
onToggleFullscreen: () => fullscreenCalls.push("toggle"),
|
|
});
|
|
|
|
assert.ok(screen.getByRole("separator", { name: "调整预览区宽度" }));
|
|
await click(screen.getByRole("button", { name: "全屏" }));
|
|
assert.deepEqual(fullscreenCalls, ["toggle"]);
|
|
|
|
rerender(drawerElement(wikiDrawer("wiki/paper.md", { content: "Paper body" }), {
|
|
fullscreen: true,
|
|
onToggleFullscreen: () => fullscreenCalls.push("toggle"),
|
|
}));
|
|
|
|
assert.equal(document.querySelector(".drawer-panel-fullscreen") !== null, true);
|
|
assert.equal(screen.queryByRole("separator", { name: "调整预览区宽度" }), null);
|
|
await click(screen.getByRole("button", { name: "退出全屏" }));
|
|
assert.deepEqual(fullscreenCalls, ["toggle", "toggle"]);
|
|
});
|
|
|
|
it("closes from the header button and Escape key", async () => {
|
|
const closeReasons: string[] = [];
|
|
renderDrawer(wikiDrawer("wiki/paper.md", { content: "Paper body" }), {
|
|
onClose: (reason) => closeReasons.push(reason),
|
|
});
|
|
|
|
await click(screen.getByRole("button", { name: "关闭" }));
|
|
await pressKey(document, "Escape");
|
|
|
|
assert.deepEqual(closeReasons, ["button", "escape"]);
|
|
});
|
|
|
|
it("does not close the drawer when Escape closes search above it", async () => {
|
|
const closeReasons: string[] = [];
|
|
const searchCloses: string[] = [];
|
|
render(
|
|
<React.Fragment>
|
|
{drawerElement(wikiDrawer("wiki/paper.md", { content: "Paper body" }), {
|
|
onClose: (reason) => closeReasons.push(reason),
|
|
})}
|
|
<SearchPanel
|
|
open
|
|
refs={[{ path: "wiki/paper.md", name: "paper", title: "Paper", category: "entities" }]}
|
|
knowledgeBaseName="AI学习知识库"
|
|
onOpenPage={noopString}
|
|
onClose={() => searchCloses.push("search")}
|
|
/>
|
|
</React.Fragment>,
|
|
);
|
|
|
|
await pressKey(screen.getByLabelText("搜索当前库页面"), "Escape");
|
|
|
|
assert.deepEqual(searchCloses, ["search"]);
|
|
assert.deepEqual(closeReasons, []);
|
|
});
|
|
|
|
it("switches artifact tabs without losing the active tab class", async () => {
|
|
const selectedIds: string[] = [];
|
|
renderDrawer(artifactDrawer([artifact("art-html", "Paper HTML", "html"), artifact("art-pdf", "Paper PDF", "pdf")], "art-html"), {
|
|
onSelectArtifact: (id) => selectedIds.push(id),
|
|
});
|
|
|
|
const first = screen.getByRole("button", { name: /Paper HTML/ });
|
|
const second = screen.getByRole("button", { name: /Paper PDF/ });
|
|
assert.equal(first.classList.contains("drawer-tab-active"), true);
|
|
assert.equal(second.classList.contains("drawer-tab-active"), false);
|
|
|
|
await click(second);
|
|
|
|
assert.deepEqual(selectedIds, ["art-pdf"]);
|
|
});
|
|
|
|
it("dispatches select-neighbors when the +邻居 command is clicked", async () => {
|
|
const commands: GraphSummaryCommand[] = [];
|
|
renderDrawer(graphNodeSummaryDrawer(nodeSummaryFixture()), {
|
|
onGraphSummaryCommand: (command) => commands.push(command),
|
|
});
|
|
|
|
await click(screen.getByRole("button", { name: "+邻居" }));
|
|
|
|
assert.deepEqual(commands, [{ kind: "select-neighbors", nodeId: "alpha-node", label: "+邻居" }]);
|
|
});
|
|
|
|
it("dispatches free-text send as a free graph selection question", async () => {
|
|
const asks: Array<{ actionId: string | null; newConversation: boolean }> = [];
|
|
renderDrawer(graphSelectionDrawer(selectionFixture(), "Alpha/Beta", "只看这两页的差异"), {
|
|
onGraphSelectionAsk: (actionId, newConversation) => asks.push({ actionId, newConversation }),
|
|
});
|
|
|
|
await click(screen.getByRole("button", { name: "发送" }));
|
|
|
|
assert.deepEqual(asks, [{ actionId: null, newConversation: false }]);
|
|
});
|
|
|
|
it("dispatches empty new-conversation from selection drawer with no explicit clicked action", async () => {
|
|
const asks: Array<{ actionId: string | null; newConversation: boolean }> = [];
|
|
renderDrawer(graphSelectionDrawer(selectionFixture(), "Alpha/Beta", ""), {
|
|
onGraphSelectionAsk: (actionId, newConversation) => asks.push({ actionId, newConversation }),
|
|
});
|
|
|
|
await click(screen.getByRole("button", { name: "新对话" }));
|
|
|
|
assert.deepEqual(asks, [{ actionId: null, newConversation: true }]);
|
|
});
|
|
|
|
it("dispatches community free-text send and empty new-conversation without an explicit clicked action", async () => {
|
|
const asks: Array<{ actionId: string | null; newConversation: boolean }> = [];
|
|
const { rerender } = renderDrawer(graphCommunitySummaryDrawer(communitySummaryFixture(), "帮我判断下一步读什么"), {
|
|
onGraphCommunityAsk: (actionId, newConversation) => asks.push({ actionId, newConversation }),
|
|
});
|
|
|
|
await click(screen.getByRole("button", { name: "发送" }));
|
|
assert.deepEqual(asks, [{ actionId: null, newConversation: false }]);
|
|
|
|
asks.length = 0;
|
|
rerender(drawerElement(graphCommunitySummaryDrawer(communitySummaryFixture(), ""), {
|
|
onGraphCommunityAsk: (actionId, newConversation) => asks.push({ actionId, newConversation }),
|
|
}));
|
|
await click(screen.getByRole("button", { name: "新对话" }));
|
|
|
|
assert.deepEqual(asks, [{ actionId: null, newConversation: true }]);
|
|
});
|
|
|
|
it("expands and collapses community core nodes without changing node click behavior", async () => {
|
|
const selectedNodeIds: string[] = [];
|
|
const payload = communitySummaryFixture({
|
|
coreNodeIds: ["alpha-node", "beta-node", "gamma-node", "delta-node"],
|
|
coreNodes: [
|
|
{ nodeId: "alpha-node", label: "Alpha node", type: "topic", role: "核心" },
|
|
{ nodeId: "beta-node", label: "Beta node", type: "entity", role: "相关" },
|
|
{ nodeId: "gamma-node", label: "Gamma node", type: "source", role: "相关" },
|
|
{ nodeId: "delta-node", label: "Delta node", type: "entity", role: "相关" },
|
|
],
|
|
});
|
|
renderDrawer(graphCommunitySummaryDrawer(payload), {
|
|
onGraphSummaryNodeSelect: (nodeId) => selectedNodeIds.push(nodeId),
|
|
});
|
|
|
|
assert.ok(screen.getByRole("button", { name: /Alpha node/ }));
|
|
assert.ok(screen.getByRole("button", { name: /Beta node/ }));
|
|
assert.ok(screen.getByRole("button", { name: /Gamma node/ }));
|
|
assert.equal(screen.queryByRole("button", { name: /Delta node/ }), null);
|
|
|
|
await click(screen.getByRole("button", { name: "查看全部" }));
|
|
assert.ok(screen.getByRole("button", { name: /Delta node/ }));
|
|
assert.ok(screen.getByRole("button", { name: "收起" }));
|
|
|
|
await click(screen.getByRole("button", { name: /Delta node/ }));
|
|
assert.deepEqual(selectedNodeIds, ["delta-node"]);
|
|
|
|
await click(screen.getByRole("button", { name: "收起" }));
|
|
assert.equal(screen.queryByRole("button", { name: /Delta node/ }), null);
|
|
});
|
|
|
|
it("preserves community core node preview callbacks", () => {
|
|
const previews: Array<string | null> = [];
|
|
renderDrawer(graphCommunitySummaryDrawer(communitySummaryFixture()), {
|
|
onGraphSummaryNodePreview: (nodeId) => previews.push(nodeId),
|
|
});
|
|
|
|
const row = screen.getByRole("button", { name: /Alpha node/ });
|
|
fireEvent.mouseEnter(row);
|
|
fireEvent.mouseLeave(row);
|
|
fireEvent.focus(row);
|
|
fireEvent.blur(row);
|
|
|
|
assert.deepEqual(previews, ["alpha-node", null, "alpha-node", null]);
|
|
});
|
|
|
|
it("clears core node preview before opening the node summary drawer", async () => {
|
|
const previews: Array<string | null> = [];
|
|
const selectedNodeIds: string[] = [];
|
|
renderDrawer(graphCommunitySummaryDrawer(communitySummaryFixture()), {
|
|
onGraphSummaryNodePreview: (nodeId) => previews.push(nodeId),
|
|
onGraphSummaryNodeSelect: (nodeId) => selectedNodeIds.push(nodeId),
|
|
});
|
|
|
|
const row = screen.getByRole("button", { name: /Alpha node/ });
|
|
fireEvent.mouseEnter(row);
|
|
await click(row);
|
|
|
|
assert.deepEqual(previews, ["alpha-node", null]);
|
|
assert.deepEqual(selectedNodeIds, ["alpha-node"]);
|
|
});
|
|
|
|
it("resets expanded core nodes when the node-list identity changes", async () => {
|
|
const first = communitySummaryFixture({
|
|
communityId: "alpha",
|
|
label: "Alpha community",
|
|
coreNodeIds: ["alpha-node", "beta-node", "gamma-node", "delta-node"],
|
|
coreNodes: [
|
|
{ nodeId: "alpha-node", label: "Alpha node", type: "topic", role: "核心" },
|
|
{ nodeId: "beta-node", label: "Beta node", type: "entity", role: "相关" },
|
|
{ nodeId: "gamma-node", label: "Gamma node", type: "source", role: "相关" },
|
|
{ nodeId: "delta-node", label: "Delta node", type: "entity", role: "相关" },
|
|
],
|
|
});
|
|
const second = communitySummaryFixture({
|
|
communityId: "alpha",
|
|
label: "Alpha community",
|
|
coreNodeIds: ["one-node", "two-node", "three-node", "four-node"],
|
|
coreNodes: [
|
|
{ nodeId: "one-node", label: "One node", type: "topic", role: "核心" },
|
|
{ nodeId: "two-node", label: "Two node", type: "entity", role: "相关" },
|
|
{ nodeId: "three-node", label: "Three node", type: "source", role: "相关" },
|
|
{ nodeId: "four-node", label: "Four node", type: "entity", role: "相关" },
|
|
],
|
|
});
|
|
const { rerender } = renderDrawer(graphCommunitySummaryDrawer(first));
|
|
|
|
await click(screen.getByRole("button", { name: "查看全部" }));
|
|
assert.ok(screen.getByRole("button", { name: /Delta node/ }));
|
|
|
|
rerender(drawerElement(graphCommunitySummaryDrawer(second)));
|
|
assert.ok(screen.getByRole("button", { name: /One node/ }));
|
|
assert.ok(screen.getByRole("button", { name: /Three node/ }));
|
|
assert.equal(screen.queryByRole("button", { name: /Four node/ }), null);
|
|
assert.ok(screen.getByRole("button", { name: "查看全部" }));
|
|
|
|
rerender(drawerElement(graphCommunitySummaryDrawer(first)));
|
|
assert.ok(screen.getByRole("button", { name: /Alpha node/ }));
|
|
assert.ok(screen.getByRole("button", { name: /Gamma node/ }));
|
|
assert.equal(screen.queryByRole("button", { name: /Delta node/ }), null);
|
|
assert.ok(screen.getByRole("button", { name: "查看全部" }));
|
|
});
|
|
});
|
|
|
|
function nodeSummaryFixture(): GraphNodeSummaryPayload {
|
|
return {
|
|
kind: "node-summary",
|
|
object: { kind: "node", nodeId: "alpha-node" },
|
|
nodeId: "alpha-node",
|
|
label: "Alpha node",
|
|
type: "topic",
|
|
communityId: "alpha",
|
|
sourcePath: "wiki/alpha.md",
|
|
summary: null,
|
|
connectionCount: 2,
|
|
searchHit: false,
|
|
pinHint: { nodeId: "alpha-node", wikiPath: "wiki/alpha.md", pinned: false, position: null },
|
|
selection: {
|
|
input: { kind: "node", id: "alpha-node" },
|
|
selectionId: "node:alpha-node",
|
|
selectedNodeIds: ["alpha-node"],
|
|
selectedCommunityIds: ["alpha"],
|
|
containsCurrentObject: true,
|
|
},
|
|
strongestRelations: [],
|
|
bridgeRelations: [],
|
|
aggregationMarkers: [],
|
|
commands: [
|
|
{ kind: "open-detail-read", nodeId: "alpha-node", path: "wiki/alpha.md", label: "打开详情" },
|
|
{ kind: "select-neighbors", nodeId: "alpha-node", label: "+邻居" },
|
|
{ kind: "set-fixed-position", mode: "fix", nodeId: "alpha-node", wikiPath: "wiki/alpha.md", label: "固定位置" },
|
|
],
|
|
};
|
|
}
|
|
|
|
function communitySummaryFixture(overrides: Partial<GraphCommunitySummaryPayload> = {}): GraphCommunitySummaryPayload {
|
|
return {
|
|
kind: "community-summary",
|
|
object: { kind: "community", communityId: "alpha" },
|
|
communityId: "alpha",
|
|
label: "Alpha community",
|
|
nodeCount: 2,
|
|
facts: { pageCount: 2, internalLinkCount: 1, communityCount: 1, isolatedCount: 0 },
|
|
structureState: "clear",
|
|
description: "这组页面围绕同一主题聚在一起。你可以先看结构,也可以直接让 agent 基于这一组页面继续工作。",
|
|
canEnterCommunity: true,
|
|
coreNodeIds: ["alpha-node", "beta-node"],
|
|
coreNodes: [
|
|
{ nodeId: "alpha-node", label: "Alpha node", type: "topic", role: "核心" },
|
|
{ nodeId: "beta-node", label: "Beta node", type: "entity", role: "相关" },
|
|
],
|
|
searchResultIds: [],
|
|
pinHints: [],
|
|
selection: {
|
|
input: { kind: "community", id: "alpha" },
|
|
selectionId: "community:alpha-node,beta-node",
|
|
selectedNodeIds: ["alpha-node", "beta-node"],
|
|
selectedCommunityIds: ["alpha"],
|
|
containsCurrentObject: true,
|
|
},
|
|
strongestRelations: [],
|
|
bridgeRelations: [],
|
|
aggregationMarkers: [],
|
|
commands: [{ kind: "enter-community", communityId: "alpha", label: "进入社区" }],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function selectionFixture(): Selection {
|
|
return {
|
|
id: "nodes:a,b",
|
|
nodeIds: ["a", "b"],
|
|
communityIds: ["alpha", "beta"],
|
|
facts: { pageCount: 2, internalLinkCount: 0, communityCount: 2, isolatedCount: 0 },
|
|
input: { kind: "nodes", ids: ["a", "b"] },
|
|
actions: [],
|
|
};
|
|
}
|
|
|
|
function renderDrawer(drawer: DrawerState, props: Partial<RightDrawerProps> = {}) {
|
|
return render(drawerElement(drawer, props));
|
|
}
|
|
|
|
function drawerElement(drawer: DrawerState, props: Partial<RightDrawerProps> = {}) {
|
|
return (
|
|
<RightDrawer
|
|
drawer={drawer}
|
|
fullscreen={props.fullscreen ?? false}
|
|
width={props.width ?? 420}
|
|
defaultWidth={props.defaultWidth ?? 420}
|
|
onSelectArtifact={props.onSelectArtifact ?? noopString}
|
|
onOpenPage={noopString}
|
|
onWikiLinkSeen={noopString}
|
|
onGraphReaderAction={noopString}
|
|
onGraphSummaryCommand={props.onGraphSummaryCommand ?? noop}
|
|
onGraphSummaryNodeSelect={props.onGraphSummaryNodeSelect ?? noopString}
|
|
onGraphSummaryNodePreview={props.onGraphSummaryNodePreview ?? noopPreviewNode}
|
|
onGraphSelectionTextChange={noopString}
|
|
onGraphSelectionAsk={props.onGraphSelectionAsk ?? noopSelectionAsk}
|
|
onGraphCommunityTextChange={noopString}
|
|
onGraphCommunityAsk={props.onGraphCommunityAsk ?? noopSelectionAsk}
|
|
onResize={props.onResize ?? noopNumber}
|
|
onToggleFullscreen={props.onToggleFullscreen ?? noop}
|
|
onClose={props.onClose ?? noopClose}
|
|
/>
|
|
);
|
|
}
|
|
|
|
type RightDrawerProps = React.ComponentProps<typeof RightDrawer>;
|
|
|
|
function stubPointerCapture(element: Element) {
|
|
Object.defineProperties(element, {
|
|
setPointerCapture: { configurable: true, value: () => {} },
|
|
hasPointerCapture: { configurable: true, value: () => true },
|
|
releasePointerCapture: { configurable: true, value: () => {} },
|
|
});
|
|
}
|
|
|
|
function artifact(id: string, title: string, kind: ArtifactManifest["kind"]): ArtifactManifest {
|
|
const extension = kind === "html" ? "html" : kind;
|
|
return {
|
|
id,
|
|
kind,
|
|
renderer: kind === "html" ? "iframe" : "download-only",
|
|
metadata: {
|
|
title,
|
|
createdAt: "2026-06-20T00:00:00.000Z",
|
|
sourceConversationId: "conversation-1",
|
|
sourceKbPath: "/kb",
|
|
sourceSkill: "paper",
|
|
sizeBytes: 128,
|
|
},
|
|
files: [{ name: `artifact.${extension}`, sizeBytes: 128, mimeType: "text/plain" }],
|
|
primaryFile: `artifact.${extension}`,
|
|
};
|
|
}
|
|
|
|
function noop() {}
|
|
function noopString() {}
|
|
function noopNumber() {}
|
|
function noopClose() {}
|
|
function noopPreviewNode() {}
|
|
function noopSelectionAsk() {}
|