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:
2026-06-30 14:43:33 -04:00
parent 589d45e0a0
commit 160c09e525
8 changed files with 557 additions and 0 deletions
+76
View File
@@ -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');
});
+68
View File
@@ -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);
});
+80
View File
@@ -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);
});
+104
View File
@@ -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]);
});
+55
View File
@@ -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]);
});
+35
View File
@@ -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);
});
+87
View File
@@ -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'],
);
});