388 lines
11 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|