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