169 lines
4.8 KiB
Go
169 lines
4.8 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_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")
|
|
}
|
|
}
|