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