package sound import ( "bytes" "context" "errors" "fmt" "log/slog" "os" "path/filepath" "sync" "testing" "time" ) func TestBundledSoundsDecodeInProcess(t *testing.T) { if !cueAvailability("default")["autocomplete"] { t.Fatal("default theme does not offer the autocomplete cue") } for _, cue := range []string{"start", "stop", "autocomplete", "button", "checkbox_checked", "checkbox_mixed", "checkbox_unchecked", "combobox", "link", "link_visited", "radiobutton_checked", "radiobutton_unchecked", "togglebutton_checked", "togglebutton_unchecked", "switch_on", "switch_off", "browse_mode", "focus_mode"} { t.Run(cue, func(t *testing.T) { data, err := load_sound("default", cue) if err != nil { t.Fatal(err) } // The first GStreamer initialization can exceed one second on a // cold process even when the cue itself is short. ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := (NativePlayer{Sink: "test"}).Play(ctx, data, 0.5, 0); err != nil { t.Fatal(err) } }) } } func TestGeneratedProgressSoundsDecodeInProcess(t *testing.T) { for _, percentage := range []int{0, 50, 99, 100} { data, err := soundData("default", fmt.Sprintf("generated-progress-tone:%d", percentage)) if err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) err = (NativePlayer{Sink: "test"}).Play(ctx, data, 0.5, 0) cancel() if err != nil { t.Fatalf("percentage %d: %v", percentage, err) } } if _, err := soundData("default", "generated-progress-tone:101"); err == nil { t.Fatal("accepted invalid generated progress tone") } } func TestBundledSoundPanDecodes(t *testing.T) { data, err := load_sound("default", "button") if err != nil { t.Fatal(err) } for _, pan := range []float64{-1, 0, 0.35, 1} { ctx, cancel := context.WithTimeout(context.Background(), time.Second) err := (NativePlayer{Sink: "test"}).Play(ctx, data, 0.5, pan) cancel() if err != nil { t.Fatalf("pan %v: %v", pan, err) } } } func TestThemePathsAndSizeAreBounded(t *testing.T) { t.Setenv("XDG_DATA_HOME", t.TempDir()) for _, name := range []string{"..", "../other", "/tmp/other"} { if _, err := load_sound(name, "start"); err == nil { t.Fatalf("accepted theme %q", name) } if _, err := load_sound("default", name); err == nil { t.Fatalf("accepted cue %q", name) } } path := filepath.Join(os.Getenv("XDG_DATA_HOME"), "cthulhu", "sounds", "custom") if err := os.MkdirAll(path, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(path, "start.wav"), []byte("custom"), 0o600); err != nil { t.Fatal(err) } data, err := load_sound("custom", "start") if err != nil || string(data) != "custom" { t.Fatalf("custom theme = %q, %v", data, err) } if err := os.WriteFile(filepath.Join(path, "start.wav"), make([]byte, maxSoundBytes+1), 0o600); err != nil { t.Fatal(err) } if _, err := load_sound("custom", "start"); err == nil { t.Fatal("accepted oversized sound") } } type blockingPlayer struct { started chan struct{} calls int } func TestQueueReplacementDoesNotBlockConcurrentConsumer(t *testing.T) { service := New(slog.New(slog.DiscardHandler), nil, "default", 0.5) service.running = true service.enabled = true service.available = map[string]bool{"button": true} ctx, cancel := context.WithCancel(context.Background()) consumerDone := make(chan struct{}) go func() { defer close(consumerDone) for { if ctx.Err() != nil { return } select { case <-ctx.Done(): return case <-service.queue: // The runtime receives before taking the playback mutex. service.mutex.Lock() service.mutex.Unlock() } } }() done := make(chan struct{}) go func() { defer close(done) for range 100000 { if ctx.Err() != nil { return } service.Enqueue(Cue{Name: "button"}) } }() select { case <-done: cancel() <-consumerDone case <-time.After(5 * time.Second): cancel() // Release a blocked drain so both test goroutines can be joined. service.queue <- queuedCue{} <-done <-consumerDone t.Fatal("queue replacement blocked while the consumer waited for the mutex") } } type interruptPlayer struct { started chan struct{} canceled chan struct{} played chan []byte once sync.Once } func (player *interruptPlayer) Play(ctx context.Context, data []byte, _, _ float64) error { first := false player.once.Do(func() { first = true; close(player.started) }) if first { <-ctx.Done() close(player.canceled) return ctx.Err() } player.played <- data return nil } func TestNewPresentationInterruptsOldCueAndDiscardsPendingCues(t *testing.T) { player := &interruptPlayer{started: make(chan struct{}), canceled: make(chan struct{}), played: make(chan []byte, 2)} service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) if err := service.Start(context.Background()); err != nil { t.Fatal(err) } if !service.Enqueue(Cue{Name: "button"}) { t.Fatal("initial cue rejected") } select { case <-player.started: case <-time.After(time.Second): t.Fatal("initial cue did not start") } for range 20 { if !service.Enqueue(Cue{Name: "link"}) { t.Fatal("new link cue rejected") } } select { case <-player.canceled: case <-time.After(time.Second): t.Fatal("initial cue was not interrupted") } want, err := load_sound("default", "link") if err != nil { t.Fatal(err) } select { case got := <-player.played: if !bytes.Equal(got, want) { t.Fatal("played stale cue instead of latest link") } case <-time.After(time.Second): t.Fatal("latest link cue did not play") } if len(service.queue) != 0 { t.Fatal("stale link cues remained queued") } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := service.Stop(ctx); err != nil { t.Fatal(err) } } type configuredPlayback struct { volume float64 pan float64 } type configurationPlayer struct { played chan configuredPlayback } type reconfigurePlayer struct { started chan struct{} release chan struct{} canceled chan struct{} played chan struct{} once sync.Once } func (player *reconfigurePlayer) Play(ctx context.Context, _ []byte, _, _ float64) error { first := false player.once.Do(func() { first = true close(player.started) }) if first { select { case <-player.release: case <-ctx.Done(): close(player.canceled) return ctx.Err() } return nil } player.played <- struct{}{} return nil } func (player *configurationPlayer) Play(_ context.Context, _ []byte, volume, pan float64) error { player.played <- configuredPlayback{volume: volume, pan: pan} return nil } func TestCuePanIsValidatedAndPassedToPlayer(t *testing.T) { player := &configurationPlayer{played: make(chan configuredPlayback, 2)} service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) if err := service.Start(context.Background()); err != nil { t.Fatal(err) } service.Enqueue(Cue{Name: "button", Pan: 0.5}) select { case playback := <-player.played: if playback.pan != 0.5 { t.Fatalf("pan = %v, want 0.5", playback.pan) } case <-time.After(time.Second): t.Fatal("panned cue was not played") } service.Enqueue(Cue{Name: "button", Pan: 2}) select { case playback := <-player.played: if playback.pan != 0 { t.Fatalf("invalid pan = %v, want centered", playback.pan) } case <-time.After(time.Second): t.Fatal("invalid-pan cue was not played") } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := service.Stop(ctx); err != nil { t.Fatal(err) } } func TestCustomThemeCueSupportsPan(t *testing.T) { dataHome := t.TempDir() t.Setenv("XDG_DATA_HOME", dataHome) path := filepath.Join(dataHome, "cthulhu", "sounds", "custom") if err := os.MkdirAll(path, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(path, "button.wav"), []byte("custom wav"), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(path, "stop.wav"), []byte("custom stop"), 0o600); err != nil { t.Fatal(err) } player := &configurationPlayer{played: make(chan configuredPlayback, 1)} service := New(slog.New(slog.DiscardHandler), player, "custom", 0.5) if err := service.Start(context.Background()); err != nil { t.Fatal(err) } if !service.Available("button") { t.Fatal("custom theme cue was not available") } service.Enqueue(Cue{Name: "button", Pan: -1}) select { case playback := <-player.played: if playback.pan != -1 { t.Fatalf("custom cue pan = %v, want -1", playback.pan) } case <-time.After(time.Second): t.Fatal("custom cue was not played") } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := service.Stop(ctx); err != nil { t.Fatal(err) } } func TestConfigureAppliesEnableAndVolumeToSubsequentCues(t *testing.T) { player := &configurationPlayer{played: make(chan configuredPlayback, 2)} service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) service.Configure(Configuration{Enabled: false, Sink: "auto", Theme: "default", Volume: 0.5}) if err := service.Start(context.Background()); err != nil { t.Fatal(err) } service.Enqueue(Cue{Name: "button"}) if len(service.queue) != 0 { t.Fatal("disabled service queued a cue") } service.Configure(Configuration{Enabled: true, Sink: "pulse", Theme: "default", Volume: 0.8}) service.Enqueue(Cue{Name: "button"}) select { case playback := <-player.played: if playback.volume != 0.8 { t.Fatalf("volume = %v, want 0.8", playback.volume) } case <-time.After(time.Second): t.Fatal("configured cue was not played") } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := service.Stop(ctx); err != nil { t.Fatal(err) } } func TestConfigureCancelsActiveOldPolicyCue(t *testing.T) { player := &reconfigurePlayer{started: make(chan struct{}), release: make(chan struct{}), canceled: make(chan struct{}), played: make(chan struct{}, 1)} service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) if err := service.Start(context.Background()); err != nil { t.Fatal(err) } if !service.Enqueue(Cue{Name: "button"}) { t.Fatal("first cue was not admitted") } select { case <-player.started: case <-time.After(time.Second): t.Fatal("first cue did not start") } service.Configure(Configuration{Enabled: false, Theme: "default", Volume: 0.5}) select { case <-player.canceled: case <-time.After(time.Second): t.Fatal("active old-policy cue was not canceled") } select { case <-player.played: t.Fatal("old-policy pending cue played after reconfiguration") case <-time.After(30 * time.Millisecond): } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := service.Stop(ctx); err != nil { t.Fatal(err) } } func TestCueBatchAdmissionIsAtomic(t *testing.T) { player := &blockingPlayer{started: make(chan struct{})} service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) if err := service.Start(context.Background()); err != nil { t.Fatal(err) } if !service.Enqueue(Cue{Name: "start"}) { t.Fatal("blocking cue was not admitted") } select { case <-player.started: case <-time.After(time.Second): t.Fatal("sound worker did not start") } batch := make([]Cue, queueCapacity+1) for index := range batch { batch[index] = Cue{Name: "button"} } if service.EnqueueBatch(batch) { t.Fatal("oversized presentation batch was admitted") } if len(service.queue) != 0 { t.Fatal("part of rejected presentation batch was queued") } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := service.Stop(ctx); err != nil { t.Fatal(err) } } func TestPartialCustomThemeStopsWithoutShutdownCue(t *testing.T) { dataHome := t.TempDir() t.Setenv("XDG_DATA_HOME", dataHome) path := filepath.Join(dataHome, "cthulhu", "sounds", "partial") if err := os.MkdirAll(path, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(path, "button.wav"), []byte("button"), 0o600); err != nil { t.Fatal(err) } service := New(slog.New(slog.DiscardHandler), &configurationPlayer{played: make(chan configuredPlayback, 1)}, "partial", 0.5) if err := service.Start(context.Background()); err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := service.Stop(ctx); err != nil { t.Fatal(err) } } func (player *blockingPlayer) Play(ctx context.Context, _ []byte, _, _ float64) error { player.calls++ if player.calls == 1 { close(player.started) <-ctx.Done() return ctx.Err() } return nil } func TestSoundWorkerBoundsQueueAndStopsActiveCue(t *testing.T) { player := &blockingPlayer{started: make(chan struct{})} service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) if err := service.Start(context.Background()); err != nil { t.Fatal(err) } service.Enqueue(Cue{Name: "start"}) select { case <-player.started: case <-time.After(time.Second): t.Fatal("sound worker did not start") } batch := make([]Cue, queueCapacity+1) for index := range batch { batch[index] = Cue{Name: "button"} } service.EnqueueBatch(batch) if service.dropped.Load() == 0 { t.Fatal("overflow not counted") } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := service.Stop(ctx); err != nil { t.Fatal(err) } if player.calls != 2 { t.Fatalf("played %d cues; expected active cue then stop", player.calls) } if err := service.Stop(ctx); err != nil { t.Fatal(err) } if player.calls != 2 { t.Fatal("repeated stop played another cue") } } func TestStopPlaysShutdownCueAfterOversizedPresentation(t *testing.T) { stopData, err := load_sound("default", "stop") if err != nil { t.Fatal(err) } player := &queueBlockingPlayer{stopData: stopData, entered: make(chan struct{})} service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) if err := service.Start(context.Background()); err != nil { t.Fatal(err) } service.Enqueue(Cue{Name: "button"}) select { case <-player.entered: case <-time.After(time.Second): t.Fatal("sound worker did not start") } batch := make([]Cue, queueCapacity+1) for index := range batch { batch[index] = Cue{Name: "button"} } service.EnqueueBatch(batch) if service.dropped.Load() == 0 { t.Fatal("oversized presentation was not dropped") } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := service.Stop(ctx); err != nil { t.Fatal(err) } if player.stopCalls != 1 { t.Fatalf("played %d stop cues; expected 1", player.stopCalls) } if err := service.Stop(ctx); err != nil { t.Fatal(err) } if player.stopCalls != 1 { t.Fatalf("repeated stop played %d stop cues; expected no more", player.stopCalls) } } func TestStopTimeoutPreservesShutdownCueForRetry(t *testing.T) { stopData, err := load_sound("default", "stop") if err != nil { t.Fatal(err) } player := &stagedStopPlayer{stopData: stopData, entered: make(chan struct{}), release: make(chan struct{})} service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) if err := service.Start(context.Background()); err != nil { t.Fatal(err) } service.Enqueue(Cue{Name: "button"}) select { case <-player.entered: case <-time.After(time.Second): t.Fatal("sound worker did not start") } timeoutContext, cancelTimeout := context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancelTimeout() if err := service.Stop(timeoutContext); !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("timed-out Stop error = %v; want deadline exceeded", err) } close(player.release) retryContext, cancelRetry := context.WithTimeout(context.Background(), time.Second) defer cancelRetry() if err := service.Stop(retryContext); err != nil { t.Fatal(err) } if player.stopCalls != 1 { t.Fatalf("played %d stop cues; expected 1 after retry", player.stopCalls) } } type stopCountingPlayer struct { stopData []byte stopCalls int } type queueBlockingPlayer struct { stopData []byte entered chan struct{} enterOnce sync.Once stopCalls int } func (player *queueBlockingPlayer) Play(ctx context.Context, data []byte, _, _ float64) error { if bytes.Equal(data, player.stopData) { player.stopCalls++ return nil } player.enterOnce.Do(func() { close(player.entered) }) <-ctx.Done() return ctx.Err() } func (player *stopCountingPlayer) Play(_ context.Context, data []byte, _, _ float64) error { if bytes.Equal(data, player.stopData) { player.stopCalls++ } return nil } type stagedStopPlayer struct { stopData []byte entered chan struct{} release chan struct{} stopCalls int } func (player *stagedStopPlayer) Play(_ context.Context, data []byte, _, _ float64) error { if bytes.Equal(data, player.stopData) { player.stopCalls++ return nil } close(player.entered) <-player.release return nil } func TestCancelledNativeSoundDoesNotStart(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() if err := (NativePlayer{Sink: "test"}).Play(ctx, []byte("invalid"), 0.5, 0); !errors.Is(err, context.Canceled) { t.Fatal(err) } } func TestLiveSound(t *testing.T) { if os.Getenv("CTHULHU_LIVE_SOUND") != "1" { t.Skip("explicit live audio test") } for _, cue := range []string{"start", "stop"} { data, err := load_sound("default", cue) if err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) err = (NativePlayer{Sink: "auto"}).Play(ctx, data, 0.5, 0) cancel() if err != nil { t.Fatal(err) } } }