diff --git a/src/app/features/room/msgContent.test.ts b/src/app/features/room/msgContent.test.ts new file mode 100644 index 000000000..6656728ad --- /dev/null +++ b/src/app/features/room/msgContent.test.ts @@ -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'); +}); diff --git a/src/app/state/backupRestore.test.ts b/src/app/state/backupRestore.test.ts new file mode 100644 index 000000000..3c5cb3715 --- /dev/null +++ b/src/app/state/backupRestore.test.ts @@ -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); +}); diff --git a/src/app/state/callEmbed.test.ts b/src/app/state/callEmbed.test.ts new file mode 100644 index 000000000..a624c3236 --- /dev/null +++ b/src/app/state/callEmbed.test.ts @@ -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); +}); diff --git a/src/app/state/closedNavCategories.test.ts b/src/app/state/closedNavCategories.test.ts new file mode 100644 index 000000000..7ca2506f3 --- /dev/null +++ b/src/app/state/closedNavCategories.test.ts @@ -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 atom whose reducer uses +// immer produce (PUT add / DELETE delete) and persists to a per-user localStorage +// key `closedNavCategories`. 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; +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); +}); diff --git a/src/app/state/room-list/inviteList.test.ts b/src/app/state/room-list/inviteList.test.ts new file mode 100644 index 000000000..9345d625b --- /dev/null +++ b/src/app/state/room-list/inviteList.test.ts @@ -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]); +}); diff --git a/src/app/state/room-list/roomList.test.ts b/src/app/state/room-list/roomList.test.ts new file mode 100644 index 000000000..dd060e4cd --- /dev/null +++ b/src/app/state/room-list/roomList.test.ts @@ -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]); +}); diff --git a/src/app/state/room-list/utils.test.ts b/src/app/state/room-list/utils.test.ts new file mode 100644 index 000000000..f55bcfcc2 --- /dev/null +++ b/src/app/state/room-list/utils.test.ts @@ -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); +}); diff --git a/src/app/state/toast.test.ts b/src/app/state/toast.test.ts new file mode 100644 index 000000000..fbceedffe --- /dev/null +++ b/src/app/state/toast.test.ts @@ -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'], + ); +});