#!/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_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-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="$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