termtap/internal/tui/model_update_test.go
Hayden Hargreaves 8c399b6754 feat: search functionality.
Bit rushed, but its on the site. Going to add a real larger prompt about
it.
2026-05-01 14:51:00 -07:00

388 lines
11 KiB
Go

package tui
import (
"errors"
"net/http"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/google/uuid"
"termtap.dev/internal/model"
)
func TestNewModelDefaults(t *testing.T) {
t.Parallel()
ch := make(chan model.Event)
m := NewModel(ch, Controls{})
if m.channel != ch {
t.Fatal("channel not set")
}
if len(m.events) != 0 || len(m.requests) != 0 {
t.Fatal("events/requests should initialize empty")
}
if m.width != 0 || m.height != 0 {
t.Fatal("width/height should initialize zero")
}
if m.showEvents || m.showStd || m.showSearch || m.restarting {
t.Fatal("toggle flags should initialize false")
}
if m.searchQuery != "" {
t.Fatal("search query should initialize empty")
}
}
func TestInitBatchesEventAndTick(t *testing.T) {
t.Parallel()
ch := make(chan model.Event)
m := NewModel(ch, Controls{})
cmd := m.Init()
if cmd == nil {
t.Fatal("Init() returned nil cmd")
}
if _, ok := cmd().(tea.BatchMsg); !ok {
t.Fatalf("Init cmd message type = %T, want tea.BatchMsg", cmd())
}
}
func TestWaitForEvent(t *testing.T) {
t.Parallel()
t.Run("returns EventMsg when channel has value", func(t *testing.T) {
t.Parallel()
ch := make(chan model.Event, 1)
ch <- model.Event{Type: model.EventTypeWarn, Body: "hello"}
msg := waitForEvent(ch)()
ev, ok := msg.(EventMsg)
if !ok {
t.Fatalf("msg type = %T, want EventMsg", msg)
}
if ev.value.Body != "hello" {
t.Fatalf("event body = %q, want %q", ev.value.Body, "hello")
}
})
t.Run("returns ErrMsg when channel closed", func(t *testing.T) {
t.Parallel()
ch := make(chan model.Event)
close(ch)
msg := waitForEvent(ch)()
if _, ok := msg.(ErrMsg); !ok {
t.Fatalf("msg type = %T, want ErrMsg", msg)
}
})
}
func TestMessagesCommands(t *testing.T) {
t.Parallel()
t.Run("restartCmd nil restart returns nil", func(t *testing.T) {
t.Parallel()
if cmd := restartCmd(nil); cmd != nil {
t.Fatal("restartCmd(nil) should return nil")
}
})
t.Run("restartCmd wraps restart result", func(t *testing.T) {
t.Parallel()
wantErr := errors.New("boom")
msg := restartCmd(func() error { return wantErr })()
rm, ok := msg.(RestartResultMsg)
if !ok {
t.Fatalf("msg type = %T, want RestartResultMsg", msg)
}
if !errors.Is(rm.err, wantErr) {
t.Fatalf("restart result error = %v, want %v", rm.err, wantErr)
}
})
t.Run("tickCmd emits TickMsg", func(t *testing.T) {
t.Parallel()
cmd := tickCmd()
if cmd == nil {
t.Fatal("tickCmd returned nil")
}
msgCh := make(chan tea.Msg, 1)
go func() { msgCh <- cmd() }()
select {
case msg := <-msgCh:
if _, ok := msg.(TickMsg); !ok {
t.Fatalf("msg type = %T, want TickMsg", msg)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("timeout waiting for tick message")
}
})
}
func TestUpdate(t *testing.T) {
t.Parallel()
t.Run("window size updates dimensions", func(t *testing.T) {
t.Parallel()
m := NewModel(make(chan model.Event), Controls{})
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
got := next.(Model)
if got.width != 120 || got.height != 40 {
t.Fatalf("dimensions = (%d,%d), want (120,40)", got.width, got.height)
}
})
t.Run("tick updates now and reschedules only with pending requests", func(t *testing.T) {
t.Parallel()
now := time.Now()
m1 := NewModel(make(chan model.Event), Controls{})
next1, cmd1 := m1.Update(TickMsg{Now: now})
got1 := next1.(Model)
if !got1.now.Equal(now) {
t.Fatal("tick should update now")
}
if cmd1 != nil {
t.Fatal("tick without pending requests should not reschedule")
}
m2 := NewModel(make(chan model.Event), Controls{})
m2.requests = append(m2.requests, model.Request{ID: uuid.New(), Pending: true})
_, cmd2 := m2.Update(TickMsg{Now: now})
if cmd2 == nil {
t.Fatal("tick with pending requests should reschedule")
}
})
t.Run("key handling toggles and quit", func(t *testing.T) {
t.Parallel()
m := NewModel(make(chan model.Event), Controls{})
next, quitCmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
_ = next
if quitCmd == nil {
t.Fatal("q should return quit cmd")
}
nextCtrlC, quitCtrlC := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
_ = nextCtrlC
if quitCtrlC == nil {
t.Fatal("ctrl+c should return quit cmd")
}
next2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
if !next2.(Model).showEvents {
t.Fatal("e should toggle showEvents")
}
next3, _ := next2.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("o")})
if !next3.(Model).showStd {
t.Fatal("o should toggle showStd")
}
next4, _ := next3.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")})
if !next4.(Model).showSearch {
t.Fatal("/ should enable search")
}
nextSearch, _ := next4.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
if nextSearch.(Model).searchQuery != "x" {
t.Fatal("typed rune should update search query")
}
nextSearchSpace, _ := nextSearch.(Model).Update(tea.KeyMsg{Type: tea.KeySpace})
if nextSearchSpace.(Model).searchQuery != "x " {
t.Fatal("space should update search query")
}
nextSearch2, _ := nextSearchSpace.(Model).Update(tea.KeyMsg{Type: tea.KeyBackspace})
if nextSearch2.(Model).searchQuery != "x" {
t.Fatal("backspace should remove one character")
}
nextSearch3, _ := nextSearch2.(Model).Update(tea.KeyMsg{Type: tea.KeyBackspace})
if nextSearch3.(Model).searchQuery != "" {
t.Fatal("backspace should update search query")
}
next5, _ := nextSearch3.(Model).Update(tea.KeyMsg{Type: tea.KeyEsc})
if next5.(Model).showSearch {
t.Fatal("esc should disable search")
}
if next5.(Model).searchQuery != "" {
t.Fatal("esc should clear search query")
}
next6, cmd6 := next5.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
if cmd6 != nil {
t.Fatal("unknown key should not return command")
}
if next6.(Model).showEvents != next5.(Model).showEvents ||
next6.(Model).showStd != next5.(Model).showStd ||
next6.(Model).showSearch != next5.(Model).showSearch {
t.Fatal("unknown key should not alter toggle state")
}
})
t.Run("restart key guarded by state and control fn", func(t *testing.T) {
t.Parallel()
m := NewModel(make(chan model.Event), Controls{})
next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlR})
if cmd != nil {
t.Fatal("ctrl+r with nil restart control should return nil cmd")
}
if next.(Model).restarting {
t.Fatal("restarting should remain false when restart control missing")
}
m2 := NewModel(make(chan model.Event), Controls{Restart: func() error { return nil }})
next2, cmd2 := m2.Update(tea.KeyMsg{Type: tea.KeyCtrlR})
if cmd2 == nil {
t.Fatal("ctrl+r with restart control should return cmd")
}
if !next2.(Model).restarting {
t.Fatal("restarting should be true after ctrl+r")
}
next3, cmd3 := next2.(Model).Update(tea.KeyMsg{Type: tea.KeyCtrlR})
if cmd3 != nil {
t.Fatal("ctrl+r while restarting should return nil cmd")
}
if !next3.(Model).restarting {
t.Fatal("restarting should stay true while guarded")
}
})
t.Run("ErrMsg pushes warning event", func(t *testing.T) {
t.Parallel()
m := NewModel(make(chan model.Event), Controls{})
next, _ := m.Update(ErrMsg{err: errors.New("closed")})
got := next.(Model)
if len(got.events) != 1 {
t.Fatalf("event len = %d, want 1", len(got.events))
}
if got.events[0].Type != model.EventTypeWarn {
t.Fatalf("event type = %s, want %s", got.events[0].Type, model.EventTypeWarn)
}
})
t.Run("RestartResultMsg clears restarting and warns on error", func(t *testing.T) {
t.Parallel()
m := NewModel(make(chan model.Event), Controls{})
m.restarting = true
next1, _ := m.Update(RestartResultMsg{err: nil})
if next1.(Model).restarting {
t.Fatal("restarting should clear on restart result")
}
next2, _ := m.Update(RestartResultMsg{err: errors.New("fail")})
got2 := next2.(Model)
if len(got2.events) != 1 || got2.events[0].Type != model.EventTypeWarn {
t.Fatalf("expected warn event on restart error, got %#v", got2.events)
}
})
t.Run("EventMsg updates events and requests", func(t *testing.T) {
t.Parallel()
ch := make(chan model.Event, 2)
m := NewModel(ch, Controls{})
reqID := uuid.New()
startEv := EventMsg{value: model.Event{Type: model.EventTypeRequestStarted, Request: model.Request{ID: reqID, Method: http.MethodGet, Pending: true}}}
next1, cmd1 := m.Update(startEv)
if cmd1 == nil {
t.Fatal("EventMsg should return wait/tick cmd")
}
got1 := next1.(Model)
if len(got1.events) != 1 || len(got1.requests) != 1 {
t.Fatalf("expected one event and one request, got events=%d requests=%d", len(got1.events), len(got1.requests))
}
finishReq := got1.requests[0]
finishReq.Pending = false
finishReq.Status = 200
finishEv := EventMsg{value: model.Event{Type: model.EventTypeRequestFinished, Request: finishReq}}
next2, cmd2 := got1.Update(finishEv)
got2 := next2.(Model)
if got2.requests[0].Pending {
t.Fatal("request should be updated to non-pending")
}
if got2.requests[0].Status != 200 {
t.Fatalf("request status = %d, want 200", got2.requests[0].Status)
}
if cmd2 == nil {
t.Fatal("expected waitForEvent cmd after finished request")
}
ch <- model.Event{Type: model.EventTypeWarn, Body: "next"}
msg := cmd2()
if _, ok := msg.(EventMsg); !ok {
t.Fatalf("cmd2 message type = %T, want EventMsg", msg)
}
})
}
func TestModelHelpers(t *testing.T) {
t.Parallel()
t.Run("pushEvent trims to maxEvents", func(t *testing.T) {
t.Parallel()
m := NewModel(make(chan model.Event), Controls{})
for range maxEvents + 5 {
m.pushEvent(model.Event{Body: "x"})
}
if len(m.events) != maxEvents {
t.Fatalf("events len = %d, want %d", len(m.events), maxEvents)
}
})
t.Run("createRequest ignores CONNECT and trims", func(t *testing.T) {
t.Parallel()
m := NewModel(make(chan model.Event), Controls{})
m.createRequest(model.Request{Method: http.MethodConnect})
if len(m.requests) != 0 {
t.Fatal("CONNECT request should be ignored")
}
for range maxRequests + 3 {
m.createRequest(model.Request{ID: uuid.New(), Method: http.MethodGet})
}
if len(m.requests) != maxRequests {
t.Fatalf("requests len = %d, want %d", len(m.requests), maxRequests)
}
})
t.Run("updateRequest updates only matching request", func(t *testing.T) {
t.Parallel()
id1 := uuid.New()
id2 := uuid.New()
m := NewModel(make(chan model.Event), Controls{})
m.requests = []model.Request{{ID: id1, Status: 100}, {ID: id2, Status: 101}}
m.updateRequest(model.Request{ID: id2, Status: 202})
if m.requests[0].Status != 100 || m.requests[1].Status != 202 {
t.Fatalf("unexpected statuses after update: %#v", m.requests)
}
})
t.Run("hasPendingRequests true and false", func(t *testing.T) {
t.Parallel()
m := NewModel(make(chan model.Event), Controls{})
if m.hasPendingRequests() {
t.Fatal("empty model should not have pending requests")
}
m.requests = []model.Request{{ID: uuid.New(), Pending: false}, {ID: uuid.New(), Pending: true}}
if !m.hasPendingRequests() {
t.Fatal("expected pending requests to be true")
}
})
}