#!/bin/bash
|
# Round01 App column alignment fixture QA. It verifies that App Shelf icon
|
# centers align with their matching Waterfall App window column centers after
|
# App hover and keyboard focus, including regular and narrow overlay widths.
|
|
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_DIR="$BUILD_REPORT_ROOT"
|
FIXTURE_APP_COUNT="${ALIGNER_ROUND1_APP_COLUMN_ALIGNMENT_FIXTURE_APP_COUNT:-8}"
|
FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_APP_COLUMN_ALIGNMENT_WINDOWS_PER_APP:-8}"
|
NARROW_FIXTURE_APP_COUNT="${ALIGNER_ROUND1_APP_COLUMN_ALIGNMENT_NARROW_FIXTURE_APP_COUNT:-10}"
|
NARROW_WINDOWS_PER_APP="${ALIGNER_ROUND1_APP_COLUMN_ALIGNMENT_NARROW_WINDOWS_PER_APP:-8}"
|
REGULAR_OVERLAY_WIDTH="${ALIGNER_ROUND1_APP_COLUMN_ALIGNMENT_REGULAR_WIDTH:-1180}"
|
NARROW_OVERLAY_WIDTH="${ALIGNER_ROUND1_APP_COLUMN_ALIGNMENT_NARROW_WIDTH:-780}"
|
ALIGNMENT_TOLERANCE="${ALIGNER_ROUND1_APP_COLUMN_ALIGNMENT_TOLERANCE:-1.5}"
|
REPORT_WAIT="${ALIGNER_ROUND1_APP_COLUMN_ALIGNMENT_REPORT_WAIT:-6.0}"
|
APP_PID=""
|
|
fail() {
|
echo "Round01 App column alignment 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"
|
}
|
|
cleanup() {
|
if [ -n "${APP_PID:-}" ]; then
|
kill "$APP_PID" 2>/dev/null || true
|
wait "$APP_PID" 2>/dev/null || true
|
APP_PID=""
|
fi
|
stop_current_aligner
|
}
|
|
keyboard_right_sequence() {
|
local target_index="$1"
|
local sequence=""
|
|
for ((index = 0; index < target_index; index++)); do
|
if [ -n "$sequence" ]; then
|
sequence="$sequence,right"
|
else
|
sequence="right"
|
fi
|
done
|
|
echo "$sequence"
|
}
|
|
wait_for_report() {
|
local report="$1"
|
local expected_mouse_sequence="$2"
|
local expected_key_sequence="$3"
|
|
/usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_mouse_sequence" "$expected_key_sequence" <<'PY'
|
import json
|
import sys
|
import time
|
|
path = sys.argv[1]
|
timeout = float(sys.argv[2])
|
expected_mouse_sequence = [part for part in sys.argv[3].split(",") if part]
|
expected_key_sequence = [part for part in sys.argv[4].split(",") if part]
|
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
|
root = report.get("rootView", {})
|
if report.get("snapshotLoaded") is True and report.get("quickSwitchVisible") is True:
|
mouse_ok = not expected_mouse_sequence or root.get("mouseCommandsApplied") == expected_mouse_sequence
|
key_ok = not expected_key_sequence or root.get("keyboardCommandsApplied") == expected_key_sequence
|
if mouse_ok and key_ok:
|
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"alignment report did not reach expected debug commands within {timeout:.1f}s", file=sys.stderr)
|
sys.exit(1)
|
PY
|
}
|
|
assert_alignment_report() {
|
local report="$1"
|
local case_name="$2"
|
local fixture_app_count="$3"
|
local fixture_windows_per_app="$4"
|
local target_app_index="$5"
|
local mode="$6"
|
local expected_mouse_sequence="$7"
|
local expected_key_sequence="$8"
|
|
/usr/bin/python3 - \
|
"$report" \
|
"$case_name" \
|
"$fixture_app_count" \
|
"$fixture_windows_per_app" \
|
"$target_app_index" \
|
"$mode" \
|
"$ALIGNMENT_TOLERANCE" \
|
"$expected_mouse_sequence" \
|
"$expected_key_sequence" <<'PY'
|
import json
|
import sys
|
|
path = sys.argv[1]
|
case_name = sys.argv[2]
|
fixture_app_count = int(sys.argv[3])
|
fixture_windows_per_app = int(sys.argv[4])
|
target_app_index = int(sys.argv[5])
|
mode = sys.argv[6]
|
tolerance = float(sys.argv[7])
|
expected_mouse_sequence = [part for part in sys.argv[8].split(",") if part]
|
expected_key_sequence = [part for part in sys.argv[9].split(",") if part]
|
|
with open(path, "r", encoding="utf-8") as file:
|
report = json.load(file)
|
|
def require(condition, message):
|
if not condition:
|
print(f"{case_name}: {message}", file=sys.stderr)
|
print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
|
sys.exit(1)
|
|
def frame_number(frame, key, label):
|
require(isinstance(frame, dict), f"{label} must be a frame dictionary")
|
require(key in frame, f"{label}.{key} must exist")
|
try:
|
return float(frame[key])
|
except (TypeError, ValueError):
|
require(False, f"{label}.{key} must be numeric")
|
raise AssertionError("unreachable")
|
|
root = report.get("rootView", {})
|
items = root.get("appShelfItems", [])
|
columns = root.get("waterfallColumns", [])
|
|
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
|
require(report.get("quickSwitchVisible") is True, "alignment fixture must keep Quick Switch visible")
|
require(report.get("appCount") == fixture_app_count, "fixture appCount must match requested count")
|
require(report.get("windowCount") == fixture_app_count * fixture_windows_per_app, "fixture windowCount must match requested shape")
|
require(root.get("appShelfItemCount") == fixture_app_count, "App Shelf item count must match fixture count")
|
require(root.get("waterfallColumnCount") == fixture_app_count, "Waterfall column count must match fixture count")
|
require(len(items) == fixture_app_count, "App Shelf item reports must match fixture count")
|
require(len(columns) == fixture_app_count, "Waterfall column reports must match fixture count")
|
require(root.get("waterfallScrollable") is True, "fixture must make Waterfall horizontally scrollable")
|
require(root.get("waterfallMaxScrollOffset", 0) > 0, "Waterfall must expose horizontal overflow")
|
|
item = next((candidate for candidate in items if candidate.get("index") == target_app_index), None)
|
column = next((candidate for candidate in columns if candidate.get("appGroupIndex") == target_app_index), None)
|
require(item is not None, "target App Shelf item must exist")
|
require(column is not None, "target Waterfall column must exist")
|
require(item.get("name") == column.get("appName"), "target App Shelf item and Waterfall column must describe the same App")
|
|
if mode == "mouse":
|
require(root.get("mouseCommandsApplied") == expected_mouse_sequence, "debug mouse sequence must be applied in order")
|
require(root.get("lastMouseCommand") == expected_mouse_sequence[-1], "last mouse command must match the alignment target")
|
require(root.get("hoveredAppGroupIndex") == target_app_index, "hover target must become hovered appGroupIndex")
|
require(item.get("isHovered") is True, "target App Shelf item must expose hover state")
|
require("hover" in item.get("visualStates", []), "target App Shelf item must expose hover visual state")
|
require(root.get("lastCommittedWindowID") is None, "hover alignment must not commit a window")
|
elif mode == "keyboard":
|
require(root.get("keyboardCommandsApplied") == expected_key_sequence, "debug key sequence must be applied in order")
|
require(root.get("lastKeyboardCommand") == expected_key_sequence[-1], "last keyboard command must match the alignment target")
|
require(root.get("selectedAppGroupIndex") == target_app_index, "keyboard focus must select the target appGroupIndex")
|
require(item.get("isSelected") is True, "target App Shelf item must expose selected state")
|
require("selectedVisualSuppressed" in item.get("visualStates", []), "non-hovered keyboard-selected App Shelf item must suppress selected visual state")
|
cards = column.get("cards", [])
|
require(cards, "target Waterfall column must include cards")
|
require(root.get("selectedWindowID") == cards[0].get("windowID"), "right-arrow focus into target column must select its first window")
|
require(root.get("lastCommittedWindowID") is None, "keyboard alignment without Enter must not commit a window")
|
else:
|
require(False, f"unknown alignment mode {mode!r}")
|
|
item_visible_frame = item.get("visibleFrame", {})
|
icon_frame = item.get("iconFrame", {})
|
column_visible_frame = column.get("visibleFrame", {})
|
|
app_icon_center_x = (
|
frame_number(item_visible_frame, "x", "item.visibleFrame")
|
+ frame_number(icon_frame, "x", "item.iconFrame")
|
+ frame_number(icon_frame, "width", "item.iconFrame") / 2
|
)
|
column_center_x = (
|
frame_number(column_visible_frame, "x", "column.visibleFrame")
|
+ frame_number(column_visible_frame, "width", "column.visibleFrame") / 2
|
)
|
delta = column_center_x - app_icon_center_x
|
|
require(abs(delta) <= tolerance, f"target App icon center and Waterfall column center must align within {tolerance:.1f}pt; delta={delta:.3f}")
|
|
print(json.dumps({
|
"case": case_name,
|
"mode": mode,
|
"targetAppIndex": target_app_index,
|
"targetAppName": item.get("name"),
|
"appIconCenterX": app_icon_center_x,
|
"columnCenterX": column_center_x,
|
"delta": delta,
|
"tolerance": tolerance,
|
"waterfallScrollOffset": root.get("waterfallScrollOffset"),
|
"waterfallMaxScrollOffset": root.get("waterfallMaxScrollOffset")
|
}, indent=2, ensure_ascii=False))
|
PY
|
}
|
|
run_fixture() {
|
local case_name="$1"
|
local report="$2"
|
local app_count="$3"
|
local windows_per_app="$4"
|
local overlay_width="$5"
|
local mode="$6"
|
local sequence="$7"
|
local target_app_index="$8"
|
local expected_mouse_sequence=""
|
local expected_key_sequence=""
|
|
stop_current_aligner
|
rm -f "$report"
|
|
local args=(
|
--round0-skip-permissions
|
--round01-open-quick-switch
|
--round01-fixture-app-count="$app_count"
|
--round01-fixture-windows-per-app="$windows_per_app"
|
--round01-debug-overlay-width="$overlay_width"
|
--round01-disable-screenshot-refresh
|
--round01-quick-switch-report="$report"
|
)
|
|
if [ "$mode" = "mouse" ]; then
|
expected_mouse_sequence="$sequence"
|
args+=(--round01-debug-mouse-sequence="$sequence")
|
elif [ "$mode" = "keyboard" ]; then
|
expected_key_sequence="$sequence"
|
args+=(--round01-debug-key-sequence="$sequence")
|
else
|
fail "unknown fixture mode: $mode"
|
fi
|
|
"$APP/Contents/MacOS/Aligner" "${args[@]}" &
|
APP_PID=$!
|
|
wait_for_report "$report" "$expected_mouse_sequence" "$expected_key_sequence"
|
swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2
|
assert_alignment_report \
|
"$report" \
|
"$case_name" \
|
"$app_count" \
|
"$windows_per_app" \
|
"$target_app_index" \
|
"$mode" \
|
"$expected_mouse_sequence" \
|
"$expected_key_sequence"
|
|
kill "$APP_PID" 2>/dev/null || true
|
wait "$APP_PID" 2>/dev/null || true
|
APP_PID=""
|
sleep 0.3
|
swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2
|
stop_current_aligner
|
}
|
|
if [ "$FIXTURE_APP_COUNT" -lt 8 ]; then
|
fail "regular fixture app count must be at least 8"
|
fi
|
if [ "$NARROW_FIXTURE_APP_COUNT" -lt 8 ]; then
|
fail "narrow fixture app count must be at least 8"
|
fi
|
|
REGULAR_MIDDLE_INDEX=$((FIXTURE_APP_COUNT / 2))
|
REGULAR_RIGHT_INDEX=$((FIXTURE_APP_COUNT - 1))
|
NARROW_MIDDLE_INDEX=$((NARROW_FIXTURE_APP_COUNT / 2))
|
NARROW_RIGHT_INDEX=$((NARROW_FIXTURE_APP_COUNT - 1))
|
REGULAR_KEY_SEQUENCE="$(keyboard_right_sequence "$REGULAR_RIGHT_INDEX")"
|
NARROW_KEY_SEQUENCE="$(keyboard_right_sequence "$NARROW_RIGHT_INDEX")"
|
|
trap cleanup EXIT
|
stop_current_aligner
|
"$SCRIPT_DIR/package-app.sh" >&2
|
|
run_fixture \
|
"regular-hover-middle" \
|
"$REPORT_DIR/round01-app-column-alignment-regular-hover-middle-report.json" \
|
"$FIXTURE_APP_COUNT" \
|
"$FIXTURE_WINDOWS_PER_APP" \
|
"$REGULAR_OVERLAY_WIDTH" \
|
"mouse" \
|
"hover-app:$REGULAR_MIDDLE_INDEX" \
|
"$REGULAR_MIDDLE_INDEX"
|
|
run_fixture \
|
"regular-hover-rightmost" \
|
"$REPORT_DIR/round01-app-column-alignment-regular-hover-rightmost-report.json" \
|
"$FIXTURE_APP_COUNT" \
|
"$FIXTURE_WINDOWS_PER_APP" \
|
"$REGULAR_OVERLAY_WIDTH" \
|
"mouse" \
|
"hover-app:$REGULAR_RIGHT_INDEX" \
|
"$REGULAR_RIGHT_INDEX"
|
|
run_fixture \
|
"regular-keyboard-rightmost" \
|
"$REPORT_DIR/round01-app-column-alignment-regular-keyboard-rightmost-report.json" \
|
"$FIXTURE_APP_COUNT" \
|
"$FIXTURE_WINDOWS_PER_APP" \
|
"$REGULAR_OVERLAY_WIDTH" \
|
"keyboard" \
|
"$REGULAR_KEY_SEQUENCE" \
|
"$REGULAR_RIGHT_INDEX"
|
|
run_fixture \
|
"narrow-hover-middle" \
|
"$REPORT_DIR/round01-app-column-alignment-narrow-hover-middle-report.json" \
|
"$NARROW_FIXTURE_APP_COUNT" \
|
"$NARROW_WINDOWS_PER_APP" \
|
"$NARROW_OVERLAY_WIDTH" \
|
"mouse" \
|
"hover-app:$NARROW_MIDDLE_INDEX" \
|
"$NARROW_MIDDLE_INDEX"
|
|
run_fixture \
|
"narrow-hover-rightmost" \
|
"$REPORT_DIR/round01-app-column-alignment-narrow-hover-rightmost-report.json" \
|
"$NARROW_FIXTURE_APP_COUNT" \
|
"$NARROW_WINDOWS_PER_APP" \
|
"$NARROW_OVERLAY_WIDTH" \
|
"mouse" \
|
"hover-app:$NARROW_RIGHT_INDEX" \
|
"$NARROW_RIGHT_INDEX"
|
|
run_fixture \
|
"narrow-keyboard-rightmost" \
|
"$REPORT_DIR/round01-app-column-alignment-narrow-keyboard-rightmost-report.json" \
|
"$NARROW_FIXTURE_APP_COUNT" \
|
"$NARROW_WINDOWS_PER_APP" \
|
"$NARROW_OVERLAY_WIDTH" \
|
"keyboard" \
|
"$NARROW_KEY_SEQUENCE" \
|
"$NARROW_RIGHT_INDEX"
|
|
trap - EXIT
|
stop_current_aligner
|