214 lines
8.3 KiB
Go
214 lines
8.3 KiB
Go
// 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):
|
|
}
|
|
}
|