// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "testing" "time" "git.stormux.org/storm/cthulhu/internal/notification" "git.stormux.org/storm/cthulhu/internal/notificationui" ) func TestNotificationListSnapshotMatchesReferenceLayout(t *testing.T) { presenter, controller, speech := notificationListFixture(t) worker := newNotificationListWorker(nil, presenter, speech) response := worker.snapshot("") if response.Title != "2 notifications" { t.Fatalf("title = %q, want %q", response.Title, "2 notifications") } if response.Selected != "1" { t.Fatalf("selected = %q, want %q", response.Selected, "1") } if len(response.Entries) != 2 { t.Fatalf("entry count = %d, want 2", len(response.Entries)) } latest := response.Entries[0] if latest.Message != "Live message" || latest.Received != "0 seconds ago" || !latest.CanControl { t.Fatalf("latest entry = %+v", latest) } if len(latest.Actions) != 2 || latest.Actions[0].Key != "default" || latest.Actions[1].Key != "reply" { t.Fatalf("actions = %+v, want sorted default and reply", latest.Actions) } if response.Entries[1].CanControl { t.Fatalf("older generic entry unexpectedly controllable: %+v", response.Entries[1]) } if len(controller.dismissed) != 0 || len(controller.invoked) != 0 { t.Fatalf("snapshot unexpectedly changed controller state: %+v", controller) } } func TestNotificationListActionsMatchReferenceBehavior(t *testing.T) { tests := []struct { name string request notificationui.Request wantClose bool wantSpeech string }{ { name: "copy", request: notificationui.Request{Copy: true}, wantSpeech: messageClipboardCopiedFull, }, { name: "dismiss", request: notificationui.Request{Dismiss: true, Selection: "1", Row: intPointer(0)}, wantSpeech: notification.MessageDismissed, }, { name: "invoke action", request: notificationui.Request{Action: "reply", Selection: "1", Row: intPointer(0)}, wantSpeech: notification.MessageActionInvoked, }, { name: "clear", request: notificationui.Request{Clear: true}, wantClose: true, wantSpeech: notification.MessageNoMessages, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { presenter, controller, speech := notificationListFixture(t) worker := newNotificationListWorker(nil, presenter, speech) response, close := worker.handle(test.request) if close != test.wantClose { t.Fatalf("close = %v, want %v", close, test.wantClose) } select { case message := <-speech.spoken: if message != test.wantSpeech { t.Fatalf("speech = %q, want %q", message, test.wantSpeech) } default: t.Fatal("no speech was presented") } if test.request.Clear && len(presenter.Entries()) != 0 { t.Fatalf("history length after clear = %d, want 0", len(presenter.Entries())) } if test.request.Dismiss && len(presenter.Entries()) != 1 { t.Fatalf("history length after dismiss = %d, want 1", len(presenter.Entries())) } if test.request.Action != "" && controller.invoked[0].key != "reply" { t.Fatalf("invoked = %+v, want reply", controller.invoked) } if response.Selected == "" && !test.wantClose { t.Fatalf("selected = %q, want a live selection", response.Selected) } }) } } func notificationListFixture(t *testing.T) (*notification.Presenter, *notificationListController, *fakeSpeechQueue) { t.Helper() now := time.Unix(1_800_000_000, 0) presenter := notification.NewPresenterAt(func() time.Time { return now }) controller := ¬ificationListController{} presenter.SetMakoController(controller) presenter.Save("Older message") identifier := 42 presenter.Save("Live message", notification.SaveOptions{ Source: notification.MakoSource, NotificationID: &identifier, Live: true, Actions: map[string]string{"reply": "Reply", "default": "View"}, }) speech := &fakeSpeechQueue{spoken: make(chan string, 4)} return presenter, controller, speech } func intPointer(value int) *int { return &value } type notificationListAction struct { id int key string } type notificationListController struct { dismissed []int invoked []notificationListAction } func (controller *notificationListController) IsCurrent(notification.Entry) bool { return true } func (controller *notificationListController) Dismiss(id int) bool { controller.dismissed = append(controller.dismissed, id) return true } func (controller *notificationListController) InvokeAction(id int, key string) bool { controller.invoked = append(controller.invoked, notificationListAction{id: id, key: key}) return true }