// 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") } }) } }