Files
llm-wiki/workbench/web/test/right-drawer-graph-summary.test.tsx
T
2026-07-12 21:26:08 +08:00

366 lines
16 KiB
TypeScript

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { RightDrawer } from "../src/components/RightDrawer";
import {
graphCommunitySummaryDrawer,
graphEmptyDrawer,
graphErrorDrawer,
graphExcludedObjectDrawer,
graphLoadingDrawer,
graphNodeSummaryDrawer,
graphReaderDrawer,
graphUnavailableObjectDrawer,
type DrawerState,
} from "../src/lib/drawer-state";
import type {
GraphCommunitySummaryPayload,
GraphExcludedObjectPayload,
GraphNodeSummaryPayload,
GraphUnavailableObjectPayload,
} from "@llm-wiki/graph-engine";
describe("RightDrawer graph lightweight summaries", () => {
it("renders node summary fields and actions without full reader markdown", () => {
const html = renderDrawer(graphNodeSummaryDrawer(nodeSummaryFixture()));
assert.match(html, /data-testid="graph-node-summary"/);
assert.match(html, /Alpha node/);
assert.match(html, /节点/);
assert.match(html, /连接/);
assert.match(html, /graph-summary-community-chip/);
assert.match(html, /graph-summary-relation-pill/);
assert.match(html, /打开详情/);
assert.match(html, /固定位置/);
assert.doesNotMatch(html, /full markdown body should stay out of summaries/);
assert.doesNotMatch(html, /graph-reader-drawer/);
assert.doesNotMatch(html, /summary-left-rail|ai-slop|border-left/);
});
it("renders unified community drawer with overview, fixed actions, core nodes, and dialogue controls", () => {
const html = renderDrawer(graphCommunitySummaryDrawer(communitySummaryFixture()));
assert.match(html, /data-testid="graph-community-summary"/);
assert.match(html, /Alpha community/);
assert.match(html, /graph-group-meta-row/);
assert.match(html, /graph-group-meta-row[\s\S]*graph-summary-kicker[^>]*>社区<\/span>/);
assert.match(html, /graph-group-meta-row[\s\S]*graph-group-status-chip[^>]*>结构清晰<\/span>/);
assert.match(html, /graph-group-hero[\s\S]*graph-group-enter[^>]*>进入社区<\/button>/);
assert.match(html, /进入社区/);
assert.match(html, /总结这一簇/);
assert.match(html, /找知识缺口/);
assert.match(html, /生成主题页/);
assert.match(html, /探索潜在关系/);
assert.match(html, /补充说明(可选)/);
assert.match(html, /发送/);
assert.match(html, /新对话/);
assert.match(html, /Alpha node/);
assert.match(html, /当前社区会带入对话/);
assert.match(html, /graph-group-node-toggle/);
assert.match(html, /data-group-drawer="send"[\s\S]*<svg/);
assert.match(html, /data-group-drawer="new-conversation"[\s\S]*<svg/);
assert.doesNotMatch(html, /暂无搜索命中/);
assert.doesNotMatch(html, /暂无固定节点/);
assert.doesNotMatch(html, /暂无桥接关系/);
});
it("renders ungrouped community without enter-community and recommends relation exploration", () => {
const html = renderDrawer(graphCommunitySummaryDrawer(communitySummaryFixture({
communityId: "_none",
label: "未分组",
structureState: "ungrouped",
description: "这些页面暂未形成明确社区。你可以让 agent 探索它们之间是否存在潜在关系。",
canEnterCommunity: false,
commands: [],
})));
assert.match(html, /未分组/);
assert.match(html, /暂未形成明确社区/);
assert.match(html, /当前社区会带入对话/);
assert.match(html, /data-recommended="true"[^>]*>[\s\S]*探索潜在关系/);
assert.doesNotMatch(html, /进入社区/);
});
it("disables send until free text exists and enables it once typed", () => {
const empty = renderDrawer(graphCommunitySummaryDrawer(communitySummaryFixture()));
assert.match(empty, /<button[^>]*data-group-drawer="send"[^>]*disabled/);
const filled = renderDrawer(graphCommunitySummaryDrawer(communitySummaryFixture(), "看一下缺口"));
assert.doesNotMatch(filled, /<button[^>]*data-group-drawer="send"[^>]*disabled/);
});
it("renders graph empty, excluded, and unavailable states", () => {
const empty = renderDrawer(graphEmptyDrawer("没有搜索结果", "no-search-results", "暂无搜索结果"));
const missingNeighbors = renderDrawer(graphEmptyDrawer("没有邻居", "missing-neighbors", "暂无邻居"));
const missingCommunity = renderDrawer(graphEmptyDrawer("没有社区摘要", "missing-community-summary", "暂无社区摘要"));
const missingStrongRelations = renderDrawer(graphEmptyDrawer("没有强关系", "missing-strong-relations", "暂无强关系"));
const excluded = renderDrawer(graphExcludedObjectDrawer(excludedFixture()));
const unavailable = renderDrawer(graphUnavailableObjectDrawer(unavailableFixture()));
assert.match(empty, /没有搜索结果/);
assert.match(empty, /暂无搜索结果/);
assert.match(missingNeighbors, /暂无邻居/);
assert.match(missingCommunity, /暂无社区摘要/);
assert.match(missingStrongRelations, /暂无强关系/);
assert.match(excluded, /data-testid="graph-excluded-object"/);
assert.match(excluded, /暂不可见/);
assert.match(excluded, /当前筛选暂时隐藏了这个对象/);
assert.match(excluded, /显示这个对象/);
assert.match(excluded, /清除临时显示/);
assert.match(unavailable, /data-testid="graph-unavailable-object"/);
assert.match(unavailable, /missing-node/);
assert.match(unavailable, /这个节点当前不可用/);
});
it("renders loading, reader loading, reader error, and hard error states honestly", () => {
const loading = renderDrawer(graphLoadingDrawer("整理图谱摘要", "正在整理当前对象"));
const readerLoading = renderDrawer(graphReaderDrawer(graphPayload(), { loading: true }));
const readerError = renderDrawer(graphReaderDrawer(graphPayload(), { error: "读取失败" }));
const hardError = renderDrawer(graphErrorDrawer("图谱错误", "图谱服务暂时不可用"));
assert.match(loading, /data-testid="graph-simple-state"/);
assert.match(loading, /整理图谱摘要/);
assert.match(loading, /正在整理当前对象/);
assert.match(readerLoading, /graph-reader-drawer/);
assert.match(readerLoading, /加载中/);
assert.doesNotMatch(readerLoading, /完整正文内容/);
assert.match(readerError, /读取失败/);
assert.doesNotMatch(readerError, /完整正文内容/);
assert.match(hardError, /图谱错误/);
assert.match(hardError, /图谱服务暂时不可用/);
assert.doesNotMatch(`${loading}${readerLoading}${readerError}${hardError}`, /推荐阅读|猜你喜欢|建议你打开/);
});
it("keeps drawer actions as tabbable buttons", () => {
const node = renderDrawer(graphNodeSummaryDrawer(nodeSummaryFixture()));
const community = renderDrawer(graphCommunitySummaryDrawer(communitySummaryFixture()));
const excluded = renderDrawer(graphExcludedObjectDrawer(excludedFixture()));
assert.match(node, /<button[^>]*class="graph-summary-action"[^>]*>打开详情<\/button>/);
assert.match(node, /<button[^>]*class="graph-summary-action"[^>]*>固定位置<\/button>/);
assert.match(community, /<button[^>]*class="graph-group-enter"[^>]*>进入社区<\/button>/);
assert.match(community, /<button[^>]*class="graph-group-action"[^>]*data-group-drawer="action"[^>]*>总结这一簇<\/button>/);
assert.match(excluded, /<button[^>]*class="graph-summary-action"[^>]*>显示这个对象<\/button>/);
assert.doesNotMatch(`${node}${community}${excluded}`, /tabindex="-1"/i);
});
it("keeps the Paper summary styling contract", () => {
const css = readFileSync(resolve(import.meta.dirname, "../src/index.css"), "utf8");
assert.match(css, /\.graph-summary-drawer[\s\S]*border-radius:\s*16px/);
assert.match(css, /\.graph-summary-drawer[\s\S]*var\(--paper-grain\)/);
assert.match(css, /\.graph-summary-drawer[\s\S]*box-shadow:\s*var\(--shadow-lg\)/);
assert.match(css, /\.graph-summary-community-chip::before[\s\S]*width:\s*7px/);
assert.match(css, /\.graph-summary-relation-pill[\s\S]*border-radius:\s*999px/);
assert.match(css, /\.graph-summary-action[\s\S]*box-shadow:\s*var\(--shadow\)/);
assert.doesNotMatch(css, /--app-shadow-color/);
assert.doesNotMatch(css, /summary-left-rail|ai-slop/);
});
it("keeps the graph group drawer visual contract", () => {
const css = readFileSync(resolve(import.meta.dirname, "../src/index.css"), "utf8");
assert.match(css, /\.graph-group-node-toggle[\s\S]*color:\s*var\(--app-accent-deep\)/);
assert.match(css, /\.graph-group-drawer \.graph-summary-facts[\s\S]*grid-template-columns:\s*repeat\(4,\s*minmax\(0,\s*1fr\)\)/);
assert.match(css, /\.graph-group-drawer \.graph-summary-fact[\s\S]*display:\s*inline-flex/);
assert.match(css, /\.graph-group-drawer \.graph-summary-fact[\s\S]*align-items:\s*center/);
assert.match(css, /\.graph-group-drawer \.graph-summary-fact[\s\S]*justify-content:\s*center/);
assert.match(css, /\.graph-group-drawer \.graph-summary-fact[\s\S]*overflow:\s*hidden/);
assert.match(css, /\.graph-group-drawer \.graph-summary-fact[\s\S]*white-space:\s*nowrap/);
assert.match(css, /\.graph-group-drawer \.graph-summary-fact strong,[\s\S]*\.graph-group-drawer \.graph-summary-fact span[\s\S]*display:\s*inline-flex/);
assert.match(css, /\.graph-group-drawer \.graph-summary-fact strong[\s\S]*text-overflow:\s*ellipsis/);
assert.match(css, /\.graph-group-drawer \.graph-summary-fact span[\s\S]*flex:\s*0 0 auto/);
assert.match(css, /\.graph-group-status-chip::before[\s\S]*background:\s*var\(--app-accent\)/);
assert.match(css, /\.graph-group-enter[\s\S]*border-radius:\s*999px/);
assert.match(css, /\.graph-group-action\[data-recommended="true"\][\s\S]*background:\s*color-mix\(in srgb, var\(--app-accent-soft\) 72%, var\(--app-raised\)\)/);
assert.doesNotMatch(css, /\.graph-group-action\[data-recommended="true"\][\s\S]{0,260}background:\s*var\(--app-accent\)/);
assert.doesNotMatch(css, /\.graph-group-enter[\s\S]{0,260}background:\s*var\(--app-accent\)/);
assert.match(css, /\.graph-group-drawer[\s\S]*container-type:\s*inline-size/);
assert.match(css, /@container \(max-width:\s*380px\)[\s\S]*\.graph-group-hero-row[\s\S]*grid-template-columns:\s*1fr/);
assert.match(css, /\.graph-group-node:hover[\s\S]*border-color:\s*color-mix\(in srgb, var\(--app-accent\)/);
assert.match(css, /\.graph-group-node:focus-visible[\s\S]*(box-shadow|outline)/);
assert.match(css, /\.graph-selection-context-hint[\s\S]*color:\s*var\(--app-muted\)/);
assert.match(css, /\.graph-selection-context-hint span[\s\S]*background:\s*var\(--app-success\)/);
assert.match(css, /\.graph-selection-footer[\s\S]*grid-template-columns:/);
assert.match(css, /\.graph-selection-footer[\s\S]*minmax\(0,\s*1fr\)/);
assert.match(css, /\.graph-selection-send[\s\S]*background:\s*var\(--app-accent\)/);
assert.match(css, /\.graph-selection-send svg[\s\S]*width:\s*13px/);
assert.match(css, /\.graph-selection-secondary[\s\S]*background:\s*var\(--app-raised\)/);
assert.match(css, /\.graph-selection-send:hover:not\(:disabled\)|\.graph-selection-send:focus-visible/);
assert.doesNotMatch(css, /搜索命中明细|桥接关系列表|固定节点明细/);
});
});
function renderDrawer(drawer: DrawerState): string {
return renderToStaticMarkup(
React.createElement(RightDrawer, {
drawer,
fullscreen: false,
width: 420,
defaultWidth: 420,
onSelectArtifact: noopString,
onOpenPage: noopString,
onWikiLinkSeen: noopString,
onGraphReaderAction: noopString,
onGraphSummaryCommand: noopCommand,
onGraphSummaryNodePreview: noopPreviewNode,
onGraphSelectionTextChange: noopString,
onGraphSelectionAsk: noopSelectionAsk,
onGraphCommunityTextChange: noopString,
onGraphCommunityAsk: noopSelectionAsk,
onResize: noopNumber,
onToggleFullscreen: noop,
onClose: noopClose,
}),
);
}
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: "short excerpt only",
connectionCount: 3,
searchHit: true,
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: [
{
edgeId: "alpha-beta",
fromNodeId: "alpha-node",
toNodeId: "beta-node",
relationType: "依赖",
confidence: "EXTRACTED",
weight: 1,
bridge: false,
},
],
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 graphPayload() {
return {
path: "wiki/alpha.md",
node: {
id: "alpha-node",
title: "Alpha node",
type: "topic",
typeLabel: "主题",
sourcePath: "wiki/alpha.md",
community: "alpha",
date: "2026-06-18",
source: "fixture",
isolated: false,
},
};
}
function communitySummaryFixture(overrides: Partial<GraphCommunitySummaryPayload> = {}): GraphCommunitySummaryPayload {
return {
kind: "community-summary",
object: { kind: "community", communityId: "alpha" },
communityId: "alpha",
label: "Alpha community",
nodeCount: 12,
facts: { pageCount: 12, internalLinkCount: 8, communityCount: 1, isolatedCount: 1 },
structureState: "clear",
description: "这组页面围绕同一主题聚在一起。你可以先看结构,也可以直接让 agent 基于这一组页面继续工作。",
canEnterCommunity: true,
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: "相关" },
],
searchResultIds: ["beta-node"],
pinHints: [
{ nodeId: "gamma-node", wikiPath: "wiki/gamma.md", pinned: true, position: { x: 12, y: 18, coordinateSpace: "world" } },
],
selection: {
input: { kind: "community", id: "alpha" },
selectionId: "community:alpha",
selectedNodeIds: ["alpha-node", "beta-node"],
selectedCommunityIds: ["alpha"],
containsCurrentObject: true,
},
strongestRelations: [],
bridgeRelations: [],
aggregationMarkers: [],
commands: [
{ kind: "enter-community", communityId: "alpha", label: "进入社区" },
],
...overrides,
};
}
function unavailableFixture(): GraphUnavailableObjectPayload {
return {
kind: "unavailable-object",
object: { kind: "node", nodeId: "missing-node" },
reason: "missing-node",
selection: {
input: null,
selectionId: null,
selectedNodeIds: [],
selectedCommunityIds: [],
containsCurrentObject: false,
},
searchResultIds: [],
pinHints: [],
aggregationMarkers: [],
commands: [],
};
}
function excludedFixture(): GraphExcludedObjectPayload {
return {
kind: "excluded-object",
object: { kind: "node", nodeId: "filtered-node" },
reason: "filter",
selection: {
input: { kind: "node", id: "filtered-node" },
selectionId: "node:filtered-node",
selectedNodeIds: ["filtered-node"],
selectedCommunityIds: ["alpha"],
containsCurrentObject: true,
},
searchResultIds: [],
pinHints: [],
aggregationMarkers: [],
commands: [
{ kind: "show-this-object", object: { kind: "node", nodeId: "filtered-node" }, label: "显示这个对象" },
{ kind: "clear-temporary-object-display", label: "清除临时显示" },
],
};
}
function noop() {}
function noopString() {}
function noopNumber() {}
function noopCommand() {}
function noopPreviewNode() {}
function noopSelectionAsk() {}
function noopClose() {}