| New file |
| | |
| | | #!/bin/bash |
| | | # Round01 window index progressive filter fixture QA. It verifies that the |
| | | # first Option window-index key dims only non-target Waterfall cards, and that |
| | | # the second key commits immediately while preserving the pre-commit snapshot. |
| | | |
| | | 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" |
| | | DOMAIN="com.ar.Aligner" |
| | | THEME_KEY="com.ar.Aligner.preferences.appearance.theme" |
| | | REPORT_WAIT="${ALIGNER_ROUND1_WINDOW_INDEX_FILTER_REPORT_WAIT:-8.0}" |
| | | FIXTURE_APP_COUNT="${ALIGNER_ROUND1_WINDOW_INDEX_FILTER_APP_COUNT:-8}" |
| | | FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_WINDOW_INDEX_FILTER_WINDOWS_PER_APP:-6}" |
| | | TARGET_APP_INDEX="${ALIGNER_ROUND1_WINDOW_INDEX_FILTER_TARGET_APP_INDEX:-2}" |
| | | TARGET_WINDOW_INDEX="${ALIGNER_ROUND1_WINDOW_INDEX_FILTER_TARGET_WINDOW_INDEX:-5}" |
| | | APP_SYMBOL="${ALIGNER_ROUND1_WINDOW_INDEX_FILTER_APP_SYMBOL:-3}" |
| | | WINDOW_SYMBOL="${ALIGNER_ROUND1_WINDOW_INDEX_FILTER_WINDOW_SYMBOL:-6}" |
| | | TARGET_CODE="${APP_SYMBOL}${WINDOW_SYMBOL}" |
| | | |
| | | REPORT_HORIZONTAL_LIGHT="$BUILD_REPORT_ROOT/round01-window-index-progressive-filter-horizontal-light-report.json" |
| | | REPORT_VERTICAL_LIGHT="$BUILD_REPORT_ROOT/round01-window-index-progressive-filter-vertical-light-report.json" |
| | | REPORT_HORIZONTAL_DARK="$BUILD_REPORT_ROOT/round01-window-index-progressive-filter-horizontal-dark-report.json" |
| | | REPORT_COMMIT="$BUILD_REPORT_ROOT/round01-window-index-progressive-filter-commit-report.json" |
| | | REPORT_INVALID="$BUILD_REPORT_ROOT/round01-window-index-progressive-filter-invalid-report.json" |
| | | SUMMARY_DIR="$BUILD_TMP_ROOT/round01-window-index-progressive-filter-fixture-qa" |
| | | |
| | | APP_PID="" |
| | | OLD_THEME_SET=0 |
| | | OLD_THEME="" |
| | | SUMMARY_FILES=() |
| | | |
| | | fail() { |
| | | echo "Round01 window index progressive filter 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" |
| | | } |
| | | |
| | | stop_current_aligner_safely() { |
| | | 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 |
| | | |
| | | echo "warning: current Aligner app did not exit during cleanup" >&2 |
| | | } |
| | | |
| | | preserve_user_theme() { |
| | | if OLD_THEME="$(/usr/bin/defaults read "$DOMAIN" "$THEME_KEY" 2>/dev/null)"; then |
| | | OLD_THEME_SET=1 |
| | | else |
| | | OLD_THEME_SET=0 |
| | | OLD_THEME="" |
| | | fi |
| | | } |
| | | |
| | | restore_user_theme() { |
| | | if [ "$OLD_THEME_SET" -eq 1 ]; then |
| | | /usr/bin/defaults write "$DOMAIN" "$THEME_KEY" -string "$OLD_THEME" |
| | | else |
| | | /usr/bin/defaults delete "$DOMAIN" "$THEME_KEY" >/dev/null 2>&1 || true |
| | | fi |
| | | } |
| | | |
| | | 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_safely |
| | | restore_user_theme |
| | | } |
| | | |
| | | set_theme() { |
| | | local theme="$1" |
| | | /usr/bin/defaults write "$DOMAIN" "$THEME_KEY" -string "$theme" |
| | | } |
| | | |
| | | mode_report_value() { |
| | | local mode="$1" |
| | | case "$mode" in |
| | | horizontal) echo "horizontalMasonry" ;; |
| | | vertical) echo "verticalColumns" ;; |
| | | *) fail "unsupported waterfall mode $mode" ;; |
| | | esac |
| | | } |
| | | |
| | | pending_key_sequence() { |
| | | echo "physical-index:$APP_SYMBOL" |
| | | } |
| | | |
| | | commit_key_sequence() { |
| | | echo "physical-index:$APP_SYMBOL,physical-index:$WINDOW_SYMBOL" |
| | | } |
| | | |
| | | pending_expected_sequence() { |
| | | echo "app:$APP_SYMBOL" |
| | | } |
| | | |
| | | commit_expected_sequence() { |
| | | echo "app:$APP_SYMBOL,window:$TARGET_CODE" |
| | | } |
| | | |
| | | invalid_key_sequence() { |
| | | echo "physical-index:$APP_SYMBOL,physical-index:Z" |
| | | } |
| | | |
| | | invalid_expected_sequence() { |
| | | echo "app:$APP_SYMBOL,windowInvalid:${APP_SYMBOL}Z" |
| | | } |
| | | |
| | | run_fixture() { |
| | | local report="$1" |
| | | local theme="$2" |
| | | local mode="$3" |
| | | local key_sequence="$4" |
| | | |
| | | stop_current_aligner |
| | | set_theme "$theme" |
| | | 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="$mode" \ |
| | | --round01-debug-key-sequence="$key_sequence" \ |
| | | --round01-quick-switch-report="$report" >/dev/null 2>&1 & |
| | | |
| | | APP_PID=$! |
| | | } |
| | | |
| | | finish_case() { |
| | | 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 |
| | | } |
| | | |
| | | wait_for_pending_report() { |
| | | local report="$1" |
| | | local expected_mode="$2" |
| | | local expected_sequence="$3" |
| | | local case_name="$4" |
| | | |
| | | /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_mode" "$expected_sequence" "$case_name" <<'PY' |
| | | import json |
| | | import sys |
| | | import time |
| | | |
| | | path = sys.argv[1] |
| | | timeout = float(sys.argv[2]) |
| | | expected_mode = sys.argv[3] |
| | | expected_sequence = [part for part in sys.argv[4].split(",") if part] |
| | | case_name = sys.argv[5] |
| | | 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("waterfallViewMode") == expected_mode |
| | | and root.get("keyboardCommandsApplied") == expected_sequence |
| | | and root.get("windowShortcutFilterPhase") == "appPending" |
| | | ): |
| | | 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"{case_name}: pending filter report did not reach appPending within {timeout:.1f}s", file=sys.stderr) |
| | | sys.exit(1) |
| | | PY |
| | | } |
| | | |
| | | wait_for_commit_report() { |
| | | local report="$1" |
| | | local expected_mode="$2" |
| | | local expected_sequence="$3" |
| | | |
| | | /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_mode" "$expected_sequence" "$TARGET_CODE" <<'PY' |
| | | import json |
| | | import sys |
| | | import time |
| | | |
| | | path = sys.argv[1] |
| | | timeout = float(sys.argv[2]) |
| | | expected_mode = sys.argv[3] |
| | | expected_sequence = [part for part in sys.argv[4].split(",") if part] |
| | | target_code = sys.argv[5] |
| | | 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", {}) |
| | | snapshot = root.get("lastWindowShortcutPreCommitFilterSnapshot") |
| | | if ( |
| | | report.get("snapshotLoaded") is True |
| | | and root.get("waterfallViewMode") == expected_mode |
| | | and root.get("keyboardCommandsApplied") == expected_sequence |
| | | and root.get("lastWindowIndexKeyCommitCode") == target_code |
| | | and isinstance(snapshot, dict) |
| | | ): |
| | | 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"commit report did not reach pre-commit snapshot within {timeout:.1f}s", file=sys.stderr) |
| | | sys.exit(1) |
| | | PY |
| | | } |
| | | |
| | | wait_for_invalid_report() { |
| | | local report="$1" |
| | | local expected_mode="$2" |
| | | local expected_sequence="$3" |
| | | |
| | | /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_mode" "$expected_sequence" "${APP_SYMBOL}Z" <<'PY' |
| | | import json |
| | | import sys |
| | | import time |
| | | |
| | | path = sys.argv[1] |
| | | timeout = float(sys.argv[2]) |
| | | expected_mode = sys.argv[3] |
| | | expected_sequence = [part for part in sys.argv[4].split(",") if part] |
| | | invalid_code = sys.argv[5] |
| | | 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("waterfallViewMode") == expected_mode |
| | | and root.get("keyboardCommandsApplied") == expected_sequence |
| | | and root.get("lastWindowIndexKeyCommand") == f"windowInvalid:{invalid_code}" |
| | | and root.get("windowShortcutFilterPhase") == "invalid" |
| | | ): |
| | | 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"invalid report did not reach invalid state within {timeout:.1f}s", file=sys.stderr) |
| | | sys.exit(1) |
| | | PY |
| | | } |
| | | |
| | | assert_pending_report() { |
| | | local report="$1" |
| | | local case_name="$2" |
| | | local theme="$3" |
| | | local expected_mode="$4" |
| | | local summary_file="$SUMMARY_DIR/$case_name.json" |
| | | |
| | | /usr/bin/python3 - \ |
| | | "$report" \ |
| | | "$case_name" \ |
| | | "$theme" \ |
| | | "$expected_mode" \ |
| | | "$(pending_expected_sequence)" \ |
| | | "$FIXTURE_APP_COUNT" \ |
| | | "$FIXTURE_WINDOWS_PER_APP" \ |
| | | "$TARGET_APP_INDEX" \ |
| | | "$APP_SYMBOL" \ |
| | | "$summary_file" <<'PY' |
| | | import json |
| | | import numbers |
| | | import sys |
| | | |
| | | path = sys.argv[1] |
| | | case_name = sys.argv[2] |
| | | theme = sys.argv[3] |
| | | expected_mode = sys.argv[4] |
| | | expected_sequence = [part for part in sys.argv[5].split(",") if part] |
| | | fixture_app_count = int(sys.argv[6]) |
| | | fixture_windows_per_app = int(sys.argv[7]) |
| | | target_app_index = int(sys.argv[8]) |
| | | app_symbol = sys.argv[9] |
| | | summary_file = sys.argv[10] |
| | | |
| | | with open(path, "r", encoding="utf-8") as file: |
| | | report = json.load(file) |
| | | |
| | | def require(condition, message): |
| | | if not condition: |
| | | print(f"{case_name}: {message}", file=sys.stderr) |
| | | print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr) |
| | | sys.exit(1) |
| | | |
| | | def sorted_ints(values): |
| | | require(isinstance(values, list), "window ID field must be a list") |
| | | return sorted(int(value) for value in values) |
| | | |
| | | def is_number(value): |
| | | return isinstance(value, numbers.Real) and not isinstance(value, bool) |
| | | |
| | | root = report.get("rootView", {}) |
| | | columns = root.get("waterfallColumns", []) |
| | | cards = [card for column in columns for card in column.get("cards", [])] |
| | | target_column = next((column for column in columns if column.get("appGroupIndex") == target_app_index), None) |
| | | |
| | | require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") |
| | | require(report.get("quickSwitchVisible") is True, "pending filter 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") == expected_mode, "Waterfall view mode must match case") |
| | | require(root.get("keyboardCommandsApplied") == expected_sequence, "pending key sequence must be applied") |
| | | require(root.get("appShelfIndexKeyDownCount") == 1, "pending filter must exercise one physical keyDown") |
| | | require(root.get("lastAppShelfIndexKeySymbol") == app_symbol, "pending physical symbol must be reported") |
| | | require(root.get("lastCommitSource") is None, "pending filter must not commit") |
| | | require(root.get("lastCommittedWindowID") is None, "pending filter must not commit a window") |
| | | require(root.get("lastWindowIndexKeyCommitCode") is None, "pending filter must not expose a commit code") |
| | | |
| | | require(root.get("windowShortcutFilterActive") is True, "windowShortcutFilterActive must be true") |
| | | require(root.get("windowShortcutFilterPhase") == "appPending", "filter phase must be appPending") |
| | | require(root.get("windowShortcutFilterPendingAppGroupIndex") == target_app_index, "pending App group must match first key") |
| | | require(root.get("windowShortcutFilterPrefix") == app_symbol, "filter prefix must match first physical key") |
| | | require(root.get("lastWindowShortcutPreCommitFilterSnapshot") is None, "pending run must not expose a pre-commit snapshot") |
| | | |
| | | require(target_column is not None, "target App column must exist") |
| | | target_cards = target_column.get("cards", []) |
| | | target_ids = sorted(int(card.get("windowID")) for card in target_cards) |
| | | dimmed_cards = [card for card in cards if int(card.get("windowID")) not in set(target_ids)] |
| | | dimmed_ids = sorted(int(card.get("windowID")) for card in dimmed_cards) |
| | | |
| | | require(len(target_cards) == fixture_windows_per_app, "target App must keep all fixture windows") |
| | | require(root.get("windowShortcutFilterMatchedCount") == len(target_ids), "matched count must equal target App window count") |
| | | require(root.get("windowShortcutFilterDimmedCount") == len(dimmed_ids), "dimmed count must equal non-target Waterfall cards") |
| | | require(sorted_ints(root.get("windowShortcutFilterMatchedWindowIDs")) == target_ids, "matched window IDs must be exactly target App windows") |
| | | require(sorted_ints(root.get("windowShortcutFilterDimmedWindowIDs")) == dimmed_ids, "dimmed window IDs must be exactly non-target Waterfall windows") |
| | | |
| | | matched_opacities = [] |
| | | dimmed_opacities = [] |
| | | for card in target_cards: |
| | | states = card.get("visualStates", []) |
| | | opacity = card.get("windowShortcutFilterOpacity") |
| | | require(card.get("windowShortcutFilterState") == "matched", "target App cards must report matched filter state") |
| | | require(is_number(opacity), "matched cards must expose numeric filter opacity") |
| | | require(0 <= opacity <= 1.01, "matched card opacity must be normalized") |
| | | require("shortcutMatched" in states, "matched cards must expose shortcutMatched visual state") |
| | | require("shortcutDimmed" not in states, "matched cards must not expose shortcutDimmed") |
| | | require("shortcutTarget" not in states, "pending matched cards must not expose shortcutTarget") |
| | | matched_opacities.append(float(opacity)) |
| | | |
| | | for card in dimmed_cards: |
| | | states = card.get("visualStates", []) |
| | | opacity = card.get("windowShortcutFilterOpacity") |
| | | require(card.get("windowShortcutFilterState") == "dimmed", "non-target Waterfall cards must report dimmed filter state") |
| | | require(is_number(opacity), "dimmed cards must expose numeric filter opacity") |
| | | require(0 <= opacity <= 1.01, "dimmed card opacity must be normalized") |
| | | require("shortcutDimmed" in states, "dimmed cards must expose shortcutDimmed visual state") |
| | | require("shortcutMatched" not in states, "dimmed cards must not expose shortcutMatched") |
| | | require("shortcutTarget" not in states, "pending dimmed cards must not expose shortcutTarget") |
| | | dimmed_opacities.append(float(opacity)) |
| | | |
| | | require(matched_opacities, "fixture must have matched cards") |
| | | require(dimmed_opacities, "fixture must have dimmed cards") |
| | | require(min(matched_opacities) > max(dimmed_opacities), "matched cards must stay visually brighter than dimmed cards") |
| | | |
| | | for node in root.get("appShelfItems", []) + root.get("spaceLaneSegments", []): |
| | | states = node.get("visualStates", []) |
| | | require("shortcutDimmed" not in states, "shortcut dim visual state must not leak to Space Lane or App Shelf") |
| | | if "windowShortcutFilterState" in node: |
| | | require(node.get("windowShortcutFilterState") != "dimmed", "Space Lane/App Shelf must not report dimmed filter state") |
| | | if "windowShortcutFilterOpacity" in node: |
| | | opacity = node.get("windowShortcutFilterOpacity") |
| | | require(is_number(opacity), "Space Lane/App Shelf filter opacity must be numeric if present") |
| | | require(opacity >= 0.99, "Space Lane/App Shelf must not be dimmed by window shortcut filter") |
| | | |
| | | summary = { |
| | | "case": case_name, |
| | | "status": "passed", |
| | | "theme": theme, |
| | | "mode": root.get("waterfallViewMode"), |
| | | "report": path, |
| | | "phase": root.get("windowShortcutFilterPhase"), |
| | | "prefix": root.get("windowShortcutFilterPrefix"), |
| | | "pendingAppGroupIndex": root.get("windowShortcutFilterPendingAppGroupIndex"), |
| | | "matchedCount": root.get("windowShortcutFilterMatchedCount"), |
| | | "dimmedCount": root.get("windowShortcutFilterDimmedCount"), |
| | | "matchedWindowIDs": target_ids, |
| | | "dimmedWindowIDs": dimmed_ids, |
| | | "matchedOpacityMin": min(matched_opacities), |
| | | "dimmedOpacityMax": max(dimmed_opacities), |
| | | "quickSwitchVisible": report.get("quickSwitchVisible"), |
| | | "commitSource": root.get("lastCommitSource"), |
| | | } |
| | | with open(summary_file, "w", encoding="utf-8") as file: |
| | | json.dump(summary, file, indent=2, ensure_ascii=False) |
| | | PY |
| | | |
| | | SUMMARY_FILES+=("$summary_file") |
| | | } |
| | | |
| | | assert_commit_report() { |
| | | local report="$1" |
| | | local case_name="valid-commit-horizontal" |
| | | local summary_file="$SUMMARY_DIR/$case_name.json" |
| | | |
| | | /usr/bin/python3 - \ |
| | | "$report" \ |
| | | "$case_name" \ |
| | | "light" \ |
| | | "horizontalMasonry" \ |
| | | "$(commit_expected_sequence)" \ |
| | | "$FIXTURE_APP_COUNT" \ |
| | | "$FIXTURE_WINDOWS_PER_APP" \ |
| | | "$TARGET_APP_INDEX" \ |
| | | "$TARGET_WINDOW_INDEX" \ |
| | | "$APP_SYMBOL" \ |
| | | "$WINDOW_SYMBOL" \ |
| | | "$TARGET_CODE" \ |
| | | "$summary_file" <<'PY' |
| | | import json |
| | | import numbers |
| | | import sys |
| | | |
| | | path = sys.argv[1] |
| | | case_name = sys.argv[2] |
| | | theme = sys.argv[3] |
| | | expected_mode = sys.argv[4] |
| | | expected_sequence = [part for part in sys.argv[5].split(",") if part] |
| | | fixture_app_count = int(sys.argv[6]) |
| | | fixture_windows_per_app = int(sys.argv[7]) |
| | | target_app_index = int(sys.argv[8]) |
| | | target_window_index = int(sys.argv[9]) |
| | | app_symbol = sys.argv[10] |
| | | window_symbol = sys.argv[11] |
| | | target_code = sys.argv[12] |
| | | summary_file = sys.argv[13] |
| | | |
| | | with open(path, "r", encoding="utf-8") as file: |
| | | report = json.load(file) |
| | | |
| | | def require(condition, message): |
| | | if not condition: |
| | | print(f"{case_name}: {message}", file=sys.stderr) |
| | | print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr) |
| | | sys.exit(1) |
| | | |
| | | def sorted_ints(values): |
| | | require(isinstance(values, list), "window ID field must be a list") |
| | | return sorted(int(value) for value in values) |
| | | |
| | | def is_number(value): |
| | | return isinstance(value, numbers.Real) and not isinstance(value, bool) |
| | | |
| | | root = report.get("rootView", {}) |
| | | columns = root.get("waterfallColumns", []) |
| | | cards = [card for column in columns for card in column.get("cards", [])] |
| | | target_column = next((column for column in columns if column.get("appGroupIndex") == target_app_index), None) |
| | | |
| | | require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") |
| | | 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") == expected_mode, "commit run must use horizontal Waterfall") |
| | | require(root.get("keyboardCommandsApplied") == expected_sequence, "commit key sequence must be applied") |
| | | require(root.get("appShelfIndexKeyDownCount") == 2, "commit must exercise two physical keyDown events") |
| | | require(root.get("lastAppShelfIndexKeySymbol") == window_symbol, "last physical symbol must be the window key") |
| | | require(root.get("lastWindowIndexKeyCommand") == f"window:{target_code}", "last window shortcut command must be reported") |
| | | require(root.get("lastWindowIndexKeyCommitCode") == target_code, "last window shortcut commit code must be reported") |
| | | require(root.get("windowIndexKeyPendingAppGroupIndex") is None, "commit must clear legacy pending App group") |
| | | require(root.get("windowIndexKeyPendingAppSymbol") is None, "commit must clear legacy pending App symbol") |
| | | require(root.get("lastCommitSource") == "keyboard", "valid shortcut commit must be keyboard sourced") |
| | | |
| | | require(target_column is not None, "target App column must exist") |
| | | target_cards = target_column.get("cards", []) |
| | | require(target_window_index < len(target_cards), "target window index must exist") |
| | | target_card = target_cards[target_window_index] |
| | | target_window_id = int(target_card.get("windowID")) |
| | | all_window_ids = sorted(int(card.get("windowID")) for card in cards) |
| | | dimmed_ids = sorted(window_id for window_id in all_window_ids if window_id != target_window_id) |
| | | |
| | | require(target_card.get("windowShortcutCode") == target_code, "target card shortcut code must match committed code") |
| | | require(root.get("lastCommittedAppGroupIndex") == target_app_index, "commit must target expected App") |
| | | require(root.get("lastCommittedWindowIndex") == target_window_index, "commit must target expected window index") |
| | | require(root.get("lastCommittedWindowID") == target_window_id, "commit must target expected window ID") |
| | | require(root.get("selectedAppGroupIndex") == target_app_index, "selection must land on target App") |
| | | require(root.get("selectedWindowIndex") == target_window_index, "selection must land on target window") |
| | | require(root.get("selectedWindowID") == target_window_id, "selectedWindowID must match target window") |
| | | |
| | | snapshot = root.get("lastWindowShortcutPreCommitFilterSnapshot") |
| | | require(isinstance(snapshot, dict), "pre-commit filter snapshot must be an object") |
| | | require(snapshot.get("phase") == "targetPreCommit", "pre-commit snapshot phase must be targetPreCommit") |
| | | require(snapshot.get("targetCode") == target_code, "pre-commit snapshot targetCode must match committed shortcut") |
| | | require(int(snapshot.get("targetWindowID")) == target_window_id, "pre-commit snapshot targetWindowID must match target card") |
| | | require(sorted_ints(snapshot.get("matchedWindowIDs")) == [target_window_id], "pre-commit snapshot must match only the target window") |
| | | require(sorted_ints(snapshot.get("dimmedWindowIDs")) == dimmed_ids, "pre-commit snapshot must dim all non-target Waterfall windows") |
| | | |
| | | states = target_card.get("visualStates", []) |
| | | opacity = target_card.get("windowShortcutFilterOpacity") |
| | | require(target_card.get("windowShortcutFilterState") == "target", "target card must report target filter state") |
| | | require(is_number(opacity), "target card must expose numeric filter opacity") |
| | | require(0 <= opacity <= 1.01, "target card opacity must be normalized") |
| | | require("shortcutTarget" in states, "target card must expose shortcutTarget visual state") |
| | | |
| | | for card in cards: |
| | | if int(card.get("windowID")) == target_window_id: |
| | | continue |
| | | card_states = card.get("visualStates", []) |
| | | card_opacity = card.get("windowShortcutFilterOpacity") |
| | | require(card.get("windowShortcutFilterState") == "dimmed", "non-target cards must remain dimmed in target pre-commit state") |
| | | require(is_number(card_opacity), "non-target cards must expose numeric filter opacity") |
| | | require("shortcutDimmed" in card_states, "non-target cards must expose shortcutDimmed in target pre-commit state") |
| | | require("shortcutTarget" not in card_states, "non-target cards must not expose shortcutTarget") |
| | | |
| | | for node in root.get("appShelfItems", []) + root.get("spaceLaneSegments", []): |
| | | states = node.get("visualStates", []) |
| | | require("shortcutDimmed" not in states, "shortcut dim visual state must not leak to Space Lane or App Shelf during commit") |
| | | if "windowShortcutFilterState" in node: |
| | | require(node.get("windowShortcutFilterState") != "dimmed", "Space Lane/App Shelf must not report dimmed filter state during commit") |
| | | |
| | | summary = { |
| | | "case": case_name, |
| | | "status": "passed", |
| | | "theme": theme, |
| | | "mode": root.get("waterfallViewMode"), |
| | | "report": path, |
| | | "commands": root.get("keyboardCommandsApplied"), |
| | | "targetCode": target_code, |
| | | "targetWindowID": target_window_id, |
| | | "matchedWindowIDs": snapshot.get("matchedWindowIDs"), |
| | | "dimmedCount": len(snapshot.get("dimmedWindowIDs", [])), |
| | | "lastCommitSource": root.get("lastCommitSource"), |
| | | "lastWindowIndexKeyCommitCode": root.get("lastWindowIndexKeyCommitCode"), |
| | | "targetCardState": target_card.get("windowShortcutFilterState"), |
| | | } |
| | | with open(summary_file, "w", encoding="utf-8") as file: |
| | | json.dump(summary, file, indent=2, ensure_ascii=False) |
| | | PY |
| | | |
| | | SUMMARY_FILES+=("$summary_file") |
| | | } |
| | | |
| | | assert_invalid_report() { |
| | | local report="$1" |
| | | local case_name="invalid-second-key-horizontal" |
| | | local summary_file="$SUMMARY_DIR/$case_name.json" |
| | | |
| | | /usr/bin/python3 - \ |
| | | "$report" \ |
| | | "$case_name" \ |
| | | "horizontalMasonry" \ |
| | | "$(invalid_expected_sequence)" \ |
| | | "$APP_SYMBOL" \ |
| | | "${APP_SYMBOL}Z" \ |
| | | "$summary_file" <<'PY' |
| | | import json |
| | | import numbers |
| | | import sys |
| | | |
| | | path = sys.argv[1] |
| | | case_name = sys.argv[2] |
| | | expected_mode = sys.argv[3] |
| | | expected_sequence = [part for part in sys.argv[4].split(",") if part] |
| | | app_symbol = sys.argv[5] |
| | | invalid_code = sys.argv[6] |
| | | summary_file = sys.argv[7] |
| | | |
| | | with open(path, "r", encoding="utf-8") as file: |
| | | report = json.load(file) |
| | | |
| | | def require(condition, message): |
| | | if not condition: |
| | | print(f"{case_name}: {message}", file=sys.stderr) |
| | | print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr) |
| | | sys.exit(1) |
| | | |
| | | def is_number(value): |
| | | return isinstance(value, numbers.Real) and not isinstance(value, bool) |
| | | |
| | | 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, "invalid shortcut must keep Quick Switch visible") |
| | | require(root.get("waterfallViewMode") == expected_mode, "invalid run must use horizontal Waterfall") |
| | | require(root.get("keyboardCommandsApplied") == expected_sequence, "invalid key sequence must be applied") |
| | | require(root.get("appShelfIndexKeyDownCount") == 2, "invalid shortcut must exercise two physical keyDown events") |
| | | require(root.get("lastAppShelfIndexKeySymbol") == "Z", "last physical symbol must be invalid window key") |
| | | require(root.get("lastWindowIndexKeyCommand") == f"windowInvalid:{invalid_code}", "invalid command must be reported") |
| | | require(root.get("lastWindowIndexKeyCommitCode") is None, "invalid shortcut must not expose commit code") |
| | | require(root.get("lastCommitSource") is None, "invalid shortcut must not commit") |
| | | require(root.get("lastCommittedWindowID") is None, "invalid shortcut must not commit a window") |
| | | require(root.get("windowIndexKeyPendingAppGroupIndex") is None, "invalid shortcut must clear legacy pending App group") |
| | | require(root.get("windowIndexKeyPendingAppSymbol") is None, "invalid shortcut must clear legacy pending App symbol") |
| | | require(root.get("windowShortcutFilterActive") is False, "invalid shortcut must not leave active filter") |
| | | require(root.get("windowShortcutFilterPhase") == "invalid", "invalid shortcut must report invalid phase") |
| | | require(root.get("windowShortcutFilterPendingAppGroupIndex") is None, "invalid shortcut must clear pending App group") |
| | | require(root.get("windowShortcutFilterPrefix") is None, "invalid shortcut must clear filter prefix") |
| | | require(root.get("windowShortcutFilterMatchedCount") == 0, "invalid shortcut must clear matched count") |
| | | require(root.get("windowShortcutFilterDimmedCount") == 0, "invalid shortcut must clear dimmed count") |
| | | require(root.get("windowShortcutFilterMatchedWindowIDs") == [], "invalid shortcut must clear matched IDs") |
| | | require(root.get("windowShortcutFilterDimmedWindowIDs") == [], "invalid shortcut must clear dimmed IDs") |
| | | require(root.get("lastWindowShortcutPreCommitFilterSnapshot") is None, "invalid shortcut must not expose pre-commit snapshot") |
| | | |
| | | for card in cards: |
| | | opacity = card.get("windowShortcutFilterOpacity") |
| | | states = card.get("visualStates", []) |
| | | require(card.get("windowShortcutFilterState") == "none", "invalid shortcut must restore card filter state") |
| | | require(is_number(opacity), "cards must expose numeric filter opacity") |
| | | require(float(opacity) >= 0.99, "invalid shortcut must restore card opacity") |
| | | require("shortcutDimmed" not in states, "invalid shortcut must not leave shortcutDimmed state") |
| | | require("shortcutMatched" not in states, "invalid shortcut must not leave shortcutMatched state") |
| | | require("shortcutTarget" not in states, "invalid shortcut must not leave shortcutTarget state") |
| | | |
| | | summary = { |
| | | "case": case_name, |
| | | "status": "passed", |
| | | "mode": root.get("waterfallViewMode"), |
| | | "report": path, |
| | | "commands": root.get("keyboardCommandsApplied"), |
| | | "firstSymbol": app_symbol, |
| | | "invalidCode": invalid_code, |
| | | "phase": root.get("windowShortcutFilterPhase"), |
| | | "quickSwitchVisible": report.get("quickSwitchVisible"), |
| | | "cardCount": len(cards) |
| | | } |
| | | with open(summary_file, "w", encoding="utf-8") as file: |
| | | json.dump(summary, file, indent=2, ensure_ascii=False) |
| | | PY |
| | | |
| | | SUMMARY_FILES+=("$summary_file") |
| | | } |
| | | |
| | | assert_pending_summaries_consistent() { |
| | | local baseline="$1" |
| | | local candidate="$2" |
| | | local label="$3" |
| | | |
| | | /usr/bin/python3 - "$baseline" "$candidate" "$label" <<'PY' |
| | | import json |
| | | import sys |
| | | |
| | | baseline_path = sys.argv[1] |
| | | candidate_path = sys.argv[2] |
| | | label = sys.argv[3] |
| | | |
| | | with open(baseline_path, "r", encoding="utf-8") as file: |
| | | baseline = json.load(file) |
| | | with open(candidate_path, "r", encoding="utf-8") as file: |
| | | candidate = json.load(file) |
| | | |
| | | fields = [ |
| | | "phase", |
| | | "prefix", |
| | | "pendingAppGroupIndex", |
| | | "matchedCount", |
| | | "dimmedCount", |
| | | "matchedWindowIDs", |
| | | "dimmedWindowIDs", |
| | | ] |
| | | |
| | | for field in fields: |
| | | if baseline.get(field) != candidate.get(field): |
| | | print(f"{label}: pending field {field} differs between baseline and candidate", file=sys.stderr) |
| | | print(json.dumps({"baseline": baseline, "candidate": candidate}, indent=2, ensure_ascii=False), file=sys.stderr) |
| | | sys.exit(1) |
| | | PY |
| | | } |
| | | |
| | | emit_summary() { |
| | | /usr/bin/python3 - "$APP" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" "${SUMMARY_FILES[@]}" <<'PY' |
| | | import json |
| | | import sys |
| | | |
| | | app = sys.argv[1] |
| | | fixture_app_count = int(sys.argv[2]) |
| | | fixture_windows_per_app = int(sys.argv[3]) |
| | | case_paths = sys.argv[4:] |
| | | cases = [] |
| | | for path in case_paths: |
| | | with open(path, "r", encoding="utf-8") as file: |
| | | cases.append(json.load(file)) |
| | | |
| | | print(json.dumps({ |
| | | "status": "passed", |
| | | "script": "round1-window-index-progressive-filter-fixture-qa.sh", |
| | | "app": app, |
| | | "fixture": { |
| | | "appCount": fixture_app_count, |
| | | "windowsPerApp": fixture_windows_per_app |
| | | }, |
| | | "cases": cases, |
| | | "timeoutCleanup": { |
| | | "status": "skipped", |
| | | "reason": "Current --round01-debug-key-sequence has no stable wait token for the 0.9s expiry path; no sleep injection was added." |
| | | } |
| | | }, indent=2, ensure_ascii=False)) |
| | | PY |
| | | } |
| | | |
| | | if [ ! -x "$APP/Contents/MacOS/Aligner" ]; then |
| | | fail "current build app executable not found at $APP/Contents/MacOS/Aligner" |
| | | fi |
| | | if [ "$FIXTURE_APP_COUNT" -le "$TARGET_APP_INDEX" ]; then |
| | | fail "fixture app count must include target App index $TARGET_APP_INDEX" |
| | | fi |
| | | if [ "$FIXTURE_WINDOWS_PER_APP" -le "$TARGET_WINDOW_INDEX" ]; then |
| | | fail "fixture windows per App must include target window index $TARGET_WINDOW_INDEX" |
| | | fi |
| | | |
| | | mkdir -p "$SUMMARY_DIR" |
| | | rm -f "$SUMMARY_DIR"/*.json |
| | | |
| | | stop_current_aligner |
| | | "$SCRIPT_DIR/package-app.sh" >&2 |
| | | preserve_user_theme |
| | | trap cleanup EXIT |
| | | stop_current_aligner |
| | | |
| | | run_fixture "$REPORT_HORIZONTAL_LIGHT" "light" "horizontal" "$(pending_key_sequence)" |
| | | wait_for_pending_report "$REPORT_HORIZONTAL_LIGHT" "$(mode_report_value horizontal)" "$(pending_expected_sequence)" "pending-horizontal-light" |
| | | assert_pending_report "$REPORT_HORIZONTAL_LIGHT" "pending-horizontal-light" "light" "$(mode_report_value horizontal)" |
| | | finish_case |
| | | |
| | | run_fixture "$REPORT_VERTICAL_LIGHT" "light" "vertical" "$(pending_key_sequence)" |
| | | wait_for_pending_report "$REPORT_VERTICAL_LIGHT" "$(mode_report_value vertical)" "$(pending_expected_sequence)" "pending-vertical-light" |
| | | assert_pending_report "$REPORT_VERTICAL_LIGHT" "pending-vertical-light" "light" "$(mode_report_value vertical)" |
| | | finish_case |
| | | |
| | | run_fixture "$REPORT_HORIZONTAL_DARK" "dark" "horizontal" "$(pending_key_sequence)" |
| | | wait_for_pending_report "$REPORT_HORIZONTAL_DARK" "$(mode_report_value horizontal)" "$(pending_expected_sequence)" "pending-horizontal-dark" |
| | | assert_pending_report "$REPORT_HORIZONTAL_DARK" "pending-horizontal-dark" "dark" "$(mode_report_value horizontal)" |
| | | assert_pending_summaries_consistent \ |
| | | "$SUMMARY_DIR/pending-horizontal-light.json" \ |
| | | "$SUMMARY_DIR/pending-horizontal-dark.json" \ |
| | | "pending-horizontal-dark" |
| | | finish_case |
| | | |
| | | run_fixture "$REPORT_COMMIT" "light" "horizontal" "$(commit_key_sequence)" |
| | | wait_for_commit_report "$REPORT_COMMIT" "$(mode_report_value horizontal)" "$(commit_expected_sequence)" |
| | | assert_commit_report "$REPORT_COMMIT" |
| | | finish_case |
| | | |
| | | run_fixture "$REPORT_INVALID" "light" "horizontal" "$(invalid_key_sequence)" |
| | | wait_for_invalid_report "$REPORT_INVALID" "$(mode_report_value horizontal)" "$(invalid_expected_sequence)" |
| | | assert_invalid_report "$REPORT_INVALID" |
| | | finish_case |
| | | |
| | | cleanup |
| | | trap - EXIT |
| | | emit_summary |