1155 lines
74 KiB
Python
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()
|