Files
learnharness/tests/render-plan-block.test.tsx
T
amosblomqvist 847caf48d3 Plan-graph round 12 (owner): docking gap + straight shaft stub
Two edge-drawing refinements (owner), both pure post-processing in
edgePathD on dagre's waypoints:

- Docking gap (4 units ≈ half a head length): the head's tip floats
  short of the target face instead of touching it — the mouth pulls
  back by tip overhang + gap, so the head kisses nothing and tilted
  entries stop biting into the box.
- Shaft stub (12 units ≈ 1.5 head lengths): from Q, every control
  point sits exactly on the approach line, so the basis spline renders
  that stretch perfectly straight and the head lands on a straight
  shaft; the corner where the route squares up is rounded by the
  curve, and the repeated mouth point makes the end tangent exactly
  the approach direction. Collinear routes render identically
  (pinned: on-axis start to mouth).

Angle alignment/perpendicular entry deliberately NOT touched yet —
owner wants to evaluate this first (round 13 candidate).
2026-09-14 07:53:54 +03:00

175 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
PlanBlock,
PlanPopup,
blockLayout,
topoFor,
edgePathD,
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
// (full = borderless + column-filling
// 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);
});
it("edgePathD draws dagre's route through curveBasis — mouth pulled back from the border", () => {
// Straight 3-point route (source border → mid → target border): the
// basis curve starts exactly at the border point; the END pulls
// back by HEAD_TIP_OVERHANG + the docking gap (92 − 3.538 − 4) so the
// head's TIP floats 4 short of the border with its mouth at the
// path's end, on a straight shaft (owner, rounds 11–12).
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");
assert.ok(d.endsWith("L80,84.462"), "mouth pulled back; tip floats 4 off the border");
// 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");
});
it("wrapTitle wraps greedily and NEVER drops content (boxes grow instead)", () => {
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"]);
// Oversized word hard-chunked, nothing lost (owner, round 7).
assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), [
"supercalifragilistic",
"expialidocious word",
]);
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: [
{ id: "a", title: "Alpha node", status: "visited" },
{ 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");
// 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
// renders past the 16.5px prose size (owner, plan-graph round 1).
assert.match(html, /<svg[^>]*class="block h-auto w-full mx-auto"/);
assert.match(html, /max-width:232\.32px/); // (160+16) × 1.32
// 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"/);
// Snapshot statuses render as carried.
assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/);
assert.match(html, /Open the full plan/);
});
});
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" },
},
],
},
];
}
it("derives statuses at click time: latest tagged call → current, earlier → visited (glowing)", () => {
const html = renderToStaticMarkup(<PlanPopup messages={messages()} onClose={() => {}} />);
assert.match(html, /data-testid="plan-popup"/);
assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
assert.match(html, /data-testid="plan-node-b" data-status="current"/);
assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/);
// The current node is the glow target: a faint yellow fill, blurred
// so it bleeds over the edges — static, no pulse (owner, round 6).
assert.match(html, /fill-yellow-400\/25/);
assert.match(html, /feGaussianBlur/);
assert.doesNotMatch(html, /animate-pulse/);
// Arrows into not-yet-visited nodes are grayed out (owner, round 6).
assert.match(html, /marker-end="url\(#.*-dim\)"/);
assert.match(html, /live — statuses derive from your check outcomes/);
});
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\./);
});
});