文件
BotStore/test/simpApiClient.test.js
T
lyizai 71e78f06dc fix: 按实测响应修正 SimpCore API-V2 的字段名
接入 V2 密钥后实跑发现字段名与文档描述对不上,全部按真实响应修正:

- /players 返回的是对象 {online,max,players:[...]} 而不是裸数组,
  原解析会拿到空列表;且里面没有坐标与延迟,只有所在世界
- /dragon 的字段是 dragonAlive,不是 alive,也没有 health / phase
- /time 直接给算好的时刻 {world,tick,phase,time12,time24},
  原实现还在用 world_time/day_time/is_day 自己换算,全部取不到
- /players/{name}/equipment 的物品是 {type,key,amount,damage,maxDurability,unbreakable}
  且挂在 equipment 子对象下;damage 是已损耗值,原来会把「损耗 157」当成
  「剩余 157」显示,现在换算成 remainingDurability 并显示「剩余/上限」+ 百分比
- 429 限流时服务端带 retryAfterSeconds,现在提示里直接写出还要等多少秒
  (末影龙 1 次/分钟,实测提示「还需等待 16 秒」)

另外给 SimpApiV2Client 加了可注入的 fetchImpl,便于测试替换网络层。

实测(DiLiDaLaDaLa):在线 8 人、游戏时间 19:45 NIGHT、
下界合金剑 剩余 1992/2031、头盔 剩余 198/407。

测试 336 -> 343,全部通过。
2026-09-28 13:56:41 +08:00

262 行
10 KiB
JavaScript

'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);
});