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,397 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||||
"git.stormux.org/storm/cthulhu/internal/preferences"
|
||||
)
|
||||
|
||||
type titleProbeSource struct{ title atspi.ForegroundTitle }
|
||||
|
||||
func (source *titleProbeSource) ForegroundTitle() atspi.ForegroundTitle { return source.title }
|
||||
func (source *titleProbeSource) TitleChanges() <-chan struct{} { return nil }
|
||||
func (source *titleProbeSource) Foreground() atspi.ForegroundState {
|
||||
return atspi.ForegroundState{Epoch: source.title.Epoch, Client: xorg.Foreground{Known: true, Window: source.title.Title.Window}}
|
||||
}
|
||||
func (source *titleProbeSource) ForegroundChanges() <-chan struct{} { return nil }
|
||||
|
||||
type titleProbeQueue struct {
|
||||
requests []speechRequest
|
||||
interruptions int
|
||||
}
|
||||
|
||||
func (queue *titleProbeQueue) Enqueue(request speechRequest) {
|
||||
queue.requests = append(queue.requests, request)
|
||||
}
|
||||
func (queue *titleProbeQueue) Interrupt() { queue.interruptions++ }
|
||||
|
||||
func titleControllerFixture(t *testing.T) (*windowTitleController, *titleProbeQueue, atspi.Event) {
|
||||
t.Helper()
|
||||
queue := &titleProbeQueue{}
|
||||
live := &liveSettings{}
|
||||
live.Store(config.DefaultSettings())
|
||||
titles := &windowTitleController{state: &eventLoopState{}, live: live, speech: queue}
|
||||
activation := ownerEvent(atspi.WindowActivatedEvent, ":1.50", "/window", "/window")
|
||||
activation.Source.Name, activation.Source.Role = "Original", "frame"
|
||||
titles.state.owner.activate(activation)
|
||||
titles.activated(activation)
|
||||
t.Cleanup(titles.reset)
|
||||
changed := activation
|
||||
changed.Type = atspi.AccessibleNameEvent
|
||||
return titles, queue, changed
|
||||
}
|
||||
|
||||
func TestAutomaticTitlePolicyCoalescesAndPreservesSpeech(t *testing.T) {
|
||||
titles, queue, changed := titleControllerFixture(t)
|
||||
for _, text := range []string{"Loading", "Loading 50%", "Loading 100%"} {
|
||||
changed.Source.Name = text
|
||||
if !titles.accessible(changed) {
|
||||
t.Fatal("title event not consumed")
|
||||
}
|
||||
}
|
||||
titles.flush()
|
||||
if len(queue.requests) != 1 || queue.requests[0].Text != "Loading 100%" ||
|
||||
!queue.requests[0].Append || queue.interruptions != 0 {
|
||||
t.Fatalf("title burst = %+v, interruptions %d", queue.requests, queue.interruptions)
|
||||
}
|
||||
titles.accessible(changed)
|
||||
titles.flush()
|
||||
if len(queue.requests) != 1 {
|
||||
t.Fatal("duplicate title was announced")
|
||||
}
|
||||
changed.Source.Name = ""
|
||||
titles.accessible(changed)
|
||||
changed.Source.Name = "Loading 100%"
|
||||
titles.accessible(changed)
|
||||
titles.flush()
|
||||
if len(queue.requests) != 2 {
|
||||
t.Fatal("restored title was not announced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticTitleUsesBrailleMessagePathAndKeepsFocus(t *testing.T) {
|
||||
titles, queue, changed := titleControllerFixture(t)
|
||||
display := &flashRecorder{}
|
||||
titles.speech = outputQueue{speech: queue, braille: display, live: titles.live}
|
||||
focused := titles.state.owner.focus
|
||||
changed.Source.Name = "Updated title"
|
||||
titles.accessible(changed)
|
||||
titles.flush()
|
||||
if len(display.messages) != 1 || display.messages[0] != "Updated title" || titles.state.owner.focus != focused {
|
||||
t.Fatalf("title braille = %q, focus = %+v", display.messages, titles.state.owner.focus)
|
||||
}
|
||||
settings := titles.live.Load()
|
||||
settings.Reading.AutomaticallyReadTitleChanges = false
|
||||
titles.live.Store(settings)
|
||||
changed.Source.Name = "Silent title"
|
||||
titles.accessible(changed)
|
||||
titles.flush()
|
||||
if len(display.messages) != 1 {
|
||||
t.Fatal("disabled title still flashed on braille")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticTitlesRejectBackgroundDisabledAndStaleEvents(t *testing.T) {
|
||||
titles, queue, changed := titleControllerFixture(t)
|
||||
background := changed
|
||||
background.Source.Path = "/background"
|
||||
background.Source.Name = "Background"
|
||||
titles.accessible(background)
|
||||
titles.flush()
|
||||
settings := titles.live.Load()
|
||||
settings.Reading.AutomaticallyReadTitleChanges = false
|
||||
titles.live.Store(settings)
|
||||
changed.Source.Name = "Disabled"
|
||||
titles.accessible(changed)
|
||||
titles.flush()
|
||||
settings.Reading.AutomaticallyReadTitleChanges = true
|
||||
titles.live.Store(settings)
|
||||
changed.Source.Name = "Stale"
|
||||
titles.accessible(changed)
|
||||
titles.state.owner.clear()
|
||||
titles.flush()
|
||||
if len(queue.requests) != 0 {
|
||||
t.Fatalf("unwanted titles = %+v", queue.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticTitlesCancelEmptyAndLockedUpdates(t *testing.T) {
|
||||
titles, queue, changed := titleControllerFixture(t)
|
||||
changed.Source.Name = "Transient"
|
||||
titles.accessible(changed)
|
||||
changed.Source.Name = ""
|
||||
titles.accessible(changed)
|
||||
titles.flush()
|
||||
titles.gate = newFocusGate(nil)
|
||||
changed.Source.Name = "Locked title"
|
||||
titles.accessible(changed)
|
||||
titles.gate.secure.Store(true)
|
||||
titles.flush()
|
||||
titles.gate.secure.Store(false)
|
||||
titles.gate.suspended.Store(true)
|
||||
changed.Source.Name = "XTerm handoff"
|
||||
titles.accessible(changed)
|
||||
titles.flush()
|
||||
if len(queue.requests) != 0 {
|
||||
t.Fatalf("unwanted titles = %+v", queue.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeXTermIntroductionDuringHandoff(t *testing.T) {
|
||||
for _, accessible := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "native only", true: "accessible activation"}[accessible], func(t *testing.T) {
|
||||
titles, queue, activation := titleControllerFixture(t)
|
||||
titles.state.owner.clear()
|
||||
titles.gate = newFocusGate(nil)
|
||||
titles.gate.SetSuspended(true)
|
||||
titles.speech = gatedSpeech{speech: queue, gate: titles.gate}
|
||||
source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 20, TitleWindow: 20, Title: "Shell", Application: "xterm"}}}
|
||||
titles.source, titles.foreground = source, source
|
||||
settings := titles.live.Load()
|
||||
settings.Reading.AutomaticallyReadTitleChanges = false
|
||||
titles.live.Store(settings)
|
||||
if accessible {
|
||||
activation.Source.Name, activation.Source.ApplicationName = "Shell", "xterm"
|
||||
activation.Foreground.Epoch = 1
|
||||
titles.state.owner.activate(activation)
|
||||
titles.activated(activation)
|
||||
}
|
||||
titles.nativeChanged()
|
||||
titles.flush()
|
||||
if len(queue.requests) != 1 || queue.requests[0].Text != "Shell" || !titles.gate.Suspended() {
|
||||
t.Fatalf("handoff title = %+v; suspended = %t", queue.requests, titles.gate.Suspended())
|
||||
}
|
||||
titles.nativeChanged()
|
||||
titles.flush()
|
||||
source.title.Title.Title = "Shell updated"
|
||||
titles.nativeChanged()
|
||||
titles.flush()
|
||||
if len(queue.requests) != 1 {
|
||||
t.Fatal("handoff announced duplicate or terminal title change")
|
||||
}
|
||||
// Separate xterms can have the same title; each switch introduces it.
|
||||
source.title.Epoch++
|
||||
source.title.Title.Window, source.title.Title.TitleWindow = 21, 21
|
||||
source.title.Title.Title = "Shell"
|
||||
titles.nativeChanged()
|
||||
titles.flush()
|
||||
if len(queue.requests) != 2 || queue.requests[1].Text != "Shell" {
|
||||
t.Fatalf("second terminal introduction = %+v", queue.requests)
|
||||
}
|
||||
source.title.Epoch++
|
||||
source.title.Title.Window, source.title.Title.TitleWindow = 22, 22
|
||||
source.title.Title.Title = "Second shell"
|
||||
titles.nativeChanged()
|
||||
titles.gate.secure.Store(true)
|
||||
titles.flush()
|
||||
if len(queue.requests) != 2 {
|
||||
t.Fatal("locked screen announced terminal title")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeTitlesSharePolicyAndActivationDeduplication(t *testing.T) {
|
||||
titles, queue, changed := titleControllerFixture(t)
|
||||
source := &titleProbeSource{title: atspi.ForegroundTitle{Title: xorg.WindowTitle{Window: 10, TitleWindow: 10, Title: "Original"}}}
|
||||
titles.source, titles.foreground = source, source
|
||||
titles.nativeChanged()
|
||||
titles.flush()
|
||||
changed.Source.Name = "Changed"
|
||||
titles.accessible(changed)
|
||||
source.title.Title.Title = "Changed"
|
||||
titles.nativeChanged()
|
||||
titles.flush()
|
||||
if len(queue.requests) != 1 || queue.requests[0].Text != "Changed" {
|
||||
t.Fatalf("duplicate source output = %+v", queue.requests)
|
||||
}
|
||||
source.title.Title.Title = "Changed again"
|
||||
titles.nativeChanged()
|
||||
settings := titles.live.Load()
|
||||
settings.Reading.AutomaticallyReadTitleChanges = false
|
||||
titles.live.Store(settings)
|
||||
titles.flush()
|
||||
if len(queue.requests) != 1 {
|
||||
t.Fatal("native change ignored disabled preference")
|
||||
}
|
||||
titles.state.owner.clear()
|
||||
source.title.Epoch++
|
||||
source.title.Title.Window, source.title.Title.TitleWindow, source.title.Title.Title = 20, 20, "Other window"
|
||||
titles.nativeChanged()
|
||||
source.title.Title.Title = "Other window ready"
|
||||
titles.nativeChanged()
|
||||
titles.flush()
|
||||
if len(queue.requests) != 2 || queue.requests[1].Text != "Other window ready" {
|
||||
t.Fatal("disabled title changes suppressed initial window introduction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeTitleDropsOldForegroundAndTracksWineChildSwitch(t *testing.T) {
|
||||
titles, queue, _ := titleControllerFixture(t)
|
||||
titles.state.owner.clear()
|
||||
source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 10, TitleWindow: 11, Title: "Wine game"}}}
|
||||
titles.source, titles.foreground = source, source
|
||||
titles.nativeChanged()
|
||||
source.title.Epoch++
|
||||
titles.flush()
|
||||
if len(queue.requests) != 0 {
|
||||
t.Fatal("old foreground title survived")
|
||||
}
|
||||
titles.nativeChanged()
|
||||
titles.flush()
|
||||
source.title.Title.TitleWindow, source.title.Title.Title = 12, "Wine dialog"
|
||||
settings := titles.live.Load()
|
||||
settings.Reading.AutomaticallyReadTitleChanges = false
|
||||
titles.live.Store(settings)
|
||||
titles.nativeChanged()
|
||||
titles.flush()
|
||||
if len(queue.requests) != 2 || queue.requests[1].Text != "Wine dialog" {
|
||||
t.Fatalf("Wine child switch = %+v", queue.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticTitlesWaitForApplicationOverride(t *testing.T) {
|
||||
titles, queue, changed := titleControllerFixture(t)
|
||||
titles.application = "Example Application"
|
||||
titles.live.onApplication = func(string) {}
|
||||
changed.Source.Name = "Changing title"
|
||||
titles.accessible(changed)
|
||||
titles.flush()
|
||||
if len(queue.requests) != 0 || titles.pending == nil {
|
||||
t.Fatal("title raced application settings")
|
||||
}
|
||||
application := titles.application
|
||||
settings := titles.live.Load()
|
||||
settings.Reading.AutomaticallyReadTitleChanges = false
|
||||
titles.live.Store(settings)
|
||||
titles.live.application.Store(&application)
|
||||
titles.flush()
|
||||
if len(queue.requests) != 0 || titles.pending != nil {
|
||||
t.Fatal("application override was not honored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLateNativeSampleHonorsDisabledTitleChanges(t *testing.T) {
|
||||
titles, queue, _ := titleControllerFixture(t)
|
||||
settings := titles.live.Load()
|
||||
settings.Reading.AutomaticallyReadTitleChanges = false
|
||||
titles.live.Store(settings)
|
||||
source := &titleProbeSource{title: atspi.ForegroundTitle{Title: xorg.WindowTitle{Window: 10, TitleWindow: 10, Title: "Changed after activation"}}}
|
||||
titles.source, titles.foreground = source, source
|
||||
titles.nativeChanged()
|
||||
titles.flush()
|
||||
if len(queue.requests) != 0 {
|
||||
t.Fatal("first native sample bypassed disabled preference")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitlePreferenceAppliesPersistsAndRestoresAcrossApplications(t *testing.T) {
|
||||
settings := config.DefaultSettings()
|
||||
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
|
||||
store, err := config.NewStore(settings.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Save(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil)
|
||||
worker.live = &liveSettings{}
|
||||
worker.live.Store(settings)
|
||||
ctx := context.Background()
|
||||
worker.switchApplication(ctx, "Noisy Application")
|
||||
worker.beginEdit(ctx, "\x00")
|
||||
values := preferences.FromSettings(worker.editing)
|
||||
if !values.Reading.AutomaticallyReadTitleChanges {
|
||||
t.Fatal("checkbox defaults off")
|
||||
}
|
||||
values.Reading.AutomaticallyReadTitleChanges = false
|
||||
if response := worker.apply(ctx, preferences.Request{Values: values, Save: true}); !response.Accepted {
|
||||
t.Fatal(response.Message)
|
||||
}
|
||||
worker.editingActive = false
|
||||
worker.switchApplication(ctx, "Other Application")
|
||||
if !worker.live.Load().Reading.AutomaticallyReadTitleChanges {
|
||||
t.Fatal("override leaked")
|
||||
}
|
||||
worker.switchApplication(ctx, "Noisy Application")
|
||||
if worker.live.Load().Reading.AutomaticallyReadTitleChanges {
|
||||
t.Fatal("override lost on return")
|
||||
}
|
||||
reopened, err := config.NewStore(settings.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, found, err := reopened.LoadForApplication("Noisy Application")
|
||||
if err != nil || !found || loaded.Reading.AutomaticallyReadTitleChanges {
|
||||
t.Fatalf("saved override = %t, %t, %v", loaded.Reading.AutomaticallyReadTitleChanges, found, err)
|
||||
}
|
||||
global, err := reopened.Load()
|
||||
if err != nil || !global.Reading.AutomaticallyReadTitleChanges {
|
||||
t.Fatalf("global default changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWineTitlesUseSharedControllerWhileControlsStayReadable(t *testing.T) {
|
||||
titles, queue, _ := titleControllerFixture(t)
|
||||
titles.state.owner.clear()
|
||||
titles.lastTitle = ""
|
||||
control := make(chan string, 2)
|
||||
owner := newWineEventPresenter(func(text string) bool { control <- text; return true })
|
||||
defer owner.Close()
|
||||
connectWineWindowTitles(owner, titles.live, &fakeAdapter{})
|
||||
owner.pending = &winePresentation{source: "control", window: "Wine game", message: "Play, button", parts: []string{"Play", "button"}}
|
||||
owner.flush()
|
||||
if got := <-control; got != "Play, button" {
|
||||
t.Fatalf("control included title: %q", got)
|
||||
}
|
||||
titles.wineChanged(<-titles.live.wineTitles)
|
||||
titles.flush()
|
||||
if len(queue.requests) != 1 || queue.requests[0].Text != "Wine game" {
|
||||
t.Fatal("Wine initial title missing")
|
||||
}
|
||||
settings := titles.live.Load()
|
||||
settings.Reading.AutomaticallyReadTitleChanges = false
|
||||
titles.live.Store(settings)
|
||||
owner.pending = &winePresentation{source: "control", window: "Wine game loading", message: "Play, button", parts: []string{"Play", "button"}}
|
||||
owner.flush()
|
||||
<-control
|
||||
titles.wineChanged(<-titles.live.wineTitles)
|
||||
titles.flush()
|
||||
if len(queue.requests) != 1 {
|
||||
t.Fatal("Wine helper bypassed preference")
|
||||
}
|
||||
owner.pending = &winePresentation{source: "window", window: "Wine game ready", message: "Wine game ready", parts: []string{"Wine game ready"}}
|
||||
owner.flush()
|
||||
titles.wineChanged(<-titles.live.wineTitles)
|
||||
titles.flush()
|
||||
select {
|
||||
case text := <-control:
|
||||
t.Fatalf("title-only helper focus bypassed preference: %q", text)
|
||||
default:
|
||||
}
|
||||
if len(queue.requests) != 1 {
|
||||
t.Fatal("title-only focus bypassed preference")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWineTitleReportsCannotClaimUnrelatedForeground(t *testing.T) {
|
||||
titles, queue, _ := titleControllerFixture(t)
|
||||
source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 10, TitleWindow: 10, Title: "Terminal"}}}
|
||||
titles.source, titles.foreground = source, source
|
||||
titles.wineChanged(wineWindowTitle{text: "Background game", foreground: source.title})
|
||||
titles.flush()
|
||||
if len(queue.requests) != 0 {
|
||||
t.Fatal("background helper title was announced")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user