#!/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:-6.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
|
}
|
|
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
|
}
|
|
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
|
}
|
|
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"
|
|
trap - EXIT
|
cleanup
|