Skip to content

Commit ba7ae2e

Browse files
committed
test(settle): keep a daemon-route dismissal asserting the confirmation, and pin the parity gap
The review on this PR found that padding the provider-scenario dialog past the arming rule's size bound (SHARED_SCREEN_NODES) silently removed the only test that checked the 1.5s confirmation through the daemon: the transition stopped being broad (4 of 6 identities survive), so `waitedMs >= 1_500` had to become `captures in 2..3`. Revert that padding, which restores the unpadded dialog and its original assertion; all four provider-scenario cases pass on this rule. Add the parity case the review asked for: the same presented sheet dismissed from an interactive baseline (4 nodes, as `snapshot -i` projects it) and from a full-tree read of the same screen (6 nodes), both asserted to confirm. Measured on the injected clock, the interactive baseline confirms for 1,502ms and the full-tree baseline for 27ms with `settled: true` either way — #1831's guard lost on the route whose baseline happens to be the richer flavour. Marked `fails`, so the lane stays green and the case turns red ("expected to fail but passed") the moment #3565's `presentedSurface` replaces the count bound: that red is the signal to drop the marker, and the assertion is the rewrite's acceptance test.
1 parent 761ace3 commit ba7ae2e

4 files changed

Lines changed: 269 additions & 146 deletions

File tree

‎src/__tests__/daemon-composition/generic-settle.test.ts‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import {
1111
} from '@agent-device/contracts/ios-system-surface';
1212
import { makeIosSession } from '@agent-device/daemon/testing/__tests__/support/session-factories';
1313
import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory';
14+
import {
15+
PRESENTED_SHEET_FULL_NODES,
16+
PRESENTED_SHEET_INTERACTIVE_NODES,
17+
SHEET_DISMISSED_NODES,
18+
} from './presented-sheet-trees.ts';
1419
import { activateCompleteRefFrame, refFrameState } from '@agent-device/daemon/testing/ref-frame';
1520
import { setSessionSnapshot } from '@agent-device/daemon/testing/session-snapshot';
1621
import type { SessionStore } from '@agent-device/daemon/testing/session-store';
@@ -77,6 +82,7 @@ type SettlePayload = {
7782
captures: number;
7883
quietMs: number;
7984
timeoutMs: number;
85+
waitedMs: number;
8086
refsGeneration?: number;
8187
diff?: {
8288
summary: { additions: number; removals: number; unchanged: number };
@@ -115,7 +121,7 @@ async function emulateCaptureSnapshotForSession(
115121
}
116122

117123
function mockCommandDispatch(
118-
snapshots: Array<typeof BEFORE_NODES>,
124+
snapshots: RawSnapshotNode[][],
119125
producer: NonNullable<GenericSettleTree['producer']> = 'apple-runner',
120126
) {
121127
let snapshotCalls = 0;
@@ -556,6 +562,61 @@ test('back --settle attaches no diff across a sheet-to-app surface change and di
556562
expect(stored.snapshot?.iosSystemSurfaceBundleId).toBeUndefined();
557563
});
558564

565+
/**
566+
* `back --settle` over a sheet left on the session by a capture of one given flavour, which is how
567+
* the generic route picks its baseline (`generic-settle.ts` reads the stored tree directly). The
568+
* flavour is the only thing the two callers vary; the settle loop's budget arithmetic runs on the
569+
* injected clock, so neither waits real time.
570+
*/
571+
async function dismissSheetFromBaseline(
572+
sessionName: string,
573+
baseline: RawSnapshotNode[],
574+
): Promise<SettlePayload> {
575+
const sessionStore = makeSessionStore();
576+
seedSession(sessionName, sessionStore, { nodes: baseline });
577+
mockCommandDispatch([SHEET_DISMISSED_NODES]);
578+
const response = await dispatchGeneric({
579+
sessionName,
580+
sessionStore,
581+
command: 'back',
582+
flags: { settle: true, settleQuietMs: 25, timeoutMs: 30_000 },
583+
clock: fakeClock(),
584+
});
585+
return expectOkData(response).settle as SettlePayload;
586+
}
587+
588+
// PARITY GAP — ASSERTED, AND CURRENTLY UNMET. Arming the confirmation here reads the baseline's node
589+
// count, so the same sheet disarms when the baseline arrives as a full read (6 nodes) instead of an
590+
// interactive one (4). Both transitions ARE broad — the dismissed screen keeps only the Application
591+
// identity from either baseline — so the only thing that differs is which flavour
592+
// `resolveSettleBaseline` handed the loop. Measured at this head on the injected clock: the
593+
// interactive baseline confirms for 1,502ms, the full-tree baseline for 27ms, and `settled: true`
594+
// either way. Marked `fails` so the suite states the gap without turning the lane red; when #3565's
595+
// producer-stamped `presentedSurface` replaces the count bound, vitest reports this as
596+
// "expected to fail but passed" — that failure is the signal to drop the marker, and the assertion
597+
// is the acceptance criterion for the rewrite.
598+
test.fails('the confirmation arms for one presented sheet whichever baseline flavour the route holds', async () => {
599+
const interactive = await dismissSheetFromBaseline(
600+
'generic-settle-dismiss-sheet-interactive',
601+
PRESENTED_SHEET_INTERACTIVE_NODES,
602+
);
603+
const full = await dismissSheetFromBaseline(
604+
'generic-settle-dismiss-sheet-full',
605+
PRESENTED_SHEET_FULL_NODES,
606+
);
607+
608+
expect(interactive.settled).toBe(true);
609+
expect(full.settled).toBe(true);
610+
expect(
611+
interactive.waitedMs,
612+
'interactive baseline did not confirm the dismissal',
613+
).toBeGreaterThanOrEqual(1_500);
614+
expect(
615+
full.waitedMs,
616+
`full-tree baseline lost the #1831 confirmation: ${full.waitedMs}ms vs the interactive baseline's ${interactive.waitedMs}ms`,
617+
).toBeGreaterThanOrEqual(1_500);
618+
});
619+
559620
test('the settle observation runs after the post-gesture stabilization marker', async () => {
560621
const sessionStore = makeSessionStore();
561622
const sessionName = 'generic-settle-order';
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
2+
3+
/**
4+
* One presented iOS `pageSheet`, in the two flavours a settle baseline can arrive as, plus the app
5+
* screen a dismissal returns to. An interactive capture carries only the surface — the content
6+
* underneath is not hittable, so it drops out of the projection (4 nodes on the iPhone 18 Pro sim,
7+
* October 2026) — while a full read of the very same screen also reports the covered content (6
8+
* nodes). Arming the broad-transition confirmation must not depend on which flavour produced the
9+
* baseline, which is what the caller that reads these asserts.
10+
*/
11+
export const PRESENTED_SHEET_INTERACTIVE_NODES: RawSnapshotNode[] = [
12+
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } },
13+
{
14+
index: 1,
15+
parentIndex: 0,
16+
type: 'StaticText',
17+
label: 'Allow notifications?',
18+
rect: { x: 10, y: 450, width: 370, height: 22 },
19+
},
20+
{
21+
index: 2,
22+
parentIndex: 0,
23+
type: 'Button',
24+
label: 'Continue',
25+
rect: { x: 10, y: 600, width: 370, height: 44 },
26+
hittable: true,
27+
},
28+
{
29+
index: 3,
30+
parentIndex: 0,
31+
type: 'Button',
32+
label: 'Cancel',
33+
rect: { x: 10, y: 660, width: 370, height: 44 },
34+
hittable: true,
35+
},
36+
];
37+
38+
export const PRESENTED_SHEET_FULL_NODES: RawSnapshotNode[] = [
39+
...PRESENTED_SHEET_INTERACTIVE_NODES,
40+
{
41+
index: 4,
42+
parentIndex: 0,
43+
type: 'Other',
44+
label: 'CoveredContent',
45+
rect: { x: 0, y: 0, width: 390, height: 440 },
46+
},
47+
{
48+
index: 5,
49+
parentIndex: 0,
50+
type: 'Button',
51+
label: 'Open settings',
52+
rect: { x: 10, y: 120, width: 370, height: 44 },
53+
},
54+
];
55+
56+
/** The app screen the sheet is dismissed from, which is what every settle capture then reads. */
57+
export const SHEET_DISMISSED_NODES: RawSnapshotNode[] = [
58+
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } },
59+
{
60+
index: 1,
61+
parentIndex: 0,
62+
type: 'StaticText',
63+
label: 'Signed in as demo',
64+
rect: { x: 10, y: 80, width: 200, height: 22 },
65+
},
66+
{
67+
index: 2,
68+
parentIndex: 0,
69+
type: 'Button',
70+
label: 'Home',
71+
rect: { x: 10, y: 120, width: 370, height: 44 },
72+
hittable: true,
73+
},
74+
{
75+
index: 3,
76+
parentIndex: 0,
77+
type: 'TabBar',
78+
label: 'Tab Bar',
79+
rect: { x: 0, y: 800, width: 390, height: 44 },
80+
},
81+
];

‎src/commands/interaction/runtime/stable-capture.test.ts‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -412,12 +412,12 @@ test('a dismissal confirmation is not dropped by a later empty projection', asyn
412412
);
413413
});
414414

415-
// The two arming inputs that belong to the capture, not to the run: the viewport-root completeness
416-
// and the declared capture backend. A dismissal of anything bigger than an alert or sheet (the
417-
// `<= TINY_STABLE_TREE_NODE_COUNT` escape hatch) has to meet BOTH before the confirmation may arm,
418-
// and a mid-animation first read that meets neither leaves the question open for the next capture
419-
// rather than settling the run on its plain window. These captures declare no producer, so that plain
420-
// window is the 500 ms default; the 1.5 s floor is what the assertions tell apart.
415+
// Under the narrowed trigger only ONE of the two inputs the parent layer re-checks per capture is
416+
// still an arming input. Viewport-root completeness on the CAPTURE was part of the parent's
417+
// completeness evidence; this layer reads the presented surface from the BASELINE instead, so a
418+
// rootless or empty mid-animation read no longer has anything to decide (the cases below pin that it
419+
// still arms). The declared capture channel stays per-capture here exactly as it does on the parent:
420+
// one read through another channel must not rule the confirmation out for the run (#3470).
421421
const MID_ANIMATION_FIRST_CAPTURES: Array<
422422
[string, NonNullable<Parameters<typeof transitionRuntime>[0]>]
423423
> = [
@@ -426,17 +426,15 @@ const MID_ANIMATION_FIRST_CAPTURES: Array<
426426
['a first capture read through another channel', { otherChannelAtCaptures: [1] }],
427427
];
428428
for (const [label, midAnimation] of MID_ANIMATION_FIRST_CAPTURES) {
429-
test(`settle arms the confirmation on the first rooted XCTest capture after ${label}`, async () => {
429+
test(`settle arms the confirmation on the first qualifying capture after ${label}`, async () => {
430430
const { runtime } = transitionRuntime({ settledAtMs: 0, ...midAnimation });
431431

432432
const outcome = await runStableCaptureLoop(
433433
runtime,
434434
{ session: 'default' },
435435
{
436436
timeoutMs: 5_000,
437-
// A full-screen settings table: the tiny-presented-baseline escape hatch does not apply, so
438-
// arming depends entirely on the captures below.
439-
broadTransitionBaselineNodes: elementSettingsSnapshot([1_138, 1_423, 2_144, 2_434]).nodes,
437+
broadTransitionBaselineNodes: elementThreadsNoticeSnapshot.nodes,
440438
},
441439
);
442440

0 commit comments

Comments
 (0)