Files
fenrir/internal/sound/notification_test.go

212 lines
5.7 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"context"
"errors"
"os"
"slices"
"testing"
"time"
"git.stormux.org/storm/fenrir/internal/presentation"
)
func notification_service(t *testing.T) *Service {
t.Helper()
s := &Service{clips: map[presentation.Cue]*clip{
presentation.Notification: {samples: []float32{.1, -.1, .2, -.2}},
presentation.Top: {samples: []float32{.3, -.3}},
presentation.Stopped: {samples: []float32{.4, -.4}},
}, done: make(chan struct{}), errors: make(chan error, 1)}
s.Set_volume(1)
t.Cleanup(s.Close)
return s
}
func queue_notification(t *testing.T, s *Service, ctx context.Context) <-chan error {
t.Helper()
result := make(chan error, 1)
go func() { result <- s.Play_wait(ctx, presentation.Notification) }()
deadline := time.NewTimer(time.Second)
defer deadline.Stop()
tick := time.NewTicker(time.Millisecond)
defer tick.Stop()
for s.notification.Load() == nil {
select {
case err := <-result:
t.Fatal("notification was rejected", err)
case <-deadline.C:
t.Fatal("notification did not queue")
case <-tick.C:
}
}
return result
}
func TestNotificationSoundPreservesTailAndWaitsForDeviceDrain(t *testing.T) {
s := notification_service(t)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
result := queue_notification(t, s, ctx)
buffer := make([]float32, 2)
s.read_samples(buffer)
if !slices.Equal(buffer, []float32{.1, -.1}) {
t.Fatal(buffer)
}
s.Play(presentation.Top)
s.read_samples(buffer)
if !slices.Equal(buffer, []float32{.2, -.2}) {
t.Fatal("cue interrupted notification tail", buffer)
}
if _, err := s.read_samples(buffer); err != errNotificationDrain {
t.Fatal(err)
}
select {
case <-result:
t.Fatal("speech released before drain")
default:
}
if err := s.drain_notification(s.draining, func(context.Context) error {
select {
case <-result:
t.Fatal("speech released during drain")
default:
}
return nil
}); err != nil {
t.Fatal(err)
}
if err := <-result; err != nil {
t.Fatal(err)
}
s.read_samples(buffer)
if !slices.Equal(buffer, []float32{.3, -.3}) {
t.Fatal("ordinary cue did not resume", buffer)
}
}
func TestNotificationCancellationAndCloseReleaseWaiter(t *testing.T) {
s := notification_service(t)
ctx, cancel := context.WithCancel(context.Background())
result := queue_notification(t, s, ctx)
cancel()
if err := <-result; !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if s.notification.Load() != nil {
t.Fatal("canceled cue remained pending")
}
result = queue_notification(t, s, context.Background())
s.Close()
if err := <-result; err != nil {
t.Fatal(err)
}
}
func TestNotificationMissingAndZeroVolumeSkipWaiting(t *testing.T) {
s := notification_service(t)
if err := s.Play_wait(context.Background(), presentation.Blank); err != nil {
t.Fatal(err)
}
s.Set_volume(0)
if err := s.Play_wait(context.Background(), presentation.Notification); err != nil {
t.Fatal(err)
}
if s.notification.Load() != nil {
t.Fatal("silent cue was queued")
}
}
func TestPulseNotificationBoundaryStopsFeedingUntilDrained(t *testing.T) {
s := notification_service(t)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
result := queue_notification(t, s, ctx)
drainRequests := make(chan *notificationPlayback, 1)
buffer := make([]float32, 4)
s.read_pulse_samples(buffer, drainRequests)
if count, err := s.read_pulse_samples(buffer, drainRequests); count != 0 || err != nil {
t.Fatal(count, err)
}
var request *notificationPlayback
select {
case request = <-drainRequests:
case <-ctx.Done():
t.Fatal("Pulse did not request drain")
}
if count, err := s.read_pulse_samples(buffer, drainRequests); count != 0 || err != nil {
t.Fatal("Pulse fed silence before drain", count, err)
}
s.drain_notification(request, func(context.Context) error { return nil })
if count, err := s.read_pulse_samples(buffer, drainRequests); count != len(buffer) || err != nil {
t.Fatal("Pulse did not resume", count, err)
}
if err := <-result; err != nil {
t.Fatal(err)
}
}
func TestPCMNotificationDrainsAndContinuesPlayback(t *testing.T) {
s := notification_service(t)
pcm := &fakePCM{}
s.start_pcm(pcm)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
for range 2 {
if err := s.Play_wait(ctx, presentation.Notification); err != nil {
t.Fatal(err)
}
}
if err := s.Finish(ctx); err != nil {
t.Fatal(err)
}
s.Close()
if pcm.drains != 3 || pcm.preparations != 2 {
t.Fatal(pcm.drains, pcm.preparations)
}
}
func TestALSANullNotificationAndShutdown(t *testing.T) {
s, err := Open([]string{"../../config/sound/default"}, Options{Driver: "alsa", Device: "null", Volume: .7})
if err != nil {
t.Fatal(err)
}
defer s.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for range 2 {
if err := s.Play_wait(ctx, presentation.Notification); err != nil {
t.Fatal(err)
}
}
if err := s.Finish(ctx); err != nil {
t.Fatal(err)
}
}
func TestPrivatePulseNotificationAndShutdown(t *testing.T) {
server := os.Getenv("FENRIR_TEST_PULSE_SERVER")
if server == "" {
t.Skip("run tests/native_notification_sound.py for a private null sink")
}
t.Setenv("PULSE_SERVER", server)
s, err := Open([]string{"../../config/sound/default"}, Options{Driver: "pulse", Device: "default", Volume: .7})
if err != nil {
t.Fatal(err)
}
defer s.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for range 2 {
if err := s.Play_wait(ctx, presentation.Notification); err != nil {
t.Fatal(err)
}
}
if err := s.Finish(ctx); err != nil {
t.Fatal(err)
}
}