From a3d82000231b8a8e3709e1a6708dbbdaf81412dc Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 20 Jun 2026 12:32:28 +0800
Subject: [PATCH] Add progressive window shortcut filtering
---
C3.tools/round1-window-index-progressive-filter-fixture-qa.sh | 809 ++++++++++++++++++++++++++++++++++++++++++++
C3.tools/round1-main-ui-qa.sh | 3
C1.source/Sources/Aligner/QuickSwitchRootView.swift | 219 +++++++++++
3 files changed, 1,020 insertions(+), 11 deletions(-)
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index 81e6310..e3811f7 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -99,6 +99,8 @@
private var windowIndexKeyPendingClearTask: Task<Void, Never>?
private var lastWindowIndexKeyCommand: String?
private var lastWindowIndexKeyCommitCode: String?
+ private var windowShortcutFilterPhase: WindowShortcutFilterPhase = .none
+ private var lastWindowShortcutPreCommitFilterSnapshot: WindowShortcutPreCommitFilterSnapshot?
private var lastBoundaryBounceAxis: String?
private var lastBoundaryBounceDirection: String?
private var boundaryBounceCount = 0
@@ -343,6 +345,47 @@
let appSymbol: String
let appGroupIndex: Int
let startedAt: CFTimeInterval
+ }
+
+ private enum WindowShortcutFilterPhase: String {
+ case none
+ case appPending
+ case targetPreCommit
+ case invalid
+ case expired
+ }
+
+ private enum WindowShortcutFilterState: String {
+ case none
+ case matched
+ case dimmed
+ case target
+ }
+
+ private struct WindowShortcutPreCommitFilterSnapshot {
+ let targetCode: String
+ let targetWindowID: UInt32
+ let matchedWindowIDs: [UInt32]
+ let dimmedWindowIDs: [UInt32]
+
+ var reportDictionary: [String: Any] {
+ [
+ "phase": WindowShortcutFilterPhase.targetPreCommit.rawValue,
+ "targetCode": targetCode,
+ "targetWindowID": Int(targetWindowID),
+ "matchedWindowIDs": matchedWindowIDs.map(Int.init),
+ "dimmedWindowIDs": dimmedWindowIDs.map(Int.init)
+ ]
+ }
+ }
+
+ private struct WindowShortcutFilterReport {
+ let active: Bool
+ let phase: WindowShortcutFilterPhase
+ let pendingAppGroupIndex: Int?
+ let prefix: String?
+ let matchedWindowIDs: [UInt32]
+ let dimmedWindowIDs: [UInt32]
}
private struct ProjectionLayerSnapshot {
@@ -1087,6 +1130,8 @@
clearWindowIndexKeyPending()
lastWindowIndexKeyCommand = nil
lastWindowIndexKeyCommitCode = nil
+ windowShortcutFilterPhase = .none
+ lastWindowShortcutPreCommitFilterSnapshot = nil
lastBoundaryBounceAxis = nil
lastBoundaryBounceDirection = nil
boundaryBounceCount = 0
@@ -1405,6 +1450,7 @@
let firstFrameGatePassed = firstFrameP95.map { $0 < 150 } ?? false
let keyboardGatePassed = keyboardP95.map { $0 < 50 } ?? false
let hoverGatePassed = hoverP95.map { $0 <= 32 } ?? false
+ let shortcutFilterReport = makeWindowShortcutFilterReport()
return [
"viewClass": String(describing: Self.self),
@@ -1535,6 +1581,15 @@
"windowIndexKeyPendingAppSymbol": windowIndexKeyPending?.appSymbol ?? NSNull(),
"lastWindowIndexKeyCommand": lastWindowIndexKeyCommand ?? NSNull(),
"lastWindowIndexKeyCommitCode": lastWindowIndexKeyCommitCode ?? NSNull(),
+ "windowShortcutFilterActive": shortcutFilterReport.active,
+ "windowShortcutFilterPhase": shortcutFilterReport.phase.rawValue,
+ "windowShortcutFilterPendingAppGroupIndex": shortcutFilterReport.pendingAppGroupIndex ?? NSNull(),
+ "windowShortcutFilterPrefix": shortcutFilterReport.prefix ?? NSNull(),
+ "windowShortcutFilterMatchedCount": shortcutFilterReport.matchedWindowIDs.count,
+ "windowShortcutFilterDimmedCount": shortcutFilterReport.dimmedWindowIDs.count,
+ "windowShortcutFilterMatchedWindowIDs": shortcutFilterReport.matchedWindowIDs.map(Int.init),
+ "windowShortcutFilterDimmedWindowIDs": shortcutFilterReport.dimmedWindowIDs.map(Int.init),
+ "lastWindowShortcutPreCommitFilterSnapshot": lastWindowShortcutPreCommitFilterSnapshot?.reportDictionary ?? NSNull(),
"lastBoundaryBounceAxis": lastBoundaryBounceAxis ?? NSNull(),
"lastBoundaryBounceDirection": lastBoundaryBounceDirection ?? NSNull(),
"boundaryBounceCount": boundaryBounceCount,
@@ -1708,7 +1763,12 @@
recordKeyboardCommand("window:\(code)")
lastWindowIndexKeyCommand = "window:\(code)"
lastWindowIndexKeyCommitCode = code
- clearWindowIndexKeyPending()
+ lastWindowShortcutPreCommitFilterSnapshot = makeWindowShortcutPreCommitFilterSnapshot(
+ target: card,
+ code: code
+ )
+ windowShortcutFilterPhase = .targetPreCommit
+ clearWindowIndexKeyPending(resetShortcutFilter: false)
commitKeyboardWindowCard(card, trigger: "windowIndexKey")
return true
}
@@ -1716,8 +1776,10 @@
recordKeyboardCommand("windowInvalid:\(code)")
lastWindowIndexKeyCommand = "windowInvalid:\(code)"
lastWindowIndexKeyCommitCode = nil
+ windowShortcutFilterPhase = .invalid
+ lastWindowShortcutPreCommitFilterSnapshot = nil
selectionChangedByLastCommand = false
- clearWindowIndexKeyPending()
+ clearWindowIndexKeyPending(resetShortcutFilter: false)
return true
}
@@ -1783,6 +1845,9 @@
appGroupIndex: appGroupIndex,
startedAt: startedAt
)
+ windowShortcutFilterPhase = .appPending
+ lastWindowShortcutPreCommitFilterSnapshot = nil
+ needsLayout = true
windowIndexKeyPendingClearTask = Task { @MainActor [weak self] in
do {
try await Task.sleep(nanoseconds: UInt64(Self.windowIndexKeyPendingInterval * 1_000_000_000))
@@ -1799,13 +1864,26 @@
}
self.windowIndexKeyPending = nil
self.windowIndexKeyPendingClearTask = nil
+ self.windowShortcutFilterPhase = .expired
+ self.lastWindowShortcutPreCommitFilterSnapshot = nil
+ self.needsLayout = true
}
}
- private func clearWindowIndexKeyPending() {
+ private func clearWindowIndexKeyPending(resetShortcutFilter: Bool = true) {
+ let hadFilterState = windowIndexKeyPending != nil
+ || windowShortcutFilterPhase != .none
+ || lastWindowShortcutPreCommitFilterSnapshot != nil
windowIndexKeyPendingClearTask?.cancel()
windowIndexKeyPendingClearTask = nil
windowIndexKeyPending = nil
+ if resetShortcutFilter {
+ windowShortcutFilterPhase = .none
+ lastWindowShortcutPreCommitFilterSnapshot = nil
+ }
+ if hadFilterState {
+ needsLayout = true
+ }
}
@discardableResult
@@ -5451,13 +5529,15 @@
let isKeyboardFocused = card.item.window.id == keyboardFocusedWindowID
let isHorizontalAppDefaultFocused = isHorizontalAppDefaultCard(card)
let isAppLinked = isWaterfallCardAppLinked(card)
+ let shortcutFilterState = windowShortcutFilterState(for: card)
applyWaterfallCardVisualState(
card,
selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered)
|| isKeyboardFocused
|| isHorizontalAppDefaultFocused,
hovered: isHovered,
- appLinked: isAppLinked
+ appLinked: isAppLinked,
+ shortcutFilterState: shortcutFilterState
)
let outerInset: CGFloat = 0
@@ -5512,7 +5592,7 @@
windowIndex: card.item.windowIndex,
windowID: card.item.window.id
),
- visible: isHovered,
+ visible: isHovered && shortcutFilterState != .dimmed,
anchorTopRight: CGPoint(x: bounds.maxX - 12, y: bounds.maxY - 12)
)
card.shineLayer.frame = CGRect(
@@ -5532,9 +5612,11 @@
_ card: WaterfallCardLayers,
selected: Bool,
hovered: Bool,
- appLinked: Bool
+ appLinked: Bool,
+ shortcutFilterState: WindowShortcutFilterState
) {
let isSpaceFocused = isWaterfallCardSpaceFocused(card)
+ let isShortcutDimmed = shortcutFilterState == .dimmed
card.containerLayer.backgroundColor = (selected
? themePalette.cardSelectedBackground
: hovered
@@ -5545,6 +5627,7 @@
? accentTintedWindowBackground(fraction: 0.10, alpha: 0.94)
: themePalette.cardBackground
).cgColor
+ card.containerLayer.opacity = isShortcutDimmed ? windowShortcutFilterDimOpacity : 1
card.containerLayer.borderColor = (selected
? NSColor.controlAccentColor.withAlphaComponent(0.52)
: hovered
@@ -5558,7 +5641,7 @@
card.containerLayer.borderWidth = (selected || hovered || appLinked || isSpaceFocused) ? 1.5 : 1
card.containerLayer.shadowColor = NSColor.black.cgColor
let darkShadowBoost: Float = resolvedTheme == .dark ? 0.08 : 0
- card.containerLayer.shadowOpacity = selected
+ let baseShadowOpacity: Float = selected
? 0.24 + darkShadowBoost
: hovered
? 0.14 + darkShadowBoost
@@ -5567,6 +5650,7 @@
: isSpaceFocused
? 0.20 + darkShadowBoost
: 0
+ card.containerLayer.shadowOpacity = isShortcutDimmed ? min(baseShadowOpacity, 0.03) : baseShadowOpacity
card.containerLayer.shadowRadius = selected ? 22 : hovered ? 17 : appLinked ? 18 : isSpaceFocused ? 24 : 0
card.containerLayer.shadowOffset = selected
? CGSize(width: 0, height: -6)
@@ -5579,10 +5663,10 @@
: .zero
card.containerLayer.zPosition = selected ? 20 : hovered ? 12 : appLinked ? 10 : isSpaceFocused ? 8 : 0
let focusedScale: CGFloat = selected ? WaterfallMetrics.selectedCardScale : isSpaceFocused ? 1.012 : 1.008
- card.containerLayer.transform = (selected || hovered || isSpaceFocused)
+ card.containerLayer.transform = (!isShortcutDimmed && (selected || hovered || isSpaceFocused))
? CATransform3DMakeScale(focusedScale, focusedScale, 1)
: CATransform3DIdentity
- card.containerLayer.shadowPath = (selected || hovered || appLinked || isSpaceFocused)
+ card.containerLayer.shadowPath = (!isShortcutDimmed && (selected || hovered || appLinked || isSpaceFocused))
? CGPath(
roundedRect: card.containerLayer.bounds,
cornerWidth: card.containerLayer.cornerRadius,
@@ -5598,7 +5682,7 @@
).cgColor
card.titleBarLayer.borderColor = NSColor.clear.cgColor
card.titleBarLayer.borderWidth = 0
- card.shineLayer.opacity = selected ? 1 : 0
+ card.shineLayer.opacity = (!isShortcutDimmed && selected) ? 1 : 0
}
private func layoutCloseButton(
@@ -6338,6 +6422,105 @@
return Self.appShelfIndexSymbols[ordinal]
}
+ private var visibleWaterfallCards: [WaterfallCardLayers] {
+ waterfallColumns.flatMap(\.cards)
+ }
+
+ private var windowShortcutFilterDimOpacity: Float {
+ resolvedTheme == .dark ? 0.44 : 0.38
+ }
+
+ private func windowShortcutFilterState(for card: WaterfallCardLayers) -> WindowShortcutFilterState {
+ switch windowShortcutFilterPhase {
+ case .appPending:
+ guard let pending = windowIndexKeyPending else { return .none }
+ return card.item.appGroupIndex == pending.appGroupIndex ? .matched : .dimmed
+ case .targetPreCommit:
+ guard let snapshot = lastWindowShortcutPreCommitFilterSnapshot else { return .none }
+ if card.item.window.id == snapshot.targetWindowID {
+ return .target
+ }
+ if snapshot.dimmedWindowIDs.contains(card.item.window.id) {
+ return .dimmed
+ }
+ if snapshot.matchedWindowIDs.contains(card.item.window.id) {
+ return .matched
+ }
+ return .none
+ case .none, .invalid, .expired:
+ return .none
+ }
+ }
+
+ private func makeWindowShortcutPreCommitFilterSnapshot(
+ target card: WaterfallCardLayers,
+ code: String
+ ) -> WindowShortcutPreCommitFilterSnapshot {
+ let targetWindowID = card.item.window.id
+ let allWindowIDs = visibleWaterfallCards.map { $0.item.window.id }
+ return WindowShortcutPreCommitFilterSnapshot(
+ targetCode: code,
+ targetWindowID: targetWindowID,
+ matchedWindowIDs: [targetWindowID],
+ dimmedWindowIDs: allWindowIDs.filter { $0 != targetWindowID }.sorted()
+ )
+ }
+
+ private func makeWindowShortcutFilterReport() -> WindowShortcutFilterReport {
+ switch windowShortcutFilterPhase {
+ case .appPending:
+ guard let pending = windowIndexKeyPending else {
+ return emptyWindowShortcutFilterReport(phase: .none)
+ }
+ let cards = visibleWaterfallCards
+ let matched = cards
+ .filter { $0.item.appGroupIndex == pending.appGroupIndex }
+ .map { $0.item.window.id }
+ .sorted()
+ let dimmed = cards
+ .filter { $0.item.appGroupIndex != pending.appGroupIndex }
+ .map { $0.item.window.id }
+ .sorted()
+ return WindowShortcutFilterReport(
+ active: true,
+ phase: .appPending,
+ pendingAppGroupIndex: pending.appGroupIndex,
+ prefix: pending.appSymbol,
+ matchedWindowIDs: matched,
+ dimmedWindowIDs: dimmed
+ )
+ case .targetPreCommit:
+ guard let snapshot = lastWindowShortcutPreCommitFilterSnapshot else {
+ return emptyWindowShortcutFilterReport(phase: .none)
+ }
+ return WindowShortcutFilterReport(
+ active: true,
+ phase: .targetPreCommit,
+ pendingAppGroupIndex: nil,
+ prefix: snapshot.targetCode,
+ matchedWindowIDs: snapshot.matchedWindowIDs,
+ dimmedWindowIDs: snapshot.dimmedWindowIDs
+ )
+ case .invalid, .expired:
+ return emptyWindowShortcutFilterReport(phase: windowShortcutFilterPhase)
+ case .none:
+ return emptyWindowShortcutFilterReport(phase: .none)
+ }
+ }
+
+ private func emptyWindowShortcutFilterReport(
+ phase: WindowShortcutFilterPhase
+ ) -> WindowShortcutFilterReport {
+ WindowShortcutFilterReport(
+ active: false,
+ phase: phase,
+ pendingAppGroupIndex: nil,
+ prefix: nil,
+ matchedWindowIDs: [],
+ dimmedWindowIDs: []
+ )
+ }
+
private func waterfallStateText(for card: QuickSwitchWindowCardViewModel) -> String {
var states: [String] = []
if card.window.isMinimized {
@@ -6844,6 +7027,10 @@
dy: column.containerLayer.frame.minY + column.cardsClipLayer.frame.minY
)
let screenshotSource = screenshotSourcesByWindowID[card.item.window.id]
+ let shortcutFilterState = windowShortcutFilterState(for: card)
+ let shortcutFilterOpacity = shortcutFilterState == .dimmed
+ ? windowShortcutFilterDimOpacity
+ : Float(1)
var report = [
"appGroupIndex": card.item.appGroupIndex,
@@ -6862,6 +7049,8 @@
"isSelected": card.item.window.id == effectiveSelection?.windowID,
"isKeyboardFocused": card.item.window.id == keyboardFocusedWindowID,
"isHorizontalAppDefault": isHorizontalAppDefaultCard(card),
+ "windowShortcutFilterState": shortcutFilterState.rawValue,
+ "windowShortcutFilterOpacity": Double(shortcutFilterOpacity),
"visualStates": waterfallCardVisualStates(for: card),
"thumbnailStrategy": thumbnailStrategyString(for: card.item.window),
"screenshotSource": screenshotSourceString(screenshotSource, for: card.item.window),
@@ -7006,6 +7195,16 @@
if isWaterfallCardSpaceFocused(card) {
states.append("spaceFocused")
}
+ switch windowShortcutFilterState(for: card) {
+ case .matched:
+ states.append("shortcutMatched")
+ case .dimmed:
+ states.append("shortcutDimmed")
+ case .target:
+ states.append("shortcutTarget")
+ case .none:
+ break
+ }
if card.item.window.isMinimized {
states.append("minimized")
}
diff --git a/C3.tools/round1-main-ui-qa.sh b/C3.tools/round1-main-ui-qa.sh
index 99c1b6d..c8e283d 100755
--- a/C3.tools/round1-main-ui-qa.sh
+++ b/C3.tools/round1-main-ui-qa.sh
@@ -117,6 +117,7 @@
run_step horizontal-waterfall "$SCRIPT_DIR/round1-horizontal-waterfall-fixture-qa.sh"
run_step theme-toggle "$SCRIPT_DIR/round1-theme-toggle-fixture-qa.sh"
run_step space-lane-dark-hover "$SCRIPT_DIR/round1-space-lane-dark-hover-fixture-qa.sh"
+run_step window-index-progressive-filter "$SCRIPT_DIR/round1-window-index-progressive-filter-fixture-qa.sh"
run_step no-quick-switch-residual swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch
assert_no_current_aligner
@@ -130,5 +131,5 @@
screenshots/fallbacks, minimized-window restore/activation, mouse activation,
multi-page precise activation/close targeting, App column alignment,
Split View Space Lane rendering, theme toggle, Space Lane dark hover readability,
-and final no-residual check.
+Option two-key progressive Waterfall filtering, and final no-residual check.
EOF
diff --git a/C3.tools/round1-window-index-progressive-filter-fixture-qa.sh b/C3.tools/round1-window-index-progressive-filter-fixture-qa.sh
new file mode 100755
index 0000000..3e1a74c
--- /dev/null
+++ b/C3.tools/round1-window-index-progressive-filter-fixture-qa.sh
@@ -0,0 +1,809 @@
+#!/bin/bash
+# Round01 window index progressive filter fixture QA. It verifies that the
+# first Option window-index key dims only non-target Waterfall cards, and that
+# the second key commits immediately while preserving the pre-commit snapshot.
+
+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_WINDOW_INDEX_FILTER_REPORT_WAIT:-8.0}"
+FIXTURE_APP_COUNT="${ALIGNER_ROUND1_WINDOW_INDEX_FILTER_APP_COUNT:-8}"
+FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_WINDOW_INDEX_FILTER_WINDOWS_PER_APP:-6}"
+TARGET_APP_INDEX="${ALIGNER_ROUND1_WINDOW_INDEX_FILTER_TARGET_APP_INDEX:-2}"
+TARGET_WINDOW_INDEX="${ALIGNER_ROUND1_WINDOW_INDEX_FILTER_TARGET_WINDOW_INDEX:-5}"
+APP_SYMBOL="${ALIGNER_ROUND1_WINDOW_INDEX_FILTER_APP_SYMBOL:-3}"
+WINDOW_SYMBOL="${ALIGNER_ROUND1_WINDOW_INDEX_FILTER_WINDOW_SYMBOL:-6}"
+TARGET_CODE="${APP_SYMBOL}${WINDOW_SYMBOL}"
+
+REPORT_HORIZONTAL_LIGHT="$BUILD_REPORT_ROOT/round01-window-index-progressive-filter-horizontal-light-report.json"
+REPORT_VERTICAL_LIGHT="$BUILD_REPORT_ROOT/round01-window-index-progressive-filter-vertical-light-report.json"
+REPORT_HORIZONTAL_DARK="$BUILD_REPORT_ROOT/round01-window-index-progressive-filter-horizontal-dark-report.json"
+REPORT_COMMIT="$BUILD_REPORT_ROOT/round01-window-index-progressive-filter-commit-report.json"
+REPORT_INVALID="$BUILD_REPORT_ROOT/round01-window-index-progressive-filter-invalid-report.json"
+SUMMARY_DIR="$BUILD_TMP_ROOT/round01-window-index-progressive-filter-fixture-qa"
+
+APP_PID=""
+OLD_THEME_SET=0
+OLD_THEME=""
+SUMMARY_FILES=()
+
+fail() {
+ echo "Round01 window index progressive filter fixture QA failed: $*" >&2
+ exit 1
+}
+
+aligner_pids_for_current_app() {
+ ps -axo pid=,args= | while read -r pid command; do
+ if [[ "$command" == "$APP/Contents/MacOS/Aligner"* ]] \
+ || [[ "$command" == "/Applications/Aligner.app/Contents/MacOS/Aligner"* ]] \
+ || [[ "$command" == "$HOME/Applications/Aligner.app/Contents/MacOS/Aligner"* ]]; then
+ echo "$pid"
+ fi
+ 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"
+}
+
+stop_current_aligner_safely() {
+ 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
+
+ echo "warning: current Aligner app did not exit during cleanup" >&2
+}
+
+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
+}
+
+cleanup() {
+ if [ -n "${APP_PID:-}" ]; then
+ kill "$APP_PID" 2>/dev/null || true
+ wait "$APP_PID" 2>/dev/null || true
+ APP_PID=""
+ fi
+ stop_current_aligner_safely
+ restore_user_theme
+}
+
+set_theme() {
+ local theme="$1"
+ /usr/bin/defaults write "$DOMAIN" "$THEME_KEY" -string "$theme"
+}
+
+mode_report_value() {
+ local mode="$1"
+ case "$mode" in
+ horizontal) echo "horizontalMasonry" ;;
+ vertical) echo "verticalColumns" ;;
+ *) fail "unsupported waterfall mode $mode" ;;
+ esac
+}
+
+pending_key_sequence() {
+ echo "physical-index:$APP_SYMBOL"
+}
+
+commit_key_sequence() {
+ echo "physical-index:$APP_SYMBOL,physical-index:$WINDOW_SYMBOL"
+}
+
+pending_expected_sequence() {
+ echo "app:$APP_SYMBOL"
+}
+
+commit_expected_sequence() {
+ echo "app:$APP_SYMBOL,window:$TARGET_CODE"
+}
+
+invalid_key_sequence() {
+ echo "physical-index:$APP_SYMBOL,physical-index:Z"
+}
+
+invalid_expected_sequence() {
+ echo "app:$APP_SYMBOL,windowInvalid:${APP_SYMBOL}Z"
+}
+
+run_fixture() {
+ local report="$1"
+ local theme="$2"
+ local mode="$3"
+ local key_sequence="$4"
+
+ stop_current_aligner
+ set_theme "$theme"
+ rm -f "$report"
+
+ "$APP/Contents/MacOS/Aligner" \
+ --round0-skip-permissions \
+ --round01-open-quick-switch \
+ --round01-fixture-app-count="$FIXTURE_APP_COUNT" \
+ --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \
+ --round01-disable-screenshot-refresh \
+ --round01-waterfall-view-mode="$mode" \
+ --round01-debug-key-sequence="$key_sequence" \
+ --round01-quick-switch-report="$report" >/dev/null 2>&1 &
+
+ APP_PID=$!
+}
+
+finish_case() {
+ if [ -n "${APP_PID:-}" ]; then
+ kill "$APP_PID" 2>/dev/null || true
+ wait "$APP_PID" 2>/dev/null || true
+ APP_PID=""
+ fi
+ stop_current_aligner
+}
+
+wait_for_pending_report() {
+ local report="$1"
+ local expected_mode="$2"
+ local expected_sequence="$3"
+ local case_name="$4"
+
+ /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_mode" "$expected_sequence" "$case_name" <<'PY'
+import json
+import sys
+import time
+
+path = sys.argv[1]
+timeout = float(sys.argv[2])
+expected_mode = sys.argv[3]
+expected_sequence = [part for part in sys.argv[4].split(",") if part]
+case_name = sys.argv[5]
+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 root.get("waterfallViewMode") == expected_mode
+ and root.get("keyboardCommandsApplied") == expected_sequence
+ and root.get("windowShortcutFilterPhase") == "appPending"
+ ):
+ 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"{case_name}: pending filter report did not reach appPending within {timeout:.1f}s", file=sys.stderr)
+sys.exit(1)
+PY
+}
+
+wait_for_commit_report() {
+ local report="$1"
+ local expected_mode="$2"
+ local expected_sequence="$3"
+
+ /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_mode" "$expected_sequence" "$TARGET_CODE" <<'PY'
+import json
+import sys
+import time
+
+path = sys.argv[1]
+timeout = float(sys.argv[2])
+expected_mode = sys.argv[3]
+expected_sequence = [part for part in sys.argv[4].split(",") if part]
+target_code = sys.argv[5]
+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", {})
+ snapshot = root.get("lastWindowShortcutPreCommitFilterSnapshot")
+ if (
+ report.get("snapshotLoaded") is True
+ and root.get("waterfallViewMode") == expected_mode
+ and root.get("keyboardCommandsApplied") == expected_sequence
+ and root.get("lastWindowIndexKeyCommitCode") == target_code
+ and isinstance(snapshot, dict)
+ ):
+ 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"commit report did not reach pre-commit snapshot within {timeout:.1f}s", file=sys.stderr)
+sys.exit(1)
+PY
+}
+
+wait_for_invalid_report() {
+ local report="$1"
+ local expected_mode="$2"
+ local expected_sequence="$3"
+
+ /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_mode" "$expected_sequence" "${APP_SYMBOL}Z" <<'PY'
+import json
+import sys
+import time
+
+path = sys.argv[1]
+timeout = float(sys.argv[2])
+expected_mode = sys.argv[3]
+expected_sequence = [part for part in sys.argv[4].split(",") if part]
+invalid_code = sys.argv[5]
+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 root.get("waterfallViewMode") == expected_mode
+ and root.get("keyboardCommandsApplied") == expected_sequence
+ and root.get("lastWindowIndexKeyCommand") == f"windowInvalid:{invalid_code}"
+ and root.get("windowShortcutFilterPhase") == "invalid"
+ ):
+ 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"invalid report did not reach invalid state within {timeout:.1f}s", file=sys.stderr)
+sys.exit(1)
+PY
+}
+
+assert_pending_report() {
+ local report="$1"
+ local case_name="$2"
+ local theme="$3"
+ local expected_mode="$4"
+ local summary_file="$SUMMARY_DIR/$case_name.json"
+
+ /usr/bin/python3 - \
+ "$report" \
+ "$case_name" \
+ "$theme" \
+ "$expected_mode" \
+ "$(pending_expected_sequence)" \
+ "$FIXTURE_APP_COUNT" \
+ "$FIXTURE_WINDOWS_PER_APP" \
+ "$TARGET_APP_INDEX" \
+ "$APP_SYMBOL" \
+ "$summary_file" <<'PY'
+import json
+import numbers
+import sys
+
+path = sys.argv[1]
+case_name = sys.argv[2]
+theme = sys.argv[3]
+expected_mode = sys.argv[4]
+expected_sequence = [part for part in sys.argv[5].split(",") if part]
+fixture_app_count = int(sys.argv[6])
+fixture_windows_per_app = int(sys.argv[7])
+target_app_index = int(sys.argv[8])
+app_symbol = sys.argv[9]
+summary_file = sys.argv[10]
+
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(f"{case_name}: {message}", file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+def sorted_ints(values):
+ require(isinstance(values, list), "window ID field must be a list")
+ return sorted(int(value) for value in values)
+
+def is_number(value):
+ return isinstance(value, numbers.Real) and not isinstance(value, bool)
+
+root = report.get("rootView", {})
+columns = root.get("waterfallColumns", [])
+cards = [card for column in columns for card in column.get("cards", [])]
+target_column = next((column for column in columns if column.get("appGroupIndex") == target_app_index), None)
+
+require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(report.get("quickSwitchVisible") is True, "pending filter must keep Quick Switch visible")
+require(report.get("appCount") == fixture_app_count, "fixture appCount must match")
+require(report.get("windowCount") == fixture_app_count * fixture_windows_per_app, "fixture windowCount must match")
+require(root.get("waterfallViewMode") == expected_mode, "Waterfall view mode must match case")
+require(root.get("keyboardCommandsApplied") == expected_sequence, "pending key sequence must be applied")
+require(root.get("appShelfIndexKeyDownCount") == 1, "pending filter must exercise one physical keyDown")
+require(root.get("lastAppShelfIndexKeySymbol") == app_symbol, "pending physical symbol must be reported")
+require(root.get("lastCommitSource") is None, "pending filter must not commit")
+require(root.get("lastCommittedWindowID") is None, "pending filter must not commit a window")
+require(root.get("lastWindowIndexKeyCommitCode") is None, "pending filter must not expose a commit code")
+
+require(root.get("windowShortcutFilterActive") is True, "windowShortcutFilterActive must be true")
+require(root.get("windowShortcutFilterPhase") == "appPending", "filter phase must be appPending")
+require(root.get("windowShortcutFilterPendingAppGroupIndex") == target_app_index, "pending App group must match first key")
+require(root.get("windowShortcutFilterPrefix") == app_symbol, "filter prefix must match first physical key")
+require(root.get("lastWindowShortcutPreCommitFilterSnapshot") is None, "pending run must not expose a pre-commit snapshot")
+
+require(target_column is not None, "target App column must exist")
+target_cards = target_column.get("cards", [])
+target_ids = sorted(int(card.get("windowID")) for card in target_cards)
+dimmed_cards = [card for card in cards if int(card.get("windowID")) not in set(target_ids)]
+dimmed_ids = sorted(int(card.get("windowID")) for card in dimmed_cards)
+
+require(len(target_cards) == fixture_windows_per_app, "target App must keep all fixture windows")
+require(root.get("windowShortcutFilterMatchedCount") == len(target_ids), "matched count must equal target App window count")
+require(root.get("windowShortcutFilterDimmedCount") == len(dimmed_ids), "dimmed count must equal non-target Waterfall cards")
+require(sorted_ints(root.get("windowShortcutFilterMatchedWindowIDs")) == target_ids, "matched window IDs must be exactly target App windows")
+require(sorted_ints(root.get("windowShortcutFilterDimmedWindowIDs")) == dimmed_ids, "dimmed window IDs must be exactly non-target Waterfall windows")
+
+matched_opacities = []
+dimmed_opacities = []
+for card in target_cards:
+ states = card.get("visualStates", [])
+ opacity = card.get("windowShortcutFilterOpacity")
+ require(card.get("windowShortcutFilterState") == "matched", "target App cards must report matched filter state")
+ require(is_number(opacity), "matched cards must expose numeric filter opacity")
+ require(0 <= opacity <= 1.01, "matched card opacity must be normalized")
+ require("shortcutMatched" in states, "matched cards must expose shortcutMatched visual state")
+ require("shortcutDimmed" not in states, "matched cards must not expose shortcutDimmed")
+ require("shortcutTarget" not in states, "pending matched cards must not expose shortcutTarget")
+ matched_opacities.append(float(opacity))
+
+for card in dimmed_cards:
+ states = card.get("visualStates", [])
+ opacity = card.get("windowShortcutFilterOpacity")
+ require(card.get("windowShortcutFilterState") == "dimmed", "non-target Waterfall cards must report dimmed filter state")
+ require(is_number(opacity), "dimmed cards must expose numeric filter opacity")
+ require(0 <= opacity <= 1.01, "dimmed card opacity must be normalized")
+ require("shortcutDimmed" in states, "dimmed cards must expose shortcutDimmed visual state")
+ require("shortcutMatched" not in states, "dimmed cards must not expose shortcutMatched")
+ require("shortcutTarget" not in states, "pending dimmed cards must not expose shortcutTarget")
+ dimmed_opacities.append(float(opacity))
+
+require(matched_opacities, "fixture must have matched cards")
+require(dimmed_opacities, "fixture must have dimmed cards")
+require(min(matched_opacities) > max(dimmed_opacities), "matched cards must stay visually brighter than dimmed cards")
+
+for node in root.get("appShelfItems", []) + root.get("spaceLaneSegments", []):
+ states = node.get("visualStates", [])
+ require("shortcutDimmed" not in states, "shortcut dim visual state must not leak to Space Lane or App Shelf")
+ if "windowShortcutFilterState" in node:
+ require(node.get("windowShortcutFilterState") != "dimmed", "Space Lane/App Shelf must not report dimmed filter state")
+ if "windowShortcutFilterOpacity" in node:
+ opacity = node.get("windowShortcutFilterOpacity")
+ require(is_number(opacity), "Space Lane/App Shelf filter opacity must be numeric if present")
+ require(opacity >= 0.99, "Space Lane/App Shelf must not be dimmed by window shortcut filter")
+
+summary = {
+ "case": case_name,
+ "status": "passed",
+ "theme": theme,
+ "mode": root.get("waterfallViewMode"),
+ "report": path,
+ "phase": root.get("windowShortcutFilterPhase"),
+ "prefix": root.get("windowShortcutFilterPrefix"),
+ "pendingAppGroupIndex": root.get("windowShortcutFilterPendingAppGroupIndex"),
+ "matchedCount": root.get("windowShortcutFilterMatchedCount"),
+ "dimmedCount": root.get("windowShortcutFilterDimmedCount"),
+ "matchedWindowIDs": target_ids,
+ "dimmedWindowIDs": dimmed_ids,
+ "matchedOpacityMin": min(matched_opacities),
+ "dimmedOpacityMax": max(dimmed_opacities),
+ "quickSwitchVisible": report.get("quickSwitchVisible"),
+ "commitSource": root.get("lastCommitSource"),
+}
+with open(summary_file, "w", encoding="utf-8") as file:
+ json.dump(summary, file, indent=2, ensure_ascii=False)
+PY
+
+ SUMMARY_FILES+=("$summary_file")
+}
+
+assert_commit_report() {
+ local report="$1"
+ local case_name="valid-commit-horizontal"
+ local summary_file="$SUMMARY_DIR/$case_name.json"
+
+ /usr/bin/python3 - \
+ "$report" \
+ "$case_name" \
+ "light" \
+ "horizontalMasonry" \
+ "$(commit_expected_sequence)" \
+ "$FIXTURE_APP_COUNT" \
+ "$FIXTURE_WINDOWS_PER_APP" \
+ "$TARGET_APP_INDEX" \
+ "$TARGET_WINDOW_INDEX" \
+ "$APP_SYMBOL" \
+ "$WINDOW_SYMBOL" \
+ "$TARGET_CODE" \
+ "$summary_file" <<'PY'
+import json
+import numbers
+import sys
+
+path = sys.argv[1]
+case_name = sys.argv[2]
+theme = sys.argv[3]
+expected_mode = sys.argv[4]
+expected_sequence = [part for part in sys.argv[5].split(",") if part]
+fixture_app_count = int(sys.argv[6])
+fixture_windows_per_app = int(sys.argv[7])
+target_app_index = int(sys.argv[8])
+target_window_index = int(sys.argv[9])
+app_symbol = sys.argv[10]
+window_symbol = sys.argv[11]
+target_code = sys.argv[12]
+summary_file = sys.argv[13]
+
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(f"{case_name}: {message}", file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+def sorted_ints(values):
+ require(isinstance(values, list), "window ID field must be a list")
+ return sorted(int(value) for value in values)
+
+def is_number(value):
+ return isinstance(value, numbers.Real) and not isinstance(value, bool)
+
+root = report.get("rootView", {})
+columns = root.get("waterfallColumns", [])
+cards = [card for column in columns for card in column.get("cards", [])]
+target_column = next((column for column in columns if column.get("appGroupIndex") == target_app_index), None)
+
+require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(report.get("appCount") == fixture_app_count, "fixture appCount must match")
+require(report.get("windowCount") == fixture_app_count * fixture_windows_per_app, "fixture windowCount must match")
+require(root.get("waterfallViewMode") == expected_mode, "commit run must use horizontal Waterfall")
+require(root.get("keyboardCommandsApplied") == expected_sequence, "commit key sequence must be applied")
+require(root.get("appShelfIndexKeyDownCount") == 2, "commit must exercise two physical keyDown events")
+require(root.get("lastAppShelfIndexKeySymbol") == window_symbol, "last physical symbol must be the window key")
+require(root.get("lastWindowIndexKeyCommand") == f"window:{target_code}", "last window shortcut command must be reported")
+require(root.get("lastWindowIndexKeyCommitCode") == target_code, "last window shortcut commit code must be reported")
+require(root.get("windowIndexKeyPendingAppGroupIndex") is None, "commit must clear legacy pending App group")
+require(root.get("windowIndexKeyPendingAppSymbol") is None, "commit must clear legacy pending App symbol")
+require(root.get("lastCommitSource") == "keyboard", "valid shortcut commit must be keyboard sourced")
+
+require(target_column is not None, "target App column must exist")
+target_cards = target_column.get("cards", [])
+require(target_window_index < len(target_cards), "target window index must exist")
+target_card = target_cards[target_window_index]
+target_window_id = int(target_card.get("windowID"))
+all_window_ids = sorted(int(card.get("windowID")) for card in cards)
+dimmed_ids = sorted(window_id for window_id in all_window_ids if window_id != target_window_id)
+
+require(target_card.get("windowShortcutCode") == target_code, "target card shortcut code must match committed code")
+require(root.get("lastCommittedAppGroupIndex") == target_app_index, "commit must target expected App")
+require(root.get("lastCommittedWindowIndex") == target_window_index, "commit must target expected window index")
+require(root.get("lastCommittedWindowID") == target_window_id, "commit must target expected window ID")
+require(root.get("selectedAppGroupIndex") == target_app_index, "selection must land on target App")
+require(root.get("selectedWindowIndex") == target_window_index, "selection must land on target window")
+require(root.get("selectedWindowID") == target_window_id, "selectedWindowID must match target window")
+
+snapshot = root.get("lastWindowShortcutPreCommitFilterSnapshot")
+require(isinstance(snapshot, dict), "pre-commit filter snapshot must be an object")
+require(snapshot.get("phase") == "targetPreCommit", "pre-commit snapshot phase must be targetPreCommit")
+require(snapshot.get("targetCode") == target_code, "pre-commit snapshot targetCode must match committed shortcut")
+require(int(snapshot.get("targetWindowID")) == target_window_id, "pre-commit snapshot targetWindowID must match target card")
+require(sorted_ints(snapshot.get("matchedWindowIDs")) == [target_window_id], "pre-commit snapshot must match only the target window")
+require(sorted_ints(snapshot.get("dimmedWindowIDs")) == dimmed_ids, "pre-commit snapshot must dim all non-target Waterfall windows")
+
+states = target_card.get("visualStates", [])
+opacity = target_card.get("windowShortcutFilterOpacity")
+require(target_card.get("windowShortcutFilterState") == "target", "target card must report target filter state")
+require(is_number(opacity), "target card must expose numeric filter opacity")
+require(0 <= opacity <= 1.01, "target card opacity must be normalized")
+require("shortcutTarget" in states, "target card must expose shortcutTarget visual state")
+
+for card in cards:
+ if int(card.get("windowID")) == target_window_id:
+ continue
+ card_states = card.get("visualStates", [])
+ card_opacity = card.get("windowShortcutFilterOpacity")
+ require(card.get("windowShortcutFilterState") == "dimmed", "non-target cards must remain dimmed in target pre-commit state")
+ require(is_number(card_opacity), "non-target cards must expose numeric filter opacity")
+ require("shortcutDimmed" in card_states, "non-target cards must expose shortcutDimmed in target pre-commit state")
+ require("shortcutTarget" not in card_states, "non-target cards must not expose shortcutTarget")
+
+for node in root.get("appShelfItems", []) + root.get("spaceLaneSegments", []):
+ states = node.get("visualStates", [])
+ require("shortcutDimmed" not in states, "shortcut dim visual state must not leak to Space Lane or App Shelf during commit")
+ if "windowShortcutFilterState" in node:
+ require(node.get("windowShortcutFilterState") != "dimmed", "Space Lane/App Shelf must not report dimmed filter state during commit")
+
+summary = {
+ "case": case_name,
+ "status": "passed",
+ "theme": theme,
+ "mode": root.get("waterfallViewMode"),
+ "report": path,
+ "commands": root.get("keyboardCommandsApplied"),
+ "targetCode": target_code,
+ "targetWindowID": target_window_id,
+ "matchedWindowIDs": snapshot.get("matchedWindowIDs"),
+ "dimmedCount": len(snapshot.get("dimmedWindowIDs", [])),
+ "lastCommitSource": root.get("lastCommitSource"),
+ "lastWindowIndexKeyCommitCode": root.get("lastWindowIndexKeyCommitCode"),
+ "targetCardState": target_card.get("windowShortcutFilterState"),
+}
+with open(summary_file, "w", encoding="utf-8") as file:
+ json.dump(summary, file, indent=2, ensure_ascii=False)
+PY
+
+ SUMMARY_FILES+=("$summary_file")
+}
+
+assert_invalid_report() {
+ local report="$1"
+ local case_name="invalid-second-key-horizontal"
+ local summary_file="$SUMMARY_DIR/$case_name.json"
+
+ /usr/bin/python3 - \
+ "$report" \
+ "$case_name" \
+ "horizontalMasonry" \
+ "$(invalid_expected_sequence)" \
+ "$APP_SYMBOL" \
+ "${APP_SYMBOL}Z" \
+ "$summary_file" <<'PY'
+import json
+import numbers
+import sys
+
+path = sys.argv[1]
+case_name = sys.argv[2]
+expected_mode = sys.argv[3]
+expected_sequence = [part for part in sys.argv[4].split(",") if part]
+app_symbol = sys.argv[5]
+invalid_code = sys.argv[6]
+summary_file = sys.argv[7]
+
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(f"{case_name}: {message}", file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+def is_number(value):
+ return isinstance(value, numbers.Real) and not isinstance(value, bool)
+
+root = report.get("rootView", {})
+columns = root.get("waterfallColumns", [])
+cards = [card for column in columns for card in column.get("cards", [])]
+
+require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(report.get("quickSwitchVisible") is True, "invalid shortcut must keep Quick Switch visible")
+require(root.get("waterfallViewMode") == expected_mode, "invalid run must use horizontal Waterfall")
+require(root.get("keyboardCommandsApplied") == expected_sequence, "invalid key sequence must be applied")
+require(root.get("appShelfIndexKeyDownCount") == 2, "invalid shortcut must exercise two physical keyDown events")
+require(root.get("lastAppShelfIndexKeySymbol") == "Z", "last physical symbol must be invalid window key")
+require(root.get("lastWindowIndexKeyCommand") == f"windowInvalid:{invalid_code}", "invalid command must be reported")
+require(root.get("lastWindowIndexKeyCommitCode") is None, "invalid shortcut must not expose commit code")
+require(root.get("lastCommitSource") is None, "invalid shortcut must not commit")
+require(root.get("lastCommittedWindowID") is None, "invalid shortcut must not commit a window")
+require(root.get("windowIndexKeyPendingAppGroupIndex") is None, "invalid shortcut must clear legacy pending App group")
+require(root.get("windowIndexKeyPendingAppSymbol") is None, "invalid shortcut must clear legacy pending App symbol")
+require(root.get("windowShortcutFilterActive") is False, "invalid shortcut must not leave active filter")
+require(root.get("windowShortcutFilterPhase") == "invalid", "invalid shortcut must report invalid phase")
+require(root.get("windowShortcutFilterPendingAppGroupIndex") is None, "invalid shortcut must clear pending App group")
+require(root.get("windowShortcutFilterPrefix") is None, "invalid shortcut must clear filter prefix")
+require(root.get("windowShortcutFilterMatchedCount") == 0, "invalid shortcut must clear matched count")
+require(root.get("windowShortcutFilterDimmedCount") == 0, "invalid shortcut must clear dimmed count")
+require(root.get("windowShortcutFilterMatchedWindowIDs") == [], "invalid shortcut must clear matched IDs")
+require(root.get("windowShortcutFilterDimmedWindowIDs") == [], "invalid shortcut must clear dimmed IDs")
+require(root.get("lastWindowShortcutPreCommitFilterSnapshot") is None, "invalid shortcut must not expose pre-commit snapshot")
+
+for card in cards:
+ opacity = card.get("windowShortcutFilterOpacity")
+ states = card.get("visualStates", [])
+ require(card.get("windowShortcutFilterState") == "none", "invalid shortcut must restore card filter state")
+ require(is_number(opacity), "cards must expose numeric filter opacity")
+ require(float(opacity) >= 0.99, "invalid shortcut must restore card opacity")
+ require("shortcutDimmed" not in states, "invalid shortcut must not leave shortcutDimmed state")
+ require("shortcutMatched" not in states, "invalid shortcut must not leave shortcutMatched state")
+ require("shortcutTarget" not in states, "invalid shortcut must not leave shortcutTarget state")
+
+summary = {
+ "case": case_name,
+ "status": "passed",
+ "mode": root.get("waterfallViewMode"),
+ "report": path,
+ "commands": root.get("keyboardCommandsApplied"),
+ "firstSymbol": app_symbol,
+ "invalidCode": invalid_code,
+ "phase": root.get("windowShortcutFilterPhase"),
+ "quickSwitchVisible": report.get("quickSwitchVisible"),
+ "cardCount": len(cards)
+}
+with open(summary_file, "w", encoding="utf-8") as file:
+ json.dump(summary, file, indent=2, ensure_ascii=False)
+PY
+
+ SUMMARY_FILES+=("$summary_file")
+}
+
+assert_pending_summaries_consistent() {
+ local baseline="$1"
+ local candidate="$2"
+ local label="$3"
+
+ /usr/bin/python3 - "$baseline" "$candidate" "$label" <<'PY'
+import json
+import sys
+
+baseline_path = sys.argv[1]
+candidate_path = sys.argv[2]
+label = sys.argv[3]
+
+with open(baseline_path, "r", encoding="utf-8") as file:
+ baseline = json.load(file)
+with open(candidate_path, "r", encoding="utf-8") as file:
+ candidate = json.load(file)
+
+fields = [
+ "phase",
+ "prefix",
+ "pendingAppGroupIndex",
+ "matchedCount",
+ "dimmedCount",
+ "matchedWindowIDs",
+ "dimmedWindowIDs",
+]
+
+for field in fields:
+ if baseline.get(field) != candidate.get(field):
+ print(f"{label}: pending field {field} differs between baseline and candidate", file=sys.stderr)
+ print(json.dumps({"baseline": baseline, "candidate": candidate}, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+PY
+}
+
+emit_summary() {
+ /usr/bin/python3 - "$APP" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" "${SUMMARY_FILES[@]}" <<'PY'
+import json
+import sys
+
+app = sys.argv[1]
+fixture_app_count = int(sys.argv[2])
+fixture_windows_per_app = int(sys.argv[3])
+case_paths = sys.argv[4:]
+cases = []
+for path in case_paths:
+ with open(path, "r", encoding="utf-8") as file:
+ cases.append(json.load(file))
+
+print(json.dumps({
+ "status": "passed",
+ "script": "round1-window-index-progressive-filter-fixture-qa.sh",
+ "app": app,
+ "fixture": {
+ "appCount": fixture_app_count,
+ "windowsPerApp": fixture_windows_per_app
+ },
+ "cases": cases,
+ "timeoutCleanup": {
+ "status": "skipped",
+ "reason": "Current --round01-debug-key-sequence has no stable wait token for the 0.9s expiry path; no sleep injection was added."
+ }
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+if [ ! -x "$APP/Contents/MacOS/Aligner" ]; then
+ fail "current build app executable not found at $APP/Contents/MacOS/Aligner"
+fi
+if [ "$FIXTURE_APP_COUNT" -le "$TARGET_APP_INDEX" ]; then
+ fail "fixture app count must include target App index $TARGET_APP_INDEX"
+fi
+if [ "$FIXTURE_WINDOWS_PER_APP" -le "$TARGET_WINDOW_INDEX" ]; then
+ fail "fixture windows per App must include target window index $TARGET_WINDOW_INDEX"
+fi
+
+mkdir -p "$SUMMARY_DIR"
+rm -f "$SUMMARY_DIR"/*.json
+
+stop_current_aligner
+"$SCRIPT_DIR/package-app.sh" >&2
+preserve_user_theme
+trap cleanup EXIT
+stop_current_aligner
+
+run_fixture "$REPORT_HORIZONTAL_LIGHT" "light" "horizontal" "$(pending_key_sequence)"
+wait_for_pending_report "$REPORT_HORIZONTAL_LIGHT" "$(mode_report_value horizontal)" "$(pending_expected_sequence)" "pending-horizontal-light"
+assert_pending_report "$REPORT_HORIZONTAL_LIGHT" "pending-horizontal-light" "light" "$(mode_report_value horizontal)"
+finish_case
+
+run_fixture "$REPORT_VERTICAL_LIGHT" "light" "vertical" "$(pending_key_sequence)"
+wait_for_pending_report "$REPORT_VERTICAL_LIGHT" "$(mode_report_value vertical)" "$(pending_expected_sequence)" "pending-vertical-light"
+assert_pending_report "$REPORT_VERTICAL_LIGHT" "pending-vertical-light" "light" "$(mode_report_value vertical)"
+finish_case
+
+run_fixture "$REPORT_HORIZONTAL_DARK" "dark" "horizontal" "$(pending_key_sequence)"
+wait_for_pending_report "$REPORT_HORIZONTAL_DARK" "$(mode_report_value horizontal)" "$(pending_expected_sequence)" "pending-horizontal-dark"
+assert_pending_report "$REPORT_HORIZONTAL_DARK" "pending-horizontal-dark" "dark" "$(mode_report_value horizontal)"
+assert_pending_summaries_consistent \
+ "$SUMMARY_DIR/pending-horizontal-light.json" \
+ "$SUMMARY_DIR/pending-horizontal-dark.json" \
+ "pending-horizontal-dark"
+finish_case
+
+run_fixture "$REPORT_COMMIT" "light" "horizontal" "$(commit_key_sequence)"
+wait_for_commit_report "$REPORT_COMMIT" "$(mode_report_value horizontal)" "$(commit_expected_sequence)"
+assert_commit_report "$REPORT_COMMIT"
+finish_case
+
+run_fixture "$REPORT_INVALID" "light" "horizontal" "$(invalid_key_sequence)"
+wait_for_invalid_report "$REPORT_INVALID" "$(mode_report_value horizontal)" "$(invalid_expected_sequence)"
+assert_invalid_report "$REPORT_INVALID"
+finish_case
+
+cleanup
+trap - EXIT
+emit_summary
--
Gitblit v1.9.3