4a579bdbc6
CI / test (pull_request) Successful in 4s
Every colour was a 256-colour ANSI index hardcoded in styles.go, so changing the palette meant editing the styles themselves. This puts a semantic token set between the two: styles name roles, a theme supplies the colours. internal/theme holds the twelve tokens, the two built-ins (gruvbox-dark, the new default, and gruvbox-light) and the loader for user themes in ~/.config/terdut-tui/themes/. A user file may 'extends:' a built-in and override only what it cares about, and may shadow a built-in name to tweak it in place. Unknown keys, malformed colours and incomplete themes are refused with a message naming what went wrong. Colours are truecolor hex now: lipgloss downsamples for 256- and 16-colour terminals and honours NO_COLOR, so themes carry no fallbacks of their own. An ANSI index is still accepted for anyone who would rather follow their terminal's own palette. The 21 package-level style vars become a Styles struct on the Model, which is what rule 3 asked for all along; the four free functions in view.go take one as their first argument. The embedded bubbles components are restyled from the same tokens — otherwise a theme would leave a pink selected row and grey help text behind. Note that the table's Cell style deliberately keeps no foreground: bubbles renders cells before wrapping the row in Selected, so a colour there cuts the selection highlight short.
352 lines
13 KiB
Go
352 lines
13 KiB
Go
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, -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, -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, -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, -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, -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, -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, 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, -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" }
|