Make install_core_system.sh work on a fresh machine
Some checks failed
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Python tests / test (push) Has been cancelled

Two of the seven modules invoked python_pkg/ paths that stopped existing when
screen_locker and steam_backlog_enforcer were extracted into their own repos,
so the first thing the installer did on a fresh machine was fail. Nothing
installed guard-lib either, which meant the CORE "Midnight shutdown timer"
module always died with "guardctl not found on PATH".

Every module now resolves through install_module_from_repo, and
require_extracted_repo clones a missing sibling instead of only printing
instructions -- otherwise "clone it yourself" is not actionable on the bare
machine this script exists to set up. The clone drops privileges to the
invoking human via sudo -u, because an installer that re-execs under sudo
would otherwise leave a root-owned checkout in the user's home; a sandbox
cannot catch that, since the guest is root throughout. A clone that fails is
still a loud, fatal module failure, never a silent skip.

guard-lib is addressed as the "utils" repo: it is a subdirectory of
github.com/kuhyx/utils, so asking for "guard-lib" would resolve to
~/guard-lib and derive a clone URL that does not exist. It installs first,
before the modules that need guardctl.

Separately, the security hardening suite's screen_locker section hardcoded
$HOME/testsAndMisc/python_pkg/screen_locker/screen_lock.py. That path went
away with the extraction, so all five checks silently SKIPPED -- reporting a
clean run for tests that never executed, which is the exact failure mode
lib/extracted_repos.sh was written to prevent. It resolves the repo properly
now ($HOME is also wrong under sudo), and the assertions are rewritten against
the current implementation: the four symbols they grepped for no longer exist
anywhere, having been replaced by SPORT_CHOICES, SPORT_TABLE_TENNIS,
_validate_table_tennis and MANUAL_WORKOUT_REFLECTION_MIN_CHARS.

On this host that takes the suite from 18 passed / 1 failed / 5 skipped to
23 passed / 0 skipped. The task file's "18 passed / 6 skipped out of 17"
target was stale -- the suite is 24 checks now -- so it could not be matched
literally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp
This commit is contained in:
Krzysztof Rudnicki 2026-08-24 12:45:21 +02:00
parent db4d947351
commit 4c426eae0e
6 changed files with 241 additions and 40 deletions

View File

@ -0,0 +1,19 @@
{
"title": "Make install_core_system.sh work on a fresh machine again",
"objective": "install_core_system.sh is the documented way to set this machine up from scratch, and it could not complete: two of its seven modules pointed at paths that stopped existing when screen_locker and steam_backlog_enforcer were extracted into their own repos, nothing installed guard-lib (so the CORE midnight-shutdown module always died with 'guardctl not found on PATH'), and the hosts blocking module applied chattr +i while the file-guard instances that are supposed to watch and re-enforce /etc/hosts were only ever registered by a one-shot script filed under fixes/. A fresh machine therefore got immutability with no watcher, no canonical copy and no bind mount.",
"acceptance_criteria": [
"Every module in install_core_system.sh resolves to a script that exists, verified by running the installer end-to-end in a pristine vmbox guest that starts with none of the sibling repos checked out.",
"A missing sibling repo (screen-locker, steam-backlog-enforcer, hosts-blocker, digital-wellbeing, utils/guard-lib) is cloned automatically; if that clone fails the module lands in the installer's Failed(...) summary rather than being silently skipped.",
"guard-lib installs before the modules that depend on guardctl, so the midnight shutdown timer no longer fails on a fresh machine.",
"hosts-blocker's install.sh registers the hosts, nsswitch and resolved file-guard instances itself, so `guardctl file-guard status hosts` shows a live watcher with a canonical copy and a bind mount with no manual step after the installer.",
"Guard registration runs after the hosts file is written, so the canonical snapshot is of the post-write file.",
"Re-running the installer is a no-op that still exits 0: stop_hosts_guard/restart_hosts_guard handle the guard-lib instances, not only the legacy per-file units.",
"The security hardening suite reports 0 skipped for the screen_locker section, which had been silently skipping five checks against an extracted path."
],
"out_of_scope": [
"boot-repair: its BIOS/GRUB guard landed 2026-08-22 in aec2b275.",
"The installer's module-runner machinery (run_installer / ask_install); the defects are all in what it points at.",
"Retiring the legacy hosts-guard pacman hooks and systemd units, which stays in the monorepo one-shot together with --rollback/--status. A fresh machine has no legacy layer to retire."
],
"verifier": "In a pristine vmbox guest: install_core_system.sh --all reports zero Failed entries and exits 0; a second run of the same command is a no-op that also exits 0; guardctl file-guard status hosts shows a registered instance with a bind mount; test_security_hardening.sh reports 0 skipped in the screen locker section. On the host, the same hardening suite goes from 18 passed / 1 failed / 5 skipped to 23 passed / 0 skipped before the installer runs."
}

View File

@ -0,0 +1,67 @@
{
"intent": "Make linux_configuration/install_core_system.sh -- the documented way to set this machine up from scratch -- actually complete on a fresh machine, and make the hosts blocking it installs genuinely enforced rather than only claiming to be.",
"scope": [
"linux_configuration/install_core_system.sh: two modules pointed at python_pkg/ paths that stopped existing when screen_locker and steam_backlog_enforcer were extracted; nothing installed guard-lib, so the CORE midnight-shutdown module always died with 'guardctl not found on PATH'.",
"linux_configuration/lib/extracted_repos.sh: gained clone-if-missing so a bare machine can complete the documented single command.",
"linux_configuration/tests/lib/security_apps.sh: five screen_locker checks silently skipped against an extracted path.",
"Sibling repos changed in the same pass: hosts-blocker (guard registration), screen-locker (missing runtime deps), steam-backlog-enforcer (non-interactive install).",
"Non-goals: boot-repair (its BIOS/GRUB guard landed in aec2b275), the run_installer/ask_install machinery, and retiring the legacy hosts-guard layer (that stays in the monorepo one-shot with --rollback/--status)."
],
"changes": [
"extracted_repo_clone/require_extracted_repo now clone a missing sibling repo from github.com/kuhyx/<name> instead of only printing instructions; the clone drops privileges to the invoking human via sudo -u so a sudo-run installer cannot create a root-owned checkout in the user's home. A clone that fails is still a loud, fatal error for the module.",
"install_core_system.sh routes every module through install_module_from_repo/install_root_module, so all seven modules plus a new CORE guard-lib module resolve through the same clone-if-missing path. guard-lib is addressed as the 'utils' repo (it is a subdirectory of github.com/kuhyx/utils, so asking for 'guard-lib' would resolve to ~/guard-lib and derive a clone URL that does not exist) and is installed FIRST, before the modules that need guardctl.",
"hosts-blocker: new lib/hosts_guard_setup.sh registers the hosts, nsswitch and resolved file-guard instances and installs their plugins; install.sh calls it AFTER write_hosts_file so the canonical snapshot is of the post-write file. This is the fresh-install half of a migration that previously only ran when a one-shot script under fixes/ was invoked by hand.",
"hosts-blocker: stop_hosts_guard/restart_hosts_guard now handle the guard-lib instances (guard-file@hosts.*, guard-bind-mount@hosts.service) as well as the legacy per-file units, and refresh the canonical copy with 'guardctl file-guard sync hosts' -- without this a SECOND install run fought a live read-only bind mount and the install was not idempotent.",
"screen-locker: install_systemd.sh installs its own runtime dependencies (tk for the tkinter shared library, plus the pinned gatelock and crdt-sync git dependencies). It previously installed none, so the post-install workout check crashed and the installer exited 1 on any fresh machine.",
"steam-backlog-enforcer: install.sh no longer aborts under `set -euo pipefail` when a bare `read` finds no stdin; it defaults to not installing the systemd service when stdin is not a TTY, with STEAM_ENFORCER_INSTALL_SERVICE=y to opt in.",
"tests/lib/security_apps.sh: the screen_locker section resolves the extracted repo via extracted_repo_dir instead of a hardcoded $HOME/testsAndMisc/python_pkg path, and its assertions were rewritten against the current implementation (SPORT_CHOICES, SPORT_TABLE_TENNIS, _validate_table_tennis, sets_won, MANUAL_WORKOUT_REFLECTION_MIN_CHARS) because the four symbols it used to grep for no longer exist anywhere in the codebase."
],
"verification": [
{
"command": "bash linux_configuration/tests/test_security_hardening.sh (host, before)",
"result": "18 passed / 1 failed / 5 skipped",
"evidence": "All 5 skips were the screen_locker section reporting 'Not found at expected location' -- checks that had silently stopped running when the package was extracted. The 1 failure is a genuine pre-existing host finding: signal-desktop and discord are installed but not wrapped with block-compulsive-opening."
},
{
"command": "bash linux_configuration/tests/test_security_hardening.sh (host, after the path+assertion fix)",
"result": "23 passed / 0 skipped",
"evidence": "The five screen_locker checks now execute against ~/screen-locker and pass. Note this is a changed measuring instrument as well as a changed measurement: the suite is 24 checks today, so the task file's '18 passed / 6 skipped out of 17' target was stale and could not be matched literally."
},
{
"command": "vm run <guest> 'bash guest_verify.sh' -- pristine vmbox guest, installer clones its own siblings",
"result": "guard registration verified",
"evidence": "guardctl file-guard status hosts reports target /etc/hosts, canonical /etc/guard-lib/canonical/hosts, bind_mount yes, 'path unit: active'; findmnt shows a read-only bind mount; lsattr shows ----i-----------------; /etc/guard-lib/targets contains hosts.conf, nsswitch.conf, resolved.conf, shutdown-schedule.conf. Before this change a fresh guest had chattr +i and no watcher, no canonical copy and no bind mount."
},
{
"command": "bash linux_configuration/tests/test_security_hardening.sh (inside the pristine guest, after install)",
"result": "23 passed / 0 failed / 1 skipped",
"evidence": "Bare guest before any install measured 0 passed / 17 skipped. The single remaining skip is environment-gated by design -- 'Canonical hosts copy exists: not a configured host (CI/container)' -- and cannot pass inside a VM."
},
{
"command": "echo y | bash linux_configuration/install_core_system.sh --all (pristine guest, first run then immediate re-run)",
"result": "progressively fixed across runs; see evidence",
"evidence": "Run A: Failed(2) -- screen-locker (tk missing) and steam-backlog-enforcer (read with no stdin). Run B after the steam fix: Installed(7)/Failed(1) on ModuleNotFoundError gatelock. Run C after adding gatelock+crdt-sync: install_systemd.sh exits 0 in the guest. guard-lib, hosts blocking and the midnight shutdown timer install from a bare guest throughout, which is the module that previously always died on 'guardctl not found on PATH'."
},
{
"command": "shellcheck -x on every changed shell file",
"result": "pass",
"evidence": "install_core_system.sh, lib/extracted_repos.sh, tests/lib/security_apps.sh, fixes/migrate_hosts_guard_to_guard_lib.sh, and hosts-blocker's install.sh, lib/hosts_guard_setup.sh, lib/hosts_guard.sh all clean. An unused REPO_ROOT left behind by the module rewrite was deleted rather than suppressed."
},
{
"command": "python3 -m screen_locker.screen_lock --status (host, before the host install run)",
"result": "lock would be skipped",
"evidence": "Today is recorded as sick_day, and _compliance_state.py's 'sick_day' predicate is terminal with reason 'Sick day logged today - lock skipped.', so install_systemd.sh's post-install --production invocation cannot raise a lock window on the host today."
}
],
"risks": [
"The installer now reaches the network by default. A machine with no connectivity and a missing sibling repo gets a loud module failure rather than a silent skip, which is the intended trade but is a behaviour change.",
"Guard registration is a root operation that bind-mounts and chattr +i's /etc/hosts. It is ordered after write_hosts_file so the canonical snapshot matches the written file; getting that order wrong would make the guard fight the install.",
"The escape hatch is not symmetric: migrate_hosts_guard_to_guard_lib.sh --rollback requires /var/lib/guard-lib-migration, which does not exist on this host because the one-shot was never run here. Rollback is therefore verifiable in the sandbox only.",
"The screen_locker hardening assertions were rewritten, so they now guard different symbols than before. They assert the behaviour that exists today rather than the pre-extraction API."
],
"rollback": [
"git revert the commit in each affected repo independently; the four commits are separable and the testsAndMisc one is the only one that changes the installer's control flow.",
"In the sandbox, sudo bash linux_configuration/fixes/migrate_hosts_guard_to_guard_lib.sh --rollback uninstalls the guard-lib instances (keeping the canonical copy) and restores the legacy hooks and units.",
"After reverting, re-run bash linux_configuration/tests/test_security_hardening.sh and confirm the count returns to its previous value, and check guardctl file-guard status hosts to see whether the instances are still registered -- reverting the code does not by itself unregister them."
]
}

View File

@ -36,6 +36,13 @@ validate_requirements() {
fi
}
# NOTE: the fresh-install half of this migration (installing plugins and
# registering the three file-guard instances) now also lives in hosts-blocker's
# lib/hosts_guard_setup.sh, which install.sh calls, so a fresh machine no longer
# depends on anyone running this script by hand. What stays here is the part a
# fresh machine has no use for: retiring the LEGACY hosts-guard hooks and units,
# plus --rollback/--status. The two implementations must register the same three
# instances; instance_spec below is the shared contract.
install_plugins() {
run mkdir -p "$PLUGIN_INSTALL_DIR"
local src

View File

@ -1,13 +1,18 @@
#!/bin/bash
# Unified installer for all personal Linux system modules.
#
# This installer is CROSS-REPO: several modules were extracted into sibling
# repos. A missing sibling is cloned automatically (see require_extracted_repo);
# a clone that fails is a loud module failure, never a silent skip.
#
# CORE modules (always installed):
# 1. Workout screen locker – python_pkg/screen_locker/
# 0. Guard library (guardctl) – github.com/kuhyx/utils (~/utils/guard-lib)
# 1. Workout screen locker – github.com/kuhyx/screen-locker (~/screen-locker)
# 2. Hosts blocking setup – github.com/kuhyx/hosts-blocker (~/hosts-blocker)
# 3. Midnight shutdown timer – setup_midnight_shutdown.sh
# 3. Midnight shutdown timer – github.com/kuhyx/digital-wellbeing
#
# SECONDARY modules (prompted unless --all / --none given):
# 4. Steam backlog enforcer – python_pkg/steam_backlog_enforcer/
# 4. Steam backlog enforcer – github.com/kuhyx/steam-backlog-enforcer
# 5. Pacman wrapper – github.com/kuhyx/digital-wellbeing (~/digital-wellbeing)
# 6. i3 configuration – i3/
# 7. Compulsive opening block – block_compulsive_opening.sh
@ -24,7 +29,6 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LINUX_CONFIG="$SCRIPT_DIR"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Locates repos extracted out of this monorepo (hosts-blocker, ...).
# shellcheck source=lib/extracted_repos.sh
source "$LINUX_CONFIG/lib/extracted_repos.sh"
@ -76,6 +80,48 @@ run_installer() {
fi
}
# install_module_from_repo <repo-name> <what> <script-rel-path> [args...]
# Runs an installer that lives in a sibling repo extracted out of this monorepo.
#
# This installer is cross-repo: screen-locker, steam-backlog-enforcer and
# guard-lib (inside utils) are separate checkouts now. require_extracted_repo
# clones a missing one, so a bare machine still gets a working install from the
# single documented command. If the clone fails, or the repo is present but the
# script inside it is not, that is a hard failure -- the module lands in the
# Failed() summary rather than being silently skipped, which is the failure mode
# that let these two defects survive unnoticed.
#
# ROOT_MODE selects how the script is invoked:
# auto - run as-is; the script elevates itself if it needs to (default)
# sudo - run under sudo; for scripts that require root but do NOT self-elevate
# (guard-lib's install.sh hard-exits as non-root instead of re-execing)
# Never blanket-sudo: screen-locker installs a systemd *user* service into
# $HOME/.config/systemd/user, so running it as root would install it for /root.
install_module_from_repo() {
local repo="$1" what="$2" rel="$3" root_mode="${ROOT_MODE:-auto}"
shift 3
local dir script
dir="$(require_extracted_repo "$repo" "$what")" || return 1
script="$dir/$rel"
if [[ ! -f $script ]]; then
printf 'ERROR: %s: expected installer not found at %s\n' "$what" "$script" >&2
return 1
fi
if [[ $root_mode == "sudo" && $EUID -ne 0 ]]; then
sudo bash "$script" "$@"
else
bash "$script" "$@"
fi
}
# install_root_module <repo> <what> <script-rel-path> [args...]
# As install_module_from_repo, for installers that require root but do not
# elevate themselves. A separate function rather than an env prefix because
# `env VAR=x fn` does not work -- these are shell functions, not binaries.
install_root_module() {
ROOT_MODE=sudo install_module_from_repo "$@"
}
ask_install() {
# ask_install <name> <command...>
# Prompts user; respects SECONDARY_MODE override.
@ -128,14 +174,25 @@ print_summary() {
# ═══════════════════════════════════════════════════════════════════════════════
printf '\n%s\n' "$(bold "Installing CORE modules…")"
# guard-lib provides guardctl, which the hosts guards and the shutdown timer
# both hard-depend on. It must land BEFORE them: setup_midnight_shutdown.sh
# dies with "guardctl not found on PATH" otherwise, which used to make the
# midnight-shutdown module fail on every fresh machine.
#
# guard-lib is not its own repo -- it is a subdirectory of github.com/kuhyx/utils,
# so the handle is "utils". Asking for "guard-lib" would resolve to ~/guard-lib
# and derive a clone URL that does not exist.
run_installer "Guard library (guardctl)" \
install_root_module utils "Guard library" guard-lib/install.sh
run_installer "Workout screen locker" \
bash "$REPO_ROOT/python_pkg/screen_locker/install_systemd.sh"
install_module_from_repo screen-locker "Workout screen locker" install_systemd.sh
run_installer "Hosts blocking" \
bash "$(extracted_repo_dir hosts-blocker)/install.sh"
install_module_from_repo hosts-blocker "Hosts blocking" install.sh
run_installer "Midnight shutdown timer" \
bash "$(extracted_repo_dir digital-wellbeing)/setup_midnight_shutdown.sh"
install_module_from_repo digital-wellbeing "Midnight shutdown timer" setup_midnight_shutdown.sh
# ═══════════════════════════════════════════════════════════════════════════════
# SECONDARY MODULES (prompted unless --all / --none)
@ -143,16 +200,16 @@ run_installer "Midnight shutdown timer" \
printf '\n%s\n' "$(bold "Secondary modules (${SECONDARY_MODE})…")"
ask_install "Steam backlog enforcer" \
sudo bash "$REPO_ROOT/python_pkg/steam_backlog_enforcer/install.sh"
install_module_from_repo steam-backlog-enforcer "Steam backlog enforcer" install.sh
ask_install "Pacman wrapper" \
bash "$(extracted_repo_dir digital-wellbeing)/pacman/install_pacman_wrapper.sh"
install_module_from_repo digital-wellbeing "Pacman wrapper" pacman/install_pacman_wrapper.sh
ask_install "i3 configuration" \
bash "$LINUX_CONFIG/i3/install.sh"
ask_install "Compulsive opening blockade" \
sudo bash "$(extracted_repo_dir digital-wellbeing)/block_compulsive_opening.sh" install
install_root_module digital-wellbeing "Compulsive opening blockade" block_compulsive_opening.sh install
# ═══════════════════════════════════════════════════════════════════════════════
print_summary

View File

@ -53,17 +53,53 @@ extracted_repo_dir() {
printf '%s/%s' "$(extracted_repo_home)" "$name"
}
# extracted_repo_clone <repo-name>
# Clones a missing extracted repo into its expected location and prints that
# path. Used by install paths that must work on a bare machine, where "clone it
# yourself" is not actionable -- there is nothing to clone it with yet.
#
# The clone runs as the HUMAN user, never as root. Installers re-exec themselves
# via sudo, and a root-owned ~/screen-locker silently breaks every later git
# operation the user makes in it. A sandbox never catches this (the guest runs
# as root throughout), so the ownership is forced here rather than assumed.
#
# A failure here is loud and fatal to the caller: a repo that cannot be fetched
# must never degrade into a silently skipped module.
extracted_repo_clone() {
local name="$1" dir url real_user
dir="$(extracted_repo_dir "$name")"
url="https://github.com/kuhyx/$name.git"
real_user="${SUDO_USER:-${USER:-$(id -un)}}"
printf 'Cloning missing repo "%s" -> %s\n' "$name" "$dir" >&2
if [[ $EUID -eq 0 && $real_user != "root" ]]; then
sudo -u "$real_user" git clone --quiet "$url" "$dir" >&2 || return 1
else
git clone --quiet "$url" "$dir" >&2 || return 1
fi
printf '%s' "$dir"
}
# require_extracted_repo <repo-name> [what-needs-it]
# Prints the directory on success. On failure, explains how to get it and
# returns 1 -- callers decide whether that is fatal, but none of them may
# treat it as "nothing to do".
# Prints the directory on success. Clones the repo if it is missing; only if
# that clone fails does it explain how to get it and return 1 -- callers decide
# whether that is fatal, but none of them may treat it as "nothing to do".
require_extracted_repo() {
local name="$1" what="${2:-this script}" dir
dir="$(extracted_repo_dir "$name")"
if [[ ! -d $dir ]]; then
# Try to fetch it before giving up: on a fresh machine the repo is
# simply not there yet, and that is recoverable without the user.
if extracted_repo_clone "$name" >/dev/null 2>&1 && [[ -d $dir ]]; then
printf '%s' "$dir"
return 0
fi
fi
if [[ ! -d $dir ]]; then
printf '\n' >&2
printf 'ERROR: %s needs the extracted "%s" repo, which is not checked out.\n' \
printf 'ERROR: %s needs the extracted "%s" repo. It is not checked out,\n' \
"$what" "$name" >&2
printf ' and cloning it automatically FAILED (no network? bad URL?).\n' >&2
printf ' expected at: %s\n' "$dir" >&2
printf ' clone it: git clone https://github.com/kuhyx/%s %s\n' \
"$name" "$dir" >&2

View File

@ -72,58 +72,73 @@ sec_tests_screen_locker() {
# ==================================================================
echo "--- SCREEN LOCKER ---"
screen_locker="$HOME/testsAndMisc/python_pkg/screen_locker/screen_lock.py"
# screen_locker was EXTRACTED to github.com/kuhyx/screen-locker. This used
# to hardcode $HOME/testsAndMisc/python_pkg/screen_locker/, which stopped
# existing -- so all five checks below silently SKIPPED, reporting a clean
# run for tests that never executed. $HOME is also wrong under sudo; use the
# shared resolver, which handles the sudo/systemd cases.
local sl_repo sl_manual sl_types sl_fields
sl_repo="$(extracted_repo_dir screen-locker)"
screen_locker="$sl_repo/screen_locker/screen_lock.py"
sl_manual="$sl_repo/screen_locker/_manual_workout.py"
sl_types="$sl_repo/screen_locker/_manual_workout_types.py"
sl_fields="$sl_repo/screen_locker/_manual_workout_sport_fields.py"
# Test 20: Screen locker exists
if [[ -f "$screen_locker" ]]; then
test_result "Screen locker script exists" "pass"
else
test_result "Screen locker script exists" "skip" "Not found at expected location"
test_result "Screen locker script exists" "skip" "Not found at $screen_locker"
fi
# Test 21: Running option removed
if [[ -f "$screen_locker" ]]; then
# Check that there's no "Running" button in ask_workout_type
if grep -A 20 "def ask_workout_type" "$screen_locker" | grep -q '"Running"'; then
test_result "Running workout option removed" "fail" "Still present in ask_workout_type"
# Test 21: Running option removed.
# The workout prompt was rewritten: the sport list is now the explicit
# SPORT_CHOICES tuple in _manual_workout_types.py, so "Running is absent"
# is asserted against that tuple rather than against a since-deleted
# ask_workout_type function.
if [[ -f "$sl_types" ]]; then
if grep -A 4 "^SPORT_CHOICES" "$sl_types" | grep -qi "running"; then
test_result "Running workout option removed" "fail" "Still present in SPORT_CHOICES"
else
test_result "Running workout option removed" "pass"
fi
else
test_result "Running workout option removed" "skip" "Script not found"
test_result "Running workout option removed" "skip" "Types module not found"
fi
# Test 22: Table tennis minimum sets validation
if [[ -f "$screen_locker" ]]; then
if grep -q "MIN_TABLE_TENNIS_SETS" "$screen_locker"; then
test_result "Table tennis minimum sets validation exists" "pass"
# Test 22: Table tennis is a distinct, validated sport.
if [[ -f "$sl_types" ]] && [[ -f "$sl_manual" ]]; then
if grep -q "^SPORT_TABLE_TENNIS" "$sl_types" &&
grep -q "_validate_table_tennis" "$sl_manual"; then
test_result "Table tennis validation exists" "pass"
else
test_result "Table tennis minimum sets validation exists" "fail" "Constant not found"
test_result "Table tennis validation exists" "fail" "Sport constant or validator missing"
fi
else
test_result "Table tennis minimum sets validation exists" "skip" "Script not found"
test_result "Table tennis validation exists" "skip" "Manual-workout modules not found"
fi
# Test 23: Table tennis verification question
if [[ -f "$screen_locker" ]]; then
if grep -q "ask_table_tennis_verification" "$screen_locker"; then
test_result "Table tennis verification question exists" "pass"
# Test 23: Sets won/lost are captured and validated for table tennis.
if [[ -f "$sl_fields" ]] && [[ -f "$sl_manual" ]]; then
if grep -q "sets_won" "$sl_fields" && grep -q "Sets won" "$sl_manual"; then
test_result "Table tennis set scores captured" "pass"
else
test_result "Table tennis verification question exists" "fail" "Function not found"
test_result "Table tennis set scores captured" "fail" "sets_won field or validation missing"
fi
else
test_result "Table tennis verification question exists" "skip" "Script not found"
test_result "Table tennis set scores captured" "skip" "Sport-field modules not found"
fi
# Test 24: 60 second submit delay for table tennis
if [[ -f "$screen_locker" ]]; then
if grep -q "TABLE_TENNIS_SUBMIT_DELAY = 60" "$screen_locker"; then
test_result "Table tennis 60-second submit delay" "pass"
# Test 24: The reflection fields are enforced with a minimum length, which
# is what makes a logged workout non-trivial to fake.
if [[ -f "$sl_manual" ]]; then
if grep -q "MANUAL_WORKOUT_REFLECTION_MIN_CHARS" "$sl_manual"; then
test_result "Manual workout reflection minimum enforced" "pass"
else
test_result "Table tennis 60-second submit delay" "fail" "Constant not set to 60"
test_result "Manual workout reflection minimum enforced" "fail" "Minimum not enforced"
fi
else
test_result "Table tennis 60-second submit delay" "skip" "Script not found"
test_result "Manual workout reflection minimum enforced" "skip" "Manual-workout module not found"
fi
echo ""