Go 1386 lines
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 <name> 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())
}
}