Files
cthulhu/tests/native_preferences.py

1155 lines
74 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
def inside():
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)
initial = {"Values": {"Rate": 50, "Pitch": 5, "Volume": 5,
"SpeechHistoryLimit": 50,
"Reading": {"AutomaticallyReadTitleChanges": True},
"KeyboardLayout": 1, "CthulhuModifierKeys": ["Insert", "KP_Insert"],
"Punctuation": 1, "Capitalization": "none",
"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."}
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_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 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():
assert select.select([process.stdout], [], [], 5)[0], "no Apply request"
return json.loads(process.stdout.readline())
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_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_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 process.stdout.read() == "", "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_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():
preview = request()
assert preview["Preview"], 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)
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("Use server default rate"))
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)
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)
wait_for(lambda: abs(slider.queryValue().currentValue - expected) < 1e-8,
f"{name}: {arrow} did not produce {expected}")
finalValue = expected
for bound, arrows in ((0, ("Left", "Down")),
(slider.queryValue().maximumValue, ("Right", "Up"))):
slider.queryValue().currentValue = bound
key(*arrows)
assert slider.queryValue().currentValue == bound, (name, "boundary", bound)
slider.queryValue().currentValue = finalValue
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
for field, expected in (("Rate", 49.9), ("AveragePitch", 4.9), ("Gain", 4.9)):
assert abs(applied["Values"]["EchoVoice"][field] - expected) < 1e-8, 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:
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_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")
pitchDefault = named("Use server default pitch")
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 pitchDefault.getState().contains(pyatspi.STATE_CHECKED)
focus(pitchDefault); key("space")
accept_preview()
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 and not pitchDefault.getState().contains(pyatspi.STATE_CHECKED), 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 process.stdout.read() == ""
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("Use server default rate"))
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 process.stdout.read() == "", "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_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 == 2
for row in range(readingTable.nRows):
stateCell = readingTable.getAccessibleAt(row, 0)
settingCell = readingTable.getAccessibleAt(row, 1)
assert settingCell.name and settingCell.name not in ("checked", "unchecked"), settingCell.name
checked = stateCell.getState().contains(pyatspi.STATE_CHECKED)
assert stateCell.name == ("checked" if checked else "unchecked"), stateCell.name
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")
for label, expected in (("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("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, 0).getState().contains(pyatspi.STATE_FOCUSED), "Left did not focus the checkbox")
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
assert not applied["Values"]["Reading"]["AutomaticallyReadTitleChanges"], applied
assert applied["Values"]["Reading"]["AutomaticallyReadClipboardChanges"], 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"
assert readingTable.getAccessibleAt(clipboardRow, 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 process.stdout.read() == "", "Cancel submitted reading changes"
print("PASS: reading table, 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 process.stdout.read() == "", "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 process.stdout.read() == "", "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")
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)
capitalization = next(n for n in nodes if n.name == "Capitalization style" and
n.getRole() == pyatspi.ROLE_COMBO_BOX)
capitalization_selection = capitalization.querySelection()
assert capitalization.description == capitalization_selection.getSelectedChild(0).name
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"], Capitalization="spell", TimeFormat="12-hour", DateFormat="dmy"))
send(changed)
wait_for(lambda: capitalization.description == "Spell",
"changed combo value was not exposed")
assert capitalization.querySelection().getSelectedChild(0).name == "Spell"
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: capitalization.description == "None",
"restored combo 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.
for name in ("Use server default rate", "Use server default pitch",
"Use server default volume", "Apply", "Cancel", "OK"):
focus(named(name))
for slider in sliders:
focus(slider)
focus(named("Use server default rate"))
key("Tab")
wait_for(lambda: rate.getState().contains(pyatspi.STATE_FOCUSED),
"Tab did not enter the rate slider")
key("shift+Tab")
wait_for(lambda: named("Use server default rate").getState().contains(
pyatspi.STATE_FOCUSED), "Shift+Tab did not leave the rate slider")
focus(named("Some")); key("space")
wait_for(lambda: named("Some").getState().contains(pyatspi.STATE_CHECKED),
"radio activation did not select punctuation")
focus(named("Use server default pitch")); key("space")
wait_for(lambda: not next(n for n in sliders if "Pitch" in n.name).getState().contains(
pyatspi.STATE_SENSITIVE), "default pitch did not disable field")
preview = request()
assert preview["Preview"] and preview["Values"]["Pitch"] is None, preview
send({"Preview": True, "Accepted": True, "Message": "Speech settings previewed."})
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"] is None, 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 adjacent 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("Use server default rate"))
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")
alphabetic = named("Enable alphabetic keys")
assert master.getState().contains(pyatspi.STATE_CHECKED)
assert alphabetic.getState().contains(pyatspi.STATE_CHECKED)
focus(master); 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")
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")
focus(echo_rate); echo_rate.queryValue().currentValue = 83
focus(named("Key echo pitch")); named("Key echo pitch").queryValue().currentValue = 7.5
focus(named("Key echo volume")); named("Key echo volume").queryValue().currentValue = 10
family = named("Key echo person")
family.querySelection().selectChild(1)
word_voice = named("Use custom voice for word echo")
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")
preview = request()
assert preview["Preview"], preview
send({"Preview": True, "Accepted": True, "Message": "Speech settings previewed."})
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"], 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("Use server default rate"))
subprocess.run(["xdotool", "windowfocus", xid], check=True)
key("Escape")
assert process.wait(timeout=2) == 0, "Escape did not close preferences promptly"
assert process.stdout.read() == "", "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)
subprocess.run(["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()