After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.
This commit is contained in:
1 parent
c018f5fd29
commit
0e50de4c76
1047 files changed
+58364
-63747
No files matched your search
@@ -0,0 +1,287 @@
|
||||
// Fenrir console screen reader.
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify it
|
||||
// under the terms of the GNU General Public License as published by the Free
|
||||
// Software Foundation, either version 3 of the License, or (at your option)
|
||||
// any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
// more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
||||
|
||||
package sound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jfreymuth/pulse"
|
||||
|
||||
"git.stormux.org/storm/fenrir/internal/presentation"
|
||||
)
|
||||
|
||||
func TestVolumeScalesSamplesWithoutChangingCachedCues(t *testing.T) {
|
||||
item := &clip{samples: []float32{0.8, -0.4}}
|
||||
s := &Service{clips: map[presentation.Cue]*clip{presentation.Ready: item}}
|
||||
s.Set_volume(0.25)
|
||||
buffer := make([]float32, 4)
|
||||
s.Play(presentation.Ready)
|
||||
s.read_samples(buffer)
|
||||
if buffer[0] != 0.2 || buffer[1] != -0.1 || buffer[2] != 0 || item.samples[0] != 0.8 {
|
||||
t.Fatal("volume changed samples or source cue", buffer, item.samples)
|
||||
}
|
||||
s.Set_volume(0)
|
||||
s.Play(presentation.Ready)
|
||||
s.read_samples(buffer)
|
||||
if buffer[0] != 0 || buffer[1] != 0 {
|
||||
t.Fatal("zero volume was audible", buffer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeChangesApplyToPlayingCuesAndProgress(t *testing.T) {
|
||||
for _, kind := range []string{"cue", "startup", "progress"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
original := []float32{1, -1, 0.5, -0.5, 1, -1, 0.25, -0.25}
|
||||
item := &clip{samples: slices.Clone(original)}
|
||||
s := &Service{clips: map[presentation.Cue]*clip{presentation.Top: item, presentation.Ready: item}}
|
||||
gain := float32(1)
|
||||
switch kind {
|
||||
case "cue":
|
||||
s.Play(presentation.Top)
|
||||
case "startup":
|
||||
s.Play(presentation.Ready)
|
||||
case "progress":
|
||||
s.tones[50] = item
|
||||
s.Play_progress(50)
|
||||
gain = progressGain
|
||||
}
|
||||
buffer := make([]float32, 2)
|
||||
for index, volume := range []float64{0.7, 0.1, 0, 1} {
|
||||
s.Set_volume(volume)
|
||||
s.read_samples(buffer)
|
||||
for channel, sample := range buffer {
|
||||
want := original[index*2+channel] * (float32(volume) * gain)
|
||||
if sample != want {
|
||||
t.Fatal("playing volume/position", volume, buffer, "want", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !slices.Equal(item.samples, original) {
|
||||
t.Fatal("volume modified cached samples", item.samples)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroVolumeSkipsShutdownPlayback(t *testing.T) {
|
||||
s := &Service{clips: map[presentation.Cue]*clip{presentation.Stopped: {samples: []float32{1, 1}}}}
|
||||
s.Set_volume(0.7)
|
||||
s.Set_volume(0)
|
||||
if err := s.Finish(context.Background()); err != nil || s.pending.Load() != nil {
|
||||
t.Fatal("zero volume queued shutdown audio", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownSubmitsFinalCueThenDrainsBeforeClose(t *testing.T) {
|
||||
s := &Service{clips: map[presentation.Cue]*clip{
|
||||
presentation.Ready: {samples: []float32{0.8, 0.8, 0.8, 0.8, 0.8, 0.8}},
|
||||
presentation.Stopped: {samples: []float32{0.4, -0.4, 0.8, -0.8}},
|
||||
}}
|
||||
s.Set_volume(0.5)
|
||||
var operations []string
|
||||
s.drainBackend = func(context.Context) error { operations = append(operations, "drain"); return nil }
|
||||
s.closeBackend = func() { operations = append(operations, "close") }
|
||||
s.Play(presentation.Ready)
|
||||
buffer := make([]float32, 2)
|
||||
s.read_samples(buffer) // Shutdown must replace even an unfinished startup.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- s.Finish(ctx) }()
|
||||
for s.pending.Load() == nil {
|
||||
if ctx.Err() != nil {
|
||||
t.Fatal("shutdown cue never submitted")
|
||||
}
|
||||
runtime.Gosched()
|
||||
}
|
||||
s.Play(presentation.Ready) // Admission has stopped; must not replace shutdown.
|
||||
s.read_samples(buffer)
|
||||
if buffer[0] != 0.2 || buffer[1] != -0.2 {
|
||||
t.Fatal("wrong shutdown start", buffer)
|
||||
}
|
||||
s.read_samples(buffer)
|
||||
if buffer[0] != 0.4 || buffer[1] != -0.4 {
|
||||
t.Fatal("shutdown tail lost", buffer)
|
||||
}
|
||||
if n, err := s.read_samples(buffer); n != 0 || err != pulse.EndOfData {
|
||||
t.Fatal("final stream did not terminate", n, err)
|
||||
}
|
||||
if err := <-result; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(operations) != 1 || operations[0] != "drain" {
|
||||
t.Fatal("closed before draining", operations)
|
||||
}
|
||||
s.Close()
|
||||
if len(operations) != 2 || operations[1] != "close" {
|
||||
t.Fatal("backend not closed after drain", operations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownDoesNotHangWithoutAudioCallbacks(t *testing.T) {
|
||||
s := &Service{clips: map[presentation.Cue]*clip{presentation.Stopped: {samples: []float32{1, 1}}}}
|
||||
s.Set_volume(1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := s.Finish(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("ignored shutdown cancellation", err)
|
||||
}
|
||||
s.Close()
|
||||
}
|
||||
|
||||
func TestLatestCueReplacesPendingAndPlayingAudio(t *testing.T) {
|
||||
s := &Service{clips: map[presentation.Cue]*clip{
|
||||
presentation.Top: {samples: []float32{1, 2, 3, 4, 5, 6}},
|
||||
presentation.Bottom: {samples: []float32{7, 8}},
|
||||
}}
|
||||
s.Set_volume(1)
|
||||
buffer := make([]float32, 4)
|
||||
s.Play(presentation.Top)
|
||||
s.Play(presentation.Bottom)
|
||||
s.read_samples(buffer)
|
||||
if buffer[0] != 7 || buffer[1] != 8 || buffer[2] != 0 {
|
||||
t.Fatal("stale pending cue", buffer)
|
||||
}
|
||||
s.Play(presentation.Top)
|
||||
s.read_samples(buffer)
|
||||
if buffer[0] != 1 || buffer[3] != 4 {
|
||||
t.Fatal("wrong first chunk", buffer)
|
||||
}
|
||||
s.Play(presentation.Bottom)
|
||||
s.read_samples(buffer)
|
||||
if buffer[0] != 7 || buffer[2] != 0 {
|
||||
t.Fatal("old playing cue continued", buffer)
|
||||
}
|
||||
s.read_samples(buffer)
|
||||
if buffer[0] != 0 {
|
||||
t.Fatal("cue replayed", buffer)
|
||||
}
|
||||
closed := 0
|
||||
s.closeBackend = func() { closed++ }
|
||||
s.Close()
|
||||
s.Close()
|
||||
s.Play(presentation.Top)
|
||||
s.read_samples(buffer)
|
||||
if buffer[0] != 0 || closed != 1 {
|
||||
t.Fatal("closed service replayed or closed twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupCompletesBeforeLatestScreenCue(t *testing.T) {
|
||||
s := &Service{clips: map[presentation.Cue]*clip{
|
||||
presentation.Ready: {samples: []float32{1, 2, 3, 4}},
|
||||
presentation.TTYChanged: {samples: []float32{5, 6}},
|
||||
presentation.Blank: {samples: []float32{7, 8}},
|
||||
presentation.Top: {samples: []float32{9, 10}},
|
||||
}}
|
||||
s.Set_volume(1)
|
||||
s.Play(presentation.Ready)
|
||||
s.Play(presentation.TTYChanged)
|
||||
s.Play(presentation.Blank)
|
||||
buffer := make([]float32, 2)
|
||||
s.read_samples(buffer)
|
||||
if buffer[0] != 1 || buffer[1] != 2 {
|
||||
t.Fatal("initial blank replaced startup", buffer)
|
||||
}
|
||||
s.Play(presentation.Top)
|
||||
s.read_samples(buffer)
|
||||
if buffer[0] != 3 || buffer[1] != 4 {
|
||||
t.Fatal("startup tail was interrupted", buffer)
|
||||
}
|
||||
s.read_samples(buffer)
|
||||
if buffer[0] != 9 || buffer[1] != 10 {
|
||||
t.Fatal("latest cue did not follow startup", buffer)
|
||||
}
|
||||
s.read_samples(buffer)
|
||||
if buffer[0] != 0 || buffer[1] != 0 {
|
||||
t.Fatal("stale startup cues replayed", buffer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRapidCueAdmissionDoesNotWaitForAudio(t *testing.T) {
|
||||
s := &Service{clips: map[presentation.Cue]*clip{presentation.Blank: {samples: []float32{1, 1}}}}
|
||||
s.Set_volume(1)
|
||||
var workers sync.WaitGroup
|
||||
workers.Go(func() {
|
||||
for index := range 10000 {
|
||||
s.Set_volume(float64(index%11) / 10)
|
||||
s.Play(presentation.Blank)
|
||||
}
|
||||
})
|
||||
buffer := make([]float32, 128)
|
||||
for range 10000 {
|
||||
s.read_samples(buffer)
|
||||
}
|
||||
workers.Wait()
|
||||
if allocations := testing.AllocsPerRun(100, func() { s.Play(presentation.Blank) }); allocations != 0 {
|
||||
t.Fatalf("cue admission allocates: %v", allocations)
|
||||
}
|
||||
s.Close()
|
||||
}
|
||||
|
||||
func TestPlayReportsUnavailableCues(t *testing.T) {
|
||||
var absent *Service
|
||||
if absent.Play(presentation.Blank) {
|
||||
t.Fatal("absent player accepted a cue")
|
||||
}
|
||||
s := &Service{clips: map[presentation.Cue]*clip{presentation.Blank: {samples: []float32{1, 1}}}}
|
||||
if s.Play(presentation.Top) || s.pending.Load() != nil {
|
||||
t.Fatal("missing cue accepted")
|
||||
}
|
||||
if !s.Play(presentation.Blank) || s.pending.Load() == nil {
|
||||
t.Fatal("available cue rejected")
|
||||
}
|
||||
s.finishing.Store(true)
|
||||
if s.Play(presentation.Blank) {
|
||||
t.Fatal("finishing player accepted a cue")
|
||||
}
|
||||
s.finishing.Store(false)
|
||||
s.Close()
|
||||
if s.Play(presentation.Blank) {
|
||||
t.Fatal("closed player accepted a cue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoundServerFollowsExplicitAndSudoSession(t *testing.T) {
|
||||
if got := Server("unix:/tmp/example-pulse", "1000"); got != "unix:/tmp/example-pulse" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := Server("", "1000"); got != "unix:/run/user/1000/pulse/native" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
for _, uid := range []string{"", "invalid", "../other"} {
|
||||
if got := Server("", uid); got != "" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCueAdmission(b *testing.B) {
|
||||
s := &Service{clips: map[presentation.Cue]*clip{presentation.Blank: {samples: []float32{1, 1}}}}
|
||||
s.Set_volume(1)
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
s.Play(presentation.Blank)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user