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,233 @@
|
||||
// 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 TestTableWindowDescriptionSurvivesImmediateTableFocus(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.20", "/desktop")
|
||||
cell := atspi.NewSnapshot("/desktop/table/item", "Alpha", "table cell", "", "")
|
||||
cell.BusName = window.BusName
|
||||
cell.TableName = "Desktop items"
|
||||
cell.TableRowCount, cell.TableColumnCount = 2, 2
|
||||
cell.TableRowText, cell.GUITable = "Alpha, Document", true
|
||||
activated := atspi.NewEvent(atspi.WindowActivatedEvent, cell)
|
||||
activated.Window = window
|
||||
activated.WindowTitle = "Desktop"
|
||||
activated.WindowDescription = "Select an item and press Enter to open it."
|
||||
adapter.events <- activated
|
||||
table := atspi.NewSnapshot("/desktop/table", "Desktop items", "table", "", "")
|
||||
table.BusName = window.BusName
|
||||
table.TableRowCount, table.TableColumnCount = 2, 2
|
||||
focused := atspi.NewEvent(atspi.FocusedStateEvent, table)
|
||||
focused.Window = window
|
||||
focused.Detail1 = 1
|
||||
adapter.events <- focused
|
||||
for index, expected := range []struct {
|
||||
text string
|
||||
append bool
|
||||
}{
|
||||
{"Desktop, Desktop items, table with 2 rows 2 columns, Alpha, Document, Select an item and press Enter to open it.", false},
|
||||
{"Desktop items, table with 2 rows 2 columns", 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()
|
||||
<-completed
|
||||
}
|
||||
|
||||
func TestTableDescriptionArrivingAfterActivation(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.21", "/desktop")
|
||||
table := atspi.NewSnapshot("/desktop/table", "Desktop items", "table", "", "")
|
||||
table.BusName = window.BusName
|
||||
activated := atspi.NewEvent(atspi.WindowActivatedEvent, table)
|
||||
activated.Window = window
|
||||
activated.WindowTitle = "Desktop"
|
||||
adapter.events <- activated
|
||||
first := <-speech.requests
|
||||
if first.Text != "Desktop, Desktop items, table" {
|
||||
t.Fatalf("activation = %q", first.Text)
|
||||
}
|
||||
table.TableRowCount, table.TableColumnCount = 2, 2
|
||||
focused := atspi.NewEvent(atspi.FocusedStateEvent, table)
|
||||
focused.Window = window
|
||||
focused.WindowDescription = "Select an item and press Enter to open it."
|
||||
focused.Detail1 = 1
|
||||
adapter.events <- focused
|
||||
second := <-speech.requests
|
||||
if second.Text != "Desktop items, table with 2 rows 2 columns, Select an item and press Enter to open it." {
|
||||
t.Fatalf("focused table = %q", second.Text)
|
||||
}
|
||||
cell := atspi.NewSnapshot("/desktop/table/first", "Alpha", "table cell", "", "")
|
||||
cell.BusName = window.BusName
|
||||
cell.TablePath = table.Path
|
||||
cell.TableRowText, cell.GUITable = "Alpha, Document", true
|
||||
cell.TablePositionKnown = true
|
||||
third := atspi.NewEvent(atspi.FocusedStateEvent, cell)
|
||||
third.Window = window
|
||||
third.Detail1 = 1
|
||||
adapter.events <- third
|
||||
request := <-speech.requests
|
||||
if request.Text != "Alpha, Document" || !request.Append {
|
||||
t.Fatalf("following cell = %q append %t", request.Text, request.Append)
|
||||
}
|
||||
cancel()
|
||||
<-completed
|
||||
}
|
||||
|
||||
func TestGUITableHorizontalMovementReadsOneCell(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
||||
inputs := &sequencedInputActivity{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
completed := make(chan struct{})
|
||||
go func() {
|
||||
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter,
|
||||
nil, nil, 0, nil, inputs, nil, nil)
|
||||
close(completed)
|
||||
}()
|
||||
window := atspi.NewObjectID(":1.22", "/desktop")
|
||||
table := atspi.NewSnapshot("/desktop/table", "Desktop items", "table", "", "")
|
||||
table.BusName = window.BusName
|
||||
activated := atspi.NewEvent(atspi.WindowActivatedEvent, table)
|
||||
activated.Window = window
|
||||
adapter.events <- activated
|
||||
<-speech.requests
|
||||
cellEvent := func(path, name, rowText string, row, column int, eventType string) atspi.Event {
|
||||
cell := atspi.NewSnapshot(path, name, "table cell", "", "")
|
||||
cell.BusName = window.BusName
|
||||
cell.TablePath = table.Path
|
||||
cell.TableRowIndex, cell.TableColumnIndex = row, column
|
||||
cell.TablePositionKnown, cell.GUITable = true, true
|
||||
cell.TableRowText = rowText
|
||||
event := atspi.NewManagedDescendantEvent(eventType, table.Path, cell)
|
||||
event.Window = window
|
||||
return event
|
||||
}
|
||||
for _, step := range []struct {
|
||||
sequence uint64
|
||||
event atspi.Event
|
||||
want string
|
||||
}{
|
||||
{1, cellEvent("/desktop/table/alpha", "Alpha", "Alpha, Document", 0, 0, atspi.SelectionChangedEvent), "Alpha, Document"},
|
||||
{2, cellEvent("/desktop/table/document", "Document", "Alpha, Document", 0, 1, atspi.ActiveDescendantEvent), "Document"},
|
||||
{3, cellEvent("/desktop/table/alpha", "Alpha", "Alpha, Document", 0, 0, atspi.ActiveDescendantEvent), "Alpha"},
|
||||
{4, cellEvent("/desktop/table/beta", "Beta", "Beta, Shortcut", 1, 0, atspi.SelectionChangedEvent), "Beta, Shortcut"},
|
||||
{4, cellEvent("/desktop/table/replacement", "Renamed", "Renamed, Shortcut", 1, 0, atspi.ActiveDescendantEvent), "Renamed"},
|
||||
} {
|
||||
inputs.set(step.sequence)
|
||||
adapter.events <- step.event
|
||||
select {
|
||||
case request := <-speech.requests:
|
||||
if request.Text != step.want {
|
||||
t.Fatalf("sequence %d = %q, want %q", step.sequence, request.Text, step.want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("sequence %d produced no speech", step.sequence)
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
<-completed
|
||||
}
|
||||
|
||||
func TestGUITableReusedCurrentCellReadsReplacementRow(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 4), requests: make(chan speechRequest, 4)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
completed := make(chan struct{})
|
||||
go func() {
|
||||
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
||||
close(completed)
|
||||
}()
|
||||
defer func() { cancel(); <-completed }()
|
||||
|
||||
cell := atspi.NewSnapshot("/mail/messages/row", "First subject", "table cell", "", "")
|
||||
cell.BusName = ":1.23"
|
||||
cell.TablePath = "/mail/messages"
|
||||
cell.TableRowIndex, cell.TableColumnIndex = 0, 0
|
||||
cell.TablePositionKnown, cell.GUITable = true, true
|
||||
cell.TableRowText = "First subject, First sender"
|
||||
focus := atspi.NewEvent(atspi.FocusedStateEvent, cell)
|
||||
focus.Detail1 = 1
|
||||
adapter.events <- focus
|
||||
if got := (<-speech.requests).Text; got != cell.TableRowText {
|
||||
t.Fatalf("initial row = %q", got)
|
||||
}
|
||||
|
||||
replacement := cell
|
||||
replacement.Name = "Next subject"
|
||||
replacement.TableRowText = "Next subject, Next sender"
|
||||
selected := atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, cell.TablePath, replacement)
|
||||
adapter.events <- selected
|
||||
select {
|
||||
case request := <-speech.requests:
|
||||
if request.Text != replacement.TableRowText {
|
||||
t.Fatalf("replacement row = %q, want %q", request.Text, replacement.TableRowText)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("replacement row was not spoken")
|
||||
}
|
||||
|
||||
// A name event is another way a provider can expose a reused row.
|
||||
third := replacement
|
||||
third.Name = "Third subject"
|
||||
third.TableRowText = "Third subject, Third sender"
|
||||
nameChanged := atspi.NewEvent(atspi.AccessibleNameEvent, third)
|
||||
adapter.events <- nameChanged
|
||||
select {
|
||||
case request := <-speech.requests:
|
||||
if request.Text != third.TableRowText {
|
||||
t.Fatalf("renamed row = %q, want %q", request.Text, third.TableRowText)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("renamed current row was not spoken")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user