#!/bin/bash
|
# Round01 App Shelf fixture QA. It uses a deterministic Quick Switch snapshot
|
# with many apps so compression, two-row layout, scrolling, and hover visuals
|
# are covered without relying on the current desktop state.
|
|
set -euo pipefail
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
# shellcheck source=build-output-paths.sh
|
source "$SCRIPT_DIR/build-output-paths.sh"
|
APP="$BUILD_CURRENT_APP"
|
REPORT="$BUILD_REPORT_ROOT/round01-app-shelf-fixture-report.json"
|
NARROW_REPORT="$BUILD_REPORT_ROOT/round01-app-shelf-narrow-fixture-report.json"
|
FIXTURE_APP_COUNT="${ALIGNER_ROUND1_APP_SHELF_FIXTURE_COUNT:-60}"
|
HOVER_INDEX="${ALIGNER_ROUND1_APP_SHELF_HOVER_INDEX:-5}"
|
NARROW_FIXTURE_APP_COUNT="${ALIGNER_ROUND1_APP_SHELF_NARROW_FIXTURE_COUNT:-10}"
|
NARROW_HOVER_INDEX="${ALIGNER_ROUND1_APP_SHELF_NARROW_HOVER_INDEX:-5}"
|
NARROW_OVERLAY_WIDTH="${ALIGNER_ROUND1_APP_SHELF_NARROW_OVERLAY_WIDTH:-780}"
|
REPORT_WAIT="${ALIGNER_ROUND1_APP_SHELF_REPORT_WAIT:-6.0}"
|
|
fail() {
|
echo "Round01 App Shelf fixture QA failed: $*" >&2
|
exit 1
|
}
|
|
aligner_pids_for_current_app() {
|
ps -axo pid=,args= | while read -r pid command; do
|
case "$command" in
|
"$APP/Contents/MacOS/Aligner"*) echo "$pid" ;;
|
esac
|
done
|
}
|
|
stop_current_aligner() {
|
for pid in $(aligner_pids_for_current_app); do
|
kill "$pid" 2>/dev/null || true
|
done
|
|
for _ in {1..30}; do
|
[ -z "$(aligner_pids_for_current_app)" ] && return
|
sleep 0.1
|
done
|
|
fail "current Aligner app did not exit before QA"
|
}
|
|
wait_for_loaded_report() {
|
local report="$1"
|
/usr/bin/python3 - "$report" "$REPORT_WAIT" <<'PY'
|
import json
|
import sys
|
import time
|
|
path = sys.argv[1]
|
timeout = float(sys.argv[2])
|
deadline = time.monotonic() + timeout
|
last_report = None
|
|
while time.monotonic() < deadline:
|
try:
|
with open(path, "r", encoding="utf-8") as file:
|
report = json.load(file)
|
last_report = report
|
if report.get("snapshotLoaded") is True:
|
sys.exit(0)
|
except FileNotFoundError:
|
pass
|
except json.JSONDecodeError:
|
pass
|
time.sleep(0.2)
|
|
if last_report is not None:
|
print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr)
|
print(f"snapshot report did not become loaded within {timeout:.1f}s", file=sys.stderr)
|
sys.exit(1)
|
PY
|
}
|
|
assert_report() {
|
local report="$1"
|
local fixture_app_count="$2"
|
local hover_index="$3"
|
local expected_rows="$4"
|
local expected_icon_size="$5"
|
local expected_scrollable="$6"
|
/usr/bin/python3 - "$report" "$fixture_app_count" "$hover_index" "$expected_rows" "$expected_icon_size" "$expected_scrollable" <<'PY'
|
import json
|
import sys
|
|
path = sys.argv[1]
|
fixture_app_count = int(sys.argv[2])
|
hover_index = int(sys.argv[3])
|
expected_rows = int(sys.argv[4])
|
expected_icon_size = int(sys.argv[5])
|
expected_scrollable = sys.argv[6].lower() == "true"
|
|
with open(path, "r", encoding="utf-8") as file:
|
report = json.load(file)
|
|
def require(condition, message):
|
if not condition:
|
print(message, file=sys.stderr)
|
print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
|
sys.exit(1)
|
|
root = report.get("rootView", {})
|
items = root.get("appShelfItems", [])
|
columns = root.get("waterfallColumns", [])
|
segments = root.get("spaceLaneSegments", [])
|
|
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
|
require(report.get("appCount") == fixture_app_count, "fixture appCount must match requested count")
|
require(root.get("appShelfItemCount") == fixture_app_count, "App Shelf item count must match fixture count")
|
require(len(items) == fixture_app_count, "App Shelf item reports must match fixture count")
|
require(root.get("appShelfRows") == expected_rows, "fixture App Shelf row count must match expected rows")
|
require(root.get("appShelfMaxRows") == 2, "App Shelf max rows must be two")
|
require(root.get("appShelfIconSize") == expected_icon_size, "fixture App Shelf icon size must match expected size")
|
require(root.get("appShelfMinIconSize") == 36, "App Shelf min icon size must be 36pt")
|
require(root.get("appShelfSelectedIconSize") == 68, "App Shelf selected icon size must be 68pt")
|
require(root.get("appShelfHoverIconSize") == 68, "App Shelf hover icon size must be 68pt")
|
require(root.get("appShelfScrollable") is expected_scrollable, "fixture App Shelf scrollable state must match expected")
|
if expected_scrollable:
|
require(root.get("appShelfContentWidth", 0) > root.get("appShelfVisibleWidth", 0), "scrollable fixture App Shelf content must overflow visible width")
|
require(root.get("appShelfMaxScrollOffset", 0) > 0, "scrollable fixture App Shelf must expose positive max scroll offset")
|
else:
|
require(root.get("appShelfContentWidth", 0) <= root.get("appShelfVisibleWidth", 0), "non-scrollable fixture App Shelf content must fit visible width")
|
require(root.get("appShelfMaxScrollOffset", 0) == 0, "non-scrollable fixture App Shelf must expose zero max scroll offset")
|
require(root.get("appShelfHoveredIndex") == hover_index, "fixture App Shelf must expose requested hovered index")
|
require(len(columns) == fixture_app_count, "Waterfall column reports must match fixture count")
|
require(sum(1 for item in items if item.get("isSelected") is True) == 1, "App Shelf must expose one selected app")
|
require(sum(1 for item in items if item.get("isHovered") is True) == 1, "App Shelf must expose one hovered app")
|
require(sum(1 for column in columns if column.get("isHovered") is True) == 1, "Waterfall must expose one hovered column")
|
require(items[0].get("isSelected") is True, "fixture first app should be selected")
|
require(items[hover_index].get("isHovered") is True, "fixture requested app should be hovered")
|
require(columns[hover_index].get("isHovered") is True, "fixture requested app column should be hovered")
|
require("selectedVisualSuppressed" in items[0].get("visualStates", []), "non-hovered selected app must suppress selected visual state")
|
require("hover" in items[hover_index].get("visualStates", []), "hovered app must expose hover visual state")
|
require("hover" in columns[hover_index].get("visualStates", []), "hovered column must expose hover visual state")
|
require(items[0].get("iconFrame", {}).get("width") == expected_icon_size, "non-hovered selected app icon must match normal fixture icon size")
|
require(items[hover_index].get("iconFrame", {}).get("width") == 68, "hovered app icon must be 68pt")
|
|
blue_reference = {"red": 135 / 255, "green": 200 / 255, "blue": 1.0, "alpha": 1.0}
|
hovered_title_color = columns[hover_index].get("appNameColor", {})
|
hovered_header_color = columns[hover_index].get("headerBackgroundColor", {})
|
hovered_count_color = columns[hover_index].get("countColor", {})
|
for channel in ("red", "green", "blue"):
|
require(
|
abs(hovered_header_color.get(channel, -1) - blue_reference.get(channel, -2)) <= 0.002,
|
f"hovered column header must use #87C8FF for {channel}"
|
)
|
require(hovered_header_color.get("alpha", 0) >= 0.98, "hovered column header blue fill must be opaque")
|
for color_name, color in (("title", hovered_title_color), ("count", hovered_count_color)):
|
for channel in ("red", "green", "blue", "alpha"):
|
require(abs(color.get(channel, -1) - 1.0) <= 0.002, f"hovered column {color_name} text must be pure white for {channel}")
|
for index, column in enumerate(columns):
|
if index == hover_index:
|
continue
|
require(column.get("isHovered") is False, "non-hovered columns must not be marked hovered")
|
require("hover" not in column.get("visualStates", []), "non-hovered columns must not expose hover visual state")
|
normal_header = column.get("headerBackgroundColor", {})
|
require(
|
any(abs(normal_header.get(channel, -1) - blue_reference[channel]) > 0.05 for channel in ("red", "green", "blue")),
|
"non-hovered column headers must not use the hover blue fill"
|
)
|
|
normal_items = [
|
item for item in items
|
if item.get("isSelected") is not True and item.get("isHovered") is not True
|
]
|
require(normal_items, "fixture must include normal app items")
|
require(all(item.get("iconFrame", {}).get("width") == expected_icon_size for item in normal_items), "normal app icons must match expected fixture icon size")
|
require(all(item.get("labelTruncationMode") == "middle" for item in items), "all App Shelf labels must use middle truncation")
|
|
row_y_values = {round(item.get("frame", {}).get("y", 0), 2) for item in items}
|
require(len(row_y_values) == expected_rows, "fixture App Shelf visible rows must match expected rows")
|
require([item.get("name") for item in items] == root.get("appShelfNames", []), "App Shelf item reports must follow App Shelf order")
|
selected_z = items[0].get("zPosition", 0)
|
hover_z = items[hover_index].get("zPosition", 0)
|
normal_z = normal_items[0].get("zPosition", 0)
|
require(selected_z == normal_z, "non-hovered selected App Shelf item must not float above normal items")
|
require(hover_z > normal_z, "hover App Shelf item must stay above normal items")
|
|
print(json.dumps({
|
"appCount": report.get("appCount"),
|
"rows": root.get("appShelfRows"),
|
"iconSize": root.get("appShelfIconSize"),
|
"selectedIcon": items[0].get("iconFrame"),
|
"hoveredIndex": root.get("appShelfHoveredIndex"),
|
"hoveredIcon": items[hover_index].get("iconFrame"),
|
"hoveredColumnHeaderColor": hovered_header_color,
|
"hoveredColumnTitleColor": hovered_title_color,
|
"hoveredColumnCountColor": hovered_count_color,
|
"blueReference": blue_reference,
|
"selectedZ": selected_z,
|
"hoverZ": hover_z,
|
"scrollable": root.get("appShelfScrollable"),
|
"maxScrollOffset": root.get("appShelfMaxScrollOffset")
|
}, indent=2, ensure_ascii=False))
|
PY
|
}
|
|
run_fixture() {
|
local report="$1"
|
local app_count="$2"
|
local hover_index="$3"
|
local overlay_width="$4"
|
local expected_rows="$5"
|
local expected_icon_size="$6"
|
local expected_scrollable="$7"
|
|
stop_current_aligner
|
rm -f "$report"
|
|
local args=(
|
--round0-skip-permissions
|
--round01-open-quick-switch
|
--round01-fixture-app-count="$app_count"
|
--round01-disable-screenshot-refresh
|
--round01-waterfall-view-mode=vertical
|
--round01-debug-hover-app-index="$hover_index"
|
--round01-quick-switch-report="$report"
|
)
|
if [ -n "$overlay_width" ]; then
|
args+=(--round01-debug-overlay-width="$overlay_width")
|
fi
|
|
"$APP/Contents/MacOS/Aligner" "${args[@]}" &
|
APP_PID=$!
|
|
cleanup() {
|
kill "$APP_PID" 2>/dev/null || true
|
wait "$APP_PID" 2>/dev/null || true
|
stop_current_aligner
|
}
|
trap cleanup EXIT
|
|
wait_for_loaded_report "$report"
|
swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2
|
assert_report "$report" "$app_count" "$hover_index" "$expected_rows" "$expected_icon_size" "$expected_scrollable"
|
|
kill "$APP_PID" 2>/dev/null || true
|
wait "$APP_PID" 2>/dev/null || true
|
sleep 0.5
|
swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2
|
trap - EXIT
|
stop_current_aligner
|
}
|
|
stop_current_aligner
|
"$SCRIPT_DIR/package-app.sh" >&2
|
run_fixture "$REPORT" "$FIXTURE_APP_COUNT" "$HOVER_INDEX" "" 2 36 true
|
run_fixture "$NARROW_REPORT" "$NARROW_FIXTURE_APP_COUNT" "$NARROW_HOVER_INDEX" "$NARROW_OVERLAY_WIDTH" 2 56 false
|