2026-09-11 03:26:56 +03:00
|
|
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
|
|
import { describe, it } from "node:test";
|
|
|
|
|
|
import assert from "node:assert/strict";
|
|
|
|
|
|
import {
|
|
|
|
|
|
PlanBlock,
|
|
|
|
|
|
PlanPopup,
|
|
|
|
|
|
blockLayout,
|
|
|
|
|
|
topoFor,
|
2026-09-14 07:38:13 +03:00
|
|
|
|
edgePathD,
|
2026-09-11 03:26:56 +03:00
|
|
|
|
wrapTitle,
|
|
|
|
|
|
GAP_X,
|
|
|
|
|
|
NODE_H,
|
|
|
|
|
|
NODE_W,
|
|
|
|
|
|
type PopupMessage,
|
|
|
|
|
|
} from "../components/plan-block.tsx";
|
|
|
|
|
|
import type { PlanBlockData, PlanTree } from "../src/core/contracts.ts";
|
|
|
|
|
|
|
|
|
|
|
|
// Plan blocks: the pure layered layout, the frozen-snapshot rendering
|
2026-09-11 06:44:20 +03:00
|
|
|
|
// (full = borderless + column-filling
|
2026-09-11 03:26:56 +03:00
|
|
|
|
// with the glowing current), and the popup's LIVE derivation.
|
|
|
|
|
|
|
|
|
|
|
|
const CHAIN: PlanTree = {
|
|
|
|
|
|
nodes: [
|
|
|
|
|
|
{ id: "a", title: "Alpha node" },
|
|
|
|
|
|
{ id: "b", title: "Beta node" },
|
|
|
|
|
|
{ id: "c", title: "Gamma node" },
|
|
|
|
|
|
],
|
|
|
|
|
|
edges: [
|
|
|
|
|
|
{ from: "a", to: "b" },
|
|
|
|
|
|
{ from: "b", to: "c" },
|
|
|
|
|
|
],
|
|
|
|
|
|
version: 1,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
describe("plan block layout (pure)", () => {
|
|
|
|
|
|
it("layers a chain top-down, one node per depth row", () => {
|
|
|
|
|
|
const { order, depth } = topoFor(CHAIN.nodes, CHAIN.edges);
|
|
|
|
|
|
assert.deepEqual(order.map((n) => n.id), ["a", "b", "c"]);
|
|
|
|
|
|
assert.equal(depth.get("c"), 2);
|
|
|
|
|
|
const layout = blockLayout(CHAIN.nodes, CHAIN.edges);
|
|
|
|
|
|
assert.equal(layout.width, NODE_W); // widest row is the single-node rows
|
|
|
|
|
|
assert.equal(layout.height, 3 * NODE_H + 2 * 48);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("centers same-depth nodes over the widest row; tolerates dangling edges", () => {
|
|
|
|
|
|
const nodes = [
|
|
|
|
|
|
{ id: "r", title: "Root" },
|
|
|
|
|
|
{ id: "x", title: "Left" },
|
|
|
|
|
|
{ id: "y", title: "Right" },
|
|
|
|
|
|
];
|
|
|
|
|
|
const layout = blockLayout(nodes, [
|
|
|
|
|
|
{ from: "r", to: "x" },
|
|
|
|
|
|
{ from: "r", to: "y" },
|
|
|
|
|
|
{ from: "ghost", to: "r" }, // dangling: ignored defensively
|
|
|
|
|
|
]);
|
|
|
|
|
|
assert.equal(layout.width, 2 * NODE_W + GAP_X);
|
|
|
|
|
|
// Root centered over its two children: (2*W+GAP - W)/2
|
|
|
|
|
|
assert.equal(layout.x.get("r"), (2 * NODE_W + GAP_X - NODE_W) / 2);
|
|
|
|
|
|
assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === NODE_W + GAP_X);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-14 07:38:13 +03:00
|
|
|
|
it("edgePathD draws dagre's route through curveBasis — mouth pulled back from the border", () => {
|
|
|
|
|
|
// Straight 3-point route (source border → mid → target border): the
|
2026-09-14 07:53:54 +03:00
|
|
|
|
// basis curve starts exactly at the border point; the END pulls
|
2026-09-27 00:47:48 +03:00
|
|
|
|
// back by HEAD_TIP_OVERHANG + the docking gap (92 − 2.359 − 2.667) so
|
|
|
|
|
|
// the head's TIP floats 2.67 short of the border with its mouth at
|
|
|
|
|
|
// the path's end, on a straight shaft (owner, rounds 11–12; the head
|
|
|
|
|
|
// + gap scaled ⅔ with the 1px lines, 2026-09-26).
|
2026-09-14 07:38:13 +03:00
|
|
|
|
const d = edgePathD([{ x: 80, y: 44 }, { x: 80, y: 68 }, { x: 80, y: 92 }]);
|
|
|
|
|
|
assert.ok(d.startsWith("M80,44"), "starts at the source border point");
|
2026-09-27 00:47:48 +03:00
|
|
|
|
assert.ok(d.endsWith("L80,86.974"), "mouth pulled back; tip floats 2.67 off the border");
|
2026-09-14 07:53:54 +03:00
|
|
|
|
// The whole run stays on-axis (every x is 80): a collinear route
|
|
|
|
|
|
// renders straight — the shaft stub never bends a straight edge.
|
|
|
|
|
|
const xs = [...d.matchAll(/(?:[MLC]|,)(-?[\d.]+),/g)].map((m) => m[1]);
|
|
|
|
|
|
assert.ok(xs.length > 0 && xs.every((v) => v === "80"), "collinear route renders on-axis");
|
2026-09-14 08:04:31 +03:00
|
|
|
|
// A perpendicular entry with the target box is the SAME geometry.
|
|
|
|
|
|
const pd = edgePathD([{ x: 80, y: 44 }, { x: 80, y: 68 }, { x: 80, y: 92 }], { x: 0, y: 92, w: 160, h: 44 });
|
|
|
|
|
|
assert.equal(pd, d);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("edgePathD clamps the entry to ±40° of the face perpendicular (owner, round 14)", () => {
|
|
|
|
|
|
const box = { x: 20, y: 100, w: 160, h: 44 };
|
|
|
|
|
|
// A 60° tilt into the top face swings to the 40° cone edge: the
|
|
|
|
|
|
// mouth lands at (97.726, 93.289) and the final segment runs at
|
|
|
|
|
|
// exactly 40° (Δx/Δy = tan 40°). The tip floats DOCK_GAP (4) off the
|
|
|
|
|
|
// face along the normal: border (100,100) → tip (100,96).
|
|
|
|
|
|
const clamped = edgePathD([{ x: 70, y: 70 }, { x: 100, y: 100 }], box);
|
2026-09-27 00:47:48 +03:00
|
|
|
|
assert.ok(clamped.endsWith("L98.484,95.526"), "swung to the cone edge");
|
2026-09-14 08:04:31 +03:00
|
|
|
|
// final L's from-point is the previous number pair; recompute direction
|
|
|
|
|
|
const nums = [...clamped.matchAll(/(-?[\d.]+)/g)].map((m) => Number(m[1]));
|
|
|
|
|
|
const ax = nums[nums.length - 4]!;
|
|
|
|
|
|
const ay = nums[nums.length - 3]!;
|
|
|
|
|
|
const bx = nums[nums.length - 2]!;
|
|
|
|
|
|
const by = nums[nums.length - 1]!;
|
|
|
|
|
|
assert.ok(Math.abs((bx - ax) / (by - ay) - Math.tan((40 * Math.PI) / 180)) < 0.01, "40° cone edge");
|
2026-09-27 00:47:48 +03:00
|
|
|
|
// Tip = mouth + u'·overhang floats 2.67 above the face.
|
|
|
|
|
|
const tipY = by + 0.766 * 2.359;
|
|
|
|
|
|
assert.ok(Math.abs(tipY - 97.333) < 0.01, "docking gap along the face normal");
|
2026-09-14 08:04:31 +03:00
|
|
|
|
// A shallow (~9.5°) tilt is WITHIN the limit: no swing.
|
|
|
|
|
|
const free = edgePathD([{ x: 90, y: 40 }, { x: 100, y: 100 }], box);
|
2026-09-27 00:47:48 +03:00
|
|
|
|
assert.ok(free.endsWith("L99.612,95.006"), "unclamped within the cone");
|
2026-09-14 07:38:13 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-14 07:12:38 +03:00
|
|
|
|
it("wrapTitle wraps greedily and NEVER drops content (boxes grow instead)", () => {
|
2026-09-11 03:26:56 +03:00
|
|
|
|
assert.deepEqual(wrapTitle("Chain rule intuition"), ["Chain rule intuition"]); // exactly 20 chars — fits one line
|
|
|
|
|
|
assert.deepEqual(wrapTitle("Chain rule intuition and fluency"), ["Chain rule intuition", "and fluency"]);
|
2026-09-14 07:12:38 +03:00
|
|
|
|
// Oversized word hard-chunked, nothing lost (owner, round 7).
|
|
|
|
|
|
assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), [
|
|
|
|
|
|
"supercalifragilistic",
|
|
|
|
|
|
"expialidocious word",
|
|
|
|
|
|
]);
|
2026-09-11 03:26:56 +03:00
|
|
|
|
assert.deepEqual(wrapTitle("supercalifragilistic"), ["supercalifragilistic"]); // exactly maxChars — fits
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("plan block render", () => {
|
|
|
|
|
|
it("full block: borderless, column-filling, statuses AS CARRIED (never re-derived)", () => {
|
|
|
|
|
|
const data: PlanBlockData = {
|
|
|
|
|
|
kind: "full",
|
|
|
|
|
|
at: "2026-09-09T12:00:00Z",
|
|
|
|
|
|
current: null,
|
|
|
|
|
|
nodes: [
|
2026-09-11 06:44:20 +03:00
|
|
|
|
{ id: "a", title: "Alpha node", status: "visited" },
|
2026-09-11 03:26:56 +03:00
|
|
|
|
{ id: "b", title: "Beta node", status: "upcoming" },
|
|
|
|
|
|
],
|
|
|
|
|
|
edges: [{ from: "a", to: "b" }],
|
|
|
|
|
|
};
|
|
|
|
|
|
const html = renderToStaticMarkup(<PlanBlock data={data} onOpen={() => {}} />);
|
|
|
|
|
|
assert.match(html, /data-testid="plan-block-full"/);
|
|
|
|
|
|
// Borderless: the wrapper carries no border/rounded chrome.
|
|
|
|
|
|
assert.ok(!/data-testid="plan-block-full"[^>]*border/.test(html), "no wrapping chrome");
|
2026-09-14 06:22:26 +03:00
|
|
|
|
// Fills the column UP TO the chat-font cap: the svg is w-full +
|
|
|
|
|
|
// mx-auto with maxWidth = extent × TEXT_CAP_SCALE, so node text never
|
2026-09-17 13:43:26 +03:00
|
|
|
|
// renders past the 16px prose size (owner, plan-graph round 1; the
|
|
|
|
|
|
// snap 16.5→16 landed in typography round 4).
|
2026-09-14 06:22:26 +03:00
|
|
|
|
assert.match(html, /<svg[^>]*class="block h-auto w-full mx-auto"/);
|
2026-09-27 00:47:48 +03:00
|
|
|
|
// The reading font, not the mono UI font (owner, 2026-09-26) — via the
|
|
|
|
|
|
// runtime var (the font-prose utility would bake the @theme literal
|
|
|
|
|
|
// and ignore the Appearance reading-font override).
|
|
|
|
|
|
assert.match(html, /<svg[^>]*font-family:var\(--font-prose\)/);
|
|
|
|
|
|
// Lines + outlines at the app's standard 1px border weight (owner,
|
|
|
|
|
|
// 2026-09-26; was 1.5).
|
|
|
|
|
|
assert.match(html, /<rect[^>]*stroke-width="1"/);
|
2026-09-17 13:43:26 +03:00
|
|
|
|
assert.match(html, /max-width:225\.28px/); // (160+16) × 1.28
|
2026-09-14 06:22:26 +03:00
|
|
|
|
// Two stacked nodes: 2*NODE_H + GAP_Y = 136 extent + GRAPH_PAD on
|
|
|
|
|
|
// every side (strokes/glow never clip at the edges).
|
|
|
|
|
|
assert.match(html, /viewBox="-8 -8 176 152"/);
|
2026-09-11 03:26:56 +03:00
|
|
|
|
// Snapshot statuses render as carried.
|
2026-09-11 06:44:20 +03:00
|
|
|
|
assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
|
2026-09-11 03:26:56 +03:00
|
|
|
|
assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/);
|
2026-09-25 01:52:20 +03:00
|
|
|
|
assert.ok(html.includes('aria-label="Open the full plan"'), "the trigger names itself (tooltip copy)");
|
2026-09-11 03:26:56 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("plan popup (live derivation)", () => {
|
|
|
|
|
|
function messages(): PopupMessage[] {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
parts: [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: "tool-plan_init",
|
|
|
|
|
|
state: "output-available",
|
|
|
|
|
|
output: { ok: true, tree: CHAIN },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
parts: [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: "tool-quiz",
|
|
|
|
|
|
state: "output-available",
|
|
|
|
|
|
input: { nodeId: "a" },
|
|
|
|
|
|
output: { correct: true },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
parts: [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: "tool-quiz",
|
|
|
|
|
|
state: "input-available",
|
|
|
|
|
|
input: { nodeId: "b" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 06:44:20 +03:00
|
|
|
|
it("derives statuses at click time: latest tagged call → current, earlier → visited (glowing)", () => {
|
2026-09-11 03:26:56 +03:00
|
|
|
|
const html = renderToStaticMarkup(<PlanPopup messages={messages()} onClose={() => {}} />);
|
|
|
|
|
|
assert.match(html, /data-testid="plan-popup"/);
|
2026-09-11 06:44:20 +03:00
|
|
|
|
assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
|
2026-09-11 03:26:56 +03:00
|
|
|
|
assert.match(html, /data-testid="plan-node-b" data-status="current"/);
|
|
|
|
|
|
assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/);
|
2026-09-14 06:55:10 +03:00
|
|
|
|
// The current node is the glow target: a faint yellow fill, blurred
|
2026-09-14 07:06:25 +03:00
|
|
|
|
// so it bleeds over the edges — static, no pulse (owner, round 6).
|
|
|
|
|
|
assert.match(html, /fill-yellow-400\/25/);
|
2026-09-14 06:55:10 +03:00
|
|
|
|
assert.match(html, /feGaussianBlur/);
|
2026-09-14 07:06:25 +03:00
|
|
|
|
assert.doesNotMatch(html, /animate-pulse/);
|
|
|
|
|
|
// Arrows into not-yet-visited nodes are grayed out (owner, round 6).
|
|
|
|
|
|
assert.match(html, /marker-end="url\(#.*-dim\)"/);
|
2026-09-11 03:26:56 +03:00
|
|
|
|
assert.match(html, /live — statuses derive from your check outcomes/);
|
2026-09-19 13:16:34 +03:00
|
|
|
|
// No top-left flash: at static render settled=false, so the diagram
|
|
|
|
|
|
// wrapper paints at opacity 0 — the fit runs in an effect AFTER the
|
|
|
|
|
|
// first paint and the chart would otherwise flash at the initial
|
|
|
|
|
|
// top-left zoom:1 state for a frame (owner report, 2026-09-19;
|
|
|
|
|
|
// transition-gating alone didn't hide it).
|
|
|
|
|
|
assert.match(html, /opacity: ?0[;,}]/, "diagram hidden until the first fit applies");
|
2026-09-11 03:26:56 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-27 00:47:48 +03:00
|
|
|
|
it("dims an arrow when EITHER end is gray — white needs both ends white (owner, 2026-09-26)", () => {
|
|
|
|
|
|
// x upcoming → y visited: the OLD rule (target-only) painted this
|
|
|
|
|
|
// white; the new rule keeps it gray — a gray box's outgoing arrows
|
|
|
|
|
|
// stay gray even into covered territory. y → z (visited → current)
|
|
|
|
|
|
// stays white: BOTH ends white.
|
|
|
|
|
|
const tree: PlanTree = {
|
|
|
|
|
|
nodes: [
|
|
|
|
|
|
{ id: "x", title: "Gray origin" },
|
|
|
|
|
|
{ id: "y", title: "Covered middle" },
|
|
|
|
|
|
{ id: "z", title: "Covered latest" },
|
|
|
|
|
|
],
|
|
|
|
|
|
edges: [
|
|
|
|
|
|
{ from: "x", to: "y" },
|
|
|
|
|
|
{ from: "y", to: "z" },
|
|
|
|
|
|
],
|
|
|
|
|
|
version: 1,
|
|
|
|
|
|
};
|
|
|
|
|
|
const msgs: PopupMessage[] = [
|
|
|
|
|
|
{ parts: [{ type: "tool-plan_init", state: "output-available", output: { ok: true, tree } }] },
|
|
|
|
|
|
{ parts: [{ type: "tool-quiz", state: "output-available", input: { nodeId: "y" }, output: { correct: true } }] },
|
|
|
|
|
|
{ parts: [{ type: "tool-quiz", state: "output-available", input: { nodeId: "z" }, output: { correct: true } }] },
|
|
|
|
|
|
];
|
|
|
|
|
|
const html = renderToStaticMarkup(<PlanPopup messages={msgs} onClose={() => {}} />);
|
|
|
|
|
|
assert.match(html, /data-testid="plan-node-x" data-status="upcoming"/);
|
|
|
|
|
|
assert.match(html, /data-testid="plan-node-y" data-status="visited"/);
|
|
|
|
|
|
assert.match(html, /data-testid="plan-node-z" data-status="current"/);
|
|
|
|
|
|
// Exactly one gray edge (x→y, gray origin) and one white edge (y→z,
|
|
|
|
|
|
// both ends white): count the head markers, one per edge.
|
|
|
|
|
|
const dimHeads = (html.match(/marker-end="url\(#.*-dim\)"/g) ?? []).length;
|
|
|
|
|
|
const whiteHeads = (html.match(/marker-end="url\(#(?:R\d+|[a-z0-9-]+)\)"/g) ?? []).length;
|
|
|
|
|
|
assert.equal(dimHeads, 1, "the gray-origin edge dims");
|
|
|
|
|
|
assert.equal(whiteHeads, 1, "the both-white edge stays white");
|
|
|
|
|
|
assert.match(html, /<path[^>]*class="fill-none stroke-border"/);
|
|
|
|
|
|
assert.match(html, /<path[^>]*class="fill-none stroke-white"/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-11 03:26:56 +03:00
|
|
|
|
it("renders the empty state when the session has no plan", () => {
|
|
|
|
|
|
const html = renderToStaticMarkup(<PlanPopup messages={[{ parts: [] }]} onClose={() => {}} />);
|
|
|
|
|
|
assert.match(html, /No plan in this session yet\./);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|