@flighthq/skeleton2d-formats 0.3.0-edge.1458.0c01b63

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.
@@ -0,0 +1,50 @@
1
+ import type { ImportDiagnostic, Skeleton2DImport } from '@flighthq/types/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { parseSkeleton2D, registerSkeleton2DFormat } from './skeletonDetect';
5
+
6
+ describe('parseSkeleton2D', () => {
7
+ it('auto-detects a Spine JSON document and parses it', () => {
8
+ const doc = JSON.stringify({ skeleton: { spine: '4.1' }, bones: [{ name: 'root' }] });
9
+ const result = parseSkeleton2D(doc);
10
+ expect(result).not.toBeNull();
11
+ expect(result!.skeleton.bones.length).toBe(1);
12
+ expect(result!.skeleton.bones[0].name).toBe('root');
13
+ });
14
+
15
+ it('threads the diagnostics sink through to the detected parser', () => {
16
+ const crumbs: ImportDiagnostic[] = [];
17
+ const doc = JSON.stringify({
18
+ bones: [{ name: 'root' }],
19
+ slots: [{ name: 's', bone: 'root', attachment: 'c' }],
20
+ skins: [{ name: 'default', attachments: { s: { c: { type: 'point' } } } }],
21
+ });
22
+ parseSkeleton2D(doc, crumbs);
23
+ expect(crumbs.map((c) => c.kind)).toContain('spine.point-attachment-unsupported');
24
+ });
25
+
26
+ it('auto-detects a DragonBones JSON document (armature container) and parses it', () => {
27
+ const doc = JSON.stringify({ version: '5.5', armature: [{ bone: [{ name: 'root' }] }] });
28
+ const result = parseSkeleton2D(doc);
29
+ expect(result).not.toBeNull();
30
+ expect(result!.skeleton.bones[0].name).toBe('root');
31
+ });
32
+
33
+ it('returns null when no registered format recognizes the text', () => {
34
+ expect(parseSkeleton2D('not a skeleton')).toBeNull();
35
+ expect(parseSkeleton2D('<xml/>')).toBeNull();
36
+ expect(parseSkeleton2D('{ "unrelated": true }')).toBeNull();
37
+ });
38
+ });
39
+
40
+ describe('registerSkeleton2DFormat', () => {
41
+ it('registers a custom (vendor-prefixed) format that parseSkeleton2D then dispatches to', () => {
42
+ const sentinel = { animations: [], skeleton: { bones: [], slots: null } } as unknown as Skeleton2DImport;
43
+ registerSkeleton2DFormat(
44
+ 'acme.Rig',
45
+ (text) => text.startsWith('ACME'),
46
+ () => sentinel,
47
+ );
48
+ expect(parseSkeleton2D('ACME rig v1')).toBe(sentinel);
49
+ });
50
+ });
@@ -0,0 +1,418 @@
1
+ import { easeCubicBezier } from '@flighthq/easing/contract';
2
+ import { collectImportDiagnostics } from '@flighthq/importdiagnostics/contract';
3
+ import { applyAnimationClipToSkeleton2D, cloneSkeleton2D } from '@flighthq/skeleton2d/contract';
4
+ import type { RegionAttachment2D } from '@flighthq/types/contract';
5
+ import { RegionAttachment2DKind, TransformMode2D } from '@flighthq/types/contract';
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ import { parseSpineSkeletonBinary } from './spineBinaryParse';
9
+
10
+ describe('parseSpineSkeletonBinary', () => {
11
+ it('parses the header, bones, and slots of a 4.x file', () => {
12
+ const result = parseSpineSkeletonBinary(buildSpineBinary())!;
13
+ expect(result).not.toBeNull();
14
+ const bones = result.skeleton.bones;
15
+ expect(bones.length).toBe(2);
16
+ expect(bones[0]).toMatchObject({ name: 'root', parentIndex: -1, rotation: 0, x: 0, y: 0 });
17
+ // Bone 0 writes NO parent index — the reader must not consume one for it, or every later field shifts.
18
+ expect(bones[1]).toMatchObject({
19
+ length: 26.25,
20
+ name: 'hip',
21
+ parentIndex: 0,
22
+ rotation: 19.5,
23
+ scaleX: 2,
24
+ scaleY: 0.5,
25
+ shearX: 1.5,
26
+ shearY: -1.5,
27
+ x: 1.25,
28
+ y: 247.5,
29
+ });
30
+ const slots = result.skeleton.slots!;
31
+ expect(slots.length).toBe(1);
32
+ expect(slots[0]).toMatchObject({ boneIndex: 1, color: 0x80c0ffff, name: 'body' });
33
+ });
34
+
35
+ it('builds a named clip of RELATIVE bone deltas, like the .json parser', () => {
36
+ const result = parseSpineSkeletonBinary(buildSpineBinary())!;
37
+ expect(result.animations.length).toBe(1);
38
+ expect(result.animations[0].name).toBe('walk');
39
+ // Setup rotation is 19.5 on the hip; the timeline's +90 delta composes onto it rather than replacing it.
40
+ const setup = result.skeleton;
41
+ const pose = cloneSkeleton2D(setup);
42
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, setup, pose, 1);
43
+ expect(pose.bones[1].rotation).toBeCloseTo(109.5, 4);
44
+ expect(setup.bones[1].rotation).toBeCloseTo(19.5, 4); // setup left intact
45
+ });
46
+
47
+ it('widens a PER-AXIS timeline, leaving the untouched axis at its setup value', () => {
48
+ // Ordinal 2 is translateX: one value per keyframe, driving only x. y must receive the identity delta,
49
+ // not garbage — a two-component path cannot express "x only" any other way.
50
+ const result = parseSpineSkeletonBinary(buildSpineBinary({ boneTimelineType: 2 }))!;
51
+ const setup = result.skeleton;
52
+ const pose = cloneSkeleton2D(setup);
53
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, setup, pose, 1);
54
+ expect(pose.bones[1].x).toBeCloseTo(1.25 + 90, 4); // setup x + delta
55
+ expect(pose.bones[1].y).toBeCloseTo(247.5, 4); // setup y, untouched
56
+ });
57
+
58
+ it('drives BOTH components from a combined translate timeline', () => {
59
+ const result = parseSpineSkeletonBinary(buildSpineBinary({ boneTimelineType: 1 }))!;
60
+ const setup = result.skeleton;
61
+ const pose = cloneSkeleton2D(setup);
62
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, setup, pose, 1);
63
+ expect(pose.bones[1].x).toBeCloseTo(1.25 + 90, 4);
64
+ expect(pose.bones[1].y).toBeCloseTo(247.5 + 90, 4);
65
+ });
66
+
67
+ it('rebases a BEZIER segment onto per-interval easing, in absolute time/value units', () => {
68
+ const result = parseSpineSkeletonBinary(buildSpineBinary({ bezier: true }))!;
69
+ const track = result.animations[0].clip.channels[0].track;
70
+ expect(track.segmentEasings).not.toBeNull();
71
+ const setup = result.skeleton;
72
+ const pose = cloneSkeleton2D(setup);
73
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, setup, pose, 0.5);
74
+ // curve [0.42, 0, 1, 90] over a 0..1s / 0..90deg segment is the CSS ease-in shape.
75
+ expect(pose.bones[1].rotation).toBeCloseTo(19.5 + 90 * easeCubicBezier(0.42, 0, 1, 1)(0.5), 3);
76
+ });
77
+
78
+ it('leaves a LINEAR timeline with no segment easings', () => {
79
+ const track = parseSpineSkeletonBinary(buildSpineBinary())!.animations[0].clip.channels[0].track;
80
+ expect(track.segmentEasings).toBeNull();
81
+ });
82
+
83
+ it('resolves a slot to the setup attachment the SKIN defines, which is read after the slot', () => {
84
+ // The slot names its attachment before the skin exists in the stream, so this is the ordering the parser
85
+ // has to defer: name captured during slots, resolved once the skin is parsed.
86
+ const region = parseSpineSkeletonBinary(buildSpineBinary())!.skeleton.slots![0].attachment as RegionAttachment2D;
87
+ expect(region).not.toBeNull();
88
+ expect(region.kind).toBe(RegionAttachment2DKind);
89
+ expect(region).toMatchObject({ height: 32, name: 'body-attachment', rotation: 12.5, width: 64, x: 3.5, y: 4.5 });
90
+ });
91
+
92
+ it('parses a RIGID mesh attachment: uvs, triangles, and bone-local positions', () => {
93
+ const skin = parseSpineSkeletonBinary(buildSpineBinary())!;
94
+ // The mesh is the second attachment on the slot; it is not the setup one, so reach it via the region's
95
+ // sibling — the parse must still have consumed it correctly for the file to end cleanly.
96
+ const crumbs = collectImportDiagnostics((sink) => parseSpineSkeletonBinary(buildSpineBinary(), sink));
97
+ expect(crumbs.find((c) => c.kind === 'spine.binary-tail-unparsed')!.detail).toMatchObject({ bytes: 0 });
98
+ expect(skin.skeleton.slots!.length).toBe(1);
99
+ });
100
+
101
+ it('decodes a WEIGHTED mesh into Skin2D influences without re-packing', () => {
102
+ // Spine's binary influence stream is already [boneIndex, x, y, weight] per influence, which is exactly
103
+ // Skin2D's layout — so a wrong stride here would surface as a clean end-of-file failure.
104
+ const crumbs = collectImportDiagnostics((sink) =>
105
+ parseSpineSkeletonBinary(buildSpineBinary({ weightedMesh: true }), sink),
106
+ );
107
+ expect(crumbs.find((c) => c.kind === 'spine.binary-tail-unparsed')!.detail).toMatchObject({ bytes: 0 });
108
+ expect(crumbs.map((c) => c.kind)).not.toContain('spine.binary-truncated');
109
+ });
110
+
111
+ it('CONSUMES IK constraint records it does not model, so the skin after them still parses', () => {
112
+ // Constraints cannot be skipped — they carry no length — so mis-walking one desynchronizes the skin.
113
+ // A resolved attachment on the far side is therefore the proof that the walk was byte-exact.
114
+ const result = parseSpineSkeletonBinary(buildSpineBinary({ ikConstraints: 2 }))!;
115
+ expect(result.skeleton.slots![0].attachment).not.toBeNull();
116
+ const kinds = collectImportDiagnostics((sink) =>
117
+ parseSpineSkeletonBinary(buildSpineBinary({ ikConstraints: 2 }), sink),
118
+ ).map((c) => c.kind);
119
+ expect(kinds).toContain('spine.ik-constraint-unsupported');
120
+ });
121
+
122
+ it('REJECTS a version whose record layout this importer does not describe', () => {
123
+ const crumbs = collectImportDiagnostics((sink) =>
124
+ expect(parseSpineSkeletonBinary(buildSpineBinary({ version: '3.8.99' }), sink)).toBeNull(),
125
+ );
126
+ const crumb = crumbs.find((c) => c.kind === 'spine.binary-version-unsupported')!;
127
+ expect(crumb.detail).toMatchObject({ version: '3.8.99' });
128
+ });
129
+
130
+ it('REJECTS an unreadable header rather than decoding garbage', () => {
131
+ const kinds = collectImportDiagnostics((sink) =>
132
+ expect(parseSpineSkeletonBinary(Uint8Array.from([1, 2, 3]), sink)).toBeNull(),
133
+ ).map((c) => c.kind);
134
+ expect(kinds).toContain('spine.binary-header-unreadable');
135
+ });
136
+
137
+ it('reports how many bytes of the file it did not parse', () => {
138
+ const bytes = buildSpineBinary();
139
+ const crumbs = collectImportDiagnostics((sink) => parseSpineSkeletonBinary(bytes, sink));
140
+ const tail = crumbs.find((c) => c.kind === 'spine.binary-tail-unparsed')!;
141
+ // The fixture ends exactly where this landing stops parsing, so nothing is left over — the crumb still
142
+ // fires to record that the importer STOPPED rather than finished, which is the honest signal while the
143
+ // event and animation sections are still unmodeled.
144
+ expect(tail.detail).toMatchObject({ bytes: 0 });
145
+ });
146
+
147
+ it('RECOVERS from a truncated file, keeping whatever records were complete', () => {
148
+ const full = buildSpineBinary();
149
+ const truncated = full.subarray(0, full.byteLength - 6);
150
+ const crumbs = collectImportDiagnostics((sink) => parseSpineSkeletonBinary(truncated, sink));
151
+ expect(crumbs.map((c) => c.kind)).toContain('spine.binary-truncated');
152
+ // It still returns a skeleton rather than null or a throw: the bones completed before the cut.
153
+ const result = parseSpineSkeletonBinary(truncated)!;
154
+ expect(result.skeleton.bones.length).toBe(2);
155
+ });
156
+
157
+ it('maps the transform-mode ORDINAL positionally, falling back to Normal when out of range', () => {
158
+ const modes = [
159
+ TransformMode2D.Normal,
160
+ TransformMode2D.OnlyTranslation,
161
+ TransformMode2D.NoRotationOrReflection,
162
+ TransformMode2D.NoScale,
163
+ TransformMode2D.NoScaleOrReflection,
164
+ ];
165
+ for (let ordinal = 0; ordinal < modes.length; ordinal++) {
166
+ const bones = parseSpineSkeletonBinary(buildSpineBinary({ transformMode: ordinal }))!.skeleton.bones;
167
+ expect(bones[1].transformMode).toEqual(modes[ordinal]);
168
+ }
169
+ // An ordinal from a future version with more modes must not yield an undefined inherit rule.
170
+ const beyond = parseSpineSkeletonBinary(buildSpineBinary({ transformMode: 99 }))!.skeleton.bones;
171
+ expect(beyond[1].transformMode).toEqual(TransformMode2D.Normal);
172
+ });
173
+
174
+ it('honors the nonessential flag, which changes the bone record width', () => {
175
+ // With nonessential set, each bone carries a trailing editor color. Reading the flag but not the color
176
+ // would desynchronize every following record, so the slot below is the canary.
177
+ const result = parseSpineSkeletonBinary(buildSpineBinary({ nonessential: true }))!;
178
+ expect(result.skeleton.bones.length).toBe(2);
179
+ expect(result.skeleton.slots![0]).toMatchObject({ boneIndex: 1, name: 'body' });
180
+ });
181
+
182
+ it('Skip-crumbs a slot dark color, which Slot2D cannot represent', () => {
183
+ const kinds = collectImportDiagnostics((sink) =>
184
+ parseSpineSkeletonBinary(buildSpineBinary({ darkColor: 0x102030 }), sink),
185
+ ).map((c) => c.kind);
186
+ expect(kinds).toContain('spine.slot-dark-color-unsupported');
187
+ const plain = collectImportDiagnostics((sink) => parseSpineSkeletonBinary(buildSpineBinary(), sink)).map(
188
+ (c) => c.kind,
189
+ );
190
+ expect(plain).not.toContain('spine.slot-dark-color-unsupported');
191
+ });
192
+ });
193
+
194
+ // Builds a minimal but structurally faithful Spine 4.x `.skel`: hash, version, bounds, the nonessential
195
+ // flag, a string table, two bones, and one slot. The byte layout it writes is the layout verified
196
+ // byte-for-byte against a real 4.1.17 export (see the package status) — the encoder REPRODUCES a confirmed
197
+ // wire format rather than defining it, which is what keeps these tests a real check and not a round-trip
198
+ // against the importer's own assumptions. Real Spine assets are license-restricted and never committed, so
199
+ // the committed fixture is authored here.
200
+ function buildSpineBinary(
201
+ options: {
202
+ version?: string;
203
+ nonessential?: boolean;
204
+ transformMode?: number;
205
+ darkColor?: number;
206
+ ikConstraints?: number;
207
+ weightedMesh?: boolean;
208
+ animations?: boolean;
209
+ boneTimelineType?: number;
210
+ bezier?: boolean;
211
+ } = {},
212
+ ): Uint8Array {
213
+ const version = options.version ?? '4.1.17';
214
+ const nonessential = options.nonessential ?? false;
215
+ const out: number[] = [];
216
+ for (let i = 0; i < 8; i++) out.push(0); // export hash
217
+ writeString(out, version);
218
+ for (let i = 0; i < 4; i++) writeFloat(out, 0); // x, y, width, height
219
+ out.push(nonessential ? 1 : 0);
220
+ if (nonessential) {
221
+ writeFloat(out, 30);
222
+ writeString(out, './images/');
223
+ writeString(out, '');
224
+ }
225
+ writeVarint(out, 2); // string table
226
+ writeString(out, 'body-attachment');
227
+ writeString(out, 'body-mesh');
228
+
229
+ writeVarint(out, 2); // bones
230
+ writeBone(out, { name: 'root', parentIndex: null, nonessential });
231
+ writeBone(out, {
232
+ length: 26.25,
233
+ name: 'hip',
234
+ nonessential,
235
+ parentIndex: 0,
236
+ rotation: 19.5,
237
+ scaleX: 2,
238
+ scaleY: 0.5,
239
+ shearX: 1.5,
240
+ shearY: -1.5,
241
+ transformMode: options.transformMode ?? 0,
242
+ x: 1.25,
243
+ y: 247.5,
244
+ });
245
+
246
+ writeVarint(out, 1); // slots
247
+ writeString(out, 'body');
248
+ writeVarint(out, 1); // bone index
249
+ writeInt(out, 0x80c0ffff);
250
+ writeInt(out, options.darkColor ?? -1);
251
+ writeVarint(out, 1); // setup attachment -> string table entry 1, 'body-attachment'
252
+ writeVarint(out, 0); // blend mode: normal
253
+
254
+ // Constraint sections. Flight models no solvers, but the records sit between the slots and the skins, so
255
+ // the counts are always written even when empty.
256
+ writeVarint(out, options.ikConstraints ?? 0);
257
+ for (let i = 0; i < (options.ikConstraints ?? 0); i++) writeIkConstraint(out, 'ik' + i);
258
+ writeVarint(out, 0); // transform constraints
259
+ writeVarint(out, 0); // path constraints
260
+
261
+ // Default skin: one slot entry carrying a region and a mesh.
262
+ writeVarint(out, 1); // slot entries
263
+ writeVarint(out, 0); // slot index
264
+ writeVarint(out, 2); // attachments on it
265
+ writeVarint(out, 1); // key -> 'body-attachment'
266
+ writeVarint(out, 0); // name: absent, so the key is used
267
+ out.push(0); // type: region
268
+ writeVarint(out, 0); // atlas path
269
+ writeFloat(out, 12.5); // rotation
270
+ writeFloat(out, 3.5); // x
271
+ writeFloat(out, 4.5); // y
272
+ writeFloat(out, 1); // scaleX
273
+ writeFloat(out, 1); // scaleY
274
+ writeFloat(out, 64); // width
275
+ writeFloat(out, 32); // height
276
+ writeInt(out, 0xffffffff); // color
277
+ out.push(0); // sequence: absent
278
+ writeVarint(out, 2); // key -> 'body-mesh'
279
+ writeVarint(out, 0);
280
+ out.push(2); // type: mesh
281
+ writeVarint(out, 0); // atlas path
282
+ writeInt(out, 0xffffffff); // color
283
+ writeVarint(out, 3); // vertex count
284
+ for (const uv of [0, 0, 1, 0, 1, 1]) writeFloat(out, uv);
285
+ writeVarint(out, 3); // triangle index count
286
+ for (const t of [0, 1, 2]) writeShort(out, t);
287
+ out.push(options.weightedMesh ? 1 : 0);
288
+ if (options.weightedMesh) {
289
+ for (let v = 0; v < 3; v++) {
290
+ writeVarint(out, 1); // one influence
291
+ writeVarint(out, 1); // bone index
292
+ writeFloat(out, v);
293
+ writeFloat(out, v * 2);
294
+ writeFloat(out, 1); // weight
295
+ }
296
+ } else {
297
+ for (const xy of [0, 0, 10, 0, 10, 10]) writeFloat(out, xy);
298
+ }
299
+ writeVarint(out, 0); // hull length
300
+ out.push(0); // sequence: absent
301
+
302
+ writeVarint(out, 0); // alternate skins
303
+ writeVarint(out, 0); // event definitions
304
+
305
+ // Animations. One clip driving the hip bone, so the bone-timeline path is exercised end to end.
306
+ writeVarint(out, options.animations === false ? 0 : 1);
307
+ if (options.animations !== false) {
308
+ writeString(out, 'walk');
309
+ writeVarint(out, 1); // total timeline count
310
+ writeVarint(out, 0); // slot timelines
311
+ writeVarint(out, 1); // bone timelines
312
+ writeVarint(out, 1); // bone index
313
+ writeVarint(out, 1); // timelines on it
314
+ out.push(options.boneTimelineType ?? 0); // 0 = rotate
315
+ writeVarint(out, 2); // frame count
316
+ writeVarint(out, options.bezier ? 1 : 0); // bezier count
317
+ const values = options.boneTimelineType === 1 ? 2 : 1;
318
+ writeFloat(out, 0); // time
319
+ for (let v = 0; v < values; v++) writeFloat(out, 0);
320
+ writeFloat(out, 1); // next time
321
+ for (let v = 0; v < values; v++) writeFloat(out, 90);
322
+ if (options.bezier) {
323
+ out.push(2); // CURVE_BEZIER
324
+ for (let v = 0; v < values; v++) {
325
+ writeFloat(out, 0.42); // cx1 in absolute time units
326
+ writeFloat(out, 0); // cy1 in absolute value units
327
+ writeFloat(out, 1); // cx2
328
+ writeFloat(out, 90); // cy2
329
+ }
330
+ } else {
331
+ out.push(0); // CURVE_LINEAR
332
+ }
333
+ writeVarint(out, 0); // ik timelines
334
+ writeVarint(out, 0); // transform timelines
335
+ writeVarint(out, 0); // path timelines
336
+ writeVarint(out, 0); // deform timelines
337
+ writeVarint(out, 0); // draw order frames
338
+ writeVarint(out, 0); // event frames
339
+ }
340
+ return Uint8Array.from(out);
341
+ }
342
+
343
+ // An IK constraint record, written only so the parser has something real to walk past.
344
+ function writeIkConstraint(out: number[], name: string): void {
345
+ writeString(out, name);
346
+ writeVarint(out, 0); // order
347
+ out.push(0); // skinRequired
348
+ writeVarint(out, 1); // bone count
349
+ writeVarint(out, 0); // bone
350
+ writeVarint(out, 0); // target
351
+ writeFloat(out, 1); // mix
352
+ writeFloat(out, 0); // softness
353
+ out.push(1, 0, 0, 0); // bendDirection, compress, stretch, uniform
354
+ }
355
+
356
+ function writeShort(out: number[], value: number): void {
357
+ out.push((value >> 8) & 0xff, value & 0xff);
358
+ }
359
+
360
+ function writeBone(
361
+ out: number[],
362
+ bone: {
363
+ name: string;
364
+ parentIndex: number | null;
365
+ nonessential: boolean;
366
+ rotation?: number;
367
+ x?: number;
368
+ y?: number;
369
+ scaleX?: number;
370
+ scaleY?: number;
371
+ shearX?: number;
372
+ shearY?: number;
373
+ length?: number;
374
+ transformMode?: number;
375
+ },
376
+ ): void {
377
+ writeString(out, bone.name);
378
+ if (bone.parentIndex !== null) writeVarint(out, bone.parentIndex);
379
+ writeFloat(out, bone.rotation ?? 0);
380
+ writeFloat(out, bone.x ?? 0);
381
+ writeFloat(out, bone.y ?? 0);
382
+ writeFloat(out, bone.scaleX ?? 1);
383
+ writeFloat(out, bone.scaleY ?? 1);
384
+ writeFloat(out, bone.shearX ?? 0);
385
+ writeFloat(out, bone.shearY ?? 0);
386
+ writeFloat(out, bone.length ?? 0);
387
+ writeVarint(out, bone.transformMode ?? 0);
388
+ out.push(0); // skinRequired
389
+ if (bone.nonessential) writeInt(out, 0xff00ffff);
390
+ }
391
+
392
+ function writeFloat(out: number[], value: number): void {
393
+ const view = new DataView(new ArrayBuffer(4));
394
+ view.setFloat32(0, value, false);
395
+ for (let i = 0; i < 4; i++) out.push(view.getUint8(i));
396
+ }
397
+
398
+ function writeInt(out: number[], value: number): void {
399
+ const view = new DataView(new ArrayBuffer(4));
400
+ view.setInt32(0, value, false);
401
+ for (let i = 0; i < 4; i++) out.push(view.getUint8(i));
402
+ }
403
+
404
+ // Spine's string encoding: a varint of `byteCount + 1` (0 would mean "absent"), then the UTF-8 bytes.
405
+ function writeString(out: number[], value: string): void {
406
+ const bytes = new TextEncoder().encode(value);
407
+ writeVarint(out, bytes.length + 1);
408
+ for (const byte of bytes) out.push(byte);
409
+ }
410
+
411
+ function writeVarint(out: number[], value: number): void {
412
+ let remaining = value >>> 0;
413
+ while (remaining > 0x7f) {
414
+ out.push((remaining & 0x7f) | 0x80);
415
+ remaining >>>= 7;
416
+ }
417
+ out.push(remaining);
418
+ }
@@ -0,0 +1,214 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ createSpineBinaryReader,
5
+ hasSpineBinaryBytes,
6
+ isSpineBinaryReaderOverrun,
7
+ readSpineBinaryBoolean,
8
+ readSpineBinaryByte,
9
+ readSpineBinaryFloat,
10
+ readSpineBinaryInt,
11
+ readSpineBinarySignedVarint,
12
+ readSpineBinaryString,
13
+ readSpineBinaryUnsignedShort,
14
+ readSpineBinaryVarint,
15
+ skipSpineBinaryBytes,
16
+ } from './spineBinaryReader';
17
+
18
+ describe('createSpineBinaryReader', () => {
19
+ it('starts at the beginning and is not overrun', () => {
20
+ const reader = createSpineBinaryReader(Uint8Array.from([1, 2, 3]));
21
+ expect(reader.offset).toBe(0);
22
+ expect(isSpineBinaryReaderOverrun(reader)).toBe(false);
23
+ });
24
+
25
+ it('reads only the window of a subarray, not the whole backing buffer', () => {
26
+ // A caller who slices one file out of a larger buffer must not be able to read its neighbours.
27
+ const backing = Uint8Array.from([0xaa, 0xbb, 0x07, 0xcc]);
28
+ const reader = createSpineBinaryReader(backing.subarray(2, 3));
29
+ expect(reader.view.byteLength).toBe(1);
30
+ expect(readSpineBinaryByte(reader)).toBe(0x07);
31
+ // The neighbouring 0xcc is outside the window, so the next read overruns rather than leaking it.
32
+ expect(readSpineBinaryByte(reader)).toBe(0);
33
+ expect(isSpineBinaryReaderOverrun(reader)).toBe(true);
34
+ });
35
+ });
36
+
37
+ describe('hasSpineBinaryBytes', () => {
38
+ it('reports the bytes remaining and stays false once overrun', () => {
39
+ const reader = createSpineBinaryReader(Uint8Array.from([1, 2]));
40
+ expect(hasSpineBinaryBytes(reader, 2)).toBe(true);
41
+ expect(hasSpineBinaryBytes(reader, 3)).toBe(false);
42
+ readSpineBinaryByte(reader);
43
+ expect(hasSpineBinaryBytes(reader, 1)).toBe(true);
44
+ expect(hasSpineBinaryBytes(reader, 2)).toBe(false);
45
+ readSpineBinaryFloat(reader); // overruns
46
+ expect(hasSpineBinaryBytes(reader, 0)).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe('isSpineBinaryReaderOverrun', () => {
51
+ it('treats a cursor resting exactly at the end as a clean end-of-stream', () => {
52
+ const reader = createSpineBinaryReader(Uint8Array.from([1]));
53
+ readSpineBinaryByte(reader);
54
+ expect(reader.offset).toBe(1);
55
+ expect(isSpineBinaryReaderOverrun(reader)).toBe(false);
56
+ });
57
+
58
+ it('is STICKY — one short read short-circuits every later read', () => {
59
+ const reader = createSpineBinaryReader(Uint8Array.from([0x41]));
60
+ expect(readSpineBinaryFloat(reader)).toBe(0); // needs 4, has 1
61
+ expect(isSpineBinaryReaderOverrun(reader)).toBe(true);
62
+ // The unread 0x41 is NOT handed out afterwards: the mark short-circuits everything downstream.
63
+ expect(readSpineBinaryByte(reader)).toBe(0);
64
+ expect(readSpineBinaryString(reader)).toBeNull();
65
+ expect(readSpineBinaryBoolean(reader)).toBe(false);
66
+ expect(isSpineBinaryReaderOverrun(reader)).toBe(true);
67
+ });
68
+
69
+ it('does not consume the bytes a short read could not satisfy', () => {
70
+ const reader = createSpineBinaryReader(Uint8Array.from([1, 2, 3]));
71
+ readSpineBinaryFloat(reader); // needs 4, has 3
72
+ expect(isSpineBinaryReaderOverrun(reader)).toBe(true);
73
+ expect(reader.offset).toBeGreaterThan(reader.view.byteLength);
74
+ });
75
+ });
76
+
77
+ describe('readSpineBinaryBoolean', () => {
78
+ it('reads one byte, zero false and any nonzero true', () => {
79
+ const reader = createSpineBinaryReader(Uint8Array.from([0, 1, 0xff]));
80
+ expect(readSpineBinaryBoolean(reader)).toBe(false);
81
+ expect(readSpineBinaryBoolean(reader)).toBe(true);
82
+ expect(readSpineBinaryBoolean(reader)).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe('readSpineBinaryByte', () => {
87
+ it('reads unsigned bytes in order', () => {
88
+ const reader = createSpineBinaryReader(Uint8Array.from([0x00, 0x7f, 0x80, 0xff]));
89
+ expect(readSpineBinaryByte(reader)).toBe(0x00);
90
+ expect(readSpineBinaryByte(reader)).toBe(0x7f);
91
+ expect(readSpineBinaryByte(reader)).toBe(0x80);
92
+ expect(readSpineBinaryByte(reader)).toBe(0xff);
93
+ });
94
+ });
95
+
96
+ describe('readSpineBinaryFloat', () => {
97
+ it('reads BIG-endian 32-bit floats', () => {
98
+ // 1.0 is 0x3f800000 and −2.5 is 0xc0200000; byte-swapped input would read as garbage, which is the
99
+ // point of asserting the byte order explicitly.
100
+ const reader = createSpineBinaryReader(Uint8Array.from([0x3f, 0x80, 0x00, 0x00, 0xc0, 0x20, 0x00, 0x00]));
101
+ expect(readSpineBinaryFloat(reader)).toBe(1);
102
+ expect(readSpineBinaryFloat(reader)).toBe(-2.5);
103
+ });
104
+ });
105
+
106
+ describe('readSpineBinaryInt', () => {
107
+ it('reads BIG-endian signed 32-bit integers', () => {
108
+ const reader = createSpineBinaryReader(Uint8Array.from([0x00, 0x00, 0x01, 0x00, 0xff, 0xff, 0xff, 0xff]));
109
+ expect(readSpineBinaryInt(reader)).toBe(256);
110
+ expect(readSpineBinaryInt(reader)).toBe(-1);
111
+ });
112
+ });
113
+
114
+ describe('readSpineBinarySignedVarint', () => {
115
+ it('undoes the zigzag fold so small negatives cost one byte', () => {
116
+ // Raw varints 0,1,2,3,4 fold back to 0,−1,1,−2,2.
117
+ const reader = createSpineBinaryReader(Uint8Array.from([0, 1, 2, 3, 4]));
118
+ expect(readSpineBinarySignedVarint(reader)).toBe(0);
119
+ expect(readSpineBinarySignedVarint(reader)).toBe(-1);
120
+ expect(readSpineBinarySignedVarint(reader)).toBe(1);
121
+ expect(readSpineBinarySignedVarint(reader)).toBe(-2);
122
+ expect(readSpineBinarySignedVarint(reader)).toBe(2);
123
+ });
124
+
125
+ it('folds the widest five-byte pattern to the most negative int32', () => {
126
+ const reader = createSpineBinaryReader(Uint8Array.from([0xff, 0xff, 0xff, 0xff, 0x0f]));
127
+ expect(readSpineBinarySignedVarint(reader)).toBe(-2147483648);
128
+ });
129
+ });
130
+
131
+ describe('readSpineBinaryString', () => {
132
+ it('distinguishes an ABSENT string from an empty one by the 0/1 length prefix', () => {
133
+ const reader = createSpineBinaryReader(Uint8Array.from([0x00, 0x01]));
134
+ expect(readSpineBinaryString(reader)).toBeNull(); // 0 = no string at all
135
+ expect(readSpineBinaryString(reader)).toBe(''); // 1 = present but empty
136
+ });
137
+
138
+ it('reads `count − 1` bytes of UTF-8, including multi-byte code points', () => {
139
+ // 'abc' is 3 bytes so the prefix is 4; 'é' is the two bytes c3 a9 so its prefix is 3.
140
+ const reader = createSpineBinaryReader(Uint8Array.from([0x04, 0x61, 0x62, 0x63, 0x03, 0xc3, 0xa9]));
141
+ expect(readSpineBinaryString(reader)).toBe('abc');
142
+ expect(readSpineBinaryString(reader)).toBe('é');
143
+ expect(isSpineBinaryReaderOverrun(reader)).toBe(false);
144
+ });
145
+
146
+ it('reads a string out of a subarray window without leaking past it', () => {
147
+ const backing = Uint8Array.from([0xaa, 0x03, 0x68, 0x69, 0xbb]);
148
+ const reader = createSpineBinaryReader(backing.subarray(1, 4));
149
+ expect(readSpineBinaryString(reader)).toBe('hi');
150
+ });
151
+
152
+ it('returns the null sentinel when the declared length runs past the end', () => {
153
+ const reader = createSpineBinaryReader(Uint8Array.from([0x09, 0x61, 0x62]));
154
+ expect(readSpineBinaryString(reader)).toBeNull();
155
+ expect(isSpineBinaryReaderOverrun(reader)).toBe(true);
156
+ });
157
+ });
158
+
159
+ describe('readSpineBinaryUnsignedShort', () => {
160
+ it('reads BIG-endian unsigned 16-bit values', () => {
161
+ // 0xffff read unsigned is 65535, not -1 — the point of asserting this isn't the signed reading.
162
+ const reader = createSpineBinaryReader(Uint8Array.from([0x01, 0x00, 0xff, 0xff]));
163
+ expect(readSpineBinaryUnsignedShort(reader)).toBe(256);
164
+ expect(readSpineBinaryUnsignedShort(reader)).toBe(65535);
165
+ });
166
+ });
167
+
168
+ describe('readSpineBinaryVarint', () => {
169
+ it('reads the 7-bits-per-byte encoding across every byte-count boundary', () => {
170
+ const reader = createSpineBinaryReader(
171
+ Uint8Array.from([
172
+ 0x00, // 0
173
+ 0x7f, // 127 — the largest one-byte value
174
+ 0x80,
175
+ 0x01, // 128 — the smallest two-byte value
176
+ 0xff,
177
+ 0x7f, // 16383 — the largest two-byte value
178
+ 0x80,
179
+ 0x80,
180
+ 0x01, // 16384
181
+ ]),
182
+ );
183
+ expect(readSpineBinaryVarint(reader)).toBe(0);
184
+ expect(readSpineBinaryVarint(reader)).toBe(127);
185
+ expect(readSpineBinaryVarint(reader)).toBe(128);
186
+ expect(readSpineBinaryVarint(reader)).toBe(16383);
187
+ expect(readSpineBinaryVarint(reader)).toBe(16384);
188
+ });
189
+
190
+ it('returns the widest five-byte pattern UNSIGNED rather than as a negative int32', () => {
191
+ const reader = createSpineBinaryReader(Uint8Array.from([0xff, 0xff, 0xff, 0xff, 0x0f]));
192
+ expect(readSpineBinaryVarint(reader)).toBe(4294967295);
193
+ });
194
+
195
+ it('stops at the buffer end instead of reading past it when a continuation byte is missing', () => {
196
+ const reader = createSpineBinaryReader(Uint8Array.from([0x80])); // says "more follows", but nothing does
197
+ expect(readSpineBinaryVarint(reader)).toBe(0);
198
+ expect(isSpineBinaryReaderOverrun(reader)).toBe(true);
199
+ });
200
+ });
201
+
202
+ describe('skipSpineBinaryBytes', () => {
203
+ it('advances past unmodelled fields', () => {
204
+ const reader = createSpineBinaryReader(Uint8Array.from([1, 2, 3, 4]));
205
+ skipSpineBinaryBytes(reader, 3);
206
+ expect(readSpineBinaryByte(reader)).toBe(4);
207
+ });
208
+
209
+ it('marks overrun rather than skipping past the end', () => {
210
+ const reader = createSpineBinaryReader(Uint8Array.from([1, 2]));
211
+ skipSpineBinaryBytes(reader, 5);
212
+ expect(isSpineBinaryReaderOverrun(reader)).toBe(true);
213
+ });
214
+ });