| 1 | import assert from 'node:assert/strict'; |
| 2 | import { test } from 'node:test'; |
| 3 | import { newShareCache, parseShared } from '../src/lib/sharing.ts'; |
| 4 | import { observeArrivals } from '../src/lib/arrivals.ts'; |
| 5 | import type { TimelineEvent, TimelineItem } from '../src/lib/types.ts'; |
| 6 | |
| 7 | interface Row { |
| 8 | id: string; |
| 9 | count: number; |
| 10 | } |
| 11 | |
| 12 | function parse(entry: unknown): Row | null { |
| 13 | const raw = entry as Record<string, unknown>; |
| 14 | return typeof raw['id'] === 'string' ? { id: raw['id'], count: Number(raw['count']) } : null; |
| 15 | } |
| 16 | |
| 17 | test('unchanged entries keep their object and unchanged lists keep their array', () => { |
| 18 | const cache = newShareCache<Row>(); |
| 19 | const first = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse); |
| 20 | const second = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse); |
| 21 | assert.equal(second, first); |
| 22 | }); |
| 23 | |
| 24 | test('a changed entry gets a new object while its neighbours keep theirs', () => { |
| 25 | const cache = newShareCache<Row>(); |
| 26 | const first = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse); |
| 27 | const second = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 2 }], parse); |
| 28 | assert.notEqual(second, first); |
| 29 | assert.equal(second[0], first[0]); |
| 30 | assert.notEqual(second[1], first[1]); |
| 31 | assert.equal(second[1].count, 2); |
| 32 | }); |
| 33 | |
| 34 | test('appended and removed entries change the array', () => { |
| 35 | const cache = newShareCache<Row>(); |
| 36 | const first = parseShared(cache, [{ id: 'a', count: 1 }], parse); |
| 37 | const grown = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse); |
| 38 | assert.notEqual(grown, first); |
| 39 | assert.equal(grown[0], first[0]); |
| 40 | const shrunk = parseShared(cache, [{ id: 'b', count: 1 }], parse); |
| 41 | assert.equal(shrunk[0], grown[1]); |
| 42 | }); |
| 43 | |
| 44 | test('unparsable entries are skipped and non-arrays yield an empty list', () => { |
| 45 | const cache = newShareCache<Row>(); |
| 46 | assert.equal(parseShared(cache, [{ id: 'a', count: 1 }, { nope: true }], parse).length, 1); |
| 47 | assert.deepEqual(parseShared(cache, 'x', parse), []); |
| 48 | }); |
| 49 | |
| 50 | function event(id: string, timestamp_ms: number): TimelineItem { |
| 51 | return { kind: 'event', event: { item_id: id, timestamp_ms, own: false } as TimelineEvent }; |
| 52 | } |
| 53 | |
| 54 | test('arrivals keeps array and lagged item identity across identical updates', () => { |
| 55 | const MINUTE = 60 * 1000; |
| 56 | const known = event('a', 90 * MINUTE); |
| 57 | const late = event('late', 80 * MINUTE); |
| 58 | const first = observeArrivals([known], [known, late], 0, 100 * MINUTE); |
| 59 | assert.ok(first); |
| 60 | const second = observeArrivals(first, [known, late], 0, 101 * MINUTE); |
| 61 | assert.equal(second, first); |
| 62 | }); |
| 63 | |
| 64 | test('identified entries are matched by identity and evicted when absent', () => { |
| 65 | const cache = newShareCache<Row>(); |
| 66 | const identify = (entry: unknown) => (entry as Row).id; |
| 67 | const first = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse, identify); |
| 68 | const second = parseShared(cache, [{ id: 'b', count: 1 }, { id: 'c', count: 1 }], parse, identify); |
| 69 | assert.equal(second[0], first[1]); |
| 70 | assert.deepEqual([...cache.byKey.keys()], ['b', 'c']); |
| 71 | }); |
| 72 | |
| 73 | test('a changed identified entry is reparsed under the same key', () => { |
| 74 | const cache = newShareCache<Row>(); |
| 75 | const identify = (entry: unknown) => (entry as Row).id; |
| 76 | const first = parseShared(cache, [{ id: 'a', count: 1 }], parse, identify); |
| 77 | const second = parseShared(cache, [{ id: 'a', count: 2 }], parse, identify); |
| 78 | assert.notEqual(second[0], first[0]); |
| 79 | assert.equal(second[0].count, 2); |
| 80 | }); |