Files

320 lines
9.1 KiB
Go

// 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 speech
import (
"fmt"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
type recordingTransport struct {
mu sync.Mutex
events []string
changed chan string
release chan struct{}
once sync.Once
}
func new_recording_transport() *recordingTransport {
return &recordingTransport{changed: make(chan string, 64), release: make(chan struct{})}
}
func (f *recordingTransport) record(event string) {
f.mu.Lock()
f.events = append(f.events, event)
f.mu.Unlock()
f.changed <- event
}
func (f *recordingTransport) Say(text string) error {
f.record("say:" + text)
if text == "old" {
<-f.release
}
f.record("sent:" + text)
return nil
}
func (f *recordingTransport) Set_pitch(pitch int) error {
f.record(fmt.Sprintf("pitch:%d", pitch))
return nil
}
func (f *recordingTransport) Cancel() error { f.record("cancel"); return nil }
func (f *recordingTransport) Configure(parameters Parameters) error {
f.record(fmt.Sprintf("configure:%d:%d:%d", parameters.Rate, parameters.Pitch, parameters.Volume))
return nil
}
func (f *recordingTransport) unblock() { f.once.Do(func() { close(f.release) }) }
func (f *recordingTransport) Close() { f.unblock() }
func TestConfigurationSurvivesInterruptAndMuteBeforeFeedback(t *testing.T) {
fake := new_recording_transport()
service := New(fake)
defer service.Close()
service.Say("old")
wait_event(t, fake.changed, "say:old")
service.Configure(Parameters{Rate: 70, Pitch: 10, Volume: 90})
service.Interrupt()
service.Configure(Parameters{Rate: 90, Pitch: 20, Volume: 80})
service.Say("new feedback")
fake.unblock()
wait_event(t, fake.changed, "sent:new feedback")
fake.mu.Lock()
events := append([]string(nil), fake.events...)
fake.mu.Unlock()
configured, spoken := -1, -1
for index, event := range events {
if event == "configure:90:20:80" {
configured = index
}
if event == "say:new feedback" {
spoken = index
}
}
if configured < 0 || configured >= spoken {
t.Fatal(events)
}
service.Toggle_mute()
service.Configure(Parameters{Rate: 100, Pitch: 0, Volume: 100})
wait_event(t, fake.changed, "configure:100:0:100")
service.Toggle_mute()
service.Say("unmuted feedback")
wait_event(t, fake.changed, "sent:unmuted feedback")
}
func TestParameterPreviewRestoresNormalParametersBeforeLaterSpeech(t *testing.T) {
fake := new_recording_transport()
service := New(fake)
defer service.Close()
service.Configure(Parameters{Rate: 40, Pitch: 10, Volume: 90})
service.Say_with_parameters("preview", Parameters{Rate: -20, Pitch: 30, Volume: 50})
service.Say("normal")
wait_event(t, fake.changed, "sent:normal")
fake.mu.Lock()
defer fake.mu.Unlock()
want := []string{"configure:40:10:90", "configure:-20:30:50", "say:preview", "sent:preview", "configure:40:10:90", "say:normal", "sent:normal"}
if !reflect.DeepEqual(fake.events, want) {
t.Fatal(fake.events)
}
}
func TestCapitalPitchRestoresBeforeFollowingSpeech(t *testing.T) {
fake := new_recording_transport()
service := New(fake)
defer service.Close()
service.Configure(Parameters{Rate: 80, Pitch: 10, Volume: 100})
service.Say_with_pitch("E", 60)
service.Say("e")
wait_event(t, fake.changed, "sent:e")
fake.mu.Lock()
defer fake.mu.Unlock()
want := []string{"configure:80:10:100", "pitch:60", "say:E", "sent:E", "pitch:10", "say:e", "sent:e"}
if !reflect.DeepEqual(fake.events, want) {
t.Fatal(fake.events)
}
}
func TestInterruptedCapitalDoesNotLeakPitchOrReplayQueuedCapital(t *testing.T) {
fake := new_recording_transport()
service := New(fake)
defer service.Close()
service.Configure(Parameters{Rate: 80, Pitch: -10, Volume: 100})
service.Say_with_pitch("old", 60)
wait_event(t, fake.changed, "say:old")
service.Say_with_pitch("discarded", 70)
service.Interrupt()
service.Configure(Parameters{Rate: 90, Pitch: 20, Volume: 100})
service.Say_with_pitch("E", 80)
service.Say("fresh")
wait_event(t, fake.changed, "cancel")
fake.unblock()
wait_event(t, fake.changed, "sent:fresh")
fake.mu.Lock()
defer fake.mu.Unlock()
joined := strings.Join(fake.events, "|")
if strings.Contains(joined, "discarded") || !strings.Contains(joined, "pitch:80|say:E|sent:E|pitch:20|say:fresh") {
t.Fatal(fake.events)
}
}
func wait_event(t *testing.T, events <-chan string, expected string) {
t.Helper()
timeout := time.NewTimer(2 * time.Second)
defer timeout.Stop()
for {
select {
case event := <-events:
if event == expected {
return
}
case <-timeout.C:
t.Fatalf("timed out waiting for %q", expected)
}
}
}
func TestPassiveSpeechIsFIFOWithoutCancellation(t *testing.T) {
fake := new_recording_transport()
s := New(fake)
defer s.Close()
s.Say("old")
wait_event(t, fake.changed, "say:old")
s.Say("second")
s.Say("third")
fake.unblock()
wait_event(t, fake.changed, "sent:third")
fake.mu.Lock()
defer fake.mu.Unlock()
if want := []string{"say:old", "sent:old", "say:second", "sent:second", "say:third", "sent:third"}; !reflect.DeepEqual(fake.events, want) {
t.Fatalf("got %v", fake.events)
}
}
func TestIdleInterruptNeedsOnlyOneCancellation(t *testing.T) {
fake := new_recording_transport()
s := New(fake)
defer s.Close()
s.Interrupt()
s.Say("fresh")
wait_event(t, fake.changed, "sent:fresh")
fake.mu.Lock()
defer fake.mu.Unlock()
want := []string{"cancel", "say:fresh", "sent:fresh"}
if !reflect.DeepEqual(fake.events, want) {
t.Fatalf("idle interruption performed extra work: %v", fake.events)
}
}
func TestInterruptCancelsBlockedSendAndGatesFreshSpeech(t *testing.T) {
fake := new_recording_transport()
s := New(fake)
defer s.Close()
s.Say("old")
wait_event(t, fake.changed, "say:old")
s.Say("discarded")
s.Interrupt()
s.Say("fresh")
// This must arrive while Say is blocked, before its acknowledgement.
wait_event(t, fake.changed, "cancel")
fake.unblock()
wait_event(t, fake.changed, "sent:fresh")
fake.mu.Lock()
defer fake.mu.Unlock()
want := []string{"say:old", "cancel", "sent:old", "cancel", "say:fresh", "sent:fresh"}
if !reflect.DeepEqual(fake.events, want) {
t.Fatalf("generation barrier failed: %v", fake.events)
}
}
func TestMuteDiscardsSuppressedSpeechAndUnmuteDoesNotReplay(t *testing.T) {
fake := new_recording_transport()
s := New(fake)
defer s.Close()
s.Say("old")
wait_event(t, fake.changed, "say:old")
s.Say("discarded")
if !s.Toggle_mute() {
t.Fatal("mute failed")
}
s.Say("suppressed")
if s.Toggle_mute() {
t.Fatal("unmute failed")
}
s.Say("fresh")
fake.unblock()
wait_event(t, fake.changed, "sent:fresh")
fake.mu.Lock()
defer fake.mu.Unlock()
for _, event := range fake.events {
if strings.Contains(event, "discarded") || strings.Contains(event, "suppressed") {
t.Fatalf("muted speech replayed: %v", fake.events)
}
}
}
type gatedCancelTransport struct {
*recordingTransport
gate chan struct{}
gateOnce sync.Once
calls atomic.Int32
}
func (f *gatedCancelTransport) Cancel() error {
call := f.calls.Add(1)
f.record(fmt.Sprintf("cancel:%d", call))
if call == 1 {
<-f.gate
}
return nil
}
func (f *gatedCancelTransport) open_gate() { f.gateOnce.Do(func() { close(f.gate) }) }
func (f *gatedCancelTransport) Close() { f.open_gate(); f.recordingTransport.Close() }
func TestRapidInterruptsDoNotRecancelSettledGeneration(t *testing.T) {
fake := &gatedCancelTransport{recordingTransport: new_recording_transport(), gate: make(chan struct{})}
s := New(fake)
defer s.Close()
s.Say("old")
wait_event(t, fake.changed, "say:old")
s.Interrupt()
wait_event(t, fake.changed, "cancel:1")
s.Interrupt() // Leaves a wake token while cancellation is already running.
s.Say("fresh")
fake.open_gate()
fake.unblock()
wait_event(t, fake.changed, "sent:fresh")
s.Close()
if got := fake.calls.Load(); got != 2 {
t.Fatalf("intervening interrupts should share the blocked-send barrier, got %d cancels", got)
}
}
func TestInterruptsDuringIdleCancelShareOneBarrier(t *testing.T) {
fake := &gatedCancelTransport{recordingTransport: new_recording_transport(), gate: make(chan struct{})}
s := New(fake)
defer s.Close()
s.Interrupt()
wait_event(t, fake.changed, "cancel:1")
for range 20 {
s.Interrupt()
s.Say("discarded")
}
s.Interrupt()
s.Say("fresh")
fake.open_gate()
wait_event(t, fake.changed, "sent:fresh")
s.Close()
if got := fake.calls.Load(); got != 1 {
t.Fatalf("idle cancellation should cover intervening input, got %d calls", got)
}
fake.mu.Lock()
defer fake.mu.Unlock()
for _, event := range fake.events {
if strings.Contains(event, "discarded") {
t.Fatal("interrupted output was replayed", fake.events)
}
}
}