package tui
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"codey/internal/llm"
)
func someModels() []llm.Model {
return []llm.Model{
{ID: "claude-opus-5", DisplayName: "Claude Opus 5"},
{ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5"},
{ID: "claude-haiku-4-5-20251001", DisplayName: "Claude Haiku 4.5"},
}
}
// /model opens on a wait, because the list comes from the endpoint rather
// than from a table compiled into Codey.
func TestModelPickerAsksTheEndpoint(t *testing.T) {
m := newTestModel(t)
m.submit("/model")
if m.phase != phaseModel {
t.Fatalf("phase = %v, want the picker open", m.phase)
}
if !m.modelsLoading {
t.Error("the picker is not waiting on anything")
}
if !strings.Contains(m.modelView(), "anthropic") {
t.Errorf("the wait does not say where it is asking:\n%s", m.modelView())
}
m.Update(modelsMsg{models: someModels()})
if m.modelsLoading {
t.Error("still loading after the answer arrived")
}
view := m.modelView()
for _, want := range []string{"Claude Opus 5", "claude-sonnet-5", "Select model"} {
if !strings.Contains(view, want) {
t.Errorf("%q missing from the picker:\n%s", want, view)
}
}
}
// The cursor starts on what is in force, and that row is marked, so the
// picker opens saying where you already are.
func TestModelPickerStartsOnTheModelInForce(t *testing.T) {
m := newTestModel(t)
m.opts.Client.SetModel("claude-sonnet-5")
m.submit("/model")
m.Update(modelsMsg{models: someModels()})
if got := m.models[m.modelIdx].ID; got != "claude-sonnet-5" {
t.Errorf("cursor on %q, want the model in force", got)
}
if !strings.Contains(m.modelView(), "✓") {
t.Errorf("the model in force is not marked:\n%s", m.modelView())
}
}
// Prices we know are shown beside the id, including for the dated ids the API
// hands out; a model we have no price for simply says less.
func TestModelRowsShowTheIDAndWhatItCosts(t *testing.T) {
m := newTestModel(t)
m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m.submit("/model")
m.Update(modelsMsg{models: append(someModels(), llm.Model{ID: "mystery-1", DisplayName: "Mystery"})})
view := m.modelView()
if !strings.Contains(view, "claude-haiku-4-5-20251001") {
t.Errorf("the id is missing:\n%s", view)
}
if !strings.Contains(view, "$1") || !strings.Contains(view, "$5") {
t.Errorf("the price of a known model is missing:\n%s", view)
}
for _, line := range strings.Split(view, "\n") {
if strings.Contains(line, "Mystery") && strings.Contains(line, "$") {
t.Errorf("a model with no known price was given one: %q", line)
}
}
// A model the endpoint gave no name for is shown once, not twice: there
// is nothing to say beyond its id.
m2 := newTestModel(t)
m2.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m2.submit("/model")
m2.Update(modelsMsg{models: []llm.Model{{ID: "gateway/mystery"}}})
for _, line := range strings.Split(m2.modelView(), "\n") {
if strings.Count(line, "gateway/mystery") > 1 {
t.Errorf("the id is printed twice: %q", line)
}
}
}
func TestModelPickerAppliesOnEnter(t *testing.T) {
m := newTestModel(t)
m.submit("/model")
m.Update(modelsMsg{models: someModels()})
m.Update(key("down"))
if m.opts.Client.Model() != "claude-opus-5" {
t.Error("moving the cursor switched the model; it should only move")
}
m.Update(key("enter"))
if got := m.opts.Client.Model(); got != "claude-sonnet-5" {
t.Errorf("model = %q, want claude-sonnet-5", got)
}
if m.phase != phaseIdle {
t.Errorf("phase = %v after applying, want the prompt back", m.phase)
}
if out := m.tr.render(); !strings.Contains(out, "claude-sonnet-5") {
t.Errorf("the switch was not reported: %q", out)
}
if !strings.Contains(m.status(), "Sonnet 5") {
t.Errorf("the status bar still names the old model: %q", m.status())
}
}
func TestModelPickerNumbersJumpAndEscapeCancels(t *testing.T) {
m := newTestModel(t)
m.submit("/model")
m.Update(modelsMsg{models: someModels()})
m.Update(key("3"))
if got := m.models[m.modelIdx].ID; got != "claude-haiku-4-5-20251001" {
t.Errorf("3 selected %q", got)
}
m.Update(key("esc"))
if got := m.opts.Client.Model(); got != "claude-opus-5" {
t.Errorf("model = %q, want it left alone", got)
}
if m.phase != phaseIdle {
t.Errorf("phase = %v, want the prompt back", m.phase)
}
}
// An endpoint that cannot be asked says so, and says where to go instead,
// rather than leaving an empty picker on screen.
func TestModelPickerReportsAFailedFetch(t *testing.T) {
m := newTestModel(t)
m.submit("/model")
m.Update(modelsMsg{err: errors.New("404 not_found_error: unknown route")})
if m.phase != phaseIdle {
t.Errorf("phase = %v, want the picker gone", m.phase)
}
out := m.tr.render()
for _, want := range []string{"404", "-model"} {
if !strings.Contains(out, want) {
t.Errorf("%q missing from the report: %q", want, out)
}
}
}
func TestModelPickerReportsAnEmptyList(t *testing.T) {
m := newTestModel(t)
m.submit("/model")
m.Update(modelsMsg{models: nil})
if m.phase != phaseIdle {
t.Errorf("phase = %v, want the picker gone", m.phase)
}
if !strings.Contains(m.tr.render(), "no models") {
t.Errorf("an empty list was not explained: %q", m.tr.render())
}
}
// Escaping the wait means the answer is no longer wanted: it must not reopen
// the picker over whatever the user did next.
func TestModelPickerIgnoresAnAnswerItNoLongerWants(t *testing.T) {
m := newTestModel(t)
m.submit("/model")
m.Update(key("esc"))
m.Update(modelsMsg{models: someModels()})
if m.phase != phaseIdle {
t.Errorf("phase = %v, want the picker to have stayed closed", m.phase)
}
if len(m.models) != 0 {
t.Error("a discarded answer was kept")
}
}
// Typing the id is still the fast path, and it does not need the endpoint to
// answer anything.
func TestTypedModelSkipsThePicker(t *testing.T) {
m := newTestModel(t)
m.submit("/model claude-haiku-4-5")
if m.phase != phaseIdle {
t.Errorf("phase = %v, want no picker", m.phase)
}
if got := m.opts.Client.Model(); got != "claude-haiku-4-5" {
t.Errorf("model = %q", got)
}
}
// A long list scrolls inside the room the terminal has rather than pushing
// the transcript off the screen.
func TestModelPickerFitsTheTerminal(t *testing.T) {
var many []llm.Model
for i := 0; i < 30; i++ {
many = append(many, llm.Model{ID: fmt.Sprintf("claude-model-%d", i), DisplayName: fmt.Sprintf("Model %d", i)})
}
for _, h := range []int{12, 24, 40} {
m := newTestModel(t)
m.Update(tea.WindowSizeMsg{Width: 80, Height: h})
m.submit("/model")
m.Update(modelsMsg{models: many})
if got := strings.Count(m.View(), "\n") + 1; got > h {
t.Errorf("height %d: the screen is %d lines", h, got)
}
if !strings.Contains(m.modelView(), "more") {
t.Errorf("height %d: a list of 30 showed no count of what did not fit", h)
}
// The cursor stays visible as it walks down the list.
for i := 0; i < 29; i++ {
m.Update(key("down"))
}
if !strings.Contains(m.modelView(), "Model 29") {
t.Errorf("height %d: the list did not scroll to the cursor:\n%s", h, m.modelView())
}
}
}
// The fetch command is the part that talks to the endpoint; it turns both
// outcomes into a message the model can handle.
func TestFetchModelsCommandCarriesTheResult(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":[{"type":"model","id":"claude-opus-5","display_name":"Claude Opus 5"}],"has_more":false}`)
}))
defer srv.Close()
client := llm.New(llm.WithBaseURL(srv.URL), llm.WithAPIKey("k"))
msg, ok := fetchModels(client)().(modelsMsg)
if !ok {
t.Fatal("the command did not produce a modelsMsg")
}
if msg.err != nil {
t.Fatalf("err = %v", msg.err)
}
if len(msg.models) != 1 || msg.models[0].ID != "claude-opus-5" {
t.Errorf("models = %+v", msg.models)
}
}
// Cost is accumulated at the model that earned it. Pricing the whole session
// at whatever it ended on would rewrite history every time you switch.
func TestCostIsPricedAtTheModelInForceWhenItWasSpent(t *testing.T) {
m := newTestModel(t)
m.opts.Client.SetModel("claude-opus-5") // $5 per Mtok in
m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000_000}})
m.opts.Client.SetModel("claude-haiku-4-5") // $1 per Mtok in
m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000_000}})
if got := m.cost; got != 6 {
t.Errorf("cost = %v, want 6: five dollars of Opus and one of Haiku", got)
}
if !strings.Contains(m.status(), "$6.0000") {
t.Errorf("the status bar disagrees: %q", m.status())
}
}
// A turn ends with the session's totals, which on a resumed session include
// usage this process never saw. That remainder is priced once.
func TestTurnTotalsPriceOnlyWhatWasNotPricedAlready(t *testing.T) {
m := newTestModel(t)
m.opts.Client.SetModel("claude-opus-5")
m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000_000}})
total := llm.Usage{InputTokens: 3_000_000}
m.Update(turnDone{usage: total, turns: 1})
if got := m.cost; got != 15 {
t.Errorf("cost = %v, want 15: the session's three million at five dollars each", got)
}
// The same totals arriving again are the same tokens, not new ones.
m.Update(turnDone{usage: total, turns: 2})
if got := m.cost; got != 15 {
t.Errorf("cost = %v after a second turn that spent nothing, want 15", got)
}
}
// The context window is the other half of what a model is: /v1/models reports
// it, so the picker shows it rather than making you remember which model is
// the 200K one.
func TestModelRowsShowTheContextWindow(t *testing.T) {
m := newTestModel(t)
m.Update(tea.WindowSizeMsg{Width: 110, Height: 30})
m.submit("/model")
m.Update(modelsMsg{models: []llm.Model{
{ID: "claude-opus-5", DisplayName: "Claude Opus 5", MaxInputTokens: 1_000_000},
{ID: "claude-haiku-4-5", DisplayName: "Claude Haiku 4.5", MaxInputTokens: 200_000},
{ID: "mystery", DisplayName: "Mystery"},
}})
view := m.modelView()
for _, want := range []string{"1.0M in", "200.0k in"} {
if !strings.Contains(view, want) {
t.Errorf("%q missing from the picker:\n%s", want, view)
}
}
for _, line := range strings.Split(view, "\n") {
if strings.Contains(line, "Mystery") && strings.Contains(line, " in") {
t.Errorf("a context window was invented for a model that reported none: %q", line)
}
}
}
// The welcome block names the model, so it has to follow a switch: it is the
// header of the session, not a record of how the session started.
func TestWelcomeBlockFollowsTheModel(t *testing.T) {
m := newTestModel(t)
if !strings.Contains(m.tr.entries[0].text, "Opus 5") {
t.Fatalf("the welcome block does not name the model it started on:\n%s", m.tr.entries[0].text)
}
m.submit("/model claude-haiku-4-5")
welcome := m.tr.entries[0].text
if !strings.Contains(welcome, "Haiku 4.5") {
t.Errorf("the welcome block still does not name the new model:\n%s", welcome)
}
if strings.Contains(welcome, "Opus 5") {
t.Errorf("the welcome block names both models:\n%s", welcome)
}
}