mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:27:02 +01:00
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
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:
parent
46dfd1a445
commit
433cec278e
@ -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"
|
||||
}
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user