iop/apps/node/internal/adapters/cli/status/parser_test.go
toki fefc198dfe refactor: antigravity CLI status 통합 및 문서 업데이트
- antigravity 기반 CLI status adapter 구현 (gemini 대체)
- milestone 파일명 통일 (接두어 제거)
- agent-ops roadmap/current.md 갱신
- edge/node README 및 설정 문서 업데이트
- e2e smoke 스크립트 개선
2026-05-21 21:39:09 +09:00

499 lines
20 KiB
Go

package status
import (
"testing"
)
func TestParseStatusOutput_CodexLimits(t *testing.T) {
rawOutput := "5h limit: [████████████████████] 98% left (resets 18:38)\n" +
"Weekly limit: [████░░░░░░░░░░░░░░░░] 22% left (resets 10:20 on 8 May)"
status, err := ParseStatusOutput(rawOutput)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
if status.DailyLimit != "98%" {
t.Errorf("expected daily limit 98%%, got %q", status.DailyLimit)
}
if status.DailyResetTime != "18:38" {
t.Errorf("expected daily reset 18:38, got %q", status.DailyResetTime)
}
if status.WeeklyLimit != "22%" {
t.Errorf("expected weekly limit 22%%, got %q", status.WeeklyLimit)
}
if status.WeeklyResetTime != "10:20 on 8 May" {
t.Errorf("expected weekly reset 10:20 on 8 May, got %q", status.WeeklyResetTime)
}
}
func TestParseStatusOutput_ClaudeUsage(t *testing.T) {
rawOutput := "Current session: [███░░░░░░░░░░░░░░░░░] 2% used\nResets 10am (Asia/Seoul)\n\n" +
"Current week (all models): [#░░░░░░░░░░░░░░░░░░░░] 0% used\nResets May 16, 6pm (Asia/Seoul)"
status, err := ParseStatusOutput(rawOutput)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
if status.DailyLimit != "98%" {
t.Errorf("expected daily limit 98%%, got %q", status.DailyLimit)
}
if status.DailyResetTime != "10am (Asia/Seoul)" {
t.Errorf("expected daily reset 10am (Asia/Seoul), got %q", status.DailyResetTime)
}
if status.WeeklyLimit != "100%" {
t.Errorf("expected weekly limit 100%%, got %q", status.WeeklyLimit)
}
if status.WeeklyResetTime != "May 16, 6pm (Asia/Seoul)" {
t.Errorf("expected weekly reset May 16, 6pm (Asia/Seoul), got %q", status.WeeklyResetTime)
}
if status.Metadata["daily_label"] != "Current session" {
t.Errorf("expected daily_label Current session, got %q", status.Metadata["daily_label"])
}
if status.Metadata["weekly_label"] != "Current week" {
t.Errorf("expected weekly_label Current week, got %q", status.Metadata["weekly_label"])
}
}
func TestParseStatusOutput_ClaudeStatusPanel(t *testing.T) {
rawOutput := "ClaudeCode v2.1.144\n\n" +
"Version: 2.1.144\n" +
"SessionID: e26e764d-dd46-4c7e-9509-5a9b964ce993\n" +
"cwd: /config/workspace/iop\n" +
"Loginmethod: Claude Pro account\n" +
"Model: opus (claude-opus-4-7)\n"
status, err := ParseStatusOutput(rawOutput)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
if status.Metadata["status_kind"] != "claude" {
t.Fatalf("status_kind: got %q", status.Metadata["status_kind"])
}
if status.Metadata["claude_status_version"] != "2.1.144" {
t.Errorf("version: got %q", status.Metadata["claude_status_version"])
}
if status.Metadata["claude_status_login_method"] != "Claude Pro account" {
t.Errorf("login_method: got %q", status.Metadata["claude_status_login_method"])
}
if status.Metadata["claude_status_model"] != "opus (claude-opus-4-7)" {
t.Errorf("model: got %q", status.Metadata["claude_status_model"])
}
}
func TestParseStatusOutput_ClaudeUsageWithANSICursorSpacing(t *testing.T) {
rawOutput := "Current\x1b[1C session: [███░░░░░░░░░░░░░░░░░] 2%\x1b[1C used\nResets 10am (Asia/Seoul)\n\n" +
"Current week (all models): [#░░░░░░░░░░░░░░░░░░░░] 0% used\nResets May 16, 6pm (Asia/Seoul)"
status, err := ParseStatusOutput(rawOutput)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
if status.DailyLimit != "98%" {
t.Errorf("expected daily limit 98%%, got %q", status.DailyLimit)
}
if status.DailyResetTime != "10am (Asia/Seoul)" {
t.Errorf("expected daily reset 10am (Asia/Seoul), got %q", status.DailyResetTime)
}
if status.WeeklyLimit != "100%" {
t.Errorf("expected weekly limit 100%%, got %q", status.WeeklyLimit)
}
if status.WeeklyResetTime != "May 16, 6pm (Asia/Seoul)" {
t.Errorf("expected weekly reset, got %q", status.WeeklyResetTime)
}
}
func TestParseStatusOutput_ClaudeUsageWithCarriageReturns(t *testing.T) {
rawOutput := "Current session: [███░░░░░░░░░░░░░░░░░] 2% used\rResets 10am (Asia/Seoul)\r\n\n" +
"Current week (all models): [#░░░░░░░░░░░░░░░░░░░░] 0% used\rResets May 16, 6pm (Asia/Seoul)\r"
status, err := ParseStatusOutput(rawOutput)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
if status.DailyLimit != "98%" {
t.Errorf("expected daily limit 98%%, got %q", status.DailyLimit)
}
if status.DailyResetTime != "10am (Asia/Seoul)" {
t.Errorf("expected daily reset 10am (Asia/Seoul), got %q", status.DailyResetTime)
}
if status.WeeklyLimit != "100%" {
t.Errorf("expected weekly limit 100%%, got %q", status.WeeklyLimit)
}
if status.WeeklyResetTime != "May 16, 6pm (Asia/Seoul)" {
t.Errorf("expected weekly reset May 16, 6pm (Asia/Seoul), got %q", status.WeeklyResetTime)
}
}
func TestParseStatusOutput_ClaudeUsageFromRepaintedScreen(t *testing.T) {
// Mirrors the actual Claude /usage panel raw stream: an initial loading
// repaint ("Scanning local sessions...", "Esc to cancel") followed by a
// clear-screen sequence and the real data block. The parser must still
// extract Current session / Current week from the combined stream.
rawOutput := "\x1b[2J\x1b[H" +
" Usage\n" +
" Scanning local sessions...\n" +
" Refreshing...\n" +
" Esc to cancel\n" +
"\x1b[2J\x1b[H" +
" Settings Status Config Usage Stats\n\n" +
" Session\n\n" +
" Current session\n" +
" \x1b[48;5;102m\x1b[38;5;153m## \x1b[49m\x1b[39m 4% used\n" +
" Resets 10am (Asia/Seoul)\n\n" +
" Current week (all models)\n" +
" 0% used\n" +
" Resets May 16, 6pm (Asia/Seoul)\n\n" +
" Esc to cancel\n"
status, err := ParseStatusOutput(rawOutput)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
if status.DailyLimit != "96%" {
t.Errorf("expected daily limit 96%%, got %q", status.DailyLimit)
}
if status.DailyResetTime != "10am (Asia/Seoul)" {
t.Errorf("expected daily reset 10am (Asia/Seoul), got %q", status.DailyResetTime)
}
if status.WeeklyLimit != "100%" {
t.Errorf("expected weekly limit 100%%, got %q", status.WeeklyLimit)
}
if status.WeeklyResetTime != "May 16, 6pm (Asia/Seoul)" {
t.Errorf("expected weekly reset May 16, 6pm (Asia/Seoul), got %q", status.WeeklyResetTime)
}
if status.Metadata["daily_label"] != "Current session" {
t.Errorf("expected daily_label Current session, got %q", status.Metadata["daily_label"])
}
if status.Metadata["weekly_label"] != "Current week" {
t.Errorf("expected weekly_label Current week, got %q", status.Metadata["weekly_label"])
}
}
func TestParseStatusOutput_ClaudeUsageCurrentRealState(t *testing.T) {
// Reproduces the exact failure state observed in the previous review run:
// the /usage panel is fully open with the trailing "Last 24h", "d to day · w
// to week", "Extra usage", "Esc to cancel" sections rendered, AND the raw
// stream interleaves the week's "% used / Resets" payload before the
// session's payload (this happens when the TUI renders the bars via cursor
// positioning rather than sequentially). The previous lazy-regex parser
// paired the session header with the week's reset time on this input —
// the new section-aware parser must keep them separated.
rawOutput := "" +
" Settings Status Config Usage Stats\n\n" +
" Session\n\n" +
" Current session\n" +
" Current week (all models)\n" +
" ############## 95% used\n" +
" Resets May 16, 6pm (Asia/Seoul)\n" +
" ### 7% used\n" +
" Resets 10am (Asia/Seoul)\n\n" +
" What's contributing to your limits usage?\n" +
" Last 24h · these are independent characteristics of your usage, not a breakdown\n" +
" Nothing over 10% in this period — try the other window.\n" +
" d to day · w to week\n\n" +
" Extra usage\n" +
" Extra usage not enabled · /extra-usage to enable\n\n" +
" Esc to cancel\n"
status, err := ParseStatusOutput(rawOutput)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
// First payload after "Current session" header (before "Current week") is
// the week's data because of the cursor-interleaved render order. The
// section-aware parser must NOT capture it for the session — it must leave
// the session payload empty since the session block has no payload of its
// own before the week header.
if status.DailyResetTime == "May 16, 6pm (Asia/Seoul)" {
t.Fatalf("session reset time leaked from week section: %q", status.DailyResetTime)
}
// The week section, starting at its header, gets the next available
// "% used / Resets" pair, which is the week's own.
if status.WeeklyLimit != "5%" {
t.Fatalf("WeeklyLimit: got %q want %q", status.WeeklyLimit, "5%")
}
if status.WeeklyResetTime != "May 16, 6pm (Asia/Seoul)" {
t.Fatalf("WeeklyResetTime: got %q want %q", status.WeeklyResetTime, "May 16, 6pm (Asia/Seoul)")
}
if status.Metadata["weekly_label"] != "Current week" {
t.Fatalf("weekly_label: got %q want %q", status.Metadata["weekly_label"], "Current week")
}
}
func TestParseStatusOutput_ClaudeUsageFromVisibleScreenRepaint(t *testing.T) {
// Reproduces the cursor-addressed repaint that caused the G08 review run-2
// failure: the panel writes a loading state ("Last 24h", "Esc to cancel"),
// then uses CSI cursor positioning to overwrite specific rows with the
// session/week payload. In the raw append stream the corrupted loading
// text remains interleaved with the payload runes ("No hing ov r",
// "21% of y ur usage..."), so a line-oriented cleanANSI parser cannot
// reassemble a coherent "Current session ... X% used\nResets Y". A
// terminal screen model must apply the cursor moves and erases to recover
// the actually-visible text.
//
// Sequence:
// 1. Clear screen, write garbage loading lines on rows 5..10.
// 2. CUP back to row 5 col 1, EL (erase line), write "Current session".
// 3. Same for row 6 with the bar + "4% used".
// 4. Same for row 7 with "Resets 10am (Asia/Seoul)".
// 5. Same for rows 9..11 with the week block.
const ESC = "\x1b"
// First write a "Current session" header and a misleading early payload
// ("21% of your usage Resets garbage time") that — if left intact in the
// stream — would be picked up by a section-aware regex as the session's
// payload. Then cursor back, erase the misleading lines, and write the
// real " 4% used / Resets 10am" payload at those same rows. The same
// pattern is applied for the week block.
raw := ESC + "[2J" + ESC + "[H" +
ESC + "[5;1H" + " Current session" +
ESC + "[6;1H" + " 21% of your usage in last 24h" +
ESC + "[7;1H" + " Resets garbage time" +
ESC + "[8;1H" + "" +
ESC + "[9;1H" + " Current week (all models)" +
ESC + "[10;1H" + " 47% of week things here" +
ESC + "[11;1H" + " Resets garbage week time" +
// Repaint rows 6,7 with real session payload.
ESC + "[6;1H" + ESC + "[2K" + " #### 4% used" +
ESC + "[7;1H" + ESC + "[2K" + " Resets 10am (Asia/Seoul)" +
// Repaint rows 10,11 with real week payload.
ESC + "[10;1H" + ESC + "[2K" + " 0% used" +
ESC + "[11;1H" + ESC + "[2K" + " Resets May 16, 6pm (Asia/Seoul)" +
// Trailing panel chrome that timeout tails would otherwise reveal.
ESC + "[13;1H" + " Last 24h" +
ESC + "[14;1H" + " d to day · w to week" +
ESC + "[15;1H" + " Extra usage" +
ESC + "[16;1H" + " Esc to cancel"
// Sanity check: the line-oriented cleanANSI alone must NOT be able to find
// a coherent payload — corrupt text leaks through and a section-aware
// regex on raw-clean ends up with empty or wrong fields. We assert that
// directly so the regression catches anyone reverting to a line-only
// parser.
lineOnly := &UsageStatus{}
lineOnly.parseClaudeUsage(cleanANSI(raw))
if lineOnly.DailyResetTime == "10am (Asia/Seoul)" && lineOnly.WeeklyResetTime == "May 16, 6pm (Asia/Seoul)" {
t.Fatalf("line-only parser must fail on cursor-repainted raw; got daily=%q weekly=%q", lineOnly.DailyResetTime, lineOnly.WeeklyResetTime)
}
status, err := ParseStatusOutput(raw)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
if status.DailyLimit != "96%" {
t.Errorf("DailyLimit: got %q want 96%%", status.DailyLimit)
}
if status.DailyResetTime != "10am (Asia/Seoul)" {
t.Errorf("DailyResetTime: got %q want %q", status.DailyResetTime, "10am (Asia/Seoul)")
}
if status.WeeklyLimit != "100%" {
t.Errorf("WeeklyLimit: got %q want 100%%", status.WeeklyLimit)
}
if status.WeeklyResetTime != "May 16, 6pm (Asia/Seoul)" {
t.Errorf("WeeklyResetTime: got %q want %q", status.WeeklyResetTime, "May 16, 6pm (Asia/Seoul)")
}
if status.Metadata["daily_label"] != "Current session" {
t.Errorf("daily_label: got %q want %q", status.Metadata["daily_label"], "Current session")
}
if status.Metadata["weekly_label"] != "Current week" {
t.Errorf("weekly_label: got %q want %q", status.Metadata["weekly_label"], "Current week")
}
}
func TestRenderVisibleScreenTreatsHangulAsWide(t *testing.T) {
const ESC = "\x1b"
got := RenderVisibleScreen("한글"+ESC+"[2DX", 1, 20)
want := "한X\n"
if got != want {
t.Fatalf("RenderVisibleScreen() = %q, want %q", got, want)
}
}
func TestRenderVisibleScreenClearsWideContinuation(t *testing.T) {
const ESC = "\x1b"
got := RenderVisibleScreen("한글"+ESC+"[2D"+ESC+"[K", 1, 20)
want := "한\n"
if got != want {
t.Fatalf("RenderVisibleScreen() = %q, want %q", got, want)
}
}
func TestParseStatusOutput_AntigravityStatsQuota(t *testing.T) {
rawOutput := "Auto (Antigravity) Stats For Nerds\n" +
"0% used (Limit resets in 24h)\n" +
"Usage limit: 200\n" +
"Usage limits span all sessions and reset daily.\n"
status, err := ParseStatusOutput(rawOutput)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
if status.DailyLimit != "100%" {
t.Errorf("DailyLimit: got %q want %q", status.DailyLimit, "100%")
}
if status.DailyResetTime != "24h" {
t.Errorf("DailyResetTime: got %q want %q", status.DailyResetTime, "24h")
}
if status.WeeklyLimit != "" {
t.Errorf("WeeklyLimit should be empty, got %q", status.WeeklyLimit)
}
if status.Metadata["daily_label"] != "Daily quota" {
t.Errorf("daily_label: got %q want %q", status.Metadata["daily_label"], "Daily quota")
}
if status.Metadata["used_percent"] != "0%" {
t.Errorf("used_percent: got %q want %q", status.Metadata["used_percent"], "0%")
}
if status.Metadata["usage_limit"] != "200" {
t.Errorf("usage_limit: got %q want %q", status.Metadata["usage_limit"], "200")
}
}
func TestParseStatusOutput_AntigravityLimitReached(t *testing.T) {
rawOutput := "Auto (Antigravity) Stats For Nerds\n" +
"Limit reached, resets in 5h\n" +
"Usage limit: 200\n"
status, err := ParseStatusOutput(rawOutput)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
if status.DailyLimit != "0%" {
t.Errorf("DailyLimit: got %q want %q", status.DailyLimit, "0%")
}
if status.DailyResetTime != "5h" {
t.Errorf("DailyResetTime: got %q want %q", status.DailyResetTime, "5h")
}
if status.Metadata["used_percent"] != "100%" {
t.Errorf("used_percent: got %q want %q", status.Metadata["used_percent"], "100%")
}
if status.Metadata["usage_limit"] != "200" {
t.Errorf("usage_limit: got %q want %q", status.Metadata["usage_limit"], "200")
}
}
func TestParseStatusOutput_AntigravityScreenReaderQuotaWithoutReset(t *testing.T) {
rawOutput := "workspace (/directory)\n" +
"~/workspace/iop branch\n" +
"main sandbox\n" +
"no sandbox /model\n" +
"Auto (Antigravity) quota\n" +
"0% used\n" +
"No API calls have been made in this session.\n"
status, err := ParseStatusOutput(rawOutput)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
if status.DailyLimit != "100%" {
t.Errorf("DailyLimit: got %q want %q", status.DailyLimit, "100%")
}
if status.DailyResetTime != "" {
t.Errorf("DailyResetTime should be empty, got %q", status.DailyResetTime)
}
if status.Metadata["daily_label"] != "Daily quota" {
t.Errorf("daily_label: got %q want %q", status.Metadata["daily_label"], "Daily quota")
}
if status.Metadata["used_percent"] != "0%" {
t.Errorf("used_percent: got %q want %q", status.Metadata["used_percent"], "0%")
}
if status.Metadata["usage_limit"] != "" {
t.Errorf("usage_limit should be empty, got %q", status.Metadata["usage_limit"])
}
}
func TestParseStatusOutput_AntigravityModelUsage(t *testing.T) {
rawOutput := "Select Model\n" +
"(checked) 1. Auto (Antigravity)\n" +
"Model usage\n" +
"Flash ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 0% Resets: 2:15 PM (24h)\n" +
"Flash Lite ▬ ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 1% Resets: 12:48 PM (22h 34m)\n" +
"\n" +
"Pro ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 2% Resets: 2:15 PM (24h)\n" +
"agy-3.1-flash-lite ▬ ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 3% Resets: 12:48 PM\n" +
" (22h 34m)\n" +
"(Press Esc to close)\n"
status, err := ParseStatusOutput(rawOutput)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
if status.Metadata["model_usage_count"] != "4" {
t.Fatalf("model_usage_count: got %q want 4", status.Metadata["model_usage_count"])
}
tests := []struct {
idx string
name string
used string
reset string
}{
{"0", "Flash", "0%", "2:15 PM (24h)"},
{"1", "Flash Lite", "1%", "12:48 PM (22h 34m)"},
{"2", "Pro", "2%", "2:15 PM (24h)"},
{"3", "agy-3.1-flash-lite", "3%", "12:48 PM (22h 34m)"},
}
for _, tt := range tests {
prefix := "model_usage_" + tt.idx
if status.Metadata[prefix+"_name"] != tt.name {
t.Errorf("%s_name: got %q want %q", prefix, status.Metadata[prefix+"_name"], tt.name)
}
if status.Metadata[prefix+"_used_percent"] != tt.used {
t.Errorf("%s_used_percent: got %q want %q", prefix, status.Metadata[prefix+"_used_percent"], tt.used)
}
if status.Metadata[prefix+"_reset"] != tt.reset {
t.Errorf("%s_reset: got %q want %q", prefix, status.Metadata[prefix+"_reset"], tt.reset)
}
}
}
func TestParseStatusOutput_AntigravityNoAPICallsLeavesRawOnly(t *testing.T) {
rawOutput := "Auto (Antigravity) Stats For Nerds\n" +
"No API calls have been made in this session.\n"
status, err := ParseStatusOutput(rawOutput)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
if status.DailyLimit != "" {
t.Errorf("DailyLimit should be empty, got %q", status.DailyLimit)
}
if status.WeeklyLimit != "" {
t.Errorf("WeeklyLimit should be empty, got %q", status.WeeklyLimit)
}
if status.RawOutput == "" {
t.Error("RawOutput should not be empty")
}
}
func TestParseStatusOutput_ClaudeUsageWithTerminalControls(t *testing.T) {
rawOutput := "Current \x1b[1C \x1b[1Csession:\x1b[10C [#\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\xb8\x1b[5C\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80] \x1b[1C2%\x1b[1C used\rResets \x1b[1C10am (Asia/Seoul)\r\n\n" +
"Current week (all models): [#\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\xb8\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80\xe2\x96\x80] 0% used\rResets May 16, 6pm (Asia/Seoul)"
status, err := ParseStatusOutput(rawOutput)
if err != nil {
t.Fatalf("ParseStatusOutput failed: %v", err)
}
if status.DailyLimit != "98%" {
t.Errorf("expected daily limit 98%%, got %q", status.DailyLimit)
}
if status.DailyResetTime != "10am (Asia/Seoul)" {
t.Errorf("expected daily reset 10am (Asia/Seoul), got %q", status.DailyResetTime)
}
if status.WeeklyLimit != "100%" {
t.Errorf("expected weekly limit 100%%, got %q", status.WeeklyLimit)
}
if status.WeeklyResetTime != "May 16, 6pm (Asia/Seoul)" {
t.Errorf("expected weekly reset May 16, 6pm (Asia/Seoul), got %q", status.WeeklyResetTime)
}
if status.Metadata["daily_label"] != "Current session" {
t.Errorf("expected daily_label Current session, got %q", status.Metadata["daily_label"])
}
if status.Metadata["weekly_label"] != "Current week" {
t.Errorf("expected weekly_label Current week, got %q", status.Metadata["weekly_label"])
}
}