// 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) } }