Ariver
2026-06-19 9114595d7575b8d79328f7dc0e3401f65414de63
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
#!/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, and horizontal-mode Tab navigation.
 
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"
LAYOUT_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-layout-report.json"
KEYBOARD_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-keyboard-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}"
REPORT_WAIT="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_REPORT_WAIT:-6.0}"
 
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"
}
 
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("frame", {}).get("width", 0), 1) for card in cards]
require(len(set(widths)) == 1, "horizontal masonry cards must be equal width")
heights = [round(card.get("frame", {}).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(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_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("frame", {}).get("width", 0), 1) for card in cards]
require(len(set(widths)) == 1, "filtered horizontal cards must remain equal width")
heights = [round(card.get("frame", {}).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
 
  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=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 [ "$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
}
trap cleanup EXIT
 
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_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
echo "Round01.1 horizontal Waterfall fixture QA passed"