Files
Panama/tests/quickshell/settings-ownership-contract
T

189 lines
8.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# A setting has one schema-routed owner. A second page may mirror it only when
# this contract names the exact owner and mirror set. The same ownership rule
# says who writes each half of the window border: ColorScheme.qml owns the
# neutral inactive role AND the accent-derived focused role, restating both
# together on every scheme or accent change.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
pages_dir="$repo_dir/config/dot/quickshell/modules/settings"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
looks="$repo_dir/config/dot/hypr/looks.lua"
catalog="$repo_dir/config/dot/quickshell/config/themes.json"
readme="$pages_dir/README.md"
fail() {
printf 'settings ownership contract: %s\n' "$1" >&2
exit 1
}
python3 - "$pages_dir" "$schema" "$search" <<'PY' \
|| fail 'page ownership or intentional mirrors drifted'
from __future__ import annotations
import re
import sys
from collections import defaultdict
from pathlib import Path
pages_dir = Path(sys.argv[1])
schema_text = Path(sys.argv[2]).read_text(encoding="utf-8")
search_text = Path(sys.argv[3]).read_text(encoding="utf-8")
expected = {
"animationsEnabled": {"owner": "appearance", "mirrors": {"accessibility"}},
"cursorInactiveTimeout": {"owner": "mouse", "mirrors": {"accessibility"}},
"cursorSize": {"owner": "accessibility", "mirrors": {"mouse"}},
"inactiveOpacity": {"owner": "appearance", "mirrors": {"accessibility"}},
# The bar decides what the bar contains, which is why Bar owns this. Agents
# mirrors it because that is the page somebody is on when they wonder where
# the number went -- the rest of the usage settings (which collectors run,
# how often) live only there, and a master switch missing from the card
# that holds them would be a card that cannot be turned off from itself.
"showAgentUsage": {"owner": "bar", "mirrors": {"agents"}},
# lockMinutes and lockOnSleep used to be mirrored onto Privacy, which was
# the one mirror in this table that nobody had asked for: Privacy carried a
# whole second Screen-lock card, so the same preference had two sliders and
# the page you were looking at might not be the one you last set. The card
# is gone and Privacy points at Power & Lock instead, so the mirror went
# with it. Power owns them outright now.
}
def strip_comments(text: str) -> str:
return re.sub(r"//.*", "", text)
def page_name(path: Path) -> str:
stem = path.stem.removesuffix("Page")
return re.sub(r"(?<!^)(?=[A-Z])", "-", stem).lower()
rows: dict[str, list[str]] = defaultdict(list)
row_pattern = re.compile(
r"(?:ToggleRow|SliderRow|ChoiceRow|TextEntryRow|TimeOfDayRow)\s*\{(?P<body>.*?)\}",
re.S,
)
for page_path in pages_dir.glob("*Page.qml"):
text = strip_comments(page_path.read_text(encoding="utf-8"))
for match in row_pattern.finditer(text):
setting = re.search(r'setting\s*:\s*"([^"]+)"', match.group("body"))
if setting:
rows[setting.group(1)].append(page_name(page_path))
duplicates = {key: set(pages) for key, pages in rows.items() if len(pages) > 1}
if set(duplicates) != set(expected):
raise SystemExit(
f"duplicate keys are {sorted(duplicates)}, expected {sorted(expected)}"
)
group_pages = dict(re.findall(r'"([^"]+)"\s*:\s*"([^"]+)"', search_text))
for key, policy in expected.items():
wanted_pages = {policy["owner"], *policy["mirrors"]}
if duplicates[key] != wanted_pages:
raise SystemExit(f"{key} appears on {sorted(duplicates[key])}, expected {sorted(wanted_pages)}")
block = re.search(
r'\{\s*\n\s*key:\s*"' + re.escape(key) + r'"(?P<body>.*?)\n\s*\}',
schema_text,
re.S,
)
if not block:
raise SystemExit(f"schema entry missing for {key}")
group = re.search(r'group:\s*"([^"]+)"', block.group("body"))
if not group:
raise SystemExit(f"schema group missing for {key}")
routed = group_pages.get(group.group(1))
if routed != policy["owner"]:
raise SystemExit(
f"{key} routes to {routed!r}, expected primary owner {policy['owner']!r}"
)
PY
for needle in \
'## Setting ownership' \
'one primary page' \
'Intentional mirrors' \
'`animationsEnabled`' \
'`cursorInactiveTimeout`' \
'`cursorSize`' \
'`inactiveOpacity`' \
'scheme-relative role' \
'mode, scale, rotation, arrangement, and primary role'; do
rg -Fq "$needle" "$readme" || fail "README is missing $needle"
done
python3 - "$scheme" "$looks" "$catalog" <<'PY' \
|| fail 'window border ownership drifted'
import json
import re
import sys
scheme = open(sys.argv[1], encoding="utf-8").read()
looks = open(sys.argv[2], encoding="utf-8").read()
themes = {t["id"]: t for t in json.load(open(sys.argv[3], encoding="utf-8"))["themes"]}
# Hyprland's own startup value has to stand alone with no settings file, so it
# carries a literal pair -- but that pair is the two DEFAULT themes' gutters,
# the same role ColorScheme restates the moment the shell is up. A drift here
# is a visible flash of the wrong border on every login.
for theme_id in ("moon", "day"):
expected = "rgba(" + themes[theme_id]["palette"]["gutter"].lstrip("#") + "99)"
if expected not in looks:
raise SystemExit(
f"Hyprland's startup inactive_border does not carry {theme_id}'s gutter ({expected})")
# The inactive border is a neutral contrast role, and it is now drawn from the
# ACTIVE THEME's gutter rather than from two Tokyo Night literals. Those
# literals were right for two of the ten shipped themes and for no custom one,
# so a hardcoded pair here is the regression worth catching.
inactive = re.search(
r'property string inactiveBorder:\s*root\.hyprColor\(Theme\.gutter,', scheme)
if not inactive:
raise SystemExit("the inactive border is no longer the active theme's neutral role")
if re.search(r'property string inactiveBorder(Dark|Light):\s*"#', scheme):
raise SystemExit("ColorScheme has regrown a hardcoded inactive-border literal")
if re.search(r'rgba\([0-9a-f]{8}\)', scheme):
raise SystemExit("ColorScheme has regrown a literal Hyprland border colour")
without_comments = re.sub(r"//.*", "", scheme)
# The focused border is the accent role, and ColorScheme.qml owns it too:
# each named accent carries a separate pair per scheme, so a scheme change
# must restate the focused border, not just the neutral one, or a chosen
# accent goes stale the moment light/dark flips. Both roles are also restated
# when the THEME changes, since both now follow the resolved palette.
start = re.search(r'property string accentBorderStart:\s*root\.hyprColor\(Theme\.accent,', scheme)
end = re.search(r'property string accentBorderEnd:\s*root\.hyprColor\(Theme\.accentSecondary,', scheme)
if not start or not end:
raise SystemExit("ColorScheme does not derive the focused border from the chosen accent")
if 'themeChanged' not in without_comments:
raise SystemExit("ColorScheme does not restate its borders when the theme changes")
if 'schemeChanged || themeChanged' not in without_comments:
raise SystemExit("the inactive border is not restated on a theme change")
if 'schemeChanged || accentChanged || themeChanged' not in without_comments:
raise SystemExit("the focused border is not restated on a theme change")
# Written as a Lua TABLE, not a string: the string form of a Hyprland gradient
# carries only one stop, so writing it that way is accepted and silently
# keeps whatever the previous accent left behind. Built through the same
# serializeValue() SystemSettings.qml uses for every other gradient, rather
# than a second hand-rolled (and unescaped) copy of that table syntax here.
if 'SystemSettings.serializeValue({' not in without_comments:
raise SystemExit("ColorScheme does not build the focused border through the shared gradient serializer")
if 'colors: [root.accentBorderStart, root.accentBorderEnd]' not in without_comments:
raise SystemExit("ColorScheme does not derive the focused-border gradient from the chosen accent")
if 'active_border = ${activeBorder}' not in without_comments:
raise SystemExit("ColorScheme does not write the focused border as the serialized gradient table")
if 'inactive_border = "${root.inactiveBorder}"' not in scheme:
raise SystemExit("ColorScheme does not apply its effective inactive role")
PY
printf 'settings ownership contract: PASS\n'