diff --git a/.github/workflows/shell-tests.yml b/.github/workflows/shell-tests.yml index 49138a2e..0a205900 100644 --- a/.github/workflows/shell-tests.yml +++ b/.github/workflows/shell-tests.yml @@ -26,6 +26,7 @@ jobs: run: | set -euo pipefail tests=( + test_i3blocks_gaming_budget.sh test_i3blocks_persist_common.sh test_setup_dns_blocker.sh ) diff --git a/docs/superpowers/evidence/i3blocks-gaming-budget-2026-09-26.json b/docs/superpowers/evidence/i3blocks-gaming-budget-2026-09-26.json new file mode 100644 index 00000000..7c1fa072 --- /dev/null +++ b/docs/superpowers/evidence/i3blocks-gaming-budget-2026-09-26.json @@ -0,0 +1,44 @@ +{ + "intent": "Show today's gaming budget in the i3 bar: time left, used/total, and which bonuses (workout, LeetCode, reading) can still be earned.", + "scope": [ + "linux_configuration/i3blocks/gaming_budget.sh (new block), linux_configuration/i3blocks/config", + "linux_configuration/tests/test_i3blocks_gaming_budget.sh, .github/workflows/shell-tests.yml", + "Non-goal: no changes to steam-backlog-enforcer, screen-locker, leetcode-guard or book-guard; the block only reads /api/budget" + ], + "changes": [ + "New interval=30 block reads steam-backlog-enforcer GET /api/budget with one curl + one jq pass; colours follow the enforcer warn_at (1h yellow, 30m orange, 10m/blocked red)", + "Unearned-bonus sizes come from ~/.config/steam_backlog_enforcer/config.json via jq --slurpfile (never on argv, the file holds secrets), falling back to the enforcer defaults 2h/1h/1h", + "Never blank: dead server shows cached text marked (stale) in orange or 'budget server down' in red; bad state_status, ok:false and unparseable bodies are red and do not overwrite the cache", + "Behaviour test with a PATH-stubbed curl, wired into the shell-tests CI list" + ], + "verification": [ + { + "command": "~/.config/i3blocks/gaming_budget.sh", + "result": "pass", + "evidence": "Live API: '๐ŸŽฎ 2h00 left ยท 6h00/8h00 +๐Ÿ“–1h' / '๐ŸŽฎ 2h00' / #50FA7B, matching /api/budget (21600s used of 28800, reading bonus 0)" + }, + { + "command": "i3-msg restart (run by the user)", + "result": "pass", + "evidence": "User confirmed the block renders in the bar as intended" + }, + { + "command": "bash linux_configuration/tests/test_i3blocks_gaming_budget.sh", + "result": "pass", + "evidence": "All gaming_budget checks passed (thresholds, blocked, overspent, config bonus sizes, stale, down, error states)" + }, + { + "command": "bash linux_configuration/tests/test_i3blocks_efficiency.sh", + "result": "pass", + "evidence": "All i3blocks efficiency regression tests passed" + } + ], + "risks": [ + "Depends on the /api/budget JSON shape (today.*, rules.bonuses.*); a renamed field renders 'unreadable budget' in red rather than a wrong number", + "Bonus fallback sizes duplicate the enforcer defaults; they only matter when config.json leaves the fields unset" + ], + "rollback": [ + "git revert the commit and remove ~/.config/i3blocks/gaming_budget.sh symlink", + "i3-msg restart and confirm the bar renders without the block" + ] +} diff --git a/docs/superpowers/evidence/nvidia-config-seams-restore-2026-08-23.json b/docs/superpowers/evidence/nvidia-config-seams-restore-2026-08-23.json deleted file mode 100644 index 87550d6b..00000000 --- a/docs/superpowers/evidence/nvidia-config-seams-restore-2026-08-23.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "intent": "Restore the four test seams in nvidia_config.sh, which were verified and measured but did not reach the previous commit, so the committed suite read and wrote the real system files.", - "scope": [ - "linux_configuration/scripts/single_use/fixes/lib/nvidia_config.sh", - "Non-goal: any change to the tests, which were already correct" - ], - "changes": [ - "Re-applied the XORG_CONF, XORG_CONF_D, PROFILE_FILE and USER_HOME defaults at their assignment sites", - "Grepped the file for all four immediately after writing it, before running anything, because a silently-reverted edit is exactly what caused this" - ], - "verification": [ - { - "command": "ci-mirror during git push", - "result": "fail", - "evidence": "'lib/tests suite failed: .../fixes/lib/tests/run_all.sh'. ci-mirror runs from a clean checkout of committed files, which is why it caught what the local run could not." - }, - { - "command": "bash run_all.sh from a detached worktree at the previous commit", - "result": "fail", - "evidence": "EXIT 1 with 10 failures, including 'configure_xorg: enables RenderAccel (expected \"RenderAccel\" \"true\", got: # NVIDIA configuration with RenderAccel disabled)' -- the suite was reading this machine's real /etc/X11 config, and 'does not re-clone over a checkout' showed a clone path of /home/testuser/pyroveil rather than the tmpdir." - }, - { - "command": "grep -n 'XORG_CONF:-|XORG_CONF_D:-|PROFILE_FILE:-|USER_HOME:-' nvidia_config.sh", - "result": "pass", - "evidence": "All four present at lines 26, 27, 60 and 85 after re-applying." - }, - { - "command": "shellcheck + shfmt -l on nvidia_config.sh", - "result": "pass", - "evidence": "No findings, zero suppressions, no reformat pending." - } - ], - "risks": [ - "The previous commit, cb1e8c53, is pushed and briefly had a suite that would write to the real /etc/X11/xorg.conf.d on any machine that ran it un-jailed. Nothing ran it in that window on this machine -- the real 20-nvidia.conf still carries its Jun 2025 mtime.", - "The local run passed against a stale seamed copy in the measurement worktree, which is why a clean-checkout run is now the check that matters before pushing." - ], - "rollback": [ - "git revert the commit; nvidia_config.sh returns to hardcoded paths and the suite will fail again from a clean checkout", - "Re-run run_all.sh from a detached worktree to confirm the failure mode returns" - ] -} diff --git a/linux_configuration/i3blocks/config b/linux_configuration/i3blocks/config index b304855e..5c8c5fb5 100644 --- a/linux_configuration/i3blocks/config +++ b/linux_configuration/i3blocks/config @@ -51,6 +51,10 @@ interval=30 color=#FFFFFF +[gaming_budget] +command=~/.config/i3blocks/gaming_budget.sh +interval=30 + [screen_locker] command=~/.config/i3blocks/screen_locker.sh interval=30 diff --git a/linux_configuration/i3blocks/gaming_budget.sh b/linux_configuration/i3blocks/gaming_budget.sh new file mode 100755 index 00000000..bf1fa203 --- /dev/null +++ b/linux_configuration/i3blocks/gaming_budget.sh @@ -0,0 +1,102 @@ +#!/bin/bash + +# ============================================================================ +# Gaming time budget for i3blocks: time left today, used/total, and which +# bonuses (workout, LeetCode, reading) are still there to be earned. +# +# Reads steam-backlog-enforcer's /api/budget, which already folds in the +# screen-locker, leetcode-guard and book-guard bonuses. Like screen_locker.sh +# it is never blank: a dead server shows the last value marked stale, or red. +# Click opens the enforcer's web UI. +# ============================================================================ + +set -euo pipefail + +readonly API="${GAMING_BUDGET_API:-http://127.0.0.1:8000/api/budget}" +readonly UI='http://127.0.0.1:8000/' +# Only for the *size* of bonuses not yet earned; /api/budget reports earned +# ones as 0. The file holds secrets, so it goes to jq by path, never on argv. +readonly CONFIG="${GAMING_BUDGET_CONFIG:-$HOME/.config/steam_backlog_enforcer/config.json}" +readonly CACHE="${GAMING_BUDGET_CACHE:-${XDG_RUNTIME_DIR:-/tmp}/i3blocks-gaming-budget.cache}" + +# Dracula-ish palette, matching screen_locker.sh. +readonly RED='#FF5555' +readonly YELLOW='#F1FA8C' +readonly GREEN='#50FA7B' +readonly ORANGE='#FFB86C' + +emit() { + # full_text, short_text, color โ€” the three lines i3blocks reads. + printf '%s\n%s\n%s\n' "$1" "$2" "$3" +} + +if [[ ${BLOCK_BUTTON:-0} -ne 0 ]]; then + xdg-open "$UI" >/dev/null 2>&1 & +fi + +if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then + emit '๐ŸŽฎ no curl/jq' '๐ŸŽฎ ?' "$ORANGE" + exit 0 +fi + +budget_json=$(curl -fsS --max-time 2 "$API" 2>/dev/null || true) +if [[ -z $budget_json ]]; then + if [[ -r $CACHE ]]; then + emit "$(<"$CACHE") (stale)" '๐ŸŽฎ ?' "$ORANGE" + else + emit '๐ŸŽฎ budget server down' '๐ŸŽฎ ?' "$RED" + fi + exit 0 +fi + +config_path=/dev/null +[[ -r $CONFIG ]] && config_path=$CONFIG + +# One jq pass renders all three lines, so a tick costs curl + jq and no more. +# The first line is "cache" or "nocache": error states must not overwrite the +# last good value that the stale path shows. +# Thresholds mirror the enforcer's own warn_at (1h, 30m, 10m). +rendered=$(jq -r \ + --slurpfile cfg "$config_path" \ + --arg red "$RED" --arg orange "$ORANGE" --arg yellow "$YELLOW" --arg green "$GREEN" ' + def hm: (. / 60 | round) as $m + | "\($m / 60 | floor)h\($m % 60 | tostring | if length < 2 then "0" + . else . end)"; + def bonus_hm: hm | sub("h00$"; "h"); + ($cfg[0] // {}) as $c + | if .state_status != "ok" then ["nocache", "๐ŸŽฎ state \(.state_status)", "๐ŸŽฎ !", $red] + elif .ok != true then ["nocache", "๐ŸŽฎ budget error", "๐ŸŽฎ !", $red] + else + .today as $t + | .rules.bonuses as $b + | ([$t.seconds_remaining, 0] | max) as $left + | "\($t.seconds_used | hm)/\($t.budget_seconds | hm)" as $ratio + # Unearned bonuses: [earned, configured size (enforcer default), icon]. + | [ [$b.workout, ($c.workout_bonus_seconds // 7200), "๐Ÿ’ช"], + [$b.leetcode, ($c.leetcode_bonus_seconds // 3600), "๐Ÿงฉ"], + [$b.reading, ($c.reading_bonus_seconds // 3600), "๐Ÿ“–"] ] + | map(select((.[0] // 0) == 0 and .[1] > 0) | "+\(.[2])\(.[1] | bonus_hm)") + | (if length > 0 then " " + join(" ") else "" end) as $todo + | if $t.blocked or $left <= 0 then + ["cache", "๐ŸŽฎ BLOCKED ยท \($ratio)\($todo)", "๐ŸŽฎ BLOCKED", $red] + else + ["cache", "๐ŸŽฎ \($left | hm) left ยท \($ratio)\($todo)", "๐ŸŽฎ \($left | hm)", + (if $left <= 600 then $red + elif $left <= 1800 then $orange + elif $left <= 3600 then $yellow + else $green end)] + end + end + | .[]' <<<"$budget_json" 2>/dev/null) || { + emit '๐ŸŽฎ unreadable budget' '๐ŸŽฎ !' "$RED" + exit 0 +} + +{ + read -r cache_flag + read -r full_text + read -r short_text + read -r color +} <<<"$rendered" + +[[ $cache_flag == 'cache' ]] && printf '%s' "$full_text" >"$CACHE" +emit "$full_text" "$short_text" "$color" diff --git a/linux_configuration/tests/test_i3blocks_gaming_budget.sh b/linux_configuration/tests/test_i3blocks_gaming_budget.sh new file mode 100755 index 00000000..ae3b9512 --- /dev/null +++ b/linux_configuration/tests/test_i3blocks_gaming_budget.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# Behaviour tests for the i3blocks gaming_budget block: text, colour +# thresholds, unearned-bonus suffix and every failure state. curl is stubbed on +# PATH to serve a fixture file, so nothing talks to the real enforcer. + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) +BLOCK="$REPO_DIR/i3blocks/gaming_budget.sh" +TMP_DIR=$(mktemp -d) + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_eq() { + local expected=$1 + local actual=$2 + local context=$3 + if [[ "$expected" != "$actual" ]]; then + fail "$context (expected '$expected', actual '$actual')" + fi +} + +printf '{}' | jq -e . >/dev/null 2>&1 || + fail 'a working jq is required for the gaming_budget tests (pacman -S jq)' + +# Stub curl: print $FIXTURE, or fail like `curl -f` on a dead server. +BIN_DIR="$TMP_DIR/bin" +mkdir -p "$BIN_DIR" +cat >"$BIN_DIR/curl" <<'STUB' +#!/bin/bash +[[ -r ${FIXTURE:-} ]] || exit 7 +cat "$FIXTURE" +STUB +chmod +x "$BIN_DIR/curl" + +FIXTURE="$TMP_DIR/budget.json" +CACHE_FILE="$TMP_DIR/cache" +CONFIG_FILE="$TMP_DIR/config.json" + +# write_budget USED BUDGET REMAINING BLOCKED WORKOUT LEETCODE READING +write_budget() { + printf '{"ok":true,"state_status":"ok","today":{"seconds_used":%s,"budget_seconds":%s,"seconds_remaining":%s,"blocked":%s},"rules":{"bonuses":{"base":18000,"workout":%s,"leetcode":%s,"reading":%s}}}\n' \ + "$@" >"$FIXTURE" +} + +run_block() { + PATH="$BIN_DIR:$PATH" FIXTURE="$FIXTURE" GAMING_BUDGET_CACHE="$CACHE_FILE" \ + GAMING_BUDGET_CONFIG="$CONFIG_FILE" bash "$BLOCK" +} + +line() { + run_block | sed -n "${1}p" +} + +printf 'Checking the normal day: time left, used/total, missing bonus...\n' +write_budget 21600.3 28800 7199.7 false 7200 3600 0 +assert_eq '๐ŸŽฎ 2h00 left ยท 6h00/8h00 +๐Ÿ“–1h' "$(line 1)" 'full text' +assert_eq '๐ŸŽฎ 2h00' "$(line 2)" 'short text' +assert_eq '#50FA7B' "$(line 3)" 'green above 1h' + +printf 'Checking colour thresholds follow the enforcer warn_at...\n' +write_budget 25800 28800 3000 false 7200 3600 3600 +assert_eq '#F1FA8C' "$(line 3)" 'yellow at <=1h' +assert_eq '๐ŸŽฎ 0h50 left ยท 7h10/8h00' "$(line 1)" 'no suffix when every bonus is earned' +write_budget 27600 28800 1200 false 7200 3600 3600 +assert_eq '#FFB86C' "$(line 3)" 'orange at <=30m' +write_budget 28500 28800 300 false 7200 3600 3600 +assert_eq '#FF5555' "$(line 3)" 'red at <=10m' + +printf 'Checking the blocked state...\n' +write_budget 18000 18000 0 true 0 0 0 +assert_eq '๐ŸŽฎ BLOCKED ยท 5h00/5h00 +๐Ÿ’ช2h +๐Ÿงฉ1h +๐Ÿ“–1h' "$(line 1)" 'blocked full text lists what can still be earned' +assert_eq '๐ŸŽฎ BLOCKED' "$(line 2)" 'blocked short text' +assert_eq '#FF5555' "$(line 3)" 'blocked is red' +write_budget 18100 18000 -100 false 7200 3600 3600 +assert_eq '๐ŸŽฎ BLOCKED' "$(line 2)" 'overspent counts as blocked even before the flag flips' + +printf 'Checking bonus sizes come from the enforcer config...\n' +printf '{"workout_bonus_seconds":5400,"leetcode_bonus_seconds":0}\n' >"$CONFIG_FILE" +write_budget 0 18000 18000 false 0 0 0 +assert_eq '๐ŸŽฎ 5h00 left ยท 0h00/5h00 +๐Ÿ’ช1h30 +๐Ÿ“–1h' "$(line 1)" 'configured sizes, zero-size bonus omitted' +rm -f "$CONFIG_FILE" + +printf 'Checking a dead server shows the cached value as stale...\n' +write_budget 21600 28800 7200 false 7200 3600 0 +run_block >/dev/null +rm -f "$FIXTURE" +assert_eq '๐ŸŽฎ 2h00 left ยท 6h00/8h00 +๐Ÿ“–1h (stale)' "$(line 1)" 'stale text' +assert_eq '#FFB86C' "$(line 3)" 'stale is orange' +rm -f "$CACHE_FILE" +assert_eq '๐ŸŽฎ budget server down' "$(line 1)" 'no cache: server down' +assert_eq '#FF5555' "$(line 3)" 'server down is red' + +printf 'Checking error states are red and never overwrite the cache...\n' +printf 'kept' >"$CACHE_FILE" +printf '{"ok":false,"state_status":"corrupt"}\n' >"$FIXTURE" +assert_eq '๐ŸŽฎ state corrupt' "$(line 1)" 'bad state_status' +assert_eq '#FF5555' "$(line 3)" 'bad state is red' +printf '{"ok":false,"state_status":"ok"}\n' >"$FIXTURE" +assert_eq '๐ŸŽฎ budget error' "$(line 1)" 'ok:false' +printf 'not json' >"$FIXTURE" +assert_eq '๐ŸŽฎ unreadable budget' "$(line 1)" 'garbage body' +assert_eq 'kept' "$(<"$CACHE_FILE")" 'error states must leave the cache alone' + +printf 'All gaming_budget checks passed.\n'