6fdb4bbbf8
The module path, the CI pipeline and the self-updater all named GitHub. They now name the Gitea instance everything else already runs on. The workflows are rewritten rather than translated, for the reason recorded in ci.yaml: Gitea's runner image is ubuntu:22.04, whose nodejs is Node 12, so no JS action runs there -- actions/checkout@v4 dies with a SyntaxError before doing anything. Every step is shell and checkout is a plain clone, which this public repo needs no credential for. upload-artifact/download-artifact are JS actions too, and there is no artifact store here, so the job that builds the binaries is the job that publishes them. internal/updater keeps its release and asset types unchanged: Gitea's release payload carries the same tag_name, and its attachments the same name and browser_download_url, so only the URL, the Accept header and one error string move. The asset naming in release.yaml is load-bearing for that matching. This does strand already-installed binaries, which still poll api.github.com. The GitHub repository is left in place and untouched, so they report themselves up to date rather than erroring; its last release is the bridge, and crossing it is a one-time manual download.
372 lines
11 KiB
Go
372 lines
11 KiB
Go
package tui
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/table"
|
|
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
|
)
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|