Vectrum
Sign In
357 lines · 10.3 KB · Go
package tui

import (
	"strings"
	"testing"

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

	"harness/internal/config"
	"harness/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(wizModels{models: llm.KnownModels()})
	// 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: four questions, four 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)

	// Theme: pick the second one.
	if w.step != stepTheme {
		t.Fatalf("after the approvals, 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.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 enter-enter-enter-enter is a no-op.
func TestWizardPreselectsWhatIsAlreadyConfigured(t *testing.T) {
	cur := config.Config{
		Model:     "claude-haiku-4-5",
		Effort:    "low",
		Approvals: config.ApprovalsAcceptAll,
		Theme:     "iris",
	}
	w := newTestWizard(t, cur)

	for i := 0; i < 5 && w.result == nil; i++ {
		w.Update(key("enter"))
		settle(w)
	}
	if w.result == nil {
		t.Fatal("four enters did not finish the wizard")
	}
	if *w.result != cur {
		t.Errorf("accepting every default changed the config:\n got %+v\nwant %+v", *w.result, cur)
	}
}

// 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(wizModels{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(wizModels{models: llm.KnownModels()})

	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 < 5 && 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(wizModels{models: llm.KnownModels()})

		for _, step := range []wizStep{stepIntro, stepModel, stepEffort, stepApprovals, 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 !config.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)
	}
}