Files
cthulhu/internal/app/qt_controls_native_test.go

250 lines
7.6 KiB
Go

// 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", "<x> "
} 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", "<x> "
}
waitSpeech(want)
waitBraille(indicator + probe.name + " check box")
}
}
}