Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,450 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user