@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.
@@ -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
+ })