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