Files

629 lines
17 KiB
Go

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