Files
Panama/tests/quickshell/battery-contract
T
Gabriel Brown 3c359f3f7e Notice the battery, and the machine it is or is not in
Panama had no idea whether it was running on a laptop. No upower, no
battery, no lid, no AC: hypridle.conf says "This is a desktop" in its
own header, and that was true of the code as well as the machine.

panama-hw answers hardware questions one at a time, exits 0 or 1, and
prints nothing, so scripts, services and contracts all ask the same
way. The definition the rest of the laptop work hangs on is one line:
clamshell is lid-closed AND an external monitor. A machine with no
mains supply at all reports as being on wall power, because a desktop
cannot run out of it.

The battery service follows Vitals: sysfs through FileView, an
availability flag, and no subprocess on the timer. Globbing is the one
thing QML cannot do -- a battery is BAT0 or BAT1 or CMB0, mains is AC
or ADP1 or ACAD -- so panama-battery resolves the names once and the
shell reads the files directly after. Nothing falls back to a
plausible zero: a desktop shows no indicator, no card, and no charge
limit control where the firmware has no ceiling.

Also repairs two contracts that were already failing and had not been
noticed, because only the full suite runs them. The dependency
scanner treated line-initial variable assignments, case labels,
comments and heredoc bodies as commands, and `count`, `host`, `cancel`
and `import` are all real binaries on Fedora, so `command -v` could
not filter them out. It now drops comments and heredoc bodies and
requires a command to be followed by whitespace. Verified it still
catches a genuinely undeclared dependency rather than passing quietly.
The launcher command contract had not been told about the fourteen
commands added earlier today.
2026-08-21 21:43:14 -04:00

160 lines
7.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# The battery, and the machines that do not have one.
#
# This is the first thing Panama has shipped that only exists on some hardware,
# and the failure that matters is not a wrong percentage -- it is a desktop
# growing battery chrome, or a laptop showing a confident 0% because a file
# could not be read. So the properties pinned here are mostly about absence:
#
# 1. No battery means `available` is false and every surface hides. Not 0%,
# not "Unknown" in the bar, not an empty card on the Power page.
# 2. A machine with no mains supply at all is on wall power. A desktop must
# never be treated as running on battery, or every battery-specific idle
# timing would apply to it.
# 3. The charge-limit control appears only where the firmware has one.
# 4. The threshold write goes through panama-sudo with a reason, never bare
# sudo, and is read back rather than assumed.
#
# The helper is driven against fixture sysfs trees; the QML side is pinned
# statically, since a battery cannot be simulated into the running shell.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-battery"
service="$repo_dir/config/dot/quickshell/services/Battery.qml"
cluster="$repo_dir/config/dot/quickshell/modules/bar/StatusCluster.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/PowerPage.qml"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
aliases="$repo_dir/config/dot/quickshell/config/Settings.qml"
findings=()
note() { findings+=("$1"); }
[[ -x "$helper" ]] || { printf 'battery contract: %s is not executable\n' "$helper" >&2; exit 1; }
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
# A fake machine. `battery <pct>` and `mains <0|1>` are both optional, and
# leaving one out means the machine genuinely does not have it.
fixture() {
local name="$1" battery="${2:-}" mains="${3:-}" threshold="${4:-}"
local root="$work/$name"
mkdir -p "$root/sys/class/power_supply"
if [[ -n "$battery" ]]; then
mkdir -p "$root/sys/class/power_supply/BAT0"
printf 'Battery\n' >"$root/sys/class/power_supply/BAT0/type"
printf '%s\n' "$battery" >"$root/sys/class/power_supply/BAT0/capacity"
printf 'Discharging\n' >"$root/sys/class/power_supply/BAT0/status"
[[ -n "$threshold" ]] && printf '%s\n' "$threshold" \
>"$root/sys/class/power_supply/BAT0/charge_control_end_threshold"
fi
if [[ -n "$mains" ]]; then
mkdir -p "$root/sys/class/power_supply/AC0"
printf 'Mains\n' >"$root/sys/class/power_supply/AC0/type"
printf '%s\n' "$mains" >"$root/sys/class/power_supply/AC0/online"
fi
printf '%s\n' "$root"
}
ask() {
local root="$1"; shift
PANAMA_HW_SYS="$root/sys" PANAMA_PATH="$repo_dir" "$helper" "$@" 2>/dev/null
}
field() { jq -r "$2" <<<"$1" 2>/dev/null; }
# ── 1. A desktop ─────────────────────────────────────────────────────────────
desktop="$(fixture desktop)"
status="$(ask "$desktop" status)"
[[ "$(field "$status" .available)" == "false" ]] \
|| note 'a machine with no battery reports one as available'
[[ "$(field "$status" .acOnline)" == "true" ]] \
|| note 'a machine with no mains supply is reported as running on battery'
paths="$(ask "$desktop" paths)"
[[ "$(field "$paths" .battery)" == "" ]] \
|| note 'a machine with no battery resolves a battery path anyway'
# ── 2. A laptop ──────────────────────────────────────────────────────────────
laptop="$(fixture laptop 64 1)"
status="$(ask "$laptop" status)"
[[ "$(field "$status" .available)" == "true" ]] || note 'a battery was not detected'
[[ "$(field "$status" .percent)" == "64" ]] \
|| note "the charge level is wrong (got $(field "$status" .percent))"
[[ "$(field "$status" .acOnline)" == "true" ]] || note 'a plugged-in laptop reads as unplugged'
unplugged="$(fixture unplugged 41 0)"
status="$(ask "$unplugged" status)"
[[ "$(field "$status" .acOnline)" == "false" ]] \
|| note 'a laptop with mains offline still reads as on wall power'
# ── 3. The charge limit appears only where it exists ─────────────────────────
paths="$(ask "$laptop" paths)"
[[ "$(field "$paths" .threshold)" == "" ]] \
|| note 'a machine without a charge threshold resolves one anyway, so the control would appear and do nothing'
limited="$(fixture limited 80 1 80)"
paths="$(ask "$limited" paths)"
[[ "$(field "$paths" .threshold)" != "" ]] \
|| note 'a machine with a charge threshold does not expose it'
[[ "$(field "$(ask "$limited" status)" .chargeLimit)" == "80" ]] \
|| note 'the charge limit is not reported'
# ── 4. The write is privileged, named, and verified ──────────────────────────
grep -q 'panama-sudo' "$helper" \
|| note 'the threshold write does not go through panama-sudo'
if grep -nE '(^|[^-[:alnum:]])sudo ' "$helper" | grep -q -v 'panama-sudo'; then
note 'the helper calls bare sudo somewhere, so the prompt would not name the change'
fi
grep -q -- '--reason' "$helper" \
|| note 'the privileged write does not state a reason, so the prompt would not say what it changes'
# Out-of-range values are refused before any password is asked for.
PANAMA_HW_SYS="$limited/sys" PANAMA_PATH="$repo_dir" "$helper" set-threshold 10 >/dev/null 2>&1 \
&& note 'a threshold below the supported range was accepted'
PANAMA_HW_SYS="$limited/sys" PANAMA_PATH="$repo_dir" "$helper" set-threshold abc >/dev/null 2>&1 \
&& note 'a non-numeric threshold was accepted'
# ── 5. The QML side hides itself ─────────────────────────────────────────────
grep -q 'property bool available' "$service" \
|| note 'the battery service has no availability flag'
grep -q 'Settings.showBattery && Battery.available' "$cluster" \
|| note 'the bar indicator does not gate on both the preference and the hardware'
grep -q 'visible: Battery.available' "$page" \
|| note 'the Power page battery card does not hide on a machine without one'
grep -q 'visible: Battery.chargeLimitSupported' "$page" \
|| note 'the charge limit control does not hide where the firmware has none'
# The alias layer has to carry the key, or the binding silently reads undefined
# and the indicator never appears. This exact mistake was made writing it.
for key in showBattery batteryLowPercent batteryCriticalPercent; do
grep -q "property .*$key" "$aliases" \
|| note "Settings.qml does not alias $key, so the binding reads undefined"
done
for key in showBattery batteryLowPercent batteryCriticalPercent batteryChargeLimit; do
grep -q "key: \"$key\"" "$schema" || note "the schema has no $key entry"
done
# No subprocess on the polling path: the whole point of resolving paths once.
if grep -A4 'Timer {' "$service" | grep -q 'running: true' && grep -q 'Process' "$service"; then
grep -q 'onTriggered: root.refresh()' "$service" \
|| note 'the poll timer does something other than re-read files'
fi
if (( ${#findings[@]} > 0 )); then
printf 'battery contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'battery contract: PASS\n'