250 lines
7.6 KiB
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")
|
|
}
|
|
}
|
|
}
|