oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
9import test from 'node:test'
10import assert from 'node:assert/strict'
11import { 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).
16function 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.
47function 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
61test('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
79test('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
101test('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
118test('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
145test('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
156test('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