@unrulysystems/native-motion-conformance 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/package.json +33 -0
- package/src/adapter.ts +42 -0
- package/src/adapters/motion-dom.ts +96 -0
- package/src/adapters/native.ts +95 -0
- package/src/authoring.ts +78 -0
- package/src/comparator.ts +129 -0
- package/src/config.ts +22 -0
- package/src/declarations.ts +21 -0
- package/src/index.ts +144 -0
- package/src/oracle/attestation.ts +100 -0
- package/src/oracle/constants.ts +24 -0
- package/src/oracle/controls.ts +202 -0
- package/src/oracle/errors.ts +12 -0
- package/src/oracle/exportTrace.ts +134 -0
- package/src/oracle/index.ts +133 -0
- package/src/oracle/judge.ts +1374 -0
- package/src/oracle/presenter.ts +372 -0
- package/src/oracle/runRecord.ts +307 -0
- package/src/oracle/scenarios.ts +115 -0
- package/src/oracle/scripts/gesture.ts +218 -0
- package/src/oracle/serialize.ts +91 -0
- package/src/oracle/sweep.ts +155 -0
- package/src/oracle/types.ts +76 -0
- package/src/oracle/velocity.ts +44 -0
- package/src/parity.ts +136 -0
- package/src/runner.ts +168 -0
- package/src/scenario.ts +179 -0
- package/src/scenarios/appstore-choreography.ts +105 -0
- package/src/scenarios/component.ts +516 -0
- package/src/scenarios/driver.ts +322 -0
- package/src/scenarios/gesture.ts +363 -0
- package/src/scenarios/layout-identity.ts +264 -0
- package/src/scenarios/layout.ts +258 -0
- package/src/scenarios/presence.ts +302 -0
- package/src/scenarios/spring.ts +180 -0
- package/src/scenarios/value-types.ts +107 -0
- package/src/suite.ts +44 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// L2 (specs/L2-BUILD-PACKET.md) Altitude-2 conformance scenarios — the shared layout-identity
|
|
2
|
+
// registry laws (REQ-LAYOUT-002/003/015 as inherited by the ratified registry seam). Authored ONCE,
|
|
3
|
+
// engine-agnostic, ADDITIVE to the suite (the existing scenarios' semantics are untouched): each
|
|
4
|
+
// drives the reference registry on a fake host (injectable clock + MotionGraph, scripted rects, no
|
|
5
|
+
// driver/React/Fabric) and asserts a registry law — sole-static, lead-departs-from-predecessor,
|
|
6
|
+
// both-holders coincidence on ONE shared flight, settle-at-lead-layout, crossfade conservation,
|
|
7
|
+
// exit/resume + releasable, fail-loud holder discipline. `layout-identity.cross-engine-parity` is
|
|
8
|
+
// external-evidence-only: recorded `unassertable` here, asserted at the named Altitude-5 Chromium gate
|
|
9
|
+
// (LAYOUT_IDENTITY_PARITY_COUNTERPART → layout-identity-parity.e2e.ts) — never flipped to an in-suite pass.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
EPSILON_POS,
|
|
13
|
+
ManualClock,
|
|
14
|
+
ManualScheduler,
|
|
15
|
+
createLayoutIdentityRegistry,
|
|
16
|
+
createMotionGraph,
|
|
17
|
+
toWindowSpaceRect,
|
|
18
|
+
type LayoutIdentityRegistry,
|
|
19
|
+
type Rect,
|
|
20
|
+
type Transform,
|
|
21
|
+
} from '@unrulysystems/native-motion-core'
|
|
22
|
+
import type { ScenarioResult, Verdict } from '../runner'
|
|
23
|
+
|
|
24
|
+
const FAKE_ENGINE = 'fake-host'
|
|
25
|
+
|
|
26
|
+
const CARD = toWindowSpaceRect({ x: 20, y: 400, width: 200, height: 150 })
|
|
27
|
+
const HERO = toWindowSpaceRect({ x: 0, y: 0, width: 390, height: 500 })
|
|
28
|
+
|
|
29
|
+
interface Outcome {
|
|
30
|
+
readonly verdict: Verdict
|
|
31
|
+
readonly detail: string
|
|
32
|
+
}
|
|
33
|
+
const pass = (detail: string): Outcome => ({ verdict: 'pass', detail })
|
|
34
|
+
const fail = (detail: string): Outcome => ({ verdict: 'fail', detail })
|
|
35
|
+
const unassertable = (detail: string): Outcome => ({ verdict: 'unassertable', detail })
|
|
36
|
+
|
|
37
|
+
function harness(): { scheduler: ManualScheduler; registry: LayoutIdentityRegistry } {
|
|
38
|
+
const clock = new ManualClock(0)
|
|
39
|
+
const scheduler = new ManualScheduler(clock)
|
|
40
|
+
const graph = createMotionGraph({ clock, scheduler })
|
|
41
|
+
return { scheduler, registry: createLayoutIdentityRegistry({ graph }) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The rect a holder paints this frame: its layout rect under its read transform (top-left anchored).
|
|
45
|
+
function painted(layout: Rect, t: Transform): Rect {
|
|
46
|
+
return {
|
|
47
|
+
x: layout.x + t.translateX,
|
|
48
|
+
y: layout.y + t.translateY,
|
|
49
|
+
width: layout.width * t.scaleX,
|
|
50
|
+
height: layout.height * t.scaleY,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rectClose(a: Rect, b: Rect, eps = EPSILON_POS): boolean {
|
|
55
|
+
return (
|
|
56
|
+
Math.abs(a.x - b.x) < eps &&
|
|
57
|
+
Math.abs(a.y - b.y) < eps &&
|
|
58
|
+
Math.abs(a.width - b.width) < eps &&
|
|
59
|
+
Math.abs(a.height - b.height) < eps
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function settleId(
|
|
64
|
+
scheduler: ManualScheduler,
|
|
65
|
+
registry: LayoutIdentityRegistry,
|
|
66
|
+
id: string,
|
|
67
|
+
): number {
|
|
68
|
+
let frames = 0
|
|
69
|
+
while (!registry.isSettled(id) && frames < 2000) {
|
|
70
|
+
scheduler.frame(16.67)
|
|
71
|
+
frames += 1
|
|
72
|
+
}
|
|
73
|
+
return frames
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface LayoutIdentityScenario {
|
|
77
|
+
readonly id: string
|
|
78
|
+
readonly requirements: readonly string[]
|
|
79
|
+
readonly assert: () => Outcome
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const LAYOUT_IDENTITY_SCENARIOS: readonly LayoutIdentityScenario[] = [
|
|
83
|
+
{
|
|
84
|
+
id: 'layout-identity.sole-static',
|
|
85
|
+
requirements: ['REQ-LAYOUT-003'],
|
|
86
|
+
assert() {
|
|
87
|
+
const { registry } = harness()
|
|
88
|
+
const { role } = registry.enter('card', 'a', CARD)
|
|
89
|
+
const read = registry.read('card', 'a')
|
|
90
|
+
if (role !== 'sole') return fail(`first enter role '${role}', want 'sole'`)
|
|
91
|
+
if (read.opacity !== 1) return fail(`sole opacity ${read.opacity}, want 1`)
|
|
92
|
+
if (!rectClose(painted(CARD, read.transform), CARD))
|
|
93
|
+
return fail('sole holder not static at its layout')
|
|
94
|
+
if (!registry.isSettled('card')) return fail('sole holder reports unsettled')
|
|
95
|
+
return pass('sole: static at own layout, opacity 1, settled')
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'layout-identity.lead-departs-from-predecessor',
|
|
100
|
+
requirements: ['REQ-LAYOUT-003', 'REQ-LAYOUT-015'],
|
|
101
|
+
assert() {
|
|
102
|
+
const { scheduler, registry } = harness()
|
|
103
|
+
registry.enter('card', 'a', CARD)
|
|
104
|
+
const { role } = registry.enter('card', 'b', HERO)
|
|
105
|
+
scheduler.frame(0)
|
|
106
|
+
const lead = registry.read('card', 'b')
|
|
107
|
+
if (role !== 'lead') return fail(`second enter role '${role}', want 'lead'`)
|
|
108
|
+
if (!rectClose(painted(HERO, lead.transform), CARD)) {
|
|
109
|
+
return fail('frame 0 of the flight does not paint at the predecessor rect (FLIP inversion)')
|
|
110
|
+
}
|
|
111
|
+
if (!registry.zLifted('card', 'b')) return fail('lead not z-lifted mid-flight')
|
|
112
|
+
return pass('lead departs from the predecessor rect, z-lifted')
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: 'layout-identity.coincidence-one-shared-flight',
|
|
117
|
+
requirements: ['REQ-LAYOUT-015'],
|
|
118
|
+
assert() {
|
|
119
|
+
const { scheduler, registry } = harness()
|
|
120
|
+
registry.enter('card', 'a', CARD)
|
|
121
|
+
registry.enter('card', 'b', HERO)
|
|
122
|
+
for (let i = 0; i < 12; i++) scheduler.frame(16.67)
|
|
123
|
+
const leadPainted = painted(HERO, registry.read('card', 'b').transform)
|
|
124
|
+
const trailingPainted = painted(CARD, registry.read('card', 'a').transform)
|
|
125
|
+
if (!rectClose(leadPainted, trailingPainted)) {
|
|
126
|
+
return fail('holders paint different rects mid-flight — not one shared flight')
|
|
127
|
+
}
|
|
128
|
+
if (rectClose(leadPainted, CARD) || rectClose(leadPainted, HERO)) {
|
|
129
|
+
return fail('shared rect sits at an endpoint mid-flight')
|
|
130
|
+
}
|
|
131
|
+
return pass('both holders coincide on one shared visual rect mid-flight')
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: 'layout-identity.settles-at-lead-layout',
|
|
136
|
+
requirements: ['REQ-LAYOUT-002'],
|
|
137
|
+
assert() {
|
|
138
|
+
const { scheduler, registry } = harness()
|
|
139
|
+
registry.enter('card', 'a', CARD)
|
|
140
|
+
registry.enter('card', 'b', HERO)
|
|
141
|
+
const frames = settleId(scheduler, registry, 'card')
|
|
142
|
+
if (frames >= 2000) return fail('flight never settled')
|
|
143
|
+
const lead = registry.read('card', 'b')
|
|
144
|
+
if (!rectClose(painted(HERO, lead.transform), HERO))
|
|
145
|
+
return fail('lead did not settle at its host layout')
|
|
146
|
+
if (lead.opacity !== 1) return fail(`settled lead opacity ${lead.opacity}, want exactly 1`)
|
|
147
|
+
if (registry.read('card', 'a').opacity !== 0)
|
|
148
|
+
return fail('settled trailing opacity not exactly 0')
|
|
149
|
+
if (registry.zLifted('card', 'b')) return fail('z-lift persists after settle')
|
|
150
|
+
return pass(`settled at host layout in ${frames} frames; exact opacity endpoints`)
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: 'layout-identity.crossfade-conservation',
|
|
155
|
+
requirements: ['REQ-LAYOUT-003'],
|
|
156
|
+
assert() {
|
|
157
|
+
const { scheduler, registry } = harness()
|
|
158
|
+
registry.enter('card', 'a', CARD)
|
|
159
|
+
registry.enter('card', 'b', HERO)
|
|
160
|
+
for (let i = 0; i < 10; i++) scheduler.frame(16.67)
|
|
161
|
+
const lead = registry.read('card', 'b').opacity
|
|
162
|
+
const trailing = registry.read('card', 'a').opacity
|
|
163
|
+
if (!(lead > 0 && lead < 1 && trailing > 0 && trailing < 1)) {
|
|
164
|
+
return fail(`mid-flight opacities at extremes (lead ${lead}, trailing ${trailing})`)
|
|
165
|
+
}
|
|
166
|
+
if (Math.abs(lead + trailing - 1) > 1e-9) {
|
|
167
|
+
return fail(`opacities not complementary (${lead} + ${trailing})`)
|
|
168
|
+
}
|
|
169
|
+
return pass('mid-flight crossfade is complementary and never both-extreme')
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: 'layout-identity.exit-reverses-and-releases',
|
|
174
|
+
requirements: ['REQ-LAYOUT-002', 'REQ-LAYOUT-003'],
|
|
175
|
+
assert() {
|
|
176
|
+
const { scheduler, registry } = harness()
|
|
177
|
+
registry.enter('card', 'a', CARD)
|
|
178
|
+
registry.enter('card', 'b', HERO)
|
|
179
|
+
settleId(scheduler, registry, 'card')
|
|
180
|
+
registry.leave('card', 'b')
|
|
181
|
+
if (registry.releasable('card', 'b'))
|
|
182
|
+
return fail('leaver releasable before the reverse flight settled')
|
|
183
|
+
scheduler.frame(0)
|
|
184
|
+
const resumed = registry.read('card', 'a')
|
|
185
|
+
if (!rectClose(painted(CARD, resumed.transform), HERO)) {
|
|
186
|
+
return fail('reverse flight does not depart from the rect being returned from')
|
|
187
|
+
}
|
|
188
|
+
settleId(scheduler, registry, 'card')
|
|
189
|
+
if (!registry.releasable('card', 'b')) return fail('leaver not releasable after settle')
|
|
190
|
+
const settled = registry.read('card', 'a')
|
|
191
|
+
if (!rectClose(painted(CARD, settled.transform), CARD) || settled.opacity !== 1) {
|
|
192
|
+
return fail('resumed holder did not settle at its own layout at opacity 1')
|
|
193
|
+
}
|
|
194
|
+
return pass(
|
|
195
|
+
'lead exit reverses the flight; leaver releasable at settle; resume lands at host layout',
|
|
196
|
+
)
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 'layout-identity.fail-loud-holders',
|
|
201
|
+
requirements: ['REQ-LAYOUT-003'],
|
|
202
|
+
assert() {
|
|
203
|
+
const { registry } = harness()
|
|
204
|
+
registry.enter('card', 'a', CARD)
|
|
205
|
+
const probes: string[] = []
|
|
206
|
+
const expectThrow = (name: string, run: () => void): void => {
|
|
207
|
+
try {
|
|
208
|
+
run()
|
|
209
|
+
probes.push(`${name}: did not throw`)
|
|
210
|
+
} catch (e) {
|
|
211
|
+
if (!(e instanceof Error) || !e.message.includes('card')) {
|
|
212
|
+
probes.push(`${name}: error does not name the id`)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const expectWindowSpaceThrow = (name: string, run: () => void): void => {
|
|
217
|
+
try {
|
|
218
|
+
run()
|
|
219
|
+
probes.push(`${name}: did not throw`)
|
|
220
|
+
} catch (e) {
|
|
221
|
+
if (!(e instanceof Error) || !e.message.includes('window-space rect')) {
|
|
222
|
+
probes.push(`${name}: error does not name the branding boundary`)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
expectThrow('double-enter', () => registry.enter('card', 'a', CARD))
|
|
227
|
+
expectThrow('ghost-read', () => registry.read('card', 'ghost'))
|
|
228
|
+
expectThrow('ghost-leave', () => registry.leave('card', 'ghost'))
|
|
229
|
+
expectWindowSpaceThrow('malformed-rect', () =>
|
|
230
|
+
toWindowSpaceRect({ x: Number.NaN, y: 0, width: 1, height: 1 }),
|
|
231
|
+
)
|
|
232
|
+
if (probes.length > 0) return fail(probes.join(' | '))
|
|
233
|
+
return pass(
|
|
234
|
+
'double-enter and ghost operations throw naming the id; malformed rects throw at the branding boundary',
|
|
235
|
+
)
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: 'layout-identity.cross-engine-parity',
|
|
240
|
+
requirements: ['REQ-LAYOUT-003', 'REQ-CONFORM-002'],
|
|
241
|
+
assert() {
|
|
242
|
+
// EXECUTING COUNTERPART registered (L4, REQ-CONFORM-011): the Altitude-5 Chromium gate
|
|
243
|
+
// asserts this registry's crossfade laws against real motion/react —
|
|
244
|
+
// `packages/native-motion-web/e2e/layout-identity-parity.e2e.ts`, declared as
|
|
245
|
+
// LAYOUT_IDENTITY_PARITY_COUNTERPART in parity.ts. Unassertable HERE only because the
|
|
246
|
+
// fake host has no second engine — a routing directive, never a skip.
|
|
247
|
+
return unassertable(
|
|
248
|
+
'asserted at alt5-playwright-chromium: layout-identity-parity.e2e.ts (registered ' +
|
|
249
|
+
'LAYOUT_IDENTITY_PARITY_COUNTERPART) runs the crossfade laws against real motion/react',
|
|
250
|
+
)
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
export function runLayoutIdentityScenario(scenario: LayoutIdentityScenario): ScenarioResult {
|
|
256
|
+
const outcome = scenario.assert()
|
|
257
|
+
return {
|
|
258
|
+
id: scenario.id,
|
|
259
|
+
engine: FAKE_ENGINE,
|
|
260
|
+
verdict: outcome.verdict,
|
|
261
|
+
readings: [],
|
|
262
|
+
detail: outcome.detail,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// SPEC-LAYOUT §4a/§4b Altitude-2 conformance scenarios (REQ-LAYOUT-002/011/012/013/016/018/019 + REQ-
|
|
2
|
+
// LAYOUT-003). Authored ONCE, engine-agnostic: each drives the reference projection engine on a fake host
|
|
3
|
+
// (scripted before/after rects + injectable null, injectable clock + MotionGraph, no driver/React/Fabric/
|
|
4
|
+
// simulator) and asserts a projection law — ends-at-host-layout, parent-relative coherence, child-undistorted
|
|
5
|
+
// under scale, null-fails-closed, retarget C0/C1, stale-identity fail-closed, settle-gating, and the layoutId
|
|
6
|
+
// two-position geometry. At Milestone 1 the fake host is the only engine; the IDENTICAL scenarios re-run
|
|
7
|
+
// against motion/react (web) + the native runtime at Milestone 2. `layout.cross-engine-parity` is
|
|
8
|
+
// external-evidence-only: recorded `unassertable` here (no in-process layout engine), asserted at the named
|
|
9
|
+
// Playwright Chromium gate (LAYOUT_PARITY_COUNTERPART → layout.e2e.ts) — never flipped to an in-suite pass.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
EPSILON_POS,
|
|
13
|
+
EPSILON_SCALE,
|
|
14
|
+
EPSILON_VEL,
|
|
15
|
+
ManualClock,
|
|
16
|
+
ManualScheduler,
|
|
17
|
+
type MotionGraph,
|
|
18
|
+
type Rect,
|
|
19
|
+
applyTransformToRect,
|
|
20
|
+
composeParentRelative,
|
|
21
|
+
counterScale,
|
|
22
|
+
createMeasureTracker,
|
|
23
|
+
createMotionGraph,
|
|
24
|
+
createProjectionSession,
|
|
25
|
+
isIdentity,
|
|
26
|
+
projectAtProgress,
|
|
27
|
+
} from '@unrulysystems/native-motion-core'
|
|
28
|
+
import type { ScenarioResult, Verdict } from '../runner'
|
|
29
|
+
|
|
30
|
+
const FAKE_ENGINE = 'fake-host'
|
|
31
|
+
|
|
32
|
+
const BEFORE: Rect = { x: 0, y: 0, width: 100, height: 100 }
|
|
33
|
+
const AFTER: Rect = { x: 200, y: 300, width: 200, height: 50 }
|
|
34
|
+
const AFTER2: Rect = { x: 400, y: 100, width: 80, height: 80 }
|
|
35
|
+
|
|
36
|
+
interface Outcome {
|
|
37
|
+
readonly verdict: Verdict
|
|
38
|
+
readonly detail: string
|
|
39
|
+
}
|
|
40
|
+
const pass = (detail: string): Outcome => ({ verdict: 'pass', detail })
|
|
41
|
+
const fail = (detail: string): Outcome => ({ verdict: 'fail', detail })
|
|
42
|
+
const unassertable = (detail: string): Outcome => ({ verdict: 'unassertable', detail })
|
|
43
|
+
|
|
44
|
+
function harness(): {
|
|
45
|
+
scheduler: ManualScheduler
|
|
46
|
+
graph: MotionGraph
|
|
47
|
+
session: ReturnType<typeof createProjectionSession>
|
|
48
|
+
} {
|
|
49
|
+
const clock = new ManualClock(0)
|
|
50
|
+
const scheduler = new ManualScheduler(clock)
|
|
51
|
+
const graph = createMotionGraph({ clock, scheduler })
|
|
52
|
+
const session = createProjectionSession({ graph })
|
|
53
|
+
return { scheduler, graph, session }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function rectClose(a: Rect, b: Rect, eps = EPSILON_POS): boolean {
|
|
57
|
+
return (
|
|
58
|
+
Math.abs(a.x - b.x) < eps &&
|
|
59
|
+
Math.abs(a.y - b.y) < eps &&
|
|
60
|
+
Math.abs(a.width - b.width) < eps &&
|
|
61
|
+
Math.abs(a.height - b.height) < eps
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface LayoutScenario {
|
|
66
|
+
readonly id: string
|
|
67
|
+
readonly requirements: readonly string[]
|
|
68
|
+
readonly assert: () => Outcome
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const LAYOUT_SCENARIOS: readonly LayoutScenario[] = [
|
|
72
|
+
{
|
|
73
|
+
id: 'layout.ends-at-host-layout',
|
|
74
|
+
requirements: ['REQ-LAYOUT-002'],
|
|
75
|
+
assert() {
|
|
76
|
+
const { scheduler, session } = harness()
|
|
77
|
+
session.begin('el', BEFORE, AFTER)
|
|
78
|
+
scheduler.frames(400, 16.67)
|
|
79
|
+
const v = session.visualRect()
|
|
80
|
+
if (v === null) return fail('visualRect null after settle')
|
|
81
|
+
if (!rectClose(v, AFTER)) return fail(`landed at ${JSON.stringify(v)}, expected AFTER`)
|
|
82
|
+
if (!isIdentity(session.transform())) return fail('transform not identity at settle')
|
|
83
|
+
if (!session.isSettled()) return fail('not settled')
|
|
84
|
+
return pass('projection lands the element on its host-computed layout (identity at settle)')
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'layout.parent-relative',
|
|
89
|
+
requirements: ['REQ-LAYOUT-012'],
|
|
90
|
+
assert() {
|
|
91
|
+
const pBefore: Rect = { x: 0, y: 0, width: 100, height: 100 }
|
|
92
|
+
const pAfter: Rect = { x: 300, y: 400, width: 200, height: 50 }
|
|
93
|
+
const childLocal: Rect = { x: 40, y: 10, width: 20, height: 20 }
|
|
94
|
+
const fracX = childLocal.x / pAfter.width
|
|
95
|
+
for (const p of [0, 0.25, 0.5, 0.75, 1]) {
|
|
96
|
+
const parentVisual = applyTransformToRect(projectAtProgress(pBefore, pAfter, p), pAfter)
|
|
97
|
+
const child = composeParentRelative(pBefore, pAfter, childLocal, p)
|
|
98
|
+
if (Math.abs((child.x - parentVisual.x) / parentVisual.width - fracX) > 1e-6) {
|
|
99
|
+
return fail(`child fractional position drifted at p=${p}`)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const childAbsX = pAfter.x + childLocal.x
|
|
103
|
+
if (
|
|
104
|
+
Math.abs(composeParentRelative(pBefore, pAfter, childLocal, 0).x - childAbsX) < EPSILON_POS
|
|
105
|
+
) {
|
|
106
|
+
return fail('child stranded at its after origin at p=0')
|
|
107
|
+
}
|
|
108
|
+
return pass(
|
|
109
|
+
'a nested child tracks the parent projection frame, never stranded at the after origin',
|
|
110
|
+
)
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'layout.child-undistorted',
|
|
115
|
+
requirements: ['REQ-LAYOUT-013'],
|
|
116
|
+
assert() {
|
|
117
|
+
const gBefore: Rect = { x: 0, y: 0, width: 100, height: 100 }
|
|
118
|
+
const gAfter: Rect = { x: 0, y: 0, width: 200, height: 200 } // 2.0 parent scale
|
|
119
|
+
const intrinsic = 20
|
|
120
|
+
for (const p of [0, 0.25, 0.5, 0.75, 1]) {
|
|
121
|
+
const tp = projectAtProgress(gBefore, gAfter, p)
|
|
122
|
+
const rendered = intrinsic * (tp.scaleX * counterScale(tp).scaleX)
|
|
123
|
+
if (Math.abs(rendered - intrinsic) / intrinsic > EPSILON_SCALE) {
|
|
124
|
+
return fail(`counter-scaled child distorted at p=${p}: rendered ${rendered}`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return pass(
|
|
128
|
+
'a counter-scaled child stays within 1% of intrinsic size under a 2x parent scale',
|
|
129
|
+
)
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: 'layout.null-fails-closed',
|
|
134
|
+
requirements: ['REQ-LAYOUT-011', 'REQ-LAYOUT-019'],
|
|
135
|
+
assert() {
|
|
136
|
+
const { scheduler, session } = harness()
|
|
137
|
+
session.begin('el', null, AFTER)
|
|
138
|
+
if (session.measured()) return fail('measured despite a null read')
|
|
139
|
+
if (!isIdentity(session.transform())) return fail('transform not identity when unmeasured')
|
|
140
|
+
if (session.isSettled()) return fail('settled while unmeasured')
|
|
141
|
+
scheduler.frames(10, 16.67)
|
|
142
|
+
if (!isIdentity(session.transform())) return fail('unmeasured element moved on clock advance')
|
|
143
|
+
session.begin('el', BEFORE, AFTER) // next epoch, valid read
|
|
144
|
+
scheduler.frames(400, 16.67)
|
|
145
|
+
if (!session.isSettled()) return fail('did not resume + settle after a valid read')
|
|
146
|
+
return pass('a null read fails closed to identity + unsettled; a later valid read resumes')
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: 'layout.retarget-continuity',
|
|
151
|
+
requirements: ['REQ-LAYOUT-018'],
|
|
152
|
+
assert() {
|
|
153
|
+
const { scheduler, session } = harness()
|
|
154
|
+
session.begin('el', BEFORE, AFTER)
|
|
155
|
+
scheduler.frames(5, 16.67)
|
|
156
|
+
const before = session.visualRect()
|
|
157
|
+
if (before === null) return fail('null mid-flight')
|
|
158
|
+
const vxMinus = session.velocity().x
|
|
159
|
+
if (Math.abs(vxMinus) <= EPSILON_VEL) return fail('not moving mid-flight (vacuous)')
|
|
160
|
+
session.retarget(AFTER2)
|
|
161
|
+
const after = session.visualRect()
|
|
162
|
+
if (after === null) return fail('null after retarget')
|
|
163
|
+
if (Math.abs(after.x - before.x) >= EPSILON_POS) return fail('C0 broken — position snapped')
|
|
164
|
+
if (Math.abs(session.velocity().x - vxMinus) >= EPSILON_VEL)
|
|
165
|
+
return fail('C1 broken — velocity zeroed')
|
|
166
|
+
scheduler.frames(400, 16.67)
|
|
167
|
+
const v = session.visualRect()
|
|
168
|
+
if (v === null || !rectClose(v, AFTER2)) return fail('did not end at the new host layout')
|
|
169
|
+
return pass(
|
|
170
|
+
'retarget continues from the live position + velocity, ends at the new layout (C0/C1)',
|
|
171
|
+
)
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'layout.stale-identity',
|
|
176
|
+
requirements: ['REQ-LAYOUT-016'],
|
|
177
|
+
assert() {
|
|
178
|
+
const t = createMeasureTracker('el-1')
|
|
179
|
+
t.attach()
|
|
180
|
+
t.beginEpoch(1)
|
|
181
|
+
if (!t.measure({ measure: () => AFTER }).measured) return fail('did not measure a valid read')
|
|
182
|
+
if (!t.validate('el-1')) return fail('a matching live token was rejected')
|
|
183
|
+
if (t.validate('el-2')) return fail('a stale token was accepted')
|
|
184
|
+
if (t.current().measured) return fail('a stale token did not re-enter the measure loop')
|
|
185
|
+
return pass(
|
|
186
|
+
'a stale identity token fails closed and re-enters the measure loop (no stale delta)',
|
|
187
|
+
)
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 'layout.settle-gating',
|
|
192
|
+
requirements: ['REQ-LAYOUT-019'],
|
|
193
|
+
assert() {
|
|
194
|
+
const { scheduler, session } = harness()
|
|
195
|
+
session.begin('el', BEFORE, AFTER)
|
|
196
|
+
if (session.isSettled()) return fail('settled while a projection is in flight')
|
|
197
|
+
let transitions = 0
|
|
198
|
+
let prev = false
|
|
199
|
+
for (let i = 0; i < 400; i++) {
|
|
200
|
+
scheduler.frame(16.67)
|
|
201
|
+
const now = session.isSettled()
|
|
202
|
+
if (now && !prev) transitions++
|
|
203
|
+
prev = now
|
|
204
|
+
}
|
|
205
|
+
if (!session.isSettled()) return fail('stuck unsettled')
|
|
206
|
+
if (transitions !== 1) return fail(`settled ${transitions} times, expected exactly 1`)
|
|
207
|
+
return pass('unsettled while projecting; settles exactly once (measurement-availability)')
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: 'layout.layoutid-two-positions',
|
|
212
|
+
requirements: ['REQ-LAYOUT-003'],
|
|
213
|
+
assert() {
|
|
214
|
+
const { scheduler, session } = harness()
|
|
215
|
+
const posA: Rect = { x: 0, y: 0, width: 100, height: 40 }
|
|
216
|
+
const posB: Rect = { x: 250, y: 120, width: 100, height: 40 }
|
|
217
|
+
const posC: Rect = { x: 60, y: 300, width: 100, height: 40 }
|
|
218
|
+
session.begin('shared', posA, posB) // one mounted element animating between two positions
|
|
219
|
+
scheduler.frames(400, 16.67)
|
|
220
|
+
let v = session.visualRect()
|
|
221
|
+
if (v === null || !rectClose(v, posB)) return fail('did not animate to the second position')
|
|
222
|
+
session.begin('shared', posB, posC) // the same element re-laid-out to a third position
|
|
223
|
+
scheduler.frames(400, 16.67)
|
|
224
|
+
v = session.visualRect()
|
|
225
|
+
if (v === null || !rectClose(v, posC)) return fail('did not animate to the next position')
|
|
226
|
+
return pass(
|
|
227
|
+
'a single mounted element animates between successive layout positions (layoutId geometry)',
|
|
228
|
+
)
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
id: 'layout.cross-engine-parity',
|
|
233
|
+
requirements: ['REQ-CONFORM-002'],
|
|
234
|
+
assert() {
|
|
235
|
+
// External-evidence-only: layout geometry needs a real DOM engine (onLayout rects would be
|
|
236
|
+
// fiction under jsdom/native), so this row stays `unassertable` and is discharged by the named
|
|
237
|
+
// Playwright Chromium gate. Owner front-loaded for the banner's 3-line routed clamp.
|
|
238
|
+
return unassertable(
|
|
239
|
+
'Owner: the Playwright Chromium gate layout.e2e.ts (LAYOUT_PARITY_COUNTERPART). Layout ' +
|
|
240
|
+
'geometry needs a real DOM engine — external-evidence-only (SPEC-CONFORMANCE §3), ' +
|
|
241
|
+
'unassertable under jsdom/native, discharged by that named Chromium altitude.',
|
|
242
|
+
)
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
// Run one layout scenario against the reference projection engine, producing a ScenarioResult under the
|
|
248
|
+
// fake-host engine (M1). The session carries its own deterministic clock/graph — no adapter needed.
|
|
249
|
+
export function runLayoutScenario(scenario: LayoutScenario): ScenarioResult {
|
|
250
|
+
const outcome = scenario.assert()
|
|
251
|
+
return {
|
|
252
|
+
id: scenario.id,
|
|
253
|
+
engine: FAKE_ENGINE,
|
|
254
|
+
verdict: outcome.verdict,
|
|
255
|
+
readings: [],
|
|
256
|
+
detail: outcome.detail,
|
|
257
|
+
}
|
|
258
|
+
}
|