#!/bin/bash
|
# Round01 three-zone linkage fixture QA. It verifies Space Lane focus drives
|
# App Shelf highlights/badges and Waterfall positioning.
|
|
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_LINKAGE_FIXTURE_APP_COUNT:-8}"
|
FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_LINKAGE_FIXTURE_WINDOWS_PER_APP:-8}"
|
REPORT_WAIT="${ALIGNER_ROUND1_LINKAGE_REPORT_WAIT:-6.0}"
|
OVERLAY_WIDTH="${ALIGNER_ROUND1_LINKAGE_OVERLAY_WIDTH:-1180}"
|
LOCK_DIR="$BUILD_LOCK_ROOT/round1-three-zone-linkage-fixture-qa.lock"
|
APP_PID=""
|
LOCK_HELD=0
|
|
fail() {
|
echo "Round01 three-zone linkage fixture QA failed: $*" >&2
|
exit 1
|
}
|
|
REMOTE_APP_INDEX=$((FIXTURE_APP_COUNT - 1))
|
if [ "$FIXTURE_APP_COUNT" -lt 8 ]; then
|
fail "fixture app count must be at least 8 to verify right-side remote App linkage"
|
fi
|
if [ "$FIXTURE_WINDOWS_PER_APP" -lt 8 ]; then
|
fail "fixture windows per app must be at least 8 so every fixture App has a Space A1 window"
|
fi
|
|
acquire_lock() {
|
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
fail "another round1-three-zone-linkage-fixture-qa.sh appears to be running; run packaging QA scripts serially"
|
fi
|
LOCK_HELD=1
|
}
|
|
release_lock() {
|
if [ "$LOCK_HELD" -eq 1 ]; then
|
rmdir "$LOCK_DIR" 2>/dev/null || true
|
LOCK_HELD=0
|
fi
|
}
|
|
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
|
release_lock
|
}
|
|
wait_for_report() {
|
local report="$1"
|
local expected_last_mouse="$2"
|
|
/usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_last_mouse" <<'PY'
|
import json
|
import sys
|
import time
|
|
path = sys.argv[1]
|
timeout = float(sys.argv[2])
|
expected_last_mouse = sys.argv[3]
|
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("lastMouseCommand") == expected_last_mouse
|
):
|
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"report did not reach expected mouse command {expected_last_mouse!r} within {timeout:.1f}s", file=sys.stderr)
|
sys.exit(1)
|
PY
|
}
|
|
run_report() {
|
local report="$1"
|
local sequence="$2"
|
local expected_last_mouse="$3"
|
local app_count="${4:-$FIXTURE_APP_COUNT}"
|
local windows_per_app="${5:-$FIXTURE_WINDOWS_PER_APP}"
|
local quick_switch_expectation="${6:-visible}"
|
local fixture_shape="${7:-single-display}"
|
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-debug-window-activation
|
--round01-debug-mouse-sequence="$sequence"
|
--round01-quick-switch-report="$report"
|
)
|
if [ "$fixture_shape" = "multi-display" ]; then
|
args+=(--round01-fixture-multi-display)
|
fi
|
|
rm -f "$report"
|
"$APP/Contents/MacOS/Aligner" "${args[@]}" &
|
|
APP_PID=$!
|
|
wait_for_report "$report" "$expected_last_mouse"
|
if [ "$quick_switch_expectation" = "hidden" ]; then
|
swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2
|
elif [ "$quick_switch_expectation" = "visible" ]; then
|
swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2
|
elif [ "$quick_switch_expectation" != "report-only" ]; then
|
fail "unknown quick switch expectation: $quick_switch_expectation"
|
fi
|
|
kill "$APP_PID" 2>/dev/null || true
|
wait "$APP_PID" 2>/dev/null || true
|
APP_PID=""
|
sleep 0.3
|
stop_current_aligner
|
}
|
|
assert_focus_report() {
|
/usr/bin/python3 - "$1" "$FIXTURE_APP_COUNT" <<'PY'
|
import json
|
import sys
|
|
path = sys.argv[1]
|
fixture_app_count = int(sys.argv[2])
|
|
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(root.get("activeSpaceFocusID") == 1, "Space Lane hover must set activeSpaceFocusID")
|
require(root.get("spaceLaneHoveredSpaceID") == 1, "hover-space must expose hovered Space")
|
require(root.get("spaceLaneFocusedSpaceID") is None, "hover-space must not persist clicked Space focus")
|
require(root.get("lastSpaceLaneClickSpaceID") is None, "hover-space must not record a Space click")
|
require(root.get("lastSpaceFocusedAppGroupIndex") == 0, "first related App column must be appGroupIndex 0")
|
require(root.get("spaceFocusAppGroupIndexes") == list(range(fixture_app_count)), "all fixture apps must be marked related to focused Space")
|
require(root.get("waterfallScrollOffset") == 0, "first related App column should remain visible at offset zero")
|
|
focused_segments = [segment for segment in segments if segment.get("isFocused") is True]
|
require(len(focused_segments) == 1, "exactly one Space Lane segment must be focused")
|
require(focused_segments[0].get("label") == "01", "focused Space should be 01")
|
require("focused" in focused_segments[0].get("visualStates", []), "focused Space segment must expose focused visual state")
|
require(focused_segments[0].get("shadowRadius", 0) >= 24, "focused Space segment shadow radius must use the stronger float treatment")
|
require(focused_segments[0].get("shadowOpacity", 0) >= 0.25, "focused Space segment shadow opacity must use the stronger float treatment")
|
|
require(len(items) == fixture_app_count, "App Shelf fixture item count must match app count")
|
require(all(item.get("isSpaceFocused") is True for item in items), "all fixture apps must be highlighted for Space A1")
|
require(all("spaceFocused" in item.get("visualStates", []) for item in items), "App Shelf items must expose spaceFocused visual state")
|
selected_items = [item for item in items if item.get("isSelected") is True]
|
require(len(selected_items) == 1, "App Shelf must keep one functional selection while Space focus is active")
|
require("selected" not in selected_items[0].get("visualStates", []), "Space focus must suppress App Shelf selected visual state")
|
require("selectedVisualSuppressed" in selected_items[0].get("visualStates", []), "suppressed App Shelf selected visual must be explicit")
|
require(all(item.get("spaceFocusWindowCount", 0) > 0 for item in items), "focused Space badge count must be positive for every fixture app")
|
require(all(item.get("badgeVisible") is True for item in items), "focused Space badges must be visible")
|
require(items[0].get("spaceFocusDirection") == "visible", "first related App must be visible")
|
require(items[-1].get("spaceFocusDirection") == "right", "last fixture App must be reported to the right before remote click")
|
require(str(items[-1].get("badgeText", "")).endswith(">"), "right-side remote App badge must include right direction marker")
|
|
require(len(columns) == fixture_app_count, "Waterfall column reports must match app count")
|
require(columns[0].get("isSpaceFocused") is True, "first Waterfall column must be marked focused for Space")
|
require(columns[0].get("spaceFocusDirection") == "visible", "first Waterfall column must be visible")
|
require(columns[-1].get("isSpaceFocused") is True, "last Waterfall column must be marked focused for Space")
|
require(columns[-1].get("spaceFocusDirection") == "right", "last Waterfall column must report right direction before remote click")
|
selected_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is True]
|
require(len(selected_cards) == 1, "Waterfall must keep one functional selection while Space focus is active")
|
require("selected" not in selected_cards[0].get("visualStates", []), "Space focus must suppress Waterfall selected visual state")
|
require("selectedVisualSuppressed" in selected_cards[0].get("visualStates", []), "suppressed Waterfall selected visual must be explicit")
|
require(selected_cards[0].get("shadowRadius", 0) >= 24, "space-focused Waterfall card shadow radius must use the stronger float treatment")
|
require(selected_cards[0].get("shadowOpacity", 0) >= 0.19, "space-focused Waterfall card shadow opacity must use the stronger float treatment")
|
|
space_fill = focused_segments[0].get("backgroundColor", {})
|
card_fill = selected_cards[0].get("backgroundColor", {})
|
for channel in ("red", "green", "blue", "alpha"):
|
require(
|
abs(space_fill.get(channel, -1) - card_fill.get(channel, -2)) <= 0.002,
|
f"focused Space fill must match space-focused card fill for {channel}"
|
)
|
|
print(json.dumps({
|
"activeSpaceFocusID": root.get("activeSpaceFocusID"),
|
"focusedApps": root.get("spaceFocusAppGroupIndexes"),
|
"focusedSpaceFill": space_fill,
|
"spaceFocusedCardFill": card_fill,
|
"focusedSpaceShadowRadius": focused_segments[0].get("shadowRadius"),
|
"spaceFocusedCardShadowRadius": selected_cards[0].get("shadowRadius"),
|
"lastRemoteDirection": items[-1].get("spaceFocusDirection"),
|
"lastRemoteBadgeText": items[-1].get("badgeText")
|
}, indent=2, ensure_ascii=False))
|
PY
|
}
|
|
assert_remote_click_report() {
|
/usr/bin/python3 - "$1" "$FIXTURE_APP_COUNT" <<'PY'
|
import json
|
import sys
|
|
path = sys.argv[1]
|
fixture_app_count = int(sys.argv[2])
|
|
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", [])
|
last_index = fixture_app_count - 1
|
|
require(report.get("quickSwitchVisible") is False, "remote App click must close Quick Switch after committing its first window")
|
require(root.get("activeSpaceFocusID") is None, "App click must clear Space hover focus before the pre-commit report")
|
require(root.get("hoverTargetKind") == "app", "App click must leave the hover target on the clicked App, not on Space")
|
require(root.get("lastClickedAppGroupIndex") == last_index, "remote App click must be recorded")
|
require(root.get("lastAppShelfClickChangedSelection") is True, "App Shelf click must move keyboard selection to the remote App column")
|
require(root.get("selectedAppGroupIndex") == last_index, "remote App click must focus the remote App column")
|
expected_window_id = columns[last_index].get("cards", [])[0].get("windowID")
|
require(root.get("lastCommittedWindowID") == expected_window_id, "App Shelf click must commit the first remote window")
|
require(report.get("lastCommittedWindowID") == expected_window_id, "session must surface the remote App commit")
|
require(report.get("lastActivationWindowID") == expected_window_id, "activation window must be the first remote window")
|
require(report.get("lastActivationResult") == "activated", "debug activation must activate the first remote window")
|
require(report.get("lastCommitSource") == "mouse", "remote App commit source must be mouse")
|
require(root.get("waterfallScrollOffset", 0) > 0, "remote App click must scroll Waterfall horizontally")
|
require(items[last_index].get("spaceFocusDirection") == "none", "App click must clear Space focus direction after leaving Space Lane")
|
require(columns[last_index].get("spaceFocusDirection") == "none", "Waterfall column must not keep Space focus after App click")
|
require(all(item.get("isSpaceFocused") is False for item in items), "App click must clear all App Shelf spaceFocused states")
|
require(all(column.get("isSpaceFocused") is False for column in columns), "App click must clear all Waterfall column spaceFocused states")
|
|
print(json.dumps({
|
"activeSpaceFocusID": root.get("activeSpaceFocusID"),
|
"hoverTargetKind": root.get("hoverTargetKind"),
|
"lastClickedAppGroupIndex": root.get("lastClickedAppGroupIndex"),
|
"lastCommittedWindowID": root.get("lastCommittedWindowID"),
|
"lastActivationResult": report.get("lastActivationResult"),
|
"waterfallScrollOffset": root.get("waterfallScrollOffset"),
|
"remoteDirectionAfterClick": items[last_index].get("spaceFocusDirection")
|
}, indent=2, ensure_ascii=False))
|
PY
|
}
|
|
assert_space_click_filter_report() {
|
/usr/bin/python3 - "$1" <<'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 = sorted(root.get("waterfallColumns", []), key=lambda column: column.get("appGroupIndex", 0))
|
target_space_id = 1
|
cards = [card for column in columns for card in column.get("cards", [])]
|
segments = root.get("spaceLaneSegments", [])
|
locked_segments = [segment for segment in segments if "locked" in segment.get("visualStates", [])]
|
|
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
|
require(report.get("quickSwitchVisible") is True, "click-space must keep Quick Switch visible after locking Space filter")
|
require(report.get("spaceFilterActive") is True, "click-space must enable session Space filter")
|
require(report.get("spaceFilterLockedSpaceID") == target_space_id, "click-space must lock the clicked Space")
|
require(report.get("lastSpaceFilterAction") == "lock", "click-space must record a lock action")
|
require(root.get("spaceFilterActive") is True, "root report must expose active Space filter")
|
require(root.get("spaceFilterLockedSpaceID") == target_space_id, "root report must expose the locked Space")
|
require(root.get("lastSpaceLaneClickSpaceID") == target_space_id, "click-space must record the clicked Space")
|
require(root.get("spaceLaneFocusedSpaceID") is None, "click-space must not reuse persistent hover focus as its behavior")
|
require(report.get("lastCommittedWindowID") is None, "ordinary click-space must not commit a window")
|
require(report.get("lastActivationWindowID") is None, "ordinary click-space must not activate a window")
|
require(report.get("lastActivationResult") is None, "ordinary click-space must not report activation")
|
require(report.get("lastCommitSource") is None, "ordinary click-space must not report a commit source")
|
require(cards, "fixture must include filtered cards for Space A1")
|
require(all(card.get("primarySpaceID") == target_space_id for card in cards), "click-space filter must only keep windows in clicked Space")
|
require(len(locked_segments) == 1, "exactly one Space Lane segment must expose locked")
|
require(locked_segments[0].get("spaceID") == target_space_id, "locked segment must be the clicked Space")
|
|
print(json.dumps({
|
"clickedSpaceID": root.get("lastSpaceLaneClickSpaceID"),
|
"lockedSpaceID": root.get("spaceFilterLockedSpaceID"),
|
"visibleCardSpaceIDs": sorted({card.get("primarySpaceID") for card in cards}),
|
"quickSwitchVisible": report.get("quickSwitchVisible")
|
}, indent=2, ensure_ascii=False))
|
PY
|
}
|
|
assert_space_focus_clear_report() {
|
/usr/bin/python3 - "$1" "$FIXTURE_APP_COUNT" <<'PY'
|
import json
|
import sys
|
|
path = sys.argv[1]
|
fixture_app_count = int(sys.argv[2])
|
|
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", [])
|
cards = [card for column in columns for card in column.get("cards", [])]
|
hovered_window = next(
|
(
|
card
|
for column in columns
|
for card in column.get("cards", [])
|
if card.get("windowID") == root.get("hoveredWindowID")
|
),
|
None
|
)
|
|
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
|
require(root.get("hoverTargetKind") == "window", "hover-card after hover-space must set the hover target to window")
|
require(root.get("activeSpaceFocusID") is None, "hover-card must clear activeSpaceFocusID after leaving Space Lane")
|
require(root.get("spaceLaneHoveredSpaceID") is None, "hover-card must clear Space Lane hovered Space ID")
|
require(root.get("spaceFocusAppGroupIndexes") == [], "hover-card must clear all Space focused App indexes")
|
require(root.get("hoveredWindowID") is not None, "hover-card must still expose hoveredWindowID")
|
require(hovered_window is not None, "hover-card report must contain the hovered window card")
|
require(root.get("windowHoverAssociatedSpaceID") == hovered_window.get("primarySpaceID"), "hover-card must expose the hovered window Space ID")
|
require(len(items) == fixture_app_count, "App Shelf fixture item count must match app count")
|
require(all(item.get("isSpaceFocused") is False for item in items), "hover-card must not leave App Shelf items spaceFocused")
|
require(all(column.get("isSpaceFocused") is False for column in columns), "hover-card must not leave Waterfall columns spaceFocused")
|
require(all("spaceFocused" not in card.get("visualStates", []) for card in cards), "hover-card must not leave Waterfall cards spaceFocused")
|
associated_segments = [segment for segment in segments if segment.get("isWindowAssociated") is True]
|
require(len(associated_segments) == 1, "hover-card must highlight exactly one associated Space segment")
|
require(associated_segments[0].get("spaceID") == hovered_window.get("primarySpaceID"), "window-associated Space segment must match hovered card Space")
|
require("windowAssociated" in associated_segments[0].get("visualStates", []), "window-associated Space segment must expose windowAssociated visual state")
|
require("focused" not in associated_segments[0].get("visualStates", []), "window-associated Space segment must not expose focused")
|
require("hover" not in associated_segments[0].get("visualStates", []), "window-associated Space segment must not expose Space hover")
|
|
print(json.dumps({
|
"hoverTargetKind": root.get("hoverTargetKind"),
|
"activeSpaceFocusID": root.get("activeSpaceFocusID"),
|
"hoveredWindowID": root.get("hoveredWindowID"),
|
"windowHoverAssociatedSpaceID": root.get("windowHoverAssociatedSpaceID"),
|
"spaceFocusAppGroupIndexes": root.get("spaceFocusAppGroupIndexes")
|
}, indent=2, ensure_ascii=False))
|
PY
|
}
|
|
assert_app_hover_space_lane_report() {
|
/usr/bin/python3 - "$1" <<'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", {})
|
items = root.get("appShelfItems", [])
|
columns = root.get("waterfallColumns", [])
|
segments = root.get("spaceLaneSegments", [])
|
cards = [card for column in columns for card in column.get("cards", [])]
|
|
target_column = next((column for column in columns if column.get("appGroupIndex") == 0), None)
|
require(target_column is not None, "fixture must include App group 0")
|
|
expected_space_ids = sorted({
|
card.get("primarySpaceID")
|
for card in target_column.get("cards", [])
|
if isinstance(card.get("primarySpaceID"), int)
|
})
|
reported_space_ids = sorted(root.get("appHoverAssociatedSpaceIDs", []))
|
associated_segments = [segment for segment in segments if segment.get("isAppAssociated") is True]
|
associated_segment_ids = sorted(segment.get("spaceID") for segment in associated_segments)
|
|
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
|
require(root.get("hoverTargetKind") == "app", "hover-app must set the hover target to app")
|
require(root.get("hoveredAppGroupIndex") == 0, "hover-app must expose the hovered App group")
|
require(root.get("activeSpaceFocusID") is None, "hover-app must not set activeSpaceFocusID")
|
require(root.get("spaceLaneHoveredSpaceID") is None, "hover-app must not masquerade as Space Lane hover")
|
require(root.get("spaceFocusAppGroupIndexes") == [], "hover-app must not activate Space focus")
|
require(reported_space_ids == expected_space_ids, "hover-app must report only the hovered App's Spaces")
|
require(associated_segment_ids == expected_space_ids, "Space Lane appAssociated segments must match hovered App Spaces")
|
require(len(associated_segments) > 0, "hover-app must visually associate at least one Space")
|
require(all("appAssociated" in segment.get("visualStates", []) for segment in associated_segments), "associated Space segments must expose appAssociated")
|
require(all("focused" not in segment.get("visualStates", []) for segment in associated_segments), "app-associated Spaces must not expose focused")
|
require(all("hover" not in segment.get("visualStates", []) for segment in associated_segments), "app-associated Spaces must not expose Space hover")
|
require(all(segment.get("shadowRadius", 0) >= 18 for segment in associated_segments), "app-associated Spaces must get a visible float shadow")
|
require(all(item.get("isSpaceFocused") is False for item in items), "hover-app must not mark App Shelf items spaceFocused")
|
require(all(column.get("isSpaceFocused") is False for column in columns), "hover-app must not mark Waterfall columns spaceFocused")
|
require(all("spaceFocused" not in card.get("visualStates", []) for card in cards), "hover-app must not mark Waterfall cards spaceFocused")
|
|
print(json.dumps({
|
"hoverTargetKind": root.get("hoverTargetKind"),
|
"hoveredAppGroupIndex": root.get("hoveredAppGroupIndex"),
|
"appHoverAssociatedSpaceIDs": reported_space_ids,
|
"associatedSegmentIDs": associated_segment_ids,
|
"activeSpaceFocusID": root.get("activeSpaceFocusID")
|
}, indent=2, ensure_ascii=False))
|
PY
|
}
|
|
assert_app_shelf_scroll_report() {
|
/usr/bin/python3 - "$1" "$1" <<'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", {})
|
items = root.get("appShelfItems", [])
|
columns = root.get("waterfallColumns", [])
|
synced = root.get("lastAppShelfScrollSyncedAppGroupIndex")
|
|
require(root.get("appShelfScrollable") is True, "App Shelf must be scrollable in the scroll linkage fixture")
|
require(root.get("appShelfScrollOffset", 0) > 0, "debug App Shelf scroll must move App Shelf")
|
require(isinstance(synced, int), "App Shelf scroll must record the synced App group")
|
require(root.get("waterfallScrollOffset", 0) > 0, "App Shelf scroll must move Waterfall to the synced App column")
|
require(0 <= synced < len(items), "synced App group must point at a real App Shelf item")
|
require(columns[synced].get("spaceFocusDirection") == "none", "App Shelf scroll QA should not depend on Space focus state")
|
|
visible = columns[synced].get("visibleFrame", {})
|
require(visible.get("x", -10_000) < root.get("waterfallVisibleWidth", 0), "synced Waterfall column must enter the visible viewport")
|
require(visible.get("x", 10_000) + visible.get("width", 0) > 0, "synced Waterfall column must not remain fully offscreen")
|
|
print(json.dumps({
|
"appShelfScrollOffset": root.get("appShelfScrollOffset"),
|
"syncedAppGroupIndex": synced,
|
"waterfallScrollOffset": root.get("waterfallScrollOffset")
|
}, indent=2, ensure_ascii=False))
|
PY
|
}
|
|
assert_display_group_report() {
|
/usr/bin/python3 - "$1" <<'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)
|
|
def right_edge(frame):
|
return frame.get("x", 0) + frame.get("width", 0)
|
|
def contains(outer, inner, tolerance=1.0):
|
return (
|
inner.get("x", 0) >= outer.get("x", 0) - tolerance
|
and inner.get("y", 0) >= outer.get("y", 0) - tolerance
|
and right_edge(inner) <= right_edge(outer) + tolerance
|
and inner.get("y", 0) + inner.get("height", 0) <= outer.get("y", 0) + outer.get("height", 0) + tolerance
|
)
|
|
def is_light_blue(color):
|
return (
|
color.get("alpha", 0) >= 0.98
|
and color.get("blue", 0) >= 0.98
|
and color.get("green", 0) >= 0.90
|
and color.get("red", 0) >= 0.80
|
and color.get("blue", 0) > color.get("red", 0) + 0.04
|
)
|
|
def is_white(color):
|
return (
|
color.get("alpha", 0) >= 0.98
|
and color.get("red", 0) >= 0.98
|
and color.get("green", 0) >= 0.98
|
and color.get("blue", 0) >= 0.98
|
)
|
|
root = report.get("rootView", {})
|
groups = root.get("spaceLaneDisplayGroups", [])
|
segments = root.get("spaceLaneSegments", [])
|
lane_background = root.get("spaceLaneBackgroundColor", {})
|
|
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
|
require(report.get("quickSwitchVisible") is True, "multi-display report must keep Quick Switch visible")
|
require(root.get("activeSpaceFocusID") == 4, "multi-display hover must focus Space B1")
|
require(root.get("spaceLaneDisplayGroupCount") == 2, "multi-display fixture must expose two Display groups")
|
require(len(groups) == 2, "Display group reports must contain two groups")
|
require(root.get("spaceLaneSegmentCount") == 5, "multi-display fixture must expose five Space segments")
|
require(len(segments) == 5, "Space segment reports must contain five segments")
|
require(groups[0].get("spaceLabels") == ["A1", "A2", "A3"], "first Display group must contain A spaces")
|
require(groups[1].get("spaceLabels") == ["B1", "B2"], "second Display group must contain B spaces")
|
require(lane_background.get("alpha", 1) <= 0.01, "Space Lane outer background must be transparent")
|
require(root.get("spaceLaneCornerRadius") == 0, "Space Lane outer layer must not draw a rounded white rectangle")
|
|
first_group_frame = groups[0].get("visibleFrame", {})
|
second_group_frame = groups[1].get("visibleFrame", {})
|
group_gap = groups[0].get("groupGap", 0)
|
actual_group_gap = second_group_frame.get("x", 0) - right_edge(first_group_frame)
|
require(actual_group_gap >= group_gap - 1.0, "Display group gap must be at least the reported group gap")
|
require(actual_group_gap > groups[0].get("segmentGap", 0) * 2, "Display group gap must be visibly larger than inner segment gap")
|
|
segments_by_label = {segment.get("label"): segment for segment in segments}
|
for group in groups:
|
group_frame = group.get("visibleFrame", {})
|
labels = group.get("spaceLabels", [])
|
require(group.get("borderWidth") == 0, "Display group container must be borderless")
|
require(group.get("shadowOpacity") == 0, "Display group container must not add a floating shadow")
|
require(group.get("backgroundColor", {}).get("alpha", 0) >= 0.98, "Display group container must use an opaque system surface")
|
require(group.get("groupHorizontalPadding", 0) >= 24, "Display group horizontal padding must be widened")
|
for label in labels:
|
require(label in segments_by_label, f"segment report must include {label}")
|
require(contains(group_frame, segments_by_label[label].get("visibleFrame", {})), f"{label} must be inside its Display group")
|
first_segment = segments_by_label[labels[0]].get("visibleFrame", {})
|
last_segment = segments_by_label[labels[-1]].get("visibleFrame", {})
|
require(
|
first_segment.get("x", 0) - group_frame.get("x", 0) >= group.get("groupHorizontalPadding", 0) - 1.0,
|
"Display group leading padding must match widened padding"
|
)
|
require(
|
right_edge(group_frame) - right_edge(last_segment) >= group.get("groupHorizontalPadding", 0) - 1.0,
|
"Display group trailing padding must match widened padding"
|
)
|
|
for group in groups:
|
labels = group.get("spaceLabels", [])
|
if len(labels) < 2:
|
continue
|
previous = segments_by_label[labels[0]].get("visibleFrame", {})
|
for label in labels[1:]:
|
current = segments_by_label[label].get("visibleFrame", {})
|
actual_segment_gap = current.get("x", 0) - right_edge(previous)
|
require(
|
abs(actual_segment_gap - group.get("segmentGap", 0)) <= 1.0,
|
"inner Space segment gap must match reported segment gap"
|
)
|
previous = current
|
|
focused = [segment for segment in segments if segment.get("isFocused") is True]
|
require(len(focused) == 1, "exactly one Space segment must be focused")
|
require(focused[0].get("label") == "B1", "hovered multi-display Space must be B1")
|
require("focused" in focused[0].get("visualStates", []), "focused B1 segment must expose focused visual state")
|
|
empty_segments = [
|
segment for segment in segments
|
if segment.get("windowCount") == 0
|
]
|
require(empty_segments, "multi-display visual fixture must include at least one empty Space")
|
require(
|
{"A3", "B1"}.issubset({segment.get("label") for segment in empty_segments}),
|
"multi-display visual fixture must include the A3/B1 empty Space regression pair"
|
)
|
require(
|
all(segment.get("backgroundKind") == "clear" for segment in empty_segments),
|
"empty Spaces must not keep a dirty gray fill, even when focused/current/associated"
|
)
|
require(
|
all(not segment.get("gradientColors", []) for segment in empty_segments),
|
"empty Spaces must clear stale gradient colors"
|
)
|
require(
|
all(segment.get("backgroundColor", {}).get("alpha", 1) <= 0.01 for segment in empty_segments),
|
"empty Spaces must keep transparent fill"
|
)
|
require(
|
all(segment.get("shadowOpacity", 1) <= 0.01 for segment in empty_segments),
|
"empty Spaces must not draw a shadow that reads as an interior gray fill"
|
)
|
idle_occupied_segments = [
|
segment for segment in segments
|
if segment.get("windowCount", 0) > 0
|
and segment.get("isFocused") is not True
|
and segment.get("isCurrent") is not True
|
and segment.get("isAppAssociated") is not True
|
and segment.get("isWindowAssociated") is not True
|
]
|
require(idle_occupied_segments, "multi-display visual fixture must include an idle occupied Space")
|
for segment in idle_occupied_segments:
|
gradient_colors = segment.get("gradientColors", [])
|
require(segment.get("backgroundKind") == "gradient", "idle occupied Spaces must use blue-white gradient fill")
|
require(len(gradient_colors) == 2, "idle occupied Space gradient must expose two color stops")
|
require(is_light_blue(gradient_colors[0]), "idle occupied Space gradient must start with pure light blue")
|
require(is_white(gradient_colors[1]), "idle occupied Space gradient must end with pure white")
|
fullscreen_segments = [segment for segment in segments if segment.get("type") == "fullscreen"]
|
require(fullscreen_segments, "multi-display visual fixture must include a fullscreen Space")
|
for segment in fullscreen_segments:
|
marker_frame = segment.get("fullscreenMarkerFrame", {})
|
app_frame = segment.get("appFrame", {})
|
marker_center_ratio = segment.get("fullscreenMarkerCenterRatio", 0)
|
marker_mid_y = marker_frame.get("y", 0) + marker_frame.get("height", 0) / 2
|
app_mid_y = app_frame.get("y", 0) + app_frame.get("height", 0) / 2
|
require(0.36 <= marker_center_ratio <= 0.44, "fullscreen Space marker must use adaptive vertical centering")
|
require(abs(marker_mid_y - app_mid_y) <= 2, "fullscreen App label must remain centered in the marker")
|
|
print(json.dumps({
|
"displayGroups": [
|
{
|
"label": group.get("displayLabel"),
|
"spaces": group.get("spaceLabels"),
|
"visibleFrame": group.get("visibleFrame")
|
}
|
for group in groups
|
],
|
"actualGroupGap": actual_group_gap,
|
"focusedSpace": focused[0].get("label")
|
}, indent=2, ensure_ascii=False))
|
PY
|
}
|
|
trap cleanup EXIT
|
|
acquire_lock
|
stop_current_aligner
|
"$SCRIPT_DIR/package-app.sh" >&2
|
|
FOCUS_REPORT="$REPORT_DIR/round01-three-zone-linkage-focus-report.json"
|
APP_HOVER_REPORT="$REPORT_DIR/round01-three-zone-linkage-app-hover-report.json"
|
CLEAR_REPORT="$REPORT_DIR/round01-three-zone-linkage-clear-report.json"
|
SPACE_CLICK_REPORT="$REPORT_DIR/round01-three-zone-linkage-space-click-report.json"
|
REMOTE_REPORT="$REPORT_DIR/round01-three-zone-linkage-remote-report.json"
|
SCROLL_REPORT="$REPORT_DIR/round01-three-zone-linkage-scroll-report.json"
|
DISPLAY_GROUP_REPORT="$REPORT_DIR/round01-three-zone-linkage-display-group-report.json"
|
|
run_report "$FOCUS_REPORT" "hover-space:1" "hover-space:1"
|
assert_focus_report "$FOCUS_REPORT"
|
|
run_report "$APP_HOVER_REPORT" "hover-app:0" "hover-app:0"
|
assert_app_hover_space_lane_report "$APP_HOVER_REPORT"
|
|
run_report "$CLEAR_REPORT" "hover-space:1,hover-card:0:0" "hover-card:0:0"
|
assert_space_focus_clear_report "$CLEAR_REPORT"
|
|
run_report "$SPACE_CLICK_REPORT" "click-space:1" "click-space:1" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" visible
|
assert_space_click_filter_report "$SPACE_CLICK_REPORT"
|
|
run_report "$REMOTE_REPORT" "hover-space:1,click-app:$REMOTE_APP_INDEX" "click-app:$REMOTE_APP_INDEX" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" hidden
|
assert_remote_click_report "$REMOTE_REPORT"
|
|
run_report "$SCROLL_REPORT" "scroll-app-shelf:99999" "scroll-app-shelf:99999" 60 1
|
assert_app_shelf_scroll_report "$SCROLL_REPORT"
|
|
run_report "$DISPLAY_GROUP_REPORT" "hover-space:4" "hover-space:4" 1 1 report-only multi-display
|
assert_display_group_report "$DISPLAY_GROUP_REPORT"
|
|
swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2
|
stop_current_aligner
|
release_lock
|
trap - EXIT
|