Files
terdut-tui/internal/tui/update_test.go
T
Niklas Ye 024dc095a5
CI / test (push) Successful in 15s
Release / test (push) Successful in 4s
Release / binaries (push) Successful in 28s
Show a colour-coded team picker instead of cycling with T
T used to silently cycle Model.activeTeamID through the caller's teams with
no visible list of choices. It now opens a full picker (modelled on the
existing user picker) listing every team plus "All teams", each with a
stable identity colour from a new six-colour theme palette. The same colour
now also shows as a bullet next to "team: <name>" in the header, so the
active team stays visible without opening the picker.

Adds an Identity palette to the theme package (six hues, skipping the ones
that already mean firing/critical), Styles.TeamColor to pick one by team id,
and identity_1..6 as theme-file tokens alongside the existing twelve so a
fully custom theme can still set every token.
2026-09-27 18:14:45 +02:00

1016 lines
31 KiB
Go

package tui
import (
"strings"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
tea "github.com/charmbracelet/bubbletea"
)
// press sends one key and returns the resulting model and command. A nil command
// means the model decided to do nothing, which is what most of these tests are
// really asserting.
func press(t *testing.T, m Model, key string) (Model, tea.Cmd) {
t.Helper()
var msg tea.KeyMsg
switch key {
case "esc":
msg = tea.KeyMsg{Type: tea.KeyEsc}
case "enter":
msg = tea.KeyMsg{Type: tea.KeyEnter}
case "tab":
msg = tea.KeyMsg{Type: tea.KeyTab}
default:
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
}
next, cmd := m.Update(msg)
return next.(Model), cmd
}
// sized returns a connected model with a usable window, which most handlers need.
func sized() Model {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40
m.connected = true
m.isAdmin = true // most handlers are being tested for what they do, not who may
return m
}
// onIncident opens the incident detail view directly, skipping the fetch.
func onIncident(inc api.Incident, timeline []api.IncidentEvent) Model {
m := sized()
m.mode = modeIncidentDetail
m.selectedIncident = inc
m.timeline = timeline
m.noteCursor = -1
return m
}
func openIncidentFixture() api.Incident {
return api.Incident{ID: 1, Title: "DiskFull", Status: api.StatusTriggered,
Severity: "critical", TriggeredAt: time.Now()}
}
func resolvedIncidentFixture() api.Incident {
now := time.Now()
source := "manual"
inc := openIncidentFixture()
inc.Status = api.StatusResolved
inc.ResolvedAt = &now
inc.ResolutionSource = &source
return inc
}
// Resolving is terminal on the server: a later occurrence opens a new incident
// rather than reopening this one. A stray keypress must not be able to do that.
func TestResolve_AsksBeforeDoingIt(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, cmd := press(t, m, "R")
if m.mode != modeConfirm {
t.Fatalf("expected a confirmation prompt, got mode %v", m.mode)
}
if m.confirmTarget != confirmResolveIncident {
t.Errorf("expected the resolve target, got %v", m.confirmTarget)
}
if cmd != nil {
t.Error("nothing should be sent to the server before confirming")
}
if !containsAll(m.confirmPrompt(), "final", "new incident") {
t.Errorf("the prompt should say resolving is final, got %q", m.confirmPrompt())
}
}
func TestResolve_CancelReturnsToDetailWithoutActing(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "R")
m, cmd := press(t, m, "n")
if m.mode != modeIncidentDetail {
t.Errorf("expected to land back on the incident, got mode %v", m.mode)
}
if cmd != nil {
t.Error("cancelling must not act")
}
}
func TestResolve_ConfirmActs(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "R")
m, cmd := press(t, m, "y")
if cmd == nil {
t.Error("confirming should issue the resolve")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to return to the incident, got mode %v", m.mode)
}
}
// The server answers 409 on all of these; saying so up front beats a round trip.
func TestResolvedIncident_RejectsWorkflowActions(t *testing.T) {
for _, key := range []string{"a", "A", "R", "s", "z", "Z"} {
t.Run(key, func(t *testing.T) {
m := onIncident(resolvedIncidentFixture(), nil)
m, cmd := press(t, m, key)
if cmd == nil {
t.Error("expected a status message command")
}
if m.statusMsg == "" {
t.Error("expected an explanation in the status line")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to stay on the incident, got mode %v", m.mode)
}
})
}
}
func TestOpenIncident_AcknowledgeTwiceIsRejected(t *testing.T) {
inc := openIncidentFixture()
id := int64(2)
at := time.Now()
inc.Status = api.StatusAcknowledged
inc.AcknowledgedByID = &id
inc.AcknowledgedBy = "alice"
inc.AcknowledgedAt = &at
m, _ := press(t, onIncident(inc, nil), "a")
if !containsAll(m.statusMsg, "already acknowledged", "alice") {
t.Errorf("expected to be told who holds it, got %q", m.statusMsg)
}
}
func TestOpenIncident_UnacknowledgeRequiresAnAcknowledgement(t *testing.T) {
m, _ := press(t, onIncident(openIncidentFixture(), nil), "A")
if m.statusMsg != "not acknowledged" {
t.Errorf("expected 'not acknowledged', got %q", m.statusMsg)
}
}
func TestOpenIncident_UnsnoozeRequiresASnooze(t *testing.T) {
m, _ := press(t, onIncident(openIncidentFixture(), nil), "Z")
if m.statusMsg != "not snoozed" {
t.Errorf("expected 'not snoozed', got %q", m.statusMsg)
}
}
// Archiving unresolved work only hides it, so the client refuses rather than
// letting the queue be cleared by pressing x.
func TestArchive_RefusesOpenIncident(t *testing.T) {
t.Run("from the detail view", func(t *testing.T) {
m, cmd := press(t, onIncident(openIncidentFixture(), nil), "x")
if cmd == nil || m.statusMsg == "" {
t.Error("expected a refusal message")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to stay put, got mode %v", m.mode)
}
})
t.Run("from the queue", func(t *testing.T) {
m := sized()
m.incidents = []api.Incident{openIncidentFixture()}
m.rebuildIncidentTable()
m, _ = press(t, m, "x")
if m.statusMsg == "" {
t.Error("expected a refusal message")
}
})
}
func TestArchive_AllowedOnResolvedIncident(t *testing.T) {
m := sized()
m.incidents = []api.Incident{resolvedIncidentFixture()}
m.rebuildIncidentTable()
m, cmd := press(t, m, "x")
if cmd == nil {
t.Error("archiving a resolved incident should act")
}
if m.statusMsg != "" {
t.Errorf("expected no refusal, got %q", m.statusMsg)
}
}
func TestSnooze_PromptThenSubmit(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "z")
if m.mode != modeSnooze {
t.Fatalf("expected the snooze prompt, got mode %v", m.mode)
}
// Typing goes to the input, not the key handler.
for _, r := range "2h" {
m, _ = press(t, m, string(r))
}
if m.snoozeInput.Value() != "2h" {
t.Fatalf("expected the typed duration, got %q", m.snoozeInput.Value())
}
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Error("expected the snooze to be sent")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to return to the incident, got mode %v", m.mode)
}
}
func TestSnooze_EmptyInputDoesNothing(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "z")
m, cmd := press(t, m, "enter")
if cmd != nil {
t.Error("an empty duration should not be sent")
}
if m.mode != modeSnooze {
t.Errorf("expected to stay on the prompt, got mode %v", m.mode)
}
}
func TestNote_EscapeAbandonsWithoutPosting(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "c")
if m.mode != modeNote {
t.Fatalf("expected the note prompt, got mode %v", m.mode)
}
m, cmd := press(t, m, "esc")
if cmd != nil {
t.Error("escaping must not post the note")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to return to the incident, got mode %v", m.mode)
}
}
func TestNoteCursor_WrapsOverNotesOnly(t *testing.T) {
timeline := []api.IncidentEvent{
{ID: 1, Type: api.EventTriggered},
{ID: 2, Type: api.EventNote, Detail: "first"},
{ID: 3, Type: api.EventAcknowledged},
{ID: 4, Type: api.EventNote, Detail: "second"},
}
m := onIncident(openIncidentFixture(), timeline)
m, _ = press(t, m, "]")
if m.noteCursor != 0 {
t.Fatalf("expected the first note, got %d", m.noteCursor)
}
m, _ = press(t, m, "]")
if m.noteCursor != 1 {
t.Fatalf("expected the second note, got %d", m.noteCursor)
}
m, _ = press(t, m, "]")
if m.noteCursor != 0 {
t.Errorf("expected to wrap to the first note, got %d", m.noteCursor)
}
m, _ = press(t, m, "[")
if m.noteCursor != 1 {
t.Errorf("expected to wrap backwards to the last note, got %d", m.noteCursor)
}
}
func TestDeleteNote_RequiresASelection(t *testing.T) {
m := onIncident(openIncidentFixture(), []api.IncidentEvent{{Type: api.EventTriggered}})
m, _ = press(t, m, "d")
if m.mode == modeConfirm {
t.Error("nothing is selected, so there is nothing to confirm")
}
if !containsAll(m.statusMsg, "select a note") {
t.Errorf("expected guidance, got %q", m.statusMsg)
}
}
func TestDeleteNote_ConfirmsThenActs(t *testing.T) {
timeline := []api.IncidentEvent{{ID: 9, Type: api.EventNote, Detail: "hi"}}
m := onIncident(openIncidentFixture(), timeline)
m, _ = press(t, m, "]")
m, _ = press(t, m, "d")
if m.mode != modeConfirm || m.confirmTarget != confirmDeleteNote {
t.Fatalf("expected a delete confirmation, got mode %v target %v", m.mode, m.confirmTarget)
}
if m.pendingDeleteID != 9 {
t.Errorf("expected the selected note's id, got %d", m.pendingDeleteID)
}
m, cmd := press(t, m, "y")
if cmd == nil {
t.Error("confirming should issue the delete")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to return to the incident, got mode %v", m.mode)
}
}
// ── Schedule reassignment ─────────────────────────────────────────────────
// pickingOnCall opens the user picker for the schedule day at dayIndex, which
// is where a reassignment actually starts.
func pickingOnCall(entries []api.ScheduleEntry, dayIndex int, week bool) Model {
m := scheduledWeek(entries)
m.users = []api.User{
{ID: 1, Username: "niklas", Email: "n@example.com"},
{ID: 2, Username: "alex", Email: "a@example.com"},
}
m.pickerTarget = pickerSchedule
m.pickerMembers = map[int64]bool{1: true, 2: true}
m.rebuildUserPickerTable()
m.scheduleTable.SetCursor(dayIndex)
m.pickerAssignWeek = week
m.pickerTarget = pickerSchedule
m.mode = modeUserPicker
m.userPickerTable.SetCursor(1) // alex
return m
}
// The bug: a day somebody already holds could not be handed to anybody else.
// The server refuses it, so the TUI has to ask first and then say so.
func TestSchedule_ReassigningATakenDayAsksFirst(t *testing.T) {
m := pickingOnCall([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
}, 0, false)
m, cmd := press(t, m, "enter")
if m.mode != modeConfirm || m.confirmTarget != confirmReassignSchedule {
t.Fatalf("expected a reassignment confirmation, got mode %v target %v",
m.mode, m.confirmTarget)
}
if cmd != nil {
t.Error("expected nothing sent to the server before confirming")
}
mustContain(t, m.confirmPrompt(), "This day is assigned to niklas", "Reassign to alex?")
}
func TestSchedule_ReassignConfirmedSends(t *testing.T) {
m := pickingOnCall([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
}, 0, false)
m, _ = press(t, m, "enter")
m, cmd := press(t, m, "y")
if cmd == nil {
t.Fatal("expected the confirmed reassignment to be sent")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
if m.pendingAssign != nil {
t.Error("expected the pending assignment cleared")
}
}
// Declining must leave the rota alone — that is the whole point of the guard.
func TestSchedule_ReassignDeclinedSendsNothing(t *testing.T) {
m := pickingOnCall([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
}, 0, false)
m, _ = press(t, m, "enter")
m, cmd := press(t, m, "n")
if cmd != nil {
t.Error("expected nothing sent when the reassignment is declined")
}
if m.pendingAssign != nil {
t.Error("expected the pending assignment discarded")
}
}
// A free day is the path that always worked, and must not grow a prompt.
func TestSchedule_AssigningAFreeDayDoesNotAsk(t *testing.T) {
m := pickingOnCall(nil, 0, false)
m, cmd := press(t, m, "enter")
if m.mode != modeDashboard {
t.Errorf("expected no prompt for a free day, got mode %v", m.mode)
}
if cmd == nil {
t.Error("expected the assignment to be sent straight away")
}
}
// The week case is the one that was worst: a single taken day rejected all
// seven. One prompt now covers the lot, and it says how much is being taken.
func TestSchedule_ReassigningAPartlyTakenWeekAsksOnce(t *testing.T) {
m := pickingOnCall([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-28", UserID: 1, Username: "niklas"},
{ID: 2, Date: "2026-07-30", UserID: 3, Username: "sam"},
}, 0, true)
m, _ = press(t, m, "enter")
if m.confirmTarget != confirmReassignSchedule {
t.Fatalf("expected one confirmation for the week, got target %v", m.confirmTarget)
}
if got := len(m.pendingAssign.dates); got != 7 {
t.Errorf("expected all 7 days in the assignment, got %d", got)
}
mustContain(t, m.confirmPrompt(), "2 of 7 days are assigned to niklas and sam")
}
// ── Ntfy topic ────────────────────────────────────────────────────────────
// onUsers puts the model in the Users section with a loaded table.
func onUsers(users []api.User) Model {
m := sized()
m.activeSection = sectionUsers
m.users = users
m.rebuildUserManageTable()
return m
}
func userFixtures() []api.User {
topic := "terdut-niklas"
return []api.User{
{ID: 1, Username: "niklas", Email: "niklas@example.com", NtfyTopic: &topic},
{ID: 2, Username: "alex", Email: "alex@example.com"},
}
}
func TestNotifyTopic_EditPrefillsTheCurrentTopic(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
if m.mode != modeUserNotifyEdit {
t.Fatalf("expected the topic editor, got mode %v", m.mode)
}
if m.selectedUser.ID != 1 {
t.Errorf("expected the user under the cursor, got %d", m.selectedUser.ID)
}
// Prefilled, so editing a topic does not mean retyping it from scratch.
if got := m.ntfyTopicInput.Value(); got != "terdut-niklas" {
t.Errorf("expected the current topic prefilled, got %q", got)
}
}
// A user with no topic opens an empty field rather than the previous user's.
func TestNotifyTopic_EditStartsEmptyWhenUnset(t *testing.T) {
m := onUsers(userFixtures())
m, _ = press(t, m, "t")
m, _ = press(t, m, "esc")
m.userManageTable.SetCursor(1)
m, _ = press(t, m, "t")
if got := m.ntfyTopicInput.Value(); got != "" {
t.Errorf("expected an empty field for a user with no topic, got %q", got)
}
}
func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, cmd := press(t, m, "esc")
if cmd != nil {
t.Error("expected escape to save nothing")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
}
// Clearing a topic is a real action, not a no-op: it is how a user is taken off
// their own topic and back onto the shared fallback. Contrast the snooze prompt,
// where an empty value means "I changed my mind".
func TestNotifyTopic_EmptyInputStillSubmits(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m.ntfyTopicInput.SetValue("")
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("expected clearing the topic to call the server")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
}
func TestNotifyTopic_IsUsersSectionOnly(t *testing.T) {
m := sized()
m.activeSection = sectionIncidents
if next, cmd := press(t, m, "t"); cmd != nil || next.mode != modeDashboard {
t.Error("expected t to do nothing outside the Users section")
}
}
func TestTab_CyclesEverySection(t *testing.T) {
m := sized()
if m.activeSection != sectionIncidents {
t.Fatal("incidents is the section the client opens on")
}
want := []section{sectionAlerts, sectionStats, sectionArchived, sectionSchedule,
sectionUsers, sectionIncidents}
for i, expected := range want {
m, _ = press(t, m, "tab")
if m.activeSection != expected {
t.Fatalf("after %d tabs expected section %v, got %v", i+1, expected, m.activeSection)
}
}
}
func TestFilter_CyclesPerSection(t *testing.T) {
m := sized()
m, _ = press(t, m, "f")
if m.incidentFilter != api.StatusTriggered {
t.Errorf("expected the incident filter to advance, got %q", m.incidentFilter)
}
m.activeSection = sectionAlerts
m, _ = press(t, m, "f")
if m.alertFilter != "resolved" {
t.Errorf("expected the alert filter to advance, got %q", m.alertFilter)
}
if m.incidentFilter != api.StatusTriggered {
t.Error("the two filters are independent")
}
}
// Stats is a section like any other: no key of its own, no mode of its own, and
// it loads once on first visit rather than on every tab-in — the three empty
// slices a quiet server returns are a real answer, not a missing one.
func TestStats_IsAnOrdinarySection(t *testing.T) {
m := sized()
m.activeSection = sectionAlerts
m, cmd := press(t, m, "tab")
if m.activeSection != sectionStats {
t.Fatalf("expected the stats section, got %v", m.activeSection)
}
if m.mode != modeDashboard {
t.Errorf("stats is a section, not a mode: got mode %v", m.mode)
}
if cmd == nil {
t.Error("the first visit should fetch")
}
m.statsLoaded = true
m.statsLoading = false
if cmd := m.loadSectionIfEmpty(); cmd != nil {
t.Error("a second visit should reuse what was already fetched")
}
}
// S used to open the stats overlay from anywhere. It is gone, and must not
// disturb the view it is pressed in.
func TestStats_KeyIsGone(t *testing.T) {
m, _ := press(t, sized(), "S")
if m.activeSection != sectionIncidents || m.mode != modeDashboard {
t.Errorf("S should do nothing on the queue, got section %v mode %v",
m.activeSection, m.mode)
}
m, _ = press(t, onIncident(openIncidentFixture(), nil), "S")
if m.mode != modeIncidentDetail {
t.Errorf("S should leave the incident open, got mode %v", m.mode)
}
}
// The overlay never auto-refreshed, because the tick skipped every non-dashboard
// mode. As a section it rides the tick like the rest.
func TestStats_RefreshesOnTick(t *testing.T) {
m := sized()
m.activeSection = sectionStats
if m.refreshActiveSection() == nil {
t.Error("the stats section should refresh on the tick")
}
}
// Alerts carry no workflow state, so the detail view offers nothing but a way
// through to the incident.
func TestAlertDetail_IsReadOnly(t *testing.T) {
m := sized()
m.mode = modeAlertDetail
m.selectedAlert = api.Alert{ID: 3, Name: "DiskFull", Status: "firing"}
for _, key := range []string{"a", "A", "R", "c", "x", "z"} {
next, cmd := press(t, m, key)
if cmd != nil {
t.Errorf("key %q should do nothing on an alert", key)
}
if next.mode != modeAlertDetail {
t.Errorf("key %q changed mode to %v", key, next.mode)
}
}
}
func TestAlertDetail_JumpToIncident(t *testing.T) {
m := sized()
m.mode = modeAlertDetail
t.Run("without an incident", func(t *testing.T) {
m.selectedAlert = api.Alert{ID: 3, Name: "Orphan"}
next, _ := press(t, m, "i")
if next.mode != modeAlertDetail || next.statusMsg == "" {
t.Errorf("expected a refusal, got mode %v msg %q", next.mode, next.statusMsg)
}
})
t.Run("with an incident", func(t *testing.T) {
id := int64(7)
m.selectedAlert = api.Alert{ID: 3, Name: "DiskFull", IncidentID: &id}
next, cmd := press(t, m, "i")
if next.mode != modeIncidentDetail {
t.Fatalf("expected the incident view, got mode %v", next.mode)
}
if next.selectedIncident.ID != 7 {
t.Errorf("expected incident 7, got %d", next.selectedIncident.ID)
}
if cmd == nil {
t.Error("expected the incident to be fetched")
}
})
}
// A refresh underneath a prompt would move the ground under the user.
func TestRefreshTick_SkipsModalStates(t *testing.T) {
modal := []mode{modeNote, modeSnooze, modeConfirm, modeUserPicker, modeTeamPicker, modeUserCreate}
for _, md := range modal {
m := sized()
m.mode = md
if cmd := m.refreshActiveSection(); cmd != nil {
t.Errorf("mode %v should not auto-refresh", md)
}
}
m := sized()
if cmd := m.refreshActiveSection(); cmd == nil {
t.Error("the dashboard should auto-refresh")
}
}
func TestIncidentsFetched_ClearsLoading(t *testing.T) {
m := sized()
m.loading = true
next, _ := m.Update(incidentsFetchedMsg{incidents: []api.Incident{openIncidentFixture()}})
got := next.(Model)
if got.loading {
t.Error("expected loading to clear")
}
if len(got.incidents) != 1 {
t.Errorf("expected the incidents stored, got %d", len(got.incidents))
}
}
// A note deleted elsewhere must not leave the cursor pointing past the end.
func TestIncidentDetailFetched_ClampsNoteCursor(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m.noteCursor = 3
next, _ := m.Update(incidentDetailFetchedMsg{
incident: openIncidentFixture(),
timeline: []api.IncidentEvent{{Type: api.EventTriggered}},
})
if got := next.(Model).noteCursor; got != -1 {
t.Errorf("expected the cursor reset, got %d", got)
}
}
func containsAll(s string, subs ...string) bool {
for _, sub := range subs {
if !strings.Contains(s, sub) {
return false
}
}
return true
}
// The bug: assigning an on-call week panicked with "index out of range [-1]"
// on a perfectly normal schedule, as long as nobody had moved the cursor first.
//
// The cause is not in this package. bubbles' SetRows clamps the cursor down but
// never up, so the empty rebuild every table gets from the first WindowSizeMsg
// -- which arrives before any fetch returns -- pins the cursor at -1, and
// loading real rows afterwards leaves it there. Pressing up or down hid it,
// which is why every existing test missed it: they all call SetCursor, and
// SetCursor clamps.
//
// So this test must NOT touch the cursor. It reproduces the real order of
// events: size first, data second, keys third.
func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.connected = true
m.isAdmin = true
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleOwner}}
m.activeSection = sectionSchedule
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
// 1. Terminal size arrives while every table is still empty.
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = next.(Model)
// 2. The schedule and the user list land.
next, _ = m.Update(scheduleFetchedMsg{entries: []api.ScheduleEntry{}})
m = next.(Model)
next, _ = m.Update(usersFetchedMsg{users: []api.User{
{ID: 1, Username: "niklas", Email: "n@example.com"},
}})
m = next.(Model)
if got := m.scheduleTable.Cursor(); got < 0 {
t.Fatalf("schedule cursor is %d after loading %d days; a populated table must have a usable cursor",
got, len(m.scheduleDays))
}
// 3. Assign the week to the first user, without ever moving a cursor.
m, _ = press(t, m, "W")
if m.mode != modeUserPicker {
t.Fatalf("W did not open the user picker, got mode %v", m.mode)
}
// The picker waits for the team's members before offering anybody.
next, _ = m.Update(pickerReadyMsg{
users: []api.User{{ID: 1, Username: "niklas", Email: "n@example.com"}},
members: map[int64]bool{1: true},
})
m = next.(Model)
m, _ = press(t, m, "enter") // panicked here
if m.mode == modeUserPicker {
t.Fatal("enter left the picker open; the assignment never went anywhere")
}
}
// ── Teams ─────────────────────────────────────────────────────────────────
func twoTeams() []api.Team {
return []api.Team{
{ID: 1, Name: "Ops", Role: api.RoleOwner},
{ID: 2, Name: "Dev", Role: api.RoleMember},
}
}
func TestConnected_LoadsTeamsAndWhoIAm(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40
next, _ := m.Update(connectedMsg{
teams: twoTeams(),
me: api.Me{User: api.User{ID: 7, IsAdmin: true}},
})
m = next.(Model)
if len(m.teams) != 2 || m.meID != 7 || !m.isAdmin {
t.Errorf("expected teams, id and admin flag to be kept, got %+v %d %v", m.teams, m.meID, m.isAdmin)
}
if m.activeTeamID != 0 {
t.Errorf("with no default team every team shows, got active %d", m.activeTeamID)
}
}
func TestConnected_DefaultTeamFromConfig(t *testing.T) {
for _, want := range []string{"dev", "2"} { // by name, any case, or by id
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark).WithDefaultTeam(want)
next, _ := m.Update(connectedMsg{teams: twoTeams()})
if got := next.(Model).activeTeamID; got != 2 {
t.Errorf("default team %q: expected team 2, got %d", want, got)
}
}
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark).WithDefaultTeam("nope")
next, cmd := m.Update(connectedMsg{teams: twoTeams()})
m = next.(Model)
if m.activeTeamID != 0 || !strings.Contains(m.statusMsg, "nope") {
t.Errorf("an unknown default should fall back to all teams and say so, got %d %q",
m.activeTeamID, m.statusMsg)
}
if cmd == nil {
t.Error("expected the initial fetches to still be issued")
}
}
func TestTeamPicker_Opens(t *testing.T) {
m := sized()
m.teams = twoTeams()
m, cmd := press(t, m, "T")
if m.mode != modeTeamPicker {
t.Fatalf("T should open the team picker, got mode %v", m.mode)
}
if cmd != nil {
t.Error("opening the picker should not itself trigger a reload")
}
}
// Row 0 of the picker table is always "All teams"; row i (i>=1) is
// m.teams[i-1] — see rebuildTeamPickerTable.
func TestTeamPicker_SelectTeamClearsRowsFromTheOtherTeam(t *testing.T) {
m := sized()
m.teams = twoTeams()
m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}}
m.rebuildIncidentTable()
m, _ = press(t, m, "T")
m.teamPickerTable.SetCursor(1) // twoTeams()[0] is Ops
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("selecting a team should reload")
}
if m.mode != modeDashboard {
t.Fatalf("enter should close the picker, got mode %v", m.mode)
}
if m.activeTeamID != 1 {
t.Fatalf("expected team 1 (Ops) active, got %d", m.activeTeamID)
}
if len(m.incidents) != 0 {
t.Errorf("the previous team's incidents must not linger, got %d", len(m.incidents))
}
if !strings.Contains(m.statusMsg, "Ops") {
t.Errorf("expected the status bar to name the team, got %q", m.statusMsg)
}
}
func TestTeamPicker_SelectAllTeams(t *testing.T) {
m := sized()
m.teams = twoTeams()
m.activeTeamID = 1
m, _ = press(t, m, "T")
m.teamPickerTable.SetCursor(0) // "All teams"
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("selecting all teams should reload")
}
if m.activeTeamID != 0 {
t.Fatalf("expected all teams (0), got %d", m.activeTeamID)
}
}
func TestTeamPicker_EscCancelsWithoutChangingTeam(t *testing.T) {
m := sized()
m.teams = twoTeams()
m.activeTeamID = 1
m, _ = press(t, m, "T")
m, cmd := press(t, m, "esc")
if cmd != nil {
t.Error("cancelling should not reload")
}
if m.mode != modeDashboard {
t.Fatalf("esc should close the picker, got mode %v", m.mode)
}
if m.activeTeamID != 1 {
t.Fatalf("cancelling must not change the active team, got %d", m.activeTeamID)
}
}
func TestTeamPicker_NoTeamsDoesNothing(t *testing.T) {
m, cmd := press(t, sized(), "T")
if cmd != nil || m.mode == modeTeamPicker {
t.Errorf("without teams T has nothing to open")
}
}
func TestScheduleTeam(t *testing.T) {
m := sized()
if _, ok := m.scheduleTeam(); ok {
t.Error("no teams means no schedule")
}
m.teams = []api.Team{{ID: 2, Name: "Dev", Role: api.RoleMember}, {ID: 1, Name: "Ops", Role: api.RoleOwner}}
if tm, _ := m.scheduleTeam(); tm.ID != 1 {
t.Errorf("with all teams showing the one the caller owns is used, got %d", tm.ID)
}
m.activeTeamID = 2
if tm, _ := m.scheduleTeam(); tm.ID != 2 {
t.Errorf("the active team wins, got %d", tm.ID)
}
}
func TestSchedule_OnlyOwnersAndAdminsEdit(t *testing.T) {
m := scheduledWeek(nil)
m.isAdmin = false
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleMember}}
m, cmd := press(t, m, "+")
if m.mode == modeUserPicker {
t.Fatal("a plain member must not get as far as the picker")
}
if !strings.Contains(m.statusMsg, "owners of Ops") {
t.Errorf("expected the reason in the status bar, got %q", m.statusMsg)
}
if cmd == nil {
t.Error("the message should clear itself")
}
m.isAdmin = true // administrators may edit any team's rota
m.statusMsg = ""
m, _ = press(t, m, "+")
if m.mode != modeUserPicker {
t.Errorf("an administrator should reach the picker, got mode %v", m.mode)
}
}
// The picker fetches the team's members, and offers nobody until they arrive:
// the server answers 404 for anyone else.
func TestSchedulePicker_OffersOnlyTeamMembers(t *testing.T) {
m := scheduledWeek(nil)
m, cmd := press(t, m, "+")
if cmd == nil || !m.usersLoading {
t.Fatal("opening the picker should start loading the members")
}
if got := len(m.pickerUsers()); got != 0 {
t.Errorf("nobody should be offered before the members are known, got %d", got)
}
disabled := time.Now()
next, _ := m.Update(pickerReadyMsg{
users: []api.User{
{ID: 1, Username: "niklas"},
{ID: 2, Username: "outsider"},
{ID: 3, Username: "gone", DisabledAt: &disabled},
},
members: map[int64]bool{1: true, 3: true},
})
m = next.(Model)
got := m.pickerUsers()
if len(got) != 1 || got[0].Username != "niklas" {
t.Errorf("expected only the enabled member, got %+v", got)
}
if rows := m.userPickerTable.Rows(); len(rows) != 1 {
t.Errorf("the table should match, got %d rows", len(rows))
}
}
func TestUsers_NonAdminsCannotCreateOrDelete(t *testing.T) {
m := threeUsers()
m.isAdmin = false
m.meID = 1
m, _ = press(t, m, "n")
if m.mode != modeDashboard || !strings.Contains(m.statusMsg, "administrators") {
t.Errorf("n should be refused with a reason, got mode %v %q", m.mode, m.statusMsg)
}
m, _ = press(t, m, "d")
if m.mode != modeDashboard {
t.Errorf("d should not ask to delete for a non-admin, got mode %v", m.mode)
}
}
func TestUsers_NonAdminManagesOnlyThemselves(t *testing.T) {
m := threeUsers() // cursor on erik, id 3
m.isAdmin = false
m.meID = 1
for _, key := range []string{"t", "k", "p"} {
next, _ := press(t, m, key)
if next.mode != modeDashboard {
t.Errorf("%s on somebody else's row should be refused, got mode %v", key, next.mode)
}
if !strings.Contains(next.statusMsg, "another user's") {
t.Errorf("%s: expected the reason, got %q", key, next.statusMsg)
}
}
m.userManageTable.SetCursor(0) // niklas, id 1: themselves
if next, _ := press(t, m, "k"); next.mode != modeAPIKeyMenu {
t.Errorf("a user may manage their own keys, got mode %v", next.mode)
}
}
func TestUserFlags(t *testing.T) {
now := time.Now()
cases := []struct {
u api.User
want string
}{
{api.User{}, "—"},
{api.User{IsAdmin: true}, "admin"},
{api.User{DisabledAt: &now}, "disabled"},
{api.User{IsAdmin: true, DisabledAt: &now}, "admin,disabled"},
}
for _, c := range cases {
if got := userFlags(c.u); got != c.want {
t.Errorf("userFlags(%+v) = %q, want %q", c.u, got, c.want)
}
}
}
// Rebuilding a list on every refresh must not send the cursor back to the top.
func TestRefresh_KeepsTheCursor(t *testing.T) {
m := sized()
m.incidents = []api.Incident{{ID: 1}, {ID: 2}, {ID: 3}}
m.rebuildIncidentTable()
m.incidentTable.SetCursor(2)
next, _ := m.Update(incidentsFetchedMsg{incidents: m.incidents})
if got := next.(Model).incidentTable.Cursor(); got != 2 {
t.Errorf("expected the cursor to stay on row 2, got %d", got)
}
}
// The Team column comes and goes as the team switches, and the table must
// survive its column count changing under rows that are already loaded.
func TestTeamColumn_AppearsWithoutPanicking(t *testing.T) {
m := sized()
m.incidents = []api.Incident{{ID: 1, Title: "a", TeamName: "Ops"}}
m.rebuildIncidentTable()
m.teams = twoTeams()
m.rebuildIncidentTable() // five columns become six over a five-cell row
if got := len(m.incidentTable.Columns()); got != 6 {
t.Errorf("expected a Team column across two teams, got %d columns", got)
}
m.activeTeamID = 1
m.rebuildIncidentTable()
if got := len(m.incidentTable.Columns()); got != 5 {
t.Errorf("expected the Team column to go when one team is chosen, got %d", got)
}
}