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,181 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/logging"
|
||||
)
|
||||
|
||||
type continuousSpeaker struct {
|
||||
started chan string
|
||||
finish chan struct{}
|
||||
}
|
||||
|
||||
func TestReadAllSentencePartsKeepLineProgress(t *testing.T) {
|
||||
progress := 0
|
||||
parts := readAllLineParts("First sentence. Second sentence! Last words", func() { progress++ }, "sentence")
|
||||
if len(parts) != 3 || parts[0].Text != "First sentence." ||
|
||||
parts[1].Text != "Second sentence!" || parts[2].Text != "Last words" {
|
||||
t.Fatalf("sentence parts = %#v", parts)
|
||||
}
|
||||
if parts[0].Began == nil || parts[1].Began != nil || parts[2].Began != nil {
|
||||
t.Fatalf("line progress callbacks = %#v", parts)
|
||||
}
|
||||
parts[0].Began()
|
||||
if progress != 1 {
|
||||
t.Fatalf("progress = %d", progress)
|
||||
}
|
||||
line := readAllLineParts("First sentence. Second sentence!", nil, "line")
|
||||
if len(line) != 1 || line[0].Text != "First sentence. Second sentence!" {
|
||||
t.Fatalf("line part = %#v", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAllContextArrivesAtItsLine(t *testing.T) {
|
||||
speaker := &continuousSpeaker{started: make(chan string, 6), finish: make(chan struct{}, 2)}
|
||||
speaker.finish <- struct{}{}
|
||||
speaker.finish <- struct{}{}
|
||||
reading := config.DefaultSettings().Reading
|
||||
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
||||
worker.reading = func() config.ReadingSettings { return reading }
|
||||
list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Items"}
|
||||
request := speechRequest{ReadAll: []readAllPart{
|
||||
{Text: "Before", Context: []atspi.Snapshot{}},
|
||||
{Text: "Inside", Context: []atspi.Snapshot{list}},
|
||||
}}
|
||||
if err := worker.deliver(context.Background(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"Before", "Items list", "Inside"} {
|
||||
if got := <-speaker.started; got != want {
|
||||
t.Fatalf("spoken %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
reading.SayAllContextList = false
|
||||
worker.speechContext = nil
|
||||
speaker.finish <- struct{}{}
|
||||
if err := worker.deliver(context.Background(), speechRequest{ReadAll: []readAllPart{{Text: "Inside again", Context: []atspi.Snapshot{list}}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := <-speaker.started; got != "Inside again" || len(speaker.started) != 0 {
|
||||
t.Fatalf("disabled list context speech = %q; queued = %d", got, len(speaker.started))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAllDiagnosticsIncludeOnlyDeliveredParts(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
speaker := &continuousSpeaker{started: make(chan string, 3), finish: make(chan struct{}, 1)}
|
||||
speaker.finish <- struct{}{}
|
||||
worker := newSpeechWorker(logging.New(&output, logging.Options{Level: slog.LevelDebug}), speaker)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
request := speechRequest{Cause: "read-all", Role: "system", ReadAll: []readAllPart{
|
||||
{Text: "First line"}, {Text: "Second line", Began: cancel}, {Text: "Unread line"},
|
||||
}}
|
||||
if err := worker.deliver(ctx, request); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("interrupted reading: %v", err)
|
||||
}
|
||||
for _, expected := range []string{`"utterance":"First line"`, `"utterance":"Second line"`, `"read_all_index":1`} {
|
||||
if !strings.Contains(output.String(), expected) {
|
||||
t.Fatalf("reading diagnostics missing %s: %s", expected, output.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(output.String(), "Unread line") {
|
||||
t.Fatalf("logged reading which never reached the backend: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (*continuousSpeaker) Connect(context.Context) error { return nil }
|
||||
func (*continuousSpeaker) Close(context.Context) error { return nil }
|
||||
func (*continuousSpeaker) Cancel(context.Context) error { return nil }
|
||||
func (speaker *continuousSpeaker) Speak(_ context.Context, text string) error {
|
||||
speaker.started <- text
|
||||
return nil
|
||||
}
|
||||
func (speaker *continuousSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error {
|
||||
if began != nil {
|
||||
began()
|
||||
}
|
||||
speaker.started <- text
|
||||
select {
|
||||
case <-speaker.finish:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAllPacesSpeechAndInterruptsOnNewInputOrFocus(t *testing.T) {
|
||||
for _, cause := range []string{"key", "focus"} {
|
||||
t.Run(cause, func(t *testing.T) {
|
||||
speaker := &continuousSpeaker{started: make(chan string, 4), finish: make(chan struct{})}
|
||||
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
||||
worker.Start(context.Background())
|
||||
defer worker.Stop(context.Background())
|
||||
ctx := worker.BeginReadAll(context.Background(), 100)
|
||||
// The initiating key can reach XRecord after command dispatch.
|
||||
if worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 100}) || ctx.Err() != nil {
|
||||
t.Fatal("read all canceled by its initiating key")
|
||||
}
|
||||
progress := make(chan int, 3)
|
||||
worker.Enqueue(speechRequest{Cause: "read-all", context: ctx, ReadAll: []readAllPart{
|
||||
{Text: "First", Began: func() { progress <- 1 }},
|
||||
{Text: "Second", Began: func() { progress <- 2 }},
|
||||
{Text: "Third", Began: func() { progress <- 3 }},
|
||||
}})
|
||||
waitText := func(want string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-speaker.started:
|
||||
if got != want {
|
||||
t.Fatalf("speech %q, want %q", got, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
}
|
||||
waitText("First")
|
||||
if <-progress != 1 {
|
||||
t.Fatal("wrong starting position")
|
||||
}
|
||||
select {
|
||||
case <-speaker.started:
|
||||
t.Fatal("advanced before speech completed")
|
||||
default:
|
||||
}
|
||||
speaker.finish <- struct{}{}
|
||||
waitText("Second")
|
||||
if <-progress != 2 {
|
||||
t.Fatal("wrong second position")
|
||||
}
|
||||
if cause == "key" {
|
||||
if !worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 101}) {
|
||||
t.Fatal("new key ignored")
|
||||
}
|
||||
}
|
||||
worker.Enqueue(speechRequest{Text: "New focus", Cause: "focus:"})
|
||||
waitText("New focus")
|
||||
if ctx.Err() == nil {
|
||||
t.Fatal("read all was not canceled")
|
||||
}
|
||||
select {
|
||||
case <-progress:
|
||||
t.Fatal("reading position advanced after interruption")
|
||||
default:
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user