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:
amosblomqvist
2026-09-02 16:40:47 +03:00
17 changed files with 1576 additions and 183 deletions
+136 -1
View File
@@ -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([]), []);
});
});
-43
View File
@@ -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");
});
});
+95
View File
@@ -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);
});
});
+109
View File
@@ -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");
});
});
+189
View File
@@ -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);
});
});