package tui import ( "testing" ) // goldenScreen is the exact sample screen from the spec. const goldenScreen = `╭─── Claude Code v2.1.161 ─────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ Tips for getting started │ │ Welcome back Enmanuel! │ Run /init to create a CLAUDE.md file with instructions for Cla… │ │ │ ─────────────────────────────────────────────────────────────── │ │ ▐▛███▜▌ │ What's new │ │ ▝▜█████▛▘ │ ` + "`OTEL_RESOURCE_ATTRIBUTES`" + ` values are now included as labels o… │ │ ▘▘ ▝▝ │ ` + "`claude agents`" + ` rows now show ` + "`done/total`" + ` before the detail w… │ │ Opus 4.8 (1M context) with xh… · Claude Max · │ ` + "`/mcp`" + ` now collapses claude.ai connectors you've never signed … │ │ gutierenmanuel15@gmail.com's Organization │ /release-notes for more │ │ ~/fn_registry │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ❯ responde unicamente con la palabra PONG, sin explicaciones ● PONG ✻ Crunched for 2s ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Opus 4.8 (1M context) │ CTX: █░░░░░░░░░ 11% (107k/1.0M) │ IN:6k OUT:5 (cache:17k) │ ⎇ master [~4 ?28 ↑1] │ 22:26 $0.565 │ +0/-0 │ Total: ↓107k/↑5 │ Limits: 5h:6% →02:40 │ 7d:11% →Sun 17:00 │ ⏱ 7s │ ~/fn_registry ← for agents` func TestParseClaudeTUI(t *testing.T) { t.Run("golden screen — banner + status bar + single Q&A", func(t *testing.T) { got := ParseClaudeTUI(goldenScreen) if got.Answer != "PONG" { t.Errorf("Answer = %q, want %q", got.Answer, "PONG") } if len(got.Turns) != 2 { t.Errorf("len(Turns) = %d, want 2", len(got.Turns)) for i, turn := range got.Turns { t.Logf(" Turns[%d]: role=%s text=%q", i, turn.Role, turn.Text) } return } if got.Turns[0].Role != ClaudeTurnUser { t.Errorf("Turns[0].Role = %q, want %q", got.Turns[0].Role, ClaudeTurnUser) } wantUserText := "responde unicamente con la palabra PONG, sin explicaciones" if got.Turns[0].Text != wantUserText { t.Errorf("Turns[0].Text = %q, want %q", got.Turns[0].Text, wantUserText) } if got.Turns[1].Role != ClaudeTurnAssistant { t.Errorf("Turns[1].Role = %q, want %q", got.Turns[1].Role, ClaudeTurnAssistant) } if got.Turns[1].Text != "PONG" { t.Errorf("Turns[1].Text = %q, want %q", got.Turns[1].Text, "PONG") } }) t.Run("multiline assistant response", func(t *testing.T) { screen := `❯ explica brevemente ● linea uno linea dos` got := ParseClaudeTUI(screen) if len(got.Turns) != 2 { t.Fatalf("len(Turns) = %d, want 2; turns: %+v", len(got.Turns), got.Turns) } wantText := "linea uno\nlinea dos" if got.Turns[1].Text != wantText { t.Errorf("Turns[1].Text = %q, want %q", got.Turns[1].Text, wantText) } if !contains(got.Answer, "linea uno") || !contains(got.Answer, "linea dos") { t.Errorf("Answer %q should contain both continuation lines", got.Answer) } }) t.Run("tool_use + tool_result + final assistant text", func(t *testing.T) { screen := `❯ pregunta ● Read(main.go) ⎿ Read 50 lines ● aqui esta el resumen` got := ParseClaudeTUI(screen) if len(got.Turns) != 4 { t.Fatalf("len(Turns) = %d, want 4; turns: %+v", len(got.Turns), got.Turns) } if got.Turns[0].Role != ClaudeTurnUser { t.Errorf("Turns[0].Role = %q", got.Turns[0].Role) } if got.Turns[1].Role != ClaudeTurnToolUse { t.Errorf("Turns[1].Role = %q, want tool_use", got.Turns[1].Role) } if got.Turns[1].ToolName != "Read" { t.Errorf("Turns[1].ToolName = %q, want Read", got.Turns[1].ToolName) } if got.Turns[2].Role != ClaudeTurnToolResult { t.Errorf("Turns[2].Role = %q, want tool_result", got.Turns[2].Role) } if got.Turns[3].Role != ClaudeTurnAssistant { t.Errorf("Turns[3].Role = %q, want assistant", got.Turns[3].Role) } // Answer must be ONLY the assistant text, not the tool_use. if got.Answer != "aqui esta el resumen" { t.Errorf("Answer = %q, want %q", got.Answer, "aqui esta el resumen") } }) t.Run("multi-turn — answer from last user only", func(t *testing.T) { screen := `❯ primera pregunta ● primera respuesta ❯ segunda pregunta ● segunda respuesta` got := ParseClaudeTUI(screen) if len(got.Turns) != 4 { t.Fatalf("len(Turns) = %d, want 4; turns: %+v", len(got.Turns), got.Turns) } if got.Answer != "segunda respuesta" { t.Errorf("Answer = %q, want %q", got.Answer, "segunda respuesta") } }) t.Run("no banner no status bar — minimal screen", func(t *testing.T) { screen := "❯ hola\n\n● mundo" got := ParseClaudeTUI(screen) if len(got.Turns) != 2 { t.Fatalf("len(Turns) = %d, want 2; turns: %+v", len(got.Turns), got.Turns) } if got.Answer != "mundo" { t.Errorf("Answer = %q, want %q", got.Answer, "mundo") } }) t.Run("determinism — same input produces same output", func(t *testing.T) { first := ParseClaudeTUI(goldenScreen) second := ParseClaudeTUI(goldenScreen) if first.Answer != second.Answer { t.Errorf("non-deterministic: %q != %q", first.Answer, second.Answer) } if len(first.Turns) != len(second.Turns) { t.Errorf("non-deterministic turns count: %d != %d", len(first.Turns), len(second.Turns)) } }) } // TestParseClaudeTUI_Spinner verifies that the generation spinner — which shows a // DIFFERENT random gerund word on every frame ("Whatchamacalliting", "Forging", // "Puzzling", "Crunched"...) — is never folded into the answer, regardless of the // word, the glyph, or whether the "(Ns · tokens)" suffix is present yet. func TestParseClaudeTUI_Spinner(t *testing.T) { cases := []struct { name string screen string want string }{ { name: "spinner with tokens suffix glued after answer", screen: "❯ di PONG\n\n● PONG\n\n✽ Whatchamacalliting… (2s · ↓ 1 tokens · esc to interrupt)\n", want: "PONG", }, { name: "spinner early frame, no suffix yet, different word", screen: "❯ di HOLA\n\n● HOLA\n\n✶ Puzzling…\n", want: "HOLA", }, { name: "classic crunched line", screen: "❯ x\n\n● respuesta\n\n✻ Crunched for 4s\n", want: "respuesta", }, { name: "spinner BEFORE the answer block (mid-generation snapshot)", screen: "❯ pregunta\n\n✽ Forging… (1s · ↑ 3 tokens · esc to interrupt)\n\n● respuesta parcial\n", want: "respuesta parcial", }, { name: "assistant line ending in ellipsis is NOT treated as spinner", screen: "❯ x\n\n● la historia continua…\n", want: "la historia continua…", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := ParseClaudeTUI(tc.screen) if got.Answer != tc.want { t.Errorf("Answer = %q, want %q", got.Answer, tc.want) } }) } } func contains(s, sub string) bool { return len(sub) == 0 || (len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))) } func containsStr(s, sub string) bool { for i := 0; i <= len(s)-len(sub); i++ { if s[i:i+len(sub)] == sub { return true } } return false }