Ariver
2026-07-13 14a1efc86d0295be3a0d3fe1ebe7b8080266da2d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
#!/bin/bash
# Round01.1 horizontal Waterfall fixture QA. It verifies the P1 masonry view:
# no horizontal scroll, centered equal-width fixed-height cards, 150pt vertical
# placement steps, App Shelf anchoring, horizontal-mode Tab navigation, and
# horizontal arrow focus across App Shelf icons plus window cards.
 
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"
LAYOUT_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-layout-report.json"
KEYBOARD_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-keyboard-report.json"
APP_ENTER_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-app-enter-report.json"
LETTER_INDEX_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-letter-index-report.json"
WINDOW_SHORTCUT_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-window-shortcut-report.json"
OVERFLOW_APP_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-window-shortcut-overflow-app-report.json"
OVERFLOW_WINDOW_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-window-shortcut-overflow-window-report.json"
ARROW_WRAP_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-arrow-wrap-report.json"
FILTER_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-filter-report.json"
FIXTURE_APP_COUNT="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_FIXTURE_APP_COUNT:-8}"
FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_FIXTURE_WINDOWS_PER_APP:-6}"
HOVER_APP_INDEX="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_HOVER_APP_INDEX:-7}"
KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_KEY_SEQUENCE:-tab}"
APP_ENTER_KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_APP_ENTER_KEY_SEQUENCE:-physical-index:3,enter}"
APP_ENTER_TARGET_APP_INDEX="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_APP_ENTER_TARGET_APP_INDEX:-2}"
LETTER_INDEX_FIXTURE_APP_COUNT="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_LETTER_INDEX_FIXTURE_APP_COUNT:-12}"
LETTER_INDEX_KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_LETTER_INDEX_KEY_SEQUENCE:-physical-index:A}"
LETTER_INDEX_TARGET_APP_INDEX="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_LETTER_INDEX_TARGET_APP_INDEX:-10}"
WINDOW_SHORTCUT_KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_WINDOW_SHORTCUT_KEY_SEQUENCE:-physical-index:3,physical-index:6}"
WINDOW_SHORTCUT_TARGET_APP_INDEX="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_WINDOW_SHORTCUT_TARGET_APP_INDEX:-2}"
WINDOW_SHORTCUT_TARGET_WINDOW_INDEX="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_WINDOW_SHORTCUT_TARGET_WINDOW_INDEX:-5}"
ARROW_WRAP_KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_ARROW_WRAP_KEY_SEQUENCE:-app:1,down,up,left,down,enter}"
REPORT_WAIT="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_REPORT_WAIT:-6.0}"
OLD_THEME_SET=0
OLD_THEME=""
 
fail() {
  echo "Round01.1 horizontal Waterfall fixture 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_loaded_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
        root = report.get("rootView", {})
        if report.get("snapshotLoaded") is True and root.get("waterfallViewMode") == "horizontalMasonry":
            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"horizontal Waterfall report did not become loaded within {timeout:.1f}s", file=sys.stderr)
sys.exit(1)
PY
}
 
assert_layout_report() {
  /usr/bin/python3 - "$LAYOUT_REPORT" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" "$HOVER_APP_INDEX" <<'PY'
import json
import sys
 
path = sys.argv[1]
fixture_app_count = int(sys.argv[2])
fixture_windows_per_app = int(sys.argv[3])
hover_app_index = int(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", {})
columns = root.get("waterfallColumns", [])
items = root.get("appShelfItems", [])
waterfall_frame = root.get("waterfallFrame", {})
waterfall_height = waterfall_frame.get("height", 0)
waterfall_width = root.get("waterfallVisibleWidth", 0)
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, "layout run must keep Quick Switch visible")
require(root.get("waterfallViewMode") == "horizontalMasonry", "Waterfall view mode must be horizontalMasonry")
require(report.get("appCount") == fixture_app_count, "fixture appCount must match requested count")
require(report.get("windowCount") == fixture_app_count * fixture_windows_per_app, "fixture windowCount must match requested shape")
require(root.get("waterfallColumnCount") == fixture_app_count, "Waterfall app section count must match app count")
require(len(columns) == fixture_app_count, "Waterfall column reports must match app count")
require(len(items) == fixture_app_count, "App Shelf item reports must match app count")
require(root.get("waterfallColumnNames") == root.get("appShelfNames"), "Waterfall app order must match App Shelf order")
 
require(root.get("waterfallScrollable") is False, "horizontal masonry must not expose horizontal scroll")
require(root.get("waterfallMaxScrollOffset") == 0, "horizontal masonry horizontal max scroll offset must be zero")
require(abs(root.get("waterfallContentWidth", 0) - waterfall_width) <= 1.0, "horizontal masonry content width must equal visible width")
require(root.get("horizontalMasonryScrollable") is True, "fixture must make horizontal masonry vertically scrollable")
require(root.get("horizontalMasonryMaxScrollOffset", 0) > 0, "horizontal masonry must expose positive vertical max scroll offset")
require(root.get("horizontalMasonryScrollOffset", 0) > 0, "hovering a later App must anchor masonry vertically")
require(root.get("appShelfHoveredIndex") == hover_app_index, "debug hover must mark the requested App Shelf item")
require(root.get("hoveredAppGroupIndex") == hover_app_index, "horizontal masonry must keep the hovered App group available to Waterfall visuals")
 
require(cards, "horizontal masonry fixture must expose cards")
widths = [round(card.get("bounds", {}).get("width", 0), 1) for card in cards]
require(len(set(widths)) == 1, "horizontal masonry cards must be equal width")
heights = [round(card.get("bounds", {}).get("height", 0), 1) for card in cards]
expected_card_height = 138.0
expected_thumbnail_height = 106.0
expected_card_step = 150.0
require(len(set(heights)) == 1, "horizontal masonry cards must use one fixed height")
require(abs(heights[0] - expected_card_height) <= 1.0, "horizontal masonry card height must match vertical Waterfall 138pt height")
require(all(card.get("frame", {}).get("x", -1) >= -1 for card in cards), "cards must not overflow left")
require(all(card.get("frame", {}).get("x", 0) + card.get("frame", {}).get("width", 0) <= waterfall_width + 1 for card in cards), "cards must not overflow right")
thumbnail_heights = [round(card.get("thumbnailFrame", {}).get("height", 0), 1) for card in cards]
require(all(abs(height - expected_thumbnail_height) <= 1.0 for height in thumbnail_heights), "horizontal masonry thumbnails must use the same fixed card template height as vertical cards")
require(all(card.get("titleBarFrame", {}).get("y", 0) >= card.get("thumbnailFrame", {}).get("y", 0) + card.get("thumbnailFrame", {}).get("height", 0) for card in cards), "title bars must remain above thumbnails")
 
cards_by_lane = {}
for card in cards:
    lane_x = round(card.get("frame", {}).get("x", 0), 1)
    cards_by_lane.setdefault(lane_x, []).append(card)
lane_steps = []
for lane_cards in cards_by_lane.values():
    ordered = sorted(lane_cards, key=lambda card: card.get("frame", {}).get("y", 0), reverse=True)
    for upper, lower in zip(ordered, ordered[1:]):
        lane_steps.append(round(upper.get("frame", {}).get("y", 0) - lower.get("frame", {}).get("y", 0), 1))
require(lane_steps, "fixture must place multiple cards in at least one horizontal masonry lane")
require(all(abs(step - expected_card_step) <= 1.0 for step in lane_steps), "horizontal masonry vertical placement step must be 150pt")
 
for expected_index, column in enumerate(columns):
    require(column.get("appGroupIndex") == expected_index, "appGroupIndex must remain sequential")
    require(column.get("windowCount") == fixture_windows_per_app, "each fixture App must keep its windows")
    column_cards = column.get("cards", [])
    require([card.get("windowIndex") for card in column_cards] == list(range(fixture_windows_per_app)), "window order must remain stable within each App")
 
hover_column = columns[hover_app_index]
hover_first_card = hover_column.get("cards", [])[0]
hover_visible = hover_first_card.get("visibleFrame", {})
require(hover_visible.get("y", -9999) < waterfall_height + 1, "hovered App first card should be brought into vertical viewport")
require(hover_visible.get("y", 0) + hover_visible.get("height", 0) > -1, "hovered App first card should not be fully below viewport")
hover_cards = hover_column.get("cards", [])
non_hover_cards = [
    card
    for column in columns
    if column.get("appGroupIndex") != hover_app_index
    for card in column.get("cards", [])
]
blue_reference = next(
    (
        color
        for segment in root.get("spaceLaneSegments", [])
        for color in segment.get("windowBlockColors", [])
        if color.get("alpha", 0) > 0
    ),
    None
)
require(blue_reference is not None, "fixture must expose a Space Lane blue reference color")
require(all("appLinked" in card.get("visualStates", []) for card in hover_cards), "all cards for the hovered App must expose appLinked visual state")
require(all("appLinked" not in card.get("visualStates", []) for card in non_hover_cards), "non-hovered App cards must not expose appLinked visual state")
require(root.get("selectedAppGroupIndex") == hover_app_index, "hovered App must select its first window as the default submit target")
require(root.get("selectedWindowIndex") == 0, "hovered App default submit target must be its first window")
require(root.get("horizontalAppDefaultWindowID") == hover_first_card.get("windowID"), "hovered App default window ID must match the first card")
require(hover_first_card.get("isSelected") is True, "hovered App first card must become the effective selection")
require(hover_first_card.get("isHorizontalAppDefault") is True, "hovered App first card must expose app default state")
require("appDefault" in hover_first_card.get("visualStates", []), "hovered App first card must report appDefault visual state")
require("selected" in hover_first_card.get("visualStates", []), "hovered App first card must get selected visual state")
require(all(card.get("shadowOpacity", 0) >= 0.15 for card in hover_cards), "hovered App cards must float with visible shadow opacity")
require(all(card.get("shadowRadius", 0) >= 18 for card in hover_cards), "hovered App cards must float with visible shadow radius")
require(all(card.get("shadowOpacity", 1) <= 0.01 for card in non_hover_cards if "spaceFocused" not in card.get("visualStates", []) and "hover" not in card.get("visualStates", []) and "selected" not in card.get("visualStates", [])), "normal non-hovered cards must not inherit App hover shadow")
for card in hover_cards:
    title_fill = card.get("titleBarBackgroundColor", {})
    require(title_fill.get("alpha", 0) >= 0.16, "hovered App card title bar must get a visible blue fill")
    for channel in ("red", "green", "blue"):
        require(
            abs(title_fill.get(channel, -1) - blue_reference.get(channel, -2)) <= 0.002,
            f"hovered App card title bar must use the Space Lane blue reference for {channel}"
        )
require(all(card.get("titleBarBackgroundColor", {}).get("alpha", 1) <= 0.01 for card in non_hover_cards), "non-hovered App card title bars must stay clear")
 
print(json.dumps({
    "mode": root.get("waterfallViewMode"),
    "appCount": report.get("appCount"),
    "windowCount": report.get("windowCount"),
    "cardWidth": widths[0],
    "cardHeight": heights[0],
    "thumbnailHeight": thumbnail_heights[0],
    "cardStep": expected_card_step,
    "hoveredAppGroupIndex": root.get("hoveredAppGroupIndex"),
    "hoveredAppLinkedCardCount": len(hover_cards),
    "hoveredAppTitleBarFill": hover_cards[0].get("titleBarBackgroundColor"),
    "horizontalMasonryScrollOffset": root.get("horizontalMasonryScrollOffset"),
    "horizontalMasonryMaxScrollOffset": root.get("horizontalMasonryMaxScrollOffset")
}, indent=2, ensure_ascii=False))
PY
}
 
assert_keyboard_report() {
  /usr/bin/python3 - "$KEYBOARD_REPORT" "$KEY_SEQUENCE" <<'PY'
import json
import sys
 
path = sys.argv[1]
key_sequence = [part for part in sys.argv[2].split(",") if part]
 
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", {})
columns = root.get("waterfallColumns", [])
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(root.get("waterfallViewMode") == "horizontalMasonry", "keyboard run must use horizontalMasonry")
require(root.get("keyboardCommandsApplied") == key_sequence, "horizontal keyboard commands must be applied")
require(root.get("tabIgnoredCount") == 0, "Tab must navigate in horizontal masonry mode")
require(root.get("selectedAppGroupIndex") == 0, "single Tab from first card must stay in first App")
require(root.get("selectedWindowIndex") == 1, "single Tab from first card must select the second laid card")
selected = columns[0].get("cards", [])[1]
require(root.get("selectedWindowID") == selected.get("windowID"), "selectedWindowID must match the second laid card")
 
print(json.dumps({
    "commands": root.get("keyboardCommandsApplied"),
    "tabIgnoredCount": root.get("tabIgnoredCount"),
    "selectedAppGroupIndex": root.get("selectedAppGroupIndex"),
    "selectedWindowIndex": root.get("selectedWindowIndex")
}, indent=2, ensure_ascii=False))
PY
}
 
assert_app_enter_report() {
  /usr/bin/python3 - "$APP_ENTER_REPORT" "$APP_ENTER_KEY_SEQUENCE" "$APP_ENTER_TARGET_APP_INDEX" <<'PY'
import json
import sys
 
path = sys.argv[1]
raw_key_sequence = [part for part in sys.argv[2].split(",") if part]
target_app_index = int(sys.argv[3])
 
with open(path, "r", encoding="utf-8") as file:
    report = json.load(file)
 
def normalize_key_sequence(sequence):
    normalized = []
    physical_symbols = []
    for part in sequence:
        lowered = part.lower()
        matched_prefix = None
        for prefix in ("physical-index:", "physical-index-", "keydown:", "keydown-"):
            if lowered.startswith(prefix):
                matched_prefix = prefix
                break
        if matched_prefix is None:
            normalized.append(part)
            continue
        symbol = part[len(matched_prefix):].strip().upper()
        physical_symbols.append(symbol)
        normalized.append(f"app:{symbol}")
    return normalized, physical_symbols
 
key_sequence, physical_symbols = normalize_key_sequence(raw_key_sequence)
 
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", {})
columns = root.get("waterfallColumns", [])
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(root.get("waterfallViewMode") == "horizontalMasonry", "App Enter run must use horizontalMasonry")
require(root.get("keyboardCommandsApplied") == key_sequence, "App Enter keyboard commands must be applied")
require(root.get("appShelfIndexKeyDownCount") == len(physical_symbols), "physical App index keyDown path must be exercised")
if physical_symbols:
    require(root.get("lastAppShelfIndexKeySymbol") == physical_symbols[-1], "last physical App index symbol must be reported")
require(target_app_index < len(columns), "target App index must exist")
current_target_cards = columns[target_app_index].get("cards", [])
committed_window_id = root.get("lastCommittedWindowID")
suppressed_window_ids = set(report.get("suppressedActivationWindowIDs", []))
require(root.get("hoverTargetKind") == "app", "App index focus must leave hover target on the App icon")
require(root.get("keyboardFocusedAppGroupIndex") == target_app_index, "keyboard App focus must target requested App")
require(root.get("keyboardFocusedWindowID") is None, "App icon focus must not keep stale window keyboard focus")
require(root.get("lastCommittedAppGroupIndex") == target_app_index, "Enter on App icon must commit the target App")
require(root.get("lastCommittedWindowIndex") == 0, "Enter on App icon must commit the first window")
require(committed_window_id in suppressed_window_ids, "Enter on App icon may remove committed first window only when suppression records it")
require(root.get("lastCommitSource") == "keyboard", "App Enter commit must be keyboard sourced")
require(committed_window_id not in [card.get("windowID") for card in current_target_cards], "suppressed committed App Enter target must not remain in current cards")
 
print(json.dumps({
    "commands": root.get("keyboardCommandsApplied"),
    "hoverTargetKind": root.get("hoverTargetKind"),
    "targetAppGroupIndex": target_app_index,
    "lastCommittedWindowID": root.get("lastCommittedWindowID")
}, indent=2, ensure_ascii=False))
PY
}
 
assert_letter_index_report() {
  /usr/bin/python3 - "$LETTER_INDEX_REPORT" "$LETTER_INDEX_KEY_SEQUENCE" "$LETTER_INDEX_TARGET_APP_INDEX" <<'PY'
import json
import sys
 
path = sys.argv[1]
raw_key_sequence = [part for part in sys.argv[2].split(",") if part]
target_app_index = int(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)
 
def normalize_key_sequence(sequence):
    normalized = []
    physical_symbols = []
    for part in sequence:
        lowered = part.lower()
        matched_prefix = None
        for prefix in ("physical-index:", "physical-index-", "keydown:", "keydown-"):
            if lowered.startswith(prefix):
                matched_prefix = prefix
                break
        if matched_prefix is None:
            normalized.append(part)
            continue
        symbol = part[len(matched_prefix):].strip().upper()
        physical_symbols.append(symbol)
        normalized.append(f"app:{symbol}")
    return normalized, physical_symbols
 
key_sequence, physical_symbols = normalize_key_sequence(raw_key_sequence)
root = report.get("rootView", {})
columns = root.get("waterfallColumns", [])
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(root.get("waterfallViewMode") == "horizontalMasonry", "letter App index run must use horizontalMasonry")
require(root.get("keyboardCommandsApplied") == key_sequence, "letter App index keyboard commands must be applied")
require(root.get("appShelfIndexKeyDownCount") == len(physical_symbols), "letter App index must exercise physical keyDown path")
require(physical_symbols, "letter App index test must contain a physical key")
require(root.get("lastAppShelfIndexKeySymbol") == physical_symbols[-1], "last letter App index symbol must be reported")
require(target_app_index < len(columns), "target letter App index must exist")
target_card = columns[target_app_index].get("cards", [])[0]
require(root.get("hoverTargetKind") == "app", "letter App index focus must leave hover target on App icon")
require(root.get("hoverTargetSource") == "keyboard", "letter App index focus must report keyboard source")
require(root.get("keyboardFocusedAppGroupIndex") == target_app_index, "letter App index must focus the expected App")
require(root.get("keyboardFocusedWindowID") is None, "letter App icon focus must not keep stale window keyboard focus")
require(root.get("selectedAppGroupIndex") == target_app_index, "letter App icon focus must select the target App")
require(root.get("selectedWindowIndex") == 0, "letter App icon focus must select the first window")
require(root.get("horizontalAppDefaultWindowID") == target_card.get("windowID"), "letter App default window must match the first card")
require(root.get("lastCommittedWindowID") is None, "letter App index focus without Enter must not commit")
require(root.get("lastCommitSource") is None, "letter App index focus without Enter must not report commit source")
require(target_card.get("isHorizontalAppDefault") is True, "letter target first card must report app default state")
require("appDefault" in target_card.get("visualStates", []), "letter target first card must expose appDefault visual state")
require("selected" in target_card.get("visualStates", []), "letter target first card must expose selected visual state")
 
print(json.dumps({
    "commands": root.get("keyboardCommandsApplied"),
    "physicalKeyDownCount": root.get("appShelfIndexKeyDownCount"),
    "lastSymbol": root.get("lastAppShelfIndexKeySymbol"),
    "targetAppGroupIndex": target_app_index,
    "defaultWindowID": root.get("horizontalAppDefaultWindowID")
}, indent=2, ensure_ascii=False))
PY
}
 
assert_window_shortcut_report() {
  /usr/bin/python3 - "$WINDOW_SHORTCUT_REPORT" "$WINDOW_SHORTCUT_KEY_SEQUENCE" "$WINDOW_SHORTCUT_TARGET_APP_INDEX" "$WINDOW_SHORTCUT_TARGET_WINDOW_INDEX" <<'PY'
import json
import sys
 
path = sys.argv[1]
raw_key_sequence = [part for part in sys.argv[2].split(",") if part]
target_app_index = int(sys.argv[3])
target_window_index = int(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)
 
def physical_symbol(part):
    lowered = part.lower()
    for prefix in ("physical-index:", "physical-index-", "keydown:", "keydown-"):
        if lowered.startswith(prefix):
            return part[len(prefix):].strip().upper()
    return None
 
physical_symbols = [symbol for part in raw_key_sequence if (symbol := physical_symbol(part))]
require(len(physical_symbols) == 2, "window shortcut test must use two physical symbols")
expected_code = "".join(physical_symbols)
expected_sequence = [f"app:{physical_symbols[0]}", f"window:{expected_code}"]
 
root = report.get("rootView", {})
columns = root.get("waterfallColumns", [])
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(root.get("waterfallViewMode") == "horizontalMasonry", "window shortcut run must use horizontalMasonry")
require(root.get("keyboardCommandsApplied") == expected_sequence, "two-key window shortcut commands must be applied")
require(root.get("appShelfIndexKeyDownCount") == 2, "two-key window shortcut must exercise two physical keyDown events")
require(root.get("lastAppShelfIndexKeySymbol") == physical_symbols[-1], "last physical symbol must be the window index key")
require(root.get("lastWindowIndexKeyCommand") == f"window:{expected_code}", "last window shortcut command must be reported")
require(root.get("lastWindowIndexKeyCommitCode") == expected_code, "last window shortcut commit code must be reported")
require(root.get("windowIndexKeyPendingAppGroupIndex") is None, "two-key commit must clear pending App")
require(root.get("windowIndexKeyPendingAppSymbol") is None, "two-key commit must clear pending App symbol")
require(target_app_index < len(columns), "target App index must exist")
target_column = columns[target_app_index]
cards = target_column.get("cards", [])
committed_window_id = root.get("lastCommittedWindowID")
suppressed_window_ids = set(report.get("suppressedActivationWindowIDs", []))
pre_commit = root.get("lastWindowShortcutPreCommitFilterSnapshot", {})
require(pre_commit.get("phase") == "targetPreCommit", "two-key shortcut must record a pre-commit filter snapshot")
require(pre_commit.get("targetCode") == expected_code, "pre-commit snapshot must record target shortcut code")
require(pre_commit.get("targetWindowID") == committed_window_id, "pre-commit target must match committed window ID")
require(pre_commit.get("matchedWindowIDs") == [committed_window_id], "pre-commit snapshot must match exactly the committed window")
require(root.get("lastCommittedAppGroupIndex") == target_app_index, "two-key shortcut must commit target App")
require(root.get("lastCommittedWindowIndex") == target_window_index, "two-key shortcut must commit target window")
require(committed_window_id in suppressed_window_ids, "two-key shortcut may remove committed target only when suppression records it")
require(root.get("lastCommitSource") == "keyboard", "two-key shortcut commit must be keyboard sourced")
require(committed_window_id not in [card.get("windowID") for column in columns for card in column.get("cards", [])], "suppressed two-key target must not remain in current cards")
 
for column in columns:
    for card in column.get("cards", []):
        code = card.get("windowShortcutCode")
        require(code != "No Space" and code != "N...", "window shortcut code must not expose the old No Space label")
 
print(json.dumps({
    "commands": root.get("keyboardCommandsApplied"),
    "code": expected_code,
    "lastCommittedWindowID": root.get("lastCommittedWindowID")
}, indent=2, ensure_ascii=False))
PY
}
 
assert_window_shortcut_overflow_report() {
  /usr/bin/python3 - "$OVERFLOW_APP_REPORT" "$OVERFLOW_WINDOW_REPORT" <<'PY'
import json
import sys
 
app_path = sys.argv[1]
window_path = sys.argv[2]
 
with open(app_path, "r", encoding="utf-8") as file:
    app_report = json.load(file)
with open(window_path, "r", encoding="utf-8") as file:
    window_report = json.load(file)
 
def require(report, 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)
 
app_root = app_report.get("rootView", {})
app_columns = app_root.get("waterfallColumns", [])
require(app_report, app_report.get("snapshotLoaded") is True, "App overflow report must be loaded")
require(app_report, len(app_columns) == 37, "App overflow fixture must expose 37 columns")
require(app_report, app_columns[35].get("cards", [])[0].get("windowShortcutCode") == "Z1", "36th App must still expose a shortcut code")
require(app_report, app_columns[36].get("cards", [])[0].get("windowShortcutCode") == "", "37th App must not expose a two-key shortcut code")
 
window_root = window_report.get("rootView", {})
window_columns = window_root.get("waterfallColumns", [])
require(window_report, window_report.get("snapshotLoaded") is True, "Window overflow report must be loaded")
require(window_report, len(window_columns) == 1, "Window overflow fixture must expose one column")
cards = window_columns[0].get("cards", [])
require(window_report, len(cards) == 37, "Window overflow fixture must expose 37 windows")
require(window_report, cards[35].get("windowShortcutCode") == "1Z", "36th window must still expose a shortcut code")
require(window_report, cards[36].get("windowShortcutCode") == "", "37th window must not expose a two-key shortcut code")
 
print(json.dumps({
    "appOverflowCode": app_columns[36].get("cards", [])[0].get("windowShortcutCode"),
    "windowOverflowCode": cards[36].get("windowShortcutCode")
}, indent=2, ensure_ascii=False))
PY
}
 
assert_arrow_wrap_report() {
  /usr/bin/python3 - "$ARROW_WRAP_REPORT" "$ARROW_WRAP_KEY_SEQUENCE" "$FIXTURE_APP_COUNT" <<'PY'
import json
import sys
 
path = sys.argv[1]
key_sequence = [part for part in sys.argv[2].split(",") if part]
fixture_app_count = int(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", {})
columns = root.get("waterfallColumns", [])
target_app_index = fixture_app_count - 1
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(root.get("waterfallViewMode") == "horizontalMasonry", "arrow wrap run must use horizontalMasonry")
require(root.get("keyboardCommandsApplied") == key_sequence, "arrow wrap keyboard commands must be applied")
require(len(columns) == fixture_app_count, "fixture App count must match")
committed_window_id = root.get("lastCommittedWindowID")
suppressed_window_ids = set(report.get("suppressedActivationWindowIDs", []))
require(root.get("lastCommittedAppGroupIndex") == target_app_index, "left arrow from first App icon must wrap to the last App")
require(root.get("lastCommittedWindowIndex") == 0, "down arrow from App icon must land on the first window")
require(committed_window_id in suppressed_window_ids, "arrow focus Enter may remove committed target only when suppression records it")
require(root.get("lastCommitSource") == "keyboard", "arrow focus Enter commit must be keyboard sourced")
require(committed_window_id not in [card.get("windowID") for column in columns for card in column.get("cards", [])], "suppressed arrow target must not remain in current cards")
 
print(json.dumps({
    "commands": root.get("keyboardCommandsApplied"),
    "wrappedAppGroupIndex": target_app_index,
    "lastCommittedWindowID": root.get("lastCommittedWindowID")
}, indent=2, ensure_ascii=False))
PY
}
 
assert_filter_report() {
  /usr/bin/python3 - "$FILTER_REPORT" <<'PY'
import json
import sys
 
path = sys.argv[1]
 
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", {})
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, "Space filter run must keep Quick Switch visible")
require(root.get("waterfallViewMode") == "horizontalMasonry", "Space filter run must use horizontalMasonry")
require(root.get("spaceFilterActive") is True, "clicking Space A1 must lock the filter")
require(root.get("spaceFilterLockedSpaceID") == 1, "Space A1 must be the locked Space")
require(root.get("appShelfNames") == ["Alpha Space App", "Beta Space App"], "horizontal Space filter must filter App Shelf")
require(root.get("waterfallColumnNames") == ["Alpha Space App", "Beta Space App"], "horizontal Space filter must filter Waterfall sections")
require([card.get("windowID") for card in cards] == [50101, 50102, 50201], "horizontal Space filter must expose only Space A1 windows")
require(all(card.get("primarySpaceID") == 1 for card in cards), "all filtered horizontal cards must belong to Space A1")
require(root.get("waterfallScrollable") is False, "horizontal Space filter must not expose horizontal scroll")
require(root.get("projectionTransitionFadeOutLayerCount", 0) > 0, "horizontal Space filter must animate removed cards/apps")
require(root.get("projectionTransitionDurationMilliseconds", 0) >= 120, "horizontal Space filter must report projection duration")
 
widths = [round(card.get("bounds", {}).get("width", 0), 1) for card in cards]
require(len(set(widths)) == 1, "filtered horizontal cards must remain equal width")
heights = [round(card.get("bounds", {}).get("height", 0), 1) for card in cards]
require(len(set(heights)) == 1 and abs(heights[0] - 138.0) <= 1.0, "filtered horizontal cards must keep the fixed 138pt height")
visible_width = root.get("waterfallVisibleWidth", 0)
require(all(card.get("frame", {}).get("x", -1) >= -1 for card in cards), "filtered cards must not overflow left")
require(all(card.get("frame", {}).get("x", 0) + card.get("frame", {}).get("width", 0) <= visible_width + 1 for card in cards), "filtered cards must not overflow right")
 
print(json.dumps({
    "mode": root.get("waterfallViewMode"),
    "lockedSpaceID": root.get("spaceFilterLockedSpaceID"),
    "apps": root.get("appShelfNames"),
    "windowIDs": [card.get("windowID") for card in cards],
    "fadeOutLayerCount": root.get("projectionTransitionFadeOutLayerCount"),
    "transitionDurationMilliseconds": root.get("projectionTransitionDurationMilliseconds")
}, indent=2, ensure_ascii=False))
PY
}
 
run_fixture() {
  local report="$1"
  shift
 
  run_fixture_with_app_count "$report" "$FIXTURE_APP_COUNT" "$@"
}
 
run_fixture_with_app_count() {
  local report="$1"
  local app_count="$2"
  shift 2
 
  run_fixture_with_shape "$report" "$app_count" "$FIXTURE_WINDOWS_PER_APP" "$@"
}
 
run_fixture_with_shape() {
  local report="$1"
  local app_count="$2"
  local windows_per_app="$3"
  shift 3
 
  rm -f "$report"
  "$APP/Contents/MacOS/Aligner" \
    --round0-skip-permissions \
    --round01-open-quick-switch \
    --round01-fixture-app-count="$app_count" \
    --round01-fixture-windows-per-app="$windows_per_app" \
    --round01-disable-screenshot-refresh \
    --round01-waterfall-view-mode=horizontal \
    --round01-quick-switch-report="$report" \
    "$@" &
 
  APP_PID=$!
}
 
run_space_filter_fixture() {
  local report="$1"
 
  rm -f "$report"
  "$APP/Contents/MacOS/Aligner" \
    --round0-skip-permissions \
    --round01-open-quick-switch \
    --round01-fixture-space-filter \
    --round01-disable-screenshot-refresh \
    --round01-waterfall-view-mode=horizontal \
    --round01-debug-mouse-sequence="click-space:1" \
    --round01-quick-switch-report="$report" &
 
  APP_PID=$!
}
 
if [ "$FIXTURE_APP_COUNT" -le "$HOVER_APP_INDEX" ]; then
  fail "hover App index must be inside fixture app count"
fi
if [ "$LETTER_INDEX_FIXTURE_APP_COUNT" -le "$LETTER_INDEX_TARGET_APP_INDEX" ]; then
  fail "letter index target App must be inside letter fixture app count"
fi
if [ "$FIXTURE_WINDOWS_PER_APP" -lt 2 ]; then
  fail "fixture must include at least two windows per App for Tab navigation"
fi
 
stop_current_aligner
"$SCRIPT_DIR/package-app.sh" >&2
 
APP_PID=""
cleanup() {
  if [ -n "${APP_PID:-}" ]; then
    kill "$APP_PID" 2>/dev/null || true
    wait "$APP_PID" 2>/dev/null || true
  fi
  stop_current_aligner
  restore_user_theme
}
trap cleanup EXIT
preserve_user_theme
/usr/bin/defaults write "$DOMAIN" "$THEME_KEY" -string "light"
 
run_fixture "$LAYOUT_REPORT" --round01-debug-mouse-sequence="hover-app:$HOVER_APP_INDEX"
wait_for_loaded_report "$LAYOUT_REPORT"
assert_layout_report
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
stop_current_aligner
 
run_fixture "$KEYBOARD_REPORT" --round01-debug-key-sequence="$KEY_SEQUENCE"
wait_for_loaded_report "$KEYBOARD_REPORT"
assert_keyboard_report
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
stop_current_aligner
 
run_fixture "$APP_ENTER_REPORT" --round01-debug-key-sequence="$APP_ENTER_KEY_SEQUENCE"
wait_for_loaded_report "$APP_ENTER_REPORT"
assert_app_enter_report
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
stop_current_aligner
 
run_fixture_with_app_count "$LETTER_INDEX_REPORT" "$LETTER_INDEX_FIXTURE_APP_COUNT" --round01-debug-key-sequence="$LETTER_INDEX_KEY_SEQUENCE"
wait_for_loaded_report "$LETTER_INDEX_REPORT"
assert_letter_index_report
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
stop_current_aligner
 
run_fixture "$WINDOW_SHORTCUT_REPORT" --round01-debug-key-sequence="$WINDOW_SHORTCUT_KEY_SEQUENCE"
wait_for_loaded_report "$WINDOW_SHORTCUT_REPORT"
assert_window_shortcut_report
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
stop_current_aligner
 
run_fixture_with_shape "$OVERFLOW_APP_REPORT" 37 1
wait_for_loaded_report "$OVERFLOW_APP_REPORT"
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
stop_current_aligner
 
run_fixture_with_shape "$OVERFLOW_WINDOW_REPORT" 1 37
wait_for_loaded_report "$OVERFLOW_WINDOW_REPORT"
assert_window_shortcut_overflow_report
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
stop_current_aligner
 
run_fixture "$ARROW_WRAP_REPORT" --round01-debug-key-sequence="$ARROW_WRAP_KEY_SEQUENCE"
wait_for_loaded_report "$ARROW_WRAP_REPORT"
assert_arrow_wrap_report
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
stop_current_aligner
 
run_space_filter_fixture "$FILTER_REPORT"
wait_for_loaded_report "$FILTER_REPORT"
assert_filter_report
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
 
trap - EXIT
stop_current_aligner
restore_user_theme
echo "Round01.1 horizontal Waterfall fixture QA passed"