Files
cthulhu/internal/app/speech_mode_test.go

183 lines
5.8 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"log/slog"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
)
type modeTestSpeaker struct{ spoken chan string }
func (*modeTestSpeaker) Connect(context.Context) error { return nil }
func (*modeTestSpeaker) Close(context.Context) error { return nil }
func (*modeTestSpeaker) Cancel(context.Context) error { return nil }
func (speaker *modeTestSpeaker) Speak(_ context.Context, text string) error {
speaker.spoken <- text
return nil
}
type modeTestBraille struct{ messages chan string }
func (*modeTestBraille) EnqueueFrame(braille.Frame) {}
func (display *modeTestBraille) Flash(text string, _ config.BrailleOptions) bool {
display.messages <- text
return true
}
func TestSpeechModesCoverEveryDeliveryPath(t *testing.T) {
for _, mode := range []speechMode{speechModeBeeps, speechModeOff} {
t.Run(mode.announcement(), func(t *testing.T) {
speaker := &fakeSpeaker{spoken: make(chan string, 10)}
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
worker.mode = mode
beeps, began := 0, false
worker.beep = func(context.Context) error { beeps++; return nil }
for _, request := range []speechRequest{
{Text: "Focused button"},
{Text: "A", Character: true},
{Text: "book", Spelling: []string{"b", "o", "o", "k"}, Character: true},
{Text: "word", CustomEcho: true},
{Text: "External message", Cause: "remote-speak"},
{ReadAll: []readAllPart{{Text: "First line", Began: func() { began = true }}}},
} {
if err := worker.deliver(context.Background(), request); err != nil {
t.Fatal(err)
}
}
if len(speaker.spoken) != 0 || speaker.canceled || began {
t.Fatal("silent/beeps mode used synth, canceled the mode announcement, or advanced Say All")
}
wantBeeps := 0
if mode == speechModeBeeps {
wantBeeps = 6
}
if beeps != wantBeeps {
t.Fatalf("beeps = %d, want %d", beeps, wantBeeps)
}
})
}
}
func TestSpeechModeCommandAnnouncesAndPreservesBraille(t *testing.T) {
speaker := &modeTestSpeaker{spoken: make(chan string, 5)}
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
beeps := make(chan struct{}, 5)
worker.beep = func(context.Context) error { beeps <- struct{}{}; return nil }
if err := worker.Start(context.Background()); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
settings := config.DefaultSettings()
display := &modeTestBraille{messages: make(chan string, 10)}
live := &liveSettings{}
live.Store(settings)
output := outputQueue{speech: worker, braille: display, live: live}
commands := &fakeCommands{commands: make(chan input.CommandEvent)}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
done <- (commandRuntime{logger: slog.New(slog.DiscardHandler), commands: commands,
focusGate: &focusGate{}, focusChanges: make(chan xorg.FocusState),
speechMessages: worker, speechOutput: output}).run(ctx)
}()
for index, want := range []string{"Speech Mode Beeps", "Speech off", "Speech Mode Speech"} {
commands.commands <- input.CommandEvent{Command: input.CycleSpeechMode}
select {
case got := <-speaker.spoken:
if got != want {
t.Fatalf("announcement = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatal("mode announcement missing")
}
select {
case got := <-display.messages:
if got != want {
t.Fatalf("Braille = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatal("Braille mode announcement missing")
}
result := make(chan error, 1)
output.Enqueue(speechRequest{Text: "Ordinary message", Role: "system", Append: true, result: result})
select {
case err := <-result:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("ordinary message did not complete")
}
if got := <-display.messages; got != "Ordinary message" {
t.Fatalf("ordinary Braille = %q", got)
}
switch index {
case 0:
if len(beeps) != 1 {
t.Fatalf("beeps = %d, want 1", len(beeps))
}
case 1:
if len(beeps) != 1 || len(speaker.spoken) != 0 {
t.Fatal("off mode emitted output")
}
case 2:
if got := <-speaker.spoken; got != "Ordinary message" {
t.Fatalf("speech did not resume: %q", got)
}
}
}
cancel()
<-done
}
func TestSpeechModeSwitchCancelsActiveBeepAndDropsPendingSpeech(t *testing.T) {
speaker := &modeTestSpeaker{spoken: make(chan string, 2)}
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
worker.mode = speechModeBeeps
started := make(chan struct{})
worker.beep = func(ctx context.Context) error { close(started); <-ctx.Done(); return ctx.Err() }
if err := worker.Start(context.Background()); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- worker.EnqueueAndWait(ctx, speechRequest{Text: "Active"}) }()
select {
case <-started:
case <-ctx.Done():
t.Fatal("beep did not start")
}
worker.Enqueue(speechRequest{Text: "Queued"})
if got := worker.cycle_speech_mode(); got != speechModeOff {
t.Fatalf("mode = %v", got)
}
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("beep result = %v", err)
}
case <-ctx.Done():
t.Fatal("mode switch did not cancel beep")
}
if err := worker.EnqueueAndWait(ctx, speechRequest{Text: "Speech off", ForceSpeech: true}); err != nil {
t.Fatal(err)
}
if got := <-speaker.spoken; got != "Speech off" {
t.Fatalf("retained stale speech: %q", got)
}
}