#!/bin/bash # Round01 vertical keyboard App focus fixture QA. It verifies that App Shelf # index keys and left/right arrows behave like App icon hover in vertical # Waterfall, while up/down move only inside the focused column and report # boundary bounce without wrapping. 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_VERTICAL_KEYBOARD_APP_COUNT:-12}" FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_VERTICAL_KEYBOARD_WINDOWS_PER_APP:-8}" REPORT_WAIT="${ALIGNER_ROUND1_VERTICAL_KEYBOARD_REPORT_WAIT:-12.0}" APP_PID="" fail() { echo "Round01 vertical keyboard App focus 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 } normalize_keyboard_sequence() { local sequence="$1" local symbols="1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ" local normalized="" local IFS=, for raw_part in $sequence; do local upper upper="$(printf "%s" "$raw_part" | tr '[:lower:]' '[:upper:]')" local part="$raw_part" if [ "${#upper}" -eq 1 ] && [[ "$symbols" == *"$upper"* ]]; then part="app:$upper" fi if [ -n "$normalized" ]; then normalized="$normalized,$part" else normalized="$part" fi done echo "$normalized" } wait_for_report() { local report="$1" local expected_sequence="$2" /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_sequence" <<'PY' import json import sys import time path = sys.argv[1] timeout = float(sys.argv[2]) expected_sequence = [part for part in sys.argv[3].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 and root.get("keyboardCommandsApplied") == expected_sequence ): 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"vertical keyboard report did not reach expected commands within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } wait_for_committed_report() { local report="$1" local expected_sequence="$2" /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_sequence" <<'PY' import json import sys import time path = sys.argv[1] timeout = float(sys.argv[2]) expected_sequence = [part for part in sys.argv[3].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 root.get("keyboardCommandsApplied") == expected_sequence ): 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"vertical keyboard committed report did not reach expected commands within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } assert_report() { local report="$1" local expected_sequence="$2" local expected_focus_app_index="$3" local expected_selected_app_index="$4" local expected_window_index="$5" local expected_boundary_count="$6" local expected_boundary_direction="$7" local expect_selection_changed="$8" local expect_keyboard_window_focus="$9" /usr/bin/python3 - \ "$report" \ "$expected_sequence" \ "$expected_focus_app_index" \ "$expected_selected_app_index" \ "$expected_window_index" \ "$expected_boundary_count" \ "$expected_boundary_direction" \ "$expect_selection_changed" \ "$expect_keyboard_window_focus" \ "$FIXTURE_APP_COUNT" \ "$FIXTURE_WINDOWS_PER_APP" <<'PY' import json import sys path = sys.argv[1] expected_sequence = [part for part in sys.argv[2].split(",") if part] expected_focus_app_index = int(sys.argv[3]) expected_selected_app_index = int(sys.argv[4]) expected_window_index = int(sys.argv[5]) expected_boundary_count = int(sys.argv[6]) expected_boundary_direction = None if sys.argv[7] == "none" else sys.argv[7] expect_selection_changed = sys.argv[8] == "true" expect_keyboard_window_focus = sys.argv[9] == "true" fixture_app_count = int(sys.argv[10]) fixture_windows_per_app = int(sys.argv[11]) 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", []) focus_item = next((item for item in items if item.get("index") == expected_focus_app_index), None) focus_column = next((column for column in columns if column.get("appGroupIndex") == expected_focus_app_index), None) selected_column = next((column for column in columns if column.get("appGroupIndex") == expected_selected_app_index), None) require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("quickSwitchVisible") is True, "fixture must keep Quick Switch visible") require(report.get("appCount") == fixture_app_count, "fixture appCount must match") require(report.get("windowCount") == fixture_app_count * fixture_windows_per_app, "fixture windowCount must match") require(root.get("waterfallViewMode") == "verticalColumns", "fixture must run in vertical Waterfall") require(root.get("keyboardCommandsApplied") == expected_sequence, "keyboard commands must match normalized sequence") require(root.get("keyboardFocusedAppGroupIndex") == expected_focus_app_index, "keyboard focus must land on expected App") require(root.get("hoveredAppGroupIndex") == expected_focus_app_index, "keyboard focus must expose hovered App") require(root.get("hoverTargetKind") == "app", "keyboard focus must use App hover target") require(root.get("hoverTargetSource") == "keyboard", "keyboard focus must report keyboard source") require(root.get("lastCommittedWindowID") is None, "no-Enter keyboard focus runs must not commit") require(root.get("lastCommitSource") is None, "no-Enter keyboard focus runs must not report commit source") require(focus_item is not None, "focused App Shelf item must exist") require(focus_column is not None, "focused Waterfall column must exist") require(selected_column is not None, "selected Waterfall column must exist") selected_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is True] selected_card = selected_column.get("cards", [])[expected_window_index] require(focus_item.get("isHovered") is True, "focused App Shelf item must expose hover") require("hover" in focus_item.get("visualStates", []), "focused App Shelf item must expose hover visual") require(focus_column.get("isHovered") is True, "focused Waterfall column must expose hover") require("hover" in focus_column.get("visualStates", []), "focused Waterfall column must expose hover visual") require(root.get("selectedAppGroupIndex") == expected_selected_app_index, "selected App must match expected selected App") require(root.get("selectedWindowIndex") == expected_window_index, "selected window index must match expected column movement") require(root.get("selectionChangedByLastCommand") is expect_selection_changed, "selectionChangedByLastCommand must match final command") require(root.get("boundaryBounceCount") == expected_boundary_count, "boundary bounce count must match") if expect_keyboard_window_focus: require(len(selected_cards) == 1, "keyboard window focus run must expose exactly one selected Waterfall card") require(selected_card.get("isSelected") is True, "expected selected card must expose selection") require(selected_card.get("isKeyboardFocused") is True, "expected selected card must expose keyboard focus") require(root.get("keyboardFocusedWindowID") == selected_card.get("windowID"), "keyboardFocusedWindowID must match selected card") require("selected" in selected_card.get("visualStates", []), "keyboard-focused selected card must expose selected visual") require("keyboardFocused" in selected_card.get("visualStates", []), "keyboard-focused selected card must expose keyboardFocused state") require(selected_card.get("shineVisible") is True, "keyboard-focused selected card must expose shine") require(selected_card.get("titleBarBackgroundColor", {}).get("alpha", 0) > 0.10, "keyboard-focused selected card title bar must expose blue fill") first_selected_column_card = selected_column.get("cards", [])[0] if first_selected_column_card.get("windowID") != selected_card.get("windowID"): require("appLinked" not in first_selected_column_card.get("visualStates", []), "App-linked first card must yield to keyboard-focused window") require(first_selected_column_card.get("titleBarBackgroundColor", {}).get("alpha", 0) <= 0.01, "first card title bar must clear App hover fill while another window has keyboard focus") header_frame = selected_column.get("headerFrame", {}) clip_frame = selected_column.get("cardsClipFrame", {}) column_frame = selected_column.get("visibleFrame", {}) card_frame = selected_card.get("visibleFrame", {}) require(selected_column.get("cardsClipMasksToBounds") is True, "vertical cards must be clipped below pinned header") require(clip_frame.get("height", 0) <= header_frame.get("y", 0) + 1, "cards clip must stop before pinned header") header_min_y = column_frame.get("y", 0) + header_frame.get("y", 0) require(card_frame.get("y", 0) + card_frame.get("height", 0) <= header_min_y + 1, "keyboard-focused card must not overlap pinned header") else: require(root.get("keyboardFocusedWindowID") is None, "App-only keyboard focus must not expose keyboardFocusedWindowID") if expected_boundary_direction is None: require(root.get("lastBoundaryBounceAxis") is None, "no-boundary run must not report boundary axis") require(root.get("lastBoundaryBounceDirection") is None, "no-boundary run must not report boundary direction") else: require(root.get("lastBoundaryBounceAxis") == "vertical", "boundary run must report vertical axis") require(root.get("lastBoundaryBounceDirection") == expected_boundary_direction, "boundary direction must match") print(json.dumps({ "commands": root.get("keyboardCommandsApplied"), "keyboardFocusedAppGroupIndex": root.get("keyboardFocusedAppGroupIndex"), "selectedAppGroupIndex": root.get("selectedAppGroupIndex"), "selectedWindowIndex": root.get("selectedWindowIndex"), "keyboardFocusedWindowID": root.get("keyboardFocusedWindowID"), "boundaryBounceCount": root.get("boundaryBounceCount"), "lastBoundaryBounceDirection": root.get("lastBoundaryBounceDirection") }, indent=2, ensure_ascii=False)) PY } assert_window_shortcut_report() { local report="$1" local expected_sequence="$2" local target_app_index="$3" local target_window_index="$4" local expected_code="$5" /usr/bin/python3 - \ "$report" \ "$expected_sequence" \ "$target_app_index" \ "$target_window_index" \ "$expected_code" \ "$FIXTURE_APP_COUNT" \ "$FIXTURE_WINDOWS_PER_APP" <<'PY' import json import sys path = sys.argv[1] expected_sequence = [part for part in sys.argv[2].split(",") if part] target_app_index = int(sys.argv[3]) target_window_index = int(sys.argv[4]) expected_code = sys.argv[5] fixture_app_count = int(sys.argv[6]) fixture_windows_per_app = int(sys.argv[7]) 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(report.get("appCount") == fixture_app_count, "fixture appCount must match") require(root.get("waterfallViewMode") == "verticalColumns", "window shortcut fixture must run in vertical Waterfall") require(root.get("keyboardCommandsApplied") == expected_sequence, "two-key window shortcut commands must match") require(root.get("appShelfIndexKeyDownCount") == 2, "two-key window shortcut must exercise two physical keyDown events") require(root.get("lastAppShelfIndexKeySymbol") == expected_code[-1], "last physical symbol must be the window index key") require(root.get("lastWindowIndexKeyCommand") == f"window:{expected_code}", "last window shortcut command must be reported") require(root.get("lastWindowIndexKeyCommitCode") == expected_code, "last window shortcut commit code must be reported") require(root.get("windowIndexKeyPendingAppGroupIndex") is None, "two-key commit must clear pending App") require(root.get("windowIndexKeyPendingAppSymbol") is None, "two-key commit must clear pending App symbol") require(target_app_index < len(columns), "target App index must exist") target_column = columns[target_app_index] cards = target_column.get("cards", []) expected_window_count = fixture_app_count * fixture_windows_per_app actual_window_count = report.get("windowCount") suppressed_window_ids = set(report.get("suppressedActivationWindowIDs", [])) committed_window_id = root.get("lastCommittedWindowID") pre_commit = root.get("lastWindowShortcutPreCommitFilterSnapshot", {}) window_count_is_valid = ( actual_window_count == expected_window_count or ( actual_window_count == expected_window_count - 1 and committed_window_id in suppressed_window_ids and root.get("lastCommitSource") == "keyboard" ) ) require(window_count_is_valid, "fixture windowCount must match or only suppress committed target") require(pre_commit.get("phase") == "targetPreCommit", "two-key shortcut must record a pre-commit filter snapshot") require(pre_commit.get("targetCode") == expected_code, "pre-commit snapshot must record target shortcut code") require(pre_commit.get("targetWindowID") == committed_window_id, "pre-commit target must match committed window ID") require(pre_commit.get("matchedWindowIDs") == [committed_window_id], "pre-commit snapshot must match exactly the committed window") if target_window_index < len(cards) and cards[target_window_index].get("windowID") == committed_window_id: target_card = cards[target_window_index] require(target_card.get("windowShortcutCode") == expected_code, "target card must expose expected two-character shortcut code") require(target_card.get("primarySpaceLabel") != target_card.get("windowShortcutCode"), "shortcut code must replace the old Space label in the UI meta slot") else: current_window_ids = [card.get("windowID") for column in columns for card in column.get("cards", [])] require(committed_window_id in suppressed_window_ids, "committed target may be absent only when suppression records it") require(committed_window_id not in current_window_ids, "suppressed committed target must not remain in current cards") require(root.get("lastCommittedAppGroupIndex") == target_app_index, "two-key shortcut must commit target App") require(root.get("lastCommittedWindowIndex") == target_window_index, "two-key shortcut must commit target window") require(root.get("lastCommitSource") == "keyboard", "two-key shortcut commit must be keyboard sourced") if actual_window_count == expected_window_count: require(root.get("selectedAppGroupIndex") == target_app_index, "two-key shortcut must select target App") require(root.get("selectedWindowIndex") == target_window_index, "two-key shortcut must select target window") require(root.get("selectedWindowID") == committed_window_id, "two-key shortcut selection ID must match target window") for column in columns: for card in column.get("cards", []): code = card.get("windowShortcutCode") require(code != "No Space" and code != "N...", "window shortcut code must not expose the old No Space label") print(json.dumps({ "commands": root.get("keyboardCommandsApplied"), "code": expected_code, "lastCommittedWindowID": root.get("lastCommittedWindowID") }, indent=2, ensure_ascii=False)) PY } run_case() { local name="$1" local sequence="$2" local expected_focus_app_index="$3" local expected_selected_app_index="$4" local expected_window_index="$5" local expected_boundary_count="$6" local expected_boundary_direction="$7" local expect_selection_changed="$8" local expect_keyboard_window_focus="$9" local report="$REPORT_DIR/round01-vertical-keyboard-$name-report.json" local expected_sequence expected_sequence="$(normalize_keyboard_sequence "$sequence")" stop_current_aligner rm -f "$report" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-waterfall-view-mode=vertical \ --round01-fixture-app-count="$FIXTURE_APP_COUNT" \ --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \ --round01-disable-screenshot-refresh \ --round01-debug-key-sequence="$sequence" \ --round01-quick-switch-report="$report" & APP_PID=$! wait_for_report "$report" "$expected_sequence" swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2 assert_report \ "$report" \ "$expected_sequence" \ "$expected_focus_app_index" \ "$expected_selected_app_index" \ "$expected_window_index" \ "$expected_boundary_count" \ "$expected_boundary_direction" \ "$expect_selection_changed" \ "$expect_keyboard_window_focus" 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 } run_window_shortcut_case() { local name="window-shortcut" local sequence="physical-index:3,physical-index:6" local expected_sequence="app:3,window:36" local report="$REPORT_DIR/round01-vertical-keyboard-$name-report.json" stop_current_aligner rm -f "$report" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-waterfall-view-mode=vertical \ --round01-fixture-app-count="$FIXTURE_APP_COUNT" \ --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \ --round01-disable-screenshot-refresh \ --round01-debug-key-sequence="$sequence" \ --round01-quick-switch-report="$report" & APP_PID=$! wait_for_committed_report "$report" "$expected_sequence" assert_window_shortcut_report "$report" "$expected_sequence" 2 5 "36" 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 } if [ "$FIXTURE_APP_COUNT" -lt 12 ]; then fail "fixture app count must be at least 12" fi if [ "$FIXTURE_WINDOWS_PER_APP" -lt 8 ]; then fail "fixture windows per app must be at least 8" fi symbols="1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ" if [ "$FIXTURE_APP_COUNT" -gt "${#symbols}" ]; then fail "fixture app count cannot exceed supported App Shelf index symbols" fi LAST_APP_INDEX=$((FIXTURE_APP_COUNT - 1)) LAST_APP_SYMBOL="${symbols:LAST_APP_INDEX:1}" trap cleanup EXIT stop_current_aligner "$SCRIPT_DIR/package-app.sh" >&2 run_case "index-hover-only" "A" 10 0 0 0 "none" "false" "false" run_case "left-wrap-to-last" "1,left" "$LAST_APP_INDEX" 0 0 0 "none" "false" "false" run_case "right-wrap-to-first" "$LAST_APP_SYMBOL,right" 0 0 0 0 "none" "false" "false" run_case "bottom-boundary" "3,down,down,down,down,down,down,down,down,down" 2 2 7 1 "down" "false" "true" run_case "top-boundary" "4,down,up" 3 3 0 1 "up" "false" "true" run_window_shortcut_case trap - EXIT cleanup