first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+292
View File
@@ -0,0 +1,292 @@
package tui
import (
"strings"
"testing"
"time"
"github.com/smallnest/pigo/internal/cli/ui"
)
// TestSubagentPanelLifecycle exercises the ordered add/update/remove set: rows
// keep insertion order, a progress refreshes activity/tokens, a progress for an
// unknown id adds a row (late/out-of-order safe), and remove drops exactly one
// row (a no-op for an absent id).
func TestSubagentPanelLifecycle(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", "task A", now)
p.add("b", "task B", now)
if got := p.active(); got != 2 {
t.Fatalf("active after two adds = %d, want 2", got)
}
if p.order[0] != "a" || p.order[1] != "b" {
t.Errorf("order = %v, want [a b]", p.order)
}
p.update("a", "task A", "Editing", 120, now)
if row := p.byID["a"]; row.activity != "Editing" || row.tokens != 120 {
t.Errorf("row a after update = %+v, want activity=Editing tokens=120", row)
}
// A progress for an id that never started adds the row (SPEC 5.4).
p.update("c", "task C", "Reading", 0, now)
if got := p.active(); got != 3 {
t.Fatalf("active after late progress = %d, want 3", got)
}
if p.order[2] != "c" {
t.Errorf("order = %v, want c appended last", p.order)
}
// Removing a middle row preserves the order of the rest.
p.remove("a")
if got := p.active(); got != 2 {
t.Fatalf("active after remove = %d, want 2", got)
}
if p.order[0] != "b" || p.order[1] != "c" {
t.Errorf("order after remove(a) = %v, want [b c]", p.order)
}
// Removing an absent id is a no-op.
p.remove("zzz")
if got := p.active(); got != 2 {
t.Errorf("active after remove(absent) = %d, want 2", got)
}
}
// TestSubagentPanelEmptyView verifies an empty panel renders nothing — zero
// lines, zero height — so the single-run layout is untouched.
func TestSubagentPanelEmptyView(t *testing.T) {
var p subagentPanel
if got := p.view(DefaultTheme(), 80, time.Now()); got != "" {
t.Errorf("empty panel view = %q, want empty", got)
}
// A non-empty panel with a non-positive width also renders nothing.
p.add("a", "task", time.Now())
if got := p.view(DefaultTheme(), 0, time.Now()); got != "" {
t.Errorf("zero-width view = %q, want empty", got)
}
}
// TestSubagentPanelViewLines verifies one line per active sub-agent, each
// carrying the description, activity, and token stat.
func TestSubagentPanelViewLines(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", "build parser", now)
p.update("a", "build parser", "Editing", 1200, now)
p.add("b", "run tests", now)
p.update("b", "run tests", "Running bash", 0, now)
view := p.view(DefaultTheme(), 200, now.Add(65*time.Second))
lines := strings.Split(view, "\n")
if len(lines) != 2 {
t.Fatalf("view has %d lines, want 2: %q", len(lines), view)
}
if !strings.Contains(lines[0], "build parser") || !strings.Contains(lines[0], "Editing") {
t.Errorf("line[0] = %q, want desc + activity", lines[0])
}
if !strings.Contains(lines[0], "1m 5s") {
t.Errorf("line[0] = %q, want elapsed 1m 5s", lines[0])
}
if !strings.Contains(lines[0], "1,200") {
t.Errorf("line[0] = %q, want token stat 1,200", lines[0])
}
if !strings.Contains(lines[1], "run tests") || !strings.Contains(lines[1], "Running bash") {
t.Errorf("line[1] = %q, want desc + activity", lines[1])
}
// A zero token estimate omits the ↓ stat.
if strings.Contains(lines[1], "↓") {
t.Errorf("line[1] = %q, should omit ↓ for zero tokens", lines[1])
}
}
// TestSubagentPanelViewTruncation verifies each rendered line is clipped to the
// given terminal width (display columns), never exceeding it.
func TestSubagentPanelViewTruncation(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", strings.Repeat("very long description ", 10), now)
p.update("a", "", "Searching", 42, now)
const width = 30
view := p.view(DefaultTheme(), width, now)
for _, line := range strings.Split(view, "\n") {
if w := ui.Width(line); w > width {
t.Errorf("line width = %d, want <= %d: %q", w, width, line)
}
}
}
// TestSubagentPanelViewBlankDescription verifies a row with no description still
// renders (leading with the activity) rather than producing a dangling "·".
func TestSubagentPanelViewBlankDescription(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", "", now)
p.update("a", "", "Thinking", 0, now)
line := p.view(DefaultTheme(), 100, now)
if !strings.Contains(line, "Thinking") {
t.Errorf("view = %q, want activity", line)
}
if strings.Contains(line, " · Thinking") {
t.Errorf("view = %q, blank desc should not leave a leading ' · '", line)
}
}
// TestSubagentPanelSelection verifies the cursor navigation: a fresh panel has no
// selection, the first ↓ lands on the top row and the first ↑ on the bottom row,
// movement clamps at both ends, and clearSelection resets to no-cursor state.
func TestSubagentPanelSelection(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", "task A", now)
p.add("b", "task B", now)
p.add("c", "task C", now)
if p.hasSelection() {
t.Fatal("fresh panel should have no selection")
}
// First ↓ selects the top row; further ↓ advance and clamp at the bottom.
p.selectDown()
if !p.hasSelection() || p.selected != 0 {
t.Fatalf("after first down: hasSelection=%v selected=%d, want true/0", p.hasSelection(), p.selected)
}
p.selectDown()
p.selectDown()
p.selectDown() // clamp
if p.selected != 2 {
t.Errorf("selected after clamp down = %d, want 2", p.selected)
}
// ↑ retreats and clamps at the top.
p.selectUp()
if p.selected != 1 {
t.Errorf("selected after up = %d, want 1", p.selected)
}
p.selectUp()
p.selectUp() // clamp
if p.selected != 0 {
t.Errorf("selected after clamp up = %d, want 0", p.selected)
}
p.clearSelection()
if p.hasSelection() {
t.Error("clearSelection should drop the cursor")
}
// From no selection, the first ↑ lands on the bottom row.
p.selectUp()
if !p.hasSelection() || p.selected != 2 {
t.Errorf("first up from none: selected=%d, want 2", p.selected)
}
}
// TestSubagentPanelExpandView verifies that expanding a selected row appends its
// accumulated output below the status line, that the cursor marker is present,
// that lineCount matches the rendered height, and that collapsing removes the
// extra lines.
func TestSubagentPanelExpandView(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", "task A", now)
p.add("b", "task B", now)
p.appendOutput("b", "hello from B\nsecond line")
p.selectDown() // selects "a"
p.selectDown() // selects "b"
if id := p.expandedID(); id != "" {
t.Errorf("expandedID before toggle = %q, want empty", id)
}
p.toggleExpand()
if id := p.expandedID(); id != "b" {
t.Errorf("expandedID after toggle = %q, want b", id)
}
const width = 80
view := p.view(DefaultTheme(), width, now)
lines := strings.Split(view, "\n")
if got := p.lineCount(width); got != len(lines) {
t.Errorf("lineCount = %d, rendered %d lines", got, len(lines))
}
// Two status rows + two output lines.
if len(lines) != 4 {
t.Fatalf("expanded view has %d lines, want 4: %q", len(lines), view)
}
if !strings.Contains(view, "") {
t.Errorf("expanded view missing selection cursor: %q", view)
}
if !strings.Contains(view, "hello from B") || !strings.Contains(view, "second line") {
t.Errorf("expanded view missing output: %q", view)
}
// Collapsing reclaims the output lines.
p.toggleExpand()
if got := p.lineCount(width); got != 2 {
t.Errorf("lineCount after collapse = %d, want 2", got)
}
}
// TestSubagentPanelExpandTruncates verifies the inline output is wrapped and
// tail-capped: every rendered line fits the width and no more than
// maxExpandedLines output lines are shown.
func TestSubagentPanelExpandTruncates(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", "task A", now)
// Many short lines exceed the cap; one very long line must wrap.
p.appendOutput("a", strings.Repeat("x\n", 40))
p.appendOutput("a", strings.Repeat("y", 500))
p.selectDown()
p.toggleExpand()
const width = 40
view := p.view(DefaultTheme(), width, now)
lines := strings.Split(view, "\n")
for _, line := range lines {
if w := ui.Width(line); w > width {
t.Errorf("line width = %d, want <= %d: %q", w, width, line)
}
}
// 1 status row + at most maxExpandedLines output lines.
if len(lines) > 1+maxExpandedLines {
t.Errorf("expanded view has %d lines, want <= %d", len(lines), 1+maxExpandedLines)
}
}
// TestSubagentPanelRemoveClampsSelection verifies that removing rows keeps the
// selection valid: removing the selected bottom row clamps the cursor to the new
// last row, and removing the final row clears the selection entirely.
func TestSubagentPanelRemoveClampsSelection(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", "task A", now)
p.add("b", "task B", now)
p.selectDown()
p.selectDown() // selects "b" (index 1)
p.remove("b")
if !p.hasSelection() || p.selected != 0 {
t.Errorf("after remove(b): hasSelection=%v selected=%d, want true/0", p.hasSelection(), p.selected)
}
p.remove("a")
if p.hasSelection() {
t.Error("removing the last row should clear the selection")
}
if got := p.lineCount(80); got != 0 {
t.Errorf("empty panel lineCount = %d, want 0", got)
}
}
// TestSubagentPanelAppendOutputUnknown verifies deltas for an unknown id are
// dropped (no phantom row, no panic).
func TestSubagentPanelAppendOutputUnknown(t *testing.T) {
var p subagentPanel
p.appendOutput("ghost", "data") // must not panic or add a row
if p.active() != 0 {
t.Errorf("active after appendOutput to unknown id = %d, want 0", p.active())
}
}