'use strict'; const test = require('node:test'); const assert = require('node:assert'); const { SimpApiClient, SimpApiV2Client, BASE_URL, BASE_URL_V2, componentToText, normalizeLimit, parseUnionSummary, parseTerritoryChunk } = require('../src/core/simpApiClient'); test('componentToText 把公告的组件 JSON 压平成纯文本', () => { const raw = '{"color":"gold","extra":[{"color":"aqua","text":"wans7891"},' + '{"color":"yellow","text":" 在坐标 (-8109, -8976)"},' + '{"color":"gold","text":" 创立了新的邦国 "},' + '{"color":"aqua","text":"「雅典维亚城邦」"}]}'; assert.strictEqual(componentToText(raw), 'wans7891 在坐标 (-8109, -8976) 创立了新的邦国 「雅典维亚城邦」'); // 嵌套 extra const nested = '{"text":"公告: ","extra":[{"text":"外层","extra":[{"text":"内层"}]}]}'; assert.strictEqual(componentToText(nested), '公告: 外层内层'); }); test('componentToText 对非 JSON 文本原样返回', () => { assert.strictEqual(componentToText('普通公告'), '普通公告'); assert.strictEqual(componentToText(''), ''); assert.strictEqual(componentToText(null), ''); }); test('normalizeLimit 钳制到 1..1000', () => { assert.strictEqual(normalizeLimit(0), 1); assert.strictEqual(normalizeLimit(50), 50); assert.strictEqual(normalizeLimit(99999), 1000); assert.strictEqual(normalizeLimit('abc'), 100, '非法值回退到文档默认值 100'); assert.strictEqual(normalizeLimit(undefined), 100); }); test('parseUnionSummary 解析邦国摘要并按方块坐标换算王城区块', () => { const union = parseUnionSummary({ id: 12, name: '养老院', leader_name: 'LouieMAIN', member_count: 6, territory_chunk_count: 28853, territory_block_area: 7386368, special_output_material: 'TERRACOTTA', capital_x: 6763.4, capital_z: 11929.2 }); assert.strictEqual(union.name, '养老院'); assert.strictEqual(union.leader, 'LouieMAIN'); assert.strictEqual(union.memberCount, 6); assert.strictEqual(union.chunkCount, 28853); assert.strictEqual(union.specialOutput, 'TERRACOTTA'); // 方块坐标 ÷ 16 才是区块坐标 assert.deepStrictEqual(union.capital, { x: 422, z: 745 }); }); test('parseUnionSummary 缺王城坐标时返回 null 而不是 NaN', () => { const union = parseUnionSummary({ name: '没有王城的邦国', capital_x: null, capital_z: null }); assert.strictEqual(union.capital, null); assert.strictEqual(parseUnionSummary({}), null, '没有邦国名应视为无效'); assert.strictEqual(parseUnionSummary(null), null); }); test('parseTerritoryChunk 解析区块并拒绝坐标缺失的行', () => { const chunk = parseTerritoryChunk({ x: 1, z: -2, union_id: 12, union_name: '养老院', loyalty: 100, last_claimed_at: '2025-08-08T13:43:21.323+08:00' }); assert.deepStrictEqual(chunk, { x: 1, z: -2, unionId: 12, unionName: '养老院', loyalty: 100, lastClaimedAt: '2025-08-08T13:43:21.323+08:00' }); assert.strictEqual(parseTerritoryChunk({ x: null, z: 1 }), null); assert.strictEqual(parseTerritoryChunk({}), null); assert.strictEqual(parseTerritoryChunk(null), null); }); test('客户端在未配置 Key 时把错误写清楚,而不是静默失败', async () => { const client = new SimpApiClient(null); assert.strictEqual(client.hasApiKey(), false); assert.strictEqual(await client.getUnionTerritoryStats('x'), null); assert.strictEqual(client.lastError(), '未配置 API Key'); const v2 = new SimpApiV2Client(null); // 在线列表在无 Key 时返回空结构,但 lastError 必须说明原因 assert.deepStrictEqual(await v2.getOnlinePlayers(), { online: 0, max: 0, players: [] }); assert.strictEqual(v2.lastError(), '未配置 API-V2 Key'); assert.strictEqual(await v2.getDragon(), null); }); // ==================== API-V2(字段名以实测响应为准)==================== /** 造一个返回固定响应的假 fetch */ function stubV2(responses) { const calls = []; return { calls, fetchImpl: async (url) => { calls.push(url); const path = url.split('/api-v2/')[1] || ''; const body = responses[path]; if (body === undefined) return { status: 404, text: async () => '{"error":"not found"}' }; if (typeof body === 'object' && body.__status) { return { status: body.__status, text: async () => JSON.stringify(body.body) }; } return { status: 200, text: async () => JSON.stringify(body) }; } }; } /** 用桩 fetch 建一个 V2 客户端 */ function v2With(responses) { const stub = stubV2(responses); const client = new SimpApiV2Client(null); client.setApiKey('test-key'); client.fetchImpl = stub.fetchImpl; return { client, stub }; } test('getOnlinePlayers 解析 {online,max,players} 结构而不是裸数组', async () => { const { client } = v2With({ players: { online: 2, max: 5000, players: [ { name: 'wans2023', displayName: 'wans2023', uuid: 'u1', world: 'world' }, { name: 'xiaofan', displayName: 'xiaofan', uuid: 'u2', world: 'world' } ] } }); const result = await client.getOnlinePlayers(); assert.strictEqual(result.online, 2); assert.strictEqual(result.max, 5000); assert.strictEqual(result.players.length, 2); assert.strictEqual(result.players[0].name, 'wans2023'); assert.strictEqual(result.players[0].world, 'world'); }); test('getGameTime 直接使用服务端算好的时刻,不再用 tick 换算', async () => { const { client } = v2With({ time: { world: 'world', tick: 10085, phase: 'DAY', time12: '04:05 PM', time24: '16:05' } }); const time = await client.getGameTime(); assert.strictEqual(time.time24, '16:05'); assert.strictEqual(time.time12, '04:05 PM'); assert.strictEqual(time.phase, 'DAY'); assert.strictEqual(time.tick, 10085); }); test('getDragon 读的是 dragonAlive 而不是 alive', async () => { const { client } = v2With({ dragon: { dragonAlive: false, world: 'world_the_end' } }); const dragon = await client.getDragon(); assert.strictEqual(dragon.alive, false); assert.strictEqual(dragon.world, 'world_the_end'); const alive = v2With({ dragon: { dragonAlive: true, world: 'world_the_end' } }); assert.strictEqual((await alive.client.getDragon()).alive, true); }); test('getPlayerEquipment 解析 equipment 子对象,并把 damage 换算成剩余耐久', async () => { const { client } = v2With({ 'players/wans2023/equipment': { name: 'wans2023', displayName: 'wans2023', uuid: 'u1', equipment: { mainHand: { type: 'BAMBOO_PLANKS', key: 'minecraft:bamboo_planks', amount: 9, damage: 0, maxDurability: 0, unbreakable: false }, offHand: null, helmet: { type: 'IRON_HELMET', key: 'minecraft:iron_helmet', amount: 1, damage: 157, maxDurability: 165, unbreakable: false } } } }); const equipment = await client.getPlayerEquipment('wans2023'); assert.strictEqual(equipment.player, 'wans2023'); assert.strictEqual(equipment.mainHand.key, 'minecraft:bamboo_planks'); assert.strictEqual(equipment.mainHand.amount, 9); assert.strictEqual(equipment.mainHand.maxDurability, 0, '方块类物品没有耐久'); assert.strictEqual(equipment.offHand, null); // damage=157 / max=165 -> 剩余 8,而不是把 157 当成剩余耐久 assert.strictEqual(equipment.helmet.damage, 157); assert.strictEqual(equipment.helmet.maxDurability, 165); assert.strictEqual(equipment.helmet.remainingDurability, 8); assert.strictEqual(equipment.chestplate, null); }); test('V2 触发限流时把 retryAfterSeconds 写进错误信息', async () => { const { client } = v2With({ dragon: { __status: 429, body: { error: '请求过于频繁,已超出限速', limit: 1, retryAfterSeconds: 16 } } }); assert.strictEqual(await client.getDragon(), null); assert.ok(client.lastError().includes('还需等待 16 秒'), '实际:' + client.lastError()); assert.strictEqual(client.lastHttpStatus(), 429); }); test('V2 的 401 提示要点明「需要单独申请密钥」', async () => { const { client } = v2With({ time: { __status: 401, body: { error: 'missing or invalid api key' } } }); assert.strictEqual(await client.getGameTime(), null); assert.ok(client.lastError().includes('单独申请'), '实际:' + client.lastError()); }); test('V2 响应走本地缓存,不会因为反复点击把配额打满', async () => { const { client, stub } = v2With({ time: { world: 'world', tick: 1, phase: 'DAY', time12: 'x', time24: '06:00' } }); await client.getGameTime(); await client.getGameTime(); await client.getGameTime(); assert.strictEqual(stub.calls.length, 1, '三次查询只应打一次接口'); assert.strictEqual(client.snapshot().cacheHits, 2); }); test('setApiKey 变更时清空缓存,避免旧 Key 的响应串味', () => { const client = new SimpApiClient(null); client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 }); client.setApiKey('first'); assert.strictEqual(client.cache.size, 0, '第一次设置也应从空 Key 状态切换'); client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 }); client.setApiKey('second'); assert.strictEqual(client.cache.size, 0, '换 Key 必须清缓存'); // 相同 Key 重复设置不应清缓存(否则每次配置下发都会白打接口) client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 }); client.setApiKey('second'); assert.strictEqual(client.cache.size, 1); }); test('API 与 API-V2 是两个独立的基础地址与密钥', () => { const v1 = new SimpApiClient(null, {}); const v2 = new SimpApiV2Client(null, {}); assert.strictEqual(v1.baseUrl, BASE_URL); assert.strictEqual(v2.baseUrl, BASE_URL_V2); assert.notStrictEqual(v1.baseUrl, v2.baseUrl); v1.setApiKey('key-v1'); assert.strictEqual(v1.hasApiKey(), true); assert.strictEqual(v2.hasApiKey(), false, 'V1 的 Key 不应影响 V2'); }); test('snapshot 暴露用量与缓存统计供控制台展示', () => { const client = new SimpApiClient(null); const snap = client.snapshot(); assert.strictEqual(snap.hasKey, false); assert.strictEqual(snap.requestCount, 0); assert.strictEqual(snap.cacheHits, 0); assert.strictEqual(snap.cachedPaths, 0); assert.strictEqual(snap.baseUrl, BASE_URL); });