92 lines
3.7 KiB
TypeScript
92 lines
3.7 KiB
TypeScript
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]);
|
||
|
|
});
|