97852f43b8
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
498 lines
21 KiB
TypeScript
498 lines
21 KiB
TypeScript
/**
|
|
* agent-topology.ts unit tests: latest-Task extraction (spawns, deep spawns, dedupe on a
|
|
* repolled child, the task boundary at a new user message), the historical-Task variant
|
|
* (extractTopologyForChild slicing around an older chip's child), per-node elapsed-time
|
|
* sources (live wall-clock stamps vs. message timestamps across a history replay), identity
|
|
* resolution (session_meta capture vs. the run_subagent agent_id argument, agent_state path
|
|
* parsing), the pending-approval subtree predicate, and layered-tree layout
|
|
* determinism/geometry.
|
|
*/
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
approvalDecision,
|
|
assistantText,
|
|
imageUrlMessage,
|
|
sessionMeta,
|
|
toolCall,
|
|
toolCallOutput,
|
|
userText,
|
|
withOrigin,
|
|
} from "@prismshadow/penguin-core/omnimessage";
|
|
import type { OmniMessage, SessionMetaPayload } from "@prismshadow/penguin-core/omnimessage";
|
|
import {
|
|
agentIdFromStatePath,
|
|
approvalKey,
|
|
createStreamModel,
|
|
hasPendingWithinOrigin,
|
|
pushMessage,
|
|
} from "../src/lib/omni/stream-model";
|
|
import type { StreamModel, SubagentItem } from "../src/lib/omni/stream-model";
|
|
import {
|
|
agentIdFromRunSubagentArgs,
|
|
extractTopology,
|
|
extractTopologyForChild,
|
|
GAP_X,
|
|
GAP_Y,
|
|
latestTaskHasSubagent,
|
|
latestTaskStart,
|
|
layoutTopology,
|
|
modelAtOrigin,
|
|
NODE_H,
|
|
NODE_W,
|
|
PAD,
|
|
resolveAgentLabel,
|
|
shortSessionId,
|
|
taskStartCount,
|
|
} from "../src/features/chat/agent-topology";
|
|
|
|
function meta(sessionId: string, agentState = "/a"): OmniMessage<SessionMetaPayload> {
|
|
return sessionMeta({
|
|
session_id: sessionId,
|
|
model_id: "m",
|
|
provider: "custom",
|
|
model_context_window: 200000,
|
|
system_prompt: "",
|
|
tools: [],
|
|
agent_state: agentState,
|
|
workspace: "/w",
|
|
source: "subagent",
|
|
});
|
|
}
|
|
|
|
/** Spawn a child bound to a run_subagent card: card + allow + first child-origin message. */
|
|
function spawnChild(m: StreamModel, toolCallId: string, sessionId: string, args = "{}"): void {
|
|
pushMessage(m, toolCall({ name: "run_subagent", arguments: args, toolCallId }));
|
|
pushMessage(m, approvalDecision("allow", toolCallId));
|
|
pushMessage(m, withOrigin(meta(sessionId), sessionId));
|
|
}
|
|
|
|
/** Override a message timestamp (the builders default to the current time). */
|
|
function at<M extends OmniMessage>(msg: M, ts: string): M {
|
|
return { ...msg, timestamp: ts };
|
|
}
|
|
|
|
describe("extractTopology", () => {
|
|
it("returns only the root (with the Task's running state) when nothing is spawned", () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, userText("hello"));
|
|
const nodes = extractTopology(m, "root", true);
|
|
expect(nodes).toHaveLength(1);
|
|
expect(nodes[0]).toMatchObject({
|
|
sessionId: "root",
|
|
depth: 0,
|
|
origin: [],
|
|
parentId: null,
|
|
running: true,
|
|
agentId: null,
|
|
});
|
|
expect(extractTopology(m, "root", false)[0]!.running).toBe(false);
|
|
});
|
|
|
|
it("collects a bound spawn with agent_id from the call arguments; running follows the card's output state", () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, userText("go"));
|
|
spawnChild(m, "t1", "child1", '{"prompt": "count", "agent_id": "researcher"}');
|
|
let nodes = extractTopology(m, "root", true);
|
|
expect(nodes).toHaveLength(2);
|
|
expect(nodes[1]).toMatchObject({
|
|
sessionId: "child1",
|
|
agentId: "researcher",
|
|
running: true,
|
|
depth: 1,
|
|
origin: ["child1"],
|
|
parentId: "root",
|
|
});
|
|
|
|
pushMessage(m, toolCallOutput({ output: "done", toolCallId: "t1" }));
|
|
nodes = extractTopology(m, "root", false);
|
|
expect(nodes[1]!.running).toBe(false);
|
|
});
|
|
|
|
it("prefers the child's own session_meta capture (agent_state path) over the call-argument agent id", () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, userText("go"));
|
|
pushMessage(
|
|
m,
|
|
toolCall({ name: "run_subagent", arguments: '{"agent_id": "arg_agent"}', toolCallId: "t1" }),
|
|
);
|
|
pushMessage(m, approvalDecision("allow", "t1"));
|
|
pushMessage(
|
|
m,
|
|
withOrigin(meta("child1", "/data/proj/agents/meta_agent/agent_state"), "child1"),
|
|
);
|
|
const nodes = extractTopology(m, "root", true);
|
|
expect(nodes[1]!.agentId).toBe("meta_agent");
|
|
});
|
|
|
|
it("recurses into a child's own items for deeper spawns (bound grandchild: depth 2, full origin chain)", () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, userText("go"));
|
|
spawnChild(m, "t1", "child1");
|
|
// The child spawns its own subagent: run_subagent card + allow + first grandchild message, all inside child1.
|
|
pushMessage(
|
|
m,
|
|
withOrigin(toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "s1" }), "child1"),
|
|
);
|
|
pushMessage(m, withOrigin(approvalDecision("allow", "s1"), "child1"));
|
|
pushMessage(m, withOrigin(withOrigin(assistantText("gc reply"), "gc1"), "child1"));
|
|
|
|
const nodes = extractTopology(m, "root", true);
|
|
expect(nodes.map((n) => n.sessionId)).toEqual(["root", "child1", "gc1"]);
|
|
expect(nodes[2]).toMatchObject({
|
|
depth: 2,
|
|
origin: ["child1", "gc1"],
|
|
parentId: "child1",
|
|
running: true,
|
|
});
|
|
});
|
|
|
|
it("collects a standalone SubagentItem (no bindable card) as a done node and dedupes a second reference to the same child", () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, userText("go"));
|
|
// Denied card never binds -> the child lands as a standalone item.
|
|
pushMessage(m, toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "t1" }));
|
|
pushMessage(m, approvalDecision("deny", "t1"));
|
|
pushMessage(m, withOrigin(meta("childX"), "childX"));
|
|
const standalone = m.items.find((i) => i.kind === "subagent") as SubagentItem;
|
|
// A repoll referencing the same child again (defensive: extraction must not duplicate the node).
|
|
m.items.push({ kind: "subagent", id: 999, sessionId: "childX", model: standalone.model });
|
|
|
|
const nodes = extractTopology(m, "root", true);
|
|
expect(nodes.filter((n) => n.sessionId === "childX")).toHaveLength(1);
|
|
expect(nodes[1]).toMatchObject({ sessionId: "childX", running: false, parentId: "root" });
|
|
});
|
|
|
|
it("shows only the latest Task's spawns: a new user message resets the graph to the root", () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, userText("task 1"));
|
|
spawnChild(m, "t1", "child1");
|
|
pushMessage(m, toolCallOutput({ output: "done", toolCallId: "t1" }));
|
|
|
|
pushMessage(m, userText("task 2"));
|
|
expect(extractTopology(m, "root", true).map((n) => n.sessionId)).toEqual(["root"]);
|
|
|
|
spawnChild(m, "t2", "child2");
|
|
expect(extractTopology(m, "root", true).map((n) => n.sessionId)).toEqual(["root", "child2"]);
|
|
});
|
|
});
|
|
|
|
describe("extractTopologyForChild (historical Task slice)", () => {
|
|
/** Task 1 spawns child1 (finished), then Task 2 spawns child2 (still running). */
|
|
const twoTasks = (): StreamModel => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, userText("task 1"));
|
|
spawnChild(m, "t1", "child1");
|
|
pushMessage(m, toolCallOutput({ output: "done", toolCallId: "t1" }));
|
|
pushMessage(m, userText("task 2"));
|
|
spawnChild(m, "t2", "child2");
|
|
return m;
|
|
};
|
|
|
|
it("a chip from an older Task yields THAT Task's tree while the default extraction stays on the latest", () => {
|
|
const m = twoTasks();
|
|
expect(extractTopology(m, "root", true).map((n) => n.sessionId)).toEqual(["root", "child2"]);
|
|
const historical = extractTopologyForChild(m, "root", true, "child1")!;
|
|
expect(historical.map((n) => n.sessionId)).toEqual(["root", "child1"]);
|
|
// An older Task has ended by definition (the next user message closed it): its root reads
|
|
// done even while the LATEST Task is running.
|
|
expect(historical[0]!.running).toBe(false);
|
|
expect(historical[1]!.running).toBe(false);
|
|
});
|
|
|
|
it("anchoring on a latest-Task child returns the live latest slice (root keeps the running state)", () => {
|
|
const latest = extractTopologyForChild(twoTasks(), "root", true, "child2")!;
|
|
expect(latest.map((n) => n.sessionId)).toEqual(["root", "child2"]);
|
|
expect(latest[0]!.running).toBe(true);
|
|
expect(latest[1]!.running).toBe(true);
|
|
});
|
|
|
|
it("an unreferenced anchor gives null — the caller falls back to the latest Task", () => {
|
|
expect(extractTopologyForChild(twoTasks(), "root", true, "ghost")).toBeNull();
|
|
});
|
|
|
|
it("anchors on a standalone SubagentItem; a mid-stream join with no leading user item slices from the start, bounded by the next Task", () => {
|
|
const m = createStreamModel();
|
|
// Mid-stream join: the child appears before any user item (no bindable card either).
|
|
pushMessage(m, withOrigin(meta("childX"), "childX"));
|
|
pushMessage(m, userText("next task"));
|
|
spawnChild(m, "t9", "childY");
|
|
const nodes = extractTopologyForChild(m, "root", true, "childX")!;
|
|
// The slice runs from the stream start to the next user item: childY never leaks in.
|
|
expect(nodes.map((n) => n.sessionId)).toEqual(["root", "childX"]);
|
|
expect(nodes[0]!.running).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("latestTaskHasSubagent (auto-open trigger)", () => {
|
|
it("true only while the LATEST Task references a spawn; a plain follow-up Task resets it", () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, userText("task 1"));
|
|
expect(latestTaskHasSubagent(m)).toBe(false);
|
|
spawnChild(m, "t1", "child1");
|
|
expect(latestTaskHasSubagent(m)).toBe(true);
|
|
// A new Task without spawns: the earlier child must not keep the trigger armed.
|
|
pushMessage(m, userText("task 2, no spawns"));
|
|
expect(latestTaskHasSubagent(m)).toBe(false);
|
|
});
|
|
|
|
it("counts a standalone SubagentItem (unbindable spawn) too", () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, userText("go"));
|
|
pushMessage(m, withOrigin(meta("childX"), "childX")); // no bindable card -> standalone item
|
|
expect(latestTaskHasSubagent(m)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("per-node elapsed time (wall clock of the spawn, never a sum of items)", () => {
|
|
const T0 = Date.parse("2026-07-25T10:00:00.000Z");
|
|
const iso = (offsetMs: number): string => new Date(T0 + offsetMs).toISOString();
|
|
|
|
it("live: a running child ticks from its first message timestamp; done freezes at its last activity", () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, at(userText("go"), iso(0)), 1_000);
|
|
pushMessage(
|
|
m,
|
|
at(toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "t1" }), iso(1_000)),
|
|
2_000,
|
|
);
|
|
pushMessage(m, at(approvalDecision("allow", "t1"), iso(1_500)), 2_500);
|
|
pushMessage(m, at(withOrigin(meta("child1"), "child1"), iso(2_000)), 3_000);
|
|
let node = extractTopology(m, "root", true)[1]!;
|
|
expect(node.running).toBe(true);
|
|
expect(node.startedMs).toBe(T0 + 2_000);
|
|
|
|
pushMessage(m, at(withOrigin(assistantText("report"), "child1"), iso(9_000)), 10_000);
|
|
pushMessage(m, at(toolCallOutput({ output: "done", toolCallId: "t1" }), iso(9_500)), 10_500);
|
|
node = extractTopology(m, "root", false)[1]!;
|
|
expect(node.running).toBe(false);
|
|
expect(node.elapsedMs).toBe(7_000); // child first appeared at +2s, last acted at +9s
|
|
// The live wall-clock stamps recorded the same lifecycle in local time.
|
|
const sub = m.subagents.get("child1")!;
|
|
expect(sub.firstSeenLocalMs).toBe(3_000);
|
|
expect(sub.lastActivityLocalMs).toBe(10_000);
|
|
});
|
|
|
|
it("history replay: local stamps collapse to the load instant, so the span comes from message timestamps", () => {
|
|
// One synchronous replay: every push shares the same local `now` (pushMessages evaluates it once).
|
|
const LOAD = 5_000_000;
|
|
const m = createStreamModel();
|
|
pushMessage(m, at(userText("go"), iso(0)), LOAD);
|
|
pushMessage(
|
|
m,
|
|
at(toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "t1" }), iso(1_000)),
|
|
LOAD,
|
|
);
|
|
pushMessage(m, at(approvalDecision("allow", "t1"), iso(1_500)), LOAD);
|
|
pushMessage(m, at(withOrigin(meta("child1"), "child1"), iso(2_000)), LOAD);
|
|
pushMessage(m, at(withOrigin(assistantText("report"), "child1"), iso(60_000)), LOAD);
|
|
pushMessage(m, at(toolCallOutput({ output: "done", toolCallId: "t1" }), iso(61_000)), LOAD);
|
|
|
|
// The trap this design avoids: after a replay the local pair spans ~0 (load-relative).
|
|
const sub = m.subagents.get("child1")!;
|
|
expect(sub.lastActivityLocalMs! - sub.firstSeenLocalMs!).toBe(0);
|
|
const node = extractTopology(m, "root", false)[1]!;
|
|
expect(node.elapsedMs).toBe(58_000); // +2s → +60s in message time, reproduced after reload
|
|
expect(node.startedMs).toBe(T0 + 2_000);
|
|
});
|
|
|
|
it("unparseable timestamps fall back to the local wall-clock pair; the root never carries a duration", () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, at(userText("go"), "not-a-date"), 1_000);
|
|
pushMessage(
|
|
m,
|
|
at(toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "t1" }), "not-a-date"),
|
|
1_100,
|
|
);
|
|
pushMessage(m, at(approvalDecision("allow", "t1"), "not-a-date"), 1_200);
|
|
pushMessage(m, at(withOrigin(meta("child1"), "child1"), "not-a-date"), 2_000);
|
|
pushMessage(m, at(withOrigin(assistantText("r"), "child1"), "not-a-date"), 6_000);
|
|
const nodes = extractTopology(m, "root", true);
|
|
expect(nodes[0]!.startedMs).toBeUndefined();
|
|
expect(nodes[0]!.elapsedMs).toBeUndefined();
|
|
expect(nodes[1]!.startedMs).toBe(2_000); // firstSeenLocalMs fallback
|
|
expect(nodes[1]!.elapsedMs).toBe(4_000); // local pair span
|
|
});
|
|
});
|
|
|
|
describe("identity helpers", () => {
|
|
it("latestTaskStart finds the last user_text/user_image and falls back to 0 (user items only — steering never starts a Task)", () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, assistantText("orphan"));
|
|
expect(latestTaskStart(m.items)).toBe(0);
|
|
pushMessage(m, userText("t1"));
|
|
expect(latestTaskStart(m.items)).toBe(1);
|
|
});
|
|
|
|
it("taskStartCount counts Task-starting user items (text and image); steering chips never count", () => {
|
|
const m = createStreamModel();
|
|
expect(taskStartCount(m.items)).toBe(0);
|
|
pushMessage(m, userText("t1"));
|
|
pushMessage(m, assistantText("r1"));
|
|
// Mid-run steering renders as a user_steering item — inside the running Task, not a new one.
|
|
pushMessage(m, userText("[user_steering]\nnudge\n[/user_steering]"));
|
|
expect(taskStartCount(m.items)).toBe(1);
|
|
// An image sent WITH that steering message follows it directly and joins its chip, so it
|
|
// starts nothing either; the assistant reply then closes the chip's collection window.
|
|
pushMessage(m, imageUrlMessage("data:image/png;base64,steered"));
|
|
expect(taskStartCount(m.items)).toBe(1);
|
|
pushMessage(m, assistantText("r2"));
|
|
// A standalone Prompt image (no steering message in front of it) still starts a Task.
|
|
pushMessage(m, imageUrlMessage("data:image/png;base64,xx"));
|
|
expect(taskStartCount(m.items)).toBe(2);
|
|
pushMessage(m, userText("t3"));
|
|
expect(taskStartCount(m.items)).toBe(3);
|
|
});
|
|
|
|
it("agentIdFromStatePath takes the parent directory name; degenerate paths give null", () => {
|
|
expect(agentIdFromStatePath("/data/proj/agents/researcher/agent_state")).toBe("researcher");
|
|
expect(agentIdFromStatePath("C:\\data\\proj\\agents\\win_agent\\agent_state")).toBe(
|
|
"win_agent",
|
|
);
|
|
expect(agentIdFromStatePath("agent_state")).toBeNull();
|
|
expect(agentIdFromStatePath("")).toBeNull();
|
|
});
|
|
|
|
it("agentIdFromRunSubagentArgs reads agent_id from complete JSON only", () => {
|
|
expect(agentIdFromRunSubagentArgs('{"prompt": "x", "agent_id": "helper"}')).toBe("helper");
|
|
expect(agentIdFromRunSubagentArgs('{"prompt": "x"}')).toBeNull();
|
|
expect(agentIdFromRunSubagentArgs('{"agent_id": "hel')).toBeNull();
|
|
expect(agentIdFromRunSubagentArgs("")).toBeNull();
|
|
});
|
|
|
|
it("session_meta capture lands on the nested model (agent/provider/model/source), never on the main model", () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, meta("main", "/data/proj/agents/main_agent/agent_state"));
|
|
expect(m.meta).toBeNull();
|
|
pushMessage(m, withOrigin(meta("c1", "/data/proj/agents/child_agent/agent_state"), "c1"));
|
|
expect(m.subagents.get("c1")!.meta).toEqual({
|
|
agentId: "child_agent",
|
|
provider: "custom",
|
|
modelId: "m",
|
|
source: "subagent",
|
|
});
|
|
});
|
|
|
|
it("modelAtOrigin walks the nested chain; a missing hop or an empty chain gives null", () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, withOrigin(withOrigin(assistantText("deep"), "gc1"), "c1"));
|
|
expect(modelAtOrigin(m, ["c1"])).toBe(m.subagents.get("c1"));
|
|
expect(modelAtOrigin(m, ["c1", "gc1"])).toBe(m.subagents.get("c1")!.subagents.get("gc1"));
|
|
expect(modelAtOrigin(m, ["c1", "nope"])).toBeNull();
|
|
expect(modelAtOrigin(m, [])).toBeNull();
|
|
});
|
|
|
|
it("hasPendingWithinOrigin matches keys at or below the chain, never the main session's own keys", () => {
|
|
const keys = [
|
|
approvalKey([], "t-main"),
|
|
approvalKey(["c1"], "t-child"),
|
|
approvalKey(["c1", "gc1"], "t-deep"),
|
|
];
|
|
expect(hasPendingWithinOrigin(keys, ["c1"])).toBe(true);
|
|
expect(hasPendingWithinOrigin(keys, ["c1", "gc1"])).toBe(true);
|
|
expect(hasPendingWithinOrigin(keys, ["c2"])).toBe(false);
|
|
expect(hasPendingWithinOrigin([approvalKey([], "t-main")], ["c1"])).toBe(false);
|
|
});
|
|
|
|
it("resolveAgentLabel: agents list name -> bare agent id -> session row's agent -> session title -> null", () => {
|
|
const agents = [
|
|
{ agentId: "researcher", name: "Researcher" },
|
|
{ agentId: "plain" },
|
|
{ agentId: "unnamed", name: "" },
|
|
];
|
|
const sessions = [
|
|
{ sessionId: "s1", agentId: "researcher", title: "Row title" },
|
|
{ sessionId: "s2", agentId: "ghost" },
|
|
];
|
|
expect(resolveAgentLabel({ sessionId: "x", agentId: "researcher" }, agents, sessions)).toBe(
|
|
"Researcher",
|
|
);
|
|
expect(resolveAgentLabel({ sessionId: "x", agentId: "plain" }, agents, sessions)).toBe("plain");
|
|
// An empty display name counts as missing, not as a real (blank) label.
|
|
expect(resolveAgentLabel({ sessionId: "x", agentId: "unnamed" }, agents, sessions)).toBe(
|
|
"unnamed",
|
|
);
|
|
// Agent unknown to the list: the id itself still beats nothing.
|
|
expect(resolveAgentLabel({ sessionId: "x", agentId: "mystery" }, agents, sessions)).toBe(
|
|
"mystery",
|
|
);
|
|
// No agent id on the node: the session row supplies it.
|
|
expect(resolveAgentLabel({ sessionId: "s1", agentId: null }, agents, sessions)).toBe(
|
|
"Researcher",
|
|
);
|
|
expect(resolveAgentLabel({ sessionId: "s2", agentId: null }, agents, sessions)).toBe("ghost");
|
|
expect(resolveAgentLabel({ sessionId: "gone", agentId: null }, agents, sessions)).toBeNull();
|
|
});
|
|
|
|
it("shortSessionId keeps the distinctive tail", () => {
|
|
expect(shortSessionId("session-2026-07-14-09-05-11-1a2b3c01")).toBe("1a2b3c01");
|
|
expect(shortSessionId("short")).toBe("short");
|
|
});
|
|
});
|
|
|
|
describe("layoutTopology", () => {
|
|
const tree = () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, userText("go"));
|
|
spawnChild(m, "t1", "child1");
|
|
pushMessage(
|
|
m,
|
|
withOrigin(toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "s1" }), "child1"),
|
|
);
|
|
pushMessage(m, withOrigin(approvalDecision("allow", "s1"), "child1"));
|
|
pushMessage(m, withOrigin(withOrigin(assistantText("gc"), "gc1"), "child1"));
|
|
spawnChild(m, "t2", "child2");
|
|
return extractTopology(m, "root", true); // root -> child1 -> gc1, root -> child2
|
|
};
|
|
|
|
it("is deterministic (same tree, same layout)", () => {
|
|
// Frozen clock: the fixture's node stamps (startedMs/elapsedMs) come from the wall clock and
|
|
// message timestamps, so two separately BUILT trees only match with time stopped — the
|
|
// layout itself is a pure function of the nodes.
|
|
vi.useFakeTimers();
|
|
try {
|
|
vi.setSystemTime(new Date("2026-07-25T10:00:00.000Z"));
|
|
expect(layoutTopology(tree())).toEqual(layoutTopology(tree()));
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("columns follow depth, leaves take successive rows, and a parent centers on its children", () => {
|
|
const layout = layoutTopology(tree());
|
|
const at = (id: string) => layout.nodes.find((p) => p.node.sessionId === id)!;
|
|
const colX = (depth: number) => PAD + depth * (NODE_W + GAP_X);
|
|
expect(at("root").x).toBe(colX(0));
|
|
expect(at("child1").x).toBe(colX(1));
|
|
expect(at("child2").x).toBe(colX(1));
|
|
expect(at("gc1").x).toBe(colX(2));
|
|
// Leaves in DFS order: gc1 row 0, child2 row 1; child1 centers on its only child; root centers between its two children.
|
|
expect(at("gc1").y).toBe(PAD);
|
|
expect(at("child2").y).toBe(PAD + (NODE_H + GAP_Y));
|
|
expect(at("child1").y).toBe(at("gc1").y);
|
|
expect(at("root").y).toBe((at("child1").y + at("child2").y) / 2);
|
|
// Canvas hugs the tree: 3 columns wide, 2 leaf rows tall.
|
|
expect(layout.width).toBe(PAD * 2 + 3 * NODE_W + 2 * GAP_X);
|
|
expect(layout.height).toBe(PAD * 2 + 2 * NODE_H + GAP_Y);
|
|
});
|
|
|
|
it("draws a straight edge between aligned nodes and an orthogonal elbow otherwise", () => {
|
|
const layout = layoutTopology(tree());
|
|
const edge = (to: string) => layout.edges.find((e) => e.toId === to)!;
|
|
expect(layout.edges).toHaveLength(3);
|
|
// child1 -> gc1 share a midline: single horizontal segment.
|
|
expect(edge("gc1").path).toMatch(/^M \S+ \S+ H \S+$/);
|
|
// root -> child2 offset rows: H mid, V to the child's midline, H into the box.
|
|
expect(edge("child2").path).toMatch(/^M \S+ \S+ H \S+ V \S+ H \S+$/);
|
|
expect(edge("child2").fromId).toBe("root");
|
|
});
|
|
|
|
it("lays out a lone root without edges", () => {
|
|
const m = createStreamModel();
|
|
pushMessage(m, userText("solo"));
|
|
const layout = layoutTopology(extractTopology(m, "root", false));
|
|
expect(layout.nodes).toHaveLength(1);
|
|
expect(layout.edges).toHaveLength(0);
|
|
expect(layout.height).toBe(PAD * 2 + NODE_H);
|
|
});
|
|
});
|