package tui import ( "context" "os" "path/filepath" "strconv" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "codey/internal/approval" "codey/internal/llm" "codey/internal/statedir" ) // newHistoryModel builds a model whose history has a real directory to live // in, so a test can look at what was written as well as at what was recalled. func newHistoryModel(t *testing.T) (*model, string) { t.Helper() root := t.TempDir() m := newModel(context.Background(), Options{ Client: llm.New(llm.WithAPIKey("x")), Root: root, HomeRoot: root, Policy: approval.New(nil), }) m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) return m, root } // The whole point of the feature: what was sent comes back. func TestUpRecallsThePreviousPrompt(t *testing.T) { m, _ := newHistoryModel(t) m.hist.record("fix the failing test") m.Update(key("up")) if v := m.input.Value(); v != "fix the failing test" { t.Errorf("prompt = %q, want the previous one back", v) } } func TestUpWalksBackThroughSeveralPrompts(t *testing.T) { m, _ := newHistoryModel(t) for _, line := range []string{"first", "second", "third"} { m.hist.record(line) } want := []string{"third", "second", "first", "first"} for i, w := range want { m.Update(key("up")) if v := m.input.Value(); v != w { t.Errorf("after %d ups prompt = %q, want %q", i+1, v, w) } } } // Walking forward again has to give back what was half-written, or the feature // costs you the prompt you were in the middle of. func TestDownReturnsTheDraftTheWalkStartedFrom(t *testing.T) { m, _ := newHistoryModel(t) m.hist.record("an old prompt") typeIn(m, "half a thought") m.Update(key("up")) if v := m.input.Value(); v != "an old prompt" { t.Fatalf("prompt = %q, want the recalled entry", v) } m.Update(key("down")) if v := m.input.Value(); v != "half a thought" { t.Errorf("prompt = %q, want the draft back", v) } } func TestEscAbandonsTheWalk(t *testing.T) { m, _ := newHistoryModel(t) m.hist.record("an old prompt") typeIn(m, "draft") m.Update(key("up")) m.Update(key("esc")) if v := m.input.Value(); v != "draft" { t.Errorf("prompt = %q, want the draft back after esc", v) } } // Once a recalled line has been edited it is yours, and the draft it displaced // is gone. What must not happen is down quietly wiping the edit. func TestEditingARecalledLineEndsTheWalk(t *testing.T) { m, _ := newHistoryModel(t) m.hist.record("go test ./...") m.Update(key("up")) typeIn(m, " -run X") m.Update(key("down")) if v := m.input.Value(); v != "go test ./... -run X" { t.Errorf("prompt = %q, want the edited line left alone", v) } } // The arrows are only history at the edge of the text. In the middle of a // paragraph they are what they look like: cursor movement. func TestUpInsideAMultiLinePromptMovesTheCursor(t *testing.T) { m, _ := newHistoryModel(t) m.hist.record("an old prompt") typeIn(m, "first line") m.Update(key("ctrl+j")) // the textarea's newline typeIn(m, "second line") m.Update(key("up")) if v := m.input.Value(); v != "first line\nsecond line" { t.Errorf("prompt = %q, want the typed paragraph untouched", v) } if m.input.Line() != 0 { t.Errorf("cursor line = %d, want the key to have moved it to the first line", m.input.Line()) } // From the first line the same key now means history, and the paragraph // is the draft it can come back to. m.Update(key("up")) if v := m.input.Value(); v != "an old prompt" { t.Errorf("prompt = %q, want the recall from the first line", v) } } // A shell command and a prompt are different kinds of thing, and offering one // where the other belongs would send `go test` to the model as prose. func TestModesKeepSeparateHistories(t *testing.T) { m, _ := newHistoryModel(t) m.hist.record("a prompt for the model") m.hist.record("!go test ./...") m.hist.record("$print(1)") m.Update(key("up")) if v := m.input.Value(); v != "a prompt for the model" { t.Errorf("prompt = %q, want the model's own history and nothing else", v) } m.Update(key("esc")) m.Update(key("!")) m.Update(key("up")) if v := m.input.Value(); v != "go test ./..." { t.Errorf("shell prompt = %q, want the shell line without its marker", v) } } func TestSubmittingAPromptRecordsIt(t *testing.T) { m, _ := newHistoryModel(t) typeIn(m, "say something") m.Update(key("enter")) if got := m.hist.entries; len(got) != 1 || got[0] != "say something" { t.Errorf("entries = %v, want the submitted line", got) } if m.hist.walking { t.Error("still walking after a submit") } } // Entries survive a restart, which is the case the file exists for at all. func TestHistoryIsReadBackFromDisk(t *testing.T) { m, root := newHistoryModel(t) m.hist.record("remembered") again := newHistory(root) if got := again.entries; len(got) != 1 || got[0] != "remembered" { t.Errorf("entries after reload = %v, want the recorded line", got) } } // A prompt with newlines in it is one entry, not three. func TestAMultiLinePromptIsOneEntry(t *testing.T) { _, root := newHistoryModel(t) h := newHistory(root) h.record("do this:\n and this\n and that") again := newHistory(root) if got := again.entries; len(got) != 1 || got[0] != "do this:\n and this\n and that" { t.Errorf("entries = %q, want one entry holding every line", got) } } func TestRepeatsAndBlanksAreNotRecorded(t *testing.T) { _, root := newHistoryModel(t) h := newHistory(root) h.record("go test") h.record("go test") h.record(" ") h.record("") h.record("go build") h.record("go test") want := []string{"go test", "go build", "go test"} if got := h.entries; !equalStrings(got, want) { t.Errorf("entries = %v, want %v — only an immediate repeat is dropped", got, want) } } func TestHistoryIsCappedAndCompacted(t *testing.T) { _, root := newHistoryModel(t) path := historyPath(root) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } var b strings.Builder total := historyLimit*compactAtMultiple + 10 for i := range total { b.WriteString(strconv.Quote("line "+strconv.Itoa(i)) + "\n") } if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { t.Fatal(err) } h := newHistory(root) if len(h.entries) != historyLimit { t.Errorf("entries = %d, want the last %d", len(h.entries), historyLimit) } if h.entries[len(h.entries)-1] != "line "+strconv.Itoa(total-1) { t.Errorf("newest entry = %q, want the last line of the file", h.entries[len(h.entries)-1]) } raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if n := strings.Count(string(raw), "\n"); n != historyLimit { t.Errorf("file has %d lines, want it compacted to %d", n, historyLimit) } } // Nothing here may be a reason the prompt box does not work. A history with // nowhere to live still walks; it just forgets at the end of the session. func TestAHistoryWithNoDirectoryStillWorks(t *testing.T) { h := newHistory(filepath.Join(t.TempDir(), "does-not-exist")) if h.path != "" { t.Errorf("path = %q, want no file for a root that is not there", h.path) } h.record("kept in memory") if text, ok := h.prev("", ""); !ok || text != "kept in memory" { t.Errorf("prev = %q, %v; want the in-memory entry", text, ok) } } func TestGarbageLinesAreSkippedRatherThanFatal(t *testing.T) { _, root := newHistoryModel(t) path := historyPath(root) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } body := strconv.Quote("good one") + "\nnot quoted at all\n\"unterminated\n" + strconv.Quote("another") + "\n" if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } h := newHistory(root) if want := []string{"good one", "another"}; !equalStrings(h.entries, want) { t.Errorf("entries = %v, want %v", h.entries, want) } } // The file belongs where the sessions and the event log are, so it is covered // by the ignore rule that is already there. func TestTheFileLivesInTheStateDirectory(t *testing.T) { root := t.TempDir() want := filepath.Join(root, statedir.Dir, historyFilename) if got := historyPath(root); got != want { t.Errorf("historyPath = %q, want %q", got, want) } } func equalStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // The prompt's hint is advertising for keys nothing else on screen mentions, // so it loses whole clauses on a narrow terminal rather than the ends of // words. func TestThePlaceholderFitsTheBox(t *testing.T) { for _, width := range []int{100, 60, 48, 34, 20, 1} { p := placeholderFor(width) if w := lipgloss.Width(p); w > width && p != placeholders[len(placeholders)-1] { t.Errorf("placeholder for width %d is %d cells: %q", width, w, p) } if !strings.Contains(p, "ask") { t.Errorf("placeholder for width %d does not say what the box is for: %q", width, p) } } // The widest one names the key this history feature added, because there // is nowhere else on the idle screen that it is written down except the // welcome block, which scrolls away. if !strings.Contains(placeholderFor(100), "↑") { t.Error("the full placeholder does not mention the up arrow") } } func TestResizingNarrowsThePlaceholder(t *testing.T) { m, _ := newHistoryModel(t) wide := m.input.Placeholder m.Update(tea.WindowSizeMsg{Width: 40, Height: 24}) if m.input.Placeholder == wide { t.Errorf("the placeholder did not shorten for a 40-column terminal: %q", m.input.Placeholder) } m.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) if m.input.Placeholder != defaultPlaceholder { t.Errorf("the placeholder did not come back on a wide terminal: %q", m.input.Placeholder) } } // A local mode has a placeholder of its own, and resizing must not replace it // with the model's. func TestResizingLeavesALocalModePlaceholderAlone(t *testing.T) { m, _ := newHistoryModel(t) m.Update(key("!")) want := m.input.Placeholder m.Update(tea.WindowSizeMsg{Width: 40, Height: 24}) if m.input.Placeholder != want { t.Errorf("shell placeholder = %q, want %q", m.input.Placeholder, want) } }