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,274 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestWindowTitleChangesSuppressRepeatedNames(t *testing.T) {
|
||||
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
|
||||
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 }()
|
||||
activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window")
|
||||
activation.Source.Name, activation.Source.Role = "Original title", "frame"
|
||||
activation.WindowTitle = "Original title"
|
||||
adapter.events <- activation
|
||||
expectActivationSpeech(t, queue.spoken, "Original title, frame")
|
||||
changed := activation
|
||||
changed.Type = atspi.AccessibleNameEvent
|
||||
for _, title := range []string{"Original title", "Intermediate title", "Updated title", "Updated title", "", "Updated title"} {
|
||||
changed.Source.Name = title
|
||||
adapter.events <- changed
|
||||
}
|
||||
expectActivationSpeech(t, queue.spoken, "Updated title")
|
||||
select {
|
||||
case got := <-queue.spoken:
|
||||
t.Fatalf("burst queued extra title %q", got)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowActivationEstablishesFocusedObjectOwnership(t *testing.T) {
|
||||
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
|
||||
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 }()
|
||||
|
||||
focused := atspi.Snapshot{
|
||||
BusName: ":1.70",
|
||||
Path: "/focused",
|
||||
ApplicationName: "steamwebhelper",
|
||||
Name: "Library",
|
||||
Role: "check box",
|
||||
Focused: true,
|
||||
}
|
||||
activation := atspi.NewEvent(atspi.WindowActivatedEvent, focused)
|
||||
activation.OriginPath = "/window"
|
||||
adapter.events <- activation
|
||||
expectActivationSpeech(t, queue.spoken, "Library, check box, not checked")
|
||||
duplicate := activation
|
||||
duplicate.Source.Name = "Duplicate activation"
|
||||
adapter.events <- duplicate
|
||||
select {
|
||||
case got := <-queue.spoken:
|
||||
t.Fatalf("duplicate activation spoke %q", got)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
|
||||
// Accessible paths are process-local. A background process using the same
|
||||
// path must not be mistaken for the active Steam object.
|
||||
background := focused
|
||||
background.BusName = ":1.71"
|
||||
background.ApplicationName = "Background Application"
|
||||
background.Name = "Background"
|
||||
background.Checked = true
|
||||
changed := atspi.NewEvent("object:state-changed:checked", background)
|
||||
changed.Detail1 = 1
|
||||
adapter.events <- changed
|
||||
select {
|
||||
case got := <-queue.spoken:
|
||||
t.Fatalf("background path collision spoke %q", got)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
|
||||
focused.Checked = true
|
||||
changed = atspi.NewEvent("object:state-changed:checked", focused)
|
||||
changed.Detail1 = 1
|
||||
adapter.events <- changed
|
||||
expectActivationSpeech(t, queue.spoken, "checked")
|
||||
}
|
||||
|
||||
func TestFocusBeforeActivationAnnouncesWindowTitleOnce(t *testing.T) {
|
||||
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
|
||||
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 }()
|
||||
|
||||
focus := ownerEvent(atspi.FocusedStateEvent, ":1.70", "/window", "/entry")
|
||||
focus.Source.Name = "Search"
|
||||
focus.Source.Role = "entry"
|
||||
focus.Source.Focused = true
|
||||
focus.Detail1 = 1
|
||||
adapter.events <- focus
|
||||
expectActivationSpeech(t, queue.spoken, "Search, entry")
|
||||
|
||||
activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window")
|
||||
activation.Source.Name = "Example Window"
|
||||
activation.Source.Role = "frame"
|
||||
activation.WindowTitle = "Example Window"
|
||||
adapter.events <- activation
|
||||
expectActivationSpeech(t, queue.spoken, "Example Window")
|
||||
adapter.events <- activation
|
||||
select {
|
||||
case got := <-queue.spoken:
|
||||
t.Fatalf("repeated activation spoke %q", got)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveWindowNameChangeAnnouncesOnlyCurrentTitle(t *testing.T) {
|
||||
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
|
||||
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 }()
|
||||
|
||||
activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window")
|
||||
activation.Source.Name = "Original title"
|
||||
activation.Source.Role = "frame"
|
||||
adapter.events <- activation
|
||||
expectActivationSpeech(t, queue.spoken, "Original title, frame")
|
||||
|
||||
background := ownerEvent(atspi.AccessibleNameEvent, ":1.70", "/other", "/other")
|
||||
background.Source.Name = "Background title"
|
||||
background.Source.Role = "frame"
|
||||
adapter.events <- background
|
||||
changed := ownerEvent(atspi.AccessibleNameEvent, ":1.70", "/window", "/window")
|
||||
changed.Source.Name = "Updated title"
|
||||
changed.Source.Role = "frame"
|
||||
adapter.events <- changed
|
||||
expectActivationSpeech(t, queue.spoken, "Updated title")
|
||||
select {
|
||||
case got := <-queue.spoken:
|
||||
t.Fatalf("background title spoke %q", got)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatedWindowActivationPreservesFocusedDescendant(t *testing.T) {
|
||||
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
|
||||
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 }()
|
||||
|
||||
window := atspi.Snapshot{
|
||||
BusName: ":1.70",
|
||||
Path: "/window",
|
||||
ApplicationName: "Example Application",
|
||||
Name: "Example Window",
|
||||
Role: "frame",
|
||||
}
|
||||
activation := atspi.NewEvent(atspi.WindowActivatedEvent, window)
|
||||
activation.OriginPath = window.Path
|
||||
adapter.events <- activation
|
||||
expectActivationSpeech(t, queue.spoken, "Example Window, frame")
|
||||
|
||||
focused := atspi.Snapshot{
|
||||
BusName: window.BusName,
|
||||
Path: "/focused",
|
||||
ApplicationName: window.ApplicationName,
|
||||
Name: "Review target",
|
||||
Role: "check box",
|
||||
Focused: true,
|
||||
}
|
||||
focusEvent := atspi.NewEvent(atspi.FocusedStateEvent, focused)
|
||||
focusEvent.Detail1 = 1
|
||||
adapter.events <- focusEvent
|
||||
expectActivationSpeech(t, queue.spoken, "Review target, check box, not checked")
|
||||
|
||||
// X11 grabs can cause the same top-level activation to be emitted again
|
||||
// with the frame as its source. It must neither speak nor replace the
|
||||
// focused descendant used by review and state-change filtering.
|
||||
repeated := activation
|
||||
repeated.Source.Name = "Repeated window activation"
|
||||
adapter.events <- repeated
|
||||
focused.Checked = true
|
||||
changed := atspi.NewEvent("object:state-changed:checked", focused)
|
||||
changed.Detail1 = 1
|
||||
adapter.events <- changed
|
||||
expectActivationSpeech(t, queue.spoken, "checked")
|
||||
}
|
||||
|
||||
func TestWindowActivationIsFocusGain(t *testing.T) {
|
||||
event := atspi.NewEvent(atspi.WindowActivatedEvent, atspi.Snapshot{})
|
||||
if !isFocusEvent(event.Type) || !isFocusGainEvent(event) {
|
||||
t.Fatal("window activation was not treated as a focus gain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowLifecycleClearsFocusedObjectOwnership(t *testing.T) {
|
||||
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
|
||||
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 }()
|
||||
|
||||
focused := atspi.Snapshot{
|
||||
BusName: ":1.70",
|
||||
Path: "/focused",
|
||||
ApplicationName: "Example Application",
|
||||
Name: "Active control",
|
||||
Role: "check box",
|
||||
Focused: true,
|
||||
}
|
||||
activation := atspi.NewEvent(atspi.WindowActivatedEvent, focused)
|
||||
activation.Window = atspi.NewObjectID(focused.BusName, "/window")
|
||||
adapter.events <- activation
|
||||
expectActivationSpeech(t, queue.spoken, "Active control, check box, not checked")
|
||||
|
||||
deactivation := atspi.NewEvent(atspi.WindowDeactivatedEvent, atspi.Snapshot{
|
||||
BusName: focused.BusName,
|
||||
Path: "/window",
|
||||
Role: "frame",
|
||||
})
|
||||
deactivation.Window = activation.Window
|
||||
adapter.events <- deactivation
|
||||
|
||||
changed := atspi.NewEvent("object:state-changed:checked", focused)
|
||||
changed.Detail1 = 1
|
||||
adapter.events <- changed
|
||||
select {
|
||||
case got := <-queue.spoken:
|
||||
t.Fatalf("event from deactivated window spoke %q", got)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func expectActivationSpeech(t *testing.T, spoken <-chan string, want string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-spoken:
|
||||
if got != want {
|
||||
t.Fatalf("speech = %q, want %q", got, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("missing speech %q", want)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user