146 lines
4.8 KiB
TypeScript
146 lines
4.8 KiB
TypeScript
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')]);
|
||
|
|
});
|