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