'use strict'; const test = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { ExpansionLog } = require('../src/core/expansionLog'); /** 建一个使用临时目录的日志实例 */ function setup(options) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-explog-')); const filePath = path.join(dir, 'expansion-log.jsonl'); return { log: new ExpansionLog(Object.assign({ filePath }, options || {})), dir, filePath }; } test('record 记录开拓玩家、区块与时间', () => { const { log } = setup(); const before = Date.now(); const entry = log.record({ botId: 'BotA', bot: 'BotA', state: '现代大陆', chunk: { x: 12, z: -34 }, block: { x: 200, z: -540 }, source: 'chat', sessionCount: 7 }); assert.strictEqual(entry.bot, 'BotA'); assert.strictEqual(entry.state, '现代大陆'); assert.strictEqual(entry.cx, 12); assert.strictEqual(entry.cz, -34); assert.strictEqual(entry.bx, 200); assert.strictEqual(entry.bz, -540); assert.strictEqual(entry.source, 'chat'); assert.strictEqual(entry.sessionCount, 7); assert.ok(entry.t >= before && entry.t <= Date.now(), '时间戳应落在调用区间内'); }); test('record 缺省字段被安全兜底(无方块坐标 / 无邦国)', () => { const { log } = setup(); const entry = log.record({ botId: 'B', chunk: { x: 1, z: 2 } }); assert.strictEqual(entry.bx, null); assert.strictEqual(entry.bz, null); assert.strictEqual(entry.state, ''); assert.strictEqual(entry.source, 'chat'); assert.strictEqual(entry.sessionCount, null); }); test('list 支持按机器人 / 邦国 / 时间过滤并限制条数', () => { const { log } = setup(); const base = Date.now(); log.record({ botId: 'A', chunk: { x: 0, z: 0 }, state: 'X' }); log.record({ botId: 'B', chunk: { x: 1, z: 0 }, state: 'Y' }); log.record({ botId: 'A', chunk: { x: 2, z: 0 }, state: 'Y' }); assert.strictEqual(log.list().length, 3); assert.strictEqual(log.list({ botId: 'A' }).length, 2); assert.strictEqual(log.list({ state: 'Y' }).length, 2); assert.strictEqual(log.list({ since: base + 1e9 }).length, 0); const limited = log.list({ limit: 2 }); assert.strictEqual(limited.length, 2); // 取最新 2 条但保持时间升序 assert.deepStrictEqual(limited.map((e) => e.cx), [1, 2]); }); test('stats 汇总总数、按机器人分布与今日条数', () => { const { log } = setup(); log.record({ botId: 'A', chunk: { x: 0, z: 0 } }); log.record({ botId: 'A', chunk: { x: 1, z: 0 } }); log.record({ botId: 'B', chunk: { x: 2, z: 0 } }); const stats = log.stats(); assert.strictEqual(stats.total, 3); assert.strictEqual(stats.buffered, 3); assert.strictEqual(stats.today, 3); assert.strictEqual(stats.byBot[0].botId, 'A'); assert.strictEqual(stats.byBot[0].count, 2); assert.ok(stats.firstAt > 0 && stats.lastAt >= stats.firstAt); }); test('落盘为 JSONL,重启后能从磁盘恢复历史', () => { const { log, filePath } = setup(); log.record({ botId: 'A', bot: 'A', chunk: { x: 5, z: 6 }, state: 'S' }); log.record({ botId: 'B', bot: 'B', chunk: { x: 7, z: 8 } }); const lines = fs.readFileSync(filePath, 'utf8').trim().split('\n'); assert.strictEqual(lines.length, 2); assert.strictEqual(JSON.parse(lines[0]).cx, 5); const reloaded = new ExpansionLog({ filePath }); assert.strictEqual(reloaded.entries.length, 2); assert.strictEqual(reloaded.totalRecorded, 2); assert.strictEqual(reloaded.stats().byBot.length, 2); }); test('磁盘上的损坏行(进程被杀留下的半行)被跳过而不是崩溃', () => { const { filePath } = setup(); fs.writeFileSync(filePath, '{"t":1,"botId":"A","bot":"A","cx":1,"cz":1}\n{"t":2,"botId"', 'utf8'); const log = new ExpansionLog({ filePath }); assert.strictEqual(log.entries.length, 1); assert.strictEqual(log.entries[0].cx, 1); }); test('内存环形缓冲超过上限时丢弃最旧记录,但总数继续累计', () => { const { log } = setup({ maxEntries: 3 }); for (let i = 0; i < 5; i++) log.record({ botId: 'A', chunk: { x: i, z: 0 } }); assert.strictEqual(log.entries.length, 3); assert.strictEqual(log.entries[0].cx, 2); assert.strictEqual(log.stats().total, 5); }); test('onEntry 回调收到新记录,回调抛异常不影响写入', () => { const { log } = setup(); const seen = []; log.onEntry = (entry) => { seen.push(entry.cx); throw new Error('订阅者异常'); }; assert.doesNotThrow(() => log.record({ botId: 'A', chunk: { x: 9, z: 9 } })); assert.deepStrictEqual(seen, [9]); assert.strictEqual(log.entries.length, 1); }); test('exportCsv 输出带 BOM 的表格,含表头与全部记录', () => { const { log, dir } = setup(); log.record({ botId: 'A', bot: 'A', state: 'X', chunk: { x: 1, z: 2 }, block: { x: 16, z: 32 } }); log.record({ botId: 'B', bot: 'B,带逗号', state: 'Y', chunk: { x: 3, z: 4 } }); const file = path.join(dir, 'out.csv'); assert.strictEqual(log.exportCsv(file), file); const text = fs.readFileSync(file, 'utf8'); assert.ok(text.startsWith('\ufeff'), '应带 BOM 以便 Excel 正确识别中文'); const lines = text.replace(/^\ufeff/, '').split('\r\n'); assert.ok(lines[0].includes('机器人')); assert.strictEqual(lines.length, 3); assert.ok(lines[2].includes('"B,带逗号"'), '含逗号的字段应被引号包裹'); }); test('clearBuffer 只清内存,磁盘文件保留', () => { const { log, filePath } = setup(); log.record({ botId: 'A', chunk: { x: 1, z: 1 } }); log.clearBuffer(); assert.strictEqual(log.entries.length, 0); assert.ok(fs.readFileSync(filePath, 'utf8').trim().length > 0); });