dsh-deepmoyu 2.3.0 → 2.4.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.
Files changed (90) hide show
  1. package/AGENTS.md +93 -153
  2. package/CHANGELOG.md +36 -0
  3. package/CONTRIBUTING.md +51 -0
  4. package/CONTRIBUTORS.md +3 -7
  5. package/NOTICE.md +3 -3
  6. package/README.md +143 -81
  7. package/SECURITY.md +21 -0
  8. package/assets/screenshots/memory.png +0 -0
  9. package/assets/screenshots/menu.png +0 -0
  10. package/assets/screenshots/workspace.png +0 -0
  11. package/client.js +1404 -441
  12. package/cordis.patch.yml +1 -1
  13. package/docs/ASSETS.md +25 -0
  14. package/docs/DEPLOYMENT.md +102 -0
  15. package/docs/PRIVACY.md +32 -0
  16. package/docs/RELEASE_AUDIT.md +70 -0
  17. package/docs/RELEASING.md +94 -0
  18. package/docs/TEST_MATRIX.md +77 -0
  19. package/docs/assets-permissions.json +20 -0
  20. package/docs/tool-evidence.json +196 -0
  21. package/index.js +1095 -285
  22. package/lib/asr.py +41 -35
  23. package/lib/background-output.mjs +64 -17
  24. package/lib/background-tools.mjs +1 -0
  25. package/lib/companion.mjs +43 -0
  26. package/lib/config-policy.mjs +20 -0
  27. package/lib/favops.py +9 -2
  28. package/lib/ids.mjs +41 -0
  29. package/lib/login-session.mjs +34 -0
  30. package/lib/login.mjs +5 -4
  31. package/lib/mcp.mjs +3 -2
  32. package/lib/memory-center.mjs +74 -0
  33. package/lib/mode.mjs +6 -7
  34. package/lib/model-relay.mjs +5 -4
  35. package/lib/open-directory.mjs +93 -0
  36. package/lib/pace.mjs +92 -12
  37. package/lib/persona.mjs +46 -0
  38. package/lib/product-status.mjs +123 -0
  39. package/lib/prompts.mjs +40 -5
  40. package/lib/pv-production.mjs +280 -0
  41. package/lib/pv-session.mjs +316 -0
  42. package/lib/pv.mjs +398 -306
  43. package/lib/pv.py +84 -22
  44. package/lib/pv_cover.py +108 -26
  45. package/lib/pv_editorial.py +336 -0
  46. package/lib/pv_verify.py +267 -0
  47. package/lib/python-env.mjs +61 -0
  48. package/lib/reply.mjs +10 -3
  49. package/lib/run-settings.mjs +6 -0
  50. package/lib/sticker-automation.mjs +5 -4
  51. package/lib/sticker.mjs +135 -25
  52. package/lib/tool-scope.mjs +65 -0
  53. package/lib/update.mjs +265 -0
  54. package/lib/uvfetch.mjs +165 -153
  55. package/lib/verify.mjs +111 -127
  56. package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
  57. package/package.json +23 -12
  58. package/presets/deepmoyu.patch.yml +1 -1
  59. package/prompts/duty.md +4 -3
  60. package/screenshots.json +5 -0
  61. package/selfcheck.mjs +21 -6
  62. package/vendor/bilibili-mcp/requirements.in +9 -0
  63. package/vendor/bilibili-mcp/requirements.txt +1936 -11
  64. package/vendor/bilibili-mcp/runtime_guard.py +35 -10
  65. package/lib/__pycache__/pv.cpython-310.pyc +0 -0
  66. package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
  67. package/tests/automation-routes.test.mjs +0 -183
  68. package/tests/automation.test.mjs +0 -79
  69. package/tests/background-tools.test.mjs +0 -182
  70. package/tests/background-window.test.mjs +0 -75
  71. package/tests/dup-cross-channel.test.mjs +0 -158
  72. package/tests/manual-browsing.test.mjs +0 -671
  73. package/tests/mcp-restart.test.mjs +0 -131
  74. package/tests/memory-chain.test.mjs +0 -413
  75. package/tests/model-relay.test.mjs +0 -116
  76. package/tests/persona.test.mjs +0 -67
  77. package/tests/pet-run-routes.test.mjs +0 -127
  78. package/tests/pet-run-settings.test.mjs +0 -38
  79. package/tests/pet-session.test.mjs +0 -125
  80. package/tests/pv-stage1.test.mjs +0 -203
  81. package/tests/sticker-routes.test.mjs +0 -141
  82. package/tests/sticker-upload-routes.test.mjs +0 -97
  83. package/tests/sticker-upload.test.mjs +0 -122
  84. package/tests/sticker.test.mjs +0 -169
  85. package/tests/test_guard_cross_channel.py +0 -141
  86. package/tests/test_pv_cover.py +0 -95
  87. package/tests/test_pv_stage1.py +0 -250
  88. package/tests/test_reply_pictures.py +0 -255
  89. package/tests/token-ledger.test.mjs +0 -78
  90. package/tests/verify_pv_sample.py +0 -91
@@ -286,7 +286,27 @@ class RuntimeGuard:
286
286
  return
287
287
  now, factor = self._clock(), self._pace_factor()
288
288
 
289
- def event(kind, stamp, next_stamp=None):
289
+ # JS pins the actual next free time into last[kind].next using the factor that
290
+ # was active when that send happened. Re-deriving it from `recent` with the
291
+ # *current* factor made "switch to careful" lengthen waits that were already
292
+ # fixed (observed: 115s became 355s). Keep the recorded value authoritative.
293
+ last = raw.get("last")
294
+ stored = []
295
+ for kind, row in (last.items() if isinstance(last, dict) else []):
296
+ if isinstance(row, dict):
297
+ at = _finite(row.get("at"))
298
+ nxt = _finite(row.get("next"))
299
+ stored.append((kind, at, nxt if nxt > 0 else None))
300
+ else:
301
+ stored.append((kind, _finite(row), None))
302
+
303
+ def recorded_next(kind, at):
304
+ for s_kind, s_at, s_next in stored:
305
+ if s_kind == kind and s_next and abs(s_at - at) <= 2000:
306
+ return s_next
307
+ return None
308
+
309
+ def event(kind, stamp, next_stamp=None, pinned=False):
290
310
  at = _finite(stamp) / 1000
291
311
  if kind not in PACE or at <= 0:
292
312
  return
@@ -300,8 +320,15 @@ class RuntimeGuard:
300
320
  "WHERE kind=? AND status='sent' AND token<>'' AND ABS(at-?)<=2 "
301
321
  "ORDER BY ABS(at-?) LIMIT 1", (kind, at, at)).fetchone()
302
322
  if native:
303
- conn.execute("UPDATE guard_pace_events SET next=MAX(next,?) WHERE event_key=?",
304
- (free, native["event_key"]))
323
+ # The host already pinned this interval (one random draw, shown in the GUI).
324
+ # Python's own independent draw must not silently lengthen it — otherwise the
325
+ # UI says "40s left" while the native guard still waits 90s.
326
+ if pinned:
327
+ conn.execute("UPDATE guard_pace_events SET next=? WHERE event_key=?",
328
+ (free, native["event_key"]))
329
+ else:
330
+ conn.execute("UPDATE guard_pace_events SET next=MAX(next,?) WHERE event_key=?",
331
+ (free, native["event_key"]))
305
332
  return
306
333
  key = f"seed:{kind}:{int(round(at * 1000))}"
307
334
  conn.execute("INSERT INTO guard_pace_events(event_key,kind,at,next,status,token) "
@@ -311,13 +338,11 @@ class RuntimeGuard:
311
338
  recent = raw.get("recent")
312
339
  for row in (recent[-200:] if isinstance(recent, list) else []):
313
340
  if isinstance(row, dict):
314
- event(str(row.get("k") or ""), row.get("t"))
315
- last = raw.get("last")
316
- for kind, row in (last.items() if isinstance(last, dict) else []):
317
- if isinstance(row, dict):
318
- event(kind, row.get("at"), row.get("next"))
319
- else:
320
- event(kind, row)
341
+ kind, at = str(row.get("k") or ""), _finite(row.get("t"))
342
+ nxt = recorded_next(kind, at)
343
+ event(kind, at, nxt, pinned=nxt is not None)
344
+ for kind, at, nxt in stored:
345
+ event(kind, at, nxt, pinned=nxt is not None)
321
346
  book = self._pace_state(conn)
322
347
  cool = _finite(raw.get("cooldownUntil")) / 1000
323
348
  if cool > book["cooldown_until"]:
Binary file
@@ -1,183 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { EventEmitter } from 'node:events'
4
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'
5
- import { createRequire, syncBuiltinESMExports } from 'node:module'
6
- import { join } from 'node:path'
7
- import { tmpdir } from 'node:os'
8
- import { apply } from '../index.js'
9
- import { learnAdd } from '../lib/learn.mjs'
10
- import { reviewMcpRequest } from '../lib/learn-review-mcp.mjs'
11
-
12
- test('schedule and review routes share background exclusion, persist days and isolate review decisions', async () => {
13
- const root = mkdtempSync(join(tmpdir(), 'fish-automation-routes-')), home = join(root, 'bili-duty')
14
- mkdirSync(home)
15
- const dummy = join(root, 'mock-cli.js'); writeFileSync(dummy, '// spawn is mocked')
16
- writeFileSync(join(home, 'config.json'), JSON.stringify({ dshBin: dummy, learnAutoReview: false, backgroundModelId: 'background/model', backgroundMinutes: 4, learnReviewModelId: 'reviewer/model' }))
17
- const stateFile = join(home, 'state.json'), pending = learnAdd({}, { text: '指出是哪一段有效,不照抄评论', from: 'BV1234567890' })
18
- writeFileSync(stateFile, JSON.stringify({ learn: pending.ledger, unrelated: 77 }))
19
- const children = [], spawned = [], cleanups = [], intervals = [], timers = []
20
- let handler
21
- const ctx = { logger: { info() {} }, get(key) { return key === 'llm' ? { listProviders: () => [{ id: 'background' }, { id: 'reviewer' }] } : key === 'dshHomePath' ? (...parts) => join(root, ...parts) : key === 'agents' ? { list: () => [] } : undefined },
22
- tools: { register: tool => tool }, settings: { describe: () => [] }, on() { return () => {} },
23
- effect(fn) { const cleanup = fn(); if (typeof cleanup === 'function') cleanups.push(cleanup) },
24
- webServer: { register(route) { handler = route.handler; return { dispose() {} } } },
25
- }
26
- const actual = { setTimeout, clearTimeout, setInterval, clearInterval }
27
- globalThis.setTimeout = (fn, ms) => { const t = { fn, ms, unref() {} }; timers.push(t); return t }
28
- globalThis.clearTimeout = () => {}
29
- globalThis.setInterval = (fn, ms) => { const t = { fn, ms, unref() {} }; intervals.push(t); return t }
30
- globalThis.clearInterval = () => {}
31
- const cp = createRequire(import.meta.url)('node:child_process'), actualSpawn = cp.spawn
32
- cp.spawn = (command, args, opts) => {
33
- spawned.push({ command, args, opts })
34
- const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.exitCode = null
35
- child.kill = () => { child.killed = true; return true }; children.push(child); return child
36
- }; syncBuiltinESMExports()
37
- const state = () => JSON.parse(readFileSync(stateFile, 'utf8'))
38
- async function request(path, body = {}, method = 'POST') {
39
- const req = new EventEmitter(); req.url = '/api/bili-duty' + path; req.method = method; req.headers = {}; req.destroy = () => {}
40
- const res = { writeHead(code) { this.code = code }, end(text) { this.body = JSON.parse(text) } }
41
- const promise = handler(req, res)
42
- if (method === 'POST') { req.emit('data', Buffer.from(JSON.stringify(body))); req.emit('end') }
43
- await promise; return { statusCode: res.code, ...res.body }
44
- }
45
- try {
46
- apply(ctx, {})
47
- await new Promise(resolve => setImmediate(resolve))
48
- assert.ok(intervals.some(t => t.ms === 15000)); assert.ok(timers.some(t => t.ms === 1500))
49
- const cfg = await request('/config', {}, 'GET')
50
- assert.equal(cfg.config.scheduleEnabled, false); assert.equal(cfg.config.learnAutoReview, false)
51
- const launch = await request('/schedule', { scheduleEnabled: true, scheduleAllDay: true })
52
- assert.equal(launch.started, true); assert.equal(spawned.length, 1); assert.equal(launch.schedule.ran, true)
53
- assert.match(spawned[0].args.at(-1), /duty\.md/)
54
- assert.equal((await request('/learn/review')).busy, true)
55
- assert.equal((await request('/schedule', { action: 'check' })).busy, true)
56
- children[0].exitCode = 0; children[0].emit('exit', 0)
57
- assert.equal((await request('/learn/review')).busy, true, 'wait for final stdio before releasing worker')
58
- children[0].emit('close', 0)
59
- assert.equal((await request('/schedule', { action: 'check' })).schedule.ran, true)
60
- assert.equal(spawned.length, 1)
61
- assert.equal(state().unrelated, 77)
62
-
63
- // Restarting from the same home keeps today's start consumed.
64
- for (const cleanup of cleanups.splice(0).reverse()) cleanup()
65
- apply(ctx, {})
66
- await new Promise(resolve => setImmediate(resolve))
67
- await request('/schedule', { action: 'check' }); assert.equal(spawned.length, 1)
68
-
69
- // The next failed spawn must release its own marker and retain a retry cooldown.
70
- const fresh = state(); fresh.schedule = {}; writeFileSync(stateFile, JSON.stringify(fresh))
71
- assert.equal((await request('/schedule', { action: 'check' })).started, true)
72
- children[1].emit('error', new Error('ENOENT fixture'))
73
- const afterFailure = await request('/schedule', { action: 'check' })
74
- assert.equal(afterFailure.schedule.ran, false); assert.match(afterFailure.schedule.lastError, /ENOENT/)
75
- assert.equal(spawned.length, 2)
76
- await request('/schedule', { scheduleEnabled: false })
77
-
78
- const review = await request('/learn/review')
79
- assert.equal(review.started, true); assert.equal(spawned.length, 3)
80
- const overlay = readFileSync(join(home, 'headless-learn-review-overlay.yml'), 'utf8')
81
- assert.match(overlay, /mcp-dafeiyu-learn-review/); assert.match(overlay, /learn-review-mcp\.mjs/)
82
- assert.match(overlay, /provider: 'dafeiyu-parent'/); assert.match(overlay, /model: 'reviewer\/model'/)
83
- assert.match(overlay, /model-relay-adapter\.mjs/); assert.doesNotMatch(overlay, /BILI_DUTY_RELAY_CAPABILITY|BILI_MCP_TOOLS/)
84
- assert.match(spawned[2].opts.env.BILI_DUTY_RELAY_CAPABILITY, /^[a-f0-9]{64}$/)
85
- assert.doesNotMatch(spawned[2].args.at(-1), /先看自己的作品/)
86
- const manifestFile = join(home, review.learnReview.id + '.json'), manifest = JSON.parse(readFileSync(manifestFile, 'utf8'))
87
- const reply = reviewMcpRequest({ id: 5, method: 'tools/call', params: { name: 'bili_learn', arguments: { action: 'approve', id: pending.id, why: '具体、可复用且有来源' } } }, manifest, { stateFile, manifestFile })
88
- assert.equal(reply.result.isError, false)
89
- assert.equal(state().learn.items[pending.id].state, 'approved')
90
- await request('/config', { learnReviewModelId: 'background/next-review' })
91
- const liveReview = (await request('/learn/review', {}, 'GET')).learnReview
92
- assert.equal(liveReview.running, true); assert.equal(liveReview.approved, 1); assert.equal(liveReview.remaining, 0)
93
- assert.equal(liveReview.model, 'reviewer/model', 'current run keeps its actual model when next-run settings change')
94
- assert.equal((await request('/learn/review', { action: 'stop' })).stopped, true)
95
- assert.equal(existsSync(manifestFile), false)
96
- const revoked = reviewMcpRequest({ id: 6, method: 'tools/call', params: { name: 'bili_learn', arguments: { action: 'reject', id: pending.id, why: 'stale worker' } } }, manifest, { stateFile, manifestFile })
97
- assert.match(revoked.result.content[0].text, /REVIEW_RUN_REVOKED/)
98
- assert.equal((await request('/pet/start', { mode: 'background' })).busy, true)
99
- children[2].emit('close', null)
100
- await request('/schedule', { action: 'check' })
101
- const reviewDone = await request('/learn/review', {}, 'GET')
102
- assert.equal(reviewDone.learnReview.running, false); assert.equal(reviewDone.learnReview.approved, 1)
103
- assert.equal(reviewDone.learnReview.stopReason, 'user')
104
- assert.equal(state().unrelated, 77)
105
- const log = await request('/pet/log?n=500', {}, 'GET')
106
- for (const type of ['schedule-start', 'schedule-error', 'learn-review-start', 'learn-review-done']) assert.ok(log.entries.some(row => row.type === type), type)
107
- const bad = await request('/learn/review', { action: 'approve' }); assert.equal(bad.statusCode, 400)
108
- } finally {
109
- for (const cleanup of cleanups.reverse()) { try { cleanup() } catch {} }
110
- Object.assign(globalThis, actual); cp.spawn = actualSpawn; syncBuiltinESMExports()
111
- rmSync(root, { recursive: true, force: true })
112
- }
113
- })
114
-
115
- test('default automatic review starts on idle startup, respects retry and off switch, then finishes pending work', async () => {
116
- const root = mkdtempSync(join(tmpdir(), 'fish-auto-review-')), home = join(root, 'bili-duty')
117
- mkdirSync(home)
118
- const dummy = join(root, 'mock-cli.js'); writeFileSync(dummy, '// spawn is mocked')
119
- writeFileSync(join(home, 'config.json'), JSON.stringify({ dshBin: dummy, backgroundModelId: 'reviewer/model' }))
120
- const stateFile = join(home, 'state.json'), pending = learnAdd({}, { text: '说明具体场景,再决定如何接话', from: 'BV1234567890' })
121
- writeFileSync(stateFile, JSON.stringify({ learn: pending.ledger, unrelated: 99 }))
122
- const children = [], spawned = [], cleanups = [], intervals = [], timers = []
123
- let handler
124
- const ctx = { logger: { info() {} }, get(key) { return key === 'llm' ? { listProviders: () => [{ id: 'reviewer' }] } : key === 'dshHomePath' ? (...parts) => join(root, ...parts) : key === 'agents' ? { list: () => [] } : undefined },
125
- tools: { register: tool => tool }, settings: { describe: () => [] }, on() { return () => {} },
126
- effect(fn) { const cleanup = fn(); if (typeof cleanup === 'function') cleanups.push(cleanup) },
127
- webServer: { register(route) { handler = route.handler; return { dispose() {} } } },
128
- }
129
- const actual = { setTimeout, clearTimeout, setInterval, clearInterval }
130
- globalThis.setTimeout = (fn, ms) => { const t = { fn, ms, unref() {} }; timers.push(t); return t }
131
- globalThis.clearTimeout = () => {}
132
- globalThis.setInterval = (fn, ms) => { const t = { fn, ms, unref() {} }; intervals.push(t); return t }
133
- globalThis.clearInterval = () => {}
134
- const cp = createRequire(import.meta.url)('node:child_process'), actualSpawn = cp.spawn
135
- cp.spawn = (command, args, opts) => {
136
- spawned.push({ command, args, opts })
137
- const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.exitCode = null
138
- child.kill = () => true; children.push(child); return child
139
- }; syncBuiltinESMExports()
140
- const flush = () => new Promise(resolve => setImmediate(resolve))
141
- async function request(path, body, method = 'GET') {
142
- const req = new EventEmitter(); req.url = '/api/bili-duty' + path; req.method = method; req.headers = {}; req.destroy = () => {}
143
- const res = { writeHead(code) { this.code = code }, end(text) { this.body = JSON.parse(text) } }
144
- const promise = handler(req, res)
145
- if (method === 'POST') { req.emit('data', Buffer.from(JSON.stringify(body || {}))); req.emit('end') }
146
- await promise; return res.body
147
- }
148
- try {
149
- apply(ctx, {}); await flush()
150
- assert.equal((await request('/config')).config.learnAutoReview, true)
151
- const startup = timers.find(t => t.ms === 1500), interval = intervals.find(t => t.ms === 15000)
152
- startup.fn(); await flush()
153
- assert.equal(spawned.length, 1, 'startup automatically launches pending review without a manual request')
154
- assert.match(spawned[0].args.at(-1), /独立的学习队列模型审核任务/)
155
- assert.equal((await request('/learn/review')).learnReview.model, 'reviewer/model', 'empty reviewer setting uses background model')
156
- interval.fn(); await flush(); assert.equal(spawned.length, 1, 'busy worker is never duplicated')
157
- children[0].emit('close', 0); await flush()
158
- const incomplete = (await request('/learn/review')).learnReview
159
- assert.equal(incomplete.complete, false); assert.equal(incomplete.pending, 1)
160
- assert.ok(incomplete.nextRetryAt > Date.now()); assert.equal(incomplete.remaining, 1)
161
- interval.fn(); await flush(); assert.equal(spawned.length, 1, 'zero exit with unprocessed pending work respects backoff')
162
- const ready = JSON.parse(readFileSync(stateFile, 'utf8'))
163
- ready.learnReview.lastAttemptAt = Date.now() - 180001; ready.learnReview.nextRetryAt = Date.now() - 1
164
- writeFileSync(stateFile, JSON.stringify(ready))
165
- await request('/config', { learnAutoReview: false }, 'POST')
166
- interval.fn(); await flush(); assert.equal(spawned.length, 1, 'explicit off is preserved')
167
- await request('/config', { learnAutoReview: true }, 'POST')
168
- interval.fn(); await flush(); assert.equal(spawned.length, 2, 'idle scheduler resumes pending review after cooldown')
169
- const running = (await request('/learn/review')).learnReview
170
- const manifestFile = join(home, running.id + '.json'), manifest = JSON.parse(readFileSync(manifestFile, 'utf8'))
171
- const reply = reviewMcpRequest({ id: 9, method: 'tools/call', params: { name: 'bili_learn', arguments: { action: 'approve', id: pending.id, why: '具体、可复用且有来源' } } }, manifest, { stateFile, manifestFile })
172
- assert.equal(reply.result.isError, false)
173
- children[1].emit('close', 0); await flush()
174
- const complete = (await request('/learn/review')).learnReview
175
- assert.equal(complete.complete, true); assert.equal(complete.approved, 1); assert.equal(complete.pending, 0)
176
- assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).unrelated, 99)
177
- interval.fn(); await flush(); assert.equal(spawned.length, 2, 'empty pending queue stays idle')
178
- } finally {
179
- for (const cleanup of cleanups.reverse()) { try { cleanup() } catch {} }
180
- Object.assign(globalThis, actual); cp.spawn = actualSpawn; syncBuiltinESMExports()
181
- rmSync(root, { recursive: true, force: true })
182
- }
183
- })
@@ -1,79 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
- import { join } from 'node:path'
5
- import { tmpdir } from 'node:os'
6
- import { AUTOMATION_DEFAULTS, clampAutomationSettings, dailyScheduleStatus, markDailyStarted, releaseDailyStart } from '../lib/automation.mjs'
7
- import { learnAdd, learnDecide, learnInject } from '../lib/learn.mjs'
8
- import { learningReviewBatch, buildLearningReviewPrompt, decideLearningReview } from '../lib/learn-review.mjs'
9
- import { reviewMcpRequest } from '../lib/learn-review-mcp.mjs'
10
- import { readRecordEvents } from '../lib/records.mjs'
11
-
12
- const local = (h, m = 0, day = 8) => new Date(2026, 0, day, h, m)
13
- const config = { scheduleEnabled: true, scheduleStart: '18:00', scheduleEnd: '09:00' }
14
- test('daily windows are local, half-open, cross midnight and persist consumed days', () => {
15
- assert.equal(AUTOMATION_DEFAULTS.scheduleEnabled, false)
16
- assert.equal(AUTOMATION_DEFAULTS.learnAutoReview, true)
17
- assert.equal(dailyScheduleStatus(config, {}, local(17, 59)).due, false)
18
- const open = dailyScheduleStatus(config, {}, local(18))
19
- assert.equal(open.due, true); assert.equal(open.windowDay, '2026-01-08')
20
- assert.equal(dailyScheduleStatus(config, {}, local(8, 59, 9)).windowDay, '2026-01-08')
21
- assert.equal(dailyScheduleStatus(config, {}, local(9)).inWindow, false)
22
- const record = markDailyStarted({}, open.windowDay, local(18), 'one')
23
- assert.equal(dailyScheduleStatus(config, JSON.parse(JSON.stringify(record)), local(8, 0, 9)).due, false)
24
- assert.equal(new Date(dailyScheduleStatus(config, record, local(8, 0, 9)).nextRunAt).getTime(), local(18, 0, 9).getTime())
25
- assert.equal(dailyScheduleStatus(config, record, local(18, 0, 9)).due, true)
26
- assert.equal(releaseDailyStart(record, 'stale', 'error'), record)
27
- assert.equal(dailyScheduleStatus(config, releaseDailyStart(record, 'one', 'ENOENT'), local(19)).due, true)
28
- })
29
- test('all day, empty/equal clocks, ordinary window boundaries and clamps', () => {
30
- const allDay = { ...config, scheduleAllDay: true }
31
- assert.equal(dailyScheduleStatus(allDay, {}, local(0)).due, true)
32
- assert.equal(dailyScheduleStatus({ ...config, scheduleEnabled: false }, {}, local(19)).nextRunAt, null)
33
- assert.equal(dailyScheduleStatus({ ...config, scheduleStart: '09:00', scheduleEnd: '09:00' }, {}, local(12)).due, false)
34
- const sameDay = { ...config, scheduleStart: '09:00', scheduleEnd: '17:00' }
35
- assert.equal(dailyScheduleStatus(sameDay, {}, local(9)).due, true)
36
- assert.equal(dailyScheduleStatus(sameDay, {}, local(17)).due, false)
37
- assert.deepEqual(clampAutomationSettings({ scheduleStart: '3:04', scheduleEnd: '29:99', learnReviewBatchSize: 999, learnReviewMinutes: 0, learnReviewTokenBudget: -1 }),
38
- { scheduleStart: '03:04', scheduleEnd: '09:00', learnReviewBatchSize: 50, learnReviewMinutes: 1, learnReviewTokenBudget: 0 })
39
- })
40
- test('review decisions accept only unchanged pending records with a reason and preserve human decisions', () => {
41
- const a = learnAdd({}, { text: '解释场景,再决定怎么接话', from: 'BV1234567890' })
42
- const batch = learningReviewBatch(a.ledger)
43
- assert.equal(batch.length, 1)
44
- assert.equal(decideLearningReview(a.ledger, batch, { action: 'forget', id: a.id, why: 'test' }).error, 'REVIEW_ACTION_NOT_ALLOWED')
45
- assert.equal(decideLearningReview(a.ledger, batch, { action: 'approve', id: 'missing', why: 'test' }).error, 'REVIEW_ID_NOT_IN_BATCH')
46
- assert.equal(decideLearningReview(a.ledger, batch, { action: 'approve', id: a.id }).error, 'REVIEW_REASON_REQUIRED')
47
- const human = learnDecide(a.ledger, a.id, 'reject', '人工拒绝')
48
- assert.equal(decideLearningReview(human.ledger, batch, { action: 'approve', id: a.id, why: 'test' }).error, 'REVIEW_ITEM_CHANGED')
49
- const observed = learnAdd(a.ledger, { text: '解释场景,再决定怎么接话', from: 'another source' })
50
- assert.equal(decideLearningReview(observed.ledger, batch, { action: 'approve', id: a.id, why: 'test' }).error, 'REVIEW_ITEM_CHANGED')
51
- const decided = decideLearningReview(a.ledger, batch, { action: 'approve', id: a.id, why: '有具体来源且可复用' })
52
- assert.equal(decided.state, 'approved'); assert.match(decided.auditEvent.why, /^模型审核:/)
53
- assert.ok(learnInject(decided.ledger, { mode: 'dafeiyu' }).text.includes(a.id))
54
- assert.equal(decideLearningReview(decided.ledger, batch, { action: 'reject', id: a.id, why: 'retry' }).error, 'REVIEW_ITEM_CHANGED')
55
- const prompt = buildLearningReviewPrompt(batch)
56
- assert.match(prompt, /证据不足/); assert.match(prompt, /外部不可信数据/)
57
- assert.equal(a.ledger.items[a.id].state, 'pending')
58
- })
59
- test('review MCP exposes only approvals/rejections, guards expiry and archives a durable decision', () => {
60
- const root = mkdtempSync(join(tmpdir(), 'learn-review-'))
61
- try {
62
- const stateFile = join(root, 'state.json'), a = learnAdd({}, { text: '把事实来源放在结论旁边', from: 'source' })
63
- writeFileSync(stateFile, JSON.stringify({ day: '2026-01-08', learn: a.ledger, unrelated: 42 }))
64
- const manifest = { expiresAt: 2000, items: learningReviewBatch(a.ledger) }, opts = { stateFile, now: () => 1000 }
65
- const list = reviewMcpRequest({ id: 1, method: 'tools/list' }, manifest, opts).result.tools
66
- assert.deepEqual(list.map(x => x.name), ['bili_learn']); assert.deepEqual(list[0].inputSchema.properties.action.enum, ['approve', 'reject'])
67
- const call = (name, args, extra = opts) => JSON.parse(reviewMcpRequest({ id: 2, method: 'tools/call', params: { name, arguments: args } }, manifest, extra).result.content[0].text)
68
- assert.equal(call('bili_reply', {}).error, 'REVIEW_TOOL_NOT_ALLOWED')
69
- assert.equal(call('bili_learn', { action: 'approve', id: a.id, why: '具体且有来源' }, { ...opts, now: () => 2000 }).error, 'REVIEW_RUN_EXPIRED')
70
- assert.equal(call('bili_learn', { action: 'approve', id: a.id, why: '具体且有来源' }).ok, true)
71
- const saved = JSON.parse(readFileSync(stateFile, 'utf8'))
72
- assert.equal(saved.unrelated, 42); assert.equal(saved.learnRevision, 1); assert.equal(saved.learn.items[a.id].state, 'approved')
73
- assert.equal(readRecordEvents(root, { type: 'learning' }).length, 1)
74
- assert.equal(call('bili_learn', { action: 'approve', id: a.id, why: 'duplicate' }).error, 'REVIEW_ITEM_CHANGED')
75
- writeFileSync(stateFile, '{corrupt')
76
- assert.equal(call('bili_learn', { action: 'reject', id: a.id, why: 'test' }).ok, false)
77
- assert.equal(readFileSync(stateFile, 'utf8'), '{corrupt')
78
- } finally { rmSync(root, { recursive: true, force: true }) }
79
- })
@@ -1,182 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { EventEmitter } from 'node:events'
4
- import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
5
- import { tmpdir } from 'node:os'
6
- import { join } from 'node:path'
7
- import { createRequire, syncBuiltinESMExports } from 'node:module'
8
- import { apply } from '../index.js'
9
- import { createRelayClient, createRelayAdapter } from '../lib/model-relay-adapter.mjs'
10
- import { RELAY_PROVIDER } from '../lib/model-relay.mjs'
11
- import { McpClient } from '../lib/mcp.mjs'
12
- import { backgroundMcpRequest } from '../lib/background-mcp.mjs'
13
- import { backgroundToolNames, toolInputSchema, validateToolArguments } from '../lib/background-tools.mjs'
14
-
15
- // No Python, credentials or Bilibili requests: exercise the real parent wrappers via localhost MCP.
16
- test('background MCP shares host activity, pending learning, usage and live automation switches', async () => {
17
- const root = mkdtempSync(join(tmpdir(), 'fish-tool-bridge-')), home = join(root, 'bili-duty')
18
- mkdirSync(home)
19
- const cli = join(root, 'mock-cli.js'); writeFileSync(cli, '// child spawn mocked')
20
- writeFileSync(join(home, 'config.json'), JSON.stringify({ dshBin: cli, learnAutoReview: false, backgroundModelId: 'buddy/fixture', reportLevel: 'off', replyToComments: true, autoComment: false, autoReplyDm: false }))
21
- const cp = createRequire(import.meta.url)('node:child_process'), originalSpawn = cp.spawn, cleanups = [], registered = new Map()
22
- let handler, childEnv, child
23
- cp.spawn = (command, args, options) => {
24
- childEnv = options.env
25
- child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.exitCode = null
26
- child.kill = () => true
27
- return child
28
- }; syncBuiltinESMExports()
29
- let streams = 0
30
- const llm = {
31
- listProviders: () => [{ id: 'buddy' }],
32
- resolveModelInfo: async (provider, id) => ({ provider, id, name: id, context: { contextWindow: 8192 } }),
33
- prepareCall: async config => ({ config, async *stream() {
34
- yield { type: 'text-delta', index: 0, text: 'fixture reply' }
35
- if (++streams <= 2) yield { type: 'usage', usage: { inputTokens: 17, outputTokens: 3, cacheReadTokens: 10 } }
36
- yield { type: 'finish', reason: { kind: 'stop' } }
37
- } }),
38
- }
39
- const ctx = {
40
- logger: { info() {} },
41
- get: key => key === 'llm' ? llm : key === 'dshHomePath' ? (...parts) => join(root, ...parts) : key === 'agents' ? { list: () => [] } : undefined,
42
- tools: { register(spec) { registered.set(spec.name, spec); return spec } }, settings: { describe: () => [] }, on() { return () => {} },
43
- effect(fn) { const cleanup = fn(); if (typeof cleanup === 'function') cleanups.push(cleanup) },
44
- webServer: { register(route) { handler = route.handler; return { dispose() {} } } },
45
- }
46
- async function request(path, body = {}, method = 'POST') {
47
- const req = new EventEmitter(); req.url = '/api/bili-duty' + path; req.method = method; req.headers = {}; req.destroy = () => {}
48
- const res = { writeHead(code) { this.code = code }, end(text) { this.body = JSON.parse(text) } }
49
- const pending = handler(req, res)
50
- if (method === 'POST') { req.emit('data', Buffer.from(JSON.stringify(body))); req.emit('end') }
51
- await pending; return { statusCode: res.code, ...res.body }
52
- }
53
- try {
54
- apply(ctx, {}); await new Promise(resolve => setImmediate(resolve))
55
- assert.equal((await request('/pet/start', { mode: 'background' })).started, true)
56
- const overlay = readFileSync(join(home, 'headless-overlay.yml'), 'utf8')
57
- assert.match(overlay, /background-mcp\.mjs/); assert.doesNotMatch(overlay, /mcp_server\.py|BILI_DUTY_RELAY_CAPABILITY/)
58
- const client = createRelayClient(childEnv), list = (await backgroundMcpRequest({ id: 1, method: 'tools/list' }, client)).result.tools
59
- assert.ok(list.some(x => x.name === 'bili_reply')); assert.ok(list.some(x => x.name === 'bili_learn'))
60
- for (const name of ['bili_dm_reply', 'bili_moment', 'bili_config', 'bili_run', 'bili_setup']) assert.ok(!list.some(x => x.name === name), name)
61
- assert.ok(!('force' in list.find(x => x.name === 'bili_reply').inputSchema.properties))
62
- let seq = 1
63
- const call = async (name, args) => {
64
- const result = (await backgroundMcpRequest({ id: ++seq, method: 'tools/call', params: { name, arguments: args } }, client)).result
65
- return { isError: result.isError, ...JSON.parse(result.content[0].text) }
66
- }
67
- const say = await call('bili_say', { text: '字幕没有来源,今天先空手。', bvid: null })
68
- assert.equal(say.ok, true)
69
- const activity = (await request('/activity', {}, 'GET')).rows
70
- assert.ok(activity.some(x => x.kind === 'say' && /今天先空手/.test(x.text)))
71
- const learned = await call('bili_learn', { action: 'add', kind: 'style', text: '先回应一个明确细节,再简短表达自己的感受', from: 'fixture:comment' })
72
- assert.equal(learned.ok, true)
73
- const state = () => JSON.parse(readFileSync(join(home, 'state.json'), 'utf8'))
74
- assert.equal(state().learn.items[learned.id].state, 'pending')
75
- const blockedApproval = await call('bili_learn', { action: 'approve', id: learned.id })
76
- assert.equal(blockedApproval.isError, true)
77
- assert.equal(state().learn.items[learned.id].state, 'pending')
78
- assert.equal((await registered.get('bili_soul').execute({ action: 'read' }, {})).reason, 'mode_required')
79
- assert.equal((await call('bili_reply', { bvid: 'BV1234567890', text: 'fixture', force: true })).isError, true)
80
- await request('/config', { replyToComments: false })
81
- const disabled = await call('bili_reply', { bvid: 'BV1234567890', text: 'fixture' })
82
- assert.equal(disabled.isError, true); assert.match(disabled.error, /BACKGROUND_TOOL_DISABLED/)
83
-
84
- const adapter = createRelayAdapter(childEnv)
85
- for (let i = 0; i < 3; i++) for await (const _ of adapter.stream({ provider: RELAY_PROVIDER, model: 'buddy/fixture', messages: [] })) {}
86
- // A state update is async; await an unrelated host request/turn before reading its persisted projection.
87
- await new Promise(resolve => setImmediate(resolve))
88
- assert.equal(state().tokensTotal, 60)
89
- assert.equal(state().tokensToday, 60)
90
- child.exitCode = 0; child.emit('close', 0)
91
- const status = await request('/status', {}, 'GET')
92
- assert.equal(status.backgroundRun.stopReason, 'exit')
93
- assert.equal(status.backgroundRun.requests, 3)
94
- assert.equal(status.backgroundRun.unknownRequests, 1)
95
- const round = status.tokenRounds.entries[0]
96
- assert.equal(round.tokensUnknown, false); assert.deepEqual(round.tokens, { input: 54, output: 6, total: 60 })
97
- assert.equal(round.unknownRequests, 1)
98
- assert.equal(status.tokenRounds.sums.partial, 1)
99
- const log = (await request('/pet/log?n=500', {}, 'GET')).entries
100
- assert.ok(log.some(x => x.type === 'bg-progress' && x.stage === 'tool-done' && /bili_say/.test(x.text)))
101
- assert.ok(log.some(x => x.type === 'bg-done' && x.tokens?.total === 60 && x.unknownRequests === 1))
102
- await assert.rejects(client.post({ action: 'tools-list' }), /撤销或过期/)
103
- } finally {
104
- for (const cleanup of cleanups.reverse()) { try { cleanup() } catch {} }
105
- cp.spawn = originalSpawn; syncBuiltinESMExports()
106
- rmSync(root, { recursive: true, force: true })
107
- }
108
- })
109
-
110
- test('stopping during DM qualification never submits a new send after the pending read returns', async () => {
111
- const root = mkdtempSync(join(tmpdir(), 'fish-stop-dm-')), home = join(root, 'bili-duty')
112
- mkdirSync(home)
113
- const cli = join(root, 'mock-cli.js'); writeFileSync(cli, '// child spawn mocked')
114
- writeFileSync(join(home, 'config.json'), JSON.stringify({ dshBin: cli, learnAutoReview: false, backgroundModelId: 'buddy/fixture', reportLevel: 'off', autoReplyDm: true }))
115
- const cp = createRequire(import.meta.url)('node:child_process'), originalSpawn = cp.spawn, originalCall = McpClient.prototype.call
116
- const cleanups = []
117
- let handler, childEnv, child, reads = 0, sends = 0, releaseRead, readStarted
118
- const reading = new Promise(resolve => { readStarted = resolve })
119
- const released = new Promise(resolve => { releaseRead = resolve })
120
- const calls = []
121
- cp.spawn = (command, args, options) => {
122
- const proc = new EventEmitter(); proc.stdout = new EventEmitter(); proc.stderr = new EventEmitter(); proc.exitCode = null; proc.kill = () => true
123
- if (options.env?.BILI_DUTY_RELAY_CAPABILITY) { child = proc; childEnv = options.env }
124
- return proc
125
- }; syncBuiltinESMExports()
126
- McpClient.prototype.call = async function(name) {
127
- calls.push(name)
128
- if (name === 'bili_dm_list') {
129
- if (++reads === 1) { readStarted(); await released }
130
- return { items: [{ uid: '123', msg_seqno: '456', is_follow: true, days_ago: 0 }] }
131
- }
132
- if (name === 'bili_send_message') { sends++; return { success: true } }
133
- throw new Error('unexpected MCP call ' + name)
134
- }
135
- const llm = { listProviders: () => [{ id: 'buddy' }] }
136
- const ctx = {
137
- logger: { info() {} }, get: key => key === 'llm' ? llm : key === 'dshHomePath' ? (...parts) => join(root, ...parts) : key === 'agents' ? { list: () => [] } : undefined,
138
- tools: { register(spec) { return spec } }, settings: { describe: () => [] }, on() { return () => {} },
139
- effect(fn) { const cleanup = fn(); if (typeof cleanup === 'function') cleanups.push(cleanup) },
140
- webServer: { register(route) { handler = route.handler; return { dispose() {} } } },
141
- }
142
- async function request(path, body = {}) {
143
- const req = new EventEmitter(); req.url = '/api/bili-duty' + path; req.method = 'POST'; req.headers = {}; req.destroy = () => {}
144
- const res = { writeHead(code) { this.code = code }, end(text) { this.body = JSON.parse(text) } }
145
- const pending = handler(req, res); req.emit('data', Buffer.from(JSON.stringify(body))); req.emit('end'); await pending
146
- return res.body
147
- }
148
- try {
149
- apply(ctx, {}); await new Promise(resolve => setImmediate(resolve))
150
- assert.equal((await request('/pet/start', { mode: 'background' })).started, true)
151
- const pending = backgroundMcpRequest({ id: 1, method: 'tools/call', params: { name: 'bili_dm_reply', arguments: { uid: '123', msg_seqno: '456', text: '这条先等一下,小鱼看到了。' } } }, createRelayClient(childEnv))
152
- const settled = pending.then(value => value, error => ({ error }))
153
- await reading
154
- assert.equal((await request('/pet/stop')).stopped, true)
155
- releaseRead(); await settled
156
- await new Promise(resolve => setImmediate(resolve))
157
- assert.equal(sends, 0)
158
- assert.ok(!calls.includes('bili_send_message'))
159
- child.emit('close', null)
160
- // A fresh task still works: cancelled execution context must not bleed into it.
161
- assert.equal((await request('/pet/start', { mode: 'background' })).started, true)
162
- const result = await backgroundMcpRequest({ id: 2, method: 'tools/call', params: { name: 'bili_dm_reply', arguments: { uid: '123', msg_seqno: '456', text: '小鱼刚看到,先慢慢来呀。' } } }, createRelayClient(childEnv))
163
- assert.equal(result.result.isError, false)
164
- assert.equal(JSON.parse(result.result.content[0].text).success, true)
165
- assert.equal(sends, 1)
166
- child.emit('close', 0)
167
- } finally {
168
- releaseRead()
169
- for (const cleanup of cleanups.reverse()) { try { cleanup() } catch {} }
170
- cp.spawn = originalSpawn; syncBuiltinESMExports(); McpClient.prototype.call = originalCall
171
- rmSync(root, { recursive: true, force: true })
172
- }
173
- })
174
-
175
- test('background schemas preserve ID strings and reject fabricated human authorization', () => {
176
- const schema = toolInputSchema({ id: { type: 'string', required: true }, force: { type: 'boolean' }, action: { type: 'string', enum: ['read', 'add'] } })
177
- assert.deepEqual(schema.required, ['id'])
178
- assert.deepEqual(validateToolArguments(schema, { id: '1234567890123456789', action: null }), { id: '1234567890123456789' })
179
- for (const bad of [{ id: 1234567890123456789 }, { id: 'x', force: true }, { id: 'x', action: 'approve' }, {}]) assert.throws(() => validateToolArguments(schema, bad), /BACKGROUND_TOOL_/)
180
- assert.ok(!backgroundToolNames({}).includes('bili_reply'))
181
- assert.ok(backgroundToolNames({ autoReplyDm: true }).includes('bili_dm_reply'))
182
- })
@@ -1,75 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { parseHM, backgroundWindowActive, backgroundWindowEndAt, backgroundWindowLaunch } from '../lib/run-settings.mjs'
4
-
5
- /* 后台「时段」任务的纯函数(2026-10-28):指定 start–end,段内一直刷。
6
- * 单测钉三类边界:常规段 / 跨午夜段(重启后接着刷尾段)/ 非法与相等段。 */
7
- const at = (h, m, day = 28) => new Date(2026, 9, day, h, m, 0, 0) // 2026-10-28(周三)
8
-
9
- test('parseHM 只认 00:00–23:59', () => {
10
- assert.deepEqual(parseHM('20:00'), { h: 20, m: 0 })
11
- assert.deepEqual(parseHM(' 9:05 '), { h: 9, m: 5 })
12
- assert.equal(parseHM('24:00'), null)
13
- assert.equal(parseHM('20:60'), null)
14
- assert.equal(parseHM('20'), null)
15
- assert.equal(parseHM(''), null)
16
- assert.equal(parseHM(null), null)
17
- })
18
-
19
- test('常规段:end 在 start 之后 ⇒ 今天结束;段外也已返回今天的 end(由调用方判断是否过期)', () => {
20
- assert.equal(backgroundWindowEndAt('20:00', '23:00', at(20, 30)), at(23, 0).getTime())
21
- assert.equal(backgroundWindowEndAt('09:00', '12:00', at(10, 0)), at(12, 0).getTime())
22
- assert.equal(backgroundWindowEndAt('09:00', '12:00', at(8, 0)), at(12, 0).getTime())
23
- })
24
-
25
- test('跨午夜段:晚间主体 ⇒ 结束在次日;半夜重启接着刷尾段 ⇒ 结束就在今天', () => {
26
- assert.equal(backgroundWindowEndAt('20:00', '02:00', at(20, 30)), new Date(2026, 9, 29, 2, 0).getTime())
27
- assert.equal(backgroundWindowEndAt('20:00', '02:00', at(23, 59)), new Date(2026, 9, 29, 2, 0).getTime())
28
- assert.equal(backgroundWindowEndAt('20:00', '02:00', at(1, 30)), at(2, 0).getTime())
29
- assert.equal(backgroundWindowEndAt('22:00', '00:00', at(22, 5)), new Date(2026, 9, 29, 0, 0).getTime())
30
- assert.equal(backgroundWindowEndAt('22:00', '00:00', at(0, 10)), at(0, 0).getTime())
31
- })
32
-
33
- test('start==end 或解析失败 ⇒ null(视为没配时段)', () => {
34
- assert.equal(backgroundWindowEndAt('20:00', '20:00', at(20, 0)), null)
35
- assert.equal(backgroundWindowEndAt('abc', '23:00', at(20, 0)), null)
36
- assert.equal(backgroundWindowEndAt('', '', at(20, 0)), null)
37
- })
38
-
39
- test('backgroundWindowActive:[start, end) 左闭右开,跨午夜两段都算', () => {
40
- assert.equal(backgroundWindowActive('20:00', '23:00', at(20, 0)), true)
41
- assert.equal(backgroundWindowActive('20:00', '23:00', at(22, 59)), true)
42
- assert.equal(backgroundWindowActive('20:00', '23:00', at(23, 0)), false)
43
- assert.equal(backgroundWindowActive('20:00', '23:00', at(19, 59)), false)
44
- assert.equal(backgroundWindowActive('20:00', '02:00', at(21, 0)), true)
45
- assert.equal(backgroundWindowActive('20:00', '02:00', at(0, 30)), true)
46
- assert.equal(backgroundWindowActive('20:00', '02:00', at(2, 0)), false)
47
- assert.equal(backgroundWindowActive('20:00', '02:00', at(19, 59)), false)
48
- assert.equal(backgroundWindowActive('20:00', '20:00', at(20, 0)), false)
49
- assert.equal(backgroundWindowActive('x', 'y', at(20, 0)), false)
50
- })
51
-
52
- test('backgroundWindowLaunch:这一跳要不要拉起鱼(常设时段任务的核心决策)', () => {
53
- const cfg = { backgroundWindowStart: '20:00', backgroundWindowEnd: '23:00' }
54
- // 段内、没挂免跑牌 ⇒ 拉起,endAt = 今天 23:00,剩余整算成分钟
55
- const hit = backgroundWindowLaunch(cfg, {}, at(20, 30))
56
- assert.equal(hit.endAt, at(23, 0).getTime())
57
- assert.equal(hit.remainMin, 150)
58
- // 不在段内(含结束边界)/ 没配 / start==end ⇒ null
59
- assert.equal(backgroundWindowLaunch(cfg, {}, at(19, 59)), null)
60
- assert.equal(backgroundWindowLaunch(cfg, {}, at(23, 0)), null)
61
- assert.equal(backgroundWindowLaunch({}, {}, at(21, 0)), null)
62
- assert.equal(backgroundWindowLaunch({ backgroundWindowStart: '20:00', backgroundWindowEnd: '20:00' }, {}, at(20, 0)), null)
63
- // 免跑牌:hold 在未来 ⇒ 不拉(手动停止后调度钩子认账);hold 已过期 ⇒ 照常拉
64
- assert.equal(backgroundWindowLaunch(cfg, { bgWindowHold: at(21, 0).getTime() }, at(20, 30)), null)
65
- const after = backgroundWindowLaunch(cfg, { bgWindowHold: at(20, 0).getTime() }, at(20, 30))
66
- assert.equal(after.endAt, at(23, 0).getTime())
67
- // 跨午夜段:半夜尾段 ⇒ endAt 就是今天;晚间主体 ⇒ 次天
68
- const nightTail = backgroundWindowLaunch({ backgroundWindowStart: '20:00', backgroundWindowEnd: '02:00' }, {}, at(1, 0))
69
- assert.equal(nightTail.endAt, at(2, 0).getTime())
70
- const nightMain = backgroundWindowLaunch({ backgroundWindowStart: '20:00', backgroundWindowEnd: '02:00' }, {}, at(21, 0))
71
- assert.equal(nightMain.endAt, new Date(2026, 9, 29, 2, 0).getTime())
72
- // 段尾剩余 <1 分钟 ⇒ remainMin 兜底 1(别出 0 分钟的任务)
73
- const tail = backgroundWindowLaunch(cfg, {}, at(22, 59))
74
- assert.equal(tail.remainMin, 1)
75
- })