Files
cthulhu/internal/app/prompt_presentation_test.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
}