Files

152 lines
4.2 KiB
Go

// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package sound
import (
"context"
"errors"
"slices"
"syscall"
"testing"
"time"
"git.stormux.org/storm/fenrir/internal/presentation"
)
type fakePCM struct {
writeSamples func([]float32) (int, error)
drainResult func() error
received []float32
preparations, drains, closes int
}
func (p *fakePCM) write(samples []float32) (int, error) {
if p.writeSamples != nil {
return p.writeSamples(samples)
}
p.received = append(p.received, samples...)
return len(samples), nil
}
func (p *fakePCM) prepare() error { p.preparations++; return nil }
func (p *fakePCM) drain() error {
p.drains++
if p.drainResult != nil {
return p.drainResult()
}
return nil
}
func (p *fakePCM) close() { p.closes++ }
func TestPCMShortWritesAndUnderrunRecoveryPreserveSampleOrder(t *testing.T) {
s := &Service{done: make(chan struct{})}
pcm := &fakePCM{}
calls := 0
pcm.writeSamples = func(samples []float32) (int, error) {
calls++
switch calls {
case 1:
return 0, syscall.EPIPE
case 2:
return 0, syscall.EAGAIN
case 3:
return 0, syscall.ESTRPIPE
case 4:
return 0, syscall.EINTR
}
pcm.received = append(pcm.received, samples[:2]...)
return 2, nil
}
samples := []float32{1, -1, .5, -.5, .25, -.25}
if err := s.write_pcm(pcm, samples); err != nil {
t.Fatal(err)
}
if !slices.Equal(pcm.received, samples) || pcm.preparations != 2 {
t.Fatal(pcm.received, pcm.preparations)
}
}
func TestPCMStallsAndDrainRespectCancellation(t *testing.T) {
s := &Service{done: make(chan struct{})}
pcm := &fakePCM{writeSamples: func([]float32) (int, error) { return 0, syscall.EAGAIN }, drainResult: func() error { return syscall.EAGAIN }}
if err := s.write_pcm(pcm, []float32{1, 1}); !errors.Is(err, context.DeadlineExceeded) {
t.Fatal("write did not expire", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := s.drain_pcm(ctx, pcm); !errors.Is(err, context.Canceled) {
t.Fatal("drain did not cancel", err)
}
close(s.done)
if err := s.drain_pcm(context.Background(), pcm); !errors.Is(err, context.Canceled) {
t.Fatal("close did not cancel drain", err)
}
}
func TestPCMShutdownUsesSharedCueVolumeAndDrainsBeforeClose(t *testing.T) {
s := &Service{clips: map[presentation.Cue]*clip{presentation.Stopped: {samples: []float32{.8, -.8, .4, -.4}}}, done: make(chan struct{}), errors: make(chan error, 1)}
s.Set_volume(.5)
pcm := &fakePCM{}
s.start_pcm(pcm)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := s.Finish(ctx); err != nil {
t.Fatal(err)
}
// Finish waits for the worker's completed drain, making its samples readable.
if pcm.drains != 1 || pcm.closes != 0 || !slices.Equal(pcm.received[:4], []float32{.4, -.4, .2, -.2}) {
t.Fatal(pcm)
}
s.Close()
s.Close()
if pcm.closes != 1 {
t.Fatal("PCM closed incorrectly", pcm.closes)
}
}
func TestPCMFailureDisablesOutputWithoutBlockingAdmission(t *testing.T) {
s := &Service{done: make(chan struct{}), errors: make(chan error, 1)}
pcm := &fakePCM{writeSamples: func([]float32) (int, error) { return 0, syscall.ENODEV }}
s.start_pcm(pcm)
select {
case err := <-s.Errors():
if !errors.Is(err, syscall.ENODEV) {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("PCM failure was not reported")
}
s.Close()
if pcm.closes != 1 {
t.Fatal(pcm.closes)
}
}
func TestALSANullSinkLifecycleWithoutHardwareOrDesktopAudio(t *testing.T) {
s, err := Open([]string{"../../config/sound/default"}, Options{Driver: "alsa", Device: "null", Volume: .7})
if err != nil {
t.Fatal(err)
}
defer s.Close()
if !s.Play(presentation.Ready) || !s.Play_progress(50) {
t.Fatal("shared audio admission failed")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := s.Finish(ctx); err != nil {
t.Fatal(err)
}
}
func TestMissingPackKeepsGeneratedTonesOnALSANull(t *testing.T) {
s, err := Open([]string{t.TempDir()}, Options{Driver: "alsa", Device: "null", Volume: .7})
if s == nil || err == nil {
t.Fatal(s, err)
}
defer s.Close()
if s.Play(presentation.Ready) || !s.Play_progress(50) {
t.Fatal("missing cue or generated tone admission")
}
}