Files
Panama/tests/hypr/prefs-fallback-contract.sh
T
Gabriel Brown 00a81edadd Make Panama settings one shared source of truth
Panama had grown into three configuration surfaces that only agreed because
they had been typed to agree: looks.lua hardcoded values, DesktopPreferences
independently defaulted the same values, and SystemSettings replayed them at
startup. Nothing kept them in sync, and the Lua side read no shared state at
all. This lands the first three stages of docs/superpowers/plans/2026-08-17-panama-cohesion.md.

Fix silently failing Hyprland writes. On a Lua-configured Hyprland, hyprctl
keyword refuses the write, prints the refusal to stdout, and still exits 0, so
the HDR, VRR, and direct-scanout toggles persisted their value and reported
success while the compositor never changed. Writes now go through hyprctl eval,
which has the same hazard on syntax and runtime errors, so success is defined
as reading the value back and finding it equal. The existing contract passed
throughout the outage because it re-applied the values already in place; the
new one flips each value to something it does not hold.

Derive preferences from a schema. Every setting used to be restated four times
-- a property alias, a JSON adapter property, a change handler, and a line in
reset -- where omitting any one failed silently. PreferenceSchema.qml is now
the single source, and persistence, validation, reset, and the Hyprland mapping
all derive from it. Unknown keys on disk survive a write so a rollback does not
discard a newer build's settings, and a corrupt file falls back to shipped
defaults. The store moved to ~/.config/panama/settings.json, migrating from the
old state directory without deleting it.

Share that file with Hyprland. prefs.lua reads it at config time with every
shipped literal kept as the fallback, so the config still stands alone. The Lua
is the default, the JSON is the truth, and Settings is the editor. The
compositor-adjustable surface goes from 3 keys to 23.

Also fixes two test-hygiene bugs found by running the suite end to end for the
first time: settings-pages-contract could see the window settings-window-contract
leaves behind, and the new write contract was persisting its deliberately-wrong
values into the user's real store.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-17 23:26:56 -04:00

129 lines
6.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# config/dot/hypr/prefs.lua is read at config time by looks.lua, input.lua, and
# monitors.lua. It is therefore the one piece of Panama that can cost the user a
# working compositor rather than merely a working feature.
#
# This contract pins the only behaviour that matters: whatever is in the
# settings file -- including nothing, garbage, or values of the wrong type --
# the config still parses, and every setting either takes the stored value or
# falls back to the value shipped in the Lua.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
hypr_dir="$repo_dir/config/dot/hypr"
work="$(mktemp -d /tmp/panama-prefs-contract.XXXXXX)"
fail() {
printf 'prefs fallback contract: %s\n' "$1" >&2
exit 1
}
cleanup() {
rm -rf "$work"
}
trap cleanup EXIT
# Exercise prefs.lua directly rather than through a full compositor launch: the
# question is what the module returns, and Hyprland's own parse is covered by
# the --verify-config case at the end.
probe() {
local settings="$1"
mkdir -p "$work/config/panama"
if [[ "$settings" == "__absent__" ]]; then
rm -f "$work/config/panama/settings.json"
else
printf '%s' "$settings" >"$work/config/panama/settings.json"
fi
XDG_CONFIG_HOME="$work/config" lua -e "
package.path = '$hypr_dir/?.lua;' .. package.path
local ok, prefs = pcall(require, 'prefs')
if not ok then
print('LOAD_ERROR ' .. tostring(prefs))
os.exit(0)
end
print(string.format(
'gapsOut=%s rounding=%s blur=%s layout=%s hdr=%s',
tostring(prefs.get('gapsOut', 10)),
tostring(prefs.get('windowRounding', 18)),
tostring(prefs.get('blurEnabled', true)),
tostring(prefs.get('keyboardLayout', 'us')),
tostring(prefs.getInt('autoHdr', 1))))
" 2>&1
}
shipped='gapsOut=10 rounding=18 blur=true layout=us hdr=1'
# ── No file at all: the normal first run ─────────────────────────────────────
result="$(probe '__absent__')"
[[ "$result" == "$shipped" ]] || fail "an absent settings file did not yield shipped defaults: $result"
# ── Empty file ───────────────────────────────────────────────────────────────
result="$(probe '')"
[[ "$result" == "$shipped" ]] || fail "an empty settings file did not yield shipped defaults: $result"
# ── Whitespace only ──────────────────────────────────────────────────────────
result="$(probe '
')"
[[ "$result" == "$shipped" ]] || fail "a whitespace-only file did not yield shipped defaults: $result"
# ── Truncated mid-object, the shape a crashed write leaves behind ────────────
result="$(probe '{ "gapsOut": 24, "windowRounding":')"
[[ "$result" == "$shipped" ]] || fail "a truncated file did not yield shipped defaults: $result"
# ── Not JSON at all ──────────────────────────────────────────────────────────
result="$(probe 'gaps_out = 24')"
[[ "$result" == "$shipped" ]] || fail "a non-JSON file did not yield shipped defaults: $result"
# ── A JSON array rather than an object ───────────────────────────────────────
result="$(probe '[1, 2, 3]')"
[[ "$result" == "$shipped" ]] || fail "a top-level array did not yield shipped defaults: $result"
# ── Wrong types: each bad value falls back on its own ────────────────────────
result="$(probe '{"gapsOut": "wide", "windowRounding": 24, "blurEnabled": 3, "keyboardLayout": 7}')"
[[ "$result" == 'gapsOut=10 rounding=24 blur=true layout=us hdr=1' ]] \
|| fail "wrong-typed values did not fall back per key: $result"
# ── Good values are actually used ────────────────────────────────────────────
result="$(probe '{"gapsOut": 24, "windowRounding": 6, "blurEnabled": false, "keyboardLayout": "us,de", "autoHdr": false}')"
[[ "$result" == 'gapsOut=24 rounding=6 blur=false layout=us,de hdr=0' ]] \
|| fail "stored values were not applied: $result"
# ── A boolean maps onto an integer option, matching SystemSettings.hyprValue ─
result="$(probe '{"autoHdr": true}')"
[[ "$result" == 'gapsOut=10 rounding=18 blur=true layout=us hdr=1' ]] \
|| fail "getInt did not convert a boolean: $result"
# ── Escapes and nesting do not break the reader ──────────────────────────────
result="$(probe '{"note": "a \"quoted\" value\nwith escapes", "nested": {"a": [1, 2, {"b": null}]}, "gapsOut": 12}')"
[[ "$result" == 'gapsOut=12 rounding=18 blur=true layout=us hdr=1' ]] \
|| fail "a file with escapes and nesting was not parsed: $result"
# ── The real config parses in every one of those states ─────────────────────
# This is the case that actually protects the desktop: prefs.lua returning
# defaults is only useful if Hyprland still accepts the config around it.
#
# Hyprland resolves its own config from $HOME/.config/hypr regardless of
# XDG_CONFIG_HOME, while prefs.lua honours XDG_CONFIG_HOME. That asymmetry is
# what makes this loop useful rather than vacuous: the *real* looks.lua and
# input.lua are parsed against a *fixture* settings file. The probe cases above
# already establish that prefs.lua reads the fixture and not the live file.
for settings in '__absent__' '' '{ "gapsOut": 24, "windowRounding":' 'gaps_out = 24' '{"gapsOut": "wide"}' \
'{"gapsOut": 24, "windowRounding": 6, "blurEnabled": false, "borderSize": 0, "keyboardLayout": "us,de"}'; do
if [[ "$settings" == "__absent__" ]]; then
rm -f "$work/config/panama/settings.json"
else
mkdir -p "$work/config/panama"
printf '%s' "$settings" >"$work/config/panama/settings.json"
fi
output="$(XDG_CONFIG_HOME="$work/config" Hyprland --verify-config 2>&1 || true)"
grep -q 'config ok' <<<"$output" \
|| fail "Hyprland rejected the config with settings=<$settings>: $(tail -5 <<<"$output")"
done
trap - EXIT
cleanup
printf 'prefs fallback contract: PASS\n'