186 lines
6.0 KiB
Go
186 lines
6.0 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package sound
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestProgressAudioMatchesNVDAGenerator(t *testing.T) {
|
|
// PCM digests from the unmodified generateBeep in the reference commit
|
|
// named in progress.go, compiled locally with a small output-only harness.
|
|
all := sha256.New()
|
|
for percentage := 0; percentage <= 100; percentage++ {
|
|
data := progress_tone_wav(float64(percentage))
|
|
if binary.LittleEndian.Uint32(data[24:]) != 44100 || binary.LittleEndian.Uint16(data[22:]) != 2 {
|
|
t.Fatal("progress tone is not 44100 Hz stereo")
|
|
}
|
|
all.Write(data[44:])
|
|
}
|
|
if got := fmt.Sprintf("%x", all.Sum(nil)); got != "ef56e46be365269af544e22d94eb1de7d171c5946d1dd5b168cc7e570c0a159f" {
|
|
t.Fatalf("0 through 100 percent PCM differs from NVDA: %s", got)
|
|
}
|
|
for percentage, digest := range map[float64]string{
|
|
12.5: "4fc87ad5310038ed281a4516e9431d5dc247b7ce75662e4fe51cb4d2ab616a36",
|
|
50.25: "6b4a9a7d254686b2fb52ead07763ff596ccdcbdd30d5e9d4ce956df13cc1bdf5",
|
|
} {
|
|
if got := fmt.Sprintf("%x", sha256.Sum256(progress_tone_wav(percentage)[44:])); got != digest {
|
|
t.Fatalf("%g percent PCM differs from NVDA: %s", percentage, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
type progressPlayback struct {
|
|
context context.Context
|
|
data []byte
|
|
}
|
|
|
|
type progressTestPlayer struct {
|
|
started chan progressPlayback
|
|
release chan struct{}
|
|
}
|
|
|
|
func (player *progressTestPlayer) Play(ctx context.Context, data []byte, _, _ float64) error {
|
|
player.started <- progressPlayback{context: ctx, data: data}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-player.release:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func new_progress_test_service(t *testing.T) (*Service, *progressTestPlayer) {
|
|
t.Helper()
|
|
player := &progressTestPlayer{started: make(chan progressPlayback, 16), release: make(chan struct{}, 16)}
|
|
service := New(slog.New(slog.DiscardHandler), player, "none", 0)
|
|
if err := service.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := service.Stop(ctx); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
return service, player
|
|
}
|
|
|
|
func next_progress_playback(t *testing.T, player *progressTestPlayer) progressPlayback {
|
|
t.Helper()
|
|
select {
|
|
case playback := <-player.started:
|
|
return playback
|
|
case <-time.After(time.Second):
|
|
t.Fatal("sound did not start")
|
|
return progressPlayback{}
|
|
}
|
|
}
|
|
|
|
func TestRapidProgressFinishesActiveToneAndPlaysLatestValue(t *testing.T) {
|
|
service, player := new_progress_test_service(t)
|
|
service.Enqueue(Cue{Name: ProgressCueName(0)})
|
|
first := next_progress_playback(t, player)
|
|
for percentage := 1; percentage <= 100; percentage++ {
|
|
if !service.EnqueueBatch([]Cue{{Name: ProgressCueName(float64(percentage))}}) {
|
|
t.Fatal("rapid progress update rejected")
|
|
}
|
|
}
|
|
if err := first.context.Err(); err != nil {
|
|
t.Fatalf("rapid updates canceled the active progress tone: %v", err)
|
|
}
|
|
player.release <- struct{}{}
|
|
latest := next_progress_playback(t, player)
|
|
if !bytes.Equal(latest.data, progress_tone_wav(100)) {
|
|
t.Fatal("played an obsolete percentage instead of the latest value")
|
|
}
|
|
}
|
|
|
|
func TestProgressPreservesActiveAndPendingRoleSounds(t *testing.T) {
|
|
service, player := new_progress_test_service(t)
|
|
service.mutex.Lock()
|
|
service.enabled, service.theme, service.volume = true, "default", 0.5
|
|
service.available = map[string]bool{"button": true, "link": true}
|
|
service.mutex.Unlock()
|
|
service.EnqueueBatch([]Cue{{Name: "button"}, {Name: "link"}})
|
|
first := next_progress_playback(t, player)
|
|
service.Enqueue(Cue{Name: ProgressCueName(100)})
|
|
if err := first.context.Err(); err != nil {
|
|
t.Fatalf("progress interrupted a role sound: %v", err)
|
|
}
|
|
player.release <- struct{}{}
|
|
second := next_progress_playback(t, player)
|
|
want, err := load_sound("default", "link")
|
|
if err != nil || !bytes.Equal(second.data, want) {
|
|
t.Fatal("progress displaced a pending role sound")
|
|
}
|
|
player.release <- struct{}{}
|
|
if got := next_progress_playback(t, player); !bytes.Equal(got.data, progress_tone_wav(100)) {
|
|
t.Fatal("latest progress did not follow the role sounds")
|
|
}
|
|
}
|
|
|
|
func TestProgressCancellationDiscardsPendingValue(t *testing.T) {
|
|
for _, action := range []string{"presentation", "configure", "stop"} {
|
|
t.Run(action, func(t *testing.T) {
|
|
service, player := new_progress_test_service(t)
|
|
service.Enqueue(Cue{Name: ProgressCueName(0)})
|
|
first := next_progress_playback(t, player)
|
|
service.Enqueue(Cue{Name: ProgressCueName(100)})
|
|
switch action {
|
|
case "presentation":
|
|
service.mutex.Lock()
|
|
service.enabled, service.available["button"] = true, true
|
|
service.theme = "default"
|
|
service.mutex.Unlock()
|
|
service.Enqueue(Cue{Name: "button"})
|
|
case "configure":
|
|
service.Configure(Configuration{Theme: "none"})
|
|
case "stop":
|
|
if err := service.Stop(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if first.context.Err() == nil {
|
|
t.Fatal("active progress was not canceled")
|
|
}
|
|
service.mutex.Lock()
|
|
pending := service.pendingProgress
|
|
service.mutex.Unlock()
|
|
if pending != nil {
|
|
t.Fatal("obsolete progress remained pending")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProgressSoundIsIndependentOfRoleSoundSettings(t *testing.T) {
|
|
service := New(nil, nil, "none", 0)
|
|
service.Configure(Configuration{Enabled: false, Theme: "none", Volume: 0})
|
|
service.running = true
|
|
if service.Available("button") || !service.Available(ProgressCueName(50.25)) {
|
|
t.Fatal("progress tone availability follows role-sound policy")
|
|
}
|
|
if !service.Enqueue(Cue{Name: ProgressCueName(50.25)}) {
|
|
t.Fatal("progress tone rejected with role sounds disabled")
|
|
}
|
|
for _, value := range []string{"NaN", "+Inf", "-1", "101", "bad"} {
|
|
if service.Available(progressCuePrefix + value) {
|
|
t.Fatalf("invalid percentage %s available", value)
|
|
}
|
|
if _, err := soundData("none", progressCuePrefix+value); err == nil {
|
|
t.Fatalf("invalid percentage %s decoded", value)
|
|
}
|
|
}
|
|
}
|