280 lines
7.7 KiB
TypeScript
280 lines
7.7 KiB
TypeScript
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']);
|
|
});
|
|
});
|