Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+356
View File
@@ -0,0 +1,356 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"bufio"
"context"
"fmt"
"log/slog"
"net"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/remote"
)
func TestEchoSpeakerSwitchesBothVoicesTransactionally(t *testing.T) {
for _, wrapped := range []bool{false, true} {
t.Run(fmt.Sprintf("configured=%v", wrapped), func(t *testing.T) { test_echo_speaker(t, wrapped) })
}
}
func test_echo_speaker(t *testing.T, wrapped bool) {
path := filepath.Join(t.TempDir(), "speech.sock")
listener, err := net.Listen("unix", path)
if err != nil {
t.Fatal(err)
}
type utterance struct {
module, text string
rate, pitch string
}
spoken := make(chan utterance, 10)
var handlers sync.WaitGroup
acceptDone := make(chan struct{})
go func() {
defer close(acceptDone)
for id := 1; ; id++ {
conn, err := listener.Accept()
if err != nil {
return
}
handlers.Add(1)
go func(id int) {
defer handlers.Done()
defer conn.Close()
reader := bufio.NewReader(conn)
module, rate, pitch := "", "", ""
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
command := strings.TrimSpace(line)
response := "200 OK\r\n"
if strings.HasPrefix(command, "SET SELF OUTPUT_MODULE ") {
module = strings.TrimPrefix(command, "SET SELF OUTPUT_MODULE ")
}
if strings.HasPrefix(command, "SET SELF RATE ") {
rate = strings.TrimPrefix(command, "SET SELF RATE ")
}
if strings.HasPrefix(command, "SET SELF PITCH ") {
pitch = strings.TrimPrefix(command, "SET SELF PITCH ")
}
if command == "SET SELF OUTPUT_MODULE rejected" {
response = "400 Module unavailable\r\n"
}
if command == "HISTORY GET CLIENT_ID" {
response = fmt.Sprintf("245-%d\r\n245 OK\r\n", id)
}
if strings.HasPrefix(command, "CHAR ") {
spoken <- utterance{module, strings.TrimPrefix(command, "CHAR "), rate, pitch}
}
if command == "SPEAK" {
var text strings.Builder
for {
part, err := reader.ReadString('\n')
if err != nil {
return
}
if part == ".\r\n" {
break
}
text.WriteString(strings.TrimSuffix(part, "\r\n"))
}
spoken <- utterance{module, text.String(), rate, pitch}
response = "230 OK\r\n200 QUEUED\r\n"
}
if _, err := conn.Write([]byte(response)); err != nil {
return
}
}
}(id)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
settings := config.DefaultSettings()
settings.UseCustomEchoVoice = true
settings.UseCustomEchoSpeechServer = true
settings.EchoSpeechServer = "echo-module"
rate := 80.0
settings.EchoVoice.Rate = &rate
var speaker interface {
configuredVoice
UpdateConfiguration(context.Context, config.Settings) error
} = newEchoSpeaker(path, settings)
if wrapped {
speaker = newConfiguredSpeaker(path, settings)
}
defer func() { speaker.Close(ctx); listener.Close(); <-acceptDone; handlers.Wait() }()
if err := speaker.Connect(ctx); err != nil {
t.Fatal(err)
}
check := func(custom bool, wantModule, wantRate string) {
t.Helper()
var err error
if custom {
err = speaker.SpeakEcho(ctx, "test")
} else {
err = speaker.Speak(ctx, "test")
}
if err != nil {
t.Fatal(err)
}
select {
case got := <-spoken:
if got.module != wantModule || got.rate != wantRate {
t.Fatalf("utterance=%+v, want module %q rate %q", got, wantModule, wantRate)
}
case <-ctx.Done():
t.Fatal("speech missing")
}
}
check(false, "", "")
check(true, "echo-module", "62")
checkCharacters := func(wantNormal string) {
t.Helper()
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
for _, tc := range []struct {
text string
character bool
pitch string
}{
{"A", true, "50"}, {"a", true, wantNormal}, {"É", true, "50"},
{"o", true, wantNormal}, {"o", true, wantNormal},
{"猫", true, wantNormal}, {"7", true, wantNormal}, {"space", true, wantNormal},
{"ABC", false, wantNormal}, {"A", false, wantNormal},
} {
if err := worker.deliver(ctx, speechRequest{Text: tc.text, Character: tc.character}); err != nil {
t.Fatal(err)
}
select {
case got := <-spoken:
if got.pitch != tc.pitch || got.text != tc.text || got.module != "" {
t.Fatalf("character %v: got %+v, want text %q pitch %q", tc.character, got, tc.text, tc.pitch)
}
case <-ctx.Done():
t.Fatal("character speech missing")
}
}
}
checkCharacters("")
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
// Exercise deletion from event presentation through the real SSIP client,
// checking both uppercase pitch and restoration on the following letter.
for _, custom := range []bool{false, true} {
deletionSettings := settings
deletionSettings.UseCustomEchoVoice = custom
live := &liveSettings{}
live.Store(deletionSettings)
queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)}
presenter := eventPresenter{state: &eventLoopState{}, logger: slog.New(slog.DiscardHandler), speech: queue, live: live}
for _, text := range []string{"A", "a", "É", "é"} {
snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/editor", Role: "entry", Editable: true}
presenter.speak(atspi.NewEventWithPayload(atspi.TextDeletedEvent, snapshot, text))
request := <-queue.requests
<-queue.spoken
if err := worker.deliver(ctx, request); err != nil {
t.Fatal(err)
}
got := <-spoken
wantPitch, wantModule, wantRate := "", "", ""
if text == "A" || text == "É" {
wantPitch = "50"
}
if custom {
wantModule, wantRate = "echo-module", "62"
}
if got.text != text || got.pitch != wantPitch || got.module != wantModule || got.rate != wantRate {
t.Fatalf("deleted %q, custom=%v: %+v", text, custom, got)
}
}
}
// Wine insertion must use the same character voice as native typing.
for _, custom := range []bool{false, true} {
wineSettings := settings
wineSettings.UseCustomEchoVoice = custom
live := &liveSettings{}
live.Store(wineSettings)
queue := &remoteSpeechQueue{}
braille := &remoteBrailleQueue{running: true}
presenter := remoteSpeech{speech: queue, braille: braille, live: live}
owner := newWineEventPresenter(func(message string) bool {
return presenter.presentMessage(message, "wine-accessible-event")
})
presenter.wine = owner
presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "42:text", EventType: "focus", Role: "edit"})
owner.flush()
queue.requests, braille.text = nil, nil
for _, text := range []string{"A", "a", "É", "é", "é", "𐐀", "𐐨", "😀", "space", "new line"} {
if !presenter.PresentAccessibleEvent(remote.AccessibleEvent{
SourceID: "42:text", EventType: "text", Name: text, States: "start=1 end=1 kind=8",
}) {
t.Fatalf("Wine insertion %q rejected", text)
}
request := queue.requests[len(queue.requests)-1]
if err := worker.deliver(ctx, request); err != nil {
t.Fatal(err)
}
got := <-spoken
wantPitch, wantModule, wantRate := "", "", ""
if text == "A" || text == "É" || text == "𐐀" {
wantPitch = "50"
}
if custom {
wantModule, wantRate = "echo-module", "62"
}
if got.text != text || got.pitch != wantPitch || got.module != wantModule || got.rate != wantRate {
t.Fatalf("Wine insertion %q, custom=%v: %+v; want pitch %q module %q rate %q", text, custom, got, wantPitch, wantModule, wantRate)
}
if braille.text[len(braille.text)-1] != text {
t.Fatalf("Wine braille changed character case: %v", braille.text)
}
}
owner.Close()
}
for _, text := range []string{"A", "a", "É"} {
if err := worker.deliver(ctx, speechRequest{Text: text, Character: true, CustomEcho: true}); err != nil {
t.Fatal(err)
}
got := <-spoken
wantPitch := "50"
if text == "a" {
wantPitch = ""
}
if got.module != "echo-module" || got.rate != "62" || got.pitch != wantPitch {
t.Fatalf("custom character echo = %+v; want pitch %s", got, wantPitch)
}
}
upperPitch, linkPitch, systemPitch := 8.0, 1.0, 2.0
settings.UppercaseVoice.AveragePitch = &upperPitch
settings.HyperlinkVoice.AveragePitch = &linkPitch
settings.SystemVoice.AveragePitch = &systemPitch
if err := speaker.UpdateConfiguration(ctx, settings); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
request speechRequest
pitch string
}{
{speechRequest{Text: "A", Character: true}, "70"},
{speechRequest{Text: "A", Character: true, CustomEcho: true}, "70"},
{speechRequest{Text: "link text", Role: "link"}, "-70"},
{speechRequest{Text: "status text", Role: "system"}, "-50"},
{speechRequest{Text: "ordinary text"}, ""},
{speechRequest{Spelling: []string{"alpha", "bravo"}, SpellingVoices: []string{"uppercase", ""}}, "70"},
} {
if err := worker.deliver(ctx, tc.request); err != nil {
t.Fatal(err)
}
got := <-spoken
if got.pitch != tc.pitch {
t.Fatalf("voice selection for %+v = %+v", tc.request, got)
}
if len(tc.request.Spelling) != 0 {
if got := <-spoken; got.pitch != "" {
t.Fatalf("uppercase spelling pitch leaked: %+v", got)
}
}
}
// Verify real SSIP SPEAK requests from focus presentation, including source
// punctuation, the selected special voice, and disabling pause chunks.
for _, enabled := range []bool{true, false} {
pauseSettings := settings
pauseSettings.Reading.EnablePauseBreaks = enabled
pauseSettings.EnableSound = false
live := &liveSettings{}
live.Store(pauseSettings)
queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)}
presenter := eventPresenter{state: &eventLoopState{}, logger: slog.New(slog.DiscardHandler), speech: queue, live: live}
presenter.speak(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{Name: "Read, draft. Next!", Role: "link", Description: "Open document"}))
if err := worker.deliver(ctx, <-queue.requests); err != nil {
t.Fatal(err)
}
want := []string{"Read, draft. Next!", "link", "Open document"}
if !enabled {
want = []string{"Read, draft. Next! link Open document"}
}
for _, text := range want {
select {
case got := <-spoken:
if got.text != text || got.pitch != "-70" || got.module != "" {
t.Fatalf("pause chunks enabled=%v: SSIP utterance=%+v, want text %q with hyperlink pitch", enabled, got, text)
}
case <-ctx.Done():
t.Fatal("pause chunk SSIP request missing")
}
}
if len(spoken) != 0 {
t.Fatal("unexpected extra SSIP request")
}
}
settings.UppercaseVoice, settings.HyperlinkVoice, settings.SystemVoice = config.Voice{}, config.Voice{}, config.Voice{}
if err := speaker.UpdateConfiguration(ctx, settings); err != nil {
t.Fatal(err)
}
settings.EchoSpeechServer = "rejected"
if err := speaker.UpdateConfiguration(ctx, settings); err == nil {
t.Fatal("invalid module accepted")
}
check(false, "", "")
check(true, "echo-module", "62")
settings.UseCustomEchoVoice = false
if err := speaker.UpdateConfiguration(ctx, settings); err != nil {
t.Fatal(err)
}
check(true, "", "")
pitch := 3.0
settings.DefaultVoice.AveragePitch = &pitch
if err := speaker.UpdateConfiguration(ctx, settings); err != nil {
t.Fatal(err)
}
checkCharacters("-30")
if wrapped {
test_hardware_switch(t, speaker.(*configuredSpeaker), settings)
check(false, "", "")
checkCharacters("-30")
}
if err := speaker.Cancel(ctx); err != nil {
t.Fatal(err)
}
if err := speaker.Close(ctx); err != nil {
t.Fatal(err)
}
if err := speaker.UpdateConfiguration(ctx, settings); err == nil {
t.Fatal("closed speaker revived")
}
}