322 lines
12 KiB
Go
322 lines
12 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
//
|
|
// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
)
|
|
|
|
func TestProcessEventsSpeaksChangedCaretLines(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 2)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
completed := make(chan struct{})
|
|
go func() {
|
|
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
|
close(completed)
|
|
}()
|
|
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "First line"))
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != "First line" {
|
|
t.Fatalf("spoken text = %q, want %q", text, "First line")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for first caret speech")
|
|
}
|
|
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "First line"))
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "Second line"))
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != "Second line" {
|
|
t.Fatalf("spoken text = %q, want %q", text, "Second line")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for second caret speech")
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for event loop to stop")
|
|
}
|
|
}
|
|
|
|
func TestProcessEventsSuppressesCaretAfterEditableTextChange(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 3)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
completed := make(chan struct{})
|
|
go func() {
|
|
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
|
close(completed)
|
|
}()
|
|
|
|
paragraph := atspi.Snapshot{Path: "/writer/paragraph", Role: "paragraph", Editable: true,
|
|
Text: "Hello", CaretCharacter: "o"}
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, paragraph)
|
|
<-speech.spoken
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, "o")
|
|
if got := <-speech.spoken; got != "o" {
|
|
t.Fatalf("insert speech = %q", got)
|
|
}
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, paragraph)
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent,
|
|
atspi.Snapshot{Path: "/status", Role: "label"}, "status update")
|
|
|
|
select {
|
|
case got := <-speech.spoken:
|
|
t.Fatalf("unexpected duplicate or status speech: %q", got)
|
|
case <-time.After(30 * time.Millisecond):
|
|
}
|
|
|
|
cancel()
|
|
<-completed
|
|
}
|
|
|
|
func TestProcessEventsReadOnlyComboUsesNameInsteadOfEditorCaret(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 16)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
|
|
combo := atspi.Snapshot{BusName: ":1.7", Path: "/combo", Role: "combo box", Name: "Classic Doom", Focused: true}
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, combo)
|
|
editor := atspi.Snapshot{BusName: combo.BusName, Path: "/combo/editor", Role: "text", Text: "Funny Pack - Classic Doom",
|
|
BrailleLine: "Funny Pack - Classic Doom", TextCaretKnown: true, Editable: true, ReadOnly: true,
|
|
Focused: true, ReadOnlyCombo: combo.ID()}
|
|
for _, value := range []string{"Funny Pack - Classic Doom", "Toby Doom", "Classic Doom"} {
|
|
editor.Text, editor.BrailleLine = value, value
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, editor)
|
|
combo.Name = value
|
|
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, combo)
|
|
}
|
|
close(adapter.events)
|
|
processEvents(context.Background(), slog.New(slog.DiscardHandler), speech, adapter, 0)
|
|
close(speech.spoken)
|
|
var got []string
|
|
for text := range speech.spoken {
|
|
got = append(got, text)
|
|
}
|
|
want := []string{"Classic Doom, combo box", "Funny Pack - Classic Doom", "Toby Doom", "Classic Doom"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("combo speech = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestReadOnlyComboCaretRequiresExactOwnedCombo(t *testing.T) {
|
|
combo := atspi.Snapshot{BusName: ":1.7", Path: "/combo", Role: "combo box"}
|
|
for _, test := range []struct {
|
|
name string
|
|
owner atspi.ObjectID
|
|
skip bool
|
|
}{
|
|
{"owned", combo.ID(), true},
|
|
{"different combo", atspi.NewObjectID(combo.BusName, "/other-combo"), false},
|
|
{"different provider", atspi.NewObjectID(":1.8", combo.Path), false},
|
|
{"ordinary editor", atspi.ObjectID{}, false},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
state := &eventLoopState{}
|
|
state.current, state.focusedID = combo, combo.ID()
|
|
handler := accessibilityEventHandler{state: state, logger: slog.New(slog.DiscardHandler), debounce: time.Hour}
|
|
event := atspi.NewEvent(atspi.CaretEvent, atspi.Snapshot{BusName: combo.BusName, Path: "/editor", Role: "text", ReadOnlyCombo: test.owner})
|
|
handler.handleCaret(event, input.Activity{}, input.NoIntent)
|
|
defer state.pending.clear()
|
|
if skipped := state.pending.caret.event.Type == ""; skipped != test.skip {
|
|
t.Fatalf("caret skipped = %v, want %v", skipped, test.skip)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTerminalCaretSeedsReviewFocusWithoutFocusEvent(t *testing.T) {
|
|
// A reader started while the terminal already has focus may never see
|
|
// a focus-gain event. Caret movement from the focused terminal is the
|
|
// evidence flat review needs to anchor to it.
|
|
worker, err := review.NewWorker(&documentLineCapturer{calls: make(chan documentLineCaptureCall, 1)}, time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state := &eventLoopState{}
|
|
defer state.pending.clear()
|
|
handler := accessibilityEventHandler{state: state, logger: slog.New(slog.DiscardHandler), objectReview: worker, debounce: time.Hour}
|
|
terminal := atspi.Snapshot{BusName: ":1.9", Path: "/terminal", Role: "terminal", Focused: true}
|
|
// Output churn without user typing must not claim review focus.
|
|
handler.handleCaret(atspi.NewEvent(atspi.CaretEvent, terminal), input.Activity{}, input.NoIntent)
|
|
if got := worker.Focus(); got.Valid() {
|
|
t.Fatalf("review focus seeded without typing = %+v", got)
|
|
}
|
|
handler.handleCaret(atspi.NewEvent(atspi.CaretEvent, terminal), input.Activity{}, input.TypingIntent)
|
|
if got := worker.Focus(); got != terminal.ID() {
|
|
t.Fatalf("review focus = %+v, want terminal %+v", got, terminal.ID())
|
|
}
|
|
if state.focusedID != terminal.ID() || state.current.ID() != terminal.ID() {
|
|
t.Fatalf("focused state = %+v/%+v, want terminal", state.focusedID, state.current.ID())
|
|
}
|
|
// An unfocused terminal caret must not steal review focus.
|
|
other := atspi.Snapshot{BusName: ":1.9", Path: "/other-terminal", Role: "terminal"}
|
|
handler.handleCaret(atspi.NewEvent(atspi.CaretEvent, other), input.Activity{}, input.TypingIntent)
|
|
if got := worker.Focus(); got != terminal.ID() {
|
|
t.Fatalf("review focus after unfocused caret = %+v, want %+v", got, terminal.ID())
|
|
}
|
|
// Another widget in the same application can retain a stale focused
|
|
// bit. Its caret must not move review away from the known focus.
|
|
other.Focused = true
|
|
handler.handleCaret(atspi.NewEvent(atspi.CaretEvent, other), input.Activity{}, input.TypingIntent)
|
|
if got := worker.Focus(); got != state.focusedID {
|
|
t.Fatalf("stale focused bit split review and input ownership: %+v != %+v", got, state.focusedID)
|
|
}
|
|
}
|
|
|
|
func TestProcessEventsSpeaksCaretCharacters(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 3)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
completed := make(chan struct{})
|
|
go func() {
|
|
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
|
close(completed)
|
|
}()
|
|
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter(
|
|
"/accessible/terminal", "", "terminal", "", "prompt ls", "l",
|
|
))
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != "l" {
|
|
t.Fatalf("spoken text = %q, want %q", text, "l")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for first caret character speech")
|
|
}
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter(
|
|
"/accessible/terminal", "", "terminal", "", "prompt ll", "l",
|
|
))
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != "l" {
|
|
t.Fatalf("repeated caret character = %q, want l", text)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for repeated caret character speech")
|
|
}
|
|
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter(
|
|
"/accessible/terminal", "", "terminal", "", "prompt ls", "s",
|
|
))
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != "s" {
|
|
t.Fatalf("spoken text = %q, want %q", text, "s")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for second caret character speech")
|
|
}
|
|
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter(
|
|
"/accessible/terminal", "", "terminal", "", "prompt ls", " ",
|
|
))
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != "space" {
|
|
t.Fatalf("spoken caret space = %q, want %q", text, "space")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for caret space speech")
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for event loop to stop")
|
|
}
|
|
}
|
|
|
|
func TestProcessEventsTextChangeCancelsPendingCaretLine(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 1)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 2)}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
completed := make(chan struct{})
|
|
go func() {
|
|
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, time.Hour)
|
|
close(completed)
|
|
}()
|
|
|
|
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "prompt l")
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, terminal)
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "l")
|
|
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != "l" {
|
|
t.Fatalf("spoken text = %q, want %q", text, "l")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for inserted text speech")
|
|
}
|
|
|
|
select {
|
|
case text := <-speech.spoken:
|
|
t.Fatalf("unexpected additional speech: %q", text)
|
|
default:
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for event loop to stop")
|
|
}
|
|
}
|
|
|
|
func TestCaretEventRequestsCharacterVoice(t *testing.T) {
|
|
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 1)}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() { defer close(done); processEvents(ctx, slog.New(slog.DiscardHandler), queue, adapter, 0) }()
|
|
defer func() { cancel(); <-done }()
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter("/terminal", "", "terminal", "", "Ab", "A"))
|
|
select {
|
|
case request := <-queue.requests:
|
|
if request.Text != "A" || !request.Character {
|
|
t.Fatalf("caret request = %+v", request)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("caret speech missing")
|
|
}
|
|
}
|