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,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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user