package tui import ( "context" "flag" "fmt" "os" "path/filepath" "slices" "strconv" "strings" "testing" "time" "github.com/ajhahnde/eeco/internal/config" "github.com/ajhahnde/eeco/internal/hooks" "github.com/ajhahnde/eeco/internal/memory" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" ) // TestMain pins the user-global config dir to an empty temp dir so the // global config layer is a hermetic no-op and these tests never read the // dev box's ~/.config/eeco. func TestMain(m *testing.M) { gdir, err := os.MkdirTemp("", "eeco-global-") if err != nil { panic(err) } os.Setenv(config.GlobalConfigEnv, gdir) code := m.Run() os.RemoveAll(gdir) os.Exit(code) } // miniDotFrames lists the MiniDot spinner glyphs; a running footer renders // one of them and an idle footer none. const miniDotFrames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" // updateGolden refreshes the snapshot files under testdata/. Pass with // `go test ./internal/tui -run TestRenderHome -update` after a deliberate // home-view change; commit the regenerated goldens with the code. var updateGolden = flag.Bool("update", false, "rewrite golden files under testdata/") // --- helpers --- // repo returns a config for a fresh temp git repo. When init is true the // workspace is scaffolded so memory/queue/gc operations are available. func repo(t *testing.T, doInit bool) *config.Config { t.Helper() root := t.TempDir() if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { t.Fatal(err) } cfg, err := config.Load(root, config.DefaultWorkspace) if err != nil { t.Fatal(err) } if doInit { if _, err := config.Init(cfg); err != nil { t.Fatal(err) } } return cfg } func asModel(t *testing.T, tm tea.Model) model { t.Helper() m, ok := tm.(model) if !ok { t.Fatalf("expected tui.model, got %T", tm) } return m } // --- non-interactive guarantee --- func TestInteractive_TestEnvIsNonInteractive(t *testing.T) { // Under `go test` stdio is piped, so the control center must take // the digest path and never start an interactive loop (no hang). if interactive() { t.Fatal("interactive() true under test; would hang CI") } } func TestRun_NonTTYPrintsDigestExitsZero(t *testing.T) { cfg := repo(t, false) var out, errb strings.Builder code := Run(cfg, "9.9.9", &out, &errb) if code != 0 { t.Fatalf("Run exit %d, want 0", code) } if !strings.Contains(out.String(), "eeco 9.9.9") { t.Errorf("digest missing version:\n%s", out.String()) } if errb.Len() != 0 { t.Errorf("unexpected stderr: %q", errb.String()) } } // --- digest --- func TestOneScreen_Fields(t *testing.T) { cfg := repo(t, false) s := OneScreen(cfg, "1.2.3") for _, want := range []string{ "eeco 1.2.3", cfg.RepoRoot, "profile", "automation", "memory", "queue", "hooks", "missing — run `eeco init`", } { if !strings.Contains(s, want) { t.Errorf("OneScreen missing %q:\n%s", want, s) } } cfg = repo(t, true) if !strings.Contains(OneScreen(cfg, "x"), "(initialised)") { t.Error("initialised workspace not reflected") } } func TestOneScreen_DoctorHintFiresOnFreshInit(t *testing.T) { cfg := repo(t, true) s := OneScreen(cfg, "x") if !strings.Contains(s, "eeco doctor") { t.Errorf("expected doctor hint on fresh init, got:\n%s", s) } } func TestOneScreen_DoctorHintSuppressedOnceQueueHasItem(t *testing.T) { cfg := repo(t, true) // Plant a queue item to count as observable activity. stateDir := filepath.Join(cfg.Workspace, "state") if err := os.MkdirAll(stateDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile( filepath.Join(stateDir, "queue.md"), []byte("# eeco queue\n\n- [ ] **k** — t _(p, 2026-05-19)_\n"), 0o644, ); err != nil { t.Fatal(err) } s := OneScreen(cfg, "x") if strings.Contains(s, "eeco doctor") { t.Errorf("hint should be suppressed once activity exists:\n%s", s) } } func TestOneScreen_DoctorHintSuppressedOnceWorkflowScaffolded(t *testing.T) { cfg := repo(t, true) wfDir := filepath.Join(cfg.Workspace, "workflows", "demo") if err := os.MkdirAll(wfDir, 0o755); err != nil { t.Fatal(err) } s := OneScreen(cfg, "x") if strings.Contains(s, "eeco doctor") { t.Errorf("hint should be suppressed once a user workflow exists:\n%s", s) } } func TestOneScreen_DoctorHintAbsentBeforeInit(t *testing.T) { cfg := repo(t, false) s := OneScreen(cfg, "x") if strings.Contains(s, "eeco doctor") { t.Errorf("hint should not fire before init:\n%s", s) } } func TestBarLine_LiveCounts(t *testing.T) { cfg := repo(t, true) b := barLine(cfg, "0.0.0", "") for _, want := range []string{"mem:0", "q:0", "auto:propose"} { if !strings.Contains(b, want) { t.Errorf("barLine missing %q: %s", want, b) } } // Version is intentionally elided from the bar — the home block already // surfaces it once on session start. if strings.Contains(b, "eeco 0.0.0") { t.Errorf("barLine should not duplicate the version banner: %s", b) } // An empty lastRun is omitted entirely (no placeholder noise). if strings.Contains(b, "run:") { t.Errorf("empty run should be omitted, got: %s", b) } // A real lastRun surfaces as a labelled field. if b := barLine(cfg, "0.0.0", "leak-guard: ok (exit 0)"); !strings.Contains(b, "run:leak-guard") { t.Errorf("barLine missing run field: %s", b) } } // --- parsing & completion --- func TestParseInput(t *testing.T) { if p := parseInput(" "); p.name != "" || p.free != "" { t.Errorf("blank should be a no-op, got %+v", p) } p := parseInput("/run --ai comment-hygiene") if p.name != "run" || len(p.args) != 1 || p.args[0] != "comment-hygiene" || !p.ai { t.Errorf("parse /run --ai: %+v", p) } if p := parseInput("why is the gate failing"); p.free != "why is the gate failing" { t.Errorf("free text not captured: %+v", p) } if p := parseInput("/quit"); p.name != "quit" { t.Errorf("parse /quit: %+v", p) } } func TestComplete(t *testing.T) { // Ambiguous command prefix -> longest common prefix + candidates. got, cands := complete("/q", nil) if got != "/qu" || len(cands) != 2 { t.Errorf("/q completion: got %q cands %v", got, cands) } // Unique command prefix -> full command and a trailing space. if got, _ := complete("/he", nil); got != "/help " { t.Errorf("/he completion: %q", got) } // `/run` argument completes against the workflow names. got, _ = complete("/run comm", []string{"comment-hygiene", "leak-guard"}) if got != "/run comment-hygiene " { t.Errorf("/run arg completion: %q", got) } // Free text never completes. if got, c := complete("explain", nil); got != "explain" || c != nil { t.Errorf("free text should not complete: %q %v", got, c) } } // --- dispatch --- func TestDispatch_SyncCommands(t *testing.T) { cfg := repo(t, true) st := newStyles(false) disp := func(input string) dispatchResult { return dispatch(cfg, st, 80, parseInput(input)) } join := func(r dispatchResult) string { return strings.Join(r.lines, "\n") } if r := disp("/quit"); !r.quit { t.Error("/quit should set quit") } if r := disp("/help"); len(r.lines) == 0 || !strings.Contains(join(r), "/run") { t.Errorf("/help lines: %v", r.lines) } if r := disp("/hooks"); len(r.lines) == 0 || !strings.Contains(join(r), "pre-commit:") { t.Errorf("/hooks should report live state: %v", r.lines) } if r := disp("/hooks pre-commit bogus"); !strings.Contains(join(r), "usage") { t.Errorf("/hooks bad action usage: %v", r.lines) } if r := disp("/settings"); len(r.lines) == 0 || !strings.Contains(join(r), "automation") { t.Errorf("/settings view: %v", r.lines) } if r := disp("/settings automation nonsense"); !strings.Contains(join(r), "must be manual") { t.Errorf("/settings rejects bad automation: %v", r.lines) } if r := disp("/settings automation auto"); !strings.Contains(join(r), "set to") { t.Errorf("/settings set automation: %v", r.lines) } if r := disp("/run"); !strings.Contains(join(r), "usage") { t.Errorf("/run no-arg usage: %v", r.lines) } if r := disp("/bogus"); !strings.Contains(join(r), "unknown command") { t.Errorf("unknown command: %v", r.lines) } if r := disp("/run comment-hygiene"); r.async != "run" || r.asyncS != "comment-hygiene" { t.Errorf("/run should be async: %+v", r) } // Free-text chat is retired (C5): a non-slash line is handled // synchronously with a dim hint, never an async pass. if r := disp("ask the model"); r.async != "" || !strings.Contains(join(r), "free-text chat is retired") { t.Errorf("free text should render the sync retirement hint, not an async pass: %+v", r) } } // --- engine ops (read-only / workspace-only, no new write path) --- func TestOpQueueAndMemory_Empty(t *testing.T) { cfg := repo(t, true) st := newStyles(false) if got := opQueue(cfg, st, 80); !strings.Contains(strings.Join(got, "\n"), "empty") { t.Errorf("opQueue empty: %v", got) } if got := opMemory(cfg, st, 80); !strings.Contains(strings.Join(got, "\n"), "no facts") { t.Errorf("opMemory empty: %v", got) } } func TestOpMemory_MarksPinnedAndDisabled(t *testing.T) { cfg := repo(t, true) store, err := memory.Open(cfg) if err != nil { t.Fatal(err) } now := time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC) mk := func(name, desc string, typ memory.FactType, opts ...func(*memory.Fact)) { f := &memory.Fact{Name: name, Description: desc, Type: typ, Created: now, LastUsed: now} for _, o := range opts { o(f) } if err := store.Save(f); err != nil { t.Fatal(err) } } mk("plain-fact", "an active fact", memory.TypeProject) mk("muted-fact", "a disabled fact", memory.TypeFeedback, func(f *memory.Fact) { f.Disabled = true }) mk("pinned-muted", "pinned and disabled", memory.TypeProject, func(f *memory.Fact) { f.Pin = true f.Disabled = true }) st := newStyles(false) lines := opMemory(cfg, st, 80) got := strings.Join(lines, "\n") for _, want := range []string{"fact", "description", "type"} { if !strings.Contains(got, want) { t.Errorf("opMemory header missing %q:\n%s", want, got) } } findLine := func(needle string) string { for _, ln := range lines { if strings.Contains(ln, needle) { return ln } } return "" } cases := []struct{ slug, desc, marks string }{ {"plain-fact", "an active fact", "project"}, {"muted-fact", "a disabled fact", "[off]"}, {"pinned-muted", "pinned and disabled", "[pinned] [off]"}, } for _, c := range cases { ln := findLine(c.slug) if ln == "" { t.Errorf("opMemory missing slug %q:\n%s", c.slug, got) continue } if !strings.Contains(ln, c.desc) { t.Errorf("fact %q: description %q missing in %q", c.slug, c.desc, ln) } if !strings.Contains(ln, c.marks) { t.Errorf("fact %q: marks %q missing in %q", c.slug, c.marks, ln) } } } func TestOpGC_GuardThenRun(t *testing.T) { st := newStyles(false) if got := opGC(repo(t, false), st, 80); !strings.Contains(strings.Join(got, "\n"), "not initialised") { t.Errorf("opGC guard: %v", got) } if got := opGC(repo(t, true), st, 80); !strings.Contains(strings.Join(got, "\n"), "archived 0") { t.Errorf("opGC run: %v", got) } } func TestOpNew_GuardThenScaffold(t *testing.T) { st := newStyles(false) if got := opNew(repo(t, false), st, 80, "checks"); !strings.Contains(strings.Join(got, "\n"), "not initialised") { t.Errorf("opNew guard: %v", got) } cfg := repo(t, true) got := opNew(cfg, st, 80, "checks") if !strings.Contains(strings.Join(got, "\n"), "scaffolded") { t.Fatalf("opNew scaffold: %v", got) } if _, err := os.Stat(filepath.Join(cfg.Workspace, "workflows", "checks", "run")); err != nil { t.Errorf("scaffolded entry missing: %v", err) } } // --- model behaviour (headless: no Bubble Tea program loop) --- func TestModel_HistoryNavigation(t *testing.T) { m := newModel(repo(t, true), "v") m.history = []string{"first", "second"} m.histPos = len(m.history) m.historyPrev() if m.ta.Value() != "second" { t.Fatalf("prev -> %q, want second", m.ta.Value()) } m.historyPrev() if m.ta.Value() != "first" { t.Fatalf("prev -> %q, want first", m.ta.Value()) } m.historyPrev() // clamps at oldest if m.ta.Value() != "first" { t.Fatalf("prev clamp -> %q, want first", m.ta.Value()) } m.historyNext() m.historyNext() // back to the (empty) live draft if m.ta.Value() != "" { t.Fatalf("next -> %q, want empty draft", m.ta.Value()) } } func TestModel_OverlayAndQuitKeys(t *testing.T) { m := newModel(repo(t, true), "v") // `?` on empty input opens the overlay; any key closes it. tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")}) if !asModel(t, tm).overlay { t.Fatal("? should open the overlay") } tm, _ = asModel(t, tm).onKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) if asModel(t, tm).overlay { t.Fatal("any key should close the overlay") } // `q` on empty input quits. tm, cmd := newModel(repo(t, true), "v").onKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) if !asModel(t, tm).quitting || cmd == nil { t.Fatal("q on empty input should quit") } // Ctrl-C always quits. tm, cmd = newModel(repo(t, true), "v").onKey(tea.KeyMsg{Type: tea.KeyCtrlC}) if !asModel(t, tm).quitting || cmd == nil { t.Fatal("Ctrl-C should quit") } } func TestModel_TabCompletesAndEscClears(t *testing.T) { m := newModel(repo(t, true), "v") m.ta.SetValue("/he") tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyTab}) if v := asModel(t, tm).ta.Value(); v != "/help " { t.Fatalf("Tab completion -> %q, want %q", v, "/help ") } m2 := asModel(t, tm) tm, _ = m2.onKey(tea.KeyMsg{Type: tea.KeyEsc}) if asModel(t, tm).ta.Value() != "" { t.Fatalf("Esc should clear input, got %q", asModel(t, tm).ta.Value()) } } // --- v1.3.0 home view --- func TestRenderHome_GoldenWidths(t *testing.T) { st := newStyles(false) for _, w := range []int{80, 120, 200} { t.Run("w"+strconv.Itoa(w), func(t *testing.T) { got := renderHome(w, st, "v1.5.0", tips[0]) path := filepath.Join("testdata", "home_w"+strconv.Itoa(w)+".golden") if *updateGolden { if err := os.MkdirAll("testdata", 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(got), 0o644); err != nil { t.Fatal(err) } return } raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read golden %s (run with -update to create): %v", path, err) } // Git for Windows can rewrite LF to CRLF on checkout when // .gitattributes is not honoured (older clients, custom // configs). Normalise so a CRLF golden still matches. want := strings.ReplaceAll(string(raw), "\r\n", "\n") if want != got { t.Errorf("home view at width %d differs from %s — re-run with -update if intentional.\n--- want ---\n%s--- got ---\n%s", w, path, want, got) } }) } } func TestRenderHome_ShowsTipAndVersion(t *testing.T) { st := newStyles(false) got := renderHome(80, st, "v1.5.0", tips[0]) if !strings.Contains(got, tips[0]) { t.Errorf("home view should contain the tip %q:\n%s", tips[0], got) } if !strings.Contains(got, "v1.5.0") { t.Errorf("home view should contain the version line:\n%s", got) } // The home no longer lists commands; the / palette and ? overlay do. if strings.Contains(got, "run memory garbage collection") { t.Errorf("home view must not list commands (moved to / palette + ? overlay):\n%s", got) } } func TestPickTip_ReturnsMember(t *testing.T) { for i := 0; i < 50; i++ { got := pickTip() ok := false for _, tp := range tips { if tp == got { ok = true break } } if !ok { t.Fatalf("pickTip returned %q, not a member of tips", got) } } } func TestView_NeverContainsHomeBlock(t *testing.T) { logoTop := strings.Split(eecoLogo, "\n")[0] m := newModel(repo(t, false), "v") if strings.Contains(m.View(), logoTop) { t.Fatalf("View must not embed the home block (printed once via scrollback):\n%s", m.View()) } m.ta.SetValue("/") if strings.Contains(m.View(), logoTop) { t.Errorf("logo must not appear when typing:\n%s", m.View()) } m.ta.SetValue("") if strings.Contains(m.View(), logoTop) { t.Errorf("logo must not re-render on clear (no logo stacking):\n%s", m.View()) } m.running = true if strings.Contains(m.View(), logoTop) { t.Errorf("logo must not appear while a background op is running:\n%s", m.View()) } } func TestUpdate_PrintsHomeOnceOnFirstWindowSize(t *testing.T) { m := newModel(repo(t, false), "v") if m.homePrinted { t.Fatal("homePrinted must start false") } tm, cmd := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) got := asModel(t, tm) if !got.homePrinted { t.Fatal("first WindowSizeMsg must mark home printed") } if got.width != 80 { t.Fatalf("width not stored: %d", got.width) } if cmd == nil { t.Fatal("first WindowSizeMsg must emit a print command") } // Second WindowSizeMsg (e.g. terminal resize) must not re-print the // home block — that would re-introduce the stacking the operator // flagged. tm2, cmd2 := got.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) got2 := asModel(t, tm2) if got2.width != 100 { t.Fatalf("resize width not stored: %d", got2.width) } if cmd2 != nil { t.Fatal("second WindowSizeMsg must not re-print the home block") } } func TestCommandIndex_MatchesSlashCommands(t *testing.T) { if len(commandIndex) != len(slashCommands) { t.Fatalf("len mismatch: commandIndex=%d slashCommands=%d", len(commandIndex), len(slashCommands)) } for i, e := range commandIndex { if e.name != slashCommands[i] { t.Errorf("index %d: commandIndex=%q slashCommands=%q", i, e.name, slashCommands[i]) } } } func TestModel_AsyncResultGenerationGuard(t *testing.T) { m := newModel(repo(t, true), "v") m.gen = 5 m.running = true // A stale result (interrupted: gen advanced) is dropped, leaving the // running flag untouched for the still-current operation. tm, _ := m.Update(asyncResultMsg{gen: 4, lines: []string{"stale"}}) if !asModel(t, tm).running { t.Fatal("stale result must not clear running") } // The matching result is accepted and clears running. tm, _ = m.Update(asyncResultMsg{gen: 5, lines: []string{"ok"}, isRun: true, summary: "run x: ok"}) got := asModel(t, tm) if got.running || got.lastRun != "run x: ok" { t.Fatalf("current result not applied: running=%v lastRun=%q", got.running, got.lastRun) } } // --- v1.3.0 slash-command palette --- func TestPaletteOpen(t *testing.T) { m := newModel(repo(t, true), "v") cases := []struct { in string want bool }{ {"/", true}, // bare slash opens {"/me", true}, // command token still being typed {"/run ", false}, // committed command + space -> argument mode {"", false}, // empty never opens {"hello", false}, // plain text never opens } for _, c := range cases { m.ta.SetValue(c.in) if got := m.paletteOpen(); got != c.want { t.Errorf("paletteOpen(%q)=%v, want %v", c.in, got, c.want) } } } func TestPaletteItems_Filter(t *testing.T) { m := newModel(repo(t, true), "v") m.ta.SetValue("/") if got := len(m.paletteItems()); got != len(commandIndex) { t.Fatalf("bare slash should list all commands; got %d want %d", got, len(commandIndex)) } m.ta.SetValue("/h") items := m.paletteItems() if len(items) != 2 || items[0].name != "/help" || items[1].name != "/hooks" { t.Fatalf("/h should prefix-match /help,/hooks; got %v", items) } m.ta.SetValue("/zz") if got := len(m.paletteItems()); got != 0 { t.Fatalf("/zz should match nothing; got %d", got) } } func TestPalette_CursorMoveClampAndReset(t *testing.T) { m := newModel(repo(t, true), "v") m.ta.SetValue("/") m.ta.CursorEnd() n := len(m.paletteItems()) // Down past the bottom clamps at the last row. for i := 0; i < n+3; i++ { tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyDown}) m = asModel(t, tm) } if m.pal.cursor != n-1 { t.Fatalf("Down clamp: cursor=%d want %d", m.pal.cursor, n-1) } // Up past the top clamps at 0. for i := 0; i < n+3; i++ { tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyUp}) m = asModel(t, tm) } if m.pal.cursor != 0 { t.Fatalf("Up clamp: cursor=%d want 0", m.pal.cursor) } // Advance, then change the filter by typing -> cursor snaps back to 0. tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyDown}) m = asModel(t, tm) if m.pal.cursor == 0 { t.Fatal("Down should have advanced the cursor before the filter test") } tm, _ = m.onKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}) m = asModel(t, tm) if m.ta.Value() != "/h" { t.Fatalf("typing should filter the input; ta=%q", m.ta.Value()) } if m.pal.cursor != 0 { t.Fatalf("a filter change must reset the cursor to 0; cursor=%d", m.pal.cursor) } } func TestPalette_AcceptFillsAndCloses(t *testing.T) { // Both Tab and Enter accept the highlighted row; neither submits. for _, key := range []tea.KeyType{tea.KeyTab, tea.KeyEnter} { m := newModel(repo(t, true), "v") m.ta.SetValue("/h") // -> /help,/hooks; cursor 0 = /help m.ta.CursorEnd() tm, _ := m.onKey(tea.KeyMsg{Type: key}) got := asModel(t, tm) if got.ta.Value() != "/help " { t.Fatalf("%v accept: ta=%q want %q", key, got.ta.Value(), "/help ") } if got.paletteOpen() { t.Fatalf("%v accept must close the palette", key) } if got.quitting { t.Fatalf("%v accept must not submit/quit", key) } } } func TestPalette_KeyRouting(t *testing.T) { // Palette open: Up/Down move the highlight, never the command history. open := newModel(repo(t, true), "v") open.history = []string{"old"} open.histPos = len(open.history) open.ta.SetValue("/") open.ta.CursorEnd() tm, _ := open.onKey(tea.KeyMsg{Type: tea.KeyDown}) if v := asModel(t, tm).ta.Value(); v != "/" { t.Fatalf("palette-open Down must not navigate history; ta=%q", v) } tm, _ = open.onKey(tea.KeyMsg{Type: tea.KeyUp}) if v := asModel(t, tm).ta.Value(); v != "/" { t.Fatalf("palette-open Up must not navigate history; ta=%q", v) } // Palette closed: Up still browses history. closed := newModel(repo(t, true), "v") closed.history = []string{"old"} closed.histPos = len(closed.history) tm, _ = closed.onKey(tea.KeyMsg{Type: tea.KeyUp}) if v := asModel(t, tm).ta.Value(); v != "old" { t.Fatalf("palette-closed Up must browse history; ta=%q", v) } } func TestRenderPalette_Content(t *testing.T) { st := newStyles(false) // plain styles so assertions read raw text items := []cmdEntry{ {"/gc", "run memory garbage collection"}, {"/help", "command and key reference"}, } out := renderPalette(items, 0, 80, st) for _, w := range []string{"/gc", "run memory garbage collection", "/help", "command and key reference"} { if !strings.Contains(out, w) { t.Errorf("renderPalette missing %q:\n%s", w, out) } } if !strings.Contains(out, "›") { t.Errorf("the selected row should carry the › marker:\n%s", out) } if got := renderPalette(nil, 0, 80, st); !strings.Contains(got, "no match") { t.Errorf("an empty palette should render \"no match\"; got %q", got) } } func TestRenderPalette_ScrollKeepsSelectionVisible(t *testing.T) { st := newStyles(false) // More items than the row cap: the highlighted row must stay visible // (regression for the cursor-past-the-window overflow bug). items := make([]cmdEntry, paletteMaxRows+4) for i := range items { items[i] = cmdEntry{name: fmt.Sprintf("/cmd%02d", i), purpose: fmt.Sprintf("does thing %d", i)} } last := len(items) - 1 out := renderPalette(items, last, 80, st) sel := items[last] if !strings.Contains(out, sel.name) || !strings.Contains(out, sel.purpose) { t.Errorf("selected (last) row %q must be visible when scrolled:\n%s", sel.name, out) } // The marker must sit on the selected row's line, not a stale top row. for _, ln := range strings.Split(out, "\n") { if strings.Contains(ln, "›") && !strings.Contains(ln, sel.name) { t.Errorf("marker on the wrong row %q (selected %q):\n%s", ln, sel.name, out) } } if !strings.Contains(out, "more") { t.Errorf("hidden rows should surface a \"+N more\" line:\n%s", out) } } func TestView_PaletteOpenVsClosed(t *testing.T) { m := newModel(repo(t, false), "v") m.width = 80 m.ta.SetValue("/") open := m.View() if !strings.Contains(open, "/help") || !strings.Contains(open, "command and key reference") { t.Errorf("open palette View should list commands and purposes:\n%s", open) } // Closed: no command rows leak into the live region. m.ta.SetValue("") if closed := m.View(); strings.Contains(closed, "command and key reference") { t.Errorf("closed palette must not render rows:\n%s", closed) } } // --- v1.4.0 multi-line composer + animated spinner --- func TestModel_EnterSubmitsAltEnterNewline(t *testing.T) { // Plain Enter on a non-empty line submits and clears the composer. Free // text is handled synchronously now (chat retired in C5), so it prints a // hint without starting a background op. m := newModel(repo(t, true), "v") m.ta.SetValue("summarise the project") tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyEnter}) got := asModel(t, tm) if got.running { t.Fatal("free text must not start a background op (chat retired)") } if got.ta.Value() != "" { t.Fatalf("submit should clear the composer; got %q", got.ta.Value()) } // Alt+Enter inserts a newline rather than submitting. m2 := newModel(repo(t, true), "v") m2.ta.SetValue("line one") m2.ta.CursorEnd() tm2, _ := m2.onKey(tea.KeyMsg{Type: tea.KeyEnter, Alt: true}) got2 := asModel(t, tm2) if got2.running || got2.quitting { t.Fatal("Alt+Enter must not submit") } if !strings.Contains(got2.ta.Value(), "\n") { t.Fatalf("Alt+Enter should insert a newline; got %q", got2.ta.Value()) } } func TestModel_CtrlJInsertsNewline(t *testing.T) { // Ctrl+J is the literal-LF fallback for terminals that swallow Alt+Enter. m := newModel(repo(t, true), "v") m.ta.SetValue("abc") m.ta.CursorEnd() tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyCtrlJ}) got := asModel(t, tm) if got.running || got.quitting { t.Fatal("Ctrl+J must not submit or quit") } if !strings.Contains(got.ta.Value(), "\n") { t.Fatalf("Ctrl+J should insert a newline; got %q", got.ta.Value()) } } func TestModel_UpDownLineBoundaryRouting(t *testing.T) { m := newModel(repo(t, true), "v") m.ta.SetWidth(80) m.history = []string{"recalled"} m.histPos = len(m.history) m.ta.SetValue("top\nmiddle\nbottom") // cursor lands on the last line m.reflowHeight() // Move the cursor to a middle line; Up there moves the cursor, it does // not recall history. m.ta.CursorUp() if m.ta.Line() != 1 { t.Fatalf("precondition: cursor should be on the middle line; Line=%d", m.ta.Line()) } tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyUp}) got := asModel(t, tm) if got.ta.Value() != "top\nmiddle\nbottom" { t.Fatalf("Up off the boundary must not recall history; value=%q", got.ta.Value()) } if got.ta.Line() != 0 { t.Fatalf("Up off the boundary should move the cursor up; Line=%d want 0", got.ta.Line()) } // Up on the first line (top boundary) recalls history. tm, _ = got.onKey(tea.KeyMsg{Type: tea.KeyUp}) if v := asModel(t, tm).ta.Value(); v != "recalled" { t.Fatalf("Up at line 0 should recall history; got %q", v) } // Down on the last line (bottom boundary) restores the saved multi-line // draft (history forward past the newest entry). m2 := newModel(repo(t, true), "v") m2.ta.SetWidth(80) m2.history = []string{"recalled"} m2.histPos = len(m2.history) m2.ta.SetValue("a\nb") m2.reflowHeight() m2.ta.CursorUp() // to line 0 so the recall fires tm2, _ := m2.onKey(tea.KeyMsg{Type: tea.KeyUp}) got2 := asModel(t, tm2) if got2.ta.Value() != "recalled" { t.Fatalf("setup recall failed; got %q", got2.ta.Value()) } tm2, _ = got2.onKey(tea.KeyMsg{Type: tea.KeyDown}) if v := asModel(t, tm2).ta.Value(); v != "a\nb" { t.Fatalf("Down at the bottom should restore the multi-line draft; got %q", v) } } func TestModel_SingleLineHistoryRegression(t *testing.T) { // A single-line draft has Line()==0==LineCount()-1, so Up and Down route // to history exactly as before the multi-line composer. m := newModel(repo(t, true), "v") m.history = []string{"first", "second"} m.histPos = len(m.history) tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyUp}) m = asModel(t, tm) if m.ta.Value() != "second" { t.Fatalf("Up -> %q, want second", m.ta.Value()) } tm, _ = m.onKey(tea.KeyMsg{Type: tea.KeyUp}) m = asModel(t, tm) if m.ta.Value() != "first" { t.Fatalf("Up -> %q, want first", m.ta.Value()) } tm, _ = m.onKey(tea.KeyMsg{Type: tea.KeyDown}) m = asModel(t, tm) if m.ta.Value() != "second" { t.Fatalf("Down -> %q, want second", m.ta.Value()) } } func TestModel_ReflowHeight(t *testing.T) { m := newModel(repo(t, true), "v") m.ta.SetWidth(80) m.ta.SetValue("a\nb\nc") m.reflowHeight() if h := m.ta.Height(); h != 3 { t.Fatalf("a 3-line draft should reflow to 3 rows; Height=%d", h) } m.ta.SetValue("") m.reflowHeight() if h := m.ta.Height(); h != 1 { t.Fatalf("a cleared draft should shrink to 1 row; Height=%d", h) } // More lines than the cap clamp at inputMaxRows (then the box scrolls). m.ta.SetValue(strings.Repeat("x\n", inputMaxRows+5)) m.reflowHeight() if h := m.ta.Height(); h != inputMaxRows { t.Fatalf("a long draft should clamp at inputMaxRows=%d; Height=%d", inputMaxRows, h) } } func TestModel_SpinnerTickLifecycle(t *testing.T) { m := newModel(repo(t, true), "v") // Running: a tick advances the spinner and re-arms the loop. m.running = true _, cmd := m.Update(spinner.TickMsg{}) if cmd == nil { t.Fatal("a spinner tick while running must return a follow-up command") } // Idle: the tick loop dies so the spinner cannot spin forever. m.running = false _, cmd = m.Update(spinner.TickMsg{}) if cmd != nil { t.Fatal("a spinner tick while idle must not re-arm the loop") } } func TestModel_SpinnerInView(t *testing.T) { m := newModel(repo(t, true), "v") m.width = 80 idle := m.View() if strings.Contains(idle, "working") { t.Errorf("idle footer must not show the working label:\n%s", idle) } if strings.ContainsAny(idle, miniDotFrames) { t.Errorf("idle footer must not show a spinner frame:\n%s", idle) } m.running = true run := m.View() if !strings.Contains(run, "working (Esc to interrupt)") { t.Errorf("running footer should show the working label:\n%s", run) } if !strings.ContainsAny(run, miniDotFrames) { t.Errorf("running footer should show a MiniDot spinner frame:\n%s", run) } } func TestModel_AltEnterPaletteOpenInsertsNewline(t *testing.T) { // With the palette open ("/he"), Alt+Enter inserts a newline (closing the // palette into free text), never accepts the highlighted command — // consistent with Ctrl+J, which already falls through. m := newModel(repo(t, true), "v") m.ta.SetValue("/he") m.ta.CursorEnd() if !m.paletteOpen() { t.Fatal("precondition: palette should be open for /he") } tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyEnter, Alt: true}) got := asModel(t, tm) if !strings.Contains(got.ta.Value(), "\n") { t.Fatalf("Alt+Enter with palette open should insert a newline; got %q", got.ta.Value()) } if got.ta.Value() == "/help " { t.Fatal("Alt+Enter must not accept the palette selection") } if got.paletteOpen() { t.Fatal("a newline must close the palette") } } func TestPaletteClosedOnMultiline(t *testing.T) { m := newModel(repo(t, true), "v") // A leading slash but containing a newline is free text, not a command. m.ta.SetValue("/foo\nbar") if m.paletteOpen() { t.Error("a multi-line value must close the palette even with a leading slash") } // A whitespace control char (tab) likewise closes it. m.ta.SetValue("/foo\tbar") if m.paletteOpen() { t.Error("a tab in the value must close the palette") } } // --- H1.4: dispatch / op-branch + async-guard depth (no seam, no prod code) --- // opRun: a bad operator attribution pattern fails NewDetector before any // workflow runs, so the run reports the compile error and never spends AI. func TestOpRun_DetectorErrorBadAttributionPattern(t *testing.T) { cfg := repo(t, true) cfg.AttributionPatterns = []string{"("} // unterminated group → compile error st := newStyles(false) summary, lines, _ := opRun(cfg, st, 80, "comment-hygiene", false) if !strings.Contains(summary, "run comment-hygiene:") { t.Errorf("a detector compile error should surface in the summary: %q", summary) } if !strings.Contains(strings.Join(lines, "\n"), "run comment-hygiene") { t.Errorf("the error section should carry the run title:\n%s", strings.Join(lines, "\n")) } } // opRun: an unknown name misses the registry and errors through ScriptRun. func TestOpRun_ScriptRunErrorUnknownWorkflow(t *testing.T) { cfg := repo(t, true) st := newStyles(false) summary, lines, _ := opRun(cfg, st, 80, "no-such-wf", false) if !strings.Contains(summary, "run no-such-wf:") { t.Errorf("an unknown workflow should error through ScriptRun: %q", summary) } if !strings.Contains(strings.Join(lines, "\n"), "run no-such-wf") { t.Errorf("the error section should carry the run title:\n%s", strings.Join(lines, "\n")) } } // opRun: the comment-hygiene builtin runs on the temp tree (filesystem walk, // no real git, no AI) and reports a clean, no-findings success. func TestOpRun_CommentHygieneCleanTreeNoFindings(t *testing.T) { cfg := repo(t, true) st := newStyles(false) summary, lines, _ := opRun(cfg, st, 80, "comment-hygiene", false) got := strings.Join(lines, "\n") if !strings.Contains(got, "no findings") { t.Errorf("a clean tree should render the no-findings body:\n%s", got) } if !strings.Contains(summary, "run comment-hygiene:") { t.Errorf("the summary should describe the run: %q", summary) } } // runNames lists the builtins, tolerates a nil config, and surfaces a // workspace-scaffolded workflow (the e.IsDir() arm over workflows/). func TestRunNames_BuiltinsWorkspaceAndNilConfig(t *testing.T) { cfg := repo(t, true) base := runNames(cfg) for _, want := range []string{"comment-hygiene", "leak-guard"} { if !slices.Contains(base, want) { t.Errorf("runNames should list builtin %q; got %v", want, base) } } if got := runNames(nil); !slices.Contains(got, "comment-hygiene") { t.Errorf("runNames(nil) should list builtins; got %v", got) } st := newStyles(false) if out := opNew(cfg, st, 80, "demo"); !strings.Contains(strings.Join(out, "\n"), "scaffolded") { t.Fatalf("precondition: opNew should scaffold demo: %v", out) } if got := runNames(cfg); !slices.Contains(got, "demo") { t.Errorf("runNames should include the scaffolded workflow; got %v", got) } } // startAsync: invoke the returned tea.Cmd for each dispatchResult branch and // assert the asyncResultMsg it produces (the three never-invoked arms + the // default fall-through). func TestModel_StartAsyncBranchesInvoked(t *testing.T) { ctx := context.Background() const gen = 7 invoke := func(m model, res dispatchResult) asyncResultMsg { t.Helper() m.width = 80 cmd := m.startAsync(ctx, gen, res) msg, ok := cmd().(asyncResultMsg) if !ok { t.Fatalf("startAsync cmd should return an asyncResultMsg") } if msg.gen != gen { t.Errorf("result gen = %d, want %d", msg.gen, gen) } return msg } // gc: runs opGC, never a run. if gcMsg := invoke(newModel(repo(t, true), "v"), dispatchResult{async: "gc"}); gcMsg.isRun { t.Error("gc branch must not flag isRun") } // run: runs opRun, isRun with a summary. runMsg := invoke(newModel(repo(t, true), "v"), dispatchResult{async: "run", asyncS: "comment-hygiene"}) if !runMsg.isRun || !strings.Contains(runMsg.summary, "run comment-hygiene") { t.Errorf("run branch should set isRun + summary: %+v", runMsg) } // default: an unrecognised async yields a bare gen result. (Free-text // chat was retired in C5, so there is no longer a "free" async branch.) defMsg := invoke(newModel(repo(t, true), "v"), dispatchResult{async: ""}) if defMsg.isRun || len(defMsg.lines) != 0 { t.Errorf("default branch should return a bare gen result: %+v", defMsg) } } // onKey: Esc while an op is running cancels it, bumps the generation to // invalidate the in-flight result, and clears the running/cancel state — // distinct from the Esc-clears-input arm when idle. func TestModel_EscInterruptsRunningOp(t *testing.T) { m := newModel(repo(t, true), "v") m.running = true m.gen = 4 cancelled := false m.cancel = func() { cancelled = true } tm, cmd := m.onKey(tea.KeyMsg{Type: tea.KeyEsc}) got := asModel(t, tm) if got.gen != 5 { t.Errorf("Esc while running should bump gen; gen=%d want 5", got.gen) } if got.running { t.Error("Esc while running should clear running") } if got.cancel != nil { t.Error("Esc while running should clear the cancel func") } if !cancelled { t.Error("Esc while running should invoke the cancel func") } if cmd == nil { t.Error("Esc while running should emit the interrupted notice") } } // opSettings: the uninitialised guard plus the per-key validation rejects. func TestOpSettings_GuardAndValidationErrors(t *testing.T) { st := newStyles(false) if got := strings.Join(opSettings(repo(t, false), st, 80, []string{"automation", "auto"}), "\n"); !strings.Contains(got, "not initialised") { t.Errorf("uninitialised settings should guard:\n%s", got) } cfg := repo(t, true) if got := strings.Join(opSettings(cfg, st, 80, []string{"ai_budget", "-1"}), "\n"); !strings.Contains(got, "non-negative") { t.Errorf("a negative ai_budget should be rejected:\n%s", got) } // One token → val == "" (a direct call: parseInput would drop the empty // trailing token, so build args by hand). if got := strings.Join(opSettings(cfg, st, 80, []string{"ai_command"}), "\n"); !strings.Contains(got, "needs an argv") { t.Errorf("an empty ai_command should be rejected:\n%s", got) } if got := strings.Join(opSettings(cfg, st, 80, []string{"bogus", "x"}), "\n"); !strings.Contains(got, "unknown key") { t.Errorf("an unknown key should render usage:\n%s", got) } } // opSettings: a configured provider renders the "configured" view. func TestOpSettings_ProviderConfiguredView(t *testing.T) { cfg := repo(t, true) cfg.AICommand = []string{"x"} st := newStyles(false) got := strings.Join(opSettings(cfg, st, 80, nil), "\n") if !strings.Contains(got, "configured") || strings.Contains(got, "every AI pass is parked") { t.Errorf("a configured provider should render the configured view:\n%s", got) } } // opSettings: a WriteLocalKeys failure (config.local is a directory → // ReadFile errors) wraps as a settings error. Assert the wrap text, never the // errno (Windows-safe). func TestOpSettings_WriteLocalKeysErrorWraps(t *testing.T) { cfg := repo(t, true) localPath := filepath.Join(cfg.Workspace, config.LocalFilename) if err := os.RemoveAll(localPath); err != nil { t.Fatal(err) } if err := os.Mkdir(localPath, 0o755); err != nil { t.Fatal(err) } st := newStyles(false) // A valid key (validated before the write) so the failure is the write. got := strings.Join(opSettings(cfg, st, 80, []string{"automation", "propose"}), "\n") if !strings.Contains(got, "settings:") { t.Errorf("a WriteLocalKeys failure should wrap as a settings error:\n%s", got) } } // opHooks: toggling the pre-commit hook on then off reports success. Use the // hooks.PreCommit constant (== "pre-commit"); a CamelCase literal falls // through to usage. Assert message text only, never the file mode. func TestOpHooks_PreCommitToggleSucceeds(t *testing.T) { cfg := repo(t, true) st := newStyles(false) if on := strings.Join(opHooks(cfg, st, 80, []string{hooks.PreCommit, "on"}), "\n"); !strings.Contains(on, "hooks:") { t.Errorf("pre-commit on should report success:\n%s", on) } if off := strings.Join(opHooks(cfg, st, 80, []string{hooks.PreCommit, "off"}), "\n"); !strings.Contains(off, "hooks:") { t.Errorf("pre-commit off should report success:\n%s", off) } } // opHooks: toggling session-start on then off reports success. The settings // path must be set first, else EnableSessionStart returns // ErrSessionNotConfigured. func TestOpHooks_SessionStartToggleSucceeds(t *testing.T) { cfg := repo(t, true) cfg.SessionSettingsPath = filepath.Join(t.TempDir(), "settings.json") st := newStyles(false) if on := strings.Join(opHooks(cfg, st, 80, []string{hooks.SessionStart, "on"}), "\n"); !strings.Contains(on, "hooks:") { t.Errorf("session-start on should report success:\n%s", on) } if off := strings.Join(opHooks(cfg, st, 80, []string{hooks.SessionStart, "off"}), "\n"); !strings.Contains(off, "hooks:") { t.Errorf("session-start off should report success:\n%s", off) } } // opHooks: wrong arity renders usage; session-start on with nothing configured // reaches the error arm via ErrSessionNotConfigured. func TestOpHooks_ArgArityAndConfigErrorArms(t *testing.T) { st := newStyles(false) usage := strings.Join(opHooks(repo(t, true), st, 80, []string{"a", "b", "c"}), "\n") if !strings.Contains(usage, "usage") { t.Errorf("a 3-arg hooks call should render usage:\n%s", usage) } errOut := strings.Join(opHooks(repo(t, true), st, 80, []string{hooks.SessionStart, "on"}), "\n") if !strings.Contains(errOut, "session-start not configured") { t.Errorf("session-start on (unconfigured) should hit the error arm:\n%s", errOut) } } // opQueue: a non-empty queue file renders the open-count and the item body. func TestOpQueue_NonEmptyBody(t *testing.T) { cfg := repo(t, true) stateDir := filepath.Join(cfg.Workspace, "state") if err := os.MkdirAll(stateDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile( filepath.Join(stateDir, "queue.md"), []byte("# eeco queue\n\n- [ ] **k** — t _(p, 2026-05-19)_\n"), 0o644, ); err != nil { t.Fatal(err) } st := newStyles(false) got := strings.Join(opQueue(cfg, st, 80), "\n") if !strings.Contains(got, "open") { t.Errorf("a non-empty queue should report an open count:\n%s", got) } if !strings.Contains(got, "**k**") { t.Errorf("a non-empty queue should render the item body:\n%s", got) } } // opGC: a missing-ref reference fact is archived (clock-free: the missing-ref // trigger fires before any staleness check); an ordinary active fact is kept // and skipped from the action body. A disabled fact would map to "kept", so a // missing-ref reference fact is used for the non-kept format line. func TestOpGC_FormatsArchivedAndKeptActions(t *testing.T) { cfg := repo(t, true) store, err := memory.Open(cfg) if err != nil { t.Fatal(err) } now := time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC) refFact := &memory.Fact{ Name: "dangling-ref", Description: "points nowhere", Type: memory.TypeReference, Ref: "no/such/file", Created: now, LastUsed: now, } if err := store.Save(refFact); err != nil { t.Fatal(err) } keepFact := &memory.Fact{ Name: "keeper", Description: "still relevant", Type: memory.TypeProject, Created: now, LastUsed: now, } if err := store.Save(keepFact); err != nil { t.Fatal(err) } st := newStyles(false) got := strings.Join(opGC(cfg, st, 80), "\n") if !strings.Contains(got, "archived 1") { t.Errorf("opGC should report one archived fact:\n%s", got) } if !strings.Contains(got, "dangling-ref") || !strings.Contains(got, "ref missing") { t.Errorf("opGC should format the archived action line:\n%s", got) } if strings.Contains(got, "keeper") { t.Errorf("a kept fact must be skipped from the action body:\n%s", got) } } // opMemory: a file sitting where the memory directory should be makes // memory.Open's MkdirAll fail (not-a-directory), surfacing a memory error. // Built on repo(t,false) so Init does not pre-create memory/. func TestOpMemory_OpenErrorOnFileAtMemoryPath(t *testing.T) { cfg := repo(t, false) if err := os.MkdirAll(cfg.Workspace, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(cfg.Workspace, "memory"), []byte("not a dir\n"), 0o644); err != nil { t.Fatal(err) } st := newStyles(false) if got := strings.Join(opMemory(cfg, st, 80), "\n"); !strings.Contains(got, "memory:") { t.Errorf("a file at the memory path should surface an Open error:\n%s", got) } } // opMemory: a malformed fact file aborts LoadAll, surfacing a memory error // rather than the empty-store section. func TestOpMemory_LoadAllErrorOnMalformedFact(t *testing.T) { cfg := repo(t, true) if err := os.WriteFile(filepath.Join(cfg.Workspace, "memory", "not-a-fact.md"), []byte("garbage\n"), 0o644); err != nil { t.Fatal(err) } st := newStyles(false) got := strings.Join(opMemory(cfg, st, 80), "\n") if !strings.Contains(got, "memory:") { t.Errorf("a malformed fact should surface a LoadAll error:\n%s", got) } if strings.Contains(got, "no facts") { t.Errorf("the error path must not fall through to the empty section:\n%s", got) } } // opNew: a name the scaffolder rejects surfaces a new error. func TestOpNew_ScaffoldErrorBadName(t *testing.T) { st := newStyles(false) got := strings.Join(opNew(repo(t, true), st, 80, "Bad Name"), "\n") if !strings.Contains(got, "new:") { t.Errorf("a bad workflow name should surface a scaffold error:\n%s", got) } if strings.Contains(got, "scaffolded") { t.Errorf("a rejected name must not report success:\n%s", got) } } // dispatch: the genuinely-uncovered queue/memory/gc/new arms (the // quit/clear/help/hooks/settings/run/bogus/free arms are covered elsewhere). func TestDispatch_QueueMemoryGCNewArms(t *testing.T) { cfg := repo(t, true) st := newStyles(false) disp := func(input string) dispatchResult { return dispatch(cfg, st, 80, parseInput(input)) } join := func(r dispatchResult) string { return strings.Join(r.lines, "\n") } if r := disp("/queue"); len(r.lines) == 0 || !strings.Contains(join(r), "queue") { t.Errorf("/queue should render the queue section: %v", r.lines) } if r := disp("/memory"); len(r.lines) == 0 || !strings.Contains(join(r), "memory") { t.Errorf("/memory should render the memory section: %v", r.lines) } if r := disp("/gc"); r.async != "gc" { t.Errorf("/gc should dispatch the async gc op: %+v", r) } if r := disp("/new demo"); !strings.Contains(join(r), "scaffolded") { t.Errorf("/new should scaffold: %v", r.lines) } if r := disp("/new"); !strings.Contains(join(r), "usage") { t.Errorf("/new no-arg should render usage: %v", r.lines) } } // Init returns the textarea blink command. func TestModel_InitReturnsBlink(t *testing.T) { if cmd := newModel(repo(t, true), "v").Init(); cmd == nil { t.Fatal("Init must return a non-nil (blink) command") } } // truncate: the three cut arms plus the passthrough. func TestTruncate_Table(t *testing.T) { cases := []struct { s string w int want string }{ {"x", 0, ""}, // w <= 0 {"ab", 1, "a"}, // w <= 1, must cut {"abcdef", 3, "ab…"}, // cut with ellipsis {"ab", 5, "ab"}, // len(r) <= w passthrough } for _, c := range cases { if got := truncate(c.s, c.w); got != c.want { t.Errorf("truncate(%q,%d) = %q, want %q", c.s, c.w, got, c.want) } } } // View: the quitting short-circuit and the overlay branch (behavioral, not // golden). func TestView_QuittingAndOverlay(t *testing.T) { q := newModel(repo(t, true), "v") q.quitting = true if q.View() != "" { t.Errorf("a quitting model renders an empty view; got %q", q.View()) } o := newModel(repo(t, true), "v") o.overlay = true o.width = 80 if !strings.Contains(o.View(), "press any key to close") { t.Errorf("an open overlay should render the close hint:\n%s", o.View()) } }