1690 lines
107 KiB
Python
1690 lines
107 KiB
Python
#!/usr/bin/env python3
|
|
"""Private Xvfb/AT-SPI proof for the Go GTK preferences helper."""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import select
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
sys.dont_write_bytecode = True
|
|
from native_x11 import wait_for
|
|
from native_test_session import run_private_session
|
|
|
|
|
|
class ResponseReader:
|
|
"""Read complete JSON lines with one deadline, preserving read-ahead bytes."""
|
|
|
|
def __init__(self, stream):
|
|
self.stream = stream
|
|
self.pendingData = bytearray()
|
|
|
|
def request(self, timeout=5):
|
|
deadline = time.monotonic() + timeout
|
|
while b"\n" not in self.pendingData:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0 or not select.select([self.stream], [], [], remaining)[0]:
|
|
raise TimeoutError("no complete preferences request within response timeout")
|
|
# Never mix buffered reads with select: TextIO can retain another
|
|
# complete reply even when the underlying pipe is empty.
|
|
chunk = os.read(self.stream.fileno(), 65536)
|
|
if not chunk:
|
|
raise EOFError("preferences helper closed stdout before a complete request")
|
|
self.pendingData.extend(chunk)
|
|
end = self.pendingData.index(b"\n") + 1
|
|
line = self.pendingData[:end]
|
|
del self.pendingData[:end]
|
|
return json.loads(line)
|
|
|
|
def read_remaining(self):
|
|
# Used only after helper exit; include any bytes read ahead by request.
|
|
remaining = bytes(self.pendingData) + self.stream.buffer.read()
|
|
self.pendingData.clear()
|
|
return remaining.decode("utf-8")
|
|
|
|
|
|
def inside():
|
|
if os.environ.get("CTHULHU_PREFS_NO_CONFIG_ONLY") == "1":
|
|
from native_echo_preview import check_runtime
|
|
check_runtime(noConfigOnly=True)
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_INTERRUPTION_RUNTIME_ONLY") == "1":
|
|
from native_echo_preview import check_runtime
|
|
check_runtime(interruptionOnly=True)
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_FLAT_REVIEW_ONLY") == "1":
|
|
from native_echo_preview import check_runtime
|
|
check_runtime(flatReviewOnly=True)
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_RADIO_RUNTIME_ONLY") == "1":
|
|
from native_echo_preview import check_runtime
|
|
check_runtime(radioOnly=True)
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_ECHO_RUNTIME_ONLY") == "1":
|
|
from native_echo_preview import check_runtime
|
|
check_runtime()
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_ADAPTER_ONLY") == "1":
|
|
environment = dict(os.environ, CTHULHU_NATIVE_PREFERENCES="1")
|
|
environment.setdefault("GOCACHE", "/tmp/cthulhu-cache")
|
|
subprocess.run(["go", "test", "./internal/atspi", "-run",
|
|
"^TestNativePreferencesDropdownEscape$", "-count=1", "-v", "-timeout=30s"],
|
|
cwd=Path(__file__).resolve().parents[1], env=environment,
|
|
check=True, timeout=35)
|
|
return
|
|
import pyatspi
|
|
window_manager = None
|
|
reader_process = None
|
|
private_directory = None
|
|
if os.environ.get("CTHULHU_PREFS_HARDWARE_ONLY") == "1" or os.environ.get("CTHULHU_PREFS_APPLY_ONLY") == "1":
|
|
private_directory = tempfile.TemporaryDirectory(prefix="cthulhu-prefs-hardware-")
|
|
wm_config = Path(private_directory.name) / "i3.config"
|
|
wm_socket = Path(private_directory.name) / "i3.sock"
|
|
wm_config.write_text(f"font pango:monospace 10\nipc-socket {wm_socket}\n")
|
|
window_manager = subprocess.Popen(["i3", "-c", str(wm_config)],
|
|
env=dict(os.environ, I3SOCK=str(wm_socket)),
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
wait_for(lambda: "0x" in subprocess.check_output(["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"], text=True), "private i3 failed to start")
|
|
application = os.environ.get("CTHULHU_PREFS_APPLICATION", "")
|
|
window_name = "Cthulhu Preferences for " + application if application else "Cthulhu Preferences"
|
|
binary = os.environ.get("CTHULHU_BINARY", str(Path(__file__).resolve().parents[1] / "cthulhu"))
|
|
process = subprocess.Popen([binary, "--preferences-ui"], stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE, text=True)
|
|
responses = ResponseReader(process.stdout)
|
|
initial = {"Values": {"Rate": 50, "Pitch": 5, "Volume": 5,
|
|
"SpeechHistoryLimit": 50,
|
|
"Reading": {"AutomaticallyReadTitleChanges": True,
|
|
"I3Modes": True, "I3Workspaces": True, "I3WindowStates": True,
|
|
"I3Urgency": True, "I3Marks": True, "I3WorkspaceMoves": True},
|
|
"KeyboardLayout": 1, "CthulhuModifierKeys": ["Insert", "KP_Insert"],
|
|
"Punctuation": 1, "RaisePitchForCapitals": True,
|
|
"BeepForCapitals": False, "SayCapForCapitals": False,
|
|
"TimeFormat": "24-hour", "DateFormat": "ymd",
|
|
"EnableSound": True, "SoundVolume": 0.5,
|
|
"SpeechVerbosityLevel": 1,
|
|
"SayAllStyle": "line",
|
|
"SoundSink": "auto", "SoundTheme": "default",
|
|
"PlaySoundForRole": True, "PlaySoundForState": True,
|
|
"PlaySoundForPositionInSet": True,
|
|
"SpatializeObjectSounds": False,
|
|
"ProgressBar": {"OutputMode": "beep", "ReportBackground": False,
|
|
"BeepPercentageInterval": 1, "SpeechPercentageInterval": 10},
|
|
"RoleSoundPresentation": "sound_and_speech",
|
|
"Braille": {"Verbosity": 1, "SelectionIndicator": 192,
|
|
"LinkIndicator": 192, "FlashMessages": True,
|
|
"FlashTime": 5000, "FlashDetailed": True},
|
|
"Pronunciations": [{"Word": "SQL", "Replacement": "sequel"}],
|
|
"TextAttributes": {"Indicator": 0, "Choices": [
|
|
{"Key": key, "Speak": True, "Braille": False}
|
|
for key in ("size", "family-name", "weight", "indent", "underline",
|
|
"strikethrough", "justification", "style", "paragraph-style",
|
|
"text-spelling", "fg-color", "bg-color")]},
|
|
"EnableKeyEcho": True, "EnableAlphabeticKeys": True,
|
|
"EnableEchoByWord": True, "UseCustomEchoForKey": True},
|
|
"Catalog": {"": [{"Name": "Test voice", "Language": "en", "Variant": "none"}],
|
|
"test-module": [{"Name": "English test voice", "Language": "en", "Variant": "none"},
|
|
{"Name": "Spanish test voice", "Language": "es", "Variant": "none"}]},
|
|
"CanSave": True, "Accepted": True, "Message": "Preferences test ready."}
|
|
echoFocusMode = os.environ.get("CTHULHU_PREFS_ECHO_PREVIEW_ONLY") == "1"
|
|
echo_mode = os.environ.get("CTHULHU_PREFS_KEY_ECHO_ONLY") or ("custom" if echoFocusMode else None)
|
|
if echo_mode:
|
|
initial["Catalog"]["echo-module"] = [
|
|
{"Name": "Echo test voice", "Language": "en", "Variant": "none"}]
|
|
initial["Values"].update(
|
|
SpeechOutputModule="test-module", EchoSpeechServer="echo-module",
|
|
UseCustomEchoVoice=echo_mode == "custom",
|
|
UseCustomEchoSpeechServer=echo_mode == "custom",
|
|
EnableEchoByCharacter=True, EnableEchoBySentence=True,
|
|
UseCustomEchoForCharacter=True)
|
|
if os.environ.get("CTHULHU_PREFS_SPEECH_ONLY") == "1":
|
|
initial["Catalog"]["espeak-ng"] = list(initial["Catalog"]["test-module"])
|
|
if os.environ.get("CTHULHU_PREFS_NUMBERS_ONLY") == "1":
|
|
initial["Values"].update(UseCustomEchoVoice=True,
|
|
EchoVoice={"Rate": 50, "AveragePitch": 5, "Gain": 5})
|
|
if os.environ.get("CTHULHU_PREFS_LARGE_CATALOG") == "1":
|
|
initial["Catalog"]["espeak-ng"].extend(
|
|
{"Name": f"Test voice {index}", "Language": "en" if index < 100 else "de", "Variant": "none"}
|
|
for index in range(12000))
|
|
initial["Catalog"]["espeak-ng"][-1]["Language"] = "fr"
|
|
# Speech Dispatcher also lists its default eSpeak catalog under "".
|
|
initial["Catalog"][""] = list(initial["Catalog"]["espeak-ng"])
|
|
initial["Values"].update(UseCustomEchoVoice=True,
|
|
EchoVoice={"Family": {"Name": "Spanish test voice", "Lang": "es"}})
|
|
if os.environ.get("CTHULHU_PREFS_TENTACLES_ONLY") == "1":
|
|
initial["Tentacles"] = {"Entries": [], "Suppressed": False}
|
|
if os.environ.get("CTHULHU_PREFS_TENTACLE_UPDATES_ONLY") == "1":
|
|
from native_tentacle_updates import update_snapshot
|
|
initial["Tentacles"] = update_snapshot()
|
|
if os.environ.get("CTHULHU_PREFS_TENTACLE_SETTINGS_ONLY") == "1":
|
|
initial["Tentacles"] = {"Entries": [{"Entry": {"ID": "probe", "Name": "Settings probe", "Enabled": True},
|
|
"State": "active", "Shortcuts": ["Desktop: Cthulhu+Ctrl+Shift+W (Probe menu)"]}], "Suppressed": False}
|
|
if os.environ.get("CTHULHU_PREFS_EXTENSIONS_ONLY") == "1":
|
|
from native_tentacle_extensions import probe_pages
|
|
initial["ExtensionPages"] = probe_pages()
|
|
if os.environ.get("CTHULHU_PREFS_WINE_ONLY") == "1":
|
|
initial["Values"]["Wine"] = {"ControllerOnly": False,
|
|
"ExcludedPrograms": ["Game.exe"]}
|
|
if application:
|
|
initial["Application"] = application
|
|
def send(value):
|
|
process.stdin.write(json.dumps(value) + "\n")
|
|
process.stdin.flush()
|
|
def send_initial(value):
|
|
if os.environ.get("CTHULHU_PREFS_LARGE_CATALOG") != "1":
|
|
send(value)
|
|
return
|
|
# Exercise the helper's bounded catalog protocol with a full catalog
|
|
# larger than one scanner line, including a duplicate default module.
|
|
assert len(json.dumps(value)) > 1024 * 1024
|
|
metadata = dict(value, Catalog=None, CatalogPending=True)
|
|
send(metadata)
|
|
for module, voices in value["Catalog"].items():
|
|
for offset in range(0, len(voices), 128):
|
|
send({"Catalog": {module: voices[offset:offset + 128]}, "CatalogPending": True})
|
|
send({"CatalogPending": False})
|
|
def walk(node):
|
|
yield node
|
|
for child in node:
|
|
if child is not None:
|
|
yield from walk(child)
|
|
def find_window():
|
|
for app in pyatspi.Registry.getDesktop(0):
|
|
for node in app:
|
|
if node and node.name == window_name:
|
|
return node
|
|
return None
|
|
nodes = []
|
|
controls = {}
|
|
def named(name):
|
|
return controls[name]
|
|
def key(*keys):
|
|
subprocess.run(["xdotool", "key", "--clearmodifiers", *keys], check=True)
|
|
def focus(node):
|
|
assert node.queryComponent().grabFocus(), ("cannot focus", node.name)
|
|
wait_for(lambda: any(child.getState().contains(pyatspi.STATE_FOCUSED) for child in walk(node)), "focus missing")
|
|
def request():
|
|
while True:
|
|
value = responses.request()
|
|
if value.get("EchoPreviewFocus") and not echoFocusMode:
|
|
send({"Preview": True, "Accepted": True})
|
|
continue
|
|
return value
|
|
try:
|
|
send_initial(initial)
|
|
window = wait_for(find_window, "preferences accessible window missing")
|
|
nodes = list(walk(window))
|
|
for node in nodes:
|
|
if node.name:
|
|
controls.setdefault(node.name, node)
|
|
xid = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", "^" + window_name + "$"], text=True).splitlines()[0]
|
|
subprocess.run(["xdotool", "windowfocus", xid], check=True)
|
|
assert not any(node.name == "Profiles" for node in nodes), "removed profile controls remain in preferences"
|
|
if os.environ.get("CTHULHU_PREFS_INTERRUPTION_ONLY") == "1":
|
|
from native_x11 import press_physical_key
|
|
tabs = next(node for node in nodes if node.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
|
|
assert tabs.querySelection().selectChild(2), "cannot select Speech tab"
|
|
allKeys = named("All keys interrupt")
|
|
commonKeys = named("Control, Enter, Space, and Escape")
|
|
customKeys = named("Define interruption keys")
|
|
recordButton = named("Record interruption keys")
|
|
summary = named("Defined interruption keys")
|
|
history = named("Speech history entries")
|
|
statusNode = named(initial["Message"])
|
|
applyCount = 0
|
|
|
|
def is_focused(node):
|
|
return any(child.getState().contains(pyatspi.STATE_FOCUSED) for child in walk(node))
|
|
|
|
def apply_interruption(mode, expectedKeys):
|
|
nonlocal applyCount
|
|
focus(named("Apply")); key("space")
|
|
applied = request()
|
|
assert applied["Save"] and not applied["Close"], applied
|
|
values = applied["Values"]
|
|
assert values["SpeechInterruption"] == {"Mode": mode, "Keys": expectedKeys}, values["SpeechInterruption"]
|
|
applyCount += 1
|
|
message = f"Interruption settings saved {applyCount}."
|
|
send(dict(initial, Values=values, Message=message))
|
|
wait_for(lambda: statusNode.name == message and named("Apply").getState().contains(pyatspi.STATE_SENSITIVE),
|
|
"Apply response did not finish reloading the draft")
|
|
return values
|
|
|
|
def recorder():
|
|
for app in pyatspi.Registry.getDesktop(0):
|
|
for child in app:
|
|
if child and child.name == "Record interruption keys" and child.getRole() == pyatspi.ROLE_DIALOG:
|
|
return child
|
|
return None
|
|
|
|
def begin_recording():
|
|
focus(recordButton); key("space")
|
|
dialog = wait_for(recorder, "key recorder did not open")
|
|
dialogID = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", "^Record interruption keys$"], text=True).splitlines()[0]
|
|
subprocess.run(["xdotool", "windowfocus", dialogID], check=True)
|
|
assert "Control+Enter" in dialog.description and "Control+Escape" in dialog.description
|
|
return dialog
|
|
|
|
assert allKeys.getState().contains(pyatspi.STATE_CHECKED)
|
|
assert not recordButton.getState().contains(pyatspi.STATE_SENSITIVE)
|
|
apply_interruption("all", None)
|
|
focus(named("Verbose")); key("Tab")
|
|
wait_for(lambda: is_focused(allKeys), "Tab did not reach interruption group; focused: " +
|
|
repr([(node.name, node.getRoleName()) for node in walk(window) if node.getState().contains(pyatspi.STATE_FOCUSED)]))
|
|
key("Down")
|
|
wait_for(lambda: commonKeys.getState().contains(pyatspi.STATE_CHECKED), "arrow did not select common keys")
|
|
apply_interruption("common", None)
|
|
focus(commonKeys); key("Down")
|
|
wait_for(lambda: customKeys.getState().contains(pyatspi.STATE_CHECKED), "arrow did not select custom keys")
|
|
key("Tab")
|
|
wait_for(lambda: is_focused(recordButton), "Tab did not reach recording button")
|
|
key("Tab")
|
|
wait_for(lambda: is_focused(summary), "Tab did not reach defined keys")
|
|
key("Home", "Right")
|
|
wait_for(lambda: summary.queryText().caretOffset == 1, "read-only summary did not move its caret")
|
|
key("Tab")
|
|
wait_for(lambda: is_focused(history), "defined keys trapped Tab")
|
|
key("shift+Tab", "shift+Tab", "shift+Tab")
|
|
wait_for(lambda: is_focused(customKeys), "Shift+Tab did not return through new controls")
|
|
dialog = begin_recording()
|
|
key("Tab", "Return", "space", "Escape", "Tab")
|
|
press_physical_key(0xFFE1) # Left Shift, without synthesized modifiers.
|
|
press_physical_key(0xFFE4) # Right Control.
|
|
press_physical_key(0xFF55) # Page Up: GTK calls this Page_Up, X11 calls it Prior.
|
|
press_physical_key(0x1008FF12) # Audio mute: GTK omits the XF86 prefix.
|
|
assert recorder() is not None, "a recordable key closed the recorder"
|
|
key("ctrl+Return")
|
|
wait_for(lambda: recorder() is None, "Control+Enter did not finish")
|
|
wait_for(lambda: is_focused(recordButton), "recording did not return focus to button")
|
|
expectedKeys = ["Tab", "Return", "space", "Escape", "Shift_L", "Control_R", "Prior", "XF86AudioMute"]
|
|
saved = apply_interruption("custom", expectedKeys)
|
|
assert customKeys.getState().contains(pyatspi.STATE_CHECKED), "Apply lost custom mode"
|
|
assert "Tab" in summary.queryText().getText(0, -1), "Apply lost displayed keys"
|
|
begin_recording(); key("F8", "ctrl+Escape")
|
|
wait_for(lambda: recorder() is None, "Control+Escape did not cancel")
|
|
wait_for(lambda: is_focused(recordButton), "cancellation did not return focus")
|
|
apply_interruption("custom", expectedKeys)
|
|
begin_recording(); key("ctrl+Return")
|
|
assert recorder() is not None, "empty recording was accepted"
|
|
key("F8", "ctrl+Return")
|
|
wait_for(lambda: recorder() is None, "replacement recording did not finish")
|
|
apply_interruption("custom", ["F8"])
|
|
# Non-custom modes preserve the defined list for later reuse.
|
|
focus(customKeys); key("Up", "Up")
|
|
wait_for(lambda: allKeys.getState().contains(pyatspi.STATE_CHECKED), "arrows did not return to All keys interrupt")
|
|
apply_interruption("all", ["F8"])
|
|
focus(named("Cancel")); key("space")
|
|
assert process.wait(timeout=5) == 0
|
|
print("PASS: interruption modes, keyboard traversal, Tab/Enter/Space/Escape and modifier recording, finish/cancel, duplicates, replacement, Apply and draft reload")
|
|
return
|
|
if echoFocusMode:
|
|
tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
|
|
assert tabs.querySelection().selectChild(4), "cannot select Key Echo tab"
|
|
def check_preview(control, isFocus):
|
|
value = request()
|
|
assert value["Preview"] and value.get("EchoPreview") == control, value
|
|
assert bool(value.get("EchoPreviewFocus")) == isFocus, value
|
|
send({"Preview": True, "Accepted": True})
|
|
return value["Values"]
|
|
focus(named("Use custom echo voice"))
|
|
key("Tab")
|
|
check_preview("synthesizer", True)
|
|
key("Down")
|
|
value = check_preview("synthesizer", False)
|
|
assert value["EchoSpeechServer"] == "test-module", value
|
|
key("Up")
|
|
value = check_preview("synthesizer", False)
|
|
assert value["EchoSpeechServer"] == "echo-module", value
|
|
key("Tab")
|
|
check_preview("person", True)
|
|
key("Down")
|
|
value = check_preview("person", False)
|
|
assert value["EchoVoice"]["Family"]["Name"] == "Echo test voice", value
|
|
for control in ("rate", "pitch", "volume"):
|
|
key("Tab")
|
|
check_preview(control, True)
|
|
key("Right")
|
|
check_preview(control, False)
|
|
for control in ("pitch", "rate", "person", "synthesizer"):
|
|
key("shift+Tab")
|
|
check_preview(control, True)
|
|
# Popup Escape restores the same combo without trapping focus.
|
|
key("alt+Down", "Escape", "Tab")
|
|
check_preview("person", True)
|
|
focus(named("Cancel")); key("space")
|
|
assert process.wait(timeout=5) == 0
|
|
print("PASS: all five echo controls preview their current values on Tab/Shift+Tab and changed values on arrows; popup Escape and Cancel")
|
|
return
|
|
if echo_mode:
|
|
assert "Use custom speech synthesizer for key echo" not in controls
|
|
assert "Use custom voice for key echo" not in controls
|
|
assert "Use the custom voice for:" in controls
|
|
tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
|
|
assert tabs.querySelection().selectChild(4), "cannot select Key Echo tab"
|
|
custom = named("Use custom echo voice")
|
|
synth = named("Key echo speech synthesizer")
|
|
family = named("Key echo person")
|
|
rate = named("Key echo rate")
|
|
expected_module = "echo-module" if echo_mode == "custom" else "test-module"
|
|
assert synth.querySelection().getSelectedChild(0).name == expected_module
|
|
if echo_mode != "custom":
|
|
# An unused saved echo module must not override the main one.
|
|
assert not synth.getState().contains(pyatspi.STATE_SENSITIVE)
|
|
assert not family.getState().contains(pyatspi.STATE_SENSITIVE)
|
|
focus(custom); key("space")
|
|
wait_for(lambda: synth.getState().contains(pyatspi.STATE_SENSITIVE),
|
|
"custom echo did not enable the synthesizer directly")
|
|
def focused(node):
|
|
return any(child.getState().contains(pyatspi.STATE_FOCUSED) for child in walk(node))
|
|
# GTK's internal combo button does not consistently expose its
|
|
# focused state. Exercise the popup, value and following Tab stop.
|
|
focus(custom); key("Tab", "alt+Down", "Home", "Down")
|
|
if echo_mode != "custom":
|
|
key("Down")
|
|
key("Return")
|
|
wait_for(lambda: synth.description == expected_module, "Tab did not reach echo synthesizer")
|
|
key("Tab", "Tab")
|
|
wait_for(lambda: focused(rate), "voice selector trapped Tab")
|
|
key("shift+Tab", "shift+Tab")
|
|
key("shift+Tab")
|
|
wait_for(lambda: focused(custom), "synthesizer trapped Shift+Tab")
|
|
assert named("Key names").getState().contains(pyatspi.STATE_CHECKED)
|
|
assert named("Typed characters").getState().contains(pyatspi.STATE_CHECKED)
|
|
for name in ("Key names", "Typed characters", "Words", "Sentences"):
|
|
assert named(name).getRole() == pyatspi.ROLE_CHECK_BOX
|
|
assert named(name).getState().contains(pyatspi.STATE_SENSITIVE)
|
|
focus(named("Words")); key("space")
|
|
def apply_echo():
|
|
focus(named("Apply")); key("space")
|
|
while True:
|
|
applied = request()
|
|
if applied["Preview"]:
|
|
send({"Preview": True, "Accepted": True})
|
|
continue
|
|
assert applied["Save"] and not applied["Close"], applied
|
|
values = applied["Values"]
|
|
send(dict(initial, Values=values, Message="Echo settings saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE),
|
|
"echo Apply stayed busy")
|
|
return values
|
|
saved = apply_echo()
|
|
assert saved["SpeechOutputModule"] == "test-module", saved
|
|
assert saved["UseCustomEchoVoice"] and saved["UseCustomEchoSpeechServer"], saved
|
|
assert saved["EchoSpeechServer"] == expected_module, saved
|
|
assert saved["UseCustomEchoForKey"] and saved["UseCustomEchoForCharacter"], saved
|
|
assert saved["UseCustomEchoForWord"] and not saved["UseCustomEchoForSentence"], saved
|
|
# Select Default first to guarantee a change in either startup
|
|
# mode. Only the selected combo value gets an echo preview.
|
|
def accept_echo_preview(control, module):
|
|
preview = request()
|
|
assert preview["Preview"] and preview.get("EchoPreview") == control, preview
|
|
assert preview["Values"]["EchoSpeechServer"] == module, preview
|
|
send({"Preview": True, "Accepted": True})
|
|
return preview["Values"]
|
|
focus(custom); key("Tab", "alt+Down", "Home", "Return")
|
|
preview = accept_echo_preview("synthesizer", "")
|
|
assert preview["EchoVoice"]["Family"] is None, preview
|
|
# Change the synthesizer using its keyboard popup, then select a
|
|
# person and verify each sends its newly selected draft voice.
|
|
focus(custom); key("Tab", "alt+Down", "Home", "Down", "Return")
|
|
accept_echo_preview("synthesizer", "echo-module")
|
|
wait_for(lambda: synth.description == "echo-module", "echo synthesizer did not change")
|
|
wait_for(lambda: any(child.name == "Echo test voice (en)" for child in walk(family)),
|
|
"echo voices did not follow the selected synthesizer")
|
|
key("Tab", "alt+Down", "Home", "Down", "Return")
|
|
preview = accept_echo_preview("person", "echo-module")
|
|
assert preview["EchoVoice"]["Family"]["Name"] == "Echo test voice", preview
|
|
saved = apply_echo()
|
|
assert saved["EchoSpeechServer"] == "echo-module", saved
|
|
assert saved["EchoVoice"]["Family"]["Name"] == "Echo test voice", saved
|
|
assert synth.querySelection().getSelectedChild(0).name == "echo-module"
|
|
assert family.querySelection().getSelectedChild(0).name == "Echo test voice (en)"
|
|
focus(custom); key("space")
|
|
wait_for(lambda: not synth.getState().contains(pyatspi.STATE_SENSITIVE),
|
|
"custom echo off left the synthesizer enabled")
|
|
saved = apply_echo()
|
|
assert not saved["UseCustomEchoVoice"] and saved["EchoSpeechServer"] == "echo-module", saved
|
|
focus(custom); key("space")
|
|
saved = apply_echo()
|
|
assert saved["UseCustomEchoVoice"] and saved["EchoSpeechServer"] == "echo-module", saved
|
|
assert saved["EchoVoice"]["Family"]["Name"] == "Echo test voice", saved
|
|
focus(named("Cancel")); key("space")
|
|
assert process.wait(timeout=5) == 0
|
|
print("PASS: echo synthesizer initialization, keyboard navigation, synthesizer/person previews, routing choices, Apply/reload, disable/re-enable")
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_EXTENSIONS_ONLY") == "1":
|
|
from native_tentacle_extensions import check_page
|
|
check_page(pyatspi, process, window, nodes, walk, focus, key, request, send, initial)
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_TENTACLE_SETTINGS_ONLY") == "1":
|
|
from native_tentacle_settings import check_page
|
|
check_page(pyatspi, window, nodes, named, walk, focus, key, request, send, initial)
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_TENTACLES_ONLY") == "1":
|
|
from native_tentacles import check_page
|
|
check_page(pyatspi, process, window, nodes, named, walk, focus, key,
|
|
request, send, initial, application)
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_TENTACLE_UPDATES_ONLY") == "1":
|
|
from native_tentacle_updates import check_page
|
|
check_page(pyatspi, process, window, named, walk, focus, key,
|
|
request, send, initial)
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_OCR_ONLY") == "1":
|
|
tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
|
|
assert tabs.querySelection().selectChild(8), "cannot select OCR tab"
|
|
wait_for(lambda: named("OCR").getState().contains(pyatspi.STATE_SELECTED), "OCR page missing")
|
|
language = named("OCR recognition languages")
|
|
layout = named("OCR text layout")
|
|
scale = named("OCR scale factor")
|
|
assert layout.getRole() == pyatspi.ROLE_COMBO_BOX
|
|
focus(language); key("Tab", "Tab")
|
|
wait_for(lambda: scale.getState().contains(pyatspi.STATE_FOCUSED),
|
|
"OCR text layout trapped Tab")
|
|
key("shift+Tab", "shift+Tab")
|
|
wait_for(lambda: language.getState().contains(pyatspi.STATE_FOCUSED),
|
|
"OCR text layout trapped Shift+Tab")
|
|
key("Tab", "alt+Down", "Down", "Return")
|
|
wait_for(lambda: layout.description == "Single text block", "OCR layout choice did not change")
|
|
key("Tab")
|
|
wait_for(lambda: scale.getState().contains(pyatspi.STATE_FOCUSED),
|
|
"OCR layout popup trapped Tab after selection")
|
|
automatic = named("Automatically read the first OCR result")
|
|
assert automatic.getRole() == pyatspi.ROLE_CHECK_BOX
|
|
assert not automatic.getState().contains(pyatspi.STATE_CHECKED), "automatic reading should default off"
|
|
focus(automatic); key("space")
|
|
key("Tab")
|
|
wait_for(lambda: named("Periodically refresh screen OCR").getState().contains(pyatspi.STATE_FOCUSED),
|
|
"Tab did not reach periodic refresh")
|
|
key("shift+Tab")
|
|
wait_for(lambda: automatic.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not return to automatic reading")
|
|
focus(named("Apply")); key("space")
|
|
applied = request()
|
|
assert applied["Values"]["OCR"]["AutoReadAll"], applied
|
|
assert applied["Values"]["OCR"]["Layout"] == 6, applied
|
|
send(dict(initial, Values=applied["Values"]))
|
|
wait_for(lambda: automatic.getState().contains(pyatspi.STATE_CHECKED), "applied value was lost")
|
|
assert layout.description == "Single text block", "applied OCR layout was lost"
|
|
send(initial)
|
|
wait_for(lambda: not automatic.getState().contains(pyatspi.STATE_CHECKED), "rollback did not restore automatic reading")
|
|
focus(automatic); key("space")
|
|
key("Escape")
|
|
assert process.wait(timeout=2) == 0, "Escape did not close OCR preferences"
|
|
assert responses.read_remaining() == "", "cancel submitted changed OCR settings"
|
|
print("PASS: OCR layout Tab/Shift+Tab, popup selection, automatic reading checkbox, Apply, rollback, Cancel")
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_TEXT_ONLY") == "1":
|
|
tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
|
|
assert tabs.querySelection().selectChild(6), "cannot select Pronunciation tab"
|
|
wait_for(lambda: named("Pronunciation").getState().contains(pyatspi.STATE_SELECTED), "Pronunciation page missing")
|
|
dictionary = named("Pronunciation dictionary").queryTable()
|
|
assert dictionary.nRows == 1 and dictionary.getAccessibleAt(0, 0).name == "SQL", dictionary.nRows
|
|
focus(named("New entry")); key("space")
|
|
wait_for(lambda: dictionary.nRows == 2, "New entry did not add a row")
|
|
subprocess.run(["xdotool", "type", "--clearmodifiers", "NVDA"], check=True)
|
|
key("Return")
|
|
focus(dictionary.getAccessibleAt(1, 1)); key("F2")
|
|
subprocess.run(["xdotool", "type", "--clearmodifiers", "en vee dee ay"], check=True)
|
|
key("Return")
|
|
wait_for(lambda: dictionary.getAccessibleAt(1, 0).name == "NVDA" and
|
|
dictionary.getAccessibleAt(1, 1).name == "en vee dee ay", "inline pronunciation editing failed")
|
|
focus(named("Delete")); key("space")
|
|
wait_for(lambda: dictionary.nRows == 1, "Delete did not remove the selected row")
|
|
assert tabs.querySelection().selectChild(7), "cannot select Text Attributes tab"
|
|
wait_for(lambda: named("Text Attributes").getState().contains(pyatspi.STATE_SELECTED), "Text Attributes page missing")
|
|
table = named("Text attributes").queryTable()
|
|
assert table.nRows == 12 and table.nColumns == 3, (table.nRows, table.nColumns)
|
|
assert table.getAccessibleAt(0, 0).name == "Font size"
|
|
focus(table.getAccessibleAt(0, 1)); key("space")
|
|
wait_for(lambda: not table.getAccessibleAt(0, 1).getState().contains(pyatspi.STATE_CHECKED), "Speak did not toggle")
|
|
focus(table.getAccessibleAt(0, 2)); key("space")
|
|
wait_for(lambda: table.getAccessibleAt(0, 2).getState().contains(pyatspi.STATE_CHECKED), "Braille did not toggle")
|
|
focus(named("Move to bottom")); key("space")
|
|
wait_for(lambda: table.getAccessibleAt(11, 0).name == "Font size", "Move to bottom did not reorder")
|
|
focus(named("Braille indicator: Dot 7")); key("space")
|
|
focus(named("Apply")); key("space")
|
|
applied = request()
|
|
assert applied["Values"]["Pronunciations"] == initial["Values"]["Pronunciations"], applied
|
|
attr = applied["Values"]["TextAttributes"]
|
|
assert attr["Indicator"] == 64 and attr["Choices"][-1]["Key"] == "size" and not attr["Choices"][-1]["Speak"] and attr["Choices"][-1]["Braille"], attr
|
|
send(dict(initial, Values=applied["Values"], Message="Text preferences saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply stayed busy")
|
|
focus(named("Reset")); key("space")
|
|
wait_for(lambda: table.getAccessibleAt(0, 0).name == "Font size", "Reset did not restore order")
|
|
focus(named("Cancel")); key("space")
|
|
assert process.wait(timeout=2) == 0
|
|
print("PASS: pronunciation and text attribute tables, add/delete, selection, order, indicator, Apply/reload, reset")
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_WINE_ONLY") == "1":
|
|
tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
|
|
assert not application or all(
|
|
tabs.getChildAtIndex(index).name != "Wine"
|
|
for index in range(tabs.childCount)), "Wine tab is not global-only"
|
|
if application:
|
|
print("PASS: Wine tab absent from application preferences")
|
|
return
|
|
assert tabs.querySelection().selectChild(tabs.childCount - 1), "cannot select Wine tab"
|
|
wait_for(lambda: named("Wine").getState().contains(pyatspi.STATE_SELECTED), "Wine page missing")
|
|
table = named("Controller-only programs").queryTable()
|
|
assert table.nRows == 1 and table.getAccessibleAt(0, 0).name == "Game.exe", table.nRows
|
|
controller_only = named("Controller-only mode for all Wine applications")
|
|
delete = [n for n in nodes if n.name == "Delete" and
|
|
n.getRole() == pyatspi.ROLE_PUSH_BUTTON][-1]
|
|
def validation_text():
|
|
for node in walk(window):
|
|
if node.name and node.name.startswith("Wine program"):
|
|
return node.name
|
|
return ""
|
|
def add_program(text):
|
|
rows = table.nRows
|
|
focus(named("New")); key("space")
|
|
wait_for(lambda: table.nRows == rows + 1, "New did not add a Wine row")
|
|
if "\t" in text:
|
|
# Paste into the draft: sending a Tab key would
|
|
# move keyboard focus instead of inserting the character.
|
|
subprocess.run(["xclip", "-selection", "clipboard", "-in"],
|
|
input=text, text=True, check=True, timeout=5)
|
|
key("ctrl+v")
|
|
else:
|
|
subprocess.run(["xdotool", "type", "--clearmodifiers", text], check=True)
|
|
key("Return")
|
|
wait_for(lambda: table.getAccessibleAt(rows, 0).name == text,
|
|
"inline Wine program editing failed")
|
|
focus(controller_only); key("space")
|
|
wait_for(lambda: controller_only.getState().contains(pyatspi.STATE_CHECKED),
|
|
"controller-only checkbox did not toggle")
|
|
for invalid in (r"C:\Games\Bad.exe", "Game\t.exe"):
|
|
add_program(invalid)
|
|
wait_for(lambda: invalid in validation_text(),
|
|
"invalid entry was not flagged by name in the Wine tab")
|
|
wait_for(lambda: not named("Apply").getState().contains(pyatspi.STATE_SENSITIVE),
|
|
"invalid Wine entry did not block Apply")
|
|
focus(table.getAccessibleAt(1, 0))
|
|
focus(delete); key("space")
|
|
wait_for(lambda: table.nRows == 1, "Delete did not remove the Wine row")
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE),
|
|
"Apply stayed blocked after deleting the invalid entry")
|
|
add_program("New Game.exe")
|
|
focus(named("Apply")); key("space")
|
|
applied = request()
|
|
assert applied["Values"]["Wine"] == {"ControllerOnly": True,
|
|
"ExcludedPrograms": ["Game.exe", "New Game.exe"]}, applied
|
|
send(dict(initial, Values=applied["Values"], Message="Wine preferences saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply stayed busy")
|
|
wait_for(lambda: table.nRows == 2 and table.getAccessibleAt(1, 0).name == "New Game.exe",
|
|
"applied Wine values were not reloaded")
|
|
focus(named("Cancel")); key("space")
|
|
assert process.wait(timeout=2) == 0
|
|
print("PASS: Wine tab checkbox, program table, add/delete, named validation blocking Apply, Apply/reload")
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_NUMBERS_ONLY") == "1":
|
|
focus(named("Desktop"))
|
|
key("ctrl+Page_Down", "ctrl+Page_Down")
|
|
wait_for(lambda: named("Speech").getState().contains(pyatspi.STATE_SELECTED), "Speech page missing")
|
|
values = initial["Values"]
|
|
def accept_preview(echoPreview=""):
|
|
preview = request()
|
|
assert preview["Preview"], preview
|
|
assert preview.get("EchoPreview", "") == echoPreview, preview
|
|
send({"Preview": True, "Accepted": True})
|
|
return preview["Values"]
|
|
for name, field in (("Rate", "Rate"), ("Pitch", "Pitch"), ("Volume", "Volume")):
|
|
slider = next(n for n in nodes if n.getRole() == pyatspi.ROLE_SLIDER and n.name.lower().rstrip(":") == name.lower())
|
|
focus(slider)
|
|
assert abs(slider.queryValue().minimumIncrement - 0.1) < 1e-8, (name, "accessible increment")
|
|
startValue = slider.queryValue().currentValue
|
|
for arrow, delta in (("Right", 0.1), ("Up", 1), ("Left", -0.1), ("Down", -1), ("Left", -0.1)):
|
|
expected = startValue + delta
|
|
key(arrow)
|
|
values = accept_preview()
|
|
assert abs(slider.queryValue().currentValue - expected) < 1e-8, (name, arrow, expected)
|
|
assert abs(values[field] - expected) < 1e-8, (name, arrow, values)
|
|
assert values[field] == round(values[field], 1), (name, "precision", values)
|
|
startValue = expected
|
|
for bound, arrows in ((0, ("Left", "Down")),
|
|
(slider.queryValue().maximumValue, ("Right", "Up"))):
|
|
slider.queryValue().currentValue = bound
|
|
accept_preview()
|
|
key(*arrows)
|
|
assert slider.queryValue().currentValue == bound, (name, "boundary", bound)
|
|
slider.queryValue().currentValue = startValue
|
|
values = accept_preview()
|
|
key("Tab")
|
|
wait_for(lambda: not slider.getState().contains(pyatspi.STATE_FOCUSED), "Tab trapped on slider")
|
|
key("shift+Tab")
|
|
wait_for(lambda: slider.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not return to slider")
|
|
focus(named("Raise pitch for capital letters"))
|
|
for page in ("Braille", "Key Echo"):
|
|
key("ctrl+Page_Down")
|
|
wait_for(lambda: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " page missing")
|
|
for name, field in (("Key echo rate", "Rate"), ("Key echo pitch", "AveragePitch"), ("Key echo volume", "Gain")):
|
|
slider = named(name)
|
|
focus(slider)
|
|
echoPreview = {"Rate": "rate", "AveragePitch": "pitch", "Gain": "volume"}[field]
|
|
assert abs(slider.queryValue().minimumIncrement - 0.1) < 1e-8, (name, "accessible increment")
|
|
for arrow, expected in (("Right", 5.1 if field != "Rate" else 50.1),
|
|
("Up", 6.1 if field != "Rate" else 51.1),
|
|
("Left", 6 if field != "Rate" else 51),
|
|
("Down", 5 if field != "Rate" else 50),
|
|
("Left", 4.9 if field != "Rate" else 49.9)):
|
|
key(arrow)
|
|
values = accept_preview(echoPreview)
|
|
wait_for(lambda: abs(slider.queryValue().currentValue - expected) < 1e-8,
|
|
f"{name}: {arrow} did not produce {expected}")
|
|
assert abs(values["EchoVoice"][field] - expected) < 1e-8, values
|
|
assert values["EchoVoice"][field] == round(values["EchoVoice"][field], 1), (name, "precision", values)
|
|
finalValue = expected
|
|
for bound, arrows in ((0, ("Left", "Down")),
|
|
(slider.queryValue().maximumValue, ("Right", "Up"))):
|
|
slider.queryValue().currentValue = bound
|
|
accept_preview(echoPreview)
|
|
key(*arrows)
|
|
assert slider.queryValue().currentValue == bound, (name, "boundary", bound)
|
|
slider.queryValue().currentValue = finalValue
|
|
accept_preview(echoPreview)
|
|
focus(named("Apply")); key("space")
|
|
applied = request()
|
|
assert applied["Save"], applied
|
|
for field, expected in (("Rate", 49.9), ("Pitch", 4.9), ("Volume", 4.9)):
|
|
assert abs(applied["Values"][field] - expected) < 1e-8, applied
|
|
assert applied["Values"][field] == round(applied["Values"][field], 1), applied
|
|
for field, expected in (("Rate", 49.9), ("AveragePitch", 4.9), ("Gain", 4.9)):
|
|
assert abs(applied["Values"]["EchoVoice"][field] - expected) < 1e-8, applied
|
|
assert applied["Values"]["EchoVoice"][field] == round(applied["Values"]["EchoVoice"][field], 1), applied
|
|
send(dict(initial, Values=applied["Values"], Message="Speech settings saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy")
|
|
for name, expected in (("Key echo rate", 49.9), ("Key echo pitch", 4.9), ("Key echo volume", 4.9)):
|
|
assert abs(named(name).queryValue().currentValue - expected) < 1e-8, name
|
|
focus(named("Cancel")); key("space")
|
|
assert process.wait(timeout=2) == 0
|
|
print("PASS: voice and echo sliders, whole/tenth arrow steps, previews, Tab/Shift+Tab, Apply/reload, Cancel")
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_APPLY_ONLY") == "1":
|
|
reading_tree = next(node for node in nodes if node.name == "Reading settings"
|
|
and node.getRole() == pyatspi.ROLE_TABLE)
|
|
reading_table = reading_tree.queryTable()
|
|
reader_config = Path(private_directory.name) / "reader.toml"
|
|
reader_log = Path(private_directory.name) / "reader.log"
|
|
reader_config.write_text('schema-version = 2\nactive-profile = "default"\n'
|
|
'[profiles.default.speech]\nenabled = false\n'
|
|
'[profiles.default.braille]\nenabled = false\n'
|
|
'[profiles.default.sound]\nenabled = false\n')
|
|
reader_process = subprocess.Popen([binary, "--config", str(reader_config),
|
|
"--debug-file", str(reader_log)],
|
|
env=dict(os.environ, NVDA2SPEECHD_HOST="http://127.0.0.1:0"),
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
def reader_rows():
|
|
if not reader_log.exists():
|
|
return []
|
|
rows = []
|
|
for line in reader_log.read_text().splitlines():
|
|
try:
|
|
rows.append(json.loads(line))
|
|
except ValueError:
|
|
pass # The last log write may still be in flight.
|
|
return rows
|
|
def reader_focused(node, offset):
|
|
return any(row.get("message") == "received accessibility event" and
|
|
row.get("type") == "object:state-changed:focused" and
|
|
row.get("event_detail1") == 1 and row.get("source_path") == node.path
|
|
for row in reader_rows()[offset:])
|
|
wait_for(lambda: any(row.get("message") == "listening for focus events" for row in reader_rows()),
|
|
"test reader did not start")
|
|
focus(named("Help"))
|
|
wait_for(lambda: reader_focused(named("Help"), 0), "reader did not observe helper focus")
|
|
status = named(initial["Message"])
|
|
for attempt in range(2):
|
|
log_offset = len(reader_rows())
|
|
focus(named("Apply")); key("space")
|
|
applied = request()
|
|
assert applied["Save"] and not applied["Close"], applied
|
|
wait_for(lambda: not named("Apply").getState().contains(pyatspi.STATE_SENSITIVE),
|
|
"Apply did not enter busy state")
|
|
assert not status.getState().contains(pyatspi.STATE_FOCUSED), "Apply moved focus to status"
|
|
assert "Applying settings" not in status.name, status.name
|
|
started = time.monotonic()
|
|
accepted_values = applied["Values"]
|
|
if attempt == 1:
|
|
accepted_values["Reading"]["SpeakBlankLines"] = True
|
|
send(dict(initial, Values=accepted_values, Message="Settings applied and saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE),
|
|
"Apply stayed busy after success")
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_FOCUSED),
|
|
"successful Apply did not restore its button focus")
|
|
assert not status.getState().contains(pyatspi.STATE_FOCUSED), "success focused status"
|
|
# GTK alone can respond while the reader is still processing
|
|
# hundreds of redundant cell events. Require reader-side focus.
|
|
focus(named("Cancel"))
|
|
wait_for(lambda: reader_focused(named("Cancel"), log_offset),
|
|
"reader did not process post-Apply focus", timeout=30)
|
|
elapsed = time.monotonic() - started
|
|
cell_events = sum(row.get("type") == "object:property-change:accessible-name" and
|
|
row.get("role") == "table cell" for row in reader_rows()[log_offset:])
|
|
print(f"Apply {attempt + 1}: {cell_events} table-cell name events; reader focus in {elapsed:.3f}s", flush=True)
|
|
assert cell_events <= 32, ("redundant reading-table name events", cell_events)
|
|
assert elapsed < 3, ("post-Apply reader focus was delayed", elapsed)
|
|
if attempt == 1:
|
|
blank_row = next(row for row in range(reading_table.nRows)
|
|
if reading_table.getAccessibleAt(row, 1).name == "Speak blank lines")
|
|
blank_cell = reading_table.getAccessibleAt(blank_row, 0)
|
|
assert blank_cell.name == "checked" and blank_cell.getState().contains(pyatspi.STATE_CHECKED)
|
|
focus(named("Apply")); key("space")
|
|
rejected = request()
|
|
assert rejected["Values"]["Reading"]["SpeakBlankLines"], "backend change did not reach next draft"
|
|
send(dict(initial, Accepted=False, Message="Settings were not applied: test rejection."))
|
|
wait_for(lambda: status.getState().contains(pyatspi.STATE_FOCUSED), "rejection did not focus error")
|
|
focus(named("OK")); key("space")
|
|
saved = request()
|
|
assert saved["Save"] and saved["Close"], saved
|
|
send(dict(initial, Values=saved["Values"], Close=True, Message="Settings applied and saved."))
|
|
assert process.wait(timeout=5) == 0, "successful OK did not close"
|
|
print("PASS: repeated Apply restores button focus, passive success status, focused rejection, OK closes")
|
|
return
|
|
if application and os.environ.get("CTHULHU_PREFS_STRUCTURAL_FOCUS_ONLY") != "1":
|
|
focus(named("Desktop"))
|
|
key("Right")
|
|
wait_for(lambda: named("Laptop").getState().contains(pyatspi.STATE_CHECKED), "application preferences keyboard layout could not be changed")
|
|
focus(named("Cancel")); key("space")
|
|
assert process.wait(timeout=5) == 0
|
|
print("PASS: application preferences title, removed profile controls, keyboard focus, Cancel")
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_HARDWARE_ONLY") == "1":
|
|
focus(named("Desktop"))
|
|
for page in ("Sounds", "Speech"):
|
|
key("ctrl+Page_Down")
|
|
wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing")
|
|
system = named("Speech system")
|
|
assert system.querySelection().selectChild(1)
|
|
preview = request()
|
|
assert preview["Preview"] and preview["Values"]["SpeechServerFactory"] == "hardwarefactory", preview
|
|
assert preview["Values"]["SpeechOutputModule"] == "litetalk", preview
|
|
send({"Preview": True, "Accepted": True})
|
|
module = named("Speech synthesizer")
|
|
wait_for(lambda: any(child.name == "DECtalk" for child in walk(module)), "hardware synthesizers missing")
|
|
assert module.querySelection().selectChild(3)
|
|
preview = request()
|
|
assert preview["Values"]["SpeechOutputModule"] == "dectalk", preview
|
|
send({"Preview": True, "Accepted": True})
|
|
device = next(child for child in walk(named("Serial device")) if child.getRole() in (pyatspi.ROLE_ENTRY, pyatspi.ROLE_TEXT))
|
|
assert device.queryEditableText().setTextContents("/dev/ttyTEST0")
|
|
preview = request()
|
|
assert preview["Values"]["HardwareSpeechDevice"] == "/dev/ttyTEST0", preview
|
|
send({"Preview": True, "Accepted": True})
|
|
baud = named("Serial baud rate")
|
|
assert baud.querySelection().selectChild(4)
|
|
preview = request()
|
|
assert preview["Values"]["HardwareSpeechBaudRate"] == 19200, preview
|
|
send({"Preview": True, "Accepted": True})
|
|
focus(device)
|
|
subprocess.run(["xdotool", "windowfocus", xid], check=True)
|
|
key("Tab")
|
|
# GTK's internal combo button does not expose STATE_FOCUSED
|
|
# reliably. Verify the actual workflow: Tab, change baud with
|
|
# Down, and inspect the typed preview request.
|
|
key("Down")
|
|
preview = request()
|
|
assert preview["Values"]["HardwareSpeechBaudRate"] == 38400, preview
|
|
send({"Preview": True, "Accepted": True})
|
|
key("shift+Tab")
|
|
wait_for(lambda: device.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not return to serial device")
|
|
assert not named("Language").getState().contains(pyatspi.STATE_ENABLED)
|
|
assert not named("Person").getState().contains(pyatspi.STATE_ENABLED)
|
|
focus(named("Apply")); key("space")
|
|
applied = request()
|
|
assert applied["Save"] and applied["Values"]["SpeechServerFactory"] == "hardwarefactory", applied
|
|
assert applied["Values"]["SpeechOutputModule"] == "dectalk" and applied["Values"]["HardwareSpeechDevice"] == "/dev/ttyTEST0", applied
|
|
send({"Values": applied["Values"], "CanSave": True, "Accepted": True, "Message": "Hardware settings applied"})
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "hardware Apply stayed busy")
|
|
assert system.querySelection().selectChild(0)
|
|
preview = request()
|
|
assert preview["Values"]["SpeechServerFactory"] == "speechdispatcherfactory", preview
|
|
send({"Preview": True, "Accepted": True})
|
|
focus(named("Cancel")); key("space")
|
|
assert process.wait(timeout=5) == 0
|
|
print("PASS: hardware system/synth/device/baud, Apply, software restore, serial keyboard navigation, Cancel")
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_CAPITALS_ONLY") == "1":
|
|
focus(named("Desktop"))
|
|
for page in ("Sounds", "Speech"):
|
|
key("ctrl+Page_Down")
|
|
wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing")
|
|
labels = ("Raise pitch for capital letters", "Beep for capital letters", "Say “cap” before capital letters")
|
|
fields = ("RaisePitchForCapitals", "BeepForCapitals", "SayCapForCapitals")
|
|
checks = [named(label) for label in labels]
|
|
assert all(check.getRole() == pyatspi.ROLE_CHECK_BOX for check in checks)
|
|
assert checks[0].getState().contains(pyatspi.STATE_CHECKED)
|
|
assert not any(check.getState().contains(pyatspi.STATE_CHECKED) for check in checks[1:])
|
|
focus(checks[0])
|
|
key("shift+Tab")
|
|
previous = next(n for n in nodes if n.getState().contains(pyatspi.STATE_FOCUSED))
|
|
assert previous.getRole() == pyatspi.ROLE_SLIDER and "Rate" in previous.name, previous.name
|
|
key("Tab")
|
|
for check in checks:
|
|
wait_for(lambda check=check: check.getState().contains(pyatspi.STATE_FOCUSED), "capital checkbox Tab order")
|
|
key("Tab")
|
|
following = next(n for n in nodes if n.getState().contains(pyatspi.STATE_FOCUSED))
|
|
assert following not in checks
|
|
for check in reversed(checks):
|
|
key("shift+Tab")
|
|
wait_for(lambda check=check: check.getState().contains(pyatspi.STATE_FOCUSED), "capital checkbox Shift+Tab order")
|
|
for mask in range(8):
|
|
for bit, check in enumerate(checks):
|
|
if check.getState().contains(pyatspi.STATE_CHECKED) != bool(mask & (1 << bit)):
|
|
focus(check); key("space")
|
|
focus(named("Apply")); key("space")
|
|
applied = request()
|
|
assert not applied.get("Preview"), applied
|
|
assert all(applied["Values"][field] == bool(mask & (1 << bit)) for bit, field in enumerate(fields)), applied
|
|
send(dict(initial, Values=applied["Values"], Message="Capital letter settings saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy")
|
|
for bit, check in enumerate(checks):
|
|
assert check.getState().contains(pyatspi.STATE_CHECKED) == bool(mask & (1 << bit))
|
|
focus(named("Cancel")); key("space")
|
|
assert process.wait(timeout=5) == 0
|
|
print("PASS: capital checkboxes, default pitch, Tab/Shift+Tab, eight independent combinations, Apply/reload")
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_VOICE_TYPES_ONLY") == "1":
|
|
focus(named("Desktop"))
|
|
for page in ("Sounds", "Speech"):
|
|
key("ctrl+Page_Down")
|
|
wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing")
|
|
voiceType = named("Voice type")
|
|
pitchScale = next(n for n in nodes if n.getRole() == pyatspi.ROLE_SLIDER and "pitch" in n.name.lower())
|
|
def accept_preview():
|
|
preview = request()
|
|
assert preview["Preview"], preview
|
|
send({"Preview": True, "Accepted": True})
|
|
return preview["Values"]
|
|
for index, kind, pitch in ((1, "uppercase", 8), (2, "hyperlink", 3), (3, "system", 2)):
|
|
assert voiceType.querySelection().selectChild(index)
|
|
values = accept_preview()
|
|
assert values["VoiceType"] == kind and values["Pitch"] == 5, values
|
|
assert values[kind.capitalize() + "Voice"]["AveragePitch"] is None, values
|
|
assert pitchScale.getState().contains(pyatspi.STATE_SENSITIVE)
|
|
pitchScale.queryValue().currentValue = pitch
|
|
values = accept_preview()
|
|
assert values[kind.capitalize() + "Voice"]["AveragePitch"] == pitch, values
|
|
assert voiceType.querySelection().selectChild(0)
|
|
values = accept_preview()
|
|
assert values["Pitch"] == 5, values
|
|
assert pitchScale.queryValue().currentValue == 5
|
|
for label, mode in (("Beeps", "beeps"), ("Speech and beeps", "both"), ("Speech", "speech")):
|
|
radio = next(n for n in nodes if n.getRole() == pyatspi.ROLE_RADIO_BUTTON and n.name == label)
|
|
focus(radio); key("space")
|
|
focus(named("Apply")); key("space")
|
|
applied = request()
|
|
assert not applied["Preview"] and applied["Values"]["Reading"]["IndentationPresentationMode"] == mode, applied
|
|
values = applied["Values"]
|
|
assert values["Pitch"] == 5 and values["UppercaseVoice"]["AveragePitch"] == 8
|
|
assert values["HyperlinkVoice"]["AveragePitch"] == 3 and values["SystemVoice"]["AveragePitch"] == 2
|
|
send(dict(initial, Values=values, Message="Speech settings saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy")
|
|
assert radio.getState().contains(pyatspi.STATE_CHECKED)
|
|
assert voiceType.querySelection().selectChild(1)
|
|
values = accept_preview()
|
|
assert values["Pitch"] == 5 and pitchScale.queryValue().currentValue == 8
|
|
focus(named("Cancel")); key("space")
|
|
assert process.wait(timeout=2) == 0 and responses.read_remaining() == ""
|
|
print("PASS: independent voice editors, inheritance, previews, indentation modes, Apply/reload, Cancel")
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_SPEECH_ONLY") == "1":
|
|
focus(named("Desktop"))
|
|
for page in ("Sounds", "Speech"):
|
|
key("ctrl+Page_Down")
|
|
wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing")
|
|
module = named("Speech synthesizer")
|
|
language = named("Language")
|
|
person = named("Person")
|
|
assert module.querySelection().selectChild(1)
|
|
preview = request()
|
|
assert preview["Preview"] and preview["Values"]["SpeechOutputModule"] == "espeak-ng", preview
|
|
send({"Preview": True, "Accepted": True})
|
|
def person_items():
|
|
return {child.name for child in walk(person) if child.getRole() == pyatspi.ROLE_MENU_ITEM}
|
|
wait_for(lambda: "English test voice (en)" in person_items() and
|
|
"Spanish test voice (es)" not in person_items(),
|
|
"default language did not filter eSpeak voices")
|
|
if os.environ.get("CTHULHU_PREFS_LARGE_CATALOG") == "1":
|
|
assert len(person_items()) == 102, "other-language catalog voices leaked into Person"
|
|
echo_items = {child.name for child in walk(named("Key echo person"))
|
|
if child.getRole() == pyatspi.ROLE_MENU_ITEM}
|
|
assert len(echo_items) == 103, "default eSpeak catalog flooded the key echo list"
|
|
assert named("Key echo person").querySelection().getSelectedChild(0).name == "Spanish test voice (es)", \
|
|
"locale filtering lost the saved foreign-language echo voice"
|
|
assert preview["Values"]["EchoVoice"]["Family"]["Name"] == "Spanish test voice", preview
|
|
# A language near the end of the old truncated catalog remains
|
|
# selectable and its whole list is retained after transport.
|
|
assert language.querySelection().selectChild(4)
|
|
preview = request()
|
|
assert preview["Values"]["SpeechVoiceFamily"]["Lang"] == "fr", preview
|
|
send({"Preview": True, "Accepted": True})
|
|
wait_for(lambda: "Test voice 11999 (fr)" in person_items(),
|
|
"bounded transfer lost the last French voice")
|
|
assert language.querySelection().selectChild(1)
|
|
preview = request()
|
|
send({"Preview": True, "Accepted": True})
|
|
wait_for(lambda: len(person_items()) == 102, "locale voice list did not recover")
|
|
focus(named("Raise pitch for capital letters"))
|
|
subprocess.run(["xdotool", "windowfocus", xid], check=True)
|
|
key("Escape")
|
|
assert process.wait(timeout=2) == 0, "large catalog blocked Escape"
|
|
print("PASS: large voice catalog selection, locale filter, preview, Escape")
|
|
return
|
|
assert language.querySelection().selectChild(2)
|
|
preview = request()
|
|
assert preview["Preview"] and preview["Values"]["SpeechVoiceFamily"] == {
|
|
"Name": "", "Lang": "es", "Dialect": "", "Variant": ""}, preview
|
|
send({"Preview": True, "Accepted": True})
|
|
wait_for(lambda: "Spanish test voice (es)" in person_items() and
|
|
"English test voice (en)" not in person_items(), "Spanish language did not filter voices")
|
|
assert person.querySelection().selectChild(1)
|
|
preview = request()
|
|
assert preview["Preview"] and preview["Values"]["SpeechVoiceFamily"]["Name"] == "Spanish test voice", preview
|
|
send({"Preview": True, "Accepted": True})
|
|
for index, module_name in ((2, "test-module"), (1, "espeak-ng")):
|
|
assert module.querySelection().selectChild(index)
|
|
preview = request()
|
|
assert preview["Preview"] and preview["Values"]["SpeechOutputModule"] == module_name, preview
|
|
send({"Preview": True, "Accepted": True})
|
|
wait_for(lambda: language.description == "es" and
|
|
"Spanish test voice (es)" in person_items() and
|
|
"English test voice (en)" not in person_items(),
|
|
"switching synthesizers lost the current language")
|
|
assert language.querySelection().selectChild(1)
|
|
preview = request()
|
|
assert preview["Preview"] and preview["Values"]["SpeechVoiceFamily"] == {
|
|
"Name": "", "Lang": "en", "Dialect": "", "Variant": ""}, preview
|
|
send({"Preview": True, "Accepted": True})
|
|
wait_for(lambda: "English test voice (en)" in person_items() and
|
|
"Spanish test voice (es)" not in person_items(), "language did not filter voices")
|
|
assert person.querySelection().selectChild(1)
|
|
preview = request()
|
|
assert preview["Values"]["SpeechVoiceFamily"]["Name"] == "English test voice", preview
|
|
send({"Preview": True, "Accepted": True})
|
|
focus(named("Brief")); key("space")
|
|
wait_for(lambda: named("Brief").getState().contains(pyatspi.STATE_CHECKED),
|
|
"brief speech verbosity did not select")
|
|
say_all = named("Say All by")
|
|
assert say_all.querySelection().selectChild(0)
|
|
focus(named("Apply")); key("space")
|
|
applied = request()
|
|
assert applied["Save"] and applied["Values"]["SpeechOutputModule"] == "espeak-ng", applied
|
|
assert applied["Values"]["SpeechVoiceFamily"]["Lang"] == "en", applied
|
|
assert applied["Values"]["SpeechVerbosityLevel"] == 0, applied
|
|
assert applied["Values"]["SayAllStyle"] == "sentence", applied
|
|
send(dict(initial, Values=applied["Values"], Message="Speech settings saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy")
|
|
assert module.description == "espeak-ng"
|
|
assert person.querySelection().getSelectedChild(0).name == "English test voice (en)"
|
|
assert named("Brief").getState().contains(pyatspi.STATE_CHECKED)
|
|
assert say_all.description == "Sentence"
|
|
focus(named("Enable speech")); key("Tab", "alt+Down", "Escape")
|
|
assert process.poll() is None, "dropdown Escape closed the whole preferences window"
|
|
key("Escape")
|
|
assert process.wait(timeout=2) == 0, "Escape did not close preferences promptly"
|
|
assert responses.read_remaining() == "", "Escape submitted changes"
|
|
print("PASS: synthesizer and voice previews, locale filtering, language preserved across synthesizers, Apply/reload, dropdown and window Escape")
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_STRUCTURAL_FOCUS_ONLY") == "1":
|
|
checkbox = named("Automatic focus mode during structural navigation")
|
|
assert checkbox.getRole() == pyatspi.ROLE_CHECK_BOX
|
|
assert not checkbox.getState().contains(pyatspi.STATE_CHECKED)
|
|
focus(checkbox)
|
|
key("Tab")
|
|
wait_for(lambda: not checkbox.getState().contains(pyatspi.STATE_FOCUSED), "Tab did not leave automatic focus checkbox")
|
|
key("shift+Tab")
|
|
wait_for(lambda: checkbox.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not return to automatic focus checkbox")
|
|
for enabled in (True, False):
|
|
key("space")
|
|
wait_for(lambda: checkbox.getState().contains(pyatspi.STATE_CHECKED) == enabled, "automatic focus checkbox did not toggle")
|
|
focus(named("Apply")); key("space")
|
|
applied = request()
|
|
assert applied["Values"]["StructuralNavigationTriggersFocusMode"] == enabled, applied
|
|
send(dict(initial, Values=applied["Values"], Message="Preferences saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy")
|
|
assert checkbox.getState().contains(pyatspi.STATE_CHECKED) == enabled
|
|
focus(checkbox)
|
|
focus(named("Cancel")); key("space")
|
|
assert process.wait(timeout=5) == 0
|
|
assert responses.read_remaining() == "", "Cancel submitted changes"
|
|
print("PASS: automatic structural focus checkbox, default, Tab/Shift+Tab, enable/disable, Apply/reload, Cancel")
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_READING_ONLY") == "1":
|
|
focus(named("Desktop"))
|
|
for page in ("Sounds", "Speech"):
|
|
key("ctrl+Page_Down")
|
|
wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing")
|
|
readingTree = next(node for node in nodes if node.name == "Reading settings"
|
|
and node.getRole() == pyatspi.ROLE_TABLE)
|
|
readingTable = readingTree.queryTable()
|
|
assert readingTable.nColumns == 3
|
|
assert [readingTable.getColumnDescription(column) for column in range(3)] == [
|
|
"Enabled", "Setting", "Description"]
|
|
for row in range(readingTable.nRows):
|
|
stateCell = readingTable.getAccessibleAt(row, 0)
|
|
settingCell = readingTable.getAccessibleAt(row, 1)
|
|
descriptionCell = readingTable.getAccessibleAt(row, 2)
|
|
assert settingCell.name and settingCell.name not in ("checked", "unchecked"), settingCell.name
|
|
assert descriptionCell.name and descriptionCell.name != settingCell.name, settingCell.name
|
|
checked = stateCell.getState().contains(pyatspi.STATE_CHECKED)
|
|
assert stateCell.name == ("checked" if checked else "unchecked"), stateCell.name
|
|
i3Choices = {
|
|
"I3Modes": "Announce i3 binding modes",
|
|
"I3Workspaces": "Announce i3 workspaces",
|
|
"I3WindowStates": "Announce i3 window states",
|
|
"I3Urgency": "Announce i3 urgent windows",
|
|
"I3Marks": "Announce i3 marks",
|
|
"I3WorkspaceMoves": "Announce i3 window workspace moves",
|
|
}
|
|
i3Rows = {}
|
|
for field, label in i3Choices.items():
|
|
row = next(row for row in range(readingTable.nRows)
|
|
if readingTable.getAccessibleAt(row, 1).name == label)
|
|
i3Rows[field] = row
|
|
cell = readingTable.getAccessibleAt(row, 0)
|
|
assert cell.name == "checked", (label, cell.name)
|
|
focus(cell); key("space")
|
|
wait_for(lambda cell=cell: cell.name == "unchecked", label + " did not toggle")
|
|
titleRow = next(row for row in range(readingTable.nRows)
|
|
if readingTable.getAccessibleAt(row, 1).name == "Automatically read title changes")
|
|
titleCell = readingTable.getAccessibleAt(titleRow, 0)
|
|
assert titleCell.name == "checked" and titleCell.getState().contains(pyatspi.STATE_CHECKED)
|
|
focus(titleCell); key("space")
|
|
wait_for(lambda: titleCell.name == "unchecked", "title preference did not toggle")
|
|
clipboardRow = next(row for row in range(readingTable.nRows)
|
|
if readingTable.getAccessibleAt(row, 1).name == "Automatically read clipboard changes")
|
|
clipboardCell = readingTable.getAccessibleAt(clipboardRow, 0)
|
|
assert clipboardCell.name == "unchecked" and not clipboardCell.getState().contains(pyatspi.STATE_CHECKED)
|
|
focus(clipboardCell); key("space")
|
|
wait_for(lambda: clipboardCell.name == "checked", "clipboard preference did not toggle")
|
|
hintRow = next(row for row in range(readingTable.nRows)
|
|
if readingTable.getAccessibleAt(row, 1).name == "Phonetic character hints")
|
|
hintCell = readingTable.getAccessibleAt(hintRow, 0)
|
|
assert hintCell.name == "unchecked" and not hintCell.getState().contains(pyatspi.STATE_CHECKED)
|
|
hintDescription = readingTable.getAccessibleAt(hintRow, 2).name
|
|
assert "Left or Right" in hintDescription and "300 ms" in hintDescription, hintDescription
|
|
assert "cancels the hint" in hintDescription, hintDescription
|
|
for label, expected in (("Phonetic character hints", "checked"),
|
|
("Announce lists in Say All", "checked"),
|
|
("Present tooltips", "checked"),
|
|
("Speak object under mouse", "checked")):
|
|
row = next(row for row in range(readingTable.nRows)
|
|
if readingTable.getAccessibleAt(row, 1).name == label)
|
|
cell = readingTable.getAccessibleAt(row, 0)
|
|
before = cell.name
|
|
focus(cell); key("space")
|
|
wait_for(lambda cell=cell, expected=expected: cell.name == expected,
|
|
f"{label} did not toggle: before={before}, after={cell.name}, row={row}")
|
|
focus(readingTree)
|
|
key("ctrl+Home", "Left")
|
|
stateCell = readingTable.getAccessibleAt(0, 0)
|
|
wasChecked = stateCell.getState().contains(pyatspi.STATE_CHECKED)
|
|
key("space")
|
|
wait_for(lambda: stateCell.name == ("unchecked" if wasChecked else "checked"), "reading checkbox name did not change")
|
|
assert stateCell.getState().contains(pyatspi.STATE_CHECKED) != wasChecked
|
|
key("Right")
|
|
wait_for(lambda: readingTable.getAccessibleAt(0, 1).getState().contains(pyatspi.STATE_FOCUSED), "Right did not focus the setting label")
|
|
key("Right")
|
|
descriptionCell = readingTable.getAccessibleAt(0, 2)
|
|
wait_for(lambda: descriptionCell.getState().contains(pyatspi.STATE_FOCUSED), "Right did not focus the description")
|
|
# Space still toggles the selected row from the new text column.
|
|
key("space")
|
|
wait_for(lambda: stateCell.getState().contains(pyatspi.STATE_CHECKED) == wasChecked,
|
|
"Space in the description did not toggle the checkbox")
|
|
key("space")
|
|
wait_for(lambda: stateCell.getState().contains(pyatspi.STATE_CHECKED) != wasChecked,
|
|
"Space in the description did not restore the checkbox")
|
|
key("Down")
|
|
wait_for(lambda: readingTable.isRowSelected(1), "Down did not select the next reading setting")
|
|
key("Up")
|
|
wait_for(lambda: readingTable.isRowSelected(0), "Up did not return to the first reading setting")
|
|
key("Left")
|
|
wait_for(lambda: readingTable.getAccessibleAt(0, 1).getState().contains(pyatspi.STATE_FOCUSED), "Left did not return to the setting label")
|
|
key("Left")
|
|
wait_for(lambda: readingTable.getAccessibleAt(0, 0).getState().contains(pyatspi.STATE_FOCUSED), "Left did not focus the checkbox")
|
|
key("Right", "Right")
|
|
wait_for(lambda: descriptionCell.getState().contains(pyatspi.STATE_FOCUSED), "description focus missing before Tab")
|
|
key("Tab")
|
|
wait_for(lambda: not any(node.getState().contains(pyatspi.STATE_FOCUSED) for node in walk(readingTree)), "Tab did not leave reading settings")
|
|
key("shift+Tab")
|
|
wait_for(lambda: any(node.getState().contains(pyatspi.STATE_FOCUSED) for node in walk(readingTree)), "Shift+Tab did not return to reading settings")
|
|
focus(named("Apply")); key("space")
|
|
applied = request()
|
|
assert applied["Values"]["Reading"]["OnlySpeakDisplayedText"] != wasChecked, applied
|
|
for field in i3Choices:
|
|
assert not applied["Values"]["Reading"][field], applied
|
|
assert not applied["Values"]["Reading"]["AutomaticallyReadTitleChanges"], applied
|
|
assert applied["Values"]["Reading"]["AutomaticallyReadClipboardChanges"], applied
|
|
assert applied["Values"]["Reading"]["PhoneticCharacterHints"], applied
|
|
assert applied["Values"]["Reading"]["SayAllContextList"], applied
|
|
assert applied["Values"]["Reading"]["PresentTooltips"], applied
|
|
assert applied["Values"]["Reading"]["EnableMouseReview"], applied
|
|
send(dict(initial, Values=applied["Values"], Message="Reading settings saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy")
|
|
assert readingTable.getAccessibleAt(0, 0).name == ("unchecked" if wasChecked else "checked")
|
|
assert readingTable.getAccessibleAt(titleRow, 0).name == "unchecked"
|
|
for row in i3Rows.values():
|
|
assert readingTable.getAccessibleAt(row, 0).name == "unchecked"
|
|
assert readingTable.getAccessibleAt(clipboardRow, 0).name == "checked"
|
|
assert readingTable.getAccessibleAt(hintRow, 0).name == "checked"
|
|
assert readingTable.getAccessibleAt(next(row for row in range(readingTable.nRows)
|
|
if readingTable.getAccessibleAt(row, 1).name == "Announce lists in Say All"), 0).name == "checked"
|
|
assert readingTable.getAccessibleAt(next(row for row in range(readingTable.nRows)
|
|
if readingTable.getAccessibleAt(row, 1).name == "Present tooltips"), 0).name == "checked"
|
|
assert readingTable.getAccessibleAt(next(row for row in range(readingTable.nRows)
|
|
if readingTable.getAccessibleAt(row, 1).name == "Speak object under mouse"), 0).name == "checked"
|
|
focus(named("Cancel")); key("space")
|
|
assert process.wait(timeout=5) == 0
|
|
assert responses.read_remaining() == "", "Cancel submitted reading changes"
|
|
print("PASS: three-column reading table and descriptions, Say All, tooltip and mouse choices, Space, arrows, Tab/Shift+Tab, Apply/reload, Cancel")
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_MODIFIERS_ONLY") == "1":
|
|
modifierTree = named("Cthulhu modifier keys")
|
|
modifierTable = modifierTree.queryTable()
|
|
assert modifierTable.nRows == 3 and modifierTable.nColumns == 2
|
|
def assert_modifiers(expected):
|
|
for row, label in enumerate(("Insert", "Numpad Insert", "Caps Lock")):
|
|
assert modifierTable.getAccessibleAt(row, 1).name == label
|
|
cell = modifierTable.getAccessibleAt(row, 0)
|
|
checked = label in expected
|
|
assert cell.name == ("checked" if checked else "unchecked"), cell.name
|
|
assert cell.getState().contains(pyatspi.STATE_CHECKED) == checked, cell.name
|
|
def open_bindings():
|
|
focus(named("Desktop"))
|
|
for page in ("Sounds", "Speech", "Braille", "Key Echo", "Key Bindings"):
|
|
key("ctrl+Page_Down")
|
|
wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing")
|
|
def apply_modifiers(layout, keys):
|
|
focus(named("Apply")); key("space")
|
|
applied = request()
|
|
assert applied["Values"]["KeyboardLayout"] == layout, applied
|
|
assert applied["Values"]["CthulhuModifierKeys"] == keys, applied
|
|
send(dict(initial, Values=applied["Values"], Message="Modifier keys saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy")
|
|
# Existing custom settings survive opening; switching General layout
|
|
# chooses defaults, and the binding editor layout stays independent.
|
|
assert_modifiers({"Insert", "Numpad Insert"})
|
|
focus(named("Laptop")); key("space")
|
|
wait_for(lambda: named("Laptop").getState().contains(pyatspi.STATE_CHECKED), "Laptop not selected")
|
|
assert_modifiers({"Caps Lock"})
|
|
open_bindings()
|
|
focus(modifierTree); key("ctrl+Home", "space", "Down", "space")
|
|
assert_modifiers({"Insert", "Numpad Insert", "Caps Lock"})
|
|
key("Tab")
|
|
wait_for(lambda: named("Desktop bindings").getState().contains(pyatspi.STATE_FOCUSED), "Tab did not leave modifier table")
|
|
key("shift+Tab")
|
|
wait_for(lambda: any(node.getState().contains(pyatspi.STATE_FOCUSED) for node in walk(modifierTree)), "Shift+Tab did not return to modifier table")
|
|
apply_modifiers(2, ["Insert", "KP_Insert", "Caps_Lock"])
|
|
assert_modifiers({"Insert", "Numpad Insert", "Caps Lock"})
|
|
# Every checkbox can be cleared; Apply/reload preserves an empty list.
|
|
focus(modifierTree); key("ctrl+Home", "space", "Down", "space", "Down", "space")
|
|
assert_modifiers(set())
|
|
apply_modifiers(2, [])
|
|
assert_modifiers(set())
|
|
focus(named("Desktop")); key("space")
|
|
wait_for(lambda: named("Desktop").getState().contains(pyatspi.STATE_CHECKED), "Desktop not selected")
|
|
assert_modifiers({"Numpad Insert"})
|
|
apply_modifiers(1, ["KP_Insert"])
|
|
assert_modifiers({"Numpad Insert"})
|
|
focus(modifierTree); key("ctrl+Home", "space")
|
|
assert_modifiers({"Insert", "Numpad Insert"})
|
|
focus(named("Cancel")); key("space")
|
|
assert process.wait(timeout=5) == 0
|
|
assert responses.read_remaining() == "", "Cancel submitted modifier changes"
|
|
print("PASS: modifier checkbox table, accessible states, Space, Tab/Shift+Tab, desktop/laptop defaults, multiple/empty selections, Apply/reload, Cancel")
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_BINDINGS_ONLY") == "1":
|
|
focus(named("Desktop"))
|
|
for page in ("Sounds", "Speech", "Braille", "Key Echo", "Key Bindings"):
|
|
key("ctrl+Page_Down")
|
|
wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing")
|
|
tree = named("Key bindings")
|
|
table = tree.queryTable()
|
|
def select_command(label, scope):
|
|
for row in range(table.nRows):
|
|
if table.getAccessibleAt(row, 0).name == label and table.getAccessibleAt(row, 1).name == scope:
|
|
focus(tree)
|
|
key("ctrl+Home")
|
|
if row:
|
|
subprocess.run(["xdotool", "key", "--repeat", str(row), "--delay", "1", "Down"], check=True)
|
|
return
|
|
raise AssertionError((label, scope))
|
|
status = named(initial["Message"])
|
|
editor = next(node for node in nodes if node.name == "Shortcuts" and node.getRole() == pyatspi.ROLE_TEXT)
|
|
def set_binding(text):
|
|
editor.queryEditableText().setTextContents(text)
|
|
focus(named("Set binding")); key("space")
|
|
wait_for(lambda: status.getState().contains(pyatspi.STATE_FOCUSED), "binding status not focused")
|
|
select_command("Preferences", "Global")
|
|
focus(editor); key("Tab")
|
|
wait_for(lambda: named("Set binding").getState().contains(pyatspi.STATE_FOCUSED), "Tab did not leave editor")
|
|
key("shift+Tab")
|
|
wait_for(lambda: editor.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not return to editor")
|
|
set_binding("Cthulhu+q")
|
|
wait_for(lambda: "conflicts" in status.name, "conflict not reported")
|
|
set_binding("Cthulhu+Ctrl+F8")
|
|
wait_for(lambda: "Binding updated" in status.name, "binding not set")
|
|
focus(named("Apply")); key("space")
|
|
saved = request()
|
|
overrides = saved["Values"]["KeyBindings"]
|
|
assert len(overrides) == 1 and overrides[0]["Command"] == "preferences" and overrides[0]["Keys"] == ["Cthulhu+Ctrl+F8"], saved
|
|
send(dict(initial, Values=saved["Values"], Message="Bindings saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy")
|
|
select_command("Preferences", "Global")
|
|
assert editor.queryText().getText(0, -1) == "Cthulhu+Ctrl+F8"
|
|
focus(named("Clear binding")); key("space")
|
|
focus(named("Apply")); key("space")
|
|
cleared = request()
|
|
assert cleared["Values"]["KeyBindings"][0]["Keys"] == [], cleared
|
|
send(dict(initial, Values=cleared["Values"], Message="Binding cleared."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Clear busy")
|
|
select_command("Preferences", "Global")
|
|
assert editor.queryText().getText(0, -1) == ""
|
|
focus(named("Restore default binding")); key("space")
|
|
focus(named("Apply")); key("space")
|
|
reset = request()
|
|
assert not reset["Values"]["KeyBindings"], reset
|
|
send(dict(initial, Values=reset["Values"], Message="Default restored."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Reset busy")
|
|
focus(named("Laptop bindings")); key("space")
|
|
wait_for(lambda: named("Laptop bindings").getState().contains(pyatspi.STATE_CHECKED), "laptop layout not selected")
|
|
select_command("Document next heading", "Browse mode")
|
|
set_binding("F6")
|
|
focus(named("Apply")); key("space")
|
|
laptop = request()
|
|
assert laptop["Values"]["KeyBindings"][0]["Layout"] == 2, laptop
|
|
send(dict(initial, Values=laptop["Values"], Message="Laptop saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Laptop busy")
|
|
focus(named("Laptop bindings")); key("space")
|
|
wait_for(lambda: named("Laptop bindings").getState().contains(pyatspi.STATE_CHECKED), "laptop layout not selected")
|
|
focus(named("Restore all bindings for this layout")); key("space")
|
|
focus(named("Apply")); key("space")
|
|
reset_all = request()
|
|
assert not reset_all["Values"]["KeyBindings"], reset_all
|
|
send(dict(initial, Values=reset_all["Values"], Message="All restored."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Reset all busy")
|
|
select_command("Preferences", "Global")
|
|
set_binding("F9")
|
|
focus(named("Cancel")); key("space")
|
|
assert process.wait(timeout=5) == 0
|
|
assert responses.read_remaining() == "", "Cancel submitted bindings"
|
|
print("PASS: accessible binding editor, Tab/Shift+Tab, conflict, save/reload, clear, reset, laptop browse, reset all, Cancel")
|
|
return
|
|
if os.environ.get("CTHULHU_PREFS_BRAILLE_ONLY") == "1":
|
|
focus(named("Desktop"))
|
|
for page in ("Sounds", "Speech", "Braille"):
|
|
key("ctrl+Page_Down")
|
|
wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED),
|
|
page + " page did not open")
|
|
combo = named("Contraction table")
|
|
assert combo.getRole() == pyatspi.ROLE_COMBO_BOX, combo.getRoleName()
|
|
focus(named("Enable contracted braille"))
|
|
key("Tab")
|
|
key("Tab")
|
|
wait_for(lambda: named("Enable word wrap").getState().contains(pyatspi.STATE_FOCUSED),
|
|
"Tab did not leave the contraction table")
|
|
key("shift+Tab")
|
|
key("shift+Tab")
|
|
wait_for(lambda: named("Enable contracted braille").getState().contains(pyatspi.STATE_FOCUSED),
|
|
"Shift+Tab did not leave the contraction table backwards")
|
|
focus(named("Abbreviated role names")); key("space")
|
|
focus(named("Disable end of line symbol")); key("space")
|
|
focus(named("Enable word wrap")); key("space")
|
|
focus(named("Messages are persistent")); key("space")
|
|
focus(named("Messages are detailed")); key("space")
|
|
braille_brief = [n for n in nodes if n.name == "Brief" and
|
|
n.getRole() == pyatspi.ROLE_RADIO_BUTTON][1]
|
|
focus(braille_brief); key("space")
|
|
focus(named("Selection indicator: Dot 7")); key("space")
|
|
focus(named("Link indicator: None")); key("space")
|
|
named("Flash message duration").queryValue().currentValue = 3
|
|
selection = combo.querySelection()
|
|
assert selection.selectChild(1), "no locale contraction tables"
|
|
wait_for(lambda: "en-ueb" in combo.description.lower(),
|
|
"US UEB table missing from contraction choices")
|
|
focus(named("Apply")); key("space")
|
|
applied = request()
|
|
braille_values = applied["Values"]["Braille"]
|
|
assert braille_values["AbbreviateRoles"] and braille_values["DisableEOL"], applied
|
|
assert braille_values["WordWrap"] and braille_values["FlashPersistent"], applied
|
|
assert not braille_values["FlashDetailed"] and braille_values["FlashTime"] == 3000, applied
|
|
assert braille_values["Verbosity"] == 0 and braille_values["SelectionIndicator"] == 64, applied
|
|
assert braille_values["LinkIndicator"] == 0, applied
|
|
assert applied["Values"]["BrailleContractionTable"].endswith("en-ueb-g1.ctb"), applied
|
|
print("PASS: Braille table chooser and preference protocol")
|
|
return
|
|
expected_tabs = {"General", "Sounds", "Speech", "Braille", "Key Echo",
|
|
"Key Bindings", "Pronunciation", "Text Attributes", "OCR"}
|
|
if not application:
|
|
expected_tabs.add("Tentacles")
|
|
expected_tabs.add("Wine")
|
|
main_tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
|
|
tabs = {main_tabs.getChildAtIndex(index).name for index in range(main_tabs.childCount)
|
|
if main_tabs.getChildAtIndex(index).getRole() == pyatspi.ROLE_PAGE_TAB}
|
|
assert tabs == expected_tabs, ("preferences tabs", tabs)
|
|
interactive_roles = {
|
|
pyatspi.ROLE_CHECK_BOX, pyatspi.ROLE_COMBO_BOX, pyatspi.ROLE_ENTRY,
|
|
pyatspi.ROLE_PAGE_TAB, pyatspi.ROLE_PUSH_BUTTON, pyatspi.ROLE_RADIO_BUTTON,
|
|
pyatspi.ROLE_SLIDER, pyatspi.ROLE_SPIN_BUTTON, pyatspi.ROLE_TABLE,
|
|
pyatspi.ROLE_TOGGLE_BUTTON, pyatspi.ROLE_TREE_TABLE,
|
|
}
|
|
unnamed = sorted(n.getRoleName() for n in nodes
|
|
if n.getRole() in interactive_roles and not n.name)
|
|
assert not unnamed, ("unnamed interactive controls", unnamed)
|
|
sliders = [n for n in nodes if n.getRole() == pyatspi.ROLE_SLIDER and
|
|
any(word in n.name for word in ("Rate", "Pitch", "Volume"))]
|
|
assert len(sliders) == 3, [(n.name, n.getRoleName()) for n in nodes]
|
|
desktop = named("Desktop")
|
|
assert desktop.getRole() == pyatspi.ROLE_RADIO_BUTTON
|
|
assert desktop.getState().contains(pyatspi.STATE_CHECKED), "Desktop radio value missing"
|
|
time_format = next(n for n in nodes if n.name == "Time format" and
|
|
n.getRole() == pyatspi.ROLE_COMBO_BOX)
|
|
time_selection = time_format.querySelection()
|
|
assert time_selection.nSelectedChildren == 1
|
|
selected_time = time_selection.getSelectedChild(0).name
|
|
assert time_format.description == selected_time, (
|
|
"combo value missing", time_format.description, selected_time)
|
|
capital_pitch = named("Raise pitch for capital letters")
|
|
capital_beep = named("Beep for capital letters")
|
|
capital_cap = named("Say “cap” before capital letters")
|
|
assert capital_pitch.getState().contains(pyatspi.STATE_CHECKED)
|
|
assert not capital_beep.getState().contains(pyatspi.STATE_CHECKED)
|
|
assert not capital_cap.getState().contains(pyatspi.STATE_CHECKED)
|
|
date_format = next(n for n in nodes if n.name == "Date format" and
|
|
n.getRole() == pyatspi.ROLE_COMBO_BOX)
|
|
changed = dict(initial, Values=dict(initial["Values"], BeepForCapitals=True, SayCapForCapitals=True, TimeFormat="12-hour", DateFormat="dmy"))
|
|
send(changed)
|
|
wait_for(lambda: capital_cap.getState().contains(pyatspi.STATE_CHECKED),
|
|
"changed capital checkbox value was not exposed")
|
|
assert capital_beep.getState().contains(pyatspi.STATE_CHECKED)
|
|
wait_for(lambda: time_format.description == time_selection.getSelectedChild(0).name,
|
|
"time format did not initialize")
|
|
assert date_format.description == date_format.querySelection().getSelectedChild(0).name
|
|
send(initial)
|
|
wait_for(lambda: not capital_cap.getState().contains(pyatspi.STATE_CHECKED),
|
|
"restored capital checkbox value was not exposed")
|
|
progress = named("Progress bar output")
|
|
assert progress.description == "Beep", progress.description
|
|
background = named("Report background progress bars")
|
|
focus(background)
|
|
key("shift+Tab", "Down")
|
|
wait_for(lambda: progress.description == "Speak", "progress mode keyboard change missing")
|
|
key("Tab")
|
|
background = named("Report background progress bars")
|
|
wait_for(lambda: background.getState().contains(pyatspi.STATE_FOCUSED),
|
|
"progress mode trapped Tab")
|
|
key("space")
|
|
assert background.getState().contains(pyatspi.STATE_CHECKED)
|
|
for old_name in ("Beep progress bar updates", "Progress bar updates apply to", "Play sound for value",
|
|
"Progress bar beep frequency in seconds", "Progress bar update frequency in seconds",
|
|
"Speak progress bar updates", "Braille progress bar updates"):
|
|
assert old_name not in controls, "obsolete progress control remains: " + old_name
|
|
assert time_selection.selectChild(1)
|
|
assert date_format.querySelection().selectChild(2)
|
|
wait_for(lambda: time_format.description == time_selection.getSelectedChild(0).name,
|
|
"edited time selection missing")
|
|
wait_for(lambda: date_format.description == date_format.querySelection().getSelectedChild(0).name,
|
|
"edited date selection missing")
|
|
focus(desktop)
|
|
key("ctrl+Page_Down")
|
|
wait_for(lambda: named("Sounds").getState().contains(pyatspi.STATE_SELECTED),
|
|
"Sounds page did not open")
|
|
for name in ("Enable sounds", "Play sound for role", "Play sound for state",
|
|
"Play sound for position in set"):
|
|
assert named(name).getState().contains(pyatspi.STATE_CHECKED), name + " default missing"
|
|
assert not named("Use stereo position for object sounds").getState().contains(
|
|
pyatspi.STATE_CHECKED), "stereo sound placement was enabled by default"
|
|
presentation = named("Element presentation")
|
|
presentation_selection = presentation.querySelection()
|
|
assert presentation_selection.nSelectedChildren == 1
|
|
for index, expected in enumerate(("Sound and speech", "Speech only", "Sound only")):
|
|
assert presentation_selection.selectChild(index)
|
|
wait_for(lambda expected=expected: presentation.description == expected,
|
|
expected + " presentation did not select")
|
|
assert presentation_selection.selectChild(0)
|
|
wait_for(lambda: presentation.description == "Sound and speech", "mixed presentation did not restore")
|
|
spatialize = named("Use stereo position for object sounds")
|
|
focus(spatialize); key("space")
|
|
wait_for(lambda: spatialize.getState().contains(pyatspi.STATE_CHECKED),
|
|
"stereo sound placement did not toggle")
|
|
sound_volume = named("Sound volume")
|
|
assert sound_volume.queryValue().minimumValue == 0
|
|
assert sound_volume.queryValue().maximumValue == 1
|
|
master_sound = named("Enable sounds")
|
|
focus(master_sound); key("space")
|
|
wait_for(lambda: not sound_volume.getState().contains(pyatspi.STATE_SENSITIVE),
|
|
"sound master did not disable sound controls")
|
|
focus(master_sound); key("space")
|
|
key("ctrl+Page_Down")
|
|
wait_for(lambda: named("Speech").getState().contains(pyatspi.STATE_SELECTED),
|
|
"Speech page did not open")
|
|
history_limit = named("Speech history entries")
|
|
assert history_limit.getRole() == pyatspi.ROLE_SPIN_BUTTON
|
|
assert history_limit.queryValue().minimumValue == 1
|
|
assert history_limit.queryValue().maximumValue == 1000
|
|
assert history_limit.queryValue().currentValue == 50
|
|
focus(history_limit)
|
|
history_limit.queryValue().currentValue = 75
|
|
for slider in sliders:
|
|
relations = slider.getRelationSet()
|
|
assert any(r.getRelationType() == pyatspi.RELATION_LABELLED_BY for r in relations), (
|
|
"missing label buddy", slider.name)
|
|
assert slider.name, "unnamed slider"
|
|
rate = next(n for n in sliders if "Rate" in n.name)
|
|
for name in ("Help", "Apply", "Cancel", "OK"):
|
|
assert named(name).getRole() == pyatspi.ROLE_PUSH_BUTTON
|
|
# Prove the supported controls accept focus, and that the scale does
|
|
# not trap forward or reverse keyboard traversal.
|
|
assert not any(name.startswith("Use server default") for name in controls)
|
|
for name in ("Apply", "Cancel", "OK"):
|
|
focus(named(name))
|
|
for slider in sliders:
|
|
assert slider.getState().contains(pyatspi.STATE_SENSITIVE)
|
|
focus(slider)
|
|
pitch = next(n for n in sliders if "Pitch" in n.name)
|
|
focus(pitch)
|
|
key("Tab")
|
|
wait_for(lambda: rate.getState().contains(pyatspi.STATE_FOCUSED),
|
|
"Tab did not enter the rate slider")
|
|
key("shift+Tab")
|
|
wait_for(lambda: pitch.getState().contains(pyatspi.STATE_FOCUSED),
|
|
"Shift+Tab did not return to the pitch slider")
|
|
focus(named("Some")); key("space")
|
|
wait_for(lambda: named("Some").getState().contains(pyatspi.STATE_CHECKED),
|
|
"radio activation did not select punctuation")
|
|
print("PASS: initial preferences accessibility and keyboard navigation", flush=True)
|
|
focus(rate)
|
|
rate.queryValue().currentValue = 72
|
|
preview = request()
|
|
assert preview["Preview"] and preview["Values"]["Rate"] == 72, preview
|
|
send({"Preview": True, "Accepted": True, "Message": "Speech settings previewed."})
|
|
focus(named("Apply"))
|
|
key("space")
|
|
draft = request()
|
|
assert draft["Values"]["SpeechHistoryLimit"] == 75, draft
|
|
assert draft["Values"]["TimeFormat"] == "12-hour" and draft["Values"]["DateFormat"] == "numbers-md", draft
|
|
assert draft["Values"]["Rate"] == 72 and draft["Save"] and not draft["Close"], draft
|
|
assert draft["Values"]["Punctuation"] == 2 and draft["Values"]["Pitch"] == 5, draft
|
|
assert draft["Values"]["EnableSound"] and draft["Values"]["PlaySoundForRole"], draft
|
|
assert draft["Values"]["PlaySoundForState"] and draft["Values"]["SpatializeObjectSounds"], draft
|
|
assert draft["Values"]["ProgressBar"]["OutputMode"] == "speak", draft
|
|
assert draft["Values"]["ProgressBar"]["ReportBackground"], draft
|
|
expectedProgress = dict(initial["Values"]["ProgressBar"], OutputMode="speak", ReportBackground=True)
|
|
assert draft["Values"]["ProgressBar"] == expectedProgress, draft
|
|
# Simulated backend failure: restore editing, expose error by focus,
|
|
# and preserve the draft for correction.
|
|
rejected = dict(initial, Accepted=False, Message="Settings were not applied: test rejection.")
|
|
send(rejected)
|
|
status = controls[initial["Message"]]
|
|
wait_for(lambda: status.name == rejected["Message"], "error status missing")
|
|
wait_for(lambda: status.getState().contains(pyatspi.STATE_FOCUSED), "error status was not focused")
|
|
assert rate.queryValue().currentValue == 72, "rejection discarded draft"
|
|
focus(named("OK")); key("space")
|
|
saved = request()
|
|
assert saved["Save"] and saved["Close"] and saved["Values"]["Rate"] == 72
|
|
assert saved["Values"]["ProgressBar"] == expectedProgress, saved
|
|
# Reload a backend response with custom TOML-only thresholds. Later
|
|
# Apply and OK requests must preserve those too.
|
|
expectedProgress = dict(expectedProgress, BeepPercentageInterval=3, SpeechPercentageInterval=17)
|
|
saved["Values"]["ProgressBar"] = expectedProgress
|
|
accepted = dict(initial, Values=saved["Values"], Message="Settings applied and saved.")
|
|
send(accepted)
|
|
wait_for(lambda: status.name == accepted["Message"], "success status missing")
|
|
print("PASS: Apply, rejected draft, OK protocol", flush=True)
|
|
# GtkScale consumes Ctrl+Page_Down as a value change. Verify that
|
|
# behavior, then navigate the notebook from the capital pitch checkbox.
|
|
focus(rate)
|
|
previous_rate = rate.queryValue().currentValue
|
|
key("ctrl+Page_Down")
|
|
wait_for(lambda: rate.queryValue().currentValue != previous_rate,
|
|
"slider did not handle Ctrl+Page_Down")
|
|
assert named("Speech").getState().contains(pyatspi.STATE_SELECTED)
|
|
preview = request()
|
|
assert preview["Preview"], preview
|
|
send({"Preview": True, "Accepted": True, "Message": "Speech settings previewed."})
|
|
rate.queryValue().currentValue = previous_rate
|
|
preview = request()
|
|
assert preview["Preview"] and preview["Values"]["Rate"] == previous_rate, preview
|
|
send({"Preview": True, "Accepted": True, "Message": "Speech settings previewed."})
|
|
focus(named("Raise pitch for capital letters"))
|
|
for page in ("Braille", "Key Echo"):
|
|
key("ctrl+Page_Down")
|
|
wait_for(lambda: named(page).getState().contains(pyatspi.STATE_SELECTED),
|
|
page + " page did not open")
|
|
# Verify keyboard access, master sensitivity, and echo protocol values.
|
|
master = named("Enable key echo")
|
|
disabled = named("Disable key echo")
|
|
edit_fields = named("Key echo in edit fields")
|
|
for radio in (disabled, master, edit_fields):
|
|
assert radio.getRole() == pyatspi.ROLE_RADIO_BUTTON, radio.name
|
|
alphabetic = named("Enable alphabetic keys")
|
|
assert master.getState().contains(pyatspi.STATE_CHECKED)
|
|
assert alphabetic.getState().contains(pyatspi.STATE_CHECKED)
|
|
focus(disabled); key("space")
|
|
wait_for(lambda: not alphabetic.getState().contains(pyatspi.STATE_SENSITIVE), "master did not disable categories")
|
|
focus(named("Apply")); key("space")
|
|
off = request()
|
|
assert off["Save"] and not off["Close"] and not off["Values"]["EnableKeyEcho"], off
|
|
assert off["Values"]["ProgressBar"] == expectedProgress, off
|
|
assert off["Values"]["EnableAlphabeticKeys"] and off["Values"]["EnableEchoByWord"], off
|
|
send(dict(initial, Values=off["Values"], Message="Echo off saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply stayed busy")
|
|
assert not master.getState().contains(pyatspi.STATE_CHECKED)
|
|
wait_for(lambda: master.getState().contains(pyatspi.STATE_SENSITIVE), "key echo master stayed insensitive")
|
|
focus(master); key("space")
|
|
wait_for(lambda: master.getState().contains(pyatspi.STATE_CHECKED), "key echo master did not re-enable")
|
|
# The three choices are one keyboard radio group. Tab exits the group,
|
|
# Shift+Tab returns to the selected radio, and Apply reloads the mode.
|
|
key("Down")
|
|
wait_for(lambda: edit_fields.getState().contains(pyatspi.STATE_CHECKED), "Down did not select edit-fields-only")
|
|
assert not master.getState().contains(pyatspi.STATE_CHECKED)
|
|
key("Tab")
|
|
wait_for(lambda: alphabetic.getState().contains(pyatspi.STATE_FOCUSED), "Tab did not leave echo radio group")
|
|
key("shift+Tab")
|
|
wait_for(lambda: edit_fields.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not return to selected radio")
|
|
focus(named("Apply")); key("space")
|
|
edit_only = request()
|
|
assert edit_only["Values"]["EnableKeyEcho"] and edit_only["Values"]["KeyEchoInEditFieldsOnly"], edit_only
|
|
send(dict(initial, Values=edit_only["Values"], Message="Edit fields echo saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "edit-fields Apply stayed busy")
|
|
assert edit_fields.getState().contains(pyatspi.STATE_CHECKED)
|
|
assert alphabetic.getState().contains(pyatspi.STATE_SENSITIVE)
|
|
focus(named("Use custom echo voice")); key("space")
|
|
echo_rate = named("Key echo rate")
|
|
wait_for(lambda: echo_rate.getState().contains(pyatspi.STATE_SENSITIVE), "custom voice controls disabled")
|
|
# Only one preview may be outstanding. Read and acknowledge each
|
|
# changed value before moving focus, which discards stale previews.
|
|
for control, field, value in (("rate", "Rate", 83),
|
|
("pitch", "AveragePitch", 7.5),
|
|
("volume", "Gain", 10)):
|
|
slider = named("Key echo " + control)
|
|
focus(slider)
|
|
slider.queryValue().currentValue = value
|
|
preview = request()
|
|
assert preview["Preview"] and preview.get("EchoPreview") == control, preview
|
|
assert preview["Values"]["EchoVoice"][field] == value, preview
|
|
send({"Preview": True, "Accepted": True})
|
|
family = named("Key echo person")
|
|
# Echo selection previews require keyboard focus in the selector.
|
|
focus(named("Use custom echo voice")); key("Tab", "Tab", "Down")
|
|
preview = request()
|
|
assert preview["Preview"] and preview.get("EchoPreview") == "person", preview
|
|
assert preview["Values"]["EchoVoice"]["Family"]["Name"] == "Test voice", preview
|
|
assert family.querySelection().getSelectedChild(0).name == "Test voice (en)"
|
|
send({"Preview": True, "Accepted": True})
|
|
word_voice = named("Words")
|
|
assert not word_voice.getState().contains(pyatspi.STATE_CHECKED)
|
|
focus(word_voice); key("space")
|
|
wait_for(lambda: word_voice.getState().contains(pyatspi.STATE_CHECKED),
|
|
"custom word voice did not become checked")
|
|
focus(named("OK")); key("space")
|
|
echo_saved = request()
|
|
ev = echo_saved["Values"]
|
|
assert ev["ProgressBar"] == expectedProgress, ev
|
|
assert echo_saved["Save"] and echo_saved["Close"] and ev["EnableKeyEcho"], echo_saved
|
|
assert ev["UseCustomEchoVoice"] and ev["UseCustomEchoForWord"] and ev["KeyEchoInEditFieldsOnly"], ev
|
|
assert ev["EchoVoice"]["Rate"] == 83 and ev["EchoVoice"]["AveragePitch"] == 7.5 and ev["EchoVoice"]["Gain"] == 10, ev
|
|
assert ev["EchoVoice"]["Family"]["Name"] == "Test voice", ev
|
|
send(dict(initial, Values=ev, Message="Echo voice saved."))
|
|
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "OK stayed busy")
|
|
# Present must retain edits. Escape closes promptly while the parent
|
|
# restores the live voice preview after helper exit.
|
|
rate.queryValue().currentValue = 61
|
|
preview = request()
|
|
assert preview["Preview"] and preview["Values"]["Rate"] == 61, preview
|
|
send({"Preview": True, "Accepted": True, "Message": "Speech settings previewed."})
|
|
send({"Present": True})
|
|
focus(master)
|
|
key("ctrl+Page_Up", "ctrl+Page_Up")
|
|
wait_for(lambda: named("Speech").getState().contains(pyatspi.STATE_SELECTED),
|
|
"Speech page did not reopen")
|
|
focus(named("Raise pitch for capital letters"))
|
|
subprocess.run(["xdotool", "windowfocus", xid], check=True)
|
|
key("Escape")
|
|
assert process.wait(timeout=2) == 0, "Escape did not close preferences promptly"
|
|
assert responses.read_remaining() == "", "Escape submitted extra settings"
|
|
print("PASS: GTK notebook, roles, label buddies, Tab/Shift+Tab, Apply, failed apply, OK, Escape")
|
|
finally:
|
|
if reader_process is not None:
|
|
reader_process.terminate()
|
|
try:
|
|
reader_process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
reader_process.kill()
|
|
reader_process.wait(timeout=5)
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
if window_manager is not None:
|
|
window_manager.terminate()
|
|
window_manager.wait(timeout=5)
|
|
if private_directory is not None:
|
|
private_directory.cleanup()
|
|
|
|
|
|
def main():
|
|
if "--inside" in sys.argv:
|
|
inside()
|
|
return
|
|
read_fd, write_fd = os.pipe()
|
|
server = subprocess.Popen(["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "1024x768x24", "-nolisten", "tcp"], pass_fds=(write_fd,), stderr=subprocess.DEVNULL)
|
|
os.close(write_fd)
|
|
try:
|
|
with os.fdopen(read_fd) as reader:
|
|
display = reader.readline().strip()
|
|
assert display, "Xvfb failed"
|
|
env = dict(os.environ, DISPLAY=":" + display, GDK_BACKEND="x11", GTK_MODULES="gail:atk-bridge", GIO_USE_VFS="local")
|
|
if env.get("CTHULHU_PREFS_SPEECH_ONLY") == "1":
|
|
env["LC_ALL"] = "en_US.UTF-8"
|
|
for key in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"):
|
|
env.pop(key, None)
|
|
run_private_session(["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], env=env, check=True, timeout=90)
|
|
finally:
|
|
server.terminate()
|
|
server.wait(timeout=5)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|