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.
679 lines
20 KiB
Go
679 lines
20 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
|
)
|
|
|
|
// 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)
|
|
m.width, m.height = 120, 40
|
|
m.connected = true
|
|
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.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, 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
|
|
}
|