src/switcher/state/TestReducerRunnerSpecs.md
An event-replay harness over the pure orchestration reducer, closing the corpus gap: the
RealWorldScenariosTests corpus pins KERNEL decisions, but most 2026-07 regressions lived in ADAPTER
seams — event-handler choices, synthetic-fact projections, cross-pass orchestration — which one-shot
kernel calls can't execute. The orchestration now lives in WindowEventReducer.reduce(inout TrackedWindowState, ReducerInput) -> [ReducerEffect] (the adapters are an IO shell around it — see TrackedWindowStateBridge), so a
recorded debug log replays as a unit test: fixture = initial TrackedWindowState + input sequence, assertions =
invariants checked after EVERY step.
TrackedWindowState (pure, both targets): the whole orchestration state — the window list in Windows.list
order (title/geometry matching is order-sensitive), app facts, the TabGroupsTable registry, the
hold/pending sets, the activation entries, Space topology. Synthetic facts (held, spaceIsBorrowed,
isFullscreenMirrored) are first-class fields; TrackedWindowState.tabWindow is the ONE projection to the
kernels, masking or forwarding each — the rec14/15/20/21 rule, enforced in ONE place: the live model's
last group mutation (TabGroups.repPicker) goes through this same projection rather than a copy of it.ReducerInput: one WindowServer event (create/destroy/move/resize/order/focus/Space add-remove/Space
change), one async read RESULT landing (discovery, title+tabs, WS state, the Spaces re-query, liveness,
the CGS window lists), or one timer check firing (hold release, drag-out). Each case carries the payload
plus the ambient facts the live handler read at that instant (uptime, in-Space-transition) — so a
recording transcribes input by input.ReducerEffect: every side effect the handlers used to fire inline (AX reads, WS queries, captures,
UI refreshes, timers, log facts). The live shell executes them verbatim; the harness records
requests/timers and applies pure twins of the model-mutating ones (removeWindow).Every real-world scenario gets pinned at the level where its bug lived:
matched=, untracked=, a verdict) against
TabWindow snapshots. This is the level for iterating on a fix (a failure names the broken rule), for
unrolling races as input states (a recording only samples one side), and for property sweeps
(frameCorpus runs the frame invariant over every capture × both claim paths — inexpressible as replays).Don't move kernel pins up to the replay level: replays assert coarse invariants over one recorded ordering,
kernels assert exact outputs over many orderings — different axes, both needed. And both levels read ONE
transcription of the recorded data: fixtures build their windows from the corpus's CapturedWindow raw
facts via modelWindow(...) wherever a capture exists.
RealWorldScenariosSpecs.md)CapturedWindow, build it with
capture.modelWindow(spaceIds:spaceIsBorrowed:isFullscreenMirrored:lastFocusOrder:) — raw facts from the
capture, model-state facts as parameters. Transcribe directly only windows no kernel test needed.holdReleaseCheck/dragOutCheck) are explicit inputs: replay the timing the recording
sampled, or unroll the orders it didn't — races are data.TestScenario fails under the same reverted fix, the recording goes.One tile per group — ≤ 1 displayed member always; exactly 1 while the group has a screen claim, 0 once it's dead remains (rec22).
A group's members must agree on their frame — as much as the way it was formed justifies. The cross-cutting theft rule (rec8/10/11/12), now split, because stated as "same frame" it asserted something FALSE about real tab groups and fired on correctly-grouped windows:
tabCount > 1) → only the WIDTH must match. A tab
bar appearing changes the window's HEIGHT, and after "Merge All Windows" background tabs keep their
pre-merge POSITION indefinitely; both measured live on macOS 26. Width held across every case measured,
so width is what the rule says. Without this split no fix for the tab-bar case could pass.The design's sanctioned transitional states stay exempt either way: a pending drag-out verdict, a held member, an unrenderable (0×0/nil) frame, and a group with a GENUINELY-fullscreen member. That last exemption is whole-group on purpose: fullscreen resizes only the ACTIVE tab, so a fullscreen window's tabs legitimately wear several frames at once (frozen before the transition vs frozen after it), and frames carry no theft signal there.
The focused window is never hidden — most recently focused window of the frontmost active app.
A real on-Space window is never claimed — tabbed ⇒ Space empty/borrowed/held; exempt while a creation is in flight (the sanctioned atomic claim) or the wid is a pending drag-out's outgoing representative (its genuine Space outlives the swap by a few ms — observed in rec19).
Space-less ungrouped unheld windows are hidden (rec15/rec20 strays).
The representative is the most recently focused presentable member (rec18/rec19 — focus is authoritative, read order is not evidence).
ReplayScenariosTests has been DELETED (2026-07-18): every recorded chain bug is now reproduced from user
actions by TestScenarioSimulator, each teeth-verified. The runner itself is still the engine underneath —
the scenario layer drives it, and its per-step invariants police every scenario.
WindowDiscriminator, findOrCreate,
bestEffortTitle): AX/CGS IPC and object creation; the reducer takes over at discoveryLanded.windowAttributesThrottler etc.): IO pacing, carried on effects as flags.Windows.removeWindows: view/scheduler/subscription cleanup stays live; the reducer decides WHEN
(removeWindow effect) and the harness twins the model part. The MRU-shift semantics exist twice
(live + harness twin) — the one accepted duplication.armHoldReleaseCheck/armDragOutCheck), the NSWorkspace observers, the AX backstop's
focused-window read, the Space-transition debounce, Window.init's checkIfFocused seed, and the
full-rescan sweeps (discardDeadPhantomWindows) — pure IO or init-time seeding.Mirrors TestReducerRunnerTests.swift 1:1.
An invariant that cannot fail is decoration, so each one is fed the bug shape it exists to catch and must report a violation. These are tests OF the harness, not of the reducer.
spacesSynced
confirms CGS places it nowhere, not on the ungroup itself); a HELD one is exempt.pendingSpaceRemoval, which is why it records the SPACE.raiseTail
names this wid: the 808 storm already orders the app's windows, and re-fronting there reverses that order
(#5596). Written with wids already drained, since that is the state the 815s actually find.inSpaceTransition cannot gate it — the Space notification lands
519ms after the order-ins — so the preceding order-out (816) is what separates a re-show from a raise.FocusPromotion and applied at the time it happened, so a window
discovered after the user alt-tabbed away lands BEHIND that window instead of at the back of the MRU.recordHandover)The kernel guards reading replacedByWid / replacedWid are decoration unless the edge is actually written
from the event stream, so these pin the RECORDING. The two halves are separate WindowServer datagrams whose
order nothing pins, so both arrival orders are tested — the same thing HandoverOrder fuzzes end-to-end.
pendingHandoverEdge and is applied at discovery.accepted: false is the ONLY moment we learn that for certain (Finder's 804 lags or
never fires, and a wid joining a non-visible Space is never even scheduled for discovery), so both pending
maps drain there. Consumption is not time-bounded, so a leaked edge could later dress a window in a stale
replacedWid that dragOutVerdict reads as settled.zOrderRead, the very first summon)The blocking CGS stacking query AltTab fires on its first summon, applied through the MRU model instead of over it. It answers AFTER that summon's first render, so anything it decides, the user watches happen.
focusedAt TIEBREAK rather than over it. Teeth-verified:
assign the ranks directly, as Windows.sortByLevel used to, and this fails with the live symptom — the
T-21 capture where the just-focused Terminal tab, backgrounded and therefore absent from the query, fell
from tile 0 to tile 3 twenty milliseconds in, taking the highlight with it.