Compare commits

...

10 Commits

Author SHA1 Message Date
jared 24662fa994 test: localStorage-backed state modules (+38)
CI / Build & Quality Checks (push) Successful in 11m15s
CI / Trigger Desktop Build (push) Successful in 10s
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>
2026-06-30 14:53:52 -04:00
jared 230ef8ed7c test: markdown parser subsystem (58) + custom-emoji readers (32)
Via subagents, probe-verified against real output, no bugs:
- markdown: internal/utils (11), inline/runner (7), inline/parser (21 — bold/
  italic/underline/strike/code/spoiler/link, nesting, precedence, URL lookbehind),
  block/parser (19 — headings/code-fences/quotes/lists/<br>/escapes). Closes the
  biggest coverage hole (core message rendering).
- custom-emoji: PackMetaReader (6), PackImageReader (7), PackImagesReader (4),
  utils equality+makeImagePacks (5), recent-emoji promote/increment/100-cap (10).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:52:48 -04:00
jared 160c09e525 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>
2026-06-30 14:43:33 -04:00
jared 589d45e0a0 test: add suites for list, roomToParents, roomToUnread reducers (+43)
Via subagent, verified against real behavior (all use jotai store + enableMapSet):
- state/list (11): createListAtom PUT/DELETE/REPLACE (single + array, identity).
- state/room/roomToParents (10): INITIALIZE/PUT/DELETE incl. cycle-skip and
  orphan-cleanup pruning of zero-parent children.
- state/room/roomToUnread (22): unreadInfoToUnread, unreadEqual, and the
  roomToUnreadAtom reducer — leaf/overwrite/equal-guard, multi-level parent
  roll-up with `from` recording, RESET rebuild, DELETE subtract/prune.

No bugs (noted a latent never-hit string-spread in deleteUnreadInfo's `from ??
roomId` fallback; left as-is). Suite growing toward full pure-logic coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:42:36 -04:00
jared acd355bb5a docs(bugs): test suite at 231 tests; 2 real bugs caught by coverage
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:35:05 -04:00
jared 6e59395fb8 test: lotus decorations, call caps, crypto, featureCheck, typing, markdown (+34)
Subagent batch (no bugs found) + markdown:
- lotus/avatarDecorations (8): decorationUrl, CDN shape, ALL_DECORATIONS
  flattening, data invariants (unique category ids + slugs, slug charset).
- plugins/call/utils (7): getCallCapabilities — static caps + room/user/device
  scoped state-keys.
- utils/matrix-crypto (3): verifiedDevice via a stubbed CryptoApi.
- utils/featureCheck (3): checkIndexedDBSupport success/error/throw paths.
- state/typingMembers (8): add/dedup-by-latest-ts/per-room-scope/delete reducer
  via a jotai store (enableMapSet, mirroring app startup).
- plugins/markdown/utils (5): inline + block escape/unescape round-trips.

Full suite now 231 tests, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:32:53 -04:00
jared 9f4516c6a8 test: add suites for state/sessions, recentSearches, upload (+17)
Via subagent, all verified against real behavior:
- state/sessions (5): fallback-session round-trip across the four cinny_* keys,
  missing-key → undefined for each required key, removeFallbackSession clears all.
- state/recentSearches (6): addRecentSearch prepend, case-sensitive dedupe +
  move-to-front, trim, ignore empty/whitespace, cap at 10.
- state/upload (6): the createUploadAtom reducer driven through a real jotai
  store — idle→loading→progress(gated)→success/error, file ref preserved.

No bugs found.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:29:36 -04:00
jared 0bd2273bee test: add suites for utils/room (40) + plugins/matrix-to (7)
- utils/room (40, via subagent): 28 helpers — state-event accessors, m.direct
  parsing, space/room classification, parent/child graph (incl. cycle safety),
  mute-rule + notification logic, unread info, reply trimming, member display/
  avatar/search, reaction/edit/mention extraction, room-icon branches. SDK/
  crypto-heavy helpers skipped. No bugs found.
- plugins/matrix-to (7): matrix.to permalink build + parse for user/room/event
  including via-server round-trips and negative cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:28:32 -04:00
jared d37fa1584c test: add suite for utils/keyboard handlers (+4)
Covers onTabPress (Tab-only), preventScrollWithArrowKey (arrows-only),
onEnterOrSpace (Enter/Space gate the callback), and stopPropagation's
editable-element check (does not swallow keys when an input/textarea/
contenteditable is focused) via mock events + a document.activeElement stub.
Full suite now 133 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:50:55 -04:00
jared e17cb09269 fix(settings): don't crash on load when localStorage is blocked + tests (+6)
Prevention work found a real bug: getSettings() runs at module load, and its
catch block called localStorage.removeItem() — but we often reach that catch
*because* localStorage access threw (blocked storage / private mode / sandboxed
context). The removeItem then re-threw, producing an uncaught error that crashed
the whole app at startup. Guarded the cleanup in its own try/catch.

New state/settings suite (6) covers the legacy-boolean callNoiseSuppression
migration, denoise-model/ringtone-id coercion of unknown values, default merge,
malformed JSON, and the blocked-storage regression.

Full suite now 129 tests, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:46:51 -04:00
40 changed files with 3870 additions and 2 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ Items from testing, with their fork-level fix path:
### Code Hygiene / DevEx
- **Automated test suite — harness in place, 123 tests, now a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Covered: `utils/common`, `regex`, `sanitize` (XSS guards), `time`, `matrix`, `matrix-uia` (auth flows), `mimeTypes`, `sort`, `accentColor` (color math), `findAndReplace`, `AsyncSearch`, `ASCIILexicalTable`, message-search filters. Prevention work already caught + fixed a real bug (`findAndReplace` infinite-loop on non-global regex). **Next:** component/integration tests; more state/reducer logic.
- **Automated test suite — 231 tests across ~26 modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck), state (settings, sessions, recentSearches, upload, typingMembers), plugins (matrix-to, call/utils, markdown/utils), lotus/avatarDecorations, search filters. Prevention work has caught + fixed **2 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
@@ -0,0 +1,68 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
DECORATION_CDN,
DECORATION_CATEGORIES,
ALL_DECORATIONS,
decorationUrl,
} from './avatarDecorations';
test('decorationUrl builds a CDN png url from the slug', () => {
assert.equal(decorationUrl('joystick'), `${DECORATION_CDN}/joystick.png`);
assert.equal(decorationUrl('lotus_flower'), `${DECORATION_CDN}/lotus_flower.png`);
// slug is used verbatim, no encoding/normalisation
assert.equal(decorationUrl(''), `${DECORATION_CDN}/.png`);
});
test('DECORATION_CDN is an https url with no trailing slash', () => {
assert.match(DECORATION_CDN, /^https:\/\//);
assert.equal(DECORATION_CDN.endsWith('/'), false);
});
test('ALL_DECORATIONS is the flattened set of every category decoration', () => {
const expectedCount = DECORATION_CATEGORIES.reduce((n, c) => n + c.decorations.length, 0);
assert.equal(ALL_DECORATIONS.length, expectedCount);
// every decoration in a category is present (by reference) in ALL_DECORATIONS
DECORATION_CATEGORIES.forEach((category) => {
category.decorations.forEach((decoration) => {
assert.ok(ALL_DECORATIONS.includes(decoration));
});
});
});
test('every category has a non-empty id, label and decorations list', () => {
DECORATION_CATEGORIES.forEach((category) => {
assert.equal(typeof category.id, 'string');
assert.ok(category.id.length > 0);
assert.equal(typeof category.label, 'string');
assert.ok(category.label.length > 0);
assert.ok(Array.isArray(category.decorations));
assert.ok(category.decorations.length > 0);
});
});
test('category ids are unique', () => {
const ids = DECORATION_CATEGORIES.map((c) => c.id);
assert.equal(new Set(ids).size, ids.length);
});
test('every decoration slug is unique across all categories', () => {
const slugs = ALL_DECORATIONS.map((d) => d.slug);
assert.equal(new Set(slugs).size, slugs.length);
});
test('every decoration has a non-empty slug and name', () => {
ALL_DECORATIONS.forEach((decoration) => {
assert.equal(typeof decoration.slug, 'string');
assert.ok(decoration.slug.length > 0);
assert.equal(typeof decoration.name, 'string');
assert.ok(decoration.name.length > 0);
});
});
test('slugs use the snake_case charset (lowercase, digits, underscore)', () => {
ALL_DECORATIONS.forEach((decoration) => {
assert.match(decoration.slug, /^[a-z0-9_]+$/, `bad slug: ${decoration.slug}`);
});
});
+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');
});
+75
View File
@@ -0,0 +1,75 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixCapabilities } from 'matrix-widget-api';
import { getCallCapabilities } from './utils';
const ROOM = '!room:server';
const USER = '@user:server';
const DEVICE = 'DEVICE1';
test('getCallCapabilities returns a non-empty Set', () => {
const caps = getCallCapabilities(ROOM, USER, DEVICE);
assert.ok(caps instanceof Set);
assert.ok(caps.size > 0);
});
test('includes the static MatrixCapabilities', () => {
const caps = getCallCapabilities(ROOM, USER, DEVICE);
assert.ok(caps.has(MatrixCapabilities.Screenshots));
assert.ok(caps.has(MatrixCapabilities.AlwaysOnScreen));
assert.ok(caps.has(MatrixCapabilities.MSC4039UploadFile));
assert.ok(caps.has(MatrixCapabilities.MSC4039DownloadFile));
assert.ok(caps.has(MatrixCapabilities.MSC3846TurnServers));
assert.ok(caps.has(MatrixCapabilities.MSC4157SendDelayedEvent));
assert.ok(caps.has(MatrixCapabilities.MSC4157UpdateDelayedEvent));
});
test('includes the room-scoped timeline and state capabilities', () => {
const caps = getCallCapabilities(ROOM, USER, DEVICE);
assert.ok(caps.has(`org.matrix.msc2762.timeline:${ROOM}`));
assert.ok(caps.has(`org.matrix.msc2762.state:${ROOM}`));
});
test('room scoping changes with the roomId', () => {
const a = getCallCapabilities('!a:server', USER, DEVICE);
const b = getCallCapabilities('!b:server', USER, DEVICE);
assert.ok(a.has('org.matrix.msc2762.timeline:!a:server'));
assert.ok(!a.has('org.matrix.msc2762.timeline:!b:server'));
assert.ok(b.has('org.matrix.msc2762.timeline:!b:server'));
});
test('includes send capability for the user-scoped call.member state event', () => {
const caps = getCallCapabilities(ROOM, USER, DEVICE);
const sendStateMember = [...caps].filter(
(c) =>
c.includes('send') && c.includes('state') && c.includes('org.matrix.msc3401.call.member'),
);
// five distinct state-keys are registered for the call.member send capability
assert.equal(sendStateMember.length, 5);
// the raw user id and the underscore-prefixed device-scoped key both appear
const joined = sendStateMember.join('\n');
assert.ok(joined.includes(USER));
assert.ok(joined.includes(`_${USER}_${DEVICE}_m.call`));
});
test('registers both send and receive for each room-event type', () => {
const caps = getCallCapabilities(ROOM, USER, DEVICE);
[
'io.element.call.encryption_keys',
'org.matrix.rageshake_request',
'io.element.call.reaction',
'org.matrix.msc4075.rtc.notification',
'org.matrix.msc4310.rtc.decline',
].forEach((type) => {
const matches = [...caps].filter((c) => c.includes(type));
// one send + one receive room-event capability each
assert.ok(matches.length >= 2, `missing capability for ${type}`);
});
});
test('user/device scoping flows into the device-keyed state keys', () => {
const caps = getCallCapabilities(ROOM, '@bob:srv', 'DEV2');
const all = [...caps].join('\n');
assert.ok(all.includes('@bob:srv_DEV2'));
assert.ok(all.includes('_@bob:srv_DEV2_m.call'));
});
@@ -0,0 +1,92 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PackImageReader } from './PackImageReader';
import { ImageUsage, PackImage } from './types';
test('fromPackImage returns undefined when url is missing or not a string', () => {
assert.equal(PackImageReader.fromPackImage('cat', {} as unknown as PackImage), undefined);
assert.equal(
PackImageReader.fromPackImage('cat', { url: 123 } as unknown as PackImage),
undefined,
);
});
test('fromPackImage builds a reader with shortcode and url', () => {
const reader = PackImageReader.fromPackImage('cat', { url: 'mxc://x/cat' });
assert.ok(reader);
assert.equal(reader?.shortcode, 'cat');
assert.equal(reader?.url, 'mxc://x/cat');
});
test('body reads only string values', () => {
assert.equal(PackImageReader.fromPackImage('c', { url: 'u', body: 'Cat' })?.body, 'Cat');
assert.equal(PackImageReader.fromPackImage('c', { url: 'u' })?.body, undefined);
assert.equal(
PackImageReader.fromPackImage('c', { url: 'u', body: 5 } as unknown as PackImage)?.body,
undefined,
);
});
test('info is passed through unchanged', () => {
const info = { w: 10, h: 10 };
assert.equal(
PackImageReader.fromPackImage('c', { url: 'u', info } as unknown as PackImage)?.info,
info,
);
assert.equal(PackImageReader.fromPackImage('c', { url: 'u' })?.info, undefined);
});
test('usage filters to known values and returns undefined when none/non-array', () => {
assert.equal(PackImageReader.fromPackImage('c', { url: 'u' })?.usage, undefined);
assert.equal(
PackImageReader.fromPackImage('c', {
url: 'u',
usage: 'emoticon' as unknown as ImageUsage[],
})?.usage,
undefined,
);
assert.equal(
PackImageReader.fromPackImage('c', {
url: 'u',
usage: ['bogus'] as unknown as ImageUsage[],
})?.usage,
undefined,
);
assert.deepEqual(
PackImageReader.fromPackImage('c', { url: 'u', usage: [ImageUsage.Sticker] })?.usage,
[ImageUsage.Sticker],
);
assert.deepEqual(
PackImageReader.fromPackImage('c', {
url: 'u',
usage: [ImageUsage.Emoticon, 'x', ImageUsage.Sticker] as unknown as ImageUsage[],
})?.usage,
[ImageUsage.Emoticon, ImageUsage.Sticker],
);
});
test('content reconstructs the PackImage from url and raw image fields', () => {
const info = { w: 1, h: 1 };
const reader = PackImageReader.fromPackImage('c', {
url: 'u',
body: 'Cat',
usage: [ImageUsage.Emoticon],
info,
} as unknown as PackImage);
assert.deepEqual(reader?.content, {
url: 'u',
body: 'Cat',
usage: [ImageUsage.Emoticon],
info,
});
});
test('content preserves raw usage even when usage getter filters it', () => {
// content uses the raw image.usage, not the filtered usage getter
const reader = PackImageReader.fromPackImage('c', {
url: 'u',
usage: ['bogus'] as unknown as ImageUsage[],
});
assert.deepEqual(reader?.content.usage, ['bogus'] as unknown as ImageUsage[]);
assert.equal(reader?.usage, undefined);
});
@@ -0,0 +1,44 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PackImagesReader } from './PackImagesReader';
import { PackImages, ImageUsage } from './types';
test('collection builds a shortcode -> reader map', () => {
const images: PackImages = {
cat: { url: 'mxc://x/cat', usage: [ImageUsage.Emoticon] },
dog: { url: 'mxc://x/dog' },
};
const reader = new PackImagesReader(images);
const collection = reader.collection;
assert.equal(collection.size, 2);
assert.equal(collection.get('cat')?.url, 'mxc://x/cat');
assert.equal(collection.get('cat')?.shortcode, 'cat');
assert.equal(collection.get('dog')?.url, 'mxc://x/dog');
});
test('collection drops invalid images (missing/non-string url)', () => {
const images = {
good: { url: 'mxc://x/good' },
noUrl: {},
badUrl: { url: 5 },
} as unknown as PackImages;
const collection = new PackImagesReader(images).collection;
assert.equal(collection.size, 1);
assert.ok(collection.has('good'));
assert.equal(collection.has('noUrl'), false);
assert.equal(collection.has('badUrl'), false);
});
test('collection of an empty image set is an empty map', () => {
const collection = new PackImagesReader({}).collection;
assert.equal(collection.size, 0);
});
test('collection is memoized (same Map instance returned)', () => {
const reader = new PackImagesReader({ cat: { url: 'u' } });
const first = reader.collection;
const second = reader.collection;
assert.equal(first, second);
});
@@ -0,0 +1,48 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PackMetaReader } from './PackMetaReader';
import { ImageUsage, PackMeta } from './types';
const reader = (meta: PackMeta) => new PackMetaReader(meta);
test('name reads display_name only when it is a string', () => {
assert.equal(reader({ display_name: 'Cats' }).name, 'Cats');
assert.equal(reader({}).name, undefined);
assert.equal(reader({ display_name: 42 } as unknown as PackMeta).name, undefined);
});
test('avatar reads avatar_url only when it is a string', () => {
assert.equal(reader({ avatar_url: 'mxc://x/y' }).avatar, 'mxc://x/y');
assert.equal(reader({}).avatar, undefined);
assert.equal(reader({ avatar_url: {} } as unknown as PackMeta).avatar, undefined);
});
test('attribution reads attribution only when it is a string', () => {
assert.equal(reader({ attribution: 'me' }).attribution, 'me');
assert.equal(reader({}).attribution, undefined);
assert.equal(reader({ attribution: 0 } as unknown as PackMeta).attribution, undefined);
});
test('usage falls back when missing, non-array, or empty/unknown', () => {
const fallback = [ImageUsage.Emoticon, ImageUsage.Sticker];
assert.deepEqual(reader({}).usage, fallback);
assert.deepEqual(reader({ usage: 'emoticon' } as unknown as PackMeta).usage, fallback);
assert.deepEqual(reader({ usage: [] }).usage, fallback);
assert.deepEqual(reader({ usage: ['bogus'] as unknown as ImageUsage[] }).usage, fallback);
});
test('usage filters to only known values', () => {
assert.deepEqual(reader({ usage: [ImageUsage.Emoticon] }).usage, [ImageUsage.Emoticon]);
assert.deepEqual(reader({ usage: [ImageUsage.Sticker] }).usage, [ImageUsage.Sticker]);
assert.deepEqual(
reader({
usage: [ImageUsage.Emoticon, 'bogus', ImageUsage.Sticker] as unknown as ImageUsage[],
}).usage,
[ImageUsage.Emoticon, ImageUsage.Sticker],
);
});
test('content returns the original meta object', () => {
const meta: PackMeta = { display_name: 'Cats', usage: [ImageUsage.Emoticon] };
assert.equal(reader(meta).content, meta);
});
@@ -0,0 +1,77 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixEvent } from 'matrix-js-sdk';
import { packAddressEqual, imageUsageEqual, packMetaEqual, makeImagePacks } from './utils';
import { PackAddress } from './PackAddress';
import { PackMetaReader } from './PackMetaReader';
import { ImageUsage, PackContent, PackMeta } from './types';
test('packAddressEqual handles undefined and value equality', () => {
assert.equal(packAddressEqual(undefined, undefined), true);
assert.equal(packAddressEqual(new PackAddress('!r', 'k'), undefined), false);
assert.equal(packAddressEqual(undefined, new PackAddress('!r', 'k')), false);
assert.equal(packAddressEqual(new PackAddress('!r', 'k'), new PackAddress('!r', 'k')), true);
assert.equal(packAddressEqual(new PackAddress('!r', 'k'), new PackAddress('!r', 'j')), false);
assert.equal(packAddressEqual(new PackAddress('!r', 'k'), new PackAddress('!s', 'k')), false);
});
test('imageUsageEqual compares set membership and length', () => {
assert.equal(imageUsageEqual([], []), true);
assert.equal(imageUsageEqual([ImageUsage.Emoticon], [ImageUsage.Emoticon]), true);
assert.equal(
imageUsageEqual(
[ImageUsage.Emoticon, ImageUsage.Sticker],
[ImageUsage.Sticker, ImageUsage.Emoticon],
),
true,
);
assert.equal(imageUsageEqual([ImageUsage.Emoticon], [ImageUsage.Sticker]), false);
assert.equal(
imageUsageEqual([ImageUsage.Emoticon], [ImageUsage.Emoticon, ImageUsage.Sticker]),
false,
);
});
const meta = (m: PackMeta) => new PackMetaReader(m);
test('packMetaEqual compares name, avatar, attribution and usage', () => {
assert.equal(packMetaEqual(meta({ display_name: 'A' }), meta({ display_name: 'A' })), true);
assert.equal(packMetaEqual(meta({ display_name: 'A' }), meta({ display_name: 'B' })), false);
assert.equal(packMetaEqual(meta({ avatar_url: 'u' }), meta({ avatar_url: 'v' })), false);
assert.equal(packMetaEqual(meta({ attribution: 'x' }), meta({ attribution: 'y' })), false);
// both fall back to default usage => equal
assert.equal(packMetaEqual(meta({}), meta({})), true);
assert.equal(
packMetaEqual(meta({ usage: [ImageUsage.Emoticon] }), meta({ usage: [ImageUsage.Sticker] })),
false,
);
});
const eventStub = (id: string | undefined, content: PackContent = {}): MatrixEvent =>
({
getId: () => id,
getRoomId: () => '!room:server',
getStateKey: () => 'state-key',
getContent: () => content,
}) as unknown as MatrixEvent;
test('makeImagePacks builds packs and skips events without an id', () => {
const events = [
eventStub('$1', { pack: { display_name: 'One' } }),
eventStub(undefined, { pack: { display_name: 'Skip' } }),
eventStub('$2', { images: { cat: { url: 'mxc://x/cat' } } }),
];
const packs = makeImagePacks(events);
assert.equal(packs.length, 2);
assert.equal(packs[0].id, '$1');
assert.equal(packs[0].meta.name, 'One');
assert.equal(packs[0].address?.roomId, '!room:server');
assert.equal(packs[0].address?.stateKey, 'state-key');
assert.equal(packs[1].id, '$2');
assert.equal(packs[1].images.collection.get('cat')?.url, 'mxc://x/cat');
});
test('makeImagePacks returns empty for empty input', () => {
assert.deepEqual(makeImagePacks([]), []);
});
@@ -0,0 +1,112 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseBlockMD } from './parser';
import { parseInlineMD } from '../inline/parser';
test('empty string is returned unchanged', () => {
assert.equal(parseBlockMD('', parseInlineMD), '');
});
test('plain single line is returned unchanged', () => {
assert.equal(parseBlockMD('hello', parseInlineMD), 'hello');
});
test('heading levels', () => {
assert.equal(parseBlockMD('# Heading', parseInlineMD), '<h1 data-md="#">Heading</h1>');
assert.equal(parseBlockMD('### Three', parseInlineMD), '<h3 data-md="###">Three</h3>');
});
test('heading requires a space after the hashes', () => {
// no space => not a heading, falls through to plain text
assert.equal(parseBlockMD('#nospace', parseInlineMD), '#nospace');
});
test('inline markdown inside a heading is parsed', () => {
assert.equal(
parseBlockMD('# **b**', parseInlineMD),
'<h1 data-md="#"><strong data-md="**">b</strong></h1>',
);
});
test('heading without a parseInline function keeps the raw text', () => {
assert.equal(parseBlockMD('# Heading', undefined), '<h1 data-md="#">Heading</h1>');
});
test('fenced code block without info string', () => {
assert.equal(
parseBlockMD('```\ncode\n```', parseInlineMD),
'<pre data-md="```"><code>code\n</code></pre>',
);
});
test('fenced code block with a language', () => {
assert.equal(
parseBlockMD('```js\ncode\n```', parseInlineMD),
'<pre data-md="```"><code class="language-js">code\n</code></pre>',
);
});
test('fenced code block with a filename adds language and data-label', () => {
assert.equal(
parseBlockMD('```example.json\ncode\n```', parseInlineMD),
'<pre data-md="```"><code class="language-json" data-label="example.json">code\n</code></pre>',
);
});
test('blockquote single line', () => {
assert.equal(
parseBlockMD('> quote', parseInlineMD),
'<blockquote data-md=">">quote<br/></blockquote>',
);
});
test('blockquote multiple lines', () => {
assert.equal(
parseBlockMD('> a\n> b', parseInlineMD),
'<blockquote data-md=">">a<br/>b<br/></blockquote>',
);
});
test('unordered list', () => {
assert.equal(parseBlockMD('* item', parseInlineMD), '<ul data-md="*"><li><p>item</p></li></ul>');
});
test('ordered list', () => {
assert.equal(
parseBlockMD('1. item', parseInlineMD),
'<ol data-md="1" start="1"><li><p>item</p></li></ol>',
);
});
test('list with multiple items', () => {
assert.equal(
parseBlockMD('* a\n* b', parseInlineMD),
'<ul data-md="*"><li><p>a</p></li><li><p>b</p></li></ul>',
);
});
test('nested list opens a child list', () => {
assert.equal(
parseBlockMD('* a\n * b', parseInlineMD),
'<ul data-md="*"><li><p>a</p><ul data-md="*"><li><p>b</p></li></ul></ul>',
);
});
test('inline markdown inside list items is parsed', () => {
assert.equal(
parseBlockMD('1. **b**', parseInlineMD),
'<ol data-md="1" start="1"><li><p><strong data-md="**">b</strong></p></li></ol>',
);
});
test('newlines are preserved as <br/>', () => {
assert.equal(parseBlockMD('line1\nline2', parseInlineMD), 'line1<br/>line2');
});
test('empty lines are preserved as <br/>', () => {
assert.equal(parseBlockMD('a\n\nb', parseInlineMD), 'a<br/><br/>b');
});
test('escaped block sequence is unescaped and not treated as a block', () => {
assert.equal(parseBlockMD('\\# not heading', parseInlineMD), '# not heading');
});
@@ -0,0 +1,102 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseInlineMD } from './parser';
test('empty string is returned unchanged', () => {
assert.equal(parseInlineMD(''), '');
});
test('plain text without markdown is returned unchanged', () => {
assert.equal(parseInlineMD('hello world'), 'hello world');
});
test('bold', () => {
assert.equal(parseInlineMD('**bold**'), '<strong data-md="**">bold</strong>');
});
test('italic with asterisk', () => {
assert.equal(parseInlineMD('*italic*'), '<i data-md="*">italic</i>');
});
test('italic with underscore', () => {
assert.equal(parseInlineMD('_italic_'), '<i data-md="_">italic</i>');
});
test('underline', () => {
assert.equal(parseInlineMD('__under__'), '<u data-md="__">under</u>');
});
test('strikethrough', () => {
assert.equal(parseInlineMD('~~strike~~'), '<s data-md="~~">strike</s>');
});
test('inline code', () => {
assert.equal(parseInlineMD('`code`'), '<code data-md="`">code</code>');
});
test('inline code does not parse markdown inside it', () => {
// code is run before the other rules and does not re-parse its content
assert.equal(parseInlineMD('`**bold**`'), '<code data-md="`">**bold**</code>');
});
test('spoiler', () => {
assert.equal(parseInlineMD('||secret||'), '<span data-md="||" data-mx-spoiler>secret</span>');
});
test('link', () => {
assert.equal(
parseInlineMD('[alt](https://example.com)'),
'<a data-md href="https://example.com">alt</a>',
);
});
test('escaped markdown characters are unescaped to literal text', () => {
assert.equal(parseInlineMD('\\*notbold\\*'), '*notbold*');
assert.equal(parseInlineMD('a\\_b'), 'a_b');
});
test('nesting: italic inside bold', () => {
assert.equal(
parseInlineMD('**bold *italic***'),
'<strong data-md="**">bold <i data-md="*">italic</i></strong>',
);
});
test('nesting: bold inside link alt text', () => {
assert.equal(
parseInlineMD('[**b**](https://e.com)'),
'<a data-md href="https://e.com"><strong data-md="**">b</strong></a>',
);
});
test('nesting: bold inside spoiler', () => {
assert.equal(
parseInlineMD('||**b**||'),
'<span data-md="||" data-mx-spoiler><strong data-md="**">b</strong></span>',
);
});
test('adjacent tokens of different types are both parsed', () => {
assert.equal(parseInlineMD('**a**_b_'), '<strong data-md="**">a</strong><i data-md="_">b</i>');
});
test('text surrounding a token is preserved', () => {
assert.equal(parseInlineMD('pre **mid** post'), 'pre <strong data-md="**">mid</strong> post');
});
test('two separate tokens are parsed in their text order', () => {
assert.equal(parseInlineMD('*a* **b**'), '<i data-md="*">a</i> <strong data-md="**">b</strong>');
assert.equal(parseInlineMD('__a__ _b_'), '<u data-md="__">a</u> <i data-md="_">b</i>');
});
test('code takes precedence and is resolved before other inline rules', () => {
assert.equal(parseInlineMD('`a` *b*'), '<code data-md="`">a</code> <i data-md="*">b</i>');
});
test('unclosed token is returned as literal text', () => {
assert.equal(parseInlineMD('**unclosed'), '**unclosed');
});
test('markdown characters inside a URL are not parsed (negative lookbehind)', () => {
assert.equal(parseInlineMD('https://e.com/*path*'), 'https://e.com/*path*');
});
@@ -0,0 +1,56 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runInlineRule, runInlineRules } from './runner';
import { parseInlineMD } from './parser';
import { BoldRule, ItalicRule1, StrikeRule } from './rules';
import { InlineMDRule } from './type';
// A trivial rule that wraps the matched token in <X>...</X>.
const makeRule = (token: string, tag: string): InlineMDRule => ({
match: (text) => text.match(new RegExp(token)),
html: (parse, match) => `<${tag}>${parse(match[0])}</${tag}>`,
});
test('runInlineRule applies a matching rule', () => {
assert.equal(runInlineRule('**b**', BoldRule, parseInlineMD), '<strong data-md="**">b</strong>');
});
test('runInlineRule returns undefined when the rule does not match', () => {
assert.equal(runInlineRule('plain', BoldRule, parseInlineMD), undefined);
});
test('runInlineRule recursively parses surrounding text', () => {
// bold matches in the middle; text on both sides is re-parsed (here it is plain)
assert.equal(
runInlineRule('pre **mid** post', BoldRule, parseInlineMD),
'pre <strong data-md="**">mid</strong> post',
);
});
test('runInlineRules returns undefined when no rule matches', () => {
assert.equal(runInlineRules('plain text', [BoldRule, ItalicRule1], parseInlineMD), undefined);
});
test('runInlineRules picks the earliest-matching rule regardless of rule order', () => {
// italic appears before bold in the text, so italic wins even though Bold is listed first
assert.equal(
runInlineRules('a *i* **b**', [BoldRule, ItalicRule1], parseInlineMD),
'a <i data-md="*">i</i> <strong data-md="**">b</strong>',
);
});
test('runInlineRules breaks index ties by rule order (first listed wins)', () => {
// Two synthetic rules both match at index 0; the first in the list wins.
const aRule = makeRule('a', 'A');
const a2Rule = makeRule('a', 'B');
assert.equal(runInlineRules('a', [aRule, a2Rule], parseInlineMD), '<A>a</A>');
assert.equal(runInlineRules('a', [a2Rule, aRule], parseInlineMD), '<B>a</B>');
});
test('runInlineRules earliest index wins even when a later-listed rule matches sooner', () => {
// Strike token "~~s~~" is at index 0; bold token is later in the string.
assert.equal(
runInlineRules('~~s~~ **b**', [BoldRule, StrikeRule], parseInlineMD),
'<s data-md="~~">s</s> <strong data-md="**">b</strong>',
);
});
@@ -0,0 +1,74 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { beforeMatch, afterMatch, replaceMatch } from './utils';
test('beforeMatch returns the slice before the match', () => {
const match = 'abXYcd'.match(/XY/)!;
assert.equal(beforeMatch('abXYcd', match), 'ab');
});
test('beforeMatch is empty when the match is at the start', () => {
const match = 'XYcd'.match(/XY/)!;
assert.equal(beforeMatch('XYcd', match), '');
});
test('beforeMatch on a match without an index treats index as undefined', () => {
// text.slice(0, undefined) returns the whole string
const fakeMatch = ['X'] as unknown as RegExpMatchArray;
assert.equal(beforeMatch('Xabc', fakeMatch), 'Xabc');
});
test('afterMatch returns the slice after the match', () => {
const match = 'abXYcd'.match(/XY/)!;
assert.equal(afterMatch('abXYcd', match), 'cd');
});
test('afterMatch is empty when the match runs to the end', () => {
const match = 'abXY'.match(/XY/)!;
assert.equal(afterMatch('abXY', match), '');
});
test('afterMatch handles a match at the start', () => {
const match = 'XYcd'.match(/XY/)!;
assert.equal(afterMatch('XYcd', match), 'cd');
});
test('afterMatch falls back to index 0 when match.index is missing', () => {
// (undefined ?? 0) + match[0].length === 1, so it slices off the first char
const fakeMatch = ['X'] as unknown as RegExpMatchArray;
assert.equal(afterMatch('Xabc', fakeMatch), 'abc');
});
test('replaceMatch splices content between processed before/after parts', () => {
const match = 'abXYcd'.match(/XY/)!;
assert.deepEqual(
replaceMatch('abXYcd', match, '<R>', (t) => [t]),
['ab', '<R>', 'cd'],
);
});
test('replaceMatch applies processPart to the surrounding text', () => {
const match = 'abXYcd'.match(/XY/)!;
assert.deepEqual(
replaceMatch('abXYcd', match, '<R>', (t) => [t.toUpperCase()]),
['AB', '<R>', 'CD'],
);
});
test('replaceMatch keeps empty surrounding parts produced by processPart', () => {
const match = 'XY'.match(/XY/)!;
// empty before and after still flow through processPart
assert.deepEqual(
replaceMatch('XY', match, '<R>', (t) => [t]),
['', '<R>', ''],
);
});
test('replaceMatch supports non-string content types', () => {
const match = 'aXb'.match(/X/)!;
const node = { type: 'node' };
assert.deepEqual(
replaceMatch('aXb', match, node, (t) => [t]),
['a', node, 'b'],
);
});
+46
View File
@@ -0,0 +1,46 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
escapeMarkdownInlineSequences,
unescapeMarkdownInlineSequences,
escapeMarkdownBlockSequences,
unescapeMarkdownBlockSequences,
} from './utils';
const identity = (t: string): string => t;
test('escapeMarkdownInlineSequences backslash-escapes inline markdown chars', () => {
assert.equal(escapeMarkdownInlineSequences('a*b'), 'a\\*b');
assert.equal(escapeMarkdownInlineSequences('under_score'), 'under\\_score');
// plain text without markdown chars is unchanged
assert.equal(escapeMarkdownInlineSequences('plain text'), 'plain text');
});
test('inline escape/unescape round-trips', () => {
for (const s of ['a*b*c', 'under_score', 'plain', 'mix *a* _b_']) {
assert.equal(unescapeMarkdownInlineSequences(escapeMarkdownInlineSequences(s)), s);
}
});
test('escapeMarkdownBlockSequences escapes leading block markers', () => {
assert.equal(escapeMarkdownBlockSequences('# heading', identity), '\\# heading');
assert.equal(escapeMarkdownBlockSequences('> quote', identity), '\\> quote');
});
test('block unescape passes non-escaped text through processPart', () => {
assert.equal(unescapeMarkdownBlockSequences('plain', identity), 'plain');
// a custom processPart is applied to the (non-escaped) text
assert.equal(
unescapeMarkdownBlockSequences('plain', (t) => t.toUpperCase()),
'PLAIN',
);
});
test('block escape/unescape round-trips', () => {
for (const s of ['# h', '> quote', 'plain line']) {
assert.equal(
unescapeMarkdownBlockSequences(escapeMarkdownBlockSequences(s, identity), identity),
s,
);
}
});
+77
View File
@@ -0,0 +1,77 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
getMatrixToUser,
getMatrixToRoom,
getMatrixToRoomEvent,
testMatrixTo,
parseMatrixToUser,
parseMatrixToRoom,
parseMatrixToRoomEvent,
} from './matrix-to';
test('getMatrixToUser builds a user permalink', () => {
assert.equal(getMatrixToUser('@alice:example.org'), 'https://matrix.to/#/@alice:example.org');
});
test('getMatrixToRoom builds room links with optional via servers', () => {
assert.equal(getMatrixToRoom('#room:example.org'), 'https://matrix.to/#/#room:example.org');
assert.equal(
getMatrixToRoom('!abc:example.org', ['a.org', 'b.org']),
'https://matrix.to/#/!abc:example.org?via=a.org&via=b.org',
);
// empty via array → no query string
assert.equal(getMatrixToRoom('!abc:example.org', []), 'https://matrix.to/#/!abc:example.org');
});
test('getMatrixToRoomEvent builds event links', () => {
assert.equal(
getMatrixToRoomEvent('!abc:example.org', '$evt', ['a.org']),
'https://matrix.to/#/!abc:example.org/$evt?via=a.org',
);
assert.equal(
getMatrixToRoomEvent('!abc:example.org', '$evt'),
'https://matrix.to/#/!abc:example.org/$evt',
);
});
test('testMatrixTo recognizes matrix.to hrefs', () => {
assert.equal(testMatrixTo('https://matrix.to/#/@a:b.org'), true);
assert.equal(testMatrixTo('http://matrix.to/#/!r:b.org'), true);
assert.equal(testMatrixTo('https://example.org/#/@a:b.org'), false);
});
test('parseMatrixToUser round-trips and rejects non-user links', () => {
assert.equal(parseMatrixToUser(getMatrixToUser('@a:b.org')), '@a:b.org');
assert.equal(parseMatrixToUser('https://matrix.to/#/@a:b.org/'), '@a:b.org'); // trailing slash ok
assert.equal(parseMatrixToUser('https://matrix.to/#/#room:b.org'), undefined);
assert.equal(parseMatrixToUser('https://example.org'), undefined);
});
test('parseMatrixToRoom extracts alias/id and via servers', () => {
assert.deepEqual(parseMatrixToRoom('https://matrix.to/#/#room:b.org'), {
roomIdOrAlias: '#room:b.org',
viaServers: undefined,
});
assert.deepEqual(parseMatrixToRoom(getMatrixToRoom('!abc:b.org', ['a.org', 'c.org'])), {
roomIdOrAlias: '!abc:b.org',
viaServers: ['a.org', 'c.org'],
});
// a user link is not a room link
assert.equal(parseMatrixToRoom('https://matrix.to/#/@a:b.org'), undefined);
});
test('parseMatrixToRoomEvent extracts room, event, and via servers', () => {
assert.deepEqual(parseMatrixToRoomEvent(getMatrixToRoomEvent('!abc:b.org', '$e', ['a.org'])), {
roomIdOrAlias: '!abc:b.org',
eventId: '$e',
viaServers: ['a.org'],
});
assert.deepEqual(parseMatrixToRoomEvent('https://matrix.to/#/!abc:b.org/$e'), {
roomIdOrAlias: '!abc:b.org',
eventId: '$e',
viaServers: undefined,
});
// a room-only link has no event
assert.equal(parseMatrixToRoomEvent('https://matrix.to/#/!abc:b.org'), undefined);
});
+114
View File
@@ -0,0 +1,114 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { addRecentEmoji, getRecentEmojis, IRecentEmojiContent } from './recent-emoji';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { emojis } from './emoji';
// A Map-backed MatrixClient stub supporting get/setAccountData.
const createMx = () => {
const store = new Map<string, unknown>();
const mx = {
getAccountData: (type: string): MatrixEvent | undefined => {
if (!store.has(type)) return undefined;
const content = store.get(type);
return { getContent: () => content } as unknown as MatrixEvent;
},
setAccountData: (type: string, content: unknown) => {
store.set(type, content);
return Promise.resolve({});
},
};
return { mx: mx as unknown as MatrixClient, store };
};
const getStored = (store: Map<string, unknown>): IRecentEmojiContent['recent_emoji'] =>
(store.get(AccountDataEvent.ElementRecentEmoji) as IRecentEmojiContent | undefined)?.recent_emoji;
// Pick two real unicode emojis to drive add->get round trips.
const u1 = emojis[0].unicode;
const u2 = emojis[1].unicode;
test('getRecentEmojis returns [] when there is no account data', () => {
const { mx } = createMx();
assert.deepEqual(getRecentEmojis(mx), []);
});
test('getRecentEmojis returns [] when content is not an array', () => {
const { mx, store } = createMx();
store.set(AccountDataEvent.ElementRecentEmoji, { recent_emoji: 'nope' });
assert.deepEqual(getRecentEmojis(mx), []);
});
test('addRecentEmoji creates an entry with count 1', () => {
const { mx, store } = createMx();
addRecentEmoji(mx, u1);
assert.deepEqual(getStored(store), [[u1, 1]]);
});
test('adding the same emoji again increments its count and keeps it at front', () => {
const { mx, store } = createMx();
addRecentEmoji(mx, u1);
addRecentEmoji(mx, u1);
assert.deepEqual(getStored(store), [[u1, 2]]);
});
test('a newly added emoji is promoted to the front', () => {
const { mx, store } = createMx();
addRecentEmoji(mx, u1);
addRecentEmoji(mx, u2);
assert.deepEqual(getStored(store), [
[u2, 1],
[u1, 1],
]);
});
test('re-adding an existing emoji promotes it to front and increments', () => {
const { mx, store } = createMx();
addRecentEmoji(mx, u1);
addRecentEmoji(mx, u2);
addRecentEmoji(mx, u1);
assert.deepEqual(getStored(store), [
[u1, 2],
[u2, 1],
]);
});
test('list is capped at 100 entries', () => {
const { mx, store } = createMx();
for (let i = 0; i < 120; i += 1) {
addRecentEmoji(mx, `:emoji-${i}:`);
}
const stored = getStored(store)!;
assert.equal(stored.length, 100);
// most recently added is at the front
assert.equal(stored[0][0], ':emoji-119:');
});
test('getRecentEmojis resolves stored unicodes to known emoji objects', () => {
const { mx } = createMx();
addRecentEmoji(mx, u1);
addRecentEmoji(mx, u2);
const recent = getRecentEmojis(mx);
assert.equal(recent.length, 2);
assert.equal(recent[0].unicode, u2);
assert.equal(recent[1].unicode, u1);
});
test('getRecentEmojis skips unicodes with no matching emoji definition', () => {
const { mx } = createMx();
addRecentEmoji(mx, ':not-a-real-emoji:');
addRecentEmoji(mx, u1);
const recent = getRecentEmojis(mx);
assert.equal(recent.length, 1);
assert.equal(recent[0].unicode, u1);
});
test('getRecentEmojis respects the limit argument (slice before resolve)', () => {
const { mx } = createMx();
addRecentEmoji(mx, u1);
addRecentEmoji(mx, u2);
const recent = getRecentEmojis(mx, 1);
assert.equal(recent.length, 1);
assert.equal(recent[0].unicode, u2);
});
+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);
});
+116
View File
@@ -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);
});
+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);
});
+104
View File
@@ -0,0 +1,104 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore } from 'jotai';
import { createListAtom } from './list';
// `createListAtom` produces a write atom whose reducer handles PUT (append a
// single item or an array of items), DELETE (identity-based removal of a single
// item or array of items) and REPLACE (identity match -> replacement). We drive
// the pure reducer through a real jotai store. The exported `TListAtom` type is
// a type alias only, nothing to test at runtime.
test('starts as an empty array', () => {
const store = createStore();
const listAtom = createListAtom<string>();
assert.deepEqual(store.get(listAtom), []);
});
test('PUT appends a single item', () => {
const store = createStore();
const listAtom = createListAtom<string>();
store.set(listAtom, { type: 'PUT', item: 'a' });
store.set(listAtom, { type: 'PUT', item: 'b' });
assert.deepEqual(store.get(listAtom), ['a', 'b']);
});
test('PUT appends an array of items in order', () => {
const store = createStore();
const listAtom = createListAtom<string>();
store.set(listAtom, { type: 'PUT', item: 'a' });
store.set(listAtom, { type: 'PUT', item: ['b', 'c'] });
assert.deepEqual(store.get(listAtom), ['a', 'b', 'c']);
});
test('PUT does not deduplicate', () => {
const store = createStore();
const listAtom = createListAtom<string>();
store.set(listAtom, { type: 'PUT', item: 'a' });
store.set(listAtom, { type: 'PUT', item: 'a' });
assert.deepEqual(store.get(listAtom), ['a', 'a']);
});
test('DELETE removes a single item by identity', () => {
const store = createStore();
const listAtom = createListAtom<string>();
store.set(listAtom, { type: 'PUT', item: ['a', 'b', 'c'] });
store.set(listAtom, { type: 'DELETE', item: 'b' });
assert.deepEqual(store.get(listAtom), ['a', 'c']);
});
test('DELETE removes an array of items', () => {
const store = createStore();
const listAtom = createListAtom<string>();
store.set(listAtom, { type: 'PUT', item: ['a', 'b', 'c', 'd'] });
store.set(listAtom, { type: 'DELETE', item: ['a', 'c'] });
assert.deepEqual(store.get(listAtom), ['b', 'd']);
});
test('DELETE of an absent item is a no-op', () => {
const store = createStore();
const listAtom = createListAtom<string>();
store.set(listAtom, { type: 'PUT', item: ['a', 'b'] });
store.set(listAtom, { type: 'DELETE', item: 'z' });
assert.deepEqual(store.get(listAtom), ['a', 'b']);
});
test('DELETE uses reference identity for object items', () => {
const store = createStore();
const listAtom = createListAtom<{ id: number }>();
const a = { id: 1 };
const b = { id: 2 };
store.set(listAtom, { type: 'PUT', item: [a, b] });
// A structurally-equal but distinct object is not removed.
store.set(listAtom, { type: 'DELETE', item: { id: 1 } });
assert.deepEqual(store.get(listAtom), [a, b]);
// The same reference is removed.
store.set(listAtom, { type: 'DELETE', item: a });
assert.deepEqual(store.get(listAtom), [b]);
});
test('REPLACE swaps the matching item, preserving position', () => {
const store = createStore();
const listAtom = createListAtom<string>();
store.set(listAtom, { type: 'PUT', item: ['a', 'b', 'c'] });
store.set(listAtom, { type: 'REPLACE', item: 'b', replacement: 'B' });
assert.deepEqual(store.get(listAtom), ['a', 'B', 'c']);
});
test('REPLACE matches by identity and replaces every match', () => {
const store = createStore();
const listAtom = createListAtom<string>();
store.set(listAtom, { type: 'PUT', item: ['a', 'b', 'a'] });
store.set(listAtom, { type: 'REPLACE', item: 'a', replacement: 'X' });
assert.deepEqual(store.get(listAtom), ['X', 'b', 'X']);
});
test('REPLACE with no match leaves the list unchanged', () => {
const store = createStore();
const listAtom = createListAtom<string>();
store.set(listAtom, { type: 'PUT', item: ['a', 'b'] });
store.set(listAtom, { type: 'REPLACE', item: 'z', replacement: 'Z' });
assert.deepEqual(store.get(listAtom), ['a', 'b']);
});
+123
View File
@@ -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'));
});
+48
View File
@@ -0,0 +1,48 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
// Importing the module evaluates `atomWithStorage(..., createJSONStorage(() => localStorage))`,
// which touches `localStorage` at load time. node has none, so install a no-op
// mock before the import is resolved.
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
// eslint-disable-next-line import/first
import { addRecentSearch } from './recentSearches';
test('addRecentSearch prepends a new term', () => {
assert.deepEqual(addRecentSearch(['a', 'b'], 'c'), ['c', 'a', 'b']);
});
test('addRecentSearch dedupes (case-sensitive) and moves the term to the front', () => {
assert.deepEqual(addRecentSearch(['a', 'b', 'c'], 'b'), ['b', 'a', 'c']);
// case-sensitive: differing case is treated as a distinct term
assert.deepEqual(addRecentSearch(['a', 'B'], 'b'), ['b', 'a', 'B']);
});
test('addRecentSearch trims whitespace before storing', () => {
assert.deepEqual(addRecentSearch(['a'], ' hello '), ['hello', 'a']);
// dedupe compares against the trimmed value
assert.deepEqual(addRecentSearch(['hello'], ' hello '), ['hello']);
});
test('addRecentSearch ignores empty / whitespace-only terms', () => {
assert.deepEqual(addRecentSearch(['a', 'b'], ''), ['a', 'b']);
assert.deepEqual(addRecentSearch(['a', 'b'], ' '), ['a', 'b']);
});
test('addRecentSearch caps the list at 10 entries', () => {
const ten = Array.from({ length: 10 }, (_, i) => `t${i}`);
const result = addRecentSearch(ten, 'new');
assert.equal(result.length, 10);
assert.equal(result[0], 'new');
// the oldest entry (last) is dropped
assert.equal(result.includes('t9'), false);
assert.deepEqual(result.slice(1), ten.slice(0, 9));
});
test('addRecentSearch on an empty history returns a single-element list', () => {
assert.deepEqual(addRecentSearch([], 'first'), ['first']);
});
@@ -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);
});
+118
View File
@@ -0,0 +1,118 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore } from 'jotai';
import { enableMapSet } from 'immer';
import { roomToParentsAtom } from './roomToParents';
import { RoomToParents } from '../../../types/matrix/room';
// `roomToParentsAtom` maps childRoomId -> Set<parentRoomId>. The reducer handles
// INITIALIZE (replace the whole map), PUT (mapParentWithChildren: register a
// parent for each given child, skipping cycles) and DELETE (drop the room as a
// parent map entry, strip it from every child's parent-set, then prune children
// left with zero parents). The reducers `produce` over an immer-managed Map/Set,
// so we enable that the same way the app does at startup (src/index.tsx).
// The React `useBindRoomToParentsAtom` hook wiring is not covered here.
enableMapSet();
const get = (store: ReturnType<typeof createStore>): RoomToParents => store.get(roomToParentsAtom);
const parentsOf = (store: ReturnType<typeof createStore>, child: string): string[] =>
Array.from(get(store).get(child) ?? []).sort();
test('starts as an empty map', () => {
const store = createStore();
assert.equal(get(store).size, 0);
});
test('INITIALIZE replaces the whole map', () => {
const store = createStore();
const seed: RoomToParents = new Map([['!child:s', new Set(['!space:s'])]]);
store.set(roomToParentsAtom, { type: 'INITIALIZE', roomToParents: seed });
assert.equal(get(store), seed);
assert.deepEqual(parentsOf(store, '!child:s'), ['!space:s']);
});
test('PUT registers a parent for each child', () => {
const store = createStore();
store.set(roomToParentsAtom, {
type: 'PUT',
parent: '!space:s',
children: ['!c1:s', '!c2:s'],
});
assert.deepEqual(parentsOf(store, '!c1:s'), ['!space:s']);
assert.deepEqual(parentsOf(store, '!c2:s'), ['!space:s']);
});
test('PUT accumulates multiple parents for the same child', () => {
const store = createStore();
store.set(roomToParentsAtom, { type: 'PUT', parent: '!p1:s', children: ['!c:s'] });
store.set(roomToParentsAtom, { type: 'PUT', parent: '!p2:s', children: ['!c:s'] });
assert.deepEqual(parentsOf(store, '!c:s'), ['!p1:s', '!p2:s']);
});
test('PUT skips a child that would create a cycle', () => {
const store = createStore();
// !b is a child of !a.
store.set(roomToParentsAtom, { type: 'PUT', parent: '!a:s', children: ['!b:s'] });
// Now make !a a child of !b -> !a's parents include !b, but !b already has !a
// as an ancestor, so the cycle branch skips re-registering.
store.set(roomToParentsAtom, { type: 'PUT', parent: '!b:s', children: ['!a:s'] });
assert.deepEqual(parentsOf(store, '!b:s'), ['!a:s']);
// !a was not registered as a child of !b because that closes a cycle.
assert.equal(get(store).has('!a:s'), false);
});
test('DELETE removes the room as a parent entry', () => {
const store = createStore();
store.set(roomToParentsAtom, { type: 'PUT', parent: '!space:s', children: ['!c:s'] });
// Register !space itself as a child of a grandparent so it has its own entry.
store.set(roomToParentsAtom, { type: 'PUT', parent: '!grand:s', children: ['!space:s'] });
assert.equal(get(store).has('!space:s'), true);
store.set(roomToParentsAtom, { type: 'DELETE', roomId: '!space:s' });
// !space's own entry (as a child of !grand) is gone.
assert.equal(get(store).has('!space:s'), false);
});
test('DELETE strips the room from every child parent-set', () => {
const store = createStore();
store.set(roomToParentsAtom, { type: 'PUT', parent: '!p1:s', children: ['!c:s'] });
store.set(roomToParentsAtom, { type: 'PUT', parent: '!p2:s', children: ['!c:s'] });
store.set(roomToParentsAtom, { type: 'DELETE', roomId: '!p1:s' });
// !c keeps !p2 as a parent.
assert.deepEqual(parentsOf(store, '!c:s'), ['!p2:s']);
});
test('DELETE prunes a child left with zero parents (orphan cleanup)', () => {
const store = createStore();
store.set(roomToParentsAtom, { type: 'PUT', parent: '!space:s', children: ['!c:s'] });
assert.equal(get(store).has('!c:s'), true);
store.set(roomToParentsAtom, { type: 'DELETE', roomId: '!space:s' });
// !c had only !space as a parent; with that gone it is pruned entirely.
assert.equal(get(store).has('!c:s'), false);
assert.equal(get(store).size, 0);
});
test('DELETE removes the room as both a parent and a child', () => {
const store = createStore();
// !mid is a child of !top and a parent of !leaf.
store.set(roomToParentsAtom, { type: 'PUT', parent: '!top:s', children: ['!mid:s'] });
store.set(roomToParentsAtom, { type: 'PUT', parent: '!mid:s', children: ['!leaf:s'] });
store.set(roomToParentsAtom, { type: 'DELETE', roomId: '!mid:s' });
// !mid's own entry is gone (was a child of !top).
assert.equal(get(store).has('!mid:s'), false);
// !leaf lost its only parent !mid, so it is pruned.
assert.equal(get(store).has('!leaf:s'), false);
});
test('DELETE of an unknown room only prunes nothing', () => {
const store = createStore();
store.set(roomToParentsAtom, { type: 'PUT', parent: '!p:s', children: ['!c:s'] });
store.set(roomToParentsAtom, { type: 'DELETE', roomId: '!unknown:s' });
assert.deepEqual(parentsOf(store, '!c:s'), ['!p:s']);
assert.equal(get(store).size, 1);
});
+280
View File
@@ -0,0 +1,280 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore } from 'jotai';
import { enableMapSet } from 'immer';
import { roomToUnreadAtom, unreadInfoToUnread, unreadEqual } from './roomToUnread';
import { roomToParentsAtom } from './roomToParents';
import { RoomToParents, Unread, UnreadInfo } from '../../../types/matrix/room';
// `roomToUnreadAtom` maps roomId -> Unread { total, highlight, from }. The
// reducer handles RESET (rebuild from a list of UnreadInfo), PUT (set one room's
// unread + roll the delta up to all ancestor parents, accumulating their `from`
// sets) and DELETE (remove a room's unread + roll the removal back up to
// parents, pruning a parent whose `from` set becomes empty). Parent aggregation
// reads `roomToParentsAtom`, which we seed directly in the store.
//
// The reducers `produce` over immer-managed Map/Set, so we enable that the same
// way the app does at startup (src/index.tsx). The React
// `useBindRoomToUnreadAtom` hook wiring (timeline/receipt/membership listeners,
// getUnreadInfos) is not covered here.
enableMapSet();
const get = (store: ReturnType<typeof createStore>) => store.get(roomToUnreadAtom);
const info = (roomId: string, total: number, highlight: number): UnreadInfo => ({
roomId,
total,
highlight,
});
const seedParents = (store: ReturnType<typeof createStore>, map: RoomToParents) =>
store.set(roomToParentsAtom, { type: 'INITIALIZE', roomToParents: map });
// ---------------------------------------------------------------------------
// unreadInfoToUnread
// ---------------------------------------------------------------------------
test('unreadInfoToUnread copies counts and nulls `from`', () => {
assert.deepEqual(unreadInfoToUnread(info('!r:s', 5, 2)), {
total: 5,
highlight: 2,
from: null,
});
});
// ---------------------------------------------------------------------------
// unreadEqual
// ---------------------------------------------------------------------------
const u = (total: number, highlight: number, from: Set<string> | null): Unread => ({
total,
highlight,
from,
});
test('unreadEqual: equal counts with both `from` null are equal', () => {
assert.equal(unreadEqual(u(1, 0, null), u(1, 0, null)), true);
});
test('unreadEqual: differing total is not equal', () => {
assert.equal(unreadEqual(u(1, 0, null), u(2, 0, null)), false);
});
test('unreadEqual: differing highlight is not equal', () => {
assert.equal(unreadEqual(u(1, 0, null), u(1, 1, null)), false);
});
test('unreadEqual: one `from` null and the other a set is not equal', () => {
assert.equal(unreadEqual(u(1, 0, null), u(1, 0, new Set(['!a:s']))), false);
assert.equal(unreadEqual(u(1, 0, new Set(['!a:s'])), u(1, 0, null)), false);
});
test('unreadEqual: `from` sets of different size are not equal', () => {
assert.equal(unreadEqual(u(1, 0, new Set(['!a:s'])), u(1, 0, new Set(['!a:s', '!b:s']))), false);
});
test('unreadEqual: same-size `from` sets with same members are equal', () => {
assert.equal(
unreadEqual(u(2, 1, new Set(['!a:s', '!b:s'])), u(2, 1, new Set(['!b:s', '!a:s']))),
true,
);
});
test('unreadEqual: same-size `from` sets with different members are not equal', () => {
assert.equal(unreadEqual(u(1, 0, new Set(['!a:s'])), u(1, 0, new Set(['!b:s']))), false);
});
// ---------------------------------------------------------------------------
// roomToUnreadAtom: PUT (no parents)
// ---------------------------------------------------------------------------
test('starts empty', () => {
const store = createStore();
assert.equal(get(store).size, 0);
});
test('PUT sets a room unread with null `from`', () => {
const store = createStore();
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
assert.deepEqual(get(store).get('!r:s'), { total: 3, highlight: 1, from: null });
});
test('PUT overwrites a room unread with the latest counts', () => {
const store = createStore();
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 7, 2) });
assert.deepEqual(get(store).get('!r:s'), { total: 7, highlight: 2, from: null });
});
test('PUT with unchanged counts is skipped (same map reference)', () => {
const store = createStore();
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
const before = get(store);
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
const after = get(store);
// The "skip update if equal" guard returns without producing a new map.
assert.equal(before, after);
});
// ---------------------------------------------------------------------------
// roomToUnreadAtom: PUT with parent aggregation
// ---------------------------------------------------------------------------
test('PUT rolls counts up to a single parent and records `from`', () => {
const store = createStore();
seedParents(store, new Map([['!c:s', new Set(['!space:s'])]]));
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c:s', 4, 2) });
assert.deepEqual(get(store).get('!c:s'), { total: 4, highlight: 2, from: null });
const parent = get(store).get('!space:s');
assert.equal(parent?.total, 4);
assert.equal(parent?.highlight, 2);
assert.deepEqual(Array.from(parent?.from ?? []), ['!c:s']);
});
test('PUT aggregates two children into the same parent', () => {
const store = createStore();
seedParents(
store,
new Map([
['!c1:s', new Set(['!space:s'])],
['!c2:s', new Set(['!space:s'])],
]),
);
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c1:s', 4, 2) });
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c2:s', 1, 1) });
const parent = get(store).get('!space:s');
assert.equal(parent?.total, 5);
assert.equal(parent?.highlight, 3);
assert.deepEqual(Array.from(parent?.from ?? []).sort(), ['!c1:s', '!c2:s']);
});
test('PUT applies only the delta to the parent on re-PUT of the same child', () => {
const store = createStore();
seedParents(store, new Map([['!c:s', new Set(['!space:s'])]]));
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c:s', 4, 2) });
// Increase the child's counts; the parent should reflect the delta, not 4+6.
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c:s', 6, 3) });
const parent = get(store).get('!space:s');
assert.equal(parent?.total, 6);
assert.equal(parent?.highlight, 3);
assert.deepEqual(Array.from(parent?.from ?? []), ['!c:s']);
});
test('PUT rolls up through a grandparent chain', () => {
const store = createStore();
seedParents(
store,
new Map([
['!c:s', new Set(['!mid:s'])],
['!mid:s', new Set(['!top:s'])],
]),
);
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c:s', 2, 1) });
// Both !mid and !top are ancestors of !c.
for (const ancestor of ['!mid:s', '!top:s']) {
const a = get(store).get(ancestor);
assert.equal(a?.total, 2, ancestor);
assert.equal(a?.highlight, 1, ancestor);
assert.deepEqual(Array.from(a?.from ?? []), ['!c:s'], ancestor);
}
});
// ---------------------------------------------------------------------------
// roomToUnreadAtom: RESET
// ---------------------------------------------------------------------------
test('RESET rebuilds the map from scratch', () => {
const store = createStore();
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!old:s', 9, 9) });
store.set(roomToUnreadAtom, {
type: 'RESET',
unreadInfos: [info('!a:s', 2, 0), info('!b:s', 3, 1)],
});
assert.equal(get(store).has('!old:s'), false);
assert.deepEqual(get(store).get('!a:s'), { total: 2, highlight: 0, from: null });
assert.deepEqual(get(store).get('!b:s'), { total: 3, highlight: 1, from: null });
});
test('RESET aggregates parents from the seeded roomToParents', () => {
const store = createStore();
seedParents(
store,
new Map([
['!a:s', new Set(['!space:s'])],
['!b:s', new Set(['!space:s'])],
]),
);
store.set(roomToUnreadAtom, {
type: 'RESET',
unreadInfos: [info('!a:s', 2, 1), info('!b:s', 3, 0)],
});
const parent = get(store).get('!space:s');
assert.equal(parent?.total, 5);
assert.equal(parent?.highlight, 1);
assert.deepEqual(Array.from(parent?.from ?? []).sort(), ['!a:s', '!b:s']);
});
// ---------------------------------------------------------------------------
// roomToUnreadAtom: DELETE
// ---------------------------------------------------------------------------
test('DELETE removes a leaf room with no parents', () => {
const store = createStore();
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
store.set(roomToUnreadAtom, { type: 'DELETE', roomId: '!r:s' });
assert.equal(get(store).has('!r:s'), false);
});
test('DELETE of an absent room is a no-op (same map reference)', () => {
const store = createStore();
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
const before = get(store);
store.set(roomToUnreadAtom, { type: 'DELETE', roomId: '!absent:s' });
assert.equal(get(store), before);
});
test('DELETE subtracts the child counts from a parent that keeps other children', () => {
const store = createStore();
seedParents(
store,
new Map([
['!c1:s', new Set(['!space:s'])],
['!c2:s', new Set(['!space:s'])],
]),
);
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c1:s', 4, 2) });
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c2:s', 1, 1) });
store.set(roomToUnreadAtom, { type: 'DELETE', roomId: '!c1:s' });
assert.equal(get(store).has('!c1:s'), false);
const parent = get(store).get('!space:s');
assert.equal(parent?.total, 1);
assert.equal(parent?.highlight, 1);
assert.deepEqual(Array.from(parent?.from ?? []), ['!c2:s']);
});
test('DELETE prunes a parent whose `from` set becomes empty', () => {
const store = createStore();
seedParents(store, new Map([['!c:s', new Set(['!space:s'])]]));
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c:s', 4, 2) });
store.set(roomToUnreadAtom, { type: 'DELETE', roomId: '!c:s' });
assert.equal(get(store).has('!c:s'), false);
// The parent had only !c contributing, so it is removed entirely.
assert.equal(get(store).has('!space:s'), false);
assert.equal(get(store).size, 0);
});
+145
View File
@@ -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')]);
});
+74
View File
@@ -0,0 +1,74 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setFallbackSession, removeFallbackSession, getFallbackSession } from './sessions';
// The fallback-session helpers read/write specific `cinny_*` keys directly on
// `localStorage`. node has none, so install a controllable in-memory mock per
// case backed by a Map.
const installStorage = (): Map<string, string> => {
const store = new Map<string, string>();
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: (key: string) => (store.has(key) ? store.get(key) : null),
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
};
return store;
};
test('setFallbackSession writes the cinny_* keys', () => {
const store = installStorage();
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
assert.equal(store.get('cinny_access_token'), 'token-1');
assert.equal(store.get('cinny_device_id'), 'DEVICE1');
assert.equal(store.get('cinny_user_id'), '@alice:example.org');
assert.equal(store.get('cinny_hs_base_url'), 'https://hs.example.org');
});
test('getFallbackSession round-trips a full session and flags fallback stores', () => {
installStorage();
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
assert.deepEqual(getFallbackSession(), {
baseUrl: 'https://hs.example.org',
userId: '@alice:example.org',
deviceId: 'DEVICE1',
accessToken: 'token-1',
fallbackSdkStores: true,
});
});
test('getFallbackSession returns undefined when nothing is stored', () => {
installStorage();
assert.equal(getFallbackSession(), undefined);
});
test('getFallbackSession returns undefined when a single key is missing', () => {
// Every one of the four keys is required; missing any one yields undefined.
const keys = [
'cinny_access_token',
'cinny_device_id',
'cinny_user_id',
'cinny_hs_base_url',
] as const;
keys.forEach((missing) => {
installStorage();
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
localStorage.removeItem(missing);
assert.equal(getFallbackSession(), undefined, `missing ${missing} should yield undefined`);
});
});
test('removeFallbackSession clears all keys', () => {
const store = installStorage();
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
removeFallbackSession();
assert.equal(store.size, 0);
assert.equal(getFallbackSession(), undefined);
});
+79
View File
@@ -0,0 +1,79 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getSettings } from './settings';
// getSettings() reads localStorage; node has none, so install a controllable
// mock per case. (The module already loaded safely with no localStorage thanks
// to the guarded catch — that path is exercised by the "throws" test below.)
const setStored = (value: string | null): void => {
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => value,
setItem: () => undefined,
removeItem: () => undefined,
};
};
const setThrowingStorage = (): void => {
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => {
throw new Error('storage blocked');
},
setItem: () => {
throw new Error('storage blocked');
},
removeItem: () => {
throw new Error('storage blocked');
},
};
};
test('returns defaults when nothing is stored', () => {
setStored(null);
assert.equal(getSettings().callNoiseSuppression, 'browser');
});
test('migrates the legacy boolean callNoiseSuppression to the 3-way mode', () => {
setStored(JSON.stringify({ callNoiseSuppression: true }));
assert.equal(getSettings().callNoiseSuppression, 'browser');
setStored(JSON.stringify({ callNoiseSuppression: false }));
assert.equal(getSettings().callNoiseSuppression, 'off');
// a new string value passes through untouched
setStored(JSON.stringify({ callNoiseSuppression: 'ml' }));
assert.equal(getSettings().callNoiseSuppression, 'ml');
});
test('coerces unknown persisted denoise model / ringtone id back to defaults', () => {
setStored(null);
const defaults = getSettings();
setStored(JSON.stringify({ callDenoiseModel: 'retired-model', ringtoneId: 'bogus' }));
const coerced = getSettings();
assert.equal(coerced.callDenoiseModel, defaults.callDenoiseModel);
assert.equal(coerced.ringtoneId, defaults.ringtoneId);
setStored(JSON.stringify({ callDenoiseModel: 'rnnoise', ringtoneId: 'chime' }));
const valid = getSettings();
assert.equal(valid.callDenoiseModel, 'rnnoise');
assert.equal(valid.ringtoneId, 'chime');
});
test('merges stored values over defaults', () => {
setStored(JSON.stringify({ callNoiseSuppression: 'off', someUnknownKey: 1 }));
const s = getSettings();
assert.equal(s.callNoiseSuppression, 'off');
// a default field not present in storage is still populated
assert.notEqual(s.callDenoiseModel, undefined);
});
test('returns defaults without throwing when localStorage access throws', () => {
setThrowingStorage();
// regression: the catch used to call localStorage.removeItem(), which re-threw
// and crashed the app at module load when storage was blocked.
assert.doesNotThrow(() => getSettings());
assert.equal(getSettings().callNoiseSuppression, 'browser');
});
test('returns defaults on malformed JSON', () => {
setStored('{ not valid json');
assert.doesNotThrow(() => getSettings());
assert.equal(getSettings().callNoiseSuppression, 'browser');
});
+9 -1
View File
@@ -296,7 +296,15 @@ export const getSettings = (): Settings => {
},
};
} catch {
localStorage.removeItem(STORAGE_KEY);
// We may be here precisely because localStorage access throws (blocked
// storage / private mode / sandboxed context). Removing the key must not be
// allowed to re-throw — getSettings() runs at module load, so an uncaught
// error here would crash the whole app on startup.
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* localStorage unavailable — nothing to clean up */
}
return defaultSettings;
}
};
+124
View File
@@ -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']);
});
+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'],
);
});
+91
View File
@@ -0,0 +1,91 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore } from 'jotai';
import { enableMapSet } from 'immer';
import {
TYPING_TIMEOUT_MS,
roomIdToTypingMembersAtom,
type IRoomIdToTypingMembers,
} from './typingMembers';
// The add/remove/dedup/filter logic lives in the unexported putTypingMember /
// deleteTypingMember reducers, reachable only through `roomIdToTypingMembersAtom`.
// We exercise it via a jotai store (the pure reducer path). The PUT branch also
// schedules a real setTimeout for auto-expiry; that timer-driven cleanup and the
// React `useBindRoomIdToTypingMembersAtom` hook wiring are not covered here.
// The reducers `produce` over an immer-managed Map. The app turns this on once
// at startup (src/index.tsx calls enableMapSet()); we replicate that here.
enableMapSet();
const ROOM = '!room:server';
const A = '@alice:server';
const B = '@bob:server';
const members = (store: ReturnType<typeof createStore>): IRoomIdToTypingMembers =>
store.get(roomIdToTypingMembersAtom);
const userIds = (store: ReturnType<typeof createStore>, roomId: string): string[] =>
(members(store).get(roomId) ?? []).map((r) => r.userId);
test('TYPING_TIMEOUT_MS is 5 seconds', () => {
assert.equal(TYPING_TIMEOUT_MS, 5000);
});
test('starts empty', () => {
const store = createStore();
assert.equal(members(store).size, 0);
});
test('PUT adds a typing member to a room', () => {
const store = createStore();
store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: A, ts: 1 });
assert.deepEqual(userIds(store, ROOM), [A]);
});
test('PUT dedups by userId, keeping the latest ts', () => {
const store = createStore();
store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: A, ts: 1 });
store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: A, ts: 99 });
const receipts = members(store).get(ROOM) ?? [];
assert.equal(receipts.length, 1);
assert.equal(receipts[0].userId, A);
assert.equal(receipts[0].ts, 99);
});
test('PUT keeps distinct users side by side', () => {
const store = createStore();
store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: A, ts: 1 });
store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: B, ts: 1 });
assert.deepEqual(userIds(store, ROOM).sort(), [A, B].sort());
});
test('typing members are scoped per room', () => {
const store = createStore();
store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: '!r1:s', userId: A, ts: 1 });
store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: '!r2:s', userId: B, ts: 1 });
assert.deepEqual(userIds(store, '!r1:s'), [A]);
assert.deepEqual(userIds(store, '!r2:s'), [B]);
});
test('DELETE removes a user and drops the room when it becomes empty', () => {
const store = createStore();
store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: A, ts: 1 });
store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: B, ts: 1 });
store.set(roomIdToTypingMembersAtom, { type: 'DELETE', roomId: ROOM, userId: A });
assert.deepEqual(userIds(store, ROOM), [B]);
store.set(roomIdToTypingMembersAtom, { type: 'DELETE', roomId: ROOM, userId: B });
assert.equal(members(store).has(ROOM), false);
});
test('DELETE of an absent user is a no-op (no empty room created)', () => {
const store = createStore();
store.set(roomIdToTypingMembersAtom, { type: 'DELETE', roomId: ROOM, userId: A });
assert.equal(members(store).has(ROOM), false);
store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: A, ts: 1 });
store.set(roomIdToTypingMembersAtom, { type: 'DELETE', roomId: ROOM, userId: B });
assert.deepEqual(userIds(store, ROOM), [A]);
});
+103
View File
@@ -0,0 +1,103 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore } from 'jotai';
import { UploadResponse, UploadProgress, MatrixError } from 'matrix-js-sdk';
import { createUploadAtom, UploadStatus, Upload } from './upload';
import { TUploadContent } from '../utils/matrix';
// We exercise the pure reducer inside `createUploadAtom` by driving it through a
// plain jotai store. The write atom is a state machine over UploadStatus.
//
// SKIPPED (require a real MatrixClient and/or React render, not pure logic):
// - useBindUploadAtom (React hook; calls mx.cancelUpload, uploadContent)
// - createUploadAtomFamily / createUploadFamilyObserverAtom (thin atomFamily
// wrappers whose behavior is just createUploadAtom + jotai plumbing)
const makeFile = (size = 100): TUploadContent => ({ size }) as unknown as TUploadContent;
test('createUploadAtom starts in the Idle state holding the file', () => {
const store = createStore();
const file = makeFile();
const uploadAtom = createUploadAtom(file);
const state = store.get(uploadAtom);
assert.equal(state.status, UploadStatus.Idle);
assert.equal(state.file, file);
});
test('a promise update transitions Idle -> Loading with zeroed progress', () => {
const store = createStore();
const file = makeFile(2048);
const uploadAtom = createUploadAtom(file);
const promise = Promise.resolve({} as UploadResponse);
store.set(uploadAtom, { promise });
const state = store.get(uploadAtom);
assert.equal(state.status, UploadStatus.Loading);
if (state.status === UploadStatus.Loading) {
assert.equal(state.promise, promise);
assert.deepEqual(state.progress, { loaded: 0, total: 2048 });
}
});
test('a progress update is applied only while Loading', () => {
const store = createStore();
const uploadAtom = createUploadAtom(makeFile(2048));
const progress: UploadProgress = { loaded: 512, total: 2048 };
// Ignored while Idle (not Loading).
store.set(uploadAtom, { progress });
assert.equal(store.get(uploadAtom).status, UploadStatus.Idle);
// Enter Loading, then progress sticks.
store.set(uploadAtom, { promise: Promise.resolve({} as UploadResponse) });
store.set(uploadAtom, { progress });
const state = store.get(uploadAtom);
assert.equal(state.status, UploadStatus.Loading);
if (state.status === UploadStatus.Loading) {
assert.deepEqual(state.progress, progress);
}
});
test('an mxc update transitions to Success', () => {
const store = createStore();
const uploadAtom = createUploadAtom(makeFile());
store.set(uploadAtom, { mxc: 'mxc://example.org/abc' });
const state = store.get(uploadAtom);
assert.equal(state.status, UploadStatus.Success);
if (state.status === UploadStatus.Success) {
assert.equal(state.mxc, 'mxc://example.org/abc');
}
});
test('an error update transitions to Error', () => {
const store = createStore();
const uploadAtom = createUploadAtom(makeFile());
const error = new Error('boom') as unknown as MatrixError;
store.set(uploadAtom, { error });
const state = store.get(uploadAtom);
assert.equal(state.status, UploadStatus.Error);
if (state.status === UploadStatus.Error) {
assert.equal(state.error, error);
}
});
test('the file reference is preserved across transitions', () => {
const store = createStore();
const file = makeFile();
const uploadAtom = createUploadAtom(file);
const seenFiles: TUploadContent[] = [];
const record = (s: Upload) => seenFiles.push(s.file);
store.set(uploadAtom, { promise: Promise.resolve({} as UploadResponse) });
record(store.get(uploadAtom));
store.set(uploadAtom, { mxc: 'mxc://example.org/x' });
record(store.get(uploadAtom));
assert.ok(seenFiles.every((f) => f === file));
});
@@ -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 });
});
+63
View File
@@ -0,0 +1,63 @@
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { checkIndexedDBSupport } from './featureCheck';
// `checkIndexedDBSupport` resolves by talking to the global `indexedDB`. There
// is no real IndexedDB in this Node test environment, so each case installs a
// minimal stub on `globalThis.indexedDB` and restores it afterwards.
type OpenRequest = {
onsuccess?: () => void;
onerror?: () => void;
};
const originalIndexedDB = (globalThis as { indexedDB?: unknown }).indexedDB;
afterEach(() => {
(globalThis as { indexedDB?: unknown }).indexedDB = originalIndexedDB;
});
const installIndexedDB = (impl: unknown) => {
(globalThis as { indexedDB?: unknown }).indexedDB = impl;
};
test('resolves true when the open request fires onsuccess', async () => {
let deleted = false;
installIndexedDB({
open: (): OpenRequest => {
const req: OpenRequest = {};
// fire async, mimicking the real event loop
queueMicrotask(() => req.onsuccess?.());
return req;
},
deleteDatabase: () => {
deleted = true;
},
});
assert.equal(await checkIndexedDBSupport(), true);
assert.equal(deleted, true);
});
test('resolves false when the open request fires onerror', async () => {
installIndexedDB({
open: (): OpenRequest => {
const req: OpenRequest = {};
queueMicrotask(() => req.onerror?.());
return req;
},
deleteDatabase: () => {},
});
assert.equal(await checkIndexedDBSupport(), false);
});
test('resolves false when indexedDB.open throws synchronously', async () => {
installIndexedDB({
open: () => {
throw new Error('blocked');
},
deleteDatabase: () => {},
});
assert.equal(await checkIndexedDBSupport(), false);
});
+117
View File
@@ -0,0 +1,117 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { onTabPress, preventScrollWithArrowKey, onEnterOrSpace, stopPropagation } from './keyboard';
type Evt = {
key: string;
which: number;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
prevented: boolean;
preventDefault(): void;
};
const evt = (key: string, which: number): Evt => ({
key,
which,
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
prevented: false,
preventDefault() {
this.prevented = true;
},
});
test('onTabPress fires only on Tab', () => {
let called = 0;
const tab = evt('Tab', 9);
onTabPress(tab, () => {
called += 1;
});
assert.equal(called, 1);
assert.equal(tab.prevented, true);
const a = evt('a', 65);
onTabPress(a, () => {
called += 1;
});
assert.equal(called, 1); // unchanged
assert.equal(a.prevented, false);
});
test('preventScrollWithArrowKey prevents default only on arrows', () => {
const up = evt('ArrowUp', 38);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
preventScrollWithArrowKey(up as any);
assert.equal(up.prevented, true);
const a = evt('a', 65);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
preventScrollWithArrowKey(a as any);
assert.equal(a.prevented, false);
});
test('onEnterOrSpace fires on Enter or Space, not others', () => {
let count = 0;
const handler = onEnterOrSpace(() => {
count += 1;
});
const enter = evt('Enter', 13);
handler(enter);
assert.equal(count, 1);
assert.equal(enter.prevented, true);
const space = evt(' ', 32);
handler(space);
assert.equal(count, 2);
assert.equal(space.prevented, true);
const other = evt('a', 65);
handler(other);
assert.equal(count, 2); // unchanged
assert.equal(other.prevented, false);
});
test('stopPropagation: stops unless an editable element is focused', () => {
const makeKeyEvt = () => {
let stopped = false;
return {
ev: {
stopPropagation() {
stopped = true;
},
},
wasStopped: () => stopped,
};
};
const withActive = (activeElement: unknown) => {
(globalThis as { document?: unknown }).document = { activeElement };
};
// nothing focused → stops, returns true
withActive(null);
let k = makeKeyEvt();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal(stopPropagation(k.ev as any), true);
assert.equal(k.wasStopped(), true);
// input focused → does not stop, returns false
withActive({ nodeName: 'INPUT', getAttribute: () => null });
k = makeKeyEvt();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal(stopPropagation(k.ev as any), false);
assert.equal(k.wasStopped(), false);
// contenteditable focused → does not stop
withActive({
nodeName: 'DIV',
getAttribute: (a: string) => (a === 'contenteditable' ? 'true' : null),
});
k = makeKeyEvt();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal(stopPropagation(k.ev as any), false);
});
+42
View File
@@ -0,0 +1,42 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
import { verifiedDevice } from './matrix-crypto';
// `verifiedDevice` only touches `api.getDeviceVerificationStatus`, so a tiny
// stub standing in for the real CryptoApi is enough to exercise the pure logic.
// Anything requiring an actual crypto backend (key import, cross-signing setup,
// etc.) is out of scope here and intentionally not covered.
const cryptoApi = (status: unknown): CryptoApi =>
({
getDeviceVerificationStatus: async () => status,
}) as unknown as CryptoApi;
test('verifiedDevice returns null when there is no verification status', async () => {
assert.equal(await verifiedDevice(cryptoApi(null), '@a:b', 'DEV'), null);
assert.equal(await verifiedDevice(cryptoApi(undefined), '@a:b', 'DEV'), null);
});
test('verifiedDevice surfaces crossSigningVerified when status exists', async () => {
assert.equal(
await verifiedDevice(cryptoApi({ crossSigningVerified: true }), '@a:b', 'DEV'),
true,
);
assert.equal(
await verifiedDevice(cryptoApi({ crossSigningVerified: false }), '@a:b', 'DEV'),
false,
);
});
test('verifiedDevice forwards userId and deviceId to the crypto api', async () => {
let received: [string, string] | undefined;
const api = {
getDeviceVerificationStatus: async (userId: string, deviceId: string) => {
received = [userId, deviceId];
return { crossSigningVerified: true };
},
} as unknown as CryptoApi;
await verifiedDevice(api, '@alice:example.org', 'ABCDEF');
assert.deepEqual(received, ['@alice:example.org', 'ABCDEF']);
});
+582
View File
@@ -0,0 +1,582 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
EventTimeline,
JoinRule,
MatrixEvent,
NotificationCountType,
Room,
RoomMember,
} from 'matrix-js-sdk';
import {
getStateEvent,
getStateEvents,
getMDirects,
isDirectInvite,
isSpace,
isRoom,
isUnsupportedRoom,
isValidChild,
getAllParents,
getSpaceChildren,
mapParentWithChildren,
getOrphanParents,
isMutedRule,
findMutedRule,
isNotificationEvent,
roomHaveNotification,
getUnreadInfo,
getRoomIconSrc,
trimReplyFromBody,
trimReplyFromFormattedBody,
parseReplyBody,
parseReplyFormattedBody,
getMemberDisplayName,
getMemberSearchStr,
getMemberAvatarMxc,
isMembershipChanged,
getReactionContent,
getLatestEdit,
reactionOrEditEvent,
getMentionContent,
getAllVersionsRoomCreator,
} from './room';
import { RoomType, StateEvent, RoomToParents } from '../../types/matrix/room';
// --- Mock helpers ---------------------------------------------------------
const mockEvent = (overrides: Partial<Record<string, unknown>> = {}): MatrixEvent => {
const base: Record<string, () => unknown> = {
getType: () => undefined,
getContent: () => ({}),
getPrevContent: () => ({}),
getStateKey: () => '',
getSender: () => undefined,
getTs: () => 0,
getId: () => undefined,
getRelation: () => null,
isRedacted: () => false,
};
return { ...base, ...overrides } as unknown as MatrixEvent;
};
// Build a Room whose live-timeline forward state returns the given state events.
const mockRoomWithState = (
stateEventsByType: Record<string, MatrixEvent[]>,
rest: Record<string, unknown> = {},
): Room => {
const state = {
getStateEvents: (eventType: string, stateKey?: string) => {
const events = stateEventsByType[eventType] ?? [];
if (stateKey !== undefined) {
return events[0]; // single-event form
}
return events; // array form
},
};
return {
getLiveTimeline: () => ({
getState: () => state,
}),
...rest,
} as unknown as Room;
};
// --- getStateEvent / getStateEvents --------------------------------------
test('getStateEvent returns the matching state event or undefined', () => {
const ev = mockEvent({ getType: () => StateEvent.RoomName });
const room = mockRoomWithState({ [StateEvent.RoomName]: [ev] });
assert.equal(getStateEvent(room, StateEvent.RoomName), ev);
const empty = mockRoomWithState({});
assert.equal(getStateEvent(empty, StateEvent.RoomName), undefined);
});
test('getStateEvent returns undefined when there is no forward state', () => {
const room = {
getLiveTimeline: () => ({ getState: () => undefined }),
} as unknown as Room;
assert.equal(getStateEvent(room, StateEvent.RoomName), undefined);
});
test('getStateEvents returns an array, empty when none', () => {
const ev = mockEvent();
const room = mockRoomWithState({ [StateEvent.SpaceChild]: [ev] });
assert.deepEqual(getStateEvents(room, StateEvent.SpaceChild), [ev]);
const empty = mockRoomWithState({});
assert.deepEqual(getStateEvents(empty, StateEvent.SpaceChild), []);
});
// --- getMDirects ----------------------------------------------------------
test('getMDirects collects all direct room ids across users', () => {
const ev = mockEvent({
getContent: () => ({
'@a:x': ['!room1', '!room2'],
'@b:x': ['!room3'],
}),
});
const result = getMDirects(ev);
assert.deepEqual([...result].sort(), ['!room1', '!room2', '!room3']);
});
test('getMDirects ignores non-array and non-string entries', () => {
const ev = mockEvent({
getContent: () => ({
'@a:x': 'not-an-array',
'@b:x': ['!ok', 42, null],
}),
});
const result = getMDirects(ev);
assert.deepEqual([...result], ['!ok']);
});
test('getMDirects returns empty set when content is undefined', () => {
const ev = mockEvent({ getContent: () => undefined });
assert.equal(getMDirects(ev).size, 0);
});
// --- isDirectInvite -------------------------------------------------------
test('isDirectInvite returns false for null room or null user', () => {
assert.equal(isDirectInvite(null, '@me:x'), false);
assert.equal(isDirectInvite({} as unknown as Room, null), false);
});
test('isDirectInvite reads is_direct from the member event content', () => {
const room = {
getMember: () => ({
events: { member: { getContent: () => ({ is_direct: true }) } },
}),
} as unknown as Room;
assert.equal(isDirectInvite(room, '@me:x'), true);
const roomNotDirect = {
getMember: () => ({
events: { member: { getContent: () => ({}) } },
}),
} as unknown as Room;
assert.equal(isDirectInvite(roomNotDirect, '@me:x'), false);
});
test('isDirectInvite returns false when member is missing', () => {
const room = { getMember: () => null } as unknown as Room;
assert.equal(isDirectInvite(room, '@me:x'), false);
});
// --- isSpace / isRoom / isUnsupportedRoom --------------------------------
test('isSpace detects space rooms via create event type', () => {
const spaceEv = mockEvent({ getContent: () => ({ type: RoomType.Space }) });
assert.equal(isSpace(mockRoomWithState({ [StateEvent.RoomCreate]: [spaceEv] })), true);
const normalEv = mockEvent({ getContent: () => ({}) });
assert.equal(isSpace(mockRoomWithState({ [StateEvent.RoomCreate]: [normalEv] })), false);
assert.equal(isSpace(null), false);
assert.equal(isSpace(mockRoomWithState({})), false); // no create event
});
test('isRoom is true for normal rooms and rooms with no create event', () => {
const spaceEv = mockEvent({ getContent: () => ({ type: RoomType.Space }) });
assert.equal(isRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [spaceEv] })), false);
const normalEv = mockEvent({ getContent: () => ({}) });
assert.equal(isRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [normalEv] })), true);
assert.equal(isRoom(mockRoomWithState({})), true); // no create event -> treated as room
assert.equal(isRoom(null), false);
});
test('isUnsupportedRoom is true when type is set and not a space', () => {
const callEv = mockEvent({ getContent: () => ({ type: RoomType.Call }) });
assert.equal(isUnsupportedRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [callEv] })), true);
const spaceEv = mockEvent({ getContent: () => ({ type: RoomType.Space }) });
assert.equal(isUnsupportedRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [spaceEv] })), false);
const normalEv = mockEvent({ getContent: () => ({}) });
assert.equal(
isUnsupportedRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [normalEv] })),
false,
);
assert.equal(isUnsupportedRoom(mockRoomWithState({})), true); // missing create event
assert.equal(isUnsupportedRoom(null), false);
});
// --- isValidChild / getSpaceChildren -------------------------------------
test('isValidChild requires SpaceChild type with via array', () => {
const valid = mockEvent({
getType: () => StateEvent.SpaceChild,
getContent: () => ({ via: ['server'] }),
});
assert.equal(isValidChild(valid), true);
const noVia = mockEvent({
getType: () => StateEvent.SpaceChild,
getContent: () => ({}),
});
assert.equal(isValidChild(noVia), false);
const wrongType = mockEvent({
getType: () => StateEvent.RoomName,
getContent: () => ({ via: ['server'] }),
});
assert.equal(isValidChild(wrongType), false);
});
test('getSpaceChildren returns state keys of valid child events', () => {
const valid = mockEvent({
getType: () => StateEvent.SpaceChild,
getStateKey: () => '!child:x',
getContent: () => ({ via: ['s'] }),
});
const invalid = mockEvent({
getType: () => StateEvent.SpaceChild,
getStateKey: () => '!bad:x',
getContent: () => ({}),
});
const noKey = mockEvent({
getType: () => StateEvent.SpaceChild,
getStateKey: () => '',
getContent: () => ({ via: ['s'] }),
});
const room = mockRoomWithState({
[StateEvent.SpaceChild]: [valid, invalid, noKey],
});
assert.deepEqual(getSpaceChildren(room), ['!child:x']);
});
// --- getAllParents / mapParentWithChildren / getOrphanParents ------------
test('getAllParents walks the parent graph excluding the start room', () => {
const map: RoomToParents = new Map([
['child', new Set(['mid'])],
['mid', new Set(['root'])],
]);
assert.deepEqual([...getAllParents(map, 'child')].sort(), ['mid', 'root']);
assert.deepEqual([...getAllParents(map, 'root')], []);
});
test('getAllParents handles cycles without infinite recursion', () => {
const map: RoomToParents = new Map([
['a', new Set(['b'])],
['b', new Set(['a'])],
]);
assert.deepEqual([...getAllParents(map, 'a')], ['b']);
});
test('mapParentWithChildren registers parent for each child', () => {
const map: RoomToParents = new Map();
mapParentWithChildren(map, 'space1', ['c1', 'c2']);
assert.deepEqual([...(map.get('c1') ?? [])], ['space1']);
assert.deepEqual([...(map.get('c2') ?? [])], ['space1']);
});
test('mapParentWithChildren skips children that would create a cycle', () => {
const map: RoomToParents = new Map([['space1', new Set(['ancestor'])]]);
mapParentWithChildren(map, 'space1', ['ancestor', 'newchild']);
// ancestor is an existing parent -> cycle -> skipped
assert.equal(map.has('ancestor'), false);
assert.deepEqual([...(map.get('newchild') ?? [])], ['space1']);
});
test('getOrphanParents returns parents not present as keys in the map', () => {
const map: RoomToParents = new Map([
['child', new Set(['mid'])],
['mid', new Set(['root'])],
]);
// root is a parent but has no entry of its own -> orphan
assert.deepEqual(getOrphanParents(map, 'child'), ['root']);
});
// --- isMutedRule / findMutedRule -----------------------------------------
test('isMutedRule matches empty actions or dont_notify with event_match', () => {
assert.equal(
isMutedRule({
actions: [],
conditions: [{ kind: 'event_match' }],
} as never),
true,
);
assert.equal(
isMutedRule({
actions: ['dont_notify'],
conditions: [{ kind: 'event_match' }],
} as never),
true,
);
assert.equal(
isMutedRule({
actions: ['notify'],
conditions: [{ kind: 'event_match' }],
} as never),
false,
);
assert.ok(
!isMutedRule({
actions: [],
conditions: [{ kind: 'other' }],
} as never),
);
});
test('findMutedRule finds rule by id that is also muted', () => {
const rules = [
{ rule_id: '!a', actions: [], conditions: [{ kind: 'event_match' }] },
{ rule_id: '!b', actions: ['notify'], conditions: [{ kind: 'event_match' }] },
] as never[];
assert.equal(findMutedRule(rules, '!a'), rules[0]);
assert.equal(findMutedRule(rules, '!b'), undefined);
assert.equal(findMutedRule(rules, '!missing'), undefined);
});
// --- isNotificationEvent --------------------------------------------------
test('isNotificationEvent accepts message/sticker but rejects member, redacted, edits', () => {
assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.room.message' })), true);
assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.sticker' })), true);
assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.room.member' })), false);
assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.room.topic' })), false);
assert.equal(
isNotificationEvent(mockEvent({ getType: () => 'm.room.message', isRedacted: () => true })),
false,
);
assert.equal(
isNotificationEvent(
mockEvent({
getType: () => 'm.room.message',
getRelation: () => ({ rel_type: 'm.replace' }),
}),
),
false,
);
});
// --- roomHaveNotification / getUnreadInfo --------------------------------
test('roomHaveNotification true when total or highlight count > 0', () => {
const make = (total: number, highlight: number) =>
({
getUnreadNotificationCount: (type: NotificationCountType) =>
type === NotificationCountType.Total ? total : highlight,
}) as unknown as Room;
assert.equal(roomHaveNotification(make(0, 0)), false);
assert.equal(roomHaveNotification(make(3, 0)), true);
assert.equal(roomHaveNotification(make(0, 1)), true);
});
test('getUnreadInfo uses highlight when it exceeds total', () => {
const room = {
roomId: '!r:x',
getUnreadNotificationCount: (type: NotificationCountType) =>
type === NotificationCountType.Total ? 2 : 5,
} as unknown as Room;
assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 5, total: 5 });
const room2 = {
roomId: '!r:y',
getUnreadNotificationCount: (type: NotificationCountType) =>
type === NotificationCountType.Total ? 7 : 1,
} as unknown as Room;
assert.deepEqual(getUnreadInfo(room2), { roomId: '!r:y', highlight: 1, total: 7 });
});
// --- getRoomIconSrc -------------------------------------------------------
test('getRoomIconSrc selects icon by room type and join rule', () => {
const icons = {
Warning: 'warning',
SpaceGlobe: 'space-globe',
SpaceLock: 'space-lock',
Space: 'space',
VolumeHighGlobe: 'vol-globe',
VolumeHighLock: 'vol-lock',
VolumeHigh: 'vol',
HashGlobe: 'hash-globe',
HashLock: 'hash-lock',
Hash: 'hash',
} as never;
assert.equal(getRoomIconSrc(icons, 'm.server_notice'), 'warning');
assert.equal(getRoomIconSrc(icons, RoomType.Space, JoinRule.Public), 'space-globe');
assert.equal(getRoomIconSrc(icons, RoomType.Space, JoinRule.Invite), 'space-lock');
assert.equal(getRoomIconSrc(icons, RoomType.Space), 'space');
assert.equal(getRoomIconSrc(icons, RoomType.Call, JoinRule.Public), 'vol-globe');
assert.equal(getRoomIconSrc(icons, RoomType.Call, JoinRule.Knock), 'vol-lock');
assert.equal(getRoomIconSrc(icons, RoomType.Call), 'vol');
assert.equal(getRoomIconSrc(icons, undefined, JoinRule.Public), 'hash-globe');
assert.equal(getRoomIconSrc(icons, undefined, JoinRule.Invite), 'hash-lock');
assert.equal(getRoomIconSrc(icons), 'hash');
});
// --- reply parsing helpers -----------------------------------------------
test('trimReplyFromBody strips a matrix reply fallback prefix', () => {
const body = '> <@a:x> hello\n> more\n\nactual message';
assert.equal(trimReplyFromBody(body), 'actual message');
// no fallback -> unchanged
assert.equal(trimReplyFromBody('plain text'), 'plain text');
});
test('trimReplyFromFormattedBody strips up to and including mx-reply', () => {
const fb = '<mx-reply><blockquote>x</blockquote></mx-reply>real';
assert.equal(trimReplyFromFormattedBody(fb), 'real');
assert.equal(trimReplyFromFormattedBody('no reply here'), 'no reply here');
});
test('parseReplyBody builds a quoted fallback with prefixed newlines', () => {
assert.equal(parseReplyBody('@a:x', 'line1\nline2'), '> <@a:x> line1\n> line2\n\n');
});
test('parseReplyFormattedBody builds an mx-reply block with encoded links', () => {
const result = parseReplyFormattedBody('!r:x', '@a:x', '$e:x', 'hi');
assert.ok(result.startsWith('<mx-reply><blockquote>'));
assert.ok(result.endsWith('hi</blockquote></mx-reply>'));
assert.ok(result.includes(encodeURIComponent('!r:x')));
assert.ok(result.includes(encodeURIComponent('$e:x')));
assert.ok(result.includes(encodeURIComponent('@a:x')));
});
// --- member helpers -------------------------------------------------------
test('getMemberDisplayName returns name unless it equals the userId', () => {
const room = {
getMember: (id: string) =>
id === '@named:x' ? { rawDisplayName: 'Alice' } : { rawDisplayName: '@plain:x' },
} as unknown as Room;
assert.equal(getMemberDisplayName(room, '@named:x'), 'Alice');
assert.equal(getMemberDisplayName(room, '@plain:x'), undefined);
const noMember = { getMember: () => null } as unknown as Room;
assert.equal(getMemberDisplayName(noMember, '@x:x'), undefined);
});
test('getMemberSearchStr produces display and id variants', () => {
const member = {
rawDisplayName: 'Alice',
userId: '@alice:x',
} as unknown as RoomMember;
const mxIdToName = (id: string) => `name(${id})`;
// query without @ or : -> uses mapped name for second entry
assert.deepEqual(getMemberSearchStr(member, 'al', mxIdToName), ['Alice', 'name(@alice:x)']);
// query with @ -> uses raw userId for second entry
assert.deepEqual(getMemberSearchStr(member, '@al', mxIdToName), ['Alice', '@alice:x']);
// when display name equals userId, first entry falls back to mapped name
const unnamed = { rawDisplayName: '@bob:x', userId: '@bob:x' } as unknown as RoomMember;
assert.deepEqual(getMemberSearchStr(unnamed, 'bob', mxIdToName), [
'name(@bob:x)',
'name(@bob:x)',
]);
});
test('getMemberAvatarMxc returns member mxc or undefined', () => {
const room = {
getMember: (id: string) => (id === '@a:x' ? { getMxcAvatarUrl: () => 'mxc://a' } : null),
} as unknown as Room;
assert.equal(getMemberAvatarMxc(room, '@a:x'), 'mxc://a');
assert.equal(getMemberAvatarMxc(room, '@b:x'), undefined);
});
// --- isMembershipChanged --------------------------------------------------
test('isMembershipChanged compares membership and reason against prev content', () => {
const changedMembership = mockEvent({
getContent: () => ({ membership: 'join' }),
getPrevContent: () => ({ membership: 'invite' }),
});
assert.equal(isMembershipChanged(changedMembership), true);
const changedReason = mockEvent({
getContent: () => ({ membership: 'leave', reason: 'spam' }),
getPrevContent: () => ({ membership: 'leave' }),
});
assert.equal(isMembershipChanged(changedReason), true);
const unchanged = mockEvent({
getContent: () => ({ membership: 'join', reason: 'x' }),
getPrevContent: () => ({ membership: 'join', reason: 'x' }),
});
assert.equal(isMembershipChanged(unchanged), false);
});
// --- getReactionContent / getMentionContent ------------------------------
test('getReactionContent builds an annotation relation', () => {
assert.deepEqual(getReactionContent('$e:x', '👍', 'thumbsup'), {
'm.relates_to': {
event_id: '$e:x',
key: '👍',
rel_type: 'm.annotation',
},
shortcode: 'thumbsup',
});
});
test('getMentionContent includes user_ids and room only when relevant', () => {
assert.deepEqual(getMentionContent(['@a:x'], true), {
user_ids: ['@a:x'],
room: true,
});
assert.deepEqual(getMentionContent([], false), {});
assert.deepEqual(getMentionContent(['@a:x'], false), { user_ids: ['@a:x'] });
assert.deepEqual(getMentionContent([], true), { room: true });
});
// --- getLatestEdit / reactionOrEditEvent ---------------------------------
test('getLatestEdit returns newest edit from the same sender', () => {
const target = mockEvent({ getSender: () => '@a:x' });
const oldEdit = mockEvent({ getSender: () => '@a:x', getTs: () => 100 });
const newEdit = mockEvent({ getSender: () => '@a:x', getTs: () => 200 });
const otherSender = mockEvent({ getSender: () => '@b:x', getTs: () => 300 });
assert.equal(getLatestEdit(target, [oldEdit, newEdit, otherSender]), newEdit);
assert.equal(getLatestEdit(target, [otherSender]), undefined);
});
test('reactionOrEditEvent detects annotation and replace relations', () => {
assert.equal(
reactionOrEditEvent(mockEvent({ getRelation: () => ({ rel_type: 'm.annotation' }) })),
true,
);
assert.equal(
reactionOrEditEvent(mockEvent({ getRelation: () => ({ rel_type: 'm.replace' }) })),
true,
);
assert.equal(
reactionOrEditEvent(mockEvent({ getRelation: () => ({ rel_type: 'm.thread' }) })),
false,
);
assert.equal(reactionOrEditEvent(mockEvent({ getRelation: () => null })), false);
});
// --- getAllVersionsRoomCreator -------------------------------------------
test('getAllVersionsRoomCreator collects sender and additional_creators', () => {
const createEv = mockEvent({
getSender: () => '@creator:x',
getContent: () => ({ additional_creators: ['@co1:x', '@co2:x', 5] }),
});
const room = mockRoomWithState({ [StateEvent.RoomCreate]: [createEv] });
const creators = getAllVersionsRoomCreator(room);
assert.deepEqual([...creators].sort(), ['@co1:x', '@co2:x', '@creator:x']);
});
test('getAllVersionsRoomCreator returns empty set when no create event', () => {
const room = mockRoomWithState({});
assert.equal(getAllVersionsRoomCreator(room).size, 0);
});
// Reference EventTimeline so the import is used even though state mocks bypass it.
test('EventTimeline.FORWARDS constant is available', () => {
assert.equal(typeof EventTimeline.FORWARDS, 'string');
});