按 TODO.md 的优先级把待办全部落地为代码,详见 CHANGELOG 的 [1.7.0]。 - 「走不动」攻关:地面状态修正(syncGroundToPhysics / trustSetbackOnGround)、 出站包类型分布、备选执行器 manualMover(只借 A* 规划、自己按键执行) - 原点自救分两轮并统计成败;回城前评估「原地是否更差」并记录前后坐标 - 新增目标区块租约表(targetLease)避免多机器人撞同一块 - 定时消息支持 cron 与「期望回复」确认;新增人工介入 Webhook 通知(notifier) - 大地图后台预热与视野裁剪;控制台日志按帧批量写入、配置面板支持搜索 顺带修掉两个影响部署的细节: - 部署脚本(deploy.js / ssh-helper.js)改为也能从 .env 读 SSH 凭据 —— 在命令行传密码会留在 shell 历史与进程列表里,写进已被忽略的 .env 更安全; 显式环境变量仍然优先。.env.example 补上占位符并说明这些只在本机用。 - package.json / package-lock.json 版本对齐到 1.7.0(此前停留在 1.1.0, 与 CHANGELOG 的语义化版本声明脱节)。 测试:368 → 436 个用例,`npm test` 与 `npm run check` 全绿。
289 行
12 KiB
JavaScript
289 行
12 KiB
JavaScript
'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();
|
|
});
|