@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,302 @@
1
+ // SPEC-PRESENCE §4(b) Altitude-2 conformance scenarios (REQ-PRESENCE-011/012/013/015/016/017/018). Authored
2
+ // ONCE, engine-agnostic: each asserts a presence lifecycle law against the reference controller on a fake
3
+ // host — retention, the settle gate, cancel-on-reentry continuity, immediate unmount, manual deferral, wait-
4
+ // mode ordering, concurrent exits, first-commit enter suppression, and popLayout lifecycle ordering. At
5
+ // Milestone 1 the "engine" is the deterministic controller under an injectable clock + MotionGraph, no
6
+ // rendering; host geometry is verified by the real web/native integration floors at Milestone 2.
7
+
8
+ import {
9
+ ManualClock,
10
+ ManualScheduler,
11
+ type MotionGraph,
12
+ type Transition,
13
+ createMotionGraph,
14
+ createPresenceController,
15
+ } from '@unrulysystems/native-motion-core'
16
+ import type { ScenarioResult, Verdict } from '../runner'
17
+
18
+ const FAKE_ENGINE = 'fake-host'
19
+ const EPS_X = 1e-3 // of the [0,1] progress range
20
+
21
+ interface Outcome {
22
+ readonly verdict: Verdict
23
+ readonly detail: string
24
+ }
25
+ const pass = (detail: string): Outcome => ({ verdict: 'pass', detail })
26
+ const fail = (detail: string): Outcome => ({ verdict: 'fail', detail })
27
+
28
+ function harness(): { scheduler: ManualScheduler; graph: MotionGraph } {
29
+ const clock = new ManualClock(0)
30
+ const scheduler = new ManualScheduler(clock)
31
+ const graph = createMotionGraph({ clock, scheduler })
32
+ return { scheduler, graph }
33
+ }
34
+
35
+ const tween = (durationS: number): Transition => ({ type: 'tween', duration: durationS })
36
+ const SPRING: Transition = { type: 'spring', stiffness: 180, damping: 20 }
37
+
38
+ export interface PresenceScenario {
39
+ readonly id: string
40
+ readonly requirements: readonly string[]
41
+ readonly assert: () => Outcome
42
+ }
43
+
44
+ export const PRESENCE_SCENARIOS: readonly PresenceScenario[] = [
45
+ {
46
+ id: 'presence.retention',
47
+ requirements: ['REQ-PRESENCE-001', 'REQ-PRESENCE-011'],
48
+ assert() {
49
+ const { scheduler, graph } = harness()
50
+ const c = createPresenceController({ graph })
51
+ c.syncChildren([{ key: 'k', exit: { opacity: 0 }, transition: tween(0.2) }])
52
+ c.syncChildren([]) // drop
53
+ if (!c.mountedKeys().includes('k')) return fail('dropped child with an exit was not retained')
54
+ scheduler.frames(6, 16.67) // ~100ms, mid-exit
55
+ if (!c.mountedKeys().includes('k')) return fail('unmounted before the exit completed')
56
+ scheduler.frames(12, 16.67) // ~300ms, past the exit
57
+ if (c.mountedKeys().includes('k')) return fail('not unmounted after the exit completed')
58
+ return pass('retained through the exit, unmounted at completion')
59
+ },
60
+ },
61
+ {
62
+ id: 'presence.settle-gate',
63
+ requirements: ['REQ-PRESENCE-013'],
64
+ assert() {
65
+ const { scheduler, graph } = harness()
66
+ const c = createPresenceController({ graph })
67
+ c.syncChildren([{ key: 'k', exit: { opacity: 0 }, transition: tween(0.2) }])
68
+ c.syncChildren([])
69
+ if (graph.isSettled()) return fail('graph settled while the exit was in flight')
70
+ let settledAt = -1
71
+ let t = 0
72
+ for (let i = 0; i < 20 && settledAt < 0; i++) {
73
+ scheduler.frame(16.67)
74
+ t += 16.67
75
+ if (graph.isSettled()) settledAt = t
76
+ }
77
+ if (settledAt < 200) return fail(`settledAt ${settledAt.toFixed(1)} < exitCompleteAt 200`)
78
+ return pass(`settledAt ${settledAt.toFixed(1)} >= exitCompleteAt 200`)
79
+ },
80
+ },
81
+ {
82
+ id: 'presence.cancel-on-reentry',
83
+ requirements: ['REQ-PRESENCE-012'],
84
+ assert() {
85
+ const { scheduler, graph } = harness()
86
+ const c = createPresenceController({ graph })
87
+ const ch = { key: 'k', animate: { opacity: 1 }, exit: { opacity: 0 }, transition: SPRING }
88
+ c.syncChildren([ch])
89
+ c.syncChildren([]) // drop → exiting
90
+ scheduler.frames(6, 16.67)
91
+ const pBefore = c.exitProgress('k') ?? -1
92
+ c.syncChildren([ch]) // re-add → cancel
93
+ if (c.stateOf('k') !== 'present') return fail('key not present after cancel')
94
+ if (!c.mountedKeys().includes('k')) return fail('key unmounted on cancel')
95
+ const pAfter = c.exitProgress('k') ?? -1
96
+ if (Math.abs(pAfter - pBefore) > EPS_X)
97
+ return fail(`continuity break |Δ|=${Math.abs(pAfter - pBefore)}`)
98
+ scheduler.frames(120, 16.67)
99
+ if (Math.abs(c.exitProgress('k') ?? 1) > EPS_X)
100
+ return fail('did not converge back to present')
101
+ return pass('cancel: never unmounted, continuity held, converged to present')
102
+ },
103
+ },
104
+ {
105
+ id: 'presence.immediate-unmount',
106
+ requirements: ['REQ-PRESENCE-011'],
107
+ assert() {
108
+ const { graph } = harness()
109
+ const c = createPresenceController({ graph })
110
+ c.syncChildren([{ key: 'k' }]) // no exit, not deferred
111
+ c.syncChildren([])
112
+ if (c.stateOf('k') !== 'removed') return fail('no-exit child not removed in one commit')
113
+ if (!graph.isSettled()) return fail('settle disturbed by an immediate unmount')
114
+ return pass('no-exit child removed in one commit; settle unaffected')
115
+ },
116
+ },
117
+ {
118
+ id: 'presence.manual-deferral',
119
+ requirements: ['REQ-PRESENCE-013', 'REQ-PRESENCE-014'],
120
+ assert() {
121
+ const { graph } = harness()
122
+ const c = createPresenceController({ graph })
123
+ c.syncChildren([{ key: 'k', deferred: true }])
124
+ c.syncChildren([])
125
+ if (c.isPresent('k')) return fail('isPresent true after a deferred child was removed')
126
+ if (!c.mountedKeys().includes('k')) return fail('deferred child not retained')
127
+ if (graph.isSettled()) return fail('settled while a manual exit was pending')
128
+ c.safeToRemove('k')
129
+ if (c.mountedKeys().includes('k')) return fail('still mounted after safeToRemove')
130
+ if (!graph.isSettled()) return fail('not settled after safeToRemove')
131
+ c.safeToRemove('k') // idempotent
132
+ if (!graph.isSettled()) return fail('a second safeToRemove disturbed settle')
133
+ return pass('deferred child stays unsettled until an idempotent safeToRemove')
134
+ },
135
+ },
136
+ {
137
+ // A usePresence consumer that registers only AFTER its key dropped (a layoutId introduced
138
+ // mid-exit) cannot ride the drop-time `deferred` flag: `deferExit` adopts manual authority
139
+ // into the live animated exit, and removal then requires BOTH the exit animation completing
140
+ // and the manual release — in either order.
141
+ id: 'presence.mid-exit-deferral-adoption',
142
+ requirements: ['REQ-PRESENCE-001', 'REQ-PRESENCE-013', 'REQ-PRESENCE-014'],
143
+ assert() {
144
+ const { scheduler, graph } = harness()
145
+ const c = createPresenceController({ graph })
146
+ c.syncChildren([
147
+ { key: 'k', animate: { opacity: 1 }, exit: { opacity: 0 }, transition: tween(0.2) },
148
+ ])
149
+ c.syncChildren([]) // drop with NO consumer → animation-driven exit
150
+ c.deferExit('k') // the consumer joins the live episode
151
+ scheduler.frames(24, 16.67) // ~400ms, past the 200ms exit
152
+ if (!c.mountedKeys().includes('k')) return fail('unmounted before the late consumer released')
153
+ if (graph.isSettled()) return fail('settled while a manual removal was pending')
154
+ c.safeToRemove('k')
155
+ if (c.stateOf('k') !== 'removed') return fail('not removed after the conjunction completed')
156
+ if (!graph.isSettled()) return fail('not settled after the adopted exit resolved')
157
+
158
+ // The other order: released while the animation is still in flight → the exit still gates.
159
+ c.syncChildren([
160
+ { key: 'j', animate: { opacity: 1 }, exit: { opacity: 0 }, transition: tween(0.2) },
161
+ ])
162
+ c.syncChildren([])
163
+ c.deferExit('j')
164
+ c.safeToRemove('j')
165
+ if (c.stateOf('j') !== 'exiting') return fail('early release unmounted mid-exit-animation')
166
+ scheduler.frames(24, 16.67)
167
+ if (c.stateOf('j') !== 'removed') return fail('not removed after the exit completed')
168
+ if (!graph.isSettled()) return fail('not settled after the early-released exit resolved')
169
+ return pass('mid-exit adoption gates removal on animation ∧ release, either order')
170
+ },
171
+ },
172
+ {
173
+ id: 'presence.wait-ordering',
174
+ requirements: ['REQ-PRESENCE-015'],
175
+ assert() {
176
+ const events: string[] = []
177
+ const { scheduler, graph } = harness()
178
+ const c = createPresenceController({
179
+ graph,
180
+ mode: 'wait',
181
+ onExitComplete: () => events.push('x'),
182
+ })
183
+ c.syncChildren([{ key: 'old', exit: { opacity: 0 }, transition: tween(0.2) }])
184
+ c.syncChildren([{ key: 'new', animate: { opacity: 1 } }]) // old exits, new enters
185
+ // Snapshot into numeric consts so control-flow does not narrow `events.length` to a literal across the
186
+ // side-effecting frames() calls (which invoke onExitComplete via the closure).
187
+ const enteredEarly = c.mountedKeys().includes('new')
188
+ const firedEarly = events.length
189
+ scheduler.frames(15, 16.67)
190
+ c.mountedKeys() // reconcile
191
+ const firedAfter = events.length
192
+ const enteredAfter = c.mountedKeys().includes('new')
193
+ if (enteredEarly) return fail('entering child committed before the exit resolved')
194
+ if (firedEarly !== 0) return fail('onExitComplete fired before the exit resolved')
195
+ if (firedAfter !== 1)
196
+ return fail(`onExitComplete fired ${firedAfter} times, expected exactly 1`)
197
+ if (!enteredAfter) return fail('entering child not committed after the exit resolved')
198
+ return pass(
199
+ 'wait defers enters until exits resolve; onExitComplete once, strictly before enters',
200
+ )
201
+ },
202
+ },
203
+ {
204
+ id: 'presence.concurrent',
205
+ requirements: ['REQ-PRESENCE-018'],
206
+ assert() {
207
+ const { scheduler, graph } = harness()
208
+ const c = createPresenceController({ graph })
209
+ c.syncChildren([
210
+ { key: 'short', exit: { opacity: 0 }, transition: tween(0.1) },
211
+ { key: 'long', exit: { opacity: 0 }, transition: tween(0.3) },
212
+ ])
213
+ c.syncChildren([])
214
+ scheduler.frames(8, 16.67) // ~133ms
215
+ c.mountedKeys()
216
+ if (c.stateOf('short') !== 'removed')
217
+ return fail('short exit did not resolve at its own completion')
218
+ if (c.stateOf('long') !== 'exiting') return fail('long exit resolved early')
219
+ if (graph.isSettled()) return fail('settled before the last concurrent exit')
220
+ scheduler.frames(12, 16.67) // ~333ms
221
+ c.mountedKeys()
222
+ if (c.stateOf('long') !== 'removed') return fail('long exit did not resolve')
223
+ if (!graph.isSettled()) return fail('not settled after the last concurrent exit')
224
+ return pass('concurrent exits resolve independently; settle waits for the last')
225
+ },
226
+ },
227
+ {
228
+ id: 'presence.initial-false',
229
+ requirements: ['REQ-PRESENCE-016'],
230
+ assert() {
231
+ const { graph } = harness()
232
+ const c = createPresenceController({ graph, initial: false })
233
+ c.syncChildren([{ key: 'k', animate: { opacity: 1 } }])
234
+ if (!c.isPresent('k')) return fail('child not present on the first commit')
235
+ if (!c.enterSuppressed('k')) return fail('enter not suppressed under initial={false}')
236
+ return pass('initial={false} suppresses the first-commit enter (mounted at animate)')
237
+ },
238
+ },
239
+ {
240
+ id: 'presence.pop-layout',
241
+ requirements: ['REQ-PRESENCE-017', 'REQ-PRESENCE-022'],
242
+ assert() {
243
+ const { scheduler, graph } = harness()
244
+ const c = createPresenceController({ graph, mode: 'popLayout' })
245
+ c.syncChildren([{ key: 'k', exit: { opacity: 0 }, transition: tween(0.2) }])
246
+ c.syncChildren([])
247
+ if (!c.mountedKeys().includes('k'))
248
+ return fail('popLayout dropped the exiting child immediately')
249
+ scheduler.frames(20, 16.67)
250
+ if (c.mountedKeys().includes('k'))
251
+ return fail('popLayout retained the child after its exit settled')
252
+ return pass(
253
+ 'popLayout retains exiting children through lifecycle settlement; host geometry is owned by integration floors',
254
+ )
255
+ },
256
+ },
257
+ {
258
+ // Family-6 (REQ-PRESENCE-010 propagate): drop-time deferred:true + authored exit is the
259
+ // consumer ∧ animation conjunction. Nested propagate registers deferred at drop; a short
260
+ // nested release must not unmount the outer key before its longer exit settles.
261
+ id: 'presence.deferred-exit-conjunction',
262
+ requirements: ['REQ-PRESENCE-001', 'REQ-PRESENCE-010', 'REQ-PRESENCE-013', 'REQ-PRESENCE-014'],
263
+ assert() {
264
+ const { scheduler, graph } = harness()
265
+ const c = createPresenceController({ graph })
266
+ c.syncChildren([
267
+ {
268
+ key: 'outer',
269
+ animate: { opacity: 1 },
270
+ exit: { opacity: 0 },
271
+ transition: tween(0.2),
272
+ deferred: true,
273
+ },
274
+ ])
275
+ c.syncChildren([]) // drop with drop-time deferral + authored exit
276
+ if (c.stateOf('outer') !== 'exiting') return fail('deferred+exit drop did not enter exiting')
277
+ c.safeToRemove('outer') // nested consumer released first
278
+ if (c.stateOf('outer') !== 'exiting')
279
+ return fail('early safeToRemove unmounted before the exit animation settled')
280
+ if (!c.mountedKeys().includes('outer')) return fail('key left mounted set mid-exit')
281
+ if (graph.isSettled()) return fail('settled while exit animation still owns retention')
282
+ scheduler.frames(24, 16.67) // past 200ms exit
283
+ if (c.stateOf('outer') !== 'removed')
284
+ return fail('not removed after exit animation completed')
285
+ if (!graph.isSettled()) return fail('not settled after the deferred+exit conjunction')
286
+ return pass('drop-time deferred + authored exit gates on animation ∧ release, either order')
287
+ },
288
+ },
289
+ ]
290
+
291
+ // Run one presence scenario against the reference controller, producing a ScenarioResult under the fake-host
292
+ // engine (M1). The controller carries its own deterministic clock/graph — no adapter needed.
293
+ export function runPresenceScenario(scenario: PresenceScenario): ScenarioResult {
294
+ const outcome = scenario.assert()
295
+ return {
296
+ id: scenario.id,
297
+ engine: FAKE_ENGINE,
298
+ verdict: outcome.verdict,
299
+ readings: [],
300
+ detail: outcome.detail,
301
+ }
302
+ }
@@ -0,0 +1,180 @@
1
+ // Spring/timing cross-engine conformance scenarios (SPEC-SPRING §5). Authored ONCE, engine-agnostic:
2
+ // each names a generator config + a t-grid; the runner samples it on BOTH the native solver and pinned
3
+ // motion-dom and the comparator differentially checks parity within SPRING_BAND. No expected values are
4
+ // authored here — the two independent engines are each other's oracle (REQ-CONFORM-011/014).
5
+ //
6
+ // The two `pendingDecisionD` scenarios are the known oracle divergences (SPEC-SPRING §8): the
7
+ // overdamped-tail #1207 bug (native exponential 630 vs oracle capped 988) and visualDuration-only
8
+ // (native vd-path vs oracle pre-plan-030 default physics). They FAIL cross-engine — carried fail-closed
9
+ // until the pin is reconciled, never papered over.
10
+
11
+ import type { SpringScenario } from '../scenario'
12
+
13
+ const STEP_MS = 1000 / 60 // Motion's 60fps grid
14
+ function grid(tMaxMs: number, extra: readonly number[] = []): number[] {
15
+ const ts = new Set<number>([0])
16
+ for (let t = 0; t <= tMaxMs; t += STEP_MS) ts.add(Number(t.toFixed(6)))
17
+ for (const e of extra) ts.add(e)
18
+ return [...ts].sort((a, b) => a - b)
19
+ }
20
+
21
+ const CAP = ['spring-trajectory'] as const
22
+
23
+ export const SPRING_SCENARIOS: readonly SpringScenario[] = [
24
+ // ── regime coverage (REQ-SPRING-002/010) ──
25
+ {
26
+ id: 'SPRING-underdamped',
27
+ requirements: ['REQ-SPRING-002'],
28
+ capabilities: CAP,
29
+ kind: 'spring',
30
+ spec: { from: 0, to: 100, stiffness: 100, damping: 10, mass: 1 },
31
+ tGrid: grid(3000),
32
+ },
33
+ {
34
+ id: 'SPRING-critical',
35
+ requirements: ['REQ-SPRING-002'],
36
+ capabilities: CAP,
37
+ kind: 'spring',
38
+ spec: { from: 0, to: 100, stiffness: 100, damping: 20, mass: 1 },
39
+ tGrid: grid(2000),
40
+ },
41
+ {
42
+ id: 'SPRING-overdamped-mild',
43
+ requirements: ['REQ-SPRING-010'],
44
+ capabilities: CAP,
45
+ kind: 'spring',
46
+ spec: { from: 0, to: 100, stiffness: 100, damping: 30, mass: 1 },
47
+ tGrid: grid(4000),
48
+ },
49
+ // ── config resolution (REQ-SPRING-004/005) ──
50
+ {
51
+ id: 'SPRING-duration',
52
+ requirements: ['REQ-SPRING-004'],
53
+ capabilities: CAP,
54
+ kind: 'spring',
55
+ spec: { from: 0, to: 100, duration: 800, bounce: 0.3 },
56
+ tGrid: grid(2000),
57
+ },
58
+ {
59
+ id: 'SPRING-visualDuration',
60
+ requirements: ['REQ-SPRING-005'],
61
+ capabilities: CAP,
62
+ kind: 'spring',
63
+ spec: { from: 0, to: 100, visualDuration: 0.3, bounce: 0 },
64
+ tGrid: grid(2000),
65
+ },
66
+ // ── precedence: a physics key ignores the duration family (REQ-SPRING-006) ──
67
+ {
68
+ id: 'SPRING-precedence-physics',
69
+ requirements: ['REQ-SPRING-006'],
70
+ capabilities: CAP,
71
+ kind: 'spring',
72
+ spec: { from: 0, to: 100, stiffness: 200, duration: 1000 },
73
+ tGrid: grid(2000),
74
+ },
75
+ // ── velocity carry (REQ-SPRING-007) + the interruption re-seed (REQ-SPRING-009): a physics spring
76
+ // seeded with incoming velocity is exactly a mid-interruption handoff ──
77
+ {
78
+ id: 'SPRING-velocity-carry',
79
+ requirements: ['REQ-SPRING-007', 'REQ-SPRING-009'],
80
+ capabilities: CAP,
81
+ kind: 'spring',
82
+ spec: { from: 0, to: 100, stiffness: 100, damping: 10, velocity: 800 },
83
+ tGrid: grid(3000),
84
+ },
85
+ // ── unit/sign boundary: a negative incoming velocity (REQ-SPRING-008) ──
86
+ {
87
+ id: 'SPRING-sign-negative-v0',
88
+ requirements: ['REQ-SPRING-008'],
89
+ capabilities: CAP,
90
+ kind: 'spring',
91
+ spec: { from: 0, to: 100, stiffness: 120, damping: 12, velocity: -600 },
92
+ tGrid: grid(3000),
93
+ },
94
+ // ── granular boundary: |delta| < 5 tightens rest thresholds (REQ-SPRING-011) ──
95
+ {
96
+ id: 'SPRING-granular',
97
+ requirements: ['REQ-SPRING-011'],
98
+ capabilities: CAP,
99
+ kind: 'spring',
100
+ spec: { from: 0, to: 3, stiffness: 100, damping: 20 },
101
+ tGrid: grid(3000),
102
+ },
103
+ // ── timing curves (REQ-TIMING-001/002) ──
104
+ {
105
+ id: 'TIMING-default-ease',
106
+ requirements: ['REQ-TIMING-002'],
107
+ capabilities: CAP,
108
+ kind: 'timing',
109
+ spec: { from: 0, to: 100, duration: 300, ease: [0.25, 0.1, 0.35, 1] },
110
+ tGrid: grid(300, [300, 400]),
111
+ },
112
+ {
113
+ id: 'TIMING-easeInOut',
114
+ requirements: ['REQ-TIMING-001'],
115
+ capabilities: CAP,
116
+ kind: 'timing',
117
+ spec: { from: 0, to: 200, duration: 500, ease: [0.42, 0, 0.58, 1] },
118
+ tGrid: grid(500, [500]),
119
+ },
120
+ {
121
+ id: 'TIMING-linear',
122
+ requirements: ['REQ-TIMING-001'],
123
+ capabilities: CAP,
124
+ kind: 'timing',
125
+ spec: { from: 10, to: 60, duration: 400, ease: 'linear' },
126
+ tGrid: grid(400, [400]),
127
+ },
128
+ // Family-2 named curves (xxytww): exercise expanded Motion EasingDefinition through both adapters.
129
+ {
130
+ id: 'TIMING-circOut',
131
+ requirements: ['REQ-TIMING-001'],
132
+ capabilities: CAP,
133
+ kind: 'timing',
134
+ spec: { from: 0, to: 100, duration: 400, ease: 'circOut' },
135
+ tGrid: grid(400, [200, 400]),
136
+ },
137
+ {
138
+ id: 'TIMING-backOut',
139
+ requirements: ['REQ-TIMING-001'],
140
+ capabilities: CAP,
141
+ kind: 'timing',
142
+ spec: { from: 0, to: 100, duration: 400, ease: 'backOut' },
143
+ tGrid: grid(400, [200, 300, 400]),
144
+ },
145
+ {
146
+ id: 'TIMING-anticipate',
147
+ requirements: ['REQ-TIMING-001'],
148
+ capabilities: CAP,
149
+ kind: 'timing',
150
+ spec: { from: 0, to: 100, duration: 400, ease: 'anticipate' },
151
+ tGrid: grid(400, [100, 200, 400]),
152
+ },
153
+ {
154
+ id: 'TIMING-easeIn-named',
155
+ requirements: ['REQ-TIMING-001'],
156
+ capabilities: CAP,
157
+ kind: 'timing',
158
+ spec: { from: 0, to: 100, duration: 400, ease: 'easeIn' },
159
+ tGrid: grid(400, [200, 400]),
160
+ },
161
+ // ── decision-D divergences (SPEC-SPRING §8) — fail closed cross-engine, never papered over ──
162
+ {
163
+ id: 'SPRING-1207-overdamped-tail',
164
+ requirements: ['REQ-SPRING-010'],
165
+ capabilities: CAP,
166
+ kind: 'spring',
167
+ spec: { from: 0, to: 1000, stiffness: 4, damping: 35, mass: 0.5 },
168
+ tGrid: grid(9000, [8700]),
169
+ pendingDecisionD: true,
170
+ },
171
+ {
172
+ id: 'SPRING-visualDuration-only',
173
+ requirements: ['REQ-SPRING-006'],
174
+ capabilities: CAP,
175
+ kind: 'spring',
176
+ spec: { from: 0, to: 100, visualDuration: 0.3 },
177
+ tGrid: grid(2000),
178
+ pendingDecisionD: true,
179
+ },
180
+ ]
@@ -0,0 +1,107 @@
1
+ // Value-type cross-engine conformance scenarios (SPEC-VALUE-TYPES §5). Authored ONCE, engine-agnostic:
2
+ // each names a property + a from/to pair + a progress grid; the runner mixes it on BOTH the native seam
3
+ // and pinned motion-dom, and the comparator differentially checks per-type-epsilon parity. No expected
4
+ // values are authored — the two independent engines are each other's oracle (REQ-CONFORM-011/014).
5
+ //
6
+ // Divergence scenarios are first-class (never excluded): `'throw'` — native fails loud on a unit/shape
7
+ // mismatch where motion warns+snaps via mixImmediate (REQ-VALUETYPE-004/-007/-008); `'snap'` — native
8
+ // mixes named colors from its own table (REQ-VALUETYPE-005) where motion, lacking a DOM, snaps to the
9
+ // target, so the engines MUST diverge (documented capability tripwire, §8 named-color vetoable).
10
+
11
+ import type { ValueTypeScenario } from '../scenario'
12
+
13
+ // Shared progress grid (matches the golden generator's GRID, SPEC-VALUE-TYPES §5).
14
+ const GRID = [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1] as const
15
+ const CAP = ['value-type-mix'] as const
16
+
17
+ export const VALUE_TYPE_SCENARIOS: readonly ValueTypeScenario[] = [
18
+ // ── color (gamma-aware RGBA mix), REQ-VALUETYPE-006 ──
19
+ {
20
+ id: 'VT-backgroundColor-hex',
21
+ requirements: ['REQ-VALUETYPE-006'],
22
+ capabilities: CAP,
23
+ property: 'backgroundColor',
24
+ from: '#000000',
25
+ to: '#ffffff',
26
+ grid: GRID,
27
+ },
28
+ {
29
+ id: 'VT-backgroundColor-rgba-alpha',
30
+ requirements: ['REQ-VALUETYPE-006'],
31
+ capabilities: CAP,
32
+ property: 'backgroundColor',
33
+ from: 'rgba(255, 0, 0, 1)',
34
+ to: 'rgba(0, 0, 255, 0)',
35
+ grid: GRID,
36
+ },
37
+ {
38
+ id: 'VT-color-hsl',
39
+ requirements: ['REQ-VALUETYPE-005', 'REQ-VALUETYPE-006'],
40
+ capabilities: CAP,
41
+ property: 'color',
42
+ from: 'hsl(0, 100%, 50%)',
43
+ to: 'hsl(240, 100%, 50%)',
44
+ grid: GRID,
45
+ },
46
+ // ── unit number (length), REQ-VALUETYPE-003 ──
47
+ {
48
+ id: 'VT-width-px',
49
+ requirements: ['REQ-VALUETYPE-003'],
50
+ capabilities: CAP,
51
+ property: 'width',
52
+ from: '0px',
53
+ to: '100px',
54
+ grid: GRID,
55
+ },
56
+ // ── complex/compound, REQ-VALUETYPE-007 ──
57
+ {
58
+ id: 'VT-borderRadius',
59
+ requirements: ['REQ-VALUETYPE-007'],
60
+ capabilities: CAP,
61
+ property: 'borderRadius',
62
+ from: '16px 16px 0px 0px',
63
+ to: '0px 0px 0px 0px',
64
+ grid: GRID,
65
+ },
66
+ {
67
+ id: 'VT-boxShadow',
68
+ requirements: ['REQ-VALUETYPE-006', 'REQ-VALUETYPE-007'],
69
+ capabilities: CAP,
70
+ property: 'boxShadow',
71
+ from: '0px 0px 0px rgba(0, 0, 0, 0.5)',
72
+ to: '10px 10px 20px rgba(255, 0, 0, 1)',
73
+ grid: GRID,
74
+ },
75
+ // ── divergences: native throws (REQ-VALUETYPE-004/-007/-008) ──
76
+ {
77
+ id: 'VT-unit-mismatch-throws',
78
+ requirements: ['REQ-VALUETYPE-004'],
79
+ capabilities: CAP,
80
+ property: 'borderRadius',
81
+ from: '16px 16px',
82
+ to: '16deg 16deg',
83
+ grid: GRID,
84
+ divergence: 'throw',
85
+ },
86
+ {
87
+ id: 'VT-shape-mismatch-throws',
88
+ requirements: ['REQ-VALUETYPE-007'],
89
+ capabilities: CAP,
90
+ property: 'borderRadius',
91
+ from: '16px',
92
+ to: '0px 0px 0px 0px',
93
+ grid: GRID,
94
+ divergence: 'throw',
95
+ },
96
+ // ── divergence: native mixes named colors, motion snaps (REQ-VALUETYPE-005) ──
97
+ {
98
+ id: 'VT-named-color-snap',
99
+ requirements: ['REQ-VALUETYPE-005'],
100
+ capabilities: CAP,
101
+ property: 'backgroundColor',
102
+ from: 'red',
103
+ to: 'blue',
104
+ grid: GRID,
105
+ divergence: 'snap',
106
+ },
107
+ ]
package/src/suite.ts ADDED
@@ -0,0 +1,44 @@
1
+ // The suite aggregator (REQ-CONFORM-011). Folds per-(scenario, engine) results into one fail-closed
2
+ // verdict. `unassertable` is a ROUTING obligation, not a pass: a scenario that no engine could assert
3
+ // is an uncovered obligation and fails the suite. The rules:
4
+ // - any `fail` on any engine → suite `fail`;
5
+ // - a scenario that is `unassertable` on every engine that ran it (no engine `pass`ed it) has no
6
+ // owner → suite `fail`;
7
+ // - otherwise → suite `pass`.
8
+
9
+ import type { ScenarioResult } from './runner'
10
+
11
+ export interface SuiteResult {
12
+ readonly verdict: 'pass' | 'fail'
13
+ readonly reasons: readonly string[]
14
+ }
15
+
16
+ export function aggregateSuite(results: readonly ScenarioResult[]): SuiteResult {
17
+ // Fail-closed on an empty run: zero results means nothing was verified (scenarios filtered out, a
18
+ // discovery bug, an aborted run). Reporting `pass` here would be the exact fail-open the
19
+ // three-valued accounting exists to prevent — an unrun suite is never a green one.
20
+ if (results.length === 0) {
21
+ return { verdict: 'fail', reasons: ['empty result set — no scenarios ran'] }
22
+ }
23
+
24
+ const reasons: string[] = []
25
+
26
+ for (const r of results) {
27
+ if (r.verdict === 'fail') reasons.push(`${r.id} failed on ${r.engine}`)
28
+ }
29
+
30
+ // Group by scenario so ownership is evaluated across all engines that ran it.
31
+ const byScenario = new Map<string, ScenarioResult[]>()
32
+ for (const r of results) {
33
+ const list = byScenario.get(r.id) ?? []
34
+ list.push(r)
35
+ byScenario.set(r.id, list)
36
+ }
37
+ for (const [id, list] of byScenario) {
38
+ const hasUnassertable = list.some((r) => r.verdict === 'unassertable')
39
+ const hasOwner = list.some((r) => r.verdict === 'pass')
40
+ if (hasUnassertable && !hasOwner) reasons.push(`${id} unassertable with no owner`)
41
+ }
42
+
43
+ return { verdict: reasons.length === 0 ? 'pass' : 'fail', reasons }
44
+ }