littlejsengine 1.18.1 → 1.18.2

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/test/setup.mjs ADDED
@@ -0,0 +1,22 @@
1
+ // Minimal globalThis stubs so dist/littlejs.esm.js can import under Node.
2
+ // The bundle has two top-level side effects that need host objects:
3
+ // 1. `const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;`
4
+ // 2. `let audioContext = new AudioContext;`
5
+ // Anything beyond these is on-demand inside functions we don't invoke from tests.
6
+
7
+ globalThis.window = {};
8
+
9
+ globalThis.AudioContext = class AudioContext
10
+ {
11
+ constructor() { this.currentTime = 0; this.destination = {}; this.state = 'running'; }
12
+ createGain() { return { connect(){}, gain: { value: 0 } }; }
13
+ createBuffer() { return {}; }
14
+ createBufferSource() { return { connect(){}, start(){}, stop(){} }; }
15
+ resume() { return Promise.resolve(); }
16
+ };
17
+
18
+ // Enable headless mode on the shared bundle instance. ES module caching
19
+ // means every test file that imports the bundle gets this same instance,
20
+ // so tile() / audio paths / input setup all take their headless branches.
21
+ const { setHeadlessMode } = await import('../dist/littlejs.esm.js');
22
+ setHeadlessMode(true);
@@ -0,0 +1,258 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ EngineObject, ParticleEmitter, TileLayerData, TileInfo,
5
+ TileLayer, CanvasLayer, Medal,
6
+ Timer, tile, vec2, rgb,
7
+ } from '../dist/littlejs.esm.js';
8
+
9
+ const near = (a, b, eps=1e-9) => Math.abs(a - b) <= eps;
10
+
11
+ // Tier 2-lite: verify core engine primitives can be constructed without
12
+ // crashing or tripping ASSERTs (debug bundle keeps them live). We do NOT
13
+ // boot the engine via engineInit and we do NOT call render() (which would
14
+ // require a canvas context). update() on the base EngineObject is safe
15
+ // because the default is a no-op.
16
+
17
+ test('bundle exposes core engine symbols', () =>
18
+ {
19
+ // If the bundle failed to load under our stubs, this file wouldn't
20
+ // have imported at all — so reaching here proves import succeeded.
21
+ // These checks also guard against an export regression stripping
22
+ // core names from the public surface.
23
+ assert.equal(typeof EngineObject, 'function');
24
+ assert.equal(typeof Timer, 'function');
25
+ assert.equal(typeof tile, 'function');
26
+ assert.equal(typeof vec2, 'function');
27
+ });
28
+
29
+ test('EngineObject constructs and base update is safe', () =>
30
+ {
31
+ const o = new EngineObject(vec2(0, 0), vec2(1, 1));
32
+ assert.equal(o.pos.x, 0);
33
+ assert.equal(o.pos.y, 0);
34
+ assert.equal(o.size.x, 1);
35
+ assert.equal(o.size.y, 1);
36
+ // base class update is an empty method — calling it should not throw
37
+ assert.doesNotThrow(() => o.update());
38
+ });
39
+
40
+ test('EngineObject with color and angle constructs', () =>
41
+ {
42
+ const o = new EngineObject(vec2(5, -3), vec2(2, 2), undefined, Math.PI/4, rgb(1, 0, 0));
43
+ assert.equal(o.pos.x, 5);
44
+ assert.equal(o.angle, Math.PI/4);
45
+ });
46
+
47
+ test('tile() returns a TileInfo', () =>
48
+ {
49
+ const t = tile(0, 16);
50
+ assert(t instanceof TileInfo);
51
+ });
52
+
53
+ test('ParticleEmitter constructs with defaults', () =>
54
+ {
55
+ // minimum reasonable args: pos, angle, emitSize, emitTime, emitRate
56
+ const e = new ParticleEmitter(vec2(0, 0), 0, 0, 0, 0);
57
+ assert(e instanceof ParticleEmitter);
58
+ assert(e instanceof EngineObject);
59
+ });
60
+
61
+ test('TileLayerData constructs', () =>
62
+ {
63
+ // TileLayerData(tile, direction, mirror, color) — minimal form
64
+ assert.doesNotThrow(() => new TileLayerData(0));
65
+ assert.doesNotThrow(() => new TileLayerData(5, 1, false));
66
+ });
67
+
68
+ test('Timer lifecycle (unset -> set -> elapsed check)', () =>
69
+ {
70
+ const t = new Timer();
71
+ assert.equal(t.isSet(), false);
72
+ assert.equal(t.get(), 0); // returns 0 when unset
73
+ assert.equal(t.getSetTime(), 0);
74
+ t.set(1);
75
+ assert.equal(t.isSet(), true);
76
+ assert.equal(t.getSetTime(), 1);
77
+ t.unset();
78
+ assert.equal(t.isSet(), false);
79
+ });
80
+
81
+ test('Timer constructed with duration is set', () =>
82
+ {
83
+ const t = new Timer(5);
84
+ assert.equal(t.isSet(), true);
85
+ assert.equal(t.getSetTime(), 5);
86
+ });
87
+
88
+ // The engine's `time` global stays at 0 because the main loop never runs
89
+ // in headless mode without engineInit. That lets us check time-derived
90
+ // Timer methods against known reference points.
91
+
92
+ test('Timer active / elapsed / get for a fresh positive-duration timer', () =>
93
+ {
94
+ const t = new Timer(1);
95
+ assert.equal(t.active(), true); // time(0) < setAt(1)
96
+ assert.equal(t.elapsed(), false);
97
+ assert(near(t.get(), -1)); // negative = still active
98
+ });
99
+
100
+ test('Timer active / elapsed / get for an already-elapsed timer', () =>
101
+ {
102
+ // negative duration -> timer's internal target is in the past
103
+ const t = new Timer(-2);
104
+ assert.equal(t.active(), false);
105
+ assert.equal(t.elapsed(), true);
106
+ assert(near(t.get(), 2)); // positive = how long since elapsed
107
+ });
108
+
109
+ test('Timer with zero duration is immediately elapsed', () =>
110
+ {
111
+ const t = new Timer(0);
112
+ assert.equal(t.active(), false);
113
+ assert.equal(t.elapsed(), true);
114
+ assert(near(t.get(), 0));
115
+ });
116
+
117
+ test('Timer getPercent for fresh positive-duration timer is 0', () =>
118
+ {
119
+ // at time=0 with a timer set for duration 1, no time has elapsed yet
120
+ assert(near(new Timer(1).getPercent(), 0));
121
+ });
122
+
123
+ test('Timer unset returns 0 from get/getPercent/getSetTime', () =>
124
+ {
125
+ const t = new Timer();
126
+ assert.equal(t.isSet(), false);
127
+ assert.equal(t.get(), 0);
128
+ assert.equal(t.getPercent(), 0);
129
+ assert.equal(t.getSetTime(), 0);
130
+ });
131
+
132
+ ///////////////////////////////////////////////////////////////////////////////
133
+ // EngineObject methods
134
+
135
+ test('EngineObject.setCollision applies each flag to the right slot', () =>
136
+ {
137
+ // Asymmetric patterns: each of the four (collideSolidObjects, isSolid,
138
+ // collideTiles, collideRaycast) slots gets a different value from its
139
+ // neighbors, so any pairwise swap of assignments would show up.
140
+ const o = new EngineObject(vec2(0, 0), vec2(1, 1));
141
+ // pattern A: [T, F, T, F]
142
+ o.setCollision(true, false, true, false);
143
+ assert.equal(o.collideSolidObjects, true);
144
+ assert.equal(o.isSolid, false);
145
+ assert.equal(o.collideTiles, true);
146
+ assert.equal(o.collideRaycast, false);
147
+ // pattern B: [T, T, F, T] — flips everything from A (ASSERT requires collideSolidObjects||!isSolid)
148
+ o.setCollision(true, true, false, true);
149
+ assert.equal(o.collideSolidObjects, true);
150
+ assert.equal(o.isSolid, true);
151
+ assert.equal(o.collideTiles, false);
152
+ assert.equal(o.collideRaycast, true);
153
+ // defaults: all true
154
+ o.setCollision();
155
+ assert.equal(o.collideSolidObjects, true);
156
+ assert.equal(o.isSolid, true);
157
+ assert.equal(o.collideTiles, true);
158
+ assert.equal(o.collideRaycast, true);
159
+ });
160
+
161
+ test('EngineObject.addChild / removeChild maintain bidirectional links', () =>
162
+ {
163
+ const parent = new EngineObject(vec2(0, 0), vec2(1, 1));
164
+ const child = new EngineObject(vec2(0, 0), vec2(1, 1));
165
+ const localPos = vec2(2, 3);
166
+ parent.addChild(child, localPos, 0.5);
167
+ assert.equal(child.parent, parent);
168
+ assert.deepEqual(parent.children, [child]);
169
+ assert.equal(child.localAngle, 0.5);
170
+ // localPos is stored as a copy — mutating the original doesn't affect the child
171
+ assert.equal(child.localPos.x, 2);
172
+ assert.equal(child.localPos.y, 3);
173
+ localPos.x = 999;
174
+ assert.equal(child.localPos.x, 2);
175
+ // removeChild clears parent and splices children
176
+ parent.removeChild(child);
177
+ assert.equal(child.parent, undefined);
178
+ assert.deepEqual(parent.children, []);
179
+ });
180
+
181
+ test('EngineObject.destroy is idempotent and cascades to children', () =>
182
+ {
183
+ const parent = new EngineObject(vec2(0, 0), vec2(1, 1));
184
+ const childA = new EngineObject(vec2(0, 0), vec2(1, 1));
185
+ const childB = new EngineObject(vec2(0, 0), vec2(1, 1));
186
+ parent.addChild(childA);
187
+ parent.addChild(childB);
188
+ parent.destroy();
189
+ assert.equal(parent.destroyed, true);
190
+ // children cascade-destroyed
191
+ assert.equal(childA.destroyed, true);
192
+ assert.equal(childB.destroyed, true);
193
+ // children have parent cleared
194
+ assert.equal(childA.parent, undefined);
195
+ assert.equal(childB.parent, undefined);
196
+ // idempotent — second call is a no-op (no throw)
197
+ assert.doesNotThrow(() => parent.destroy());
198
+ });
199
+
200
+ test('EngineObject.destroy disconnects from parent', () =>
201
+ {
202
+ const parent = new EngineObject(vec2(0, 0), vec2(1, 1));
203
+ const child = new EngineObject(vec2(0, 0), vec2(1, 1));
204
+ parent.addChild(child);
205
+ child.destroy();
206
+ assert.equal(child.destroyed, true);
207
+ assert.equal(child.parent, undefined);
208
+ assert.deepEqual(parent.children, []); // parent's children array spliced
209
+ });
210
+
211
+ test('EngineObject.getAliveTime is 0 right after construction (time=0 in headless)', () =>
212
+ {
213
+ const o = new EngineObject(vec2(0, 0), vec2(1, 1));
214
+ assert(near(o.getAliveTime(), 0));
215
+ });
216
+
217
+ ///////////////////////////////////////////////////////////////////////////////
218
+ // Medal
219
+
220
+ test('Medal construction sets fields and registers in medals[]', () =>
221
+ {
222
+ const m = new Medal(100, 'Test Medal', 'A test medal', '🏆');
223
+ assert.equal(m.id, 100);
224
+ assert.equal(m.name, 'Test Medal');
225
+ assert.equal(m.description, 'A test medal');
226
+ assert.equal(m.icon, '🏆');
227
+ assert.equal(m.unlocked, false);
228
+ });
229
+
230
+ test('Medal description and icon default when omitted', () =>
231
+ {
232
+ const m = new Medal(101, 'Defaults Medal');
233
+ assert.equal(m.description, '');
234
+ assert.equal(m.icon, '🏆'); // default trophy
235
+ assert.equal(m.unlocked, false);
236
+ });
237
+
238
+ ///////////////////////////////////////////////////////////////////////////////
239
+ // CanvasLayer / TileLayer
240
+
241
+ test('CanvasLayer extends EngineObject and has no canvas in headless', () =>
242
+ {
243
+ const cl = new CanvasLayer(vec2(0, 0), vec2(10, 10), 0, 0, vec2(64), false);
244
+ assert(cl instanceof EngineObject);
245
+ assert(cl instanceof CanvasLayer);
246
+ assert.equal(cl.canvas, undefined); // headless: no OffscreenCanvas created
247
+ assert.equal(cl.mass, 0); // physics disabled by default
248
+ });
249
+
250
+ test('TileLayer extends CanvasLayer and stubs render methods in headless', () =>
251
+ {
252
+ const tl = new TileLayer(vec2(0, 0), vec2(4, 3), tile(0, 16), 0, false);
253
+ assert(tl instanceof CanvasLayer);
254
+ assert(tl instanceof TileLayer);
255
+ // in headless the render-family methods are replaced with no-op arrow functions
256
+ assert.doesNotThrow(() => tl.render());
257
+ assert.doesNotThrow(() => tl.redraw());
258
+ });
@@ -0,0 +1,80 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { isNumber, isString, isArray, isVector2, isColor, vec2, rgb, Vector2, Color, formatTime } from '../dist/littlejs.esm.js';
4
+
5
+ test('isNumber', () =>
6
+ {
7
+ assert.equal(isNumber(0), true);
8
+ assert.equal(isNumber(-1.5), true);
9
+ assert.equal(isNumber(Infinity), true);
10
+ assert.equal(isNumber(NaN), false);
11
+ assert.equal(isNumber('1'), false);
12
+ assert.equal(isNumber(null), false);
13
+ assert.equal(isNumber(undefined), false);
14
+ assert.equal(isNumber({}), false);
15
+ });
16
+
17
+ test('isString', () =>
18
+ {
19
+ // Implementation is `s != null && typeof s?.toString() === 'string'`, so it
20
+ // accepts anything with a toString — including arrays, objects, numbers.
21
+ // Only null/undefined are rejected. These cases pin that (surprising) contract
22
+ // so a future "tightening" to `typeof s === 'string'` would fail here loudly.
23
+ assert.equal(isString(''), true);
24
+ assert.equal(isString('abc'), true);
25
+ assert.equal(isString(42), true);
26
+ assert.equal(isString([]), true);
27
+ assert.equal(isString({}), true);
28
+ assert.equal(isString(null), false);
29
+ assert.equal(isString(undefined), false);
30
+ });
31
+
32
+ test('isArray', () =>
33
+ {
34
+ assert.equal(isArray([]), true);
35
+ assert.equal(isArray([1, 2, 3]), true);
36
+ assert.equal(isArray({}), false);
37
+ assert.equal(isArray('abc'), false);
38
+ assert.equal(isArray(null), false);
39
+ });
40
+
41
+ test('isVector2', () =>
42
+ {
43
+ // Contract: instanceof Vector2 AND isValid() (no NaN components).
44
+ assert.equal(isVector2(vec2()), true);
45
+ assert.equal(isVector2(new Vector2(1, 2)), true);
46
+ assert.equal(isVector2({ x: 1, y: 2 }), false);
47
+ assert.equal(isVector2(null), false);
48
+ assert.equal(isVector2(undefined), false);
49
+ assert.equal(isVector2([1, 2]), false);
50
+ // post-hoc NaN mutation: still a Vector2 instance but no longer valid
51
+ const bad = vec2(1, 2); bad.x = NaN;
52
+ assert.equal(isVector2(bad), false);
53
+ });
54
+
55
+ test('isColor', () =>
56
+ {
57
+ // Contract: instanceof Color AND isValid() (no NaN components).
58
+ assert.equal(isColor(rgb()), true);
59
+ assert.equal(isColor(new Color(1, 0, 0)), true);
60
+ assert.equal(isColor({ r: 1, g: 1, b: 1, a: 1 }), false);
61
+ assert.equal(isColor(null), false);
62
+ assert.equal(isColor(undefined), false);
63
+ const bad = rgb(1, 0, 0); bad.g = NaN;
64
+ assert.equal(isColor(bad), false);
65
+ });
66
+
67
+ test('formatTime', () =>
68
+ {
69
+ assert.equal(formatTime(0), '0:00');
70
+ assert.equal(formatTime(5), '0:05');
71
+ assert.equal(formatTime(59), '0:59');
72
+ assert.equal(formatTime(60), '1:00');
73
+ assert.equal(formatTime(125), '2:05');
74
+ assert.equal(formatTime(3599), '59:59');
75
+ // fractional seconds are truncated (floor)
76
+ assert.equal(formatTime(1.9), '0:01');
77
+ // negative times get a leading minus
78
+ assert.equal(formatTime(-30), '-0:30');
79
+ assert.equal(formatTime(-125), '-2:05');
80
+ });