Files
Panama/tests/hypr/idle-config-contract
Gabriel Brown 3d21e20041 Make every control tell the truth
The audit's first tier, in one change: every case found where the interface
asserted something the system did not do.

Twenty-one compositor-owned preferences -- the whole Mouse & Touchpad page,
plus window layout, snapping, dim-inactive and the magnifier -- had the live
half (hyprctl eval) and not the config-time half, so they quietly reverted on
every hyprctl reload. All 70 hypr-backed keys now have a prefs.get() in the
Lua, and hypr-prefs-contract pins both the presence and that the Lua fallback
equals the schema default, which is how the touchpad page misreported natural
scrolling on first boot.

The idle generator fell back from unwritten battery keys to the AC values
while the Power page displayed the schema defaults: a fresh laptop showed
"suspend at 20 minutes" and generated no suspend listener, then discharged to
zero in a bag. Unwritten keys now use the defaults the page shows
(idle-defaults-contract pins generator to schema; idle-config-contract
re-pinned to the new rule with the tradeoff recorded), and change-settings
enables managed idle on any machine with a battery -- without starting
hypridle in whatever session the installer runs under.

The per-app lock-screen notification switches wrote fields nothing read:
hyprlock cannot render notifications. Removed, with the rule model shrunk to
{enabled}, stale stored fields dropped at normalization, and the contract now
forbidding the page from growing lock-screen switches it cannot honor.

The battery warning thresholds were searchable, documented as "Found on
Power & Lock", and rendered nowhere -- and crossing the low threshold changed
only a glyph's color. Both sliders now exist where search was already sending
people, and low battery publishes a real notification at important priority.

Three handoffs opened GNOME panels that are inert in a Hyprland session. The
keyboard handoff is gone (that panel writes gsettings nothing here reads, and
the working controls sat on the same page); Connectivity gains a Wi-Fi row
that opens GNOME's actual Wi-Fi panel -- hidden SSIDs and 802.1X finally have
a road -- beside the network row that legitimately drives NetworkManager; the
universal-access handoff is gone, its few working toggles being controls this
app already owns. And the accessibility page now gives the true reason sticky
keys are missing: each Wayland compositor implements its own and Hyprland
does not yet -- not "an X11 feature with no Wayland equivalent," which sent
people to the wrong conclusion about the platform.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 11:43:39 -04:00

171 lines
8.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# scripts/panama-idle generates hypridle's configuration from Panama's settings.
#
# This is the one generator that can cost the user their automatic screen lock,
# so the properties that matter are: zero means never (rather than "immediately",
# which a naive template would produce), values are clamped even when the
# settings file has been hand-edited, and a missing or corrupt file still yields
# a working configuration rather than an empty one.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-idle"
work="$(mktemp -d /tmp/panama-idle-contract.XXXXXX)"
fail() {
printf 'idle config contract: %s\n' "$1" >&2
exit 1
}
cleanup() { rm -rf "$work"; }
trap cleanup EXIT
generated="$work/state/panama/hypridle.conf"
run_with() {
# Isolated config AND state, so this never touches the real generated file
# and never restarts the user's hypridle: `apply` only restarts when the
# systemd drop-in exists, and it cannot exist under this temporary root.
mkdir -p "$work/config/panama"
printf '%s' "$1" >"$work/config/panama/settings.json"
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" apply
}
# ── Shipped defaults ─────────────────────────────────────────────────────────
run_with '{}'
grep -q 'timeout = 300' "$generated" || fail 'default screen blank is not 5 minutes'
grep -q 'timeout = 600' "$generated" || fail 'default lock is not 10 minutes'
grep -q 'before_sleep_cmd' "$generated" || fail 'lock before sleep missing by default'
grep -q 'systemctl suspend' "$generated" && fail 'automatic suspend is on by default'
# ── Zero means never, not immediately ────────────────────────────────────────
run_with '{"screenBlankMinutes":0,"lockMinutes":0,"suspendMinutes":0}'
grep -q 'timeout = 0' "$generated" && fail 'a zero timeout was written as an immediate trigger'
# `dpms` also appears in general.after_sleep_cmd, which is correct and must
# stay; what must not exist is any listener at all.
grep -qE '^listener' "$generated" && fail 'a listener was written when everything is set to never'
grep -q 'after_sleep_cmd' "$generated" || fail 'the general block was lost when all timers are off'
# ── Values are honored ──────────────────────────────────────────────────────
run_with '{"screenBlankMinutes":2,"lockMinutes":7,"suspendMinutes":45}'
grep -q 'timeout = 120' "$generated" || fail '2 minute blank not honored'
grep -q 'timeout = 420' "$generated" || fail '7 minute lock not honored'
grep -q 'timeout = 2700' "$generated" || fail '45 minute suspend not honored'
grep -q 'systemctl suspend' "$generated" || fail 'suspend listener missing when set'
# ── lockOnSleep off removes the pre-sleep lock ───────────────────────────────
run_with '{"lockOnSleep":false}'
grep -q 'before_sleep_cmd' "$generated" && fail 'pre-sleep lock present when disabled'
# ── Hand-edited nonsense is clamped, not passed through ──────────────────────
run_with '{"screenBlankMinutes":99999,"lockMinutes":-40,"suspendMinutes":"soon"}'
grep -q 'timeout = 7200' "$generated" || fail 'an above-range blank was not clamped to the maximum'
grep -q 'timeout = 0' "$generated" && fail 'a negative lock produced an immediate trigger'
grep -q 'systemctl suspend' "$generated" && fail 'a non-numeric suspend produced a listener'
# ── A corrupt or absent settings file still yields a working config ──────────
run_with '{ not json at all'
grep -q 'timeout = 300' "$generated" || fail 'a corrupt settings file did not fall back to defaults'
rm -f "$work/config/panama/settings.json"
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" apply
grep -q 'timeout = 300' "$generated" || fail 'an absent settings file did not fall back to defaults'
# ── status reports what it generated ─────────────────────────────────────────
status="$(XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" status)"
jq -e '.managed == false and .blankMinutes == 5 and .lockMinutes == 10' <<<"$status" >/dev/null \
|| fail "status did not report the generated values: $status"
# ── Power source ─────────────────────────────────────────────────────────────
#
# hypridle has no concept of AC versus battery, so there is one config and it
# is rebuilt from the other set of keys when the charger comes or goes. Three
# things must hold, and the third is the one that protects every existing
# desktop:
#
# 1. On battery, the battery keys win.
# 2. A battery key that was never written falls back to its AC counterpart,
# not to the schema's shorter shipped default -- otherwise unplugging
# would silently override a deliberately long setting.
# 3. A machine with no battery reads none of them, and generates exactly
# what it generated before any of this existed.
#
# panama-hw is stubbed rather than the sysfs tree, because this is testing
# which keys panama-idle chooses, not how the hardware is detected. That is
# the hardware predicates contract's job.
stub_hw() {
mkdir -p "$work/fake/bin"
cat >"$work/fake/bin/panama-hw" <<STUB
#!/usr/bin/env bash
case "\$1" in
battery) exit $1 ;;
ac) exit $2 ;;
*) exit 1 ;;
esac
STUB
chmod +x "$work/fake/bin/panama-hw"
}
run_powered() {
mkdir -p "$work/config/panama"
printf '%s' "$1" >"$work/config/panama/settings.json"
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \
PANAMA_PATH="$work/fake" "$helper" apply
}
settings='{"screenBlankMinutes":30,"lockMinutes":45,"suspendMinutes":0,
"screenBlankMinutesBattery":2,"lockMinutesBattery":5,"suspendMinutesBattery":20}'
# On battery: battery keys.
stub_hw 0 1
run_powered "$settings"
grep -q 'timeout = 120' "$generated" || fail 'on battery, the battery blank timing was not used'
grep -q 'timeout = 300' "$generated" || fail 'on battery, the battery lock timing was not used'
grep -q 'systemctl suspend' "$generated" || fail 'on battery, the battery suspend listener is missing'
grep -q 'Power source at generation: battery' "$generated" \
|| fail 'the generated config does not record that it was built for battery'
# Plugged in: AC keys, and no suspend, even though the battery set has one.
stub_hw 0 0
run_powered "$settings"
grep -q 'timeout = 1800' "$generated" || fail 'on wall power, the AC blank timing was not used'
grep -q 'timeout = 2700' "$generated" || fail 'on wall power, the AC lock timing was not used'
grep -q 'systemctl suspend' "$generated" && fail 'on wall power, a battery-only suspend listener was written'
# A battery key that was never written falls back to ITS OWN schema default,
# not to the AC value. This section once pinned the opposite -- protecting a
# deliberate AC setting from being "overridden" by unplugging -- but the
# Power page's battery card shows the schema defaults for unwritten battery
# keys, so the AC fallback made the generator disagree with what the screen
# said: a fresh laptop displayed "suspend at 20 minutes on battery" and
# generated no suspend listener at all. Whatever the sliders show is what
# must be generated; a person who wants battery to match AC sets it so, on
# the card that has said the real values all along.
stub_hw 0 1
run_powered '{"screenBlankMinutes":30,"lockMinutes":45}'
grep -q 'timeout = 120' "$generated" \
|| fail 'an unset battery blank did not use the schema default the Power page displays'
grep -q 'timeout = 300' "$generated" \
|| fail 'an unset battery lock did not use the schema default the Power page displays'
grep -q 'systemctl suspend' "$generated" \
|| fail 'an unset battery suspend generated no suspend listener -- the discharged-in-a-bag bug, back again'
# No battery at all: the battery keys are never consulted, even when present.
stub_hw 1 0
run_powered "$settings"
grep -q 'timeout = 1800' "$generated" || fail 'a machine with no battery did not use the AC blank timing'
grep -q 'timeout = 120' "$generated" && fail 'a machine with no battery read the battery keys'
grep -q 'Power source at generation: wall power' "$generated" \
|| fail 'a machine with no battery did not record wall power'
status="$(XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \
PANAMA_PATH="$work/fake" "$helper" status)"
jq -e '.powerSource == "ac"' <<<"$status" >/dev/null \
|| fail "status did not report the power source: $status"
trap - EXIT
cleanup
printf 'idle config contract: PASS\n'