// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "fmt" "io" "net" "os" "path/filepath" "reflect" "strings" "sync" "testing" "time" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/control" "git.stormux.org/storm/fenrir/internal/input" "git.stormux.org/storm/fenrir/internal/interaction" "git.stormux.org/storm/fenrir/internal/presentation" ) func TestNotificationSoundCooldownBoundaryAndConcurrentAdmission(t *testing.T) { var gate notificationSoundGate now := time.Now() var workers sync.WaitGroup accepted := make(chan bool, 32) for range cap(accepted) { workers.Go(func() { accepted <- gate.allow(now) }) } workers.Wait() close(accepted) count := 0 for allowed := range accepted { if allowed { count++ } } if count != 1 { t.Fatal("concurrent notifications admitted multiple cues", count) } for _, trial := range []struct { after time.Duration want bool }{ {time.Second, false}, {30*time.Second - time.Nanosecond, false}, {30 * time.Second, true}, {31 * time.Second, false}, {60 * time.Second, true}, } { if got := gate.allow(now.Add(trial.after)); got != trial.want { t.Fatalf("after %v: allowed=%v, want %v", trial.after, got, trial.want) } } } func TestNotificationBurstLimitsCuesWithoutLosingSpeechOrHistory(t *testing.T) { for _, mode := range []string{"enabled", "muted", "silenced"} { t.Run(mode, func(t *testing.T) { a, voice := persistence_application(t, true) voice.messages = nil sounds := &fakeSounds{} a.cues = sounds a.muted, voice.muted = mode == "muted", mode == "muted" a.temporarySilence = mode == "silenced" var want []string for index := range 10 { text := fmt.Sprintf("Notification %d", index) want = append(want, text) a.notify(text, time.Now()) if len(sounds.cues) != 1 || len(a.notifications) != index+1 || a.notifications[index].text != text { t.Fatal("burst cue or retained message", sounds.cues, a.notifications) } } if mode != "enabled" { want = nil } if !reflect.DeepEqual(voice.messages, want) { t.Fatal("burst changed speech policy", voice.messages) } // A mute transition shares the cooldown with ordinary notifications. a.muted, voice.muted, a.temporarySilence = false, false, false a.notify("Still cooling down", time.Now()) if len(sounds.cues) != 1 || voice.messages[len(voice.messages)-1] != "Still cooling down" { t.Fatal("unmuting reset the cooldown", sounds.cues, voice.messages) } a.notificationSound.mu.Lock() a.notificationSound.lastPlay = time.Now().Add(-notificationSoundInterval) a.notificationSound.mu.Unlock() a.notify("After cooldown", time.Now()) if len(sounds.cues) != 2 || voice.messages[len(voice.messages)-1] != "After cooldown" || len(a.notifications) != 12 { t.Fatal("cue did not resume after cooldown", sounds.cues, voice.messages, a.notifications) } }) } } type queuedNotificationVoice struct { fakeVoice requests []struct { text string before func(context.Context) } } func (v *queuedNotificationVoice) Say_after(text string, before func(context.Context)) { v.requests = append(v.requests, struct { text string before func(context.Context) }{text, before}) } func TestQueuedNotificationsCheckCooldownAtPlaybackAndCancellation(t *testing.T) { a, _ := persistence_application(t, true) voice := &queuedNotificationVoice{} a.voice.Speaker = voice sounds := &fakeSounds{} a.cues = sounds a.notify("Canceled", time.Now()) ctx, cancel := context.WithCancel(context.Background()) cancel() voice.requests[0].before(ctx) if !a.notificationSound.lastPlay.IsZero() || len(sounds.cues) != 0 { t.Fatal("canceled cue started the cooldown") } a.notify("First queued", time.Now()) // More than 30 seconds can pass while notifications wait in the speech queue. a.notify("Second queued", time.Now().Add(time.Minute)) if !a.notificationSound.lastPlay.IsZero() || len(sounds.cues) != 0 { t.Fatal("cue cooldown started before playback") } for _, request := range voice.requests[1:] { request.before(context.Background()) voice.Say(request.text) } if !reflect.DeepEqual(sounds.cues, []presentation.Cue{presentation.Notification}) || !reflect.DeepEqual(voice.messages, []string{"First queued", "Second queued"}) { t.Fatal("queued notifications accumulated sound waits or lost speech", sounds.cues, voice.messages) } } func TestNotificationCuePrecedesSpeechAndOnlyPlaysForNewNotifications(t *testing.T) { for _, mode := range []string{"enabled", "disabled", "zero volume", "missing cue", "unavailable", "muted", "silenced"} { t.Run(mode, func(t *testing.T) { a, voice := persistence_application(t, true) voice.messages = nil sounds := &fakeSounds{} a.cues = sounds switch mode { case "disabled": a.settings.live.Presentation.Sound.Enabled = false case "zero volume": a.settings.live.Presentation.Sound.Volume = 0 case "missing cue": sounds.missing = map[presentation.Cue]bool{presentation.Notification: true} case "unavailable": a.cues = nil case "muted": a.muted, voice.muted = true, true case "silenced": a.temporarySilence = true } wantCue := mode == "enabled" || mode == "muted" || mode == "silenced" voice.onSay = func(string) { if wantCue && !reflect.DeepEqual(sounds.cues, []presentation.Cue{presentation.Notification}) { t.Fatal("speech arrived before notification cue", sounds.cues) } } if reply := runtime_request(a, "command notify Incoming message"); reply != "" { t.Fatal(reply) } if (len(sounds.cues) == 1) != wantCue || len(a.notifications) != 1 { t.Fatal("notification cue or history", sounds.cues, a.notifications) } if (len(voice.messages) == 1) != (mode != "muted" && mode != "silenced") { t.Fatal("notification speech policy", voice.messages) } a.last_notification() runtime_request(a, "command say Self-voice") if (len(sounds.cues) == 1) != wantCue || len(a.notifications) != 1 { t.Fatal("recall or self-voice replayed notification cue", sounds.cues, a.notifications) } }) } } func TestNotificationThroughPrivateSocket(t *testing.T) { a, voice := persistence_application(t, true) voice.messages = nil directory := t.TempDir() path := filepath.Join(directory, "reader.sock") ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() server, err := control.Open(ctx, control.Options{SocketFile: path, RegistryDirectory: filepath.Join(directory, "registry")}) if err != nil { t.Fatal(err) } defer server.Close() connection, err := net.Dial("unix", path) if err != nil { t.Fatal(err) } defer connection.Close() connection.SetDeadline(time.Now().Add(2 * time.Second)) if _, err := io.WriteString(connection, "command notify Hello, 世界\n"); err != nil { t.Fatal(err) } select { case request := <-server.Requests(): a.remote_request(request) case <-ctx.Done(): t.Fatal("notification did not reach the application") } response, err := io.ReadAll(connection) if err != nil || len(response) != 0 || len(a.notifications) != 1 || a.notifications[0].text != "Hello, 世界" || !reflect.DeepEqual(voice.messages, []string{"Hello, 世界"}) { t.Fatal("socket notification delivery failed", string(response), err, a.notifications, voice.messages) } } func TestNotificationRemoteAdmissionAndSpeechPolicy(t *testing.T) { for _, terminalMode := range []bool{false, true} { a, voice := persistence_application(t, terminalMode) voice.messages = nil for _, trial := range []struct { text string uid int enabled bool }{ {"COMMAND NOTIFY blocked", os.Geteuid() + 1, true}, {"COMMAND NOTIFY blocked", os.Geteuid(), false}, {"COMMAND NOTIFY", os.Geteuid(), true}, {"COMMAND NOTIFY ", os.Geteuid(), true}, } { a.settings.live.Remote.EnableCommandRemote = trial.enabled reply := make(chan string, 1) a.remote_request(control.Request{Text: trial.text, UID: trial.uid, Reply: reply}) if response := <-reply; response == "" || len(a.notifications) != 0 || len(voice.messages) != 0 { t.Fatal("rejected notification had side effects", response, a.notifications, voice.messages) } } a.settings.live.Remote.EnableCommandRemote = true before := time.Now() if response := runtime_request(a, "command notify You are transmatting"); response != "" { t.Fatal(response) } entry := a.notifications[0] if entry.text != "You are transmatting" || entry.received.Before(before) || entry.received.After(time.Now()) || !reflect.DeepEqual(voice.messages, []string{entry.text}) { t.Fatal("notification was not spoken and timestamped", entry, voice.messages) } runtime_request(a, "COMMAND SAY unrelated self-voice") a.handle_command(command.LastNotification) if len(a.notifications) != 1 || voice.messages[len(voice.messages)-1] != entry.text { t.Fatal("self-voice replaced notification", a.notifications, voice.messages) } for _, state := range []string{"temporary", "muted", "automatic off"} { a.temporarySilence = state == "temporary" a.muted, voice.muted = state == "muted", state == "muted" a.settings.live.Reading.Automatic = false voice.messages = nil runtime_request(a, "COMMAND NOTIFY "+state) if a.notifications[len(a.notifications)-1].text != state || (len(voice.messages) > 0) != (state == "automatic off") { t.Fatal("notification storage or speech policy", state, a.notifications, voice.messages) } } a.temporarySilence, a.muted, voice.muted = false, false, false a.handle_command(command.LastNotification) if voice.messages[len(voice.messages)-1] != "automatic off" { t.Fatal("retained notification could not be recalled", voice.messages) } } } func TestNotificationHistoryTimesNavigationAndNewArrivals(t *testing.T) { a, voice := persistence_application(t, true) keys := a.keyboards.(*fakeKeys) a.notify("Hello world", time.Date(2026, 1, 2, 13, 38, 0, 0, time.Local)) a.notify("You are transmatting", time.Date(2026, 1, 2, 22, 50, 0, 0, time.Local)) for _, trial := range []struct{ format, first, second string }{ {"12-hour", "10:50 PM", "01:38 PM"}, {"24-hour", "22:50", "13:38"}, {"24-hour-seconds", "22:50:00", "13:38:00"}, } { a.settings.live.Presentation.DateTime.TimeFormat = trial.format a.handle_input(input.Decision{Command: command.NotificationHistory, Interrupt: true, Keypress: true}) if !a.ui.active || a.ui.session == nil || !strings.HasSuffix(voice.messages[len(voice.messages)-1], "You are transmatting "+trial.first) { t.Fatal("history did not open at newest entry", trial, voice.messages) } a.ui.handle(interaction.Key{Name: "down"}) a.ui.handle(interaction.Key{Name: "enter"}) if got := voice.messages[len(voice.messages)-1]; got != "Hello world "+trial.second { t.Fatal("history order, repeat or received time", trial, got) } a.ui.handle(interaction.Key{Name: "tab"}) control, _ := a.ui.session.Focused_control() if control.ID != "close" { t.Fatal("Tab could not reach Close", control) } a.ui.handle(interaction.Key{Name: "tab", Shift: true}) control, _ = a.ui.session.Focused_control() if control.ID != "history" { t.Fatal("Shift+Tab could not return to list", control) } a.ui.handle(interaction.Key{Name: "escape"}) if a.ui.active || keys.captures[len(keys.captures)-1] { t.Fatal("history did not release capture") } } a.open_notifications() a.ui.handle(interaction.Key{Name: "down"}) selected := a.ui.session.Values()["notifications.history"] a.notify("New arrival", time.Now()) if got := a.ui.session.Values()["notifications.history"]; !reflect.DeepEqual(got, selected) { t.Fatal("arrival moved the selection", got, selected) } a.ui.close() a.open_notifications() if !strings.Contains(voice.messages[len(voice.messages)-1], "New arrival") { t.Fatal("reopened history missed arrival") } a.ui.handle(interaction.Key{Name: "tab"}) a.ui.handle(interaction.Key{Name: "enter"}) if a.ui.active { t.Fatal("Close button did not close history") } keys.captureErr = errors.New("capture unavailable") a.open_notifications() if a.ui.active || !strings.Contains(voice.messages[len(voice.messages)-1], "Could not open Notifications") { t.Fatal("capture failure not reported", voice.messages) } } func TestNotificationHistoryEmptyAndBounded(t *testing.T) { a, voice := persistence_application(t, true) for _, id := range []command.ID{command.LastNotification, command.NotificationHistory} { a.handle_command(id) if a.ui.active || voice.messages[len(voice.messages)-1] != "No notifications" { t.Fatal(id, voice.messages) } } a.notify(" ", time.Now()) if len(a.notifications) != 0 { t.Fatal("empty notification retained") } for index := range notificationLimit + 2 { a.notify(fmt.Sprint(index), time.Now()) } if len(a.notifications) != notificationLimit || a.notifications[0].text != "2" || a.notifications[notificationLimit-1].text != "101" { t.Fatal("history did not evict oldest", a.notifications) } }