Files
Panama/tests/quickshell/settings_write_sweep.py
T
Gabriel Brown 180308135a Verify every setting actually does something
The audit that followed "some things in the settings app don't work".

Sixty-five settings reach the compositor and five of them were checked
against it. The rest were covered only by tests that read source text,
which is exactly where a dead setting hides: the write no-ops, nothing
fails, nothing logs, and the row simply does nothing.

The sweep drives each setting through SystemSettings.commitPreference --
the entry point a settings row uses -- flips it to a value it does not
hold, reads it back from the live compositor, and puts it straight back
before touching the next one. Settings Panama stores itself get the same
treatment against the store, since a value that fails to persist is the
same dead row from the outside.

Result: 61 of 63 compositor settings verified against the running
compositor, and 51 stored settings round-tripped. No failures. The
breakage was confined to the Applications page, which is fixed.

Proven able to fail before being trusted: with commitPreference stubbed
to return true without applying, 61 settings are reported; with the
store stubbed to return nothing, 51 are. A one-second settle window
keeps a slow read from being reported as a dead write, which it briefly
was.

Also here: control-center-contract asserted the literal margin
expression that made the panel hang 38 pixels below the bar, so the
contract and the code agreed and the bug was invisible to both. And
settings-page-registry-contract is deleted -- settings-nav-contract
already checked those files and more. It would have caught the Storage
page omission if I had run the suite instead of a hand-picked subset.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 12:17:27 -04:00

291 lines
12 KiB
Python

#!/usr/bin/env python3
"""Flip every compositor-backed setting, read it back, and put it back.
The existing write contract does this for five settings. There are sixty-five,
and the ones it does not cover are exactly where a silently-dead setting would
live: nothing fails, nothing logs, the row just does not do anything.
Each setting is driven through SystemSettings.commitPreference -- the same entry
point a settings row uses -- so this exercises the real routing, the real
compositor write, and the real read-back verification, not a shape.
Every original value is captured before anything changes and restored at the
end, through hyprctl directly, so a broken write path cannot also break the
restore.
"""
from __future__ import annotations
import ast
import json
import os
import re
import subprocess
import sys
import time
SCHEMA = "config/dot/quickshell/config/PreferenceSchema.qml"
# Read-back shapes this sweep knows how to compare. A gradient or a vec2 has no
# single scalar to diff, and guessing one would produce false failures; those
# are reported as skipped rather than quietly counted as verified.
COMPARABLE = {"bool", "int", "float", "str"}
# Settings whose value this must not choose freely.
#
# kb_layout is the one that can lock someone out of their own keyboard if a
# sweep picks a layout they cannot type in, and the existing write contract
# already covers it deliberately.
SKIP_KEYS = {"keyboardLayout", "keyboardVariant", "keyboardOptions", "keyboardModel"}
def run(command: list[str], timeout: float = 20.0) -> subprocess.CompletedProcess:
return subprocess.run(command, capture_output=True, text=True,
timeout=timeout, check=False)
def local_entries() -> list[dict]:
"""User-facing settings that Panama stores itself.
These never reach the compositor, so the compositor cannot be asked whether
they took. What can be asked is whether the value came back out of the
preference store -- a setting that silently fails to persist is the same
dead row from the user's side.
"""
source = open(SCHEMA, encoding="utf-8").read()
entries = []
for chunk in source.split("key: ")[1:]:
head = chunk[:1400]
if re.search(r"internal:\s*true", head) or re.search(r"hypr:\s*\{", head):
continue
key = re.match(r'"([A-Za-z0-9_]+)"', chunk)
kind = re.search(r'type:\s*"([a-z]+)"', head)
if not (key and kind) or kind.group(1) == "json":
continue
entry = {"key": key.group(1), "type": kind.group(1)}
for bound in ("min", "max", "step"):
found = re.search(rf"\b{bound}:\s*(-?[0-9.]+)", head)
if found:
entry[bound] = float(found.group(1))
options = re.search(r"options:\s*\[(.*?)\]", head, re.S)
if options:
entry["values"] = [
ast.literal_eval(value) if value.lstrip().startswith(("'", '"'))
else int(value)
for value in re.findall(r"value:\s*(\"[^\"]*\"|-?\d+)", options.group(1))
]
entries.append(entry)
return entries
def schema_entries() -> list[dict]:
"""Every user-facing setting that maps onto a compositor option."""
source = open(SCHEMA, encoding="utf-8").read()
entries = []
for chunk in source.split("key: ")[1:]:
head = chunk[:1400]
if re.search(r"internal:\s*true", head):
continue
hypr = re.search(r"hypr:\s*\{(.*?)\n\s*\}", head, re.S)
if not hypr:
continue
key = re.match(r'"([A-Za-z0-9_]+)"', chunk)
kind = re.search(r'type:\s*"([a-z]+)"', head)
option = re.search(r'option:\s*"([^"]+)"', hypr.group(1))
read_as = re.search(r'readAs:\s*"([a-z]+)"', hypr.group(1))
if not (key and kind and option):
continue
entry = {
"key": key.group(1),
"type": kind.group(1),
"option": option.group(1),
"readAs": read_as.group(1) if read_as else "int",
"invert": bool(re.search(r"invert:\s*true", hypr.group(1))),
}
for bound in ("min", "max", "step"):
found = re.search(rf"\b{bound}:\s*(-?[0-9.]+)", head)
if found:
entry[bound] = float(found.group(1))
options = re.search(r"options:\s*\[(.*?)\]", head, re.S)
if options:
entry["values"] = [
ast.literal_eval(value) if value.lstrip().startswith(("'", '"'))
else int(value)
for value in re.findall(r"value:\s*(\"[^\"]*\"|-?\d+)", options.group(1))
]
entries.append(entry)
return entries
def read_option(entry: dict):
"""What the compositor currently holds, in the shape the schema declares."""
result = run(["hyprctl", "-j", "getoption", entry["option"]])
if result.returncode != 0:
return None
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError:
return None
field = {"bool": "bool", "int": "int", "float": "float", "str": "str"}.get(entry["readAs"])
if field is None or field not in payload:
return None
return payload[field]
def stored_from_option(entry: dict, raw):
"""The value a preference would hold for this compositor reading."""
if entry["readAs"] == "bool":
value = bool(raw)
return (not value) if entry["invert"] else value
if entry["readAs"] == "int" and entry["type"] == "bool":
value = bool(raw)
return (not value) if entry["invert"] else value
return raw
def pick_target(entry: dict, current):
"""A valid value this setting does not currently hold."""
if entry["type"] == "bool":
return not bool(current)
if entry["type"] == "enum":
for candidate in entry.get("values", []):
if candidate != current:
return candidate
return None
if entry["type"] in ("int", "real"):
low = entry.get("min", 0.0)
high = entry.get("max", max(low + 1.0, float(current or 0) + 1.0))
step = entry.get("step", 1.0)
candidate = float(current or 0) + step
if candidate > high:
candidate = float(current or 0) - step
if candidate < low:
candidate = low if low != current else high
if entry["type"] == "int":
candidate = int(round(candidate))
if candidate == current:
return None
return candidate
return None
def ipc(config_home: str, harness: str, *arguments: str) -> subprocess.CompletedProcess:
environment = dict(os.environ, XDG_CONFIG_HOME=config_home)
return subprocess.run(["qs", "-p", harness, "ipc", "call", "settings-system-test", *arguments],
capture_output=True, text=True, timeout=30, check=False,
env=environment)
def main() -> int:
config_home = sys.argv[1]
harness = sys.argv[2]
entries = [entry for entry in schema_entries() if entry["key"] not in SKIP_KEYS]
verified, skipped, failures, originals = [], [], [], {}
for entry in entries:
raw = read_option(entry)
if raw is None or entry["readAs"] not in COMPARABLE:
skipped.append((entry["key"], f"read-back shape {entry['readAs']}"))
continue
current = stored_from_option(entry, raw)
originals[entry["key"]] = (entry, raw)
target = pick_target(entry, current)
if target is None:
skipped.append((entry["key"], "no second valid value"))
continue
# Whatever happens below, this setting goes back to where it was
# before the next one is touched. A sweep that dies holding sixty
# settings at test values would be worse than no sweep.
try:
answer = ipc(config_home, harness, "commit", entry["key"], json.dumps(target))
if answer.returncode != 0:
failures.append((entry["key"], f"commit failed: {answer.stderr.strip()[:80]}"))
continue
if answer.stdout.strip().lower() not in ("true", ""):
failures.append((entry["key"], f"commit refused the value ({answer.stdout.strip()[:40]})"))
continue
# The compositor is asked again for up to a second. commitPreference
# verifies before storing, so a correct write is usually visible
# immediately -- but "usually" makes a flaky test, and a flaky test
# is worse than none. A write that never happens never matches, no
# matter how long this waits.
after = None
matched = False
deadline = time.monotonic() + 1.0
while True:
after = stored_from_option(entry, read_option(entry))
if isinstance(target, float) or isinstance(after, float):
matched = after is not None and abs(float(after) - float(target)) < 0.02
else:
matched = after == target
if matched or time.monotonic() > deadline:
break
time.sleep(0.05)
if not matched:
failures.append((entry["key"],
f"set to {target!r} but the compositor reports {after!r} "
f"({entry['option']})"))
continue
verified.append(entry["key"])
finally:
ipc(config_home, harness, "commit", entry["key"], json.dumps(current))
# ── Settings Panama stores itself ────────────────────────────────────────
local_verified, local_skipped, local_failures = [], [], []
for entry in local_entries():
raw = ipc(config_home, harness, "stored", entry["key"])
if raw.returncode != 0:
local_failures.append((entry["key"], "could not be read from the store"))
continue
try:
current = json.loads(raw.stdout.strip() or "null")
except json.JSONDecodeError:
local_failures.append((entry["key"], f"stored value is not readable: {raw.stdout.strip()[:40]}"))
continue
target = pick_target(entry, current)
if target is None:
local_skipped.append((entry["key"], "no second valid value"))
continue
try:
answer = ipc(config_home, harness, "commit", entry["key"], json.dumps(target))
if answer.returncode != 0 or answer.stdout.strip().lower() not in ("true", ""):
local_failures.append((entry["key"], "the store refused the value"))
continue
back = ipc(config_home, harness, "stored", entry["key"])
try:
after = json.loads(back.stdout.strip() or "null")
except json.JSONDecodeError:
after = None
if isinstance(target, float) or isinstance(after, float):
matched = after is not None and abs(float(after) - float(target)) < 0.02
else:
matched = after == target
if not matched:
local_failures.append((entry["key"], f"set to {target!r} but the store reports {after!r}"))
continue
local_verified.append(entry["key"])
finally:
ipc(config_home, harness, "commit", entry["key"], json.dumps(current))
print(json.dumps({
"verified": verified,
"skipped": skipped,
"failures": failures,
"total": len(entries),
"localVerified": local_verified,
"localSkipped": local_skipped,
"localFailures": local_failures,
}))
return 0
if __name__ == "__main__":
raise SystemExit(main())