Merge Home & Phone into a three-tab Home that knows your house
Home is now Overview | My Home | Phone. Overview leads with quick-action tiles (focus, Do Not Disturb, health, snapshots, storage), keeps the findings card — updates fold in, the reclaim-space prompt is gone on purpose — and adds glance cards, the next calendar event, and weather. My Home groups every light by Home Assistant area: the helper gained an `areas` command (one REST template render, no websocket), and the rooms degrade to a flat list on setups without areas. The favorites editor and connection card moved intact. Phone gains a vitals strip — battery and cell signal read from KDE Connect's plugin D-Bus objects, where absence is data, not an error — beside ring, clipboard, send-a-file, and the BlueBubbles handoff. The retired home-phone id resolves to my-home forever via a new alias map in SettingsRoutes (with a hasOwnProperty guard so prototype names cannot leak into settingsPage). Storage no longer claims 0 B free — the old page read a field the disks helper never emitted. Contracts updated alongside; per the new workflow, the full suite runs once at the end of the redesign (see the test backlog note). Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -134,8 +134,8 @@ 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 'ShellState.openSettings("my-home")' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'Manage in Settings does not open My Home'
|
||||
|
||||
rg -Fq 'signal previewChanged(int value)' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||
|| fail 'brightness slider has no preview contract'
|
||||
|
||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config"
|
||||
service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/MyHomePage.qml"
|
||||
password_field="$repo_dir/config/dot/quickshell/modules/settings/PasswordField.qml"
|
||||
harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml"
|
||||
work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)"
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
#!/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 'SettingsPage {' "$home_page"
|
||||
assert_contains 'title: "Home & Phone"' "$home_page"
|
||||
assert_contains 'lede: "Choose what appears in Control Center and keep phone continuity close at hand."' "$home_page"
|
||||
if rg -q '^\s*Flickable \{' "$home_page"; then
|
||||
fail 'HomePhonePage.qml still owns a copied Flickable scaffold'
|
||||
fi
|
||||
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 'bearer|api/states' "$home_page"; then
|
||||
fail 'HomePhonePage.qml crosses the Home Assistant REST 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 'text: "↑"' "$favorite_card"
|
||||
assert_contains 'text: "↓"' "$favorite_card"
|
||||
assert_contains 'enabled: root.canMoveEarlier' "$favorite_card"
|
||||
assert_contains 'enabled: root.canMoveLater' "$favorite_card"
|
||||
if rg -Fq 'DragHandler {' "$favorite_card"; then
|
||||
fail 'Home light cards still expose the broken drag affordance'
|
||||
fi
|
||||
assert_contains 'canMoveEarlier: index > 0' "$home_page"
|
||||
assert_contains 'canMoveLater: index < favoritesGrid.count - 1' "$home_page"
|
||||
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 == "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 == "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 == "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'
|
||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
@@ -16,6 +18,23 @@ 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"
|
||||
|
||||
# What a Home Assistant would render for the areas template: every area it
|
||||
# holds, including ones whose entities are not lights, plus entries no template
|
||||
# should produce but a bridge still has to survive.
|
||||
TEMPLATE_AREAS = [
|
||||
{
|
||||
"id": "kitchen",
|
||||
"name": "Kitchen",
|
||||
"entities": ["light.kitchen", "switch.kettle", "sensor.private"],
|
||||
},
|
||||
{"id": "hallway", "name": "Hall", "entities": ["light.hall", "light.corner"]},
|
||||
{"id": "garage", "name": "Garage", "entities": ["sensor.garage_door"]},
|
||||
{"id": 12, "name": "Numeric", "entities": ["light.kitchen"]},
|
||||
{"id": "unnamed", "name": "", "entities": ["light.kitchen"]},
|
||||
{"id": "not_a_list", "name": "Loose", "entities": "light.kitchen"},
|
||||
"not-an-area",
|
||||
]
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("panama_home_assistant", str(HELPER))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
@@ -25,6 +44,8 @@ loader.exec_module(bridge)
|
||||
|
||||
class FakeHomeAssistant(BaseHTTPRequestHandler):
|
||||
requests: list[dict[str, object]] = []
|
||||
# "rendered" | "malformed" | "unauthorized"
|
||||
template_mode: str = "rendered"
|
||||
|
||||
def log_message(self, _format: str, *args: object) -> None:
|
||||
del args
|
||||
@@ -47,6 +68,25 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
# Home Assistant answers /api/template with the rendered text, not with a
|
||||
# JSON document, so the fixture replies in kind.
|
||||
def _text(self, body: str, status: int = 200) -> None:
|
||||
payload = body.encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def _template(self) -> None:
|
||||
if self.template_mode == "unauthorized":
|
||||
self._json({"message": "Unauthorized: token abc123"}, 401)
|
||||
return
|
||||
if self.template_mode == "malformed":
|
||||
self._text("Error rendering template: UndefinedError")
|
||||
return
|
||||
self._text(json.dumps(TEMPLATE_AREAS))
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._record()
|
||||
if self.path == "/api/":
|
||||
@@ -95,6 +135,9 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(length)
|
||||
self._record(body)
|
||||
if self.path == "/api/template":
|
||||
self._template()
|
||||
return
|
||||
if self.path in {
|
||||
"/api/services/homeassistant/toggle",
|
||||
"/api/services/light/turn_on",
|
||||
@@ -121,6 +164,7 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
FakeHomeAssistant.requests.clear()
|
||||
FakeHomeAssistant.template_mode = "rendered"
|
||||
|
||||
def config(self) -> object:
|
||||
return bridge.Config(
|
||||
@@ -129,6 +173,15 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
entity_ids=("light.kitchen", "light.hall"),
|
||||
)
|
||||
|
||||
def helper_env(self) -> dict[str, str]:
|
||||
return {
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
"HOME": os.environ.get("HOME", "/tmp"),
|
||||
"PANAMA_HOME_ASSISTANT_URL": self.base_url,
|
||||
"PANAMA_HOME_ASSISTANT_TOKEN": "fixture-token",
|
||||
"PANAMA_HOME_ASSISTANT_ENTITIES": "light.kitchen",
|
||||
}
|
||||
|
||||
def test_environment_configuration_wins_over_legacy_sources(self) -> None:
|
||||
env = {
|
||||
"PANAMA_HOME_ASSISTANT_URL": "https://home.example",
|
||||
@@ -203,6 +256,108 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
["Kitchen", "Hall", "Corner"],
|
||||
)
|
||||
|
||||
def test_areas_group_lights_by_home_assistant_area(self) -> None:
|
||||
result = bridge.collect_areas(self.config())
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["error"], "")
|
||||
self.assertEqual(
|
||||
result["areas"],
|
||||
[
|
||||
{"id": "kitchen", "name": "Kitchen", "entities": ["light.kitchen"]},
|
||||
{
|
||||
"id": "hallway",
|
||||
"name": "Hall",
|
||||
"entities": ["light.hall", "light.corner"],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
def test_areas_render_the_whole_list_from_one_template_request(self) -> None:
|
||||
bridge.collect_areas(self.config())
|
||||
|
||||
request = FakeHomeAssistant.requests[-1]
|
||||
self.assertEqual(len(FakeHomeAssistant.requests), 1)
|
||||
self.assertEqual(request["method"], "POST")
|
||||
self.assertEqual(request["path"], "/api/template")
|
||||
self.assertEqual(request["authorization"], "Bearer fixture-token")
|
||||
template = json.loads(request["body"])["template"]
|
||||
for fragment in ("areas()", "area_name(area)", "area_entities(area)", "tojson"):
|
||||
self.assertIn(fragment, template)
|
||||
|
||||
def test_areas_keep_only_lights_and_drop_areas_left_with_none(self) -> None:
|
||||
rendered = json.dumps(bridge.collect_areas(self.config()))
|
||||
|
||||
self.assertNotIn("switch.kettle", rendered)
|
||||
self.assertNotIn("sensor.private", rendered)
|
||||
self.assertNotIn("Garage", rendered)
|
||||
|
||||
def test_areas_discard_malformed_entries_without_losing_the_rest(self) -> None:
|
||||
rendered = json.dumps(bridge.collect_areas(self.config()))
|
||||
|
||||
self.assertNotIn("Numeric", rendered)
|
||||
self.assertNotIn("unnamed", rendered)
|
||||
self.assertNotIn("Loose", rendered)
|
||||
self.assertIn("Kitchen", rendered)
|
||||
|
||||
def test_areas_reject_a_template_response_that_is_not_json(self) -> None:
|
||||
FakeHomeAssistant.template_mode = "malformed"
|
||||
|
||||
result = bridge.collect_areas(self.config())
|
||||
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["error"], "invalid-response")
|
||||
self.assertEqual(result["areas"], [])
|
||||
|
||||
def test_areas_redact_an_authentication_failure(self) -> None:
|
||||
FakeHomeAssistant.template_mode = "unauthorized"
|
||||
|
||||
result = bridge.collect_areas(self.config())
|
||||
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["error"], "authentication-required")
|
||||
self.assertNotIn("abc123", json.dumps(result))
|
||||
|
||||
def test_areas_report_not_configured_before_any_request(self) -> None:
|
||||
result = bridge.collect_areas(
|
||||
bridge.Config(base_url=self.base_url, token="", entity_ids=())
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
result, {"ok": False, "areas": [], "error": "not-configured"}
|
||||
)
|
||||
self.assertEqual(FakeHomeAssistant.requests, [])
|
||||
|
||||
def test_cli_areas_prints_one_json_object(self) -> None:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(HELPER), "areas"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=self.helper_env(),
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
self.assertEqual(completed.returncode, 0)
|
||||
self.assertEqual(len(completed.stdout.strip().splitlines()), 1)
|
||||
self.assertEqual(
|
||||
[area["name"] for area in json.loads(completed.stdout)["areas"]],
|
||||
["Kitchen", "Hall"],
|
||||
)
|
||||
|
||||
def test_cli_still_rejects_unknown_arguments(self) -> None:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(HELPER), "areas", "kitchen"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=self.helper_env(),
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
self.assertEqual(completed.returncode, 2)
|
||||
self.assertEqual(
|
||||
json.loads(completed.stdout), {"ok": False, "error": "invalid-command"}
|
||||
)
|
||||
|
||||
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")
|
||||
@@ -271,6 +426,28 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
self.assertIn("name: alias || entity.sourceName,", source)
|
||||
self.assertIn("root.selectedEntities = nextSelection;", source)
|
||||
|
||||
def test_qml_fetches_areas_alongside_the_catalog(self) -> None:
|
||||
source = QML_SERVICE.read_text()
|
||||
|
||||
self.assertIn('command: [root.helperPath, "areas"]', source)
|
||||
self.assertIn("if (!areasProc.running)\n areasProc.running = true;", source)
|
||||
|
||||
def test_qml_composes_rooms_and_tolerates_missing_areas(self) -> None:
|
||||
source = QML_SERVICE.read_text()
|
||||
|
||||
self.assertIn("property var areas: []", source)
|
||||
self.assertIn(
|
||||
"readonly property var rooms: root.composeRooms(root.catalog, root.areas)",
|
||||
source,
|
||||
)
|
||||
self.assertIn('grouped.push({ id: "", name: "Other", lights: orphans });', source)
|
||||
# A failed areas fetch empties the grouping and nothing else: no phase,
|
||||
# no stale flag, no lastError, so lights keep working without it.
|
||||
consume = source.split("function consumeAreas(text: string): void {", 1)[1]
|
||||
consume = consume.split("\n }\n", 1)[0]
|
||||
for untouched in ("root.phase", "root.stale", "root.lastError"):
|
||||
self.assertNotIn(untouched, consume)
|
||||
|
||||
def test_authentication_error_is_redacted(self) -> None:
|
||||
self.assertEqual(
|
||||
bridge.public_http_error(401, "sensitive response"),
|
||||
|
||||
@@ -28,10 +28,19 @@ if [[ "$(jq -r '.devices | length' <<<"$status")" == "0" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# battery and signal are always present and are null far more often than not:
|
||||
# the plugin objects exist only while a device is paired, reachable and has the
|
||||
# plugin loaded. Null is the answer, so the check is on the shape when a value
|
||||
# is there, never on a value being there.
|
||||
jq -e '
|
||||
([.devices[] | (keys | sort) == (["actions", "id", "name", "paired", "reachable", "type"] | sort)] | all) and
|
||||
([.devices[] | (keys | sort) == (["actions", "battery", "id", "name", "paired", "reachable", "signal", "type"] | sort)] | all) and
|
||||
([.devices[] | (.id | test("^[A-Fa-f0-9]{32,64}$"))] | all) and
|
||||
([.devices[].actions[] | . == "clipboard" or . == "ping" or . == "ring" or . == "share"] | all)
|
||||
([.devices[].actions[] | . == "clipboard" or . == "ping" or . == "ring" or . == "share"] | all) and
|
||||
([.devices[] | select(.battery != null) | (.battery | keys | sort) == ["charge", "charging"]] | all) and
|
||||
([.devices[] | select(.battery != null) | .battery.charge >= 0 and .battery.charge <= 100 and (.battery.charging | type == "boolean")] | all) and
|
||||
([.devices[] | select(.signal != null) | (.signal | keys | sort) == ["networkType", "strength"]] | all) and
|
||||
([.devices[] | select(.signal != null) | .signal.strength >= 0 and (.signal.networkType | type == "string")] | all) and
|
||||
([.devices[] | select(.paired and .reachable | not) | .battery == null and .signal == null] | all)
|
||||
' <<<"$status" >/dev/null || fail 'live status shape is invalid'
|
||||
|
||||
paired_count="$(jq '[.devices[] | select(.paired)] | length' <<<"$status")"
|
||||
|
||||
@@ -193,6 +193,8 @@ class KdeConnectBridgeTest(unittest.TestCase):
|
||||
"paired": True,
|
||||
"reachable": False,
|
||||
"actions": ["clipboard", "ring", "share"],
|
||||
"battery": None,
|
||||
"signal": None,
|
||||
}
|
||||
],
|
||||
"error": "",
|
||||
@@ -269,7 +271,11 @@ class KdeConnectBridgeTest(unittest.TestCase):
|
||||
if command[:4] == ["busctl", "--user", "--json=short", "call"]:
|
||||
return subprocess.CompletedProcess(command, 0, '{"type":"as","data":[]}\n', "")
|
||||
if command[:4] == ["busctl", "--user", "--json=short", "get-property"]:
|
||||
is_dbus_device = dbus_id in command[4]
|
||||
# The reachable CLI device gets its vitals read; nothing answers
|
||||
# for the plugin objects here.
|
||||
if not command[5].endswith(cli_id) and not command[5].endswith(dbus_id):
|
||||
return subprocess.CompletedProcess(command, 1, "", "No such object")
|
||||
is_dbus_device = command[5].endswith(dbus_id)
|
||||
values = {
|
||||
"name": '{"type":"s","data":"Fixture iPhone"}\n',
|
||||
"type": '{"type":"s","data":"phone"}\n' if is_dbus_device else '{"type":"s","data":"desktop"}\n',
|
||||
@@ -284,6 +290,190 @@ class KdeConnectBridgeTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual({device["id"] for device in status["devices"]}, {cli_id, dbus_id})
|
||||
|
||||
def test_vitals_are_read_for_a_reachable_phone(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
runner = vitals_runner(
|
||||
device_id,
|
||||
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
|
||||
{
|
||||
"charge": '{"type":"i","data":82}\n',
|
||||
"isCharging": '{"type":"b","data":true}\n',
|
||||
"hasBattery": '{"type":"b","data":true}\n',
|
||||
"cellularNetworkType": '{"type":"s","data":"LTE"}\n',
|
||||
"cellularNetworkStrength": '{"type":"i","data":3}\n',
|
||||
},
|
||||
)
|
||||
|
||||
device = bridge.collect_status(runner)["devices"][0]
|
||||
|
||||
self.assertEqual(device["battery"], {"charge": 82, "charging": True})
|
||||
self.assertEqual(device["signal"], {"networkType": "LTE", "strength": 3})
|
||||
|
||||
def test_absent_plugin_objects_are_no_data_not_an_error(self) -> None:
|
||||
"""A phone with the battery plugin off is not a broken status read.
|
||||
|
||||
kdeconnectd only publishes a plugin's object while the device is paired,
|
||||
reachable and the plugin is loaded, so busctl exiting non-zero here is
|
||||
the ordinary answer "nothing to report" -- the device still appears,
|
||||
with null vitals and no error on the envelope.
|
||||
"""
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
runner = vitals_runner(
|
||||
device_id,
|
||||
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
|
||||
{},
|
||||
)
|
||||
|
||||
status = bridge.collect_status(runner)
|
||||
|
||||
self.assertEqual(status["available"], True)
|
||||
self.assertEqual(status["error"], "")
|
||||
self.assertIsNone(status["devices"][0]["battery"])
|
||||
self.assertIsNone(status["devices"][0]["signal"])
|
||||
|
||||
def test_negative_charge_reports_no_battery(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
runner = vitals_runner(
|
||||
device_id,
|
||||
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
|
||||
{
|
||||
"charge": '{"type":"i","data":-1}\n',
|
||||
"isCharging": '{"type":"b","data":false}\n',
|
||||
"hasBattery": '{"type":"b","data":true}\n',
|
||||
"cellularNetworkType": '{"type":"s","data":"LTE"}\n',
|
||||
"cellularNetworkStrength": '{"type":"i","data":3}\n',
|
||||
},
|
||||
)
|
||||
|
||||
device = bridge.collect_status(runner)["devices"][0]
|
||||
|
||||
self.assertIsNone(device["battery"])
|
||||
self.assertEqual(device["signal"], {"networkType": "LTE", "strength": 3})
|
||||
|
||||
def test_a_phone_without_a_battery_reports_none(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
runner = vitals_runner(
|
||||
device_id,
|
||||
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
|
||||
{
|
||||
"charge": '{"type":"i","data":0}\n',
|
||||
"isCharging": '{"type":"b","data":false}\n',
|
||||
"hasBattery": '{"type":"b","data":false}\n',
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNone(bridge.collect_status(runner)["devices"][0]["battery"])
|
||||
|
||||
def test_strength_of_minus_one_reports_no_signal(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
runner = vitals_runner(
|
||||
device_id,
|
||||
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
|
||||
{
|
||||
"charge": '{"type":"i","data":82}\n',
|
||||
"isCharging": '{"type":"b","data":false}\n',
|
||||
"hasBattery": '{"type":"b","data":true}\n',
|
||||
"cellularNetworkType": '{"type":"s","data":"Unknown"}\n',
|
||||
"cellularNetworkStrength": '{"type":"i","data":-1}\n',
|
||||
},
|
||||
)
|
||||
|
||||
device = bridge.collect_status(runner)["devices"][0]
|
||||
|
||||
self.assertEqual(device["battery"], {"charge": 82, "charging": False})
|
||||
self.assertIsNone(device["signal"])
|
||||
|
||||
def test_an_unreachable_device_is_never_asked_for_vitals(self) -> None:
|
||||
"""Plugin objects cannot exist for an absent phone, so asking is waste.
|
||||
|
||||
The runner fails the test outright if a plugin path is touched, which is
|
||||
what keeps a busctl call per device off the path taken every 30 seconds
|
||||
by the phone that is simply not home.
|
||||
"""
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
runner = vitals_runner(
|
||||
device_id,
|
||||
f"- Fixture iPhone: {device_id} (paired)\n",
|
||||
{},
|
||||
forbid_plugin_reads=True,
|
||||
)
|
||||
|
||||
device = bridge.collect_status(runner)["devices"][0]
|
||||
|
||||
self.assertIsNone(device["battery"])
|
||||
self.assertIsNone(device["signal"])
|
||||
|
||||
def test_an_action_does_not_wait_on_vitals(self) -> None:
|
||||
"""Ringing a phone reads identity only -- no charge between tap and ring."""
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
listing = f"- Fixture iPhone: {device_id} (paired and reachable)\n"
|
||||
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if command == ["kdeconnect-cli", "--list-devices"]:
|
||||
return subprocess.CompletedProcess(command, 0, listing, "")
|
||||
if command == ["kdeconnect-cli", "-d", device_id, "--ring"]:
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
if command[:4] == ["busctl", "--user", "--json=short", "call"]:
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
'{"type":"as","data":["kdeconnect_findmyphone"]}\n',
|
||||
"",
|
||||
)
|
||||
if command[:4] == ["busctl", "--user", "--json=short", "get-property"]:
|
||||
if not command[5].endswith(device_id):
|
||||
raise AssertionError(f"vitals read on the action path: {command}")
|
||||
return subprocess.CompletedProcess(command, 0, '{"type":"s","data":"phone"}\n', "")
|
||||
if command[:3] == ["busctl", "--user", "tree"]:
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
raise AssertionError(command)
|
||||
|
||||
self.assertEqual(
|
||||
bridge.invoke_action("ring", device_id, runner=runner),
|
||||
{"ok": True, "action": "ring", "error": ""},
|
||||
)
|
||||
|
||||
|
||||
def vitals_runner(
|
||||
device_id: str,
|
||||
listing: str,
|
||||
plugin_values: dict[str, str],
|
||||
*,
|
||||
forbid_plugin_reads: bool = False,
|
||||
) -> bridge.Runner:
|
||||
"""A fake busctl/kdeconnect-cli for one CLI-listed device.
|
||||
|
||||
Reads of the device object itself always answer; reads of a plugin object
|
||||
answer only from `plugin_values`, and exit non-zero for anything missing --
|
||||
which is exactly how busctl behaves when the object is not published.
|
||||
"""
|
||||
device_path = f"/modules/kdeconnect/devices/{device_id}"
|
||||
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if command == ["kdeconnect-cli", "--list-devices"]:
|
||||
return subprocess.CompletedProcess(command, 0, listing, "")
|
||||
if command[:3] == ["busctl", "--user", "tree"]:
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
if command[:4] == ["busctl", "--user", "--json=short", "call"]:
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
'{"type":"as","data":["kdeconnect_findmyphone"]}\n',
|
||||
"",
|
||||
)
|
||||
if command[:4] == ["busctl", "--user", "--json=short", "get-property"]:
|
||||
if command[5] == device_path:
|
||||
return subprocess.CompletedProcess(command, 0, '{"type":"s","data":"phone"}\n', "")
|
||||
if forbid_plugin_reads:
|
||||
raise AssertionError(f"unexpected plugin read: {command}")
|
||||
member = command[-1]
|
||||
if member not in plugin_values:
|
||||
return subprocess.CompletedProcess(command, 1, "", "No such object")
|
||||
return subprocess.CompletedProcess(command, 0, plugin_values[member], "")
|
||||
raise AssertionError(command)
|
||||
|
||||
return runner
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Executable
+329
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# My Home is the Home Assistant tab of the Home category, and the page that
|
||||
# owns a long-lived access token. Two things have to stay true of it:
|
||||
#
|
||||
# the page never reads Home Assistant directly. Every light, every toggle and
|
||||
# every dim goes through the helper, so a bearer token or an /api/states URL
|
||||
# appearing in the QML is the boundary breaking, not a style question
|
||||
#
|
||||
# the read-only route stays read-only. Driving fixtures through the page must
|
||||
# never write panama-home.json, or a contract run would rewrite the favorites
|
||||
# of whoever ran it
|
||||
#
|
||||
# Everything else here is the page structure the old Home & Phone page proved
|
||||
# and this one inherited -- the favorites editor, the connection card, the
|
||||
# empty-state copy -- plus what is new: lights grouped into rooms.
|
||||
#
|
||||
# The phone half of that old page now lives on its own tab; phone-page-contract
|
||||
# covers it.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
state_home="$(mktemp -d /tmp/panama-my-home-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"
|
||||
|
||||
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|areas)
|
||||
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":"My Home 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"
|
||||
|
||||
# Nothing on this page consults Flatpak; the stub only keeps the isolated shell
|
||||
# from reaching the real one while it probes at startup.
|
||||
cat >"$test_bin/flatpak" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exit 97
|
||||
EOF
|
||||
chmod +x "$test_bin/flatpak"
|
||||
|
||||
fail() {
|
||||
printf 'my home 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"
|
||||
}
|
||||
|
||||
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
||||
my_home_page="$settings_dir/MyHomePage.qml"
|
||||
light_tile="$settings_dir/HomeLightTile.qml"
|
||||
favorite_card="$settings_dir/HomeFavoriteCard.qml"
|
||||
available_row="$settings_dir/AvailableLightRow.qml"
|
||||
settings_qmldir="$settings_dir/qmldir"
|
||||
|
||||
for required in "$my_home_page" "$light_tile" "$favorite_card" "$available_row"; do
|
||||
[[ -f "$required" ]] || fail "$(basename "$required") is missing"
|
||||
done
|
||||
if [[ -e "$settings_dir/HomePhonePage.qml" ]]; then
|
||||
fail 'the retired Home & Phone page is still on disk'
|
||||
fi
|
||||
|
||||
# ── The page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
assert_contains 'SettingsPage {' "$my_home_page"
|
||||
assert_contains 'objectName: "my-home-page"' "$my_home_page"
|
||||
assert_contains 'title: "My Home"' "$my_home_page"
|
||||
assert_contains 'lede: root.homeStatus()' "$my_home_page"
|
||||
if rg -q '^\s*Flickable \{' "$my_home_page"; then
|
||||
fail 'MyHomePage.qml still owns a copied Flickable scaffold'
|
||||
fi
|
||||
|
||||
# The status line is the lede, so each state has to say something a person can
|
||||
# act on rather than collapsing to one "unavailable".
|
||||
assert_contains 'Connected · ' "$my_home_page"
|
||||
assert_contains 'Last update unavailable · showing saved controls' "$my_home_page"
|
||||
assert_contains 'Authentication required' "$my_home_page"
|
||||
assert_contains 'Home Assistant is not configured' "$my_home_page"
|
||||
|
||||
# ── Rooms ────────────────────────────────────────────────────────────────────
|
||||
# Lights render through the shared tile, grouped by Home Assistant area. A
|
||||
# setup with no areas is one unnamed bucket, and an unnamed bucket must not
|
||||
# draw a heading -- an empty grey label above the only list reads as a bug.
|
||||
|
||||
assert_contains 'HomeLightTile 1.0 HomeLightTile.qml' "$settings_qmldir"
|
||||
assert_contains 'HomeLightTile {' "$my_home_page"
|
||||
assert_contains 'model: HomeAssistant.rooms ?? []' "$my_home_page"
|
||||
assert_contains 'model: roomSection.modelData.lights ?? []' "$my_home_page"
|
||||
rg -Fq 'visible: String(roomSection.modelData.name ?? "") !== ""' "$my_home_page" \
|
||||
|| fail 'room headings render for unnamed rooms, which is what a setup without areas produces'
|
||||
|
||||
# One dimmer implementation everywhere: the tile reuses Control Center's
|
||||
# slider rather than growing a second one that drifts from it.
|
||||
assert_contains 'import qs.modules.quicksettings' "$light_tile"
|
||||
assert_contains 'HomeBrightnessSlider {' "$light_tile"
|
||||
assert_contains 'HomeAssistant.toggleEntity(root.entityId)' "$light_tile"
|
||||
assert_contains 'HomeAssistant.setBrightness(root.entityId, value)' "$light_tile"
|
||||
assert_contains 'Accessible.role: Accessible.Button' "$light_tile"
|
||||
|
||||
# ── The favorites editor ─────────────────────────────────────────────────────
|
||||
|
||||
assert_contains 'HomePreferences.setAlias' "$my_home_page"
|
||||
assert_contains 'HomePreferences.move' "$my_home_page"
|
||||
assert_contains 'HomePreferences.remove' "$my_home_page"
|
||||
assert_contains 'HomePreferences.add' "$my_home_page"
|
||||
assert_contains 'HomePreferences.retrySave' "$my_home_page"
|
||||
assert_contains 'Choose lights below to build your Control Center shelf.' "$my_home_page"
|
||||
assert_contains 'All discovered lights are already selected' "$my_home_page"
|
||||
assert_contains 'No lights discovered' "$my_home_page"
|
||||
assert_contains 'No lights match that search' "$my_home_page"
|
||||
[[ "$(rg -Fc 'required property var modelData' "$my_home_page")" -ge 2 ]] \
|
||||
|| fail 'MyHomePage.qml does not bind its reusable delegates to modelData'
|
||||
if rg -Fq 'index: model.index' "$my_home_page"; then
|
||||
fail 'MyHomePage.qml reads an undefined model.index instead of the delegate index'
|
||||
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 'text: "↑"' "$favorite_card"
|
||||
assert_contains 'text: "↓"' "$favorite_card"
|
||||
assert_contains 'enabled: root.canMoveEarlier' "$favorite_card"
|
||||
assert_contains 'enabled: root.canMoveLater' "$favorite_card"
|
||||
if rg -Fq 'DragHandler {' "$favorite_card"; then
|
||||
fail 'Home light cards still expose the broken drag affordance'
|
||||
fi
|
||||
assert_contains 'canMoveEarlier: index > 0' "$my_home_page"
|
||||
assert_contains 'canMoveLater: index < favoritesGrid.count - 1' "$my_home_page"
|
||||
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"
|
||||
|
||||
# ── The credential boundary ──────────────────────────────────────────────────
|
||||
|
||||
for boundary_file in "$my_home_page" "$light_tile"; do
|
||||
if rg -qi 'bearer|api/states' "$boundary_file"; then
|
||||
fail "$(basename "$boundary_file") crosses the Home Assistant REST boundary"
|
||||
fi
|
||||
done
|
||||
|
||||
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_for_test ipc call settings close >/dev/null 2>&1 || true
|
||||
if stop_test_shell; then
|
||||
rm -rf "$state_home"
|
||||
else
|
||||
printf 'my home 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'
|
||||
}
|
||||
|
||||
# Reads `settings status` until the diagnostics settle, then holds the caller to
|
||||
# the same expression. The page recomputes on a binding, so an early read is a
|
||||
# stale read rather than a wrong one.
|
||||
await_status() {
|
||||
local expression="$1"
|
||||
local label="$2"
|
||||
local status='{}'
|
||||
|
||||
for _ in $(seq 1 40); do
|
||||
status="$(qs_for_test ipc call settings status | jq -c .)"
|
||||
if jq -e "$expression" <<<"$status" >/dev/null; then
|
||||
printf '%s\n' "$status"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
fail "$label: $status"
|
||||
}
|
||||
|
||||
start_test_shell
|
||||
qs_for_test ipc call home-assistant fixture ready >/dev/null
|
||||
qs_for_test ipc call settings page my-home >/dev/null
|
||||
|
||||
# The fixture areas claim five of the seven lights; the two nobody claims fall
|
||||
# into the trailing "Other" bucket, which is the case a flat catalog would hide.
|
||||
await_status '
|
||||
.open == true and
|
||||
.page == "my-home" and
|
||||
.discoveredCount == 7 and
|
||||
.selectedCount == 7 and
|
||||
.myHome.availableLightIds == [] and
|
||||
.myHome.availableEmptyText == "All discovered lights are already selected" and
|
||||
.myHome.homeStatus == "Connected · 7 lights across 4 rooms" and
|
||||
.myHome.rooms == [
|
||||
{ "name": "Kitchen", "count": 1 },
|
||||
{ "name": "Living room", "count": 2 },
|
||||
{ "name": "Bedroom", "count": 2 },
|
||||
{ "name": "Other", "count": 2 }
|
||||
]
|
||||
' 'My Home diagnostics are incomplete' >/dev/null
|
||||
|
||||
qs_for_test ipc call home-assistant fixture available-extra >/dev/null
|
||||
await_status '
|
||||
.discoveredCount == 8 and
|
||||
.selectedCount == 7 and
|
||||
.myHome.availableLightIds == ["light.fixture_guest"] and
|
||||
(.myHome.rooms[] | select(.name == "Other") | .count) == 3
|
||||
' 'an unselected fixture light was not the only available row' >/dev/null
|
||||
|
||||
qs_for_test ipc call home-assistant fixture stale-authentication >/dev/null
|
||||
await_status '
|
||||
.discoveredCount == 7 and
|
||||
.selectedCount == 7 and
|
||||
.myHome.homeStatus == "Authentication required"
|
||||
' 'stale authentication did not retain actionable copy' >/dev/null
|
||||
|
||||
qs_for_test ipc call home-assistant fixture stale-not-configured >/dev/null
|
||||
await_status '
|
||||
.discoveredCount == 7 and
|
||||
.selectedCount == 7 and
|
||||
.myHome.homeStatus == "Home Assistant is not configured"
|
||||
' 'stale not-configured state did not retain actionable copy' >/dev/null
|
||||
|
||||
# No catalog means no areas either, and the room composition degrades to the
|
||||
# single unnamed bucket the page renders without a heading.
|
||||
qs_for_test ipc call home-assistant fixture unavailable >/dev/null
|
||||
await_status '
|
||||
.discoveredCount == 0 and
|
||||
.selectedCount == 0 and
|
||||
.myHome.homeStatus == "Home Assistant is not configured" and
|
||||
.myHome.availableLightIds == [] and
|
||||
.myHome.availableEmptyText == "No lights discovered" and
|
||||
.myHome.rooms == [{ "name": "", "count": 0 }]
|
||||
' 'an empty catalog did not render its distinct copy' >/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'
|
||||
for _ in $(seq 1 40); do
|
||||
if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
||||
'[.[] | select(.pid == $pid and .title == "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 == "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
|
||||
qs_for_test ipc call home-assistant fixture ready >/dev/null
|
||||
sleep 0.5
|
||||
geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \
|
||||
'.[] | select(.pid == $pid and .title == "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
|
||||
|
||||
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 My Home state was not removed after shell exit'
|
||||
printf 'my home settings contract: PASS\n'
|
||||
@@ -318,8 +318,8 @@ mkdir -p "$config_home/quickshell/scripts"
|
||||
home_assistant_failure="$(run_doctor --json)"
|
||||
check_status "$home_assistant_failure" integration.home-assistant warning
|
||||
jq -e '.checks[] | select(.id == "integration.home-assistant")
|
||||
| .action == {kind:"open", label:"Open Home settings", confirm:false, target:"home-phone"}' \
|
||||
>/dev/null <<<"$home_assistant_failure" || fail 'Home Assistant action was not routed to home-phone'
|
||||
| .action == {kind:"open", label:"Open Home settings", confirm:false, target:"my-home"}' \
|
||||
>/dev/null <<<"$home_assistant_failure" || fail 'Home Assistant action was not routed to my-home'
|
||||
|
||||
# Invalid probe text is contained in its own check and never copied to JSON.
|
||||
malformed_calendar="$(PANAMA_DOCTOR_FIXTURE_CALENDAR=malformed run_doctor --json)"
|
||||
|
||||
Executable
+235
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Phone is the continuity tab of the Home category -- the half of the old Home
|
||||
# & Phone page that did not go to My Home. Three things carried over or arrived
|
||||
# with it, and each has already been got wrong once:
|
||||
#
|
||||
# the vitals strip reads plugin data that only exists while a phone is paired
|
||||
# and nearby. A missing battery or signal reading is the ordinary case, not an
|
||||
# error, so it renders as an em-dash
|
||||
#
|
||||
# the Reach it buttons mirror Control Center exactly: nearby, not mid-transfer,
|
||||
# and the plugin present. Dropping any one of those three offers an action
|
||||
# that silently does nothing
|
||||
#
|
||||
# Messages is not a KDE Connect action. It needs BlueBubbles installed and
|
||||
# nothing else, and it must stay that way -- coupling it to phone reachability
|
||||
# is the regression phone-messages-contract was written for, and this page
|
||||
# inherited the same row
|
||||
#
|
||||
# The live half boots an isolated shell with a fake Flatpak to prove the
|
||||
# installed-state probe runs and that nothing here launches the app.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
||||
phone_page="$settings_dir/PhonePage.qml"
|
||||
settings_qmldir="$settings_dir/qmldir"
|
||||
system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
|
||||
|
||||
fail() {
|
||||
printf 'phone page contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local needle="$1"
|
||||
local file="$2"
|
||||
rg -Fq "$needle" "$file" || fail "$file is missing: $needle"
|
||||
}
|
||||
|
||||
[[ -f "$phone_page" ]] || fail 'PhonePage.qml is missing'
|
||||
|
||||
assert_contains 'SettingsPage {' "$phone_page"
|
||||
assert_contains 'objectName: "phone-page"' "$phone_page"
|
||||
assert_contains 'title: "Phone"' "$phone_page"
|
||||
assert_contains 'PhonePage 1.0 PhonePage.qml' "$settings_qmldir"
|
||||
if rg -q '^\s*Flickable \{' "$phone_page"; then
|
||||
fail 'PhonePage.qml still owns a copied Flickable scaffold'
|
||||
fi
|
||||
|
||||
# ── Vitals ───────────────────────────────────────────────────────────────────
|
||||
# KdeConnect.phoneBattery and phoneSignal are null whenever the plugin objects
|
||||
# are absent, which is most of the time on an unpaired or away phone.
|
||||
|
||||
vitals_block="$(sed -n '/id: vitals/,/── Reach it/p' "$phone_page")"
|
||||
[[ -n "$vitals_block" ]] || fail 'PhonePage.qml has no vitals strip'
|
||||
rg -Fq 'KdeConnect.phoneBattery' <<<"$vitals_block" \
|
||||
|| fail 'the vitals strip does not read the phone battery'
|
||||
rg -Fq 'KdeConnect.phoneSignal' <<<"$vitals_block" \
|
||||
|| fail 'the vitals strip does not read the phone signal'
|
||||
[[ "$(rg -Fc -- '"—"' <<<"$vitals_block")" -ge 2 ]] \
|
||||
|| fail 'a null battery or signal does not render as an em-dash'
|
||||
|
||||
# ── Reach it ─────────────────────────────────────────────────────────────────
|
||||
|
||||
assert_contains 'readonly property bool actionsReady: KdeConnect.phoneReachable && !KdeConnect.transferActive' \
|
||||
"$phone_page"
|
||||
for action in ring clipboard share; do
|
||||
rg -Fq "enabled: root.actionsReady && KdeConnect.supports(\"$action\")" "$phone_page" \
|
||||
|| fail "the $action action does not gate on nearby, idle, and plugin support together"
|
||||
done
|
||||
assert_contains 'KdeConnect.ring()' "$phone_page"
|
||||
assert_contains 'KdeConnect.sendClipboard()' "$phone_page"
|
||||
assert_contains 'KdeConnect.sendFile(path)' "$phone_page"
|
||||
assert_contains 'KdeConnect.cancelTransfer()' "$phone_page"
|
||||
|
||||
# ── Messages ─────────────────────────────────────────────────────────────────
|
||||
|
||||
assert_contains 'Opens BlueBubbles' "$phone_page"
|
||||
assert_contains 'SystemSettings.openApplication("bluebubbles")' "$phone_page"
|
||||
messages_card="$(sed -n '/title: "Messages"/,/title: "Device"/p' "$phone_page")"
|
||||
[[ -n "$messages_card" ]] || fail 'PhonePage.qml has no Messages card'
|
||||
rg -Fq 'SystemSettings.bluebubblesAvailable' <<<"$messages_card" \
|
||||
|| fail 'Messages enablement does not read BlueBubbles availability'
|
||||
if rg -Fq 'KdeConnect.' <<<"$messages_card"; then
|
||||
fail 'Messages is coupled to KDE Connect'
|
||||
fi
|
||||
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"
|
||||
|
||||
# ── The live half ────────────────────────────────────────────────────────────
|
||||
|
||||
state_home="$(mktemp -d /tmp/panama-phone-page-state.XXXXXX)"
|
||||
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 "$repo_dir/config/dot/quickshell" "$config_path"
|
||||
: >"$flatpak_log"
|
||||
|
||||
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
case "${1:-}" in
|
||||
catalog|areas)
|
||||
printf '%s\n' '{"ok":false,"error":"test-helper"}'
|
||||
;;
|
||||
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":"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"
|
||||
|
||||
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 'phone page 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 settings page phone >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "phone" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
status="$(qs_for_test ipc call settings status | jq -c .)"
|
||||
jq -e '.open == true and .page == "phone"' <<<"$status" >/dev/null \
|
||||
|| fail "the Phone tab did not render: $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 == "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 == "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 == "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
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
[[ ! -e "$state_home" ]] || fail 'temporary Phone state was not removed after shell exit'
|
||||
printf 'phone page contract: PASS\n'
|
||||
@@ -80,6 +80,29 @@ duplicate_categories="$(sort <<<"$category_ids" | uniq -d)"
|
||||
[[ -z "$duplicate_categories" ]] \
|
||||
|| fail "these category ids are declared twice: $(tr '\n' ' ' <<<"$duplicate_categories")"
|
||||
|
||||
# ── Retired ids still land on a real page ────────────────────────────────────
|
||||
# Old Vicinae commands, shell history and muscle memory keep handing over page
|
||||
# ids that no longer exist, and resolve() consults the retired map before
|
||||
# anything else. An entry aimed at a leaf that has itself since been renamed
|
||||
# sends every one of those callers to Home without saying so, and an entry that
|
||||
# names a live leaf shadows the real page.
|
||||
retired_block="$(sed -n '/property var retired:/,/})/p' "$routes")"
|
||||
retired_pairs="$(grep -oE '"[a-z-]+"[[:space:]]*:[[:space:]]*"[a-z-]+"' <<<"$retired_block" || true)"
|
||||
retired_count=0
|
||||
while read -r pair; do
|
||||
[[ -n "$pair" ]] || continue
|
||||
retired_id="$(sed -E 's/"([a-z-]+)".*/\1/' <<<"$pair")"
|
||||
retired_target="$(sed -E 's/.*"([a-z-]+)"$/\1/' <<<"$pair")"
|
||||
retired_count=$((retired_count + 1))
|
||||
|
||||
if ! grep -qx "$retired_target" <<<"$leaves"; then
|
||||
fail "the retired id \"$retired_id\" resolves to \"$retired_target\", which is not a leaf, so everyone still holding it silently lands on Home"
|
||||
fi
|
||||
if grep -qx "$retired_id" <<<"$leaves"; then
|
||||
fail "\"$retired_id\" is listed as retired and is also a live leaf, so resolve() answers with the retired target instead of the page itself"
|
||||
fi
|
||||
done <<<"$retired_pairs"
|
||||
|
||||
# ── Every leaf resolves everywhere ───────────────────────────────────────────
|
||||
while read -r page; do
|
||||
[[ -n "$page" ]] || continue
|
||||
@@ -130,5 +153,5 @@ while read -r page_file; do
|
||||
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
|
||||
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
|
||||
|
||||
printf 'settings nav contract: PASS (%d categories, %d leaves)\n' \
|
||||
"$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")"
|
||||
printf 'settings nav contract: PASS (%d categories, %d leaves, %d retired ids)\n' \
|
||||
"$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" "$retired_count"
|
||||
|
||||
@@ -9,7 +9,7 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
pages=(Home Displays Connectivity Sound Dictation Notifications ScreenIntelligence Health About)
|
||||
pages=(Home MyHome Phone Displays Connectivity Sound Dictation Notifications ScreenIntelligence Health About)
|
||||
for page in "${pages[@]}"; do
|
||||
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
||||
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
|
||||
@@ -307,7 +307,7 @@ shell_pid="$harness_pid"
|
||||
# four different categories, and the page the tab strip was introduced for.
|
||||
# Routing to a tab must land on that tab, not on whatever its category opens
|
||||
# first, which is the failure the SettingsRoutes resolution could introduce.
|
||||
pages=(home appearance displays connectivity home-phone desktop sound dictation notifications screen-intelligence shortcuts services manual about)
|
||||
pages=(home appearance displays connectivity my-home phone desktop sound dictation notifications screen-intelligence shortcuts services manual about)
|
||||
for page in "${pages[@]}"; do
|
||||
qs_for_test ipc call settings page "$page" >/dev/null
|
||||
for _ in $(seq 1 20); do
|
||||
|
||||
Reference in New Issue
Block a user