#!/bin/bash
|
# Round01.1 view-mode entry QA. It verifies that the installed-app path exposes
|
# a discoverable status-menu entry for switching Quick Switch 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_SETTINGS="$BUILD_REPORT_ROOT/round01-view-mode-entry-settings-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", {})
|
|
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 True, "Quick Switch must expose a top-right Settings button")
|
settings_frame = root.get("settingsButtonFrame", {})
|
require(settings_frame.get("width", 0) >= 30 and settings_frame.get("height", 0) >= 30, "Settings button must have tappable size")
|
require(settings_frame.get("x", 0) + settings_frame.get("width", 0) > root.get("bounds", {}).get("width", 0) * 0.90, "Settings button must sit near the top-right edge")
|
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,
|
"settingsButtonFrame": settings_frame
|
}, indent=2, ensure_ascii=False))
|
PY
|
}
|
|
wait_for_settings_report() {
|
local report="$1"
|
|
/usr/bin/python3 - "$report" "$REPORT_WAIT" <<'PY'
|
import json
|
import sys
|
import time
|
|
path = sys.argv[1]
|
timeout = float(sys.argv[2])
|
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
|
if report.get("settingsWindow", {}).get("visible") is True:
|
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"settings report did not become visible within {timeout:.1f}s", file=sys.stderr)
|
sys.exit(1)
|
PY
|
}
|
|
assert_settings_report() {
|
local report="$1"
|
local app_version="$2"
|
local app_build="$3"
|
|
/usr/bin/python3 - "$report" "$app_version" "$app_build" <<'PY'
|
import json
|
import sys
|
|
path = sys.argv[1]
|
app_version = sys.argv[2]
|
app_build = sys.argv[3]
|
|
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", {})
|
main_menu = report.get("mainMenu", {})
|
settings_window = report.get("settingsWindow", {})
|
|
require(report.get("snapshotLoaded") is True, "settings case must load the snapshot before clicking the gear")
|
require(report.get("quickSwitchVisible") is False, "clicking the Settings gear must close Quick Switch")
|
require(root.get("lastMouseCommand") == "click-settings", "debug mouse sequence must click the Settings gear")
|
require(root.get("settingsButtonVisible") is True, "Quick Switch root must report the Settings gear")
|
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("exists") is True, "Settings window controller must be created")
|
require(settings_window.get("visible") is True, "Settings window must be visible after clicking gear")
|
require(settings_window.get("selectedTabLabel") == "视图", "Settings must open on the View tab")
|
require(settings_window.get("selectedTabIdentifier") == "view", "Settings View tab identifier must be stable")
|
require(settings_window.get("hasViewTab") is True, "Settings window must expose View tab")
|
require(settings_window.get("usesSwiftUI") is False, "Settings window must not use SwiftUI")
|
require(settings_window.get("waterfallViewMode") == "verticalColumns", "Settings must read the current Waterfall view mode")
|
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": "settings-entry",
|
"quickSwitchVisible": report.get("quickSwitchVisible"),
|
"lastMouseCommand": root.get("lastMouseCommand"),
|
"settingsWindow": settings_window,
|
"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_settings_case() {
|
local report="$1"
|
local app_version="$2"
|
local app_build="$3"
|
|
stop_current_aligner
|
rm -f "$report"
|
/usr/bin/defaults write "$DOMAIN" "$WATERFALL_MODE_KEY" -string "verticalColumns"
|
|
"$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-settings" \
|
--round01-quick-switch-report="$report" \
|
--round01-quick-switch-quit-after=1.2 &
|
|
wait_for_settings_report "$report"
|
assert_settings_report "$report" "$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_settings_case "$REPORT_SETTINGS" "$APP_VERSION" "$APP_BUILD"
|
|
echo "Round01.1 view-mode entry QA passed"
|