Files
cthulhu/internal/app/text_selection_test.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):
}
}