320 lines
9.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|