Merge feat/ui-polish: three-phase session model, plan graph, layout restructure
chat.tsx woven by hand: UI's session-surface layout (transcript column + right sidebar as full-height siblings, h-10 header with PhaseChip) wrapped in sub-agents' JobsContext.Provider; the message map carries both the steer ternary (steers bypass the Message wrapper — the model-facing glue text never renders) and UI's PhaseDivider emission (steers are verified phase-boundary no-ops). Right sidebar = sub-agents' fleet panel column (SubAgentsPanel above TreePanel, xl-only) carried onto UI's skeleton. contracts.ts auto-merged (ChatSummary.phase/pendingQuestion + ChatListItem, additive above the async-protocol section). Smoke-verified on the merged tree: steer renders bare as its artifact badge with no learner bubble, no rendered glue text, and no spurious phase divider at the steer boundary.
This commit is contained in:
+136
-1
@@ -8,7 +8,14 @@
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { phaseAt, type PhaseMessage } from "../src/core/phases.ts";
|
||||
import {
|
||||
phaseAt,
|
||||
phaseAt3,
|
||||
phaseOfTree,
|
||||
phaseTimeline,
|
||||
type Phase3Message,
|
||||
type PhaseMessage,
|
||||
} from "../src/core/phases.ts";
|
||||
|
||||
/** A minimal message: just typed parts, exactly the structural slice phaseAt reads. */
|
||||
function msg(...partTypes: string[]): PhaseMessage {
|
||||
@@ -83,3 +90,131 @@ describe("phaseAt", () => {
|
||||
assert.equal(phaseAt([]), "probe");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── The three-phase view (picker strips, header chip, transcript dividers) ────
|
||||
|
||||
/** A minimal tree: nodes as opaque ids is all phaseOfTree reads. */
|
||||
function tree(nodes: string[], currentNodeId: string | null): { nodes: unknown[]; currentNodeId: string | null } {
|
||||
return { nodes: nodes.map((id) => ({ id })), currentNodeId };
|
||||
}
|
||||
|
||||
/** A message whose parts are full plan-op tool parts with outputs. */
|
||||
function planMsg(
|
||||
...ops: { tool: string; ok: boolean; tree: ReturnType<typeof tree> }[]
|
||||
): Phase3Message {
|
||||
return {
|
||||
parts: ops.map((op, i) => ({
|
||||
type: `tool-plan_${op.tool}`,
|
||||
state: "output-available",
|
||||
toolCallId: `p${i}`,
|
||||
output: { ok: op.ok, tree: op.tree },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("phaseOfTree", () => {
|
||||
it("no tree / empty tree → probe", () => {
|
||||
assert.equal(phaseOfTree(undefined), "probe");
|
||||
assert.equal(phaseOfTree(null), "probe");
|
||||
assert.equal(phaseOfTree(tree([], null)), "probe", "emptyTree() (a failed init) reads probe");
|
||||
});
|
||||
|
||||
it("tree with no current node → plan", () => {
|
||||
assert.equal(phaseOfTree(tree(["a", "b"], null)), "plan");
|
||||
});
|
||||
|
||||
it("occupied node → teach", () => {
|
||||
assert.equal(phaseOfTree(tree(["a", "b"], "a")), "teach");
|
||||
});
|
||||
});
|
||||
|
||||
describe("phaseAt3", () => {
|
||||
it("no plan parts anywhere → probe at every index", () => {
|
||||
const messages: Phase3Message[] = [msg("text"), msg("tool-quiz"), msg("text")];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
assert.equal(phaseAt3(messages, i), "probe", `index ${i}`);
|
||||
}
|
||||
assert.equal(phaseAt3([]), "probe");
|
||||
});
|
||||
|
||||
it("the probe→plan seam: the first plan output with a non-empty tree", () => {
|
||||
const messages: Phase3Message[] = [
|
||||
msg("text"), // probe chatter
|
||||
planMsg({ tool: "plan_init", ok: false, tree: tree([], null) }), // failed init: NOT a seam
|
||||
msg("text"),
|
||||
planMsg({ tool: "plan_init", ok: true, tree: tree(["a", "b"], null) }), // ← the seam
|
||||
msg("text"),
|
||||
];
|
||||
assert.equal(phaseAt3(messages, 0), "probe");
|
||||
assert.equal(phaseAt3(messages, 1), "probe", "a failed init carries an empty tree — no seam");
|
||||
assert.equal(phaseAt3(messages, 2), "probe");
|
||||
assert.equal(phaseAt3(messages, 3), "plan");
|
||||
assert.equal(phaseAt3(messages, 4), "plan");
|
||||
assert.equal(phaseAt3(messages), "plan", "default = current phase");
|
||||
});
|
||||
|
||||
it("the plan→teach seam: the first tree output with an occupied node", () => {
|
||||
const messages: Phase3Message[] = [
|
||||
msg("text"),
|
||||
planMsg({ tool: "plan_init", ok: true, tree: tree(["a", "b"], null) }),
|
||||
msg("text"),
|
||||
planMsg({ tool: "plan_set_current", ok: true, tree: tree(["a", "b"], "a") }), // ← the seam
|
||||
msg("text"),
|
||||
];
|
||||
assert.equal(phaseAt3(messages, 2), "plan");
|
||||
assert.equal(phaseAt3(messages, 3), "teach");
|
||||
assert.equal(phaseAt3(messages, 4), "teach");
|
||||
});
|
||||
|
||||
it("collapsed seams: init and set_current in one message → straight to teach", () => {
|
||||
const messages: Phase3Message[] = [
|
||||
msg("text"),
|
||||
planMsg(
|
||||
{ tool: "plan_init", ok: true, tree: tree(["a"], null) },
|
||||
{ tool: "plan_set_current", ok: true, tree: tree(["a"], "a") },
|
||||
),
|
||||
];
|
||||
assert.equal(phaseAt3(messages, 0), "probe");
|
||||
assert.equal(phaseAt3(messages, 1), "teach");
|
||||
});
|
||||
|
||||
it("monotonic: a mid-teach node removal (current → null) never regresses the phase", () => {
|
||||
const messages: Phase3Message[] = [
|
||||
planMsg({ tool: "plan_init", ok: true, tree: tree(["a", "b"], null) }),
|
||||
planMsg({ tool: "plan_set_current", ok: true, tree: tree(["a", "b"], "a") }),
|
||||
msg("text"),
|
||||
planMsg({ tool: "plan_remove_node", ok: true, tree: tree(["b"], null) }), // edit, not a regression
|
||||
];
|
||||
assert.equal(phaseAt3(messages, 2), "teach");
|
||||
assert.equal(phaseAt3(messages, 3), "teach", "phase only ever advances");
|
||||
});
|
||||
|
||||
it("upToIndex past the end / negative → clamped, never throws", () => {
|
||||
const messages: Phase3Message[] = [planMsg({ tool: "plan_init", ok: true, tree: tree(["a"], null) })];
|
||||
assert.equal(phaseAt3(messages, 99), "plan");
|
||||
assert.equal(phaseAt3(messages, -1), "probe", "empty scan range → probe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("phaseTimeline", () => {
|
||||
it("equals phaseAt3 at every index (the linear fold is the same derivation)", () => {
|
||||
const messages: Phase3Message[] = [
|
||||
msg("text"),
|
||||
planMsg({ tool: "plan_init", ok: false, tree: tree([], null) }),
|
||||
planMsg({ tool: "plan_init", ok: true, tree: tree(["a", "b"], null) }),
|
||||
msg("text"),
|
||||
planMsg({ tool: "plan_set_current", ok: true, tree: tree(["a", "b"], "a") }),
|
||||
msg("text"),
|
||||
];
|
||||
const timeline = phaseTimeline(messages);
|
||||
assert.equal(timeline.length, messages.length);
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
assert.equal(timeline[i], phaseAt3(messages, i), `index ${i}`);
|
||||
}
|
||||
assert.deepEqual(timeline, ["probe", "probe", "plan", "plan", "teach", "teach"]);
|
||||
});
|
||||
|
||||
it("empty transcript → empty timeline", () => {
|
||||
assert.deepEqual(phaseTimeline([]), []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MessageResponse } from "../components/ai-elements/message.tsx";
|
||||
import { QuestionDock } from "../components/question-dock.tsx";
|
||||
import { AnsweredCard } from "../components/answered-card.tsx";
|
||||
import { PlanDivider } from "../components/artifacts/plan-divider.tsx";
|
||||
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
||||
|
||||
const quizInput = {
|
||||
@@ -42,7 +41,6 @@ function quizQD(): QuestionData {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function renderDock(props: { toolName: "quiz" | "ask_user_question"; toolCallId: string; input: never; onSubmit?: () => void }) {
|
||||
// expanded=true renders the full form (the collapsed bar is covered by the
|
||||
// QuestionDockBar test below). No scroll context needed — the DockOverlay
|
||||
@@ -220,44 +218,3 @@ describe("teach-check reveal (W5 phase separation)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlanDivider (the probe→teach seam)", () => {
|
||||
const tree = {
|
||||
nodes: [
|
||||
{ id: "a", title: "A" },
|
||||
{ id: "b", title: "B" },
|
||||
{ id: "c", title: "C" },
|
||||
],
|
||||
edges: [{ from: "a", to: "b" }],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
};
|
||||
|
||||
it("renders the quiet divider with the node count once output-available", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PlanDivider part={{ type: "tool-plan_init", state: "output-available", toolCallId: "p1", output: { ok: true, tree } }} />,
|
||||
);
|
||||
assert.ok(html.includes('data-testid="plan-divider"'));
|
||||
assert.match(html, /Teaching/);
|
||||
assert.match(html, /3 nodes/, "node count from the output tree");
|
||||
assert.ok(!html.includes("banner"), "never a loud banner");
|
||||
});
|
||||
|
||||
it("renders null while the tool streams and on a failed init", () => {
|
||||
const streaming = renderToStaticMarkup(
|
||||
<PlanDivider part={{ type: "tool-plan_init", state: "input-streaming", toolCallId: "p1" }} />,
|
||||
);
|
||||
assert.equal(streaming, "", "null while input-streaming");
|
||||
const failed = renderToStaticMarkup(
|
||||
<PlanDivider
|
||||
part={{
|
||||
type: "tool-plan_init",
|
||||
state: "output-available",
|
||||
toolCallId: "p1",
|
||||
output: { ok: false, error: "Tree already initialized", tree },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
assert.equal(failed, "", "a refused re-init must not draw a second seam");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Phase presentation render tests — the in-session face of the picker's
|
||||
* color strips (components/phase-divider.tsx): the header chip and the
|
||||
* dotted transcript boundary rules. Same tsx classic-JSX shim as
|
||||
* render-cards.test.tsx. (Replaces the old part-level PlanDivider tests.)
|
||||
*/
|
||||
import React from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { PhaseChip, PhaseDivider, planNodeCount } from "../components/phase-divider.tsx";
|
||||
|
||||
describe("PhaseChip (header)", () => {
|
||||
it("renders a phase-colored dot + the learner-facing label", () => {
|
||||
for (const [phase, label] of [
|
||||
["probe", "Probing"],
|
||||
["plan", "Planning"],
|
||||
["teach", "Teaching"],
|
||||
] as const) {
|
||||
const html = renderToStaticMarkup(<PhaseChip phase={phase} />);
|
||||
assert.ok(html.includes('data-testid="phase-chip"'));
|
||||
assert.ok(html.includes(`data-phase="${phase}"`));
|
||||
assert.ok(html.includes(label), `${phase} label`);
|
||||
assert.ok(html.includes(`bg-phase-${phase}`), `${phase} dot color`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PhaseDivider (transcript boundary)", () => {
|
||||
it("the plan seam: dotted rules in the plan color + node count", () => {
|
||||
const html = renderToStaticMarkup(<PhaseDivider phase="plan" nodeCount={3} />);
|
||||
assert.ok(html.includes('data-testid="phase-divider-plan"'));
|
||||
assert.match(html, /Plan · 3 nodes/);
|
||||
assert.ok(html.includes("border-dotted"), "dotted rule");
|
||||
assert.ok(html.includes("border-phase-plan"), "plan-colored rule");
|
||||
});
|
||||
|
||||
it("the teach seam: dotted rules in the teach color, no count", () => {
|
||||
const html = renderToStaticMarkup(<PhaseDivider phase="teach" />);
|
||||
assert.ok(html.includes('data-testid="phase-divider-teach"'));
|
||||
assert.match(html, /Teaching/);
|
||||
assert.ok(html.includes("border-phase-teach"));
|
||||
assert.ok(!html.includes("nodes"));
|
||||
});
|
||||
|
||||
it("singular node count", () => {
|
||||
const html = renderToStaticMarkup(<PhaseDivider phase="plan" nodeCount={1} />);
|
||||
assert.match(html, /Plan · 1 node\b/);
|
||||
});
|
||||
|
||||
it("a plan seam without a count still labels itself", () => {
|
||||
const html = renderToStaticMarkup(<PhaseDivider phase="plan" />);
|
||||
assert.match(html, /Planning/);
|
||||
});
|
||||
|
||||
it("probe renders null (never a boundary — phases only advance)", () => {
|
||||
assert.equal(renderToStaticMarkup(<PhaseDivider phase="probe" />), "");
|
||||
});
|
||||
});
|
||||
|
||||
describe("planNodeCount (the seam message's tree)", () => {
|
||||
it("counts the first output-available plan tree in the message", () => {
|
||||
const count = planNodeCount({
|
||||
parts: [
|
||||
{ type: "text", text: "Here's your plan." },
|
||||
{
|
||||
type: "tool-plan_init",
|
||||
state: "output-available",
|
||||
toolCallId: "p1",
|
||||
output: { ok: true, tree: { nodes: [{ id: "a" }, { id: "b" }], edges: [], currentNodeId: null, doneNodeIds: [], version: 1 } },
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(count, 2);
|
||||
});
|
||||
|
||||
it("skips streaming/failed parts (an empty or failed tree gives no count)", () => {
|
||||
assert.equal(
|
||||
planNodeCount({ parts: [{ type: "tool-plan_init", state: "input-streaming" }] }),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
planNodeCount({
|
||||
parts: [
|
||||
{ type: "tool-plan_init", state: "output-available", toolCallId: "p1", output: { ok: false, tree: { nodes: [], edges: [], currentNodeId: null, doneNodeIds: [], version: 0 } } },
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
"an empty tree (failed init) carries no count",
|
||||
);
|
||||
assert.equal(planNodeCount({ parts: [{ type: "text" }] }), undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Session-row render tests — the picker's status language. One dot, two
|
||||
* channels: COLOR = phase (sky/violet/emerald), FILL+MOTION = what's owed
|
||||
* (pulsing = teacher working, solid = answer owed, hollow = idle). Pure
|
||||
* function of the list item, so SSR of <SessionRow> pins every visible
|
||||
* state. Same tsx classic-JSX shim as render-cards.test.tsx.
|
||||
*/
|
||||
import React from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { SessionRow, timeAgo } from "../components/sidebar.tsx";
|
||||
import type { ChatListItem, Phase3 } from "../src/core/index.ts";
|
||||
|
||||
function item(overrides: Partial<ChatListItem>): ChatListItem {
|
||||
return {
|
||||
id: "c1",
|
||||
title: "Test session",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
messageCount: 4,
|
||||
phase: "probe",
|
||||
pendingQuestion: false,
|
||||
busy: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(chat: ChatListItem, active = false) {
|
||||
return renderToStaticMarkup(<SessionRow chat={chat} active={active} onOpen={() => {}} />);
|
||||
}
|
||||
|
||||
describe("SessionRow", () => {
|
||||
it("no phase strips — the dot alone carries the phase color", () => {
|
||||
const html = render(item({ phase: "teach" }));
|
||||
assert.ok(!html.includes("phase-strip"), "strip removed by design (too busy)");
|
||||
assert.ok(!html.includes("absolute top-2"), "no leftover strip geometry");
|
||||
});
|
||||
|
||||
it("idle session: hollow phase-colored ring, perfectly still", () => {
|
||||
for (const phase of ["probe", "plan", "teach"] as Phase3[]) {
|
||||
const html = render(item({ phase }));
|
||||
assert.ok(html.includes("session-dot-idle"), `${phase} idle dot present`);
|
||||
assert.ok(html.includes(`border-phase-${phase}`), `${phase} ring carries its phase color`);
|
||||
assert.ok(!html.includes("lh-dot-breathe"), "idle is never animated");
|
||||
assert.ok(!html.includes("bg-phase-"), "hollow: no fill");
|
||||
}
|
||||
});
|
||||
|
||||
it("a pending question renders the SOLID phase dot — no animation (the dominant state stays calm)", () => {
|
||||
const html = render(item({ phase: "teach", pendingQuestion: true }));
|
||||
assert.ok(html.includes("session-dot-awaiting"), "awaiting dot present");
|
||||
assert.ok(html.includes("bg-phase-teach"), "solid fill in the phase color");
|
||||
assert.ok(!html.includes("lh-dot-breathe"), "motion is reserved for working");
|
||||
assert.ok(!html.includes("bg-attention"), "amber channel is gone");
|
||||
assert.ok(html.includes("Waiting for your answer"), "label");
|
||||
});
|
||||
|
||||
it("a working session renders the pulsing phase dot", () => {
|
||||
const html = render(item({ phase: "plan", busy: true }));
|
||||
assert.ok(html.includes("session-dot-working"), "working dot present");
|
||||
assert.ok(html.includes("lh-dot-breathe"), "breathing animation");
|
||||
assert.ok(html.includes("bg-phase-plan"), "the pulse is phase-colored");
|
||||
assert.ok(html.includes("Teacher is working"), "label");
|
||||
});
|
||||
|
||||
it("a pending answer outranks 'working' when both are true", () => {
|
||||
const html = render(item({ pendingQuestion: true, busy: true }));
|
||||
assert.ok(html.includes("session-dot-awaiting"));
|
||||
assert.ok(!html.includes("session-dot-working"), "one dot at a time — awaiting wins");
|
||||
});
|
||||
|
||||
it("the meta line: state replaces the count when the state is the useful fact", () => {
|
||||
const awaiting = render(item({ pendingQuestion: true, updatedAt: "2026-01-02T01:00:00.000Z" }));
|
||||
assert.ok(awaiting.includes("Waiting for your answer · "), "awaiting meta");
|
||||
assert.ok(!awaiting.includes("messages"), "count yields to the state");
|
||||
assert.ok(!awaiting.includes("lh-dot-breathe"), "…and the dot is still solid");
|
||||
|
||||
const working = render(item({ busy: true }));
|
||||
assert.ok(working.includes("Teacher is working…"), "working meta");
|
||||
|
||||
const idle = render(item({ title: "Partial derivatives", messageCount: 7 }));
|
||||
assert.ok(idle.includes("Partial derivatives"));
|
||||
assert.match(idle, /7 messages · \d{1,2}\/\d{1,2}\/\d{4}/, "idle keeps the count");
|
||||
});
|
||||
|
||||
it("active row is visually marked", () => {
|
||||
const html = render(item({}), true);
|
||||
assert.ok(html.includes("font-medium"), "active emphasis");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeAgo", () => {
|
||||
const now = new Date("2026-01-02T12:00:00.000Z");
|
||||
|
||||
it("just now / minutes / hours / days, then falls back to a date", () => {
|
||||
assert.equal(timeAgo("2026-01-02T11:59:30.000Z", now), "just now");
|
||||
assert.equal(timeAgo("2026-01-02T11:55:00.000Z", now), "5m ago");
|
||||
assert.equal(timeAgo("2026-01-02T09:00:00.000Z", now), "3h ago");
|
||||
assert.equal(timeAgo("2025-12-31T12:00:00.000Z", now), "2d ago");
|
||||
assert.match(timeAgo("2025-06-02T12:00:00.000Z", now), /\d/, "beyond a week: a date");
|
||||
});
|
||||
|
||||
it("negative drift (clock skew) reads as just now, never a future date", () => {
|
||||
assert.equal(timeAgo("2026-01-02T12:01:00.000Z", now), "just now");
|
||||
});
|
||||
});
|
||||
@@ -270,3 +270,192 @@ describe("TreePanel rendering", () => {
|
||||
assert.ok(!html.includes("Foundation"), "no node content in the strip");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the graph (expanded view) ────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
TreeGraph,
|
||||
graphLayout,
|
||||
wrapTitle,
|
||||
NODE_H,
|
||||
NODE_W,
|
||||
} from "../components/tree-panel.tsx";
|
||||
|
||||
describe("wrapTitle (box labels)", () => {
|
||||
it("wraps to lines within the char budget", () => {
|
||||
assert.deepEqual(wrapTitle("Chain rule intuition for composites"), [
|
||||
"Chain rule intuition",
|
||||
"for composites",
|
||||
]);
|
||||
});
|
||||
|
||||
it("a title that fits stays one line", () => {
|
||||
assert.deepEqual(wrapTitle("Foundation"), ["Foundation"]);
|
||||
});
|
||||
|
||||
it("overflowing titles end in an ellipsis", () => {
|
||||
const lines = wrapTitle("Substitution and the differential of implicit functions");
|
||||
assert.equal(lines.length, 2);
|
||||
assert.ok(lines.at(-1)?.endsWith("…"));
|
||||
});
|
||||
|
||||
it("a single oversized word is hard-clamped", () => {
|
||||
const lines = wrapTitle("antidifferentiation-techniques-overview");
|
||||
assert.ok(lines.every((l) => l.length <= 22));
|
||||
assert.ok(lines.at(-1)?.endsWith("…"));
|
||||
});
|
||||
|
||||
it("empty titles render one empty line (never an empty array)", () => {
|
||||
assert.deepEqual(wrapTitle(""), [""]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("graphLayout (layered, top-down)", () => {
|
||||
it("one node per depth row, authoring order within the row, rows centered", () => {
|
||||
const { order, depth } = topoLayout(chainTree);
|
||||
const layout = graphLayout(order, depth);
|
||||
assert.equal(layout.height, NODE_H * 3 + 64 * 2, "three rows plus arrow gaps");
|
||||
// b, a: depth 0; c: depth 1 → wait: a is authored second but is the root.
|
||||
assert.equal(depth.get("a"), 0);
|
||||
assert.equal(depth.get("b"), 1);
|
||||
assert.equal(depth.get("c"), 2);
|
||||
// each row of one node is centered on the (single-column) width.
|
||||
assert.equal(layout.x.get("a"), 0);
|
||||
assert.equal(layout.x.get("b"), 0);
|
||||
assert.equal(layout.x.get("c"), 0);
|
||||
assert.equal(layout.y.get("a"), 0);
|
||||
assert.equal(layout.y.get("b"), NODE_H + 64);
|
||||
});
|
||||
|
||||
it("two same-depth nodes sit side by side, centered over the widest row", () => {
|
||||
const tree: PlanTree = {
|
||||
nodes: [
|
||||
{ id: "r", title: "Root" },
|
||||
{ id: "x", title: "X" },
|
||||
{ id: "y", title: "Y" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "r", to: "x" },
|
||||
{ from: "r", to: "y" },
|
||||
],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
};
|
||||
const { order, depth } = topoLayout(tree);
|
||||
const layout = graphLayout(order, depth);
|
||||
const xX = layout.x.get("x")!;
|
||||
const xY = layout.x.get("y")!;
|
||||
assert.equal(xY - xX, NODE_W + 28, "one box + one gap apart");
|
||||
// row 1 is the widest → row 0 centers over it: the root sits exactly
|
||||
// between the two dependents' centers (88 and 292 → 190 - 88 = 102).
|
||||
assert.equal(layout.x.get("r"), (2 * NODE_W + 28 - NODE_W) / 2, "the single root centers over the two-wide row");
|
||||
assert.equal(layout.width, 2 * NODE_W + 28);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TreeGraph rendering", () => {
|
||||
it("renders one box per node with its status, one arrow per edge", () => {
|
||||
const html = renderToStaticMarkup(<TreeGraph tree={chainTree} />);
|
||||
for (const [id, status] of [
|
||||
["a", "done"],
|
||||
["b", "current"],
|
||||
["c", "upcoming"],
|
||||
] as const) {
|
||||
assert.ok(
|
||||
new RegExp(`data-testid="graph-node-${id}"[^>]*data-status="${status}"`).test(html),
|
||||
`${id} is ${status}`,
|
||||
);
|
||||
}
|
||||
assert.equal(html.match(/marker-end="url\(#lh-plan-arrow\)"/g)?.length, 2, "two edges, two arrows");
|
||||
});
|
||||
|
||||
it("colors progress: done boxes carry the teach-phase tint", () => {
|
||||
const html = renderToStaticMarkup(<TreeGraph tree={chainTree} />);
|
||||
assert.match(html, /data-status="done"[\s\S]*?fill-phase-teach\/10/);
|
||||
assert.match(html, /data-status="current"[\s\S]*?stroke-primary/);
|
||||
});
|
||||
|
||||
it("marks the next unlocked node", () => {
|
||||
const tree: PlanTree = {
|
||||
nodes: [
|
||||
{ id: "a", title: "Foundation" },
|
||||
{ id: "b", title: "Middle" },
|
||||
],
|
||||
edges: [{ from: "a", to: "b" }],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: ["a"],
|
||||
version: 2,
|
||||
};
|
||||
const html = renderToStaticMarkup(<TreeGraph tree={tree} />);
|
||||
assert.match(html, /data-testid="graph-node-b"[^>]*data-next="true"/);
|
||||
});
|
||||
|
||||
it("node titles wrap inside the box (two lines max, ellipsis on overflow)", () => {
|
||||
const tree: PlanTree = {
|
||||
nodes: [{ id: "long", title: "Substitution and the differential of implicit functions" }],
|
||||
edges: [],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
};
|
||||
const html = renderToStaticMarkup(<TreeGraph tree={tree} />);
|
||||
const texts = html.match(/<text[^>]*>([^<]*)<\/text>/g) ?? [];
|
||||
assert.equal(texts.length, 2, "two wrapped lines");
|
||||
assert.ok(texts.at(-1)?.includes("…"));
|
||||
});
|
||||
|
||||
it("hover shows title and summary", () => {
|
||||
const html = renderToStaticMarkup(<TreeGraph tree={chainTree} />);
|
||||
assert.ok(html.includes("Foundation — Where it all starts."));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── graph view math (pan lock at fit, zoom-to-cursor) ────────────────────────
|
||||
|
||||
import {
|
||||
fitGraphView,
|
||||
zoomedGraphView,
|
||||
type GraphView,
|
||||
} from "../components/tree-panel.tsx";
|
||||
|
||||
describe("fitGraphView / zoomedGraphView", () => {
|
||||
const box = { w: 1000, h: 800 };
|
||||
const viewport = { w: 300, h: 400 };
|
||||
|
||||
it("fit centers the whole graph in the viewport at the given zoom", () => {
|
||||
const v = fitGraphView(box, viewport, 0.3);
|
||||
assert.equal(v.zoom, 0.3);
|
||||
assert.equal(v.x, (300 - 1000 * 0.3) / 2);
|
||||
assert.equal(v.y, (400 - 800 * 0.3) / 2);
|
||||
});
|
||||
|
||||
it("zooming in moves the point under the cursor to stay put (zoom-to-cursor)", () => {
|
||||
const v: GraphView = { zoom: 0.5, x: -100, y: -50 };
|
||||
// world point at cursor (50, 60) pre-zoom: (50 - (-100))/0.5 = 300
|
||||
const next = zoomedGraphView(v, 2, 0.1, { x: 50, y: 60 }, box, viewport);
|
||||
assert.equal(next.zoom, 1);
|
||||
// the same world point maps back to the cursor: (50 - next.x)/1 = 300
|
||||
assert.equal(50 - next.x, 300);
|
||||
assert.equal(60 - next.y, 220);
|
||||
});
|
||||
|
||||
it("zooming out past the floor SNAPS to the centered fit (fully zoomed out = the fit)", () => {
|
||||
const v: GraphView = { zoom: 1.8, x: -900, y: -400 };
|
||||
const next = zoomedGraphView(v, 1 / 8, 0.3, { x: 100, y: 100 }, box, viewport);
|
||||
assert.equal(next.zoom, 0.3, "clamped at the floor");
|
||||
assert.deepEqual(next, fitGraphView(box, viewport, 0.3), "and re-centered: nothing to pan");
|
||||
});
|
||||
|
||||
it("a no-op zoom returns the same view (no drift)", () => {
|
||||
const v: GraphView = { zoom: 2, x: 10, y: 10 };
|
||||
// factor 1 → same zoom; at MAX already → clamped to same → same view
|
||||
assert.equal(zoomedGraphView(v, 1, 0.3, { x: 0, y: 0 }, box, viewport), v);
|
||||
});
|
||||
|
||||
it("zoom-in clamps at the ceiling (2×)", () => {
|
||||
const v: GraphView = { zoom: 1.9, x: 0, y: 0 };
|
||||
const next = zoomedGraphView(v, 3, 0.3, { x: 10, y: 10 }, box, viewport);
|
||||
assert.equal(next.zoom, 2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user