Compare commits
10 Commits
4d55e45962
...
24662fa994
| Author | SHA1 | Date | |
|---|---|---|---|
| 24662fa994 | |||
| 230ef8ed7c | |||
| 160c09e525 | |||
| 589d45e0a0 | |||
| acd355bb5a | |||
| 6e59395fb8 | |||
| 9f4516c6a8 | |||
| 0bd2273bee | |||
| d37fa1584c | |||
| e17cb09269 |
+1
-1
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MsgType } from 'matrix-js-sdk';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { getAudioMsgContent, getFileMsgContent } from './msgContent';
|
||||
import { TUploadItem } from '../../state/room/roomInputDrafts';
|
||||
|
||||
// Pure builders getAudioMsgContent / getFileMsgContent: msgtype/body/filename/info
|
||||
// shape plus the encInfo branch (content.file w/ url vs plain content.url). The
|
||||
// image/video builders need a DOM + MatrixClient and are not covered here.
|
||||
|
||||
const MXC = 'mxc://example.org/abc';
|
||||
|
||||
const makeItem = (encInfo?: EncryptedAttachmentInfo): TUploadItem => {
|
||||
const file = { name: 'sound.ogg', type: 'audio/ogg', size: 4096 };
|
||||
return {
|
||||
file,
|
||||
originalFile: file,
|
||||
metadata: { markedAsSpoiler: false },
|
||||
encInfo,
|
||||
} as unknown as TUploadItem;
|
||||
};
|
||||
|
||||
const fakeEncInfo = (): EncryptedAttachmentInfo =>
|
||||
({
|
||||
v: 'v2',
|
||||
key: { alg: 'A256CTR' },
|
||||
iv: 'iv',
|
||||
hashes: { sha256: 'h' },
|
||||
}) as unknown as EncryptedAttachmentInfo;
|
||||
|
||||
test('getAudioMsgContent builds an unencrypted audio message', () => {
|
||||
const content = getAudioMsgContent(makeItem(), MXC);
|
||||
assert.equal(content.msgtype, MsgType.Audio);
|
||||
assert.equal(content.body, 'sound.ogg');
|
||||
assert.equal(content.filename, 'sound.ogg');
|
||||
assert.deepEqual(content.info, { mimetype: 'audio/ogg', size: 4096 });
|
||||
assert.equal(content.url, MXC);
|
||||
assert.equal(content.file, undefined);
|
||||
});
|
||||
|
||||
test('getAudioMsgContent uses content.file with url when encInfo is present', () => {
|
||||
const enc = fakeEncInfo();
|
||||
const content = getAudioMsgContent(makeItem(enc), MXC);
|
||||
assert.equal(content.url, undefined);
|
||||
assert.deepEqual(content.file, { ...enc, url: MXC });
|
||||
});
|
||||
|
||||
test('getFileMsgContent builds an unencrypted file message', () => {
|
||||
const content = getFileMsgContent(makeItem(), MXC);
|
||||
assert.equal(content.msgtype, MsgType.File);
|
||||
assert.equal(content.body, 'sound.ogg');
|
||||
assert.equal(content.filename, 'sound.ogg');
|
||||
assert.deepEqual(content.info, { mimetype: 'audio/ogg', size: 4096 });
|
||||
assert.equal(content.url, MXC);
|
||||
assert.equal(content.file, undefined);
|
||||
});
|
||||
|
||||
test('getFileMsgContent uses content.file with url when encInfo is present', () => {
|
||||
const enc = fakeEncInfo();
|
||||
const content = getFileMsgContent(makeItem(enc), MXC);
|
||||
assert.equal(content.url, undefined);
|
||||
assert.deepEqual(content.file, { ...enc, url: MXC });
|
||||
});
|
||||
|
||||
test('info mirrors the file mimetype and size', () => {
|
||||
const item = {
|
||||
file: { name: 'doc.pdf', type: 'application/pdf', size: 12 },
|
||||
originalFile: { name: 'doc.pdf', type: 'application/pdf', size: 12 },
|
||||
metadata: { markedAsSpoiler: false },
|
||||
encInfo: undefined,
|
||||
} as unknown as TUploadItem;
|
||||
const content = getFileMsgContent(item, MXC);
|
||||
assert.deepEqual(content.info, { mimetype: 'application/pdf', size: 12 });
|
||||
assert.equal(content.body, 'doc.pdf');
|
||||
});
|
||||
@@ -0,0 +1,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'],
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import { ImportRoomKeyProgressData, ImportRoomKeyStage } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import { backupRestoreProgressAtom, BackupProgressStatus } from './backupRestore';
|
||||
|
||||
// backupRestoreProgressAtom maps ImportRoomKeyProgressData stages onto an
|
||||
// IBackupProgress status held in an unexported baseAtom:
|
||||
// Fetch -> Fetching
|
||||
// LoadKeys with successes+failures === total -> Done
|
||||
// LoadKeys otherwise -> Loading{ downloaded = successes+failures }
|
||||
// We drive the write atom through a jotai store and read back the getter.
|
||||
|
||||
const load = (successes: number, failures: number, total: number): ImportRoomKeyProgressData => ({
|
||||
stage: ImportRoomKeyStage.LoadKeys,
|
||||
successes,
|
||||
failures,
|
||||
total,
|
||||
});
|
||||
|
||||
test('starts Idle', () => {
|
||||
const store = createStore();
|
||||
assert.deepEqual(store.get(backupRestoreProgressAtom), {
|
||||
status: BackupProgressStatus.Idle,
|
||||
});
|
||||
});
|
||||
|
||||
test('Fetch stage -> Fetching', () => {
|
||||
const store = createStore();
|
||||
store.set(backupRestoreProgressAtom, { stage: ImportRoomKeyStage.Fetch });
|
||||
assert.deepEqual(store.get(backupRestoreProgressAtom), {
|
||||
status: BackupProgressStatus.Fetching,
|
||||
});
|
||||
});
|
||||
|
||||
test('LoadKeys with downloaded === total -> Done', () => {
|
||||
const store = createStore();
|
||||
store.set(backupRestoreProgressAtom, load(8, 2, 10));
|
||||
assert.deepEqual(store.get(backupRestoreProgressAtom), {
|
||||
status: BackupProgressStatus.Done,
|
||||
});
|
||||
});
|
||||
|
||||
test('LoadKeys mid-progress -> Loading with downloaded = successes + failures', () => {
|
||||
const store = createStore();
|
||||
store.set(backupRestoreProgressAtom, load(3, 1, 10));
|
||||
assert.deepEqual(store.get(backupRestoreProgressAtom), {
|
||||
status: BackupProgressStatus.Loading,
|
||||
data: { downloaded: 4, successes: 3, failures: 1, total: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
test('LoadKeys at zero progress -> Loading with downloaded 0', () => {
|
||||
const store = createStore();
|
||||
store.set(backupRestoreProgressAtom, load(0, 0, 5));
|
||||
assert.deepEqual(store.get(backupRestoreProgressAtom), {
|
||||
status: BackupProgressStatus.Loading,
|
||||
data: { downloaded: 0, successes: 0, failures: 0, total: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
test('Fetch then LoadKeys completion transitions Fetching -> Done', () => {
|
||||
const store = createStore();
|
||||
store.set(backupRestoreProgressAtom, { stage: ImportRoomKeyStage.Fetch });
|
||||
assert.equal(store.get(backupRestoreProgressAtom).status, BackupProgressStatus.Fetching);
|
||||
store.set(backupRestoreProgressAtom, load(5, 0, 5));
|
||||
assert.equal(store.get(backupRestoreProgressAtom).status, BackupProgressStatus.Done);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import { callEmbedAtom } from './callEmbed';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
|
||||
// callEmbedAtom holds the current CallEmbed in an unexported baseAtom. The setter:
|
||||
// - identity no-op guard: setting the same reference does nothing
|
||||
// - when replacing a previous embed, calls prevCallEmbed.dispose()
|
||||
// We substitute a stub embed exposing a dispose() spy (the real CallEmbed needs a
|
||||
// MatrixClient + DOM). `as unknown as CallEmbed` keeps eslint/prettier quiet.
|
||||
|
||||
type StubEmbed = { dispose: () => void; disposed: number };
|
||||
|
||||
const makeEmbed = (): StubEmbed => {
|
||||
const stub: StubEmbed = {
|
||||
disposed: 0,
|
||||
dispose() {
|
||||
stub.disposed += 1;
|
||||
},
|
||||
};
|
||||
return stub;
|
||||
};
|
||||
|
||||
const asEmbed = (e: StubEmbed): CallEmbed => e as unknown as CallEmbed;
|
||||
|
||||
test('starts undefined', () => {
|
||||
const store = createStore();
|
||||
assert.equal(store.get(callEmbedAtom), undefined);
|
||||
});
|
||||
|
||||
test('sets a call embed', () => {
|
||||
const store = createStore();
|
||||
const embed = makeEmbed();
|
||||
store.set(callEmbedAtom, asEmbed(embed));
|
||||
assert.equal(store.get(callEmbedAtom), asEmbed(embed));
|
||||
assert.equal(embed.disposed, 0);
|
||||
});
|
||||
|
||||
test('replacing an embed disposes the previous one', () => {
|
||||
const store = createStore();
|
||||
const first = makeEmbed();
|
||||
const second = makeEmbed();
|
||||
|
||||
store.set(callEmbedAtom, asEmbed(first));
|
||||
store.set(callEmbedAtom, asEmbed(second));
|
||||
|
||||
assert.equal(first.disposed, 1);
|
||||
assert.equal(second.disposed, 0);
|
||||
assert.equal(store.get(callEmbedAtom), asEmbed(second));
|
||||
});
|
||||
|
||||
test('setting the same embed reference is a no-op (no dispose)', () => {
|
||||
const store = createStore();
|
||||
const embed = makeEmbed();
|
||||
|
||||
store.set(callEmbedAtom, asEmbed(embed));
|
||||
store.set(callEmbedAtom, asEmbed(embed));
|
||||
|
||||
assert.equal(embed.disposed, 0);
|
||||
assert.equal(store.get(callEmbedAtom), asEmbed(embed));
|
||||
});
|
||||
|
||||
test('clearing to undefined disposes the previous embed', () => {
|
||||
const store = createStore();
|
||||
const embed = makeEmbed();
|
||||
|
||||
store.set(callEmbedAtom, asEmbed(embed));
|
||||
store.set(callEmbedAtom, undefined);
|
||||
|
||||
assert.equal(embed.disposed, 1);
|
||||
assert.equal(store.get(callEmbedAtom), undefined);
|
||||
});
|
||||
|
||||
test('setting undefined when already undefined is a no-op', () => {
|
||||
const store = createStore();
|
||||
// No previous embed: the identity guard (undefined === undefined) returns early.
|
||||
store.set(callEmbedAtom, undefined);
|
||||
assert.equal(store.get(callEmbedAtom), undefined);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import type { CallPreferences } from './callPreferences';
|
||||
|
||||
// `makeCallPreferencesAtom(userId)` is a factory backed by `atomWithLocalStorage`.
|
||||
// localStorage is read when the returned atom is created; we install an
|
||||
// in-memory mock first so reads/writes resolve against it.
|
||||
const CALL_PREFERENCES = 'callPreferences';
|
||||
const storeKeyFor = (userId: string): string => `${CALL_PREFERENCES}${userId}`;
|
||||
|
||||
const installStorage = (): Map<string, string> => {
|
||||
const map = new Map<string, string>();
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
map.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
map.delete(k);
|
||||
},
|
||||
};
|
||||
return map;
|
||||
};
|
||||
|
||||
installStorage();
|
||||
// eslint-disable-next-line import/first
|
||||
import { makeCallPreferencesAtom } from './callPreferences';
|
||||
|
||||
const USER = '@user:server';
|
||||
|
||||
test('defaults to mic on, sound on, video off', () => {
|
||||
installStorage();
|
||||
const prefsAtom = makeCallPreferencesAtom(USER);
|
||||
const store = createStore();
|
||||
assert.deepEqual(store.get(prefsAtom), {
|
||||
microphone: true,
|
||||
video: false,
|
||||
sound: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('forces video to false on LOAD even when stored as true', () => {
|
||||
const backing = installStorage();
|
||||
backing.set(storeKeyFor(USER), JSON.stringify({ microphone: false, video: true, sound: false }));
|
||||
|
||||
const prefsAtom = makeCallPreferencesAtom(USER);
|
||||
const store = createStore();
|
||||
const prefs = store.get(prefsAtom);
|
||||
|
||||
// video is overridden, but mic/sound are preserved from storage.
|
||||
assert.equal(prefs.video, false);
|
||||
assert.equal(prefs.microphone, false);
|
||||
assert.equal(prefs.sound, false);
|
||||
});
|
||||
|
||||
test('forces video to false on PERSIST even when set to true', () => {
|
||||
const backing = installStorage();
|
||||
const prefsAtom = makeCallPreferencesAtom(USER);
|
||||
const store = createStore();
|
||||
|
||||
store.set(prefsAtom, { microphone: true, video: true, sound: true });
|
||||
|
||||
const raw = backing.get(storeKeyFor(USER));
|
||||
assert.ok(raw);
|
||||
const persisted = JSON.parse(raw!) as CallPreferences;
|
||||
assert.equal(persisted.video, false);
|
||||
// mic/sound persisted as given.
|
||||
assert.equal(persisted.microphone, true);
|
||||
assert.equal(persisted.sound, true);
|
||||
});
|
||||
|
||||
test('the in-memory atom value also has video forced off after a write', () => {
|
||||
installStorage();
|
||||
const prefsAtom = makeCallPreferencesAtom(USER);
|
||||
const store = createStore();
|
||||
|
||||
// atomWithLocalStorage write-through stores newValue verbatim on the atom,
|
||||
// while only the persisted copy is sanitized. The next load re-sanitizes.
|
||||
store.set(prefsAtom, { microphone: true, video: true, sound: true });
|
||||
|
||||
// Re-create the atom to model a fresh load from the (sanitized) storage.
|
||||
const reloaded = makeCallPreferencesAtom(USER);
|
||||
assert.equal(store.get(reloaded).video, false);
|
||||
});
|
||||
|
||||
test('preserves mic/sound toggles across a persist + reload cycle', () => {
|
||||
installStorage();
|
||||
const prefsAtom = makeCallPreferencesAtom(USER);
|
||||
const store = createStore();
|
||||
|
||||
store.set(prefsAtom, { microphone: false, video: false, sound: false });
|
||||
|
||||
const reloaded = makeCallPreferencesAtom(USER);
|
||||
const prefs = store.get(reloaded);
|
||||
assert.equal(prefs.microphone, false);
|
||||
assert.equal(prefs.sound, false);
|
||||
assert.equal(prefs.video, false);
|
||||
});
|
||||
|
||||
test('preferences are scoped per userId', () => {
|
||||
const backing = installStorage();
|
||||
const userA = '@a:s';
|
||||
const userB = '@b:s';
|
||||
const atomA = makeCallPreferencesAtom(userA);
|
||||
const atomB = makeCallPreferencesAtom(userB);
|
||||
const store = createStore();
|
||||
|
||||
store.set(atomA, { microphone: false, video: false, sound: true });
|
||||
store.set(atomB, { microphone: true, video: false, sound: false });
|
||||
|
||||
assert.ok(backing.has(storeKeyFor(userA)));
|
||||
assert.ok(backing.has(storeKeyFor(userB)));
|
||||
assert.equal(store.get(atomA).microphone, false);
|
||||
assert.equal(store.get(atomB).microphone, true);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import { enableMapSet } from 'immer';
|
||||
import { makeClosedNavCategoriesAtom, makeNavCategoryId } from './closedNavCategories';
|
||||
|
||||
// makeClosedNavCategoriesAtom(userId) builds a Set<string> atom whose reducer uses
|
||||
// immer produce (PUT add / DELETE delete) and persists to a per-user localStorage
|
||||
// key `closedNavCategories<userId>`. The reducers produce over a Set, so we enable
|
||||
// immer's Map/Set plugin (the app does this once at startup).
|
||||
// makeNavCategoryId joins args with '|'.
|
||||
enableMapSet();
|
||||
|
||||
// In-memory localStorage so atomWithLocalStorage can read/write at construction
|
||||
// and on set. window is referenced for the onMount storage listener.
|
||||
type Store = Record<string, string>;
|
||||
const installLocalStorage = (): Store => {
|
||||
const data: Store = {};
|
||||
const ls = {
|
||||
getItem: (k: string) => (k in data ? data[k] : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
data[k] = String(v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
delete data[k];
|
||||
},
|
||||
};
|
||||
(globalThis as { localStorage?: unknown }).localStorage = ls;
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
test('makeNavCategoryId joins args with "|"', () => {
|
||||
assert.equal(makeNavCategoryId('a', 'b', 'c'), 'a|b|c');
|
||||
assert.equal(makeNavCategoryId('only'), 'only');
|
||||
assert.equal(makeNavCategoryId(), '');
|
||||
});
|
||||
|
||||
test('starts empty when nothing is stored', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const navAtom = makeClosedNavCategoriesAtom('@u:server');
|
||||
assert.equal(store.get(navAtom).size, 0);
|
||||
});
|
||||
|
||||
test('hydrates the Set from the per-user localStorage key', () => {
|
||||
const data = installLocalStorage();
|
||||
data['closedNavCategories@u:server'] = JSON.stringify(['x', 'y']);
|
||||
const store = createStore();
|
||||
const navAtom = makeClosedNavCategoriesAtom('@u:server');
|
||||
assert.deepEqual(Array.from(store.get(navAtom)).sort(), ['x', 'y']);
|
||||
});
|
||||
|
||||
test('PUT adds a category and DELETE removes it', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const navAtom = makeClosedNavCategoriesAtom('@u:server');
|
||||
|
||||
store.set(navAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
assert.deepEqual(Array.from(store.get(navAtom)), ['cat1']);
|
||||
|
||||
store.set(navAtom, { type: 'DELETE', categoryId: 'cat1' });
|
||||
assert.equal(store.get(navAtom).has('cat1'), false);
|
||||
});
|
||||
|
||||
test('PUT of an existing category keeps the Set unchanged (idempotent)', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const navAtom = makeClosedNavCategoriesAtom('@u:server');
|
||||
store.set(navAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
store.set(navAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
assert.equal(store.get(navAtom).size, 1);
|
||||
});
|
||||
|
||||
test('DELETE of an absent category is a no-op', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const navAtom = makeClosedNavCategoriesAtom('@u:server');
|
||||
store.set(navAtom, { type: 'DELETE', categoryId: 'missing' });
|
||||
assert.equal(store.get(navAtom).size, 0);
|
||||
});
|
||||
|
||||
test('writes persist to localStorage as an array', () => {
|
||||
const data = installLocalStorage();
|
||||
const store = createStore();
|
||||
const navAtom = makeClosedNavCategoriesAtom('@u:server');
|
||||
store.set(navAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
assert.deepEqual(JSON.parse(data['closedNavCategories@u:server']), ['cat1']);
|
||||
});
|
||||
|
||||
test('the storage key is namespaced per user', () => {
|
||||
const data = installLocalStorage();
|
||||
const store = createStore();
|
||||
const aAtom = makeClosedNavCategoriesAtom('@a:server');
|
||||
const bAtom = makeClosedNavCategoriesAtom('@b:server');
|
||||
|
||||
store.set(aAtom, { type: 'PUT', categoryId: 'only-a' });
|
||||
assert.deepEqual(JSON.parse(data['closedNavCategories@a:server']), ['only-a']);
|
||||
assert.equal(data['closedNavCategories@b:server'], undefined);
|
||||
assert.equal(store.get(bAtom).size, 0);
|
||||
});
|
||||
@@ -0,0 +1,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']);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import { enableMapSet } from 'immer';
|
||||
import type { Path } from 'react-router-dom';
|
||||
|
||||
// `makeNavToActivePathAtom(userId)` is a factory: localStorage is read when the
|
||||
// returned atom is first created/accessed (not at module load), but we still
|
||||
// install the mock up front. The reducers `produce` over an immer-managed Map,
|
||||
// so immer's Map/Set plugin must be enabled.
|
||||
const NAV_TO_ACTIVE_PATH = 'navToActivePath';
|
||||
const storeKeyFor = (userId: string): string => `${NAV_TO_ACTIVE_PATH}${userId}`;
|
||||
|
||||
const installStorage = (): Map<string, string> => {
|
||||
const map = new Map<string, string>();
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
map.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
map.delete(k);
|
||||
},
|
||||
};
|
||||
return map;
|
||||
};
|
||||
|
||||
enableMapSet();
|
||||
installStorage();
|
||||
// eslint-disable-next-line import/first
|
||||
import { makeNavToActivePathAtom } from './navToActivePath';
|
||||
|
||||
const USER = '@user:server';
|
||||
const path = (pathname: string): Path => ({ pathname, search: '', hash: '' });
|
||||
|
||||
test('starts as an empty Map', () => {
|
||||
installStorage();
|
||||
const navAtom = makeNavToActivePathAtom(USER);
|
||||
const store = createStore();
|
||||
const map = store.get(navAtom);
|
||||
assert.ok(map instanceof Map);
|
||||
assert.equal(map.size, 0);
|
||||
});
|
||||
|
||||
test('PUT stores a path under its navId', () => {
|
||||
installStorage();
|
||||
const navAtom = makeNavToActivePathAtom(USER);
|
||||
const store = createStore();
|
||||
store.set(navAtom, { type: 'PUT', navId: 'home', path: path('/home') });
|
||||
assert.deepEqual(store.get(navAtom).get('home'), path('/home'));
|
||||
});
|
||||
|
||||
test('PUT overwrites the path for an existing navId', () => {
|
||||
installStorage();
|
||||
const navAtom = makeNavToActivePathAtom(USER);
|
||||
const store = createStore();
|
||||
store.set(navAtom, { type: 'PUT', navId: 'home', path: path('/old') });
|
||||
store.set(navAtom, { type: 'PUT', navId: 'home', path: path('/new') });
|
||||
const map = store.get(navAtom);
|
||||
assert.equal(map.size, 1);
|
||||
assert.deepEqual(map.get('home'), path('/new'));
|
||||
});
|
||||
|
||||
test('DELETE removes a navId', () => {
|
||||
installStorage();
|
||||
const navAtom = makeNavToActivePathAtom(USER);
|
||||
const store = createStore();
|
||||
store.set(navAtom, { type: 'PUT', navId: 'home', path: path('/home') });
|
||||
store.set(navAtom, { type: 'PUT', navId: 'dms', path: path('/dms') });
|
||||
store.set(navAtom, { type: 'DELETE', navId: 'home' });
|
||||
|
||||
const map = store.get(navAtom);
|
||||
assert.equal(map.has('home'), false);
|
||||
assert.deepEqual(map.get('dms'), path('/dms'));
|
||||
});
|
||||
|
||||
test('DELETE of an absent navId is a no-op', () => {
|
||||
installStorage();
|
||||
const navAtom = makeNavToActivePathAtom(USER);
|
||||
const store = createStore();
|
||||
store.set(navAtom, { type: 'PUT', navId: 'home', path: path('/home') });
|
||||
store.set(navAtom, { type: 'DELETE', navId: 'ghost' });
|
||||
assert.deepEqual([...store.get(navAtom).keys()], ['home']);
|
||||
});
|
||||
|
||||
test('persists to localStorage as an Object keyed per user', () => {
|
||||
const backing = installStorage();
|
||||
const navAtom = makeNavToActivePathAtom(USER);
|
||||
const store = createStore();
|
||||
store.set(navAtom, { type: 'PUT', navId: 'home', path: path('/home') });
|
||||
|
||||
const raw = backing.get(storeKeyFor(USER));
|
||||
assert.ok(raw);
|
||||
const obj = JSON.parse(raw!) as Record<string, Path>;
|
||||
assert.deepEqual(obj, { home: path('/home') });
|
||||
});
|
||||
|
||||
test('hydrates the Map from a stored Object', () => {
|
||||
const backing = installStorage();
|
||||
backing.set(storeKeyFor(USER), JSON.stringify({ home: path('/home') }));
|
||||
|
||||
const navAtom = makeNavToActivePathAtom(USER);
|
||||
const store = createStore();
|
||||
assert.deepEqual(store.get(navAtom).get('home'), path('/home'));
|
||||
});
|
||||
|
||||
test('storage is scoped per userId', () => {
|
||||
const backing = installStorage();
|
||||
const userA = '@a:s';
|
||||
const userB = '@b:s';
|
||||
|
||||
const atomA = makeNavToActivePathAtom(userA);
|
||||
const atomB = makeNavToActivePathAtom(userB);
|
||||
const store = createStore();
|
||||
|
||||
store.set(atomA, { type: 'PUT', navId: 'home', path: path('/a-home') });
|
||||
store.set(atomB, { type: 'PUT', navId: 'home', path: path('/b-home') });
|
||||
|
||||
assert.ok(backing.has(storeKeyFor(userA)));
|
||||
assert.ok(backing.has(storeKeyFor(userB)));
|
||||
assert.deepEqual(store.get(atomA).get('home'), path('/a-home'));
|
||||
assert.deepEqual(store.get(atomB).get('home'), path('/b-home'));
|
||||
});
|
||||
@@ -0,0 +1,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]);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import { allRoomsAtom } from './roomList';
|
||||
|
||||
// allRoomsAtom wraps an unexported baseRoomsAtom string[] reducer:
|
||||
// INITIALIZE -> replace wholesale
|
||||
// PUT -> filter-out-then-push (move-to-end dedupe)
|
||||
// DELETE -> filter-out
|
||||
// We drive the reducer through a jotai store. The React binding hook
|
||||
// (useBindAllRoomsAtom) wires this to MatrixClient events and is not covered.
|
||||
|
||||
const R1 = '!r1:server';
|
||||
const R2 = '!r2:server';
|
||||
const R3 = '!r3:server';
|
||||
|
||||
test('starts empty', () => {
|
||||
const store = createStore();
|
||||
assert.deepEqual(store.get(allRoomsAtom), []);
|
||||
});
|
||||
|
||||
test('INITIALIZE replaces the whole list', () => {
|
||||
const store = createStore();
|
||||
store.set(allRoomsAtom, { type: 'PUT', roomId: R1 });
|
||||
store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [R2, R3] });
|
||||
assert.deepEqual(store.get(allRoomsAtom), [R2, R3]);
|
||||
});
|
||||
|
||||
test('PUT appends a new room', () => {
|
||||
const store = createStore();
|
||||
store.set(allRoomsAtom, { type: 'PUT', roomId: R1 });
|
||||
store.set(allRoomsAtom, { type: 'PUT', roomId: R2 });
|
||||
assert.deepEqual(store.get(allRoomsAtom), [R1, R2]);
|
||||
});
|
||||
|
||||
test('PUT of an existing room moves it to the end (dedupe)', () => {
|
||||
const store = createStore();
|
||||
store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [R1, R2, R3] });
|
||||
store.set(allRoomsAtom, { type: 'PUT', roomId: R1 });
|
||||
assert.deepEqual(store.get(allRoomsAtom), [R2, R3, R1]);
|
||||
});
|
||||
|
||||
test('DELETE removes a room', () => {
|
||||
const store = createStore();
|
||||
store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [R1, R2, R3] });
|
||||
store.set(allRoomsAtom, { type: 'DELETE', roomId: R2 });
|
||||
assert.deepEqual(store.get(allRoomsAtom), [R1, R3]);
|
||||
});
|
||||
|
||||
test('DELETE of an absent room is a no-op', () => {
|
||||
const store = createStore();
|
||||
store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [R1] });
|
||||
store.set(allRoomsAtom, { type: 'DELETE', roomId: R2 });
|
||||
assert.deepEqual(store.get(allRoomsAtom), [R1]);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { compareRoomsEqual } from './utils';
|
||||
|
||||
// compareRoomsEqual(a, b): length mismatch short-circuits to false, otherwise an
|
||||
// order-sensitive element-by-element equality. The React hook in the same file
|
||||
// (useBindRoomsWithMembershipsAtom) is not covered.
|
||||
|
||||
test('two empty arrays are equal', () => {
|
||||
assert.equal(compareRoomsEqual([], []), true);
|
||||
});
|
||||
|
||||
test('identical arrays are equal', () => {
|
||||
assert.equal(compareRoomsEqual(['a', 'b', 'c'], ['a', 'b', 'c']), true);
|
||||
});
|
||||
|
||||
test('different lengths are not equal', () => {
|
||||
assert.equal(compareRoomsEqual(['a', 'b'], ['a', 'b', 'c']), false);
|
||||
assert.equal(compareRoomsEqual(['a'], []), false);
|
||||
});
|
||||
|
||||
test('same elements in a different order are not equal (order-sensitive)', () => {
|
||||
assert.equal(compareRoomsEqual(['a', 'b'], ['b', 'a']), false);
|
||||
});
|
||||
|
||||
test('a single differing element makes them unequal', () => {
|
||||
assert.equal(compareRoomsEqual(['a', 'b', 'c'], ['a', 'x', 'c']), false);
|
||||
});
|
||||
|
||||
test('reference equality is not required, only value equality', () => {
|
||||
const a = ['a', 'b'];
|
||||
const b = ['a', 'b'];
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(compareRoomsEqual(a, b), true);
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import type { ScheduledMessage } from './scheduledMessages';
|
||||
|
||||
// scheduledMessagesAtom is backed by jotai's atomWithStorage over
|
||||
// `createJSONStorage(() => localStorage)`, which dereferences `localStorage`
|
||||
// (absent in node) lazily on first store access. We install an in-memory mock
|
||||
// before importing the module so both module init and the storage reads/writes
|
||||
// resolve against it.
|
||||
const STORAGE_KEY = 'cinny_scheduled_messages_v1';
|
||||
|
||||
const installStorage = (): Map<string, string> => {
|
||||
const map = new Map<string, string>();
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
map.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
map.delete(k);
|
||||
},
|
||||
};
|
||||
return map;
|
||||
};
|
||||
|
||||
installStorage();
|
||||
const { scheduledMessagesAtom } = await import('./scheduledMessages');
|
||||
|
||||
// jotai's `atomWithStorage` binds to the `localStorage` captured at module
|
||||
// evaluation. To exercise hydration from pre-existing storage we install seeded
|
||||
// storage and then import a *fresh* (cache-busted) copy of the module.
|
||||
let freshCounter = 0;
|
||||
const importWithStorage = async (
|
||||
seed?: Record<string, ScheduledMessage[]>,
|
||||
): Promise<typeof import('./scheduledMessages').scheduledMessagesAtom> => {
|
||||
const backing = installStorage();
|
||||
if (seed) backing.set(STORAGE_KEY, JSON.stringify(seed));
|
||||
freshCounter += 1;
|
||||
const mod = await import(`./scheduledMessages?fresh=${freshCounter}`);
|
||||
return mod.scheduledMessagesAtom;
|
||||
};
|
||||
|
||||
const msg = (delayId: string, roomId: string): ScheduledMessage => ({
|
||||
delayId,
|
||||
roomId,
|
||||
content: { body: delayId, msgtype: 'm.text' },
|
||||
sendAt: 1000,
|
||||
});
|
||||
|
||||
test('starts as an empty Map', () => {
|
||||
installStorage();
|
||||
const store = createStore();
|
||||
const map = store.get(scheduledMessagesAtom);
|
||||
assert.ok(map instanceof Map);
|
||||
assert.equal(map.size, 0);
|
||||
});
|
||||
|
||||
test('setting a Map is readable back as an equivalent Map (round-trip)', () => {
|
||||
installStorage();
|
||||
const store = createStore();
|
||||
|
||||
const next = new Map<string, ScheduledMessage[]>([['!room:s', [msg('d1', '!room:s')]]]);
|
||||
store.set(scheduledMessagesAtom, next);
|
||||
|
||||
const got = store.get(scheduledMessagesAtom);
|
||||
assert.ok(got instanceof Map);
|
||||
assert.deepEqual(got.get('!room:s'), [msg('d1', '!room:s')]);
|
||||
});
|
||||
|
||||
test('functional-updater form receives the previous Map', () => {
|
||||
installStorage();
|
||||
const store = createStore();
|
||||
|
||||
store.set(scheduledMessagesAtom, new Map([['!a:s', [msg('d1', '!a:s')]]]));
|
||||
|
||||
let seenPrev: Map<string, ScheduledMessage[]> | undefined;
|
||||
store.set(scheduledMessagesAtom, (prev) => {
|
||||
seenPrev = prev;
|
||||
const copy = new Map(prev);
|
||||
copy.set('!b:s', [msg('d2', '!b:s')]);
|
||||
return copy;
|
||||
});
|
||||
|
||||
assert.ok(seenPrev instanceof Map);
|
||||
assert.deepEqual(seenPrev?.get('!a:s'), [msg('d1', '!a:s')]);
|
||||
|
||||
const got = store.get(scheduledMessagesAtom);
|
||||
assert.deepEqual([...got.keys()].sort(), ['!a:s', '!b:s']);
|
||||
});
|
||||
|
||||
test('persists to localStorage as a plain Record keyed by roomId', () => {
|
||||
const backing = installStorage();
|
||||
const store = createStore();
|
||||
|
||||
store.set(
|
||||
scheduledMessagesAtom,
|
||||
new Map<string, ScheduledMessage[]>([
|
||||
['!a:s', [msg('d1', '!a:s')]],
|
||||
['!b:s', [msg('d2', '!b:s'), msg('d3', '!b:s')]],
|
||||
]),
|
||||
);
|
||||
|
||||
const raw = backing.get(STORAGE_KEY);
|
||||
assert.ok(raw, 'expected the storage key to be written');
|
||||
const parsed = JSON.parse(raw!) as Record<string, ScheduledMessage[]>;
|
||||
assert.deepEqual(Object.keys(parsed).sort(), ['!a:s', '!b:s']);
|
||||
assert.equal(parsed['!b:s'].length, 2);
|
||||
assert.equal(parsed['!a:s'][0].delayId, 'd1');
|
||||
});
|
||||
|
||||
test('hydrates the Map from a previously stored Record once the atom is mounted', async () => {
|
||||
const freshAtom = await importWithStorage({ '!room:s': [msg('stored', '!room:s')] });
|
||||
const store = createStore();
|
||||
|
||||
// The underlying jotai atomWithStorage is created without `getOnInit`, so a
|
||||
// bare `store.get` returns the default ({}); storage is synced on mount. We
|
||||
// model the React mount with `store.sub`, which fires the onMount hydration.
|
||||
const unsub = store.sub(freshAtom, () => {});
|
||||
try {
|
||||
assert.deepEqual(store.get(freshAtom).get('!room:s'), [msg('stored', '!room:s')]);
|
||||
} finally {
|
||||
unsub();
|
||||
}
|
||||
});
|
||||
|
||||
test('supports multiple rooms independently', () => {
|
||||
installStorage();
|
||||
const store = createStore();
|
||||
|
||||
store.set(scheduledMessagesAtom, (prev) => {
|
||||
const copy = new Map(prev);
|
||||
copy.set('!r1:s', [msg('a', '!r1:s')]);
|
||||
return copy;
|
||||
});
|
||||
store.set(scheduledMessagesAtom, (prev) => {
|
||||
const copy = new Map(prev);
|
||||
copy.set('!r2:s', [msg('b', '!r2:s')]);
|
||||
return copy;
|
||||
});
|
||||
|
||||
const map = store.get(scheduledMessagesAtom);
|
||||
assert.deepEqual(map.get('!r1:s'), [msg('a', '!r1:s')]);
|
||||
assert.deepEqual(map.get('!r2:s'), [msg('b', '!r2:s')]);
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import { enableMapSet } from 'immer';
|
||||
|
||||
// `spaceRoomsAtom` is backed by `atomWithLocalStorage`, which reads
|
||||
// `localStorage` AT MODULE LOAD to seed the atom (absent in node). We install an
|
||||
// in-memory mock first. The reducers also `produce` over an immer-managed Set,
|
||||
// so immer's Map/Set plugin must be enabled (the app does this at startup).
|
||||
const SPACE_ROOMS = 'spaceRooms';
|
||||
|
||||
const installStorage = (seed?: string[]): Map<string, string> => {
|
||||
const map = new Map<string, string>();
|
||||
if (seed) map.set(SPACE_ROOMS, JSON.stringify(seed));
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
map.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
map.delete(k);
|
||||
},
|
||||
};
|
||||
return map;
|
||||
};
|
||||
|
||||
enableMapSet();
|
||||
installStorage();
|
||||
const { spaceRoomsAtom } = await import('./spaceRooms');
|
||||
|
||||
// `atomWithLocalStorage` seeds the atom from storage exactly once, when the
|
||||
// module is evaluated. To exercise hydration we install seeded storage and then
|
||||
// import a *fresh* copy of the module (cache-busted) so the seed is read.
|
||||
let freshCounter = 0;
|
||||
const importWithStorage = async (
|
||||
seed?: string[],
|
||||
): Promise<typeof import('./spaceRooms').spaceRoomsAtom> => {
|
||||
installStorage(seed);
|
||||
freshCounter += 1;
|
||||
const mod = await import(`./spaceRooms?fresh=${freshCounter}`);
|
||||
return mod.spaceRoomsAtom;
|
||||
};
|
||||
|
||||
const sorted = (s: Set<string>): string[] => [...s].sort();
|
||||
|
||||
test('starts empty when nothing is stored', () => {
|
||||
installStorage();
|
||||
const store = createStore();
|
||||
assert.equal(store.get(spaceRoomsAtom).size, 0);
|
||||
});
|
||||
|
||||
test('PUT adds new room ids to the Set', () => {
|
||||
installStorage();
|
||||
const store = createStore();
|
||||
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s', '!b:s'] });
|
||||
assert.deepEqual(sorted(store.get(spaceRoomsAtom)), ['!a:s', '!b:s']);
|
||||
});
|
||||
|
||||
test('PUT dedupes against existing entries', () => {
|
||||
installStorage();
|
||||
const store = createStore();
|
||||
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s'] });
|
||||
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s', '!b:s'] });
|
||||
assert.deepEqual(sorted(store.get(spaceRoomsAtom)), ['!a:s', '!b:s']);
|
||||
});
|
||||
|
||||
test('PUT with only already-present ids does not write (Set identity unchanged)', () => {
|
||||
installStorage();
|
||||
const store = createStore();
|
||||
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s'] });
|
||||
const before = store.get(spaceRoomsAtom);
|
||||
|
||||
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s'] });
|
||||
const after = store.get(spaceRoomsAtom);
|
||||
|
||||
// No new entries -> the reducer skips set(), so the Set reference is the same.
|
||||
assert.equal(before, after);
|
||||
});
|
||||
|
||||
test('DELETE removes present members', () => {
|
||||
installStorage();
|
||||
const store = createStore();
|
||||
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s', '!b:s'] });
|
||||
store.set(spaceRoomsAtom, { type: 'DELETE', roomIds: ['!a:s'] });
|
||||
assert.deepEqual(sorted(store.get(spaceRoomsAtom)), ['!b:s']);
|
||||
});
|
||||
|
||||
test('DELETE of an absent member does not write (Set identity unchanged)', () => {
|
||||
installStorage();
|
||||
const store = createStore();
|
||||
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s'] });
|
||||
const before = store.get(spaceRoomsAtom);
|
||||
|
||||
store.set(spaceRoomsAtom, { type: 'DELETE', roomIds: ['!nope:s'] });
|
||||
const after = store.get(spaceRoomsAtom);
|
||||
|
||||
assert.equal(before, after);
|
||||
assert.deepEqual(sorted(after), ['!a:s']);
|
||||
});
|
||||
|
||||
test('DELETE acts when at least one of the ids is present', () => {
|
||||
installStorage();
|
||||
const store = createStore();
|
||||
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s', '!b:s'] });
|
||||
store.set(spaceRoomsAtom, { type: 'DELETE', roomIds: ['!a:s', '!nope:s'] });
|
||||
assert.deepEqual(sorted(store.get(spaceRoomsAtom)), ['!b:s']);
|
||||
});
|
||||
|
||||
test('hydrates the Set from a stored array', async () => {
|
||||
const freshAtom = await importWithStorage(['!x:s', '!y:s']);
|
||||
const store = createStore();
|
||||
assert.deepEqual(sorted(store.get(freshAtom)), ['!x:s', '!y:s']);
|
||||
});
|
||||
|
||||
test('persists the Set to localStorage as an array', () => {
|
||||
const backing = installStorage();
|
||||
const store = createStore();
|
||||
store.set(spaceRoomsAtom, { type: 'PUT', roomIds: ['!a:s', '!b:s'] });
|
||||
|
||||
const raw = backing.get(SPACE_ROOMS);
|
||||
assert.ok(raw);
|
||||
const arr = JSON.parse(raw!) as string[];
|
||||
assert.deepEqual([...arr].sort(), ['!a:s', '!b:s']);
|
||||
});
|
||||
@@ -0,0 +1,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'],
|
||||
);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user