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
330 lines
13 KiB
Bash
Executable File
330 lines
13 KiB
Bash
Executable File
#!/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'
|