Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,182 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user