// 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), []); }); });