451 lines
16 KiB
Go
451 lines
16 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"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
func TestAlertMessageFinishesBeforeInitialButton(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 4), requests: make(chan speechRequest, 4)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 5)}
|
|
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)
|
|
}()
|
|
|
|
window := atspi.NewObjectID(":1.4", "/question")
|
|
alert := atspi.NewSnapshot(window.Path, "Question", "alert", "", "Version 0.6.0 is available. Open the releases page in your browser?")
|
|
alert.BusName = window.BusName
|
|
created := atspi.NewEvent(atspi.WindowCreateEvent, alert)
|
|
created.Window = window
|
|
adapter.events <- created
|
|
activated := atspi.NewEvent(atspi.WindowActivatedEvent, atspi.NewSnapshot(window.Path, "Question", "alert", "", ""))
|
|
activated.Source.BusName = window.BusName
|
|
activated.Window = window
|
|
adapter.events <- activated
|
|
for _, item := range []struct{ path, name string }{{"/question/no", "No"}, {"/question/yes", "Yes"}} {
|
|
button := atspi.NewSnapshot(item.path, item.name, "button", "", "")
|
|
button.BusName = window.BusName
|
|
focused := atspi.NewEvent(atspi.FocusedStateEvent, button)
|
|
focused.Window = window
|
|
focused.Detail1 = 1
|
|
adapter.events <- focused
|
|
}
|
|
|
|
for index, expected := range []struct {
|
|
text string
|
|
append bool
|
|
}{
|
|
{"Question, alert, Version 0.6.0 is available. Open the releases page in your browser?", false},
|
|
{"No, button", true},
|
|
{"Yes, button", false},
|
|
} {
|
|
select {
|
|
case request := <-speech.requests:
|
|
if request.Text != expected.text || request.Append != expected.append {
|
|
t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("timed out waiting for request %d", index)
|
|
}
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for event loop to stop")
|
|
}
|
|
}
|
|
|
|
func TestSimpleFramePromptMessageFinishesBeforeButton(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 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)
|
|
}()
|
|
|
|
window := atspi.NewObjectID(":1.5", "/prompt")
|
|
frame := atspi.NewSnapshot(window.Path, "Alert", "frame", "", "Microsoft Windows has been found! Would you like to remove it?")
|
|
frame.BusName = window.BusName
|
|
activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame)
|
|
activated.Window = window
|
|
adapter.events <- activated
|
|
for _, item := range []struct{ path, name string }{{"/prompt/yes", "gtk-yes"}, {"/prompt/no", "gtk-no"}} {
|
|
button := atspi.NewSnapshot(item.path, item.name, "button", "", "")
|
|
button.BusName = window.BusName
|
|
focused := atspi.NewEvent(atspi.FocusedStateEvent, button)
|
|
focused.Window = window
|
|
focused.Detail1 = 1
|
|
adapter.events <- focused
|
|
}
|
|
for index, expected := range []struct {
|
|
text string
|
|
append bool
|
|
}{
|
|
{"Alert, frame, Microsoft Windows has been found! Would you like to remove it?", false},
|
|
{"gtk-yes, button", true},
|
|
{"gtk-no, button", false},
|
|
} {
|
|
select {
|
|
case request := <-speech.requests:
|
|
if request.Text != expected.text || request.Append != expected.append {
|
|
t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("timed out waiting for request %d", index)
|
|
}
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for event loop to stop")
|
|
}
|
|
}
|
|
|
|
func TestSimplePromptRecoversMessageAtFirstButton(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 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)
|
|
}()
|
|
window := atspi.NewObjectID(":1.6", "/prompt")
|
|
frame := atspi.NewSnapshot(window.Path, "Alert", "frame", "", "")
|
|
frame.BusName = window.BusName
|
|
activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame)
|
|
activated.Window = window
|
|
adapter.events <- activated
|
|
button := atspi.NewSnapshot("/prompt/yes", "gtk-yes", "button", "", "")
|
|
button.BusName = window.BusName
|
|
focused := atspi.NewEvent(atspi.FocusedStateEvent, button)
|
|
focused.Window = window
|
|
focused.Detail1 = 1
|
|
focused.WindowMessage = "Microsoft Windows has been found! Would you like to remove it?"
|
|
adapter.events <- focused
|
|
for index, expected := range []struct {
|
|
text string
|
|
append bool
|
|
}{
|
|
{"Alert, frame", false},
|
|
{"Microsoft Windows has been found! Would you like to remove it?, gtk-yes, button", true},
|
|
} {
|
|
select {
|
|
case request := <-speech.requests:
|
|
if request.Text != expected.text || request.Append != expected.append {
|
|
t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("timed out waiting for request %d", index)
|
|
}
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for event loop to stop")
|
|
}
|
|
}
|
|
|
|
func TestListPromptAnnouncesQuestionBeforeChoices(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 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)
|
|
}()
|
|
window := atspi.NewObjectID(":1.9", "/list")
|
|
list := atspi.NewSnapshot("/list/choices", "", "tree table", "", "")
|
|
list.BusName = window.BusName
|
|
activated := atspi.NewEvent(atspi.WindowActivatedEvent, list)
|
|
activated.Window = window
|
|
activated.WindowTitle = "Select Sound Type"
|
|
activated.WindowMessage = "Which sound type would you like to use?"
|
|
adapter.events <- activated
|
|
focused := atspi.NewEvent(atspi.FocusedStateEvent, list)
|
|
focused.Window = window
|
|
focused.Detail1 = 1
|
|
adapter.events <- focused
|
|
for index, expected := range []struct {
|
|
text string
|
|
append bool
|
|
}{
|
|
{"Select Sound Type, Which sound type would you like to use?", false},
|
|
{"tree table", true},
|
|
} {
|
|
select {
|
|
case request := <-speech.requests:
|
|
if request.Text != expected.text || request.Append != expected.append {
|
|
t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("timed out waiting for request %d", index)
|
|
}
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for event loop to stop")
|
|
}
|
|
}
|
|
|
|
func TestListPromptAnnouncesQuestionWhenActivationTargetsFirstCell(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 2)}
|
|
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)
|
|
}()
|
|
window := atspi.NewObjectID(":1.9", "/power")
|
|
cell := atspi.NewSnapshot("/power/list/first", "Power off", "table cell", "", "")
|
|
cell.BusName = window.BusName
|
|
activated := atspi.NewEvent(atspi.WindowActivatedEvent, cell)
|
|
activated.Window = window
|
|
activated.WindowTitle = "I38"
|
|
activated.WindowMessage = "Power options"
|
|
adapter.events <- activated
|
|
select {
|
|
case request := <-speech.requests:
|
|
if request.Text != "I38, Power options" {
|
|
t.Fatalf("activation speech = %q", request.Text)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for list prompt")
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for event loop to stop")
|
|
}
|
|
}
|
|
|
|
func TestListPromptRecoversQuestionOnFocus(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 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)
|
|
}()
|
|
window := atspi.NewObjectID(":1.10", "/list")
|
|
frame := atspi.NewSnapshot(window.Path, "Select Sound Type", "frame", "", "")
|
|
frame.BusName = window.BusName
|
|
activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame)
|
|
activated.Window = window
|
|
adapter.events <- activated
|
|
list := atspi.NewSnapshot("/list/choices", "", "tree table", "", "")
|
|
list.BusName = window.BusName
|
|
focused := atspi.NewEvent(atspi.FocusedStateEvent, list)
|
|
focused.Window = window
|
|
focused.Detail1 = 1
|
|
focused.WindowMessage = "Which sound type would you like to use?"
|
|
adapter.events <- focused
|
|
for index, expected := range []struct {
|
|
text string
|
|
append bool
|
|
}{
|
|
{"Select Sound Type, frame", false},
|
|
{"Which sound type would you like to use?, tree table", true},
|
|
} {
|
|
select {
|
|
case request := <-speech.requests:
|
|
if request.Text != expected.text || request.Append != expected.append {
|
|
t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("timed out waiting for request %d", index)
|
|
}
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for event loop to stop")
|
|
}
|
|
}
|
|
|
|
func TestFocusedButtonActivationAnnouncesPromptBeforeButton(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 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)
|
|
}()
|
|
window := atspi.NewObjectID(":1.7", "/prompt")
|
|
button := atspi.NewSnapshot("/prompt/yes", "gtk-yes", "button", "", "")
|
|
button.BusName = window.BusName
|
|
activated := atspi.NewEvent(atspi.WindowActivatedEvent, button)
|
|
activated.Window = window
|
|
activated.WindowTitle = "Alert"
|
|
activated.WindowMessage = "Microsoft Windows has been found! Would you like to remove it?"
|
|
adapter.events <- activated
|
|
focused := atspi.NewEvent(atspi.FocusedStateEvent, button)
|
|
focused.Window = window
|
|
focused.Detail1 = 1
|
|
adapter.events <- focused
|
|
other := atspi.NewSnapshot("/prompt/no", "gtk-no", "button", "", "")
|
|
other.BusName = window.BusName
|
|
focusedOther := atspi.NewEvent(atspi.FocusedStateEvent, other)
|
|
focusedOther.Window = window
|
|
focusedOther.Detail1 = 1
|
|
adapter.events <- focusedOther
|
|
for index, expected := range []struct {
|
|
text string
|
|
append bool
|
|
}{
|
|
{"Alert, Microsoft Windows has been found! Would you like to remove it?", false},
|
|
{"gtk-yes, button", true},
|
|
{"gtk-no, button", false},
|
|
} {
|
|
select {
|
|
case request := <-speech.requests:
|
|
if request.Text != expected.text || request.Append != expected.append {
|
|
t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("timed out waiting for request %d", index)
|
|
}
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for event loop to stop")
|
|
}
|
|
}
|
|
|
|
func TestFocusedButtonActivationAfterFocusKeepsPrompt(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 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)
|
|
}()
|
|
window := atspi.NewObjectID(":1.8", "/prompt")
|
|
button := atspi.NewSnapshot("/prompt/yes", "gtk-yes", "button", "", "")
|
|
button.BusName = window.BusName
|
|
button.Focused = true
|
|
focused := atspi.NewEvent(atspi.FocusedStateEvent, button)
|
|
focused.Window = window
|
|
focused.Detail1 = 1
|
|
adapter.events <- focused
|
|
activated := atspi.NewEvent(atspi.WindowActivatedEvent, button)
|
|
activated.Window = window
|
|
activated.WindowTitle = "Alert"
|
|
activated.WindowMessage = "Microsoft Windows has been found! Would you like to remove it?"
|
|
adapter.events <- activated
|
|
for index, expected := range []string{
|
|
"gtk-yes, button",
|
|
"Alert, Microsoft Windows has been found! Would you like to remove it?",
|
|
} {
|
|
select {
|
|
case request := <-speech.requests:
|
|
if request.Text != expected {
|
|
t.Fatalf("request %d = %q, want %q", index, request.Text, expected)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("timed out waiting for request %d", index)
|
|
}
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for event loop to stop")
|
|
}
|
|
}
|
|
|
|
func TestTerminalTextChangeSuppressesPromptReplayButPreservesNavigation(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
|
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", "", "Prompt")
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Output")
|
|
if got := <-speech.spoken; got != "Output" {
|
|
t.Fatalf("text = %q", got)
|
|
}
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, terminal)
|
|
// The caret timer must have fired before the next navigation event.
|
|
select {
|
|
case got := <-speech.spoken:
|
|
t.Fatalf("prompt replayed after insertion: %q", got)
|
|
case <-time.After(30 * time.Millisecond):
|
|
}
|
|
terminal.CaretCharacter = "P"
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, terminal)
|
|
select {
|
|
case got := <-speech.spoken:
|
|
if got != "P" {
|
|
t.Fatalf("caret navigation = %q", got)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("caret navigation was suppressed")
|
|
}
|
|
terminal.CaretCharacter = ""
|
|
terminal.Text = "Another line"
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, terminal)
|
|
select {
|
|
case got := <-speech.spoken:
|
|
if got != "Another line" {
|
|
t.Fatalf("line navigation = %q", got)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("line navigation was suppressed")
|
|
}
|
|
cancel()
|
|
<-completed
|
|
}
|