// The others in the project (plan §7.4): another editor's state read with // care, the others by name without this editor, each person once however // many tabs they have, their choices as frames and their windows as dots, // a colour for whoever the dashboard gave none, initials, their drags; and // this editor's state told only when it changed, a drag at most every 50 ms — // nothing that changes without a cause. import { describe, expect, it } from 'vitest'; import { initialsOf } from '../src/remote/PresenceMarks.js'; import type { Moving } from '../src/moving.js'; import { colorOf, movesOf, peersOf, peopleOf, PRESENCE_COLORS, readPresence, selectionsOf, watchersOf, windowKey, type PresenceState } from '../src/remote/presence.js'; import { MOVING_MILLISECONDS, presencePublisher, type PublisherClock } from '../src/remote/usePresence.js'; const state = (id: string, name: string, selection: string | null = null, windows: string[] = [], color = '#112233', moving: Moving | null = null): PresenceState => ({ v: 1, user: { id, name, color }, selection, windows, moving, }); describe('the others in the project', () => { it('reads a state of the shape it writes, and leaves out any other', () => { expect(readPresence(state('u', 'Mia', 'd', ['desktop:d']))).toEqual(state('u', 'Mia', 'd', ['desktop:d'])); for (const bad of [ null, 'Mia', { ...state('u', 'Mia'), v: 2 }, { ...state('u', 'Mia'), user: null }, { ...state('', 'Mia') }, { ...state('u', 7 as unknown as string) }, { ...state('u', 'x'.repeat(201)) }, { ...state('u', 'Mia'), user: { id: 'u', name: 'Mia', color: 'c'.repeat(33) } }, { ...state('u', 'Mia'), selection: 5 }, { ...state('u', 'Mia'), windows: 'desktop:d' }, { ...state('u', 'Mia'), windows: [7] }, { ...state('u', 'Mia'), windows: Array.from({ length: 101 }, () => 'w') }, ]) expect(readPresence(bad)).toBeUndefined(); }); it("reads another editor's drag, none from an editor that does not tell drags, and leaves out a drag of another shape", () => { const device: Moving = { kind: 'device', id: 'pc', x: 140.5, y: -20 }; const item: Moving = { kind: 'doc', id: 'box', x: 1, y: 2, width: 30, height: 40 }; expect(readPresence(state('u', 'Mia', null, [], '#112233', device))?.moving).toEqual(device); expect(readPresence(state('u', 'Mia', null, [], '#112233', item))?.moving).toEqual(item); const { moving: _left, ...older } = state('u', 'Mia'); expect(readPresence(older)?.moving).toBeNull(); for (const bad of [ 'pc', { kind: 'rotate', id: 'pc', x: 1, y: 1 }, { kind: 'device', id: '', x: 1, y: 1 }, { kind: 'device', id: 7, x: 1, y: 1 }, { kind: 'device', id: 'pc', x: Number.NaN, y: 1 }, { kind: 'device', id: 'pc', x: 1, y: Number.POSITIVE_INFINITY }, { kind: 'device', id: 'pc', x: 1_000_001, y: 1 }, { kind: 'device', id: 'pc', x: '1', y: 1 }, { kind: 'doc', id: 'box', x: 1, y: 2, width: 30 }, { kind: 'doc', id: 'box', x: 1, y: 2, width: 30, height: -2_000_000 }, ]) expect(readPresence({ ...state('u', 'Mia'), moving: bad })).toBeUndefined(); }); it("gathers the others' drags", () => { const device: Moving = { kind: 'device', id: 'pc', x: 1, y: 2 }; const peers = peersOf(new Map([[2, state('b', 'Ben', null, [], '#112233', device)], [3, state('a', 'Ada')]]), 1); expect(movesOf(peers)).toEqual([device]); }); it('lists the other editors by name, without this one and without what it cannot read', () => { const states = new Map([ [1, state('me', 'Zoe')], [2, state('b', 'Ben')], [3, { cursor: { x: 1 } }], [4, state('a', 'Ada')], [5, state('b', 'Ben')], ]); expect(peersOf(states, 1).map((peer) => [peer.clientId, peer.state.user.name])).toEqual([ [4, 'Ada'], [2, 'Ben'], [5, 'Ben'], ]); }); it('counts a person with two tabs once, and leaves out this person in another tab', () => { const peers = peersOf( new Map([ [2, state('b', 'Ben')], [3, state('b', 'Ben')], [4, state('me', 'Zoe')], ]), 1, ); expect(peopleOf(peers, 'me')).toEqual([{ id: 'b', name: 'Ben', color: '#112233' }]); expect(peopleOf(peers).map((user) => user.id)).toEqual(['b', 'me']); }); it('gives each chosen device the colours of who chose it, each person once', () => { const peers = peersOf( new Map([ [2, state('b', 'Ben', 'router', [], '#aa0000')], [3, state('b', 'Ben', 'router', [], '#aa0000')], [4, state('a', 'Ada', 'router', [], '#00aa00')], [5, state('c', 'Cem', 'pc', [], '#0000aa')], [6, state('d', 'Dan', null)], ]), 1, ); expect(selectionsOf(peers)).toEqual( new Map([ ['router', ['#00aa00', '#aa0000']], ['pc', ['#0000aa']], ]), ); }); it('gives each open window who has it open, each person once', () => { const desktop = windowKey('desktop', 'pc'); const peers = peersOf( new Map([ [2, state('b', 'Ben', null, [desktop, 'capture'])], [3, state('b', 'Ben', null, [desktop])], [4, state('a', 'Ada', null, [windowKey('sat', 's')])], ]), 1, ); const watchers = watchersOf(peers); expect([...watchers.keys()].sort()).toEqual(['capture', 'desktop:pc', 'sat:s']); expect(watchers.get(desktop)!.map((user) => user.id)).toEqual(['b']); expect([windowKey('nat', 'g'), windowKey('capture', 'ignored')]).toEqual(['nat:g', 'capture']); }); it("takes the dashboard's colour, and gives one by the id to whoever has none", () => { expect(colorOf({ id: 'x', name: 'X', color: '#ABCDEF' })).toBe('#ABCDEF'); const given = colorOf({ id: 'person-1', name: 'X', color: '' }); expect(PRESENCE_COLORS).toContain(given); expect(colorOf({ id: 'person-1', name: 'Y', color: 'blue' })).toBe(given); // Every editor gives the same colour, whatever its version: pinned. expect([colorOf({ id: '0b8f5f2e-1a3c-4d5e-8f90-123456789abc', name: '', color: '' }), colorOf({ id: 'person-2', name: '', color: '' })]).toEqual(['#e67700', '#1971c2']); const all = new Set(Array.from({ length: 40 }, (_, index) => colorOf({ id: `p${index}`, name: '', color: '' }))); expect(all.size).toBeGreaterThan(4); }); it('writes the initials of the first and the last word', () => { expect([initialsOf('Ada Beispiel'), initialsOf('mia'), initialsOf('Jörg Ömer Weiß'), initialsOf(' '), initialsOf('🐧 Pinguin')]).toEqual(['AB', 'M', 'JW', '?', '🐧P']); }); it("tells this editor's state only when it changed", () => { const set: unknown[] = []; const publish = presencePublisher({ setLocalState: (value) => void set.push(value) }, { id: 'me', name: 'Zoe', color: '' }); publish(null, [], null); publish(null, [], null); publish('pc', [], null); publish('pc', [], null); publish('pc', ['desktop:pc'], null); expect(set).toEqual([ { v: 1, user: { id: 'me', name: 'Zoe', color: '' }, selection: null, windows: [], moving: null }, { v: 1, user: { id: 'me', name: 'Zoe', color: '' }, selection: 'pc', windows: [], moving: null }, { v: 1, user: { id: 'me', name: 'Zoe', color: '' }, selection: 'pc', windows: ['desktop:pc'], moving: null }, ]); }); it('tells a drag at most every 50 ms, its latest place, and its end at once', () => { let time = 0; const timers: { at: number; run: () => void; cancelled: boolean }[] = []; const clock: PublisherClock = { now: () => time, later: (run, milliseconds) => { const timer = { at: time + milliseconds, run, cancelled: false }; timers.push(timer); return timer; }, cancel: (timer) => void ((timer as { cancelled: boolean }).cancelled = true), }; const until = (at: number) => { time = at; for (const timer of timers) if (!timer.cancelled && timer.at <= at && timers.includes(timer)) timers.splice(timers.indexOf(timer), 1), timer.run(); }; const told: unknown[] = []; const publish = presencePublisher({ setLocalState: (value) => void told.push((value as PresenceState).moving) }, { id: 'me', name: 'Zoe', color: '' }, clock); const at = (x: number): Moving => ({ kind: 'device', id: 'pc', x, y: 0 }); publish('pc', [], null); until(10); publish('pc', [], at(1)); until(20); publish('pc', [], at(2)); expect(told).toEqual([null]); until(MOVING_MILLISECONDS); expect(told).toEqual([null, at(2)]); until(80); publish('pc', [], at(3)); until(90); publish('pc', [], at(4)); // Back to what was told last: nothing is left waiting. publish('pc', [], at(2)); until(200); expect(told).toEqual([null, at(2)]); publish('pc', [], at(5)); expect(told).toEqual([null, at(2), at(5)]); until(210); publish('pc', [], at(6)); until(220); publish('pc', [], null); until(400); expect(told).toEqual([null, at(2), at(5), null]); }); });