#!/usr/bin/env bash set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" config_path="$repo_dir/config/dot/quickshell" state_home="$(mktemp -d /tmp/panama-control-center-state.XXXXXX)" shell_log="$state_home/quickshell.log" fail() { printf 'Control Center services contract: %s\n' "$1" >&2 exit 1 } qs_for_test() { QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \ qs -p "$config_path" "$@" } stop_test_shell() { qs_for_test kill >/dev/null 2>&1 || true for _ in $(seq 1 80); do if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \ && ! qs_for_test ipc show >/dev/null 2>&1; then return 0 fi sleep 0.1 done return 1 } cleanup() { qs_for_test ipc call kdeconnect reset >/dev/null 2>&1 || true qs_for_test ipc call home-assistant reset >/dev/null 2>&1 || true qs_for_test ipc call status-events reset >/dev/null 2>&1 || true qs_for_test ipc call quicksettings close >/dev/null 2>&1 || true if stop_test_shell; then rm -rf "$state_home" else printf 'Control Center services contract: branch shell did not stop; retained %s\n' \ "$state_home" >&2 fi } trap cleanup EXIT start_test_shell() { stop_test_shell || fail 'pre-existing branch shell did not stop cleanly' for _attempt in 1 2; do qs_for_test --daemonize >"$shell_log" 2>&1 for _ in $(seq 1 80); do if qs_for_test ipc show 2>/dev/null | rg '^target home-assistant$' >/dev/null; then return fi sleep 0.1 done stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly' done sed -n '1,200p' "$shell_log" >&2 fail 'isolated branch shell did not start' } wait_for_home_status() { local filter="$1" local message="$2" local status="" for _ in $(seq 1 80); do status="$(qs_for_test ipc call home-assistant status)" if jq -e "$filter" <<<"$status" >/dev/null; then return fi sleep 0.1 done printf 'Last Home status: %s\n' "$status" >&2 fail "$message" } start_test_shell qs_for_test ipc show | rg '^target kdeconnect$' >/dev/null \ || fail 'KDE Connect IPC target is missing' qs_for_test ipc show | rg '^target home-assistant$' >/dev/null \ || fail 'Home Assistant IPC target is missing' qs_for_test ipc call kdeconnect fixture reachable >/dev/null jq -e ' .fixture == true and .available == true and .reachable == true and .actionCount == 4 and .transferActive == false and .ongoingCount == 0 ' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \ || fail 'reachable phone fixture is malformed' qs_for_test ipc call kdeconnect fixture offline >/dev/null jq -e ' .fixture == true and .available == true and .reachable == false and .pairedCount == 1 and .ongoingCount == 0 ' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \ || fail 'offline phone fixture is malformed' qs_for_test ipc call kdeconnect fixture transfer >/dev/null jq -e ' .fixture == true and .transferActive == true and .transferFileName == "Fixture document.pdf" and .ongoingCount == 1 ' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \ || fail 'phone transfer did not enter Ongoing' qs_for_test ipc call kdeconnect cancel >/dev/null jq -e '.transferActive == false and .ongoingCount == 0' \ <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \ || fail 'phone transfer did not leave Ongoing' qs_for_test ipc call home-assistant fixture ready >/dev/null home_ready="$(qs_for_test ipc call home-assistant status)" jq -e ' .fixture == true and .phase == "ready" and .discoveredCount == 7 and .configuredCount == 7 and .visibleCount == 4 and .selectedIds[0:4] == [ "light.fixture_all", "light.fixture_kitchen", "light.fixture_living", "light.fixture_bedroom" ] and (.entities[0:4] | map(.name)) == [ "Whole home", "Kitchen island", "Living room", "Bedroom" ] and .stale == false and .busy == false and .lastError == "" ' <<<"$home_ready" >/dev/null \ || fail 'ready Home fixture is malformed' qs_for_test ipc call home-assistant brightness light.fixture_living 64 >/dev/null jq -e ' .entities[] | select(.id == "light.fixture_living") | .active == true and .state == "on" and .brightnessPct == 64 ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'Home brightness fixture did not update local catalog state' qs_for_test ipc call home-assistant toggle light.fixture_living >/dev/null jq -e ' .entities[] | select(.id == "light.fixture_living") | .active == false and .state == "off" ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'Home toggle fixture did not update local catalog state' qs_for_test ipc call home-assistant fixture process-actions >/dev/null qs_for_test ipc call home-assistant brightness light.fixture_living 64 >/dev/null qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null jq -e ' .busy == true and .busyEntityIds == ["light.fixture_living", "light.fixture_hall"] and .pendingBrightness["light.fixture_living"] == 64 ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'process-backed Home actions did not remain per-entity busy' wait_for_home_status ' .busy == false and .busyEntityIds == [] and .pendingBrightness == {} and (.entities[] | select(.id == "light.fixture_living") | .active == true and .brightnessPct == 64) and (.entities[] | select(.id == "light.fixture_hall") | .active == true) ' 'process-backed Home actions did not complete in queue order' qs_for_test ipc call home-assistant fixture process-no-output >/dev/null qs_for_test ipc call home-assistant toggle light.fixture_kitchen >/dev/null qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null wait_for_home_status ' .phase == "ready" and .busy == false and .busyEntityIds == [] and .entityErrors["light.fixture_kitchen"] == "action-failed" and (.entityErrors["light.fixture_hall"] // "") == "" and (.entities[] | select(.id == "light.fixture_hall") | .active == true) ' 'nonzero no-output Home action did not fail safely and continue the queue' qs_for_test ipc call home-assistant fixture process-actions >/dev/null qs_for_test ipc call home-assistant brightness light.fixture_living 88 >/dev/null jq -e ' .busyEntityIds == ["light.fixture_living"] and .pendingBrightness["light.fixture_living"] == 88 ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'fixture transition setup did not start an in-flight action' qs_for_test ipc call home-assistant fixture process-no-output >/dev/null qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null wait_for_home_status ' .phase == "ready" and .busy == false and .busyEntityIds == [] and .pendingBrightness == {} and .entityErrors == {} and (.entities[] | select(.id == "light.fixture_living") | .active == false and .brightnessPct == 36) and (.entities[] | select(.id == "light.fixture_hall") | .active == true) ' 'switching process fixtures stranded or misattributed the new action' qs_for_test ipc call home-assistant fixture process-delayed-exit >/dev/null qs_for_test ipc call home-assistant brightness light.fixture_living 88 >/dev/null wait_for_home_status ' .busyEntityIds == ["light.fixture_living"] and .pendingBrightness["light.fixture_living"] == 88 and .actionProcessRunning == false and .actionStreamFinished == true ' 'nested fixture transition setup did not reach its delayed-exit window' qs_for_test ipc call home-assistant fixture process-no-output >/dev/null qs_for_test ipc call home-assistant fixture process-slow-actions >/dev/null qs_for_test ipc call home-assistant brightness light.fixture_desk 57 >/dev/null wait_for_home_status ' .phase == "ready" and .busy == false and .busyEntityIds == [] and .pendingBrightness == {} and .entityErrors == {} and (.entities[] | select(.id == "light.fixture_living") | .active == false and .brightnessPct == 36) and (.entities[] | select(.id == "light.fixture_desk") | .active == true and .brightnessPct == 57) ' 'nested fixture transitions released the drain or corrupted the latest action' qs_for_test ipc call home-assistant fixture missing-selected >/dev/null jq -e ' .fixture == true and .phase == "ready" and .discoveredCount == 7 and .configuredCount == 8 and (.entities[] | select(.id == "light.fixture_missing") | .sourceName == "fixture missing" and .name == "Porch" and .state == "unavailable" and .available == false and .active == false and .dimmable == false and .brightnessPct == 0) ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'missing Home selection was not retained as unavailable' qs_for_test ipc call home-assistant fixture action-error >/dev/null jq -e ' .fixture == true and .phase == "ready" and .stale == false and .entityErrors["light.fixture_kitchen"] == "request-failed" and (.entityErrors["light.fixture_hall"] // "") == "" and .lastError == "" ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'Home action error was not isolated to one entity' qs_for_test ipc call home-assistant fixture stale >/dev/null jq -e ' .fixture == true and .phase == "degraded" and .discoveredCount == 7 and .configuredCount == 7 and .visibleCount == 4 and .stale == true and .lastError == "unreachable" ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'stale Home fixture did not retain entities' qs_for_test ipc call home-assistant fixture unavailable >/dev/null jq -e ' .fixture == true and .phase == "unavailable" and .discoveredCount == 0 and .configuredCount == 0 and .visibleCount == 0 and .lastError == "not-configured" ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'unavailable Home fixture is malformed' trap - EXIT cleanup [[ ! -e "$state_home" ]] \ || fail 'temporary Home preferences state was not removed after shell exit' printf 'Control Center services contract: PASS\n'