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