package tui import ( "regexp" "strings" "testing" "time" "git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/theme" tea "github.com/charmbracelet/bubbletea" ) // ansi matches the escape sequences lipgloss emits when it decides the output // supports colour, so assertions can be made against the text alone. var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`) func plain(s string) string { return ansi.ReplaceAllString(s, "") } // testStyles is the default theme, so assertions here run against what a user // with no 'theme:' key actually sees. func testStyles() Styles { return newStyles(theme.GruvboxDark) } func mustContain(t *testing.T, got string, wants ...string) { t.Helper() got = plain(got) for _, w := range wants { if !strings.Contains(got, w) { t.Errorf("expected output to contain %q\n--- got ---\n%s", w, got) } } } func TestIncidentDetail_RendersTheWholeStory(t *testing.T) { now := time.Now() ackID := int64(1) alertID := int64(3) inc := api.Incident{ ID: 1, Title: "DiskFull (namespace=prod)", Status: api.StatusAcknowledged, Severity: "critical", GroupLabels: map[string]string{"alertname": "DiskFull", "namespace": "prod"}, TriggeredAt: now.Add(-2 * time.Hour), AssignedTo: "admin", AcknowledgedByID: &ackID, AcknowledgedBy: "admin", AcknowledgedAt: &now, Alerts: []api.Alert{ {ID: 3, Name: "DiskFull", Status: "firing", Labels: map[string]string{"instance": "node-1"}, ReceivedAt: now}, {ID: 4, Name: "DiskFull", Status: "resolved", Labels: map[string]string{"instance": "node-2"}, ReceivedAt: now}, }, } timeline := []api.IncidentEvent{ {Type: api.EventTriggered, CreatedAt: now}, {Type: api.EventAssigned, Username: "admin", CreatedAt: now}, {Type: api.EventAlertAdded, AlertID: &alertID, CreatedAt: now}, {Type: api.EventAcknowledged, Username: "admin", CreatedAt: now}, {Type: api.EventNote, Username: "admin", Detail: "draining node-2", CreatedAt: now}, } out := buildIncidentDetailContent(testStyles(), inc, timeline, nil, -1, 110) mustContain(t, out, "DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL", "Assigned:", "admin", "Grouped By", "namespace", "prod", "Alerts (2)", "node-1", "node-2", "Timeline (5 events, 1 notes)", "Incident opened", "Assigned to admin", "Alert #3 joined", "Acknowledged by admin", "admin wrote", "draining node-2", ) } func TestIncidentDetail_ShowsSnooze(t *testing.T) { future := time.Now().Add(2 * time.Hour) inc := api.Incident{ Title: "Noisy", Status: api.StatusTriggered, TriggeredAt: time.Now(), SnoozedUntil: &future, } // The exact remaining time is humanUntil's business, not this test's — a few // microseconds of elapsed clock turn "in 2h" into "in 1h 59m". mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110), "TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h") } // An expired snooze is not a snooze, so it must not be reported as one. func TestIncidentDetail_HidesExpiredSnooze(t *testing.T) { past := time.Now().Add(-time.Hour) inc := api.Incident{ Title: "Noisy", Status: api.StatusTriggered, TriggeredAt: time.Now(), SnoozedUntil: &past, } if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110)), "Snoozed:") { t.Error("an expired snooze should not be rendered") } } func TestIncidentDetail_ShowsResolutionSource(t *testing.T) { now := time.Now() source := "manual" inc := api.Incident{ Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour), ResolvedAt: &now, ResolutionSource: &source, } mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110), "RESOLVED", "Resolved:", "manual") } func TestIncidentDetail_UnassignedAndUnacknowledged(t *testing.T) { inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()} mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110), "nobody", "not acknowledged") } func TestIncidentDetail_EmptyTimeline(t *testing.T) { inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()} mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110), "Nothing recorded yet") } func TestIncidentDetail_MarksSelectedNote(t *testing.T) { now := time.Now() timeline := []api.IncidentEvent{ {Type: api.EventNote, Username: "admin", Detail: "first", CreatedAt: now}, {Type: api.EventNote, Username: "alice", Detail: "second", CreatedAt: now}, } inc := api.Incident{Title: "X", Status: api.StatusTriggered, TriggeredAt: now} out := plain(buildIncidentDetailContent(testStyles(), inc, timeline, nil, 1, 110)) for _, line := range strings.Split(out, "\n") { if strings.Contains(line, "alice") && !strings.HasPrefix(line, "> ") { t.Errorf("expected the selected note marked, got %q", line) } if strings.Contains(line, "admin wrote") && strings.HasPrefix(line, "> ") { t.Errorf("expected the unselected note unmarked, got %q", line) } } } func TestIncidentStatusLabel(t *testing.T) { future := time.Now().Add(time.Hour) tests := []struct { name string inc api.Incident want string }{ {"triggered", api.Incident{Status: api.StatusTriggered}, "● TRIGGERED"}, {"acknowledged", api.Incident{Status: api.StatusAcknowledged}, "◐ ACKNOWLEDGED"}, {"resolved", api.Incident{Status: api.StatusResolved}, "✓ RESOLVED"}, {"snoozed", api.Incident{Status: api.StatusTriggered, SnoozedUntil: &future}, "● TRIGGERED (snoozed)"}, // A status this client does not know about still has to render. {"unknown", api.Incident{Status: "escalated"}, "ESCALATED"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := incidentStatusLabel(tt.inc); got != tt.want { t.Errorf("expected %q, got %q", tt.want, got) } }) } } // The server may add event types after this client ships. An unknown one must // still appear on the timeline rather than silently vanishing. func TestEventLabel_UnknownTypeFallsBackToItsName(t *testing.T) { got := eventLabel(api.IncidentEvent{Type: "escalated", Detail: "to sre-oncall"}) mustContain(t, got, "escalated", "to sre-oncall") } func TestEventLabel_KnownTypes(t *testing.T) { alertID := int64(9) tests := []struct { event api.IncidentEvent want string }{ {api.IncidentEvent{Type: api.EventTriggered}, "Incident opened"}, {api.IncidentEvent{Type: api.EventAlertAdded, AlertID: &alertID}, "Alert #9 joined"}, {api.IncidentEvent{Type: api.EventAlertResolved, AlertID: &alertID}, "Alert #9 resolved"}, {api.IncidentEvent{Type: api.EventAcknowledged, Username: "bo"}, "Acknowledged by bo"}, {api.IncidentEvent{Type: api.EventAssigned, Username: "bo"}, "Assigned to bo"}, {api.IncidentEvent{Type: api.EventSnoozed, Detail: "2026-08-01T00:00:00Z"}, "Snoozed until 2026-08-01T00:00:00Z"}, {api.IncidentEvent{Type: api.EventResolved, Username: "bo"}, "Resolved by bo"}, // No user means the server closed it via the alert cascade. {api.IncidentEvent{Type: api.EventResolved}, "all alerts stopped firing"}, {api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "triggered"}, "Notified bo (triggered)"}, {api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "reminder"}, "Notified bo (reminder)"}, // On a notification, no user means the shared fallback topic — not that // the server acted on its own. {api.IncidentEvent{Type: api.EventNotified, Detail: "triggered"}, "Notified the fallback topic (triggered)"}, {api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered: ntfy returned 502"}, "Notification to bo failed"}, {api.IncidentEvent{Type: api.EventNotifyFailed, Detail: "triggered: no route to host"}, "Notification to the fallback topic failed"}, } for _, tt := range tests { t.Run(tt.event.Type, func(t *testing.T) { mustContain(t, eventLabel(tt.event), tt.want) }) } } // The timeline is where a page that never landed becomes visible, so both // outcomes have to survive into the rendered pane. func TestIncidentDetail_RendersNotifications(t *testing.T) { now := time.Now() inc := api.Incident{ID: 1, Title: "DiskFull", Status: api.StatusTriggered, TriggeredAt: now} timeline := []api.IncidentEvent{ {Type: api.EventTriggered, CreatedAt: now}, {Type: api.EventNotified, Username: "niklas", Detail: "triggered", CreatedAt: now}, {Type: api.EventNotifyFailed, Username: "niklas", Detail: "reminder: ntfy returned 502", CreatedAt: now}, } got := buildIncidentDetailContent(testStyles(), inc, timeline, nil, -1, 120) mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed") } func TestUserNotifyEdit_SaysWhatAnEmptyValueDoes(t *testing.T) { m := sized() m.mode = modeUserNotifyEdit m.selectedUser = api.User{ID: 1, Username: "niklas"} mustContain(t, m.View(), "niklas", "empty to clear it", "fallback topic") // The footer has to repeat it: that is where the reader looks for what a key does. mustContain(t, m.renderFooter(), "empty clears the topic") } func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) { now := time.Now() id := int64(7) alert := api.Alert{ ID: 3, Name: "DiskFull", Status: "firing", StartsAt: now.Add(-time.Hour), ReceivedAt: now, IncidentID: &id, Labels: map[string]string{"instance": "node-1", "severity": "critical"}, Annotations: map[string]string{"summary": "disk 90%"}, } mustContain(t, buildAlertDetailContent(testStyles(), alert, 110), "DiskFull", "FIRING", "Incident:", "#7", "press i to open it", "instance", "node-1", "summary", "disk 90%", "Alerts are read-only") } func TestAlertDetail_NoIncident(t *testing.T) { alert := api.Alert{ID: 3, Name: "Orphan", Status: "resolved", ReceivedAt: time.Now()} mustContain(t, buildAlertDetailContent(testStyles(), alert, 110), "Incident:", "none") } func TestAlertDetail_ShowsResolutionSource(t *testing.T) { source := "expiry" alert := api.Alert{Name: "Gone", Status: "resolved", ReceivedAt: time.Now(), ResolutionSource: &source} mustContain(t, buildAlertDetailContent(testStyles(), alert, 110), "RESOLVED", "expiry") } // Null MTTA means nothing has been acknowledged, which is a different claim // from an instant response. func TestStats_RendersDashForMissingAverages(t *testing.T) { stats := &api.IncidentStats{Total: 2, Triggered: 2} out := buildStatsContent(testStyles(), stats, nil, nil, nil, 110) mustContain(t, out, "Incident Response", "Mean time to acknowledge", "—", "nothing has been acknowledged or resolved yet") } func TestStats_RendersAverages(t *testing.T) { mtta, mttr := 150.0, 3600.0 stats := &api.IncidentStats{Total: 3, Resolved: 1, MTTASeconds: &mtta, MTTRSeconds: &mttr} out := buildStatsContent(testStyles(), stats, []api.TopAlert{{Name: "DiskFull", Count: 4}}, nil, nil, 110) mustContain(t, out, "2m", "1h", "Top Alerts", "DiskFull") } func TestStats_HandlesNoIncidentData(t *testing.T) { mustContain(t, buildStatsContent(testStyles(), nil, nil, nil, nil, 110), "Incident Response", "No data") } func TestView_TabsAndDashboardRender(t *testing.T) { m := sized() m.incidents = []api.Incident{{ ID: 1, Title: "DiskFull", Status: api.StatusTriggered, Severity: "critical", AssignedTo: "admin", TriggeredAt: time.Now(), }} m.incidentStats = &api.IncidentStats{Triggered: 1} m.rebuildIncidentTable() mustContain(t, m.View(), "Incidents", "Alerts", "Stats", "Archived", "Schedule", "Users", "Triggered: 1", "filter: open", "DiskFull", "critical", "admin", "enter·detail") } func TestView_EmptyStates(t *testing.T) { m := sized() m.loading = false mustContain(t, m.View(), "No open incidents.") m.activeSection = sectionArchived mustContain(t, m.View(), "No archived incidents.") } // The stats page renders inside the normal section chrome now, so it has to // survive the real path: a window size message sizes the viewport and fills it. func TestView_StatsSectionRendersInPlace(t *testing.T) { m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark) m.connected = true m.incidentStats = &api.IncidentStats{Total: 3, Triggered: 1} m.topAlerts = []api.TopAlert{{Name: "DiskFull", Count: 4}} m.statsLoaded = true next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) m = next.(Model) m.activeSection = sectionStats out := m.View() mustContain(t, out, "Stats", "Incident Response", "Top Alerts", "DiskFull", "tab·section") if strings.Contains(plain(out), "Loading statistics") { t.Error("loaded stats should not show the loading placeholder") } } func TestView_ConnectionError(t *testing.T) { m := sized() m.connected = false m.err = errFixture{} mustContain(t, m.View(), "Error:", "Press r to retry") } // The footer is the only place the terminal states are explained, so the // destructive one has to be visible before it is pressed. func TestFooter_IncidentDetailOffersResolveOnlyWhileOpen(t *testing.T) { open := onIncident(openIncidentFixture(), nil) mustContain(t, open.renderFooter(), "R·resolve", "z·snooze", "a·ack") closed := onIncident(resolvedIncidentFixture(), nil) if strings.Contains(plain(closed.renderFooter()), "R·resolve") { t.Error("a resolved incident should not offer resolve") } mustContain(t, closed.renderFooter(), "x·archive", "c·note") } func TestView_ZeroWidthRendersNothing(t *testing.T) { m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark) if m.View() != "" { t.Error("expected no output before the first window size message") } } type errFixture struct{} func (errFixture) Error() string { return "connection refused" } func TestIncidentDetail_ShowsSimilarWithResolutionNotes(t *testing.T) { now := time.Now() inc := api.Incident{Title: "DiskFull", Status: api.StatusTriggered, TriggeredAt: now} similar := []api.SimilarIncident{ {ID: 4, Title: "DiskFull (job=node)", ResolvedAt: now.Add(-48 * time.Hour), ResolutionNotes: []api.IncidentEvent{{Type: api.EventResolutionNote, Detail: "rotated the logs"}}}, {ID: 2, Title: "DiskFull (job=node)", ResolvedAt: now.Add(-96 * time.Hour), NoteCount: 3}, } out := plain(buildIncidentDetailContent(testStyles(), inc, nil, similar, -1, 110)) mustContain(t, out, "Seen before", "#4", "fixed: rotated the logs", "3 note(s), no resolution note") // Nothing similar, no section. if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110)), "Seen before") { t.Error("expected no Seen before section without similar incidents") } }