Files

170 lines
5.1 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"context"
"errors"
"math"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
"git.stormux.org/storm/fenrir/internal/presentation"
)
type fakeBackend struct {
errors chan error
closed chan struct{}
once sync.Once
plays atomic.Int32
volume atomic.Uint64
}
func fake_backend() *fakeBackend {
return &fakeBackend{errors: make(chan error, 1), closed: make(chan struct{})}
}
func (f *fakeBackend) Play(presentation.Cue) bool { f.plays.Add(1); return true }
func (f *fakeBackend) Play_wait(context.Context, presentation.Cue) error { f.plays.Add(1); return nil }
func (f *fakeBackend) Play_progress(float64) bool { return true }
func (f *fakeBackend) Play_indentation(int) bool { return true }
func (f *fakeBackend) Discard_progress() {}
func (f *fakeBackend) Clear_progress() {}
func (f *fakeBackend) Set_volume(volume float64) { f.volume.Store(math.Float64bits(volume)) }
func (f *fakeBackend) Errors() <-chan error { return f.errors }
func (f *fakeBackend) Finish(context.Context) error { return nil }
func (f *fakeBackend) Close() { f.once.Do(func() { close(f.closed) }) }
func wait_player(t *testing.T, player *Player, backend *fakeBackend) {
t.Helper()
deadline := time.NewTimer(time.Second)
defer deadline.Stop()
tick := time.NewTicker(time.Millisecond)
defer tick.Stop()
for {
if connection := player.connection.Load(); connection != nil && connection.backend == backend && math.Float64frombits(backend.volume.Load()) == math.Float64frombits(player.volume.Load()) {
return
}
select {
case <-deadline.C:
t.Fatal("sound connection did not recover")
case <-tick.C:
}
}
}
func TestSoundPlayerRecoversStartupAndDisconnectWithoutReplayingCues(t *testing.T) {
first, second := fake_backend(), fake_backend()
firstGate, secondGate := make(chan struct{}), make(chan struct{})
attempts := make(chan int, 8)
count := 0
player := new_player(context.Background(), 0.7, func() (soundBackend, error) {
count++
attempts <- count
switch count {
case 1:
// A completed library request timeout is safe to retry. It is
// distinct from an opener that is still running after our deadline.
return nil, context.DeadlineExceeded
case 2:
<-firstGate
return first, nil
default:
<-secondGate
return second, nil
}
})
defer player.Close()
<-attempts
<-attempts
if player.Play(presentation.Ready) || player.Play_progress(50) || player.Play_indentation(2) {
t.Fatal("offline sound suppressed speech fallback")
}
player.Set_volume(0.4)
close(firstGate)
wait_player(t, player, first)
if !player.Play(presentation.Ready) {
t.Fatal("recovered sound rejected cue")
}
first.errors <- errors.New("server disconnected")
<-attempts
if player.Play(presentation.Error) {
t.Fatal("disconnected sound accepted stale cue")
}
player.Set_volume(0.2)
close(secondGate)
wait_player(t, player, second)
if second.plays.Load() != 0 || first.plays.Load() != 1 {
t.Fatal("recovery replayed old cues", first.plays.Load(), second.plays.Load())
}
select {
case <-first.closed:
default:
t.Fatal("old sound connection leaked")
}
}
func TestUnresponsiveSoundAttemptIsCancelledAndLateResultClosed(t *testing.T) {
gate := make(chan struct{})
backend := fake_backend()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
started := time.Now()
got, err := sound_attempt(ctx, func() (soundBackend, error) { <-gate; return backend, nil })
if got != nil || !errors.Is(err, context.DeadlineExceeded) || time.Since(started) > time.Second {
t.Fatal("sound attempt blocked caller", got, err)
}
close(gate)
select {
case <-backend.closed:
case <-time.After(time.Second):
t.Fatal("late sound result leaked")
}
attempts := 0
player := new_player(context.Background(), 0.7, func() (soundBackend, error) {
attempts++
return nil, errors.Join(errSoundAttemptStalled, context.DeadlineExceeded)
})
defer player.Close()
select {
case <-player.Errors():
case <-time.After(time.Second):
t.Fatal("unresponsive attempt was retried")
}
if attempts != 1 {
t.Fatal("overlapping sound opener calls", attempts)
}
}
func TestSoundPlayerCloseDoesNotWaitForHungReconnect(t *testing.T) {
backend := fake_backend()
gate, entered := make(chan struct{}), make(chan struct{})
attempts := 0
player := new_player(context.Background(), 0.7, func() (soundBackend, error) {
attempts++
if attempts == 1 {
return nil, syscall.ENOENT
}
close(entered)
<-gate
return backend, nil
})
<-entered
closed := make(chan struct{})
go func() { player.Close(); close(closed) }()
select {
case <-closed:
case <-time.After(time.Second):
t.Fatal("shutdown waited for hung sound server")
}
close(gate)
select {
case <-backend.closed:
case <-time.After(time.Second):
t.Fatal("late sound connection leaked")
}
}