#!/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
|