Merge branch 'feat/home-accessories-customization'
# Conflicts: # config/dot/quickshell/config/qmldir # config/dot/quickshell/services/SystemSettings.qml # tests/quickshell/settings-pages-contract.sh
This commit is contained in:
@@ -2,72 +2,205 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source_config_path="$project_root/config/dot/quickshell"
|
||||
quicksettings_path="$source_config_path/modules/quicksettings"
|
||||
state_home="$(mktemp -d /tmp/panama-control-center-ui.XXXXXX)"
|
||||
config_path="$state_home/quickshell"
|
||||
helper_log="$state_home/home-helper.log"
|
||||
shell_log="$state_home/quickshell.log"
|
||||
|
||||
fail() {
|
||||
printf 'Control Center contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
qs_for_test() {
|
||||
QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
|
||||
QS_DISABLE_CRASH_HANDLER=1 \
|
||||
PANAMA_HOME_HELPER_LOG="$helper_log" \
|
||||
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 ipc call quicksettings close >/dev/null 2>&1 || true
|
||||
qs ipc call kdeconnect reset >/dev/null 2>&1 || true
|
||||
qs ipc call home-assistant reset >/dev/null 2>&1 || true
|
||||
qs_for_test ipc call quicksettings close >/dev/null 2>&1 || true
|
||||
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
|
||||
if stop_test_shell; then
|
||||
rm -rf "$state_home"
|
||||
else
|
||||
printf 'Control Center 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 quicksettings$' >/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'
|
||||
}
|
||||
|
||||
rg -Fq 'readonly property int controlCenterWidth: 430' \
|
||||
"$project_root/config/dot/quickshell/config/Theme.qml" \
|
||||
"$source_config_path/config/Theme.qml" \
|
||||
|| fail 'approved Control Center width is missing'
|
||||
rg -Fq 'readonly property int controlCenterTopGap: 2' \
|
||||
"$project_root/config/dot/quickshell/config/Theme.qml" \
|
||||
"$source_config_path/config/Theme.qml" \
|
||||
|| fail 'approved top attachment is missing'
|
||||
rg -Fq 'margins.top: Theme.barHeight + Theme.controlCenterTopGap' \
|
||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettings.qml" \
|
||||
"$quicksettings_path/QuickSettings.qml" \
|
||||
|| fail 'Control Center is not tightly attached to the bar'
|
||||
rg -Fq 'implicitWidth: Theme.controlCenterWidth' \
|
||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettings.qml" \
|
||||
"$quicksettings_path/QuickSettings.qml" \
|
||||
|| fail 'Control Center window does not use its geometry token'
|
||||
rg -Fq 'HomeControls' \
|
||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \
|
||||
"$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'Home controls are not mounted'
|
||||
rg -Fq 'PhoneControls' \
|
||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \
|
||||
"$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'Phone controls are not mounted'
|
||||
rg -Fq 'visible: KdeConnect.phoneReachable' \
|
||||
"$project_root/config/dot/quickshell/modules/bar/StatusCluster.qml" \
|
||||
"$source_config_path/modules/bar/StatusCluster.qml" \
|
||||
|| fail 'reachable phone state is absent from the bar'
|
||||
|
||||
for component in ControlSectionHeader HomeControls HomeTile PhoneControls RecentExchange; do
|
||||
[[ -f "$project_root/config/dot/quickshell/modules/quicksettings/$component.qml" ]] \
|
||||
for component in ControlSectionHeader HomeBrightnessSlider HomeControls HomeTile PhoneActions PhoneControls RecentExchange; do
|
||||
[[ -f "$quicksettings_path/$component.qml" ]] \
|
||||
|| fail "$component is missing"
|
||||
done
|
||||
|
||||
qs ipc call home-assistant fixture ready >/dev/null
|
||||
qs ipc call kdeconnect fixture reachable >/dev/null
|
||||
qs ipc call quicksettings open >/dev/null
|
||||
[[ "$(rg -c 'id: "(share|clipboard|ring|messages)"' "$quicksettings_path/PhoneActions.qml")" -eq 4 ]] \
|
||||
|| fail 'Phone controls do not expose four stable action models'
|
||||
rg -Fq 'columns: 4' "$quicksettings_path/PhoneControls.qml" \
|
||||
|| fail 'Phone actions are not arranged in four equal columns'
|
||||
if rg -Fq '.filter(' "$quicksettings_path/PhoneActions.qml"; then
|
||||
fail 'Phone action columns change when a KDE capability is unavailable'
|
||||
fi
|
||||
rg -Fq 'SystemSettings.bluebubblesAvailable' "$quicksettings_path/PhoneControls.qml" \
|
||||
|| fail 'Messages action is not independently enabled by BlueBubbles'
|
||||
rg -Fq 'KdeConnect.phoneReachable' "$quicksettings_path/PhoneControls.qml" \
|
||||
|| fail 'KDE action reachability behavior is missing'
|
||||
|
||||
[[ -f "$quicksettings_path/qmldir" ]] \
|
||||
|| fail 'quick-settings module manifest is missing'
|
||||
rg -Fq 'HomeBrightnessSlider 1.0 HomeBrightnessSlider.qml' \
|
||||
"$quicksettings_path/qmldir" \
|
||||
|| fail 'brightness slider is not registered in the quick-settings module'
|
||||
rg -Fq 'PhoneActions 1.0 PhoneActions.qml' \
|
||||
"$quicksettings_path/qmldir" \
|
||||
|| fail 'Phone actions model is not registered in the quick-settings module'
|
||||
|
||||
[[ "$(rg -c '^[[:space:]]*columns: 2$' "$quicksettings_path/HomeControls.qml")" -ge 2 ]] \
|
||||
|| fail 'resting and expanded Home shelves are not both two-column grids'
|
||||
rg -Fq 'model: HomeAssistant.visibleEntities' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'resting Home shelf does not use the first four selected lights'
|
||||
rg -Fq 'model: HomeAssistant.selectedEntities' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'expanded Home shelf does not use every selected light'
|
||||
rg -Fq 'HomeAssistant.pendingFor(' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'Home tiles do not receive per-light pending brightness'
|
||||
rg -Fq 'HomeAssistant.setBrightness(' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'Home brightness commits are not wired to the service'
|
||||
rg -Fq 'ShellState.openSettings("home-phone")' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'Manage in Settings does not open Home & Phone'
|
||||
|
||||
rg -Fq 'signal previewChanged(int value)' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||
|| fail 'brightness slider has no preview contract'
|
||||
rg -Fq 'signal committed(int value)' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||
|| fail 'brightness slider has no release-commit contract'
|
||||
rg -Fq 'onReleased: event =>' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||
|| fail 'brightness slider does not route pointer release through its interaction state'
|
||||
rg -Fq 'root.releasePointerInteraction();' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||
|| fail 'brightness slider release is not wired to one-shot commit semantics'
|
||||
rg -Fq 'onCanceled: root.cancelPointerInteraction()' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||
|| fail 'brightness slider does not restore its external value when a Flickable steals the pointer'
|
||||
if rg -Fq 'preventStealing: true' "$quicksettings_path/HomeBrightnessSlider.qml"; then
|
||||
fail 'brightness slider blocks the expanded shelf from stealing vertical drags'
|
||||
fi
|
||||
rg -Fq 'onWheel:' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||
|| fail 'brightness slider has no wheel commit path'
|
||||
rg -Fq 'onCommitted: value => root.brightnessRequested(value)' "$quicksettings_path/HomeTile.qml" \
|
||||
|| fail 'Home tile does not forward the slider release commit'
|
||||
rg -Fq 'accessibleName: root.entity.name + " brightness"' "$quicksettings_path/HomeTile.qml" \
|
||||
|| fail 'Home tile does not give its dimmer an accessory-specific accessible name'
|
||||
|
||||
cp -a "$source_config_path" "$config_path"
|
||||
: >"$helper_log"
|
||||
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >>"$PANAMA_HOME_HELPER_LOG"
|
||||
case "${1:-}" in
|
||||
catalog)
|
||||
printf '%s\n' '{"ok":false,"configured":false,"entities":[],"legacyEntityIds":[],"error":"test-helper"}'
|
||||
;;
|
||||
toggle|brightness)
|
||||
printf '%s\n' '{"ok":false,"error":"contract-action-forbidden"}'
|
||||
exit 73
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$config_path/scripts/panama-home-assistant"
|
||||
|
||||
start_test_shell
|
||||
|
||||
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 .visibleCount == 4 and .configuredCount == 7' \
|
||||
<<<"$home_ready" >/dev/null \
|
||||
|| fail 'Home fixture does not expose four resting and seven expanded accessories'
|
||||
|
||||
qs_for_test ipc call kdeconnect fixture reachable >/dev/null
|
||||
qs_for_test ipc call quicksettings open >/dev/null
|
||||
|
||||
hyprctl layers | rg -q 'namespace: qs-popover-quicksettings' \
|
||||
|| fail 'Control Center layer did not map'
|
||||
jq -e '.open == true and .expandedSection == ""' \
|
||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
||||
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|
||||
|| fail 'Control Center did not open in its resting state'
|
||||
|
||||
qs ipc call quicksettings section home >/dev/null
|
||||
qs_for_test ipc call quicksettings section home >/dev/null
|
||||
jq -e '.open == true and .expandedSection == "home"' \
|
||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
||||
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|
||||
|| fail 'Home section did not expand'
|
||||
|
||||
qs ipc call quicksettings section phone >/dev/null
|
||||
qs_for_test ipc call quicksettings section phone >/dev/null
|
||||
jq -e '.open == true and .expandedSection == "phone"' \
|
||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
||||
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|
||||
|| fail 'Phone did not replace Home as the expanded section'
|
||||
|
||||
qs ipc call quicksettings section phone >/dev/null
|
||||
qs_for_test ipc call quicksettings section phone >/dev/null
|
||||
jq -e '.open == true and .expandedSection == ""' \
|
||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
||||
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|
||||
|| fail 'expanded Phone section did not collapse'
|
||||
|
||||
if rg '^(toggle|brightness)( |$)' "$helper_log" >&2; then
|
||||
fail 'Control Center contract attempted a real Home action path'
|
||||
fi
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
[[ ! -e "$state_home" ]] \
|
||||
|| fail 'temporary Control Center state was not removed after shell exit'
|
||||
printf 'Control Center contract: PASS\n'
|
||||
|
||||
@@ -2,25 +2,108 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
state_home="$(mktemp -d /tmp/panama-control-center-state.XXXXXX)"
|
||||
source_config_path="$repo_dir/config/dot/quickshell"
|
||||
config_path="$state_home/quickshell"
|
||||
helper_log="$state_home/home-helper.log"
|
||||
shell_log="$state_home/quickshell.log"
|
||||
|
||||
: >"$helper_log"
|
||||
cp -a "$source_config_path" "$config_path"
|
||||
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >>"$PANAMA_HOME_HELPER_LOG"
|
||||
case "${1:-}" in
|
||||
catalog)
|
||||
printf '%s\n' '{"ok":false,"error":"test-helper"}'
|
||||
;;
|
||||
toggle|brightness)
|
||||
printf '%s\n' '{"ok":true}'
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$config_path/scripts/panama-home-assistant"
|
||||
|
||||
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" \
|
||||
PANAMA_HOME_HELPER_LOG="$helper_log" \
|
||||
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 ipc call kdeconnect reset >/dev/null 2>&1 || true
|
||||
qs ipc call home-assistant reset >/dev/null 2>&1 || true
|
||||
qs ipc call status-events reset >/dev/null 2>&1 || true
|
||||
qs ipc call quicksettings close >/dev/null 2>&1 || true
|
||||
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
|
||||
|
||||
qs ipc show | rg -q '^target kdeconnect$' \
|
||||
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 ipc show | rg -q '^target home-assistant$' \
|
||||
qs_for_test ipc show | rg '^target home-assistant$' >/dev/null \
|
||||
|| fail 'Home Assistant IPC target is missing'
|
||||
|
||||
qs ipc call kdeconnect fixture reachable >/dev/null
|
||||
qs_for_test ipc call kdeconnect fixture reachable >/dev/null
|
||||
jq -e '
|
||||
.fixture == true and
|
||||
.available == true and
|
||||
@@ -28,65 +111,270 @@ jq -e '
|
||||
.actionCount == 4 and
|
||||
.transferActive == false and
|
||||
.ongoingCount == 0
|
||||
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \
|
||||
' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \
|
||||
|| fail 'reachable phone fixture is malformed'
|
||||
|
||||
qs ipc call kdeconnect fixture offline >/dev/null
|
||||
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 ipc call kdeconnect status)" >/dev/null \
|
||||
' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \
|
||||
|| fail 'offline phone fixture is malformed'
|
||||
|
||||
qs ipc call kdeconnect fixture transfer >/dev/null
|
||||
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 ipc call kdeconnect status)" >/dev/null \
|
||||
' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \
|
||||
|| fail 'phone transfer did not enter Ongoing'
|
||||
|
||||
qs ipc call kdeconnect cancel >/dev/null
|
||||
qs_for_test ipc call kdeconnect cancel >/dev/null
|
||||
jq -e '.transferActive == false and .ongoingCount == 0' \
|
||||
<<<"$(qs ipc call kdeconnect status)" >/dev/null \
|
||||
<<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \
|
||||
|| fail 'phone transfer did not leave Ongoing'
|
||||
|
||||
qs ipc call home-assistant fixture ready >/dev/null
|
||||
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 == ""
|
||||
' <<<"$(qs ipc call home-assistant status)" >/dev/null \
|
||||
' <<<"$home_ready" >/dev/null \
|
||||
|| fail 'ready Home fixture is malformed'
|
||||
|
||||
qs ipc call home-assistant fixture stale >/dev/null
|
||||
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
|
||||
jq -e '
|
||||
.fixture == true and
|
||||
.fixtureTransitionDraining == true and
|
||||
.busy == false and
|
||||
.busyEntityIds == [] and
|
||||
.pendingBrightness == {} and
|
||||
.entityErrors == {} and
|
||||
.queuedActionCount == 0 and
|
||||
.actionActive == false and
|
||||
.actionProcessRunning == false and
|
||||
(.entities[] |
|
||||
select(.id == "light.fixture_desk") |
|
||||
.active == true and .brightnessPct == 24)
|
||||
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|
||||
|| fail 'nested fixture transition accepted an action during its drain'
|
||||
wait_for_home_status '
|
||||
.fixture == true and
|
||||
.fixtureTransitionDraining == false and
|
||||
.busy == false and
|
||||
.queuedActionCount == 0 and
|
||||
.actionActive == false and
|
||||
.actionProcessRunning == false
|
||||
' 'nested fixture transition did not install the latest stable fixture'
|
||||
qs_for_test ipc call home-assistant brightness light.fixture_desk 57 >/dev/null
|
||||
wait_for_home_status '
|
||||
.phase == "ready" and
|
||||
.fixtureTransitionDraining == false and
|
||||
.busy == false and
|
||||
.busyEntityIds == [] and
|
||||
.pendingBrightness == {} and
|
||||
.entityErrors == {} and
|
||||
.queuedActionCount == 0 and
|
||||
.actionActive == false 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 process-delayed-exit >/dev/null
|
||||
qs_for_test ipc call home-assistant brightness light.fixture_living 88 >/dev/null
|
||||
wait_for_home_status '
|
||||
.fixture == true and
|
||||
.busyEntityIds == ["light.fixture_living"] and
|
||||
.pendingBrightness["light.fixture_living"] == 88 and
|
||||
.actionProcessRunning == false and
|
||||
.actionStreamFinished == true
|
||||
' 'reset-to-live setup did not reach its process drain window'
|
||||
qs_for_test ipc call home-assistant reset >/dev/null
|
||||
qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null
|
||||
qs_for_test ipc call home-assistant brightness light.fixture_living 63 >/dev/null
|
||||
jq -e '
|
||||
.fixture == true and
|
||||
.fixtureTransitionDraining == true and
|
||||
.busy == false and
|
||||
.busyEntityIds == [] and
|
||||
.pendingBrightness == {} and
|
||||
.entityErrors == {} and
|
||||
.queuedActionCount == 0 and
|
||||
.actionActive == false and
|
||||
.actionProcessRunning == false
|
||||
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|
||||
|| fail 'reset-to-live drain accepted an action from the old fixture'
|
||||
wait_for_home_status '
|
||||
.fixture == false and
|
||||
.fixtureTransitionDraining == false and
|
||||
.busy == false and
|
||||
.busyEntityIds == [] and
|
||||
.pendingBrightness == {} and
|
||||
.entityErrors == {} and
|
||||
.queuedActionCount == 0 and
|
||||
.actionActive == false and
|
||||
.actionProcessRunning == false
|
||||
' 'reset-to-live left stale fixture action state or started a replacement process'
|
||||
if rg '^(toggle|brightness)( |$)' "$helper_log" >&2; then
|
||||
fail 'reset-to-live issued a stale fixture action to the live helper'
|
||||
fi
|
||||
|
||||
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 ipc call home-assistant status)" >/dev/null \
|
||||
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|
||||
|| fail 'stale Home fixture did not retain entities'
|
||||
|
||||
qs ipc call home-assistant fixture unavailable >/dev/null
|
||||
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 ipc call home-assistant status)" >/dev/null \
|
||||
' <<<"$(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'
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
property bool available: false
|
||||
property var devices: []
|
||||
property bool transferActive: false
|
||||
property string lastError: ""
|
||||
property var recentExchange: null
|
||||
property int actionCount: 0
|
||||
|
||||
readonly property var preferredPhone: {
|
||||
const phones = root.devices.filter(device => device.type === "phone" && device.paired);
|
||||
return phones.find(device => device.reachable) ?? phones[0] ?? null;
|
||||
}
|
||||
readonly property bool phoneReachable: root.preferredPhone?.reachable === true
|
||||
readonly property var phoneActions: root.preferredPhone?.actions ?? []
|
||||
|
||||
function supports(action: string): bool {
|
||||
return root.phoneActions.indexOf(action) >= 0;
|
||||
}
|
||||
|
||||
function refresh(): void {}
|
||||
function sendFile(path: string): void { root.actionCount += 1; }
|
||||
function sendClipboard(): void { root.actionCount += 1; }
|
||||
function ring(): void { root.actionCount += 1; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
property bool bluebubblesAvailable: true
|
||||
property int launchCount: 0
|
||||
|
||||
function openApplication(id: string): bool {
|
||||
launchCount += 1;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -12,24 +12,14 @@ helper="$project_root/config/dot/quickshell/scripts/panama-home-assistant"
|
||||
|
||||
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
|
||||
|
||||
probe="$($helper probe)"
|
||||
catalog="$($helper catalog)"
|
||||
jq -e '
|
||||
.configured == true and
|
||||
.reachable == true and
|
||||
(.entityCount | type == "number" and . > 0) and
|
||||
.error == ""
|
||||
' <<<"$probe" >/dev/null || fail 'live API probe failed'
|
||||
.ok == true and .configured == true and .error == "" and
|
||||
(.entities | type == "array" and length > 0) and
|
||||
([.entities[] | (keys | sort) == (["active", "available", "brightnessPct", "dimmable", "id", "sourceName", "state"] | sort)] | all) and
|
||||
([.entities[] | (.id | startswith("light.")) and (.brightnessPct >= 0 and .brightnessPct <= 100)] | all) and
|
||||
(.legacyEntityIds | type == "array")
|
||||
' <<<"$catalog" >/dev/null || fail 'live catalog shape is invalid'
|
||||
|
||||
snapshot="$($helper snapshot)"
|
||||
jq -e --argjson expected "$(jq '.entityCount' <<<"$probe")" '
|
||||
.ok == true and
|
||||
.configured == true and
|
||||
.error == "" and
|
||||
(.generatedAt | type == "number") and
|
||||
(.entities | type == "array" and length == $expected) and
|
||||
([.entities[] | (keys | sort) == (["active", "available", "domain", "id", "name", "state"] | sort)] | all) and
|
||||
([.entities[] | (.active | type == "boolean") and (.available | type == "boolean")] | all)
|
||||
' <<<"$snapshot" >/dev/null || fail 'live snapshot shape is invalid'
|
||||
|
||||
printf 'Home Assistant helper contract: PASS (configured=true, %s favourites; contents redacted)\n' \
|
||||
"$(jq '.entities | length' <<<"$snapshot")"
|
||||
printf 'Home Assistant helper contract: PASS (configured=true, %s lights; contents redacted)\n' \
|
||||
"$(jq '.entities | length' <<<"$catalog")"
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
harness="$repo_dir/config/dot/quickshell/home-brightness-slider-harness.qml"
|
||||
state_home="$(mktemp -d /tmp/panama-home-brightness-slider.XXXXXX)"
|
||||
shell_log="$state_home/quickshell.log"
|
||||
|
||||
fail() {
|
||||
printf 'Home brightness slider contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
qs_for_harness() {
|
||||
QS_DISABLE_CRASH_HANDLER=1 XDG_STATE_HOME="$state_home" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
qs_for_harness kill >/dev/null 2>&1 || true
|
||||
rm -rf "$state_home"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
start_harness() {
|
||||
qs_for_harness --daemonize >"$shell_log" 2>&1
|
||||
for _ in $(seq 1 50); do
|
||||
if qs_for_harness ipc show 2>/dev/null \
|
||||
| rg -q '^target home-brightness-slider-test$'; then
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
sed -n '1,160p' "$shell_log" >&2
|
||||
fail 'isolated slider harness did not start'
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local filter="$1"
|
||||
local message="$2"
|
||||
local status=""
|
||||
|
||||
status="$(qs_for_harness ipc call home-brightness-slider-test status)"
|
||||
jq -e "$filter" <<<"$status" >/dev/null || {
|
||||
printf 'Slider status: %s\n' "$status" >&2
|
||||
fail "$message"
|
||||
}
|
||||
}
|
||||
|
||||
start_harness
|
||||
|
||||
qs_for_harness ipc call home-brightness-slider-test reset 30 >/dev/null
|
||||
assert_status \
|
||||
'.accessibleRoleIsSlider == true and .accessibleName == "Desk lamp brightness" and .accessibleDescription == "30 percent, range 0 to 100" and .accessibleFocusable == true' \
|
||||
'slider accessibility metadata is incomplete or incorrect'
|
||||
qs_for_harness ipc call home-brightness-slider-test press 140 >/dev/null
|
||||
assert_status \
|
||||
'.confirmedValue == 30 and .previewValue == 70 and .interactionActive == true and .commitCount == 0 and .accessibleDescription == "70 percent, range 0 to 100"' \
|
||||
'pointer press did not remain a local preview'
|
||||
qs_for_harness ipc call home-brightness-slider-test release >/dev/null
|
||||
assert_status \
|
||||
'.previewValue == 70 and .interactionActive == false and .commitCount == 1 and .lastCommit == 70' \
|
||||
'one pointer release did not emit exactly one commit'
|
||||
qs_for_harness ipc call home-brightness-slider-test release >/dev/null
|
||||
assert_status '.commitCount == 1' 'release without an interaction emitted another commit'
|
||||
|
||||
qs_for_harness ipc call home-brightness-slider-test reset 30 >/dev/null
|
||||
qs_for_harness ipc call home-brightness-slider-test wheel 120 >/dev/null
|
||||
assert_status \
|
||||
'.previewValue == 35 and .interactionActive == false and .commitCount == 1 and .lastCommit == 35' \
|
||||
'one wheel event did not emit exactly one five-point commit'
|
||||
|
||||
qs_for_harness ipc call home-brightness-slider-test reset 30 >/dev/null
|
||||
qs_for_harness ipc call home-brightness-slider-test press 160 >/dev/null
|
||||
assert_status \
|
||||
'.previewValue == 80 and .interactionActive == true and .commitCount == 0' \
|
||||
'cancellation setup did not enter local preview state'
|
||||
qs_for_harness ipc call home-brightness-slider-test external 45 >/dev/null
|
||||
assert_status \
|
||||
'.confirmedValue == 45 and .previewValue == 80 and .interactionActive == true and .commitCount == 0' \
|
||||
'external confirmed or pending state replaced an active local preview'
|
||||
qs_for_harness ipc call home-brightness-slider-test cancel >/dev/null
|
||||
assert_status \
|
||||
'.confirmedValue == 45 and .previewValue == 45 and .interactionActive == false and .commitCount == 0 and .lastCommit == -1' \
|
||||
'canceled interaction committed or failed to restore the external value'
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'Home brightness slider contract: PASS\n'
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
state_home="$(mktemp -d /tmp/panama-home-phone-settings-state.XXXXXX)"
|
||||
source_config_path="$repo_dir/config/dot/quickshell"
|
||||
config_path="$state_home/quickshell"
|
||||
test_bin="$state_home/bin"
|
||||
shell_log="$state_home/quickshell.log"
|
||||
flatpak_log="$state_home/flatpak.log"
|
||||
|
||||
cleanup_bootstrap() {
|
||||
rm -rf "$state_home"
|
||||
}
|
||||
trap cleanup_bootstrap EXIT
|
||||
|
||||
mkdir -p "$test_bin"
|
||||
cp -a "$source_config_path" "$config_path"
|
||||
: >"$flatpak_log"
|
||||
|
||||
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
case "${1:-}" in
|
||||
catalog)
|
||||
printf '%s\n' '{"ok":false,"error":"test-helper"}'
|
||||
;;
|
||||
toggle|brightness)
|
||||
printf '%s\n' '{"ok":true}'
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$config_path/scripts/panama-home-assistant"
|
||||
|
||||
cat >"$test_bin/hyprctl" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
|
||||
printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Home and Phone contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${1:-}" == "keyword" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
exec /usr/sbin/hyprctl "$@"
|
||||
EOF
|
||||
chmod +x "$test_bin/hyprctl"
|
||||
|
||||
cat >"$test_bin/flatpak" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >>"$PANAMA_FLATPAK_LOG"
|
||||
if [[ "${1:-}" == "info" && "${2:-}" == "app.bluebubbles.BlueBubbles" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 97
|
||||
EOF
|
||||
chmod +x "$test_bin/flatpak"
|
||||
|
||||
fail() {
|
||||
printf 'home phone settings contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local needle="$1"
|
||||
local file="$2"
|
||||
rg -Fq "$needle" "$file" || fail "$file is missing: $needle"
|
||||
}
|
||||
|
||||
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml"
|
||||
favorite_card="$repo_dir/config/dot/quickshell/modules/settings/HomeFavoriteCard.qml"
|
||||
available_row="$repo_dir/config/dot/quickshell/modules/settings/AvailableLightRow.qml"
|
||||
system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
|
||||
|
||||
[[ -f "$home_page" ]] || fail 'HomePhonePage.qml is missing'
|
||||
[[ -f "$favorite_card" ]] || fail 'HomeFavoriteCard.qml is missing'
|
||||
[[ -f "$available_row" ]] || fail 'AvailableLightRow.qml is missing'
|
||||
assert_contains 'text: "Home & Phone"' "$home_page"
|
||||
assert_contains 'text: "Choose what appears in Control Center and keep phone continuity close at hand."' "$home_page"
|
||||
assert_contains 'Connected · ' "$home_page"
|
||||
assert_contains 'Last update unavailable · showing saved controls' "$home_page"
|
||||
assert_contains 'Authentication required' "$home_page"
|
||||
assert_contains 'Home Assistant is not configured' "$home_page"
|
||||
assert_contains 'HomePreferences.setAlias' "$home_page"
|
||||
assert_contains 'HomePreferences.move' "$home_page"
|
||||
assert_contains 'HomePreferences.remove' "$home_page"
|
||||
assert_contains 'HomePreferences.add' "$home_page"
|
||||
assert_contains 'HomePreferences.retrySave' "$home_page"
|
||||
assert_contains 'Choose lights below to build your Control Center shelf.' "$home_page"
|
||||
assert_contains 'All discovered lights are already selected' "$home_page"
|
||||
assert_contains 'No lights discovered' "$home_page"
|
||||
assert_contains 'No lights match that search' "$home_page"
|
||||
assert_contains 'Opens BlueBubbles' "$home_page"
|
||||
[[ "$(rg -Fc 'required property var modelData' "$home_page")" -ge 2 ]] \
|
||||
|| fail 'HomePhonePage.qml does not bind both reusable delegates to modelData'
|
||||
if rg -Fq 'index: model.index' "$home_page"; then
|
||||
fail 'HomePhonePage.qml reads an undefined model.index instead of the delegate index'
|
||||
fi
|
||||
if rg -qi 'token|bearer|api/states' "$home_page"; then
|
||||
fail 'HomePhonePage.qml crosses the credential or REST privacy boundary'
|
||||
fi
|
||||
assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card"
|
||||
assert_contains 'signal removeRequested(string id)' "$favorite_card"
|
||||
assert_contains 'signal moveRequested(string id, int targetIndex)' "$favorite_card"
|
||||
assert_contains 'DragHandler {' "$favorite_card"
|
||||
assert_contains 'onEditingFinished:' "$favorite_card"
|
||||
assert_contains 'text: "Control Center"' "$favorite_card"
|
||||
assert_contains 'activeFocusOnTab: true' "$favorite_card"
|
||||
assert_contains 'signal addRequested(string id)' "$available_row"
|
||||
assert_contains 'activeFocusOnTab: true' "$available_row"
|
||||
assert_contains 'readonly property bool bluebubblesAvailable: root.bluebubblesDetected' "$system_settings"
|
||||
assert_contains 'command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]' "$system_settings"
|
||||
assert_contains '"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]' "$system_settings"
|
||||
|
||||
qs_for_test() {
|
||||
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
|
||||
PANAMA_FLATPAK_LOG="$flatpak_log" 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 settings close >/dev/null 2>&1 || true
|
||||
if stop_test_shell; then
|
||||
rm -rf "$state_home"
|
||||
else
|
||||
printf 'home phone settings 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 settings-system$' >/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,240p' "$shell_log" >&2
|
||||
fail 'isolated branch shell did not start'
|
||||
}
|
||||
|
||||
start_test_shell
|
||||
qs_for_test ipc call home-assistant fixture ready >/dev/null
|
||||
qs_for_test ipc call settings page home-phone >/dev/null
|
||||
|
||||
status='{}'
|
||||
for _ in $(seq 1 40); do
|
||||
status="$(qs_for_test ipc call settings status | jq -c .)"
|
||||
if jq -e '.page == "home-phone" and .discoveredCount == 7 and .selectedCount == 7' \
|
||||
<<<"$status" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '
|
||||
.open == true and
|
||||
.page == "home-phone" and
|
||||
.discoveredCount == 7 and
|
||||
.selectedCount == 7 and
|
||||
.homePhone.availableLightIds == [] and
|
||||
.homePhone.availableEmptyText == "All discovered lights are already selected"
|
||||
' \
|
||||
<<<"$status" >/dev/null || fail "Home & Phone diagnostics are incomplete: $status"
|
||||
|
||||
qs_for_test ipc call home-assistant fixture available-extra >/dev/null
|
||||
available_status='{}'
|
||||
for _ in $(seq 1 40); do
|
||||
available_status="$(qs_for_test ipc call settings status | jq -c .)"
|
||||
if jq -e '
|
||||
.discoveredCount == 8 and
|
||||
.selectedCount == 7 and
|
||||
.homePhone.availableLightIds == ["light.fixture_guest"]
|
||||
' <<<"$available_status" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '
|
||||
.discoveredCount == 8 and
|
||||
.selectedCount == 7 and
|
||||
.homePhone.availableLightIds == ["light.fixture_guest"]
|
||||
' <<<"$available_status" >/dev/null \
|
||||
|| fail "an unselected fixture light was not the only available row: $available_status"
|
||||
|
||||
qs_for_test ipc call home-assistant fixture stale-authentication >/dev/null
|
||||
authentication_status='{}'
|
||||
for _ in $(seq 1 40); do
|
||||
authentication_status="$(qs_for_test ipc call settings status | jq -c .)"
|
||||
if jq -e '
|
||||
.discoveredCount == 7 and
|
||||
.selectedCount == 7 and
|
||||
.homePhone.homeStatus == "Authentication required"
|
||||
' <<<"$authentication_status" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '
|
||||
.discoveredCount == 7 and
|
||||
.selectedCount == 7 and
|
||||
.homePhone.homeStatus == "Authentication required"
|
||||
' <<<"$authentication_status" >/dev/null \
|
||||
|| fail "stale authentication did not retain actionable copy: $authentication_status"
|
||||
|
||||
qs_for_test ipc call home-assistant fixture stale-not-configured >/dev/null
|
||||
not_configured_status='{}'
|
||||
for _ in $(seq 1 40); do
|
||||
not_configured_status="$(qs_for_test ipc call settings status | jq -c .)"
|
||||
if jq -e '
|
||||
.discoveredCount == 7 and
|
||||
.selectedCount == 7 and
|
||||
.homePhone.homeStatus == "Home Assistant is not configured"
|
||||
' <<<"$not_configured_status" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '
|
||||
.discoveredCount == 7 and
|
||||
.selectedCount == 7 and
|
||||
.homePhone.homeStatus == "Home Assistant is not configured"
|
||||
' <<<"$not_configured_status" >/dev/null \
|
||||
|| fail "stale not-configured state did not retain actionable copy: $not_configured_status"
|
||||
|
||||
qs_for_test ipc call home-assistant fixture unavailable >/dev/null
|
||||
empty_status='{}'
|
||||
for _ in $(seq 1 40); do
|
||||
empty_status="$(qs_for_test ipc call settings status | jq -c .)"
|
||||
if jq -e '
|
||||
.discoveredCount == 0 and
|
||||
.selectedCount == 0 and
|
||||
.homePhone.homeStatus == "Home Assistant is not configured" and
|
||||
.homePhone.availableLightIds == [] and
|
||||
.homePhone.availableEmptyText == "No lights discovered"
|
||||
' <<<"$empty_status" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '
|
||||
.discoveredCount == 0 and
|
||||
.selectedCount == 0 and
|
||||
.homePhone.homeStatus == "Home Assistant is not configured" and
|
||||
.homePhone.availableLightIds == [] and
|
||||
.homePhone.availableEmptyText == "No lights discovered"
|
||||
' <<<"$empty_status" >/dev/null \
|
||||
|| fail "an empty catalog did not render its distinct copy: $empty_status"
|
||||
|
||||
system_status="$(qs_for_test ipc call settings-system status | jq -c .)"
|
||||
jq -e '.bluebubblesAvailable == true' <<<"$system_status" >/dev/null \
|
||||
|| fail "BlueBubbles availability was not exposed: $system_status"
|
||||
|
||||
shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
|
||||
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
|
||||
for _ in $(seq 1 40); do
|
||||
if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
||||
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
||||
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
|
||||
|| fail 'the branch shell did not own exactly one tiled Panama Settings client'
|
||||
|
||||
if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then
|
||||
geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \
|
||||
'.[] | select(.pid == $pid and .title == "Panama Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
|
||||
[[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry'
|
||||
grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH"
|
||||
fi
|
||||
|
||||
rg -Fxq 'info app.bluebubbles.BlueBubbles' "$flatpak_log" \
|
||||
|| fail 'the fixed BlueBubbles availability probe did not run'
|
||||
if rg -q '^run ' "$flatpak_log"; then
|
||||
fail 'the contract started BlueBubbles'
|
||||
fi
|
||||
if find "$state_home" -name panama-home.json -print -quit | rg -q .; then
|
||||
fail 'the read-only fixture route wrote Home preferences'
|
||||
fi
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
[[ ! -e "$state_home" ]] || fail 'temporary Home & Phone state was not removed after shell exit'
|
||||
printf 'home phone settings contract: PASS\n'
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
harness="$repo_dir/config/dot/quickshell/home-preferences-harness.qml"
|
||||
state_home="$(mktemp -d /tmp/panama-home-preferences-state.XXXXXX)"
|
||||
|
||||
fail() {
|
||||
printf 'home preferences contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
qs_for_harness() {
|
||||
XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
qs_for_harness kill >/dev/null 2>&1 || true
|
||||
rm -rf "$state_home"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
start_harness() {
|
||||
qs_for_harness --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
if qs_for_harness ipc show 2>/dev/null | rg -q '^target home-pref-test$'; then
|
||||
# Construct the lazy singleton and let its explicit reload finish
|
||||
# before issuing mutations through the IPC boundary.
|
||||
qs_for_harness ipc call home-pref-test status >/dev/null
|
||||
sleep 0.2
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
fail 'test IPC target did not start'
|
||||
}
|
||||
|
||||
stop_harness() {
|
||||
qs_for_harness kill >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 40); do
|
||||
if ! qs_for_harness ipc show >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
fail 'test shell did not stop cleanly'
|
||||
}
|
||||
|
||||
status_without_state_dir() {
|
||||
qs_for_harness ipc call home-pref-test status | jq -c 'del(.stateDir)'
|
||||
}
|
||||
|
||||
wait_for_status() {
|
||||
local expected="$1"
|
||||
local actual=""
|
||||
|
||||
for _ in $(seq 1 40); do
|
||||
actual="$(status_without_state_dir)"
|
||||
if jq -e --argjson expected "$expected" \
|
||||
'.initialized == $expected.initialized and .favorites == $expected.favorites and .saveError == $expected.saveError' \
|
||||
<<<"$actual" >/dev/null; then
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
fail "unexpected status: $actual"
|
||||
}
|
||||
|
||||
wait_for_file_content() {
|
||||
local expected="$1"
|
||||
|
||||
for _ in $(seq 1 40); do
|
||||
state_file="$(find "$state_home" -name panama-home.json -print -quit)"
|
||||
if [[ -n "$state_file" ]] \
|
||||
&& jq -e --argjson expected "$expected" \
|
||||
'.initialized == $expected.initialized and .favorites == $expected.favorites' \
|
||||
"$state_file" >/dev/null; then
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
fail 'preferences file did not contain the complete atomic update'
|
||||
}
|
||||
|
||||
# A leading JSON whitespace prevents qs from expanding the array into IPC
|
||||
# positional arguments; JSON.parse() intentionally accepts that whitespace.
|
||||
initial_ids=' ["light.kitchen","light.hall","light.desk"]'
|
||||
expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}],"saveError":""}'
|
||||
expected_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}]}'
|
||||
empty_expected='{"initialized":true,"favorites":[],"saveError":""}'
|
||||
empty_file='{"initialized":true,"favorites":[]}'
|
||||
|
||||
start_harness
|
||||
qs_for_harness ipc call home-pref-test initialize "$initial_ids" >/dev/null
|
||||
qs_for_harness ipc call home-pref-test alias light.kitchen ' Island ' >/dev/null
|
||||
qs_for_harness ipc call home-pref-test move light.desk 0 >/dev/null
|
||||
qs_for_harness ipc call home-pref-test remove light.hall >/dev/null
|
||||
wait_for_status "$expected"
|
||||
wait_for_file_content "$expected_file"
|
||||
|
||||
stop_harness
|
||||
start_harness
|
||||
wait_for_status "$expected"
|
||||
|
||||
qs_for_harness ipc call home-pref-test remove light.desk >/dev/null
|
||||
qs_for_harness ipc call home-pref-test remove light.kitchen >/dev/null
|
||||
wait_for_status "$empty_expected"
|
||||
wait_for_file_content "$empty_file"
|
||||
stop_harness
|
||||
start_harness
|
||||
wait_for_status "$empty_expected"
|
||||
qs_for_harness ipc call home-pref-test initialize ' ["light.new","light.other"]' >/dev/null
|
||||
wait_for_status "$empty_expected"
|
||||
|
||||
jq -e '(keys | sort) == ["favorites", "initialized"]' "$state_file" >/dev/null \
|
||||
|| fail 'preferences file contains keys other than initialized and favorites'
|
||||
if jq -r '.. | strings' "$state_file" | rg -qi 'token|url|api'; then
|
||||
fail 'preferences file contains credential-like data'
|
||||
fi
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'home preferences contract: PASS\n'
|
||||
@@ -14,6 +14,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
HELPER = ROOT / "config/dot/quickshell/scripts/panama-home-assistant"
|
||||
QML_SERVICE = ROOT / "config/dot/quickshell/services/HomeAssistant.qml"
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("panama_home_assistant", str(HELPER))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
@@ -54,20 +55,36 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
|
||||
if self.path == "/api/states":
|
||||
self._json(
|
||||
[
|
||||
{
|
||||
"entity_id": "light.hall",
|
||||
"state": "off",
|
||||
"attributes": {"friendly_name": "Hall"},
|
||||
},
|
||||
{
|
||||
"entity_id": "sensor.private",
|
||||
"state": "1",
|
||||
"attributes": {"friendly_name": "Private"},
|
||||
},
|
||||
{
|
||||
"entity_id": "light.kitchen",
|
||||
"state": "on",
|
||||
"attributes": {"friendly_name": "Kitchen"},
|
||||
"attributes": {
|
||||
"friendly_name": "Kitchen",
|
||||
"brightness": 128,
|
||||
"supported_color_modes": ["brightness"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"entity_id": "light.hall",
|
||||
"state": "off",
|
||||
"attributes": {
|
||||
"friendly_name": "Hall",
|
||||
"supported_color_modes": ["color_temp"],
|
||||
},
|
||||
},
|
||||
{"entity_id": "sensor.private", "state": "1", "attributes": {"token": "never-return"}},
|
||||
{
|
||||
"entity_id": "light.malformed",
|
||||
"state": "on",
|
||||
"attributes": "invalid",
|
||||
},
|
||||
{
|
||||
"entity_id": "light.corner",
|
||||
"state": "unavailable",
|
||||
"attributes": {
|
||||
"friendly_name": "Corner",
|
||||
"supported_color_modes": ["brightness"],
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
@@ -78,7 +95,11 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(length)
|
||||
self._record(body)
|
||||
if self.path == "/api/services/homeassistant/toggle":
|
||||
if self.path in {
|
||||
"/api/services/homeassistant/toggle",
|
||||
"/api/services/light/turn_on",
|
||||
"/api/services/light/turn_off",
|
||||
}:
|
||||
self._json([])
|
||||
return
|
||||
self._json({"message": "not found"}, 404)
|
||||
@@ -123,33 +144,71 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
self.assertEqual(config.base_url, "https://home.example")
|
||||
self.assertEqual(config.entity_ids, ("light.kitchen", "light.hall"))
|
||||
|
||||
def test_snapshot_filters_and_preserves_configured_order(self) -> None:
|
||||
snapshot = bridge.collect_snapshot(self.config())
|
||||
|
||||
self.assertTrue(snapshot["ok"])
|
||||
self.assertEqual(
|
||||
[item["name"] for item in snapshot["entities"]],
|
||||
["Kitchen", "Hall"],
|
||||
def test_config_does_not_require_legacy_entity_ids(self) -> None:
|
||||
self.assertTrue(
|
||||
bridge.Config(
|
||||
base_url="https://home.example",
|
||||
token="fixture-token",
|
||||
entity_ids=(),
|
||||
).configured
|
||||
)
|
||||
self.assertNotIn("sensor.private", json.dumps(snapshot))
|
||||
|
||||
def test_legacy_entity_ids_remain_migration_metadata(self) -> None:
|
||||
config = bridge.resolve_config(
|
||||
{
|
||||
"PANAMA_HOME_ASSISTANT_URL": "https://home.example",
|
||||
"PANAMA_HOME_ASSISTANT_TOKEN": "fixture-token",
|
||||
},
|
||||
legacy=lambda: bridge.Config(
|
||||
base_url="https://legacy.example",
|
||||
token="legacy-token",
|
||||
entity_ids=("light.kitchen",),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(config.base_url, "https://home.example")
|
||||
self.assertEqual(config.token, "fixture-token")
|
||||
self.assertEqual(config.entity_ids, ("light.kitchen",))
|
||||
|
||||
def test_catalog_filters_and_normalizes_discovered_lights(self) -> None:
|
||||
catalog = bridge.collect_catalog(self.config())
|
||||
|
||||
self.assertTrue(catalog["ok"])
|
||||
self.assertEqual(
|
||||
[item["sourceName"] for item in catalog["entities"]],
|
||||
["Kitchen", "Hall", "Corner"],
|
||||
)
|
||||
self.assertEqual(catalog["entities"][0]["brightnessPct"], 50)
|
||||
self.assertEqual(catalog["entities"][1]["brightnessPct"], 0)
|
||||
self.assertTrue(all(item["dimmable"] for item in catalog["entities"]))
|
||||
rendered = json.dumps(catalog)
|
||||
self.assertNotIn("sensor.private", rendered)
|
||||
self.assertNotIn("light.malformed", rendered)
|
||||
self.assertNotIn("token", rendered)
|
||||
|
||||
def test_snapshot_uses_bearer_authentication(self) -> None:
|
||||
bridge.collect_snapshot(self.config())
|
||||
bridge.collect_catalog(self.config())
|
||||
|
||||
request = FakeHomeAssistant.requests[-1]
|
||||
self.assertEqual(request["method"], "GET")
|
||||
self.assertEqual(request["path"], "/api/states")
|
||||
self.assertEqual(request["authorization"], "Bearer fixture-token")
|
||||
|
||||
def test_toggle_rejects_an_unconfigured_entity(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "entity-not-configured"):
|
||||
bridge.ensure_configured(
|
||||
"light.office",
|
||||
("light.kitchen",),
|
||||
)
|
||||
def test_snapshot_remains_a_catalog_compatibility_alias(self) -> None:
|
||||
snapshot = bridge.collect_snapshot(self.config())
|
||||
|
||||
def test_toggle_calls_the_homeassistant_service(self) -> None:
|
||||
result = bridge.toggle(self.config(), "light.kitchen")
|
||||
self.assertTrue(snapshot["ok"])
|
||||
self.assertEqual(
|
||||
[item["sourceName"] for item in snapshot["entities"]],
|
||||
["Kitchen", "Hall", "Corner"],
|
||||
)
|
||||
|
||||
def test_action_rejects_an_entity_not_in_the_live_catalog(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "entity-not-discovered"):
|
||||
bridge.toggle(self.config(), "light.office")
|
||||
|
||||
def test_toggle_authorizes_against_discovered_catalog(self) -> None:
|
||||
result = bridge.toggle(self.config(), "light.corner")
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
request = FakeHomeAssistant.requests[-1]
|
||||
@@ -157,9 +216,61 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
self.assertEqual(request["path"], "/api/services/homeassistant/toggle")
|
||||
self.assertEqual(
|
||||
json.loads(request["body"]),
|
||||
{"entity_id": "light.kitchen"},
|
||||
{"entity_id": "light.corner"},
|
||||
)
|
||||
|
||||
def test_brightness_uses_turn_on_for_positive_percent(self) -> None:
|
||||
bridge.set_brightness(self.config(), "light.kitchen", 62)
|
||||
|
||||
request = FakeHomeAssistant.requests[-1]
|
||||
self.assertEqual(request["path"], "/api/services/light/turn_on")
|
||||
self.assertEqual(
|
||||
json.loads(request["body"]),
|
||||
{"entity_id": "light.kitchen", "brightness_pct": 62},
|
||||
)
|
||||
|
||||
def test_brightness_zero_uses_turn_off(self) -> None:
|
||||
bridge.set_brightness(self.config(), "light.hall", 0)
|
||||
|
||||
request = FakeHomeAssistant.requests[-1]
|
||||
self.assertEqual(request["path"], "/api/services/light/turn_off")
|
||||
self.assertEqual(
|
||||
json.loads(request["body"]),
|
||||
{"entity_id": "light.hall"},
|
||||
)
|
||||
|
||||
def test_brightness_rejects_invalid_values_before_any_request(self) -> None:
|
||||
for value in (-1, 101, 1.5, "bright"):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(ValueError, "invalid-brightness"):
|
||||
bridge.set_brightness(self.config(), "light.kitchen", value)
|
||||
self.assertEqual(FakeHomeAssistant.requests, [])
|
||||
|
||||
def test_parse_brightness_rejects_invalid_cli_values(self) -> None:
|
||||
for value in ("-1", "101", "1.5", "bright"):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(ValueError, "invalid-brightness"):
|
||||
bridge.parse_brightness(value)
|
||||
|
||||
def test_qml_refreshes_through_the_catalog_command(self) -> None:
|
||||
self.assertIn('command: [root.helperPath, "catalog"]', QML_SERVICE.read_text())
|
||||
|
||||
def test_qml_composes_catalog_and_preferences_for_selected_entities(self) -> None:
|
||||
source = QML_SERVICE.read_text()
|
||||
|
||||
self.assertIn(
|
||||
"root.catalog = Array.isArray(result.entities) ? result.entities : [];",
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
"const favorites = root.fixtureMode\n"
|
||||
" ? root.fixtureFavorites\n"
|
||||
" : HomePreferences.favorites;",
|
||||
source,
|
||||
)
|
||||
self.assertIn("name: alias || entity.sourceName,", source)
|
||||
self.assertIn("root.selectedEntities = nextSelection;", source)
|
||||
|
||||
def test_authentication_error_is_redacted(self) -> None:
|
||||
self.assertEqual(
|
||||
bridge.public_http_error(401, "sensitive response"),
|
||||
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
phone_controls="$project_root/config/dot/quickshell/modules/quicksettings/PhoneControls.qml"
|
||||
phone_actions="$project_root/config/dot/quickshell/modules/quicksettings/PhoneActions.qml"
|
||||
quicksettings_qmldir="$project_root/config/dot/quickshell/modules/quicksettings/qmldir"
|
||||
system_settings="$project_root/config/dot/quickshell/services/SystemSettings.qml"
|
||||
harness="$project_root/config/dot/quickshell/phone-controls-harness.qml"
|
||||
kde_fixture="$project_root/tests/quickshell/fixtures/PhoneControlsKdeConnect.qml"
|
||||
settings_fixture="$project_root/tests/quickshell/fixtures/PhoneControlsSystemSettings.qml"
|
||||
state_home="$(mktemp -d /tmp/panama-phone-controls.XXXXXX)"
|
||||
config_path="$state_home/quickshell"
|
||||
|
||||
fail() {
|
||||
printf 'Phone Messages contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -f "$phone_controls" ]] || fail 'Phone controls component is missing'
|
||||
[[ -f "$phone_actions" ]] || fail 'Nonvisual Phone actions component is missing'
|
||||
[[ -f "$system_settings" ]] || fail 'System settings service is missing'
|
||||
[[ -f "$harness" ]] || fail 'Phone controls runtime harness is missing'
|
||||
[[ -f "$kde_fixture" ]] || fail 'Phone controls KDE fixture is missing'
|
||||
[[ -f "$settings_fixture" ]] || fail 'Phone controls System Settings fixture is missing'
|
||||
|
||||
rg -Fq 'PhoneActions 1.0 PhoneActions.qml' "$quicksettings_qmldir" \
|
||||
|| fail 'Phone actions component is not registered'
|
||||
rg -Fq 'PhoneActions {' "$phone_controls" \
|
||||
|| fail 'Phone controls do not consume the shared action model'
|
||||
rg -Fq 'readonly property var actionModels: phoneActions.actionModels' "$phone_controls" \
|
||||
|| fail 'Phone controls do not render the shared action models'
|
||||
rg -Fq 'PhoneActions {' "$harness" \
|
||||
|| fail 'Runtime harness does not use the nonvisual action model'
|
||||
if rg -Fq 'PhoneControls {' "$harness"; then
|
||||
fail 'Runtime harness still instantiates the visual Phone controls tree'
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
XDG_STATE_HOME="$state_home" QS_DISABLE_CRASH_HANDLER=1 \
|
||||
qs -p "$config_path/phone-controls-harness.qml" kill >/dev/null 2>&1 || true
|
||||
rm -rf "$state_home"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
qs_for_harness() {
|
||||
XDG_STATE_HOME="$state_home" QS_DISABLE_CRASH_HANDLER=1 \
|
||||
qs -p "$config_path/phone-controls-harness.qml" "$@"
|
||||
}
|
||||
|
||||
start_harness() {
|
||||
cp -a "$project_root/config/dot/quickshell" "$config_path"
|
||||
cp "$kde_fixture" "$config_path/services/KdeConnect.qml"
|
||||
cp "$settings_fixture" "$config_path/services/SystemSettings.qml"
|
||||
qs_for_harness --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
if qs_for_harness ipc show 2>/dev/null | rg -q '^target phone-controls-test$'; then
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
fail 'isolated Phone controls harness did not start'
|
||||
}
|
||||
|
||||
assert_fixture_state() {
|
||||
local fixture="$1"
|
||||
local expected="$2"
|
||||
|
||||
qs_for_harness ipc call phone-controls-test fixture "$fixture" >/dev/null
|
||||
local actual
|
||||
actual="$(qs_for_harness ipc call phone-controls-test status)"
|
||||
[[ -n "$actual" ]] || fail 'Phone controls do not expose runtime action enablement'
|
||||
jq -e --argjson expected "$expected" '
|
||||
.bluebubblesAvailable == true and .appLaunches == 0 and .phoneActions == 0 and
|
||||
(.actions | length == 4) and
|
||||
([.actions[] | select(.id == "messages") | .enabled] == [true]) and
|
||||
([.actions[] | select(.id != "messages") | .enabled] == $expected)
|
||||
' <<<"$actual" >/dev/null \
|
||||
|| fail "unexpected $fixture action state: $actual"
|
||||
}
|
||||
|
||||
# These source checks do not evaluate the launch branch. The isolated runtime
|
||||
# harness below uses fixture singletons and only reads action-model status.
|
||||
|
||||
rg -Fq '"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]' "$system_settings" \
|
||||
|| fail 'BlueBubbles does not use the fixed Flatpak argument vector'
|
||||
rg -Fq 'readonly property bool bluebubblesAvailable: root.bluebubblesDetected' "$system_settings" \
|
||||
|| fail 'BlueBubbles availability is not exposed independently'
|
||||
rg -Fq 'command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]' "$system_settings" \
|
||||
|| fail 'BlueBubbles installed-state probe is missing'
|
||||
|
||||
for action in share clipboard ring messages; do
|
||||
rg -Fq "id: \"$action\"" "$phone_actions" \
|
||||
|| fail "Phone actions do not include $action"
|
||||
done
|
||||
|
||||
if rg -Fq '.filter(' "$phone_actions"; then
|
||||
fail 'Phone actions are filtered instead of keeping four stable columns'
|
||||
fi
|
||||
rg -Fq 'columns: 4' "$phone_controls" \
|
||||
|| fail 'Phone actions do not use four equal columns'
|
||||
rg -Fq 'root.actionModels.length - 1' "$phone_controls" \
|
||||
|| fail 'Phone action widths are not calculated from the fixed model count'
|
||||
rg -Fq 'SystemSettings.bluebubblesAvailable' "$phone_controls" \
|
||||
|| fail 'Messages enablement does not read BlueBubbles availability'
|
||||
rg -Fq 'KdeConnect.phoneReachable' "$phone_controls" \
|
||||
|| fail 'KDE action reachability semantics are missing'
|
||||
rg -Fq 'function actionEnabled(action: string): bool' "$phone_controls" \
|
||||
|| fail 'Phone controls do not expose action enablement'
|
||||
rg -Fq 'KdeConnect.supports(action)' "$phone_actions" \
|
||||
|| fail 'KDE action capability semantics are missing'
|
||||
rg -Fq 'else if (action === "messages")' "$phone_controls" \
|
||||
|| fail 'Messages has no independent invocation branch'
|
||||
rg -Fq 'SystemSettings.openApplication("bluebubbles")' "$phone_controls" \
|
||||
|| fail 'Messages does not use the allow-listed BlueBubbles launcher'
|
||||
rg -Fq 'BlueBubbles is not installed' "$phone_controls" \
|
||||
|| fail 'Missing BlueBubbles has no quiet explanatory row'
|
||||
rg -Fq 'KDE Connect actions are unavailable until the iPhone reconnects' "$phone_controls" \
|
||||
|| fail 'Offline copy does not scope unavailability to KDE Connect actions'
|
||||
rg -Fq 'border.width: actionMouse.activeFocus ? 2 : 0' "$phone_controls" \
|
||||
|| fail 'Phone actions do not visibly indicate keyboard focus'
|
||||
rg -Fq 'Accessible.role: Accessible.Button' "$phone_controls" \
|
||||
|| fail 'Phone actions do not expose an Accessible button role'
|
||||
rg -Fq 'Accessible.name: actionButton.modelData.label' "$phone_controls" \
|
||||
|| fail 'Phone actions do not expose their accessible name'
|
||||
rg -Fq 'Accessible.description: root.actionAccessibleDescription(actionButton.modelData.id)' "$phone_controls" \
|
||||
|| fail 'Phone actions do not expose their availability state'
|
||||
rg -Fq 'Accessible.focusable: actionMouse.enabled' "$phone_controls" \
|
||||
|| fail 'Phone action accessibility does not respect disabled state'
|
||||
rg -Fq 'Accessible.onPressAction:' "$phone_controls" \
|
||||
|| fail 'Phone actions do not provide an accessible press handler'
|
||||
|
||||
# A static negative guard keeps the Messages branch from accidentally inheriting
|
||||
# KDE Connect reachability, transfer, or plugin conditions.
|
||||
messages_branch="$(sed -n '/else if (action === "messages")/,/^ }/p' "$phone_controls")"
|
||||
printf '%s\n' "$messages_branch" | rg -Fq 'KdeConnect.' \
|
||||
&& fail 'Messages is coupled to KDE Connect'
|
||||
|
||||
start_harness
|
||||
assert_fixture_state offline '[false, false, false]'
|
||||
assert_fixture_state unsupported '[true, false, false]'
|
||||
assert_fixture_state transfer '[false, false, false]'
|
||||
|
||||
printf 'Phone Messages contract: PASS\n'
|
||||
@@ -2,47 +2,135 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)"
|
||||
source_config_path="$repo_dir/config/dot/quickshell"
|
||||
config_path="$state_home/quickshell"
|
||||
test_bin="$state_home/bin"
|
||||
shell_log="$state_home/quickshell.log"
|
||||
|
||||
cleanup_bootstrap() {
|
||||
rm -rf "$state_home"
|
||||
}
|
||||
trap cleanup_bootstrap EXIT
|
||||
|
||||
mkdir -p "$test_bin"
|
||||
cp -a "$source_config_path" "$config_path"
|
||||
|
||||
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
case "${1:-}" in
|
||||
catalog)
|
||||
printf '%s\n' '{"ok":false,"error":"test-helper"}'
|
||||
;;
|
||||
toggle|brightness)
|
||||
printf '%s\n' '{"ok":true}'
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$config_path/scripts/panama-home-assistant"
|
||||
|
||||
cat >"$test_bin/hyprctl" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
|
||||
printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Settings contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${1:-}" == "keyword" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
exec /usr/sbin/hyprctl "$@"
|
||||
EOF
|
||||
chmod +x "$test_bin/hyprctl"
|
||||
|
||||
cat >"$test_bin/flatpak" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${1:-}" == "info" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 97
|
||||
EOF
|
||||
chmod +x "$test_bin/flatpak"
|
||||
|
||||
fail() {
|
||||
printf 'settings pages contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
qs_for_test() {
|
||||
PATH="$test_bin:$PATH" 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 ipc call settings close >/dev/null 2>&1 || true
|
||||
qs_for_test ipc call settings close >/dev/null 2>&1 || true
|
||||
if stop_test_shell; then
|
||||
rm -rf "$state_home"
|
||||
else
|
||||
printf 'settings pages contract: branch shell did not stop; retained %s\n' \
|
||||
"$state_home" >&2
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Start from a clean slate. Closing the Settings window is asynchronous: the
|
||||
# shell reports it closed as soon as it drops its own state, while the toplevel
|
||||
# survives until the compositor destroys it. Without this wait, running straight
|
||||
# after settings-window-contract sees the outgoing window and reads it as a
|
||||
# duplicate.
|
||||
qs ipc call settings close >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 40); do
|
||||
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 0' >/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 0' >/dev/null \
|
||||
|| fail 'a Settings window was still open when the contract started'
|
||||
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 settings$' >/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'
|
||||
}
|
||||
|
||||
pages=(home appearance displays connectivity desktop sound notifications screen-intelligence shortcuts services about)
|
||||
start_test_shell
|
||||
qs_for_test ipc call home-assistant fixture ready >/dev/null
|
||||
shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
|
||||
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
|
||||
|
||||
pages=(home appearance displays connectivity home-phone desktop sound notifications screen-intelligence shortcuts services about)
|
||||
for page in "${pages[@]}"; do
|
||||
qs ipc call settings page "$page" >/dev/null
|
||||
qs_for_test ipc call settings page "$page" >/dev/null
|
||||
for _ in $(seq 1 20); do
|
||||
[[ "$(qs ipc call settings status | jq -r .page)" == "$page" ]] && break
|
||||
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[[ "$(qs ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
|
||||
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null \
|
||||
|| fail "$page created a missing or duplicate Settings window"
|
||||
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
|
||||
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
||||
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
|
||||
|| fail "$page created a missing, floating, or duplicate Settings window"
|
||||
done
|
||||
|
||||
qs ipc call settings page '__unsupported__' >/dev/null
|
||||
[[ "$(qs ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
|
||||
qs_for_test ipc call settings page '__unsupported__' >/dev/null
|
||||
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
|
||||
|
||||
hyprctl -j binds | jq -e '.[] | select(.description == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|
||||
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|
||||
|| fail 'Super+I is not registered as Panama Settings'
|
||||
hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
|
||||
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
|
||||
|| fail 'Super+Shift+S is not registered as Screen Intelligence'
|
||||
|
||||
desktop_file="$HOME/.local/share/applications/panama-settings.desktop"
|
||||
@@ -57,4 +145,5 @@ desktop-file-validate "$intelligence_desktop_file" >/dev/null || fail 'Screen In
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
[[ ! -e "$state_home" ]] || fail 'temporary Settings state was not removed after shell exit'
|
||||
printf 'settings pages contract: PASS\n'
|
||||
|
||||
Reference in New Issue
Block a user