import { describe, expect, it } from 'vitest'; import type { UsageRow } from './dashboard-usage-rows.js'; import { buildWebUsageRowsForSnapshot, formatStatusHeader, renderUsageTable, summarizeWatcherTasks, } from './unified-dashboard.js'; describe('summarizeWatcherTasks', () => { it('counts active and paused watcher tasks only', () => { const summary = summarizeWatcherTasks([ { prompt: '[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 1\n\nCheck instructions:\na', status: 'active', }, { prompt: '[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 2\n\nCheck instructions:\nb', status: 'paused', }, { prompt: '[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 3\n\nCheck instructions:\nc', status: 'completed', }, { prompt: 'normal scheduled task', status: 'active', }, ]); expect(summary).toEqual({ active: 1, paused: 1, }); }); }); describe('formatStatusHeader', () => { it('shows active watcher count in the dashboard header', () => { expect( formatStatusHeader({ totalActive: 3, totalRooms: 8, watchers: { active: 2, paused: 0 }, }), ).toBe('**πŸ“Š μ—μ΄μ „νŠΈ μƒνƒœ** β€” ν™œμ„± 3 / 8 | κ°μ‹œ 2'); }); it('adds paused watcher count only when present', () => { expect( formatStatusHeader({ totalActive: 3, totalRooms: 8, watchers: { active: 2, paused: 1 }, }), ).toBe('**πŸ“Š μ—μ΄μ „νŠΈ μƒνƒœ** β€” ν™œμ„± 3 / 8 | κ°μ‹œ 2 | μΌμ‹œμ •μ§€ 1'); }); }); describe('renderUsageTable', () => { const claudeRow: UsageRow = { name: 'Claude pro', h5pct: 50, h5reset: '', d7pct: 30, d7reset: '', }; const codexRow: UsageRow = { name: 'Codex', h5pct: 40, h5reset: '', d7pct: 25, d7reset: '', }; it('renders Claude before separator before Codex', () => { const lines = renderUsageTable([claudeRow], [codexRow]); const claudeIdx = lines.findIndex((l) => l.includes('Claude')); const sepIdx = lines.findIndex((l) => /^─+$/.test(l)); const codexIdx = lines.findIndex((l) => l.includes('Codex')); expect(claudeIdx).toBeGreaterThan(-1); expect(sepIdx).toBeGreaterThan(claudeIdx); expect(codexIdx).toBeGreaterThan(sepIdx); }); it('shows "Codex: 쑰회 λΆˆκ°€" when only Claude rows exist', () => { const lines = renderUsageTable([claudeRow], []); expect(lines.some((l) => l.includes('Claude'))).toBe(true); expect( lines.some((l) => l.includes('Codex') && l.includes('쑰회 λΆˆκ°€')), ).toBe(true); }); it('shows "Claude: 쑰회 λΆˆκ°€" when only Codex rows exist', () => { const lines = renderUsageTable([], [codexRow]); expect( lines.some((l) => l.includes('Claude') && l.includes('쑰회 λΆˆκ°€')), ).toBe(true); expect(lines.some((l) => l.includes('Codex'))).toBe(true); }); it('shows separate 쑰회 λΆˆκ°€ for both when both groups are empty', () => { const lines = renderUsageTable([], []); expect( lines.some((l) => l.includes('Claude') && l.includes('쑰회 λΆˆκ°€')), ).toBe(true); expect( lines.some((l) => l.includes('Codex') && l.includes('쑰회 λΆˆκ°€')), ).toBe(true); }); it('shows "Claude: μ‚¬μš©λŸ‰ μ—†μŒ" when all Claude rows are at >=99% in a window', () => { const exhaustedClaude: UsageRow = { name: 'Claude pro', h5pct: 99, h5reset: '', d7pct: 30, d7reset: '', }; const lines = renderUsageTable([exhaustedClaude], [codexRow]); expect( lines.some((l) => l.includes('Claude') && l.includes('μ‚¬μš©λŸ‰ μ—†μŒ')), ).toBe(true); // Codex side should still render normally expect(lines.some((l) => l.includes('Codex') && /\d+%/.test(l))).toBe(true); }); it('shows "Codex: μ‚¬μš©λŸ‰ μ—†μŒ" when all Codex rows are at 100% in either window', () => { const exhaustedCodex: UsageRow = { name: 'Codex', h5pct: 100, h5reset: '', d7pct: 50, d7reset: '', }; const lines = renderUsageTable([claudeRow], [exhaustedCodex]); expect( lines.some((l) => l.includes('Codex') && l.includes('μ‚¬μš©λŸ‰ μ—†μŒ')), ).toBe(true); }); it('treats <99% rows as having capacity remaining', () => { const almostExhausted: UsageRow = { name: 'Claude pro', h5pct: 98, h5reset: '', d7pct: 50, d7reset: '', }; const lines = renderUsageTable([almostExhausted], [codexRow]); // Should render the row as data, not the "μ‚¬μš©λŸ‰ μ—†μŒ" status line expect(lines.some((l) => l.includes('μ‚¬μš©λŸ‰ μ—†μŒ'))).toBe(false); }); it('treats source as exhausted only when ALL its rows are exhausted', () => { const exhausted: UsageRow = { name: 'Claude max', h5pct: 100, h5reset: '', d7pct: 100, d7reset: '', }; const fresh: UsageRow = { name: 'Claude pro', h5pct: 10, h5reset: '', d7pct: 5, d7reset: '', }; const lines = renderUsageTable([exhausted, fresh], [codexRow]); // Mixed exhausted/fresh β†’ render rows normally, no μ‚¬μš©λŸ‰ μ—†μŒ banner expect(lines.some((l) => l.includes('μ‚¬μš©λŸ‰ μ—†μŒ'))).toBe(false); expect(lines.some((l) => l.includes('Claude max'))).toBe(true); expect(lines.some((l) => l.includes('Claude pro'))).toBe(true); }); }); describe('buildWebUsageRowsForSnapshot', () => { it('keeps real Claude and Kimi rows ahead of Codex rows for web snapshots', () => { const rows = buildWebUsageRowsForSnapshot({ serviceAgentType: 'claude-code', claudeAccounts: [ { index: 0, masked: 'claude-1', isActive: true, isRateLimited: false, usage: { five_hour: { utilization: 0.2, resets_at: '2026-04-26T12:00:00.000Z', }, seven_day: { utilization: 0.4, resets_at: '2026-04-27T12:00:00.000Z', }, }, }, { index: 1, masked: 'claude-2', isActive: false, isRateLimited: false, usage: { five_hour: { utilization: 0.3, resets_at: '2026-04-26T12:00:00.000Z', }, seven_day: { utilization: 0.5, resets_at: '2026-04-27T12:00:00.000Z', }, }, }, ], kimiUsage: { fiveHour: { pct: 18, resetTime: '2026-04-26T12:00:00.000Z' }, weekly: { pct: 29, resetTime: '2026-04-27T12:00:00.000Z' }, }, codexRows: [ { name: 'Codex1', h5pct: 25, h5reset: '1h', d7pct: 35, d7reset: '2d', }, ], }); const names = rows.map((row) => row.name); expect(names[0]).toMatch(/^Claude1/); expect(names[1]).toMatch(/^Claude2/); expect(names[2]).toMatch(/^Kimi/); expect(names[3]).toBe('Codex1'); }); it('does not invent Claude or Kimi rows for codex-only services', () => { const rows = buildWebUsageRowsForSnapshot({ serviceAgentType: 'codex', claudeAccounts: [ { index: 0, masked: 'claude-1', isActive: true, isRateLimited: false, usage: null, }, ], kimiUsage: { fiveHour: { pct: 18, resetTime: '2026-04-26T12:00:00.000Z' }, weekly: { pct: 29, resetTime: '2026-04-27T12:00:00.000Z' }, }, codexRows: [ { name: 'Codex1', h5pct: 25, h5reset: '1h', d7pct: 35, d7reset: '2d', }, ], }); expect(rows.map((row) => row.name)).toEqual(['Codex1']); }); });