Files
learnharness/tests/render-stats-bar.test.tsx
T
amosblomqvist 7a17230f26 Wave 7 close-out: the graphTools surface, wrapped cluster labels, the orphan sweep
Part 1 — the surface:
- Settings ▸ Agent ▸ Tools gains the prober GRAPH-TOOLS row as its FIRST
  row (owner ruling: in the Tools section, not the tool-toggles list —
  its subject is the map, its mechanism is agent capability). Writes
  agent.graphTools {enabled} (absent = on, the toggle discipline) via
  the section PUT; composeGraphTools pure seam. Its own key in the agent
  contract — never in the tools map.
- The row carries the search-row hint discipline: "Connects prior
  sessions' knowledge into new ones — the prober reads the knowledge
  map" (owner wording). The honest interplay composes both: when
  graph.enabled is off the row dims + disables with a "Graph is
  disabled" hint (the effective gate itself is server-side, per-turn
  fresh). AgentSection reads the graph section (record.graph, passed by
  the dialog) — read-only.
- Cluster labels wrap: wave 7 made labels session names and FILE PATHS,
  which the old single-line fillText could not bound. wrapRegionLabel
  (pure, exported): word-wrap first, char-split long paths (the
  node-label discipline), capped at 3 lines, overflow folded into an
  ellipsized last line (owner: wrap, cap 2–3, ellipsize). Pre-wrapped
  once per geometry build at the SCREEN-fixed size (viewer-relative
  labels — the wrap never goes stale mid-camera-move), drawn centered
  line-by-line at the centroid. Deleted-session clusters keep their
  hull-only honest silence (label null → no lines).
- The orphan sweep: StatsBar's leftSlot prop is dead (its one consumer,
  the knowledge-context slider, died in c690181) — removed, and pre-chat
  views now render NOTHING on the left (owner ruling this round: a
  "— · —" summary over an empty popover is pure noise) — the dictation
  middle just widens. Stale knowledge-context comments swept in
  chat.tsx (adoption-window + activity-nudge docs), activity-dock.tsx
  (useActivity ground rules), dictation.tsx (×2 adaptive-meter docs),
  home.tsx (pre-chat dock comment).

Part 2 — Teacher's c690181 review: clean. Imports, KIND_ICON/KIND_LABEL
entries, render branches and event seams all excised whole; the probe-
handoff branch re-indented intact and merge-adopted; tests updated in
lockstep (merge-adopted, record-adapter, render-new-session retired).
Platform's 4b60e0f roster-string fixes (labeler dropped from both
SLOT_META blocks) reviewed — consistent across agent-section +
setup-dialog.

Gates: tsc clean, 854/854 (8 new: wrapRegionLabel cases, graphTools
SSR rows ×3; stats-bar + ladder fixtures updated to the new rulings),
lint 0, build ok. Live on :3001: graphTools row first-in-group with
hint, off→{enabled:false}→on round-trip, graph-off dimming + hint +
disabled toggle, pre-chat rows summary-free, chat view keeps its
summary, long-title cluster label wrapped without console errors
(visual read unavailable this session — pure wrap is unit-pinned).
2026-09-23 15:57:59 +03:00

89 lines
4.2 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 { StatsBar } from "../components/stats-bar.tsx";
import { prettyModel } from "../components/usage-format.ts";
import type { ChatSettings, TurnUsage } from "../src/core/contracts.ts";
// The stats bar: folds only (no cost math in the component), the "—"
// doctrine for absent data, honest cost (never a fake $0), and the model +
// thinking control (components/model-picker.tsx — pi-ai catalog backed).
function step(inputTokens: number, outputTokens: number, over: Record<string, number | undefined> = {}) {
return { inputTokens, outputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined, ...over };
}
function turn(over: Partial<TurnUsage> = {}): TurnUsage {
return {
id: "t1",
messageId: "m1",
at: "2026-09-09T12:00:00Z",
model: "openrouter:z-ai/glm-5.3-flash",
steps: [step(10_000, 1_000)],
...over,
};
}
describe("stats bar", () => {
it("renders NOTHING on the left when there are no turns yet (owner, wave 7 sweep)", () => {
const html = renderToStaticMarkup(
<StatsBar turns={[]} />,
);
assert.match(html, /data-testid="stats-bar"/);
// pre-chat: no summary button, no "—" theater (the knowledge-context
// slider died with the injection — the left side is simply empty)
assert.ok(!html.includes('data-testid="stats-summary"'), "no summary button pre-chat");
assert.ok(!html.includes('data-testid="stats-context"'), "no context figure pre-chat");
assert.ok(!html.includes('data-testid="stats-cost"'), "no cost figure pre-chat");
// no turns, no settings, catalog not loaded → both control labels
// "—" (the thinking button shows just the level, like the model
// button shows just the model name — no prefix, no dot separator)
assert.match(html, /data-testid="stats-model"[^>]*>\s*<span[^>]*>—<\/span>/);
assert.match(html, /data-testid="stats-thinking"[^>]*>\s*<span[^>]*>—<\/span>/);
assert.ok(!html.includes("aria-hidden>·"), "no dot between the controls");
// the control reads as a button: pill border + dropdown chevron
assert.match(html, /data-testid="stats-model"[^>]*rounded-full[^>]*>/);
assert.match(html, /▾/);
});
it("folds context + cost from the turns log (core/prices, no local math)", () => {
const html = renderToStaticMarkup(
<StatsBar turns={[turn(), turn()]} />,
);
// last turn's last call: 10k in + 1k out = 11k; pi catalog limit
// 1_048_576 → "1M" (tables regenerated from pi-ai's catalog)
assert.match(html, /data-testid="stats-context">11k \/ 1M</);
// pi catalog price 0.075/0.25 per 1M: each turn 10k×0.075 + 1k×0.25
// = $0.001 → two = $0.0020
assert.match(html, /data-testid="stats-cost">\$0\.0020</);
// model button label: SSR (record fetch not run yet) reads "—" —
// the strong slot arrives client-side via GET /api/settings; the
// turn log below still folds the ACTUAL models billed
assert.match(html, /data-testid="stats-model"[^>]*>\s*<span[^>]*>—<\/span>/);
});
it("shows — for cost when every turn is unpriceable, never a fake $0", () => {
const html = renderToStaticMarkup(
<StatsBar turns={[turn({ model: "nope/unknown" })]} />,
);
assert.match(html, /data-testid="stats-cost">—</);
// unknown model also kills the context limit
assert.match(html, /data-testid="stats-context">—</);
});
it("prettyModel strips vendor + date suffix, capitalizes", () => {
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "Glm 5.3 Flash");
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "Qwen3.8 Max");
});
it("provider-qualified stamps (usage contract, model-slots wave)", () => {
// qualified: the provider rides as a quiet suffix — federation makes
// the same model via two providers different bookkeeping
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "Glm 5.3 Flash · openrouter");
assert.equal(prettyModel("openai:gpt-5.2", true), "Gpt 5.2 · openai");
// unqualified on a qualified stamp: provider silently dropped (old shape)
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "Glm 5.3 Flash");
// qualified on a bare id: no suffix invented
assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "Glm 5.3 Flash");
});
});