Files
learnharness/tests/render-phase-divider.test.tsx
T
amosblomqvist bc1f668e2a Extraction surfaces: sidebar coverage block, chat-bar ring, phase un-pill, panel death
The owner-locked extraction-UX redesign (dispatch 2026-09-24), UI
chunk over Platform's data layer (eed4b4e — GET /api/chats/[id]
carries `coverage`, the threshold is Graph ▸ extractionChunkTokens):

1. SIDEBAR BLOCK (components/extraction-coverage.tsx +
   sidebar's session menu): the coverage info block at the TOP of
   the context menu — uncovered tokens, uncovered messages, running
   state — then the Extract affordance (optimistic "extracting… /
   Running" until the server says running; 409 = benign, the trigger
   raced us), fetched ON MENU OPEN (the block mounts with the menu —
   on-demand, never eagerly), polled at 2s while a run is in flight.
   Visually separated from Rename/Delete by a hairline; those stay
   the two session-management actions. Failed read → nothing (never
   fake zeros).

2. RING METER (CoverageRing, chat top bar, before the PhaseChip):
   a 16px donut filling uncoveredTokens / extractionChunkTokens —
   "how close this session is to its next extraction run". Click →
   the exact numbers (covered/uncovered tokens, messages, the
   threshold, lastFoldedAt via timeAgo). Refresh chains: mount, the
   turn-settle bump (chat.tsx coverageTick), the settings signal
   (threshold/kill-switch re-read live), a 2s drain-poll while a run
   is in flight, and the ACTIVITY FEED — a run for this session
   appearing or settling in the dock's feed re-reads coverage (runs
   fire from other surfaces — the sidebar menu, the turn pipeline —
   and no turn-settle bump announces them; the runs-empty-transition
   the graph view rides). Vanishes entirely when the graph is off or
   before the first honest read. Running = the whole ring PULSES
   (implementer's visual call: motion is the working-state signal in
   this app's quiet register).

3. PHASECHIP UN-PILL: the phase label is a flat tag — no border, no
   rounding, no padding. The Plan chip is the only button-shaped
   thing in the top bar. Drag-surface (pointer-events-none) and aria
   semantics unchanged; the transcript PhaseDivider keeps its look.

4. PANEL DEATH: components/graph-coverage.tsx, its graph-view
   mount, and tests/render-graph-coverage.test.tsx die whole.
   Confirmed: after this, NOTHING client-side consumes GET
   /api/graph/coverage — the only remaining client use of the
   coverage endpoints is the Extract POST (kept, by design); the
   routes stay (Platform's call).

Pure seams exported for tests: ringFill (clamp math),
CoverageMenuBody, CoverageRingGlyph, CoverageRingDetail.
901/901.

Live-verified on :3001: ring fill arcs match the API byte-for-byte
(483/4096 = 4.45/37.70; 1991/4096 = 18.32/37.70); the sidebar block
reads "2k uncovered / 4 messages unextracted"; a real Extract run
went optimistic → dock run → menu settled "fully extracted" AND the
ring drained to the empty arc (the feed chain); graph off → the ring
vanishes; the flat phase label renders chrome-free beside the Plan
pill.
2026-09-24 15:26:22 +03:00

115 lines
4.6 KiB
TypeScript

/**
* Phase presentation render tests — the in-session face (components/
* phase-divider.tsx), monochrome since the minimal-color round: the header
* chip (label + the braille spinner in the old dot slot while a turn is in
* flight) 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 the learner-facing label — no colored dot (phase color retired)", () => {
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} dot color retired`);
assert.ok(!html.includes("rounded-full bg-"), "no dot of any color — label only");
}
});
it("UN-PILLED (owner, extraction-UX redesign 2026-09-24): a flat tag — no border, no rounding, no padding", () => {
const html = renderToStaticMarkup(<PhaseChip phase="plan" />);
assert.ok(!html.includes("rounded-full"), "no pill curve");
assert.ok(!html.includes("border-border"), "no border chrome");
assert.ok(!html.includes("px-2"), "no pill padding");
assert.match(html, /class="[^"]*text-xs font-medium uppercase/, "the quiet flat label keeps its type treatment");
});
it("NEVER shows a working spinner — the pill's curve suits text, not glyph cells (owner, round 2)", () => {
const html = renderToStaticMarkup(<PhaseChip phase="teach" />);
assert.ok(!html.includes("braille-spinner"), "the chip is label-only by design");
assert.ok(!html.includes("braille-frame"), "no spinner frames leak in");
});
});
describe("PhaseDivider (transcript boundary)", () => {
it("the plan seam: monochrome dotted rules + 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-border"), "monochrome rule");
assert.ok(!html.includes("border-phase-"), "phase-colored rules are retired");
});
it("the teach seam: monochrome rule, 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-"));
assert.ok(!html.includes("text-phase-"));
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);
});
});