Download
234 lines 7.4 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
package tui

import (
	"os"
	"path/filepath"
	"reflect"
	"slices"
	"strings"
	"syscall"
	"testing"
	"time"

	"charm.land/bubbles/v2/textinput"
	tea "charm.land/bubbletea/v2"

	"blinker/internal/config"
)

// Every setting in agent.json and config.json is in /config, once.
func TestSettingsCoverConfig(t *testing.T) {
	listed := map[string]bool{}
	for _, s := range settings {
		if s.file != "" {
			continue
		}
		if listed[s.key] {
			t.Errorf("%s is listed twice", s.key)
		}
		listed[s.key] = true
	}
	for _, typ := range []reflect.Type{reflect.TypeFor[config.Config](), reflect.TypeFor[config.Sandbox]()} {
		for i := range typ.NumField() {
			key, _, _ := strings.Cut(typ.Field(i).Tag.Get("json"), ",")
			if key == "-" {
				continue
			}
			if !listed[key] {
				t.Errorf("%s is not in /config", key)
			}
			delete(listed, key)
		}
	}
	for key := range listed {
		t.Errorf("%s is not a setting", key)
	}
}

func byKey(t *testing.T, key string) setting {
	t.Helper()
	for _, s := range settings {
		if s.key == key {
			return s
		}
	}
	t.Fatalf("no setting %s", key)
	return setting{}
}

func TestShowSetting(t *testing.T) {
	cfg := config.Defaults()
	cfg.APIKey = "sk-secret"
	cfg.RequestTimeout = config.Duration(90 * time.Minute)
	for key, want := range map[string]string{
		"base_url":             "http://localhost:8080/v1", // no quotes
		"api_key":              "*********",                // as long as sk-secret
		"model":                "none",
		"context_window":       "ask the server",
		"thinking":             "on",
		"sampling":             "sent",
		"allow_private_egress": "blocked",
		"temperature":          "0.6",
		"command_timeout":      "2m",
		"request_timeout":      "1h30m",
		"proxy_ports":          "443, 80",
		"extra_blocklist":      "none",
		"thinking_param":       "chat_template_kwargs",
	} {
		if got := show(cfg, byKey(t, key)); got != want {
			t.Errorf("%s shows %q, want %q", key, got, want)
		}
	}
	cfg.DiffCommand = []string{"delta", "--width={width}"}
	if got := show(cfg, byKey(t, "diff_command")); got != "delta --width={width}" {
		t.Errorf("diff_command shows %q", got)
	}
	cfg.CommandTimeout = config.Duration(30 * time.Second)
	if got := show(cfg, byKey(t, "command_timeout")); got != "30s" {
		t.Errorf("30s shows %q", got)
	}
}

func TestSetSetting(t *testing.T) {
	cfg := config.Defaults()
	for _, tc := range []struct {
		key, typed string
		check      func(config.Config) bool
	}{
		{"command_timeout", "5m", func(c config.Config) bool { return time.Duration(c.CommandTimeout) == 5*time.Minute }},
		{"model", "  4 ", func(c config.Config) bool { return c.Model == "4" }},
		{"proxy_ports", "443, 8080,", func(c config.Config) bool { return slices.Equal(c.Sandbox.ProxyPorts, []int{443, 8080}) }},
		{"proxy_ports", "", func(c config.Config) bool { return len(c.Sandbox.ProxyPorts) == 0 }},
		{"diff_command", " delta --width={width} ", func(c config.Config) bool {
			return slices.Equal(c.DiffCommand, []string{"delta", "--width={width}"})
		}},
		{"max_tokens", "", func(c config.Config) bool { return c.MaxTokens == 0 }},
		{"temperature", "1.2", func(c config.Config) bool { return c.Temperature == 1.2 }},
	} {
		got, err := set(cfg, byKey(t, tc.key), tc.typed)
		if err != nil || !tc.check(got) {
			t.Errorf("%s = %q: %+v, %v", tc.key, tc.typed, got, err)
		}
	}
	if !slices.Equal(cfg.Sandbox.ProxyPorts, []int{443, 80}) {
		t.Error("set changed the config it was given")
	}
	for key, typed := range map[string]string{
		"max_tokens":      "lots",     // not a number
		"proxy_ports":     "443, web", // not numbers
		"temperature":     "3",        // out of range
		"command_timeout": "5",        // no unit
		"scratch_root":    "relative", // not absolute
		"base_url":        "nowhere",  // not a URL
	} {
		if got, err := set(cfg, byKey(t, key), typed); err == nil {
			t.Errorf("%s = %q accepted: %+v", key, typed, got)
		}
	}
}

func TestSettingsScreen(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, config.AgentFile)
	cfg := config.Defaults()
	cfg.APIKey = "sk-secret"
	s := newSettings(cfg, dir, 80)
	if strings.Contains(s.View(), "sk-secret") || strings.Contains(s.View(), `"`) {
		t.Errorf("the screen shows the key or quotes:\n%s", s.View())
	}
	choose := func(key string) {
		for s.sel = 0; settings[s.sel].key != key; s.sel++ {
		}
	}
	enter := func() { s, _ = s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) }
	choose("max_tokens")
	enter()
	if !s.editing || s.input.Value() != "32768" {
		t.Fatalf("editing %v, value %q", s.editing, s.input.Value())
	}
	// Not valid: kept for fixing, and nothing saved.
	s.input.SetValue("-5")
	enter()
	if !s.editing || s.errText == "" {
		t.Errorf("an invalid value was accepted")
	}
	if _, err := os.Stat(path); err == nil {
		t.Error("an invalid value was saved")
	}
	s.input.SetValue("1000")
	enter()
	if s.editing || s.errText != "" || s.cfg.MaxTokens != 1000 {
		t.Fatalf("editing %v, error %q, max_tokens %d", s.editing, s.errText, s.cfg.MaxTokens)
	}
	// On/off toggles, and choices cycle, without typing.
	choose("thinking")
	enter()
	choose("max_tokens_param")
	enter()
	if s.editing || s.cfg.Thinking || s.cfg.MaxTokensParam != "max_completion_tokens" {
		t.Errorf("thinking %v, max_tokens_param %q", s.cfg.Thinking, s.cfg.MaxTokensParam)
	}
	// The API key is typed blind, from empty.
	choose("api_key")
	enter()
	if s.input.Value() != "" || s.input.EchoMode != textinput.EchoPassword {
		t.Errorf("editing the key starts with %q", s.input.Value())
	}
	s, _ = s.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
	saved, err := config.Load(dir)
	if err != nil || saved.MaxTokens != 1000 || saved.Thinking || saved.APIKey != "sk-secret" {
		t.Errorf("saved %+v, %v", saved, err)
	}
	_, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
	if done, ok := cmd().(settingsDone); !ok || !done.saved || done.cfg.MaxTokens != 1000 {
		t.Errorf("closing gave %+v", done)
	}
	// Ctrl+c closes it too, even mid-edit, without saving the edit.
	choose("max_tokens")
	enter()
	s.input.SetValue("2000")
	_, cmd = s.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
	if done, ok := cmd().(settingsDone); !ok || done.cfg.MaxTokens != 1000 {
		t.Errorf("ctrl+c gave %+v", done)
	}
}

// A file setting shows how long the file is, and enter edits it.
func TestSettingsFile(t *testing.T) {
	dir := filepath.Join(t.TempDir(), "blinker")
	s := newSettings(config.Defaults(), dir, 80)
	for s.sel = 0; settings[s.sel].file != "AGENTS.md"; s.sel++ {
	}
	if !strings.Contains(s.View(), "Your instructions    none") {
		t.Errorf("no file shows as none:\n%s", s.View())
	}
	_, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
	if cmd == nil {
		t.Error("enter does not open the editor")
	}
	path := filepath.Join(dir, "AGENTS.md")
	if err := os.WriteFile(path, []byte("Be brief.\nUse tabs.\n"), 0o600); err != nil {
		t.Fatal(err)
	}
	s, _ = s.Update(settingsFileEdited{})
	if !strings.Contains(s.View(), "Your instructions    2 lines") {
		t.Errorf("the file's length is not shown:\n%s", s.View())
	}
	// A FIFO there would block a plain read until something wrote to it.
	if err := os.Remove(path); err != nil {
		t.Fatal(err)
	}
	if err := syscall.Mkfifo(path, 0o600); err != nil {
		t.Fatal(err)
	}
	done := make(chan settingsModel, 1)
	go func() { s, _ := s.Update(settingsFileEdited{}); done <- s }()
	select {
	case s = <-done:
	case <-time.After(5 * time.Second):
		t.Fatal("a FIFO hangs the settings screen")
	}
	if !strings.Contains(s.View(), "Your instructions    unreadable") {
		t.Errorf("a FIFO does not show as unreadable:\n%s", s.View())
	}
}