355 lines
13 KiB
Go
355 lines
13 KiB
Go
// 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)
|
|
}
|
|
}
|