Files
learnharness/tests/render-activity-dock.test.tsx
T
amosblomqvist df58bb87f2 The stats bar: context + cost beneath the composer, per-turn detail
One quiet row beneath the composer/question shell (not flush to the
screen bottom; static height, outside the morphing shell):

- LEFT: context window + session cost, clickable → per-turn detail
  popover (context, total, unpriced count, one row per turn with
  model/steps/cost). All folds from core/prices.ts — sessionCost,
  contextWindow, turnCost — no cost math in components.
- RIGHT: model + "Thinking —" — a placeholder slot for the coming
  model/thinking-level CONTROL (later work with its contract changes);
  display-only today, model read from the last turn's usage,
  pretty-printed (Glm 5.3 Flash).
- Data: the record's turns log, seeded on mount, refreshed when the
  local stream settles, kept fresh by the existing record re-syncs.
  No turns yet → "—" everywhere; unpriceable turns never fold into a
  fake $0 (the doctrine).
- formatUsd/formatTokens moved to components/usage-format.ts (shared
  with the activity dock); prettyModel joins them, pure + tested.
2026-09-11 03:15:55 +03:00

173 lines
6.1 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 { ActivityDock, formatElapsed, toBlobs, type ActivityFeed } from "../components/activity-dock.tsx";
import { formatTokens, formatUsd } from "../components/usage-format.ts";
// The dock's pure layer: folds, formatting, and blob order — then the SSR
// render (blobs, "—" doctrine, the abort affordance, the narrow badge).
const NOW = Date.parse("2026-09-09T12:00:00Z");
/** A full CallUsage (its fields are undefined-valued, not optional). */
function step(inputTokens: number, outputTokens: number) {
return { inputTokens, outputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined };
}
function job(over: Partial<ActivityFeed["jobs"][number]> = {}): ActivityFeed["jobs"][number] {
return {
id: "job-1",
kind: "research",
chatId: "c1",
toolCallId: "t1",
status: "running",
activity: 'web_search: "sigma.js v3"',
createdAt: new Date(NOW - 45_000).toISOString(),
updatedAt: new Date(NOW - 1000).toISOString(),
title: "Limits lesson",
...over,
};
}
describe("activity dock pure layer", () => {
it("merges jobs + extractions oldest-first (the stable blob order)", () => {
const feed: ActivityFeed = {
jobs: [job({ id: "new", createdAt: new Date(NOW - 10_000).toISOString() })],
extractions: [{ chatId: "cx", title: "Old lesson", phase: "auditing", startedAt: NOW - 100_000 }],
};
const blobs = toBlobs(feed);
assert.equal(blobs.length, 2);
const first = blobs[0]!;
assert.equal(first.id, "cx"); // extraction started first
assert.equal(first.process, "extraction");
assert.equal(first.phase, "auditing");
assert.equal(blobs[1]!.id, "new");
});
it("folds cost with jobCost; absent usage is null, never zero", () => {
const priced = job({
usage: {
model: "z-ai/glm-5.3-flash",
inputTokens: 10_000,
outputTokens: 1000,
cacheReadTokens: 5000,
cacheWriteTokens: 0,
reasoningTokens: 0,
steps: 2,
},
});
const blob = toBlobs({ jobs: [priced], extractions: [] })[0]!;
// (10000-5000)/1e6*0.15 + 5000/1e6*0.03 + 1000/1e6*0.5 = 0.0014
assert.ok(blob.cost !== null && blob.cost > 0.0013 && blob.cost < 0.0015);
const absent = toBlobs({ jobs: [job()], extractions: [] })[0]!;
assert.equal(absent.cost, null);
});
it("context line = LAST step's tokens vs the model limit, never a cumulative sum", () => {
const base = {
model: "z-ai/glm-5.3-flash",
inputTokens: 400_000, // running total (much bigger — must NOT feed the line)
outputTokens: 8_000,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
steps: 5,
};
const withLast = job({
usage: { ...base, lastStep: step(90_000, 2_000) },
});
const blob = toBlobs({ jobs: [withLast], extractions: [] })[0]!;
assert.equal(blob.context?.used, 92_000);
assert.equal(blob.context?.limit, 1310720);
// No lastStep yet (capture pending) → "—", even though usage exists.
const stripped = toBlobs({ jobs: [job({ usage: { ...base } })], extractions: [] })[0]!;
assert.equal(stripped.context, null);
// Unknown model → no limit → "—".
const unknownModel = toBlobs({
jobs: [job({ usage: { ...base, model: "nope/unknown", lastStep: step(5, 5) } })],
extractions: [],
})[0]!;
assert.equal(unknownModel.context, null);
});
it("extraction blobs carry no money/context (—, the doctrine)", () => {
const blob = toBlobs({
jobs: [],
extractions: [{ chatId: "cx", title: "T", phase: "extracting", startedAt: NOW }],
})[0]!;
assert.equal(blob.cost, null);
assert.equal(blob.context, null);
});
it("formatElapsed ticks compactly", () => {
assert.equal(formatElapsed(45_000), "45s");
assert.equal(formatElapsed(184_000), "3m 04s");
assert.equal(formatElapsed(4_320_000), "1h 12m");
});
it("formatUsd keeps four decimals under $1, two above", () => {
assert.equal(formatUsd(0.0014), "$0.0014");
assert.equal(formatUsd(1.5), "$1.50");
});
it("formatTokens trims (never 38.0k)", () => {
assert.equal(formatTokens(38_000), "38k");
assert.equal(formatTokens(1_234_567), "1.2M");
assert.equal(formatTokens(950), "950");
});
});
describe("activity dock render", () => {
it("renders NOTHING when idle (no jobs, no extractions)", () => {
assert.equal(renderToStaticMarkup(<ActivityDock feed={{ jobs: [], extractions: [] }} />), "");
});
it("renders one blob per running process with cost, elapsed, and the abort affordance", () => {
const html = renderToStaticMarkup(
<ActivityDock
feed={{
jobs: [job({ id: "j1", kind: "demo", activity: "building sandbox…", title: "Chain-rule demo" })],
extractions: [{ chatId: "cx", title: "Old lesson", phase: "merging", startedAt: NOW - 60_000 }],
}}
/>,
);
assert.match(html, /data-testid="activity-dock"/);
assert.match(html, /data-testid="activity-blob-job"/);
assert.match(html, /data-testid="activity-blob-extraction"/);
assert.match(html, /Chain-rule demo/);
assert.match(html, /building sandbox…/);
assert.match(html, /Extraction — merging/);
// Unpriced job → "—", never $0.0000 (doctrine); both blobs show it.
assert.match(html, /activity-blob-cost[^>]*>—</);
assert.match(html, /data-testid="activity-abort"/, "END on the blob face, popover-independent");
assert.match(html, /\bEnd\b/, "the explicit END button");
assert.match(html, /Background activity/);
// Total cost: nothing priced → "—" in the header.
assert.match(html, /activity-total-cost[^>]*>—</);
});
it("shows total running cost when jobs are priced", () => {
const usage = {
model: "z-ai/glm-5.3-flash",
inputTokens: 10_000,
outputTokens: 1000,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
steps: 1,
};
const html = renderToStaticMarkup(
<ActivityDock
feed={{
jobs: [
job({ id: "a", usage }),
job({ id: "b", usage, createdAt: new Date(NOW - 20_000).toISOString() }),
],
extractions: [],
}}
/>,
);
// two identical priced jobs → 2 × $0.002 = $0.0040 total
assert.match(html, /activity-total-cost[^>]*>\$0\.0040</);
});
});