summaryrefslogtreecommitdiff
path: root/web/test/dashboard.test.mjs
blob: 0f96c08e7e6a07380e95e722956d912165a4a7ec (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
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), []);
  });
});