// presence.mjs under a fake clock: the beat's cadence, the debounce a // move goes through, what a hidden tab stops doing — and the diff of // the whole set the stream sends, which is where this tab's own entry // is dropped. // // No DOM and no fetch: createPresence takes its post, its clock and // its "where am I" from the caller, so the whole of the control flow // runs under plain Node. import test from 'node:test' import assert from 'node:assert/strict' import { createPresence, diffSets, toMap, initials, newClientId } from './presence.mjs' // A clock with a hand: timers fire when advance() reaches them, in // the order they were due, and a timer armed by a firing timer is due // from that moment (which is what the heartbeat re-arming itself is). function fakeClock () { let now = 0 let next = 1 const timers = new Map() return { setTimeout (fn, ms) { const id = next++ timers.set(id, { at: now + (ms || 0), fn }) return id }, clearTimeout (id) { timers.delete(id) }, advance (ms) { const until = now + ms for (;;) { let due = null for (const [id, t] of timers) { if (t.at <= until && (due === null || t.at < timers.get(due).at)) due = id } if (due === null) break const t = timers.get(due) timers.delete(due) now = t.at t.fn() } now = until }, get pending () { return timers.size } } } // A presence with a recording post and a place this tab can be moved. function harness ({ clientId = 'aaaa000000000000' } = {}) { const clock = fakeClock() const sent = [] const at = { slide: 's1', block: '' } const presence = createPresence({ clientId, post: body => { sent.push(body); return Promise.resolve() }, getState: () => ({ ...at }), setTimeoutFn: clock.setTimeout, clearTimeoutFn: clock.clearTimeout }) return { clock, sent, at, presence } } test('a beat on connect, then one every twenty seconds', () => { const { clock, sent, presence } = harness() presence.start() assert.deepEqual(sent, [{ client: 'aaaa000000000000', slide: 's1', block: '' }]) clock.advance(19000) assert.equal(sent.length, 1, 'nothing before the interval is up') clock.advance(1000) assert.equal(sent.length, 2) clock.advance(60000) assert.equal(sent.length, 5, 'three more beats in a minute') presence.stop() clock.advance(60000) assert.equal(sent.length, 5, 'a stopped presence beats no more') assert.equal(clock.pending, 0, 'and leaves no timer behind') }) test('a move posts once, 200ms after the last of a burst, and re-arms the beat', () => { const { clock, sent, at, presence } = harness() presence.start() at.slide = 's2' presence.changed() at.block = 'b7' presence.changed() clock.advance(199) assert.equal(sent.length, 1, 'still debounced') clock.advance(1) assert.deepEqual(sent[1], { client: 'aaaa000000000000', slide: 's2', block: 'b7' }, 'one beat, carrying where the burst ended') // The move WAS the heartbeat: the next one is twenty seconds after // it, not twenty seconds after connecting. clock.advance(19799) assert.equal(sent.length, 2) clock.advance(201) assert.equal(sent.length, 3) }) test('a hidden tab stops beating; coming back posts at once', () => { const { clock, sent, at, presence } = harness() presence.start() presence.noteHidden() clock.advance(120000) assert.equal(sent.length, 1, 'the server expires it rather than being told') at.slide = 's3' presence.changed() clock.advance(1000) assert.equal(sent.length, 1, 'and a move while hidden says nothing') presence.noteVisible() assert.deepEqual(sent[1], { client: 'aaaa000000000000', slide: 's3', block: '' }) clock.advance(20000) assert.equal(sent.length, 3, 'the beat is running again') }) test('applySet leaves this tab out and answers what changed', () => { const { presence } = harness() const me = { client: 'aaaa000000000000', name: 'Me', colour: '#2f6f4f', slide: 's1', block: 'b1' } const ana = { client: 'bbbb', name: 'Ana Ruiz', colour: '#8a4b2a', slide: 's1', block: '' } const bo = { client: 'cccc', name: 'bo@example.test', colour: '#3a4f8a', slide: 's2', block: 'b9' } let d = presence.applySet([me, ana, bo]) assert.deepEqual(d.added.map(p => p.client), ['bbbb', 'cccc'], 'both of them, and never me') assert.deepEqual(d.moved, []) assert.deepEqual(d.removed, []) assert.deepEqual(presence.people.map(p => p.name), ['Ana Ruiz', 'bo@example.test']) // The same set again — a reconnect sends the whole thing — is no // news at all. d = presence.applySet([me, ana, bo]) assert.deepEqual([d.added, d.moved, d.removed], [[], [], []]) d = presence.applySet([me, { ...ana, slide: 's2', block: 'b9' }, bo]) assert.deepEqual(d.moved.map(p => p.client), ['bbbb']) assert.deepEqual(d.added, []) d = presence.applySet([me]) assert.deepEqual(d.removed.map(p => p.name), ['Ana Ruiz', 'bo@example.test'], 'the ones who left are named by their last record') assert.deepEqual(presence.people, []) }) test('a set with rubbish in it is read for what it does carry', () => { const map = toMap([null, 'no', { name: 'nameless' }, { client: 'dddd' }], 'mine') assert.deepEqual(Array.from(map.keys()), ['dddd']) assert.deepEqual(map.get('dddd'), { client: 'dddd', name: '', colour: '', slide: '', block: '' }) assert.equal(toMap(undefined, 'mine').size, 0) // A name or colour changing on its own is not somewhere a person went. const before = toMap([{ client: 'e', name: 'A', slide: 's1', block: '' }], 'mine') const after = toMap([{ client: 'e', name: 'A. Ruiz', slide: 's1', block: '' }], 'mine') assert.deepEqual(diffSets(before, after), { added: [], moved: [], removed: [] }) }) test('initials, and a client id per tab', () => { assert.equal(initials('Ana Ruiz'), 'AR') assert.equal(initials('ana.ruiz@example.test'), 'AR') assert.equal(initials('bo@example.test'), 'BO') assert.equal(initials(' '), '') assert.equal(initials(undefined), '') const id = newClientId() assert.match(id, /^[0-9a-f]{16}$/) assert.notEqual(id, newClientId()) })