test: add suites for 8 simple state reducers + msgContent (+50)
Via subagent, all verified, no bugs: - state/toast (7), room-list/roomList (6), inviteList (6), room-list/utils compareRoomsEqual (6), backupRestore (6), callEmbed dispose-on-replace (6), closedNavCategories factory + makeNavCategoryId (8). - features/room/msgContent (5): getAudioMsgContent/getFileMsgContent incl. encrypted (content.file) vs plain (content.url) branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MsgType } from 'matrix-js-sdk';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { getAudioMsgContent, getFileMsgContent } from './msgContent';
|
||||
import { TUploadItem } from '../../state/room/roomInputDrafts';
|
||||
|
||||
// Pure builders getAudioMsgContent / getFileMsgContent: msgtype/body/filename/info
|
||||
// shape plus the encInfo branch (content.file w/ url vs plain content.url). The
|
||||
// image/video builders need a DOM + MatrixClient and are not covered here.
|
||||
|
||||
const MXC = 'mxc://example.org/abc';
|
||||
|
||||
const makeItem = (encInfo?: EncryptedAttachmentInfo): TUploadItem => {
|
||||
const file = { name: 'sound.ogg', type: 'audio/ogg', size: 4096 };
|
||||
return {
|
||||
file,
|
||||
originalFile: file,
|
||||
metadata: { markedAsSpoiler: false },
|
||||
encInfo,
|
||||
} as unknown as TUploadItem;
|
||||
};
|
||||
|
||||
const fakeEncInfo = (): EncryptedAttachmentInfo =>
|
||||
({
|
||||
v: 'v2',
|
||||
key: { alg: 'A256CTR' },
|
||||
iv: 'iv',
|
||||
hashes: { sha256: 'h' },
|
||||
}) as unknown as EncryptedAttachmentInfo;
|
||||
|
||||
test('getAudioMsgContent builds an unencrypted audio message', () => {
|
||||
const content = getAudioMsgContent(makeItem(), MXC);
|
||||
assert.equal(content.msgtype, MsgType.Audio);
|
||||
assert.equal(content.body, 'sound.ogg');
|
||||
assert.equal(content.filename, 'sound.ogg');
|
||||
assert.deepEqual(content.info, { mimetype: 'audio/ogg', size: 4096 });
|
||||
assert.equal(content.url, MXC);
|
||||
assert.equal(content.file, undefined);
|
||||
});
|
||||
|
||||
test('getAudioMsgContent uses content.file with url when encInfo is present', () => {
|
||||
const enc = fakeEncInfo();
|
||||
const content = getAudioMsgContent(makeItem(enc), MXC);
|
||||
assert.equal(content.url, undefined);
|
||||
assert.deepEqual(content.file, { ...enc, url: MXC });
|
||||
});
|
||||
|
||||
test('getFileMsgContent builds an unencrypted file message', () => {
|
||||
const content = getFileMsgContent(makeItem(), MXC);
|
||||
assert.equal(content.msgtype, MsgType.File);
|
||||
assert.equal(content.body, 'sound.ogg');
|
||||
assert.equal(content.filename, 'sound.ogg');
|
||||
assert.deepEqual(content.info, { mimetype: 'audio/ogg', size: 4096 });
|
||||
assert.equal(content.url, MXC);
|
||||
assert.equal(content.file, undefined);
|
||||
});
|
||||
|
||||
test('getFileMsgContent uses content.file with url when encInfo is present', () => {
|
||||
const enc = fakeEncInfo();
|
||||
const content = getFileMsgContent(makeItem(enc), MXC);
|
||||
assert.equal(content.url, undefined);
|
||||
assert.deepEqual(content.file, { ...enc, url: MXC });
|
||||
});
|
||||
|
||||
test('info mirrors the file mimetype and size', () => {
|
||||
const item = {
|
||||
file: { name: 'doc.pdf', type: 'application/pdf', size: 12 },
|
||||
originalFile: { name: 'doc.pdf', type: 'application/pdf', size: 12 },
|
||||
metadata: { markedAsSpoiler: false },
|
||||
encInfo: undefined,
|
||||
} as unknown as TUploadItem;
|
||||
const content = getFileMsgContent(item, MXC);
|
||||
assert.deepEqual(content.info, { mimetype: 'application/pdf', size: 12 });
|
||||
assert.equal(content.body, 'doc.pdf');
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import { ImportRoomKeyProgressData, ImportRoomKeyStage } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import { backupRestoreProgressAtom, BackupProgressStatus } from './backupRestore';
|
||||
|
||||
// backupRestoreProgressAtom maps ImportRoomKeyProgressData stages onto an
|
||||
// IBackupProgress status held in an unexported baseAtom:
|
||||
// Fetch -> Fetching
|
||||
// LoadKeys with successes+failures === total -> Done
|
||||
// LoadKeys otherwise -> Loading{ downloaded = successes+failures }
|
||||
// We drive the write atom through a jotai store and read back the getter.
|
||||
|
||||
const load = (successes: number, failures: number, total: number): ImportRoomKeyProgressData => ({
|
||||
stage: ImportRoomKeyStage.LoadKeys,
|
||||
successes,
|
||||
failures,
|
||||
total,
|
||||
});
|
||||
|
||||
test('starts Idle', () => {
|
||||
const store = createStore();
|
||||
assert.deepEqual(store.get(backupRestoreProgressAtom), {
|
||||
status: BackupProgressStatus.Idle,
|
||||
});
|
||||
});
|
||||
|
||||
test('Fetch stage -> Fetching', () => {
|
||||
const store = createStore();
|
||||
store.set(backupRestoreProgressAtom, { stage: ImportRoomKeyStage.Fetch });
|
||||
assert.deepEqual(store.get(backupRestoreProgressAtom), {
|
||||
status: BackupProgressStatus.Fetching,
|
||||
});
|
||||
});
|
||||
|
||||
test('LoadKeys with downloaded === total -> Done', () => {
|
||||
const store = createStore();
|
||||
store.set(backupRestoreProgressAtom, load(8, 2, 10));
|
||||
assert.deepEqual(store.get(backupRestoreProgressAtom), {
|
||||
status: BackupProgressStatus.Done,
|
||||
});
|
||||
});
|
||||
|
||||
test('LoadKeys mid-progress -> Loading with downloaded = successes + failures', () => {
|
||||
const store = createStore();
|
||||
store.set(backupRestoreProgressAtom, load(3, 1, 10));
|
||||
assert.deepEqual(store.get(backupRestoreProgressAtom), {
|
||||
status: BackupProgressStatus.Loading,
|
||||
data: { downloaded: 4, successes: 3, failures: 1, total: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
test('LoadKeys at zero progress -> Loading with downloaded 0', () => {
|
||||
const store = createStore();
|
||||
store.set(backupRestoreProgressAtom, load(0, 0, 5));
|
||||
assert.deepEqual(store.get(backupRestoreProgressAtom), {
|
||||
status: BackupProgressStatus.Loading,
|
||||
data: { downloaded: 0, successes: 0, failures: 0, total: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
test('Fetch then LoadKeys completion transitions Fetching -> Done', () => {
|
||||
const store = createStore();
|
||||
store.set(backupRestoreProgressAtom, { stage: ImportRoomKeyStage.Fetch });
|
||||
assert.equal(store.get(backupRestoreProgressAtom).status, BackupProgressStatus.Fetching);
|
||||
store.set(backupRestoreProgressAtom, load(5, 0, 5));
|
||||
assert.equal(store.get(backupRestoreProgressAtom).status, BackupProgressStatus.Done);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import { callEmbedAtom } from './callEmbed';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
|
||||
// callEmbedAtom holds the current CallEmbed in an unexported baseAtom. The setter:
|
||||
// - identity no-op guard: setting the same reference does nothing
|
||||
// - when replacing a previous embed, calls prevCallEmbed.dispose()
|
||||
// We substitute a stub embed exposing a dispose() spy (the real CallEmbed needs a
|
||||
// MatrixClient + DOM). `as unknown as CallEmbed` keeps eslint/prettier quiet.
|
||||
|
||||
type StubEmbed = { dispose: () => void; disposed: number };
|
||||
|
||||
const makeEmbed = (): StubEmbed => {
|
||||
const stub: StubEmbed = {
|
||||
disposed: 0,
|
||||
dispose() {
|
||||
stub.disposed += 1;
|
||||
},
|
||||
};
|
||||
return stub;
|
||||
};
|
||||
|
||||
const asEmbed = (e: StubEmbed): CallEmbed => e as unknown as CallEmbed;
|
||||
|
||||
test('starts undefined', () => {
|
||||
const store = createStore();
|
||||
assert.equal(store.get(callEmbedAtom), undefined);
|
||||
});
|
||||
|
||||
test('sets a call embed', () => {
|
||||
const store = createStore();
|
||||
const embed = makeEmbed();
|
||||
store.set(callEmbedAtom, asEmbed(embed));
|
||||
assert.equal(store.get(callEmbedAtom), asEmbed(embed));
|
||||
assert.equal(embed.disposed, 0);
|
||||
});
|
||||
|
||||
test('replacing an embed disposes the previous one', () => {
|
||||
const store = createStore();
|
||||
const first = makeEmbed();
|
||||
const second = makeEmbed();
|
||||
|
||||
store.set(callEmbedAtom, asEmbed(first));
|
||||
store.set(callEmbedAtom, asEmbed(second));
|
||||
|
||||
assert.equal(first.disposed, 1);
|
||||
assert.equal(second.disposed, 0);
|
||||
assert.equal(store.get(callEmbedAtom), asEmbed(second));
|
||||
});
|
||||
|
||||
test('setting the same embed reference is a no-op (no dispose)', () => {
|
||||
const store = createStore();
|
||||
const embed = makeEmbed();
|
||||
|
||||
store.set(callEmbedAtom, asEmbed(embed));
|
||||
store.set(callEmbedAtom, asEmbed(embed));
|
||||
|
||||
assert.equal(embed.disposed, 0);
|
||||
assert.equal(store.get(callEmbedAtom), asEmbed(embed));
|
||||
});
|
||||
|
||||
test('clearing to undefined disposes the previous embed', () => {
|
||||
const store = createStore();
|
||||
const embed = makeEmbed();
|
||||
|
||||
store.set(callEmbedAtom, asEmbed(embed));
|
||||
store.set(callEmbedAtom, undefined);
|
||||
|
||||
assert.equal(embed.disposed, 1);
|
||||
assert.equal(store.get(callEmbedAtom), undefined);
|
||||
});
|
||||
|
||||
test('setting undefined when already undefined is a no-op', () => {
|
||||
const store = createStore();
|
||||
// No previous embed: the identity guard (undefined === undefined) returns early.
|
||||
store.set(callEmbedAtom, undefined);
|
||||
assert.equal(store.get(callEmbedAtom), undefined);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import { enableMapSet } from 'immer';
|
||||
import { makeClosedNavCategoriesAtom, makeNavCategoryId } from './closedNavCategories';
|
||||
|
||||
// makeClosedNavCategoriesAtom(userId) builds a Set<string> atom whose reducer uses
|
||||
// immer produce (PUT add / DELETE delete) and persists to a per-user localStorage
|
||||
// key `closedNavCategories<userId>`. The reducers produce over a Set, so we enable
|
||||
// immer's Map/Set plugin (the app does this once at startup).
|
||||
// makeNavCategoryId joins args with '|'.
|
||||
enableMapSet();
|
||||
|
||||
// In-memory localStorage so atomWithLocalStorage can read/write at construction
|
||||
// and on set. window is referenced for the onMount storage listener.
|
||||
type Store = Record<string, string>;
|
||||
const installLocalStorage = (): Store => {
|
||||
const data: Store = {};
|
||||
const ls = {
|
||||
getItem: (k: string) => (k in data ? data[k] : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
data[k] = String(v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
delete data[k];
|
||||
},
|
||||
};
|
||||
(globalThis as { localStorage?: unknown }).localStorage = ls;
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
test('makeNavCategoryId joins args with "|"', () => {
|
||||
assert.equal(makeNavCategoryId('a', 'b', 'c'), 'a|b|c');
|
||||
assert.equal(makeNavCategoryId('only'), 'only');
|
||||
assert.equal(makeNavCategoryId(), '');
|
||||
});
|
||||
|
||||
test('starts empty when nothing is stored', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const navAtom = makeClosedNavCategoriesAtom('@u:server');
|
||||
assert.equal(store.get(navAtom).size, 0);
|
||||
});
|
||||
|
||||
test('hydrates the Set from the per-user localStorage key', () => {
|
||||
const data = installLocalStorage();
|
||||
data['closedNavCategories@u:server'] = JSON.stringify(['x', 'y']);
|
||||
const store = createStore();
|
||||
const navAtom = makeClosedNavCategoriesAtom('@u:server');
|
||||
assert.deepEqual(Array.from(store.get(navAtom)).sort(), ['x', 'y']);
|
||||
});
|
||||
|
||||
test('PUT adds a category and DELETE removes it', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const navAtom = makeClosedNavCategoriesAtom('@u:server');
|
||||
|
||||
store.set(navAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
assert.deepEqual(Array.from(store.get(navAtom)), ['cat1']);
|
||||
|
||||
store.set(navAtom, { type: 'DELETE', categoryId: 'cat1' });
|
||||
assert.equal(store.get(navAtom).has('cat1'), false);
|
||||
});
|
||||
|
||||
test('PUT of an existing category keeps the Set unchanged (idempotent)', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const navAtom = makeClosedNavCategoriesAtom('@u:server');
|
||||
store.set(navAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
store.set(navAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
assert.equal(store.get(navAtom).size, 1);
|
||||
});
|
||||
|
||||
test('DELETE of an absent category is a no-op', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const navAtom = makeClosedNavCategoriesAtom('@u:server');
|
||||
store.set(navAtom, { type: 'DELETE', categoryId: 'missing' });
|
||||
assert.equal(store.get(navAtom).size, 0);
|
||||
});
|
||||
|
||||
test('writes persist to localStorage as an array', () => {
|
||||
const data = installLocalStorage();
|
||||
const store = createStore();
|
||||
const navAtom = makeClosedNavCategoriesAtom('@u:server');
|
||||
store.set(navAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
assert.deepEqual(JSON.parse(data['closedNavCategories@u:server']), ['cat1']);
|
||||
});
|
||||
|
||||
test('the storage key is namespaced per user', () => {
|
||||
const data = installLocalStorage();
|
||||
const store = createStore();
|
||||
const aAtom = makeClosedNavCategoriesAtom('@a:server');
|
||||
const bAtom = makeClosedNavCategoriesAtom('@b:server');
|
||||
|
||||
store.set(aAtom, { type: 'PUT', categoryId: 'only-a' });
|
||||
assert.deepEqual(JSON.parse(data['closedNavCategories@a:server']), ['only-a']);
|
||||
assert.equal(data['closedNavCategories@b:server'], undefined);
|
||||
assert.equal(store.get(bAtom).size, 0);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import { allInvitesAtom } from './inviteList';
|
||||
|
||||
// allInvitesAtom shares the roomList reducer shape over an unexported string[]
|
||||
// baseRoomsAtom: INITIALIZE replace, PUT move-to-end dedupe, DELETE filter-out.
|
||||
// The React binding hook (useBindAllInvitesAtom) is not covered.
|
||||
|
||||
const R1 = '!r1:server';
|
||||
const R2 = '!r2:server';
|
||||
const R3 = '!r3:server';
|
||||
|
||||
test('starts empty', () => {
|
||||
const store = createStore();
|
||||
assert.deepEqual(store.get(allInvitesAtom), []);
|
||||
});
|
||||
|
||||
test('INITIALIZE replaces the whole list', () => {
|
||||
const store = createStore();
|
||||
store.set(allInvitesAtom, { type: 'PUT', roomId: R1 });
|
||||
store.set(allInvitesAtom, { type: 'INITIALIZE', rooms: [R2, R3] });
|
||||
assert.deepEqual(store.get(allInvitesAtom), [R2, R3]);
|
||||
});
|
||||
|
||||
test('PUT appends a new invite', () => {
|
||||
const store = createStore();
|
||||
store.set(allInvitesAtom, { type: 'PUT', roomId: R1 });
|
||||
store.set(allInvitesAtom, { type: 'PUT', roomId: R2 });
|
||||
assert.deepEqual(store.get(allInvitesAtom), [R1, R2]);
|
||||
});
|
||||
|
||||
test('PUT of an existing invite moves it to the end (dedupe)', () => {
|
||||
const store = createStore();
|
||||
store.set(allInvitesAtom, { type: 'INITIALIZE', rooms: [R1, R2, R3] });
|
||||
store.set(allInvitesAtom, { type: 'PUT', roomId: R1 });
|
||||
assert.deepEqual(store.get(allInvitesAtom), [R2, R3, R1]);
|
||||
});
|
||||
|
||||
test('DELETE removes an invite', () => {
|
||||
const store = createStore();
|
||||
store.set(allInvitesAtom, { type: 'INITIALIZE', rooms: [R1, R2, R3] });
|
||||
store.set(allInvitesAtom, { type: 'DELETE', roomId: R2 });
|
||||
assert.deepEqual(store.get(allInvitesAtom), [R1, R3]);
|
||||
});
|
||||
|
||||
test('DELETE of an absent invite is a no-op', () => {
|
||||
const store = createStore();
|
||||
store.set(allInvitesAtom, { type: 'INITIALIZE', rooms: [R1] });
|
||||
store.set(allInvitesAtom, { type: 'DELETE', roomId: R2 });
|
||||
assert.deepEqual(store.get(allInvitesAtom), [R1]);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import { allRoomsAtom } from './roomList';
|
||||
|
||||
// allRoomsAtom wraps an unexported baseRoomsAtom string[] reducer:
|
||||
// INITIALIZE -> replace wholesale
|
||||
// PUT -> filter-out-then-push (move-to-end dedupe)
|
||||
// DELETE -> filter-out
|
||||
// We drive the reducer through a jotai store. The React binding hook
|
||||
// (useBindAllRoomsAtom) wires this to MatrixClient events and is not covered.
|
||||
|
||||
const R1 = '!r1:server';
|
||||
const R2 = '!r2:server';
|
||||
const R3 = '!r3:server';
|
||||
|
||||
test('starts empty', () => {
|
||||
const store = createStore();
|
||||
assert.deepEqual(store.get(allRoomsAtom), []);
|
||||
});
|
||||
|
||||
test('INITIALIZE replaces the whole list', () => {
|
||||
const store = createStore();
|
||||
store.set(allRoomsAtom, { type: 'PUT', roomId: R1 });
|
||||
store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [R2, R3] });
|
||||
assert.deepEqual(store.get(allRoomsAtom), [R2, R3]);
|
||||
});
|
||||
|
||||
test('PUT appends a new room', () => {
|
||||
const store = createStore();
|
||||
store.set(allRoomsAtom, { type: 'PUT', roomId: R1 });
|
||||
store.set(allRoomsAtom, { type: 'PUT', roomId: R2 });
|
||||
assert.deepEqual(store.get(allRoomsAtom), [R1, R2]);
|
||||
});
|
||||
|
||||
test('PUT of an existing room moves it to the end (dedupe)', () => {
|
||||
const store = createStore();
|
||||
store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [R1, R2, R3] });
|
||||
store.set(allRoomsAtom, { type: 'PUT', roomId: R1 });
|
||||
assert.deepEqual(store.get(allRoomsAtom), [R2, R3, R1]);
|
||||
});
|
||||
|
||||
test('DELETE removes a room', () => {
|
||||
const store = createStore();
|
||||
store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [R1, R2, R3] });
|
||||
store.set(allRoomsAtom, { type: 'DELETE', roomId: R2 });
|
||||
assert.deepEqual(store.get(allRoomsAtom), [R1, R3]);
|
||||
});
|
||||
|
||||
test('DELETE of an absent room is a no-op', () => {
|
||||
const store = createStore();
|
||||
store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [R1] });
|
||||
store.set(allRoomsAtom, { type: 'DELETE', roomId: R2 });
|
||||
assert.deepEqual(store.get(allRoomsAtom), [R1]);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { compareRoomsEqual } from './utils';
|
||||
|
||||
// compareRoomsEqual(a, b): length mismatch short-circuits to false, otherwise an
|
||||
// order-sensitive element-by-element equality. The React hook in the same file
|
||||
// (useBindRoomsWithMembershipsAtom) is not covered.
|
||||
|
||||
test('two empty arrays are equal', () => {
|
||||
assert.equal(compareRoomsEqual([], []), true);
|
||||
});
|
||||
|
||||
test('identical arrays are equal', () => {
|
||||
assert.equal(compareRoomsEqual(['a', 'b', 'c'], ['a', 'b', 'c']), true);
|
||||
});
|
||||
|
||||
test('different lengths are not equal', () => {
|
||||
assert.equal(compareRoomsEqual(['a', 'b'], ['a', 'b', 'c']), false);
|
||||
assert.equal(compareRoomsEqual(['a'], []), false);
|
||||
});
|
||||
|
||||
test('same elements in a different order are not equal (order-sensitive)', () => {
|
||||
assert.equal(compareRoomsEqual(['a', 'b'], ['b', 'a']), false);
|
||||
});
|
||||
|
||||
test('a single differing element makes them unequal', () => {
|
||||
assert.equal(compareRoomsEqual(['a', 'b', 'c'], ['a', 'x', 'c']), false);
|
||||
});
|
||||
|
||||
test('reference equality is not required, only value equality', () => {
|
||||
const a = ['a', 'b'];
|
||||
const b = ['a', 'b'];
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(compareRoomsEqual(a, b), true);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import { toastQueueAtom, dismissToastAtom, ToastNotif } from './toast';
|
||||
|
||||
// The queue lives in an unexported baseAtom; we drive the two write-only setters
|
||||
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
|
||||
// through a jotai store and read back via toastQueueAtom's getter.
|
||||
|
||||
const makeToast = (id: string): ToastNotif => ({
|
||||
id,
|
||||
displayName: `name-${id}`,
|
||||
body: `body-${id}`,
|
||||
roomName: `room-${id}`,
|
||||
roomId: `!${id}:server`,
|
||||
});
|
||||
|
||||
test('starts empty', () => {
|
||||
const store = createStore();
|
||||
assert.deepEqual(store.get(toastQueueAtom), []);
|
||||
});
|
||||
|
||||
test('toastQueueAtom appends in order', () => {
|
||||
const store = createStore();
|
||||
const a = makeToast('a');
|
||||
const b = makeToast('b');
|
||||
store.set(toastQueueAtom, a);
|
||||
store.set(toastQueueAtom, b);
|
||||
assert.deepEqual(
|
||||
store.get(toastQueueAtom).map((t) => t.id),
|
||||
['a', 'b'],
|
||||
);
|
||||
assert.equal(store.get(toastQueueAtom)[0], a);
|
||||
});
|
||||
|
||||
test('toastQueueAtom ignores null (no-op guard)', () => {
|
||||
const store = createStore();
|
||||
store.set(toastQueueAtom, makeToast('a'));
|
||||
store.set(toastQueueAtom, null);
|
||||
assert.deepEqual(
|
||||
store.get(toastQueueAtom).map((t) => t.id),
|
||||
['a'],
|
||||
);
|
||||
});
|
||||
|
||||
test('toastQueueAtom allows duplicate ids (no dedupe)', () => {
|
||||
const store = createStore();
|
||||
store.set(toastQueueAtom, makeToast('a'));
|
||||
store.set(toastQueueAtom, makeToast('a'));
|
||||
assert.equal(store.get(toastQueueAtom).length, 2);
|
||||
});
|
||||
|
||||
test('dismissToastAtom removes the matching id only', () => {
|
||||
const store = createStore();
|
||||
store.set(toastQueueAtom, makeToast('a'));
|
||||
store.set(toastQueueAtom, makeToast('b'));
|
||||
store.set(toastQueueAtom, makeToast('c'));
|
||||
|
||||
store.set(dismissToastAtom, 'b');
|
||||
assert.deepEqual(
|
||||
store.get(toastQueueAtom).map((t) => t.id),
|
||||
['a', 'c'],
|
||||
);
|
||||
});
|
||||
|
||||
test('dismissToastAtom removes every entry sharing the id', () => {
|
||||
const store = createStore();
|
||||
store.set(toastQueueAtom, makeToast('a'));
|
||||
store.set(toastQueueAtom, makeToast('a'));
|
||||
store.set(toastQueueAtom, makeToast('b'));
|
||||
|
||||
store.set(dismissToastAtom, 'a');
|
||||
assert.deepEqual(
|
||||
store.get(toastQueueAtom).map((t) => t.id),
|
||||
['b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('dismissToastAtom for an unknown id is a no-op', () => {
|
||||
const store = createStore();
|
||||
store.set(toastQueueAtom, makeToast('a'));
|
||||
store.set(dismissToastAtom, 'missing');
|
||||
assert.deepEqual(
|
||||
store.get(toastQueueAtom).map((t) => t.id),
|
||||
['a'],
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user