summaryrefslogtreecommitdiff
path: root/web/test/dashboard.test.mjs
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-04 08:50:46 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-04 08:50:46 +0000
commitf8ae821240f33d615a9e91cdfeb6c026b7970782 (patch)
tree458ce067db47a92248f163c26ce5fcf71299f3b0 /web/test/dashboard.test.mjs
parentd105eca610b0e737c3313e4978d6a917b4f55d10 (diff)
feat(web,api): add Budget/Roles dashboard -- final phase of the harness redesign (Phase 9b)
Two new tabs, matching Phase 9a's conventions: Budget (per-provider spend meters, escalation funnel, spend-over-time) and Roles (version history, draft activation, readable escalation-ladder view) -- the human-facing surface for the token-husbanding value proposition and the Phase 5/8 versioned role-config system. New backend (minimal, additive, matching existing endpoint conventions -- unauthenticated like /api/budget and /api/roles/*): - internal/storage/dashboard.go: QueryEscalationFunnel (executions grouped by escalation_rung + agent, count + cost) and QuerySpendTimeseries (cost per provider bucketed hourly/daily, mirroring QueryDashboardStats' existing bucketing expressions). Documented honestly: rung 0 is not exclusively "resolved locally" -- escalation_rung defaults to 0 uniformly, so non-role-typed executions (which never climb a ladder) land there too, alongside role-typed tasks genuinely resolved at tier 0. A role-only variant would need a join against tasks.config_json with no queryable role column on executions -- intentionally out of scope. - internal/api/dashboard.go: GET /api/escalation-funnel, GET /api/spend-timeseries (both ?window=5h|24h|7d|<duration>, default 24h). - internal/storage.ListRoleNames + GET /api/roles: the "which roles exist" gap -- there was no way to discover role names before this, only to list versions for a role you already knew the name of. Chart-form decisions (dataviz skill, invoked before writing chart code): horizontal stacked bar for the escalation funnel (rung order already encodes the funnel shape positionally; color only needed for per-provider segments within each rung); multi-line for spend-over-time; a fixed-order categorical palette from the skill's validated palette.md slots for provider identity (re-validated against this app's dark surface, passing); a separate status (good/warning/critical) palette for budget meters, deliberately distinct from both --state-* and the provider palette. Caught and fixed a real bug during visual QA: converging near-zero end-labels on the spend chart were overlapping (an anti-pattern the skill explicitly flags) -- fixed with a 14px minimum-gap check before direct-labeling an endpoint, leaning on the legend/tooltip otherwise. Verified with a real running server, real seeded data (65 executions across rungs 0-2 with a realistic provider mix, 2 roles with active/draft/retired role_configs versions written directly via internal/storage), and a real headless-browser session (reusing Phase 9a's Chromium/proxy scaffinding): confirmed correct rung totals/percentages, provider legends, a 3-line spend chart, live window-selector re-render, correct active-version highlighting on the role panel, and a real Activate click on a draft version -- verified via both DOM re-render and a direct backend GET that it truly persisted. go build/vet/test -race -count=1 all pass, full suite. node --test web/test/*.mjs: 291/291 passing (16 new). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'web/test/dashboard.test.mjs')
-rw-r--r--web/test/dashboard.test.mjs154
1 files changed, 154 insertions, 0 deletions
diff --git a/web/test/dashboard.test.mjs b/web/test/dashboard.test.mjs
new file mode 100644
index 0000000..0f96c08
--- /dev/null
+++ b/web/test/dashboard.test.mjs
@@ -0,0 +1,154 @@
+// dashboard.test.mjs — Unit tests for the Phase 9b budget/escalation
+// dashboard's pure logic: reshaping the raw escalation-funnel and
+// spend-timeseries aggregate rows, provider color/label lookup, and
+// escalation-ladder formatting for the role/config panel. Mirrors
+// stories-board.test.mjs's convention of testing pure computation only —
+// DOM-building render functions are verified against a real running server
+// (see Phase 9b's manual verification notes), not unit-tested here.
+//
+// Run with: node --test web/test/dashboard.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ computeEscalationFunnel,
+ computeSpendSeries,
+ formatBucketLabel,
+ colorForProvider,
+ providerLabel,
+ formatEscalationLadder,
+} from '../app.js';
+
+describe('computeEscalationFunnel', () => {
+ it('groups by rung, summing counts/cost and collecting per-agent breakdown', () => {
+ const buckets = [
+ { rung: 0, agent: 'local', count: 5, cost_usd: 0 },
+ { rung: 0, agent: 'anthropic', count: 1, cost_usd: 0.02 },
+ { rung: 1, agent: 'anthropic', count: 2, cost_usd: 0.10 },
+ ];
+ const { rungs, grandTotal } = computeEscalationFunnel(buckets);
+ assert.equal(grandTotal, 8);
+ assert.equal(rungs.length, 2);
+ assert.equal(rungs[0].rung, 0);
+ assert.equal(rungs[0].total, 6);
+ assert.equal(rungs[0].byAgent.length, 2);
+ assert.equal(rungs[1].rung, 1);
+ assert.equal(rungs[1].total, 2);
+ assert.equal(rungs[1].cost, 0.10);
+ });
+
+ it('sorts rungs ascending regardless of input order', () => {
+ const buckets = [
+ { rung: 2, agent: 'openai', count: 1, cost_usd: 0.5 },
+ { rung: 0, agent: 'local', count: 3, cost_usd: 0 },
+ { rung: 1, agent: 'google', count: 2, cost_usd: 0.1 },
+ ];
+ const { rungs } = computeEscalationFunnel(buckets);
+ assert.deepEqual(rungs.map(r => r.rung), [0, 1, 2]);
+ });
+
+ it('returns an empty rungs array and zero grand total for no data', () => {
+ const { rungs, grandTotal } = computeEscalationFunnel([]);
+ assert.deepEqual(rungs, []);
+ assert.equal(grandTotal, 0);
+ });
+
+ it('handles a missing/null buckets argument gracefully', () => {
+ const { rungs, grandTotal } = computeEscalationFunnel(undefined);
+ assert.deepEqual(rungs, []);
+ assert.equal(grandTotal, 0);
+ });
+});
+
+describe('computeSpendSeries', () => {
+ it('aligns per-provider series over a shared sorted bucket axis, filling gaps with 0', () => {
+ const points = [
+ { bucket: '2026-07-01', agent: 'local', cost_usd: 1.0 },
+ { bucket: '2026-07-02', agent: 'local', cost_usd: 2.0 },
+ { bucket: '2026-07-02', agent: 'anthropic', cost_usd: 0.5 },
+ ];
+ const { buckets, agents, series, maxCost } = computeSpendSeries(points);
+ assert.deepEqual(buckets, ['2026-07-01', '2026-07-02']);
+ assert.deepEqual(agents, ['anthropic', 'local']);
+ assert.deepEqual(series.local, [1.0, 2.0]);
+ assert.deepEqual(series.anthropic, [0, 0.5]);
+ assert.equal(maxCost, 2.0);
+ });
+
+ it('returns empty structures for no data', () => {
+ const { buckets, agents, series, maxCost } = computeSpendSeries([]);
+ assert.deepEqual(buckets, []);
+ assert.deepEqual(agents, []);
+ assert.deepEqual(series, {});
+ assert.equal(maxCost, 0);
+ });
+});
+
+describe('formatBucketLabel', () => {
+ it('formats an hourly RFC3339 bucket with the hour', () => {
+ const label = formatBucketLabel('2026-07-02T14:00:00Z');
+ assert.match(label, /\d/); // exact format is locale-dependent; just confirm it's non-empty and date-like
+ });
+
+ it('formats a daily YYYY-MM-DD bucket', () => {
+ const label = formatBucketLabel('2026-07-02');
+ assert.match(label, /\d/);
+ });
+
+ it('returns empty string for falsy input', () => {
+ assert.equal(formatBucketLabel(''), '');
+ assert.equal(formatBucketLabel(null), '');
+ });
+});
+
+describe('colorForProvider / providerLabel', () => {
+ it('returns a distinct hex color for each known provider', () => {
+ const providers = ['local', 'anthropic', 'google', 'groq', 'openrouter', 'openai'];
+ const colors = new Set(providers.map(colorForProvider));
+ assert.equal(colors.size, providers.length, 'expected all known providers to have distinct colors');
+ for (const c of colors) assert.match(c, /^#[0-9a-f]{6}$/i);
+ });
+
+ it('falls back to a muted color for an unknown provider', () => {
+ assert.match(colorForProvider('some-future-provider'), /^#[0-9a-f]{6}$/i);
+ });
+
+ it('capitalizes provider labels', () => {
+ assert.equal(providerLabel('anthropic'), 'Anthropic');
+ assert.equal(providerLabel('local'), 'Local');
+ });
+
+ it('labels a missing/empty provider as Unknown', () => {
+ assert.equal(providerLabel(''), 'Unknown');
+ assert.equal(providerLabel(undefined), 'Unknown');
+ });
+});
+
+describe('formatEscalationLadder', () => {
+ it('formats each tier with index, mode, retry budget, and candidate strings', () => {
+ const ladder = [
+ { candidates: [{ provider: 'local', model: 'llama3.1:8b' }], selection_mode: 'single', max_retries: 2 },
+ { candidates: [{ provider: 'anthropic', model: 'claude-haiku' }, { provider: 'google', model: 'gemini-flash' }], max_retries: 0 },
+ ];
+ const rows = formatEscalationLadder(ladder);
+ assert.equal(rows.length, 2);
+ assert.equal(rows[0].tier, 0);
+ assert.equal(rows[0].mode, 'single');
+ assert.equal(rows[0].maxRetries, 2);
+ assert.deepEqual(rows[0].candidates, ['local/llama3.1:8b']);
+
+ assert.equal(rows[1].tier, 1);
+ assert.equal(rows[1].mode, 'round_robin'); // default when unset
+ assert.deepEqual(rows[1].candidates, ['anthropic/claude-haiku', 'google/gemini-flash']);
+ });
+
+ it('formats a provider-only candidate (no model) without a trailing slash', () => {
+ const rows = formatEscalationLadder([{ candidates: [{ provider: 'local' }], max_retries: 0 }]);
+ assert.deepEqual(rows[0].candidates, ['local']);
+ });
+
+ it('returns an empty array for an empty/missing ladder', () => {
+ assert.deepEqual(formatEscalationLadder([]), []);
+ assert.deepEqual(formatEscalationLadder(undefined), []);
+ });
+});