589d45e0a0
Via subagent, verified against real behavior (all use jotai store + enableMapSet): - state/list (11): createListAtom PUT/DELETE/REPLACE (single + array, identity). - state/room/roomToParents (10): INITIALIZE/PUT/DELETE incl. cycle-skip and orphan-cleanup pruning of zero-parent children. - state/room/roomToUnread (22): unreadInfoToUnread, unreadEqual, and the roomToUnreadAtom reducer — leaf/overwrite/equal-guard, multi-level parent roll-up with `from` recording, RESET rebuild, DELETE subtract/prune. No bugs (noted a latent never-hit string-spread in deleteUnreadInfo's `from ?? roomId` fallback; left as-is). Suite growing toward full pure-logic coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
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);
|
|
});
|