687 lines
24 KiB
Go
687 lines
24 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"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
)
|
|
|
|
func TestProcessEventsTerminalScrollbackDeletionBothSessions(t *testing.T) {
|
|
for _, sessionType := range []string{"wayland", "x11"} {
|
|
t.Run(sessionType, func(t *testing.T) {
|
|
t.Setenv("XDG_SESSION_TYPE", sessionType)
|
|
// Both sessions must apply the same event policy, including
|
|
// an X11 launcher which also supplies WAYLAND_DISPLAY.
|
|
t.Setenv("WAYLAND_DISPLAY", "wayland-test")
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 3)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
completed := make(chan struct{})
|
|
go func() {
|
|
defer close(completed)
|
|
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
|
}()
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Error("event loop did not stop")
|
|
}
|
|
})
|
|
|
|
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "Username@Host> ")
|
|
terminal.TextCaretKnown = true
|
|
terminal.BrailleLineStart = 300
|
|
terminal.BrailleCaret = 315
|
|
oldPrompt := "Username@Host> old command\n"
|
|
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, oldPrompt)
|
|
output := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Current command output")
|
|
output.Detail1 = 200
|
|
// A caret event can arrive between the scroll and its insertion.
|
|
caret := atspi.NewEvent(atspi.CaretEvent, terminal)
|
|
adapter.events <- deletion
|
|
adapter.events <- caret
|
|
adapter.events <- output
|
|
|
|
for _, want := range []string{"Current command output"} {
|
|
select {
|
|
case got := <-speech.spoken:
|
|
if got != want {
|
|
t.Fatalf("speech = %q, want %q", got, want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("timed out waiting for %q", want)
|
|
}
|
|
}
|
|
for _, deleted := range []string{"x", "command"} {
|
|
edit := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, deleted)
|
|
edit.Detail1 = terminal.BrailleLineStart
|
|
adapter.events <- edit
|
|
select {
|
|
case got := <-speech.spoken:
|
|
if got != deleted {
|
|
t.Fatalf("editing speech = %q, want %q", got, deleted)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("timed out waiting for deletion %q", deleted)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTerminalScrollbackDeletionPreservesEditing(t *testing.T) {
|
|
t.Setenv("XDG_SESSION_TYPE", "wayland")
|
|
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "Prompt")
|
|
terminal.TextCaretKnown = true
|
|
terminal.BrailleLineStart = 100
|
|
terminal.BrailleCaret = 110
|
|
base := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old prompt\n")
|
|
for _, test := range []struct {
|
|
name string
|
|
modify func(*atspi.Event)
|
|
ignore bool
|
|
}{
|
|
{"scroll", func(event *atspi.Event) {}, true},
|
|
{"unicode offsets", func(event *atspi.Event) {
|
|
*event = atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, strings.Repeat("猫", 40)+"\n")
|
|
}, true},
|
|
{"backspace", func(event *atspi.Event) { event.Payload = "x"; event.Detail1 = 109 }, false},
|
|
{"delete", func(event *atspi.Event) { event.Payload = "x"; event.Detail1 = 110 }, false},
|
|
{"delete word", func(event *atspi.Event) { event.Payload = "command"; event.Detail1 = 100 }, false},
|
|
{"single character at start", func(event *atspi.Event) {
|
|
// The caret line starts at 100, so a deletion at offset 0 is a
|
|
// wrap/scroll shift, not an edit on the caret line. A real
|
|
// first-line backspace has a line start of 0 (see above).
|
|
*event = atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x")
|
|
}, true},
|
|
{"newline", func(event *atspi.Event) { event.Payload = "\n"; event.Detail1 = 100 }, false},
|
|
{"current first line", func(event *atspi.Event) { event.Source.BrailleLineStart = 0 }, false},
|
|
{"range reaches current line", func(event *atspi.Event) { event.Source.BrailleLineStart = 5 }, false},
|
|
{"unknown caret", func(event *atspi.Event) { event.Source.TextCaretKnown = false }, false},
|
|
{"partial line", func(event *atspi.Event) {
|
|
// VTE wrapped rows have no trailing newline, so a scrollback
|
|
// deletion entirely before the caret line is still a scroll,
|
|
// never an edit on the caret line.
|
|
*event = atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old prompt")
|
|
}, true},
|
|
{"editor", func(event *atspi.Event) { event.Source.Role = "text" }, false},
|
|
{"insertion", func(event *atspi.Event) { event.Type = atspi.TextInsertedEvent }, false},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
event := base
|
|
test.modify(&event)
|
|
if got := should_ignore_terminal_scrollback(event); got != test.ignore {
|
|
t.Fatalf("ignore = %v, want %v", got, test.ignore)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProcessEventsCoalescesTerminalDeletionWithReplacementInsertion(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)
|
|
}()
|
|
|
|
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
|
output := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Command output")
|
|
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "old prompt")
|
|
deletion.Detail1 = 12
|
|
prompt := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "new prompt")
|
|
prompt.Detail1 = 12
|
|
adapter.events <- output
|
|
adapter.events <- deletion
|
|
adapter.events <- prompt
|
|
|
|
for _, expected := range []string{"Command output", "new prompt"} {
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != expected {
|
|
t.Fatalf("spoken text = %q, want %q", text, expected)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("timed out waiting for terminal speech %q", expected)
|
|
}
|
|
}
|
|
select {
|
|
case text := <-speech.spoken:
|
|
t.Fatalf("terminal redraw deletion was presented as %q", text)
|
|
case <-time.After(2 * terminalMutationDebounce):
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
func TestProcessEventsCoalescesTerminalRepaintAcrossScrollShift(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)
|
|
}()
|
|
|
|
// A TUI repaints its live region: it deletes the old region, a
|
|
// one-character scroll shift at the start of the buffer moves every
|
|
// later offset back by one, then it inserts the new region. Only the
|
|
// insertion may speak.
|
|
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
|
terminal.TextCaretKnown = true
|
|
terminal.BrailleLineStart = 190
|
|
terminal.BrailleCaret = 195
|
|
repaint := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, strings.Repeat("x", 50))
|
|
repaint.Detail1 = 100
|
|
shift := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, " ")
|
|
replacement := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "new region text")
|
|
replacement.Detail1 = 99
|
|
adapter.events <- repaint
|
|
adapter.events <- shift
|
|
adapter.events <- replacement
|
|
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != "new region text" {
|
|
t.Fatalf("spoken text = %q, want only the replacement insertion", text)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for replacement insertion")
|
|
}
|
|
select {
|
|
case text := <-speech.spoken:
|
|
t.Fatalf("terminal repaint deletion was presented as %q", text)
|
|
case <-time.After(2 * terminalMutationDebounce):
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
func repaintSequence(adapter *fakeAdapter, terminal atspi.Snapshot, detail1 int, texts ...string) {
|
|
for _, text := range texts {
|
|
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, strings.Repeat("x", len(text)))
|
|
deletion.Detail1 = detail1
|
|
insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, text)
|
|
insertion.Detail1 = detail1
|
|
adapter.events <- deletion
|
|
adapter.events <- insertion
|
|
}
|
|
}
|
|
|
|
func TestProcessEventsLatestTerminalRepaintWins(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
|
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)
|
|
}()
|
|
|
|
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
|
terminal.TextCaretKnown = true
|
|
terminal.BrailleLineStart = 200
|
|
terminal.BrailleCaret = 205
|
|
repaintSequence(adapter, terminal, 100, "Thinking (1s)", "Thinking (2s)", "Thinking (3s)")
|
|
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != "Thinking (3s)" {
|
|
t.Fatalf("spoken text = %q, want only the latest repaint", text)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("latest repaint was not presented")
|
|
}
|
|
select {
|
|
case text := <-speech.spoken:
|
|
t.Fatalf("superseded repaint was presented as %q", text)
|
|
case <-time.After(terminalRepaintDebounce):
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
func TestProcessEventsTerminalRepaintsInDifferentRegionsBothSpeak(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
|
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)
|
|
}()
|
|
|
|
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
|
terminal.TextCaretKnown = true
|
|
terminal.BrailleLineStart = 400
|
|
terminal.BrailleCaret = 405
|
|
repaintSequence(adapter, terminal, 100, "First region")
|
|
repaintSequence(adapter, terminal, 300, "Second region")
|
|
|
|
for _, expected := range []string{"First region", "Second region"} {
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != expected {
|
|
t.Fatalf("spoken text = %q, want %q", text, expected)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatalf("timed out waiting for %q", expected)
|
|
}
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
func TestProcessEventsPreservesShortTerminalRepaints(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
|
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)
|
|
}()
|
|
|
|
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
|
terminal.TextCaretKnown = true
|
|
terminal.BrailleLineStart = 200
|
|
terminal.BrailleCaret = 205
|
|
for _, expected := range []string{"40", "OK", "猫"} {
|
|
repaintSequence(adapter, terminal, 100, expected)
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != expected {
|
|
t.Fatalf("short repaint = %q, want %q", text, expected)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatalf("short repaint %q was lost", expected)
|
|
}
|
|
}
|
|
repaintSequence(adapter, terminal, 100, "Thinking (4s)")
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != "Thinking (4s)" {
|
|
t.Fatalf("spoken text = %q, want the full repaint", text)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("full repaint was not presented")
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
func TestProcessEventsPresentsUserTerminalDeletionDespiteRedraw(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 3)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
|
|
tracker := &input.IntentTracker{}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
completed := make(chan struct{})
|
|
go func() {
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil, tracker, nil, nil)
|
|
close(completed)
|
|
}()
|
|
|
|
// Backspace in a terminal input redraws the erased cell: the deleted
|
|
// character is replaced by a blank at the same offset. The deletion is
|
|
// the user's confirmation that the edit took and must still speak.
|
|
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
|
terminal.TextCaretKnown = true
|
|
terminal.BrailleLineStart = 100
|
|
terminal.BrailleCaret = 105
|
|
tracker.Observe(input.NewKeyEvent(0, true, "BackSpace"))
|
|
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x")
|
|
deletion.Detail1 = 105
|
|
redraw := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, " ")
|
|
redraw.Detail1 = 105
|
|
adapter.events <- deletion
|
|
adapter.events <- redraw
|
|
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != "x" {
|
|
t.Fatalf("spoken text = %q, want the deleted character", text)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("user deletion was not presented")
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
func TestUserTerminalDeletionClassification(t *testing.T) {
|
|
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
|
terminal.TextCaretKnown = true
|
|
terminal.BrailleLineStart = 100
|
|
terminal.BrailleCaret = 105
|
|
for _, test := range []struct {
|
|
name string
|
|
intent input.Intent
|
|
modify func(*atspi.Event)
|
|
want bool
|
|
}{
|
|
{"backspace at caret", input.DeleteIntent, func(event *atspi.Event) { event.Detail1 = 105 }, true},
|
|
{"backspace before caret", input.DeleteIntent, func(event *atspi.Event) { event.Detail1 = 104 }, true},
|
|
{"typing intent", input.TypingIntent, func(event *atspi.Event) { event.Detail1 = 105 }, false},
|
|
{"no intent", input.NoIntent, func(event *atspi.Event) { event.Detail1 = 105 }, false},
|
|
{"repaint above caret line", input.DeleteIntent, func(event *atspi.Event) { event.Detail1 = 40 }, false},
|
|
{"unknown caret", input.DeleteIntent, func(event *atspi.Event) {
|
|
event.Detail1 = 105
|
|
event.Source.TextCaretKnown = false
|
|
}, false},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
event := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x")
|
|
test.modify(&event)
|
|
if got := is_user_terminal_deletion(event, test.intent); got != test.want {
|
|
t.Fatalf("is_user_terminal_deletion = %v, want %v", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProcessEventsPresentsStandaloneTerminalDeletion(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 1)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 1)}
|
|
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)
|
|
}()
|
|
|
|
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x")
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != "x" {
|
|
t.Fatalf("spoken deletion = %q, want %q", text, "x")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("standalone terminal deletion was not presented")
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
func TestProcessEventsDoesNotCoalesceTerminalChangesAtDifferentOffsets(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 2)}
|
|
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, 0)
|
|
close(completed)
|
|
}()
|
|
|
|
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
|
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x")
|
|
deletion.Detail1 = 12
|
|
insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "y")
|
|
insertion.Detail1 = 13
|
|
adapter.events <- deletion
|
|
adapter.events <- insertion
|
|
|
|
for _, expected := range []string{"x", "y"} {
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != expected {
|
|
t.Fatalf("spoken text = %q, want %q", text, expected)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("timed out waiting for terminal speech %q", expected)
|
|
}
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
func TestProcessEventsCoalescesTerminalFocusWithFollowingInsertion(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 2)}
|
|
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", "terminal", "", "Old prompt")
|
|
focused := atspi.NewEvent(atspi.FocusedStateEvent, terminal)
|
|
focused.Detail1 = 1
|
|
adapter.events <- focused
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "New prompt")
|
|
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != "New prompt" {
|
|
t.Fatalf("speech = %q, want only following insertion", text)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("following terminal insertion was not presented")
|
|
}
|
|
select {
|
|
case text := <-speech.spoken:
|
|
t.Fatalf("redundant terminal focus was presented as %q", text)
|
|
default:
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
func TestProcessEventsPresentsTerminalFocusWithoutFollowingInsertion(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 1)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 1)}
|
|
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)
|
|
}()
|
|
|
|
terminal := atspi.NewSnapshot("/accessible/terminal", "Terminal", "terminal", "", "Prompt")
|
|
terminal.ApplicationName = "TestTerminal"
|
|
focused := atspi.NewEvent(atspi.FocusedStateEvent, terminal)
|
|
focused.Detail1 = 1
|
|
adapter.events <- focused
|
|
select {
|
|
case text := <-speech.spoken:
|
|
if text != "Terminal, terminal, Prompt" {
|
|
t.Fatalf("terminal focus speech = %q", text)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("terminal focus was not presented after debounce")
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
// Run the real event handler synchronously so coalescing assertions do not
|
|
// depend on a scheduler processing an entire burst before a timer fires.
|
|
func terminalHandlerFixture(t *testing.T) (*accessibilityEventHandler, *[]string) {
|
|
t.Helper()
|
|
state := &eventLoopState{}
|
|
t.Cleanup(state.pending.clear)
|
|
spoken := []string{}
|
|
handler := &accessibilityEventHandler{
|
|
state: state, logger: slog.New(slog.DiscardHandler), adapter: &fakeAdapter{},
|
|
syncForeground: func() {}, webScripts: &webScriptController{},
|
|
speak: func(event atspi.Event, _ ...input.Intent) { spoken = append(spoken, event.Payload) },
|
|
}
|
|
handler.flushTerminalDeletion = func() {
|
|
event, _ := state.pending.terminalDeletion.take()
|
|
handler.speak(event)
|
|
}
|
|
return handler, &spoken
|
|
}
|
|
|
|
func TestTerminalRepaintsKeepTerminalIdentityAndFirstDeadline(t *testing.T) {
|
|
for _, differentTerminal := range []bool{false, true} {
|
|
t.Run(map[bool]string{false: "same terminal", true: "different bus"}[differentTerminal], func(t *testing.T) {
|
|
handler, spoken := terminalHandlerFixture(t)
|
|
terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"}
|
|
repaint := func(text string) {
|
|
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old status")
|
|
insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, text)
|
|
handler.handle(deletion)
|
|
handler.handle(insertion)
|
|
}
|
|
repaint("First status")
|
|
firstTimer := handler.state.pending.terminalRepaint.timer
|
|
if differentTerminal {
|
|
terminal.BusName = ":1.11"
|
|
}
|
|
repaint("Second status")
|
|
pending := &handler.state.pending.terminalRepaint
|
|
if differentTerminal {
|
|
if len(*spoken) != 1 || (*spoken)[0] != "First status" {
|
|
t.Fatalf("different terminal discarded earlier repaint: %v", *spoken)
|
|
}
|
|
} else if pending.timer != firstTimer {
|
|
t.Fatal("continuous repaint postponed the first deadline")
|
|
}
|
|
event, _ := pending.take()
|
|
if event.Payload != "Second status" {
|
|
t.Fatalf("latest repaint = %q", event.Payload)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTerminalSeparateDeletionsPreserveEarlierFeedback(t *testing.T) {
|
|
handler, spoken := terminalHandlerFixture(t)
|
|
terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"}
|
|
first := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "First deletion")
|
|
first.Detail1 = 100
|
|
second := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Second deletion")
|
|
second.Detail1 = 300
|
|
handler.handle(first)
|
|
handler.handle(second)
|
|
if len(*spoken) != 1 || (*spoken)[0] != first.Payload {
|
|
t.Fatalf("unrelated deletion discarded earlier feedback: %v", *spoken)
|
|
}
|
|
if handler.state.pending.terminalDeletion.event.Payload != second.Payload {
|
|
t.Fatal("later deletion was lost")
|
|
}
|
|
}
|
|
|
|
func TestTerminalHeldRepaintPreservesOrderAndRetiresOnFocusChange(t *testing.T) {
|
|
for _, next := range []string{"append", "deletion", "focus"} {
|
|
t.Run(next, func(t *testing.T) {
|
|
handler, spoken := terminalHandlerFixture(t)
|
|
terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"}
|
|
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old status")
|
|
insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Latest status")
|
|
handler.handle(deletion)
|
|
handler.handle(insertion)
|
|
event := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Appended output")
|
|
event.Detail1 = 300
|
|
switch next {
|
|
case "deletion":
|
|
event.Type, event.Payload = atspi.TextDeletedEvent, "Later deletion"
|
|
case "focus":
|
|
event = atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.10", Path: "/entry", Role: "text", Focused: true})
|
|
}
|
|
handler.handle(event)
|
|
if handler.state.pending.terminalRepaint.event.Type != "" {
|
|
t.Fatal("old repaint remains pending")
|
|
}
|
|
if next == "focus" {
|
|
for _, text := range *spoken {
|
|
if text == "Latest status" {
|
|
t.Fatal("old terminal spoke after focus moved to another control")
|
|
}
|
|
}
|
|
} else if len(*spoken) == 0 || (*spoken)[0] != "Latest status" {
|
|
t.Fatalf("later output overtook pending repaint: %v", *spoken)
|
|
}
|
|
})
|
|
}
|
|
}
|