#!/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 ` 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'