366 lines
16 KiB
TypeScript
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() {}
|