Files
shoot-miniprograms/scripts/melee-completion-audio.test.mjs
T

180 lines
7.9 KiB
JavaScript

import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
// 项目源码由 Vite 按 ESM 处理,测试通过 data URL 加载无环境依赖的判定模块。
const source = await readFile(
new URL("../src/utils/meleeCompletionAudio.js", import.meta.url), "utf8"
);
const {
createMeleeCompletionTracker,
MELEE_FIRST_HALF_COMPLETE_AUDIO: firstAudio,
MELEE_SECOND_HALF_COMPLETE_AUDIO: secondAudio,
} = await import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`);
function snapshot(mode = 4, half = 1, ownCount = 6, othersCount = 5) {
const count = mode === 5 ? 10 : 5;
const players = Array.from({ length: count }, (_, i) => ({ id: String(i + 1) }));
const shoots = Object.fromEntries(players.map(({ id }) => [
id, Array.from({ length: id === "1" ? ownCount : othersCount }, () => ({ ring: 10 })),
]));
return {
matchId: "match-a", mode, status: 1, teams: { 0: { players } },
rounds: Array.from({ length: half }, (_, index) => ({
round: index + 1,
shoots: index === half - 1 ? shoots : Object.fromEntries(
players.map(({ id }) => [id, Array(6).fill({ ring: 10 })])
),
})),
shootData: { playerId: 1, ring: 10 },
};
}
for (const mode of [4, 5]) {
for (const half of [1, 2]) {
test(`${mode === 4 ? 5 : 10} 人大乱斗第 ${half} 半场:所有非最后完成者播放一次`, () => {
const expected = half === 1 ? firstAudio : secondAudio;
const count = mode === 4 ? 5 : 10;
for (let finishedOthers = 0; finishedOthers < count - 1; finishedOthers++) {
const tracker = createMeleeCompletionTracker();
tracker.observe(snapshot(mode, half, 5), "1");
const info = snapshot(mode, half);
for (let i = 2; i <= finishedOthers + 1; i++) {
info.rounds[half - 1].shoots[i] = Array(6).fill({});
}
assert.equal(tracker.getShotAudio(info, "1"), expected);
assert.equal(tracker.getShotAudio(info, "1"), "");
}
});
test(`${mode}/${half}:最后完成、未满六箭、他人完成均不播`, () => {
assert.equal(createMeleeCompletionTracker().getShotAudio(snapshot(mode, half, 6, 6), "1"), "");
assert.equal(createMeleeCompletionTracker().getShotAudio(snapshot(mode, half, 5), "1"), "");
const other = snapshot(mode, half);
other.shootData.playerId = "2";
assert.equal(createMeleeCompletionTracker().getShotAudio(other, "1"), "");
});
}
}
test("上下半场独立,同场重连不重复,新比赛正常触发", () => {
const tracker = createMeleeCompletionTracker();
assert.equal(tracker.getShotAudio(snapshot(), "1"), firstAudio);
tracker.observe(snapshot(), "1");
assert.equal(tracker.getShotAudio(snapshot(), "1"), "");
assert.equal(tracker.getShotAudio(snapshot(4, 2), "1"), secondAudio);
const next = snapshot();
next.matchId = "match-b";
assert.equal(tracker.getShotAudio(next, "1"), firstAudio);
});
test("恢复已完成快照后不补播,包括页面重新进入", () => {
for (const half of [1, 2]) {
const tracker = createMeleeCompletionTracker();
const info = snapshot(4, half);
tracker.observe(info, "1");
assert.equal(tracker.getShotAudio(info, "1"), "");
}
});
test("中场、结束、进入下半场后忽略过期射箭消息", () => {
for (const status of [2, 3, 4]) {
const tracker = createMeleeCompletionTracker();
tracker.observe({ matchId: "match-a", status }, "1");
assert.equal(tracker.getShotAudio(snapshot(), "1"), "");
assert.equal(tracker.getShotAudio({ ...snapshot(), status }, "1"), "");
}
const tracker = createMeleeCompletionTracker();
tracker.observe(snapshot(4, 2, 0), "1");
assert.equal(tracker.getShotAudio(snapshot(), "1"), "");
assert.equal(tracker.getShotAudio(snapshot(4, 2), "1"), secondAudio);
});
test("其他模式和不完整名单不触发", () => {
for (const mode of [1, 2, 3, 6]) {
assert.equal(createMeleeCompletionTracker().getShotAudio(snapshot(mode), "1"), "");
}
for (const mutate of [
(info) => { delete info.teams; },
(info) => { info.teams[0].players.pop(); },
(info) => { info.teams[0].players[1].id = "1"; },
(info) => { delete info.rounds; },
(info) => { info.rounds[0].shoots[2] = {}; },
(info) => { delete info.matchId; },
]) {
const info = snapshot();
mutate(info);
assert.equal(createMeleeCompletionTracker().getShotAudio(info, "1"), "");
}
});
test("protobuf 缺失零箭用户的 map 项仍可判断有人未完成", () => {
const info = snapshot(4, 1, 6, 6);
delete info.rounds[0].shoots[2];
assert.equal(createMeleeCompletionTracker().getShotAudio(info, "1"), firstAudio);
});
test("播报队列与 ACK 一致:三连十环后播完成提示,提示结束才确认", async () => {
const ws = await readFile(new URL("../src/matchWebsocket.js", import.meta.url), "utf8");
const component = await readFile(new URL("../src/components/ShootProgress.vue", import.meta.url), "utf8");
const played = [];
const timers = [];
const acked = [];
const types = { BattleStart: 1, BattleEnd: 2, ShootResult: 3, HalfRest: 4 };
const serverTypes = { SERVER_MSG_SHOT: 4 };
const context = vm.createContext({
MESSAGETYPESV2: types,
ServerMessageType: serverTypes,
props: { melee: true, endAudioKey: "比赛结束" },
user: { value: { id: "1" } },
currentContext: { userId: "1", matchId: "match-a" },
halfTime: { value: false },
audioManager: { play: (keys, interrupt) => played.push({ keys, interrupt }) },
getDirectionText: () => "上",
isMeleeMessage: () => true,
pendingAcks: [],
ACK_AUDIO_TIMEOUT_MS: 9000,
shouldCloseAfterAck: () => false,
normalizeId: String,
pickField: (value, key) => value[key],
getServerMessageTypeName: () => "SHOT",
completeAckTask: (task) => acked.push(task.sequence),
setTimeout: (callback, delay) => { timers.push({ callback, delay }); return timers.length; },
console: { log() {} },
});
// 只加载实际业务函数,用轻量替身隔离小程序生命周期与网络。
for (const [text, start, end] of [
[component, "async function onReceiveMessage", "const playSound"],
[ws, "function getShootResultAudioKeys", "function normalizeScoreSlot"],
[ws, "function getAckAudioKeys", "function shouldCloseAfterAck"],
[ws, "function queueAckAfterAudio", "function handleMessage"],
[ws, "function flushPendingAcks", "function ensureAudioAckListener"],
]) {
vm.runInContext(text.slice(text.indexOf(start), text.indexOf(end)), context);
}
const msg = {
...snapshot(), type: types.ShootResult, sequence: 123,
shootData: { playerId: "1", ring: 10, angle: 0, threeConsecutive10Rings: true },
meleeCompletionAudioKey: firstAudio,
};
context.msg = msg;
await vm.runInContext("onReceiveMessage(msg)", context);
const ackKeys = vm.runInContext("getAckAudioKeys({ type: 4 }, msg)", context);
assert.deepEqual(Array.from(played[0].keys), ["10环", "向上调整", "tententen", firstAudio]);
assert.deepEqual(Array.from(ackKeys), Array.from(played[0].keys));
assert.equal(played[0].interrupt, false);
vm.runInContext("queueAckAfterAudio({ type: 4, sequence: 123 }, msg)", context);
assert.equal(context.pendingAcks[0].expectedAudioKey, firstAudio);
assert.equal(timers[0].delay, 20000);
vm.runInContext('flushPendingAcks("tententen")', context);
assert.deepEqual(acked, []);
vm.runInContext(`flushPendingAcks(${JSON.stringify(firstAudio)})`, context);
assert.deepEqual(acked, [123]);
// 阶段切换使用打断播放,清除尚未播出的等待提示。
await vm.runInContext("onReceiveMessage({ type: MESSAGETYPESV2.HalfRest })", context);
assert.equal(played.at(-1).keys, "中场休息");
await vm.runInContext("onReceiveMessage({ type: MESSAGETYPESV2.BattleEnd })", context);
assert.deepEqual(played.at(-1), { keys: "比赛结束", interrupt: true });
});