'use strict'; const test = require('node:test'); const assert = require('node:assert'); const { ScheduledMessenger, normalizeTasks, clampInterval, parseCron, nextCronTime, MIN_INTERVAL_SECONDS, MAX_TASKS } = require('../src/bot/scheduledMessenger'); const { Logger } = require('../src/util/logger'); const { EventEmitter } = require('events'); /** 造一个会发事件、可控的假 BotManager(用于「发送结果确认」测试) */ function emittableManager(options) { const opts = options || {}; const manager = new EventEmitter(); manager.connection = { username: 'BotA' }; manager.sent = []; manager.canSendChat = () => opts.canSend !== false; manager.sendChat = (content) => { manager.sent.push(content); return true; }; return manager; } /** 造一个可控的假 BotManager */ function fakeManager(options) { const opts = options || {}; const sent = []; return { connection: { username: 'BotA' }, canSendChat: () => opts.canSend !== false, sendChat: (content) => { sent.push(content); return true; }, sent }; } test('normalizeTasks 丢弃空内容、补齐默认值、按 / 前缀判定类型', () => { const tasks = normalizeTasks([ { id: 'a', content: '/u restore confirm', intervalSeconds: 300, label: '恢复民心' }, { content: '大家好', intervalSeconds: 60 }, { content: ' ' }, null, { intervalSeconds: 10 } ]); assert.strictEqual(tasks.length, 2); assert.strictEqual(tasks[0].kind, 'command', '以 / 开头应识别为指令'); assert.strictEqual(tasks[0].enabled, true, '默认启用'); assert.strictEqual(tasks[1].kind, 'chat'); assert.ok(tasks[1].id, '缺 id 时应自动生成'); }); test('normalizeTasks 钳制间隔到合理范围并限制条数', () => { assert.strictEqual(clampInterval(1), MIN_INTERVAL_SECONDS, '间隔不能小于下限,否则等同刷屏'); assert.strictEqual(clampInterval(999999), 86400); assert.strictEqual(clampInterval('abc'), 300, '非法值回退到 5 分钟'); const many = []; for (let i = 0; i < MAX_TASKS + 10; i++) many.push({ content: 'm' + i, intervalSeconds: 60 }); assert.strictEqual(normalizeTasks(many).length, MAX_TASKS); }); test('id 重复时自动改名,保证每个任务独立计时', () => { const tasks = normalizeTasks([ { id: 'x', content: 'a', intervalSeconds: 60 }, { id: 'x', content: 'b', intervalSeconds: 60 } ]); assert.strictEqual(tasks.length, 2); assert.notStrictEqual(tasks[0].id, tasks[1].id); }); test('到点才发送,且按间隔重复', () => { const manager = fakeManager(); const messenger = new ScheduledMessenger(manager, null); const t0 = 1_000_000; messenger.setTasks([{ id: 'restore', content: '/u restore confirm', intervalSeconds: 300 }], t0); assert.strictEqual(messenger.tick(t0), 0, '刚设置完不应立即发送'); assert.strictEqual(messenger.tick(t0 + 299_000), 0, '未到间隔不发'); assert.strictEqual(messenger.tick(t0 + 300_000), 1, '到点应发送'); assert.deepStrictEqual(manager.sent, ['/u restore confirm']); assert.strictEqual(messenger.tick(t0 + 599_000), 0); assert.strictEqual(messenger.tick(t0 + 600_000), 1, '第二个周期应再发一次'); assert.strictEqual(manager.sent.length, 2); }); test('未连接 / 未通过登录验证时跳过本次并顺延,不补发', () => { const manager = fakeManager({ canSend: false }); const messenger = new ScheduledMessenger(manager, null); const t0 = 1_000_000; messenger.setTasks([{ id: 'a', content: 'hi', intervalSeconds: 60 }], t0); assert.strictEqual(messenger.tick(t0 + 60_000), 0, '未就绪时不发送'); assert.deepStrictEqual(manager.sent, []); // 顺延一个周期:即使中间恢复也不会把漏掉的补发出来 manager.canSendChat = () => true; assert.strictEqual(messenger.tick(t0 + 61_000), 0, '不能补发上一周期的内容'); assert.strictEqual(messenger.tick(t0 + 120_000), 1); assert.deepStrictEqual(manager.sent, ['hi']); }); test('禁用的任务不发送', () => { const manager = fakeManager(); const messenger = new ScheduledMessenger(manager, null); messenger.setTasks([{ id: 'a', content: 'hi', intervalSeconds: 60, enabled: false }], 1_000_000); assert.strictEqual(messenger.tick(1_000_000 + 120_000), 0); assert.deepStrictEqual(manager.sent, []); }); test('多个任务各自独立计时', () => { const manager = fakeManager(); const messenger = new ScheduledMessenger(manager, null); const t0 = 1_000_000; messenger.setTasks([ { id: 'fast', content: 'A', intervalSeconds: 30 }, { id: 'slow', content: 'B', intervalSeconds: 300 } ], t0); assert.strictEqual(messenger.tick(t0 + 30_000), 1); assert.strictEqual(messenger.tick(t0 + 60_000), 1); assert.strictEqual(messenger.tick(t0 + 300_000), 2, '两个任务在同一时刻都到点'); assert.deepStrictEqual(manager.sent, ['A', 'A', 'A', 'B']); }); test('snapshot 暴露剩余秒数供控制台展示', () => { const manager = fakeManager(); const messenger = new ScheduledMessenger(manager, null); messenger.setTasks([{ id: 'a', label: '恢复', content: '/u restore confirm', intervalSeconds: 300 }], Date.now()); const snap = messenger.snapshot(); assert.strictEqual(snap.length, 1); assert.strictEqual(snap[0].label, '恢复'); assert.strictEqual(snap[0].kind, 'command'); assert.ok(snap[0].nextDueAt > 0); assert.ok(snap[0].nextInSeconds <= 300 && snap[0].nextInSeconds >= 0); }); test('setTasks 整体替换并重置计时', () => { const manager = fakeManager(); const messenger = new ScheduledMessenger(manager, null); const t0 = 1_000_000; messenger.setTasks([{ id: 'a', content: 'A', intervalSeconds: 60 }], t0); messenger.tick(t0 + 60_000); assert.strictEqual(manager.sent.length, 1); messenger.setTasks([{ id: 'b', content: 'B', intervalSeconds: 60 }], t0 + 60_000); assert.strictEqual(messenger.snapshot().length, 1); assert.strictEqual(messenger.tick(t0 + 61_000), 0, '替换后重新计时,不应立刻发送'); assert.strictEqual(messenger.tick(t0 + 200_000), 1); assert.deepStrictEqual(manager.sent, ['A', 'B']); }); test('start / stop 控制心跳定时器', () => { const messenger = new ScheduledMessenger(fakeManager(), null); messenger.start(); assert.ok(messenger._timer, 'start 后应有定时器'); const timer = messenger._timer; messenger.start(); assert.strictEqual(messenger._timer, timer, '重复 start 不应叠加定时器'); messenger.stop(); assert.strictEqual(messenger._timer, null); }); // ==================== cron 式调度 ==================== test('parseCron 接受常用写法,拒绝非法表达式', () => { assert.ok(parseCron('0 9 * * *'), '每天 09:00'); assert.ok(parseCron('0/30 * * * *'), '每 30 分钟'); assert.ok(parseCron('0 12 * * 1-5'), '工作日 12:00'); assert.ok(parseCron('5,35 8-18 * * *'), '区间 + 列表'); assert.strictEqual(parseCron('0 9 * *'), null, '段数不足应拒绝'); assert.strictEqual(parseCron('99 9 * * *'), null, '越界应拒绝'); assert.strictEqual(parseCron('0 9 * * 9'), null, '周只能是 0-6'); assert.strictEqual(parseCron(''), null); }); test('cron 任务只在匹配时刻发送,且不会同一天重复发', () => { const manager = fakeManager(); const messenger = new ScheduledMessenger(manager, null); const now = new Date(2026, 8, 28, 8, 0, 0); // 2026-09-28 08:00(本地时间) messenger.setTasks([{ id: 'daily', content: '早安公告', cron: '0 9 * * *' }], now.getTime()); messenger.tick(new Date(2026, 8, 28, 8, 59, 0).getTime()); assert.strictEqual(manager.sent.length, 0, '09:00 之前不该发'); messenger.tick(new Date(2026, 8, 28, 9, 0, 10).getTime()); assert.strictEqual(manager.sent.length, 1, '09:00 到点应发送'); messenger.tick(new Date(2026, 8, 28, 9, 1, 0).getTime()); assert.strictEqual(manager.sent.length, 1, '同一天不应重复发'); const snap = messenger.snapshot()[0]; assert.strictEqual(snap.mode, 'cron'); assert.strictEqual(snap.cron, '0 9 * * *'); assert.strictEqual(new Date(snap.nextDueAt).getDate(), 29, '下一次应是明天 09:00'); assert.strictEqual(new Date(snap.nextDueAt).getHours(), 9); }); test('cron 支持星期范围:工作日表达式会跳过周末', () => { const manager = fakeManager(); const messenger = new ScheduledMessenger(manager, null); const saturday = new Date(2026, 8, 26, 13, 0, 0); // 2026-09-26 周六 assert.strictEqual(saturday.getDay(), 6); messenger.setTasks([{ id: 'workday', content: 'x', cron: '0 12 * * 1-5' }], saturday.getTime()); const due = new Date(messenger.snapshot()[0].nextDueAt); assert.strictEqual(due.getDay(), 1, '应跳到周一'); assert.strictEqual(due.getDate(), 28); }); test('cron 步长写法:每 30 分钟', () => { const manager = fakeManager(); const messenger = new ScheduledMessenger(manager, null); const now = new Date(2026, 8, 28, 8, 1, 0); messenger.setTasks([{ id: 'half', content: 'x', cron: '0/30 * * * *' }], now.getTime()); const due = new Date(messenger.snapshot()[0].nextDueAt); assert.strictEqual(due.getHours(), 8); assert.strictEqual(due.getMinutes(), 30, '下一次应是 08:30'); }); test('无效 cron 表达式回退到固定间隔,并说明原因', () => { const tasks = normalizeTasks([{ content: 'x', cron: '99 99 * * *', intervalSeconds: 60 }]); assert.strictEqual(tasks[0].mode, 'interval', '非法表达式不能静默吞掉,应按间隔发送'); assert.ok(tasks[0].cronError.includes('无效'), '要给出可见的原因:' + tasks[0].cronError); assert.strictEqual(tasks[0].cron, '', '非法表达式不应被保留'); }); test('nextCronTime 能跳到下一个月(稀缺表达式)', () => { const parsed = parseCron('0 0 1 * *'); // 每月 1 日 00:00 const from = new Date(2026, 8, 28, 12, 0, 0).getTime(); const next = new Date(nextCronTime(parsed, from)); assert.strictEqual(next.getMonth(), 9, '应跳到 10 月'); assert.strictEqual(next.getDate(), 1); }); // ==================== 发送结果确认 ==================== test('发送结果确认:命中服务器回复记为「已确认」', () => { const manager = emittableManager(); const messenger = new ScheduledMessenger(manager, null); const t0 = 1_000_000; messenger.setTasks([{ id: 'restore', content: '/u restore confirm', intervalSeconds: 300, expectPattern: '民心.*恢复', confirmTimeoutSeconds: 10 }], t0); messenger.start(); // 注册 chat 监听 assert.strictEqual(messenger.tick(t0 + 300_000), 1); manager.emit('chat', '你的疆土民心已经恢复', 'system'); const result = messenger.snapshot()[0].lastResult; assert.strictEqual(result.ok, true); assert.ok(result.detail.includes('民心')); messenger.stop(); }); test('发送结果确认:超时未回复记为「未确认」并写警告', () => { const manager = emittableManager(); const logger = new Logger({ echo: false }); const messenger = new ScheduledMessenger(manager, logger); const t0 = 1_000_000; messenger.setTasks([{ id: 'restore', content: '/u restore confirm', intervalSeconds: 300, expectPattern: '民心.*恢复', confirmTimeoutSeconds: 10 }], t0); messenger.start(); assert.strictEqual(messenger.tick(t0 + 300_000), 1); messenger.tick(t0 + 300_000 + 11_000); // 超过确认超时 const result = messenger.snapshot()[0].lastResult; assert.strictEqual(result.ok, false); assert.ok(result.detail.includes('没有收到预期回复')); assert.ok(logger.list().some((e) => e.level === 'warn' && e.message.includes('没有收到预期回复')), '日志里必须能看到「没生效」这件事'); messenger.stop(); }); test('没配「期望回复」的任务不做确认,也不会误报未确认', () => { const manager = emittableManager(); const messenger = new ScheduledMessenger(manager, null); const t0 = 1_000_000; messenger.setTasks([{ id: 'plain', content: 'hi', intervalSeconds: 60 }], t0); messenger.start(); assert.strictEqual(messenger.tick(t0 + 60_000), 1); messenger.tick(t0 + 120_000); assert.strictEqual(messenger.snapshot()[0].lastResult, null, '未配置确认就不该有结果状态'); messenger.stop(); });