#!/usr/bin/env bash set -euo pipefail repoRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" selector="$repoRoot/pi4/files/usr/local/lib/stormux/select-speech-module" tempDir="$(mktemp -d)" mockBin="$tempDir/bin" testCount=0 cleanup() { rm -rf "$tempDir" } trap cleanup EXIT mkdir -p "$mockBin" "$tempDir/runtime" cat > "$mockBin/sudo" <<'EOF' #!/usr/bin/env bash if [[ "${1:-}" == "-A" ]]; then shift fi if [[ "${MOCK_SUDO_FAIL_WRITE:-no}" == "yes" && "$*" == *"--write-selection"* ]]; then exit 1 fi exec "$@" EOF cat > "$mockBin/spd-say" <<'EOF' #!/usr/bin/env bash printf '%s\n' "$*" >> "$MOCK_LOG" if [[ "${1:-}" == "-O" ]]; then cat "$MOCK_MODULES" exit "${MOCK_DISCOVERY_STATUS:-0}" fi exit 0 EOF cat > "$mockBin/socat" <<'EOF' #!/usr/bin/env bash read -r command printf 'socat %s: %s\n' "$*" "$command" >> "$MOCK_LOG" EOF cat > "$mockBin/systemctl" <<'EOF' #!/usr/bin/env bash printf 'systemctl %s\n' "$*" >> "$MOCK_LOG" if [[ "${1:-}" == "is-active" ]]; then [[ "${MOCK_FENRIR_ACTIVE:-no}" == "yes" ]] exit fi exit 0 EOF chmod 755 "$mockBin"/* fail() { printf 'FAIL: %s\n' "$1" >&2 exit 1 } assert_equals() { local expected="$1" local actual="$2" local description="$3" [[ "$actual" == "$expected" ]] || fail "$description (expected '$expected', got '$actual')" } assert_file_contains() { local filePath="$1" local pattern="$2" local description="$3" grep -qE -- "$pattern" "$filePath" || fail "$description" } assert_file_absent() { local filePath="$1" local description="$2" [[ ! -e "$filePath" ]] || fail "$description" } reset_case() { caseDir="$tempDir/case-$testCount" testCount=$((testCount + 1)) mkdir -p "$caseDir/runtime" configFile="$caseDir/speechd.conf" selectionFile="$caseDir/selected" modulesFile="$caseDir/modules" mockLog="$caseDir/mock.log" : > "$mockLog" printf 'OUTPUT MODULES\nrhvoice\nopenevv\nespeak-ng\n' > "$modulesFile" printf '# Speech Dispatcher\nDefaultModule rhvoice\n' > "$configFile" } run_selector() { local input="$1" printf '%b' "$input" | env \ PATH="$mockBin:$PATH" \ MOCK_LOG="$mockLog" \ MOCK_MODULES="$modulesFile" \ MOCK_DISCOVERY_STATUS="${MOCK_DISCOVERY_STATUS:-0}" \ MOCK_FENRIR_ACTIVE="${MOCK_FENRIR_ACTIVE:-no}" \ MOCK_SUDO_FAIL_WRITE="${MOCK_SUDO_FAIL_WRITE:-no}" \ STORMUX_SPEECH_CONFIG="$configFile" \ STORMUX_RUNTIME_DIR="$caseDir/runtime" \ STORMUX_SPD_SAY=spd-say \ STORMUX_SOCAT=socat \ STORMUX_SYSTEMCTL=systemctl \ "$selector" --selection-file "$selectionFile" >/dev/null } # Enter accepts the configured initial module and collapses duplicate directives. reset_case printf 'DefaultModule rhvoice\n# DefaultModule espeak-ng\nDefaultModule openevv\n' > "$configFile" MOCK_FENRIR_ACTIVE=yes run_selector '\n' assert_equals 1 "$(grep -c '^DefaultModule ' "$configFile")" "initial selection should leave one active default" assert_file_contains "$configFile" '^DefaultModule rhvoice$' "initial selection should retain rhvoice" assert_equals rhvoice "$(< "$selectionFile")" "initial selection should be recorded" assert_file_contains "$mockLog" 'speech#enabled=False' "active Fenrir speech should be muted" assert_file_contains "$mockLog" 'speech#enabled=True' "active Fenrir speech should be restored" assert_file_contains "$mockLog" 'Use up and down arrows to select the default voice for this computer\.' "the selector should voice its own instructions" if grep -q '^systemctl restart ' "$mockLog"; then fail "the selector should defer the Fenrir restart until after layout selection" fi # Down previews and selects the next module. reset_case run_selector 'x\033[B\n' assert_file_contains "$mockLog" '^-o openevv This is openevv\.' "Down should preview OpenEVV" assert_file_contains "$configFile" '^DefaultModule openevv$' "Down then Enter should select OpenEVV" # Up wraps from the first module to the last. reset_case run_selector '\033[A\n' assert_file_contains "$mockLog" '^-o espeak-ng This is espeak-ng\.' "Up should wrap and preview eSpeak NG" assert_file_contains "$configFile" '^DefaultModule espeak-ng$' "wrapped module should persist" # Escape retains the prior file and does not create an installer selection. reset_case cp "$configFile" "$caseDir/original.conf" run_selector '\033' cmp -s "$configFile" "$caseDir/original.conf" || fail "Escape should retain the configuration" assert_file_absent "$selectionFile" "Escape should not record an installer selection" # Empty discovery fails safely. reset_case printf 'OUTPUT MODULES\n' > "$modulesFile" cp "$configFile" "$caseDir/original.conf" run_selector '\n' cmp -s "$configFile" "$caseDir/original.conf" || fail "empty discovery should retain the configuration" assert_file_absent "$selectionFile" "empty discovery should not record a selection" # Unsafe names are rejected rather than passed to spd-say or configuration. reset_case printf 'OUTPUT MODULES\nbad name\n;command\nopenevv\n' > "$modulesFile" run_selector '\n' assert_file_contains "$configFile" '^DefaultModule openevv$' "the remaining safe module should be selected" if grep -qE 'bad name|;command' "$mockLog"; then fail "unsafe module names should not reach spd-say" fi # A missing configuration simulates failed persistence and remains nonfatal. reset_case rm -f "$configFile" MOCK_FENRIR_ACTIVE=yes run_selector '\n' assert_file_absent "$selectionFile" "failed persistence should not record a selection" assert_file_contains "$mockLog" 'selection failed' "failed persistence should be announced" assert_file_contains "$mockLog" 'speech#enabled=True' "failed persistence should restore Fenrir speech" # Failure to record the live choice also rolls the system configuration back. reset_case cp "$configFile" "$caseDir/original.conf" MOCK_SUDO_FAIL_WRITE=yes run_selector '\033[B\n' cmp -s "$configFile" "$caseDir/original.conf" || fail "selection-file failure should roll back the configuration" assert_file_absent "$selectionFile" "selection-file failure should leave no selection" printf 'PASS: %s speech selector cases\n' "$testCount"