Files
learnharness/tests/render-activity-dock.test.tsx
T
amosblomqvist b19e7e7eae Production tooltips: styled tooltips everywhere, shortcuts surfaced, dev-speak culled
Owner-approved full pass (tooltip wave, 2026-09-25). Mechanism: icon/pill
controls ride the styled Radix tooltip via a new Hint composition
(components/ui/hint.tsx) — the shared primitive adopted as-is, no
restyling; the shortcut appends as an em-dash suffix off the LIVE registry
(a rebind in Settings → Keyboard changes the suffix): "Dictate — ⌘D".
Surface-owned keys (Esc closes, Enter submits) ride a raw binding prop.
Native title= stays only for truncation-reveals (dock spans, the rename
field) and menu-interior abbreviations (MenuShell untouched). One
em-dash pattern; per-state truth on the send square (busy = "Stop the
reply — Esc", idle = "Send — ↵"), Enter on the plan verdict's accept,
"Stop dictation (Esc)" kept verbatim per the owner's flag.

Copy: the dev-speak cull — no pipeline/prober/tier/wave vocabulary
anywhere a user reads. New pins in tests/tooltip-mechanism.test.tsx
(hintText composition, asChild no-wrapper-DOM, the auto-update hint
constant); dock truncation titles pinned native; graph-chrome + settings
pins updated to the new copy.

Live-verified on :3001 — 12.6ms hover appearance (no native 1s lag);
glyphs render on every suffixed surface (⌘D, ⌘K, ⌘⇧F, ⌘,, ⌘B, F2, ↵,
Esc); per-page probes confirm Settings entry, Home logo, session search,
Start session, Close sidebar, model + thinking pills, stats summary,
rename title, plan chip, coverage ring, send square, cost button, node
search (⌘F), graph gear, settings close (Close — Esc), keyboard section
"Fixed — part of this screen", auto-update hint, light-mode "Coming
soon", the three slot rosters, graph-tools hint. Esc still closes the
settings dialog.
2026-09-25 01:52:20 +03:00

238 lines
8.8 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 { ActivitySection, formatElapsed, toBlobs, type ActivityFeed } from "../components/activity-dock.tsx";
import { formatTokens, formatUsd } from "../components/usage-format.ts";
// The section's pure layer: folds, formatting, and blob order — then the SSR
// render (blobs, "—" doctrine, the abort affordance, the always-present
// header — the sidebar's background-agents strip).
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",
contextLimit: null,
...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: "openrouter: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.075 + 5000/1e6*0.015 + 1000/1e6*0.25 = 0.0007
// (pi-ai catalog prices)
assert.ok(blob.cost !== null && blob.cost > 0.0006 && blob.cost < 0.0008);
const absent = toBlobs({ jobs: [job()], extractions: [] })[0]!;
assert.equal(absent.cost, null);
});
it("a key-refused job reads as info — 'needs a provider key' (zero-keys wave, 66dbea9)", () => {
const refused = job({
status: "error",
activity: "failed",
// the runner composes the envelope's error verbatim (invariant tail)
error: "No OpenRouter key is set — add one in Settings → Keys",
});
const blob = toBlobs({ jobs: [refused], extractions: [] })[0]!;
assert.equal(blob.activity, "needs a provider key");
});
it("a models-refused job reads as info — 'needs model setup', not 'failed' (BYOK, 2026-09-22)", () => {
const refused = job({
status: "error",
activity: "failed",
error: "No model configured — set up your models first (missing: vision)",
});
const blob = toBlobs({ jobs: [refused], extractions: [] })[0]!;
assert.equal(blob.activity, "needs model setup");
// A real failure keeps the runner's honest wording.
const crashed = job({ status: "error", activity: "failed", error: "FAILED: headless crashed" });
const real = toBlobs({ jobs: [crashed], extractions: [] })[0]!;
assert.equal(real.activity, "failed");
});
it("context line = LAST step's tokens vs the model limit, never a cumulative sum", () => {
const base = {
model: "openrouter: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) },
// The amendment (owner 2026-09-23): the limit is the server's
// live-catalog join riding the feed — the dock never resolves it.
contextLimit: 131_072,
});
const blob = toBlobs({ jobs: [withLast], extractions: [] })[0]!;
assert.equal(blob.context?.used, 92_000);
assert.equal(blob.context?.limit, 131_072);
// No lastStep yet (capture pending) → "—", even though usage exists.
const stripped = toBlobs({ jobs: [job({ usage: { ...base } })], extractions: [] })[0]!;
assert.equal(stripped.context, null);
// Model missing from the live catalog (historical session,
// package update) → the feed carries null → no context line.
const uncatalogued = toBlobs({
jobs: [job({ usage: { ...base, model: "nope/unknown", lastStep: step(5, 5), } })],
extractions: [],
})[0]!;
assert.equal(uncatalogued.context, null);
// The numerator is the same cache-inclusive fold the session meter
// uses (context-meter audit, owner 2026-09-23): provider "input"
// EXCLUDES cacheRead/cacheWrite, so input+output alone reads wildly
// low on cache-heavy jobs — 10k+0.5k+120k+30k = 160.5k, not 10.5k.
const cached = toBlobs({
jobs: [
job({
usage: {
...base,
lastStep: {
inputTokens: 10_000,
outputTokens: 500,
cacheReadTokens: 120_000,
cacheWriteTokens: 30_000,
reasoningTokens: undefined,
},
},
contextLimit: 200_000,
}),
],
extractions: [],
})[0]!;
assert.equal(cached.context?.used, 160_500);
assert.equal(cached.context?.limit, 200_000);
});
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 section render", () => {
it("renders the ALWAYS-PRESENT header when idle — empty means empty, nothing hides", () => {
const html = renderToStaticMarkup(<ActivitySection feed={{ jobs: [], extractions: [] }} />);
assert.match(html, /data-testid="activity-section"/);
assert.match(html, /Background agents/);
assert.doesNotMatch(html, /activity-blob-(job|extraction)/, "no rows while idle");
assert.doesNotMatch(html, /activity-total-cost/, "the cost span is population, not decoration");
});
it("renders one blob per running process with cost, elapsed, and the abort affordance", () => {
const html = renderToStaticMarkup(
<ActivitySection
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-section"/);
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 agents/);
// Total cost: nothing priced → "—" in the header.
assert.match(html, /activity-total-cost[^>]*>—</);
// Truncation-reveals KEEP native title (tooltip wave mechanism rule):
// the truncated activity + title spans carry title= — a styled
// tooltip adds nothing over an ellipsis reveal.
assert.match(html, /title="building sandbox…"/);
});
it("shows total running cost when jobs are priced", () => {
const usage = {
model: "openrouter:z-ai/glm-5.3-flash",
inputTokens: 10_000,
outputTokens: 1000,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
steps: 1,
};
const html = renderToStaticMarkup(
<ActivitySection
feed={{
jobs: [
job({ id: "a", usage }),
job({ id: "b", usage, createdAt: new Date(NOW - 20_000).toISOString() }),
],
extractions: [],
}}
/>,
);
// two identical priced jobs → 2 × $0.001 = $0.0020 total (pi prices)
assert.match(html, /activity-total-cost[^>]*>\$0\.0020</);
});
});