@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,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
|
+
}
|