1050 lines
42 KiB
Go
1050 lines
42 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/presentation"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
)
|
|
|
|
type autocompleteRequestQueue struct{ requests chan speechRequest }
|
|
|
|
func TestBrowserNewTabsKeepAddressFocusAndSuppressURLRewrites(t *testing.T) {
|
|
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 32)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 32)}
|
|
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) speechRequest {
|
|
t.Helper()
|
|
select {
|
|
case request := <-queue.requests:
|
|
if request.Text != want {
|
|
t.Fatalf("speech = %q, want %q", request.Text, want)
|
|
}
|
|
return request
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("missing %q", want)
|
|
return speechRequest{}
|
|
}
|
|
}
|
|
page := ownerEvent(atspi.FocusEvent, ":1.7", "/window", "/page")
|
|
page.Source.Role, page.Source.Name = "document web", "Original page"
|
|
adapter.events <- page
|
|
expect("Original page, document web")
|
|
tracker.Observe(input.NewKeyEvent(0, true, "Control_L"))
|
|
tracker.Observe(input.NewKeyEvent(0, true, "t"))
|
|
tab := ownerEvent(atspi.SelectedStateEvent, ":1.7", "/window", "/tab1")
|
|
tab.Source.Role, tab.Source.Name, tab.Source.BrowserTab = "page tab", "New Tab", true
|
|
tab.Detail1 = 1
|
|
adapter.events <- tab
|
|
expect("New Tab, page tab")
|
|
entry := ownerEvent(atspi.FocusEvent, ":1.7", "/window", "/address")
|
|
entry.Source.Role, entry.Source.Name = "entry", "Address"
|
|
entry.Source.Editable, entry.Source.Autocomplete, entry.Source.Focused = true, true, true
|
|
adapter.events <- entry
|
|
if request := expect("Address, autocomplete"); !request.Append {
|
|
t.Fatal("address focus would interrupt new-tab announcement")
|
|
}
|
|
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry.Source, "previous.example")
|
|
adapter.events <- deletion
|
|
tracker.Observe(input.NewKeyEvent(0, true, "t"))
|
|
tab.Source.Path = "/tab2"
|
|
adapter.events <- tab
|
|
expect("New Tab, page tab") // No address focus event for the second Ctrl+T.
|
|
adapter.events <- tab // Duplicate selected-state event.
|
|
selection := tab
|
|
selection.Type, selection.OriginPath = atspi.SelectionChangedEvent, "/tabs"
|
|
adapter.events <- selection
|
|
selection.Type, selection.Detail1 = atspi.SelectedStateEvent, 0
|
|
adapter.events <- selection
|
|
background := tab
|
|
background.Window.Path, background.Source.Path = "/background", "/backgroundTab"
|
|
adapter.events <- background
|
|
tracker.Observe(input.NewKeyEvent(0, false, "Control_L"))
|
|
tracker.Observe(input.NewKeyEvent(0, true, "a"))
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry.Source, "a")
|
|
expect("a") // The selected tab did not steal the owned address focus.
|
|
tracker.Observe(input.NewKeyEvent(0, true, "BackSpace"))
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry.Source, "a")
|
|
expect("a")
|
|
tracker.Observe(input.NewKeyEvent(0, true, "Control_L"))
|
|
tracker.Observe(input.NewKeyEvent(0, true, "x"))
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry.Source, "selected.example")
|
|
expect("selected.example")
|
|
tracker.Observe(input.NewKeyEvent(0, false, "Control_L"))
|
|
tab.Source.Path, tab.Source.Name = "/tab1", "First tab"
|
|
adapter.events <- tab
|
|
expect("First tab, page tab")
|
|
// Firefox may report only a tab-list selection, followed by a delayed
|
|
// selected-state gain from the previous tab. Fresh state rejects that gain.
|
|
selection = tab
|
|
selection.Type, selection.Source.Path, selection.Source.Name = atspi.SelectionChangedEvent, "/tab3", "Third tab"
|
|
selection.Source.StatesKnown, selection.Source.Selected = true, true
|
|
selection.Source.ToolkitName = "Gecko"
|
|
adapter.events <- selection
|
|
expect("Third tab, page tab")
|
|
adapter.events <- selection
|
|
tab.Source.StatesKnown, tab.Source.Selected = true, false
|
|
adapter.events <- tab
|
|
marker := entry
|
|
marker.Source.Path, marker.Source.Name = "/marker", "End"
|
|
marker.Source.Role, marker.Source.Autocomplete = "button", false
|
|
adapter.events <- marker
|
|
expect("End, button") // Marker also catches unexpected queued announcements.
|
|
}
|
|
|
|
func (queue *autocompleteRequestQueue) Enqueue(request speechRequest) { queue.requests <- request }
|
|
func (queue *autocompleteRequestQueue) Interrupt() {}
|
|
|
|
func TestAutocompleteAnnouncesSelectedAddressWhenItChanges(t *testing.T) {
|
|
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 16)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
|
|
tracker := &input.IntentTracker{}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
options := presentation.DefaultOptions()
|
|
options.EnableSound = true
|
|
options.PlaySoundForRole = true
|
|
go func() {
|
|
defer close(done)
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil, options)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
|
|
entry := atspi.Snapshot{BusName: ":1.7", Path: "/address", Role: "entry", Autocomplete: true, Editable: true, Focused: true}
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
|
|
select {
|
|
case request := <-queue.requests:
|
|
if request.Text != "autocomplete" || len(request.Sound) != 0 {
|
|
t.Fatalf("focus request = %#v", request)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("missing address-bar focus")
|
|
}
|
|
tracker.Observe(input.NewKeyEvent(0, true, "s"))
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "s")
|
|
select {
|
|
case request := <-queue.requests:
|
|
if len(request.Sound) != 0 {
|
|
t.Fatalf("ordinary typing sounded like a completion: %#v", request)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("missing typing presentation")
|
|
}
|
|
entry.Text = "https://stormux.org"
|
|
entry.SelectionStart, entry.SelectionEnd = 1, len([]rune(entry.Text))
|
|
inserted := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "suggestion")
|
|
inserted.Detail1 = 1
|
|
adapter.events <- inserted
|
|
select {
|
|
case request := <-queue.requests:
|
|
if request.Text != entry.Text || len(request.Sound) != 1 || request.Sound[0].Name != "autocomplete" {
|
|
t.Fatalf("completion request = %#v", request)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("missing autocomplete sound")
|
|
}
|
|
popup := atspi.NewEvent(atspi.SelectionChangedEvent, atspi.Snapshot{BusName: ":1.7", Path: "/suggestion", Role: "list item", Name: "Other result", AutocompletePopup: true})
|
|
popup.PopupFor = entry.ID()
|
|
adapter.events <- popup
|
|
adapter.events <- inserted
|
|
select {
|
|
case request := <-queue.requests:
|
|
if request.Text == entry.Text || len(request.Sound) != 0 {
|
|
t.Fatalf("duplicate selected address announcement = %#v", request)
|
|
}
|
|
case <-time.After(40 * time.Millisecond):
|
|
}
|
|
tracker.Observe(input.NewKeyEvent(0, true, "t"))
|
|
entry.Text = "https://stormux.org/docs"
|
|
entry.SelectionStart, entry.SelectionEnd = 2, len([]rune(entry.Text))
|
|
updated := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "updated suggestion")
|
|
updated.Detail1 = 2
|
|
adapter.events <- updated
|
|
select {
|
|
case request := <-queue.requests:
|
|
if request.Text != entry.Text || len(request.Sound) != 1 || request.Sound[0].Name != "autocomplete" {
|
|
t.Fatalf("updated address request = %#v", request)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("missing updated address announcement")
|
|
}
|
|
}
|
|
|
|
func TestAutocompleteAnnouncementRequiresSelectedAddress(t *testing.T) {
|
|
entry := atspi.Snapshot{BusName: ":1.7", Path: "/address", Role: "entry", Autocomplete: true}
|
|
var availability autocompleteAvailability
|
|
availability.focus(atspi.NewEvent(atspi.FocusEvent, entry))
|
|
edit := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "h")
|
|
activity := input.Activity{Intent: input.TypingIntent, Sequence: 1}
|
|
if got := availability.offered(edit, activity); got != "" {
|
|
t.Fatalf("unselected text sounded as completion: %q", got)
|
|
}
|
|
entry.Text = "https://stormux.org"
|
|
entry.SelectionStart, entry.SelectionEnd = 1, len([]rune(entry.Text))
|
|
edit.Source = entry
|
|
if got := availability.offered(edit, input.Activity{}); got != "" {
|
|
t.Fatalf("completion without user edit sounded: %q", got)
|
|
}
|
|
if got := availability.offered(edit, activity); got != entry.Text {
|
|
t.Fatalf("selected address = %q, want %q", got, entry.Text)
|
|
}
|
|
if got := availability.offered(edit, input.Activity{Intent: input.TypingIntent, Sequence: 2}); got != "" {
|
|
t.Fatalf("unchanged address sounded again: %q", got)
|
|
}
|
|
other := edit
|
|
other.Source.Path = "/other"
|
|
other.Source.Text = "https://different.example"
|
|
if got := availability.offered(other, activity); got != "" {
|
|
t.Fatalf("other entry sounded as owned address: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestFirstBrowserLoadKeepsActivationSpeech(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 8), interrupt: make(chan struct{}, 8)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
|
browser := newBrowserModeController(nil, &modeKeys{}, nil)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
|
|
activation := ownerEvent(atspi.WindowActivatedEvent, ":1.42", "/window", "/document")
|
|
activation.Source.Role, activation.Source.Name = "document web", "First page"
|
|
adapter.events <- activation
|
|
expectActivationSpeech(t, queue.spoken, "First page, document web")
|
|
load := ownerEvent(atspi.DocumentLoadCompleteEvent, ":1.42", "/window", "/document")
|
|
load.Source.Role, load.Source.Name = "document web", "First page"
|
|
adapter.events <- load
|
|
expectActivationSpeech(t, queue.spoken, "Finished loading First page.")
|
|
button := ownerEvent(atspi.FocusedStateEvent, ":1.42", "/window", "/button")
|
|
button.Source.Role, button.Source.Name = "button", "Next"
|
|
button.Detail1 = 1
|
|
adapter.events <- button
|
|
expectActivationSpeech(t, queue.spoken, "Next, button")
|
|
select {
|
|
case <-queue.interrupt:
|
|
t.Fatal("first load interrupted its own activation speech")
|
|
default:
|
|
}
|
|
load.Source.Path = "/second-document"
|
|
adapter.events <- load
|
|
adapter.events <- button
|
|
expectActivationSpeech(t, queue.spoken, "Finished loading First page.")
|
|
expectActivationSpeech(t, queue.spoken, "Next, button")
|
|
select {
|
|
case <-queue.interrupt:
|
|
t.Fatal("document completion interrupted earlier speech")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestBrowserTypingAndNavigation(t *testing.T) {
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 32)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 32)}
|
|
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.spoken:
|
|
if got != want {
|
|
t.Fatalf("speech %q, want %q", got, want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("missing %q", want)
|
|
}
|
|
}
|
|
quiet := func() {
|
|
t.Helper()
|
|
select {
|
|
case got := <-queue.spoken:
|
|
t.Fatalf("unexpected %q", got)
|
|
case <-time.After(30 * time.Millisecond):
|
|
}
|
|
}
|
|
key := func(name string) { tracker.Observe(input.NewKeyEvent(0, true, name)) }
|
|
entry := atspi.Snapshot{BusName: ":1.7", Path: "/entry", Role: "entry", Editable: true, Autocomplete: true, Focused: true}
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
|
|
expect("autocomplete")
|
|
key("s")
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry, "previous.example")
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "s")
|
|
expect("s")
|
|
// Suggestions can clear the native focused bit without changing the owned entry.
|
|
entry.Focused = false
|
|
key("t")
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "t")
|
|
expect("t")
|
|
// Chromium inserts the selected completion and emits several popup events.
|
|
entry.SelectionStart, entry.SelectionEnd = 1, 11
|
|
completion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "tormux.org")
|
|
completion.Detail1 = 1
|
|
adapter.events <- completion
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry)
|
|
popup := atspi.Snapshot{BusName: ":1.7", Path: "/suggestion", Role: "list item", Name: "Suggested site", Selected: true, AutocompletePopup: true}
|
|
popupFocus := atspi.NewEvent(atspi.FocusedStateEvent, popup)
|
|
popupFocus.Detail1 = 1
|
|
adapter.events <- popupFocus
|
|
adapter.events <- atspi.NewEvent("object:state-changed:selected", popup)
|
|
adapter.events <- atspi.NewEvent(atspi.SelectionChangedEvent, popup)
|
|
quiet()
|
|
// Explicit suggestion navigation still speaks the chosen item, once.
|
|
key("Down")
|
|
adapter.events <- popupFocus
|
|
expect("Suggested site, list item, selected")
|
|
adapter.events <- atspi.NewEvent("object:state-changed:selected", popup)
|
|
adapter.events <- atspi.NewEvent(atspi.SelectionChangedEvent, popup)
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "suggested.example")
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry)
|
|
quiet()
|
|
page := atspi.Snapshot{BusName: ":1.7", Path: "/paragraph", Role: "paragraph", Text: "Plain document text", CaretWord: "document", CaretCharacter: "d"}
|
|
key("Down")
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, page)
|
|
expect("Plain document text")
|
|
blank := page
|
|
blank.Text, blank.CaretCharacter = "", "\n"
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, blank)
|
|
expect("blank")
|
|
key("Control_L")
|
|
key("Right")
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, page)
|
|
expect("document")
|
|
tracker.Observe(input.NewKeyEvent(0, false, "Control_L"))
|
|
key("Right")
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, page)
|
|
expect("d")
|
|
}
|
|
|
|
func TestAutocompleteFocusCaretDoesNotInterruptAnnouncement(t *testing.T) {
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
|
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 }()
|
|
entry := atspi.Snapshot{
|
|
BusName: ":1.7", Path: "/address", Name: "Address and search bar", Role: "entry",
|
|
Editable: true, Autocomplete: true, Focused: true, Text: "https://example.test",
|
|
SelectionEnd: len("https://example.test"), CaretCharacter: "h",
|
|
}
|
|
tracker.Observe(input.NewKeyEvent(0, true, "Control_L"))
|
|
tracker.Observe(input.NewKeyEvent(0, true, "l"))
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry)
|
|
select {
|
|
case got := <-queue.spoken:
|
|
if want := "Address and search bar, autocomplete, https://example.test, selected"; got != want {
|
|
t.Fatalf("focus speech = %q, want %q", got, want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("missing address-bar focus speech")
|
|
}
|
|
select {
|
|
case got := <-queue.spoken:
|
|
t.Fatalf("focus caret interrupted announcement with %q", got)
|
|
case <-time.After(40 * time.Millisecond):
|
|
}
|
|
tracker.Observe(input.NewKeyEvent(0, false, "Control_L"))
|
|
tracker.Observe(input.NewKeyEvent(0, true, "Right"))
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry)
|
|
select {
|
|
case got := <-queue.spoken:
|
|
if got != "h" {
|
|
t.Fatalf("arrow caret speech = %q, want h", got)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("missing arrow caret speech")
|
|
}
|
|
}
|
|
|
|
func TestFocusedStateLossIsIgnored(t *testing.T) {
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
|
|
keys := &modeKeys{enabled: true}
|
|
browser := newBrowserModeController(nil, keys, nil)
|
|
browser.ready = true
|
|
browser.mode = review.BrowseMode
|
|
browser.document = atspi.NewObjectID(":1.7", "/document")
|
|
|
|
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, browser, nil, nil)
|
|
}()
|
|
lost := atspi.Snapshot{BusName: ":1.7", Path: "/button", Name: "Old", Role: "button", Focusable: true}
|
|
event := atspi.NewEvent(atspi.FocusedStateEvent, lost)
|
|
event.Detail1 = 0
|
|
adapter.events <- event
|
|
time.Sleep(30 * time.Millisecond)
|
|
if !browser.ready || browser.mode != review.BrowseMode || !keys.enabled {
|
|
t.Fatal("focus loss changed browser mode or browse grabs")
|
|
}
|
|
select {
|
|
case got := <-queue.spoken:
|
|
t.Fatalf("focus loss spoke %q", got)
|
|
default:
|
|
}
|
|
cancel()
|
|
<-done
|
|
}
|
|
|
|
func TestNewDocumentFocusIsNotRetainedOldCaret(t *testing.T) {
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
|
|
browser := newBrowserModeController(nil, &modeKeys{}, nil)
|
|
browser.ready, browser.mode = true, review.BrowseMode
|
|
browser.document = atspi.NewObjectID(":1.7", "/old_document")
|
|
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, browser, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
newDocument := atspi.Snapshot{BusName: ":1.7", Path: "/new_document", Role: "document web", Name: "New topic"}
|
|
focus := atspi.NewEvent(atspi.FocusedStateEvent, newDocument)
|
|
focus.Detail1 = 1
|
|
adapter.events <- focus
|
|
select {
|
|
case spoken := <-queue.spoken:
|
|
if spoken != "New topic, document web" {
|
|
t.Fatalf("new document focus said %q", spoken)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("new document focus was suppressed")
|
|
}
|
|
if browser.focus != newDocument.ID() {
|
|
t.Fatalf("browser retained old focus: %v", browser.focus)
|
|
}
|
|
}
|
|
|
|
func TestDelayedDocumentCaretCannotOverrideEditableFocus(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
|
where := make(chan input.Command)
|
|
browser := newBrowserModeController(nil, &modeKeys{}, nil)
|
|
browser.ready, browser.mode = true, review.BrowseMode
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
entry := atspi.Snapshot{BusName: ":1.42", Path: "/address", Role: "entry", Editable: true, Name: "Address"}
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
|
|
select {
|
|
case <-queue.spoken:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("missing entry focus")
|
|
}
|
|
oldHeading := atspi.Snapshot{BusName: ":1.42", Path: "/old-heading", Role: "heading", Text: "Old page"}
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, oldHeading)
|
|
// The event loop must finish processing the caret before it can receive
|
|
// this command. Join before inspecting controller state.
|
|
where <- input.WhereAmI
|
|
cancel()
|
|
<-done
|
|
if browser.focus != entry.ID() || browser.generation != 1 {
|
|
t.Fatalf("old document caret replaced editable focus: %v generation %d", browser.focus, browser.generation)
|
|
}
|
|
}
|
|
|
|
func TestBrowseSelectionCaretUpdatesReviewFocus(t *testing.T) {
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 2)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 1)}
|
|
browser := newBrowserModeController(nil, &modeKeys{}, nil)
|
|
browser.ready = true
|
|
browser.mode = review.BrowseMode
|
|
browser.document = atspi.NewObjectID(":1.42", "/document")
|
|
browser.focus = browser.document
|
|
|
|
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, browser, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
|
|
selection := atspi.Snapshot{
|
|
BusName: ":1.42", Path: "/selection", Role: "paragraph",
|
|
Text: "Selected", CaretCharacter: "h",
|
|
SelectionStart: 0, SelectionEnd: 1,
|
|
}
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, selection)
|
|
|
|
select {
|
|
case got := <-queue.spoken:
|
|
if got != "h" {
|
|
t.Fatalf("selected caret speech = %q, want h", got)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("selected caret did not speak")
|
|
}
|
|
if browser.focus != selection.ID() {
|
|
t.Fatalf("selection caret did not update browse focus: %v", browser.focus)
|
|
}
|
|
if browser.generation != 1 {
|
|
t.Fatalf("selection caret generation = %d, want 1", browser.generation)
|
|
}
|
|
}
|
|
|
|
func TestBackgroundDocumentLoadPreservesChromeFocus(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
|
where := make(chan input.Command)
|
|
keys := &modeKeys{}
|
|
browser := newBrowserModeController(nil, keys, nil)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
expect := func(want string) {
|
|
t.Helper()
|
|
select {
|
|
case got := <-queue.spoken:
|
|
if got != want {
|
|
t.Fatalf("speech = %q, want %q", got, want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("missing %q", want)
|
|
}
|
|
}
|
|
entry := atspi.Snapshot{BusName: ":1.42", Path: "/address", Role: "entry", Editable: true, Autocomplete: true, Name: "Address"}
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
|
|
expect("Address, autocomplete")
|
|
document := atspi.Snapshot{BusName: ":1.42", Path: "/document", Role: "document web", Name: "Loaded page"}
|
|
load := atspi.NewEvent(atspi.DocumentLoadCompleteEvent, document)
|
|
load.FocusOutsideDocument = entry.ID()
|
|
adapter.events <- load
|
|
where <- input.WhereAmI
|
|
expect("Address, autocomplete")
|
|
// A subsequent load with document focus must still announce and acquire
|
|
// the new document, even if the previous chrome snapshot was editable.
|
|
adapter.events <- atspi.NewEvent(atspi.DocumentLoadCompleteEvent, document)
|
|
expect("Finished loading Loaded page.")
|
|
cancel()
|
|
<-done
|
|
if browser.focus != document.ID() || browser.ready || keys.enabled {
|
|
t.Fatalf("load did not schedule new document resolution: %+v", browser.focus)
|
|
}
|
|
}
|
|
|
|
func TestDocumentCompletionPreservesBackendSpeech(t *testing.T) {
|
|
for _, eventType := range []string{atspi.DocumentLoadCompleteEvent, atspi.BusyStateEvent} {
|
|
t.Run(eventType, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
|
browser := newBrowserModeController(nil, &modeKeys{}, nil)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
activation := ownerEvent(atspi.WindowActivatedEvent, ":1.42", "/window", "/document")
|
|
activation.Source.Role, activation.Source.Name = "document web", "First page"
|
|
adapter.events <- activation
|
|
expectActivationSpeech(t, queue.spoken, "First page, document web")
|
|
<-queue.requests
|
|
load := activation
|
|
load.Type = eventType
|
|
load.Detail1 = 0
|
|
adapter.events <- load
|
|
expectActivationSpeech(t, queue.spoken, "Finished loading First page.")
|
|
request := <-queue.requests
|
|
// Exercise backend cancellation: checking queue.Interrupt alone
|
|
// misses the implicit cancellation of non-appended requests.
|
|
speaker := &fakeSpeaker{spoken: make(chan string, 1)}
|
|
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
|
if err := worker.deliver(ctx, request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if speaker.canceled {
|
|
t.Fatal("load completion canceled earlier speech at the backend")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocumentLifecycleRecoversBrowseFocusWithoutLoadComplete(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
start string
|
|
end string
|
|
}{
|
|
{"reload stopped", atspi.DocumentReloadEvent, atspi.DocumentLoadStoppedEvent},
|
|
{"busy completed", atspi.BusyStateEvent, atspi.BusyStateEvent},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
|
where := make(chan input.Command)
|
|
keys := &modeKeys{}
|
|
browser := newBrowserModeController(nil, keys, nil)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
focus := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/document")
|
|
focus.Source.Role, focus.Source.Name = "document web", "Example page"
|
|
adapter.events <- focus
|
|
expectActivationSpeech(t, queue.spoken, "Example page, document web")
|
|
start := ownerEvent(tc.start, ":1.42", "/window", "/document")
|
|
start.Source.Role = "document web"
|
|
start.Detail1 = 1
|
|
adapter.events <- start
|
|
if tc.start == atspi.BusyStateEvent {
|
|
expectActivationSpeech(t, queue.spoken, "Loading. Please wait.")
|
|
}
|
|
where <- input.WhereAmI
|
|
expectActivationSpeech(t, queue.spoken, "Example page, document web")
|
|
if browser.focus.Valid() || browser.ready || keys.enabled {
|
|
t.Fatal("loading retained browse focus")
|
|
}
|
|
end := ownerEvent(tc.end, ":1.42", "/window", "/new_document")
|
|
end.Source.Role, end.Source.Name = "document web", "New page"
|
|
adapter.events <- end
|
|
if tc.end == atspi.BusyStateEvent {
|
|
expectActivationSpeech(t, queue.spoken, "Finished loading New page.")
|
|
}
|
|
where <- input.WhereAmI
|
|
expectActivationSpeech(t, queue.spoken, "New page, document web")
|
|
if browser.focus != end.Source.ID() || !browser.resolving {
|
|
t.Fatalf("load completion did not schedule document recovery: %+v", browser.focus)
|
|
}
|
|
// Retiring the old page must not clear the replacement's ownership.
|
|
dead := focus
|
|
dead.Type, dead.Detail1 = atspi.DefunctStateEvent, 1
|
|
adapter.events <- dead
|
|
where <- input.WhereAmI
|
|
expectActivationSpeech(t, queue.spoken, "New page, document web")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocumentLifecycleKeepsChromeFocus(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
|
where := make(chan input.Command)
|
|
browser := newBrowserModeController(nil, &modeKeys{}, nil)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil)
|
|
}()
|
|
entry := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/address")
|
|
entry.Source.Role, entry.Source.Name = "entry", "Address"
|
|
adapter.events <- entry
|
|
expectActivationSpeech(t, queue.spoken, "Address, entry")
|
|
for _, eventType := range []string{atspi.DocumentReloadEvent, atspi.BusyStateEvent, atspi.DocumentLoadStoppedEvent} {
|
|
event := ownerEvent(eventType, ":1.42", "/window", "/document")
|
|
event.Source.Role = "document web"
|
|
event.Detail1 = 1
|
|
event.FocusOutsideDocument = entry.Source.ID()
|
|
adapter.events <- event
|
|
}
|
|
where <- input.WhereAmI
|
|
expectActivationSpeech(t, queue.spoken, "Address, entry")
|
|
cancel()
|
|
<-done
|
|
if browser.focus != entry.Source.ID() {
|
|
t.Fatalf("document lifecycle displaced chrome focus: %v", browser.focus)
|
|
}
|
|
}
|
|
|
|
func TestDocumentBusyCompletionKeepsVerifiedMessageListFocus(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
|
where := make(chan input.Command)
|
|
keys := &modeKeys{}
|
|
browser := newBrowserModeController(nil, keys, nil)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
|
|
row := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/message_row")
|
|
row.Source.Role, row.Source.Name = "table row", "Message one"
|
|
adapter.events <- row
|
|
expectActivationSpeech(t, queue.spoken, "Message one, table row")
|
|
load := ownerEvent(atspi.DocumentLoadCompleteEvent, ":1.42", "/window", "/preview")
|
|
load.Source.Role = "document web"
|
|
load.FocusOutsideDocument = row.Source.ID()
|
|
adapter.events <- load
|
|
busy := load
|
|
busy.Type, busy.Detail1 = atspi.BusyStateEvent, 0
|
|
busy.FocusOutsideDocument = atspi.ObjectID{}
|
|
adapter.events <- busy
|
|
where <- input.WhereAmI
|
|
expectActivationSpeech(t, queue.spoken, "Message one, table row")
|
|
if browser.focus != row.Source.ID() || browser.mode != review.InactiveMode || keys.enabled {
|
|
t.Fatalf("preview busy completion took message-list arrows: focus %v, mode %v, grabs %v", browser.focus, browser.mode, keys.enabled)
|
|
}
|
|
entered := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/preview")
|
|
entered.Source.Role, entered.Source.Name = "document web", "Message preview"
|
|
adapter.events <- entered
|
|
expectActivationSpeech(t, queue.spoken, "Message preview, document web")
|
|
busy.Source.Name = entered.Source.Name
|
|
adapter.events <- busy
|
|
expectActivationSpeech(t, queue.spoken, "Finished loading Message preview.")
|
|
// Speech is queued before the controller finishes adopting the document.
|
|
// Join the event loop before inspecting its state.
|
|
cancel()
|
|
<-done
|
|
if browser.focus != entered.Source.ID() {
|
|
t.Fatalf("document focus did not enter preview: %v", browser.focus)
|
|
}
|
|
}
|
|
|
|
func TestDocumentLoadRespectsWindowAndFocusOwnership(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
bus string
|
|
window string
|
|
deactivate bool
|
|
defunctBefore bool
|
|
defunct string
|
|
accepted bool
|
|
want string
|
|
}{
|
|
{name: "other application", bus: ":1.43", window: "/window", want: "Address, entry"},
|
|
{name: "other window", bus: ":1.42", window: "/other", want: "Address, entry"},
|
|
{name: "owner ended", bus: ":1.42", window: "/window", deactivate: true, want: "Could not find current location."},
|
|
{name: "loaded document dies", bus: ":1.42", window: "/window", defunct: "/document", accepted: true, want: "Could not find current location."},
|
|
{name: "old focus dies", bus: ":1.42", window: "/window", defunct: "/address", accepted: true, want: "Loaded page, document web"},
|
|
{name: "replacement after old focus dies", bus: ":1.42", window: "/window", defunctBefore: true, accepted: true, want: "Loaded page, document web"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
|
where := make(chan input.Command)
|
|
browser := newBrowserModeController(nil, &modeKeys{}, nil)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
focus := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/address")
|
|
focus.Source.Role, focus.Source.Name, focus.Source.Editable = "entry", "Address", true
|
|
adapter.events <- focus
|
|
expectActivationSpeech(t, queue.spoken, "Address, entry")
|
|
if test.deactivate {
|
|
adapter.events <- ownerEvent(atspi.WindowDeactivatedEvent, ":1.42", "/window", "/window")
|
|
}
|
|
if test.defunctBefore {
|
|
dead := ownerEvent(atspi.DefunctStateEvent, test.bus, test.window, "/address")
|
|
dead.Detail1 = 1
|
|
adapter.events <- dead
|
|
}
|
|
load := ownerEvent(atspi.DocumentLoadCompleteEvent, test.bus, test.window, "/document")
|
|
load.Source.Role, load.Source.Name = "document web", "Loaded page"
|
|
adapter.events <- load
|
|
if test.accepted {
|
|
expectActivationSpeech(t, queue.spoken, "Finished loading Loaded page.")
|
|
}
|
|
if test.defunct != "" {
|
|
dead := ownerEvent(atspi.DefunctStateEvent, test.bus, test.window, test.defunct)
|
|
dead.Detail1 = 1
|
|
adapter.events <- dead
|
|
}
|
|
// The unbuffered command follows event processing, without a sleep.
|
|
where <- input.WhereAmI
|
|
expectActivationSpeech(t, queue.spoken, test.want)
|
|
cancel()
|
|
<-done
|
|
if test.want == "Could not find current location." && browser.focus.Valid() {
|
|
t.Fatalf("browser retained ended focus: %v", browser.focus)
|
|
}
|
|
if !test.accepted && !test.deactivate && browser.focus != focus.Source.ID() {
|
|
t.Fatalf("background load replaced browser focus: %v", browser.focus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBrowserAddressBarNameEventsAreIgnored(t *testing.T) {
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
|
|
tracker := &input.IntentTracker{}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
options := presentation.DefaultOptions()
|
|
options.EnableKeyEcho = false
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil, options)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
|
|
entry := atspi.Snapshot{
|
|
BusName: ":1.7", Path: "/address-bar", ApplicationName: "Chromium Variant",
|
|
Name: "Address and search bar", Role: "entry", Editable: true, Autocomplete: true, Focused: true,
|
|
}
|
|
focused := atspi.NewEvent(atspi.FocusedStateEvent, entry)
|
|
focused.Detail1 = 1
|
|
adapter.events <- focused
|
|
select {
|
|
case got := <-queue.spoken:
|
|
if got != "Address and search bar, autocomplete" {
|
|
t.Fatalf("focus speech %q", got)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("missing address bar focus speech")
|
|
}
|
|
|
|
siteInformation := entry
|
|
siteInformation.Path = "/site-information"
|
|
siteInformation.Name = "View site information"
|
|
siteInformation.Role = "image map"
|
|
siteInformation.Editable = false
|
|
siteInformation.Autocomplete = false
|
|
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, siteInformation)
|
|
|
|
typed := entry
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, typed, "c")
|
|
select {
|
|
case got := <-queue.spoken:
|
|
t.Fatalf("unexpected typing speech %q", got)
|
|
case <-time.After(30 * time.Millisecond):
|
|
}
|
|
|
|
suggestion := atspi.Snapshot{
|
|
BusName: ":1.7", Path: "/suggestions/first", ApplicationName: "Chromium Variant",
|
|
Name: "stormux.org", Role: "list item", Selected: true, AutocompletePopup: true,
|
|
}
|
|
selected := atspi.NewEvent("object:state-changed:selected", suggestion)
|
|
selected.Detail1 = 1
|
|
tracker.Observe(input.NewKeyEvent(0, true, "Down"))
|
|
adapter.events <- selected
|
|
select {
|
|
case got := <-queue.spoken:
|
|
if got != "stormux.org, list item, selected" {
|
|
t.Fatalf("completion speech %q", got)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("missing completion speech")
|
|
}
|
|
// A focus event for the same popup item is another route to the same
|
|
// presentation in Chromium and must not repeat it.
|
|
adapter.events <- atspi.NewEvent(atspi.FocusedStateEvent, suggestion)
|
|
|
|
suggestionName := suggestion
|
|
suggestionName.Name = "stormux.org suggestion"
|
|
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, suggestionName)
|
|
adapter.events <- selected
|
|
select {
|
|
case got := <-queue.spoken:
|
|
t.Fatalf("unexpected duplicate completion or browser UI name speech %q", got)
|
|
case <-time.After(30 * time.Millisecond):
|
|
}
|
|
|
|
select {
|
|
case got := <-queue.spoken:
|
|
t.Fatalf("unexpected browser UI name speech %q", got)
|
|
case <-time.After(30 * time.Millisecond):
|
|
}
|
|
|
|
if !isBrowserUINameNoise(atspi.NewEvent(atspi.AccessibleNameEvent, suggestionName), suggestion) {
|
|
t.Fatal("focused autocomplete popup name event was not classified as noise")
|
|
}
|
|
}
|
|
|
|
func TestBrowserNameChangesBeforeDocumentFocus(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
// A new browser can send these before its first focus event. The last
|
|
// focused application and address-bar state cannot identify their owner.
|
|
for _, role := range []string{"button", "page tab", "frame", "static", "heading"} {
|
|
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, atspi.Snapshot{
|
|
BusName: ":1.7", Path: "/changed", ToolkitName: "Chromium", Role: role, Name: "Background update",
|
|
})
|
|
}
|
|
// This focus announcement synchronizes the event loop and must be first.
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{
|
|
BusName: ":1.7", Path: "/document", Role: "document web", Name: "Example page",
|
|
})
|
|
select {
|
|
case got := <-queue.spoken:
|
|
if got != "Example page, document web" {
|
|
t.Fatalf("page focus was preceded by name noise: %q", got)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("missing page focus")
|
|
}
|
|
// Preserve ordinary toolkit name changes and actual browser focus events.
|
|
for _, toolkit := range []string{"gtk", "Qt", ""} {
|
|
event := atspi.NewEvent(atspi.AccessibleNameEvent, atspi.Snapshot{ToolkitName: toolkit})
|
|
if isBrowserUINameNoise(event, atspi.Snapshot{}) {
|
|
t.Fatalf("filtered non-web toolkit %q", toolkit)
|
|
}
|
|
}
|
|
if isBrowserUINameNoise(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{ToolkitName: "Chromium"}), atspi.Snapshot{}) {
|
|
t.Fatal("filtered browser focus")
|
|
}
|
|
}
|
|
|
|
func TestEditableEntryEchoExcludesPasswordAndReadOnlyContent(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
snapshot atspi.Snapshot
|
|
want bool
|
|
}{
|
|
{atspi.Snapshot{Role: "entry", Editable: true}, true},
|
|
{atspi.Snapshot{Role: "text", Editable: true}, true},
|
|
{atspi.Snapshot{Role: "password text", Editable: true}, false},
|
|
{atspi.Snapshot{Role: "entry"}, false},
|
|
{atspi.Snapshot{Role: "entry", Editable: true, ReadOnly: true}, false},
|
|
{atspi.Snapshot{Role: "paragraph"}, false},
|
|
} {
|
|
if got := speaksTextChanges(tc.snapshot); got != tc.want {
|
|
t.Fatalf("%+v: %v", tc.snapshot, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSystemTextMutationsDoNotSpeakWebContainers(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
page := atspi.Snapshot{BusName: ":1.7", Path: "/page", Role: "document web", Name: "Example page"}
|
|
section := atspi.Snapshot{BusName: ":1.7", Path: "/section", Role: "section"}
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, page)
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent+":system", section, "new page content")
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent+":system", section, "old page content")
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/end", Role: "button", Name: "End"})
|
|
for _, want := range []string{"Example page, document web", "End, button"} {
|
|
select {
|
|
case got := <-queue.spoken:
|
|
if got != want {
|
|
t.Fatalf("speech = %q, want %q", got, want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAutocompleteRetainsTypedPrefix(t *testing.T) {
|
|
entry := atspi.Snapshot{BusName: ":1.7", Path: "/entry", Role: "entry", Editable: true, Autocomplete: true, SelectionStart: 1, SelectionEnd: 15, CompletionPrefixCharacter: "b"}
|
|
event := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "rave://version")
|
|
event.Detail1 = 1
|
|
echo := &autocompleteEcho{}
|
|
activity := input.Activity{Intent: input.TypingIntent, Sequence: 1}
|
|
if got := echo.insertion(event, activity); got != "b" {
|
|
t.Fatalf("retained prefix: %q", got)
|
|
}
|
|
if got := echo.insertion(event, activity); got != "" {
|
|
t.Fatalf("duplicate completion: %q", got)
|
|
}
|
|
activity.Sequence++
|
|
entry.SelectionStart = 2
|
|
entry.CompletionPrefixCharacter = "r"
|
|
event = atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "ave://version")
|
|
event.Detail1 = 2
|
|
if got := echo.insertion(event, activity); got != "r" {
|
|
t.Fatalf("extended prefix: %q", got)
|
|
}
|
|
// A separately reported typed character already covers the completion.
|
|
activity.Sequence++
|
|
typed := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "r")
|
|
typed.Detail1 = 1
|
|
if got := echo.insertion(typed, activity); got != "r" {
|
|
t.Fatalf("typed letter: %q", got)
|
|
}
|
|
if got := echo.insertion(event, activity); got != "" {
|
|
t.Fatalf("completion duplicated typed letter: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBackgroundAddressChangesDoNotInterruptDocument(t *testing.T) {
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
|
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, nil, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
entry := atspi.Snapshot{BusName: ":1.7", Path: "/address", Role: "entry", Editable: true, Autocomplete: true, Focused: true}
|
|
page := atspi.Snapshot{BusName: ":1.7", Path: "/page", Role: "document web", Name: "Example page"}
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
|
|
// Focus state can change before the page's focus event arrives.
|
|
entry.Focused = false
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "automatic redirect")
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, page)
|
|
// Even a stale focused bit must not override the owned document focus.
|
|
entry.Focused = true
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry, "previous address")
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "new address")
|
|
marker := atspi.Snapshot{Path: "/marker", Role: "button", Name: "End"}
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, marker)
|
|
for _, want := range []string{"autocomplete", "Example page, document web", "End, button"} {
|
|
select {
|
|
case got := <-queue.spoken:
|
|
if got != want {
|
|
t.Fatalf("speech = %q, want %q", got, want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("missing %q", want)
|
|
}
|
|
}
|
|
}
|