Vectrum
Sign In
375 lines · 12.1 KB · Go
package tui

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

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

	"codey/internal/approval"
	"codey/internal/config"
	"codey/internal/llm"
)

// newSettingsModel builds a chat model with a config already on disk and a
// SaveConfig that records rather than writes. The returned pointer is nil
// until the panel saves something, which is what most of these tests are
// about.
func newSettingsModel(t *testing.T, cur config.Config) (*model, **config.Config) {
	t.Helper()
	t.Cleanup(func() { applyTheme(defaultTheme) })

	var saved *config.Config
	m := newModel(context.Background(), Options{
		Client: llm.New(llm.WithAPIKey("x")),
		Root:   "/tmp/repo",
		Policy: approval.New(nil),
		Config: cur,
		SaveConfig: func(c config.Config) error {
			saved = &c
			return nil
		},
	})
	m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
	return m, &saved
}

// open puts the panel up the way a person does.
func openSettingsPanel(t *testing.T, m *model) {
	t.Helper()
	m.submit("/settings")
	if m.phase != phaseSettings {
		t.Fatalf("/settings left the model in phase %v", m.phase)
	}
}

// The walk the panel exists for: change one thing, leave, and find it both in
// force and in the file.
func TestSettingsChangeAppliesAndSaves(t *testing.T) {
	m, saved := newSettingsModel(t, config.Config{Effort: "high", Theme: "ember", Setup: config.SetupDone})
	openSettingsPanel(t, m)

	m.Update(key("down")) // → effort
	if m.setIdx != setEffortRow {
		t.Fatalf("down from the model row landed on %v", m.setIdx)
	}
	m.Update(key("right"))

	want := m.opts.Effort
	if want == "high" {
		t.Fatal("cycling the effort row did not change the level in force")
	}
	m.Update(key("esc"))

	if m.phase != phaseIdle {
		t.Fatalf("esc did not close the panel: phase %v", m.phase)
	}
	if *saved == nil {
		t.Fatal("leaving the panel saved nothing")
	}
	if (*saved).Effort != want {
		t.Errorf("saved effort %q, want the %q that is in force", (*saved).Effort, want)
	}
	if !(*saved).Done() {
		t.Error("the panel dropped the setup marker that was already in the file")
	}
	if !strings.Contains(m.tr.render(), "settings saved") {
		t.Error("the change was never reported in the transcript")
	}
}

// A theme is applied as you move onto it, not when you leave: the whole point
// of a panel over a wizard step is watching the transcript change colour.
func TestSettingsThemeAppliesWhileYouCycle(t *testing.T) {
	m, saved := newSettingsModel(t, config.Config{Theme: "ember"})
	openSettingsPanel(t, m)

	m.setIdx = setThemeRow
	m.Update(key("right"))
	if currentTheme.name == "ember" {
		t.Fatal("cycling the theme row did not repaint")
	}
	previewed := currentTheme.name

	m.Update(key("esc"))
	if currentTheme.name != previewed {
		t.Errorf("leaving the panel undid the theme: %q, want %q", currentTheme.name, previewed)
	}
	if *saved == nil || (*saved).Theme != previewed {
		t.Errorf("the previewed theme was not saved: %+v", *saved)
	}
}

// Browsing is not editing. Opening the panel, walking every row and leaving
// must not rewrite the file — and must not say it did.
func TestSettingsSavesNothingWhenNothingChanged(t *testing.T) {
	m, saved := newSettingsModel(t, config.Config{Effort: "low", Theme: "mint", Setup: config.SetupDone})
	openSettingsPanel(t, m)

	for i := 0; i < int(numSettingRows)+2; i++ {
		m.Update(key("down"))
	}
	for i := 0; i < int(numSettingRows)+2; i++ {
		m.Update(key("up"))
	}
	m.Update(key("esc"))

	if *saved != nil {
		t.Errorf("walking the panel rewrote the config: %+v", *saved)
	}
	if strings.Contains(m.tr.render(), "settings saved") {
		t.Error("nothing changed, but the panel said it saved")
	}
}

// A value cycled back to where it started is not a change either.
func TestSettingsSavesNothingWhenCycledBack(t *testing.T) {
	m, saved := newSettingsModel(t, config.Config{Theme: "ember", Setup: config.SetupDone})
	openSettingsPanel(t, m)

	m.setIdx = setThemeRow
	for range themeOptions() {
		m.Update(key("right"))
	}
	if currentTheme.name != "ember" {
		t.Fatalf("a full cycle did not come back to ember: %q", currentTheme.name)
	}
	m.Update(key("esc"))
	if *saved != nil {
		t.Errorf("a value cycled back to itself was still written: %+v", *saved)
	}
}

// -yolo is a decision about one session, taken on the command line. Somebody
// who opens the panel on such a session to change their theme must not find
// "run everything without asking" written into their config afterwards.
func TestSettingsDoesNotPersistAnApprovalModeItWasNotAsked(t *testing.T) {
	m, saved := newSettingsModel(t, config.Config{Theme: "ember", Approvals: config.ApprovalsAsk})
	m.opts.Policy.YOLO = true // as -yolo leaves it
	openSettingsPanel(t, m)

	m.setIdx = setThemeRow
	m.Update(key("right"))
	m.Update(key("esc"))

	if *saved == nil {
		t.Fatal("the theme change was not saved")
	}
	if (*saved).Approvals != config.ApprovalsAsk {
		t.Errorf("the session's -yolo was written to the config: %q", (*saved).Approvals)
	}
}

// Changing approvals in the panel does take, both in the file and in the
// policy the agent actually consults.
func TestSettingsApprovalsReachThePolicy(t *testing.T) {
	m, saved := newSettingsModel(t, config.Config{Approvals: config.ApprovalsAsk, Setup: config.SetupDone})
	openSettingsPanel(t, m)

	m.setIdx = setApprovalsRow
	m.Update(key("right"))
	if !m.opts.Policy.YOLO {
		t.Fatal("cycling to accept-all did not reach the policy")
	}
	m.Update(key("esc"))
	if *saved == nil || (*saved).Approvals != config.ApprovalsAcceptAll {
		t.Errorf("accept-all was not saved: %+v", *saved)
	}
}

// The panel shows the session, not the file: /effort five minutes ago is what
// the effort row has to say, or the two disagree in front of the user.
func TestSettingsShowsWhatTheSessionIsRunning(t *testing.T) {
	m, _ := newSettingsModel(t, config.Config{Effort: "low", Model: "claude-opus-5"})
	m.useEffort("max")
	m.opts.Client.SetModel("claude-haiku-4-5")
	openSettingsPanel(t, m)

	view := m.settingsView()
	for _, want := range []string{"max", prettyModel("claude-haiku-4-5")} {
		if !strings.Contains(view, want) {
			t.Errorf("the panel does not report %q:\n%s", want, view)
		}
	}
}

// The model row is the one that cannot cycle: its choices come from the
// endpoint, so enter hands over to the picker and the picker comes back here.
func TestSettingsModelRowOpensThePickerAndReturns(t *testing.T) {
	m, saved := newSettingsModel(t, config.Config{Model: "claude-opus-5", Setup: config.SetupDone})
	openSettingsPanel(t, m)

	m.Update(key("right")) // the model row does not cycle
	if m.opts.Client.Model() != "claude-opus-5" {
		t.Errorf("the model row cycled to %q", m.opts.Client.Model())
	}

	m.Update(key("enter"))
	if m.phase != phaseModel {
		t.Fatalf("enter on the model row left the model in phase %v", m.phase)
	}
	m.Update(modelsMsg{models: []llm.Model{
		{ID: "claude-opus-5", DisplayName: "Claude Opus 5"},
		{ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5"},
	}})
	m.Update(key("down"))
	m.Update(key("enter"))

	if m.phase != phaseSettings {
		t.Fatalf("the picker did not come back to the panel: phase %v", m.phase)
	}
	if m.opts.Client.Model() != "claude-sonnet-5" {
		t.Fatalf("the picked model did not take: %q", m.opts.Client.Model())
	}
	m.Update(key("esc"))
	if *saved == nil || (*saved).Model != "claude-sonnet-5" {
		t.Errorf("the picked model was not saved: %+v", *saved)
	}
}

// Escaping the picker is not choosing a model, and must leave the panel with
// nothing to save.
func TestSettingsPickerEscapeReturnsWithoutChanging(t *testing.T) {
	m, saved := newSettingsModel(t, config.Config{Model: "claude-opus-5", Setup: config.SetupDone})
	openSettingsPanel(t, m)

	m.Update(key("enter"))
	m.Update(modelsMsg{models: []llm.Model{{ID: "claude-opus-5"}, {ID: "claude-sonnet-5"}}})
	m.Update(key("esc"))
	if m.phase != phaseSettings {
		t.Fatalf("esc in the picker did not return to the panel: phase %v", m.phase)
	}
	m.Update(key("esc"))
	if *saved != nil {
		t.Errorf("an escaped picker still wrote the config: %+v", *saved)
	}
}

// /model opened from the prompt has nothing to do with the panel and must
// still land back at the prompt.
func TestModelPickerStillReturnsToThePrompt(t *testing.T) {
	m, _ := newSettingsModel(t, config.Config{Model: "claude-opus-5"})
	m.submit("/model")
	m.Update(modelsMsg{models: []llm.Model{{ID: "claude-opus-5"}}})
	m.Update(key("enter"))
	if m.phase != phaseIdle {
		t.Errorf("the picker returned to phase %v rather than the prompt", m.phase)
	}
}

// The settings are already in force by the time the file is written, so a
// failed write is a warning, not a lost change.
func TestSettingsReportsAFailedSave(t *testing.T) {
	m, _ := newSettingsModel(t, config.Config{Theme: "ember"})
	m.opts.SaveConfig = func(config.Config) error { return errors.New("disk is full") }
	openSettingsPanel(t, m)

	m.setIdx = setThemeRow
	m.Update(key("right"))
	applied := currentTheme.name
	m.Update(key("esc"))

	if currentTheme.name != applied {
		t.Error("a failed save undid the change it failed to record")
	}
	if !strings.Contains(m.tr.render(), "disk is full") {
		t.Errorf("a failed save was not reported:\n%s", m.tr.render())
	}
}

// The panel is not the wizard: changing a theme is not answering the setup
// questions, so somebody who has never seen the wizard still gets it.
func TestSettingsDoesNotMarkTheSetupDone(t *testing.T) {
	m, saved := newSettingsModel(t, config.Config{})
	openSettingsPanel(t, m)

	m.setIdx = setThemeRow
	m.Update(key("right"))
	m.Update(key("esc"))

	if *saved == nil {
		t.Fatal("the theme change was not saved")
	}
	if (*saved).Done() {
		t.Error("the settings panel claimed the setup was finished")
	}
}

// A click on a row is a choice, the way it is everywhere else in the chrome.
func TestSettingsClickPicksTheRowUnderThePointer(t *testing.T) {
	m, _ := newSettingsModel(t, config.Config{Theme: "ember"})
	openSettingsPanel(t, m)

	secs := m.layout()
	top := maxInt(1, m.height-rowsOf(secs))
	// The rows start under the heading, and the panel is the second section.
	row := top + lipgloss.Height(secs[0].text) + settingsHeadRows + int(setThemeRow)
	m.Update(tea.MouseMsg{X: 4, Y: row, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft})

	if m.setIdx != setThemeRow {
		t.Fatalf("the click landed on row %v, want the theme row", m.setIdx)
	}
	if currentTheme.name == "ember" {
		t.Error("clicking a row did not change its value")
	}
}

// Every row has to be spelled out, or the panel is a list of blanks.
func TestSettingsViewNamesEveryRow(t *testing.T) {
	m, _ := newSettingsModel(t, config.Config{Effort: "high", Theme: "ember"})
	openSettingsPanel(t, m)

	view := m.settingsView()
	for r := settingRow(0); r < numSettingRows; r++ {
		if !strings.Contains(view, settingLabels[r]) {
			t.Errorf("the panel does not name the %s row:\n%s", settingLabels[r], view)
		}
		if m.settingLabel(r) == "" {
			t.Errorf("the %s row has no value to show", settingLabels[r])
		}
	}
	if !strings.Contains(view, "saved to") {
		t.Error("the panel does not say where it saves")
	}
}

// A narrow terminal must not panic the renderer, the way every other piece of
// chrome is expected to survive one.
func TestSettingsViewSurvivesANarrowScreen(t *testing.T) {
	m, _ := newSettingsModel(t, config.Config{})
	openSettingsPanel(t, m)
	for _, w := range []int{1, 8, 20, 40} {
		m.Update(tea.WindowSizeMsg{Width: w, Height: 20})
		for _, line := range strings.Split(m.settingsView(), "\n") {
			if lipgloss.Width(line) > w {
				t.Errorf("width %d: a line is %d wide: %q", w, lipgloss.Width(line), line)
			}
		}
	}
}

// The panel shows four rows but writes the whole config, so a setting it has
// no row for has to come through untouched — otherwise cycling the theme would
// quietly turn auto-compaction back on.
func TestSettingsKeepsSettingsItHasNoRowFor(t *testing.T) {
	m, saved := newSettingsModel(t, config.Config{
		Theme:       "ember",
		Setup:       config.SetupDone,
		AutoCompact: config.AutoCompactOff,
	})
	openSettingsPanel(t, m)

	m.setIdx = setThemeRow
	m.Update(key("right"))
	m.Update(key("esc"))

	if *saved == nil {
		t.Fatal("changing the theme saved nothing")
	}
	if (*saved).AutoCompact != config.AutoCompactOff {
		t.Errorf("auto_compact = %q, want it carried through untouched", (*saved).AutoCompact)
	}
}