Files
learnharness/tests/render-phase-divider.test.tsx
T
amosblomqvist 326222f53c Minimal-color round 1 (owner): phase color retired — the braille spinner is the one working indicator
Nothing exists purely for decoration: phase color-coding is retired
app-wide (phases only advance — every session ends Teaching — so
phase-as-signal was decoration with a one-time function), and what
state genuinely deserved — 'a turn is in flight' — is now carried by
MOTION alone, in the terminal's own glyph language.

- BrailleSpinner (components/braille-spinner.tsx): the canonical
  cli-spinner sequence (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) as exact dot bitmaps in a 2×4
  braille cell, 80ms/frame (800ms cycle). Pure CSS: one shared keyframes,
  frames staggered via animation-delay; no JS timer, no re-render per
  frame — cheaper than the old breathing dot. Static contexts (SSR,
  prefers-reduced-motion) show frame 0 — a legible glyph, not a blank.
- Sidebar rows: spinner ONLY while a teach turn is in flight; idle and
  question-pending render NOTHING (a resting state has no scan value —
  the meta line's 'Waiting for your answer' text carries it). The
  three-state phase dot (pulse/solid/hollow) and PHASE_FILL/RING maps
  are gone.
- Top bar PhaseChip: label-only pill; the old dot slot becomes the
  spinner's home while a turn is in flight (busy prop wired from
  ChatView's busy = localStream || turnInFlight). Transcript
  PhaseDividers drop phase colors for monochrome rules + labels.
- API narrowing (app/api/chats/route.ts + ChatListItem contract):
  busy = isTurnRunning ONLY — background jobs no longer count (the
  sidebar's Background agents section is their channel; a per-row
  spinner would double-signal them).
- globals.css: --phase-* tokens + lh-dot-breathe removed (dead after
  the sweep — no remaining users).

Live-verified: idle/pending rows render no indicator, /chat/new chip is
label-only, 10 CSSAnimations at 800ms with correct 80ms stagger, static
frame 0 under reduced motion. Gates: typecheck, lint, 470/470, build.

Lane crossings to flag at merge: app/api/chats/route.ts + the
ChatListItem contract comment (src/core) — the busy-semantics narrowing;
components/chat.tsx one-line busy prop.
2026-09-15 17:01:01 +03:00

113 lines
4.3 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("busy: the old dot slot becomes the braille spinner (motion = turn in flight)", () => {
const html = renderToStaticMarkup(<PhaseChip phase="teach" busy />);
assert.ok(html.includes('data-testid="braille-spinner"'), "spinner in the chip");
assert.ok(html.includes("Teaching"), "label stays");
assert.ok((html.match(/class="braille-frame"/g) ?? []).length === 10, "all ten frames");
});
it("idle: no spinner — the chip is the label alone", () => {
const html = renderToStaticMarkup(<PhaseChip phase="teach" />);
assert.ok(!html.includes("braille-spinner"));
});
});
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);
});
});