#!/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 if [[ "$command" == "$APP/Contents/MacOS/Aligner"* ]] \ || [[ "$command" == "/Applications/Aligner.app/Contents/MacOS/Aligner"* ]] \ || [[ "$command" == "$HOME/Applications/Aligner.app/Contents/MacOS/Aligner"* ]]; then echo "$pid" fi 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_index_sequence() { local target_index="$1" local symbols="1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ" if [ "$target_index" -ge "${#symbols}" ]; then fail "keyboard index sequence does not support target index $target_index" fi echo "${symbols:$target_index:1}" } normalize_keyboard_sequence() { local sequence="$1" local symbols="1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ" local normalized="" local IFS=, for raw_part in $sequence; do local part part="$(printf "%s" "$raw_part" | tr '[:lower:]' '[:upper:]')" if [ "${#part}" -eq 1 ] && [[ "$symbols" == *"$part"* ]]; then part="app:$part" else part="$raw_part" fi if [ -n "$normalized" ]; then normalized="$normalized,$part" else normalized="$part" fi done echo "$normalized" } 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("keyboardFocusedAppGroupIndex") == target_app_index, "keyboard App focus must target the requested App") require(root.get("hoveredAppGroupIndex") == target_app_index, "keyboard App focus must become hovered appGroupIndex") require(root.get("hoverTargetKind") == "app", "keyboard App focus must expose an App hover target") require(root.get("hoverTargetSource") == "keyboard", "keyboard App focus must be reported as keyboard sourced") 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(item.get("isSelected") is False, "keyboard App focus must not immediately move selected window") require(root.get("selectionChangedByLastCommand") is False, "keyboard App focus must not change selected 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-waterfall-view-mode=vertical --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="$(normalize_keyboard_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_index_sequence "$REGULAR_RIGHT_INDEX")" NARROW_KEY_SEQUENCE="$(keyboard_index_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