#!/bin/bash # Round01.1 horizontal Waterfall fixture QA. It verifies the P1 masonry view: # no horizontal scroll, centered equal-width fixed-height cards, 150pt vertical # placement steps, App Shelf anchoring, and horizontal-mode Tab navigation. 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" LAYOUT_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-layout-report.json" KEYBOARD_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-keyboard-report.json" FILTER_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-filter-report.json" FIXTURE_APP_COUNT="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_FIXTURE_APP_COUNT:-8}" FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_FIXTURE_WINDOWS_PER_APP:-6}" HOVER_APP_INDEX="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_HOVER_APP_INDEX:-7}" KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_KEY_SEQUENCE:-tab}" REPORT_WAIT="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_REPORT_WAIT:-6.0}" fail() { echo "Round01.1 horizontal Waterfall 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 root = report.get("rootView", {}) if report.get("snapshotLoaded") is True and root.get("waterfallViewMode") == "horizontalMasonry": 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"horizontal Waterfall report did not become loaded within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } assert_layout_report() { /usr/bin/python3 - "$LAYOUT_REPORT" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" "$HOVER_APP_INDEX" <<'PY' import json import sys path = sys.argv[1] fixture_app_count = int(sys.argv[2]) fixture_windows_per_app = int(sys.argv[3]) hover_app_index = int(sys.argv[4]) 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", {}) columns = root.get("waterfallColumns", []) items = root.get("appShelfItems", []) waterfall_frame = root.get("waterfallFrame", {}) waterfall_height = waterfall_frame.get("height", 0) waterfall_width = root.get("waterfallVisibleWidth", 0) cards = [card for column in columns for card in column.get("cards", [])] require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("quickSwitchVisible") is True, "layout run must keep Quick Switch visible") require(root.get("waterfallViewMode") == "horizontalMasonry", "Waterfall view mode must be horizontalMasonry") 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("waterfallColumnCount") == fixture_app_count, "Waterfall app section count must match app count") require(len(columns) == fixture_app_count, "Waterfall column reports must match app count") require(len(items) == fixture_app_count, "App Shelf item reports must match app count") require(root.get("waterfallColumnNames") == root.get("appShelfNames"), "Waterfall app order must match App Shelf order") require(root.get("waterfallScrollable") is False, "horizontal masonry must not expose horizontal scroll") require(root.get("waterfallMaxScrollOffset") == 0, "horizontal masonry horizontal max scroll offset must be zero") require(abs(root.get("waterfallContentWidth", 0) - waterfall_width) <= 1.0, "horizontal masonry content width must equal visible width") require(root.get("horizontalMasonryScrollable") is True, "fixture must make horizontal masonry vertically scrollable") require(root.get("horizontalMasonryMaxScrollOffset", 0) > 0, "horizontal masonry must expose positive vertical max scroll offset") require(root.get("horizontalMasonryScrollOffset", 0) > 0, "hovering a later App must anchor masonry vertically") require(root.get("appShelfHoveredIndex") == hover_app_index, "debug hover must mark the requested App Shelf item") require(cards, "horizontal masonry fixture must expose cards") widths = [round(card.get("frame", {}).get("width", 0), 1) for card in cards] require(len(set(widths)) == 1, "horizontal masonry cards must be equal width") heights = [round(card.get("frame", {}).get("height", 0), 1) for card in cards] expected_card_height = 138.0 expected_thumbnail_height = 106.0 expected_card_step = 150.0 require(len(set(heights)) == 1, "horizontal masonry cards must use one fixed height") require(abs(heights[0] - expected_card_height) <= 1.0, "horizontal masonry card height must match vertical Waterfall 138pt height") require(all(card.get("frame", {}).get("x", -1) >= -1 for card in cards), "cards must not overflow left") require(all(card.get("frame", {}).get("x", 0) + card.get("frame", {}).get("width", 0) <= waterfall_width + 1 for card in cards), "cards must not overflow right") thumbnail_heights = [round(card.get("thumbnailFrame", {}).get("height", 0), 1) for card in cards] require(all(abs(height - expected_thumbnail_height) <= 1.0 for height in thumbnail_heights), "horizontal masonry thumbnails must use the same fixed card template height as vertical cards") require(all(card.get("titleBarFrame", {}).get("y", 0) >= card.get("thumbnailFrame", {}).get("y", 0) + card.get("thumbnailFrame", {}).get("height", 0) for card in cards), "title bars must remain above thumbnails") cards_by_lane = {} for card in cards: lane_x = round(card.get("frame", {}).get("x", 0), 1) cards_by_lane.setdefault(lane_x, []).append(card) lane_steps = [] for lane_cards in cards_by_lane.values(): ordered = sorted(lane_cards, key=lambda card: card.get("frame", {}).get("y", 0), reverse=True) for upper, lower in zip(ordered, ordered[1:]): lane_steps.append(round(upper.get("frame", {}).get("y", 0) - lower.get("frame", {}).get("y", 0), 1)) require(lane_steps, "fixture must place multiple cards in at least one horizontal masonry lane") require(all(abs(step - expected_card_step) <= 1.0 for step in lane_steps), "horizontal masonry vertical placement step must be 150pt") for expected_index, column in enumerate(columns): require(column.get("appGroupIndex") == expected_index, "appGroupIndex must remain sequential") require(column.get("windowCount") == fixture_windows_per_app, "each fixture App must keep its windows") column_cards = column.get("cards", []) require([card.get("windowIndex") for card in column_cards] == list(range(fixture_windows_per_app)), "window order must remain stable within each App") hover_column = columns[hover_app_index] hover_first_card = hover_column.get("cards", [])[0] hover_visible = hover_first_card.get("visibleFrame", {}) require(hover_visible.get("y", -9999) < waterfall_height + 1, "hovered App first card should be brought into vertical viewport") require(hover_visible.get("y", 0) + hover_visible.get("height", 0) > -1, "hovered App first card should not be fully below viewport") print(json.dumps({ "mode": root.get("waterfallViewMode"), "appCount": report.get("appCount"), "windowCount": report.get("windowCount"), "cardWidth": widths[0], "cardHeight": heights[0], "thumbnailHeight": thumbnail_heights[0], "cardStep": expected_card_step, "horizontalMasonryScrollOffset": root.get("horizontalMasonryScrollOffset"), "horizontalMasonryMaxScrollOffset": root.get("horizontalMasonryMaxScrollOffset") }, indent=2, ensure_ascii=False)) PY } assert_keyboard_report() { /usr/bin/python3 - "$KEYBOARD_REPORT" "$KEY_SEQUENCE" <<'PY' import json import sys path = sys.argv[1] key_sequence = [part for part in sys.argv[2].split(",") if part] 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", {}) columns = root.get("waterfallColumns", []) require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(root.get("waterfallViewMode") == "horizontalMasonry", "keyboard run must use horizontalMasonry") require(root.get("keyboardCommandsApplied") == key_sequence, "horizontal keyboard commands must be applied") require(root.get("tabIgnoredCount") == 0, "Tab must navigate in horizontal masonry mode") require(root.get("selectedAppGroupIndex") == 0, "single Tab from first card must stay in first App") require(root.get("selectedWindowIndex") == 1, "single Tab from first card must select the second laid card") selected = columns[0].get("cards", [])[1] require(root.get("selectedWindowID") == selected.get("windowID"), "selectedWindowID must match the second laid card") print(json.dumps({ "commands": root.get("keyboardCommandsApplied"), "tabIgnoredCount": root.get("tabIgnoredCount"), "selectedAppGroupIndex": root.get("selectedAppGroupIndex"), "selectedWindowIndex": root.get("selectedWindowIndex") }, indent=2, ensure_ascii=False)) PY } assert_filter_report() { /usr/bin/python3 - "$FILTER_REPORT" <<'PY' import json import sys path = sys.argv[1] 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", {}) columns = root.get("waterfallColumns", []) cards = [card for column in columns for card in column.get("cards", [])] require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("quickSwitchVisible") is True, "Space filter run must keep Quick Switch visible") require(root.get("waterfallViewMode") == "horizontalMasonry", "Space filter run must use horizontalMasonry") require(root.get("spaceFilterActive") is True, "clicking Space A1 must lock the filter") require(root.get("spaceFilterLockedSpaceID") == 1, "Space A1 must be the locked Space") require(root.get("appShelfNames") == ["Alpha Space App", "Beta Space App"], "horizontal Space filter must filter App Shelf") require(root.get("waterfallColumnNames") == ["Alpha Space App", "Beta Space App"], "horizontal Space filter must filter Waterfall sections") require([card.get("windowID") for card in cards] == [50101, 50102, 50201], "horizontal Space filter must expose only Space A1 windows") require(all(card.get("primarySpaceID") == 1 for card in cards), "all filtered horizontal cards must belong to Space A1") require(root.get("waterfallScrollable") is False, "horizontal Space filter must not expose horizontal scroll") require(root.get("projectionTransitionFadeOutLayerCount", 0) > 0, "horizontal Space filter must animate removed cards/apps") require(root.get("projectionTransitionDurationMilliseconds", 0) >= 120, "horizontal Space filter must report projection duration") widths = [round(card.get("frame", {}).get("width", 0), 1) for card in cards] require(len(set(widths)) == 1, "filtered horizontal cards must remain equal width") heights = [round(card.get("frame", {}).get("height", 0), 1) for card in cards] require(len(set(heights)) == 1 and abs(heights[0] - 138.0) <= 1.0, "filtered horizontal cards must keep the fixed 138pt height") visible_width = root.get("waterfallVisibleWidth", 0) require(all(card.get("frame", {}).get("x", -1) >= -1 for card in cards), "filtered cards must not overflow left") require(all(card.get("frame", {}).get("x", 0) + card.get("frame", {}).get("width", 0) <= visible_width + 1 for card in cards), "filtered cards must not overflow right") print(json.dumps({ "mode": root.get("waterfallViewMode"), "lockedSpaceID": root.get("spaceFilterLockedSpaceID"), "apps": root.get("appShelfNames"), "windowIDs": [card.get("windowID") for card in cards], "fadeOutLayerCount": root.get("projectionTransitionFadeOutLayerCount"), "transitionDurationMilliseconds": root.get("projectionTransitionDurationMilliseconds") }, indent=2, ensure_ascii=False)) PY } run_fixture() { local report="$1" shift rm -f "$report" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-fixture-app-count="$FIXTURE_APP_COUNT" \ --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \ --round01-disable-screenshot-refresh \ --round01-waterfall-view-mode=horizontal \ --round01-quick-switch-report="$report" \ "$@" & APP_PID=$! } run_space_filter_fixture() { local report="$1" rm -f "$report" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-fixture-space-filter \ --round01-disable-screenshot-refresh \ --round01-waterfall-view-mode=horizontal \ --round01-debug-mouse-sequence="click-space:1" \ --round01-quick-switch-report="$report" & APP_PID=$! } if [ "$FIXTURE_APP_COUNT" -le "$HOVER_APP_INDEX" ]; then fail "hover App index must be inside fixture app count" fi if [ "$FIXTURE_WINDOWS_PER_APP" -lt 2 ]; then fail "fixture must include at least two windows per App for Tab navigation" fi stop_current_aligner "$SCRIPT_DIR/package-app.sh" >&2 APP_PID="" cleanup() { if [ -n "${APP_PID:-}" ]; then kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true fi stop_current_aligner } trap cleanup EXIT run_fixture "$LAYOUT_REPORT" --round01-debug-mouse-sequence="hover-app:$HOVER_APP_INDEX" wait_for_loaded_report "$LAYOUT_REPORT" assert_layout_report kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true stop_current_aligner run_fixture "$KEYBOARD_REPORT" --round01-debug-key-sequence="$KEY_SEQUENCE" wait_for_loaded_report "$KEYBOARD_REPORT" assert_keyboard_report kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true stop_current_aligner run_space_filter_fixture "$FILTER_REPORT" wait_for_loaded_report "$FILTER_REPORT" assert_filter_report kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true trap - EXIT stop_current_aligner echo "Round01.1 horizontal Waterfall fixture QA passed"