Files
Panama/tests/quickshell/preference-schema-contract.sh
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

150 lines
6.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# The preference store is derived from config/PreferenceSchema.qml rather than
# restating each key. This contract pins the properties that derivation is
# supposed to buy, so that a future change cannot quietly reintroduce the
# hand-maintained variant:
#
# * every schema key round-trips through disk
# * out-of-range numbers are clamped, not stored raw and not rejected
# * unknown keys in the file are preserved across a write
# * set() refuses a key that is not in the schema
# * reset() restores *every* schema default with no hand-written list
# * a corrupt file yields shipped defaults rather than a broken shell
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/preference-schema-harness.qml"
state_home="$(mktemp -d /tmp/panama-schema-state.XXXXXX)"
config_home="$(mktemp -d /tmp/panama-schema-config.XXXXXX)"
store="$config_home/panama/settings.json"
fail() {
printf 'preference schema contract: %s\n' "$1" >&2
exit 1
}
qs_for_harness() {
XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"
}
cleanup() {
qs_for_harness kill >/dev/null 2>&1 || true
}
trap cleanup EXIT
start_harness() {
XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
if qs_for_harness ipc show 2>/dev/null | rg -q '^target preference-schema-test$'; then
return
fi
sleep 0.1
done
fail 'test IPC target did not start'
}
stop_harness() {
qs_for_harness kill >/dev/null 2>&1 || true
for _ in $(seq 1 40); do
# A bare `return` would propagate the failed `ipc show` status, which
# under `set -e` ends the whole contract instead of the function.
qs_for_harness ipc show >/dev/null 2>&1 || return 0
sleep 0.1
done
fail 'test shell did not stop cleanly'
}
wait_for_store() {
for _ in $(seq 1 40); do
[[ -f "$store" ]] && jq -e . "$store" >/dev/null 2>&1 && return
sleep 0.1
done
fail 'preferences file was not written'
}
# ── A fresh store reports schema defaults ────────────────────────────────────
start_harness
key_count="$(qs_for_harness ipc call preference-schema-test keyCount)"
[[ "$key_count" -gt 0 ]] || fail 'schema is empty'
initial="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
defaults="$(qs_for_harness ipc call preference-schema-test defaults | jq -cS .)"
[[ "$initial" == "$defaults" ]] || fail "a fresh store did not report schema defaults: $initial"
# ── Out-of-range numbers are clamped, not stored raw ─────────────────────────
qs_for_harness ipc call preference-schema-test applyJson \
'{"dockHideDelayMs": 99999, "nightLightTemperature": 100, "focusDurationMinutes": 45}' >/dev/null
clamped="$(qs_for_harness ipc call preference-schema-test dump)"
[[ "$(jq -r .dockHideDelayMs <<<"$clamped")" == "2000" ]] \
|| fail "an above-range value was not clamped to the schema maximum: $(jq -r .dockHideDelayMs <<<"$clamped")"
[[ "$(jq -r .nightLightTemperature <<<"$clamped")" == "2000" ]] \
|| fail "a below-range value was not clamped to the schema minimum: $(jq -r .nightLightTemperature <<<"$clamped")"
# ── An unknown key is refused rather than silently accepted ──────────────────
verdict="$(qs_for_harness ipc call preference-schema-test applyJson '{"__not_a_setting__": 1}')"
[[ "$(jq -r .__not_a_setting__ <<<"$verdict")" == "false" ]] || fail 'set() accepted a key outside the schema'
qs_for_harness ipc call preference-schema-test dump | jq -e 'has("__not_a_setting__") | not' >/dev/null \
|| fail 'a key outside the schema entered the store'
# ── An out-of-range enum value is refused ────────────────────────────────────
verdict="$(qs_for_harness ipc call preference-schema-test applyJson '{"vrrPolicy": 7}')"
[[ "$(jq -r .vrrPolicy <<<"$verdict")" == "false" ]] || fail 'set() accepted an enum value outside its options'
# ── Every schema key round-trips across a restart ────────────────────────────
qs_for_harness ipc call preference-schema-test applyJson \
'{"use24Hour": true, "showSeconds": false, "showWeekday": false, "showCpu": false,
"showMemory": false, "showGpu": false, "dockAutohide": false, "dockRevealDelayMs": 75,
"dockHideDelayMs": 400, "focusDurationMinutes": 25, "autoHdr": false, "vrrPolicy": 0,
"directScanoutPolicy": 0, "nightLightEnabled": true, "nightLightAutomatic": true,
"nightLightTemperature": 4100, "lastPage": "desktop"}' >/dev/null
wait_for_store
before="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
[[ "$before" != "$defaults" ]] || fail 'the fixture did not change anything'
stop_harness
start_harness
after=""
for _ in $(seq 1 40); do
after="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
[[ "$after" == "$before" ]] && break
sleep 0.1
done
[[ "$after" == "$before" ]] || fail "values did not survive a restart: $after"
# ── A key this build does not know is carried through, not dropped ───────────
stop_harness
jq '. + {"__future_setting__": "keep me"}' "$store" >"$store.tmp" && mv "$store.tmp" "$store"
start_harness
qs_for_harness ipc call preference-schema-test applyJson '{"dockHideDelayMs": 500}' >/dev/null
for _ in $(seq 1 40); do
jq -e '.__future_setting__ == "keep me" and .dockHideDelayMs == 500' "$store" >/dev/null 2>&1 && break
sleep 0.1
done
jq -e '.__future_setting__ == "keep me"' "$store" >/dev/null \
|| fail 'a setting from a newer build was dropped on write'
# ── reset() restores every schema key, with no hand-maintained list ──────────
qs_for_harness ipc call preference-schema-test reset >/dev/null
restored=""
for _ in $(seq 1 40); do
restored="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
[[ "$restored" == "$defaults" ]] && break
sleep 0.1
done
[[ "$restored" == "$defaults" ]] || fail "reset did not restore every schema default: $restored"
jq -e '.__future_setting__ == "keep me"' "$store" >/dev/null \
|| fail 'reset discarded a setting it does not own'
# ── A corrupt file degrades to defaults instead of breaking the shell ────────
stop_harness
printf '{ this is not json' >"$store"
start_harness
corrupt="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
[[ "$corrupt" == "$defaults" ]] || fail "a corrupt store did not fall back to defaults: $corrupt"
trap - EXIT
cleanup
printf 'preference schema contract: PASS\n'