| | |
| | | wait_for_visible_report() { |
| | | local report="$1" |
| | | local expected_last_mouse="$2" |
| | | local expected_action="${3:-}" |
| | | |
| | | /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_last_mouse" <<'PY' |
| | | /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_last_mouse" "$expected_action" <<'PY' |
| | | import json |
| | | import sys |
| | | import time |
| | |
| | | path = sys.argv[1] |
| | | timeout = float(sys.argv[2]) |
| | | expected_last_mouse = sys.argv[3] |
| | | expected_action = sys.argv[4] |
| | | deadline = time.monotonic() + timeout |
| | | last_report = None |
| | | |
| | |
| | | report.get("snapshotLoaded") is True |
| | | and report.get("quickSwitchVisible") is True |
| | | and root.get("lastMouseCommand") == expected_last_mouse |
| | | and (expected_action == "" or report.get("lastSpaceFilterAction") == expected_action) |
| | | ): |
| | | sys.exit(0) |
| | | except FileNotFoundError: |
| | |
| | | |
| | | if last_report is not None: |
| | | print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr) |
| | | print(f"visible report did not reach mouse command {expected_last_mouse!r} within {timeout:.1f}s", file=sys.stderr) |
| | | print( |
| | | f"visible report did not reach mouse command {expected_last_mouse!r}" |
| | | f" and action {expected_action!r} within {timeout:.1f}s", |
| | | file=sys.stderr |
| | | ) |
| | | sys.exit(1) |
| | | PY |
| | | } |
| | |
| | | PY |
| | | } |
| | | |
| | | wait_for_space_activation_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 report.get("quickSwitchVisible") is False |
| | | and report.get("lastSpaceFilterAction") == "activateSpace" |
| | | and report.get("lastSpaceActivationSpaceID") is not None |
| | | 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"space activation report did not reach 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 quick_switch_expectation="${4:-visible}" |
| | | local expected_action="${5:-}" |
| | | |
| | | rm -f "$report" |
| | | "$APP/Contents/MacOS/Aligner" \ |
| | |
| | | if [ "$quick_switch_expectation" = "hidden" ]; then |
| | | wait_for_activation_report "$report" "$expected_last_mouse" |
| | | swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2 |
| | | elif [ "$quick_switch_expectation" = "space-hidden" ]; then |
| | | wait_for_space_activation_report "$report" "$expected_last_mouse" |
| | | swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2 |
| | | elif [ "$quick_switch_expectation" = "visible" ]; then |
| | | wait_for_visible_report "$report" "$expected_last_mouse" |
| | | wait_for_visible_report "$report" "$expected_last_mouse" "$expected_action" |
| | | swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2 |
| | | else |
| | | fail "unknown quick switch expectation: $quick_switch_expectation" |
| | |
| | | segments = root.get("spaceLaneSegments", []) |
| | | cards = [card for column in columns for card in column.get("cards", [])] |
| | | segment_by_id = {segment.get("spaceID"): segment for segment in segments} |
| | | visible_frames = [column.get("visibleFrame", {}) for column in columns] |
| | | visible_min_x = min(frame.get("x", 0) for frame in visible_frames) |
| | | visible_max_x = max(frame.get("x", 0) + frame.get("width", 0) for frame in visible_frames) |
| | | left_blank_width = max(0, visible_min_x) |
| | | right_blank_width = max(0, root.get("waterfallVisibleWidth", 0) - visible_max_x) |
| | | |
| | | require(report.get("snapshotLoaded") is True, "snapshot must load") |
| | | require(report.get("quickSwitchVisible") is True, "Quick Switch must stay visible after filtering") |
| | |
| | | require(report.get("appShelfNames") == ["Alpha Space App", "Beta Space App"], "App Shelf must only include A1 apps") |
| | | require(root.get("appShelfNames") == ["Alpha Space App", "Beta Space App"], "root App Shelf must only include A1 apps") |
| | | require(root.get("waterfallColumnNames") == ["Alpha Space App", "Beta Space App"], "Waterfall must only include A1 columns") |
| | | require(abs(left_blank_width - right_blank_width) <= 2.0, "filtered Waterfall columns must be centered as one content block") |
| | | require(report.get("windowCount") == 3, "A1 must expose exactly three windows") |
| | | require(len(cards) == 3, "A1 card count must be three") |
| | | require(all(card.get("primarySpaceID") == 1 for card in cards), "every visible card must belong to A1") |
| | |
| | | require(segment_by_id[4].get("windowCount") == 0, "B1 must remain visible as empty Space") |
| | | require(segment_by_id[5].get("windowCount") == 1, "B2 fullscreen Space must remain visible") |
| | | require("locked" in segment_by_id[1].get("visualStates", []), "locked Space must expose locked visual state") |
| | | require(segment_by_id[1].get("borderWidth", 0) >= 5.5, "locked Space must use a heavy blue border") |
| | | lock_shadow = segment_by_id[1].get("shadowColor", {}) |
| | | require(lock_shadow.get("red", 0) >= 0.90 and lock_shadow.get("green", 0) >= 0.90 and lock_shadow.get("blue", 0) >= 0.90, "locked Space must use a white glow") |
| | | require(segment_by_id[1].get("shadowOpacity", 0) >= 0.60, "locked Space glow must be visibly stronger than hover") |
| | | require(segment_by_id[1].get("shadowRadius", 0) >= 26, "locked Space glow radius must be strong enough") |
| | | require("locked" not in segment_by_id[5].get("visualStates", []), "single fullscreen Space must not be marked locked") |
| | | require(root.get("projectionTransitionFadeOutLayerCount", 0) >= 1, "filter transition must fade out removed visual layers") |
| | | require(1 <= root.get("projectionTransitionDurationMilliseconds", 0) <= 220, "filter transition duration must stay within P0 native-feel budget") |
| | |
| | | "columns": root.get("waterfallColumnNames"), |
| | | "cardWindowIDs": [card.get("windowID") for card in cards], |
| | | "lockedSpace": root.get("spaceFilterLockedSpaceID"), |
| | | "lockedBorderWidth": segment_by_id[1].get("borderWidth"), |
| | | "lockedShadowOpacity": segment_by_id[1].get("shadowOpacity"), |
| | | "lockedShadowRadius": segment_by_id[1].get("shadowRadius"), |
| | | "waterfallLeftBlankWidth": left_blank_width, |
| | | "waterfallRightBlankWidth": right_blank_width, |
| | | "fadeOutLayerCount": root.get("projectionTransitionFadeOutLayerCount"), |
| | | "transitionDurationMilliseconds": root.get("projectionTransitionDurationMilliseconds") |
| | | }, indent=2, ensure_ascii=False)) |
| | |
| | | PY |
| | | } |
| | | |
| | | assert_background_unlock_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", {}) |
| | | cards = [card for column in root.get("waterfallColumns", []) for card in column.get("cards", [])] |
| | | |
| | | require(report.get("spaceFilterActive") is False, "background click must unlock session filter") |
| | | require(root.get("spaceFilterActive") is False, "background click must unlock root filter") |
| | | require(report.get("lastSpaceFilterAction") == "unlockBackground", "background click must record unlockBackground") |
| | | require(root.get("lastMouseCommand") == "click-background", "last mouse command must be click-background") |
| | | require(report.get("appShelfNames") == ["Alpha Space App", "Beta Space App", "Fullscreen Solo App", "No Space App"], "background unlock must restore all fixture apps") |
| | | require(report.get("windowCount") == 6, "background unlock must restore all six fixture windows") |
| | | require(50901 in [card.get("windowID") for card in cards], "no-space window should be visible again after background unlock") |
| | | |
| | | print(json.dumps({ |
| | | "case": "background-unlock", |
| | | "apps": report.get("appShelfNames"), |
| | | "windowCount": report.get("windowCount"), |
| | | "lastSpaceFilterAction": report.get("lastSpaceFilterAction") |
| | | }, indent=2, ensure_ascii=False)) |
| | | PY |
| | | } |
| | | |
| | | assert_empty_a3_report() { |
| | | /usr/bin/python3 - "$1" <<'PY' |
| | | import json |
| | |
| | | require(root.get("waterfallColumnCount") == 0, "root Waterfall must be empty") |
| | | require(root.get("spaceLaneLabels") == ["A1", "A2", "A3", "B1", "B2"], "Space Lane must stay global while empty Space is locked") |
| | | require("locked" in segment_by_id[3].get("visualStates", []), "empty locked Space must expose locked visual state") |
| | | require(segment_by_id[3].get("borderWidth", 0) >= 5.5, "empty locked Space must still use a heavy locked border") |
| | | empty_shadow = segment_by_id[3].get("shadowColor", {}) |
| | | require(empty_shadow.get("red", 0) >= 0.90 and empty_shadow.get("green", 0) >= 0.90 and empty_shadow.get("blue", 0) >= 0.90, "empty locked Space must use white glow") |
| | | require(segment_by_id[3].get("shadowOpacity", 0) >= 0.60, "empty locked Space glow must be visible") |
| | | require(segment_by_id[3].get("backgroundKind") == "clear", "empty locked Space must not use gray/gradient fill") |
| | | require(segment_by_id[4].get("backgroundKind") == "clear", "empty sibling Space must not inherit gray/gradient fill") |
| | | |
| | |
| | | "columnCount": report.get("columnCount"), |
| | | "a3BackgroundKind": segment_by_id[3].get("backgroundKind"), |
| | | "b1BackgroundKind": segment_by_id[4].get("backgroundKind") |
| | | }, indent=2, ensure_ascii=False)) |
| | | PY |
| | | } |
| | | |
| | | assert_locked_space_double_click_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", {}) |
| | | |
| | | require(report.get("quickSwitchVisible") is False, "double-click locked Space must close Quick Switch") |
| | | require(report.get("spaceFilterActive") is False, "double-click locked Space must release filter") |
| | | require(root.get("spaceFilterActive") is False, "root must show no filter after Space activation") |
| | | require(report.get("lastSpaceFilterAction") == "activateSpace", "double-click locked Space must record activateSpace") |
| | | require(report.get("lastCommittedWindowID") is None, "double-click Space must not commit a window") |
| | | require(report.get("lastActivationWindowID") is None, "double-click Space must not target a window") |
| | | require(report.get("lastActivationResult") is None, "double-click Space must not run window activation") |
| | | require(report.get("lastCommitSource") is None, "double-click Space must not have a commit source") |
| | | require(report.get("lastSpaceActivationSpaceID") == 1, "double-click locked A1 must request Space 1 activation") |
| | | require(report.get("lastSpaceActivationDidRequestFocus") is True, "debug Space activation must request focus") |
| | | require(report.get("lastSpaceActivationDisplayIdentifier") == "debug", "fixture must use debug Space activation service") |
| | | require(report.get("lastSpaceActivationError") is None, "debug Space activation must not fail") |
| | | require(root.get("lastMouseCommand") == "double-click-space:1", "last mouse command must be double-click-space:1") |
| | | |
| | | print(json.dumps({ |
| | | "case": "locked-space-double-click", |
| | | "quickSwitchVisible": report.get("quickSwitchVisible"), |
| | | "lastSpaceFilterAction": report.get("lastSpaceFilterAction"), |
| | | "lastSpaceActivationSpaceID": report.get("lastSpaceActivationSpaceID"), |
| | | "lastSpaceActivationDisplayIdentifier": report.get("lastSpaceActivationDisplayIdentifier") |
| | | }, indent=2, ensure_ascii=False)) |
| | | PY |
| | | } |
| | |
| | | LOCK_A1_REPORT="$REPORT_PREFIX-lock-a1-report.json" |
| | | SWITCH_A2_REPORT="$REPORT_PREFIX-switch-a2-report.json" |
| | | UNLOCK_REPORT="$REPORT_PREFIX-unlock-report.json" |
| | | BACKGROUND_UNLOCK_REPORT="$REPORT_PREFIX-background-unlock-report.json" |
| | | EMPTY_A3_REPORT="$REPORT_PREFIX-empty-a3-report.json" |
| | | FULLSCREEN_REPORT="$REPORT_PREFIX-fullscreen-report.json" |
| | | LOCK_THEN_FULLSCREEN_REPORT="$REPORT_PREFIX-lock-then-fullscreen-report.json" |
| | | LOCKED_DOUBLE_CLICK_REPORT="$REPORT_PREFIX-locked-double-click-report.json" |
| | | |
| | | run_report "$LOCK_A1_REPORT" "click-space:1" "click-space:1" "visible" |
| | | run_report "$LOCK_A1_REPORT" "click-space:1" "click-space:1" "visible" "lock" |
| | | assert_lock_a1_report "$LOCK_A1_REPORT" |
| | | |
| | | run_report "$SWITCH_A2_REPORT" "click-space:1,click-space:2" "click-space:2" "visible" |
| | | run_report "$SWITCH_A2_REPORT" "click-space:1,click-space:2" "click-space:2" "visible" "switch" |
| | | assert_switch_a2_report "$SWITCH_A2_REPORT" |
| | | |
| | | run_report "$UNLOCK_REPORT" "click-space:1,click-space:1" "click-space:1" "visible" |
| | | run_report "$UNLOCK_REPORT" "click-space:1,click-space:1" "click-space:1" "visible" "unlock" |
| | | assert_unlock_report "$UNLOCK_REPORT" |
| | | |
| | | run_report "$EMPTY_A3_REPORT" "click-space:3" "click-space:3" "visible" |
| | | run_report "$BACKGROUND_UNLOCK_REPORT" "click-space:1,click-background" "click-background" "visible" "unlockBackground" |
| | | assert_background_unlock_report "$BACKGROUND_UNLOCK_REPORT" |
| | | |
| | | run_report "$EMPTY_A3_REPORT" "click-space:3" "click-space:3" "visible" "lock" |
| | | assert_empty_a3_report "$EMPTY_A3_REPORT" |
| | | |
| | | run_report "$FULLSCREEN_REPORT" "click-space:5" "click-space:5" "hidden" |
| | |
| | | run_report "$LOCK_THEN_FULLSCREEN_REPORT" "click-space:1,click-space:5" "click-space:5" "hidden" |
| | | assert_single_fullscreen_report "$LOCK_THEN_FULLSCREEN_REPORT" "lock-then-single-fullscreen" |
| | | |
| | | run_report "$LOCKED_DOUBLE_CLICK_REPORT" "click-space:1,double-click-space:1" "double-click-space:1" "space-hidden" |
| | | assert_locked_space_double_click_report "$LOCKED_DOUBLE_CLICK_REPORT" |
| | | |
| | | trap - EXIT |
| | | cleanup |
| | | |