| New file |
| | |
| | | #!/bin/bash |
| | | # Round01.1 Space Lane dark-theme hover readability QA. |
| | | # Verifies that dark theme keeps the light hover surface but flips Space tile |
| | | # foreground elements to dark colors only when the tile actually uses a light |
| | | # occupied active/associated fill. |
| | | |
| | | 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_SPACE_DARK_HOVER_REPORT_WAIT:-6.0}" |
| | | REPORT_DARK_BASE="$BUILD_REPORT_ROOT/round01-space-dark-hover-base-report.json" |
| | | REPORT_DARK_HOVER="$BUILD_REPORT_ROOT/round01-space-dark-hover-report.json" |
| | | REPORT_DARK_SPLIT_BASE="$BUILD_REPORT_ROOT/round01-space-dark-hover-split-base-report.json" |
| | | REPORT_DARK_SPLIT_HOVER="$BUILD_REPORT_ROOT/round01-space-dark-hover-split-report.json" |
| | | REPORT_DARK_EMPTY_BASE="$BUILD_REPORT_ROOT/round01-space-dark-hover-empty-base-report.json" |
| | | REPORT_DARK_EMPTY_HOVER="$BUILD_REPORT_ROOT/round01-space-dark-hover-empty-report.json" |
| | | REPORT_LIGHT_HOVER="$BUILD_REPORT_ROOT/round01-space-dark-hover-light-report.json" |
| | | OLD_THEME_SET=0 |
| | | OLD_THEME="" |
| | | APP_PID="" |
| | | |
| | | fail() { |
| | | echo "Round01.1 Space Lane dark hover 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() { |
| | | if [ -n "${APP_PID:-}" ]; then |
| | | kill "$APP_PID" 2>/dev/null || true |
| | | wait "$APP_PID" 2>/dev/null || true |
| | | APP_PID="" |
| | | fi |
| | | |
| | | 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_loaded_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"report did not reach {expected_theme} within {timeout:.1f}s", file=sys.stderr) |
| | | sys.exit(1) |
| | | PY |
| | | } |
| | | |
| | | select_space_id() { |
| | | local report="$1" |
| | | local selector="$2" |
| | | |
| | | /usr/bin/python3 - "$report" "$selector" <<'PY' |
| | | import json |
| | | import sys |
| | | |
| | | with open(sys.argv[1], "r", encoding="utf-8") as file: |
| | | report = json.load(file) |
| | | |
| | | selector = sys.argv[2] |
| | | segments = report.get("rootView", {}).get("spaceLaneSegments", []) |
| | | |
| | | def unlocked(segment): |
| | | return "locked" not in segment.get("visualStates", []) |
| | | |
| | | for segment in segments: |
| | | if selector == "occupied" and segment.get("windowCount", 0) > 0 and unlocked(segment): |
| | | print(segment["spaceID"]) |
| | | sys.exit(0) |
| | | if selector == "split" and len(segment.get("splitViewAppNames", [])) >= 2 and segment.get("windowCount", 0) > 0 and unlocked(segment): |
| | | print(segment["spaceID"]) |
| | | sys.exit(0) |
| | | if selector == "empty" and segment.get("windowCount", 0) == 0 and unlocked(segment): |
| | | print(segment["spaceID"]) |
| | | sys.exit(0) |
| | | |
| | | print("", end="") |
| | | PY |
| | | } |
| | | |
| | | run_fixture() { |
| | | local theme="$1" |
| | | local report="$2" |
| | | shift 2 |
| | | |
| | | stop_current_aligner |
| | | /usr/bin/defaults write "$DOMAIN" "$THEME_KEY" -string "$theme" |
| | | 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-quit-after=1.8 \ |
| | | "$@" >/dev/null 2>&1 & |
| | | |
| | | APP_PID=$! |
| | | } |
| | | |
| | | run_split_fixture() { |
| | | local theme="$1" |
| | | local report="$2" |
| | | shift 2 |
| | | |
| | | stop_current_aligner |
| | | /usr/bin/defaults write "$DOMAIN" "$THEME_KEY" -string "$theme" |
| | | rm -f "$report" |
| | | |
| | | "$APP/Contents/MacOS/Aligner" \ |
| | | --round0-skip-permissions \ |
| | | --round01-open-quick-switch \ |
| | | --round01-fixture-split-view \ |
| | | --round01-disable-screenshot-refresh \ |
| | | --round01-quick-switch-report="$report" \ |
| | | --round01-quick-switch-quit-after=1.8 \ |
| | | "$@" >/dev/null 2>&1 & |
| | | |
| | | APP_PID=$! |
| | | } |
| | | |
| | | run_space_filter_fixture() { |
| | | local theme="$1" |
| | | local report="$2" |
| | | shift 2 |
| | | |
| | | stop_current_aligner |
| | | /usr/bin/defaults write "$DOMAIN" "$THEME_KEY" -string "$theme" |
| | | rm -f "$report" |
| | | |
| | | "$APP/Contents/MacOS/Aligner" \ |
| | | --round0-skip-permissions \ |
| | | --round01-open-quick-switch \ |
| | | --round01-fixture-space-filter \ |
| | | --round01-disable-screenshot-refresh \ |
| | | --round01-quick-switch-report="$report" \ |
| | | --round01-quick-switch-quit-after=1.8 \ |
| | | "$@" >/dev/null 2>&1 & |
| | | |
| | | APP_PID=$! |
| | | } |
| | | |
| | | assert_dark_on_light_hover() { |
| | | local report="$1" |
| | | local space_id="$2" |
| | | local require_split="${3:-0}" |
| | | |
| | | /usr/bin/python3 - "$report" "$space_id" "$require_split" <<'PY' |
| | | import json |
| | | import sys |
| | | |
| | | path = sys.argv[1] |
| | | space_id = int(sys.argv[2]) |
| | | require_split = sys.argv[3] == "1" |
| | | |
| | | with open(path, "r", encoding="utf-8") as file: |
| | | report = json.load(file) |
| | | |
| | | root = report.get("rootView", {}) |
| | | segments = root.get("spaceLaneSegments", []) |
| | | segment = next((item for item in segments if int(item.get("spaceID")) == space_id), None) |
| | | |
| | | def fail(message): |
| | | print(message, file=sys.stderr) |
| | | print(json.dumps(segment or report, indent=2, ensure_ascii=False), file=sys.stderr) |
| | | sys.exit(1) |
| | | |
| | | def avg(color): |
| | | return (color.get("red", 0) + color.get("green", 0) + color.get("blue", 0)) / 3 |
| | | |
| | | def require_dark(color, name, max_avg=0.36, min_alpha=0.12): |
| | | if color.get("alpha", 0) < min_alpha or avg(color) > max_avg: |
| | | fail(f"{name} must be dark enough on light hover fill") |
| | | |
| | | def require_light(color, name, min_avg=0.44, min_alpha=0.50): |
| | | if color.get("alpha", 0) < min_alpha or avg(color) < min_avg: |
| | | fail(f"{name} must remain a light hover fill") |
| | | |
| | | if root.get("themeResolved") != "dark" or report.get("resolvedQuickSwitchTheme") != "dark": |
| | | fail("report must be in dark theme") |
| | | if segment is None: |
| | | fail("target space segment not found") |
| | | if segment.get("isHovered") is not True or "hover" not in segment.get("visualStates", []): |
| | | fail("target space must be hovered") |
| | | if segment.get("usesLightActiveFill") is not True: |
| | | fail("hovered occupied dark tile must report usesLightActiveFill") |
| | | if segment.get("foregroundContrastStyle") != "darkOnLight": |
| | | fail("hovered occupied dark tile must use darkOnLight foreground style") |
| | | if segment.get("backgroundKind") != "solid": |
| | | fail("hovered occupied dark tile must use a solid light hover background") |
| | | |
| | | require_light(segment.get("backgroundColor", {}), "hover background") |
| | | require_dark(segment.get("labelColor", {}), "space label") |
| | | require_dark(segment.get("countColor", {}), "window count") |
| | | require_dark(segment.get("appColor", {}), "app text") |
| | | |
| | | if require_split and len(segment.get("splitViewAppNames", [])) < 2: |
| | | fail("split fixture target must expose split view app names") |
| | | for index, color in enumerate(segment.get("splitViewLabelColors", [])): |
| | | require_dark(color, f"split app label {index}") |
| | | |
| | | if segment.get("fullscreenMarkerVisible"): |
| | | require_dark(segment.get("fullscreenMarkerStrokeColor", {}), "fullscreen/split marker") |
| | | |
| | | for index, color in enumerate(segment.get("windowBlockColors", [])): |
| | | require_dark(color, f"window block {index}", max_avg=0.38, min_alpha=0.05) |
| | | |
| | | print(json.dumps({ |
| | | "spaceID": space_id, |
| | | "label": segment.get("label"), |
| | | "foregroundContrastStyle": segment.get("foregroundContrastStyle"), |
| | | "backgroundKind": segment.get("backgroundKind"), |
| | | "labelColor": segment.get("labelColor"), |
| | | "countColor": segment.get("countColor"), |
| | | "appColor": segment.get("appColor") |
| | | }, indent=2, ensure_ascii=False)) |
| | | PY |
| | | } |
| | | |
| | | assert_no_light_active_fill() { |
| | | local report="$1" |
| | | local space_id="$2" |
| | | local expected_theme="$3" |
| | | |
| | | /usr/bin/python3 - "$report" "$space_id" "$expected_theme" <<'PY' |
| | | import json |
| | | import sys |
| | | |
| | | with open(sys.argv[1], "r", encoding="utf-8") as file: |
| | | report = json.load(file) |
| | | |
| | | space_id = int(sys.argv[2]) |
| | | expected_theme = sys.argv[3] |
| | | root = report.get("rootView", {}) |
| | | segment = next((item for item in root.get("spaceLaneSegments", []) if int(item.get("spaceID")) == space_id), None) |
| | | |
| | | def fail(message): |
| | | print(message, file=sys.stderr) |
| | | print(json.dumps(segment or report, indent=2, ensure_ascii=False), file=sys.stderr) |
| | | sys.exit(1) |
| | | |
| | | if root.get("themeResolved") != expected_theme or report.get("resolvedQuickSwitchTheme") != expected_theme: |
| | | fail("theme mismatch") |
| | | if segment is None: |
| | | fail("target space segment not found") |
| | | if segment.get("isHovered") is not True: |
| | | fail("target space must be hovered") |
| | | if segment.get("usesLightActiveFill") is not False: |
| | | fail("segment must not use the dark-theme light active fill path") |
| | | if segment.get("foregroundContrastStyle") != "themeDefault": |
| | | fail("segment must keep theme default foreground style") |
| | | |
| | | print(json.dumps({ |
| | | "theme": expected_theme, |
| | | "spaceID": space_id, |
| | | "label": segment.get("label"), |
| | | "foregroundContrastStyle": segment.get("foregroundContrastStyle"), |
| | | "backgroundKind": segment.get("backgroundKind"), |
| | | "windowCount": segment.get("windowCount") |
| | | }, indent=2, ensure_ascii=False)) |
| | | PY |
| | | } |
| | | |
| | | cleanup() { |
| | | stop_current_aligner || true |
| | | restore_user_theme |
| | | } |
| | | |
| | | preserve_user_theme |
| | | trap cleanup EXIT |
| | | stop_current_aligner |
| | | |
| | | run_fixture "dark" "$REPORT_DARK_BASE" |
| | | wait_for_loaded_report "$REPORT_DARK_BASE" "dark" |
| | | OCCUPIED_SPACE_ID="$(select_space_id "$REPORT_DARK_BASE" occupied)" |
| | | [ -n "$OCCUPIED_SPACE_ID" ] || fail "no occupied Space found in layout fixture" |
| | | |
| | | run_fixture "dark" "$REPORT_DARK_HOVER" --round01-debug-mouse-sequence="hover-space:$OCCUPIED_SPACE_ID" |
| | | wait_for_loaded_report "$REPORT_DARK_HOVER" "dark" "hover-space:$OCCUPIED_SPACE_ID" |
| | | assert_dark_on_light_hover "$REPORT_DARK_HOVER" "$OCCUPIED_SPACE_ID" |
| | | |
| | | run_split_fixture "dark" "$REPORT_DARK_SPLIT_BASE" |
| | | wait_for_loaded_report "$REPORT_DARK_SPLIT_BASE" "dark" |
| | | SPLIT_SPACE_ID="$(select_space_id "$REPORT_DARK_SPLIT_BASE" split)" |
| | | [ -n "$SPLIT_SPACE_ID" ] || fail "no Split View Space found in split fixture" |
| | | |
| | | run_split_fixture "dark" "$REPORT_DARK_SPLIT_HOVER" --round01-debug-mouse-sequence="hover-space:$SPLIT_SPACE_ID" |
| | | wait_for_loaded_report "$REPORT_DARK_SPLIT_HOVER" "dark" "hover-space:$SPLIT_SPACE_ID" |
| | | assert_dark_on_light_hover "$REPORT_DARK_SPLIT_HOVER" "$SPLIT_SPACE_ID" 1 |
| | | |
| | | run_space_filter_fixture "dark" "$REPORT_DARK_EMPTY_BASE" |
| | | wait_for_loaded_report "$REPORT_DARK_EMPTY_BASE" "dark" |
| | | EMPTY_SPACE_ID="$(select_space_id "$REPORT_DARK_EMPTY_BASE" empty)" |
| | | [ -n "$EMPTY_SPACE_ID" ] || fail "no empty Space found in space-filter fixture" |
| | | |
| | | run_space_filter_fixture "dark" "$REPORT_DARK_EMPTY_HOVER" --round01-debug-mouse-sequence="hover-space:$EMPTY_SPACE_ID" |
| | | wait_for_loaded_report "$REPORT_DARK_EMPTY_HOVER" "dark" "hover-space:$EMPTY_SPACE_ID" |
| | | assert_no_light_active_fill "$REPORT_DARK_EMPTY_HOVER" "$EMPTY_SPACE_ID" "dark" |
| | | |
| | | run_fixture "light" "$REPORT_LIGHT_HOVER" --round01-debug-mouse-sequence="hover-space:$OCCUPIED_SPACE_ID" |
| | | wait_for_loaded_report "$REPORT_LIGHT_HOVER" "light" "hover-space:$OCCUPIED_SPACE_ID" |
| | | assert_no_light_active_fill "$REPORT_LIGHT_HOVER" "$OCCUPIED_SPACE_ID" "light" |
| | | |
| | | stop_current_aligner |
| | | restore_user_theme |
| | | trap - EXIT |
| | | |
| | | cat <<EOF |
| | | Round01.1 Space Lane dark hover QA passed |
| | | Reports: |
| | | - $REPORT_DARK_HOVER |
| | | - $REPORT_DARK_SPLIT_HOVER |
| | | - $REPORT_DARK_EMPTY_HOVER |
| | | - $REPORT_LIGHT_HOVER |
| | | EOF |