Files
cthulhu/tests/native_speech_defaults.py

115 lines
6.1 KiB
Python

"""Missing-file acceptance checks for the real private reader/preferences pair."""
import json
import subprocess
import tomllib
from native_x11 import wait_for
def check_no_config(window, tabs, server, configPath):
import pyatspi
def walk(node):
yield node
for child in node:
if child is not None:
yield from walk(child)
def controls_for(currentWindow):
nodes = list(walk(currentWindow))
controls = {node.name.rstrip(":"): node for node in nodes if node.name}
controls.update({node.name.rstrip(":"): node for node in nodes
if node.getRole() in (pyatspi.ROLE_SLIDER, pyatspi.ROLE_COMBO_BOX)})
return controls
def key(*keys):
subprocess.run(["xdotool", "key", "--clearmodifiers", *keys], check=True)
def focus(control):
assert control.queryComponent().grabFocus(), control.name
wait_for(lambda: control.getState().contains(pyatspi.STATE_FOCUSED), "focus missing: " + control.name)
def saved_speech():
if configPath.exists():
data = tomllib.loads(configPath.read_text())
return data["profiles"][data["active-profile"]]["speech"]
def find_window():
for application in pyatspi.Registry.getDesktop(0):
for candidate in application:
if candidate and candidate.name == "Cthulhu Preferences":
return candidate
assert not configPath.exists(), "opening preferences unexpectedly created a configuration"
assert tabs.querySelection().selectChild(2)
controls = controls_for(window)
assert not any(name.startswith(("Use server default", "Use Default voice", "Use automatic uppercase"))
for name in controls)
expected = {"Rate": 59, "Pitch": 4, "Volume": 9} if server.defaultsSupported else {"Rate": 50, "Pitch": 5, "Volume": 5}
for name, value in expected.items():
slider = controls[name]
assert slider.getRole() == pyatspi.ROLE_SLIDER, (name, slider.getRoleName())
assert slider.getState().contains(pyatspi.STATE_SENSITIVE), name
assert slider.queryValue().currentValue == value, (name, slider.queryValue().currentValue, value)
for previous, following in (("Volume", "Pitch"), ("Pitch", "Rate")):
focus(controls[previous]); key("Tab")
wait_for(lambda: controls[following].getState().contains(pyatspi.STATE_FOCUSED), "Tab traversal failed")
key("shift+Tab")
wait_for(lambda: controls[previous].getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab traversal failed")
voiceType = controls["Voice type"]
assert voiceType.querySelection().selectChild(1)
wait_for(lambda: controls["Pitch"].queryValue().currentValue == 7, "automatic uppercase pitch missing")
assert voiceType.querySelection().selectChild(0)
wait_for(lambda: controls["Pitch"].queryValue().currentValue == expected["Pitch"], "Default pitch did not return")
focus(controls["Apply"]); key("space")
wait_for(lambda: saved_speech() is not None, "Apply did not create the missing config")
speech = saved_speech()
assert not speech.get("default-voice"), speech
assert not speech.get("uppercase-voice"), speech
wait_for(lambda: controls["Apply"].getState().contains(pyatspi.STATE_SENSITIVE), "Apply stayed busy")
expectedRaw = {}
for name, scale, offset in (("Rate", 2, 98), ("Pitch", 20, 90), ("Volume", 15, 35)):
slider = controls[name]
focus(slider); key("Down" if name == "Volume" else "Up")
expected[name] += -1 if name == "Volume" else 1
expectedRaw[name.upper()] = int(scale * expected[name] - offset)
wait_for(lambda: slider.queryValue().currentValue == expected[name], name + " did not change directly")
wait_for(lambda: any(all(record["settings"][field] == value for field, value in expectedRaw.items())
for record in server.snapshot()), name + " preview did not reach speech backend")
focus(controls["Apply"]); key("space")
def saved_values_match():
voice = saved_speech().get("default-voice", {})
return all(voice.get(field) == expected[name] for name, field in
(("Rate", "rate"), ("Pitch", "average-pitch"), ("Volume", "gain")))
wait_for(saved_values_match, "slider values were not saved")
assert not saved_speech().get("uppercase-voice"), saved_speech()
wait_for(lambda: controls["Apply"].getState().contains(pyatspi.STATE_SENSITIVE), "Apply stayed busy")
focus(controls["Cancel"]); key("space")
wait_for(lambda: find_window() is None, "preferences did not close")
key("Insert+space")
reopened = wait_for(find_window, "preferences did not reopen")
controls = controls_for(reopened)
tabs = next(node for node in walk(reopened) if node.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
assert tabs.querySelection().selectChild(2)
for name, value in expected.items():
assert controls[name].queryValue().currentValue == value, (name, value)
assert controls["Voice type"].querySelection().selectChild(1)
wait_for(lambda: controls["Pitch"].queryValue().currentValue == max(7, expected["Pitch"] + 2),
"untouched Uppercase pitch stopped following Default")
assert controls["Voice type"].querySelection().selectChild(0)
focus(controls["Rate"]); key("Up")
changedRaw = dict(expectedRaw, RATE=expectedRaw["RATE"] + 2)
wait_for(lambda: any(record["settings"] == changedRaw for record in server.snapshot()), "unsaved preview missing")
focus(controls["Cancel"]); key("space")
wait_for(lambda: find_window() is None, "Cancel did not close preferences")
assert saved_values_match(), "Cancel changed the saved settings"
key("Insert+space")
reopened = wait_for(find_window, "preferences did not reopen after Cancel")
controls = controls_for(reopened)
assert controls["Rate"].queryValue().currentValue == expected["Rate"], "Cancel retained the unsaved rate"
(configPath.parent / "speech-defaults.json").write_text(json.dumps({
"initialConfigMissing": True, "serverDefaultsSupported": server.defaultsSupported,
"savedValues": expected, "savedBackendValues": expectedRaw}, indent=2))