Skip to content

Commit 5408ab3

Browse files
Add store tests
1 parent bfc96cd commit 5408ab3

File tree

2 files changed

+171
-3
lines changed

2 files changed

+171
-3
lines changed

src/store.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,11 @@ export class StateStore<T extends Record<string, unknown>> {
154154
*
155155
* @template O The type of the original state store
156156
* @template M The type of the merged state store
157+
*
158+
* @experimental
159+
* This class is experimental and may change in future versions.
157160
*/
158-
class MergedStateStore<
161+
export class MergedStateStore<
159162
O extends Record<string, unknown>,
160163
M extends Record<string, unknown>,
161164
> extends StateStore<O & M> {
@@ -292,8 +295,7 @@ class MergedStateStore<
292295
}
293296
}
294297

295-
// EXAMPLE:
296-
298+
/** EXAMPLE:
297299
const Uninitialized = Symbol('uninitialized');
298300
299301
const b = new StateStore<{
@@ -327,3 +329,4 @@ a.subscribe((ns) => console.log(ns));
327329
a.original.partialNext({ next: 'next' });
328330
a.original.partialNext({ next: null });
329331
a.original.partialNext({ next: Uninitialized });
332+
*/

test/unit/store.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { MergedStateStore, StateStore } from '../../src/store';
3+
4+
describe('StateStore', () => {
5+
type State = { count: number; flag: boolean };
6+
7+
let store: StateStore<State>;
8+
const initialState = { count: 0, flag: false };
9+
10+
beforeEach(() => {
11+
store = new StateStore<State>(initialState);
12+
});
13+
14+
it('should initialize with given value', () => {
15+
expect(store.getLatestValue()).toEqual(initialState);
16+
});
17+
18+
it('should update state with next (object)', () => {
19+
const newState = { count: 5, flag: true };
20+
store.next(newState);
21+
expect(store.getLatestValue()).toStrictEqual(newState);
22+
});
23+
24+
it('should update state with next (patch function)', () => {
25+
store.next((prev) => ({ ...prev, count: prev.count + 1 }));
26+
expect(store.getLatestValue()).toEqual({ count: 1, flag: false });
27+
});
28+
29+
it('should not notify subscribers if value does not change', () => {
30+
const handler = vi.fn();
31+
store.subscribe(handler);
32+
handler.mockClear();
33+
store.next(store.getLatestValue());
34+
expect(handler).not.toHaveBeenCalled();
35+
});
36+
37+
it('should notify subscribers on value change', () => {
38+
const handler = vi.fn();
39+
store.subscribe(handler);
40+
handler.mockClear();
41+
store.next({ count: 2, flag: true });
42+
expect(handler).toHaveBeenCalledWith({ count: 2, flag: true }, initialState);
43+
});
44+
45+
it('should support partialNext', () => {
46+
store.partialNext({ count: 10 });
47+
expect(store.getLatestValue()).toEqual({ count: 10, flag: false });
48+
});
49+
50+
it('should unsubscribe handlers', () => {
51+
const handler = vi.fn();
52+
const unsub = store.subscribe(handler);
53+
unsub();
54+
store.next({ count: 1, flag: true });
55+
expect(handler).toHaveBeenCalledTimes(1); // only initial call
56+
});
57+
58+
it('should support subscribeWithSelector', () => {
59+
const handler = vi.fn();
60+
store.subscribeWithSelector((s) => ({ count: s.count }), handler);
61+
handler.mockClear();
62+
store.partialNext({ flag: true });
63+
expect(handler).not.toHaveBeenCalled();
64+
store.partialNext({ count: 42 });
65+
expect(handler).toHaveBeenCalledWith({ count: 42 }, { count: 0 });
66+
});
67+
68+
it('should support registerModifier', () => {
69+
const handler = vi.fn();
70+
store.subscribe(handler);
71+
72+
store.registerModifier((next) => {
73+
if (next.count > 5) next.count = 5;
74+
});
75+
76+
store.partialNext({ count: 10 });
77+
expect(handler).toHaveBeenCalledTimes(2);
78+
expect(store.getLatestValue().count).toBe(5);
79+
});
80+
81+
it('should not call modifiers if value does not change', () => {
82+
const mod = vi.fn();
83+
84+
store.registerModifier(mod);
85+
store.next(store.getLatestValue());
86+
87+
expect(mod).not.toHaveBeenCalled();
88+
});
89+
90+
it('should allow unregistering modifiers', () => {
91+
const mod = vi.fn();
92+
const unregister = store.registerModifier(mod);
93+
unregister();
94+
store.partialNext({ count: 2 });
95+
expect(mod).not.toHaveBeenCalled();
96+
});
97+
});
98+
99+
describe('MergedStateStore', () => {
100+
type StateA = { a: number };
101+
type StateB = { b: string };
102+
103+
let storeA: StateStore<StateA>;
104+
let storeB: StateStore<StateB>;
105+
let merged: MergedStateStore<StateA, StateB>;
106+
107+
beforeEach(() => {
108+
storeA = new StateStore<StateA>({ a: 1 });
109+
storeB = new StateStore<StateB>({ b: 'x' });
110+
merged = storeA.merge(storeB);
111+
});
112+
113+
it('should combine values from both stores', () => {
114+
expect(merged.getLatestValue()).toEqual({ a: 1, b: 'x' });
115+
});
116+
117+
it('should update merged value when original changes', () => {
118+
storeA.partialNext({ a: 2 });
119+
expect(merged.getLatestValue()).toEqual({ a: 2, b: 'x' });
120+
});
121+
122+
it('should update merged value when merged store changes', () => {
123+
storeB.partialNext({ b: 'y' });
124+
expect(merged.getLatestValue()).toEqual({ a: 1, b: 'y' });
125+
});
126+
127+
it('should notify subscribers on changes from either store', () => {
128+
const handler = vi.fn();
129+
merged.subscribe(handler);
130+
handler.mockClear();
131+
storeA.partialNext({ a: 3 });
132+
expect(handler).toHaveBeenCalledWith({ a: 3, b: 'x' }, { a: 1, b: 'x' });
133+
handler.mockClear();
134+
storeB.partialNext({ b: 'z' });
135+
expect(handler).toHaveBeenCalledWith({ a: 3, b: 'z' }, { a: 3, b: 'x' });
136+
});
137+
138+
it('should not allow direct mutation via next/partialNext/registerModifier', () => {
139+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
140+
// @ts-expect-error testing purposes
141+
merged.next({ a: 5, b: 'q' });
142+
// @ts-expect-error testing purposes
143+
merged.partialNext({ a: 5 });
144+
// @ts-expect-error testing purposes
145+
merged.registerModifier(() => {});
146+
expect(warn).toHaveBeenCalledTimes(3);
147+
warn.mockRestore();
148+
});
149+
150+
it('should keep merged value in sync even without subscribers', () => {
151+
storeA.partialNext({ a: 10 });
152+
storeB.partialNext({ b: 'sync' });
153+
expect(merged.getLatestValue()).toEqual({ a: 10, b: 'sync' });
154+
});
155+
156+
it('should unsubscribe all handlers (helpers too)', () => {
157+
const handler = vi.fn();
158+
const unsub = merged.subscribe(handler);
159+
unsub();
160+
storeA.partialNext({ a: 99 });
161+
expect(handler).toHaveBeenCalledTimes(1); // only initial call
162+
// @ts-expect-error testing internals
163+
expect(merged.handlers.size).to.equal(0);
164+
});
165+
});

0 commit comments

Comments
 (0)