From 589d45e0a01b410cfb90d0a2475375222b8edd30 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 30 Jun 2026 14:42:36 -0400 Subject: [PATCH] test: add suites for list, roomToParents, roomToUnread reducers (+43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Via subagent, verified against real behavior (all use jotai store + enableMapSet): - state/list (11): createListAtom PUT/DELETE/REPLACE (single + array, identity). - state/room/roomToParents (10): INITIALIZE/PUT/DELETE incl. cycle-skip and orphan-cleanup pruning of zero-parent children. - state/room/roomToUnread (22): unreadInfoToUnread, unreadEqual, and the roomToUnreadAtom reducer — leaf/overwrite/equal-guard, multi-level parent roll-up with `from` recording, RESET rebuild, DELETE subtract/prune. No bugs (noted a latent never-hit string-spread in deleteUnreadInfo's `from ?? roomId` fallback; left as-is). Suite growing toward full pure-logic coverage. Co-Authored-By: Claude Opus 4.8 --- src/app/state/list.test.ts | 104 +++++++++ src/app/state/room/roomToParents.test.ts | 118 ++++++++++ src/app/state/room/roomToUnread.test.ts | 280 +++++++++++++++++++++++ 3 files changed, 502 insertions(+) create mode 100644 src/app/state/list.test.ts create mode 100644 src/app/state/room/roomToParents.test.ts create mode 100644 src/app/state/room/roomToUnread.test.ts diff --git a/src/app/state/list.test.ts b/src/app/state/list.test.ts new file mode 100644 index 000000000..0f57dba7f --- /dev/null +++ b/src/app/state/list.test.ts @@ -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(); + assert.deepEqual(store.get(listAtom), []); +}); + +test('PUT appends a single item', () => { + const store = createStore(); + const listAtom = createListAtom(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + store.set(listAtom, { type: 'PUT', item: ['a', 'b'] }); + store.set(listAtom, { type: 'REPLACE', item: 'z', replacement: 'Z' }); + assert.deepEqual(store.get(listAtom), ['a', 'b']); +}); diff --git a/src/app/state/room/roomToParents.test.ts b/src/app/state/room/roomToParents.test.ts new file mode 100644 index 000000000..e820c7766 --- /dev/null +++ b/src/app/state/room/roomToParents.test.ts @@ -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. 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): RoomToParents => store.get(roomToParentsAtom); + +const parentsOf = (store: ReturnType, 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); +}); diff --git a/src/app/state/room/roomToUnread.test.ts b/src/app/state/room/roomToUnread.test.ts new file mode 100644 index 000000000..93fb48390 --- /dev/null +++ b/src/app/state/room/roomToUnread.test.ts @@ -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) => store.get(roomToUnreadAtom); + +const info = (roomId: string, total: number, highlight: number): UnreadInfo => ({ + roomId, + total, + highlight, +}); + +const seedParents = (store: ReturnType, 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 | 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); +});