Files
terdut-tui/internal/tui/model_test.go
T
Niklas Ye d6c0f7508c
CI / test (push) Successful in 4s
Release / test (push) Successful in 2s
Release / binaries (push) Successful in 9s
Stop a table cursor from getting stuck at -1
Assigning an on-call week panicked with "index out of range [-1]" on an
ordinary schedule. The index came from scheduleTable.Cursor().

The cursor is not ours. bubbles' SetRows clamps it down when rows shrink
(`if m.cursor > len(rows)-1`) but never back up, so setting zero rows drives it
to -1 and filling the table afterwards leaves it there -- -1 is not greater
than len-1, so nothing corrects it. Every table in this package is rebuilt from
empty exactly once, when the first WindowSizeMsg arrives before any fetch has
returned, so every cursor started at -1 and stayed there until the user pressed
up or down. Pressing a direction key first is why this was survivable at all.

setRows restores the invariant the rest of the package already assumes: a table
with rows has a usable cursor. Every rebuild goes through it.

Five call sites also bounds-checked only the top of the range, and are now
consistent with their siblings, which already had `i < 0 ||`. They were the
same latent panic: deleting a schedule entry, deleting a user, editing a topic,
opening the API key menu, and the week assignment that actually fired.

The regression test deliberately never calls SetCursor. That is what the
existing schedule tests do, and SetCursor clamps, which is exactly how this got
past them. It drives the real order instead: size, then data, then keys.

Also carries a gofmt pass, which is why untouched files appear in the diff.
The move to git.ryuvia.com rewrote import paths without re-sorting them, and
the new path sorts before github.com/charmbracelet/..., where the old one
sorted after. go vet does not look at import order, so CI had nothing to say.
2026-08-19 21:23:13 +02:00

372 lines
11 KiB
Go

package tui
import (
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"github.com/charmbracelet/bubbles/table"
)
func TestNextFilter(t *testing.T) {
tests := []struct {
name string
cycle []string
current string
want string
}{
{"advances", incidentFilters, "", api.StatusTriggered},
{"advances again", incidentFilters, api.StatusTriggered, api.StatusAcknowledged},
{"wraps back to the open queue", incidentFilters, "snoozed", ""},
{"alerts advance", alertFilters, "firing", "resolved"},
{"alerts wrap", alertFilters, "archived", "firing"},
{"unknown current restarts the cycle", incidentFilters, "bogus", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := nextFilter(tt.cycle, tt.current); got != tt.want {
t.Errorf("expected %q, got %q", tt.want, got)
}
})
}
}
// "snoozed" is a pseudo-status in the filter cycle: the server has no such
// status, it is a separate query axis.
func TestIncidentQuery(t *testing.T) {
tests := []struct {
filter string
wantStatus string
wantSnoozed bool
}{
{"", "", false},
{api.StatusTriggered, api.StatusTriggered, false},
{api.StatusResolved, api.StatusResolved, false},
{"snoozed", "", true},
}
for _, tt := range tests {
t.Run(tt.filter, func(t *testing.T) {
status, snoozed := incidentQuery(tt.filter)
if status != tt.wantStatus || snoozed != tt.wantSnoozed {
t.Errorf("expected (%q, %v), got (%q, %v)",
tt.wantStatus, tt.wantSnoozed, status, snoozed)
}
})
}
}
func TestFilterLabel(t *testing.T) {
if got := filterLabel(""); got != "open" {
t.Errorf("the empty filter is the open queue, got %q", got)
}
if got := filterLabel("resolved"); got != "resolved" {
t.Errorf("expected resolved, got %q", got)
}
}
func TestNoteEvents(t *testing.T) {
timeline := []api.IncidentEvent{
{Type: api.EventTriggered},
{Type: api.EventNote, Detail: "first"},
{Type: api.EventAcknowledged},
{Type: api.EventNote, Detail: "second"},
}
notes := noteEvents(timeline)
if len(notes) != 2 {
t.Fatalf("expected 2 notes, got %d", len(notes))
}
if notes[0].Detail != "first" || notes[1].Detail != "second" {
t.Errorf("notes out of order: %v", notes)
}
if len(noteEvents(nil)) != 0 {
t.Error("an empty timeline has no notes")
}
}
func TestHumanDuration(t *testing.T) {
tests := []struct {
d time.Duration
want string
}{
{5 * time.Second, "moments"},
{90 * time.Second, "1m"},
{45 * time.Minute, "45m"},
{2 * time.Hour, "2h"},
{150 * time.Minute, "2h 30m"},
{48 * time.Hour, "2d"},
{50 * time.Hour, "2d 2h"},
}
for _, tt := range tests {
if got := humanDuration(tt.d); got != tt.want {
t.Errorf("humanDuration(%v) = %q, want %q", tt.d, got, tt.want)
}
}
}
func TestHumanAgo_ClampsFutureToNow(t *testing.T) {
now := time.Now()
// Server and client clocks disagree often enough that this must not render
// as a negative age.
if got := humanAgo(now, now.Add(time.Hour)); got != "moments ago" {
t.Errorf("expected a future timestamp to clamp, got %q", got)
}
}
func TestHumanUntil(t *testing.T) {
now := time.Now()
if got := humanUntil(now, now.Add(2*time.Hour)); got != "in 2h" {
t.Errorf("expected 'in 2h', got %q", got)
}
if got := humanUntil(now, now.Add(-time.Minute)); got != "expired" {
t.Errorf("a deadline in the past has expired, got %q", got)
}
}
// MTTA and MTTR are nil until something has been acknowledged or resolved, and
// that has to read as "no data" rather than an instant response.
func TestHumanSeconds(t *testing.T) {
if got := humanSeconds(nil); got != "—" {
t.Errorf("expected an em dash for no data, got %q", got)
}
secs := 150.0
if got := humanSeconds(&secs); got != "2m" {
t.Errorf("expected 2m, got %q", got)
}
}
func TestIncidentRows(t *testing.T) {
future := time.Now().Add(time.Hour)
rows := incidentRows([]api.Incident{
{Title: "DiskFull", Status: api.StatusTriggered, Severity: "critical",
AssignedTo: "admin", TriggeredAt: time.Now()},
{Title: "Unowned", Status: api.StatusTriggered, TriggeredAt: time.Now()},
{Title: "Quiet", Status: api.StatusTriggered, Severity: "info",
AssignedTo: "alice", SnoozedUntil: &future, TriggeredAt: time.Now()},
})
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
if rows[0][0] != "critical" || rows[0][3] != "admin" {
t.Errorf("unexpected first row %v", rows[0])
}
if rows[1][0] != "—" || rows[1][3] != "—" {
t.Errorf("missing severity and assignee should show an em dash, got %v", rows[1])
}
// bubbles' table renders plain strings, so snooze has to be marked in text.
if rows[2][2] != "triggered (zzz)" {
t.Errorf("expected a snooze marker in the status cell, got %q", rows[2][2])
}
}
func TestAlertRows_ShowIncidentLink(t *testing.T) {
id := int64(7)
rows := alertRows([]api.Alert{
{Name: "DiskFull", Status: "firing", IncidentID: &id},
{Name: "Orphan", Status: "resolved"},
})
if rows[0][4] != "#7" {
t.Errorf("expected #7, got %q", rows[0][4])
}
if rows[1][4] != "—" {
t.Errorf("an alert with no incident shows an em dash, got %q", rows[1][4])
}
}
// A user with no ntfy topic gets no pages of their own — the row has to say so
// rather than leaving a blank that reads as "not loaded yet".
func TestUserManageRows_ShowMissingTopic(t *testing.T) {
topic := "terdut-niklas"
empty := ""
m := NewModel(nil, "http://test", time.Minute)
m.width, m.height = 120, 40
m.users = []api.User{
{ID: 1, Username: "niklas", NtfyTopic: &topic},
{ID: 2, Username: "alex"},
// The server stores a blank topic as NULL, but a stale client or an older
// server can still hand one back; it means the same thing.
{ID: 3, Username: "sam", NtfyTopic: &empty},
}
m.rebuildUserManageTable()
rows := m.userManageTable.Rows()
if rows[0][2] != "terdut-niklas" {
t.Errorf("expected the topic in the row, got %q", rows[0][2])
}
if rows[1][2] != "—" || rows[2][2] != "—" {
t.Errorf("expected an em dash for nil and empty topics, got %q and %q",
rows[1][2], rows[2][2])
}
}
// A previous release overflowed the terminal by two columns because the padding
// budget was wrong. Columns plus bubbles' per-cell padding must land exactly on
// the window width.
func TestColumnWidthsFitTheTerminal(t *testing.T) {
for _, width := range []int{100, 110, 140, 200} {
for name, cols := range map[string][]int{
"incident": widths(incidentColumns(width)),
"alert": widths(alertColumns(width)),
} {
sum := 0
for _, w := range cols {
sum += w
}
const padding = 10 // bubbles applies Padding(0, 1) to each of five cells
if sum+padding != width {
t.Errorf("%s columns at width %d sum to %d+%d = %d",
name, width, sum, padding, sum+padding)
}
}
// The users table is four cells, so its padding budget differs.
sum := 0
for _, w := range widths(userManageColumns(width)) {
sum += w
}
const userPadding = 8
if sum+userPadding != width {
t.Errorf("user columns at width %d sum to %d+%d = %d",
width, sum, userPadding, sum+userPadding)
}
}
}
// Narrow terminals fall back to minimum widths, which legitimately overflow;
// what must not happen is a negative or zero column.
func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) {
for _, width := range []int{20, 40, 60} {
cols := append(widths(incidentColumns(width)), widths(alertColumns(width))...)
cols = append(cols, widths(userManageColumns(width))...)
for _, w := range cols {
if w < 1 {
t.Errorf("width %d produced a non-positive column %d", width, w)
}
}
}
}
func widths(cols []table.Column) []int {
out := make([]int, len(cols))
for i, c := range cols {
out[i] = c.Width
}
return out
}
func TestTableHeight_NeverGoesBelowOne(t *testing.T) {
if got := tableHeight(3, 10); got != 1 {
t.Errorf("expected a floor of 1, got %d", got)
}
if got := tableHeight(40, 8); got != 32 {
t.Errorf("expected 32, got %d", got)
}
}
func TestBuildScheduleDays(t *testing.T) {
monday := time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
days := buildScheduleDays(monday, []api.ScheduleEntry{
{Date: "2026-07-29", Username: "alice"},
})
if len(days) != 7 {
t.Fatalf("expected a 7-day window, got %d", len(days))
}
if days[2].entry == nil || days[2].entry.Username != "alice" {
t.Errorf("expected alice on the third day, got %+v", days[2].entry)
}
if days[0].entry != nil {
t.Error("expected unassigned days to have no entry")
}
}
// ── Schedule reassignment ─────────────────────────────────────────────────
// scheduledWeek builds a model showing the week of 2026-07-27 with the given
// entries already on the rota.
func scheduledWeek(entries []api.ScheduleEntry) Model {
m := NewModel(nil, "http://test", time.Minute)
m.width, m.height = 120, 40
m.connected = true
m.activeSection = sectionSchedule
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
m.scheduleEntries = entries
m.scheduleDays = buildScheduleDays(m.scheduleWindow, entries)
m.rebuildScheduleTable()
return m
}
func TestScheduleConflicts(t *testing.T) {
m := scheduledWeek([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
{ID: 2, Date: "2026-07-28", UserID: 3, Username: "sam"},
{ID: 3, Date: "2026-07-29", UserID: 2, Username: "alex"},
})
week := []string{"2026-07-27", "2026-07-28", "2026-07-29", "2026-07-30"}
// Assigning alex: the days niklas and sam hold are conflicts, the day alex
// already holds is not, and the free day is not.
taken, holders := m.scheduleConflicts(week, 2)
if len(taken) != 2 || taken[0] != "2026-07-27" || taken[1] != "2026-07-28" {
t.Errorf("expected the two other people's days, got %v", taken)
}
if len(holders) != 2 || holders[0] != "niklas" || holders[1] != "sam" {
t.Errorf("expected both holders named once, got %v", holders)
}
}
// Reassigning somebody to a day they already hold takes nothing from anyone, so
// it must not raise a prompt — but it still needs replace, because the server
// rejects any date that already exists.
func TestScheduleConflicts_OwnDayIsNotAConflict(t *testing.T) {
m := scheduledWeek([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 2, Username: "alex"},
})
dates := []string{"2026-07-27"}
if taken, _ := m.scheduleConflicts(dates, 2); len(taken) != 0 {
t.Errorf("expected no conflict on the user's own day, got %v", taken)
}
if !m.scheduleOccupied(dates) {
t.Error("expected the day to still count as occupied, so replace is sent")
}
}
func TestScheduleOccupied_FreeDays(t *testing.T) {
m := scheduledWeek(nil)
if m.scheduleOccupied([]string{"2026-07-27", "2026-07-28"}) {
t.Error("expected an empty rota to need no replace")
}
}
func TestDayCount(t *testing.T) {
tests := []struct {
taken, total int
want string
}{
{1, 1, "This day is"},
{7, 7, "All 7 days are"},
{3, 7, "3 of 7 days are"},
}
for _, tt := range tests {
if got := dayCount(tt.taken, tt.total); got != tt.want {
t.Errorf("dayCount(%d, %d) = %q, want %q", tt.taken, tt.total, got, tt.want)
}
}
}
func TestJoinNames(t *testing.T) {
tests := []struct {
names []string
want string
}{
{nil, "somebody else"},
{[]string{"niklas"}, "niklas"},
{[]string{"niklas", "alex"}, "niklas and alex"},
{[]string{"niklas", "alex", "sam"}, "niklas, alex and sam"},
}
for _, tt := range tests {
if got := joinNames(tt.names); got != tt.want {
t.Errorf("joinNames(%v) = %q, want %q", tt.names, got, tt.want)
}
}
}