Files
uptop/internal/tui/update_test.go
T
lerko a32a443a4a
CI / test (pull_request) Successful in 1m45s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 46s
perf(tui): precompute maintenance set, eliminate redundant GetAllSites
Replace O(windows × sites) isMonitorInMaintenance with O(1) map lookup.
buildMaintSet runs once per refreshLive/handleTabData, not per call site.

groupSparkline/groupUptime now use m.sites (already on model) instead of
calling engine.GetAllSites() which copies the full map under a mutex.
2026-07-01 19:37:27 -04:00

475 lines
15 KiB
Go

package tui
import (
"context"
"strings"
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
tea "github.com/charmbracelet/bubbletea"
zone "github.com/lrstanley/bubblezone"
)
// --- minimal Store mock for TUI data-flow tests ---
type tuiMockStore struct {
storetest.BaseMock
alerts []models.AlertConfig
users []models.User
nodes []models.ProbeNode
maint []models.MaintenanceWindow
stateChanges []models.StateChange
stateChangeCalls int
deleteSiteCalls int
}
func (m *tuiMockStore) GetAllAlerts(_ context.Context) ([]models.AlertConfig, error) {
return m.alerts, nil
}
func (m *tuiMockStore) GetAllUsers(_ context.Context) ([]models.User, error) { return m.users, nil }
func (m *tuiMockStore) GetAllNodes(_ context.Context) ([]models.ProbeNode, error) {
return m.nodes, nil
}
func (m *tuiMockStore) GetStateChanges(_ context.Context, _ int, _ int) ([]models.StateChange, error) {
m.stateChangeCalls++
return m.stateChanges, nil
}
func (m *tuiMockStore) GetAllMaintenanceWindows(_ context.Context, _ int) ([]models.MaintenanceWindow, error) {
return m.maint, nil
}
func (m *tuiMockStore) DeleteSite(_ context.Context, _ int) error {
m.deleteSiteCalls++
return nil
}
func newTestModel(ms *tuiMockStore) Model {
return Model{
store: ms,
engine: monitor.NewEngine(ms),
isAdmin: true,
zones: zone.New(),
detailChangesSiteID: -1,
theme: themeFlexokiDark,
st: newStyles(themeFlexokiDark),
}
}
// --- Tests ---
func TestLoadTabDataCmd_ReturnsRows(t *testing.T) {
ms := &tuiMockStore{
alerts: []models.AlertConfig{{ID: 1, Name: "a"}},
nodes: []models.ProbeNode{{ID: "n1"}},
users: []models.User{{Username: "u"}},
maint: []models.MaintenanceWindow{{ID: 7}},
}
m := newTestModel(ms)
msg := m.loadTabDataCmd()()
td, ok := msg.(tabDataMsg)
if !ok {
t.Fatalf("expected tabDataMsg, got %T", msg)
}
if len(td.alerts) != 1 || len(td.nodes) != 1 || len(td.users) != 1 || len(td.maint) != 1 {
t.Errorf("unexpected counts: %+v", td)
}
if td.err != nil {
t.Errorf("unexpected err: %v", td.err)
}
}
func TestHandleTabData_PopulatesModel(t *testing.T) {
m := newTestModel(&tuiMockStore{})
msg := tabDataMsg{
alerts: []models.AlertConfig{{ID: 1}},
nodes: []models.ProbeNode{{ID: "n"}},
users: []models.User{{Username: "u"}},
maint: []models.MaintenanceWindow{{ID: 2}},
}
updated, _ := m.handleTabData(msg)
got := updated.(*Model)
if len(got.alerts) != 1 || len(got.nodes) != 1 || len(got.users) != 1 || len(got.maintenanceWindows) != 1 {
t.Errorf("model not populated: alerts=%d nodes=%d users=%d maint=%d",
len(got.alerts), len(got.nodes), len(got.users), len(got.maintenanceWindows))
}
}
func TestHandleTabData_ErrorKeepsPreviousData(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.alerts = []models.AlertConfig{{ID: 99}} // pre-existing data
updated, _ := m.handleTabData(tabDataMsg{err: errSentinel})
got := updated.(*Model)
if len(got.alerts) != 1 || got.alerts[0].ID != 99 {
t.Error("transient error wiped previous tab data")
}
}
var errSentinel = &stubErr{}
type stubErr struct{}
func (*stubErr) Error() string { return "boom" }
func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}}
m.cursor = 0
m.detailOpen = true
m.termWidth = 120
m.termHeight = 40
cmd := m.loadDetailCmd(1)
if cmd == nil {
t.Fatal("loadDetailCmd returned nil")
}
msg := cmd()
dd, ok := msg.(detailDataMsg)
if !ok || dd.siteID != 1 || len(dd.changes) != 1 {
t.Fatalf("unexpected detailDataMsg: %+v", msg)
}
if ms.stateChangeCalls != 1 {
t.Fatalf("expected exactly 1 store hit from the load Cmd, got %d", ms.stateChangeCalls)
}
updated, _ := m.Update(dd)
m = updated.(Model)
if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 {
t.Fatalf("detail changes not cached: id=%d n=%d", m.detailChangesSiteID, len(m.detailChanges))
}
for i := 0; i < 3; i++ {
_ = m.viewDetailInline(80, 30)
}
if ms.stateChangeCalls != 1 {
t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls)
}
}
func TestHandleTick_ThrottlesTabLoad(t *testing.T) {
m := newTestModel(&tuiMockStore{})
mp := &m
t0 := time.Unix(1_000_000, 0)
mp.handleTick(t0)
if !mp.lastTabLoad.Equal(t0) {
t.Fatalf("first tick should dispatch + stamp lastTabLoad=%v, got %v", t0, mp.lastTabLoad)
}
// Within the TTL → no new dispatch, stamp unchanged.
mp.handleTick(t0.Add(time.Second))
if !mp.lastTabLoad.Equal(t0) {
t.Errorf("tick within TTL should not re-dispatch; lastTabLoad=%v", mp.lastTabLoad)
}
// Past the TTL → dispatch again.
t2 := t0.Add(tabRefreshTTL + time.Second)
mp.handleTick(t2)
if !mp.lastTabLoad.Equal(t2) {
t.Errorf("tick past TTL should re-dispatch; lastTabLoad=%v want %v", mp.lastTabLoad, t2)
}
}
// keyMsg builds a plain-rune key message ("h", "s", ...).
func keyMsg(s string) tea.KeyMsg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
}
func TestHandleTabData_DropsStaleSeq(t *testing.T) {
m := newTestModel(&tuiMockStore{})
mp := &m
_ = mp.loadTabDataCmd() // seq 1 (superseded)
_ = mp.loadTabDataCmd() // seq 2 (newest)
updated, _ := mp.handleTabData(tabDataMsg{seq: 1, alerts: []models.AlertConfig{{ID: 1}}})
if got := updated.(*Model); len(got.alerts) != 0 {
t.Error("stale tab-data reply was applied over a newer in-flight load")
}
updated, _ = mp.handleTabData(tabDataMsg{seq: 2, alerts: []models.AlertConfig{{ID: 2}}})
if got := updated.(*Model); len(got.alerts) != 1 || got.alerts[0].ID != 2 {
t.Error("fresh tab-data reply was not applied")
}
}
func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}}
m.detailOpen = true
m.termWidth, m.termHeight = 120, 40
updated, cmd := (&m).handleDashboardKey(keyMsg("h"))
if ms.stateChangeCalls != 0 {
t.Fatal("history keypress hit the store synchronously in Update")
}
got := updated.(*Model)
if got.detailMode != detailHistory || got.historySiteID != 7 {
t.Fatalf("history mode not set: mode=%v siteID=%d", got.detailMode, got.historySiteID)
}
if cmd == nil {
t.Fatal("expected a history load Cmd")
}
msg := cmd()
hd, ok := msg.(historyDataMsg)
if !ok || hd.siteID != 7 || len(hd.changes) != 1 {
t.Fatalf("unexpected historyDataMsg: %+v", msg)
}
folded, _ := got.Update(hd)
m2 := folded.(Model)
if len(m2.historyChanges) != 1 {
t.Fatal("history reply not folded into the model")
}
m2.historySiteID = 9
stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil})
if m3 := stale.(Model); len(m3.historyChanges) != 1 {
t.Error("stale history reply overwrote the current view")
}
}
func TestSLAData_DropsStaleReply(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.termWidth, m.termHeight = 120, 40
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 3}, SiteState: models.SiteState{Status: "UP"}}}
m.detailOpen = true
m.slaSiteID = 3
m.slaPeriodIdx = 2
updated, _ := m.Update(slaDataMsg{siteID: 3, periodIdx: 0})
if mm := updated.(*Model); mm.slaDailyBreakdown != nil {
t.Error("stale SLA reply (old period) was applied")
}
updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx})
if mm := updated.(*Model); mm.slaDailyBreakdown == nil {
t.Error("matching SLA reply was not applied")
}
}
func TestConfirmDelete_WritesOffUIGoroutine(t *testing.T) {
ms := &tuiMockStore{}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 4, Name: "s"}}}
m.state = stateConfirmDelete
m.deleteKind = "site"
m.deleteID = 4
updated, cmd := (&m).handleConfirmDelete(keyMsg("y"))
if ms.deleteSiteCalls != 0 {
t.Fatal("delete hit the store synchronously in Update")
}
if cmd == nil {
t.Fatal("expected a write Cmd")
}
if got := updated.(*Model); got.state != stateDashboard {
t.Fatalf("expected return to dashboard, got state %v", got.state)
}
wd, ok := cmd().(writeDoneMsg)
if !ok || wd.err != nil {
t.Fatalf("unexpected write result: %+v", wd)
}
if ms.deleteSiteCalls != 1 {
t.Fatalf("expected exactly 1 store delete from the Cmd, got %d", ms.deleteSiteCalls)
}
}
func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) {
m := newTestModel(&tuiMockStore{})
updated, cmd := m.Update(writeDoneMsg{op: "Delete site", err: errSentinel})
if cmd == nil {
t.Error("writeDoneMsg did not trigger a tab-data reload")
}
mm := updated.(Model)
found := false
for _, line := range mm.engine.GetLogs() {
if strings.Contains(line.Message, "Delete site failed: boom") {
found = true
}
}
if !found {
t.Error("write error was not logged")
}
}
func TestEnterNarrowTerminal_OpensFullscreen(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "UP"}}}
m.termWidth = 80 // narrow, below wideBreakpoint (120)
m.termHeight = 40
m.focusedPanel = panelMonitors
updated, cmd := (&m).handleDashboardKey(keyMsg("enter"))
got := updated.(*Model)
if got.state != stateDetailFullscreen {
t.Fatalf("expected stateDetailFullscreen, got %d", got.state)
}
if cmd == nil {
t.Fatal("expected a detail load Cmd")
}
}
func TestEnterWideTerminal_TogglesSidebar(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "UP"}}}
m.termWidth = 140 // wide, above wideBreakpoint (120)
m.termHeight = 40
m.focusedPanel = panelMonitors
updated, _ := (&m).handleDashboardKey(keyMsg("enter"))
got := updated.(*Model)
if got.state != stateDashboard {
t.Fatalf("expected stateDashboard, got %d", got.state)
}
if !got.detailOpen {
t.Fatal("expected detailOpen to be true")
}
}
func TestDetailFullscreen_EscReturnsToDashboard(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.state = stateDetailFullscreen
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}}}
updated, _ := (&m).handleDetailFullscreen(tea.KeyMsg{Type: tea.KeyEsc})
got := updated.(*Model)
if got.state != stateDashboard {
t.Fatalf("expected stateDashboard after Esc, got %d", got.state)
}
if got.focusedPanel != panelMonitors {
t.Fatalf("expected panelMonitors focus, got %d", got.focusedPanel)
}
}
func TestDetailRefreshCmd_FiresForFullscreen(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
m.state = stateDetailFullscreen
m.detailOpen = false
cmd := (&m).detailRefreshCmd()
if cmd == nil {
t.Fatal("detailRefreshCmd should fire for stateDetailFullscreen")
}
}
func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
m.detailOpen = false
if (&m).detailRefreshCmd() != nil {
t.Error("refresh Cmd issued outside the detail view")
}
m.detailOpen = true
cmd := (&m).detailRefreshCmd()
if cmd == nil {
t.Fatal("open detail panel should refresh on the tab-data cadence")
}
dd, ok := cmd().(detailDataMsg)
if !ok || dd.siteID != 5 || len(dd.changes) != 1 {
t.Fatalf("unexpected detail refresh reply: %+v", dd)
}
m.cursor = 7 // cursor out of range → no refresh, no panic
if (&m).detailRefreshCmd() != nil {
t.Error("refresh Cmd issued for an out-of-range cursor")
}
}
func TestBuildMaintSet_GlobalWindow(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
{SiteConfig: models.SiteConfig{ID: 2, Name: "b"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 0, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if !m.maintSet[1] || !m.maintSet[2] {
t.Error("global maint window should mark all monitors")
}
}
func TestBuildMaintSet_TargetedWindow(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
{SiteConfig: models.SiteConfig{ID: 2, Name: "b"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 1, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if !m.maintSet[1] {
t.Error("targeted window should mark monitor 1")
}
if m.maintSet[2] {
t.Error("targeted window should NOT mark monitor 2")
}
}
func TestBuildMaintSet_GroupPropagates(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 10, Name: "group", Type: "group"}},
{SiteConfig: models.SiteConfig{ID: 11, Name: "child1", ParentID: 10}},
{SiteConfig: models.SiteConfig{ID: 12, Name: "child2", ParentID: 10}},
{SiteConfig: models.SiteConfig{ID: 20, Name: "other"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 10, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if !m.maintSet[10] || !m.maintSet[11] || !m.maintSet[12] {
t.Error("group maint window should mark group + children")
}
if m.maintSet[20] {
t.Error("unrelated monitor should NOT be marked")
}
}
func TestBuildMaintSet_ExpiredIgnored(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 1, Type: "maintenance",
StartTime: time.Now().Add(-2 * time.Hour),
EndTime: time.Now().Add(-1 * time.Hour)},
}
m.buildMaintSet()
if m.maintSet[1] {
t.Error("expired maint window should not mark monitor")
}
}
func TestBuildMaintSet_IncidentIgnored(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 0, Type: "incident", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if m.maintSet[1] {
t.Error("incident windows should not mark monitors as in maintenance")
}
}