first
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import GraphologyGraph from "graphology";
|
||||
|
||||
import type { GraphRendererAdapterData } from "../src";
|
||||
import {
|
||||
buildSigmaGlobalGraphologyGraph,
|
||||
canPatchSigmaGlobalGraphAttributes,
|
||||
patchSigmaGlobalGraphAttributes,
|
||||
sigmaGlobalEdgeStyle
|
||||
} from "../src/render/sigma-graphology-model";
|
||||
|
||||
describe("Sigma graphology render model", () => {
|
||||
it("builds graphology nodes, edges, communities, and aggregations from adapter data", () => {
|
||||
const adapterData = adapterDataFixture();
|
||||
const graph = buildSigmaGlobalGraphologyGraph(adapterData, { GraphologyGraph });
|
||||
|
||||
assert.equal(graph.order, 2);
|
||||
assert.equal(graph.size, 1);
|
||||
assert.deepEqual(graph.getNodeAttributes("alpha"), {
|
||||
x: 10,
|
||||
y: 20,
|
||||
label: "Alpha",
|
||||
size: 10,
|
||||
color: "#ef4444",
|
||||
type: "circle",
|
||||
graphNodeType: "topic",
|
||||
communityId: "community-a",
|
||||
sourcePath: "alpha.md",
|
||||
selected: true,
|
||||
searchHit: false,
|
||||
pinned: false,
|
||||
communityDimmed: false,
|
||||
communitySpotlightVisible: true,
|
||||
aggregationIds: ["aggregation-a"],
|
||||
labelVisible: true,
|
||||
displayMode: "card",
|
||||
visualRole: "landmark",
|
||||
priority: 900,
|
||||
drawerTarget: {
|
||||
summaryKind: "node-summary",
|
||||
object: { kind: "node", nodeId: "alpha" }
|
||||
}
|
||||
});
|
||||
assert.equal(graph.getEdgeAttribute("edge-a", "relationType"), "depends-on");
|
||||
assert.equal(graph.source("edge-a"), "alpha");
|
||||
assert.equal(graph.target("edge-a"), "beta");
|
||||
assert.equal(graph.getAttribute("communities")[0].id, "community-a");
|
||||
assert.equal(graph.getAttribute("aggregations")[0].id, "aggregation-a");
|
||||
});
|
||||
|
||||
it("applies selected-community focus edge styling and semantic emphasis", () => {
|
||||
const adapterData = adapterDataFixture({ selectedCommunityIds: ["community-b"] });
|
||||
const graph = buildSigmaGlobalGraphologyGraph(
|
||||
adapterData,
|
||||
{ GraphologyGraph },
|
||||
"shan-shui",
|
||||
{ semanticEmphasis: true, focusHighlight: true }
|
||||
);
|
||||
|
||||
assert.deepEqual(graph.getEdgeAttributes("edge-a"), {
|
||||
size: 0.85,
|
||||
color: "rgba(49, 95, 114, 0.087)",
|
||||
relationType: "depends-on",
|
||||
confidence: "EXTRACTED",
|
||||
weight: 0.75,
|
||||
sourceCommunityId: "community-a",
|
||||
targetCommunityId: "community-a"
|
||||
});
|
||||
assert.deepEqual(
|
||||
sigmaGlobalEdgeStyle({
|
||||
relationType: "矛盾",
|
||||
sourceCommunityId: "community-a",
|
||||
targetCommunityId: "community-b",
|
||||
weight: 1
|
||||
}, "mo-ye"),
|
||||
{ color: "rgba(244, 114, 182, 0.66)", size: 2.25 }
|
||||
);
|
||||
});
|
||||
|
||||
it("dims ordinary nodes outside the selected community while keeping priority nodes visible", () => {
|
||||
const graph = buildSigmaGlobalGraphologyGraph(spotlightAdapterData(), { GraphologyGraph });
|
||||
|
||||
assert.equal(graph.getNodeAttribute("alpha", "communityDimmed"), false);
|
||||
assert.equal(graph.getNodeAttribute("beta", "communityDimmed"), true);
|
||||
assert.equal(graph.getNodeAttribute("beta", "color"), "rgba(18, 52, 86, 0.2)");
|
||||
assert.equal(graph.getNodeAttribute("beta", "size"), 3.6);
|
||||
assert.equal(graph.getNodeAttribute("beta-search", "communityDimmed"), false);
|
||||
assert.equal(graph.getNodeAttribute("beta-pinned", "communityDimmed"), false);
|
||||
});
|
||||
|
||||
it("detects patch eligibility from graph structure and theme", () => {
|
||||
const adapterData = adapterDataFixture();
|
||||
const sameShape = adapterDataFixture({ alphaLabel: "Alpha changed" });
|
||||
const nodeChanged = adapterDataFixture({ alphaId: "alpha-next" });
|
||||
const edgeChanged = adapterDataFixture({ edgeId: "edge-next" });
|
||||
const targetChanged = adapterDataFixture({ betaId: "beta-next" });
|
||||
const nodeAdded = {
|
||||
...adapterData,
|
||||
nodes: [
|
||||
...adapterData.nodes,
|
||||
nodeFixture("gamma", "community-a", { point: { x: 50, y: 60 } })
|
||||
]
|
||||
};
|
||||
const edgeAdded = {
|
||||
...adapterData,
|
||||
edges: [
|
||||
...adapterData.edges,
|
||||
{
|
||||
...adapterData.edges[0],
|
||||
id: "edge-added"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
assert.equal(canPatchSigmaGlobalGraphAttributes(adapterData, sameShape, "shan-shui", "shan-shui"), true);
|
||||
assert.equal(canPatchSigmaGlobalGraphAttributes(adapterData, sameShape, "shan-shui", "mo-ye"), false);
|
||||
assert.equal(canPatchSigmaGlobalGraphAttributes(adapterData, nodeChanged, "shan-shui", "shan-shui"), false);
|
||||
assert.equal(canPatchSigmaGlobalGraphAttributes(adapterData, edgeChanged, "shan-shui", "shan-shui"), false);
|
||||
assert.equal(canPatchSigmaGlobalGraphAttributes(adapterData, targetChanged, "shan-shui", "shan-shui"), false);
|
||||
assert.equal(canPatchSigmaGlobalGraphAttributes(adapterData, nodeAdded, "shan-shui", "shan-shui"), false);
|
||||
assert.equal(canPatchSigmaGlobalGraphAttributes(adapterData, edgeAdded, "shan-shui", "shan-shui"), false);
|
||||
});
|
||||
|
||||
it("patches graph attributes in place", () => {
|
||||
const graph = buildSigmaGlobalGraphologyGraph(adapterDataFixture(), { GraphologyGraph });
|
||||
const sameShape = adapterDataFixture({ alphaLabel: "Alpha changed", selectedCommunityIds: [] });
|
||||
|
||||
patchSigmaGlobalGraphAttributes(graph, sameShape, "shan-shui");
|
||||
|
||||
assert.equal(graph.getNodeAttribute("alpha", "label"), "Alpha changed");
|
||||
assert.equal(graph.getNodeAttribute("beta", "communityDimmed"), false);
|
||||
assert.deepEqual(graph.getAttribute("selection"), sameShape.selection);
|
||||
});
|
||||
|
||||
it("keeps the Sigma production model boundary on adapter data, not raw graph data", async () => {
|
||||
const modelSource = await readFile(new URL("../src/render/sigma-graphology-model.ts", import.meta.url), "utf8");
|
||||
const rendererSource = await readFile(new URL("../src/render/sigma-global-renderer.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(modelSource, /buildSigmaGlobalGraphologyGraph\(\s*adapterData: GraphRendererAdapterData/);
|
||||
for (const source of [modelSource, rendererSource]) {
|
||||
assert.doesNotMatch(source, /buildGraphRendererAdapterData/);
|
||||
assert.doesNotMatch(source, /GraphData/);
|
||||
assert.doesNotMatch(source, /\bdata\.nodes\b/);
|
||||
assert.doesNotMatch(source, /\bdata\.edges\b/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function adapterDataFixture(options: {
|
||||
alphaId?: string;
|
||||
betaId?: string;
|
||||
edgeId?: string;
|
||||
alphaLabel?: string;
|
||||
selectedCommunityIds?: string[];
|
||||
} = {}): GraphRendererAdapterData {
|
||||
const alphaId = options.alphaId ?? "alpha";
|
||||
const betaId = options.betaId ?? "beta";
|
||||
const edgeId = options.edgeId ?? "edge-a";
|
||||
const selectedCommunityIds = options.selectedCommunityIds ?? ["community-a"];
|
||||
return {
|
||||
counts: {
|
||||
nodes: 2,
|
||||
edges: 1,
|
||||
communities: 1,
|
||||
hidden: 0,
|
||||
renderedNodes: 2,
|
||||
renderedEdges: 1,
|
||||
aggregationContainers: 1
|
||||
},
|
||||
selection: {
|
||||
input: { kind: "community", id: selectedCommunityIds[0] ?? "community-a" },
|
||||
selectionId: selectedCommunityIds[0] ? `community:${selectedCommunityIds[0]}` : null,
|
||||
selectedNodeIds: [alphaId],
|
||||
selectedCommunityIds,
|
||||
containsCurrentObject: selectedCommunityIds.length > 0
|
||||
},
|
||||
nodes: [
|
||||
nodeFixture(alphaId, "community-a", {
|
||||
label: options.alphaLabel ?? "Alpha",
|
||||
point: { x: 10, y: 20 },
|
||||
selected: true,
|
||||
priority: 900,
|
||||
displayMode: "card",
|
||||
labelVisible: true
|
||||
}),
|
||||
nodeFixture(betaId, "community-a", {
|
||||
label: "Beta",
|
||||
point: { x: 30, y: 40 },
|
||||
searchHit: true,
|
||||
pinned: true,
|
||||
type: "source"
|
||||
})
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: edgeId,
|
||||
sourceNodeId: alphaId,
|
||||
targetNodeId: betaId,
|
||||
sourceCommunityId: "community-a",
|
||||
targetCommunityId: "community-a",
|
||||
relationType: "depends-on",
|
||||
confidence: "EXTRACTED",
|
||||
weight: 0.75,
|
||||
render: { strokeWidth: 3, opacity: 0.42 }
|
||||
}
|
||||
],
|
||||
communities: [
|
||||
{
|
||||
id: "community-a",
|
||||
object: { kind: "community", communityId: "community-a" },
|
||||
label: "Community A",
|
||||
nodeIds: [alphaId, betaId],
|
||||
nodeCount: 2,
|
||||
selected: selectedCommunityIds.includes("community-a"),
|
||||
searchResultIds: [betaId],
|
||||
pinHints: [pinHint(betaId, true, { x: 30, y: 40 })],
|
||||
aggregationIds: ["aggregation-a"],
|
||||
drawerTarget: communityDrawerTarget("community-a"),
|
||||
commands: [{ kind: "enter-community", communityId: "community-a", label: "进入社区" }]
|
||||
}
|
||||
],
|
||||
aggregations: [
|
||||
{
|
||||
id: "aggregation-a",
|
||||
object: { kind: "aggregation", aggregationId: "aggregation-a", nodeIds: [alphaId, betaId], communityId: "community-a" },
|
||||
label: "Aggregation A",
|
||||
communityId: "community-a",
|
||||
nodeIds: [alphaId, betaId],
|
||||
selectedNodeIds: [alphaId],
|
||||
searchResultIds: [betaId],
|
||||
pinnedNodeIds: [betaId],
|
||||
totalCount: 2,
|
||||
selected: true,
|
||||
pinHints: [pinHint(betaId, true, { x: 30, y: 40 })],
|
||||
drawerTarget: communityDrawerTarget("community-a"),
|
||||
commands: [
|
||||
{
|
||||
kind: "show-this-object",
|
||||
object: { kind: "aggregation", aggregationId: "aggregation-a", nodeIds: [alphaId, betaId], communityId: "community-a" },
|
||||
label: "显示这个对象"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
renderable: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
communities: [
|
||||
renderableCommunity("community-a", "#ef4444", true),
|
||||
renderableCommunity("community-b", "#123456", false)
|
||||
],
|
||||
aggregationContainers: [
|
||||
{
|
||||
id: "aggregation-a",
|
||||
role: "aggregation-container",
|
||||
label: "Aggregation A",
|
||||
communityId: "community-a",
|
||||
nodeIds: [alphaId, betaId],
|
||||
nodeCount: 2,
|
||||
searchHitCount: 1,
|
||||
pinnedCount: 1,
|
||||
selectedCount: 1,
|
||||
selected: true,
|
||||
searchResultIds: [betaId],
|
||||
pinnedNodeIds: [betaId],
|
||||
selectedNodeIds: [alphaId],
|
||||
pinHints: [pinHint(betaId, true, { x: 30, y: 40 })],
|
||||
point: { x: 20, y: 30 },
|
||||
x: 20,
|
||||
y: 30,
|
||||
radius: 12,
|
||||
color: "#abcdef"
|
||||
}
|
||||
],
|
||||
minimap: { path: "", nodes: [] },
|
||||
relationLegend: [],
|
||||
selectedNodeId: alphaId,
|
||||
selectedCommunityId: selectedCommunityIds[0] ?? null,
|
||||
selectedNodeIds: [alphaId],
|
||||
hiddenNodeIds: new Set(),
|
||||
searchResultIds: [betaId],
|
||||
worldBounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
|
||||
budgets: {
|
||||
limits: {
|
||||
maxNodes: 2,
|
||||
maxEdges: 1,
|
||||
maxLabels: 1,
|
||||
maxCards: 1,
|
||||
maxInteractionUpdates: 3,
|
||||
maxVisibleCommunities: 2
|
||||
},
|
||||
usage: {
|
||||
nodes: 2,
|
||||
edges: 1,
|
||||
labels: 1,
|
||||
cards: 1,
|
||||
interactionUpdate: 3,
|
||||
activeInteraction: 3,
|
||||
communities: 2,
|
||||
aggregationContainers: 1
|
||||
}
|
||||
},
|
||||
qualityNotice: null,
|
||||
communityFocus: null,
|
||||
communityQuality: {
|
||||
boundaryCertainty: "high",
|
||||
skeletonLabel: "stable",
|
||||
hiddenNodeCount: 0,
|
||||
hiddenEdgeCount: 0,
|
||||
stableCoreNodeIds: [alphaId],
|
||||
stableSkeletonEdgeIds: [edgeId],
|
||||
temporaryBoostNodeIds: []
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function spotlightAdapterData(): GraphRendererAdapterData {
|
||||
const data = adapterDataFixture({ selectedCommunityIds: ["community-a"] });
|
||||
data.nodes = [
|
||||
nodeFixture("alpha", "community-a", { point: { x: 10, y: 20 }, selected: true }),
|
||||
nodeFixture("beta", "community-b", { point: { x: 30, y: 40 } }),
|
||||
nodeFixture("beta-search", "community-b", { point: { x: 35, y: 45 }, searchHit: true }),
|
||||
nodeFixture("beta-pinned", "community-b", { point: { x: 40, y: 50 }, pinned: true })
|
||||
];
|
||||
data.edges = [];
|
||||
return data;
|
||||
}
|
||||
|
||||
function nodeFixture(
|
||||
id: string,
|
||||
communityId: string,
|
||||
options: {
|
||||
label?: string;
|
||||
point?: { x: number; y: number };
|
||||
selected?: boolean;
|
||||
searchHit?: boolean;
|
||||
pinned?: boolean;
|
||||
type?: "topic" | "source";
|
||||
priority?: number;
|
||||
displayMode?: string;
|
||||
labelVisible?: boolean;
|
||||
} = {}
|
||||
): GraphRendererAdapterData["nodes"][number] {
|
||||
const point = options.point ?? { x: 0, y: 0 };
|
||||
return {
|
||||
id,
|
||||
object: { kind: "node", nodeId: id },
|
||||
label: options.label ?? id,
|
||||
type: options.type ?? "topic",
|
||||
communityId,
|
||||
sourcePath: `${id}.md`,
|
||||
point,
|
||||
selected: options.selected ?? false,
|
||||
searchHit: options.searchHit ?? false,
|
||||
pinHint: pinHint(id, options.pinned ?? false, point),
|
||||
aggregationIds: ["aggregation-a"],
|
||||
drawerTarget: {
|
||||
summaryKind: "node-summary",
|
||||
object: { kind: "node", nodeId: id }
|
||||
},
|
||||
render: {
|
||||
displayMode: options.displayMode ?? "point",
|
||||
visualRole: options.pinned ? "map-pin" : "landmark",
|
||||
priority: options.priority ?? 100,
|
||||
labelVisible: options.labelVisible ?? false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderableCommunity(id: string, color: string, selected: boolean) {
|
||||
return {
|
||||
id,
|
||||
role: "community" as const,
|
||||
label: id,
|
||||
nodeCount: 2,
|
||||
selected,
|
||||
searchHitCount: 0,
|
||||
pinnedCount: 0,
|
||||
selectedCount: selected ? 1 : 0,
|
||||
color,
|
||||
x: 0,
|
||||
y: 0,
|
||||
radius: 20,
|
||||
wash: { cx: 0, cy: 0, rx: 20, ry: 20 },
|
||||
drawerTarget: communityDrawerTarget(id),
|
||||
commands: [{ kind: "enter-community" as const, communityId: id, label: "进入社区" }]
|
||||
};
|
||||
}
|
||||
|
||||
function communityDrawerTarget(id: string): GraphRendererAdapterData["communities"][number]["drawerTarget"] {
|
||||
return {
|
||||
summaryKind: "community-summary",
|
||||
object: { kind: "community", communityId: id }
|
||||
};
|
||||
}
|
||||
|
||||
function pinHint(nodeId: string, pinned: boolean, point: { x: number; y: number }) {
|
||||
return {
|
||||
nodeId,
|
||||
wikiPath: `${nodeId}.md`,
|
||||
pinned,
|
||||
position: pinned ? { ...point, coordinateSpace: "world" as const } : null
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user