Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
// 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"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
)
func TestProgressSecureEpochAndBrailleGates(t *testing.T) {
settings := config.DefaultSettings()
settings.ProgressBar.ReportBackground = true
live := &liveSettings{}
live.Store(settings)
speech := &fakeSpeechQueue{spoken: make(chan string, 4), requests: make(chan speechRequest, 4)}
frames := frameRecorder{frames: make(chan braille.Frame, 4)}
gate := &focusGate{}
state := &eventLoopState{}
event := atspi.NewEvent(atspi.ValueChangedEvent, atspi.Snapshot{BusName: ":1.1", Path: "/progress", Role: "progress bar", Name: "Copy", ValueKnown: true, CurrentValue: 25, MaximumValue: 100})
state.current = event.Source
handler := &accessibilityEventHandler{state: state, live: live, speech: speech, brailleOutput: frames, focusGate: gate}
gate.secure.Store(true)
handler.handle_progress_bar(event)
if len(speech.requests) != 0 || len(frames.frames) != 0 {
t.Fatal("progress bypassed secure input")
}
gate.secure.Store(false)
handler.foreground = &titleProbeSource{}
state.foregroundState.Epoch = 2
event.Foreground.Epoch = 1
handler.handle_progress_bar(event)
if len(speech.requests) != 0 || len(frames.frames) != 0 {
t.Fatal("stale epoch produced progress output")
}
event.Foreground.Epoch = 2
handler.handle_progress_bar(event)
if len(speech.requests) != 1 || len(frames.frames) != 1 {
t.Fatal("displayed progress failed to update")
}
if frame := <-frames.frames; frame.Text != "Copy 25% progress bar" {
t.Fatalf("progress braille: %+v", frame)
}
event.Source.Path, event.Source.CurrentValue = "/other-progress", 50
handler.handle_progress_bar(event)
if len(frames.frames) != 0 {
t.Fatal("unrelated progress took over braille")
}
}
func TestProgressShortcutCyclesNVDAOrderWithoutSaving(t *testing.T) {
settings := config.DefaultSettings()
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil)
worker.live = &liveSettings{}
worker.live.Store(settings)
message := ""
worker.announce = func(text string) { message = text }
for _, step := range []struct{ mode, message string }{
{"both", "Beep and speak progress bar updates"}, {"off", "No progress bar updates"},
{"speak", "Speak progress bar updates"}, {"beep", "Beep for progress bar updates"},
} {
worker.handleSpeechCommand(context.Background(), input.CycleProgressBarOutput)
if worker.live.Load().ProgressBar.OutputMode != step.mode || worker.globalSettings.ProgressBar.OutputMode != step.mode || message != step.message {
t.Fatalf("progress shortcut: mode=%s, message=%q", worker.settings.ProgressBar.OutputMode, message)
}
}
for _, layout := range []input.KeyboardLayout{input.DesktopKeyboardLayout, input.LaptopKeyboardLayout} {
for _, browse := range []bool{false, true} {
bindings, err := input.ResolveCommandBindings(layout, browse, nil)
chord := input.CommandChord{Symbol: 'u', CthulhuModifier: true}
if layout == input.LaptopKeyboardLayout {
chord.Modifiers = 1
if bindings[input.CommandChord{Symbol: 'u', CthulhuModifier: true}] != input.ReviewPreviousLine {
t.Fatal("laptop previous-line review changed")
}
}
if err != nil || bindings[chord] != input.CycleProgressBarOutput {
t.Fatalf("progress binding: layout=%v browse=%v err=%v", layout, browse, err)
}
}
}
}
func TestProgressThresholdsAreIndependentAndBidirectional(t *testing.T) {
var history ProgressBarHistory
settings := config.DefaultProgressBarSettings()
settings.OutputMode = "both"
id := atspi.NewObjectID(":1.1", "/progress/one")
for _, step := range []struct {
value float64
beep, speak bool
}{
{0, true, true}, {0, false, false}, {0.9, false, false}, {1, true, false},
{9.9, true, false}, {10, false, true}, {11, true, false},
{1, true, false}, {0, true, true}, {100, true, true}, {99, true, false},
} {
beep, speak := history.report(id, step.value, settings)
if beep != step.beep || speak != step.speak {
t.Fatalf("%g: beep=%t speech=%t", step.value, beep, speak)
}
}
if beep, speak := history.report(atspi.NewObjectID(":1.1", "/progress/two"), 99, settings); !beep || !speak {
t.Fatal("independent bar did not report its initial value")
}
for index := 0; index < 100; index++ {
history.report(atspi.NewObjectID(":1.1", "/progress/"+string(rune('A'+index))), 0, settings)
}
if len(history.entries) != 32 || len(history.order) != 32 {
t.Fatal("progress history is unbounded")
}
}
func TestProgressModesBackgroundAndFocusOwnership(t *testing.T) {
for _, mode := range []string{"off", "beep", "speak", "both"} {
t.Run(mode, func(t *testing.T) {
settings := config.DefaultSettings()
settings.ProgressBar.OutputMode = mode
settings.EnableSound, settings.PlaySoundForRole = false, false
live := &liveSettings{}
live.Store(settings)
speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
state := &eventLoopState{}
state.owner = windowOwner{window: atspi.NewObjectID(":1.1", "/window"), applicationBus: ":1.1", focus: atspi.NewObjectID(":1.1", "/button")}
before := state.owner
handler := &accessibilityEventHandler{state: state, live: live, speech: speech}
event := atspi.NewEvent(atspi.ValueChangedEvent, atspi.Snapshot{BusName: ":1.1", Path: "/progress", Role: "progress bar", ValueKnown: true, CurrentValue: 50.25, MaximumValue: 100, StatesKnown: true, Visible: true, Showing: true})
event.Window = state.owner.window
handled := handler.handle_progress_bar(event)
if mode == "off" {
if handled || len(speech.requests) != 0 {
t.Fatal("off bypassed focused-control fallback")
}
return
}
if !handled {
t.Fatal("unfocused foreground progress was not handled")
}
request := <-speech.requests
if !request.Append || request.Text != map[string]string{"beep": "", "speak": "50 percent", "both": "50 percent"}[mode] {
t.Fatalf("progress speech = %+v", request)
}
if (len(request.Sound) != 0) != (mode == "beep" || mode == "both") {
t.Fatalf("progress sounds = %+v", request.Sound)
}
if len(request.Sound) != 0 && request.Sound[0].Name != "generated-progress-tone:50.25" {
t.Fatal("fractional pitch was rounded")
}
if state.owner != before || state.focusedID.Valid() || state.current.ID().Valid() {
t.Fatal("progress stole focus")
}
event.Source.CurrentValue = 75
event.ForegroundMismatch = true
handler.handle_progress_bar(event)
if len(speech.requests) != 0 {
t.Fatal("background reporting enabled by default")
}
settings.ProgressBar.ReportBackground = true
live.Store(settings)
handler.handle_progress_bar(event)
if len(speech.requests) != 1 {
t.Fatal("opted-in background reporting was lost")
}
<-speech.requests
event.Source.Showing = false
if handler.handle_progress_bar(event) {
t.Fatal("offscreen progress did not use focused fallback")
}
event.Source.Showing, event.Source.Indeterminate = true, true
if handler.handle_progress_bar(event) {
t.Fatal("indeterminate progress generated output")
}
})
}
}