250 lines
10 KiB
Go
250 lines
10 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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 TestSteamShaderProgressUsesSharedOutputAndGates(t *testing.T) {
|
|
for _, step := range []struct {
|
|
name, mode string
|
|
background, reportBackground bool
|
|
hidden, defunct, locked, stale bool
|
|
wantHandled, wantBeep, wantSpeech bool
|
|
}{
|
|
{name: "beep", mode: "beep", wantHandled: true, wantBeep: true},
|
|
{name: "speak", mode: "speak", wantHandled: true, wantSpeech: true},
|
|
{name: "both", mode: "both", wantHandled: true, wantBeep: true, wantSpeech: true},
|
|
{name: "off", mode: "off"},
|
|
{name: "background suppressed", mode: "both", background: true, wantHandled: true},
|
|
{name: "background enabled", mode: "both", background: true, reportBackground: true, wantHandled: true, wantBeep: true, wantSpeech: true},
|
|
{name: "hidden", mode: "both", hidden: true},
|
|
{name: "defunct", mode: "both", defunct: true},
|
|
{name: "secure", mode: "both", locked: true, wantHandled: true},
|
|
{name: "stale", mode: "both", stale: true, wantHandled: true},
|
|
} {
|
|
t.Run(step.name, func(t *testing.T) {
|
|
settings := config.DefaultSettings()
|
|
settings.ProgressBar.OutputMode = step.mode
|
|
settings.ProgressBar.ReportBackground = step.reportBackground
|
|
live := &liveSettings{}
|
|
live.Store(settings)
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
|
|
frames := frameRecorder{frames: make(chan braille.Frame, 8)}
|
|
state := &eventLoopState{}
|
|
state.current = atspi.Snapshot{BusName: ":1.1", Path: "/button", Role: "button"}
|
|
before := state.current
|
|
gate := &focusGate{}
|
|
gate.secure.Store(step.locked)
|
|
handler := &accessibilityEventHandler{state: state, live: live, speech: speech, focusGate: gate, brailleOutput: frames}
|
|
event := atspi.NewEvent(atspi.AccessibleNameEvent, atspi.Snapshot{BusName: ":1.1", Path: "/shader-status", Role: "static",
|
|
ApplicationProcess: "steamwebhelper", StatesKnown: true, Visible: true, Showing: !step.hidden, Defunct: step.defunct})
|
|
event.ForegroundMismatch = step.background
|
|
if step.stale {
|
|
handler.foreground = &titleProbeSource{}
|
|
state.foregroundState.Epoch = 1
|
|
}
|
|
for _, percentage := range []int{18, 18, 19, 100} {
|
|
event.Source.Name = fmt.Sprintf("Processing Vulkan shaders (%d%%)", percentage)
|
|
if handled := handler.handle_progress_bar(event); handled != step.wantHandled {
|
|
t.Fatalf("handled=%t, want %t", handled, step.wantHandled)
|
|
}
|
|
}
|
|
wantCount := 0
|
|
if step.wantBeep {
|
|
wantCount = 3
|
|
} else if step.wantSpeech {
|
|
wantCount = 2
|
|
}
|
|
if len(speech.requests) != wantCount {
|
|
t.Fatalf("got %d requests, want %d", len(speech.requests), wantCount)
|
|
}
|
|
if wantCount > 0 {
|
|
request := <-speech.requests
|
|
if !request.Append || (len(request.Sound) == 1) != step.wantBeep || (request.Text == "18 percent") != step.wantSpeech {
|
|
t.Fatalf("shader output: %+v", request)
|
|
}
|
|
}
|
|
if state.current.ID() != before.ID() || state.focusedID.Valid() || len(frames.frames) != 0 {
|
|
t.Fatal("shader progress changed focus or unrelated braille")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
})
|
|
}
|
|
}
|