test: localStorage-backed state modules (+38)
Via subagent, no bugs: - state/utils/atomWithLocalStorage (9): get/set helpers + atom write-through. - state/scheduledMessages (6): Map<->Record round-trip, persistence, mount-gated hydration (atomWithStorage w/o getOnInit — modeled with a subscription). - state/spaceRooms (9): Set dedupe + no-write-when-unchanged + serialization. - state/navToActivePath (8): per-user Map<->Object serialization. - state/callPreferences (6): the privacy rule forcing video=false on load+persist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createStore } from 'jotai';
|
||||||
|
import type { CallPreferences } from './callPreferences';
|
||||||
|
|
||||||
|
// `makeCallPreferencesAtom(userId)` is a factory backed by `atomWithLocalStorage`.
|
||||||
|
// localStorage is read when the returned atom is created; we install an
|
||||||
|
// in-memory mock first so reads/writes resolve against it.
|
||||||
|
const CALL_PREFERENCES = 'callPreferences';
|
||||||
|
const storeKeyFor = (userId: string): string => `${CALL_PREFERENCES}${userId}`;
|
||||||
|
|
||||||
|
const installStorage = (): Map<string, string> => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||||
|
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
||||||
|
setItem: (k: string, v: string) => {
|
||||||
|
map.set(k, v);
|
||||||
|
},
|
||||||
|
removeItem: (k: string) => {
|
||||||
|
map.delete(k);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
installStorage();
|
||||||
|
// eslint-disable-next-line import/first
|
||||||
|
import { makeCallPreferencesAtom } from './callPreferences';
|
||||||
|
|
||||||
|
const USER = '@user:server';
|
||||||
|
|
||||||
|
test('defaults to mic on, sound on, video off', () => {
|
||||||
|
installStorage();
|
||||||
|
const prefsAtom = makeCallPreferencesAtom(USER);
|
||||||
|
const store = createStore();
|
||||||
|
assert.deepEqual(store.get(prefsAtom), {
|
||||||
|
microphone: true,
|
||||||
|
video: false,
|
||||||
|
sound: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forces video to false on LOAD even when stored as true', () => {
|
||||||
|
const backing = installStorage();
|
||||||
|
backing.set(storeKeyFor(USER), JSON.stringify({ microphone: false, video: true, sound: false }));
|
||||||
|
|
||||||
|
const prefsAtom = makeCallPreferencesAtom(USER);
|
||||||
|
const store = createStore();
|
||||||
|
const prefs = store.get(prefsAtom);
|
||||||
|
|
||||||
|
// video is overridden, but mic/sound are preserved from storage.
|
||||||
|
assert.equal(prefs.video, false);
|
||||||
|
assert.equal(prefs.microphone, false);
|
||||||
|
assert.equal(prefs.sound, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forces video to false on PERSIST even when set to true', () => {
|
||||||
|
const backing = installStorage();
|
||||||
|
const prefsAtom = makeCallPreferencesAtom(USER);
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
store.set(prefsAtom, { microphone: true, video: true, sound: true });
|
||||||
|
|
||||||
|
const raw = backing.get(storeKeyFor(USER));
|
||||||
|
assert.ok(raw);
|
||||||
|
const persisted = JSON.parse(raw!) as CallPreferences;
|
||||||
|
assert.equal(persisted.video, false);
|
||||||
|
// mic/sound persisted as given.
|
||||||
|
assert.equal(persisted.microphone, true);
|
||||||
|
assert.equal(persisted.sound, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the in-memory atom value also has video forced off after a write', () => {
|
||||||
|
installStorage();
|
||||||
|
const prefsAtom = makeCallPreferencesAtom(USER);
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
// atomWithLocalStorage write-through stores newValue verbatim on the atom,
|
||||||
|
// while only the persisted copy is sanitized. The next load re-sanitizes.
|
||||||
|
store.set(prefsAtom, { microphone: true, video: true, sound: true });
|
||||||
|
|
||||||
|
// Re-create the atom to model a fresh load from the (sanitized) storage.
|
||||||
|
const reloaded = makeCallPreferencesAtom(USER);
|
||||||
|
assert.equal(store.get(reloaded).video, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves mic/sound toggles across a persist + reload cycle', () => {
|
||||||
|
installStorage();
|
||||||
|
const prefsAtom = makeCallPreferencesAtom(USER);
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
store.set(prefsAtom, { microphone: false, video: false, sound: false });
|
||||||
|
|
||||||
|
const reloaded = makeCallPreferencesAtom(USER);
|
||||||
|
const prefs = store.get(reloaded);
|
||||||
|
assert.equal(prefs.microphone, false);
|
||||||
|
assert.equal(prefs.sound, false);
|
||||||
|
assert.equal(prefs.video, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preferences are scoped per userId', () => {
|
||||||
|
const backing = installStorage();
|
||||||
|
const userA = '@a:s';
|
||||||
|
const userB = '@b:s';
|
||||||
|
const atomA = makeCallPreferencesAtom(userA);
|
||||||
|
const atomB = makeCallPreferencesAtom(userB);
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
store.set(atomA, { microphone: false, video: false, sound: true });
|
||||||
|
store.set(atomB, { microphone: true, video: false, sound: false });
|
||||||
|
|
||||||
|
assert.ok(backing.has(storeKeyFor(userA)));
|
||||||
|
assert.ok(backing.has(storeKeyFor(userB)));
|
||||||
|
assert.equal(store.get(atomA).microphone, false);
|
||||||
|
assert.equal(store.get(atomB).microphone, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createStore } from 'jotai';
|
||||||
|
import { enableMapSet } from 'immer';
|
||||||
|
import type { Path } from 'react-router-dom';
|
||||||
|
|
||||||
|
// `makeNavToActivePathAtom(userId)` is a factory: localStorage is read when the
|
||||||
|
// returned atom is first created/accessed (not at module load), but we still
|
||||||
|
// install the mock up front. The reducers `produce` over an immer-managed Map,
|
||||||
|
// so immer's Map/Set plugin must be enabled.
|
||||||
|
const NAV_TO_ACTIVE_PATH = 'navToActivePath';
|
||||||
|
const storeKeyFor = (userId: string): string => `${NAV_TO_ACTIVE_PATH}${userId}`;
|
||||||
|
|
||||||
|
const installStorage = (): Map<string, string> => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||||
|
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
||||||
|
setItem: (k: string, v: string) => {
|
||||||
|
map.set(k, v);
|
||||||
|
},
|
||||||
|
removeItem: (k: string) => {
|
||||||
|
map.delete(k);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
enableMapSet();
|
||||||
|
installStorage();
|
||||||
|
// eslint-disable-next-line import/first
|
||||||
|
import { makeNavToActivePathAtom } from './navToActivePath';
|
||||||
|
|
||||||
|
const USER = '@user:server';
|
||||||
|
const path = (pathname: string): Path => ({ pathname, search: '', hash: '' });
|
||||||
|
|
||||||
|
test('starts as an empty Map', () => {
|
||||||
|
installStorage();
|
||||||
|
const navAtom = makeNavToActivePathAtom(USER);
|
||||||
|
const store = createStore();
|
||||||
|
const map = store.get(navAtom);
|
||||||
|
assert.ok(map instanceof Map);
|
||||||
|
assert.equal(map.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT stores a path under its navId', () => {
|
||||||
|
installStorage();
|
||||||
|
const navAtom = makeNavToActivePathAtom(USER);
|
||||||
|
const store = createStore();
|
||||||
|
store.set(navAtom, { type: 'PUT', navId: 'home', path: path('/home') });
|
||||||
|
assert.deepEqual(store.get(navAtom).get('home'), path('/home'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT overwrites the path for an existing navId', () => {
|
||||||
|
installStorage();
|
||||||
|
const navAtom = makeNavToActivePathAtom(USER);
|
||||||
|
const store = createStore();
|
||||||
|
store.set(navAtom, { type: 'PUT', navId: 'home', path: path('/old') });
|
||||||
|
store.set(navAtom, { type: 'PUT', navId: 'home', path: path('/new') });
|
||||||
|
const map = store.get(navAtom);
|
||||||
|
assert.equal(map.size, 1);
|
||||||
|
assert.deepEqual(map.get('home'), path('/new'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE removes a navId', () => {
|
||||||
|
installStorage();
|
||||||
|
const navAtom = makeNavToActivePathAtom(USER);
|
||||||
|
const store = createStore();
|
||||||
|
store.set(navAtom, { type: 'PUT', navId: 'home', path: path('/home') });
|
||||||
|
store.set(navAtom, { type: 'PUT', navId: 'dms', path: path('/dms') });
|
||||||
|
store.set(navAtom, { type: 'DELETE', navId: 'home' });
|
||||||
|
|
||||||
|
const map = store.get(navAtom);
|
||||||
|
assert.equal(map.has('home'), false);
|
||||||
|
assert.deepEqual(map.get('dms'), path('/dms'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE of an absent navId is a no-op', () => {
|
||||||
|
installStorage();
|
||||||
|
const navAtom = makeNavToActivePathAtom(USER);
|
||||||
|
const store = createStore();
|
||||||
|
store.set(navAtom, { type: 'PUT', navId: 'home', path: path('/home') });
|
||||||
|
store.set(navAtom, { type: 'DELETE', navId: 'ghost' });
|
||||||
|
assert.deepEqual([...store.get(navAtom).keys()], ['home']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists to localStorage as an Object keyed per user', () => {
|
||||||
|
const backing = installStorage();
|
||||||
|
const navAtom = makeNavToActivePathAtom(USER);
|
||||||
|
const store = createStore();
|
||||||
|
store.set(navAtom, { type: 'PUT', navId: 'home', path: path('/home') });
|
||||||
|
|
||||||
|
const raw = backing.get(storeKeyFor(USER));
|
||||||
|
assert.ok(raw);
|
||||||
|
const obj = JSON.parse(raw!) as Record<string, Path>;
|
||||||
|
assert.deepEqual(obj, { home: path('/home') });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hydrates the Map from a stored Object', () => {
|
||||||
|
const backing = installStorage();
|
||||||
|
backing.set(storeKeyFor(USER), JSON.stringify({ home: path('/home') }));
|
||||||
|
|
||||||
|
const navAtom = makeNavToActivePathAtom(USER);
|
||||||
|
const store = createStore();
|
||||||
|
assert.deepEqual(store.get(navAtom).get('home'), path('/home'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('storage is scoped per userId', () => {
|
||||||
|
const backing = installStorage();
|
||||||
|
const userA = '@a:s';
|
||||||
|
const userB = '@b:s';
|
||||||
|
|
||||||
|
const atomA = makeNavToActivePathAtom(userA);
|
||||||
|
const atomB = makeNavToActivePathAtom(userB);
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
store.set(atomA, { type: 'PUT', navId: 'home', path: path('/a-home') });
|
||||||
|
store.set(atomB, { type: 'PUT', navId: 'home', path: path('/b-home') });
|
||||||
|
|
||||||
|
assert.ok(backing.has(storeKeyFor(userA)));
|
||||||
|
assert.ok(backing.has(storeKeyFor(userB)));
|
||||||
|
assert.deepEqual(store.get(atomA).get('home'), path('/a-home'));
|
||||||
|
assert.deepEqual(store.get(atomB).get('home'), path('/b-home'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createStore } from 'jotai';
|
||||||
|
import type { ScheduledMessage } from './scheduledMessages';
|
||||||
|
|
||||||
|
// scheduledMessagesAtom is backed by jotai's atomWithStorage over
|
||||||
|
// `createJSONStorage(() => localStorage)`, which dereferences `localStorage`
|
||||||
|
// (absent in node) lazily on first store access. We install an in-memory mock
|
||||||
|
// before importing the module so both module init and the storage reads/writes
|
||||||
|
// resolve against it.
|
||||||
|
const STORAGE_KEY = 'cinny_scheduled_messages_v1';
|
||||||
|
|
||||||
|
const installStorage = (): Map<string, string> => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||||
|
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
||||||
|
setItem: (k: string, v: string) => {
|
||||||
|
map.set(k, v);
|
||||||
|
},
|
||||||
|
removeItem: (k: string) => {
|
||||||
|
map.delete(k);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
installStorage();
|
||||||
|
const { scheduledMessagesAtom } = await import('./scheduledMessages');
|
||||||
|
|
||||||
|
// jotai's `atomWithStorage` binds to the `localStorage` captured at module
|
||||||
|
// evaluation. To exercise hydration from pre-existing storage we install seeded
|
||||||
|
// storage and then import a *fresh* (cache-busted) copy of the module.
|
||||||
|
let freshCounter = 0;
|
||||||
|
const importWithStorage = async (
|
||||||
|
seed?: Record<string, ScheduledMessage[]>,
|
||||||
|
): Promise<typeof import('./scheduledMessages').scheduledMessagesAtom> => {
|
||||||
|
const backing = installStorage();
|
||||||
|
if (seed) backing.set(STORAGE_KEY, JSON.stringify(seed));
|
||||||
|
freshCounter += 1;
|
||||||
|
const mod = await import(`./scheduledMessages?fresh=${freshCounter}`);
|
||||||
|
return mod.scheduledMessagesAtom;
|
||||||
|
};
|
||||||
|
|
||||||
|
const msg = (delayId: string, roomId: string): ScheduledMessage => ({
|
||||||
|
delayId,
|
||||||
|
roomId,
|
||||||
|
content: { body: delayId, msgtype: 'm.text' },
|
||||||
|
sendAt: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starts as an empty Map', () => {
|
||||||
|
installStorage();
|
||||||
|
const store = createStore();
|
||||||
|
const map = store.get(scheduledMessagesAtom);
|
||||||
|
assert.ok(map instanceof Map);
|
||||||
|
assert.equal(map.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setting a Map is readable back as an equivalent Map (round-trip)', () => {
|
||||||
|
installStorage();
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
const next = new Map<string, ScheduledMessage[]>([['!room:s', [msg('d1', '!room:s')]]]);
|
||||||
|
store.set(scheduledMessagesAtom, next);
|
||||||
|
|
||||||
|
const got = store.get(scheduledMessagesAtom);
|
||||||
|
assert.ok(got instanceof Map);
|
||||||
|
assert.deepEqual(got.get('!room:s'), [msg('d1', '!room:s')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('functional-updater form receives the previous Map', () => {
|
||||||
|
installStorage();
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
store.set(scheduledMessagesAtom, new Map([['!a:s', [msg('d1', '!a:s')]]]));
|
||||||
|
|
||||||
|
let seenPrev: Map<string, ScheduledMessage[]> | undefined;
|
||||||
|
store.set(scheduledMessagesAtom, (prev) => {
|
||||||
|
seenPrev = prev;
|
||||||
|
const copy = new Map(prev);
|
||||||
|
copy.set('!b:s', [msg('d2', '!b:s')]);
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(seenPrev instanceof Map);
|
||||||
|
assert.deepEqual(seenPrev?.get('!a:s'), [msg('d1', '!a:s')]);
|
||||||
|
|
||||||
|
const got = store.get(scheduledMessagesAtom);
|
||||||
|
assert.deepEqual([...got.keys()].sort(), ['!a:s', '!b:s']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists to localStorage as a plain Record keyed by roomId', () => {
|
||||||
|
const backing = installStorage();
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
store.set(
|
||||||
|
scheduledMessagesAtom,
|
||||||
|
new Map<string, ScheduledMessage[]>([
|
||||||
|
['!a:s', [msg('d1', '!a:s')]],
|
||||||
|
['!b:s', [msg('d2', '!b:s'), msg('d3', '!b:s')]],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const raw = backing.get(STORAGE_KEY);
|
||||||
|
assert.ok(raw, 'expected the storage key to be written');
|
||||||
|
const parsed = JSON.parse(raw!) as Record<string, ScheduledMessage[]>;
|
||||||
|
assert.deepEqual(Object.keys(parsed).sort(), ['!a:s', '!b:s']);
|
||||||
|
assert.equal(parsed['!b:s'].length, 2);
|
||||||
|
assert.equal(parsed['!a:s'][0].delayId, 'd1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hydrates the Map from a previously stored Record once the atom is mounted', async () => {
|
||||||
|
const freshAtom = await importWithStorage({ '!room:s': [msg('stored', '!room:s')] });
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
// The underlying jotai atomWithStorage is created without `getOnInit`, so a
|
||||||
|
// bare `store.get` returns the default ({}); storage is synced on mount. We
|
||||||
|
// model the React mount with `store.sub`, which fires the onMount hydration.
|
||||||
|
const unsub = store.sub(freshAtom, () => {});
|
||||||
|
try {
|
||||||
|
assert.deepEqual(store.get(freshAtom).get('!room:s'), [msg('stored', '!room:s')]);
|
||||||
|
} finally {
|
||||||
|
unsub();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports multiple rooms independently', () => {
|
||||||
|
installStorage();
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
store.set(scheduledMessagesAtom, (prev) => {
|
||||||
|
const copy = new Map(prev);
|
||||||
|
copy.set('!r1:s', [msg('a', '!r1:s')]);
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
store.set(scheduledMessagesAtom, (prev) => {
|
||||||
|
const copy = new Map(prev);
|
||||||
|
copy.set('!r2:s', [msg('b', '!r2:s')]);
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
|
||||||
|
const map = store.get(scheduledMessagesAtom);
|
||||||
|
assert.deepEqual(map.get('!r1:s'), [msg('a', '!r1:s')]);
|
||||||
|
assert.deepEqual(map.get('!r2:s'), [msg('b', '!r2:s')]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createStore } from 'jotai';
|
||||||
|
import { enableMapSet } from 'immer';
|
||||||
|
|
||||||
|
// `spaceRoomsAtom` is backed by `atomWithLocalStorage`, which reads
|
||||||
|
// `localStorage` AT MODULE LOAD to seed the atom (absent in node). We install an
|
||||||
|
// in-memory mock first. The reducers also `produce` over an immer-managed Set,
|
||||||
|
// so immer's Map/Set plugin must be enabled (the app does this at startup).
|
||||||
|
const SPACE_ROOMS = 'spaceRooms';
|
||||||
|
|
||||||
|
const installStorage = (seed?: string[]): Map<string, string> => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
if (seed) map.set(SPACE_ROOMS, JSON.stringify(seed));
|
||||||
|
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||||
|
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
||||||
|
setItem: (k: string, v: string) => {
|
||||||
|
map.set(k, v);
|
||||||
|
},
|
||||||
|
removeItem: (k: string) => {
|
||||||
|
map.delete(k);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
enableMapSet();
|
||||||
|
installStorage();
|
||||||
|
const { spaceRoomsAtom } = await import('./spaceRooms');
|
||||||
|
|
||||||
|
// `atomWithLocalStorage` seeds the atom from storage exactly once, when the
|
||||||
|
// module is evaluated. To exercise hydration we install seeded storage and then
|
||||||
|
// import a *fresh* copy of the module (cache-busted) so the seed is read.
|
||||||
|
let freshCounter = 0;
|
||||||
|
const importWithStorage = async (
|
||||||
|
seed?: string[],
|
||||||
|
): Promise<typeof import('./spaceRooms').spaceRoomsAtom> => {
|
||||||
|
installStorage(seed);
|
||||||
|
freshCounter += 1;
|
||||||
|
const mod = await import(`./spaceRooms?fresh=${freshCounter}`);
|
||||||
|
return mod.spaceRoomsAtom;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = (s: Set<string>): string[] => [...s].sort();
|
||||||
|
|
||||||
|
test('starts empty when nothing is stored', () => {
|
||||||
|
installStorage();
|
||||||
|
const store = createStore();
|
||||||
|
assert.equal(store.get(spaceRoomsAtom).size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT adds new room ids to the Set', () => {
|
||||||
|
installStorage();
|
||||||
|
const store = createStore();
|
||||||
|
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s', '!b:s'] });
|
||||||
|
assert.deepEqual(sorted(store.get(spaceRoomsAtom)), ['!a:s', '!b:s']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT dedupes against existing entries', () => {
|
||||||
|
installStorage();
|
||||||
|
const store = createStore();
|
||||||
|
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s'] });
|
||||||
|
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s', '!b:s'] });
|
||||||
|
assert.deepEqual(sorted(store.get(spaceRoomsAtom)), ['!a:s', '!b:s']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT with only already-present ids does not write (Set identity unchanged)', () => {
|
||||||
|
installStorage();
|
||||||
|
const store = createStore();
|
||||||
|
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s'] });
|
||||||
|
const before = store.get(spaceRoomsAtom);
|
||||||
|
|
||||||
|
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s'] });
|
||||||
|
const after = store.get(spaceRoomsAtom);
|
||||||
|
|
||||||
|
// No new entries -> the reducer skips set(), so the Set reference is the same.
|
||||||
|
assert.equal(before, after);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE removes present members', () => {
|
||||||
|
installStorage();
|
||||||
|
const store = createStore();
|
||||||
|
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s', '!b:s'] });
|
||||||
|
store.set(spaceRoomsAtom, { type: 'DELETE', roomIds: ['!a:s'] });
|
||||||
|
assert.deepEqual(sorted(store.get(spaceRoomsAtom)), ['!b:s']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE of an absent member does not write (Set identity unchanged)', () => {
|
||||||
|
installStorage();
|
||||||
|
const store = createStore();
|
||||||
|
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s'] });
|
||||||
|
const before = store.get(spaceRoomsAtom);
|
||||||
|
|
||||||
|
store.set(spaceRoomsAtom, { type: 'DELETE', roomIds: ['!nope:s'] });
|
||||||
|
const after = store.get(spaceRoomsAtom);
|
||||||
|
|
||||||
|
assert.equal(before, after);
|
||||||
|
assert.deepEqual(sorted(after), ['!a:s']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE acts when at least one of the ids is present', () => {
|
||||||
|
installStorage();
|
||||||
|
const store = createStore();
|
||||||
|
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s', '!b:s'] });
|
||||||
|
store.set(spaceRoomsAtom, { type: 'DELETE', roomIds: ['!a:s', '!nope:s'] });
|
||||||
|
assert.deepEqual(sorted(store.get(spaceRoomsAtom)), ['!b:s']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hydrates the Set from a stored array', async () => {
|
||||||
|
const freshAtom = await importWithStorage(['!x:s', '!y:s']);
|
||||||
|
const store = createStore();
|
||||||
|
assert.deepEqual(sorted(store.get(freshAtom)), ['!x:s', '!y:s']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists the Set to localStorage as an array', () => {
|
||||||
|
const backing = installStorage();
|
||||||
|
const store = createStore();
|
||||||
|
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s', '!b:s'] });
|
||||||
|
|
||||||
|
const raw = backing.get(SPACE_ROOMS);
|
||||||
|
assert.ok(raw);
|
||||||
|
const arr = JSON.parse(raw!) as string[];
|
||||||
|
assert.deepEqual([...arr].sort(), ['!a:s', '!b:s']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createStore } from 'jotai';
|
||||||
|
import {
|
||||||
|
getLocalStorageItem,
|
||||||
|
setLocalStorageItem,
|
||||||
|
atomWithLocalStorage,
|
||||||
|
} from './atomWithLocalStorage';
|
||||||
|
|
||||||
|
// These helpers read/write the real `localStorage` global, which node lacks, so
|
||||||
|
// we install a small in-memory mock before each case. `atomWithLocalStorage`
|
||||||
|
// also registers a `window` storage listener via `onMount`; we only drive the
|
||||||
|
// pure read/write path through a jotai store (no onMount), so `window` is not
|
||||||
|
// required here.
|
||||||
|
const installStorage = (): Map<string, string> => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||||
|
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
||||||
|
setItem: (k: string, v: string) => {
|
||||||
|
map.set(k, v);
|
||||||
|
},
|
||||||
|
removeItem: (k: string) => {
|
||||||
|
map.delete(k);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('getLocalStorageItem returns the default when the key is absent', () => {
|
||||||
|
installStorage();
|
||||||
|
assert.deepEqual(getLocalStorageItem('missing', { a: 1 }), { a: 1 });
|
||||||
|
assert.equal(getLocalStorageItem('missing', 7), 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLocalStorageItem maps the literal string "undefined" to undefined', () => {
|
||||||
|
const store = installStorage();
|
||||||
|
store.set('k', 'undefined');
|
||||||
|
assert.equal(getLocalStorageItem('k', 'fallback'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLocalStorageItem parses stored JSON', () => {
|
||||||
|
const store = installStorage();
|
||||||
|
store.set('k', JSON.stringify({ nested: [1, 2, 3] }));
|
||||||
|
assert.deepEqual(getLocalStorageItem('k', null), { nested: [1, 2, 3] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLocalStorageItem returns the default on malformed JSON', () => {
|
||||||
|
const store = installStorage();
|
||||||
|
store.set('k', '{ not valid json');
|
||||||
|
assert.equal(getLocalStorageItem('k', 'fallback'), 'fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setLocalStorageItem writes the JSON-serialized value', () => {
|
||||||
|
const store = installStorage();
|
||||||
|
setLocalStorageItem('k', { hello: 'world' });
|
||||||
|
assert.equal(store.get('k'), JSON.stringify({ hello: 'world' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trips a value through set + get', () => {
|
||||||
|
installStorage();
|
||||||
|
setLocalStorageItem('k', [1, 'two', { three: true }]);
|
||||||
|
assert.deepEqual(getLocalStorageItem('k', null), [1, 'two', { three: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('atomWithLocalStorage seeds the atom from getItem on creation', () => {
|
||||||
|
installStorage();
|
||||||
|
const seeded = atomWithLocalStorage<number>(
|
||||||
|
'k',
|
||||||
|
() => 42,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
const store = createStore();
|
||||||
|
assert.equal(store.get(seeded), 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('atomWithLocalStorage write-through updates BOTH the atom and storage', () => {
|
||||||
|
installStorage();
|
||||||
|
const writes: Array<[string, number]> = [];
|
||||||
|
const theAtom = atomWithLocalStorage<number>(
|
||||||
|
'k',
|
||||||
|
() => 0,
|
||||||
|
(key, value) => {
|
||||||
|
writes.push([key, value]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = createStore();
|
||||||
|
store.set(theAtom, 5);
|
||||||
|
|
||||||
|
// Atom value reflects the write...
|
||||||
|
assert.equal(store.get(theAtom), 5);
|
||||||
|
// ...and setItem was invoked with the key + new value.
|
||||||
|
assert.deepEqual(writes, [['k', 5]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('atomWithLocalStorage persists through the real setLocalStorageItem helper', () => {
|
||||||
|
const backing = installStorage();
|
||||||
|
const theAtom = atomWithLocalStorage<{ count: number }>(
|
||||||
|
'k',
|
||||||
|
(key) => getLocalStorageItem(key, { count: 0 }),
|
||||||
|
(key, value) => setLocalStorageItem(key, value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = createStore();
|
||||||
|
store.set(theAtom, { count: 9 });
|
||||||
|
|
||||||
|
assert.equal(backing.get('k'), JSON.stringify({ count: 9 }));
|
||||||
|
assert.deepEqual(store.get(theAtom), { count: 9 });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user