package tui
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"codey/internal/llm"
)
// levelAt is where a level sits on the track, so tests can talk about
// positions without hard-coding the order.
func levelAt(name string) int {
for i, l := range llm.EffortLevels {
if l == name {
return i
}
}
return -1
}
// runeIndex is strings.Index counted in cells rather than in bytes.
func runeIndex(haystack, needle string) int {
i := strings.Index(haystack, needle)
if i < 0 {
return -1
}
return len([]rune(haystack[:i]))
}
// Bare /effort opens the slider on the level in force. Unset is not "nothing
// selected": the API's default is high, and that is where the marker belongs.
func TestEffortPickerOpensOnTheLevelInForce(t *testing.T) {
for _, tc := range []struct{ effort, want string }{
{"", "high"},
{"low", "low"},
{"max", "max"},
} {
m := newTestModel(t)
m.opts.Effort = tc.effort
m.submit("/effort")
if m.phase != phaseEffort {
t.Fatalf("effort %q: phase = %v, want the picker open", tc.effort, m.phase)
}
if got := llm.EffortLevels[m.effortIdx]; got != tc.want {
t.Errorf("effort %q: marker on %q, want %q", tc.effort, got, tc.want)
}
}
}
// It is a slider, so the ends are ends: moving past one stays put rather than
// wrapping around to the opposite extreme.
func TestEffortPickerMovesAndClampsAtTheEnds(t *testing.T) {
m := newTestModel(t)
m.opts.Effort = "high"
m.submit("/effort")
m.Update(key("right"))
if got := m.effortIdx; got != levelAt("xhigh") {
t.Errorf("right put the marker on %q", llm.EffortLevels[got])
}
m.Update(key("left"))
m.Update(key("left"))
if got := m.effortIdx; got != levelAt("medium") {
t.Errorf("two lefts put the marker on %q", llm.EffortLevels[got])
}
for i := 0; i < 5; i++ {
m.Update(key("left"))
}
if got := m.effortIdx; got != 0 {
t.Errorf("marker ran off the cheap end to %d", got)
}
for i := 0; i < 9; i++ {
m.Update(key("right"))
}
if got, want := m.effortIdx, len(llm.EffortLevels)-1; got != want {
t.Errorf("marker = %d, want it held at %d", got, want)
}
}
func TestEffortPickerNumbersJumpToALevel(t *testing.T) {
m := newTestModel(t)
m.submit("/effort")
m.Update(key("1"))
if got := llm.EffortLevels[m.effortIdx]; got != "low" {
t.Errorf("1 selected %q, want low", got)
}
m.Update(key("5"))
if got := llm.EffortLevels[m.effortIdx]; got != "max" {
t.Errorf("5 selected %q, want max", got)
}
// There is no sixth level; the key is not a level, so nothing moves.
m.Update(key("6"))
if got := llm.EffortLevels[m.effortIdx]; got != "max" {
t.Errorf("6 moved the marker to %q", got)
}
}
// Moving the marker changes nothing until enter; enter closes the picker and
// the level takes.
func TestEffortPickerAppliesOnEnter(t *testing.T) {
m := newTestModel(t)
m.opts.Effort = "high"
m.submit("/effort")
m.Update(key("right"))
if m.opts.Effort != "high" {
t.Errorf("effort = %q — moving the marker must not change anything", m.opts.Effort)
}
m.Update(key("enter"))
if m.opts.Effort != "xhigh" {
t.Errorf("effort = %q, want xhigh", m.opts.Effort)
}
if m.phase != phaseIdle {
t.Errorf("phase = %v after applying, want the prompt back", m.phase)
}
if !strings.Contains(m.tr.render(), "xhigh") {
t.Error("the change was not reported in the transcript")
}
if !strings.Contains(m.activity(), "xhigh") {
t.Error("the effort pill did not follow")
}
}
func TestEffortPickerCancelsOnEscape(t *testing.T) {
m := newTestModel(t)
m.opts.Effort = "low"
m.submit("/effort")
m.Update(key("right"))
m.Update(key("esc"))
if m.opts.Effort != "low" {
t.Errorf("effort = %q, want the level left alone", m.opts.Effort)
}
if m.phase != phaseIdle {
t.Errorf("phase = %v after cancelling, want the prompt back", m.phase)
}
}
// Typing the level is still the fast path: it applies without a picker to
// dismiss.
func TestTypedLevelSkipsThePicker(t *testing.T) {
m := newTestModel(t)
m.submit("/effort max")
if m.phase != phaseIdle {
t.Errorf("phase = %v, want no picker for a level that was typed out", m.phase)
}
if m.opts.Effort != "max" {
t.Errorf("effort = %q, want max", m.opts.Effort)
}
}
// The marker has to sit over the label it selects — that alignment is the
// whole illusion of a slider.
func TestSliderMarkerSitsOverTheSelectedLabel(t *testing.T) {
for _, width := range []int{60, 80, 120} {
for i, level := range llm.EffortLevels {
m := newTestModel(t)
m.Update(tea.WindowSizeMsg{Width: width, Height: 30})
m.submit("/effort")
m.effortIdx = i
lines := strings.Split(m.effortView(), "\n")
if len(lines) < 4 {
t.Fatalf("width %d: the slider is %d lines: %q", width, len(lines), lines)
}
// Columns are cells, not bytes: the track is drawn in box
// characters that are three bytes each.
track, labels := lines[1], lines[2]
marker := runeIndex(track, "▲")
if marker < 0 {
t.Fatalf("width %d: no marker on the track %q", width, track)
}
start := runeIndex(labels, level)
if start < 0 {
t.Fatalf("width %d: %q missing from the labels %q", width, level, labels)
}
if marker < start || marker >= start+len(level) {
t.Errorf("width %d, %s: marker at column %d, label spans %d-%d\n%s\n%s",
width, level, marker, start, start+len(level)-1, track, labels)
}
}
}
}
func TestSliderShowsEveryLevelAndItsPoles(t *testing.T) {
m := newTestModel(t)
m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
m.submit("/effort")
view := m.effortView()
for _, want := range append([]string{"Faster", "Smarter", "enter", "esc"}, llm.EffortLevels...) {
if !strings.Contains(view, want) {
t.Errorf("%q missing from the slider:\n%s", want, view)
}
}
}
// A terminal too narrow for five labels gets the level and the keys rather
// than a track with the words sliced in half.
func TestSliderFallsBackOnANarrowTerminal(t *testing.T) {
m := newTestModel(t)
m.Update(tea.WindowSizeMsg{Width: 30, Height: 20})
m.opts.Effort = "xhigh"
m.submit("/effort")
view := m.effortView()
if !strings.Contains(view, "xhigh") {
t.Errorf("the selected level is missing:\n%s", view)
}
if strings.Contains(view, "medium") {
t.Errorf("a label that cannot fit was rendered anyway:\n%s", view)
}
for _, line := range strings.Split(view, "\n") {
if len(line) > 30 {
t.Errorf("line is %d cells wide on a 30-cell terminal: %q", len(line), line)
}
}
if got := strings.Count(m.View(), "\n") + 1; got > 20 {
t.Errorf("the screen is %d lines on a 20-line terminal", got)
}
}
// While the picker owns the screen there is no prompt to type into, so keys
// that are not its own must not leak into the textarea.
func TestPickerSwallowsTyping(t *testing.T) {
m := newTestModel(t)
m.submit("/effort")
typeIn(m, "hello")
if got := m.input.Value(); got != "" {
t.Errorf("input = %q, want the picker to have eaten those keys", got)
}
if m.phase != phaseEffort {
t.Errorf("phase = %v, want the picker still open", m.phase)
}
}