Files
EJClaw/src/unified-dashboard.test.ts
2026-05-27 10:53:31 +09:00

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']);
});
});