1// Checks on the transcript store in store.ts — the part that has to not lose
2// anything, since a transcript is the one thing here meant to survive the
3// call. It touches only localStorage and timers, both shimmed below, so it
4// runs under node once store.ts is bundled:
5//
6// npx esbuild src/transcribe/store.ts --format=esm --outfile=/tmp/store.mjs
7// node src/transcribe/store.test.mjs /tmp/store.mjs
9const backing = new Map()
10globalThis.localStorage = {
11 getItem: k => (backing.has(k) ? backing.get(k) : null),
12 setItem: (k, v) => backing.set(k, String(v)),
13 removeItem: k => backing.delete(k)
14}
15// Saves are debounced through window.setTimeout; the tests drive flush()
16// directly rather than waiting on wall-clock time.
17globalThis.window = {setTimeout: setTimeout, clearTimeout: clearTimeout}
19const {TranscriptStore, formatTranscript} = await import(
20 process.argv[2] ?? '/tmp/store.mjs'
21)
23let failed = 0
24const check = (label, ok, detail = '') => {
25 if (!ok) failed++
26 console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`)
27}
29const open = room => new TranscriptStore(room, () => {})
31// 1. A transcript comes back in the room it was made in, and only there.
32{
33 const a = open('standup')
34 a.append('peer1', 'Ada', 'the build is green')
35 a.append('peer2', 'Grace', 'ship it')
36 a.flush()
38 const reopened = open('standup')
39 check('a transcript survives a rejoin', reopened.items.length === 2)
40 check(
41 'with speakers and text intact',
42 reopened.items[0].name === 'Ada' &&
43 reopened.items[0].text === 'the build is green' &&
44 reopened.items[1].name === 'Grace',
45 reopened.items.map(i => `${i.name}: ${i.text}`).join(' | ')
46 )
47 check('a different room is a different transcript', open('retro').items.length === 0)
48}
50// 2. Consecutive results from one speaker join into a paragraph; a change of
51// speaker starts a new one.
52{
53 const s = open('merge')
54 s.append('peer1', 'Ada', 'one')
55 s.append('peer1', 'Ada', 'two')
56 s.append('peer2', 'Grace', 'three')
57 check(
58 'a speaker’s results merge into a paragraph',
59 s.items.length === 2 && s.items[0].text === 'one two',
60 s.items.map(i => i.text).join(' | ')
61 )
62}
64// 3. A restored transcript is never merged into: a new sitting starts its own
65// paragraph even if the last line was from the same person.
66{
67 const s = open('sitting')
68 s.append('peer1', 'Ada', 'yesterday')
69 s.flush()
70 const next = open('sitting')
71 next.append('peer1', 'Ada', 'today')
72 check(
73 'a new sitting starts a new paragraph',
74 next.items.length === 2,
75 next.items.map(i => i.text).join(' | ')
76 )
77}
79// 4. Sequence numbers are unique after a reload — they are React keys.
80{
81 const s = open('keys')
82 for (let i = 0; i < 5; i++) s.append(`p${i}`, `N${i}`, `line ${i}`)
83 s.flush()
84 const next = open('keys')
85 next.append('px', 'Nx', 'fresh')
86 const seqs = next.items.map(i => i.seq)
87 check('sequence numbers stay unique', new Set(seqs).size === seqs.length)
88}
90// 5. Audio seconds accumulate across sittings, since the bill does.
91{
92 const s = open('cost')
93 s.addAudioSeconds(30)
94 s.append('p', 'N', 'x')
95 s.flush()
96 const next = open('cost')
97 check('audio seconds carry over', next.audioSeconds === 30, `${next.audioSeconds}`)
98 next.addAudioSeconds(15)
99 check('and keep accumulating', next.audioSeconds === 45)
100}
102// 6. A long-running room must not grow without bound on disk. Trimming keeps
103// the recent end, which is the part worth having.
104{
105 const s = open('marathon')
106 const line = 'x'.repeat(1000)
107 for (let i = 0; i < 400; i++) s.append('p', 'N', `${i} ${line}`)
108 s.flush()
109 const stored = backing.get('commonroom:transcript:marathon')
110 const next = open('marathon')
111 check(
112 'storage stays bounded',
113 stored.length < 300_000,
114 `${(stored.length / 1000).toFixed(0)} kB for ${(400 * 1000) / 1000} kB of text`
115 )
116 check('and keeps the most recent lines', next.items.at(-1).text.startsWith('399 '))
117 check('dropping the oldest', !next.items[0].text.startsWith('0 '))
118}
120// 7. Clearing removes it from disk too, not just from the screen.
121{
122 const s = open('gone')
123 s.append('p', 'N', 'secret')
124 s.flush()
125 check('written before clearing', backing.has('commonroom:transcript:gone'))
126 s.clear()
127 check('clear empties the panel', s.items.length === 0)
128 check('and the stored copy', !backing.has('commonroom:transcript:gone'))
129 check('and does not come back on rejoin', open('gone').items.length === 0)
130 check('and resets the cost readout', open('gone').audioSeconds === 0)
131}
133// 8. Corrupt or foreign storage must not break entering a room.
134{
135 backing.set('commonroom:transcript:bad', 'not json at all')
136 check('unparseable storage is ignored', open('bad').items.length === 0)
137 backing.set('commonroom:transcript:bad2', JSON.stringify({v: 99, items: [1, 2]}))
138 check('a future version is ignored', open('bad2').items.length === 0)
139 backing.set(
140 'commonroom:transcript:bad3',
141 JSON.stringify({v: 1, items: [{t: 'kept', ts: 1}, {nope: true}, null]})
142 )
143 check('malformed entries are skipped, good ones kept', open('bad3').items.length === 1)
144}
146// 9. Storage that throws (private mode, quota) must not take the call down.
147{
148 const good = globalThis.localStorage.setItem
149 globalThis.localStorage.setItem = () => {
150 throw new Error('QuotaExceededError')
151 }
152 let threw = false
153 try {
154 const s = open('quota')
155 s.append('p', 'N', 'still on screen')
156 s.flush()
157 check('a failing write leaves the transcript on screen', s.items.length === 1)
158 } catch {
159 threw = true
160 }
161 globalThis.localStorage.setItem = good
162 check('a failing write does not throw', !threw)
163}
165// 10. The exported text is what lands in the clipboard and the .txt file.
166{
167 const s = open('format')
168 s.append('p1', 'Ada', 'hello')
169 const out = formatTranscript(s.items)
170 check('formatted output names the speaker', /\] Ada: hello$/.test(out), out)
171}
173console.log(failed === 0 ? '\nall checks passed' : `\n${failed} FAILED`)
174process.exit(failed === 0 ? 0 : 1)