phaser-hooks 0.3.0 → 0.5.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.
@@ -1,7 +1,6 @@
1
- /* eslint-disable max-lines-per-function */
2
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
3
- import { buildSceneMock } from "../test/scene-mock";
4
- import { withLocalState } from "./with-local-state";
2
+ import { buildSceneMock } from '../test/scene-mock';
3
+ import { withLocalState } from './with-local-state';
5
4
  describe('withLocalState', () => {
6
5
  const baseState = {
7
6
  life: 100,
@@ -11,8 +10,8 @@ describe('withLocalState', () => {
11
10
  });
12
11
  describe('validation', () => {
13
12
  it('should throw an error if the scene is not provided', () => {
14
- // @ts-expect-error - we want to test the error case
15
- expect(() => withLocalState(null, 'test-state', baseState)).toThrow('[withLocalState] Scene parameter is required');
13
+ const scene = null;
14
+ expect(() => withLocalState(scene, 'test-state', baseState)).toThrow('[withLocalState] Scene parameter is required');
16
15
  });
17
16
  it('should throw an error if the data manager is not provided', () => {
18
17
  const scene = buildSceneMock();
@@ -43,7 +42,10 @@ describe('withLocalState', () => {
43
42
  withLocalState(scene, key, initialState);
44
43
  expect(setSpy).toHaveBeenCalledWith(`phaser-hooks:local:test-scene:${key}`, initialState);
45
44
  // second call should not set state
46
- const hook = withLocalState(scene, key, { ...baseState, life: 100 });
45
+ const hook = withLocalState(scene, key, {
46
+ ...baseState,
47
+ life: 100,
48
+ });
47
49
  expect(setSpy).toHaveBeenCalledTimes(1);
48
50
  expect(hook.get()).toEqual(initialState);
49
51
  setSpy.mockRestore();
@@ -66,6 +68,606 @@ describe('withLocalState', () => {
66
68
  expect(hook.get()).toEqual({ ...baseState, life: 90 });
67
69
  });
68
70
  });
71
+ describe('set with updater function', () => {
72
+ describe('object state', () => {
73
+ it('should update state using updater function', () => {
74
+ const scene = buildSceneMock();
75
+ const key = `test-state-${Date.now()}`;
76
+ const initialState = { ...baseState, life: 100 };
77
+ const hook = withLocalState(scene, key, initialState);
78
+ hook.set((currentState) => ({
79
+ ...currentState,
80
+ life: currentState.life - 10,
81
+ }));
82
+ expect(hook.get()).toEqual({ ...baseState, life: 90 });
83
+ });
84
+ it('should update state using updater function multiple times', () => {
85
+ const scene = buildSceneMock();
86
+ const key = `test-state-${Date.now()}`;
87
+ const initialState = { ...baseState, life: 100 };
88
+ const hook = withLocalState(scene, key, initialState);
89
+ // First update
90
+ hook.set((currentState) => ({
91
+ ...currentState,
92
+ life: currentState.life - 20,
93
+ }));
94
+ expect(hook.get()).toEqual({ ...baseState, life: 80 });
95
+ // Second update
96
+ hook.set((currentState) => ({
97
+ ...currentState,
98
+ life: currentState.life + 5,
99
+ }));
100
+ expect(hook.get()).toEqual({ ...baseState, life: 85 });
101
+ });
102
+ it('should work with complex updater logic', () => {
103
+ const scene = buildSceneMock();
104
+ const key = `test-state-${Date.now()}`;
105
+ const initialState = { ...baseState, life: 100 };
106
+ const hook = withLocalState(scene, key, initialState);
107
+ hook.set((currentState) => ({
108
+ ...currentState,
109
+ life: Math.min(currentState.life + 30, 100),
110
+ }));
111
+ expect(hook.get()).toEqual({ ...baseState, life: 100 });
112
+ });
113
+ it('should trigger change events when using updater function', () => {
114
+ const scene = buildSceneMock();
115
+ const key = `test-state-${Date.now()}`;
116
+ const initialState = { ...baseState, life: 100 };
117
+ const hook = withLocalState(scene, key, initialState);
118
+ const callback = vi.fn();
119
+ hook.on('change', callback);
120
+ hook.set((currentState) => ({
121
+ ...currentState,
122
+ life: currentState.life - 15,
123
+ }));
124
+ expect(callback).toHaveBeenCalledWith(expect.anything(), // Phaser adds an extra parameter
125
+ { ...baseState, life: 85 }, { ...baseState, life: 100 });
126
+ });
127
+ });
128
+ describe('primitive state', () => {
129
+ it('should work with number state', () => {
130
+ const scene = buildSceneMock();
131
+ const key = `test-state-${Date.now()}`;
132
+ const initialState = 100;
133
+ const hook = withLocalState(scene, key, initialState);
134
+ hook.set((currentValue) => currentValue + 10);
135
+ expect(hook.get()).toEqual(110);
136
+ hook.set((currentValue) => currentValue * 2);
137
+ expect(hook.get()).toEqual(220);
138
+ });
139
+ it('should work with string state', () => {
140
+ const scene = buildSceneMock();
141
+ const key = `test-state-${Date.now()}`;
142
+ const initialState = 'hello';
143
+ const hook = withLocalState(scene, key, initialState);
144
+ hook.set((currentValue) => `${currentValue} world`);
145
+ expect(hook.get()).toEqual('hello world');
146
+ hook.set((currentValue) => currentValue.toUpperCase());
147
+ expect(hook.get()).toEqual('HELLO WORLD');
148
+ });
149
+ it('should work with boolean state', () => {
150
+ const scene = buildSceneMock();
151
+ const key = `test-state-${Date.now()}`;
152
+ const initialState = false;
153
+ const hook = withLocalState(scene, key, initialState);
154
+ hook.set((currentValue) => !currentValue);
155
+ expect(hook.get()).toEqual(true);
156
+ hook.set((currentValue) => !currentValue);
157
+ expect(hook.get()).toEqual(false);
158
+ });
159
+ it('should work with array state', () => {
160
+ const scene = buildSceneMock();
161
+ const key = `test-state-${Date.now()}`;
162
+ const initialState = [1, 2, 3];
163
+ const hook = withLocalState(scene, key, initialState);
164
+ hook.set((currentValue) => [...currentValue, 4]);
165
+ expect(hook.get()).toEqual([1, 2, 3, 4]);
166
+ hook.set((currentValue) => currentValue.map(x => x * 2));
167
+ expect(hook.get()).toEqual([2, 4, 6, 8]);
168
+ });
169
+ });
170
+ describe('validator with updater function', () => {
171
+ it('should validate the result of updater function', () => {
172
+ const scene = buildSceneMock();
173
+ const key = `test-state-${Date.now()}`;
174
+ const initialState = { ...baseState, life: 50 };
175
+ const validator = vi.fn().mockImplementation((value) => {
176
+ return value.life >= 0 && value.life <= 100 ? true : 'Invalid life value';
177
+ });
178
+ const hook = withLocalState(scene, key, initialState, {
179
+ validator,
180
+ });
181
+ // Valid updater function
182
+ hook.set((currentState) => ({
183
+ ...currentState,
184
+ life: currentState.life + 20,
185
+ }));
186
+ expect(hook.get()).toEqual({ ...baseState, life: 70 });
187
+ expect(validator).toHaveBeenCalledWith({ ...baseState, life: 70 });
188
+ // Invalid updater function result
189
+ expect(() => hook.set((currentState) => ({
190
+ ...currentState,
191
+ life: currentState.life + 50, // This would make life = 120, which is invalid
192
+ }))).toThrow('[withStateDef] Invalid life value');
193
+ });
194
+ it('should not update state when updater function result is invalid', () => {
195
+ const scene = buildSceneMock();
196
+ const key = `test-state-${Date.now()}`;
197
+ const initialState = { ...baseState, life: 50 };
198
+ const validator = vi.fn().mockImplementation((value) => {
199
+ return value.life >= 0 && value.life <= 100 ? true : 'Invalid life value';
200
+ });
201
+ const hook = withLocalState(scene, key, initialState, {
202
+ validator,
203
+ });
204
+ // Try to set invalid value via updater function
205
+ expect(() => hook.set((currentState) => ({
206
+ ...currentState,
207
+ life: 150, // Invalid value
208
+ }))).toThrow('[withStateDef] Invalid life value');
209
+ // State should remain unchanged
210
+ expect(hook.get()).toEqual(initialState);
211
+ });
212
+ });
213
+ describe('edge cases', () => {
214
+ it('should handle updater function that returns the same reference', () => {
215
+ const scene = buildSceneMock();
216
+ const key = `test-state-${Date.now()}`;
217
+ const initialState = { ...baseState, life: 100 };
218
+ const hook = withLocalState(scene, key, initialState);
219
+ const callback = vi.fn();
220
+ hook.on('change', callback);
221
+ // Updater function that returns the same object reference
222
+ hook.set((currentState) => currentState);
223
+ // Should still trigger change event (Phaser's registry will detect the change)
224
+ expect(callback).toHaveBeenCalled();
225
+ });
226
+ it('should handle updater function with complex logic', () => {
227
+ const scene = buildSceneMock();
228
+ const key = `test-state-${Date.now()}`;
229
+ const initialState = { ...baseState, life: 100 };
230
+ const hook = withLocalState(scene, key, initialState);
231
+ hook.set((currentState) => {
232
+ const newLife = currentState.life - 10;
233
+ if (newLife < 0) {
234
+ return { ...currentState, life: 0 };
235
+ }
236
+ return { ...currentState, life: newLife };
237
+ });
238
+ expect(hook.get()).toEqual({ ...baseState, life: 90 });
239
+ });
240
+ it('should work with nested object updates', () => {
241
+ const scene = buildSceneMock();
242
+ const key = `test-state-${Date.now()}`;
243
+ const initialState = {
244
+ player: {
245
+ stats: { hp: 100, mp: 50 },
246
+ level: 1,
247
+ },
248
+ };
249
+ const hook = withLocalState(scene, key, initialState);
250
+ hook.set((currentState) => ({
251
+ ...currentState,
252
+ player: {
253
+ ...currentState.player,
254
+ stats: {
255
+ ...currentState.player.stats,
256
+ hp: currentState.player.stats.hp - 20,
257
+ },
258
+ },
259
+ }));
260
+ expect(hook.get()).toEqual({
261
+ player: {
262
+ stats: { hp: 80, mp: 50 },
263
+ level: 1,
264
+ },
265
+ });
266
+ });
267
+ });
268
+ });
269
+ describe('patch method', () => {
270
+ describe('object state patching', () => {
271
+ it('should patch object state with partial updates', () => {
272
+ const scene = buildSceneMock();
273
+ const key = `test-state-${Date.now()}`;
274
+ const initialState = {
275
+ life: 100,
276
+ mana: 50,
277
+ level: 1,
278
+ stats: {
279
+ strength: 10,
280
+ agility: 8,
281
+ intelligence: 12
282
+ }
283
+ };
284
+ const hook = withLocalState(scene, key, initialState);
285
+ // Patch only specific properties
286
+ hook.patch({ life: 90 });
287
+ expect(hook.get()).toEqual({
288
+ life: 90,
289
+ mana: 50,
290
+ level: 1,
291
+ stats: {
292
+ strength: 10,
293
+ agility: 8,
294
+ intelligence: 12
295
+ }
296
+ });
297
+ // Patch multiple properties
298
+ hook.patch({ mana: 75, level: 2 });
299
+ expect(hook.get()).toEqual({
300
+ life: 90,
301
+ mana: 75,
302
+ level: 2,
303
+ stats: {
304
+ strength: 10,
305
+ agility: 8,
306
+ intelligence: 12
307
+ }
308
+ });
309
+ });
310
+ it('should patch nested object properties', () => {
311
+ const scene = buildSceneMock();
312
+ const key = `test-state-${Date.now()}`;
313
+ const initialState = {
314
+ player: {
315
+ name: 'Hero',
316
+ stats: {
317
+ hp: 100,
318
+ mp: 50,
319
+ level: 1
320
+ },
321
+ inventory: ['sword', 'potion']
322
+ },
323
+ game: {
324
+ score: 0,
325
+ difficulty: 'normal'
326
+ }
327
+ };
328
+ const hook = withLocalState(scene, key, initialState);
329
+ // Patch nested stats
330
+ hook.patch({
331
+ player: {
332
+ stats: {
333
+ hp: 80
334
+ }
335
+ }
336
+ });
337
+ expect(hook.get()).toEqual({
338
+ player: {
339
+ name: 'Hero',
340
+ stats: {
341
+ hp: 80,
342
+ mp: 50,
343
+ level: 1
344
+ },
345
+ inventory: ['sword', 'potion']
346
+ },
347
+ game: {
348
+ score: 0,
349
+ difficulty: 'normal'
350
+ }
351
+ });
352
+ });
353
+ it('should work with updater function for patching', () => {
354
+ const scene = buildSceneMock();
355
+ const key = `test-state-${Date.now()}`;
356
+ const initialState = {
357
+ life: 100,
358
+ mana: 50,
359
+ level: 1
360
+ };
361
+ const hook = withLocalState(scene, key, initialState);
362
+ // Use updater function to patch
363
+ hook.patch((currentState) => ({
364
+ life: currentState.life - 10,
365
+ level: currentState.level + 1
366
+ }));
367
+ expect(hook.get()).toEqual({
368
+ life: 90,
369
+ mana: 50,
370
+ level: 2
371
+ });
372
+ });
373
+ it('should trigger change events when patching', () => {
374
+ const scene = buildSceneMock();
375
+ const key = `test-state-${Date.now()}`;
376
+ const initialState = {
377
+ life: 100,
378
+ mana: 50,
379
+ level: 1
380
+ };
381
+ const hook = withLocalState(scene, key, initialState);
382
+ const callback = vi.fn();
383
+ hook.on('change', callback);
384
+ hook.patch({ life: 90 });
385
+ expect(callback).toHaveBeenCalledWith(expect.anything(), // Phaser adds an extra parameter
386
+ { life: 90, mana: 50, level: 1 }, { life: 100, mana: 50, level: 1 });
387
+ });
388
+ it('should preserve other properties when patching', () => {
389
+ const scene = buildSceneMock();
390
+ const key = `test-state-${Date.now()}`;
391
+ const initialState = {
392
+ a: 1,
393
+ b: 2,
394
+ c: 3,
395
+ d: 4,
396
+ e: 5
397
+ };
398
+ const hook = withLocalState(scene, key, initialState);
399
+ // Patch only one property
400
+ hook.patch({ c: 30 });
401
+ expect(hook.get()).toEqual({
402
+ a: 1,
403
+ b: 2,
404
+ c: 30,
405
+ d: 4,
406
+ e: 5
407
+ });
408
+ });
409
+ });
410
+ describe('complex object patching', () => {
411
+ it('should patch deeply nested objects', () => {
412
+ const scene = buildSceneMock();
413
+ const key = `test-state-${Date.now()}`;
414
+ const initialState = {
415
+ game: {
416
+ player: {
417
+ character: {
418
+ stats: {
419
+ primary: {
420
+ strength: 10,
421
+ dexterity: 8
422
+ },
423
+ secondary: {
424
+ charisma: 5,
425
+ wisdom: 7
426
+ }
427
+ },
428
+ equipment: {
429
+ weapon: 'sword',
430
+ armor: 'leather'
431
+ }
432
+ },
433
+ position: { x: 0, y: 0 }
434
+ },
435
+ world: {
436
+ level: 1,
437
+ difficulty: 'normal'
438
+ }
439
+ }
440
+ };
441
+ const hook = withLocalState(scene, key, initialState);
442
+ // Patch deeply nested property
443
+ hook.patch({
444
+ game: {
445
+ player: {
446
+ character: {
447
+ stats: {
448
+ primary: {
449
+ strength: 15
450
+ }
451
+ }
452
+ }
453
+ }
454
+ }
455
+ });
456
+ expect(hook.get()).toEqual({
457
+ game: {
458
+ player: {
459
+ character: {
460
+ stats: {
461
+ primary: {
462
+ strength: 15,
463
+ dexterity: 8
464
+ },
465
+ secondary: {
466
+ charisma: 5,
467
+ wisdom: 7
468
+ }
469
+ },
470
+ equipment: {
471
+ weapon: 'sword',
472
+ armor: 'leather'
473
+ }
474
+ },
475
+ position: { x: 0, y: 0 }
476
+ },
477
+ world: {
478
+ level: 1,
479
+ difficulty: 'normal'
480
+ }
481
+ }
482
+ });
483
+ });
484
+ it('should handle array properties in objects', () => {
485
+ const scene = buildSceneMock();
486
+ const key = `test-state-${Date.now()}`;
487
+ const initialState = {
488
+ player: {
489
+ name: 'Hero',
490
+ inventory: ['sword', 'potion'],
491
+ skills: ['attack', 'defend'],
492
+ stats: {
493
+ hp: 100,
494
+ mp: 50
495
+ }
496
+ }
497
+ };
498
+ const hook = withLocalState(scene, key, initialState);
499
+ // Patch array property
500
+ hook.patch({
501
+ player: {
502
+ inventory: ['sword', 'potion', 'shield']
503
+ }
504
+ });
505
+ expect(hook.get()).toEqual({
506
+ player: {
507
+ name: 'Hero',
508
+ inventory: ['sword', 'potion', 'shield'],
509
+ skills: ['attack', 'defend'],
510
+ stats: {
511
+ hp: 100,
512
+ mp: 50
513
+ }
514
+ }
515
+ });
516
+ });
517
+ });
518
+ describe('error handling', () => {
519
+ it('should throw error when trying to patch non-object state', () => {
520
+ const scene = buildSceneMock();
521
+ const key = `test-state-${Date.now()}`;
522
+ const initialState = 100; // Primitive value
523
+ const hook = withLocalState(scene, key, initialState);
524
+ expect(() => {
525
+ // @ts-expect-error - we want to test the error case
526
+ hook.patch({ value: 200 });
527
+ }).toThrow('[withStateDef] Current value is not an object');
528
+ });
529
+ it('should throw error when trying to patch null state', () => {
530
+ const scene = buildSceneMock();
531
+ const key = `test-state-${Date.now()}`;
532
+ const initialState = null;
533
+ const hook = withLocalState(scene, key, initialState);
534
+ expect(() => {
535
+ // @ts-expect-error - we want to test the error case
536
+ hook.patch({ value: 'something' });
537
+ }).toThrow('[withStateDef] Current value is not an object');
538
+ });
539
+ });
540
+ describe('validator with patch', () => {
541
+ it('should validate patched values', () => {
542
+ const scene = buildSceneMock();
543
+ const key = `test-state-${Date.now()}`;
544
+ const initialState = {
545
+ life: 50,
546
+ mana: 30
547
+ };
548
+ const validator = vi.fn().mockImplementation((value) => {
549
+ return value.life >= 0 && value.life <= 100 ? true : 'Invalid life value';
550
+ });
551
+ const hook = withLocalState(scene, key, initialState, {
552
+ validator,
553
+ });
554
+ // Valid patch
555
+ hook.patch({ life: 75 });
556
+ expect(hook.get()).toEqual({ life: 75, mana: 30 });
557
+ expect(validator).toHaveBeenCalledWith({ life: 75, mana: 30 });
558
+ // Invalid patch
559
+ expect(() => hook.patch({ life: 150 })).toThrow('[withStateDef] Invalid life value');
560
+ expect(hook.get()).toEqual({ life: 75, mana: 30 }); // Should remain unchanged
561
+ });
562
+ it('should not update state when patch result is invalid', () => {
563
+ const scene = buildSceneMock();
564
+ const key = `test-state-${Date.now()}`;
565
+ const initialState = {
566
+ score: 100,
567
+ level: 1
568
+ };
569
+ const validator = vi.fn().mockImplementation((value) => {
570
+ return value.score >= 0 && value.score <= 1000 ? true : 'Score must be 0-1000';
571
+ });
572
+ const hook = withLocalState(scene, key, initialState, {
573
+ validator,
574
+ });
575
+ // Try to patch with invalid value
576
+ expect(() => hook.patch({ score: 2000 })).toThrow('[withStateDef] Score must be 0-1000');
577
+ expect(hook.get()).toEqual(initialState); // Should remain unchanged
578
+ });
579
+ });
580
+ describe('edge cases', () => {
581
+ it('should handle patching with empty object', () => {
582
+ const scene = buildSceneMock();
583
+ const key = `test-state-${Date.now()}`;
584
+ const initialState = {
585
+ life: 100,
586
+ mana: 50
587
+ };
588
+ const hook = withLocalState(scene, key, initialState);
589
+ // Patch with empty object should not change anything
590
+ hook.patch({});
591
+ expect(hook.get()).toEqual(initialState);
592
+ });
593
+ it('should handle patching with undefined values', () => {
594
+ const scene = buildSceneMock();
595
+ const key = `test-state-${Date.now()}`;
596
+ const initialState = {
597
+ life: 100,
598
+ mana: 50
599
+ };
600
+ const hook = withLocalState(scene, key, initialState);
601
+ // Patch with undefined should not change anything
602
+ hook.patch({ life: undefined });
603
+ expect(hook.get()).toEqual(initialState);
604
+ });
605
+ it('should handle patching with null values', () => {
606
+ const scene = buildSceneMock();
607
+ const key = `test-state-${Date.now()}`;
608
+ const initialState = {
609
+ life: 100,
610
+ mana: 50
611
+ };
612
+ const hook = withLocalState(scene, key, initialState);
613
+ // Patch with null
614
+ hook.patch({ life: null });
615
+ expect(hook.get()).toEqual({ life: null, mana: 50 });
616
+ });
617
+ it('should handle patching with function values', () => {
618
+ const scene = buildSceneMock();
619
+ const key = `test-state-${Date.now()}`;
620
+ const initialState = {
621
+ life: 100,
622
+ mana: 50
623
+ };
624
+ const hook = withLocalState(scene, key, initialState);
625
+ const testFunction = () => 'test';
626
+ // Patch with function
627
+ hook.patch({ life: testFunction });
628
+ expect(hook.get()).toEqual({ life: testFunction, mana: 50 });
629
+ });
630
+ });
631
+ describe('performance and immutability', () => {
632
+ it('should create new object references when patching', () => {
633
+ const scene = buildSceneMock();
634
+ const key = `test-state-${Date.now()}`;
635
+ const initialState = {
636
+ life: 100,
637
+ mana: 50,
638
+ nested: {
639
+ value: 10
640
+ }
641
+ };
642
+ const hook = withLocalState(scene, key, initialState);
643
+ const originalState = hook.get();
644
+ hook.patch({ life: 90 });
645
+ const newState = hook.get();
646
+ // Should be different object references
647
+ expect(newState).not.toBe(originalState);
648
+ expect(newState.nested).not.toBe(originalState.nested);
649
+ // But nested objects should be preserved if not patched
650
+ expect(newState.nested).toEqual(originalState.nested);
651
+ });
652
+ it('should handle multiple sequential patches', () => {
653
+ const scene = buildSceneMock();
654
+ const key = `test-state-${Date.now()}`;
655
+ const initialState = {
656
+ a: 1,
657
+ b: 2,
658
+ c: 3,
659
+ d: 4
660
+ };
661
+ const hook = withLocalState(scene, key, initialState);
662
+ // Multiple sequential patches
663
+ hook.patch({ a: 10 });
664
+ hook.patch({ b: 20 });
665
+ hook.patch({ c: 30 });
666
+ hook.patch({ d: 40 });
667
+ expect(hook.get()).toEqual({ a: 10, b: 20, c: 30, d: 40 });
668
+ });
669
+ });
670
+ });
69
671
  describe('events', () => {
70
672
  describe('on', () => {
71
673
  it('should register a change listener', () => {
@@ -151,5 +753,292 @@ describe('withLocalState', () => {
151
753
  });
152
754
  });
153
755
  });
756
+ describe('validators', () => {
757
+ describe('valid values', () => {
758
+ it('should accept valid values when validator returns true', () => {
759
+ const scene = buildSceneMock();
760
+ const key = `test-state-${Date.now()}`;
761
+ const initialState = { ...baseState, life: 50 };
762
+ const validator = vi.fn().mockReturnValue(true);
763
+ const hook = withLocalState(scene, key, initialState, {
764
+ validator,
765
+ });
766
+ expect(validator).toHaveBeenCalledWith(initialState);
767
+ expect(hook.get()).toEqual(initialState);
768
+ // Test setting a new valid value
769
+ const newValue = { ...baseState, life: 75 };
770
+ hook.set(newValue);
771
+ expect(validator).toHaveBeenCalledWith(newValue);
772
+ expect(hook.get()).toEqual(newValue);
773
+ expect(validator).toHaveBeenCalledTimes(2);
774
+ });
775
+ it('should accept valid values when validator returns string true', () => {
776
+ const scene = buildSceneMock();
777
+ const key = `test-state-${Date.now()}`;
778
+ const initialState = { ...baseState, life: 50 };
779
+ const validator = vi.fn().mockReturnValue(true);
780
+ const hook = withLocalState(scene, key, initialState, {
781
+ validator,
782
+ });
783
+ expect(validator).toHaveBeenCalledWith(initialState);
784
+ expect(hook.get()).toEqual(initialState);
785
+ });
786
+ });
787
+ describe('invalid values', () => {
788
+ it('should reject invalid values when validator returns false', () => {
789
+ const scene = buildSceneMock();
790
+ const key = `test-state-${Date.now()}`;
791
+ const initialState = { ...baseState, life: 50 };
792
+ const validator = vi.fn().mockImplementation((value) => {
793
+ return value.life >= 0 ? true : false;
794
+ });
795
+ const hook = withLocalState(scene, key, initialState, {
796
+ validator,
797
+ });
798
+ expect(validator).toHaveBeenCalledWith(initialState);
799
+ expect(hook.get()).toEqual(initialState);
800
+ // Test setting an invalid value
801
+ const invalidValue = { ...baseState, life: -10 };
802
+ expect(() => hook.set(invalidValue)).toThrow('[withStateDef] Invalid value for key');
803
+ expect(validator).toHaveBeenCalledWith(invalidValue);
804
+ expect(hook.get()).toEqual(initialState); // Should remain unchanged
805
+ });
806
+ it('should reject invalid values when validator returns error message', () => {
807
+ const scene = buildSceneMock();
808
+ const key = `test-state-${Date.now()}`;
809
+ const initialState = { ...baseState, life: 50 };
810
+ const errorMessage = 'Life must be between 0 and 100';
811
+ const validator = vi.fn().mockImplementation((value) => {
812
+ return value.life >= 0 && value.life <= 100 ? true : errorMessage;
813
+ });
814
+ const hook = withLocalState(scene, key, initialState, {
815
+ validator,
816
+ });
817
+ expect(validator).toHaveBeenCalledWith(initialState);
818
+ expect(hook.get()).toEqual(initialState);
819
+ // Test setting an invalid value
820
+ const invalidValue = { ...baseState, life: 150 };
821
+ expect(() => hook.set(invalidValue)).toThrow(`[withStateDef] ${errorMessage}`);
822
+ expect(validator).toHaveBeenCalledWith(invalidValue);
823
+ expect(hook.get()).toEqual(initialState); // Should remain unchanged
824
+ });
825
+ it('should reject invalid initial value when validator returns false', () => {
826
+ const scene = buildSceneMock();
827
+ const key = `test-state-${Date.now()}`;
828
+ const invalidInitialValue = { ...baseState, life: -50 };
829
+ const validator = vi.fn().mockReturnValue(false);
830
+ expect(() => withLocalState(scene, key, invalidInitialValue, {
831
+ validator,
832
+ })).toThrow('[withStateDef] Invalid initial value for key');
833
+ expect(validator).toHaveBeenCalledWith(invalidInitialValue);
834
+ });
835
+ it('should reject invalid initial value when validator returns error message', () => {
836
+ const scene = buildSceneMock();
837
+ const key = `test-state-${Date.now()}`;
838
+ const invalidInitialValue = { ...baseState, life: 200 };
839
+ const errorMessage = 'Life cannot exceed 100';
840
+ const validator = vi.fn().mockReturnValue(errorMessage);
841
+ expect(() => withLocalState(scene, key, invalidInitialValue, {
842
+ validator,
843
+ })).toThrow(`[withStateDef] ${errorMessage}`);
844
+ expect(validator).toHaveBeenCalledWith(invalidInitialValue);
845
+ });
846
+ });
847
+ describe('validator behavior', () => {
848
+ it('should not call validator on get operations', () => {
849
+ const scene = buildSceneMock();
850
+ const key = `test-state-${Date.now()}`;
851
+ const initialState = { ...baseState, life: 50 };
852
+ const validator = vi.fn().mockReturnValue(true);
853
+ const hook = withLocalState(scene, key, initialState, {
854
+ validator,
855
+ });
856
+ // Clear the validator calls from initialization
857
+ validator.mockClear();
858
+ // Get the value multiple times
859
+ hook.get();
860
+ hook.get();
861
+ hook.get();
862
+ expect(validator).not.toHaveBeenCalled();
863
+ });
864
+ it('should call validator on every set operation', () => {
865
+ const scene = buildSceneMock();
866
+ const key = `test-state-${Date.now()}`;
867
+ const initialState = { ...baseState, life: 50 };
868
+ const validator = vi.fn().mockReturnValue(true);
869
+ const hook = withLocalState(scene, key, initialState, {
870
+ validator,
871
+ });
872
+ // Clear the validator calls from initialization
873
+ validator.mockClear();
874
+ // Set multiple values
875
+ hook.set({ ...baseState, life: 60 });
876
+ hook.set({ ...baseState, life: 70 });
877
+ hook.set({ ...baseState, life: 80 });
878
+ expect(validator).toHaveBeenCalledTimes(3);
879
+ expect(validator).toHaveBeenCalledWith({ ...baseState, life: 60 });
880
+ expect(validator).toHaveBeenCalledWith({ ...baseState, life: 70 });
881
+ expect(validator).toHaveBeenCalledWith({ ...baseState, life: 80 });
882
+ });
883
+ it('should work with complex validator logic', () => {
884
+ const scene = buildSceneMock();
885
+ const key = `test-state-${Date.now()}`;
886
+ const initialState = { ...baseState, life: 50 };
887
+ const validator = vi.fn().mockImplementation((value) => {
888
+ if (value.life < 0)
889
+ return 'Life cannot be negative';
890
+ if (value.life > 100)
891
+ return 'Life cannot exceed 100';
892
+ if (value.life % 10 !== 0)
893
+ return 'Life must be a multiple of 10';
894
+ return true;
895
+ });
896
+ const hook = withLocalState(scene, key, initialState, {
897
+ validator,
898
+ });
899
+ // Test valid value
900
+ hook.set({ ...baseState, life: 90 });
901
+ expect(hook.get()).toEqual({ ...baseState, life: 90 });
902
+ // Test invalid values
903
+ expect(() => hook.set({ ...baseState, life: -10 })).toThrow('[withStateDef] Life cannot be negative');
904
+ expect(() => hook.set({ ...baseState, life: 150 })).toThrow('[withStateDef] Life cannot exceed 100');
905
+ expect(() => hook.set({ ...baseState, life: 55 })).toThrow('[withStateDef] Life must be a multiple of 10');
906
+ // State should remain unchanged after invalid attempts
907
+ expect(hook.get()).toEqual({ ...baseState, life: 90 });
908
+ });
909
+ it('should work with async-like validator (synchronous)', () => {
910
+ const scene = buildSceneMock();
911
+ const key = `test-state-${Date.now()}`;
912
+ const initialState = { ...baseState, life: 50 };
913
+ const validator = vi.fn().mockImplementation((value) => {
914
+ // Simulate some validation logic
915
+ const isValid = value.life >= 0 && value.life <= 100;
916
+ return isValid ? true : 'Invalid life value';
917
+ });
918
+ const hook = withLocalState(scene, key, initialState, {
919
+ validator,
920
+ });
921
+ // Test valid value
922
+ hook.set({ ...baseState, life: 75 });
923
+ expect(hook.get()).toEqual({ ...baseState, life: 75 });
924
+ // Test invalid value
925
+ expect(() => hook.set({ ...baseState, life: 150 })).toThrow('[withStateDef] Invalid life value');
926
+ });
927
+ });
928
+ describe('clearListeners', () => {
929
+ it('should clear all event listeners', () => {
930
+ const scene = buildSceneMock();
931
+ const key = `test-state-${Date.now()}`;
932
+ const initialState = { ...baseState, life: 100 };
933
+ const callback1 = vi.fn();
934
+ const callback2 = vi.fn();
935
+ const callback3 = vi.fn();
936
+ const hook = withLocalState(scene, key, initialState);
937
+ // Add multiple listeners
938
+ hook.on('change', callback1);
939
+ hook.on('change', callback2);
940
+ hook.once('change', callback3);
941
+ // Verify listeners are working
942
+ hook.set({ ...baseState, life: 90 });
943
+ expect(callback1).toHaveBeenCalled();
944
+ expect(callback2).toHaveBeenCalled();
945
+ expect(callback3).toHaveBeenCalled();
946
+ // Clear all listeners
947
+ hook.clearListeners();
948
+ // Verify listeners are cleared
949
+ callback1.mockClear();
950
+ callback2.mockClear();
951
+ callback3.mockClear();
952
+ hook.set({ ...baseState, life: 80 });
953
+ expect(callback1).not.toHaveBeenCalled();
954
+ expect(callback2).not.toHaveBeenCalled();
955
+ expect(callback3).not.toHaveBeenCalled();
956
+ });
957
+ it('should clear listeners added via onChange (deprecated)', () => {
958
+ const scene = buildSceneMock();
959
+ const key = `test-state-${Date.now()}`;
960
+ const initialState = { ...baseState, life: 100 };
961
+ const callback = vi.fn();
962
+ const hook = withLocalState(scene, key, initialState);
963
+ // Add listener via deprecated onChange
964
+ hook.onChange(callback);
965
+ // Verify listener is working
966
+ hook.set({ ...baseState, life: 90 });
967
+ expect(callback).toHaveBeenCalled();
968
+ // Clear all listeners
969
+ hook.clearListeners();
970
+ // Verify listener is cleared
971
+ callback.mockClear();
972
+ hook.set({ ...baseState, life: 80 });
973
+ expect(callback).not.toHaveBeenCalled();
974
+ });
975
+ it('should work with debug mode enabled', () => {
976
+ const scene = buildSceneMock();
977
+ const key = `test-state-${Date.now()}`;
978
+ const initialState = { ...baseState, life: 100 };
979
+ // Test that debug mode doesn't throw errors
980
+ const hook = withLocalState(scene, key, initialState, {
981
+ debug: true,
982
+ });
983
+ const callback = vi.fn();
984
+ hook.on('change', callback);
985
+ // Clear listeners with debug enabled - should not throw
986
+ expect(() => hook.clearListeners()).not.toThrow();
987
+ });
988
+ it('should not affect other state instances', () => {
989
+ const scene = buildSceneMock();
990
+ const key1 = `test-state-1-${Date.now()}`;
991
+ const key2 = `test-state-2-${Date.now()}`;
992
+ const initialState = { ...baseState, life: 100 };
993
+ const callback1 = vi.fn();
994
+ const callback2 = vi.fn();
995
+ const hook1 = withLocalState(scene, key1, initialState);
996
+ const hook2 = withLocalState(scene, key2, initialState);
997
+ // Add listeners to both hooks
998
+ hook1.on('change', callback1);
999
+ hook2.on('change', callback2);
1000
+ // Clear listeners from hook1 only
1001
+ hook1.clearListeners();
1002
+ // Verify hook1 listeners are cleared but hook2 listeners still work
1003
+ hook1.set({ ...baseState, life: 90 });
1004
+ hook2.set({ ...baseState, life: 90 });
1005
+ expect(callback1).not.toHaveBeenCalled();
1006
+ expect(callback2).toHaveBeenCalled();
1007
+ });
1008
+ it('should work when no listeners are present', () => {
1009
+ const scene = buildSceneMock();
1010
+ const key = `test-state-${Date.now()}`;
1011
+ const initialState = { ...baseState, life: 100 };
1012
+ const hook = withLocalState(scene, key, initialState);
1013
+ // Should not throw when clearing listeners that don't exist
1014
+ expect(() => hook.clearListeners()).not.toThrow();
1015
+ });
1016
+ it('should clear listeners after partial removal', () => {
1017
+ const scene = buildSceneMock();
1018
+ const key = `test-state-${Date.now()}`;
1019
+ const initialState = { ...baseState, life: 100 };
1020
+ const callback1 = vi.fn();
1021
+ const callback2 = vi.fn();
1022
+ const callback3 = vi.fn();
1023
+ const hook = withLocalState(scene, key, initialState);
1024
+ // Add multiple listeners
1025
+ hook.on('change', callback1);
1026
+ hook.on('change', callback2);
1027
+ hook.on('change', callback3);
1028
+ // Remove one listener manually
1029
+ hook.off('change', callback2);
1030
+ // Clear all remaining listeners
1031
+ hook.clearListeners();
1032
+ // Verify all listeners are cleared
1033
+ callback1.mockClear();
1034
+ callback2.mockClear();
1035
+ callback3.mockClear();
1036
+ hook.set({ ...baseState, life: 90 });
1037
+ expect(callback1).not.toHaveBeenCalled();
1038
+ expect(callback2).not.toHaveBeenCalled();
1039
+ expect(callback3).not.toHaveBeenCalled();
1040
+ });
1041
+ });
1042
+ });
154
1043
  });
155
1044
  //# sourceMappingURL=with-local-state.spec.js.map