Vectrum
Sign In
1195 lines · 36.6 KB · Go
package tui

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"testing"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"

	"github.com/johannSo/codey/internal/config"
	"github.com/johannSo/codey/internal/llm"
	"github.com/johannSo/codey/internal/skills"
)

// 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, 0, false) // 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.Equal(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{}, 0, false)
	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{}, 0, false)
	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 {
		// Both conditional steps offered, so every screen there is gets drawn.
		w := newWizard(nil, config.Config{}, 14, true)
		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, stepClaudeSkills, stepHerdr, 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, 0, false)
	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, 0, false)
	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, 0, false)
	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)
	}
}

// newHerdrWizard is newTestWizard on a machine that has herdr, which is the
// only place the skill question is asked.
func newHerdrWizard(t *testing.T) *wizard {
	t.Helper()
	t.Cleanup(func() { applyTheme(defaultTheme) })

	w := newWizard(nil, config.Config{}, 0, true)
	w.reduced = false
	w.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
	w.Update(modelsMsg{models: pricedModels()})
	w.anim, w.frame = animNone, introFrames
	return w
}

// enterThrough presses enter until the wizard finishes or the presses run out.
func enterThrough(w *wizard, n int) {
	for i := 0; i < n && w.result == nil && !w.cancelled; i++ {
		w.Update(key("enter"))
		settle(w)
	}
}

// Asking someone who has never heard of herdr whether they want its skill is
// noise in the first five minutes of using Codey.
func TestTheHerdrStepIsOnlyAskedWhereHerdrIs(t *testing.T) {
	tests := []struct {
		name  string
		offer bool
		want  wizStep
	}{
		{name: "no herdr on the machine", offer: false, want: stepDone},
		{name: "herdr present", offer: true, want: stepHerdr},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			w := newWizard(nil, config.Config{}, 0, tc.offer)
			w.reduced = false
			w.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
			w.Update(modelsMsg{models: pricedModels()})
			w.anim, w.frame = animNone, introFrames

			// Intro, model, effort, approvals, web search, theme.
			enterThrough(w, 6)

			if w.step != tc.want {
				t.Errorf("after the theme step the wizard is on %v, want %v", w.step, tc.want)
			}
		})
	}
}

// The rail counts the questions this run will actually ask; a node nobody
// stands on is a progress bar that never fills.
func TestTheRailCountsTheHerdrStepOnlyWhenItIsAsked(t *testing.T) {
	if got := newWizard(nil, config.Config{}, 0, false).rails(); got != baseRailSteps {
		t.Errorf("rails = %d without herdr, want %d", got, baseRailSteps)
	}
	if got := newWizard(nil, config.Config{}, 0, true).rails(); got != baseRailSteps+1 {
		t.Errorf("rails = %d with herdr, want %d", got, baseRailSteps+1)
	}
}

func TestTheHerdrStepRecordsTheAnswer(t *testing.T) {
	tests := []struct {
		name string
		down int
		want bool
	}{
		{name: "yes is the row the cursor opens on", down: 0, want: true},
		{name: "no", down: 1, want: false},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			w := newHerdrWizard(t)
			enterThrough(w, 6)
			if w.step != stepHerdr {
				t.Fatalf("the walk did not reach the herdr step, stopped at %v", w.step)
			}

			down(w, tc.down)
			w.Update(key("enter"))
			settle(w)

			if w.result == nil {
				t.Fatal("answering the last question did not finish the wizard")
			}
			if w.installSkill != tc.want {
				t.Errorf("installSkill = %v, want %v", w.installSkill, tc.want)
			}
		})
	}
}

// The answer is an act, not a preference: nothing about it belongs in the file
// that is read on every launch.
func TestTheHerdrAnswerIsNotSavedAsConfig(t *testing.T) {
	w := newHerdrWizard(t)
	enterThrough(w, 7)

	if w.result == nil {
		t.Fatal("the walk did not finish")
	}
	if !w.installSkill {
		t.Fatal("this walk was supposed to accept the install")
	}
	want := config.Config{
		Model:     w.result.Model,
		Effort:    w.result.Effort,
		Approvals: config.ApprovalsAsk,
		WebSearch: config.WebSearchNone,
		Theme:     w.result.Theme,
		Setup:     config.SetupDone,
	}
	if !w.result.Equal(want) {
		t.Errorf("the herdr answer leaked into the config:\n got %+v\nwant %+v", *w.result, want)
	}
}

func TestBackFromTheHerdrStepReturnsToTheTheme(t *testing.T) {
	w := newHerdrWizard(t)
	enterThrough(w, 6)
	if w.step != stepHerdr {
		t.Fatalf("the walk did not reach the herdr step, stopped at %v", w.step)
	}

	w.Update(key("esc"))
	settle(w)

	if w.step != stepTheme {
		t.Errorf("esc from the herdr step landed on %v, want the theme step", w.step)
	}
}

// runCmd drains a command, including a batch of them, and returns every
// message that came out. The install answers from another goroutine, so this
// is how a test gets to the notice it produces.
func runCmd(t *testing.T, cmd tea.Cmd) []tea.Msg {
	t.Helper()
	if cmd == nil {
		return nil
	}
	msg := cmd()
	switch m := msg.(type) {
	case nil:
		return nil
	case tea.BatchMsg:
		var out []tea.Msg
		for _, c := range m {
			out = append(out, runCmd(t, c)...)
		}
		return out
	default:
		return []tea.Msg{msg}
	}
}

// deliver hands the model whatever the install answered with.
func deliver(m *model, msgs []tea.Msg) {
	for _, msg := range msgs {
		if tagged, ok := msg.(forTab); ok {
			msg = tagged.msg
		}
		if skill, ok := msg.(herdrSkillMsg); ok {
			m.onHerdrSkill(skill)
		}
	}
}

func TestSayingYesInstallsTheSkillAndSaysWhere(t *testing.T) {
	m := newTestModel(t)
	m.opts.SaveConfig = func(config.Config) error { return nil }
	m.opts.HerdrPresent = true
	called := 0
	m.opts.InstallHerdrSkill = func(context.Context) (string, error) {
		called++
		return "/home/u/.agents/skills/herdr/SKILL.md", nil
	}

	m.wiz = newWizard(nil, m.opts.Config, 0, true)
	m.wiz.installSkill = true
	m.wiz.finish()
	deliver(m, runCmd(t, m.closeWizard()))

	if called != 1 {
		t.Fatalf("the installer ran %d times, want once", called)
	}
	view := m.tr.render()
	if !strings.Contains(view, "/home/u/.agents/skills/herdr/SKILL.md") {
		t.Errorf("the notice does not name the file that was written:\n%s", view)
	}
	// Skills are listed into the cached prompt prefix at startup, so this
	// session genuinely cannot use what was just installed.
	if !strings.Contains(view, "next start") {
		t.Errorf("the notice does not say when the skill takes effect:\n%s", view)
	}
}

// A failed download is not a failed setup: the answers are already saved, and
// the only thing lost is a file that can be fetched again.
func TestAFailedInstallIsReportedWithoutLosingTheAnswers(t *testing.T) {
	m := newTestModel(t)
	saved := 0
	m.opts.SaveConfig = func(config.Config) error { saved++; return nil }
	m.opts.HerdrPresent = true
	m.opts.InstallHerdrSkill = func(context.Context) (string, error) {
		return "", errors.New("dial tcp: no route to host")
	}

	m.wiz = newWizard(nil, m.opts.Config, 0, true)
	m.wiz.installSkill = true
	m.wiz.finish()
	deliver(m, runCmd(t, m.closeWizard()))

	if saved != 1 {
		t.Errorf("the config was saved %d times, want once regardless of the install", saved)
	}
	view := m.tr.render()
	if !strings.Contains(view, "no route to host") {
		t.Errorf("the failure does not say what went wrong:\n%s", view)
	}
	if !strings.Contains(view, "/setup") {
		t.Errorf("the failure does not say how to try again:\n%s", view)
	}
}

func TestSayingNoInstallsNothing(t *testing.T) {
	m := newTestModel(t)
	m.opts.SaveConfig = func(config.Config) error { return nil }
	m.opts.HerdrPresent = true
	called := 0
	m.opts.InstallHerdrSkill = func(context.Context) (string, error) {
		called++
		return "", nil
	}

	m.wiz = newWizard(nil, m.opts.Config, 0, true)
	m.wiz.installSkill = false
	m.wiz.finish()
	runCmd(t, m.closeWizard())

	if called != 0 {
		t.Errorf("the installer ran %d times after the answer was no", called)
	}
}

// Cancelling the wizard cancels everything it was going to do.
func TestCancellingTheWizardInstallsNothing(t *testing.T) {
	m := newTestModel(t)
	m.opts.HerdrPresent = true
	called := 0
	m.opts.InstallHerdrSkill = func(context.Context) (string, error) {
		called++
		return "", nil
	}

	m.wiz = newWizard(nil, m.opts.Config, 0, true)
	m.wiz.installSkill = true
	m.wiz.cancel()
	runCmd(t, m.closeWizard())

	if called != 0 {
		t.Errorf("the installer ran %d times for a wizard that was escaped out of", called)
	}
}

// The rail and the counter are computed from the step's ordinal, so the step
// that is sometimes not there is the one that can put them out.
func TestTheHerdrStepDrawsAsTheLastOfSix(t *testing.T) {
	w := newHerdrWizard(t)
	enterThrough(w, 6)
	if w.step != stepHerdr {
		t.Fatalf("the walk did not reach the herdr step, stopped at %v", w.step)
	}

	view := w.View()
	if !strings.Contains(view, "step 6 of 6") {
		t.Errorf("the header does not count the herdr step as the last of six:\n%s", view)
	}
	if !strings.Contains(view, "herdr") {
		t.Errorf("the herdr step does not mention herdr:\n%s", view)
	}
	if !strings.Contains(view, skills.Dirname) {
		t.Errorf("the step does not say where the file goes:\n%s", view)
	}
}

// Without the step, nothing may claim there is a sixth.
func TestWithoutHerdrTheRailStillEndsAtFive(t *testing.T) {
	w := newTestWizard(t, config.Config{})
	enterThrough(w, 5)
	if w.step != stepTheme {
		t.Fatalf("the walk stopped at %v, want the theme step", w.step)
	}

	if view := w.View(); !strings.Contains(view, "step 5 of 5") {
		t.Errorf("the header miscounts without the herdr step:\n%s", view)
	}
}

// newClaudeWizard is newTestWizard on a machine where Claude Code has n
// skills, with or without herdr, which are the two things that decide which
// questions the run asks.
func newClaudeWizard(t *testing.T, n int, herdr bool) *wizard {
	t.Helper()
	t.Cleanup(func() { applyTheme(defaultTheme) })

	w := newWizard(nil, config.Config{}, n, herdr)
	w.reduced = false
	w.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
	w.Update(modelsMsg{models: pricedModels()})
	w.anim, w.frame = animNone, introFrames
	return w
}

// A question about skills that are not there is a question nobody can answer.
func TestTheClaudeSkillsStepIsOnlyAskedWhenThereAreSome(t *testing.T) {
	tests := []struct {
		name  string
		n     int
		herdr bool
		want  wizStep
	}{
		{name: "none, no herdr", n: 0, herdr: false, want: stepDone},
		{name: "none, herdr", n: 0, herdr: true, want: stepHerdr},
		{name: "some, no herdr", n: 14, herdr: false, want: stepClaudeSkills},
		{name: "some, herdr", n: 14, herdr: true, want: stepClaudeSkills},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			w := newClaudeWizard(t, tc.n, tc.herdr)
			// Intro, model, effort, approvals, web search, theme.
			enterThrough(w, 6)
			if w.step != tc.want {
				t.Errorf("after the theme step the wizard is on %v, want %v", w.step, tc.want)
			}
		})
	}
}

// Two conditional steps: the rail has to count exactly the ones asked, and
// the counter in the header has to agree with it on every step of every
// combination, or one run in four shows "step 6 of 7" with no seventh.
func TestTheRailCountsTheQuestionsActuallyAsked(t *testing.T) {
	tests := []struct {
		name  string
		n     int
		herdr bool
		rails int
	}{
		{name: "neither", n: 0, herdr: false, rails: baseRailSteps},
		{name: "claude only", n: 3, herdr: false, rails: baseRailSteps + 1},
		{name: "herdr only", n: 0, herdr: true, rails: baseRailSteps + 1},
		{name: "both", n: 3, herdr: true, rails: baseRailSteps + 2},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			w := newClaudeWizard(t, tc.n, tc.herdr)
			if got := w.rails(); got != tc.rails {
				t.Fatalf("rails = %d, want %d", got, tc.rails)
			}
			w.Update(key("enter")) // off the intro
			settle(w)
			for i := 1; w.result == nil; i++ {
				want := fmt.Sprintf("step %d of %d", i, tc.rails)
				if view := w.View(); !strings.Contains(view, want) {
					t.Errorf("on %v the header does not say %q:\n%s", w.step, want, view)
				}
				if got := w.railPos(); got != float64(i-1) {
					t.Errorf("on %v the marker is at %v, want %d", w.step, got, i-1)
				}
				w.Update(key("enter"))
				settle(w)
				if i > tc.rails {
					t.Fatalf("the walk asked more than %d questions", tc.rails)
				}
			}
		})
	}
}

func TestTheClaudeSkillsStepRecordsTheAnswer(t *testing.T) {
	tests := []struct {
		name string
		down int
		want string
	}{
		{name: "off is the row the cursor opens on", down: 0, want: config.ClaudeSkillsOff},
		{name: "use", down: 1, want: config.ClaudeSkillsUse},
		{name: "copy", down: 2, want: config.ClaudeSkillsCopy},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			w := newClaudeWizard(t, 14, false)
			enterThrough(w, 6)
			if w.step != stepClaudeSkills {
				t.Fatalf("the walk did not reach the Claude skills step, stopped at %v", w.step)
			}
			if view := w.View(); !strings.Contains(view, "14 skills") {
				t.Errorf("the question does not say how many skills are on offer:\n%s", view)
			}

			down(w, tc.down)
			w.Update(key("enter"))
			settle(w)

			if w.result == nil {
				t.Fatal("answering the last question did not finish the wizard")
			}
			if w.result.ClaudeSkills != tc.want {
				t.Errorf("claude_skills = %q, want %q", w.result.ClaudeSkills, tc.want)
			}
			if !config.ValidClaudeSkills(w.result.ClaudeSkills) {
				t.Errorf("claude_skills = %q is not one the config accepts", w.result.ClaudeSkills)
			}
		})
	}
}

// The wizard offers exactly the config's modes, in the config's order: an
// answer that cannot be saved is worse than no question.
func TestTheClaudeSkillsOptionsAreTheConfigsModes(t *testing.T) {
	var got []string
	for _, o := range claudeSkillsOptions() {
		got = append(got, o.value)
		if o.desc == "" {
			t.Errorf("mode %q is offered with no explanation", o.value)
		}
	}
	if strings.Join(got, ",") != strings.Join(config.ClaudeSkillsModes, ",") {
		t.Errorf("the wizard offers %v, the config accepts %v", got, config.ClaudeSkillsModes)
	}
}

// Re-running setup opens the step on the mode in force, so enter through it
// changes nothing — the rule every other step follows.
func TestTheClaudeSkillsStepPreselectsTheModeInForce(t *testing.T) {
	w := newClaudeWizard(t, 2, false)
	w.cfg.ClaudeSkills = config.ClaudeSkillsUse
	w.step = stepClaudeSkills
	w.restoreCursor()
	if got := w.options()[w.cursor].value; got != config.ClaudeSkillsUse {
		t.Errorf("the step opened on %q, want the configured %q", got, config.ClaudeSkillsUse)
	}
}

// Both conditional steps at once: back from herdr lands on the Claude step,
// and back from there on the theme. Without the Claude step, back from herdr
// skips straight to the theme.
func TestBackWalksTheConditionalStepsInOrder(t *testing.T) {
	w := newClaudeWizard(t, 2, true)
	enterThrough(w, 7)
	if w.step != stepHerdr {
		t.Fatalf("the walk did not reach the herdr step, stopped at %v", w.step)
	}
	w.Update(key("esc"))
	settle(w)
	if w.step != stepClaudeSkills {
		t.Errorf("esc from herdr landed on %v, want the Claude skills step", w.step)
	}
	w.Update(key("esc"))
	settle(w)
	if w.step != stepTheme {
		t.Errorf("esc from the Claude skills step landed on %v, want the theme", w.step)
	}

	w = newClaudeWizard(t, 0, true)
	enterThrough(w, 6)
	w.Update(key("esc"))
	settle(w)
	if w.step != stepTheme {
		t.Errorf("without Claude skills, esc from herdr landed on %v, want the theme", w.step)
	}
}

func TestSayingCopyCopiesTheSkillsAndSaysHowMany(t *testing.T) {
	m := newTestModel(t)
	m.opts.SaveConfig = func(config.Config) error { return nil }
	m.opts.ClaudeSkills = 14
	called := 0
	m.opts.CopyClaudeSkills = func() (int, error) { called++; return 14, nil }

	m.wiz = newWizard(nil, m.opts.Config, 14, false)
	m.wiz.cfg.ClaudeSkills = config.ClaudeSkillsCopy
	m.wiz.finish()
	runCmd(t, m.closeWizard())

	if called != 1 {
		t.Fatalf("the copy ran %d times, want once", called)
	}
	view := m.tr.render()
	if !strings.Contains(view, "copied 14") {
		t.Errorf("the notice does not say how many were copied:\n%s", view)
	}
	if !strings.Contains(view, skills.Dirname) {
		t.Errorf("the notice does not say where they went:\n%s", view)
	}
	if !strings.Contains(view, "next start") {
		t.Errorf("the notice does not say when they take effect:\n%s", view)
	}
}

// The copy is a one-off act the answer asks for; the other two answers ask
// for nothing to be done now.
func TestOtherAnswersCopyNothing(t *testing.T) {
	for _, mode := range []string{config.ClaudeSkillsOff, config.ClaudeSkillsUse} {
		t.Run(mode, func(t *testing.T) {
			m := newTestModel(t)
			m.opts.SaveConfig = func(config.Config) error { return nil }
			called := 0
			m.opts.CopyClaudeSkills = func() (int, error) { called++; return 0, nil }

			m.wiz = newWizard(nil, m.opts.Config, 14, false)
			m.wiz.cfg.ClaudeSkills = mode
			m.wiz.finish()
			runCmd(t, m.closeWizard())

			if called != 0 {
				t.Errorf("the copy ran %d times after the answer was %q", called, mode)
			}
		})
	}
}

// A failed copy is not a failed setup: the answer is saved, and /setup is how
// to have another go.
func TestAFailedCopyIsReportedWithoutLosingTheAnswers(t *testing.T) {
	m := newTestModel(t)
	saved := 0
	m.opts.SaveConfig = func(config.Config) error { saved++; return nil }
	m.opts.CopyClaudeSkills = func() (int, error) { return 0, errors.New("permission denied") }

	m.wiz = newWizard(nil, m.opts.Config, 14, false)
	m.wiz.cfg.ClaudeSkills = config.ClaudeSkillsCopy
	m.wiz.finish()
	runCmd(t, m.closeWizard())

	if saved != 1 {
		t.Errorf("the config was saved %d times, want once regardless of the copy", saved)
	}
	view := m.tr.render()
	if !strings.Contains(view, "permission denied") {
		t.Errorf("the failure does not say what went wrong:\n%s", view)
	}
	if !strings.Contains(view, "/setup") {
		t.Errorf("the failure does not say how to try again:\n%s", view)
	}
}

func TestCancellingTheWizardCopiesNothing(t *testing.T) {
	m := newTestModel(t)
	called := 0
	m.opts.CopyClaudeSkills = func() (int, error) { called++; return 0, nil }

	m.wiz = newWizard(nil, m.opts.Config, 14, false)
	m.wiz.cfg.ClaudeSkills = config.ClaudeSkillsCopy
	m.wiz.cancel()
	runCmd(t, m.closeWizard())

	if called != 0 {
		t.Errorf("the copy ran %d times for a wizard that was escaped out of", called)
	}
}