@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -0
  4. package/package.json +33 -0
  5. package/src/adapter.ts +42 -0
  6. package/src/adapters/motion-dom.ts +96 -0
  7. package/src/adapters/native.ts +95 -0
  8. package/src/authoring.ts +78 -0
  9. package/src/comparator.ts +129 -0
  10. package/src/config.ts +22 -0
  11. package/src/declarations.ts +21 -0
  12. package/src/index.ts +144 -0
  13. package/src/oracle/attestation.ts +100 -0
  14. package/src/oracle/constants.ts +24 -0
  15. package/src/oracle/controls.ts +202 -0
  16. package/src/oracle/errors.ts +12 -0
  17. package/src/oracle/exportTrace.ts +134 -0
  18. package/src/oracle/index.ts +133 -0
  19. package/src/oracle/judge.ts +1374 -0
  20. package/src/oracle/presenter.ts +372 -0
  21. package/src/oracle/runRecord.ts +307 -0
  22. package/src/oracle/scenarios.ts +115 -0
  23. package/src/oracle/scripts/gesture.ts +218 -0
  24. package/src/oracle/serialize.ts +91 -0
  25. package/src/oracle/sweep.ts +155 -0
  26. package/src/oracle/types.ts +76 -0
  27. package/src/oracle/velocity.ts +44 -0
  28. package/src/parity.ts +136 -0
  29. package/src/runner.ts +168 -0
  30. package/src/scenario.ts +179 -0
  31. package/src/scenarios/appstore-choreography.ts +105 -0
  32. package/src/scenarios/component.ts +516 -0
  33. package/src/scenarios/driver.ts +322 -0
  34. package/src/scenarios/gesture.ts +363 -0
  35. package/src/scenarios/layout-identity.ts +264 -0
  36. package/src/scenarios/layout.ts +258 -0
  37. package/src/scenarios/presence.ts +302 -0
  38. package/src/scenarios/spring.ts +180 -0
  39. package/src/scenarios/value-types.ts +107 -0
  40. package/src/suite.ts +44 -0
@@ -0,0 +1,322 @@
1
+ // SPEC-NATIVE-DRIVER §4 Altitude-2 conformance scenarios (REQ-DRIVER-012/013/017/020). Authored ONCE,
2
+ // engine-agnostic: each asserts a driver design-law against the reference in-core Driver — the JS-crossing
3
+ // contract (one seeded command drives every frame), dt-cadence invariance, quiesce-before-teardown,
4
+ // trajectory parity vs the built generators (the driver forks no math), and prop-tier routing. At Milestone
5
+ // 1 the "engine" is the deterministic reference driver on a fake clock, no rendering; the IDENTICAL
6
+ // scenarios re-run against the native worklet + web drivers at Milestone 2 (one contract, many substrates).
7
+ // Fail-closed: a gate passes ONLY if the observed behavior holds; a driver that cannot run a gate would be
8
+ // `unassertable`, never a silent pass.
9
+
10
+ import {
11
+ type Driver,
12
+ type Transition,
13
+ constants,
14
+ createReferenceDriver,
15
+ resolveSpringGenerator,
16
+ selectReleaseTarget,
17
+ tierFor,
18
+ timingGenerator,
19
+ } from '@unrulysystems/native-motion-core'
20
+ import { toTimingLaneConfig } from '@unrulysystems/native-motion-core/internal-driver'
21
+ import type { ScenarioResult, Verdict } from '../runner'
22
+
23
+ // The engine label these deterministic-interior results are recorded under (M1). M2 adds 'native' / 'web'.
24
+ const FAKE_ENGINE = 'fake-host'
25
+
26
+ // Trajectory tolerance — the ratified CORE epsilon (single-sourced; never widened to force a pass).
27
+ const EPS = constants.EPS_TRAJ
28
+
29
+ const TWEEN: Transition = { type: 'tween', duration: 0.2 } // 200ms
30
+ const SPRING: Transition = { type: 'spring', stiffness: 180, damping: 20 }
31
+
32
+ // A single deterministic-interior gate outcome. `unassertable` distinguishes "could not run here" from a
33
+ // genuine pass/fail (three-valued accounting), reserved for M2 engines that lack a capability.
34
+ interface Outcome {
35
+ readonly verdict: Verdict
36
+ readonly detail: string
37
+ }
38
+ const pass = (detail: string): Outcome => ({ verdict: 'pass', detail })
39
+ const fail = (detail: string): Outcome => ({ verdict: 'fail', detail })
40
+
41
+ // Wrap a Driver so the JS→UI boundary is observable: a `command` is a JS-thread hop; a `step` is a UI-thread
42
+ // frame. REQ-DRIVER-013 requires O(1) hops per logical command and ZERO per frame — one seeded command must
43
+ // drive an entire animation. The counters make a per-frame hop (the forbidden anti-pattern) visible: a
44
+ // correct driver animates N frames while `commands()` stays at the number of logical commands, independent
45
+ // of N.
46
+ function countingDriver(inner: Driver): {
47
+ readonly driver: Driver
48
+ readonly commands: () => number
49
+ readonly steps: () => number
50
+ readonly writes: () => number
51
+ readonly liveReads: () => number
52
+ } {
53
+ let commands = 0
54
+ let steps = 0
55
+ let writes = 0
56
+ let liveReads = 0
57
+ const driver: Driver = {
58
+ register: (initial) => inner.register(initial),
59
+ command: (handle, command) => {
60
+ commands += 1
61
+ inner.command(handle, command)
62
+ },
63
+ step: (elapsedMs) => {
64
+ steps += 1
65
+ inner.step(elapsedMs)
66
+ },
67
+ committed: (handle) => inner.committed(handle),
68
+ setActive: (handle, active) => inner.setActive(handle, active),
69
+ isActive: (handle) => inner.isActive(handle),
70
+ // The FLAG 5a gesture lane is a boundary hop too: a write is O(1) per recognizer EVENT
71
+ // (REQ-DRIVER-024) and a live-read O(1) per gesture EDGE (REQ-DRIVER-025) — counted so a
72
+ // scenario can assert the crossing budget exactly like commands-vs-frames.
73
+ write: (handle, values, velocities) => {
74
+ writes += 1
75
+ inner.write(handle, values, velocities) // forward the gesture-phase velocity (r4 c14e15)
76
+ },
77
+ liveFor: (handle, key) => {
78
+ liveReads += 1
79
+ return inner.liveFor(handle, key)
80
+ },
81
+ }
82
+ return {
83
+ driver,
84
+ commands: () => commands,
85
+ steps: () => steps,
86
+ writes: () => writes,
87
+ liveReads: () => liveReads,
88
+ }
89
+ }
90
+
91
+ export interface DriverScenario {
92
+ readonly id: string
93
+ readonly requirements: readonly string[]
94
+ readonly assert: () => Outcome
95
+ }
96
+
97
+ export const DRIVER_SCENARIOS: readonly DriverScenario[] = [
98
+ {
99
+ // REQ-DRIVER-013 — ZERO per-frame JS crossings, O(1) hops per command. One seeded command must drive an
100
+ // entire multi-frame animation with no further commands.
101
+ id: 'DRV-js-crossing-zero-per-frame',
102
+ requirements: ['REQ-DRIVER-013'],
103
+ assert() {
104
+ const { driver, commands, steps } = countingDriver(createReferenceDriver())
105
+ const h = driver.register({ x: 0 })
106
+ driver.command(h, { kind: 'start', targets: { x: { to: 100, from: 0 } }, transition: TWEEN })
107
+ const FRAMES = 120 // ~2s at 60Hz, well past the 200ms tween
108
+ for (let i = 0; i < FRAMES; i++) driver.step(16.67)
109
+ if (commands() !== 1) {
110
+ return fail(`expected O(1) = 1 command, got ${commands()} (a per-frame JS crossing)`)
111
+ }
112
+ if (steps() !== FRAMES) return fail(`expected ${FRAMES} UI-thread steps, got ${steps()}`)
113
+ if (driver.committed(h)['x'] !== 100) {
114
+ return fail('animation did not reach target from a single seeded command')
115
+ }
116
+ return pass(`${FRAMES} frames driven by 1 command (0 per-frame JS crossings), reached target`)
117
+ },
118
+ },
119
+ {
120
+ // REQ-DRIVER-012 — end-to-end dt-cadence invariance: the same spec to the same total elapsed under a
121
+ // uniform vs a variable/duplicated frame cadence lands on the same value (time-integrated, not frame-
122
+ // counted; reproduces the gesture-speedup class the reference upstream regresses on).
123
+ id: 'DRV-dt-cadence-invariance',
124
+ requirements: ['REQ-DRIVER-012'],
125
+ assert() {
126
+ const uniform = createReferenceDriver()
127
+ const hu = uniform.register({ x: 0 })
128
+ uniform.command(hu, {
129
+ kind: 'start',
130
+ targets: { x: { to: 100, from: 0 } },
131
+ transition: SPRING,
132
+ })
133
+ for (let i = 0; i < 9; i++) uniform.step(16.67) // 150.03ms
134
+
135
+ const variable = createReferenceDriver()
136
+ const hv = variable.register({ x: 0 })
137
+ variable.command(hv, {
138
+ kind: 'start',
139
+ targets: { x: { to: 100, from: 0 } },
140
+ transition: SPRING,
141
+ })
142
+ for (const dt of [40, 0, 30, 20, 0, 30, 30.03]) variable.step(dt) // sums to 150.03ms, all ≤ ceiling
143
+
144
+ const du = uniform.committed(hu)['x'] as number
145
+ const dv = variable.committed(hv)['x'] as number
146
+ return Math.abs(du - dv) <= EPS
147
+ ? pass(`uniform ${du.toFixed(3)} ≈ variable ${dv.toFixed(3)} within ε=${EPS}`)
148
+ : fail(`dt-cadence divergence: uniform ${du} vs variable ${dv} (> ${EPS})`)
149
+ },
150
+ },
151
+ {
152
+ // REQ-DRIVER-020 — an element is active while animating and quiesces (isActive→false) once settled, so a
153
+ // consumer can safely tear it down only after quiesce. A never-quiescing element would strand resources.
154
+ id: 'DRV-quiesce-before-teardown',
155
+ requirements: ['REQ-DRIVER-020'],
156
+ assert() {
157
+ const d = createReferenceDriver()
158
+ const h = d.register({ x: 0 })
159
+ d.command(h, { kind: 'start', targets: { x: { to: 100, from: 0 } }, transition: TWEEN })
160
+ d.step(16.67)
161
+ if (!d.isActive(h)) return fail('element reported inactive while still animating')
162
+ for (let i = 0; i < 30; i++) d.step(16.67) // past the 200ms tween → settle
163
+ if (d.isActive(h))
164
+ return fail('element never quiesced after settling (teardown would strand it)')
165
+ return d.committed(h)['x'] === 100
166
+ ? pass('active while animating, quiesced at settle, committed == target')
167
+ : fail(`quiesced but committed ${String(d.committed(h)['x'])} != target 100`)
168
+ },
169
+ },
170
+ {
171
+ // REQ-DRIVER-003/012 — the driver forks no trajectory math: its committed value matches the built
172
+ // SPRING and TIMING generators sampled at the same cumulative elapsed, at every grid point.
173
+ id: 'DRV-trajectory-parity',
174
+ requirements: ['REQ-DRIVER-003', 'REQ-DRIVER-012'],
175
+ assert() {
176
+ const cases: ReadonlyArray<{ transition: Transition; ref: (t: number) => number }> = [
177
+ {
178
+ transition: SPRING,
179
+ ref: (() => {
180
+ const g = resolveSpringGenerator(100, { stiffness: 180, damping: 20 })({
181
+ from: 0,
182
+ velocity: 0,
183
+ })
184
+ return (t: number) => g.sample(t).value
185
+ })(),
186
+ },
187
+ {
188
+ transition: TWEEN,
189
+ // The reference resolves the SAME bag through the SAME ONE config builder the drivers
190
+ // read (`toTimingLaneConfig`) — never a hand-written config. Hand-deriving it re-asks
191
+ // the question at a different altitude: the pin applies its `easeOut` base at the
192
+ // bag->options seam (`animateMotionValue`), which a direct `timingGenerator({duration})`
193
+ // call bypasses, landing on the generator-level DEFAULT_EASE instead. That skew broke
194
+ // this gate when the base landed, reporting a driver fork that did not exist. Deriving
195
+ // from TWEEN also kills the duplicated 200ms literal and its manual seconds->ms crossing.
196
+ ref: (() => {
197
+ const g = timingGenerator(100, toTimingLaneConfig(TWEEN))({ from: 0, velocity: 0 })
198
+ return (t: number) => g.sample(t).value
199
+ })(),
200
+ },
201
+ ]
202
+ for (const { transition, ref } of cases) {
203
+ const d = createReferenceDriver()
204
+ const h = d.register({ x: 0 })
205
+ d.command(h, { kind: 'start', targets: { x: { to: 100, from: 0 } }, transition })
206
+ let cumulative = 0
207
+ for (let i = 0; i < 12; i++) {
208
+ d.step(16.67)
209
+ cumulative += 16.67
210
+ const got = d.committed(h)['x'] as number
211
+ const want = ref(cumulative)
212
+ if (Math.abs(got - want) > EPS) {
213
+ return fail(
214
+ `${transition.type} parity break at t=${cumulative.toFixed(1)}ms: driver ${got} vs generator ${want}`,
215
+ )
216
+ }
217
+ }
218
+ }
219
+ return pass(
220
+ 'driver trajectory matches the built spring + tween generators within ε (no forked math)',
221
+ )
222
+ },
223
+ },
224
+ {
225
+ // REQ-DRIVER-017 — every animatable prop routes to a substrate tier: non-layout → fast, layout-affecting
226
+ // → commit; a non-animatable prop fails loud (no silent mis-route).
227
+ id: 'DRV-tier-routing-fail-loud',
228
+ requirements: ['REQ-DRIVER-017'],
229
+ assert() {
230
+ if (tierFor('x') !== 'fast' || tierFor('opacity') !== 'fast') {
231
+ return fail('transform/opacity not routed to the fast (non-layout) tier')
232
+ }
233
+ if (tierFor('width') !== 'commit' || tierFor('height') !== 'commit') {
234
+ return fail('width/height not routed to the commit (layout-affecting) tier')
235
+ }
236
+ let threw = false
237
+ try {
238
+ tierFor('whileTap')
239
+ } catch {
240
+ threw = true
241
+ }
242
+ if (!threw) return fail('a non-animatable gesture prop did not fail loud (fail-open)')
243
+ return pass('non-layout → fast, layout → commit, and an untiered prop fails loud')
244
+ },
245
+ },
246
+ {
247
+ // REQ-DRIVER-026 (+ REQ-GESTURE-022) — Gesture↔declarative same-axis contention, the FLAG 5a
248
+ // parity oracle. Reproduces the full cross-engine sequence deterministically against the
249
+ // reference driver (the shared contract seam both substrates honor): the driver integrates the
250
+ // CONTENDING generator while the finger holds, and core's shared `selectReleaseTarget` projects
251
+ // the release. The load-bearing point is Motion's shared-value model — a mid-drag `animate` drifts
252
+ // the LIVE value past the snap midpoint, so the release origin (the live value, REQ-GESTURE-022),
253
+ // not the frozen finger, selects the far snap. The pre-fix "skip declarative on a held prop" froze
254
+ // the origin at the finger and selected the NEAR snap — the counterfactual asserted below.
255
+ id: 'DRV-contention-release-snap',
256
+ requirements: ['REQ-DRIVER-026', 'REQ-GESTURE-022'],
257
+ assert() {
258
+ const SNAP = { points: [0, 240] } as const // midpoint 120: origin < 120 ⇒ 0, > 120 ⇒ 240
259
+ const FINGER = 110 // dragged-to position, held (release velocity 0)
260
+ const d = createReferenceDriver()
261
+ const h = d.register({ x: 0 })
262
+ // Grab + drag to the finger position, held (a write is O(1) per recognizer event, velocity 0).
263
+ d.write(h, { x: FINGER }, { x: 0 })
264
+ // While held, `animate.x = 200` — a declarative retarget that CONTENDS (never skipped).
265
+ d.command(h, { kind: 'retarget', targets: { x: 200 }, transition: SPRING })
266
+ // Dwell: the contending generator drifts the live value from 110 toward 200, PAST the 120
267
+ // midpoint (bounded loop — a scenario read, not the frame path).
268
+ let dwell = 0
269
+ while (d.committed(h)['x']! <= 130 && dwell < 120) {
270
+ d.step(16.67)
271
+ dwell += 1
272
+ }
273
+ const releaseOrigin = d.liveFor(h, 'x').value // the drifted LIVE value (REQ-GESTURE-022 origin)
274
+ if (releaseOrigin <= 120) {
275
+ return fail(`dwell did not drift past the midpoint: live=${releaseOrigin.toFixed(2)} ≤ 120`)
276
+ }
277
+ // Non-vacuity: the frozen finger (pre-fix origin) selects the NEAR snap; the drifted live value
278
+ // selects the FAR snap. The contention drift is exactly what flips 0 → 240.
279
+ if (selectReleaseTarget(FINGER, 0, undefined, SNAP) !== 0) {
280
+ return fail('counterfactual broken: a finger-frozen origin (110) must select snap 0')
281
+ }
282
+ const target = selectReleaseTarget(releaseOrigin, 0, undefined, SNAP)
283
+ if (target !== 240) {
284
+ return fail(
285
+ `drifted origin ${releaseOrigin.toFixed(2)} selected snap ${target}, expected 240`,
286
+ )
287
+ }
288
+ // Release: seeded start ends the hold and settles to the selected snap (velocity 0).
289
+ d.command(h, {
290
+ kind: 'start',
291
+ targets: { x: { to: target, from: releaseOrigin, velocity: 0 } },
292
+ transition: SPRING,
293
+ releasesGesture: true,
294
+ })
295
+ let settle = 0
296
+ while (d.isActive(h) && settle < 600) {
297
+ d.step(16.67)
298
+ settle += 1
299
+ }
300
+ const settled = d.committed(h)['x']!
301
+ return Math.abs(settled - 240) <= EPS
302
+ ? pass(
303
+ `drag→110, animate→200 drifted live to ${releaseOrigin.toFixed(2)} (past 120), ` +
304
+ `release v0 settled at ${settled.toFixed(3)} (snap 240); finger-frozen counterfactual → 0`,
305
+ )
306
+ : fail(`settled at ${settled.toFixed(3)}, expected snap 240 (±${EPS})`)
307
+ },
308
+ },
309
+ ]
310
+
311
+ // Run one driver scenario against the reference driver, producing a ScenarioResult under the fake-host
312
+ // engine (M1). No adapter/clock needed — the reference driver carries its own deterministic integration.
313
+ export function runDriverScenario(scenario: DriverScenario): ScenarioResult {
314
+ const outcome = scenario.assert()
315
+ return {
316
+ id: scenario.id,
317
+ engine: FAKE_ENGINE,
318
+ verdict: outcome.verdict,
319
+ readings: [],
320
+ detail: outcome.detail,
321
+ }
322
+ }
@@ -0,0 +1,363 @@
1
+ // SPEC-GESTURE §4(b) Altitude-2 conformance scenarios (REQ-GESTURE-010..018 / G-INV-*). Authored ONCE,
2
+ // engine-agnostic: each drives a recognizer sample stream through the reference gesture session on a fake
3
+ // host and asserts a lifecycle law — grab continuity, mid-flight re-grab, velocity handoff, the release
4
+ // seam, project-then-snap selection + monotonicity, hard clamp, rubber-band + spring-back, settle
5
+ // accounting, cancelation, and hold-then-release. At Milestone 1 the "engine" is the deterministic session
6
+ // under an injectable clock + MotionGraph, no rendering; the IDENTICAL scenarios re-run against motion/react
7
+ // (web) + the native runtime at Milestone 2. `gesture.cross-engine-parity` is scalar-comparable → additively
8
+ // OWNED in-suite by parity.ts (native core vs an injected web engine); routed here only when no web engine is
9
+ // injected (this fake host, the on-device banner), recorded with its owner named — never a silent pass.
10
+
11
+ import {
12
+ ManualClock,
13
+ ManualScheduler,
14
+ type MotionGraph,
15
+ createGestureHandoffSession,
16
+ createGestureSession,
17
+ createMotionGraph,
18
+ resolveViewportConstraints,
19
+ } from '@unrulysystems/native-motion-core'
20
+ import type { ScenarioResult, Verdict } from '../runner'
21
+
22
+ const FAKE_ENGINE = 'fake-host'
23
+ const EPS_X = 0.5 // px
24
+ const EPS_V = 2 // px/s
25
+
26
+ interface Outcome {
27
+ readonly verdict: Verdict
28
+ readonly detail: string
29
+ }
30
+ const pass = (detail: string): Outcome => ({ verdict: 'pass', detail })
31
+ const fail = (detail: string): Outcome => ({ verdict: 'fail', detail })
32
+ const unassertable = (detail: string): Outcome => ({ verdict: 'unassertable', detail })
33
+
34
+ type SessionOpts = Parameters<typeof createGestureSession>[0]
35
+
36
+ function harness(opts: Omit<SessionOpts, 'graph'>): {
37
+ scheduler: ManualScheduler
38
+ graph: MotionGraph
39
+ session: ReturnType<typeof createGestureSession>
40
+ } {
41
+ const clock = new ManualClock(0)
42
+ const scheduler = new ManualScheduler(clock)
43
+ const graph = createMotionGraph({ clock, scheduler })
44
+ const session = createGestureSession({ graph, ...opts })
45
+ return { scheduler, graph, session }
46
+ }
47
+
48
+ export interface GestureScenario {
49
+ readonly id: string
50
+ readonly requirements: readonly string[]
51
+ readonly assert: () => Outcome
52
+ }
53
+
54
+ export const GESTURE_SCENARIOS: readonly GestureScenario[] = [
55
+ {
56
+ id: 'gesture.grab-continuity',
57
+ requirements: ['REQ-GESTURE-011'],
58
+ assert() {
59
+ const { session } = harness({ initial: 100 })
60
+ session.begin(20) // non-zero activation dead zone
61
+ session.active({ t: 16, translation: 20, velocity: 0 })
62
+ if (Math.abs(session.value() - 100) >= EPS_X) return fail('first active frame jumped from P0')
63
+ session.active({ t: 32, translation: 70, velocity: 900 }) // +50 since activation
64
+ if (Math.abs(session.value() - 150) > EPS_X)
65
+ return fail(`value ${session.value()} != P0+D (150)`)
66
+ return pass('grab seeds from the delta since activation (C0 across the dead zone)')
67
+ },
68
+ },
69
+ {
70
+ id: 'gesture.grab-during-animation',
71
+ requirements: ['REQ-GESTURE-012'],
72
+ assert() {
73
+ const { scheduler, graph, session } = harness({ snap: { points: [0, 300, 600] } })
74
+ session.begin(0)
75
+ session.active({ t: 16, translation: 200, velocity: 2000 })
76
+ session.end({ t: 32, translation: 200, velocity: 2000 })
77
+ if (graph.activeAnimationCount() !== 1) return fail('release animation not counted')
78
+ scheduler.frames(3, 16.67)
79
+ const vi = session.value()
80
+ session.begin(1000) // re-grab mid-flight
81
+ if (graph.activeAnimationCount() !== 0) return fail('animation not stopped at re-grab')
82
+ if (Math.abs(session.value() - vi) >= EPS_X) return fail('re-grab jumped the value')
83
+ session.active({ t: 64, translation: 1050, velocity: 0 }) // +50 since activation
84
+ if (Math.abs(session.value() - (vi + 50)) > EPS_X)
85
+ return fail('did not continue from the live value')
86
+ return pass('mid-flight re-grab stops the animation and continues from the live value')
87
+ },
88
+ },
89
+ {
90
+ id: 'gesture.velocity-handoff',
91
+ requirements: ['REQ-GESTURE-010'],
92
+ assert() {
93
+ const { scheduler, session } = harness({ initial: 0, snap: { points: [0, 1600, 3200] } })
94
+ session.begin(0)
95
+ session.active({ t: 16, translation: 0, velocity: 2000 })
96
+ session.end({ t: 32, translation: 0, velocity: 2000 })
97
+ if (session.handoffVelocity() !== 2000)
98
+ return fail(`handoff ${session.handoffVelocity()} != 2000 (unscaled)`)
99
+ const before = session.value()
100
+ scheduler.frame(16.67)
101
+ if (session.value() <= before) return fail('value did not move in the +velocity direction')
102
+ return pass('the platform velocity is handed off UNSCALED with sign')
103
+ },
104
+ },
105
+ {
106
+ id: 'gesture.release-seam',
107
+ requirements: ['REQ-GESTURE-013'],
108
+ assert() {
109
+ const { session } = harness({ initial: 0, snap: { points: [0, 1600] } })
110
+ session.begin(0)
111
+ session.active({ t: 16, translation: 30, velocity: 1800 })
112
+ session.active({ t: 32, translation: 60, velocity: 1800 })
113
+ const xMinus = session.value()
114
+ session.end({ t: 48, translation: 60, velocity: 1800 })
115
+ if (Math.abs(session.value() - xMinus) >= EPS_X)
116
+ return fail('C0 broken — position jumped at release')
117
+ if (Math.abs((session.handoffVelocity() as number) - 1800) > EPS_V)
118
+ return fail('C1 broken — handoff != platform velocity')
119
+ return pass('release is a zero-discontinuity seam (C0 position, C1 velocity)')
120
+ },
121
+ },
122
+ {
123
+ id: 'gesture.projected-snap',
124
+ requirements: ['REQ-GESTURE-017'],
125
+ assert() {
126
+ const { scheduler, session } = harness({ initial: 0, snap: { points: [0, 300, 600] } })
127
+ session.begin(0)
128
+ session.active({ t: 16, translation: 50, velocity: 400 }) // at 50, nearest snap is 0
129
+ session.end({ t: 32, translation: 50, velocity: 400 }) // project(50,400)=370 ⇒ snap 300
130
+ scheduler.frames(300, 16.67)
131
+ if (Math.abs(session.value() - 300) > 1)
132
+ return fail(`landed at ${session.value()}, expected the projected snap 300`)
133
+ return pass(
134
+ 'project-then-snap selects the projected snap (300), not the nearest-to-release (0)',
135
+ )
136
+ },
137
+ },
138
+ {
139
+ id: 'gesture.projection-monotonicity',
140
+ requirements: ['REQ-GESTURE-017'],
141
+ assert() {
142
+ const points = [0, 300, 600, 900]
143
+ let prevIndex = -1
144
+ for (const v of [0, 200, 500, 1000, 1500, 2500]) {
145
+ const { scheduler, session } = harness({ initial: 0, snap: { points } })
146
+ session.begin(0)
147
+ session.active({ t: 16, translation: 50, velocity: v })
148
+ session.end({ t: 32, translation: 50, velocity: v })
149
+ scheduler.frames(400, 16.67)
150
+ const index = points.indexOf(Math.round(session.value() / 300) * 300)
151
+ if (index < prevIndex) return fail(`snap index decreased at v=${v} (non-monotone)`)
152
+ prevIndex = index
153
+ }
154
+ if (prevIndex <= 0) return fail('snap index never advanced (vacuous)')
155
+ return pass('the selected snap index is monotone non-decreasing in |v|')
156
+ },
157
+ },
158
+ {
159
+ id: 'gesture.hard-clamp',
160
+ requirements: ['REQ-GESTURE-015'],
161
+ assert() {
162
+ const { session } = harness({ initial: 0, constraints: { min: 0, max: 100 } })
163
+ session.begin(0)
164
+ session.active({ t: 16, translation: 150, velocity: 0 })
165
+ if (session.value() !== 100) return fail('not clamped at max')
166
+ session.active({ t: 32, translation: -50, velocity: 0 })
167
+ if (session.value() !== 0) return fail('not clamped at min')
168
+ return pass('hard clamp holds min <= value <= max on every active frame')
169
+ },
170
+ },
171
+ {
172
+ id: 'gesture.one-sided-open-constraints',
173
+ requirements: ['REQ-API-024', 'REQ-GESTURE-015', 'REQ-GESTURE-023'],
174
+ assert() {
175
+ // REQ-API-024 shipping slice: open upper (max=+∞) clamps only min live; open-side release
176
+ // settles at the free projected landing (finite — never ±Infinity / non-settle).
177
+ const { scheduler, session } = harness({
178
+ initial: 0,
179
+ constraints: { min: 0, max: Number.POSITIVE_INFINITY, elastic: 0 },
180
+ })
181
+ session.begin(0)
182
+ session.active({ t: 16, translation: -40, velocity: 0 })
183
+ if (session.value() !== 0) return fail(`finite side not clamped: ${session.value()}`)
184
+ session.active({ t: 32, translation: 40, velocity: 500 })
185
+ if (session.value() !== 40) return fail(`open upper wrongly clamped live: ${session.value()}`)
186
+ session.end({ t: 48, translation: 40, velocity: 500 })
187
+ // ideal = 40 + 0.8*500 = 440
188
+ scheduler.frames(400, 16.67)
189
+ if (!Number.isFinite(session.value()))
190
+ return fail(`release endpoint non-finite: ${session.value()}`)
191
+ if (Math.abs(session.value() - 440) > 1)
192
+ return fail(`open-side release landed at ${session.value()}, expected ~440`)
193
+ if (!session.isSettled()) return fail('open-side release did not settle')
194
+ return pass('one-sided open bounds clamp the finite side and free-release to a finite ideal')
195
+ },
196
+ },
197
+ {
198
+ id: 'gesture.rubber-band',
199
+ requirements: ['REQ-GESTURE-016'],
200
+ assert() {
201
+ const { scheduler, session } = harness({
202
+ initial: 0,
203
+ constraints: { min: 0, max: 100, elastic: 0.5 },
204
+ })
205
+ session.begin(0)
206
+ session.active({ t: 16, translation: 160, velocity: 1000 }) // 100 + 0.5*60 = 130
207
+ if (Math.abs(session.value() - 130) > EPS_X)
208
+ return fail(`rubber-band overshoot wrong: ${session.value()}`)
209
+ session.end({ t: 32, translation: 160, velocity: 1000 })
210
+ scheduler.frames(400, 16.67)
211
+ if (Math.abs(session.value() - 100) > 1) return fail('did not spring back to the bound')
212
+ return pass('elastic overshoot = e·excess, springs back to settle exactly at the bound')
213
+ },
214
+ },
215
+ {
216
+ id: 'gesture.settle-accounting',
217
+ requirements: ['REQ-GESTURE-003'],
218
+ assert() {
219
+ const { scheduler, session } = harness({ initial: 0, snap: { points: [0, 300] } })
220
+ session.begin(0)
221
+ session.active({ t: 16, translation: 100, velocity: 500 })
222
+ if (session.isSettled()) return fail('settled while a finger is down (G-INV-7)')
223
+ session.end({ t: 32, translation: 100, velocity: 500 })
224
+ let transitions = 0
225
+ let prev = false
226
+ for (let i = 0; i < 400; i++) {
227
+ scheduler.frame(16.67)
228
+ const now = session.isSettled()
229
+ if (now && !prev) transitions++
230
+ prev = now
231
+ }
232
+ if (!session.isSettled()) return fail('stuck unsettled')
233
+ if (transitions !== 1) return fail(`settled ${transitions} times, expected exactly 1`)
234
+ return pass('unsettled while down + in flight; settles exactly once')
235
+ },
236
+ },
237
+ {
238
+ id: 'gesture.cancelation',
239
+ requirements: ['REQ-GESTURE-018'],
240
+ assert() {
241
+ const { scheduler, session } = harness({ initial: 0, snap: { points: [0, 300] } })
242
+ session.begin(0) // pre-grab value 0
243
+ session.active({ t: 16, translation: 100, velocity: 500 })
244
+ session.cancel()
245
+ scheduler.frames(400, 16.67)
246
+ if (Math.abs(session.value()) > 1) return fail('cancel did not return to the pre-grab value')
247
+ if (!session.isSettled()) return fail('cancel left the graph unsettled')
248
+ return pass('cancel returns to the pre-grab value and settles')
249
+ },
250
+ },
251
+ {
252
+ id: 'gesture.hold-then-release',
253
+ requirements: ['REQ-GESTURE-014'],
254
+ assert() {
255
+ const { scheduler, session } = harness({ initial: 0, snap: { points: [0, 300, 600] } })
256
+ session.begin(0)
257
+ session.active({ t: 16, translation: 250, velocity: 500 }) // dragged to 250 (nearest snap 300)
258
+ session.end({ t: 200, translation: 250, velocity: 0 }) // held ⇒ ~0 velocity ⇒ ideal = 250
259
+ scheduler.frames(300, 16.67)
260
+ if (Math.abs(session.value() - 300) > 1)
261
+ return fail(`landed at ${session.value()}, expected nearest snap 300`)
262
+ return pass('a ~0-velocity release snaps to the nearest point (project = origin)')
263
+ },
264
+ },
265
+ {
266
+ id: 'gesture.both-axes-per-axis-channels',
267
+ requirements: ['REQ-GESTURE-024', 'REQ-API-041'],
268
+ assert() {
269
+ // R15 D1: one recognizer stream drives one independent channel PER AXIS — a bounded axis
270
+ // clamps and springs to its bound while the free axis projects its own landing. The wiring
271
+ // composes two of these per-axis sessions over one Pan stream (dragGestureWiring.test.ts
272
+ // pins that composition); this scenario pins the per-axis SEMANTIC they compose.
273
+ const xHarness = harness({ initial: 0, constraints: { min: 0, max: 100, elastic: 0 } })
274
+ const yHarness = harness({ initial: 0 })
275
+ xHarness.session.begin(0)
276
+ yHarness.session.begin(0)
277
+ xHarness.session.active({ t: 16, translation: 250, velocity: 900 })
278
+ yHarness.session.active({ t: 16, translation: 250, velocity: 900 })
279
+ if (xHarness.session.value() !== 100)
280
+ return fail(`bounded axis not clamped live: ${xHarness.session.value()}`)
281
+ if (yHarness.session.value() !== 250)
282
+ return fail(`free axis wrongly clamped: ${yHarness.session.value()}`)
283
+ xHarness.session.end({ t: 32, translation: 250, velocity: 900 })
284
+ yHarness.session.end({ t: 32, translation: 250, velocity: 900 })
285
+ xHarness.scheduler.frames(400, 16.67)
286
+ yHarness.scheduler.frames(400, 16.67)
287
+ // Bounded axis: past the max at release → spring-back settles exactly at 100
288
+ // (REQ-GESTURE-016). Free axis: projected landing 250 + 0.8·900 = 970 (REQ-GESTURE-023).
289
+ if (Math.abs(xHarness.session.value() - 100) > 1)
290
+ return fail(`bounded axis settled at ${xHarness.session.value()}, expected 100`)
291
+ if (Math.abs(yHarness.session.value() - 970) > 1)
292
+ return fail(`free axis landed at ${yHarness.session.value()}, expected 970`)
293
+ return pass(
294
+ 'per-axis channels: the bounded axis clamps + springs to its bound while the free axis projects its own landing',
295
+ )
296
+ },
297
+ },
298
+ {
299
+ id: 'gesture.ref-constraints-viewport',
300
+ requirements: ['REQ-GESTURE-028', 'REQ-API-042'],
301
+ assert() {
302
+ // R15 D2: the element-ref channel computes the pin's viewport constraints from the
303
+ // measured boxes — bounds relative to the draggable's current layout, flipped per axis
304
+ // when the container is shorter than the child — then the session clamps with them.
305
+ const xBounds = resolveViewportConstraints({ min: 100, max: 180 }, { min: 50, max: 250 })
306
+ if (xBounds.min !== -50 || xBounds.max !== 70)
307
+ return fail(`bounds ${xBounds.min}..${xBounds.max} != -50..70`)
308
+ const flipped = resolveViewportConstraints({ min: 0, max: 400 }, { min: 50, max: 250 })
309
+ if (flipped.min !== -150 || flipped.max !== 50)
310
+ return fail(`flipped ${flipped.min}..${flipped.max} != -150..50`)
311
+ // The per-grab override clamps THIS grab with the measured bounds. Native-motion
312
+ // re-measures at every grab, so the runtime carries them on the DRIVER-INTEGRATED
313
+ // handoff session (the wiring's session) — a pure event fold, no graph/scheduler.
314
+ const session = createGestureHandoffSession({})
315
+ session.begin(0, { value: 100 }, { constraints: { min: -50, max: 70, elastic: 0 } })
316
+ session.active(30) // raw 130 → hard max clamp
317
+ if (session.value() !== 70) return fail(`no max clamp live: ${session.value()}`)
318
+ session.active(-160) // raw -60 → hard min clamp
319
+ if (session.value() !== -50) return fail(`no min clamp live: ${session.value()}`)
320
+ session.end(0, -50)
321
+ // Elastic: the finger rests past the bound rubber-banded; the release hands off the bound
322
+ // as its target (REQ-GESTURE-016's spring-back seam — the integrator owns the spring, so
323
+ // the law asserts on the handed-off target).
324
+ session.begin(0, { value: 100 }, { constraints: { min: -50, max: 70, elastic: 0.35 } })
325
+ session.active(-160) // raw -60 → -50 − 0.35·10 = -53.5
326
+ const past = session.value()
327
+ if (Math.abs(past - -53.5) > EPS_X) return fail(`no rubber-band past the min: ${past}`)
328
+ const handoff = session.end(0, past)
329
+ if (handoff.target !== -50) return fail(`release target ${handoff.target} != the bound -50`)
330
+ return pass(
331
+ 'viewport constraints: pin bounds computed from measured boxes (incl. the smaller-container flip), the per-grab override clamps live, and a release past the bound hands off the bound as its target',
332
+ )
333
+ },
334
+ },
335
+ {
336
+ id: 'gesture.cross-engine-parity',
337
+ requirements: ['REQ-CONFORM-002'],
338
+ assert() {
339
+ // G-INV-10 parity is scalar-comparable → additively OWNED in-suite; the REAL motion/react web
340
+ // engine is constructed and executed in native-motion-web/src/gestureParity.test.ts (parity.ts is
341
+ // the comparator seam it drives). This fake host injects no web engine, so the row is routed here.
342
+ return unassertable(
343
+ 'Owner: packages/native-motion-web/src/gestureParity.test.ts — it constructs the REAL ' +
344
+ "motion/react web engine (motion's inertia) and runs runGestureParityScenario (the " +
345
+ 'src/parity.ts comparator) vs the native-core reference, GESTURE_PARITY release-endpoint ' +
346
+ 'band; owned in-suite there. Routed here: this fake host injects no web engine.',
347
+ )
348
+ },
349
+ },
350
+ ]
351
+
352
+ // Run one gesture scenario against the reference session, producing a ScenarioResult under the fake-host
353
+ // engine (M1). The session carries its own deterministic clock/graph — no adapter needed.
354
+ export function runGestureScenario(scenario: GestureScenario): ScenarioResult {
355
+ const outcome = scenario.assert()
356
+ return {
357
+ id: scenario.id,
358
+ engine: FAKE_ENGINE,
359
+ verdict: outcome.verdict,
360
+ readings: [],
361
+ detail: outcome.detail,
362
+ }
363
+ }