#!/bin/bash # Round01.1 theme toggle fixture QA. It verifies the 0.1.1 Quick Switch # light/dark skin toggle, persistence, top-right placement, and dark gradient # backdrop evidence without changing the 0.1 interaction model. 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_THEME_TOGGLE_REPORT_WAIT:-6.0}" REPORT_DEFAULT="$BUILD_REPORT_ROOT/round01-theme-toggle-default-report.json" REPORT_DARK="$BUILD_REPORT_ROOT/round01-theme-toggle-dark-report.json" REPORT_DARK_REOPEN="$BUILD_REPORT_ROOT/round01-theme-toggle-dark-reopen-report.json" REPORT_LIGHT="$BUILD_REPORT_ROOT/round01-theme-toggle-light-report.json" OLD_THEME_SET=0 OLD_THEME="" fail() { echo "Round01.1 theme toggle 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_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 } wait_for_theme_report() { local report="$1" local expected_theme="$2" local expected_mouse_command="${3:-}" /usr/bin/python3 - "$report" "$expected_theme" "$expected_mouse_command" "$REPORT_WAIT" <<'PY' import json import sys import time path = sys.argv[1] expected_theme = sys.argv[2] expected_mouse_command = sys.argv[3] timeout = float(sys.argv[4]) 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("resolvedQuickSwitchTheme") == expected_theme and root.get("themeResolved") == expected_theme and (not expected_mouse_command or root.get("lastMouseCommand") == expected_mouse_command) ): 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"theme report did not reach {expected_theme} within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } assert_theme_report() { local report="$1" local expected_theme="$2" local expected_next_theme="$3" local expected_mouse_command="${4:-}" /usr/bin/python3 - "$report" "$expected_theme" "$expected_next_theme" "$expected_mouse_command" <<'PY' import json import sys path = sys.argv[1] expected_theme = sys.argv[2] expected_next_theme = sys.argv[3] expected_mouse_command = sys.argv[4] 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", {}) theme_frame = root.get("themeToggleButtonFrame", {}) view_frame = root.get("viewModeToggleButtonFrame", {}) bounds = root.get("bounds", {}) gradient_colors = root.get("darkBackdropBackgroundGradientColors", []) require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("quickSwitchVisible") is True, "Quick Switch must remain visible during theme QA") require(report.get("resolvedQuickSwitchTheme") == expected_theme, "app-level resolved Quick Switch theme must match") require(root.get("themeCurrentPreference") == expected_theme, "root theme preference must match expected theme") require(root.get("themeResolved") == expected_theme, "root resolved theme must match expected theme") require(root.get("themeNextPreference") == expected_next_theme, "theme next preference must point to the opposite theme") require(root.get("themeToggleButtonVisible") is True, "theme toggle button must be visible") require(root.get("themeToggleIconKind") == "halfDisk", "theme toggle must use the approved half-disk icon") require(root.get("themeToggleHitSize") == 34, "theme toggle must keep a 34pt hit target") require(theme_frame.get("width") == 30 and theme_frame.get("height") == 30, "theme toggle visual size must be 30pt") require(view_frame.get("width") == 30 and view_frame.get("height") == 30, "view toggle visual size must remain 30pt") require(theme_frame.get("y") == view_frame.get("y"), "theme toggle must align vertically with view toggle") require(theme_frame.get("x", 0) < view_frame.get("x", 0), "theme toggle must sit to the left of the view-mode toggle") require(view_frame.get("x", 0) + view_frame.get("width", 0) > bounds.get("width", 0) * 0.90, "top-right toggle cluster must remain near the right edge") if expected_mouse_command: require(root.get("lastMouseCommand") == expected_mouse_command, "debug mouse command must be recorded") if expected_theme == "dark": require(root.get("darkBackdropLayersVisible") is True, "dark backdrop ambient layers must be visible in dark theme") require(root.get("darkBackdropLayerCount") == 4, "dark theme must expose three ambient layers plus vignette") require(len(gradient_colors) >= 4, "dark background must expose a multi-stop gradient") reds = {round(color.get("red", 0), 3) for color in gradient_colors} greens = {round(color.get("green", 0), 3) for color in gradient_colors} blues = {round(color.get("blue", 0), 3) for color in gradient_colors} require(len(reds) > 1 and len(greens) > 1 and len(blues) > 1, "dark gradient must not collapse to a single color") first = gradient_colors[0] require(first.get("blue", 0) > first.get("red", 1), "dark gradient must start in a blue-family tone") else: require(root.get("darkBackdropLayersVisible") is False, "dark backdrop layers must be hidden in light theme") require(len(gradient_colors) == 2, "light background should keep the existing two-stop baseline gradient") print(json.dumps({ "theme": expected_theme, "nextTheme": root.get("themeNextPreference"), "themeToggleButtonFrame": theme_frame, "viewModeToggleButtonFrame": view_frame, "darkBackdropLayerCount": root.get("darkBackdropLayerCount"), "darkBackdropLayersVisible": root.get("darkBackdropLayersVisible") }, indent=2, ensure_ascii=False)) PY } run_fixture() { local report="$1" shift stop_current_aligner rm -f "$report" "$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.2 \ --round01-quick-switch-quit-after=1.8 \ "$@" >/dev/null 2>&1 & } cleanup() { stop_current_aligner || true restore_user_theme } preserve_user_theme trap cleanup EXIT stop_current_aligner /usr/bin/defaults delete "$DOMAIN" "$THEME_KEY" >/dev/null 2>&1 || true run_fixture "$REPORT_DEFAULT" wait_for_theme_report "$REPORT_DEFAULT" "light" assert_theme_report "$REPORT_DEFAULT" "light" "dark" run_fixture "$REPORT_DARK" --round01-debug-mouse-sequence="click-theme-toggle" wait_for_theme_report "$REPORT_DARK" "dark" "click-theme-toggle" assert_theme_report "$REPORT_DARK" "dark" "light" "click-theme-toggle" run_fixture "$REPORT_DARK_REOPEN" wait_for_theme_report "$REPORT_DARK_REOPEN" "dark" assert_theme_report "$REPORT_DARK_REOPEN" "dark" "light" run_fixture "$REPORT_LIGHT" --round01-debug-mouse-sequence="click-theme-toggle" wait_for_theme_report "$REPORT_LIGHT" "light" "click-theme-toggle" assert_theme_report "$REPORT_LIGHT" "light" "dark" "click-theme-toggle" stop_current_aligner restore_user_theme trap - EXIT cat <