Files
learnharness/tests/render-sidebar.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

115 lines
5.1 KiB
TypeScript

/**
* Session-row render tests — the picker's status language (owner,
* minimal-color round): ONE indicator, the braille spinner, present ONLY
* while a teach turn is in flight; every other state (idle, question
* pending) renders nothing — phases only advance, so phase-as-signal was
* decoration, and question-pending is the dominant RESTING state (the
* meta line's text carries it). 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 } 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, no phase colors — the retired decoration", () => {
const html = render(item({ phase: "teach" }));
assert.ok(!html.includes("phase-strip"), "strip removed by design (too busy)");
assert.ok(!html.includes("bg-phase-"), "phase fills are retired app-wide");
assert.ok(!html.includes("border-phase-"), "phase rings are retired app-wide");
});
it("idle session: NO indicator — absence is the state", () => {
for (const phase of ["probe", "plan", "teach"] as const) {
const html = render(item({ phase }));
assert.ok(!html.includes("session-dot"), "no dots — the status language is spinner-only");
assert.ok(!html.includes("braille-spinner"), "idle never shows the spinner");
}
});
it("a pending question renders NO indicator — resting state, carried by the meta text", () => {
const html = render(item({ phase: "teach", pendingQuestion: true }));
assert.ok(!html.includes("session-dot"), "no dots");
assert.ok(!html.includes("braille-spinner"), "motion is reserved for working");
assert.ok(html.includes("Waiting for your answer"), "the meta line's text");
});
it("a working session (turn in flight) renders the braille spinner — the ONLY indicator", () => {
const html = render(item({ phase: "plan", busy: true }));
assert.ok(html.includes('data-testid="braille-spinner"'), "the spinner");
assert.ok(html.includes("braille-frame"), "CSS-animated frames");
assert.ok((html.match(/class="braille-frame"/g) ?? []).length === 10, "all ten frames");
assert.ok(html.includes('data-frame="0"'), "frame 0 leads (static fallback)");
assert.ok(html.includes("Teacher is working"), "accessible label");
assert.ok(!html.includes("bg-attention"), "no colored state fill — monochrome by design");
});
it("busy does not double-signal background agents (that is the sidebar section's channel)", () => {
// The narrowing lives in the API route (busy = isTurnRunning only);
// the row itself just renders busy faithfully. Pinned here so the
// contract can't silently re-widen: an extraction-only "working"
// row would be a double signal.
const html = render(item({ busy: true, pendingQuestion: false }));
assert.ok(html.includes("Teacher is working"), "the spinner's label names the turn, not agents");
});
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("braille-spinner"), "…and the spinner stays off");
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");
});
});