feat: 1.7.0 落地 TODO 全部待办(地面修正 / 手动执行器 / 自救 / 回城 / 租约 / 通知)
按 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` 全绿。
这个提交包含在:
+146
-1
@@ -3,7 +3,24 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const { ScheduledMessenger, normalizeTasks, clampInterval, MIN_INTERVAL_SECONDS, MAX_TASKS } = require('../src/bot/scheduledMessenger');
|
||||
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) {
|
||||
@@ -141,3 +158,131 @@ test('start / stop 控制心跳定时器', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户