// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "os" "os/exec" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/braille" ) func TestNativeQtControlStates(t *testing.T) { version := os.Getenv("CTHULHU_NATIVE_QT_CONTROLS") if version != "5" && version != "6" { t.Skip("requires private Qt control fixture") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() native := atspi.New() if err := native.Start(ctx); err != nil { t.Fatal(err) } defer native.Stop(context.Background()) adapter := &menuRecordingAdapter{Adapter: native, events: make(chan atspi.Event, 256)} observed := make(chan atspi.Event, 256) pumpDone := make(chan struct{}) go func() { defer close(pumpDone) for { select { case event := <-native.Events(): select { case observed <- event: case <-ctx.Done(): return } select { case adapter.events <- event: case <-ctx.Done(): return } case <-ctx.Done(): return } } }() queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 256)} frames := make(chan braille.Frame, 256) done := make(chan struct{}) go func() { defer close(done) output := outputQueue{speech: queue, braille: frameRecorder{frames: frames}} processEventsWithInput(ctx, slog.New(slog.DiscardHandler), output, adapter, nil, nil, 0, nil, nil, nil, nil) }() defer func() { cancel(); <-done; <-pumpDone }() keys := func(chord string) { t.Helper() if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil { t.Fatalf("keys: %v %s", err, out) } } waitEvent := func(name, eventType string, checked bool) atspi.Event { t.Helper() for { select { case event := <-observed: if event.Source.Name == name { t.Logf("event: %s detail=%d checked=%v checkable=%v focused=%v window=%s", event.Type, event.Detail1, event.Source.Checked, event.Source.Checkable, event.Source.Focused, event.Window.Path) } if event.Source.Name == name && event.Type == eventType && event.Source.Checked == checked && (eventType != atspi.FocusedStateEvent || event.Detail1 == 1) { return event } case <-ctx.Done(): t.Fatalf("missing %s event for %s checked=%v", eventType, name, checked) } } } waitSpeech := func(want string) { t.Helper() timer := time.NewTimer(2 * time.Second) defer timer.Stop() for { select { case request := <-queue.requests: if request.Text == want || strings.HasSuffix(request.Text, ", "+want) { t.Logf("speech: %q", request.Text) return } case <-timer.C: t.Fatalf("missing speech %q", want) } } } waitBraille := func(want string) { t.Helper() timer := time.NewTimer(2 * time.Second) defer timer.Stop() for { select { case frame := <-frames: if frame.Text == want || strings.HasSuffix(frame.Text, " "+want) { t.Logf("Braille: %q", frame.Text) return } case <-timer.C: t.Fatalf("missing Braille %q", want) } } } // Qt 5 omits STATE_CHECKABLE. Checked-state evidence must survive the // next uncheck without assigning a state to unrelated menu commands. for _, probe := range []struct{ key, name string }{{"F6", "Optional feature"}, {"F8", "Context option"}} { for step, checked := range []bool{false, true, false} { keys(probe.key) event := waitEvent(probe.name, atspi.FocusedStateEvent, checked) if event.Source.Checkable != (version == "6" || step > 0) { t.Fatalf("Qt %s checkable metadata = %v", version, event.Source.Checkable) } role, suffix, indicator := "check menu item", ", not checked", "< > " if checked { suffix, indicator = ", checked", " " } else if version == "5" && step == 0 { role, suffix, indicator = "menu item", "", "" } waitSpeech(probe.name + ", " + role + suffix) brailleText := indicator + probe.name if role == "check menu item" { brailleText += " check menu item" } waitBraille(brailleText) if step < 2 { keys("Return") } else { keys("Escape") } } } keys("F7") waitEvent("Ordinary action", atspi.FocusedStateEvent, false) waitSpeech("Ordinary action, menu item") waitBraille("Ordinary action") keys("Escape") // Traverse between context-only entries and actions shared with the // menu bar, in both directions and after reopening the popup. for range 2 { keys("F8") waitEvent("Context option", atspi.FocusedStateEvent, false) waitSpeech("Context option, check menu item, not checked") for _, step := range []struct{ key, name string }{ {"Down", "Ordinary action"}, {"Down", "Context command"}, {"Down", "Optional feature"}, {"Up", "Context command"}, {"Up", "Ordinary action"}, {"Up", "Context option"}, } { keys(step.key) waitEvent(step.name, atspi.FocusedStateEvent, false) wantSpeech, wantBraille := step.name+", menu item", step.name if step.name == "Optional feature" || step.name == "Context option" { wantSpeech = step.name + ", check menu item, not checked" wantBraille = "< > " + step.name + " check menu item" } waitSpeech(wantSpeech) waitBraille(wantBraille) } keys("Escape") } // Qt can report only SHOWING=false for a closing submenu, even though // its parent action regains FOCUSED state. Restore that actual parent. for _, closeKey := range []string{"Left", "Escape"} { keys("F7") waitEvent("Ordinary action", atspi.FocusedStateEvent, false) waitSpeech("Ordinary action, menu item") keys("Down") waitEvent("Nested actions", atspi.FocusedStateEvent, false) waitSpeech("Nested actions, menu item") keys("Right") waitEvent("Nested command", atspi.FocusedStateEvent, false) waitSpeech("Nested command, menu item") keys(closeKey) // The normalized event preserves its native showing-event origin. for { select { case event := <-observed: if event.Source.Name == "Nested actions" && isFocusGainEvent(event) { goto returned } case <-ctx.Done(): t.Fatal("missing focus return from submenu") } } returned: waitSpeech("Nested actions, menu item") waitBraille("Nested actions") keys("Escape") } if version == "6" { // When the provider exports CHECKABLE, withdrawing that capability is // authoritative and must retire the prior evidence. keys("F12") keys("F6") event := waitEvent("Optional feature", atspi.FocusedStateEvent, false) if event.Source.Checkable { t.Fatal("explicit removal of checkability was ignored") } waitSpeech("Optional feature, menu item") waitBraille("Optional feature") keys("Escape") } // Replacing an action with an ordinary action of the same name must not // inherit the removed accessible's checkability evidence. keys("F11") keys("F6") event := waitEvent("Optional feature", atspi.FocusedStateEvent, false) if event.Source.Checkable { t.Fatal("replacement action inherited checkability") } waitSpeech("Optional feature, menu item") waitBraille("Optional feature") keys("Escape") for _, probe := range []struct{ key, name string }{{"F9", "Updates"}, {"F10", "Power"}} { if probe.key == "F9" { keys("F10") } else { keys("F9") } keys(probe.key) waitEvent(probe.name, atspi.FocusedStateEvent, false) waitSpeech(probe.name + ", check box, not checked") for _, checked := range []bool{true, false} { keys("space") waitEvent(probe.name, "object:state-changed:checked", checked) want, indicator := "not checked", "< > " if checked { want, indicator = "checked", " " } waitSpeech(want) waitBraille(indicator + probe.name + " check box") } } }