|
| 1 | +package tui |
| 2 | + |
| 3 | +import ( |
| 4 | + "strings" |
| 5 | + "testing" |
| 6 | + |
| 7 | + "github.com/BackendStack21/bodek/internal/client" |
| 8 | +) |
| 9 | + |
| 10 | +func TestPadRight(t *testing.T) { |
| 11 | + if padRight("ab", 4) != "ab " { |
| 12 | + t.Errorf("padRight short: %q", padRight("ab", 4)) |
| 13 | + } |
| 14 | + if padRight("abcd", 2) != "abcd" { |
| 15 | + t.Errorf("padRight overflow: %q", padRight("abcd", 2)) |
| 16 | + } |
| 17 | +} |
| 18 | + |
| 19 | +func TestIsSubagent(t *testing.T) { |
| 20 | + for _, n := range []string{"task", "delegate_task", "Subagent", "spawn_subagent"} { |
| 21 | + if !isSubagent(n) { |
| 22 | + t.Errorf("isSubagent(%q) = false, want true", n) |
| 23 | + } |
| 24 | + } |
| 25 | + for _, n := range []string{"shell", "read_file", "grep"} { |
| 26 | + if isSubagent(n) { |
| 27 | + t.Errorf("isSubagent(%q) = true, want false", n) |
| 28 | + } |
| 29 | + } |
| 30 | +} |
| 31 | + |
| 32 | +func TestLooksLikeError(t *testing.T) { |
| 33 | + errs := []string{ |
| 34 | + "Error: boom", "fatal: not a git repo", "panic: nil deref", |
| 35 | + "Traceback (most recent call last):", "exit status 1", |
| 36 | + "bash: foo: command not found", "open x: no such file or directory", |
| 37 | + } |
| 38 | + for _, s := range errs { |
| 39 | + if !looksLikeError(s) { |
| 40 | + t.Errorf("looksLikeError(%q) = false, want true", s) |
| 41 | + } |
| 42 | + } |
| 43 | + oks := []string{"", "ok", "found 3 matches mentioning error", "PASS"} |
| 44 | + for _, s := range oks { |
| 45 | + if looksLikeError(s) { |
| 46 | + t.Errorf("looksLikeError(%q) = true, want false", s) |
| 47 | + } |
| 48 | + } |
| 49 | +} |
| 50 | + |
| 51 | +func TestResultExcerpt(t *testing.T) { |
| 52 | + // Blank lines are dropped; short output is returned whole. |
| 53 | + got := resultExcerpt("a\n\n \nb") |
| 54 | + if len(got) != 2 || got[0] != "a" || got[1] != "b" { |
| 55 | + t.Errorf("resultExcerpt blanks: %#v", got) |
| 56 | + } |
| 57 | + // Long output is capped with a "+N more lines" footer. |
| 58 | + var lines []string |
| 59 | + for i := 0; i < 9; i++ { |
| 60 | + lines = append(lines, "line") |
| 61 | + } |
| 62 | + got = resultExcerpt(strings.Join(lines, "\n")) |
| 63 | + if len(got) != 6 { // 5 + footer |
| 64 | + t.Fatalf("resultExcerpt cap: %#v", got) |
| 65 | + } |
| 66 | + if !strings.Contains(got[5], "+4 more lines") { |
| 67 | + t.Errorf("missing overflow footer: %q", got[5]) |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +func TestResultPreviewCapsAndSanitizes(t *testing.T) { |
| 72 | + var b strings.Builder |
| 73 | + for i := 0; i < 300; i++ { |
| 74 | + b.WriteString("x\n") |
| 75 | + } |
| 76 | + out := resultPreview(b.String() + "tail\x1b[2J") |
| 77 | + if strings.ContainsRune(out, '\x1b') { |
| 78 | + t.Error("resultPreview left an escape byte") |
| 79 | + } |
| 80 | + if n := strings.Count(out, "\n"); n > 200 { |
| 81 | + t.Errorf("resultPreview did not cap lines: %d", n) |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +// TestSubagentLogNesting verifies a subagent_log lands under the in-flight |
| 86 | +// sub-agent step when one exists, and falls back to a notice otherwise. |
| 87 | +func TestSubagentLogNesting(t *testing.T) { |
| 88 | + m := newTestModel() |
| 89 | + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) |
| 90 | + m.curIdx = 0 |
| 91 | + m.busy = true |
| 92 | + |
| 93 | + // A non-sub-agent tool: the log has nowhere to nest → notice. |
| 94 | + m.handleEvent(client.Event{Type: "tool_call", Name: "shell", Data: `{"command":"ls"}`}) |
| 95 | + m.handleEvent(client.Event{Type: "subagent_log", SubType: "started", Name: "explorer"}) |
| 96 | + if got := strings.Join(m.notices, "\n"); !strings.Contains(got, "subagent · started explorer") { |
| 97 | + t.Errorf("expected fallback notice, notices=%q", got) |
| 98 | + } |
| 99 | + |
| 100 | + // A sub-agent tool: subsequent logs nest under its step. |
| 101 | + m.handleEvent(client.Event{Type: "tool_call", Name: "delegate_task", Data: `{"task":"explore the repo"}`}) |
| 102 | + m.handleEvent(client.Event{Type: "subagent_log", SubType: "tool_call", Name: "read", Detail: "main.go"}) |
| 103 | + step := m.msgs[0].steps[len(m.msgs[0].steps)-1] |
| 104 | + if !step.subagent { |
| 105 | + t.Fatal("delegate step not flagged as sub-agent") |
| 106 | + } |
| 107 | + if len(step.logs) != 1 || !strings.Contains(step.logs[0], "read") { |
| 108 | + t.Errorf("sub-agent log not nested: %#v", step.logs) |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +// TestRenderStepsSubagentAndError exercises the enriched step rendering: a |
| 113 | +// sub-agent label, a nested log tree, and an error-tinted result. |
| 114 | +func TestRenderStepsSubagentAndError(t *testing.T) { |
| 115 | + m := newTestModel() |
| 116 | + msg := message{ |
| 117 | + role: roleAsst, |
| 118 | + streaming: false, |
| 119 | + steps: []step{ |
| 120 | + {name: "delegate_task", arg: "explore", subagent: true, done: true, |
| 121 | + logs: []string{"started explorer"}, result: "done"}, |
| 122 | + {name: "shell", arg: "go test", done: true, isErr: true, |
| 123 | + result: "exit status 1\nFAIL"}, |
| 124 | + }, |
| 125 | + } |
| 126 | + out := plain(m.renderSteps(msg)) |
| 127 | + for _, want := range []string{"sub-agent", "⎿", "explorer", "✗", "exit status 1"} { |
| 128 | + if !strings.Contains(out, want) { |
| 129 | + t.Errorf("renderSteps missing %q in:\n%s", want, out) |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + // Streaming turn: a not-done step renders the live spinner; a not-done step |
| 134 | + // in a finalized turn renders the pending glyph. Also drive the narrow-width |
| 135 | + // budget floor. |
| 136 | + m.vp.Width = 8 |
| 137 | + if s := m.renderSteps(message{streaming: true, steps: []step{{name: "read", arg: "x"}}}); s == "" { |
| 138 | + t.Error("streaming step rendered empty") |
| 139 | + } |
| 140 | + if s := m.renderSteps(message{streaming: false, steps: []step{{name: "read"}}}); !strings.Contains(plain(s), "▸") { |
| 141 | + t.Errorf("pending step missing ▸ glyph: %q", plain(s)) |
| 142 | + } |
| 143 | +} |
0 commit comments