165 lines
5.0 KiB
Go
165 lines
5.0 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
)
|
|
|
|
// ctrlKey builds a Ctrl+<letter> key press matching String()=="ctrl+<letter>".
|
|
func ctrlKey(r rune) tea.KeyPressMsg {
|
|
return tea.KeyPressMsg{Code: r, Mod: tea.ModCtrl}
|
|
}
|
|
|
|
// TestParseToolResult verifies depth inference from leading spaces and trailing
|
|
// blank-line trimming.
|
|
func TestParseToolResult(t *testing.T) {
|
|
nodes := parseToolResult("root\n child\n grandchild\n\n")
|
|
if len(nodes) != 3 {
|
|
t.Fatalf("node count = %d, want 3 (trailing blank trimmed)", len(nodes))
|
|
}
|
|
want := []respNode{
|
|
{text: "root", depth: 0},
|
|
{text: "child", depth: 1},
|
|
{text: "grandchild", depth: 2},
|
|
}
|
|
for i, w := range want {
|
|
if nodes[i] != w {
|
|
t.Errorf("node[%d] = %+v, want %+v", i, nodes[i], w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestToolCardRender checks the header (name + status icon), the input section,
|
|
// and the response tree lines appear in the rendered card, and that the status
|
|
// icon reflects the state.
|
|
func TestToolCardRender(t *testing.T) {
|
|
theme := DefaultTheme()
|
|
cases := []struct {
|
|
name string
|
|
state cardState
|
|
icon string
|
|
}{
|
|
{"running", cardRunning, "…"},
|
|
{"success", cardSuccess, "✓"},
|
|
{"warn", cardWarn, "!"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
card := toolCard{
|
|
id: "1",
|
|
name: "read_file",
|
|
input: map[string]any{"path": "/tmp/x"},
|
|
response: parseToolResult("line one\n nested"),
|
|
state: tc.state,
|
|
}
|
|
out := card.render(theme, 60)
|
|
for _, want := range []string{"read_file", tc.icon, "Input arguments", "path: /tmp/x", "Response", "line one", "nested"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("render missing %q\n%s", want, out)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestToolCardExpandTruncation verifies the collapsed card caps the response and
|
|
// shows the Ctrl+O hint, while the expanded card reveals every line.
|
|
func TestToolCardExpandTruncation(t *testing.T) {
|
|
theme := DefaultTheme()
|
|
var b strings.Builder
|
|
for i := 0; i < collapsedResponseLines+3; i++ {
|
|
b.WriteString("resp-line-")
|
|
b.WriteByte(byte('a' + i))
|
|
b.WriteByte('\n')
|
|
}
|
|
card := toolCard{name: "grep", response: parseToolResult(b.String()), state: cardSuccess}
|
|
|
|
collapsed := card.render(theme, 60)
|
|
if !strings.Contains(collapsed, "(Ctrl+O for more)") {
|
|
t.Errorf("collapsed card should show Ctrl+O hint\n%s", collapsed)
|
|
}
|
|
lastLine := "resp-line-" + string(byte('a'+collapsedResponseLines+2))
|
|
if strings.Contains(collapsed, lastLine) {
|
|
t.Errorf("collapsed card should not show %q\n%s", lastLine, collapsed)
|
|
}
|
|
|
|
card.expanded = true
|
|
expanded := card.render(theme, 60)
|
|
if strings.Contains(expanded, "(Ctrl+O for more)") {
|
|
t.Errorf("expanded card should not show Ctrl+O hint\n%s", expanded)
|
|
}
|
|
if !strings.Contains(expanded, lastLine) {
|
|
t.Errorf("expanded card should show %q\n%s", lastLine, expanded)
|
|
}
|
|
}
|
|
|
|
// TestModelToolCardFlow drives the model through a tool start/end and asserts the
|
|
// card is created, transitions running→success, and that a failed tool yields
|
|
// warn.
|
|
func TestModelToolCardFlow(t *testing.T) {
|
|
m := NewModel(Options{})
|
|
next, _ := m.Update(toolStartMsg{id: "t1", name: "read_file", input: map[string]any{"path": "a.go"}})
|
|
mm := next.(Model)
|
|
card, ok := mm.toolCards["t1"]
|
|
if !ok {
|
|
t.Fatalf("toolStartMsg should create a card")
|
|
}
|
|
if card.state != cardRunning {
|
|
t.Errorf("new card state = %v, want cardRunning", card.state)
|
|
}
|
|
|
|
next, _ = mm.Update(toolEndMsg{id: "t1", ok: true, result: "done\n detail"})
|
|
mm = next.(Model)
|
|
if mm.toolCards["t1"].state != cardSuccess {
|
|
t.Errorf("state after ok end = %v, want cardSuccess", mm.toolCards["t1"].state)
|
|
}
|
|
if len(mm.toolCards["t1"].response) != 2 {
|
|
t.Errorf("response nodes = %d, want 2", len(mm.toolCards["t1"].response))
|
|
}
|
|
|
|
// A failed tool flips the same card to warn.
|
|
next, _ = m.Update(toolStartMsg{id: "t2", name: "bash"})
|
|
mm = next.(Model)
|
|
next, _ = mm.Update(toolEndMsg{id: "t2", ok: false, result: "boom"})
|
|
mm = next.(Model)
|
|
if mm.toolCards["t2"].state != cardWarn {
|
|
t.Errorf("state after failed end = %v, want cardWarn", mm.toolCards["t2"].state)
|
|
}
|
|
}
|
|
|
|
// TestModelCtrlOTogglesExpanded verifies Ctrl+O flips the most-recent card's
|
|
// expanded flag so more response lines become visible.
|
|
func TestModelCtrlOTogglesExpanded(t *testing.T) {
|
|
m := NewModel(Options{})
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 24})
|
|
mm := next.(Model)
|
|
|
|
var b strings.Builder
|
|
for i := 0; i < collapsedResponseLines+3; i++ {
|
|
b.WriteString("row")
|
|
b.WriteByte(byte('0' + i))
|
|
b.WriteByte('\n')
|
|
}
|
|
next, _ = mm.Update(toolStartMsg{id: "t1", name: "grep"})
|
|
mm = next.(Model)
|
|
next, _ = mm.Update(toolEndMsg{id: "t1", ok: true, result: b.String()})
|
|
mm = next.(Model)
|
|
|
|
if mm.lastToolCard.expanded {
|
|
t.Fatalf("card should start collapsed")
|
|
}
|
|
next, _ = mm.Update(ctrlKey('o'))
|
|
mm = next.(Model)
|
|
if !mm.lastToolCard.expanded {
|
|
t.Errorf("Ctrl+O should expand the most-recent card")
|
|
}
|
|
// Toggling again collapses it.
|
|
next, _ = mm.Update(ctrlKey('o'))
|
|
mm = next.(Model)
|
|
if mm.lastToolCard.expanded {
|
|
t.Errorf("second Ctrl+O should collapse the card")
|
|
}
|
|
}
|