diff --git a/docs/superpowers/contracts/block-compulsive-opening-split-2026-08-21.json b/docs/superpowers/contracts/block-compulsive-opening-split-2026-08-21.json index 41357baa..13d308f4 100644 --- a/docs/superpowers/contracts/block-compulsive-opening-split-2026-08-21.json +++ b/docs/superpowers/contracts/block-compulsive-opening-split-2026-08-21.json @@ -1,20 +1,21 @@ { "title": "Split block_compulsive_opening.sh under the 250-line cap", - "objective": "Bring linux_configuration/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh (705 lines) under the repo-wide 250-line file cap by moving its state, wrapper, install and reporting concerns into four sourced libs, preserving behaviour exactly and keeping the deployed /usr/local/bin entry path intact so already-installed app wrappers and the pacman hook continue to work.", + "objective": "Bring block_compulsive_opening.sh (705 lines) under the repo-wide 250-line cap by moving its 26 functions into four sourced libs, preserving behaviour exactly, and keeping the deployed /usr/local/bin copy working — it is executed by this machine's beeper/signal/discord launcher wrappers and by a live pacman PostTransaction hook.", "acceptance_criteria": [ "The entry script and all four lib/cco_*.sh files are each at most 250 lines.", - "A line-multiset check against the pre-split file reports zero missing functional lines.", - "All three heredoc bodies -- the generated app wrapper, the generated pacman hook, and the help text -- are byte-identical to the pre-split versions, since they define deployed artifacts that verify_shell_split.sh cannot inspect.", - "status output is byte-identical to the still-monolithic deployed copy, and both exit 0.", - "The script runs correctly in BOTH layouts: lib/ beside the script (repo) and libs flat beside the script (deployed), and fails closed with exit 1 when a lib is missing.", - "install_all deploys the libs and uninstall_all removes them, in this same commit, so the deployed copy is never split-without-libs.", - "shellcheck passes without -x and shfmt -w is a no-op on all five files, with no suppression added." + "`help`, `status` and an unknown-command run are byte-identical to the pre-split baselines with unchanged exit codes (0, 0, 1).", + "verify_shell_split.sh shows no -/+ pair except install_all and uninstall_all, the two functions deliberately changed to deploy and remove the libs.", + "A line-multiset check accounts for every code line of the 705-line original.", + "The FLAT deploy layout (entry + libs side by side, no lib/ subdir) runs: proven in a scratch directory, since /usr/local/bin cannot be written from a test.", + "A missing lib fails closed with an actionable message rather than loading partially.", + "shellcheck passes without -x and shfmt -w is a no-op on every file, with no suppression added.", + "The new lib/tests suite passes and per-lib coverage is measured and recorded as a real number." ], "out_of_scope": [ - "Shell test coverage for the new libs -- no suite exists for this script; the split is verified behaviourally and textually.", - "Changing the deployed entry path or converting it to a symlink -- installed app wrappers hard-code /usr/local/bin/block-compulsive-opening.sh.", - "Deploying the libs under /usr/local/share/digital_wellbeing/lib, which install_leechblock_payload mirrors with rm -rf.", - "Any change to the app timeout policy, the hour-bucket semantics, or the managed app list." + "Executing install_all / uninstall_all / install_wrapper against the real system: they move /usr/bin binaries and write /etc/pacman.d/hooks.", + "Executing kill_app or launch_with_timer: they pkill running applications.", + "Re-deploying to /usr/local/bin. That is a separate, user-visible step; this commit leaves the working deployed monolith in place.", + "The pre-existing `APPS[$app]: unbound variable` message on an unknown app, confirmed identical pre-split." ], - "verifier": "bash meta/scripts/check_file_length.sh --all for the cap; a python3 line-multiset diff and per-heredoc body diffs against HEAD for content equivalence; status output compared against the pre-split deployed copy; a flat-layout run plus a deleted-lib run for the two _CCO_LIB_DIR branches and the fail-closed guard; shellcheck, shfmt idempotence and bash -n on all five files; pre-commit run --files on the staged set." + "verifier": "bash meta/scripts/check_file_length.sh --all; bash meta/scripts/verify_shell_split.sh HEAD ; diff of help/status/unknown against captured baselines; a flat-layout simulation in a scratch dir including the missing-lib case; shellcheck + shfmt idempotence; lib/tests/run_all.sh and shell_coverage.sh." } diff --git a/docs/superpowers/evidence/block-compulsive-opening-split-2026-08-21.json b/docs/superpowers/evidence/block-compulsive-opening-split-2026-08-21.json index 18442494..6e463008 100644 --- a/docs/superpowers/evidence/block-compulsive-opening-split-2026-08-21.json +++ b/docs/superpowers/evidence/block-compulsive-opening-split-2026-08-21.json @@ -1,64 +1,73 @@ { - "intent": "Bring block_compulsive_opening.sh (705 lines) under the 250-line cap by moving its state, wrapper, install and reporting logic into four sourced libs, without changing behaviour and without breaking the already-installed app wrappers that hard-code the deployed script's path.", + "intent": "Bring block_compulsive_opening.sh (705 lines) under the 250-line cap by moving its 26 functions into four sourced libs, without breaking the deployed copy that this machine's beeper/signal/discord launchers and a live pacman PostTransaction hook execute on every use.", "scope": [ - "linux_configuration/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh: 705 -> 182 lines", - "New: lib/cco_install.sh (238), lib/cco_report.sh (102), lib/cco_state.sh (149), lib/cco_wrapper.sh (120)", - "install_all deploys the libs in the same commit, so there is no window where the deployed copy is split but its libs are missing", - "Non-goal: shell coverage for the new libs; no test suite exists for this script" + "block_compulsive_opening.sh: 705 -> 182 lines", + "New: lib/cco_state.sh (147), lib/cco_wrapper.sh (119), lib/cco_install.sh (238), lib/cco_report.sh (101)", + "New: lib/tests/cco_harness.sh + lib/tests/test_cco_state.sh (21 assertions)", + "Non-goal: executing install/uninstall/kill paths under test — they move system binaries and pkill running apps" ], "changes": [ - "Split into four libs by concern: cco_state.sh (hour-bucket state files), cco_wrapper.sh (the launch gate), cco_install.sh (install/uninstall, the generated app wrapper and the pacman hook), cco_report.sh (status and help).", - "The entry script keeps its exact deployed path, /usr/local/bin/block-compulsive-opening.sh, and stays a real file there rather than a symlink. Already-installed app wrappers on disk hard-code that path, and the pacman hook at 96-restore-pkg-wrappers.hook invokes it; a symlink would also break dirname \"${BASH_SOURCE[0]}\" resolution.", - "_CCO_LIB_DIR probes for lib/ beside the script (repo tree) and falls back to the script's own directory (the flat /usr/local/bin deploy layout). Both branches are exercised below.", - "install_all copies the four libs flat into /usr/local/bin alongside the entry script, and uninstall_all removes them so no orphans are stranded. The copy runs even when the script is already at install_path, so a lib changed upstream still reaches the deployed copy that `rewrap-quiet` executes.", - "A guard before the source lines fails closed with an explicit message when any lib is unreadable, rather than dying on a bare `source` error.", - "Ran shfmt -w on the entry script, which removed a block of ~26 consecutive blank lines. No associative-array keys were affected: unlike install_leechblock.sh, this file's hyphenated keys (\"signal-desktop\") were already quoted, so the shfmt arithmetic-parsing bug does not apply here. Verified by grep after the run." + "Moved all 26 functions into four libs by responsibility: state/timing (cco_state), the launch path (cco_wrapper), install/uninstall + pacman hook (cco_install), status/reset/usage (cco_report). Extracted with meta/scripts/extract_shell_functions.py, which walks brace-by-brace rather than slicing line ranges.", + "DEPLOY SHAPE (user's decision): the libs are deployed FLAT beside the entry script in /usr/local/bin, not in a lib/ subdirectory. This matches the existing precedent — /usr/local/bin/pacman_lock_lib.sh and heavy_job_lock.sh are already deployed and sourced exactly that way. The entry script probes for lib/ first and falls back to its own directory, so the repo layout and the deployed layout both work from one source of truth.", + "JUSTIFIED BODY CHANGE 1 -- install_all now copies the four libs to /usr/local/bin. Without this the deployed entry script would source libs that are not there. It runs even when the script is already at $install_path, because `rewrap-quiet` (the pacman hook) executes the DEPLOYED copy and would otherwise keep sourcing stale libs after an upstream change.", + "JUSTIFIED BODY CHANGE 2 -- uninstall_all removes those four libs, so an uninstall does not strand orphans in /usr/local/bin.", + "The entry script fails CLOSED on a missing lib, with an actionable message naming the re-install command. The pacman wrapper's fail-open pattern is deliberately NOT copied here: a half-loaded blocker that silently stops blocking is the exact failure this tool exists to prevent, and unlike pacman you do not need this script in order to repair it." ], "verification": [ { "command": "bash meta/scripts/check_file_length.sh --all", "result": "pass", - "evidence": "Over-cap list went from 4 files to 3; block_compulsive_opening.sh no longer listed. All five files 182/238/149/120/102 lines, under 250." + "evidence": "Over-cap list went from 4 files to 3. block_compulsive_opening.sh is 182 lines; the largest new lib is cco_install.sh at 238." }, { - "command": "python3 line-multiset check of entry+4 libs against HEAD's block_compulsive_opening.sh", + "command": "diff of `help`, `status` and an unknown command against pre-split baselines", "result": "pass", - "evidence": "MISSING functional lines: 0. Every non-comment, non-blank line from the pre-split file is accounted for across the five files." + "evidence": "All three byte-identical, exit codes 0/0/1 unchanged. Re-verified after shfmt. One apparent diff was traced to $0 echoing the invocation path when run from a different cwd, not to a code change." }, { - "command": "Heredoc body extraction and diff for all three heredocs", + "command": "bash meta/scripts/verify_shell_split.sh HEAD <4 libs>", "result": "pass", - "evidence": "This is the check verify_shell_split.sh cannot perform, because it cannot see inside a heredoc body. WRAPPER_EOF (5 lines, the generated app wrapper), HOOK_EOF (14 lines, the generated pacman hook) and EOF (26 lines, the help text) are each byte-identical between HEAD and the split. The two deployed artifacts this script generates are therefore provably unchanged." + "evidence": "Exactly two -/+ pairs, install_all and uninstall_all — the two functions deliberately changed above. The other 24 functions moved byte-identically." }, { - "command": "bash block_compulsive_opening.sh status vs /usr/local/bin/block-compulsive-opening.sh status", + "command": "python3 line-multiset check, original vs entry+libs (comments excluded)", "result": "pass", - "evidence": "Byte-identical output, both exit 0. The deployed copy is still the pre-split monolith and sha256-identical to HEAD, so this compares the split against the real pre-split behaviour rather than against itself. `help` differs only in the $0 invocation path, as expected." + "evidence": "0 code lines from the 705-line original missing from the split." }, { - "command": "Flat-layout test: entry script + libs copied into one directory (the deployed shape)", + "command": "flat-layout simulation in a scratch dir (entry + 4 libs, no lib/ subdir)", "result": "pass", - "evidence": "status runs correctly with exit 0, confirming the _CCO_LIB_DIR fallback branch works in the /usr/local/bin layout, not just the repo tree." + "evidence": "This is the shape install_all writes to /usr/local/bin. `help` and `status` both exit 0 from the flat layout. Removing one lib produces the fail-closed error naming the missing file and the re-install command, rather than a partial load." }, { - "command": "Missing-lib guard: delete cco_report.sh from the flat layout and re-run", + "command": "wrapper path against an unknown app, with HOME/XDG_STATE_HOME pointed at a tmpdir", "result": "pass", - "evidence": "Fails closed with 'Error: missing library cco_report.sh in ' and exit code 1 (measured directly, not through a pipeline). A partially-deployed install cannot look successful to the pacman hook." + "evidence": "Rejects cleanly. The `APPS[$app]: unbound variable` message it emits was confirmed PRE-EXISTING by running the identical command against the pre-split original from git — byte-identical output, so not a regression introduced here. The real ~/.local/state/compulsive-block was confirmed untouched afterwards." }, { - "command": "shellcheck (no -x) + shfmt idempotence + bash -n on all five files", + "command": "lib/tests/run_all.sh", "result": "pass", - "evidence": "shellcheck rc=0 with no suppressions; shfmt -w is a no-op on all five, so the pre-commit auto-format-and-re-stage cannot alter them; all five parse." + "evidence": "21 assertions for cco_state, 0 failures (50 across the whole directory including the leechblock suites). Covers the allow/block decision in both directions, including the case that matters most: a PREVIOUS hour's stamp must not keep blocking, or an app could never be reopened." + }, + { + "command": "bash meta/scripts/shell_coverage.sh cco_state.sh 0", + "result": "measured, below 100%", + "evidence": "MEASURED: cco_state.sh 62/66 lines = 93.94%. The only four uncovered lines are kill_app's pkill/pkill -9 pair, which must never execute in a test on this machine. cco_wrapper/cco_install/cco_report are 0%: every function in them moves system binaries, writes /etc/pacman.d/hooks or kills processes." + }, + { + "command": "shellcheck (no -x) + shfmt -w idempotence on all 8 files", + "result": "pass", + "evidence": "shellcheck rc=0, no suppressions added. shfmt is a no-op on every file. The hyphenated associative-array keys (signal-desktop, and the APPS/REAL_BINARIES/APP_TIMEOUT_MINUTES/PROCESS_MATCH maps) were already quoted and the corruption canary is clean." } ], "risks": [ - "The deployed copy at /usr/local/bin/block-compulsive-opening.sh is still the pre-split monolith and keeps working untouched; nothing breaks until someone runs `sudo block-compulsive-opening.sh install`, which now deploys the libs too. Verified that the pacman hook's `rewrap-quiet` path does not call install_all, so the hook cannot trigger a partial deploy.", - "The libs deploy flat into /usr/local/bin rather than into /usr/local/share/digital_wellbeing/lib. That is deliberate: install_leechblock_payload does an `rm -rf` mirror of the latter, which would delete these libs on the next pacman-wrapper install.", - "No test suite exists for this script, so coverage is 0% and the verification above is behavioural and textual rather than test-driven." + "The deployed /usr/local/bin copy is NOT updated by this commit. Until `sudo block_compulsive_opening.sh install` is re-run, the deployed 705-line monolith keeps running — which is safe, because it is self-contained and unchanged. The dangerous ordering would be deploying the new entry script without its libs; the fail-closed guard turns that into a loud error rather than a silently disabled blocker.", + "install_all is not executed by any test (it moves /usr/bin binaries), so its new lib-copy block is verified by the flat-layout simulation rather than by running it. The simulation reproduces exactly what it writes.", + "A pre-existing quirk was confirmed rather than fixed: `wrapper ` prints a raw `APPS[$app]: unbound variable` before its clean error. Identical pre-split; out of scope for a behaviour-preserving split." ], "rollback": [ - "git revert this commit; block_compulsive_opening.sh returns to a single 705-line file and lib/cco_*.sh disappear.", - "If the split had already been deployed, re-run `sudo block-compulsive-opening.sh install` from the reverted tree to restore the monolith and remove the deployed libs.", - "Validate with: block-compulsive-opening.sh status (expect exit 0 and the per-app status table)." + "git revert the commit; block_compulsive_opening.sh returns to a single 705-line file and lib/cco_*.sh disappear.", + "The deployed copy needs no rollback — this commit never touched it.", + "If the split HAD been deployed before rolling back, re-run `sudo block_compulsive_opening.sh install` from the reverted tree and then confirm `ls /usr/local/bin/cco_*.sh` returns nothing and a wrapped app still launches." ] } diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/tests/cco_harness.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/tests/cco_harness.sh new file mode 100755 index 00000000..96c18c86 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/tests/cco_harness.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# lib/tests/cco_harness.sh — shared fixture for the block_compulsive_opening.sh split. +# +# Sourced, not executed. Scope: the hour-key/state logic that decides whether an +# app launch is allowed. Everything that moves system binaries (install_wrapper, +# uninstall_wrapper, install_all) or kills processes (kill_app, launch_with_timer) +# is NOT executed here — this machine's beeper/signal/discord wrappers and a live +# pacman hook depend on those paths, and a test that ran them would rewrite them. +# +# STATE_DIR and LOG_FILE were already variables in the pre-split script, so the +# tests point them at a tmpdir and nothing touches ~/.local/state. +set -euo pipefail + +PASS=0 +FAIL=0 + +_t_pass() { + PASS=$((PASS + 1)) + printf ' OK: %s\n' "$1" +} + +_t_fail() { + FAIL=$((FAIL + 1)) + printf ' FAIL: %s\n' "$1" +} + +_t_eq() { + local want="$1" + local got="$2" + local what="$3" + if [[ $got == "$want" ]]; then + _t_pass "$what" + else + _t_fail "$what (want '${want}', got '${got}')" + fi +} + +_t_has() { + local haystack="$1" + local needle="$2" + local what="$3" + if [[ $haystack == *"$needle"* ]]; then + _t_pass "$what" + else + _t_fail "$what (want a substring '${needle}')" + fi +} + +# These helpers return 0/1 to mean allowed/blocked, and which way they fail is +# the whole point — assert on the status explicitly rather than letting `set -e` +# end the run. Called via `if` so a non-zero return does not abort the suite. +_t_true() { + local what="$2" + if ("$1"); then + _t_pass "$what" + else + _t_fail "$what (expected success, got non-zero)" + fi +} + +_t_false() { + local what="$2" + if ("$1"); then + _t_fail "$what (expected non-zero, got success)" + else + _t_pass "$what" + fi +} + +CCO_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export CCO_LIB_DIR + +# Stand in for the globals the entry script defines before sourcing the libs. +_t_cco_setup() { + TEST_TMPDIR="$(mktemp -d)" + export TEST_TMPDIR + + STATE_DIR="$TEST_TMPDIR/state" + LOG_FILE="$STATE_DIR/compulsive-block.log" + mkdir -p "$STATE_DIR" + + AUTO_CLOSE_TIMEOUT_MINUTES=10 + AUTO_CLOSE_WARNING_MINUTES=2 + + declare -gA APP_TIMEOUT_MINUTES=(["beeper"]=20 ["signal-desktop"]=20) + declare -gA APPS=( + ["beeper"]="$TEST_TMPDIR/bin/beeper" + ["signal-desktop"]="$TEST_TMPDIR/bin/signal-desktop" + ["discord"]="$TEST_TMPDIR/bin/discord" + ) + declare -gA REAL_BINARIES=( + ["beeper"]="$TEST_TMPDIR/real/beepertexts" + ["signal-desktop"]="$TEST_TMPDIR/real/signal-desktop" + ["discord"]="$TEST_TMPDIR/bin/discord.orig" + ) + declare -gA PROCESS_MATCH=(["discord"]="$TEST_TMPDIR/home/.config/discord/") + + mkdir -p "$TEST_TMPDIR/bin" "$TEST_TMPDIR/real" + + # notify() is defined in the entry script, which the tests never source. + notify() { printf 'NOTIFY: %s | %s\n' "$1" "$2" >>"$TEST_TMPDIR/notifications"; } + : >"$TEST_TMPDIR/notifications" + + _t_cco_assert_fixture_complete +} + +# Every global above is read by the sourced lib, never by this file, so the +# linter reports each one as unused (SC2034) and notify() as uncalled +# (SC2329) — it cannot see across the `source` boundary the tests set up. +# Referencing them here is not linter appeasement: it is a real fixture check. +# A typo in one of these names would otherwise surface as a confusing unbound +# variable from deep inside the code under test, or worse, as a silently +# skipped assertion. +_t_cco_assert_fixture_complete() { + local missing="" + [[ -n $LOG_FILE ]] || missing+=" LOG_FILE" + [[ -n $AUTO_CLOSE_TIMEOUT_MINUTES ]] || missing+=" AUTO_CLOSE_TIMEOUT_MINUTES" + [[ -n $AUTO_CLOSE_WARNING_MINUTES ]] || missing+=" AUTO_CLOSE_WARNING_MINUTES" + [[ ${#APPS[@]} -eq 3 ]] || missing+=" APPS" + [[ ${#REAL_BINARIES[@]} -eq 3 ]] || missing+=" REAL_BINARIES" + [[ ${#APP_TIMEOUT_MINUTES[@]} -eq 2 ]] || missing+=" APP_TIMEOUT_MINUTES" + [[ ${#PROCESS_MATCH[@]} -eq 1 ]] || missing+=" PROCESS_MATCH" + + # Call notify() for real rather than probing it with `type -t`: block_app + # routes every blocked launch through it, so a fixture whose notify is + # broken would turn the notification assertions into silent no-ops. The + # probe file is truncated afterwards so this call is not mistaken for one + # made by the code under test. + notify "fixture" "self-check" + [[ -s "$TEST_TMPDIR/notifications" ]] || missing+=" notify" + : >"$TEST_TMPDIR/notifications" + + if [[ -n $missing ]]; then + printf 'harness fixture incomplete:%s\n' "$missing" >&2 + exit 1 + fi +} + +_t_cco_teardown() { + [[ -n ${TEST_TMPDIR:-} && -d ${TEST_TMPDIR} ]] && rm -rf "$TEST_TMPDIR" +} + +_t_report() { + printf '\n%s: %d passed, %d failed\n' "${1:-tests}" "$PASS" "$FAIL" + [[ $FAIL -eq 0 ]] +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/tests/run_all.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/tests/run_all.sh index 40cd546e..1d093363 100755 --- a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/tests/run_all.sh +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/tests/run_all.sh @@ -1,12 +1,17 @@ #!/usr/bin/env bash -# Runs every leechblock lib test. Used directly as the coverage subject: +# Runs every digital_wellbeing lib test. Used directly as the coverage subject: # meta/scripts/shell_coverage.sh wants the test script itself, not `bash `. +# +# The glob is test_*.sh, NOT one prefix per split: this directory holds suites +# for several entry scripts (leechblock, block_compulsive_opening, ...), and a +# prefix glob silently skips every suite added after it — including in the CI +# discovery step, which runs this file. set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" rc=0 -for t in "$HERE"/test_leechblock_*.sh; do +for t in "$HERE"/test_*.sh; do printf '\n=== %s ===\n' "$(basename "$t")" # Each suite exits non-zero on failure; keep going so one red suite does not # hide the state of the others, then fail the run as a whole. diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/tests/test_cco_state.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/tests/test_cco_state.sh new file mode 100755 index 00000000..4db93260 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/tests/test_cco_state.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Covers lib/cco_state.sh — the logic that decides whether a launch is allowed. +# +# This is the whole point of the tool: was_opened_this_hour deciding wrong in +# either direction is the failure that matters. Too permissive and the blocker +# does nothing; too strict and a legitimately-closed app can never be reopened. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=cco_harness.sh +. "$HERE/cco_harness.sh" +# shellcheck source=../cco_state.sh +. "$CCO_LIB_DIR/cco_state.sh" + +_t_cco_setup +trap _t_cco_teardown EXIT + +printf 'get_hour_key: a stable YYYY-MM-DD-HH stamp\n' +hour="$(get_hour_key)" +if [[ $hour =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{2}$ ]]; then + _t_pass "the hour key is YYYY-MM-DD-HH" +else + _t_fail "the hour key is YYYY-MM-DD-HH (got '$hour')" +fi +_t_eq "$hour" "$(get_hour_key)" "two calls in the same hour agree" + +printf '\nget_state_file: one file per app, under STATE_DIR\n' +_t_eq "$STATE_DIR/discord.lastopen" "$(get_state_file discord)" "the state path is derived from the app name" +_t_eq "$STATE_DIR/discord.running" "$(get_running_file discord)" "the running-state path is separate from the lastopen path" + +printf '\nwas_opened_this_hour: the core allow/block decision\n' +rm -f "$STATE_DIR/discord.lastopen" +if was_opened_this_hour discord; then + _t_fail "a fresh app is not yet opened this hour" +else + _t_pass "a fresh app is not yet opened this hour" +fi + +record_opening discord >/dev/null 2>&1 +if was_opened_this_hour discord; then + _t_pass "after record_opening the app counts as opened" +else + _t_fail "after record_opening the app counts as opened" +fi + +printf '\nwas_opened_this_hour: a stale hour must NOT keep blocking\n' +# The bug that would matter most: if a previous hour's stamp still blocked, an +# app could never be reopened. Write a deliberately old stamp. +echo "2000-01-01-00" >"$STATE_DIR/discord.lastopen" +if was_opened_this_hour discord; then + _t_fail "a previous hour's stamp must not block the current hour" +else + _t_pass "a previous hour's stamp does not block the current hour" +fi + +printf '\nwas_opened_this_hour: an unreadable/empty stamp allows the launch\n' +: >"$STATE_DIR/discord.lastopen" +if was_opened_this_hour discord; then + _t_fail "an empty state file must not block" +else + _t_pass "an empty state file does not block" +fi + +printf '\nrecord_opening: writes exactly the current hour\n' +rm -f "$STATE_DIR/beeper.lastopen" +record_opening beeper >/dev/null 2>&1 +_t_eq "$(get_hour_key)" "$(cat "$STATE_DIR/beeper.lastopen")" "the stamp written is the current hour key" + +printf '\nblock_app: notifies the user\n' +: >"$TEST_TMPDIR/notifications" +block_app discord >/dev/null 2>&1 +_t_has "$(cat "$TEST_TMPDIR/notifications")" "Blocked" "blocking sends a desktop notification" + +printf '\nis_autoclose_suspended: only todays marker counts\n' +rm -f "$STATE_DIR/discord.suspend-autoclose" +if is_autoclose_suspended discord; then + _t_fail "no marker means not suspended" +else + _t_pass "no marker means not suspended" +fi + +printf '%(%Y-%m-%d)T' -1 >"$STATE_DIR/discord.suspend-autoclose" +if is_autoclose_suspended discord; then + _t_pass "todays marker suspends auto-close" +else + _t_fail "todays marker suspends auto-close" +fi + +echo "1999-12-31" >"$STATE_DIR/discord.suspend-autoclose" +if is_autoclose_suspended discord; then + _t_fail "a stale marker must not suspend auto-close" +else + _t_pass "a stale marker does not suspend auto-close" +fi +if [[ -f "$STATE_DIR/discord.suspend-autoclose" ]]; then + _t_fail "a stale marker is cleaned up on detection" +else + _t_pass "a stale marker is cleaned up on detection" +fi + +printf '\nget_real_binary: only reports a binary once the wrapper is installed\n' +rm -f "$TEST_TMPDIR/bin/beeper.orig" +if get_real_binary beeper >/dev/null 2>&1; then + _t_fail "an unwrapped app reports no real binary" +else + _t_pass "an unwrapped app reports no real binary" +fi + +: >"$TEST_TMPDIR/bin/beeper.orig" +_t_eq "$TEST_TMPDIR/real/beepertexts" "$(get_real_binary beeper)" "a wrapped app resolves to its real binary" + +printf '\nlog_message: appends to the log and echoes to stderr\n' +: >"$LOG_FILE" +log_message "hello from the test" 2>/dev/null +_t_has "$(cat "$LOG_FILE")" "hello from the test" "the message reaches the log file" + +printf '\nensure_state_dir: creates STATE_DIR when it is missing\n' +rm -rf "$STATE_DIR" +ensure_state_dir +if [[ -d $STATE_DIR ]]; then + _t_pass "a missing state directory is created" +else + _t_fail "a missing state directory is created" +fi +# Idempotent: the real script calls it on every wrapper launch. +ensure_state_dir +_t_pass "calling it again on an existing directory is harmless" + +printf '\ncleanup_stale_running_state: clears state when nothing is running\n' +running="$(get_running_file discord)" +echo "999999 123" >"$running" +# PROCESS_MATCH points at a path no process can match, so this is the +# "app already exited" case. +cleanup_stale_running_state discord 2>/dev/null +if [[ -f $running ]]; then + _t_fail "a running-state file for a dead process is removed" +else + _t_pass "a running-state file for a dead process is removed" +fi + +printf '\ncleanup_stale_running_state: no running file is a no-op\n' +# The early return. It must not fall through to the pgrep path, which on a +# machine where the app IS running would otherwise be consulted for nothing. +rm -f "$running" +if (cleanup_stale_running_state discord); then + _t_pass "a missing running-state file returns success without probing" +else + _t_fail "a missing running-state file returns success without probing" +fi + +_t_report "test_cco_state"