@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,205 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ BREAK,
6
+ DEFAULT_CONFIG,
7
+ FOCUS,
8
+ MINUTE_MS,
9
+ configFromJSON,
10
+ createRunningState,
11
+ durationForPhase,
12
+ formatRemaining,
13
+ isValidState,
14
+ otherPhase,
15
+ parseConfig,
16
+ pauseState,
17
+ remainingAt,
18
+ resetState,
19
+ resumeState,
20
+ settleState,
21
+ skipState,
22
+ stateFromJSON,
23
+ } from '../src/timer-core.js'
24
+
25
+ const T0 = 1_000_000
26
+
27
+ test('configuration accepts integer minutes and rejects invalid values', () => {
28
+ assert.deepEqual(parseConfig({ focusMinutes: '50', breakMinutes: 10 }), {
29
+ ...DEFAULT_CONFIG,
30
+ focusMinutes: 50,
31
+ breakMinutes: 10,
32
+ })
33
+ for (const value of [0, -1, 1.5, 241, '', null, true, Number.NaN, Infinity]) {
34
+ assert.throws(() => parseConfig({ focusMinutes: value, breakMinutes: 5 }))
35
+ assert.throws(() => parseConfig({ focusMinutes: 25, breakMinutes: value }))
36
+ }
37
+ assert.deepEqual(configFromJSON('{broken'), DEFAULT_CONFIG)
38
+ assert.deepEqual(configFromJSON('{"focusMinutes":50,"breakMinutes":0}'), {
39
+ ...DEFAULT_CONFIG,
40
+ focusMinutes: 50,
41
+ breakMinutes: 5,
42
+ })
43
+ })
44
+
45
+ test('appearance settings accept hex colors and opacity while preserving legacy configs', () => {
46
+ assert.equal(DEFAULT_CONFIG.focusOpacity, 0.1)
47
+ assert.equal(DEFAULT_CONFIG.breakOpacity, 0.3)
48
+ assert.equal(DEFAULT_CONFIG.fontFamily, '等线')
49
+ const custom = parseConfig({
50
+ focusMinutes: 25,
51
+ breakMinutes: 5,
52
+ focusColor: '#123ABC',
53
+ breakColor: '#fedcba',
54
+ focusOpacity: '0.45',
55
+ breakOpacity: 0,
56
+ fontFamily: 'Noto Sans CJK SC',
57
+ })
58
+ assert.equal(custom.focusColor, '#123ABC')
59
+ assert.equal(custom.breakColor, '#fedcba')
60
+ assert.equal(custom.focusOpacity, 0.45)
61
+ assert.equal(custom.breakOpacity, 0)
62
+ assert.equal(custom.fontFamily, 'Noto Sans CJK SC')
63
+ assert.deepEqual(parseConfig({ focusMinutes: 25, breakMinutes: 5 }), DEFAULT_CONFIG)
64
+ for (const focusColor of ['red', '#12345', '#1234567', 123456, null]) {
65
+ assert.throws(() => parseConfig({ focusMinutes: 25, breakMinutes: 5, focusColor }))
66
+ }
67
+ for (const breakOpacity of [-0.01, 1.01, '', Number.NaN, Infinity]) {
68
+ assert.throws(() => parseConfig({ focusMinutes: 25, breakMinutes: 5, breakOpacity }))
69
+ }
70
+ for (const fontFamily of ['', ' ', 'Arial, sans-serif', '"Arial"', 'Arial; color:red', null, 'A'.repeat(81)]) {
71
+ assert.throws(() => parseConfig({ focusMinutes: 25, breakMinutes: 5, fontFamily }))
72
+ }
73
+ const recovered = configFromJSON('{"focusColor":"bad","breakOpacity":2,"fontFamily":"Arial, sans-serif"}')
74
+ assert.equal(recovered.focusColor, DEFAULT_CONFIG.focusColor)
75
+ assert.equal(recovered.breakOpacity, DEFAULT_CONFIG.breakOpacity)
76
+ assert.equal(recovered.fontFamily, DEFAULT_CONFIG.fontFamily)
77
+ })
78
+
79
+ test('phase and duration helpers validate their input', () => {
80
+ assert.equal(otherPhase(FOCUS), BREAK)
81
+ assert.equal(otherPhase(BREAK), FOCUS)
82
+ assert.throws(() => otherPhase('invalid'))
83
+ assert.throws(() => durationForPhase('invalid', DEFAULT_CONFIG))
84
+ assert.equal(durationForPhase(FOCUS, DEFAULT_CONFIG), 25 * MINUTE_MS)
85
+ assert.equal(durationForPhase(BREAK, DEFAULT_CONFIG), 5 * MINUTE_MS)
86
+ })
87
+
88
+ test('first state starts running immediately and remaining uses absolute time', () => {
89
+ const state = createRunningState(FOCUS, DEFAULT_CONFIG, T0)
90
+ assert.equal(state.running, true)
91
+ assert.equal(state.phase, FOCUS)
92
+ assert.equal(state.endsAt, T0 + 25 * MINUTE_MS)
93
+ assert.equal(remainingAt(state, T0 + 60_000), 24 * MINUTE_MS)
94
+ assert.equal(remainingAt(state, T0 - 60_000), 25 * MINUTE_MS)
95
+ })
96
+
97
+ test('exact boundary and boundary + 1ms each advance once', () => {
98
+ const initial = createRunningState(FOCUS, DEFAULT_CONFIG, T0)
99
+ const atBoundary = settleState(initial, DEFAULT_CONFIG, initial.endsAt)
100
+ assert.equal(atBoundary.phase, BREAK)
101
+ assert.equal(atBoundary.startedAt, initial.endsAt)
102
+ assert.equal(atBoundary.endsAt, initial.endsAt + 5 * MINUTE_MS)
103
+
104
+ const after = settleState(initial, DEFAULT_CONFIG, initial.endsAt + 1)
105
+ assert.deepEqual(after, { ...atBoundary, updatedAt: initial.endsAt + 1 })
106
+ assert.equal(settleState(atBoundary, DEFAULT_CONFIG, initial.endsAt), atBoundary)
107
+ })
108
+
109
+ test('wall-clock recovery lands in the correct phase after several cycles', () => {
110
+ const initial = createRunningState(FOCUS, DEFAULT_CONFIG, T0)
111
+ const at65 = settleState(initial, DEFAULT_CONFIG, T0 + 65 * MINUTE_MS)
112
+ assert.equal(at65.phase, FOCUS)
113
+ assert.equal(at65.startedAt, T0 + 60 * MINUTE_MS)
114
+ assert.equal(at65.endsAt, T0 + 85 * MINUTE_MS)
115
+ assert.equal(remainingAt(at65, T0 + 65 * MINUTE_MS), 20 * MINUTE_MS)
116
+
117
+ const at58 = settleState(initial, DEFAULT_CONFIG, T0 + 58 * MINUTE_MS)
118
+ assert.equal(at58.phase, BREAK)
119
+ assert.equal(at58.startedAt, T0 + 55 * MINUTE_MS)
120
+ assert.equal(at58.endsAt, T0 + 60 * MINUTE_MS)
121
+
122
+ const repeated = settleState(at65, DEFAULT_CONFIG, T0 + 65 * MINUTE_MS)
123
+ assert.equal(repeated, at65)
124
+ })
125
+
126
+ test('very long offline intervals are skipped arithmetically', () => {
127
+ const initial = createRunningState(FOCUS, DEFAULT_CONFIG, T0)
128
+ const cycles = 100_000
129
+ const now = T0 + cycles * 30 * MINUTE_MS
130
+ const settled = settleState(initial, DEFAULT_CONFIG, now)
131
+ assert.equal(settled.phase, FOCUS)
132
+ assert.equal(settled.startedAt, now)
133
+ assert.equal(settled.endsAt, now + 25 * MINUTE_MS)
134
+ })
135
+
136
+ test('new settings apply at the next phase without changing the current deadline', () => {
137
+ const nextConfig = { focusMinutes: 50, breakMinutes: 10 }
138
+ const initial = createRunningState(FOCUS, DEFAULT_CONFIG, T0)
139
+ const sameBeforeBoundary = settleState(initial, nextConfig, T0 + 10 * MINUTE_MS)
140
+ assert.equal(sameBeforeBoundary, initial)
141
+
142
+ const inBreak = settleState(initial, nextConfig, T0 + 30 * MINUTE_MS)
143
+ assert.equal(inBreak.phase, BREAK)
144
+ assert.equal(inBreak.startedAt, T0 + 25 * MINUTE_MS)
145
+ assert.equal(inBreak.endsAt, T0 + 35 * MINUTE_MS)
146
+
147
+ const inFocus = settleState(inBreak, nextConfig, T0 + 36 * MINUTE_MS)
148
+ assert.equal(inFocus.phase, FOCUS)
149
+ assert.equal(inFocus.endsAt, T0 + 85 * MINUTE_MS)
150
+ })
151
+
152
+ test('pause, resume, skip and reset preserve explicit semantics', () => {
153
+ const initial = createRunningState(FOCUS, DEFAULT_CONFIG, T0)
154
+ const paused = pauseState(initial, DEFAULT_CONFIG, T0 + 60_000)
155
+ assert.equal(paused.running, false)
156
+ assert.equal(paused.endsAt, null)
157
+ assert.equal(paused.remainingMs, 24 * MINUTE_MS)
158
+ assert.equal(remainingAt(paused, T0 + 10 * MINUTE_MS), 24 * MINUTE_MS)
159
+
160
+ const resumed = resumeState(paused, DEFAULT_CONFIG, T0 + 10 * MINUTE_MS)
161
+ assert.equal(resumed.running, true)
162
+ assert.equal(resumed.endsAt, T0 + 34 * MINUTE_MS)
163
+
164
+ const skipped = skipState(paused, DEFAULT_CONFIG, T0 + 11 * MINUTE_MS)
165
+ assert.equal(skipped.phase, BREAK)
166
+ assert.equal(skipped.running, true)
167
+ assert.equal(skipped.endsAt, T0 + 16 * MINUTE_MS)
168
+
169
+ const reset = resetState({ focusMinutes: 50, breakMinutes: 10 }, T0 + 12 * MINUTE_MS)
170
+ assert.equal(reset.phase, FOCUS)
171
+ assert.equal(reset.endsAt, T0 + 62 * MINUTE_MS)
172
+ })
173
+
174
+ test('a zero-length paused remainder resumes by settling the next phase', () => {
175
+ const initial = createRunningState(FOCUS, DEFAULT_CONFIG, T0)
176
+ const paused = pauseState(initial, DEFAULT_CONFIG, initial.endsAt)
177
+ // pauseState settles first, so construct the legal zero-paused edge directly.
178
+ const zero = { ...paused, phase: FOCUS, remainingMs: 0, durationMs: 25 * MINUTE_MS }
179
+ const resumed = resumeState(zero, DEFAULT_CONFIG, T0 + 50 * MINUTE_MS)
180
+ assert.equal(resumed.phase, BREAK)
181
+ assert.equal(resumed.running, true)
182
+ assert.equal(resumed.endsAt, T0 + 55 * MINUTE_MS)
183
+ })
184
+
185
+ test('stored state validation rejects corruption and old versions', () => {
186
+ const valid = createRunningState(FOCUS, DEFAULT_CONFIG, T0)
187
+ assert.equal(isValidState(valid), true)
188
+ assert.deepEqual(stateFromJSON(JSON.stringify(valid)), valid)
189
+ assert.equal(stateFromJSON('{broken'), null)
190
+ assert.equal(stateFromJSON('null'), null)
191
+ assert.equal(stateFromJSON(JSON.stringify({ ...valid, version: 0 })), null)
192
+ assert.equal(stateFromJSON(JSON.stringify({ ...valid, phase: 'other' })), null)
193
+ assert.equal(stateFromJSON(JSON.stringify({ ...valid, endsAt: -1 })), null)
194
+ assert.equal(stateFromJSON(JSON.stringify({ ...valid, endsAt: null })), null)
195
+ assert.equal(stateFromJSON(JSON.stringify({ ...valid, endsAt: valid.endsAt + 1 })), null)
196
+ })
197
+
198
+ test('remaining display rounds upward and supports long durations', () => {
199
+ assert.equal(formatRemaining(0), '00:00')
200
+ assert.equal(formatRemaining(1), '00:01')
201
+ assert.equal(formatRemaining(1000), '00:01')
202
+ assert.equal(formatRemaining(1001), '00:02')
203
+ assert.equal(formatRemaining(60_000), '01:00')
204
+ assert.equal(formatRemaining(240 * MINUTE_MS), '240:00')
205
+ })
@@ -0,0 +1,37 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { build } from 'esbuild'
5
+
6
+ const root = dirname(dirname(fileURLToPath(import.meta.url)))
7
+ const packageName = '@tom2012/dsh-pomodoro-overlay'
8
+ const outputPath = join(root, 'client', 'client.js')
9
+
10
+ const result = await build({
11
+ entryPoints: [join(root, 'src', 'client.js')],
12
+ bundle: true,
13
+ write: false,
14
+ format: 'cjs',
15
+ platform: 'browser',
16
+ target: 'chrome110',
17
+ external: ['react'],
18
+ minify: true,
19
+ legalComments: 'none',
20
+ })
21
+
22
+ const bundle = result.outputFiles[0].text
23
+ const output = `/* ${packageName} — generated by tools/build.mjs; do not edit directly. */\n` +
24
+ `window.__ModuleLoader__.load({ id: ${JSON.stringify(packageName)}, factory: (require) => {\n` +
25
+ `var module = { exports: {} }; var exports = module.exports;\n` +
26
+ `${bundle}\n` +
27
+ `return module.exports;\n` +
28
+ `} });\n`
29
+
30
+ if (process.argv.includes('--check')) {
31
+ const current = await readFile(outputPath, 'utf8').catch(() => '')
32
+ if (current !== output) throw new Error('client/client.js is stale; run npm run build')
33
+ } else {
34
+ await mkdir(dirname(outputPath), { recursive: true })
35
+ await writeFile(outputPath, output)
36
+ console.log(`Built client/client.js (${Buffer.byteLength(output)} bytes)`)
37
+ }
@@ -0,0 +1,50 @@
1
+ import assert from 'node:assert/strict'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ const root = dirname(dirname(fileURLToPath(import.meta.url)))
7
+ const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
8
+ const patch = await readFile(join(root, 'cordis.patch.yml'), 'utf8')
9
+ const host = await readFile(join(root, 'lib', 'index.js'), 'utf8')
10
+ const client = await readFile(join(root, 'client', 'client.js'), 'utf8')
11
+
12
+ assert.equal(manifest.name, '@tom2012/dsh-pomodoro-overlay')
13
+ assert.equal(manifest.type, 'module')
14
+ assert.equal(manifest.exports['.'], './lib/index.js')
15
+ assert.equal(manifest.exports['./client'], './client/client.js')
16
+ assert.equal(manifest.dsh.bundle.patch, './cordis.patch.yml')
17
+ assert.equal(manifest.dsh.client.platform, 'web')
18
+ assert.equal(manifest.dsh.client.immediately, true)
19
+ for (const dependency of [
20
+ '@deepseek-ai/dsh-client-ui-layout',
21
+ '@deepseek-ai/dsh-client-ui-settings',
22
+ ]) assert(manifest.dsh.client.inject.includes(dependency), `missing client inject: ${dependency}`)
23
+ assert(manifest.dependencies['@deepseek-ai/schemastery'])
24
+ assert(!manifest.dependencies.react, 'React must remain host-provided')
25
+ assert(patch.includes('id: pomodoro-overlay'))
26
+ assert(patch.includes("name: '@tom2012/dsh-pomodoro-overlay'"))
27
+ for (const field of ['focusMinutes', 'breakMinutes', 'focusColor', 'breakColor', 'focusOpacity', 'breakOpacity', 'fontFamily']) {
28
+ assert(host.includes(field), `missing host config: ${field}`)
29
+ }
30
+ assert(host.match(/\.volatile\(\)/g)?.length === 7)
31
+ assert(client.includes('window.__ModuleLoader__.load'))
32
+ assert(client.includes('@tom2012/dsh-pomodoro-overlay'))
33
+ assert(client.includes('shell.overlay'))
34
+ assert(client.includes('settings.section'))
35
+ assert(!client.includes('plugins.bundle.config'), 'settings must not remain in plugin details')
36
+ for (const field of ['focusMinutes', 'breakMinutes', 'focusColor', 'breakColor', 'focusOpacity', 'breakOpacity', 'fontFamily']) {
37
+ assert(client.includes(field), `missing client setting: ${field}`)
38
+ }
39
+ assert(client.includes('data-dpo-preview'))
40
+ assert(client.includes('data-dpo-font-settings'))
41
+ assert(client.includes('DengXian'))
42
+ assert(client.includes('data-dpo-overlay'))
43
+ assert(!client.includes('data-dpo-action'), 'chat overlay controls must stay removed')
44
+ assert(!client.includes('dpo-toolbar'), 'chat overlay toolbar must stay removed')
45
+ assert(!/className:\s*["']dpo-phase["']/.test(client), 'visible phase label must stay removed')
46
+ assert(!client.includes('data-dpo-phase":'), 'visible phase marker must stay removed')
47
+ assert(!client.includes('番茄钟控制'), 'chat overlay control group must stay removed')
48
+ assert(!client.includes('require("react-dom")'))
49
+
50
+ console.log('Package manifest, host config and client bundle checks passed')