@flighthq/easing 0.3.0 → 0.3.1-next.1041.35b950c

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flighthq/easing",
3
- "version": "0.3.0",
3
+ "version": "0.3.1-next.1041.35b950c",
4
4
  "author": "Joshua Granick and other contributors",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,8 +38,8 @@
38
38
  "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
39
39
  },
40
40
  "dependencies": {
41
- "@flighthq/log": "0.3.0",
42
- "@flighthq/types": "0.3.0"
41
+ "@flighthq/log": "0.3.1-next.1041.35b950c",
42
+ "@flighthq/types": "0.3.1-next.1041.35b950c"
43
43
  },
44
44
  "devDependencies": {
45
45
  "typescript": "^5.3.0"
@@ -0,0 +1,276 @@
1
+ import type { EasingFunction } from '@flighthq/types/contract';
2
+
3
+ // Cross-family invariants: relationships BETWEEN the In/Out/InOut siblings, which is where this package's
4
+ // real defects live. A per-curve value assertion is close to blind here — `easeInQuadratic(0.5) === 0.25`
5
+ // also accepts curves that are not easeInQuadratic, and every easing function satisfies f(0)=0 and f(1)=1,
6
+ // so endpoint assertions accept nearly everything. In ten families of near-identical arithmetic the likely
7
+ // defect is a copy-paste between neighbours, and only a relationship between siblings can see it.
8
+ //
9
+ // This file is cross-family by construction, which is why it has no single source sibling: each assertion
10
+ // is about a PAIR or the whole set. Per-curve behaviour stays in each family's own test file.
11
+ import {
12
+ easeInBack,
13
+ easeInBounce,
14
+ easeInCircular,
15
+ easeInCubic,
16
+ easeInElastic,
17
+ easeInExponential,
18
+ easeInOutBack,
19
+ easeInOutBounce,
20
+ easeInOutCircular,
21
+ easeInOutCubic,
22
+ easeInOutElastic,
23
+ easeInOutExponential,
24
+ easeInOutQuadratic,
25
+ easeInOutQuartic,
26
+ easeInOutQuintic,
27
+ easeInOutSine,
28
+ easeInQuadratic,
29
+ easeInQuartic,
30
+ easeInQuintic,
31
+ easeInSine,
32
+ easeOutBack,
33
+ easeOutBounce,
34
+ easeOutCircular,
35
+ easeOutCubic,
36
+ easeOutElastic,
37
+ easeOutExponential,
38
+ easeOutQuadratic,
39
+ easeOutQuartic,
40
+ easeOutQuintic,
41
+ easeOutSine,
42
+ } from './contract';
43
+
44
+ interface Family {
45
+ easeIn: EasingFunction;
46
+ easeInOut: EasingFunction;
47
+ easeOut: EasingFunction;
48
+ // Whether easeInOut is the two halves of easeIn/easeOut scaled into place. FALSE for the two families
49
+ // whose canonical definition gives the InOut variant a DIFFERENT constant — Back scales its overshoot
50
+ // by 1.525 and Elastic widens its period from 0.4 to 0.45 — so forcing the relation here would be
51
+ // asserting a curve the family does not have.
52
+ halvesMatch: boolean;
53
+ // Whether easeIn rises without ever stepping back. False where the shape is the point: Back undershoots
54
+ // below 0, Elastic oscillates, Bounce bounces.
55
+ monotonic: boolean;
56
+ name: string;
57
+ }
58
+
59
+ const FAMILIES: readonly Family[] = [
60
+ {
61
+ easeIn: easeInBack,
62
+ easeInOut: easeInOutBack,
63
+ easeOut: easeOutBack,
64
+ halvesMatch: false,
65
+ monotonic: false,
66
+ name: 'Back',
67
+ },
68
+ {
69
+ easeIn: easeInBounce,
70
+ easeInOut: easeInOutBounce,
71
+ easeOut: easeOutBounce,
72
+ halvesMatch: true,
73
+ monotonic: false,
74
+ name: 'Bounce',
75
+ },
76
+ {
77
+ easeIn: easeInCircular,
78
+ easeInOut: easeInOutCircular,
79
+ easeOut: easeOutCircular,
80
+ halvesMatch: true,
81
+ monotonic: true,
82
+ name: 'Circular',
83
+ },
84
+ {
85
+ easeIn: easeInCubic,
86
+ easeInOut: easeInOutCubic,
87
+ easeOut: easeOutCubic,
88
+ halvesMatch: true,
89
+ monotonic: true,
90
+ name: 'Cubic',
91
+ },
92
+ {
93
+ easeIn: easeInElastic,
94
+ easeInOut: easeInOutElastic,
95
+ easeOut: easeOutElastic,
96
+ halvesMatch: false,
97
+ monotonic: false,
98
+ name: 'Elastic',
99
+ },
100
+ {
101
+ easeIn: easeInExponential,
102
+ easeInOut: easeInOutExponential,
103
+ easeOut: easeOutExponential,
104
+ halvesMatch: true,
105
+ monotonic: true,
106
+ name: 'Exponential',
107
+ },
108
+ {
109
+ easeIn: easeInQuadratic,
110
+ easeInOut: easeInOutQuadratic,
111
+ easeOut: easeOutQuadratic,
112
+ halvesMatch: true,
113
+ monotonic: true,
114
+ name: 'Quadratic',
115
+ },
116
+ {
117
+ easeIn: easeInQuartic,
118
+ easeInOut: easeInOutQuartic,
119
+ easeOut: easeOutQuartic,
120
+ halvesMatch: true,
121
+ monotonic: true,
122
+ name: 'Quartic',
123
+ },
124
+ {
125
+ easeIn: easeInQuintic,
126
+ easeInOut: easeInOutQuintic,
127
+ easeOut: easeOutQuintic,
128
+ halvesMatch: true,
129
+ monotonic: true,
130
+ name: 'Quintic',
131
+ },
132
+ {
133
+ easeIn: easeInSine,
134
+ easeInOut: easeInOutSine,
135
+ easeOut: easeOutSine,
136
+ halvesMatch: true,
137
+ monotonic: true,
138
+ name: 'Sine',
139
+ },
140
+ ];
141
+
142
+ // 201 points rather than a handful: a copy-paste between neighbours can agree at the sample points a
143
+ // hand-picked list would choose, and cannot agree across the whole unit interval.
144
+ const SAMPLES: readonly number[] = Array.from({ length: 201 }, (_, index) => index / 200);
145
+
146
+ // The measured worst reflection error across every family is 1.1e-15, so this bound is roughly three
147
+ // orders of magnitude tighter than the smallest real difference a wrong constant could produce, and
148
+ // still far above double-precision noise.
149
+ const EPSILON = 1e-12;
150
+
151
+ describe('easing family distinctness', () => {
152
+ // The direct test for the copy-paste defect, and the only one that can see it: two families whose
153
+ // bodies were pasted from each other agree everywhere, and every other assertion in this file passes
154
+ // for both. The measured closest pair is Quadratic against Sine at 5.6e-2, so this bound sits an order
155
+ // of magnitude below the nearest real neighbours and far above any rounding difference.
156
+ it.each(['easeIn', 'easeInOut', 'easeOut'] as const)('%s curves are pairwise distinct', (direction) => {
157
+ for (let a = 0; a < FAMILIES.length; a += 1) {
158
+ for (let b = a + 1; b < FAMILIES.length; b += 1) {
159
+ const first = FAMILIES[a][direction];
160
+ const second = FAMILIES[b][direction];
161
+ const separation = SAMPLES.reduce((worst, t) => Math.max(worst, Math.abs(first(t) - second(t))), 0);
162
+ expect({ pair: `${FAMILIES[a].name}/${FAMILIES[b].name}`, separated: separation > 0.005 }).toEqual({
163
+ pair: `${FAMILIES[a].name}/${FAMILIES[b].name}`,
164
+ separated: true,
165
+ });
166
+ }
167
+ }
168
+ });
169
+ });
170
+
171
+ describe('easing family endpoints', () => {
172
+ // EXACT, not approximate — except where the formula itself cannot be exact in binary floating point,
173
+ // and those two are named rather than folded into a loose tolerance for everyone. easeInSine(1) is
174
+ // 1 - cos(pi/2) and cos(pi/2) is 6.1e-17 rather than 0; easeInBack(1) is (s + 1) - s, which cancels to
175
+ // 0.9999999999999998 for s = 1.70158. Both are one ulp, both are properties of the arithmetic, and
176
+ // special-casing the code to force a rounder number would be making the curve fit the test.
177
+ const INEXACT_AT_ONE = new Set(['Back', 'Sine']);
178
+
179
+ // OBSERVED, and harmless: easeInBack(0) and easeInOutSine(0) return NEGATIVE zero, because both bodies
180
+ // end in a multiplication by zero with a negative factor. It is numerically equal to 0 and behaves
181
+ // identically everywhere an easing value is consumed; only Object.is separates them, which is what
182
+ // `toBe` uses. Normalising rather than loosening keeps the assertion EXACT for every other value.
183
+ it.each(FAMILIES)('$name: starts at exactly 0', ({ easeIn, easeInOut, easeOut }) => {
184
+ expect(normalizeZero(easeIn(0))).toBe(0);
185
+ expect(normalizeZero(easeInOut(0))).toBe(0);
186
+ expect(Math.abs(easeOut(0))).toBeLessThan(EPSILON);
187
+ });
188
+
189
+ it.each(FAMILIES)('$name: ends at 1, exactly where the arithmetic allows', ({ easeIn, easeInOut, easeOut }) => {
190
+ expect(easeOut(1)).toBe(1);
191
+ expect(easeInOut(1)).toBe(1);
192
+ if (INEXACT_AT_ONE.has(name(easeIn))) expect(easeIn(1)).toBeCloseTo(1, 15);
193
+ else expect(easeIn(1)).toBe(1);
194
+ });
195
+
196
+ function normalizeZero(value: number): number {
197
+ return value === 0 ? 0 : value;
198
+ }
199
+
200
+ function name(easeIn: EasingFunction): string {
201
+ return FAMILIES.find((family) => family.easeIn === easeIn)!.name;
202
+ }
203
+ });
204
+
205
+ describe('easing family halves', () => {
206
+ it.each(FAMILIES.filter((family) => family.halvesMatch))(
207
+ '$name: easeInOut is easeIn on the first half and easeOut on the second',
208
+ ({ easeIn, easeInOut, easeOut }) => {
209
+ for (const t of SAMPLES) {
210
+ const expected = t <= 0.5 ? easeIn(t * 2) / 2 : 0.5 + easeOut(t * 2 - 1) / 2;
211
+ expect(Math.abs(easeInOut(t) - expected)).toBeLessThan(EPSILON);
212
+ }
213
+ },
214
+ );
215
+
216
+ // The two exceptions are asserted as exceptions rather than skipped, so a later edit that "fixes" one
217
+ // of them into the scaled-halves shape fails here and has to be a deliberate change to the curve.
218
+ it.each(FAMILIES.filter((family) => !family.halvesMatch))(
219
+ '$name: easeInOut deliberately differs from the scaled halves, because its canonical constant differs',
220
+ ({ easeIn, easeInOut }) => {
221
+ const worst = SAMPLES.filter((t) => t <= 0.5).reduce(
222
+ (accumulated, t) => Math.max(accumulated, Math.abs(easeInOut(t) - easeIn(t * 2) / 2)),
223
+ 0,
224
+ );
225
+ expect(worst).toBeGreaterThan(0.01);
226
+ },
227
+ );
228
+
229
+ // Continuity asserted as the gap SHRINKING with the interval, not as the gap being small. A small-gap
230
+ // test is the wrong instrument here: Circular has a vertical tangent at the midpoint by construction —
231
+ // it is a quarter circle — so its slope there measures ~1000 against 0.58 at t=0.25, and any fixed
232
+ // bound either fails a correct curve or is loose enough to accept a real step. A genuine discontinuity
233
+ // has a gap that stops shrinking; a steep one has a gap that keeps going.
234
+ it.each(FAMILIES)('$name: easeInOut passes through the midpoint without a step', ({ easeInOut }) => {
235
+ expect(easeInOut(0.5)).toBeCloseTo(0.5, 12);
236
+ const wide = Math.abs(easeInOut(0.5 + 1e-4) - easeInOut(0.5 - 1e-4));
237
+ const narrow = Math.abs(easeInOut(0.5 + 1e-6) - easeInOut(0.5 - 1e-6));
238
+ expect(narrow).toBeLessThan(Math.max(wide / 2, 1e-9));
239
+ });
240
+ });
241
+
242
+ describe('easing family reflection', () => {
243
+ // The workhorse. easeOut is easeIn rotated 180 degrees about the centre of the unit square, and that
244
+ // holds BY DEFINITION for every family here — so a constant that differs between the two, or a body
245
+ // pasted from the wrong neighbour, breaks it at once.
246
+ it.each(FAMILIES)('$name: easeOut(t) is 1 - easeIn(1 - t) across the interval', ({ easeIn, easeOut }) => {
247
+ for (const t of SAMPLES) {
248
+ expect(Math.abs(easeOut(t) - (1 - easeIn(1 - t)))).toBeLessThan(EPSILON);
249
+ }
250
+ });
251
+ });
252
+
253
+ describe('easing family shape', () => {
254
+ it.each(FAMILIES.filter((family) => family.monotonic))('$name: easeIn never steps back', ({ easeIn }) => {
255
+ for (let index = 1; index < SAMPLES.length; index += 1) {
256
+ expect(easeIn(SAMPLES[index])).toBeGreaterThanOrEqual(easeIn(SAMPLES[index - 1]));
257
+ }
258
+ });
259
+
260
+ // The overshoot IS the family. A wrong constant removes it silently and leaves a curve that still runs
261
+ // 0 to 1 monotonically — which every endpoint assertion in this package would happily accept.
262
+ it.each([
263
+ { easeIn: easeInBack, easeOut: easeOutBack, name: 'Back' },
264
+ { easeIn: easeInElastic, easeOut: easeOutElastic, name: 'Elastic' },
265
+ ])('$name: easeIn dips below 0 and easeOut rises above 1', ({ easeIn, easeOut }) => {
266
+ expect(Math.min(...SAMPLES.map(easeIn))).toBeLessThan(-0.05);
267
+ expect(Math.max(...SAMPLES.map(easeOut))).toBeGreaterThan(1.05);
268
+ });
269
+
270
+ it('Bounce reverses without ever leaving the unit interval', () => {
271
+ const values = SAMPLES.map(easeInBounce);
272
+ expect(Math.min(...values)).toBeGreaterThanOrEqual(0);
273
+ expect(Math.max(...values)).toBeLessThanOrEqual(1);
274
+ expect(values.some((value, index) => index > 0 && value < values[index - 1])).toBe(true);
275
+ });
276
+ });