Files
fn_registry/functions/tui/parse_claude_tui_test.go
T
2026-06-04 23:44:39 +02:00

215 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}