package tui
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"codey/internal/config"
"codey/internal/llm"
)
// newTestWizard builds a wizard on a comfortable screen with the catalogue
// already delivered, which is the state every question is answered in.
func newTestWizard(t *testing.T, cur config.Config) *wizard {
t.Helper()
t.Cleanup(func() { applyTheme(defaultTheme) })
w := newWizard(nil, cur) // nil client: no fetch, the fallback list is used
w.reduced = false
w.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
w.Update(modelsMsg{models: pricedModels()})
// Past the intro, where every walk below starts.
w.anim, w.frame = animNone, introFrames
return w
}
// down presses the down arrow n times.
func down(w *wizard, n int) {
for i := 0; i < n; i++ {
w.Update(key("down"))
}
}
// settle runs the wizard's clock until nothing is animating, so a test can
// assert on the state a transition lands in rather than on a frame of it.
func settle(w *wizard) {
for i := 0; i < doneFrames*2 && w.anim != animNone; i++ {
w.Update(wizTick{})
}
}
// The walk a first run actually takes: five questions, five answers, saved.
func TestWizardWalkProducesTheChosenConfig(t *testing.T) {
w := newTestWizard(t, config.Config{})
w.Update(key("enter")) // intro → model
settle(w)
if w.step != stepModel {
t.Fatalf("enter on the intro landed on step %v", w.step)
}
// Second model in the list, whatever it is.
models := w.options()
down(w, 1)
wantModel := models[1].value
w.Update(key("enter"))
settle(w)
// Effort: "low" is first.
if w.step != stepEffort {
t.Fatalf("after the model, step is %v", w.step)
}
w.cursor = w.indexOf(w.options(), "max")
w.Update(key("enter"))
settle(w)
// Approvals: leave it on the default, "ask".
if w.step != stepApprovals {
t.Fatalf("after the effort, step is %v", w.step)
}
w.Update(key("enter"))
settle(w)
// Web search: leave it on the default, "none".
if w.step != stepWebSearch {
t.Fatalf("after the approvals, step is %v", w.step)
}
w.Update(key("enter"))
settle(w)
// Theme: pick the second one.
if w.step != stepTheme {
t.Fatalf("after the web search, step is %v", w.step)
}
down(w, 1)
wantTheme := w.options()[w.cursor].value
w.Update(key("enter"))
settle(w)
if w.result == nil {
t.Fatal("the wizard never finished")
}
got := *w.result
if got.Model != wantModel {
t.Errorf("model = %q, want %q", got.Model, wantModel)
}
if got.Effort != "max" {
t.Errorf("effort = %q, want max", got.Effort)
}
if got.Approvals != config.ApprovalsAsk {
t.Errorf("approvals = %q, want %q", got.Approvals, config.ApprovalsAsk)
}
if got.WebSearch != config.WebSearchNone {
t.Errorf("web search = %q, want %q", got.WebSearch, config.WebSearchNone)
}
if got.Theme != wantTheme {
t.Errorf("theme = %q, want %q", got.Theme, wantTheme)
}
}
// Re-running setup should not silently reset what is already configured: every
// step opens on the current value, so pressing enter through it is a no-op.
func TestWizardPreselectsWhatIsAlreadyConfigured(t *testing.T) {
cur := config.Config{
Model: "claude-haiku-4-5",
Effort: "low",
Approvals: config.ApprovalsAcceptAll,
WebSearch: config.WebSearchClaude,
Theme: "iris",
}
w := newTestWizard(t, cur)
for i := 0; i < 6 && w.result == nil; i++ {
w.Update(key("enter"))
settle(w)
}
if w.result == nil {
t.Fatal("pressing enter through every step did not finish the wizard")
}
want := cur
want.Setup = config.SetupDone // the one thing finishing does add
if *w.result != want {
t.Errorf("accepting every default changed the config:\n got %+v\nwant %+v", *w.result, want)
}
}
// Finishing is what the marker means, so it must come back on the result and
// not be something the wizard hands out before the last question is answered.
func TestWizardMarksTheSetupDoneOnlyWhenItFinishes(t *testing.T) {
w := newTestWizard(t, config.Config{})
w.Update(key("enter")) // → model
w.Update(key("enter")) // → effort
if w.cfg.Setup != "" {
t.Errorf("the setup was marked done two questions in: %q", w.cfg.Setup)
}
for i := 0; i < 5 && w.result == nil; i++ {
w.Update(key("enter"))
settle(w)
}
if w.result == nil {
t.Fatal("the wizard did not finish")
}
if !w.result.Done() {
t.Errorf("a finished wizard did not mark the setup done: %+v", *w.result)
}
}
// esc is back, not cancel: an answer given three steps ago must survive a
// walk backwards and forwards again.
func TestWizardBackKeepsEarlierAnswers(t *testing.T) {
w := newTestWizard(t, config.Config{})
w.Update(key("enter")) // → model
settle(w)
down(w, 2)
want := w.options()[w.cursor].value
w.Update(key("enter")) // → effort
settle(w)
w.Update(key("esc")) // back to model
settle(w)
if w.step != stepModel {
t.Fatalf("esc on the effort step landed on %v", w.step)
}
if got := w.options()[w.cursor].value; got != want {
t.Errorf("stepping back lost the highlight: on %q, want %q", got, want)
}
}
// ctrl+c abandons everything, including the theme the preview applied.
func TestWizardCancelRestoresTheme(t *testing.T) {
applyTheme("ember")
w := newTestWizard(t, config.Config{})
w.step, w.cursor = stepTheme, 0
w.restoreCursor()
down(w, 2) // previews by applying for real
if currentTheme.name == "ember" {
t.Fatal("the theme step did not preview by applying the palette")
}
w.Update(key("ctrl+c"))
if !w.cancelled {
t.Error("ctrl+c did not cancel")
}
if w.result != nil {
t.Error("a cancelled wizard produced a config")
}
if currentTheme.name != "ember" {
t.Errorf("cancel left the theme on %q instead of restoring ember", currentTheme.name)
}
}
// The wizard has to stay usable against an endpoint with no catalogue: a
// gateway that 404s /v1/models is a normal thing to be pointed at.
func TestWizardFallsBackWhenTheCatalogueFails(t *testing.T) {
w := newWizard(nil, config.Config{})
w.reduced = false
w.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
w.anim, w.frame = animNone, introFrames
w.Update(modelsMsg{err: errFetch{}})
w.step = stepModel
w.restoreCursor()
opts := w.options()
if len(opts) < 2 {
t.Fatalf("a failed fetch left %d options", len(opts))
}
if opts[0].value == customModel {
t.Error("the fallback offered nothing but the custom entry")
}
// And it has to say so rather than pretending the short list is the truth.
if view := w.View(); !strings.Contains(view, "couldn't reach") {
t.Error("the wizard does not report that the catalogue was unreachable")
}
}
type errFetch struct{}
func (errFetch) Error() string { return "no catalogue here" }
// The custom entry is what makes the wizard usable against a gateway serving
// a model this build has never heard of.
func TestWizardCustomModelAcceptsAnyID(t *testing.T) {
w := newTestWizard(t, config.Config{})
w.step = stepModel
w.restoreCursor()
w.cursor = w.indexOf(w.options(), customModel)
w.Update(key("enter"))
if !w.typing {
t.Fatal("the custom entry did not open a text field")
}
for _, r := range "my-gateway/llama-3" {
w.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
w.Update(key("enter"))
settle(w)
if w.cfg.Model != "my-gateway/llama-3" {
t.Errorf("model = %q, want the typed id", w.cfg.Model)
}
if w.step != stepEffort {
t.Errorf("accepting a custom id landed on %v, want the effort step", w.step)
}
}
// An empty custom id must not be accepted: it would be saved as "no model",
// which silently means the built-in default on the next run.
func TestWizardRejectsAnEmptyCustomModel(t *testing.T) {
w := newTestWizard(t, config.Config{})
w.step = stepModel
w.restoreCursor()
w.cursor = w.indexOf(w.options(), customModel)
w.Update(key("enter"))
w.Update(key("enter")) // nothing typed
if !w.typing {
t.Error("an empty id was accepted")
}
if w.step != stepModel {
t.Errorf("an empty id advanced to %v", w.step)
}
}
// Ticking has to stop. A wizard that keeps scheduling frames after it is done
// burns a core until the process exits.
func TestWizardStopsTickingWhenFinished(t *testing.T) {
w := newTestWizard(t, config.Config{})
w.step = stepTheme
w.restoreCursor()
w.Update(key("enter")) // → done, which animates
var cmd tea.Cmd
for i := 0; i < doneFrames*2; i++ {
cmd = w.Update(wizTick{})
if w.result != nil {
break
}
}
if w.result == nil {
t.Fatal("the closing animation never finished")
}
if cmd != nil {
t.Error("the wizard scheduled another frame after finishing")
}
}
// A terminal that cannot show the animations still has to show the wizard.
func TestWizardWithoutAnimationsIsStillAnswerable(t *testing.T) {
w := newWizard(nil, config.Config{})
w.reduced = true
w.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
w.Update(modelsMsg{models: pricedModels()})
w.Init()
// No ticks are scheduled, so the intro must already be over.
if w.anim != animNone {
t.Errorf("a still wizard opened mid-animation: %v", w.anim)
}
if cmd := w.Update(wizTick{}); cmd != nil {
t.Error("a still wizard scheduled a frame")
}
for i := 0; i < 6 && w.result == nil; i++ {
w.Update(key("enter"))
}
if w.result == nil {
t.Fatal("the wizard cannot be completed without animations")
}
if view := w.View(); view == "" {
t.Error("a still wizard renders nothing")
}
}
// Every step has to fit, and none of it may overflow the terminal it is drawn
// into — a wizard that wraps is a wizard that looks broken.
func TestWizardFitsItsScreen(t *testing.T) {
sizes := [][2]int{{100, 40}, {80, 24}, {62, 21}, {50, 18}}
for _, size := range sizes {
w := newWizard(nil, config.Config{})
w.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]})
w.Update(modelsMsg{models: pricedModels()})
for _, step := range []wizStep{stepIntro, stepModel, stepEffort, stepApprovals, stepWebSearch, stepTheme, stepDone} {
w.step = step
w.restoreCursor()
view := w.View()
for i, line := range strings.Split(view, "\n") {
if got := lipgloss.Width(line); got > size[0] {
t.Errorf("%dx%d step %v line %d is %d cells wide", size[0], size[1], step, i, got)
}
}
if h := strings.Count(view, "\n") + 1; h > size[1] {
t.Errorf("%dx%d step %v is %d lines tall", size[0], size[1], step, h)
}
}
}
}
// The theme list the wizard offers and the names the config will accept are
// two lists that must not drift apart: a theme you can pick but not save is
// worse than one that does not exist.
func TestThemeNamesMatchTheConfigsIdeaOfThem(t *testing.T) {
got, want := themeNames(), config.Themes
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("tui offers %v, config accepts %v", got, want)
}
for _, name := range want {
if lookupTheme(name).name != name {
t.Errorf("config accepts %q but the tui has no such theme", name)
}
}
}
// Nothing is written to disk by the wizard itself; the caller saves. But the
// caller can only save what the wizard fully answers, so no field may come
// back empty from a completed walk.
func TestAFinishedWizardAnswersEveryField(t *testing.T) {
w := newTestWizard(t, config.Config{})
for i := 0; i < 6 && w.result == nil; i++ {
w.Update(key("enter"))
settle(w)
}
if w.result == nil {
t.Fatal("the wizard never finished")
}
r := *w.result
if r.Model == "" || r.Effort == "" || r.Approvals == "" || r.Theme == "" {
t.Errorf("a completed wizard left a field empty: %+v", r)
}
if !llm.ValidEffort(r.Effort) {
t.Errorf("effort %q is not one the API accepts", r.Effort)
}
if !config.ValidTheme(r.Theme) {
t.Errorf("theme %q is not one the config accepts", r.Theme)
}
}
// Every built-in command has to be reachable the same three ways: typed,
// offered by the completion menu, and listed by /help. A command that works
// but is not in the catalogue is one nobody finds.
func TestSetupIsInTheCommandCatalogue(t *testing.T) {
m := newTestModel(t)
var found bool
for _, c := range m.commands() {
if c.name == "setup" {
found = true
if c.desc == "" {
t.Error("/setup is in the catalogue with no description")
}
}
}
if !found {
t.Fatal("/setup is not in the command catalogue")
}
if !strings.Contains(m.helpText(), "/setup") {
t.Error("/help does not list /setup")
}
// /set is a prefix of both, and the menu offers them in catalogue order:
// /settings first, because it is the one reached for more than once. One
// letter more is unambiguous, which is what has to keep working.
if got := matchCommands(m.commands(), "setu"); len(got) == 0 || got[0].name != "setup" {
t.Errorf("typing /setu does not offer /setup first: %v", got)
}
if got := matchCommands(m.commands(), "sett"); len(got) == 0 || got[0].name != "settings" {
t.Errorf("typing /sett does not offer /settings first: %v", got)
}
}
// The wizard and the /model picker must describe a model identically — they
// are the same list, and two spellings of one model id is a bug people report
// as "it changed my model".
func TestWizardAndPickerAgreeOnModels(t *testing.T) {
models := []llm.Model{{ID: "claude-opus-5", DisplayName: "Claude Opus 5"}}
w := newTestWizard(t, config.Config{})
w.Update(modelsMsg{models: models})
w.step = stepModel
w.restoreCursor()
opts := w.options()
if opts[0].value != models[0].ID {
t.Errorf("the wizard offers %q, want the id the endpoint gave", opts[0].value)
}
if opts[0].label != models[0].DisplayName {
t.Errorf("the wizard renames the model to %q", opts[0].label)
}
if want := priceLabel(models[0].ID); opts[0].note != want {
t.Errorf("the wizard prices it %q, the picker %q", opts[0].note, want)
}
}
// The effort levels the wizard offers are the API's, not a copy: a level that
// only exists in the wizard is a 400 nobody can explain.
func TestWizardOffersExactlyTheAPIsEffortLevels(t *testing.T) {
var got []string
for _, o := range effortOptions() {
got = append(got, o.value)
}
if strings.Join(got, ",") != strings.Join(llm.EffortLevels, ",") {
t.Errorf("the wizard offers %v, the API accepts %v", got, llm.EffortLevels)
}
for _, o := range effortOptions() {
if o.desc == "" {
t.Errorf("effort %q is offered with no explanation", o.value)
}
}
}
// The effort pill reports the running session's level, so the command it names
// must be the one that changes that — not the wizard, which changes what the
// next session starts with.
func TestEffortPillNamesTheCommandThatChangesIt(t *testing.T) {
m := newTestModel(t)
m.opts.Effort = "high"
got := m.activity()
if !strings.Contains(got, "/effort") {
t.Errorf("the effort pill does not name /effort: %q", got)
}
if strings.Contains(got, "/setup") {
t.Errorf("the effort pill points at /setup, which changes the saved default: %q", got)
}
}
// The wizard saves the whole config, but only asks about four of its fields.
// A setting it never mentions has to survive being carried through it, or
// running /setup would quietly turn auto-compaction back on.
func TestWizardKeepsSettingsItDoesNotAskAbout(t *testing.T) {
cur := config.Config{
Model: "claude-opus-5",
Effort: "high",
Approvals: config.ApprovalsAsk,
Theme: "ember",
AutoCompact: config.AutoCompactOff,
}
w := newWizard(nil, cur)
w.reduced = true
for w.step != stepDone && w.result == nil {
before := w.step
w.advance()
if w.step == before {
t.Fatalf("the wizard stopped advancing at step %d", w.step)
}
}
w.finish()
if w.result == nil {
t.Fatal("the wizard produced no config")
}
if w.result.AutoCompact != config.AutoCompactOff {
t.Errorf("auto_compact = %q, want it carried through untouched", w.result.AutoCompact)
}
}
// The web-search step is the only question whose answer can be a command line,
// so picking "a command on this machine" has to open the box already filled in
// with something that works — and pressing enter on that default must not pin
// the user to today's command line by writing it into their config.
func TestWizardCustomSearchCommand(t *testing.T) {
w := newTestWizard(t, config.Config{})
w.step = stepWebSearch
w.restoreCursor()
w.cursor = w.indexOf(w.options(), config.WebSearchCustom)
w.Update(key("enter"))
settle(w)
if !w.typing {
t.Fatal("choosing the custom command did not open the entry box")
}
if got := w.custom.Value(); got != config.DefaultSearchCommand {
t.Errorf("the box opened on %q, want the default %q", got, config.DefaultSearchCommand)
}
if !strings.Contains(w.View(), "{{query}}") {
t.Error("the entry screen does not explain the placeholders")
}
// Accepting the default stores the mode but no command, so a later build
// shipping a better one is picked up.
w.Update(key("enter"))
settle(w)
if w.typing {
t.Fatal("enter did not close the entry box")
}
if w.cfg.WebSearch != config.WebSearchCustom {
t.Errorf("mode = %q, want %q", w.cfg.WebSearch, config.WebSearchCustom)
}
if w.cfg.SearchCommand != "" {
t.Errorf("accepting the default wrote it into the config: %q", w.cfg.SearchCommand)
}
if w.step != stepTheme {
t.Errorf("after the command, step is %v, want the theme", w.step)
}
// An edited command is kept verbatim.
w.step = stepWebSearch
w.cursor = w.indexOf(w.options(), config.WebSearchCustom)
w.Update(key("enter"))
settle(w)
w.custom.SetValue("mysearch {{query}}")
w.Update(key("enter"))
settle(w)
if w.cfg.SearchCommand != "mysearch {{query}}" {
t.Errorf("search command = %q, want the edited one", w.cfg.SearchCommand)
}
}
// Escaping the box leaves the step as it was rather than half-answering it:
// the mode must not flip to custom on the strength of a question the user
// backed out of.
func TestWizardEscapingTheSearchBoxChangesNothing(t *testing.T) {
w := newTestWizard(t, config.Config{})
w.step = stepWebSearch
w.restoreCursor()
w.cursor = w.indexOf(w.options(), config.WebSearchCustom)
w.Update(key("enter"))
settle(w)
w.Update(key("esc"))
settle(w)
if w.typing {
t.Error("esc did not close the entry box")
}
if w.cfg.WebSearch != config.WebSearchNone {
t.Errorf("mode = %q, want it left at %q", w.cfg.WebSearch, config.WebSearchNone)
}
if w.step != stepWebSearch {
t.Errorf("esc moved off the step: %v", w.step)
}
}
// The bug this exists to stop: the wizard writes "web_search": "claude" to the
// file, the session it was run in cannot adopt it — the tool set and the
// hosted tool definitions were built before the first turn — and nothing says
// so. What you get is an agent that cannot search and a config file insisting
// that it can, twelve seconds apart.
func TestWizardSaysWebSearchNeedsARestart(t *testing.T) {
m := newTestModel(t)
m.opts.WebSearch = config.WebSearchNone // what this process is running with
m.opts.SaveConfig = func(config.Config) error { return nil }
m.wiz = newWizard(nil, m.opts.Config)
m.wiz.cfg.WebSearch = config.WebSearchClaude
m.wiz.finish()
m.closeWizard()
view := m.tr.render()
if !strings.Contains(view, "next start") {
t.Errorf("the wizard accepted a web-search answer without saying it needs a restart:\n%s", view)
}
if !strings.Contains(view, "hosted") {
t.Errorf("the notice does not say which answer it is about:\n%s", view)
}
}
// The other half: an answer the session already matches must not nag about a
// restart it does not need.
func TestWizardIsQuietWhenWebSearchDidNotChange(t *testing.T) {
m := newTestModel(t)
m.opts.WebSearch = config.WebSearchNone
m.opts.SaveConfig = func(config.Config) error { return nil }
m.wiz = newWizard(nil, m.opts.Config)
m.wiz.cfg.WebSearch = config.WebSearchNone
m.wiz.finish()
m.closeWizard()
if view := m.tr.render(); strings.Contains(view, "next start") {
t.Errorf("an unchanged answer asked for a restart:\n%s", view)
}
}