// 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): } }