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): IRoomIdToTypingMembers => store.get(roomIdToTypingMembersAtom); const userIds = (store: ReturnType, 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]); });