i3blocks: shutdown and gaming blocks render from the daily-limits cache
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
Python tests / test (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

Both blocks read $XDG_RUNTIME_DIR/daily-limits.json (kuhyx/daily-limits,
refreshed every 60 s by a user timer) instead of curling the enforcer and
duplicating the earner table. Stale or missing cache shows ⏱? rather than
old numbers; unknown earners are shown with '?'; left-click opens
daily-limits --gui. Steady-state tick: bash only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrjUTo1yhgAsUUasdw4bpG
This commit is contained in:
Krzysztof Rudnicki 2026-10-09 10:46:19 +02:00
parent 46dfd1a445
commit 433cec278e
8 changed files with 356 additions and 267 deletions

View File

@ -0,0 +1,15 @@
{
"title": "i3blocks: shutdown and gaming blocks render from the daily-limits cache",
"objective": "Both status-bar blocks show today's shutdown time, gaming budget and the unearned earners from $XDG_RUNTIME_DIR/daily-limits.json, with no earner logic duplicated in shell and no fork in the steady-state tick beyond bash itself.",
"acceptance_criteria": [
"Live output keeps the existing format (⏻ HH:MM +💪2h ... →max 23:00, 🎮 Xh left · used/budget +...)",
"A cache older than 180 s or missing shows ⏱? and never stale numbers",
"Unknown earners are shown with a ? marker, not hidden",
"test_i3blocks_gaming_budget.sh, test_i3blocks_shutdown_hint.sh and test_i3blocks_efficiency.sh pass, and pre-commit is clean"
],
"out_of_scope": [
"The daily-limits app itself (kuhyx/daily-limits)",
"Shutdown enforcement in screen-locker / digital-wellbeing"
],
"verifier": "bash linux_configuration/i3blocks/{gaming_budget,shutdown_countdown}.sh; bash linux_configuration/tests/test_i3blocks_*.sh; pre-commit run"
}

View File

@ -1,41 +0,0 @@
{
"intent": "Unblock every push: the pre-push ci-mirror was failing on test_music_parallelism.sh, whose worktree drift guard still assumed the pre-flattening layout.",
"scope": [
"linux_configuration/tests/test_music_parallelism.sh - derive the lib prefix instead of hardcoding it",
"Non-goal: music_parallelism.sh itself, which is correct - it sources ../../lib/common.sh, matching where lib/ actually sits after the flattening."
],
"changes": [
"The guard hardcoded ../lib/common.sh while the script sources ../../lib/common.sh. The flattening that moved the script from scripts/periodic_background/... up to periodic_background/... changed its depth, so the test failed on a layout that is in fact correct.",
"The prefix is now read out of the script under test with sed, so a future reorganisation cannot desynchronise the two again. The failure message reports the real prefix rather than a fixed string.",
"Matching the literal text $SCRIPT_DIR meant matching a dollar sign as data, which shellcheck flags as SC2016. Spelled the byte via printf '\\\\x24' rather than adding a suppression."
],
"verification": [
{
"command": "bash linux_configuration/tests/test_music_parallelism.sh",
"result": "pass",
"evidence": "'music_parallelism.sh regression checks passed.' Previously failed with 'worktree layout drift: ... resolves ../lib/common.sh ...'."
},
{
"command": "Mutation check in a detached worktree: rewrote the script to source ../../../lib/common.sh",
"result": "pass",
"evidence": "The guard failed as intended and named the real prefix (../../../). Confirms the guard is non-vacuous rather than merely passing."
},
{
"command": "pre-commit run --files linux_configuration/tests/test_music_parallelism.sh",
"result": "pass",
"evidence": "shellcheck and shfmt clean; SC2016 resolved without a suppression."
},
{
"command": "systemd-run --scope -p MemoryMax=2G -p MemorySwapMax=0 /usr/bin/time -v python -m pytest ...",
"result": "pass",
"evidence": "1798 passed in 20.88s, Maximum RSS 293872 KB (~0.29 GB) under the existing 2G cap. The earlier 'Killed' was transient host pressure, not an inadequate ceiling; no cap change needed."
}
],
"risks": [
"The sed pattern matches the specific shape of the source line. If that line is rewritten to a different form the test fails loudly with 'cannot determine how ... sources lib/common.sh' rather than silently passing, which is the intended direction."
],
"rollback": [
"git revert the commit; the test returns to its hardcoded prefix and the pre-push gate fails again until the layout matches.",
"After rollback, run bash linux_configuration/tests/test_music_parallelism.sh to confirm the expected failure."
]
}

View File

@ -0,0 +1,39 @@
{
"intent": "The i3blocks shutdown and gaming_budget blocks render from the daily-limits cache ($XDG_RUNTIME_DIR/daily-limits.json), so the earner list, sizes and ceiling come from the earned_time registry instead of being duplicated in shell; left-click opens daily-limits --gui.",
"scope": [
"linux_configuration/i3blocks/gaming_budget.sh, shutdown_countdown.sh",
"linux_configuration/tests/test_i3blocks_{gaming_budget,shutdown_hint,efficiency}.sh",
"Non-goal: changing the shutdown enforcement itself (screen-locker / digital-wellbeing)"
],
"changes": [
"gaming_budget.sh: drop the curl to the enforcer /api/budget, the config.json read and the fallback bonus sizes; read gaming budget/used/day and the not-done earners from the cache",
"shutdown_countdown.sh: replace earner_hint() with render_limits/load_limits fed by todo[]; →max from shutdown_after reaching the ceiling; unknown earners shown with '?'",
"Both: a cache older than 180 s (or from the future) shows a ⏱? marker and never stale numbers; jq runs only when the cache mtime changes (rendered line cached in $XDG_RUNTIME_DIR)",
"Tests rewritten from the curl stub to DAILY_LIMITS_CACHE fixtures; the shutdown exec bound drops from 2 to 1"
],
"verification": [
{
"command": "bash linux_configuration/i3blocks/gaming_budget.sh; bash linux_configuration/i3blocks/shutdown_countdown.sh (live, daily-limits timer running)",
"result": "pass",
"evidence": "🎮 0h43 left · 2h17/3h00 +💪2h +🧩1h +📖1h +🗂30m +⚙30m / ⏻ 20:00 +💪2h +🧩1h +📖1h +🗂30m +⚙30m →max 23:00 — same format as before the change"
},
{
"command": "capped.sh bash tests/test_i3blocks_gaming_budget.sh; test_i3blocks_shutdown_hint.sh; test_i3blocks_efficiency.sh",
"result": "pass",
"evidence": "All gaming_budget checks passed. / All shutdown hint tests passed. / All i3blocks efficiency regression tests passed."
},
{
"command": "strace -f -c (clone+execve per tick)",
"result": "pass",
"evidence": "gaming steady state 0 clone + 1 execve (was 4 + 3 with curl and jq); shutdown 3 clone + 1 execve (was 8 + 3)"
}
],
"risks": [
"If the daily-limits user timer stops, both blocks show ⏱? (shutdown falls back to the local config time, which is still correct)",
"daily-limits must be on PATH for left-click; otherwise the click does nothing"
],
"rollback": [
"git revert this commit; the blocks go back to the enforcer API and the in-script earner table",
"After a rollback, check the bar shows numbers again with the enforcer running"
]
}

View File

@ -2,109 +2,111 @@
# ============================================================================
# Gaming time budget for i3blocks: time left today, used/total, and which
# bonuses (workout, LeetCode, reading, Anki, Automation, ...) are still there to be earned.
# bonuses (workout, LeetCode, reading, Anki, Automation, ...) 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.
# Renders daily-limits' cache ($XDG_RUNTIME_DIR/daily-limits.json, rewritten
# every 60 s) and owns no earner logic. The cache is re-parsed with jq only
# when its mtime changes; the rendered block is kept in a small file, so a
# steady-state tick forks nothing. A missing or >180 s old cache shows "⏱?",
# never old numbers. Left-click opens `daily-limits --gui`.
# ============================================================================
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}"
readonly LIMITS_CACHE="${DAILY_LIMITS_CACHE:-${XDG_RUNTIME_DIR:-/tmp}/daily-limits.json}"
readonly RENDER_CACHE="${DAILY_LIMITS_RENDER_CACHE:-${XDG_RUNTIME_DIR:-/tmp}/i3blocks-gaming-budget.render}"
readonly STALE_AFTER_SECONDS=180
# Dracula-ish palette, matching screen_locker.sh.
readonly RED='#FF5555'
readonly YELLOW='#F1FA8C'
readonly GREEN='#50FA7B'
readonly ORANGE='#FFB86C'
readonly GREY='#6272A4'
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 &
if [[ ${BLOCK_BUTTON:-0} -eq 1 ]] && command -v daily-limits >/dev/null 2>&1; then
daily-limits --gui >/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: if . < 3600 then "\(. / 60 | round)m" else hm | sub("h00$"; "h") end;
($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, size, icon]. A daemon that lists the
# registry (.rules.earners) is the source of truth, so a new earner
# needs only an icon here; an older daemon falls back to the three
# fixed fields and the configured sizes.
| {workout: "💪", leetcode: "🧩", reading: "📖", anki: "🗂", automation: "⚙"} as $icon
| (if .rules.earners then
[.rules.earners[] | [.earned_seconds, .bonus_seconds, ($icon[.name] // .label)]]
# One jq pass renders four lines: generated_at, full text, short text, color.
# Thresholds mirror the enforcer's own warn_at (1h, 30m, 10m). Bonuses still
# not done are listed as " +💪2h +🧩?1h" ("?" = status unknown, still counted);
# a gaming day other than the date is shown as " (10-08)"; used_minutes == null means the enforcer
# could not report usage, so only the budget is shown.
render_limits() {
local rendered
rendered=$(jq -r \
--arg red "$RED" --arg orange "$ORANGE" --arg yellow "$YELLOW" \
--arg green "$GREEN" --arg grey "$GREY" '
def hm: . as $m
| "\($m / 60 | floor)h\($m % 60 | tostring | if length < 2 then "0" + . else . end)";
def bonus_hm: if . < 60 then "\(.)m" else hm | sub("h00$"; "h") end;
{workout: "💪", leetcode: "🧩", reading: "📖", anki: "🗂", automation: "⚙"} as $icon
| .gaming as $g
| ([.earners[] | select(.status != "done" and .gaming_minutes > 0)
| "+\($icon[.name] // .label)\(if .status == "unknown" then "?" else "" end)\(.gaming_minutes | bonus_hm)"]
| if length > 0 then " " + join(" ") else "" end) as $todo
| (if (.gaming.day // .date) != .date then " (\((.gaming.day // .date)[5:]))" else "" end) as $day
| .generated_at,
(if $g.used_minutes == null then
"🎮 budget \($g.budget_minutes | hm)\($day)\($todo)", "🎮 \($g.budget_minutes | hm)", $grey
else
[ [$b.workout, ($c.workout_bonus_seconds // 7200), "💪"],
[$b.leetcode, ($c.leetcode_bonus_seconds // 3600), "🧩"],
[$b.reading, ($c.reading_bonus_seconds // 3600), "📖"] ]
end)
| 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
([$g.budget_minutes - $g.used_minutes, 0] | max) as $left
| "\($g.used_minutes | hm)/\($g.budget_minutes | hm)\($day)" as $ratio
| if $left <= 0 then
"🎮 BLOCKED · \($ratio)\($todo)", "🎮 BLOCKED", $red
else
"🎮 \($left | hm) left · \($ratio)\($todo)", "🎮 \($left | hm)",
(if $left <= 10 then $red
elif $left <= 30 then $orange
elif $left <= 60 then $yellow
else $green end)
end
end)' "$LIMITS_CACHE" 2>/dev/null) || return 1
printf '%s\n' "$rendered" >"$RENDER_CACHE"
}
if [[ ! -r $LIMITS_CACHE ]]; then
emit '🎮 ⏱? no data' '🎮 ⏱?' "$ORANGE"
exit 0
fi
if [[ $LIMITS_CACHE -nt $RENDER_CACHE ]]; then
if ! command -v jq >/dev/null 2>&1; then
emit '🎮 no jq' '🎮 ?' "$ORANGE"
exit 0
fi
render_limits || {
emit '🎮 unreadable cache' '🎮 !' "$RED"
exit 0
}
fi
{
read -r cache_flag
read -r generated_at
read -r full_text
read -r short_text
read -r color
} <<<"$rendered"
} <"$RENDER_CACHE" || {
emit '🎮 unreadable cache' '🎮 !' "$RED"
exit 0
}
if [[ -n ${NOW_EPOCH:-} ]]; then
now=$NOW_EPOCH
else
printf -v now '%(%s)T' -1
fi
age=$((now - generated_at))
if ((age > STALE_AFTER_SECONDS || age < -STALE_AFTER_SECONDS)); then
emit "🎮 ⏱? stale $((age / 60))m" '🎮 ⏱?' "$ORANGE"
exit 0
fi
[[ $cache_flag == 'cache' ]] && printf '%s' "$full_text" >"$CACHE"
emit "$full_text" "$short_text" "$color"

View File

@ -2,15 +2,28 @@
# Shutdown countdown status script for i3blocks.
# Shows the exact absolute time of the next enforced shutdown, or the
# overridden time if a shutdown-override-manager.sh window rescues it.
#
# The applied time and the still-unearned earners come from daily-limits'
# cache ($XDG_RUNTIME_DIR/daily-limits.json, rewritten every 60 s); this
# script owns no earner logic. The cache is re-parsed with jq only when its
# mtime changes, the rendered result is kept in a small file, so a steady-state
# tick forks nothing. A missing or >180 s old cache shows a "⏱?" marker (and
# the config-derived time), never old cache numbers. Left-click opens
# `daily-limits --gui`.
set -euo pipefail
SHUTDOWN_CONFIG=${SHUTDOWN_CONFIG:-/etc/shutdown-schedule.conf}
SKIP_DATES_FILE=${SKIP_DATES_FILE:-/etc/shutdown-skip-dates}
OVERRIDES_FILE=${OVERRIDES_FILE:-/etc/shutdown-schedule-overrides.conf}
BUDGET_API=${GAMING_BUDGET_API:-http://127.0.0.1:8000/api/budget}
# earned_time's SHUTDOWN_CEILING_MINUTES (23:00): no earner pushes past it.
SHUTDOWN_CEILING_MINUTES=${SHUTDOWN_CEILING_MINUTES:-1380}
LIMITS_CACHE=${DAILY_LIMITS_CACHE:-${XDG_RUNTIME_DIR:-/tmp}/daily-limits.json}
RENDER_CACHE=${DAILY_LIMITS_RENDER_CACHE:-${XDG_RUNTIME_DIR:-/tmp}/i3blocks-shutdown.render}
readonly STALE_AFTER_SECONDS=180
# Left-click: open the daily-limits popup (a fork only on click).
if [[ ${BLOCK_BUTTON:-0} -eq 1 ]] && command -v daily-limits >/dev/null 2>&1; then
daily-limits --gui >/dev/null 2>&1 &
fi
# Function to show error state in i3blocks and exit
show_error() {
@ -151,31 +164,71 @@ if override_match=$(find_override_covering "$shutdown_epoch_today"); then
exit 0
fi
# Earners not yet done today, rendered like gaming_budget.sh: " +🧩1h +📖1h".
# The enforcer's /api/budget reports gaming seconds per earner; every
# earned_time earner moves shutdown by the same amount, so that one source
# serves both blocks. Past the earned_time shutdown ceiling the extra is lost,
# hence the "→max" note. Server down = no hint: the time above is still right.
earner_hint() {
local headroom_seconds=$(((SHUTDOWN_CEILING_MINUTES - shutdown_time_minutes) * 60))
local ceiling_hhmm budget_json
((headroom_seconds > 0)) || return 0
command -v curl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 || return 0
budget_json=$(curl -fsS --max-time 2 "$BUDGET_API" 2>/dev/null) || return 0
printf -v ceiling_hhmm '%02d:%02d' $((SHUTDOWN_CEILING_MINUTES / 60)) $((SHUTDOWN_CEILING_MINUTES % 60))
jq -r --argjson headroom "$headroom_seconds" --arg ceiling "$ceiling_hhmm" '
def bonus_hm: (. / 60 | round) as $m
# One jq pass renders three lines: generated_at, the applied shutdown time
# (falling back to the resolved one) and the hint for earners still in `todo`,
# e.g. " +🧩1h +📖?1h →max 23:00" ("?" = status unknown, still counted). "→max"
# appears once the cumulative `shutdown_after` of the todo list reaches the ceiling.
# Re-run only when the cache file is newer than the render.
render_limits() {
local rendered
command -v jq >/dev/null 2>&1 || return 1
rendered=$(jq -r '
def bonus_hm: . as $m
| if $m < 60 then "\($m)m"
else "\($m / 60 | floor)h" + ($m % 60 | if . == 0 then "" else "\(.)" | if length < 2 then "0" + . else . end end) end;
{workout: "💪", leetcode: "🧩", reading: "📖", anki: "🗂", automation: "⚙"} as $icon
| select(.ok == true)
| [.rules.earners[]? | select((.earned_seconds // 0) == 0 and .bonus_seconds > 0)]
| select(length > 0)
| " " + (map("+\($icon[.name] // .label)\(.bonus_seconds | bonus_hm)") | join(" "))
+ (if (map(.bonus_seconds) | add) > $headroom then " →max \($ceiling)" else "" end)
' <<<"$budget_json" 2>/dev/null || true
| (.earners | map({(.name): .}) | add // {}) as $by
| (.shutdown.applied // .shutdown.resolved) as $time
| (.shutdown.ceiling) as $ceiling
| (if $time < $ceiling and (.todo | length) > 0
then " " + (.todo | map("+\($icon[.name] // .label)\(if .status == "unknown" then "?" else "" end)\($by[.name].shutdown_minutes // 0 | bonus_hm)") | join(" "))
+ (if (.todo | map(.shutdown_after) | max) >= $ceiling then " →max \($ceiling)" else "" end)
else "" end) as $hint
| .generated_at, $time, $hint' "$LIMITS_CACHE" 2>/dev/null) || return 1
printf '%s\n' "$rendered" >"$RENDER_CACHE"
}
# Sets limits_state (fresh|stale|missing|bad), limits_time, limits_hint, limits_age.
limits_state=missing
limits_time=''
limits_hint=''
limits_age=0
load_limits() {
local gen=''
[[ -r $LIMITS_CACHE ]] || return 0
limits_state=bad
if [[ $LIMITS_CACHE -nt $RENDER_CACHE ]]; then
render_limits || return 0
fi
{
read -r gen
read -r limits_time
IFS= read -r limits_hint || true
} <"$RENDER_CACHE" 2>/dev/null || return 0
[[ $gen =~ ^[0-9]+$ && $limits_time =~ ^[0-9]{2}:[0-9]{2}$ ]] || return 0
limits_age=$((now_epoch - gen))
if ((limits_age > STALE_AFTER_SECONDS || limits_age < -STALE_AFTER_SECONDS)); then
limits_state=stale
else
limits_state=fresh
fi
}
load_limits
printf -v shutdown_label '%(%H:%M)T' "$shutdown_epoch_today"
hint=''
case $limits_state in
fresh)
shutdown_label=$limits_time
hint=$limits_hint
shutdown_time_minutes=$((10#${limits_time%:*} * 60 + 10#${limits_time#*:}))
;;
stale) hint=" ⏱? $((limits_age / 60))m" ;;
missing) hint=' ⏱?' ;;
bad) hint=' ⏱!' ;;
esac
minutes_until_shutdown=$((shutdown_time_minutes - current_time_minutes))
if [[ $minutes_until_shutdown -le 30 ]]; then
@ -188,6 +241,6 @@ else
color="#6272A4"
fi
echo "⏻ $(format_hhmm "$shutdown_epoch_today")$(earner_hint)"
echo "⏻ ${shutdown_label}${hint}"
echo "⏻"
echo "$color"

View File

@ -55,13 +55,10 @@ count_execs() {
grep -c 'execve(' "$log_file"
}
# A dead budget server: shutdown_countdown.sh's earner hint must never reach
# the real enforcer from a test, and with no hint it runs no jq either.
cat >"$BIN_DIR/curl" <<'EOF'
#!/bin/bash
exit 7
EOF
chmod +x "$BIN_DIR/curl"
# shutdown_countdown.sh renders daily-limits' cache; keep these checks off the
# machine's real $XDG_RUNTIME_DIR files.
export DAILY_LIMITS_CACHE="$TMP_DIR/daily-limits.json"
export DAILY_LIMITS_RENDER_CACHE="$TMP_DIR/shutdown.render"
cat >"$BIN_DIR/pacman" <<'EOF'
#!/bin/bash
@ -193,14 +190,17 @@ shutdown_overrides_empty="$TMP_DIR/shutdown-overrides-empty.conf"
# is 21:00. The block shows the exact clock time of the shutdown, not a
# relative countdown.
shutdown_countdown_epoch=$(epoch_utc '2026-05-01 19:30:00')
printf '{"date":"2026-05-01","generated_at":%s,"shutdown":{"applied":"21:00","resolved":"21:00","floor":"20:00","ceiling":"23:00"},"gaming":{"budget_minutes":180,"used_minutes":null,"ceiling_minutes":480},"earners":[],"todo":[]}\n' \
"$shutdown_countdown_epoch" >"$DAILY_LIMITS_CACHE"
shutdown_countdown_output=$(TZ=UTC NOW_EPOCH="$shutdown_countdown_epoch" SHUTDOWN_CONFIG="$shutdown_config_file" OVERRIDES_FILE="$shutdown_overrides_empty" PATH="$BIN_DIR:$PATH" bash "$I3BLOCKS_DIR/shutdown_countdown.sh")
assert_equals '⏻ 21:00' "$(printf '%s\n' "$shutdown_countdown_output" | sed -n '1p')" \
'shutdown countdown should show the exact clock time of the next shutdown'
assert_equals '#F1FA8C' "$(printf '%s\n' "$shutdown_countdown_output" | sed -n '3p')" \
'shutdown countdown should show yellow for two hours or less remaining'
# bash itself plus the one curl for the earner hint; no date helpers.
assert_le "$(count_execs "$I3BLOCKS_DIR/shutdown_countdown.sh")" 2 \
'shutdown countdown should avoid date helpers in the hot path'
# The run above rendered the cache; with the cache file unchanged a tick is
# bash alone: no jq, no curl, no date helpers.
assert_le "$(NOW_EPOCH="$shutdown_countdown_epoch" SHUTDOWN_CONFIG="$shutdown_config_file" OVERRIDES_FILE="$shutdown_overrides_empty" TZ=UTC count_execs "$I3BLOCKS_DIR/shutdown_countdown.sh")" 1 \
'shutdown countdown should fork nothing once the cache is rendered'
shutdown_window_epoch=$(epoch_utc '2026-05-01 21:15:00')
shutdown_window_output=$(TZ=UTC NOW_EPOCH="$shutdown_window_epoch" SHUTDOWN_CONFIG="$shutdown_config_file" OVERRIDES_FILE="$shutdown_overrides_empty" PATH="$BIN_DIR:$PATH" bash "$I3BLOCKS_DIR/shutdown_countdown.sh")
@ -225,9 +225,12 @@ assert_equals '#50FA7B' "$(printf '%s\n' "$shutdown_override_output" | sed -n '3
shutdown_minutes_file="$TMP_DIR/shutdown-schedule-minutes.conf"
printf '%s\n' MON_WED_MINUTES=1380 THU_SUN_MINUTES=1110 MORNING_END_MINUTES=300 \
MON_WED_HOUR=23 THU_SUN_HOUR=18 MORNING_END_HOUR=5 >"$shutdown_minutes_file"
# No cache here: the config itself is under test, so the block falls back to it
# and marks the missing cache.
rm -f "$DAILY_LIMITS_CACHE"
shutdown_minutes_output=$(TZ=UTC NOW_EPOCH="$(epoch_utc '2026-05-01 18:15:00')" SHUTDOWN_CONFIG="$shutdown_minutes_file" OVERRIDES_FILE="$shutdown_overrides_empty" PATH="$BIN_DIR:$PATH" bash "$I3BLOCKS_DIR/shutdown_countdown.sh")
assert_equals '⏻ 18:30' "$(printf '%s\n' "$shutdown_minutes_output" | sed -n '1p')" \
'shutdown countdown should show the minute-precision shutdown time'
assert_equals '⏻ 18:30 ⏱?' "$(printf '%s\n' "$shutdown_minutes_output" | sed -n '1p')" \
'shutdown countdown should show the minute-precision config time when the cache is missing'
shutdown_minutes_output=$(TZ=UTC NOW_EPOCH="$(epoch_utc '2026-05-01 18:31:00')" SHUTDOWN_CONFIG="$shutdown_minutes_file" OVERRIDES_FILE="$shutdown_overrides_empty" PATH="$BIN_DIR:$PATH" bash "$I3BLOCKS_DIR/shutdown_countdown.sh")
assert_equals '⏻ SHUTDOWN' "$(printf '%s\n' "$shutdown_minutes_output" | sed -n '1p')" \
'shutdown countdown should be due once the shutdown minute has passed'

View File

@ -1,7 +1,8 @@
#!/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.
# Behaviour tests for the i3blocks gaming_budget block: it renders daily-limits'
# cache ($DAILY_LIMITS_CACHE). Covers text, colour thresholds, the not-done
# earner suffix (todo and unknown), the gaming-day note, the stale/missing/
# garbage states and the "re-parse only when the cache changes" contract.
set -euo pipefail
@ -32,98 +33,104 @@ assert_eq() {
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"
CACHE="$TMP_DIR/daily-limits.json"
RENDER="$TMP_DIR/render"
NOW=1790000000
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"
# write_cache USED BUDGET GENERATED_AT GAMING_DAY EARNER... where EARNER is
# NAME:STATUS:MINUTES. USED may be "null".
write_cache() {
local used=$1 budget=$2 generated=$3 day=$4
shift 4
local rows=() name status minutes spec
for spec in "$@"; do
IFS=: read -r name status minutes <<<"$spec"
rows+=("{\"name\":\"$name\",\"label\":\"$name\",\"status\":\"$status\",\"shutdown_minutes\":$minutes,\"gaming_minutes\":$minutes}")
done
local IFS=,
printf '{"date":"2026-10-09","generated_at":%s,"shutdown":{"applied":"20:00","resolved":"20:00","floor":"20:00","ceiling":"23:00"},"gaming":{"budget_minutes":%s,"used_minutes":%s,"ceiling_minutes":480,"day":"%s"},"earners":[%s],"todo":[]}\n' \
"$generated" "$budget" "$used" "$day" "${rows[*]}" >"$CACHE"
rm -f "$RENDER" # force a re-render for the new content
}
run_block() {
PATH="$BIN_DIR:$PATH" FIXTURE="$FIXTURE" GAMING_BUDGET_CACHE="$CACHE_FILE" \
GAMING_BUDGET_CONFIG="$CONFIG_FILE" bash "$BLOCK"
NOW_EPOCH="$NOW" DAILY_LIMITS_CACHE="$CACHE" DAILY_LIMITS_RENDER_CACHE="$RENDER" 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
D=2026-10-09
printf 'Checking the normal day: time left, used/total, not-done bonus...\n'
write_cache 360 480 "$NOW" "$D" workout:done:120 leetcode:done:60 reading:todo:60
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
write_cache 430 480 "$NOW" "$D" workout:done:120
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
write_cache 460 480 "$NOW" "$D"
assert_eq '#FFB86C' "$(line 3)" 'orange at <=30m'
write_budget 28500 28800 300 false 7200 3600 3600
write_cache 475 480 "$NOW" "$D"
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'
write_cache 300 300 "$NOW" "$D" workout:todo:120 leetcode:todo:60 reading:todo:60
assert_eq '🎮 BLOCKED · 5h00/5h00 +💪2h +🧩1h +📖1h' "$(line 1)" 'blocked 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'
write_cache 302 300 "$NOW" "$D"
assert_eq '🎮 BLOCKED' "$(line 2)" 'overspent counts as blocked'
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 earner icons, labels, minutes and the unknown marker...\n'
write_cache 0 240 "$NOW" "$D" workout:done:120 leetcode:todo:60 reading:todo:60 anki:todo:30 automation:todo:30 piano:todo:90
assert_eq '🎮 4h00 left · 0h00/4h00 +🧩1h +📖1h +🗂30m +⚙30m +piano1h30' "$(line 1)" \
'cache order, done omitted, label for an unmapped earner'
write_cache 0 240 "$NOW" "$D" leetcode:unknown:60 anki:unknown:30 reading:done:60
assert_eq '🎮 4h00 left · 0h00/4h00 +🧩?1h +🗂?30m' "$(line 1)" \
'unknown earners are shown with a ? marker, not hidden'
printf 'Checking a daemon that lists the registry (.rules.earners)...\n'
# earner NAME LABEL EARNED SIZE -> one JSON object for .rules.earners.
earner() {
jq -nc --arg n "$1" --arg l "$2" --argjson e "$3" --argjson s "$4" \
'{name: $n, label: $l, earned_seconds: $e, bonus_seconds: $s}'
}
jq -n --argjson earners "[$(earner workout workout 7200 7200),$(earner leetcode LeetCode 0 3600),$(earner reading reading 0 3600),$(earner anki Anki 0 1800),$(earner automation Automation 0 1800),$(earner piano Piano 0 5400)]" \
'{ok: true, state_status: "ok",
today: {seconds_used: 0, budget_seconds: 14400, seconds_remaining: 14400, blocked: false},
rules: {bonuses: {}, earners: $earners}}' >"$FIXTURE"
printf '{"leetcode_bonus_seconds":0}\n' >"$CONFIG_FILE"
assert_eq '🎮 4h00 left · 0h00/4h00 +🧩1h +📖1h +🗂30m +⚙30m +Piano1h30' "$(line 1)" \
'registry order, earned omitted, sizes from the daemon not the config, label for an unmapped earner'
rm -f "$CONFIG_FILE"
printf 'Checking used_minutes null shows only the budget...\n'
write_cache null 180 "$NOW" "$D" leetcode:todo:60
assert_eq '🎮 budget 3h00 +🧩1h' "$(line 1)" 'budget only'
assert_eq '🎮 3h00' "$(line 2)" 'budget only short text'
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'
printf 'Checking the gaming day shows only when it differs from the date...\n'
write_cache 60 180 "$NOW" 2026-10-08 leetcode:todo:60
assert_eq '🎮 2h00 left · 1h00/3h00 (10-08) +🧩1h' "$(line 1)" 'previous gaming day noted'
write_cache 60 180 "$NOW" "$D" leetcode:todo:60
assert_eq '🎮 2h00 left · 1h00/3h00 +🧩1h' "$(line 1)" 'same day: no note'
printf 'Checking a stale or missing cache never shows numbers as fresh...\n'
write_cache 60 180 "$((NOW - 181))" "$D"
assert_eq '🎮 ⏱? stale 3m' "$(line 1)" 'older than 180 s'
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'
write_cache 60 180 "$((NOW - 180))" "$D"
assert_eq '🎮 2h00 left · 1h00/3h00' "$(line 1)" '180 s is still fresh'
rm -f "$CACHE"
assert_eq '🎮 ⏱? no data' "$(line 1)" 'missing cache'
assert_eq '🎮 ⏱?' "$(line 2)" 'missing cache short text'
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 'Checking an unreadable cache is red...\n'
printf 'not json' >"$CACHE"
rm -f "$RENDER"
assert_eq '🎮 unreadable cache' "$(line 1)" 'garbage body'
assert_eq '#FF5555' "$(line 3)" 'garbage is red'
printf 'Checking jq runs only when the cache file changes...\n'
write_cache 60 180 "$NOW" "$D"
mkdir -p "$TMP_DIR/bin"
printf '#!/bin/bash\necho x >>"%s/jq.calls"\nexec %s "$@"\n' "$TMP_DIR" "$(command -v jq)" >"$TMP_DIR/bin/jq"
chmod +x "$TMP_DIR/bin/jq"
PATH="$TMP_DIR/bin:$PATH" run_block >/dev/null
PATH="$TMP_DIR/bin:$PATH" run_block >/dev/null
assert_eq '1' "$(wc -l <"$TMP_DIR/jq.calls")" 'two ticks, one unchanged cache: one jq run'
sleep 0.05
touch "$CACHE"
PATH="$TMP_DIR/bin:$PATH" run_block >/dev/null
assert_eq '2' "$(wc -l <"$TMP_DIR/jq.calls")" 'a rewritten cache is re-parsed'
printf 'All gaming_budget checks passed.\n'

View File

@ -1,8 +1,8 @@
#!/bin/bash
# Behaviour tests for shutdown_countdown.sh's earner hint: which earners are
# listed, their amounts, the 23:00 ceiling note, and the states that show no
# hint. curl is stubbed on PATH to serve a fixture, so nothing talks to the
# real enforcer.
# Behaviour tests for shutdown_countdown.sh's rendering of daily-limits' cache
# ($DAILY_LIMITS_CACHE): the applied time, the unearned-earner hint (todo and
# unknown), the ceiling note driven by `shutdown_after`, and the stale/missing/
# garbage markers.
set -euo pipefail
@ -30,68 +30,79 @@ assert_eq() {
printf '{}' | jq -e . >/dev/null 2>&1 ||
fail 'a working jq is required for the shutdown hint 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="$TMP_DIR/daily-limits.json"
RENDER="$TMP_DIR/render"
SCHEDULE="$TMP_DIR/schedule.conf"
NO_OVERRIDES="$TMP_DIR/overrides.conf"
: >"$NO_OVERRIDES"
# 2026-05-01 is a Friday, so THU_SUN_MINUTES applies.
NOW=$(TZ=UTC date -d '2026-05-01 18:00:00' +%s)
# write_earners NAME:EARNED:BONUS ... -- the /api/budget earners list.
write_earners() {
local rows=() name earned bonus
# write_cache APPLIED GENERATED_AT TODO... where TODO is NAME:STATUS:MINUTES:AFTER.
# APPLIED may be "null" (then resolved = 20:00 is shown). Each todo entry is also
# an earner, carrying its shutdown_minutes.
write_cache() {
local applied=$1 generated=$2
shift 2
[[ $applied == null ]] || applied="\"$applied\""
local earners=() todos=() name status minutes after spec
for spec in "$@"; do
IFS=: read -r name earned bonus <<<"$spec"
rows+=("{\"name\":\"$name\",\"label\":\"$name\",\"earned_seconds\":$earned,\"bonus_seconds\":$bonus}")
IFS=: read -r name status minutes after <<<"$spec"
earners+=("{\"name\":\"$name\",\"label\":\"$name\",\"status\":\"$status\",\"shutdown_minutes\":$minutes,\"gaming_minutes\":$minutes}")
todos+=("{\"name\":\"$name\",\"label\":\"$name\",\"status\":\"$status\",\"shutdown_after\":\"$after\"}")
done
local IFS=,
printf '{"ok":true,"rules":{"earners":[%s]}}\n' "${rows[*]}" >"$FIXTURE"
printf '{"date":"2026-05-01","generated_at":%s,"shutdown":{"applied":%s,"resolved":"20:00","floor":"20:00","ceiling":"23:00"},"gaming":{"budget_minutes":180,"used_minutes":null,"ceiling_minutes":480,"day":"2026-05-01"},"earners":[%s],"todo":[%s]}\n' \
"$generated" "$applied" "${earners[*]}" "${todos[*]}" >"$CACHE"
rm -f "$RENDER" # force a re-render for the new content
}
# first_line SHUTDOWN_MINUTES -- the block's full_text at 18:00.
first_line() {
printf 'THU_SUN_MINUTES=%s\nMON_WED_MINUTES=%s\nMORNING_END_MINUTES=300\n' \
"$1" "$1" >"$SCHEDULE"
TZ=UTC NOW_EPOCH="$NOW" PATH="$BIN_DIR:$PATH" FIXTURE="$FIXTURE" \
TZ=UTC NOW_EPOCH="$NOW" DAILY_LIMITS_CACHE="$CACHE" DAILY_LIMITS_RENDER_CACHE="$RENDER" \
SHUTDOWN_CONFIG="$SCHEDULE" OVERRIDES_FILE="$NO_OVERRIDES" \
SKIP_DATES_FILE=/dev/null bash "$BLOCK" | sed -n 1p
}
printf 'Checking unearned earners are listed with icon and amount...\n'
write_earners workout:7200:7200 leetcode:0:3600 reading:0:3600 anki:0:1800 automation:0:1800
assert_eq '⏻ 20:00 +🧩1h +📖1h +🗂30m +⚙30m' "$(first_line 1200)" \
'earned ones hidden, the rest listed; 3h fits under 23:00'
printf 'Checking not-done earners are listed with icon and amount...\n'
write_cache 20:00 "$NOW" leetcode:todo:60:21:00 reading:todo:60:22:00 anki:todo:30:22:30 automation:todo:30:23:00
assert_eq '⏻ 20:00 +🧩1h +📖1h +🗂30m +⚙30m →max 23:00' "$(first_line 1200)" \
'cumulative shutdown_after reaching 23:00 gives the ceiling note'
write_cache 20:00 "$NOW" leetcode:todo:60:21:00 reading:todo:60:22:00
assert_eq '⏻ 20:00 +🧩1h +📖1h' "$(first_line 1200)" 'below the ceiling: no note'
printf 'Checking the ceiling note once the bonuses overflow 23:00...\n'
assert_eq '⏻ 22:00 +🧩1h +📖1h +🗂30m +⚙30m →max 23:00' "$(first_line 1320)" \
'3h of bonuses with 1h of headroom'
printf 'Checking unknown earners are shown with ? and counted...\n'
write_cache 20:00 "$NOW" leetcode:unknown:60:21:00 reading:todo:60:22:00 anki:unknown:30:23:00
assert_eq '⏻ 20:00 +🧩?1h +📖1h +🗂?30m →max 23:00' "$(first_line 1200)" \
'unknown marked, and its shutdown_after still drives →max'
printf 'Checking nothing is listed at the ceiling or with all earned...\n'
printf 'Checking the applied time wins, resolved is the fallback...\n'
write_cache 21:30 "$NOW"
assert_eq '⏻ 21:30' "$(first_line 1200)" 'applied time shown'
write_cache null "$NOW"
assert_eq '⏻ 20:00' "$(first_line 1200)" 'applied null falls back to resolved'
printf 'Checking nothing is listed at the ceiling or with all done...\n'
write_cache 23:00 "$NOW" leetcode:todo:60:23:00
assert_eq '⏻ 23:00' "$(first_line 1380)" 'no headroom, no hint'
write_earners leetcode:3600:3600 reading:3600:3600
assert_eq '⏻ 20:00' "$(first_line 1200)" 'everything earned'
write_cache 20:00 "$NOW"
assert_eq '⏻ 20:00' "$(first_line 1200)" 'everything done'
printf 'Checking odd amounts and unknown earners...\n'
write_earners leetcode:0:5400 newgate:0:900
printf 'Checking odd amounts and unmapped earners...\n'
write_cache 20:00 "$NOW" leetcode:todo:90:21:30 newgate:todo:15:21:45
assert_eq '⏻ 20:00 +🧩1h30 +newgate15m' "$(first_line 1200)" \
'1h30 padded like gaming_budget.sh; unknown earner falls back to label'
'1h30 padded like gaming_budget.sh; unmapped earner falls back to label'
printf 'Checking a dead or unhappy server shows the bare time...\n'
rm -f "$FIXTURE"
assert_eq '⏻ 20:00' "$(first_line 1200)" 'server down'
printf '{"ok":false}\n' >"$FIXTURE"
assert_eq '⏻ 20:00' "$(first_line 1200)" 'budget error'
printf 'not json\n' >"$FIXTURE"
assert_eq '⏻ 20:00' "$(first_line 1200)" 'unreadable reply'
printf 'Checking stale, missing and unreadable caches show a marker...\n'
write_cache 21:30 "$((NOW - 181))" leetcode:todo:60:22:30
assert_eq '⏻ 20:00 ⏱? 3m' "$(first_line 1200)" 'stale: config time, no cache numbers'
write_cache 21:30 "$((NOW - 180))"
assert_eq '⏻ 21:30' "$(first_line 1200)" '180 s is still fresh'
rm -f "$CACHE"
assert_eq '⏻ 20:00 ⏱?' "$(first_line 1200)" 'missing cache'
printf 'not json\n' >"$CACHE"
rm -f "$RENDER"
assert_eq '⏻ 20:00 ⏱!' "$(first_line 1200)" 'unreadable cache'
printf 'All shutdown hint tests passed.\n'