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