| 1 | // presence.mjs under a fake clock: the beat's cadence, the debounce a |
| 2 | // move goes through, what a hidden tab stops doing — and the diff of |
| 3 | // the whole set the stream sends, which is where this tab's own entry |
| 4 | // is dropped. |
| 5 | // |
| 6 | // No DOM and no fetch: createPresence takes its post, its clock and |
| 7 | // its "where am I" from the caller, so the whole of the control flow |
| 8 | // runs under plain Node. |
| 9 | import test from 'node:test' |
| 10 | import assert from 'node:assert/strict' |
| 11 | import { createPresence, diffSets, toMap, initials, newClientId } from './presence.mjs' |
| 12 | |
| 13 | // A clock with a hand: timers fire when advance() reaches them, in |
| 14 | // the order they were due, and a timer armed by a firing timer is due |
| 15 | // from that moment (which is what the heartbeat re-arming itself is). |
| 16 | function fakeClock () { |
| 17 | let now = 0 |
| 18 | let next = 1 |
| 19 | const timers = new Map() |
| 20 | return { |
| 21 | setTimeout (fn, ms) { |
| 22 | const id = next++ |
| 23 | timers.set(id, { at: now + (ms || 0), fn }) |
| 24 | return id |
| 25 | }, |
| 26 | clearTimeout (id) { timers.delete(id) }, |
| 27 | advance (ms) { |
| 28 | const until = now + ms |
| 29 | for (;;) { |
| 30 | let due = null |
| 31 | for (const [id, t] of timers) { |
| 32 | if (t.at <= until && (due === null || t.at < timers.get(due).at)) due = id |
| 33 | } |
| 34 | if (due === null) break |
| 35 | const t = timers.get(due) |
| 36 | timers.delete(due) |
| 37 | now = t.at |
| 38 | t.fn() |
| 39 | } |
| 40 | now = until |
| 41 | }, |
| 42 | get pending () { return timers.size } |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | // A presence with a recording post and a place this tab can be moved. |
| 47 | function harness ({ clientId = 'aaaa000000000000' } = {}) { |
| 48 | const clock = fakeClock() |
| 49 | const sent = [] |
| 50 | const at = { slide: 's1', block: '' } |
| 51 | const presence = createPresence({ |
| 52 | clientId, |
| 53 | post: body => { sent.push(body); return Promise.resolve() }, |
| 54 | getState: () => ({ ...at }), |
| 55 | setTimeoutFn: clock.setTimeout, |
| 56 | clearTimeoutFn: clock.clearTimeout |
| 57 | }) |
| 58 | return { clock, sent, at, presence } |
| 59 | } |
| 60 | |
| 61 | test('a beat on connect, then one every twenty seconds', () => { |
| 62 | const { clock, sent, presence } = harness() |
| 63 | presence.start() |
| 64 | assert.deepEqual(sent, [{ client: 'aaaa000000000000', slide: 's1', block: '' }]) |
| 65 | |
| 66 | clock.advance(19000) |
| 67 | assert.equal(sent.length, 1, 'nothing before the interval is up') |
| 68 | clock.advance(1000) |
| 69 | assert.equal(sent.length, 2) |
| 70 | clock.advance(60000) |
| 71 | assert.equal(sent.length, 5, 'three more beats in a minute') |
| 72 | |
| 73 | presence.stop() |
| 74 | clock.advance(60000) |
| 75 | assert.equal(sent.length, 5, 'a stopped presence beats no more') |
| 76 | assert.equal(clock.pending, 0, 'and leaves no timer behind') |
| 77 | }) |
| 78 | |
| 79 | test('a move posts once, 200ms after the last of a burst, and re-arms the beat', () => { |
| 80 | const { clock, sent, at, presence } = harness() |
| 81 | presence.start() |
| 82 | |
| 83 | at.slide = 's2' |
| 84 | presence.changed() |
| 85 | at.block = 'b7' |
| 86 | presence.changed() |
| 87 | clock.advance(199) |
| 88 | assert.equal(sent.length, 1, 'still debounced') |
| 89 | clock.advance(1) |
| 90 | assert.deepEqual(sent[1], { client: 'aaaa000000000000', slide: 's2', block: 'b7' }, |
| 91 | 'one beat, carrying where the burst ended') |
| 92 | |
| 93 | // The move WAS the heartbeat: the next one is twenty seconds after |
| 94 | // it, not twenty seconds after connecting. |
| 95 | clock.advance(19799) |
| 96 | assert.equal(sent.length, 2) |
| 97 | clock.advance(201) |
| 98 | assert.equal(sent.length, 3) |
| 99 | }) |
| 100 | |
| 101 | test('a hidden tab stops beating; coming back posts at once', () => { |
| 102 | const { clock, sent, at, presence } = harness() |
| 103 | presence.start() |
| 104 | presence.noteHidden() |
| 105 | clock.advance(120000) |
| 106 | assert.equal(sent.length, 1, 'the server expires it rather than being told') |
| 107 | at.slide = 's3' |
| 108 | presence.changed() |
| 109 | clock.advance(1000) |
| 110 | assert.equal(sent.length, 1, 'and a move while hidden says nothing') |
| 111 | |
| 112 | presence.noteVisible() |
| 113 | assert.deepEqual(sent[1], { client: 'aaaa000000000000', slide: 's3', block: '' }) |
| 114 | clock.advance(20000) |
| 115 | assert.equal(sent.length, 3, 'the beat is running again') |
| 116 | }) |
| 117 | |
| 118 | test('applySet leaves this tab out and answers what changed', () => { |
| 119 | const { presence } = harness() |
| 120 | const me = { client: 'aaaa000000000000', name: 'Me', colour: '#2f6f4f', slide: 's1', block: 'b1' } |
| 121 | const ana = { client: 'bbbb', name: 'Ana Ruiz', colour: '#8a4b2a', slide: 's1', block: '' } |
| 122 | const bo = { client: 'cccc', name: 'bo@example.test', colour: '#3a4f8a', slide: 's2', block: 'b9' } |
| 123 | |
| 124 | let d = presence.applySet([me, ana, bo]) |
| 125 | assert.deepEqual(d.added.map(p => p.client), ['bbbb', 'cccc'], 'both of them, and never me') |
| 126 | assert.deepEqual(d.moved, []) |
| 127 | assert.deepEqual(d.removed, []) |
| 128 | assert.deepEqual(presence.people.map(p => p.name), ['Ana Ruiz', 'bo@example.test']) |
| 129 | |
| 130 | // The same set again — a reconnect sends the whole thing — is no |
| 131 | // news at all. |
| 132 | d = presence.applySet([me, ana, bo]) |
| 133 | assert.deepEqual([d.added, d.moved, d.removed], [[], [], []]) |
| 134 | |
| 135 | d = presence.applySet([me, { ...ana, slide: 's2', block: 'b9' }, bo]) |
| 136 | assert.deepEqual(d.moved.map(p => p.client), ['bbbb']) |
| 137 | assert.deepEqual(d.added, []) |
| 138 | |
| 139 | d = presence.applySet([me]) |
| 140 | assert.deepEqual(d.removed.map(p => p.name), ['Ana Ruiz', 'bo@example.test'], |
| 141 | 'the ones who left are named by their last record') |
| 142 | assert.deepEqual(presence.people, []) |
| 143 | }) |
| 144 | |
| 145 | test('a set with rubbish in it is read for what it does carry', () => { |
| 146 | const map = toMap([null, 'no', { name: 'nameless' }, { client: 'dddd' }], 'mine') |
| 147 | assert.deepEqual(Array.from(map.keys()), ['dddd']) |
| 148 | assert.deepEqual(map.get('dddd'), { client: 'dddd', name: '', colour: '', slide: '', block: '' }) |
| 149 | assert.equal(toMap(undefined, 'mine').size, 0) |
| 150 | // A name or colour changing on its own is not somewhere a person went. |
| 151 | const before = toMap([{ client: 'e', name: 'A', slide: 's1', block: '' }], 'mine') |
| 152 | const after = toMap([{ client: 'e', name: 'A. Ruiz', slide: 's1', block: '' }], 'mine') |
| 153 | assert.deepEqual(diffSets(before, after), { added: [], moved: [], removed: [] }) |
| 154 | }) |
| 155 | |
| 156 | test('initials, and a client id per tab', () => { |
| 157 | assert.equal(initials('Ana Ruiz'), 'AR') |
| 158 | assert.equal(initials('ana.ruiz@example.test'), 'AR') |
| 159 | assert.equal(initials('bo@example.test'), 'BO') |
| 160 | assert.equal(initials(' '), '') |
| 161 | assert.equal(initials(undefined), '') |
| 162 | |
| 163 | const id = newClientId() |
| 164 | assert.match(id, /^[0-9a-f]{16}$/) |
| 165 | assert.notEqual(id, newClientId()) |
| 166 | }) |
| 167 | |