Skip to content

Commit 580838e

Browse files
committed
test(utils): add extensive test coverage for edge cases in utilities
- Added additional tests for `devtools` to cover time travel, error handling, and message payloads. - Extended test cases for `persist` to handle null states, invalid envelopes, and unsubscribe behavior. - Introduced more cases for `subscribeKey` with various data types and subscriber behavior. - Enhanced `shallowEqual` tests to include edge cases such as array vs object comparison and handling undefined values.
1 parent 6b101bc commit 580838e

9 files changed

Lines changed: 843 additions & 24 deletions

File tree

src/utils/devtools/devtools.test.ts

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,234 @@ describe('devtools', () => {
189189
expect(store.doubled).toBe(6);
190190
});
191191

192+
it('handles JUMP_TO_ACTION time-travel', async () => {
193+
const conn = createMockConnection();
194+
const ext = createMockExtension(conn);
195+
setExtension(ext);
196+
197+
class Store {
198+
count = 0;
199+
}
200+
201+
const store = createClassyStore(new Store());
202+
devtools(store);
203+
204+
store.count = 10;
205+
await tick();
206+
207+
conn._listener?.({
208+
type: 'DISPATCH',
209+
payload: {type: 'JUMP_TO_ACTION'},
210+
state: JSON.stringify({count: 3}),
211+
});
212+
await tick();
213+
214+
expect(store.count).toBe(3);
215+
});
216+
217+
it('skips methods during time-travel restore', async () => {
218+
const conn = createMockConnection();
219+
const ext = createMockExtension(conn);
220+
setExtension(ext);
221+
222+
class Store {
223+
count = 0;
224+
increment() {
225+
this.count++;
226+
}
227+
}
228+
229+
const store = createClassyStore(new Store());
230+
devtools(store);
231+
232+
conn._listener?.({
233+
type: 'DISPATCH',
234+
payload: {type: 'JUMP_TO_STATE'},
235+
state: JSON.stringify({count: 5, increment: 'should be skipped'}),
236+
});
237+
await tick();
238+
239+
expect(store.count).toBe(5);
240+
expect(typeof store.increment).toBe('function');
241+
});
242+
243+
it('does not send state updates during time-travel', async () => {
244+
const conn = createMockConnection();
245+
const ext = createMockExtension(conn);
246+
setExtension(ext);
247+
248+
class Store {
249+
count = 0;
250+
}
251+
252+
const store = createClassyStore(new Store());
253+
devtools(store);
254+
255+
store.count = 5;
256+
await tick();
257+
const sendCountBefore = conn.send.mock.calls.length;
258+
259+
// Simulate time-travel — should NOT trigger a send
260+
conn._listener?.({
261+
type: 'DISPATCH',
262+
payload: {type: 'JUMP_TO_STATE'},
263+
state: JSON.stringify({count: 0}),
264+
});
265+
await tick();
266+
267+
expect(conn.send.mock.calls.length).toBe(sendCountBefore);
268+
});
269+
270+
it('ignores non-DISPATCH messages', async () => {
271+
const conn = createMockConnection();
272+
const ext = createMockExtension(conn);
273+
setExtension(ext);
274+
275+
class Store {
276+
count = 0;
277+
}
278+
279+
const store = createClassyStore(new Store());
280+
devtools(store);
281+
282+
conn._listener?.({
283+
type: 'ACTION',
284+
state: JSON.stringify({count: 999}),
285+
});
286+
await tick();
287+
288+
expect(store.count).toBe(0);
289+
});
290+
291+
it('ignores DISPATCH messages without state', async () => {
292+
const conn = createMockConnection();
293+
const ext = createMockExtension(conn);
294+
setExtension(ext);
295+
296+
class Store {
297+
count = 0;
298+
}
299+
300+
const store = createClassyStore(new Store());
301+
devtools(store);
302+
303+
conn._listener?.({
304+
type: 'DISPATCH',
305+
payload: {type: 'JUMP_TO_STATE'},
306+
// no state field
307+
});
308+
await tick();
309+
310+
expect(store.count).toBe(0);
311+
});
312+
313+
it('ignores DISPATCH messages with non-jump payload types', async () => {
314+
const conn = createMockConnection();
315+
const ext = createMockExtension(conn);
316+
setExtension(ext);
317+
318+
class Store {
319+
count = 0;
320+
}
321+
322+
const store = createClassyStore(new Store());
323+
devtools(store);
324+
325+
conn._listener?.({
326+
type: 'DISPATCH',
327+
payload: {type: 'COMMIT'},
328+
state: JSON.stringify({count: 999}),
329+
});
330+
await tick();
331+
332+
expect(store.count).toBe(0);
333+
});
334+
335+
it('handles corrupted JSON in time-travel state gracefully', async () => {
336+
const conn = createMockConnection();
337+
const ext = createMockExtension(conn);
338+
setExtension(ext);
339+
340+
class Store {
341+
count = 5;
342+
}
343+
344+
const store = createClassyStore(new Store());
345+
devtools(store);
346+
347+
// Should not throw
348+
conn._listener?.({
349+
type: 'DISPATCH',
350+
payload: {type: 'JUMP_TO_STATE'},
351+
state: 'not-valid-json!!!',
352+
});
353+
await tick();
354+
355+
expect(store.count).toBe(5); // unchanged
356+
});
357+
358+
it('uses default name ClassyStore when no name provided', () => {
359+
const conn = createMockConnection();
360+
const ext = createMockExtension(conn);
361+
setExtension(ext);
362+
363+
class Store {
364+
count = 0;
365+
}
366+
367+
const store = createClassyStore(new Store());
368+
devtools(store);
369+
370+
expect(ext.connect).toHaveBeenCalledWith({name: 'ClassyStore'});
371+
});
372+
373+
it('handles connection.subscribe returning {unsubscribe} object', async () => {
374+
const unsubMock = mock(() => {});
375+
const conn = createMockConnection();
376+
// Override subscribe to return an object with unsubscribe method
377+
conn.subscribe = mock((listener: (message: unknown) => void) => {
378+
conn._listener = listener;
379+
return {unsubscribe: unsubMock};
380+
});
381+
const ext = createMockExtension(conn);
382+
setExtension(ext);
383+
384+
class Store {
385+
count = 0;
386+
}
387+
388+
const store = createClassyStore(new Store());
389+
const dispose = devtools(store);
390+
391+
dispose();
392+
393+
expect(unsubMock).toHaveBeenCalledTimes(1);
394+
});
395+
396+
it('sends multiple state updates for sequential mutations', async () => {
397+
const conn = createMockConnection();
398+
const ext = createMockExtension(conn);
399+
setExtension(ext);
400+
401+
class Store {
402+
count = 0;
403+
}
404+
405+
const store = createClassyStore(new Store());
406+
devtools(store);
407+
408+
store.count = 1;
409+
await tick();
410+
store.count = 2;
411+
await tick();
412+
store.count = 3;
413+
await tick();
414+
415+
expect(conn.send).toHaveBeenCalledTimes(3);
416+
const lastState = conn.send.mock.calls[2][1] as Record<string, unknown>;
417+
expect(lastState.count).toBe(3);
418+
});
419+
192420
it('disposes correctly (unsubscribes from store and devtools)', async () => {
193421
const conn = createMockConnection();
194422
const ext = createMockExtension(conn);

src/utils/devtools/devtools.ts

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -90,25 +90,27 @@ export function devtools<T extends object>(
9090
const newState = JSON.parse(message.state) as Record<string, unknown>;
9191
isTimeTraveling = true;
9292

93-
try {
94-
// Apply state back to the proxy, skipping getters and methods
95-
for (const key of Object.keys(newState)) {
96-
// Skip getters
97-
if (findGetterDescriptor(proxyStore, key)?.get) continue;
98-
// Skip methods
99-
if (
100-
typeof (proxyStore as Record<string, unknown>)[key] ===
101-
'function'
102-
) {
103-
continue;
104-
}
105-
(proxyStore as Record<string, unknown>)[key] = newState[key];
93+
// Apply state back to the proxy, skipping getters and methods
94+
for (const key of Object.keys(newState)) {
95+
// Skip getters
96+
if (findGetterDescriptor(proxyStore, key)?.get) continue;
97+
// Skip methods
98+
if (
99+
typeof (proxyStore as Record<string, unknown>)[key] === 'function'
100+
) {
101+
continue;
106102
}
107-
} finally {
108-
isTimeTraveling = false;
103+
(proxyStore as Record<string, unknown>)[key] = newState[key];
109104
}
105+
106+
// Reset after microtask so the batched subscription callback
107+
// (which fires via queueMicrotask) still sees the flag as true.
108+
queueMicrotask(() => {
109+
isTimeTraveling = false;
110+
});
110111
} catch {
111-
// JSON.parse failed — ignore corrupted DevTools state.
112+
// JSON.parse or property assignment failed — ignore and reset flag.
113+
isTimeTraveling = false;
112114
}
113115
}
114116
}

src/utils/equality/equality.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,52 @@ describe('shallowEqual', () => {
7979
it('treats +0 and -0 as not equal (Object.is semantics)', () => {
8080
expect(shallowEqual(+0, -0)).toBe(false);
8181
});
82+
83+
// ── Additional edge cases ───────────────────────────────────────────
84+
85+
it('returns false for array vs non-array-like object', () => {
86+
expect(shallowEqual([1, 2] as unknown, {a: 1, b: 2} as unknown)).toBe(
87+
false,
88+
);
89+
});
90+
91+
it('returns true for empty objects', () => {
92+
expect(shallowEqual({}, {})).toBe(true);
93+
});
94+
95+
it('returns true for empty arrays', () => {
96+
expect(shallowEqual([], [])).toBe(true);
97+
});
98+
99+
it('returns false for undefined vs null', () => {
100+
expect(shallowEqual(undefined, null as unknown as undefined)).toBe(false);
101+
});
102+
103+
it('handles objects with undefined values', () => {
104+
expect(shallowEqual({a: undefined}, {a: undefined})).toBe(true);
105+
expect(
106+
shallowEqual({a: undefined}, {a: null} as unknown as {a: undefined}),
107+
).toBe(false);
108+
});
109+
110+
it('compares nested arrays by reference only', () => {
111+
const arr1 = [1, 2];
112+
const arr2 = [1, 2];
113+
expect(shallowEqual({a: arr1}, {a: arr1})).toBe(true);
114+
expect(shallowEqual({a: arr1}, {a: arr2})).toBe(false);
115+
});
116+
117+
it('handles arrays with NaN elements', () => {
118+
expect(shallowEqual([Number.NaN], [Number.NaN])).toBe(true);
119+
expect(shallowEqual([Number.NaN, 1], [Number.NaN, 2])).toBe(false);
120+
});
121+
122+
it('returns false when one is array and the other is not', () => {
123+
expect(shallowEqual([1] as unknown, 'not-array' as unknown)).toBe(false);
124+
expect(shallowEqual('not-array' as unknown, [1] as unknown)).toBe(false);
125+
});
126+
127+
it('handles objects with symbol-like string keys', () => {
128+
expect(shallowEqual({'Symbol(foo)': 1}, {'Symbol(foo)': 1})).toBe(true);
129+
});
82130
});

0 commit comments

Comments
 (0)