@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,516 @@
1
+ // SPEC-COMPONENT §4 Altitude-2 conformance scenarios (REQ-API-001/002/005/006). Authored ONCE,
2
+ // engine-agnostic: each asserts the declarative component boundary contract — first-paint from
3
+ // `initial`/`animate`, the `initial={false}` no-entrance sentinel, partial-target hold under retarget, and
4
+ // fail-loud validation of unsupported / unimplemented properties. At Milestone 1 the "engine" is the
5
+ // deterministic interior — the pure `validateTarget`/`resolveTarget`/`resolveStartValue` core functions
6
+ // against a fake host, with no rendering. The IDENTICAL scenarios re-run against the real native runtime
7
+ // and the motion/react web shim at Milestone 2 (one contract, two engines). Fail-closed: a validation
8
+ // scenario passes ONLY if the descriptive error is raised (no throw = FAIL) and its message content
9
+ // matches; a scenario a host cannot execute is `unassertable`, never a silent pass.
10
+
11
+ import {
12
+ type HostCapabilities,
13
+ InvalidTargetError,
14
+ InvalidTransitionError,
15
+ type ResolvedTargetValue,
16
+ type ResolvedValue,
17
+ type Target,
18
+ type Transition,
19
+ createReferenceDriver,
20
+ hostCapabilities,
21
+ resolveInitialOverlay,
22
+ resolveStartValue,
23
+ resolveTarget,
24
+ resolveVariantDefinition,
25
+ validateTarget,
26
+ validateTransition,
27
+ } from '@unrulysystems/native-motion-core'
28
+ import type { ScenarioResult, Verdict } from '../runner'
29
+
30
+ // The M1 fake hosts, derived from the registry. web declares universal + web-only + pointer-gated; native
31
+ // declares universal (+ the currently-empty native-extension category).
32
+ const WEB: HostCapabilities = hostCapabilities('web', ['universal', 'web-only', 'pointer-gated'])
33
+ const NATIVE: HostCapabilities = hostCapabilities('native', ['universal', 'native-extension'])
34
+
35
+ // The engine label these deterministic-interior results are recorded under (M1). M2 adds 'native' / 'web'.
36
+ const FAKE_ENGINE = 'fake-host'
37
+
38
+ // Model the value shape a Target carries as a plain value record for the pure merge functions. A target
39
+ // value may be a keyframe array (REQ-API-033), so the honest element type is ResolvedTargetValue.
40
+ function asValues(target: Target): Record<string, ResolvedTargetValue> {
41
+ return { ...target } as Record<string, ResolvedTargetValue>
42
+ }
43
+
44
+ // The first painted frame's value set, computed from the declarative props via the core primitives — the
45
+ // contract the M2 real driver must reproduce on-device (REQ-API-010/011). Each covered key's base is
46
+ // resolved from the `style` record else the documented host base (resolveStartValue); `initial===false`
47
+ // starts the element at the resolved `animate` with no entrance; an `initial` Target paints as the first
48
+ // frame; `initial` omitted paints the resolved base.
49
+ function firstFrameValues(
50
+ props: { initial?: Target | false; animate?: Target },
51
+ style?: Record<string, ResolvedValue>,
52
+ ): Record<string, ResolvedTargetValue> {
53
+ const animate = props.animate ?? {}
54
+ const keys = new Set<string>(Object.keys(animate))
55
+ if (props.initial !== undefined && props.initial !== false) {
56
+ for (const k of Object.keys(props.initial)) keys.add(k)
57
+ }
58
+ const resolution = style !== undefined ? { style } : undefined
59
+ const base: Record<string, ResolvedValue> = {}
60
+ for (const k of keys) base[k] = resolveStartValue(k, resolution)
61
+
62
+ if (props.initial === false) return resolveTarget(base, asValues(animate)) // start at animate, no entrance
63
+ if (props.initial !== undefined) return resolveTarget(base, asValues(props.initial)) // paint initial first
64
+ return base // initial omitted → resolved style/host base (REQ-API-010 default)
65
+ }
66
+
67
+ // A single deterministic-interior assertion outcome. `unassertable` distinguishes "could not run here"
68
+ // from a genuine pass/fail (three-valued accounting).
69
+ interface Outcome {
70
+ readonly verdict: Verdict
71
+ readonly detail: string
72
+ }
73
+
74
+ function pass(detail: string): Outcome {
75
+ return { verdict: 'pass', detail }
76
+ }
77
+ function fail(detail: string): Outcome {
78
+ return { verdict: 'fail', detail }
79
+ }
80
+ function unassertable(detail: string): Outcome {
81
+ return { verdict: 'unassertable', detail }
82
+ }
83
+
84
+ export interface ComponentScenario {
85
+ readonly id: string
86
+ readonly requirements: readonly string[]
87
+ // Assert the boundary contract against the deterministic interior; pure, no rendering.
88
+ readonly assert: () => Outcome
89
+ }
90
+
91
+ export const COMPONENT_SCENARIOS: readonly ComponentScenario[] = [
92
+ {
93
+ // REQ-API-001/010 — the first painted frame is `initial`, not `animate`.
94
+ id: 'CMP-initial-paint',
95
+ requirements: ['REQ-API-001', 'REQ-API-010'],
96
+ assert() {
97
+ const frame = firstFrameValues({ initial: { opacity: 0 }, animate: { opacity: 1 } })
98
+ return frame['opacity'] === 0
99
+ ? pass('first frame paints initial opacity 0')
100
+ : fail(`expected first-frame opacity 0, got ${String(frame['opacity'])}`)
101
+ },
102
+ },
103
+ {
104
+ // REQ-API-010 — `initial={false}` starts at the resolved `animate` with no entrance.
105
+ id: 'CMP-initial-false',
106
+ requirements: ['REQ-API-010'],
107
+ assert() {
108
+ const frame = firstFrameValues({ initial: false, animate: { opacity: 1 } })
109
+ return frame['opacity'] === 1
110
+ ? pass('initial={false} paints the resolved animate opacity 1 (no entrance)')
111
+ : fail(
112
+ `expected first-frame opacity 1 for initial={false}, got ${String(frame['opacity'])}`,
113
+ )
114
+ },
115
+ },
116
+ {
117
+ // REQ-API-002/012 — a retarget holds every unlisted property; a target never resets.
118
+ id: 'CMP-partial-hold',
119
+ requirements: ['REQ-API-002'],
120
+ assert() {
121
+ const live = resolveTarget({ x: 0, y: 0 }, asValues({ x: 100 }))
122
+ return live['y'] === 0 && live['x'] === 100
123
+ ? pass('retarget {x:100} holds y at 0')
124
+ : fail(`expected {x:100,y:0}, got ${JSON.stringify(live)}`)
125
+ },
126
+ },
127
+ {
128
+ // REQ-API-011 — a first-seen property's base resolves from the resolved style, else host base.
129
+ id: 'CMP-base-resolution',
130
+ requirements: ['REQ-API-002'],
131
+ assert() {
132
+ const fromStyle = firstFrameValues({ animate: { x: 100 } }, { x: 40 })
133
+ const fromHostBase = firstFrameValues({ animate: { x: 100 } })
134
+ return fromStyle['x'] === 40 && fromHostBase['x'] === 0
135
+ ? pass('first-seen base reads style (40) then host base (0)')
136
+ : fail(
137
+ `expected style 40 / host-base 0, got ${String(fromStyle['x'])} / ${String(fromHostBase['x'])}`,
138
+ )
139
+ },
140
+ },
141
+ {
142
+ // REQ-API-005/013 — an unsupported (off-host) property fails loud with the descriptive error; no
143
+ // throw is a FAIL (fail-closed), and the message content is asserted.
144
+ id: 'CMP-unsupported-fail-loud',
145
+ requirements: ['REQ-API-005', 'REQ-API-013'],
146
+ assert() {
147
+ try {
148
+ validateTarget({ boxShadow: '0 1px 4px rgba(0,0,0,0.2)' }, NATIVE)
149
+ return fail('expected a throw for an off-host property; none raised (fail-open)')
150
+ } catch (err) {
151
+ const named =
152
+ err instanceof InvalidTargetError &&
153
+ /boxShadow/.test(err.message) &&
154
+ /web/.test(err.message)
155
+ return named
156
+ ? pass(
157
+ 'off-host boxShadow raised InvalidTargetError naming the key + supporting platform',
158
+ )
159
+ : fail(`threw but wrong class/message: ${(err as Error).message}`)
160
+ }
161
+ },
162
+ },
163
+ {
164
+ // REQ-API-032 laws (a)/(c) — R7: initial labels overlay IN ORDER into one first-paint
165
+ // state (later label wins shared keys; transitions discarded); a local miss is the
166
+ // pinned NO-OP, never an error.
167
+ id: 'CMP-R7-initial-label-overlay',
168
+ requirements: ['REQ-API-032', 'REQ-API-010'],
169
+ assert() {
170
+ const dictionary = {
171
+ a: { opacity: 0.2, transition: { type: 'spring', stiffness: 500 } },
172
+ b: { opacity: 0.6, scale: 2 },
173
+ } as const
174
+ const overlay = resolveInitialOverlay(['a', 'b', 'missing'], dictionary)
175
+ const ordered =
176
+ (overlay as { opacity?: number }).opacity === 0.6 &&
177
+ (overlay as { scale?: number }).scale === 2 &&
178
+ !('transition' in overlay)
179
+ return ordered
180
+ ? pass('ordered overlay {opacity:0.6, scale:2}; miss is a no-op; transitions discarded')
181
+ : fail(`expected {opacity:0.6, scale:2}, got ${JSON.stringify(overlay)}`)
182
+ },
183
+ },
184
+ {
185
+ // REQ-API-032 law (b) — R7: an animation-time array resolves to ORDERED per-label
186
+ // applications, each preserving its OWN embedded transition (the collapsed-batch
187
+ // execution is the recorded FAIL shape).
188
+ id: 'CMP-R7-per-label-applications',
189
+ requirements: ['REQ-API-032'],
190
+ assert() {
191
+ const soft = { type: 'spring', stiffness: 120, damping: 20 } as const
192
+ const stiff = { type: 'spring', stiffness: 500, damping: 40 } as const
193
+ const applications = resolveVariantDefinition(['softOpacity', 'stiffScale'], {
194
+ softOpacity: { opacity: 0.5, transition: soft },
195
+ stiffScale: { scale: 2, transition: stiff },
196
+ })
197
+ const perLabel =
198
+ applications.length === 2 &&
199
+ applications[0]!.transition === soft &&
200
+ (applications[0]!.target as { opacity?: number }).opacity === 0.5 &&
201
+ applications[1]!.transition === stiff &&
202
+ (applications[1]!.target as { scale?: number }).scale === 2
203
+ return perLabel
204
+ ? pass('two applications in array order, each with its own transition')
205
+ : fail(`expected 2 attributed applications, got ${JSON.stringify(applications)}`)
206
+ },
207
+ },
208
+ {
209
+ // REQ-API-032 — this is an EXTERNAL-EVIDENCE-ONLY parity row (the sealed precedent is
210
+ // appstore-choreography.cross-engine-parity / layout.cross-engine-parity, NOT the
211
+ // scalar-injectable gesture.cross-engine-parity which parity.ts additively owns). The R7
212
+ // per-label/gesture/exit/propagation scenarios execute against real components on the NATIVE
213
+ // engine and against the WEB engine, but the comparison is not reducible to an injectable
214
+ // deterministic scalar (the engine adapter is scalar-only), so no single deterministic context
215
+ // owns it. Per the additive law the row therefore STAYS `unassertable` here, and at seal
216
+ // (R7 M5, ratified Option A; SPEC-CONFORMANCE §3) its reason NAMES the executed counterparts so
217
+ // the accounting records where parity actually executes — visible, never a silent pass.
218
+ id: 'CMP-R7-cross-engine-parity',
219
+ // REQ-CONFORM-002 rides every *.cross-engine-parity scenario (traceability for the
220
+ // routed parity obligation — M2 r4 minor 91be23a506dd).
221
+ requirements: ['REQ-API-032', 'REQ-CONFORM-002'],
222
+ assert() {
223
+ // Owners front-loaded: the on-device banner clamps a routed row's detail to 3 lines
224
+ // (ConformanceScreen numberOfLines), so both executing owners are named first.
225
+ return unassertable(
226
+ 'WEB owner: variants.e2e.ts (Chromium, 19/19). NATIVE owner: PROOF.md §R7 device ' +
227
+ 'cards variants-list + tap-label (Android real-touch VARIANTS_PUBLIC/TAPLABEL_PUBLIC, ' +
228
+ 'iOS self-driving 12/12). Routed here — this deterministic host owns no second engine; ' +
229
+ 'parity is discharged at those altitudes, never a silent pass ' +
230
+ '(appstore-choreography.cross-engine-parity precedent).',
231
+ )
232
+ },
233
+ },
234
+ {
235
+ // REQ-API-033 (R8) — like the R7 routed row, this deterministic host has no real second
236
+ // renderer to compare. The Chromium web leg and M5 device card executed at their own
237
+ // altitudes; recording both owners here keeps the parity obligation visible without
238
+ // pretending this scalar suite performed a cross-engine comparison.
239
+ id: 'CMP-R8-cross-engine-parity',
240
+ requirements: ['REQ-API-033', 'REQ-CONFORM-002'],
241
+ assert() {
242
+ return unassertable(
243
+ 'WEB owner: keyframes.e2e.ts (Chromium, 20/20). NATIVE owner: PROOF.md §R8 device ' +
244
+ 'keyframes card (Android real-touch KEYFRAMES_PUBLIC verdicts + measured arcs; iOS ' +
245
+ 'self-driving AUTO-RUN 13/13 + measured peaks). Routed here — this deterministic host ' +
246
+ 'owns no second engine; both named external owners executed, never a silent pass.',
247
+ )
248
+ },
249
+ },
250
+ {
251
+ // REQ-API-034 (R9) — like the R7/R8 routed rows, this deterministic host has no real second
252
+ // renderer to compare. The Chromium web leg and M5 device cards executed at their own
253
+ // altitudes; recording both owners here keeps the parity obligation visible without
254
+ // pretending this scalar suite performed a cross-engine comparison.
255
+ id: 'CMP-R9-cross-engine-parity',
256
+ requirements: ['REQ-API-034', 'REQ-CONFORM-002'],
257
+ assert() {
258
+ return unassertable(
259
+ 'WEB owner: examples.smoke.e2e.ts + text-layout.e2e.ts + image-gallery.e2e.ts ' +
260
+ '(Chromium, 22/22). NATIVE owner: PROOF.md §R9 device element cards (Android gallery ' +
261
+ '17/17 including image-gallery stable post-fix — no Image-children crash; iOS ' +
262
+ 'self-driving AUTO-RUN COMPLETE 17/17 with wallpaper loaded). Routed here — this ' +
263
+ 'deterministic host owns no second engine; both named external owners executed, ' +
264
+ 'never a silent pass.',
265
+ )
266
+ },
267
+ },
268
+ {
269
+ // REQ-API-033 (R8) — a keyframe ARRAY target interpolates THROUGH its elements over the transition
270
+ // and SETTLES at the LAST keyframe (not the interior peak); committed reads keyframes[0] at tick-0
271
+ // (the first-keyframe seed law), never the nominal `from`. Driven against the deterministic-interior
272
+ // reference driver — the engine-agnostic contract the M2 native runtime + M3 web shim must reproduce.
273
+ id: 'CMP-R8-keyframe-through-array',
274
+ requirements: ['REQ-API-033', 'REQ-API-001'],
275
+ assert() {
276
+ // Accept the valid keyframe target at the boundary, then drive it to settlement.
277
+ validateTarget({ x: [0, 100, 50] }, NATIVE)
278
+ const d = createReferenceDriver()
279
+ const h = d.register({ x: 999 }) // a live value that disagrees with keyframes[0]
280
+ d.command(h, {
281
+ kind: 'start',
282
+ targets: { x: { to: [0, 100, 50], from: 999 } },
283
+ transition: { type: 'tween', duration: 0.2 },
284
+ })
285
+ const seed = d.committed(h)['x'] // tick-0 = keyframes[0] (0), NOT the nominal from (999)
286
+ let peak = 0
287
+ for (let i = 0; i < 30; i++) {
288
+ d.step(16.67)
289
+ peak = Math.max(peak, d.committed(h)['x'] ?? 0)
290
+ }
291
+ for (let i = 0; i < 30; i++) d.step(16.67) // well past the 200ms duration
292
+ const settled = d.committed(h)['x']
293
+ const settledIdle = !d.isActive(h) // quiesces once the keyframe run settles (REQ-DRIVER-020)
294
+
295
+ // null-FIRST (R8-F2): the first keyframe reads the element's live value (20), never `null`.
296
+ const dn = createReferenceDriver()
297
+ const hn = dn.register({ x: 20 })
298
+ dn.command(hn, {
299
+ kind: 'start',
300
+ targets: { x: { to: [null, 100], from: 20 } },
301
+ transition: { type: 'tween', duration: 0.2 },
302
+ })
303
+ const nullFirstSeed = dn.committed(hn)['x'] // seeded from `from` (20), never `null`
304
+ for (let i = 0; i < 30; i++) dn.step(16.67)
305
+ const nullFirstSettled = dn.committed(hn)['x']
306
+
307
+ return seed === 0 &&
308
+ peak > 60 &&
309
+ settled === 50 &&
310
+ settledIdle &&
311
+ nullFirstSeed === 20 &&
312
+ nullFirstSettled === 100
313
+ ? pass(
314
+ 'through-array: tick-0=0, peak>60, settle 50 + quiesce; null-first seeds 20→100 (R8-F2)',
315
+ )
316
+ : fail(
317
+ `seed/${String(seed)} peak/${peak} settle/${String(settled)} idle/${settledIdle} ` +
318
+ `nullseed/${String(nullFirstSeed)} nullsettle/${String(nullFirstSettled)}`,
319
+ )
320
+ },
321
+ },
322
+ {
323
+ // REQ-API-033 (R8, laws c/f + R8-F1/F2) — malformed keyframe arrays FAIL LOUD naming the offense:
324
+ // length<2, a mixed value-type, a null anywhere but index 0, a nested array; and an EXPLICIT spring
325
+ // on a >2 array (a spring interpolates exactly two keyframes — the driver refuses the successor).
326
+ id: 'CMP-R8-keyframe-refusals',
327
+ requirements: ['REQ-API-033', 'REQ-API-013'],
328
+ assert() {
329
+ // Capture the thrown error so IDENTITY + MESSAGE CONTENT are asserted — a blanket "did anything
330
+ // throw?" is fail-open (a raw TypeError or an unrelated driver failure would count as compliance;
331
+ // review major 9). No throw ⇒ null ⇒ the case fails below.
332
+ // Return the RAW thrown value — do NOT normalize a non-Error throw into an Error. The contract
333
+ // requires a TYPED Error; a thrown string/object is a contract violation the predicates below
334
+ // reject via `instanceof` (normalizing it to an Error masked exactly that, review major
335
+ // 0e4c6b8d2f19). No throw ⇒ null ⇒ the case fails below.
336
+ const capture = (fn: () => void): unknown => {
337
+ try {
338
+ fn()
339
+ return null
340
+ } catch (e) {
341
+ return e
342
+ }
343
+ }
344
+ // Each validation refusal must be the TYPED InvalidTargetError naming the offense (R8 laws c/f, F2).
345
+ const validationCases: readonly {
346
+ readonly label: string
347
+ readonly run: () => void
348
+ readonly want: RegExp
349
+ }[] = [
350
+ {
351
+ label: 'length<2',
352
+ run: () => validateTarget({ x: [0] }, NATIVE),
353
+ want: /keyframe array.*(at least two|≥ ?2)|(at least two|≥ ?2).*keyframe/i,
354
+ },
355
+ {
356
+ label: 'mixed-type',
357
+ run: () => validateTarget({ x: [0, 'not-a-length'] }, NATIVE),
358
+ want: /index 1|\[1\]/,
359
+ },
360
+ {
361
+ label: 'misplaced-null',
362
+ run: () => validateTarget({ x: [0, null, 50] }, NATIVE),
363
+ want: /index 1|\[1\]/,
364
+ },
365
+ {
366
+ label: 'nested-array',
367
+ run: () => validateTarget({ x: [0, [1, 2]] }, NATIVE),
368
+ want: /nested array/i,
369
+ },
370
+ ]
371
+ const validationFails = validationCases.filter((c) => {
372
+ const e = capture(c.run)
373
+ return !(e instanceof InvalidTargetError && c.want.test(e.message))
374
+ })
375
+ // The EXPLICIT spring on a >2 array is refused by the driver with the SPECIFIC R8-F1 message —
376
+ // asserted verbatim, and a raw TypeError/RangeError (the prohibited failure category) is
377
+ // EXCLUDED, so a broad "any Error mentioning keyframe" can no longer count as compliance
378
+ // (review major 11): the fail-open teeth are the exact phrase, not a fuzzy term.
379
+ const springErr = capture(() => {
380
+ const d = createReferenceDriver()
381
+ const h = d.register({ x: 0 })
382
+ d.command(h, {
383
+ kind: 'start',
384
+ targets: { x: { to: [0, 100, 50], from: 0 } },
385
+ transition: { type: 'spring', stiffness: 200, damping: 20 },
386
+ })
387
+ })
388
+ const springOk =
389
+ springErr instanceof Error &&
390
+ !(springErr instanceof TypeError) &&
391
+ !(springErr instanceof RangeError) &&
392
+ /a spring transition cannot interpolate through \d+ keyframes/.test(springErr.message)
393
+
394
+ return validationFails.length === 0 && springOk
395
+ ? pass(
396
+ 'length<2 / mixed / misplaced-null / nested → typed InvalidTargetError; spring+>2 → named driver refusal',
397
+ )
398
+ : fail(
399
+ `refusal gap: validation=[${validationFails.map((c) => c.label).join(',')}] ` +
400
+ `spring>2 ok=${springOk} (${springErr instanceof Error ? springErr.message : String(springErr)})`,
401
+ )
402
+ },
403
+ },
404
+ {
405
+ // REQ-API-033 (R8, law d) — custom `transition.times` (keyframe offsets) and a per-segment
406
+ // `transition.ease` LIST EXECUTE through the keyframe generator: both reshape the trajectory versus
407
+ // the defaults (even offsets + `easeInOut` per segment) — proven engine-agnostically against the
408
+ // deterministic-interior reference driver, the cross-backend contract the native + web engines must
409
+ // reproduce. A shipped option that is INERT would sample identically to its default; these do not.
410
+ // `transition.times`/`transition.ease`-list shapes are also accepted by core `validateTransition`,
411
+ // and malformed shapes (an offset outside [0,1], a bogus per-segment easing) fail loud typed.
412
+ id: 'CMP-R8-keyframe-timing',
413
+ requirements: ['REQ-API-033', 'REQ-API-001'],
414
+ assert() {
415
+ // Sample committed x after driving `to` for `steps` × 16.67ms under `transition`.
416
+ const sampleAt = (
417
+ to: readonly (number | null)[],
418
+ transition: Transition,
419
+ steps: number,
420
+ ): number => {
421
+ const d = createReferenceDriver()
422
+ const h = d.register({ x: 0 })
423
+ d.command(h, { kind: 'start', targets: { x: { to, from: 0 } }, transition })
424
+ for (let i = 0; i < steps; i++) d.step(16.67)
425
+ return d.committed(h)['x'] ?? Number.NaN
426
+ }
427
+ // `times` honored: front-loaded offsets [0, 0.1, 1] reach the interior peak far sooner than the
428
+ // even default [0, 0.5, 1], so at the same instant the sampled value differs materially.
429
+ const evenOffsets = sampleAt([0, 100, 0], { type: 'tween', duration: 0.2 }, 3)
430
+ const customTimes = sampleAt(
431
+ [0, 100, 0],
432
+ { type: 'tween', duration: 0.2, times: [0, 0.1, 1] },
433
+ 3,
434
+ )
435
+ const timesHonored = Math.abs(evenOffsets - customTimes) > 5
436
+ // Per-segment `ease` list honored: a `['linear', 'linear']` list samples the first segment
437
+ // linearly, differing from the default `easeInOut` per segment at an interior instant.
438
+ const defaultEase = sampleAt([0, 100, 50], { type: 'tween', duration: 0.3 }, 3)
439
+ const listEase = sampleAt(
440
+ [0, 100, 50],
441
+ { type: 'tween', duration: 0.3, ease: ['linear', 'linear'] },
442
+ 3,
443
+ )
444
+ const easeListHonored = Math.abs(defaultEase - listEase) > 2
445
+
446
+ // Core shape law: valid shapes accepted; malformed shapes fail loud typed.
447
+ const acceptsValid = (() => {
448
+ try {
449
+ validateTransition({ type: 'tween', duration: 0.2, times: [0, 0.5, 1] })
450
+ validateTransition({ type: 'tween', duration: 0.2, ease: ['easeIn', 'easeOut'] })
451
+ return true
452
+ } catch {
453
+ return false
454
+ }
455
+ })()
456
+ const capture = (fn: () => void): unknown => {
457
+ try {
458
+ fn()
459
+ return null
460
+ } catch (e) {
461
+ return e
462
+ }
463
+ }
464
+ const badOffset = capture(() => validateTransition({ times: [0, 1.5] }))
465
+ const badEasing = capture(() => validateTransition({ ease: ['easeIn', 'nope'] }))
466
+ const shapeRefused =
467
+ badOffset instanceof InvalidTransitionError &&
468
+ /times/.test(badOffset.message) &&
469
+ badEasing instanceof InvalidTransitionError &&
470
+ /ease/.test(badEasing.message)
471
+
472
+ return timesHonored && easeListHonored && acceptsValid && shapeRefused
473
+ ? pass('custom times + per-segment ease execute (not inert); shapes typed-validated')
474
+ : fail(
475
+ `timesHonored/${timesHonored} (even ${evenOffsets.toFixed(1)} vs custom ${customTimes.toFixed(1)}) ` +
476
+ `easeListHonored/${easeListHonored} (default ${defaultEase.toFixed(1)} vs list ${listEase.toFixed(1)}) ` +
477
+ `acceptsValid/${acceptsValid} shapeRefused/${shapeRefused}`,
478
+ )
479
+ },
480
+ },
481
+ {
482
+ // REQ-API-016 — a reserved-but-unimplemented capability fails loud "not yet supported", not a no-op.
483
+ id: 'CMP-unimplemented-fail-loud',
484
+ requirements: ['REQ-API-006', 'REQ-API-016'],
485
+ assert() {
486
+ try {
487
+ // filter is declared on web (web-only) so it clears membership, but its animation is unimplemented.
488
+ validateTarget({ filter: 'blur(4px)' }, WEB)
489
+ return fail(
490
+ 'expected a "not yet supported" throw for an unimplemented capability; none raised',
491
+ )
492
+ } catch (err) {
493
+ const named =
494
+ err instanceof InvalidTargetError &&
495
+ /filter/.test(err.message) &&
496
+ /not yet supported/i.test(err.message)
497
+ return named
498
+ ? pass('unimplemented filter raised "not yet supported" naming the key')
499
+ : fail(`threw but wrong class/message: ${(err as Error).message}`)
500
+ }
501
+ },
502
+ },
503
+ ]
504
+
505
+ // Run one component scenario against the deterministic interior, producing a ScenarioResult under the
506
+ // fake-host engine (M1). No adapter/clock needed — the boundary contract is pure.
507
+ export function runComponentScenario(scenario: ComponentScenario): ScenarioResult {
508
+ const outcome = scenario.assert()
509
+ return {
510
+ id: scenario.id,
511
+ engine: FAKE_ENGINE,
512
+ verdict: outcome.verdict,
513
+ readings: [],
514
+ detail: outcome.detail,
515
+ }
516
+ }