bansa 0.0.1

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,1036 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { $, type ThenableSignal } from '../src/index';
3
+
4
+ const flushMicrotasks = () =>
5
+ new Promise((resolve) => {
6
+ const { port1, port2 } = new MessageChannel();
7
+ port1.onmessage = resolve;
8
+ port2.postMessage(null);
9
+ });
10
+ const wait = () =>
11
+ new Promise((resolve) => {
12
+ setTimeout(resolve, 4);
13
+ });
14
+
15
+ const inc = (x: number) => x + 1;
16
+ const nop = () => {};
17
+ const nops = (): (() => void)[] => [];
18
+
19
+ describe('Atom Library - Basic Tests', () => {
20
+ it('primitive atom', async () => {
21
+ const atom = $(42);
22
+ expect(atom.get()).toBe(42);
23
+
24
+ atom.set(100);
25
+ await flushMicrotasks();
26
+ expect(atom.get()).toBe(100);
27
+
28
+ atom.set((x) => x * 2);
29
+ await flushMicrotasks();
30
+ expect(atom.get()).toBe(200);
31
+ });
32
+
33
+ it('derived atom', async () => {
34
+ const atom1 = $(5);
35
+ const atom2 = $(5);
36
+ const derivedAtom = $((get) => get(atom1) + get(atom2));
37
+
38
+ atom1.set(10);
39
+ expect(derivedAtom.get()).toBe(10);
40
+ await flushMicrotasks();
41
+ expect(derivedAtom.get()).toBe(15);
42
+
43
+ atom2.set(10);
44
+ expect(derivedAtom.get()).toBe(15);
45
+ await flushMicrotasks();
46
+ expect(derivedAtom.get()).toBe(20);
47
+ });
48
+
49
+ it('async atom', async () => {
50
+ const atom1 = $(async () => 10);
51
+ const atom2 = $(async () => 20);
52
+ const atom3 = $(0);
53
+ const derivedAtom = $((get) => {
54
+ return get(atom1) + get(atom2) + get(atom3);
55
+ });
56
+ derivedAtom.subscribe(nop);
57
+
58
+ expect(!!atom1.state.promise).toBe(true);
59
+ expect(!!atom2.state.promise).toBe(true);
60
+ expect(!!derivedAtom.state.promise).toBe(true);
61
+
62
+ await flushMicrotasks();
63
+ expect(derivedAtom.state.value).toBe(30);
64
+
65
+ atom3.set(inc);
66
+ // expect(!!derivedAtom.state.promise).toBe(true);
67
+ await flushMicrotasks();
68
+ expect(derivedAtom.state.value).toBe(31);
69
+ });
70
+
71
+ it('repeated addition', async () => {
72
+ const atom = $(0);
73
+ const atom2 = $(2);
74
+ const derivedAtom = $((get) => get(atom) * get(atom2));
75
+
76
+ for (let i = 0; i < 100; i++) {
77
+ atom.set(inc);
78
+ }
79
+ await flushMicrotasks();
80
+ expect(derivedAtom.get()).toBe(200);
81
+
82
+ atom2.set(3);
83
+ await flushMicrotasks();
84
+ expect(derivedAtom.get()).toBe(300);
85
+ });
86
+
87
+ it('deep addition', async () => {
88
+ const atom = $(0);
89
+ const atom2 = $(1);
90
+ let derivedAtom = $((get) => get(atom) + get(atom2));
91
+ for (let i = 1; i < 100; i++) {
92
+ const prevAtom = derivedAtom;
93
+ derivedAtom = $((get) => get(prevAtom) + get(atom2));
94
+ }
95
+ expect(derivedAtom.get()).toBe(100);
96
+
97
+ atom.set(1);
98
+ await flushMicrotasks();
99
+ expect(derivedAtom.get()).toBe(101);
100
+
101
+ atom2.set(2);
102
+ await flushMicrotasks();
103
+ expect(derivedAtom.get()).toBe(201);
104
+ });
105
+
106
+ it('primitive atom subscribe', async () => {
107
+ const atom = $(42);
108
+
109
+ const mockFn = vi.fn();
110
+ const unsub = atom.subscribe(mockFn);
111
+ await flushMicrotasks();
112
+ expect(mockFn).toHaveBeenCalledWith(42, expect.anything());
113
+ mockFn.mockClear();
114
+
115
+ atom.set(100);
116
+ await flushMicrotasks();
117
+ expect(mockFn).toHaveBeenCalledWith(100, expect.anything());
118
+ mockFn.mockClear();
119
+
120
+ unsub();
121
+ atom.set(0);
122
+ await flushMicrotasks();
123
+ expect(mockFn).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it('derived atom subscribe', async () => {
127
+ const atom = $(42);
128
+ const derivedAtom = $((get) => get(atom) * 2);
129
+
130
+ const mockFn = vi.fn();
131
+ const unsub = derivedAtom.subscribe(mockFn);
132
+ await flushMicrotasks();
133
+ expect(mockFn).toHaveBeenCalledWith(84, expect.anything());
134
+ mockFn.mockClear();
135
+
136
+ atom.set(100);
137
+ await flushMicrotasks();
138
+ expect(mockFn).toHaveBeenCalledWith(200, expect.anything());
139
+ mockFn.mockClear();
140
+
141
+ unsub();
142
+ atom.set(0);
143
+ await flushMicrotasks();
144
+ expect(mockFn).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it('atom watch', async () => {
148
+ const atom = $(42);
149
+ const derivedAtom = $((get) => Promise.resolve(get(atom) * 2));
150
+
151
+ const mockFn = vi.fn();
152
+ const unwatch = derivedAtom.watch(mockFn);
153
+ await flushMicrotasks();
154
+ expect(mockFn).toBeCalledTimes(2);
155
+ mockFn.mockClear();
156
+
157
+ unwatch();
158
+ atom.set(0);
159
+ await flushMicrotasks();
160
+ expect(mockFn).toBeCalledTimes(0);
161
+ });
162
+
163
+ it('state property reflects current value', async () => {
164
+ const atom = $(42);
165
+ expect(atom.state).toEqual({
166
+ promise: undefined,
167
+ error: undefined,
168
+ value: 42,
169
+ });
170
+ });
171
+ });
172
+
173
+ describe('Atom Library - Advanced Tests', () => {
174
+ it('handles complex dependency chains', async () => {
175
+ const baseAtom = $(1);
176
+ const derived1 = $((get) => get(baseAtom) * 2);
177
+ const derived2 = $((get) => get(derived1) + 1);
178
+ let resolve = nop;
179
+ const asyncDerived = $(async (get) => {
180
+ const value = get(derived2);
181
+ await new Promise<void>((r) => (resolve = r));
182
+ return value * 2;
183
+ });
184
+ asyncDerived.subscribe(nop);
185
+ await flushMicrotasks();
186
+ expect(!!asyncDerived.state.promise).toBe(true);
187
+
188
+ resolve();
189
+ await flushMicrotasks();
190
+ expect(asyncDerived.state.value).toBe(6);
191
+
192
+ baseAtom.set(2);
193
+ await flushMicrotasks();
194
+ expect(!!asyncDerived.state.promise).toBe(true);
195
+
196
+ resolve();
197
+ await flushMicrotasks();
198
+ expect(asyncDerived.state.value).toBe(10);
199
+ });
200
+
201
+ it('first promise first resolved 1', async () => {
202
+ const countAtom = $(0);
203
+ const resolve = nops();
204
+ const asyncAtom = $(async (get) => {
205
+ const count = get(countAtom);
206
+ await new Promise<void>((r) => resolve.push(r));
207
+ return count;
208
+ });
209
+ const mock = vi.fn();
210
+ asyncAtom.subscribe(mock);
211
+
212
+ countAtom.set((c) => c + 1);
213
+ countAtom.set((c) => c + 1);
214
+ await flushMicrotasks();
215
+ expect(resolve.length).toBe(1);
216
+
217
+ resolve.shift()?.();
218
+ await flushMicrotasks();
219
+ expect(asyncAtom.state.value).toBe(2);
220
+ });
221
+
222
+ it('first promise first resolved 2', async () => {
223
+ const countAtom = $(0);
224
+ const resolve = nops();
225
+ const asyncAtom = $(async (get) => {
226
+ const count = get(countAtom);
227
+ await new Promise<void>((r) => resolve.push(r));
228
+ return count;
229
+ });
230
+ const mock = vi.fn();
231
+ asyncAtom.subscribe(mock);
232
+
233
+ countAtom.set((c) => c + 1);
234
+ await flushMicrotasks();
235
+ countAtom.set((c) => c + 1);
236
+ await flushMicrotasks();
237
+ expect(resolve.length).toBe(2);
238
+
239
+ resolve.shift()?.();
240
+ await flushMicrotasks();
241
+ expect(!!asyncAtom.state.promise).toBe(true);
242
+
243
+ resolve.shift()?.();
244
+ await flushMicrotasks();
245
+ expect(asyncAtom.state.value).toBe(2);
246
+ });
247
+
248
+ it('last promise first resolved 1', async () => {
249
+ const countAtom = $(0);
250
+ const resolve = nops();
251
+ const asyncAtom = $(async (get) => {
252
+ const count = get(countAtom);
253
+ await new Promise<void>((r) => resolve.push(r));
254
+ return count;
255
+ });
256
+ const mock = vi.fn();
257
+ asyncAtom.subscribe(mock);
258
+
259
+ countAtom.set((c) => c + 1);
260
+ countAtom.set((c) => c + 1);
261
+ await flushMicrotasks();
262
+ expect(resolve.length).toBe(1);
263
+
264
+ resolve.pop()?.();
265
+ await flushMicrotasks();
266
+ expect(asyncAtom.state.value).toBe(2);
267
+ });
268
+
269
+ it('last promise first resolved 2', async () => {
270
+ const countAtom = $(0);
271
+ const resolve = nops();
272
+ const asyncAtom = $(async (get) => {
273
+ const count = get(countAtom);
274
+ await new Promise<void>((r) => resolve.push(r));
275
+ return count;
276
+ });
277
+ const mock = vi.fn();
278
+ asyncAtom.subscribe(mock);
279
+
280
+ countAtom.set((c) => c + 1);
281
+ await flushMicrotasks();
282
+ countAtom.set((c) => c + 1);
283
+ await flushMicrotasks();
284
+ expect(resolve.length).toBe(2);
285
+
286
+ resolve.pop()?.();
287
+ await flushMicrotasks();
288
+ expect(asyncAtom.state.value).toBe(2);
289
+
290
+ resolve.pop()?.();
291
+ await flushMicrotasks();
292
+ expect(asyncAtom.state.value).toBe(2);
293
+ });
294
+
295
+ it('deep addition', async () => {
296
+ const atom = $(0);
297
+ let derivedAtom = $((get) => get(atom) + 1);
298
+ for (let i = 1; i < 100; i++) {
299
+ const prevAtom = derivedAtom;
300
+ derivedAtom = $((get) => get(prevAtom) + 1);
301
+ }
302
+ expect(derivedAtom.get()).toBe(100);
303
+ });
304
+
305
+ it('custom equality function prevents unnecessary updates', async () => {
306
+ const mockFn = vi.fn();
307
+ const atom = $(
308
+ { value: 42 },
309
+ {
310
+ equals: (a, b) => a.value === b.value,
311
+ },
312
+ );
313
+
314
+ atom.subscribe(mockFn);
315
+ await flushMicrotasks();
316
+ expect(mockFn).toHaveBeenCalled();
317
+ mockFn.mockClear();
318
+
319
+ // This should not trigger an update due to custom equality
320
+ atom.set({ value: 42 });
321
+ await flushMicrotasks();
322
+ expect(mockFn).not.toHaveBeenCalled();
323
+
324
+ // This should trigger an update
325
+ atom.set({ value: 100 });
326
+ await flushMicrotasks();
327
+ expect(mockFn).toHaveBeenCalled();
328
+ });
329
+
330
+ it('async derived atoms handle promises correctly', async () => {
331
+ const atom = $(1);
332
+ const asyncAtom = $((get) => {
333
+ const id = get(atom);
334
+ return Promise.resolve(`user-${id}`);
335
+ });
336
+ const mockFn1 = vi.fn();
337
+ asyncAtom.subscribe(mockFn1);
338
+
339
+ // Initial fetch should be pending
340
+ expect(!!asyncAtom.state.promise).toBe(true);
341
+
342
+ // Wait for resolution
343
+ await flushMicrotasks();
344
+ await flushMicrotasks();
345
+
346
+ expect(!!asyncAtom.state.promise).toBe(false);
347
+ expect(asyncAtom.get()).toBe('user-1');
348
+
349
+ // Update dependency
350
+ atom.set(2);
351
+ await flushMicrotasks();
352
+ await flushMicrotasks();
353
+
354
+ expect(asyncAtom.get()).toBe('user-2');
355
+ });
356
+
357
+ it('atom mount/unmount', async () => {
358
+ const atom1 = $(10);
359
+ const atom2 = $(20);
360
+
361
+ const metrics1 = { mounted: 0, unmounted: 0 };
362
+ const derivedAtom1 = $(async (get, { signal }) => {
363
+ metrics1.mounted++;
364
+ signal.then(() => {
365
+ metrics1.unmounted++;
366
+ });
367
+ return get(atom1);
368
+ });
369
+
370
+ const metrics2 = { mounted: 0, unmounted: 0 };
371
+ const derivedAtom2 = $(async (get, { signal }) => {
372
+ metrics2.mounted++;
373
+ signal.then(() => {
374
+ metrics2.unmounted++;
375
+ });
376
+ return get(atom2);
377
+ });
378
+
379
+ let resolve = nop;
380
+ const metrics3 = { mounted: 0, unmounted: 0 };
381
+ const derivedAtom3 = $(async (get, { signal }) => {
382
+ metrics3.mounted++;
383
+ signal.then(() => {
384
+ metrics3.unmounted++;
385
+ });
386
+ const v1 = get(derivedAtom1);
387
+ await new Promise<void>((r) => (resolve = r));
388
+ const v2 = get(derivedAtom2);
389
+ return v1 + v2;
390
+ });
391
+
392
+ const unsub = derivedAtom3.subscribe(nop);
393
+ await flushMicrotasks();
394
+ expect(metrics1).toEqual({ mounted: 1, unmounted: 0 });
395
+ expect(metrics2).toEqual({ mounted: 0, unmounted: 0 });
396
+ expect(metrics3).toEqual({ mounted: 2, unmounted: 1 });
397
+ expect(derivedAtom1.state.value).toEqual(10);
398
+ expect(derivedAtom2.state.value).toEqual(undefined);
399
+ expect(!!derivedAtom3.state.promise).toEqual(true);
400
+
401
+ resolve();
402
+ await flushMicrotasks();
403
+ expect(metrics1).toEqual({ mounted: 1, unmounted: 0 });
404
+ expect(metrics2).toEqual({ mounted: 1, unmounted: 0 });
405
+ expect(metrics3).toEqual({ mounted: 3, unmounted: 2 });
406
+ expect(derivedAtom1.state.value).toEqual(10);
407
+ expect(derivedAtom2.state.value).toEqual(20);
408
+ expect(!!derivedAtom3.state.promise).toEqual(true);
409
+
410
+ resolve();
411
+ await flushMicrotasks();
412
+ expect(metrics1).toEqual({ mounted: 1, unmounted: 0 });
413
+ expect(metrics2).toEqual({ mounted: 1, unmounted: 0 });
414
+ expect(metrics3).toEqual({ mounted: 3, unmounted: 2 });
415
+ expect(derivedAtom1.state.value).toEqual(10);
416
+ expect(derivedAtom2.state.value).toEqual(20);
417
+ expect(derivedAtom3.state.value).toEqual(30);
418
+
419
+ atom2.set(30);
420
+ await flushMicrotasks();
421
+ resolve();
422
+ await wait();
423
+ expect(metrics1).toEqual({ mounted: 1, unmounted: 0 });
424
+ expect(metrics2).toEqual({ mounted: 2, unmounted: 1 });
425
+ expect(metrics3).toEqual({ mounted: 4, unmounted: 3 });
426
+ expect(derivedAtom1.state.value).toEqual(10);
427
+ expect(derivedAtom2.state.value).toEqual(30);
428
+ expect(derivedAtom3.state.value).toEqual(40);
429
+
430
+ unsub();
431
+ resolve = nop;
432
+ await new Promise((r) => setTimeout(r, 10));
433
+ expect(metrics1).toEqual({ mounted: 1, unmounted: 1 });
434
+ expect(metrics2).toEqual({ mounted: 2, unmounted: 2 });
435
+ expect(metrics3).toEqual({ mounted: 4, unmounted: 4 });
436
+ expect(!!derivedAtom3.state.promise).toEqual(true);
437
+
438
+ atom1.set(20);
439
+ await flushMicrotasks();
440
+ expect(resolve).toBe(nop);
441
+ await flushMicrotasks();
442
+ expect(metrics1).toEqual({ mounted: 1, unmounted: 1 });
443
+ expect(metrics2).toEqual({ mounted: 2, unmounted: 2 });
444
+ expect(metrics3).toEqual({ mounted: 4, unmounted: 4 });
445
+ expect(derivedAtom3.state.value).toEqual(undefined);
446
+ });
447
+
448
+ it('should not provide stale values to conditional dependents', async () => {
449
+ const dataAtom = $([100]);
450
+ const hasFilterAtom = $(false);
451
+ const filteredAtom = $((get) => {
452
+ const data = get(dataAtom);
453
+ return get(hasFilterAtom) ? [] : data;
454
+ });
455
+ const stageAtom = $((get) =>
456
+ !get(hasFilterAtom) ? 0 : get(filteredAtom).length === 0 ? 1 : 2,
457
+ );
458
+
459
+ filteredAtom.subscribe(nop);
460
+ stageAtom.subscribe(nop);
461
+
462
+ expect(stageAtom.get(), 'should start without filter').toBe(0);
463
+
464
+ hasFilterAtom.set(true);
465
+ await flushMicrotasks();
466
+ expect(stageAtom.get(), 'should update').toBe(1);
467
+ });
468
+
469
+ it('async derived atoms handle errors correctly', async () => {
470
+ const error = new Error('Test error');
471
+ const errorAtom = $(() => Promise.reject(error));
472
+ const mockFn1 = vi.fn();
473
+ errorAtom.subscribe(mockFn1);
474
+
475
+ await flushMicrotasks();
476
+ await flushMicrotasks(); // Additional wait for promise resolution
477
+
478
+ expect(errorAtom.state.error).toBe(error);
479
+ expect(() => errorAtom.get()).toThrow(error);
480
+ });
481
+
482
+ it('multiple subscribers receive updates', async () => {
483
+ const atom = $(42);
484
+ const mockFn1 = vi.fn();
485
+ const mockFn2 = vi.fn();
486
+
487
+ atom.subscribe(mockFn1);
488
+ atom.subscribe(mockFn2);
489
+ await flushMicrotasks();
490
+ mockFn1.mockClear();
491
+ mockFn2.mockClear();
492
+
493
+ atom.set(100);
494
+ await flushMicrotasks();
495
+
496
+ expect(mockFn1).toHaveBeenCalledWith(100, expect.anything());
497
+ expect(mockFn2).toHaveBeenCalledWith(100, expect.anything());
498
+ });
499
+
500
+ it('atoms with unused dependencies are garbage collected', async () => {
501
+ const a = $(10);
502
+ const b = $((get) => get(a) + 5);
503
+
504
+ // Create a derived atom and subscribe to it
505
+ const c = $((get) => get(b) * 2);
506
+ const unsub = c.subscribe(nop);
507
+ await flushMicrotasks();
508
+
509
+ // Get initial state
510
+ expect(c.get()).toBe(30);
511
+
512
+ // Unsubscribe and ensure atom is "disabled"
513
+ unsub();
514
+ await flushMicrotasks();
515
+
516
+ // Change dependency
517
+ a.set(20);
518
+ await flushMicrotasks();
519
+
520
+ // Re-access should recompute with latest values
521
+ expect(c.get()).toBe(50);
522
+ });
523
+
524
+ it('batched updates are processed efficiently', async () => {
525
+ const atom = $(0);
526
+ const derivedAtom = $((get) => get(atom) * 2);
527
+ const mockFn = vi.fn();
528
+
529
+ derivedAtom.subscribe(mockFn);
530
+ await flushMicrotasks();
531
+ mockFn.mockClear();
532
+
533
+ // Multiple updates in same microtask should batch
534
+ atom.set(1);
535
+ atom.set(2);
536
+ atom.set(3);
537
+
538
+ await flushMicrotasks();
539
+
540
+ // Should only be called once with final value
541
+ expect(mockFn).toHaveBeenCalledTimes(1);
542
+ expect(mockFn).toHaveBeenCalledWith(6, expect.anything());
543
+ });
544
+
545
+ it('can propagate updates with async $ chains', async () => {
546
+ const countAtom = $(1);
547
+ let resolve = nop;
548
+ const asyncAtom = $(async (get) => {
549
+ const count = get(countAtom);
550
+ await new Promise<void>((r) => (resolve = r));
551
+ return count;
552
+ });
553
+ const async2Atom = $((get) => get(asyncAtom));
554
+ const async3Atom = $((get) => get(async2Atom));
555
+
556
+ async3Atom.subscribe(nop);
557
+ await flushMicrotasks();
558
+ resolve();
559
+ await flushMicrotasks();
560
+ await expect(async3Atom.state.value).toBe(1);
561
+
562
+ countAtom.set((c) => c + 1);
563
+ await flushMicrotasks();
564
+ resolve();
565
+ await flushMicrotasks();
566
+ await expect(async3Atom.state.value).toBe(2);
567
+
568
+ countAtom.set((c) => c + 1);
569
+ await flushMicrotasks();
570
+ resolve();
571
+ await flushMicrotasks();
572
+ await expect(async3Atom.state.value).toBe(3);
573
+ });
574
+ });
575
+
576
+ describe('Bansa Documentation Examples as Tests', () => {
577
+ // fetch 모킹
578
+ const mockFetch = vi.fn();
579
+ beforeEach(() => {
580
+ vi.stubGlobal('fetch', mockFetch);
581
+ mockFetch.mockClear();
582
+ });
583
+
584
+ // --- "상태를 얼마나 쪼개는 게 좋나요?" 예제 테스트 ---
585
+ describe('Atom Granularity Example', () => {
586
+ // 테스트에 사용할 가상 DOM 요소
587
+ const userElm = { innerHTML: '' };
588
+ const postElm = { innerHTML: '' };
589
+ const commentElm = { innerHTML: '' };
590
+
591
+ beforeEach(() => {
592
+ userElm.innerHTML = '';
593
+ postElm.innerHTML = '';
594
+ commentElm.innerHTML = '';
595
+ mockFetch.mockImplementation(async (url: string) => {
596
+ if (url.includes('/users/')) {
597
+ return {
598
+ ok: true,
599
+ json: async () => ({
600
+ name: 'John Doe',
601
+ author: 'John Doe',
602
+ }),
603
+ };
604
+ }
605
+ if (url.includes('/posts/')) {
606
+ return {
607
+ ok: true,
608
+ json: async () => ({
609
+ html: '<p>Post content</p>',
610
+ author: 'Jane Smith',
611
+ }),
612
+ };
613
+ }
614
+ return { ok: false, status: 404 };
615
+ });
616
+ });
617
+
618
+ it('Bad Practice: Combined state causes unnecessary re-fetches', async () => {
619
+ const $userId = $(123);
620
+ const $postId = $(456);
621
+
622
+ // 모든 로직이 하나의 파생 상태에 결합된 경우
623
+ const $pageData = $(async (get, { signal }) => {
624
+ const user = await fetch(`/users/${get($userId)}`, {
625
+ signal,
626
+ }).then((res) => res.json());
627
+ const post = await fetch(`/posts/${get($postId)}`, {
628
+ signal,
629
+ }).then((res) => res.json());
630
+
631
+ userElm.innerHTML = user.name;
632
+ postElm.innerHTML = post.html;
633
+ commentElm.innerHTML = `Hello ${user.name}! Comment to ${post.author}.`;
634
+ return { user, post };
635
+ });
636
+
637
+ const unsub = $pageData.subscribe(() => {});
638
+ await flushMicrotasks(); // 초기 실행 대기
639
+
640
+ expect(mockFetch).toHaveBeenCalledTimes(2);
641
+ expect(mockFetch).toHaveBeenCalledWith(
642
+ '/users/123',
643
+ expect.anything(),
644
+ );
645
+ expect(mockFetch).toHaveBeenCalledWith(
646
+ '/posts/456',
647
+ expect.anything(),
648
+ );
649
+ expect(userElm.innerHTML).toBe('John Doe');
650
+ expect(postElm.innerHTML).toBe('<p>Post content</p>');
651
+
652
+ mockFetch.mockClear();
653
+
654
+ // postId만 변경되어도 user와 post를 모두 다시 fetch함
655
+ $postId.set(789);
656
+ await flushMicrotasks(); // 업데이트 대기
657
+
658
+ expect(mockFetch).toHaveBeenCalledTimes(2);
659
+ expect(mockFetch).toHaveBeenCalledWith(
660
+ '/users/123',
661
+ expect.anything(),
662
+ ); // 불필요한 호출
663
+ expect(mockFetch).toHaveBeenCalledWith(
664
+ '/posts/789',
665
+ expect.anything(),
666
+ );
667
+
668
+ unsub();
669
+ });
670
+
671
+ it('Good Practice: Split states prevent unnecessary re-fetches', async () => {
672
+ const $userId = $(123);
673
+ const $user = $((get, { signal }) =>
674
+ fetch(`/users/${get($userId)}`, { signal }).then((res) =>
675
+ res.json(),
676
+ ),
677
+ );
678
+
679
+ const $postId = $(456);
680
+ const $post = $((get, { signal }) =>
681
+ fetch(`/posts/${get($postId)}`, { signal }).then((res) =>
682
+ res.json(),
683
+ ),
684
+ );
685
+
686
+ const $pageData = $((get) => ({
687
+ userName: get($user).name,
688
+ postAuthor: get($post).author,
689
+ }));
690
+
691
+ const userSub = $user.subscribe((user) => {
692
+ userElm.innerHTML = user.name;
693
+ });
694
+ const postSub = $post.subscribe((post) => {
695
+ postElm.innerHTML = post.html;
696
+ });
697
+ const pageDataSub = $pageData.subscribe(
698
+ ({ userName, postAuthor }) => {
699
+ commentElm.innerHTML = `Hello ${userName}! Comment to ${postAuthor}.`;
700
+ },
701
+ );
702
+
703
+ await flushMicrotasks(); // 초기 실행 대기
704
+
705
+ expect(mockFetch).toHaveBeenCalledTimes(2);
706
+ expect(mockFetch).toHaveBeenCalledWith(
707
+ '/users/123',
708
+ expect.anything(),
709
+ );
710
+ expect(mockFetch).toHaveBeenCalledWith(
711
+ '/posts/456',
712
+ expect.anything(),
713
+ );
714
+ expect(userElm.innerHTML).toBe('John Doe');
715
+ expect(postElm.innerHTML).toBe('<p>Post content</p>');
716
+ expect(commentElm.innerHTML).toBe(
717
+ 'Hello John Doe! Comment to Jane Smith.',
718
+ );
719
+
720
+ mockFetch.mockClear();
721
+
722
+ // postId만 변경. user는 다시 fetch되지 않음
723
+ $postId.set(789);
724
+ await flushMicrotasks(); // 업데이트 대기
725
+
726
+ expect(mockFetch).toHaveBeenCalledTimes(1); // post만 호출됨
727
+ expect(mockFetch).toHaveBeenCalledWith(
728
+ '/posts/789',
729
+ expect.anything(),
730
+ );
731
+ expect(mockFetch).not.toHaveBeenCalledWith(
732
+ '/users/123',
733
+ expect.anything(),
734
+ );
735
+ expect(commentElm.innerHTML).toBe(
736
+ 'Hello John Doe! Comment to Jane Smith.',
737
+ ); // post.author가 업데이트됨
738
+
739
+ userSub();
740
+ postSub();
741
+ pageDataSub();
742
+ });
743
+ });
744
+
745
+ // --- "onMount/onCleanup은 어떻게 하나요?" 예제 테스트 ---
746
+ describe('onMount / onCleanup Patterns', () => {
747
+ it('Pattern 1: Atom returning an atom for shared resources', async () => {
748
+ const onMount = vi.fn();
749
+ const onCleanup = vi.fn();
750
+ const mockWs = {
751
+ close: vi.fn(),
752
+ send: vi.fn(),
753
+ addEventListener: vi.fn(),
754
+ removeEventListener: vi.fn(),
755
+ };
756
+
757
+ // 공유 커넥션 상태
758
+ const $wsConnection = $((_, { signal }) => {
759
+ onMount();
760
+ signal.then(onCleanup);
761
+ signal.then(() => mockWs.close());
762
+
763
+ return {
764
+ send: (message: string) => mockWs.send(message),
765
+ addEventListener: (
766
+ listener: (data: any) => void,
767
+ listenerSignal: ThenableSignal,
768
+ ) => {
769
+ mockWs.addEventListener('message', listener);
770
+ listenerSignal.then(() =>
771
+ mockWs.removeEventListener('message', listener),
772
+ );
773
+ },
774
+ };
775
+ });
776
+
777
+ // 커넥션을 사용하는 상태 팩토리
778
+ const lastMessage = (name: string) =>
779
+ $((get, { signal }) => {
780
+ const { send, addEventListener } = get($wsConnection);
781
+ const $lastMessage = $(null);
782
+ addEventListener(({ type, value }) => {
783
+ if (type === name) $lastMessage.set(value);
784
+ }, signal);
785
+
786
+ send(`+${name}`);
787
+ signal.then(() => send(`-${name}`));
788
+ return $lastMessage;
789
+ });
790
+
791
+ const $alice = lastMessage('alice');
792
+ const $bob = lastMessage('bob');
793
+
794
+ // 첫 구독: onMount 호출
795
+ const unsubAlice = $alice.subscribe(() => {});
796
+ await flushMicrotasks();
797
+ expect(onMount).toHaveBeenCalledTimes(1);
798
+ expect(onCleanup).not.toHaveBeenCalled();
799
+ expect(mockWs.send).toHaveBeenCalledWith('+alice');
800
+
801
+ // 두 번째 구독: onMount는 다시 호출되지 않음
802
+ const unsubBob = $bob.subscribe(() => {});
803
+ await flushMicrotasks();
804
+ expect(onMount).toHaveBeenCalledTimes(1);
805
+ expect(mockWs.send).toHaveBeenCalledWith('+bob');
806
+
807
+ // 첫 구독 해제: onCleanup은 아직 호출되지 않음
808
+ unsubAlice();
809
+ await new Promise((r) => setTimeout(r, 10)); // disableAtom 대기
810
+ expect(onCleanup).not.toHaveBeenCalled();
811
+ expect(mockWs.send).toHaveBeenCalledWith('-alice');
812
+
813
+ // 마지막 구독 해제: onCleanup 호출
814
+ unsubBob();
815
+ await new Promise((r) => setTimeout(r, 10)); // disableAtom 대기
816
+ expect(onCleanup).toHaveBeenCalledTimes(1);
817
+ expect(mockWs.close).toHaveBeenCalledTimes(1);
818
+ expect(mockWs.send).toHaveBeenCalledWith('-bob');
819
+ });
820
+
821
+ it('Pattern 2: Read/Write separation for lifecycles', async () => {
822
+ const onMount = vi.fn();
823
+ const onCleanup = vi.fn();
824
+
825
+ const $writer = $(0);
826
+ const $shared = $((_, { signal }) => {
827
+ onMount();
828
+ signal.then(onCleanup);
829
+ });
830
+ const $reader = $((get) => {
831
+ get($shared); // $shared의 생명주기에 의존
832
+ return get($writer);
833
+ });
834
+
835
+ // 구독 전: 아무것도 호출되지 않음
836
+ expect(onMount).not.toHaveBeenCalled();
837
+
838
+ // 구독 시: onMount 호출
839
+ const unsub = $reader.subscribe(() => {});
840
+ await flushMicrotasks();
841
+ expect(onMount).toHaveBeenCalledTimes(1);
842
+ expect($reader.get()).toBe(0);
843
+
844
+ // 쓰기 상태 업데이트: onMount는 다시 호출되지 않음
845
+ $writer.set(10);
846
+ await flushMicrotasks();
847
+ expect(onMount).toHaveBeenCalledTimes(1);
848
+ expect($reader.get()).toBe(10);
849
+
850
+ // 구독 해제 시: onCleanup 호출
851
+ unsub();
852
+ await new Promise((r) => setTimeout(r, 10)); // disableAtom 대기
853
+ expect(onCleanup).toHaveBeenCalledTimes(1);
854
+ });
855
+ });
856
+
857
+ // --- "디바운스-스로틀링" 예제 테스트 ---
858
+ describe('Debounce-Throttling Example', () => {
859
+ beforeEach(() => {
860
+ vi.useFakeTimers();
861
+ });
862
+ afterEach(() => {
863
+ vi.useRealTimers();
864
+ });
865
+
866
+ const delayedState = <Value>(
867
+ initial: Value,
868
+ minDelay: number,
869
+ maxDelay: number,
870
+ ) => {
871
+ const $value = $(initial);
872
+ const $delayedValue = $(initial);
873
+
874
+ const $eventStartTime = $(0);
875
+ const $eventLastTime = $(0);
876
+ const $delayedTime = $((get) =>
877
+ Math.min(
878
+ get($eventStartTime) + maxDelay,
879
+ get($eventLastTime) + minDelay,
880
+ ),
881
+ );
882
+ const $delayedInfo = $((get) => ({
883
+ value: get($value),
884
+ time: get($delayedTime),
885
+ }));
886
+ $delayedInfo.subscribe(({ value, time }, { signal }) => {
887
+ const timeout = Math.max(0, time - Date.now());
888
+ const timer = setTimeout(
889
+ () => $delayedValue.set(value),
890
+ timeout,
891
+ );
892
+ signal.then(() => clearTimeout(timer));
893
+ });
894
+
895
+ const update = (value: Value, eager = false) => {
896
+ const now = eager ? -Infinity : Date.now();
897
+ if ($delayedValue.state.value === $value.state.value) {
898
+ $eventStartTime.set(now);
899
+ }
900
+ $eventLastTime.set(now);
901
+ $value.set(value);
902
+ };
903
+
904
+ return [$delayedValue, update] as const;
905
+ };
906
+
907
+ it('should update after minDelay', async () => {
908
+ const [$inputValue, updateInput] = delayedState('', 200, 1000);
909
+ const subscriber = vi.fn();
910
+ $inputValue.subscribe(subscriber);
911
+ await flushMicrotasks();
912
+ subscriber.mockClear();
913
+
914
+ updateInput('test');
915
+ await flushMicrotasks();
916
+ expect(subscriber).not.toHaveBeenCalled();
917
+
918
+ vi.advanceTimersByTime(199);
919
+ await flushMicrotasks();
920
+ expect(subscriber).not.toHaveBeenCalled();
921
+
922
+ vi.advanceTimersByTime(2); // 총 201ms 경과
923
+ await flushMicrotasks();
924
+ expect(subscriber).toHaveBeenCalledWith('test', expect.anything());
925
+ });
926
+
927
+ it('should update after maxDelay even with continuous updates', async () => {
928
+ const [$inputValue, updateInput] = delayedState('', 200, 1000);
929
+ const subscriber = vi.fn();
930
+ $inputValue.subscribe(subscriber);
931
+ await flushMicrotasks();
932
+ subscriber.mockClear();
933
+
934
+ updateInput('a'); // 0ms
935
+ await flushMicrotasks();
936
+
937
+ vi.advanceTimersByTime(500); // 500ms
938
+ updateInput('ab');
939
+ await flushMicrotasks();
940
+
941
+ vi.advanceTimersByTime(500); // 1000ms
942
+ await flushMicrotasks();
943
+ expect(subscriber).toHaveBeenCalledWith('ab', expect.anything());
944
+ subscriber.mockClear();
945
+
946
+ updateInput('abc');
947
+ await flushMicrotasks();
948
+ vi.advanceTimersByTime(1001); // maxDelay 경과
949
+ await flushMicrotasks();
950
+ expect(subscriber).toHaveBeenCalledWith('abc', expect.anything());
951
+ });
952
+ });
953
+
954
+ // --- "스크롤 방향 감지" 예제 테스트 ---
955
+ /*
956
+ describe('Scroll Direction Detection Example', () => {
957
+ let scrollHandler: () => void;
958
+
959
+ beforeEach(() => {
960
+ vi.spyOn(window, 'scrollY', 'get').mockReturnValue(0);
961
+ vi.spyOn(window, 'addEventListener').mockImplementation((event, handler) => {
962
+ if (event === 'scroll' || event === 'resize') {
963
+ scrollHandler = handler as () => void;
964
+ }
965
+ });
966
+ vi.spyOn(window, 'removeEventListener');
967
+ });
968
+
969
+ afterEach(() => {
970
+ vi.mocked(window.addEventListener).mockRestore();
971
+ vi.mocked(window.removeEventListener).mockRestore();
972
+ vi.mocked(window.scrollY, 'get').mockRestore();
973
+ });
974
+
975
+ it('should detect scroll direction and update navHidden state', async () => {
976
+ const $windowScroll = $((_, { signal }) => {
977
+ let lastTime = Date.now();
978
+ const $scrollY = $(window.scrollY);
979
+ const $scrollOnTop = $((get) => get($scrollY) === 0);
980
+
981
+ const $scrollMovingAvgY = $(0);
982
+ const $scrollDirectionY = $((get) => Math.sign(get($scrollMovingAvgY)));
983
+
984
+ const onScrollChange = () => {
985
+ const now = Date.now();
986
+ const alpha = 0.995 ** (now - lastTime);
987
+ lastTime = now;
988
+ const scrollY = window.scrollY;
989
+ const deltaY = scrollY - $scrollY.state.value;
990
+ const movingAvgY = alpha * $scrollMovingAvgY.state.value + (1 - alpha) * deltaY;
991
+
992
+ $scrollY.set(scrollY);
993
+ $scrollMovingAvgY.set(movingAvgY);
994
+ };
995
+ window.addEventListener('scroll', onScrollChange, { passive: true, signal });
996
+ window.addEventListener('resize', onScrollChange, { passive: true, signal });
997
+
998
+ return { $scrollOnTop, $scrollDirectionY };
999
+ });
1000
+
1001
+ const $navHidden = $((get) => {
1002
+ const { $scrollOnTop, $scrollDirectionY } = get($windowScroll);
1003
+ const scrollOnTop = get($scrollOnTop);
1004
+ const directionY = get($scrollDirectionY);
1005
+ return !scrollOnTop && directionY > 0;
1006
+ });
1007
+
1008
+ const unsub = $navHidden.subscribe(() => {});
1009
+ await flushMicrotasks();
1010
+
1011
+ // 초기 상태: 스크롤 최상단, nav 보임
1012
+ expect($navHidden.get()).toBe(false);
1013
+
1014
+ // 아래로 스크롤: nav 숨김
1015
+ vi.spyOn(window, 'scrollY', 'get').mockReturnValue(200);
1016
+ scrollHandler();
1017
+ await flushMicrotasks();
1018
+ expect($navHidden.get()).toBe(true);
1019
+
1020
+ // 위로 스크롤: nav 보임
1021
+ vi.spyOn(window, 'scrollY', 'get').mockReturnValue(100);
1022
+ scrollHandler();
1023
+ await flushMicrotasks();
1024
+ expect($navHidden.get()).toBe(false);
1025
+
1026
+ // 최상단으로 스크롤: nav 보임
1027
+ vi.spyOn(window, 'scrollY', 'get').mockReturnValue(0);
1028
+ scrollHandler();
1029
+ await flushMicrotasks();
1030
+ expect($navHidden.get()).toBe(false);
1031
+
1032
+ unsub();
1033
+ });
1034
+ });
1035
+ */
1036
+ });