#!/bin/bash # Round01.1 view-mode entry QA. It verifies that the installed-app path exposes # menu entries plus the in-panel Quick Switch view-mode toggle for switching # Waterfall view modes, and reports visible About version/build metadata. 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_VERTICAL="$BUILD_REPORT_ROOT/round01-view-mode-entry-vertical-report.json" REPORT_HORIZONTAL="$BUILD_REPORT_ROOT/round01-view-mode-entry-horizontal-report.json" REPORT_TOGGLE_TO_HORIZONTAL="$BUILD_REPORT_ROOT/round01-view-mode-entry-toggle-to-horizontal-report.json" REPORT_TOGGLE_TO_VERTICAL="$BUILD_REPORT_ROOT/round01-view-mode-entry-toggle-to-vertical-report.json" DOMAIN="com.ar.Aligner" WATERFALL_MODE_KEY="com.ar.Aligner.preferences.quickSwitch.waterfallViewMode" REPORT_WAIT="${ALIGNER_ROUND1_VIEW_MODE_ENTRY_REPORT_WAIT:-6.0}" OLD_WATERFALL_MODE_SET=0 OLD_WATERFALL_MODE="" fail() { echo "Round01.1 view-mode entry QA failed: $*" >&2 exit 1 } 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" } preserve_user_preference() { if OLD_WATERFALL_MODE="$(/usr/bin/defaults read "$DOMAIN" "$WATERFALL_MODE_KEY" 2>/dev/null)"; then OLD_WATERFALL_MODE_SET=1 else OLD_WATERFALL_MODE_SET=0 OLD_WATERFALL_MODE="" fi } restore_user_preference() { if [ "$OLD_WATERFALL_MODE_SET" -eq 1 ]; then /usr/bin/defaults write "$DOMAIN" "$WATERFALL_MODE_KEY" -string "$OLD_WATERFALL_MODE" else /usr/bin/defaults delete "$DOMAIN" "$WATERFALL_MODE_KEY" >/dev/null 2>&1 || true fi } wait_for_loaded_report() { local report="$1" local expected_mode="$2" /usr/bin/python3 - "$report" "$expected_mode" "$REPORT_WAIT" <<'PY' import json import sys import time path = sys.argv[1] expected_mode = sys.argv[2] timeout = float(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("resolvedWaterfallViewMode") == expected_mode and root.get("waterfallViewMode") == expected_mode ): 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"view-mode entry report did not reach {expected_mode} within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } assert_report() { local report="$1" local expected_mode="$2" local expected_vertical_state="$3" local expected_horizontal_state="$4" local app_version="$5" local app_build="$6" /usr/bin/python3 - "$report" "$expected_mode" "$expected_vertical_state" "$expected_horizontal_state" "$app_version" "$app_build" <<'PY' import json import re import sys path = sys.argv[1] expected_mode = sys.argv[2] expected_vertical_state = sys.argv[3] expected_horizontal_state = sys.argv[4] app_version = sys.argv[5] app_build = sys.argv[6] 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) status_menu = report.get("statusMenu", {}) main_menu = report.get("mainMenu", {}) settings_window = report.get("settingsWindow", {}) root = report.get("rootView", {}) expected_target_mode = "verticalColumns" if expected_mode == "horizontalMasonry" else "horizontalMasonry" expected_icon_kind = "verticalColumnsTarget" if expected_target_mode == "verticalColumns" else "horizontalMasonryTarget" expected_accessibility_label = "切换到纵栏瀑布" if expected_target_mode == "verticalColumns" else "切换到横栏瀑布" require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("quickSwitchVisible") is True, "Quick Switch must be visible during entry QA") require(report.get("preferenceWaterfallViewMode") == expected_mode, "preference Waterfall mode must match expected mode") require(report.get("resolvedWaterfallViewMode") == expected_mode, "resolved Waterfall mode must match expected mode") require(root.get("waterfallViewMode") == expected_mode, "root view Waterfall mode must match expected mode") require(status_menu.get("hasAboutItem") is True, "status menu must expose About Aligner") require(status_menu.get("hasSettingsItem") is True, "status menu must expose Settings") require(status_menu.get("hasQuickSwitchViewSubmenu") is True, "status menu must expose Quick Switch View submenu") require(status_menu.get("hasVerticalColumnsItem") is True, "status menu must expose Vertical Columns item") require(status_menu.get("hasHorizontalWaterfallItem") is True, "status menu must expose Horizontal Waterfall item") require(status_menu.get("verticalColumnsState") == expected_vertical_state, "Vertical Columns check state must match expected mode") require(status_menu.get("horizontalWaterfallState") == expected_horizontal_state, "Horizontal Waterfall check state must match expected mode") require(main_menu.get("hasMenu") is True, "main menu must exist") require(main_menu.get("hasAppMenu") is True, "main menu must expose app menu") require(main_menu.get("hasSettingsItem") is True, "main menu must expose Settings") require(main_menu.get("settingsKeyEquivalent") == ",", "Settings must use Cmd+, key equivalent") require(main_menu.get("hasQuickSwitchViewSubmenuInAppMenu") is True, "app menu must expose Quick Switch View submenu") require(main_menu.get("hasQuickSwitchViewSubmenuInViewMenu") is True, "View menu must expose Quick Switch View submenu") require(root.get("settingsButtonVisible") is False, "Quick Switch must not expose the old top-right Settings button") require(root.get("viewModeToggleButtonVisible") is True, "Quick Switch must expose a top-right view-mode toggle button") toggle_frame = root.get("viewModeToggleButtonFrame", {}) require(toggle_frame.get("width", 0) >= 30 and toggle_frame.get("height", 0) >= 30, "view-mode toggle button must have tappable size") require(toggle_frame.get("x", 0) + toggle_frame.get("width", 0) > root.get("bounds", {}).get("width", 0) * 0.90, "view-mode toggle button must sit near the top-right edge") require(root.get("viewModeToggleCurrentMode") == expected_mode, "view-mode toggle must report the current mode") require(root.get("viewModeToggleTargetMode") == expected_target_mode, "view-mode toggle must report the next target mode") require(root.get("viewModeToggleIconKind") == expected_icon_kind, "view-mode toggle icon kind must represent the target mode") require(root.get("viewModeToggleAccessibilityLabel") == expected_accessibility_label, "view-mode toggle label must name the target mode") require(settings_window.get("visible") is False, "Settings window should not be open in passive view-mode entry cases") require(report.get("aboutVersion") == app_version, "About version must match app Info.plist") require(report.get("aboutBuild") == app_build, "About build must match app Info.plist") require(re.match(r"^[0-9]+\.[0-9]+\.[0-9]+$", app_version) is not None, "version must be MAJOR.MINOR.PATCH") require(re.match(r"^[0-9]{8}\.[0-9]{4}$", app_build) is not None, "build must be YYYYMMDD.HHMM") print(json.dumps({ "mode": expected_mode, "aboutVersion": report.get("aboutVersion"), "aboutBuild": report.get("aboutBuild"), "statusMenu": status_menu, "mainMenu": main_menu, "viewModeToggleButtonFrame": toggle_frame, "viewModeToggleTargetMode": root.get("viewModeToggleTargetMode"), "viewModeToggleIconKind": root.get("viewModeToggleIconKind") }, indent=2, ensure_ascii=False)) PY } wait_for_toggle_report() { local report="$1" local expected_mode="$2" /usr/bin/python3 - "$report" "$expected_mode" "$REPORT_WAIT" <<'PY' import json import sys import time path = sys.argv[1] expected_mode = sys.argv[2] timeout = float(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 True and report.get("resolvedWaterfallViewMode") == expected_mode and root.get("waterfallViewMode") == expected_mode and root.get("lastMouseCommand") == "click-view-toggle" ): 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"view-mode toggle report did not reach {expected_mode} within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } assert_toggle_report() { local report="$1" local initial_mode="$2" local expected_mode="$3" local expected_vertical_state="$4" local expected_horizontal_state="$5" local app_version="$6" local app_build="$7" /usr/bin/python3 - "$report" "$initial_mode" "$expected_mode" "$expected_vertical_state" "$expected_horizontal_state" "$app_version" "$app_build" <<'PY' import json import sys path = sys.argv[1] initial_mode = sys.argv[2] expected_mode = sys.argv[3] expected_vertical_state = sys.argv[4] expected_horizontal_state = sys.argv[5] app_version = sys.argv[6] app_build = 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", {}) status_menu = report.get("statusMenu", {}) main_menu = report.get("mainMenu", {}) settings_window = report.get("settingsWindow", {}) expected_icon_kind = "verticalColumnsTarget" if initial_mode == "verticalColumns" else "horizontalMasonryTarget" expected_label = "切换到纵栏瀑布" if initial_mode == "verticalColumns" else "切换到横栏瀑布" require(report.get("snapshotLoaded") is True, "toggle case must load the snapshot before clicking the view-mode toggle") require(report.get("quickSwitchVisible") is True, "clicking the view-mode toggle must keep Quick Switch visible") require(report.get("preferenceWaterfallViewMode") == expected_mode, "preference Waterfall mode must switch after toggle") require(report.get("resolvedWaterfallViewMode") == expected_mode, "resolved Waterfall mode must switch after toggle") require(root.get("waterfallViewMode") == expected_mode, "root view Waterfall mode must switch after toggle") require(root.get("lastMouseCommand") == "click-view-toggle", "debug mouse sequence must click the view-mode toggle") require(root.get("settingsButtonVisible") is False, "old Settings button must stay hidden") require(root.get("viewModeToggleButtonVisible") is True, "Quick Switch root must report the view-mode toggle") require(root.get("viewModeToggleCurrentMode") == expected_mode, "toggle current mode must update after click") require(root.get("viewModeToggleTargetMode") == initial_mode, "toggle target mode must flip back to the initial mode after click") require(root.get("viewModeToggleIconKind") == expected_icon_kind, "toggle icon must represent the next target mode after click") require(root.get("viewModeToggleAccessibilityLabel") == expected_label, "toggle label must represent the next target mode after click") require(status_menu.get("verticalColumnsState") == expected_vertical_state, "Vertical Columns menu state must update after toggle") require(status_menu.get("horizontalWaterfallState") == expected_horizontal_state, "Horizontal Waterfall menu state must update after toggle") require(main_menu.get("hasSettingsItem") is True, "main menu must expose Settings") require(main_menu.get("settingsKeyEquivalent") == ",", "Settings must use Cmd+, key equivalent") require(settings_window.get("visible") is False, "view-mode toggle must not open Settings") require(report.get("aboutVersion") == app_version, "About version must match app Info.plist") require(report.get("aboutBuild") == app_build, "About build must match app Info.plist") print(json.dumps({ "case": "view-mode-toggle", "initialMode": initial_mode, "expectedMode": expected_mode, "quickSwitchVisible": report.get("quickSwitchVisible"), "lastMouseCommand": root.get("lastMouseCommand"), "viewModeToggleIconKind": root.get("viewModeToggleIconKind"), "statusMenu": status_menu, "mainMenu": main_menu }, indent=2, ensure_ascii=False)) PY } run_case() { local mode="$1" local report="$2" local vertical_state="$3" local horizontal_state="$4" local app_version="$5" local app_build="$6" stop_current_aligner rm -f "$report" /usr/bin/defaults write "$DOMAIN" "$WATERFALL_MODE_KEY" -string "$mode" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-fixture-app-count=4 \ --round01-fixture-windows-per-app=2 \ --round01-disable-screenshot-refresh \ --round01-quick-switch-report="$report" \ --round01-quick-switch-auto-hide-after=1.0 \ --round01-quick-switch-quit-after=1.2 & wait_for_loaded_report "$report" "$mode" assert_report "$report" "$mode" "$vertical_state" "$horizontal_state" "$app_version" "$app_build" wait "$!" || true stop_current_aligner } run_toggle_case() { local initial_mode="$1" local expected_mode="$2" local report="$3" local vertical_state="$4" local horizontal_state="$5" local app_version="$6" local app_build="$7" stop_current_aligner rm -f "$report" /usr/bin/defaults write "$DOMAIN" "$WATERFALL_MODE_KEY" -string "$initial_mode" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-fixture-app-count=4 \ --round01-fixture-windows-per-app=2 \ --round01-disable-screenshot-refresh \ --round01-debug-mouse-sequence="click-view-toggle" \ --round01-quick-switch-report="$report" \ --round01-quick-switch-quit-after=1.2 & wait_for_toggle_report "$report" "$expected_mode" assert_toggle_report "$report" "$initial_mode" "$expected_mode" "$vertical_state" "$horizontal_state" "$app_version" "$app_build" wait "$!" || true stop_current_aligner } [ -x "$APP/Contents/MacOS/Aligner" ] || fail "missing executable app at $APP" INFO_PLIST="$APP/Contents/Info.plist" APP_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$INFO_PLIST" 2>/dev/null)" \ || fail "unable to read CFBundleShortVersionString from $INFO_PLIST" APP_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$INFO_PLIST" 2>/dev/null)" \ || fail "unable to read CFBundleVersion from $INFO_PLIST" preserve_user_preference trap 'stop_current_aligner; restore_user_preference' EXIT run_case "verticalColumns" "$REPORT_VERTICAL" "on" "off" "$APP_VERSION" "$APP_BUILD" run_case "horizontalMasonry" "$REPORT_HORIZONTAL" "off" "on" "$APP_VERSION" "$APP_BUILD" run_toggle_case "verticalColumns" "horizontalMasonry" "$REPORT_TOGGLE_TO_HORIZONTAL" "off" "on" "$APP_VERSION" "$APP_BUILD" run_toggle_case "horizontalMasonry" "verticalColumns" "$REPORT_TOGGLE_TO_VERTICAL" "on" "off" "$APP_VERSION" "$APP_BUILD" echo "Round01.1 view-mode entry QA passed"