@tom2012/dsh-pomodoro-overlay 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +127 -2
- package/client/client.js +236 -0
- package/cordis.patch.yml +4 -0
- package/docs/architecture.md +84 -0
- package/docs/design.md +72 -0
- package/docs/verification.md +102 -0
- package/lib/index.js +28 -0
- package/package.json +77 -4
- package/src/browser-store.js +251 -0
- package/src/chat-geometry.js +49 -0
- package/src/client.js +235 -0
- package/src/font.js +18 -0
- package/src/settings.js +329 -0
- package/src/styles.js +230 -0
- package/src/timer-core.js +254 -0
- package/tests/browser-store.test.js +362 -0
- package/tests/chat-geometry.test.js +84 -0
- package/tests/client-bundle.test.js +106 -0
- package/tests/font.test.js +21 -0
- package/tests/host-config.test.js +67 -0
- package/tests/timer-core.test.js +205 -0
- package/tools/build.mjs +37 -0
- package/tools/check-package.mjs +50 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { createPomodoroStore, STATE_KEY } from '../src/browser-store.js'
|
|
5
|
+
import { BREAK, DEFAULT_CONFIG, FOCUS, MINUTE_MS } from '../src/timer-core.js'
|
|
6
|
+
|
|
7
|
+
const T0 = 1_000_000
|
|
8
|
+
|
|
9
|
+
class FakeStorage {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.values = new Map()
|
|
12
|
+
this.throwGet = false
|
|
13
|
+
this.throwSet = false
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
getItem(key) {
|
|
17
|
+
if (this.throwGet) throw new Error('denied')
|
|
18
|
+
return this.values.has(key) ? this.values.get(key) : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
setItem(key, value) {
|
|
22
|
+
if (this.throwSet) throw new Error('quota')
|
|
23
|
+
this.values.set(key, String(value))
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class FakeEvents {
|
|
28
|
+
constructor() {
|
|
29
|
+
this.listeners = new Map()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
addEventListener(type, listener) {
|
|
33
|
+
if (!this.listeners.has(type)) this.listeners.set(type, new Set())
|
|
34
|
+
this.listeners.get(type).add(listener)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
removeEventListener(type, listener) {
|
|
38
|
+
this.listeners.get(type)?.delete(listener)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
dispatch(type, event = {}) {
|
|
42
|
+
for (const listener of [...(this.listeners.get(type) ?? [])]) listener(event)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
count(type) {
|
|
46
|
+
return this.listeners.get(type)?.size ?? 0
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class FakeTimers {
|
|
51
|
+
constructor() {
|
|
52
|
+
this.nextId = 0
|
|
53
|
+
this.callbacks = new Map()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setInterval(callback) {
|
|
57
|
+
const id = ++this.nextId
|
|
58
|
+
this.callbacks.set(id, callback)
|
|
59
|
+
return id
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
clearInterval(id) {
|
|
63
|
+
this.callbacks.delete(id)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fire() {
|
|
67
|
+
for (const callback of [...this.callbacks.values()]) callback()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function harness({ config = DEFAULT_CONFIG, storage = new FakeStorage(), clock = { value: T0 } } = {}) {
|
|
72
|
+
const events = new FakeEvents()
|
|
73
|
+
const documentRef = new FakeEvents()
|
|
74
|
+
documentRef.hidden = false
|
|
75
|
+
const timers = new FakeTimers()
|
|
76
|
+
const store = createPomodoroStore({
|
|
77
|
+
initialConfig: config,
|
|
78
|
+
storage,
|
|
79
|
+
clock: undefined,
|
|
80
|
+
now: () => clock.value,
|
|
81
|
+
events,
|
|
82
|
+
document: documentRef,
|
|
83
|
+
timers,
|
|
84
|
+
})
|
|
85
|
+
return { store, storage, clock, events, documentRef, timers }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test('store waits for start, then uses the already-ready native configuration', () => {
|
|
89
|
+
const h = harness({ config: { focusMinutes: 50, breakMinutes: 10 } })
|
|
90
|
+
assert.equal(h.store.getSnapshot().active, false)
|
|
91
|
+
h.store.start()
|
|
92
|
+
const snapshot = h.store.getSnapshot()
|
|
93
|
+
assert.equal(snapshot.active, true)
|
|
94
|
+
assert.equal(snapshot.phase, FOCUS)
|
|
95
|
+
assert.equal(snapshot.running, true)
|
|
96
|
+
assert.equal(snapshot.text, '50:00')
|
|
97
|
+
assert.equal(snapshot.state.endsAt, T0 + 50 * MINUTE_MS)
|
|
98
|
+
h.store.dispose()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('timer ticks render from absolute time and automatically cycle', () => {
|
|
102
|
+
const h = harness()
|
|
103
|
+
h.store.start()
|
|
104
|
+
h.clock.value += 1000
|
|
105
|
+
h.timers.fire()
|
|
106
|
+
assert.equal(h.store.getSnapshot().text, '24:59')
|
|
107
|
+
|
|
108
|
+
h.clock.value = T0 + 25 * MINUTE_MS
|
|
109
|
+
h.timers.fire()
|
|
110
|
+
assert.equal(h.store.getSnapshot().phase, BREAK)
|
|
111
|
+
assert.equal(h.store.getSnapshot().text, '05:00')
|
|
112
|
+
|
|
113
|
+
h.clock.value = T0 + 30 * MINUTE_MS
|
|
114
|
+
h.events.dispatch('focus')
|
|
115
|
+
assert.equal(h.store.getSnapshot().phase, FOCUS)
|
|
116
|
+
assert.equal(h.store.getSnapshot().text, '25:00')
|
|
117
|
+
h.store.dispose()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('reopening after multiple offline cycles restores the wall-clock phase once', () => {
|
|
121
|
+
const clock = { value: T0 }
|
|
122
|
+
const storage = new FakeStorage()
|
|
123
|
+
const first = harness({ clock, storage })
|
|
124
|
+
first.store.start()
|
|
125
|
+
first.store.dispose()
|
|
126
|
+
|
|
127
|
+
clock.value = T0 + 65 * MINUTE_MS
|
|
128
|
+
const reopened = harness({ clock, storage })
|
|
129
|
+
reopened.store.start()
|
|
130
|
+
assert.equal(reopened.store.getSnapshot().phase, FOCUS)
|
|
131
|
+
assert.equal(reopened.store.getSnapshot().text, '20:00')
|
|
132
|
+
assert.equal(reopened.store.getSnapshot().state.startedAt, T0 + 60 * MINUTE_MS)
|
|
133
|
+
const endsAt = reopened.store.getSnapshot().state.endsAt
|
|
134
|
+
reopened.store.refresh()
|
|
135
|
+
assert.equal(reopened.store.getSnapshot().state.endsAt, endsAt)
|
|
136
|
+
reopened.store.dispose()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('pause, resume, skip and reset are persisted and do not drift', () => {
|
|
140
|
+
const h = harness()
|
|
141
|
+
h.store.start()
|
|
142
|
+
h.clock.value += 60_000
|
|
143
|
+
h.store.pause()
|
|
144
|
+
assert.equal(h.store.getSnapshot().running, false)
|
|
145
|
+
assert.equal(h.store.getSnapshot().text, '24:00')
|
|
146
|
+
|
|
147
|
+
h.clock.value += 10 * MINUTE_MS
|
|
148
|
+
h.timers.fire()
|
|
149
|
+
assert.equal(h.store.getSnapshot().text, '24:00')
|
|
150
|
+
h.store.resume()
|
|
151
|
+
assert.equal(h.store.getSnapshot().state.endsAt, h.clock.value + 24 * MINUTE_MS)
|
|
152
|
+
|
|
153
|
+
h.store.skip()
|
|
154
|
+
assert.equal(h.store.getSnapshot().phase, BREAK)
|
|
155
|
+
assert.equal(h.store.getSnapshot().running, true)
|
|
156
|
+
h.store.reset()
|
|
157
|
+
assert.equal(h.store.getSnapshot().phase, FOCUS)
|
|
158
|
+
assert.equal(h.store.getSnapshot().text, '25:00')
|
|
159
|
+
h.store.dispose()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('native config updates preserve the current phase and apply next phase', () => {
|
|
163
|
+
const h = harness()
|
|
164
|
+
h.store.start()
|
|
165
|
+
const originalEndsAt = h.store.getSnapshot().state.endsAt
|
|
166
|
+
h.store.setConfig({ focusMinutes: 50, breakMinutes: 10 })
|
|
167
|
+
assert.equal(h.store.getSnapshot().state.endsAt, originalEndsAt)
|
|
168
|
+
|
|
169
|
+
h.clock.value = originalEndsAt
|
|
170
|
+
h.timers.fire()
|
|
171
|
+
assert.equal(h.store.getSnapshot().phase, BREAK)
|
|
172
|
+
assert.equal(h.store.getSnapshot().text, '10:00')
|
|
173
|
+
|
|
174
|
+
h.clock.value = T0 + 35 * MINUTE_MS
|
|
175
|
+
h.timers.fire()
|
|
176
|
+
assert.equal(h.store.getSnapshot().phase, FOCUS)
|
|
177
|
+
assert.equal(h.store.getSnapshot().text, '50:00')
|
|
178
|
+
assert.throws(() => h.store.setConfig({ focusMinutes: 0, breakMinutes: 10 }))
|
|
179
|
+
assert.deepEqual(h.store.getSnapshot().config, { ...DEFAULT_CONFIG, focusMinutes: 50, breakMinutes: 10 })
|
|
180
|
+
h.store.dispose()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('appearance updates apply immediately without resetting the running timeline', () => {
|
|
184
|
+
const h = harness()
|
|
185
|
+
h.store.start()
|
|
186
|
+
const before = h.store.getSnapshot()
|
|
187
|
+
h.store.setConfig({
|
|
188
|
+
...DEFAULT_CONFIG,
|
|
189
|
+
focusColor: '#123456',
|
|
190
|
+
focusOpacity: 0.45,
|
|
191
|
+
breakColor: '#abcdef',
|
|
192
|
+
breakOpacity: 0.65,
|
|
193
|
+
fontFamily: 'Courier New',
|
|
194
|
+
})
|
|
195
|
+
const after = h.store.getSnapshot()
|
|
196
|
+
assert.equal(after.phase, before.phase)
|
|
197
|
+
assert.equal(after.state, before.state)
|
|
198
|
+
assert.equal(after.state.endsAt, before.state.endsAt)
|
|
199
|
+
assert.equal(after.config.focusColor, '#123456')
|
|
200
|
+
assert.equal(after.config.focusOpacity, 0.45)
|
|
201
|
+
assert.equal(after.config.breakColor, '#abcdef')
|
|
202
|
+
assert.equal(after.config.breakOpacity, 0.65)
|
|
203
|
+
assert.equal(after.config.fontFamily, 'Courier New')
|
|
204
|
+
h.store.dispose()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('storage events synchronize phase and deadline without independent ticking', () => {
|
|
208
|
+
const clock = { value: T0 }
|
|
209
|
+
const storage = new FakeStorage()
|
|
210
|
+
const first = harness({ clock, storage })
|
|
211
|
+
const second = harness({ clock, storage })
|
|
212
|
+
first.store.start()
|
|
213
|
+
second.store.start()
|
|
214
|
+
|
|
215
|
+
first.store.skip()
|
|
216
|
+
second.events.dispatch('storage', { key: STATE_KEY, newValue: storage.getItem(STATE_KEY) })
|
|
217
|
+
assert.equal(second.store.getSnapshot().phase, BREAK)
|
|
218
|
+
assert.equal(second.store.getSnapshot().state.endsAt, first.store.getSnapshot().state.endsAt)
|
|
219
|
+
first.store.dispose()
|
|
220
|
+
second.store.dispose()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('corrupt persisted JSON safely falls back to a fresh automatic focus', () => {
|
|
224
|
+
const storage = new FakeStorage()
|
|
225
|
+
storage.setItem(STATE_KEY, '{broken')
|
|
226
|
+
const h = harness({ storage })
|
|
227
|
+
h.store.start()
|
|
228
|
+
assert.equal(h.store.getSnapshot().active, true)
|
|
229
|
+
assert.equal(h.store.getSnapshot().phase, FOCUS)
|
|
230
|
+
assert.equal(h.store.getSnapshot().text, '25:00')
|
|
231
|
+
assert.doesNotThrow(() => JSON.parse(storage.getItem(STATE_KEY)))
|
|
232
|
+
h.store.dispose()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('storage read/write failures leave a working in-memory automatic cycle', () => {
|
|
236
|
+
const storage = new FakeStorage()
|
|
237
|
+
storage.throwGet = true
|
|
238
|
+
storage.throwSet = true
|
|
239
|
+
const h = harness({ storage })
|
|
240
|
+
h.store.start()
|
|
241
|
+
assert.equal(h.store.getSnapshot().active, true)
|
|
242
|
+
assert.equal(h.store.getSnapshot().storageAvailable, false)
|
|
243
|
+
|
|
244
|
+
h.clock.value = T0 + 25 * MINUTE_MS
|
|
245
|
+
h.timers.fire()
|
|
246
|
+
assert.equal(h.store.getSnapshot().phase, BREAK)
|
|
247
|
+
assert.equal(h.store.getSnapshot().text, '05:00')
|
|
248
|
+
assert.equal(h.store.getSnapshot().storageAvailable, false)
|
|
249
|
+
h.store.dispose()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('unchanged ticks do not notify React and dispose cleans all listeners', () => {
|
|
253
|
+
const h = harness()
|
|
254
|
+
let notifications = 0
|
|
255
|
+
const unsubscribe = h.store.subscribe(() => { notifications += 1 })
|
|
256
|
+
h.store.start()
|
|
257
|
+
const afterStart = notifications
|
|
258
|
+
h.clock.value += 100
|
|
259
|
+
h.timers.fire()
|
|
260
|
+
assert.equal(notifications, afterStart)
|
|
261
|
+
h.clock.value += 900
|
|
262
|
+
h.timers.fire()
|
|
263
|
+
assert.equal(notifications, afterStart + 1)
|
|
264
|
+
|
|
265
|
+
h.store.dispose()
|
|
266
|
+
assert.equal(h.timers.callbacks.size, 0)
|
|
267
|
+
assert.equal(h.events.count('storage'), 0)
|
|
268
|
+
assert.equal(h.events.count('focus'), 0)
|
|
269
|
+
assert.equal(h.events.count('pageshow'), 0)
|
|
270
|
+
assert.equal(h.documentRef.count('visibilitychange'), 0)
|
|
271
|
+
unsubscribe()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('config changes first settle an expired phase with the old durations', () => {
|
|
275
|
+
const h = harness()
|
|
276
|
+
h.store.start()
|
|
277
|
+
h.clock.value = T0 + 26 * MINUTE_MS
|
|
278
|
+
h.store.setConfig({ focusMinutes: 50, breakMinutes: 10 })
|
|
279
|
+
const snapshot = h.store.getSnapshot()
|
|
280
|
+
assert.equal(snapshot.phase, BREAK)
|
|
281
|
+
assert.equal(snapshot.text, '04:00')
|
|
282
|
+
assert.equal(snapshot.state.durationMs, 5 * MINUTE_MS)
|
|
283
|
+
assert.equal(snapshot.state.endsAt, T0 + 30 * MINUTE_MS)
|
|
284
|
+
h.store.dispose()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('skip first settles the wall-clock phase instead of skipping a stale phase', () => {
|
|
288
|
+
const h = harness()
|
|
289
|
+
h.store.start()
|
|
290
|
+
h.clock.value = T0 + 26 * MINUTE_MS
|
|
291
|
+
h.store.skip()
|
|
292
|
+
const snapshot = h.store.getSnapshot()
|
|
293
|
+
assert.equal(snapshot.phase, FOCUS)
|
|
294
|
+
assert.equal(snapshot.text, '25:00')
|
|
295
|
+
assert.equal(snapshot.state.startedAt, T0 + 26 * MINUTE_MS)
|
|
296
|
+
assert.equal(snapshot.state.endsAt, T0 + 51 * MINUTE_MS)
|
|
297
|
+
h.store.dispose()
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('an older storage event cannot overwrite a newer local action', () => {
|
|
301
|
+
const h = harness()
|
|
302
|
+
h.store.start()
|
|
303
|
+
const stale = h.storage.getItem(STATE_KEY)
|
|
304
|
+
h.clock.value += 1000
|
|
305
|
+
h.store.skip()
|
|
306
|
+
const current = h.store.getSnapshot().state
|
|
307
|
+
h.events.dispatch('storage', { key: STATE_KEY, newValue: stale })
|
|
308
|
+
assert.equal(h.store.getSnapshot().phase, BREAK)
|
|
309
|
+
assert.equal(h.store.getSnapshot().state.endsAt, current.endsAt)
|
|
310
|
+
h.store.dispose()
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('actions after dispose neither change state nor write persistence', () => {
|
|
314
|
+
const h = harness()
|
|
315
|
+
h.store.start()
|
|
316
|
+
h.store.dispose()
|
|
317
|
+
assert.equal(h.store.getSnapshot().active, false)
|
|
318
|
+
const persisted = h.storage.getItem(STATE_KEY)
|
|
319
|
+
h.store.pause()
|
|
320
|
+
h.store.resume()
|
|
321
|
+
h.store.skip()
|
|
322
|
+
h.store.reset()
|
|
323
|
+
h.store.setConfig({ focusMinutes: 50, breakMinutes: 10 })
|
|
324
|
+
assert.equal(h.storage.getItem(STATE_KEY), persisted)
|
|
325
|
+
assert.equal(h.timers.callbacks.size, 0)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
test('resume on an expired running snapshot settles to the actual phase', () => {
|
|
329
|
+
const h = harness()
|
|
330
|
+
h.store.start()
|
|
331
|
+
h.clock.value = T0 + 26 * MINUTE_MS
|
|
332
|
+
h.store.resume()
|
|
333
|
+
const snapshot = h.store.getSnapshot()
|
|
334
|
+
assert.equal(snapshot.phase, BREAK)
|
|
335
|
+
assert.equal(snapshot.running, true)
|
|
336
|
+
assert.equal(snapshot.text, '04:00')
|
|
337
|
+
assert.equal(snapshot.state.endsAt, T0 + 30 * MINUTE_MS)
|
|
338
|
+
h.store.dispose()
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('a paused snapshot remains paused and fixed after store reconstruction', () => {
|
|
342
|
+
const clock = { value: T0 }
|
|
343
|
+
const storage = new FakeStorage()
|
|
344
|
+
const first = harness({ clock, storage })
|
|
345
|
+
first.store.start()
|
|
346
|
+
clock.value += 60_000
|
|
347
|
+
first.store.pause()
|
|
348
|
+
const remaining = first.store.getSnapshot().state.remainingMs
|
|
349
|
+
first.store.dispose()
|
|
350
|
+
|
|
351
|
+
clock.value += 10 * MINUTE_MS
|
|
352
|
+
const reopened = harness({ clock, storage })
|
|
353
|
+
reopened.store.start()
|
|
354
|
+
assert.equal(reopened.store.getSnapshot().phase, FOCUS)
|
|
355
|
+
assert.equal(reopened.store.getSnapshot().running, false)
|
|
356
|
+
assert.equal(reopened.store.getSnapshot().state.remainingMs, remaining)
|
|
357
|
+
assert.equal(reopened.store.getSnapshot().text, '24:00')
|
|
358
|
+
clock.value += 10 * MINUTE_MS
|
|
359
|
+
reopened.timers.fire()
|
|
360
|
+
assert.equal(reopened.store.getSnapshot().text, '24:00')
|
|
361
|
+
reopened.store.dispose()
|
|
362
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CHAT_FLOW_SELECTOR,
|
|
6
|
+
CHAT_SELECTOR,
|
|
7
|
+
COMPOSER_SELECTOR,
|
|
8
|
+
chatOverlayRect,
|
|
9
|
+
findChatOverlay,
|
|
10
|
+
} from '../src/chat-geometry.js'
|
|
11
|
+
|
|
12
|
+
function rect(left, top, width, height) {
|
|
13
|
+
return { left, top, width, height, right: left + width, bottom: top + height }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fakeChat({
|
|
17
|
+
body = rect(200, 100, 800, 800),
|
|
18
|
+
composer = rect(200, 700, 800, 200),
|
|
19
|
+
flow = true,
|
|
20
|
+
connected = true,
|
|
21
|
+
hidden = false,
|
|
22
|
+
} = {}) {
|
|
23
|
+
const flowNode = flow ? {} : null
|
|
24
|
+
const composerNode = composer ? { getBoundingClientRect: () => composer } : null
|
|
25
|
+
return {
|
|
26
|
+
isConnected: connected,
|
|
27
|
+
closest: () => hidden ? {} : null,
|
|
28
|
+
querySelector(selector) {
|
|
29
|
+
if (selector === CHAT_FLOW_SELECTOR) return flowNode
|
|
30
|
+
if (selector === COMPOSER_SELECTOR) return composerNode
|
|
31
|
+
return null
|
|
32
|
+
},
|
|
33
|
+
getBoundingClientRect: () => body,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('overlay uses chat bounds and excludes the composer', () => {
|
|
38
|
+
const value = chatOverlayRect(fakeChat())
|
|
39
|
+
assert.deepEqual(value, {
|
|
40
|
+
left: 200,
|
|
41
|
+
top: 100,
|
|
42
|
+
right: 1000,
|
|
43
|
+
bottom: 700,
|
|
44
|
+
width: 800,
|
|
45
|
+
height: 600,
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('composer below the viewport is clamped to the chat bottom', () => {
|
|
50
|
+
const value = chatOverlayRect(fakeChat({ composer: rect(200, 1000, 800, 200) }))
|
|
51
|
+
assert.equal(value.bottom, 900)
|
|
52
|
+
assert.equal(value.height, 800)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('missing composer leaves the full chat rectangle', () => {
|
|
56
|
+
const value = chatOverlayRect(fakeChat({ composer: null }))
|
|
57
|
+
assert.equal(value.bottom, 900)
|
|
58
|
+
assert.equal(value.height, 800)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('invalid or inactive chat geometry fails closed', () => {
|
|
62
|
+
assert.equal(chatOverlayRect(null), null)
|
|
63
|
+
assert.equal(chatOverlayRect(fakeChat({ flow: false })), null)
|
|
64
|
+
assert.equal(chatOverlayRect(fakeChat({ connected: false })), null)
|
|
65
|
+
assert.equal(chatOverlayRect(fakeChat({ hidden: true })), null)
|
|
66
|
+
assert.equal(chatOverlayRect(fakeChat({ body: rect(0, 0, 0, 0) })), null)
|
|
67
|
+
assert.equal(chatOverlayRect(fakeChat({ body: rect(0, 0, 100, 500) })), null)
|
|
68
|
+
assert.equal(chatOverlayRect(fakeChat({ composer: rect(0, 50, 100, 50) })), null)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('largest visible conversation wins over embedded or invalid candidates', () => {
|
|
72
|
+
const embedded = fakeChat({ body: rect(0, 0, 300, 300) })
|
|
73
|
+
const main = fakeChat({ body: rect(300, 0, 1000, 900) })
|
|
74
|
+
const inactive = fakeChat({ flow: false, body: rect(0, 0, 2000, 2000) })
|
|
75
|
+
const documentRef = {
|
|
76
|
+
querySelectorAll(selector) {
|
|
77
|
+
assert.equal(selector, CHAT_SELECTOR)
|
|
78
|
+
return [embedded, inactive, main]
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
const match = findChatOverlay(documentRef)
|
|
82
|
+
assert.equal(match.chat, main)
|
|
83
|
+
assert.equal(match.rect.width, 1000)
|
|
84
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import vm from 'node:vm'
|
|
5
|
+
|
|
6
|
+
import { STATE_KEY } from '../src/browser-store.js'
|
|
7
|
+
|
|
8
|
+
test('built client loads, auto-starts after config readiness and cleans up', async () => {
|
|
9
|
+
const code = await readFile(new URL('../client/client.js', import.meta.url), 'utf8')
|
|
10
|
+
const listeners = new Map()
|
|
11
|
+
const values = new Map()
|
|
12
|
+
const timers = new Map()
|
|
13
|
+
let timerId = 0
|
|
14
|
+
let exported
|
|
15
|
+
let removedStyle = false
|
|
16
|
+
const style = {
|
|
17
|
+
setAttribute() {},
|
|
18
|
+
remove() { removedStyle = true },
|
|
19
|
+
}
|
|
20
|
+
const documentRef = {
|
|
21
|
+
hidden: false,
|
|
22
|
+
head: { appendChild() {} },
|
|
23
|
+
createElement(name) {
|
|
24
|
+
assert.equal(name, 'style')
|
|
25
|
+
return style
|
|
26
|
+
},
|
|
27
|
+
addEventListener(type, listener) { listeners.set(`document:${type}`, listener) },
|
|
28
|
+
removeEventListener(type) { listeners.delete(`document:${type}`) },
|
|
29
|
+
}
|
|
30
|
+
const windowRef = {
|
|
31
|
+
localStorage: {
|
|
32
|
+
getItem: (key) => values.get(key) ?? null,
|
|
33
|
+
setItem: (key, value) => values.set(key, value),
|
|
34
|
+
},
|
|
35
|
+
__ModuleLoader__: {
|
|
36
|
+
load({ id, factory }) {
|
|
37
|
+
assert.equal(id, '@tom2012/dsh-pomodoro-overlay')
|
|
38
|
+
exported = factory((name) => {
|
|
39
|
+
assert.equal(name, 'react')
|
|
40
|
+
return { createElement: () => ({}) }
|
|
41
|
+
})
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
addEventListener(type, listener) { listeners.set(`window:${type}`, listener) },
|
|
45
|
+
removeEventListener(type) { listeners.delete(`window:${type}`) },
|
|
46
|
+
setInterval(callback) {
|
|
47
|
+
const id = ++timerId
|
|
48
|
+
timers.set(id, callback)
|
|
49
|
+
return id
|
|
50
|
+
},
|
|
51
|
+
clearInterval(id) { timers.delete(id) },
|
|
52
|
+
}
|
|
53
|
+
vm.runInNewContext(code, { window: windowRef, document: documentRef, console })
|
|
54
|
+
assert.equal(exported.name, '@tom2012/dsh-pomodoro-overlay')
|
|
55
|
+
assert.deepEqual(Array.from(exported.inject), ['slots', 'configForms'])
|
|
56
|
+
|
|
57
|
+
const form = {
|
|
58
|
+
getSnapshot: () => ({
|
|
59
|
+
status: 'ready',
|
|
60
|
+
value: { focusMinutes: 50, breakMinutes: 10 },
|
|
61
|
+
revision: 1,
|
|
62
|
+
writable: true,
|
|
63
|
+
}),
|
|
64
|
+
subscribe: () => () => {},
|
|
65
|
+
}
|
|
66
|
+
const effects = []
|
|
67
|
+
const registrations = []
|
|
68
|
+
const ctx = {
|
|
69
|
+
configForms: {
|
|
70
|
+
get(id) {
|
|
71
|
+
assert.equal(id, 'pomodoro-overlay')
|
|
72
|
+
return form
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
effect(callback) {
|
|
76
|
+
const dispose = callback()
|
|
77
|
+
if (dispose) effects.push(dispose)
|
|
78
|
+
},
|
|
79
|
+
slots: {
|
|
80
|
+
inject(name, callback) { callback() },
|
|
81
|
+
register(options, component) {
|
|
82
|
+
registrations.push({ options, component })
|
|
83
|
+
return () => {}
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
logger: { error() {} },
|
|
87
|
+
}
|
|
88
|
+
exported.apply(ctx)
|
|
89
|
+
assert.equal(registrations.length, 2)
|
|
90
|
+
assert.deepEqual(registrations.map((entry) => entry.options.name), [
|
|
91
|
+
'shell.overlay',
|
|
92
|
+
'settings.section',
|
|
93
|
+
])
|
|
94
|
+
assert.equal(registrations[1].options.id, 'pomodoro')
|
|
95
|
+
assert.equal(registrations[1].options.label, '番茄钟')
|
|
96
|
+
assert(values.has(STATE_KEY), 'first ready config must auto-create persisted focus state')
|
|
97
|
+
const state = JSON.parse(values.get(STATE_KEY))
|
|
98
|
+
assert.equal(state.phase, 'focus')
|
|
99
|
+
assert.equal(state.durationMs, 50 * 60_000)
|
|
100
|
+
assert.equal(timers.size, 1)
|
|
101
|
+
|
|
102
|
+
for (const dispose of effects.reverse()) dispose()
|
|
103
|
+
assert.equal(removedStyle, true)
|
|
104
|
+
assert.equal(timers.size, 0)
|
|
105
|
+
assert.equal(listeners.size, 0)
|
|
106
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { FONT_OPTIONS, fontStack } from '../src/font.js'
|
|
5
|
+
|
|
6
|
+
test('default font is DengXian with a local Chinese-name fallback', () => {
|
|
7
|
+
assert.equal(FONT_OPTIONS[0].value, '等线')
|
|
8
|
+
assert.equal(fontStack('等线'), '"DengXian", "等线", sans-serif')
|
|
9
|
+
assert(fontStack('monospace').includes('ui-monospace'))
|
|
10
|
+
assert(fontStack('system-ui').includes('system-ui'))
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('custom font names use local stacks without remote font loading', () => {
|
|
14
|
+
for (const option of FONT_OPTIONS) {
|
|
15
|
+
assert.equal(fontStack(option.value), option.stack)
|
|
16
|
+
assert(!option.stack.includes('http'))
|
|
17
|
+
}
|
|
18
|
+
assert.equal(fontStack('Noto Sans CJK SC'), '"Noto Sans CJK SC", sans-serif')
|
|
19
|
+
assert.equal(fontStack(''), 'sans-serif')
|
|
20
|
+
assert(fontStack('A"B').includes('\\"'))
|
|
21
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { Config, apply } from '../lib/index.js'
|
|
5
|
+
|
|
6
|
+
test('host Config exposes volatile defaults and validates all settings', () => {
|
|
7
|
+
const defaults = Config({})
|
|
8
|
+
assert.equal(defaults.focusMinutes.get(), 25)
|
|
9
|
+
assert.equal(defaults.breakMinutes.get(), 5)
|
|
10
|
+
assert.equal(defaults.focusColor.get(), '#9ca3af')
|
|
11
|
+
assert.equal(defaults.breakColor.get(), '#ef4444')
|
|
12
|
+
assert.equal(defaults.focusOpacity.get(), 0.1)
|
|
13
|
+
assert.equal(defaults.breakOpacity.get(), 0.3)
|
|
14
|
+
assert.equal(defaults.fontFamily.get(), '等线')
|
|
15
|
+
|
|
16
|
+
const custom = Config({
|
|
17
|
+
focusMinutes: 50,
|
|
18
|
+
breakMinutes: 10,
|
|
19
|
+
focusColor: '#123456',
|
|
20
|
+
breakColor: '#ABCDEF',
|
|
21
|
+
focusOpacity: 0.45,
|
|
22
|
+
breakOpacity: 0,
|
|
23
|
+
fontFamily: 'Noto Sans CJK SC',
|
|
24
|
+
})
|
|
25
|
+
assert.equal(custom.focusMinutes.get(), 50)
|
|
26
|
+
assert.equal(custom.breakMinutes.get(), 10)
|
|
27
|
+
assert.equal(custom.focusColor.get(), '#123456')
|
|
28
|
+
assert.equal(custom.breakColor.get(), '#ABCDEF')
|
|
29
|
+
assert.equal(custom.focusOpacity.get(), 0.45)
|
|
30
|
+
assert.equal(custom.breakOpacity.get(), 0)
|
|
31
|
+
assert.equal(custom.fontFamily.get(), 'Noto Sans CJK SC')
|
|
32
|
+
assert.throws(() => Config({ focusMinutes: 0, breakMinutes: 5 }))
|
|
33
|
+
assert.throws(() => Config({ focusMinutes: 25, breakMinutes: 241 }))
|
|
34
|
+
assert.throws(() => Config({ focusMinutes: 1.5, breakMinutes: 5 }))
|
|
35
|
+
assert.throws(() => Config({ focusColor: 'gray' }))
|
|
36
|
+
assert.throws(() => Config({ breakColor: '#fff' }))
|
|
37
|
+
assert.throws(() => Config({ focusOpacity: -0.01 }))
|
|
38
|
+
assert.throws(() => Config({ breakOpacity: 1.01 }))
|
|
39
|
+
assert.throws(() => Config({ fontFamily: 'Arial, sans-serif' }))
|
|
40
|
+
assert.throws(() => Config({ fontFamily: '"Arial"' }))
|
|
41
|
+
assert.throws(() => Config({ fontFamily: '' }))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('host applies the custom-settings policy in the optional settings scope', () => {
|
|
45
|
+
const fiber = {}
|
|
46
|
+
let configured = null
|
|
47
|
+
const child = {
|
|
48
|
+
settings: {
|
|
49
|
+
configure(options, owner) {
|
|
50
|
+
configured = { options, owner }
|
|
51
|
+
return () => {}
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
effect(callback) {
|
|
55
|
+
callback()
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
const ctx = {
|
|
59
|
+
fiber,
|
|
60
|
+
inject(dependencies, callback) {
|
|
61
|
+
assert.deepEqual(dependencies, ['settings'])
|
|
62
|
+
callback(child)
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
apply(ctx)
|
|
66
|
+
assert.deepEqual(configured, { options: { auto: false }, owner: fiber })
|
|
67
|
+
})
|