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,213 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/logging"
|
||||
)
|
||||
|
||||
func TestTextSelectionDiagnosticsIncludeNativeOffsets(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := logging.New(&output, logging.Options{Level: slog.LevelDebug})
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 2)}
|
||||
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 4)}
|
||||
entry := atspi.Snapshot{BusName: ":1.7", Path: "/entry", Role: "entry", Editable: true, Focused: true}
|
||||
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
|
||||
entry.TextCaretKnown, entry.BrailleCaret, entry.BrailleLineStart = true, 13, 10
|
||||
entry.SelectionStart, entry.SelectionEnd, entry.SelectionText = 11, 13, "猫a"
|
||||
adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry)
|
||||
close(adapter.events)
|
||||
if err := processEventsWithInput(context.Background(), logger, queue, adapter, nil, nil, 0, nil, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoder := json.NewDecoder(&output)
|
||||
for decoder.More() {
|
||||
var record map[string]any
|
||||
if err := decoder.Decode(&record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record["message"] != "received accessibility event" || record["type"] != atspi.TextSelectionChangedEvent {
|
||||
continue
|
||||
}
|
||||
for key, want := range map[string]any{"bus": ":1.7", "source_path": "/entry", "native_caret_known": true,
|
||||
"native_caret_offset": float64(13), "native_line_start": float64(10), "selection_start": float64(11), "selection_end": float64(13)} {
|
||||
if record[key] != want {
|
||||
t.Fatalf("selection diagnostic %s = %v, want %v", key, record[key], want)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("selection event diagnostics missing")
|
||||
}
|
||||
|
||||
func TestTextSelectionCrossingAnchor(t *testing.T) {
|
||||
old := atspi.Snapshot{BusName: ":1.7", Path: "/entry", SelectionStart: 0, SelectionEnd: 2, SelectionText: "ab"}
|
||||
var selection textSelection
|
||||
selection.reset(old)
|
||||
newSelection := old
|
||||
newSelection.SelectionStart, newSelection.SelectionEnd, newSelection.SelectionText = 3, 5, "de"
|
||||
if got := selection.changed(newSelection); got != "ab unselected, de selected" {
|
||||
t.Fatalf("crossed anchor = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextSelectionSpeaksChangedCharactersAndWords(t *testing.T) {
|
||||
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 16)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
||||
tracker := &input.IntentTracker{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil)
|
||||
}()
|
||||
defer func() { cancel(); <-done }()
|
||||
expect := func(want string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-queue.requests:
|
||||
if got.Text != want {
|
||||
t.Fatalf("speech = %q, want %q", got.Text, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
}
|
||||
entry := atspi.Snapshot{BusName: ":1.7", Path: "/entry", Role: "entry", Name: "Message", Editable: true, Focused: true}
|
||||
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
|
||||
expect("Message, entry")
|
||||
|
||||
tracker.Observe(input.NewKeyEvent(0, true, "Shift_L"))
|
||||
tracker.Observe(input.NewKeyEvent(0, true, "Right"))
|
||||
entry.SelectionStart, entry.SelectionEnd, entry.SelectionText = 0, 1, "h"
|
||||
adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry)
|
||||
expect("h selected")
|
||||
entry.CaretCharacter = "h"
|
||||
adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry)
|
||||
select {
|
||||
case got := <-queue.requests:
|
||||
t.Fatalf("selection caret spoke twice: %q", got.Text)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
|
||||
tracker.Observe(input.NewKeyEvent(0, true, "Control_L"))
|
||||
tracker.Observe(input.NewKeyEvent(0, true, "Right"))
|
||||
entry.SelectionEnd, entry.SelectionText = 5, "hello"
|
||||
adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry)
|
||||
expect("ello selected")
|
||||
|
||||
tracker.Observe(input.NewKeyEvent(0, true, "Left"))
|
||||
entry.SelectionEnd, entry.SelectionText = 1, "h"
|
||||
adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry)
|
||||
expect("ello unselected")
|
||||
|
||||
adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry)
|
||||
select {
|
||||
case got := <-queue.requests:
|
||||
t.Fatalf("unchanged selection spoke: %q", got.Text)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
tracker.Observe(input.NewKeyEvent(0, false, "Control_L"))
|
||||
longLine := "h" + strings.Repeat("a", 199)
|
||||
tracker.Observe(input.NewKeyEvent(0, true, "Down"))
|
||||
entry.SelectionEnd, entry.SelectionText = 200, longLine
|
||||
adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry)
|
||||
expect(strings.Repeat("a", 199) + " selected")
|
||||
tracker.Observe(input.NewKeyEvent(0, true, "Up"))
|
||||
entry.SelectionEnd, entry.SelectionText = 1, "h"
|
||||
adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry)
|
||||
expect(strings.Repeat("a", 199) + " unselected")
|
||||
tracker.Observe(input.NewKeyEvent(0, false, "Shift_L"))
|
||||
tracker.Observe(input.NewKeyEvent(0, true, "Right"))
|
||||
entry.CaretCharacter = "o"
|
||||
adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry)
|
||||
expect("o")
|
||||
}
|
||||
|
||||
func TestLongSelectionWithoutLineNavigationKeepsBoundedSpeech(t *testing.T) {
|
||||
var selection textSelection
|
||||
snapshot := atspi.Snapshot{BusName: ":1.7", Path: "/entry"}
|
||||
selection.reset(snapshot)
|
||||
snapshot.SelectionEnd, snapshot.SelectionText = 200, strings.Repeat("a", 200)
|
||||
if got := selection.changed(snapshot); got != "selected" {
|
||||
t.Fatalf("large non-line selection = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongLineSelectionExtendsUpward(t *testing.T) {
|
||||
var selection textSelection
|
||||
snapshot := atspi.Snapshot{BusName: ":1.7", Path: "/entry", SelectionStart: 200, SelectionEnd: 201, SelectionText: "z"}
|
||||
selection.reset(snapshot)
|
||||
snapshot.SelectionStart, snapshot.SelectionText = 0, strings.Repeat("a", 200)+"z"
|
||||
if got, want := selection.changedWithLimit(snapshot, lineSelectionFragmentLimit), strings.Repeat("a", 200)+" selected"; got != want {
|
||||
t.Fatalf("upward line selection = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboardFeedbackRequiresEditableMutation(t *testing.T) {
|
||||
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 16)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
||||
tracker := &input.IntentTracker{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil)
|
||||
}()
|
||||
defer func() { cancel(); <-done }()
|
||||
expect := func(want string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-queue.requests:
|
||||
if got.Text != want {
|
||||
t.Fatalf("speech = %q, want %q", got.Text, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
}
|
||||
entry := atspi.Snapshot{BusName: ":1.7", Path: "/entry", Role: "entry", Name: "Message", Editable: true, Focused: true}
|
||||
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
|
||||
expect("Message, entry")
|
||||
tracker.Observe(input.NewKeyEvent(0, true, "Control_L"))
|
||||
tracker.Observe(input.NewKeyEvent(0, true, "v"))
|
||||
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "hello")
|
||||
expect("pasted from clipboard")
|
||||
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, " again")
|
||||
entry.SelectionStart, entry.SelectionEnd, entry.SelectionText = 0, 5, "hello"
|
||||
adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry)
|
||||
entry.CaretCharacter = "o"
|
||||
adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry)
|
||||
select {
|
||||
case got := <-queue.requests:
|
||||
t.Fatalf("paste follow-up interrupted announcement: %q", got.Text)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
tracker.Observe(input.NewKeyEvent(0, true, "x"))
|
||||
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry, "hello")
|
||||
expect("cut")
|
||||
|
||||
password := entry
|
||||
password.Path, password.Role = "/password", "password text"
|
||||
adapter.events <- atspi.NewEvent(atspi.FocusEvent, password)
|
||||
expect("Message, password text")
|
||||
tracker.Observe(input.NewKeyEvent(0, true, "v"))
|
||||
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, password, "secret")
|
||||
select {
|
||||
case got := <-queue.requests:
|
||||
t.Fatalf("protected edit spoke: %q", got.Text)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user