729921e16e
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
215 lines
8.8 KiB
Go
215 lines
8.8 KiB
Go
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
|
||
}
|