二七王:增量 vs 全量一致性测试(B 阶段核心验收)

把前端红线「视图 = f(服务端快照)」变成可执行断言:按序喂推送包累积出的
GameState,必须与同一时点 deskinfo 全量重建的完全相等。

不一致只有两种可能,都要修:前端私存了服务端不知道的状态,或服务端漏发了字段。
EXCLUDE 白名单只收「一次性事件」与「重连不重放」两类,每条都写明理由。

本测试当前保持失败:比对发现了 7 处前端 handler 缺陷(含 table.playproc 字段
结构与协议不符等高优先级问题)与 1 处疑似服务端下发顺序不一致,均未在本次
改动中修复,详见 task-12-report.md,留待裁决。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 18:48:08 +08:00
co-authored by Claude Opus 5
parent e2d470d37f
commit 33c18a2c17
+95
View File
@@ -0,0 +1,95 @@
// 【核心验收】增量累积的 GameState 必须与 deskinfo 全量重建的完全相等
const fs = require('fs');
const path = require('path');
const { ROOT, load } = require('./_load');
const t = require('./_assert')();
load('client/js/gameabc-framework/system/EventBus.js');
load('client/js/01_SubGame/codes/state/Events.js');
load('client/js/01_SubGame/codes/state/GameState.js');
load('client/js/01_SubGame/codes/state/RoomOptions.js');
load('client/js/01_SubGame/codes/net/handlers/DealHandler.js');
load('client/js/01_SubGame/codes/net/handlers/CallHandler.js');
load('client/js/01_SubGame/codes/net/handlers/MainHandler.js');
load('client/js/01_SubGame/codes/net/handlers/BuryHandler.js');
load('client/js/01_SubGame/codes/net/handlers/PlayHandler.js');
load('client/js/01_SubGame/codes/net/handlers/QueryHandler.js');
load('client/js/01_SubGame/codes/net/handlers/ReadyHandler.js');
load('client/js/01_SubGame/codes/net/handlers/ResultHandler.js');
load('client/js/01_SubGame/codes/net/handlers/ResyncHandler.js');
load('client/js/01_SubGame/codes/net/Dispatcher.js');
const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'client/tests/fixtures/packets.json'), 'utf8'));
// 这些字段是「一次性事件」或「重连不重放」的,不参与一致性比对。
// 每加一条都必须写清理由——这个白名单是本测试唯一的松口处,滥用它等于废掉这条守卫。
const EXCLUDE = [
// 70 分坐庄的 3 秒开底是上庄时的一次性事件,协议明确 deskinfo 不重放
'table.ancard3s',
// 本轮必出牌只对当前轮有效;deskinfo 仅在轮到本家时才带 mustcard
'my.mustCard',
// 结算明细来自 jiesuan 包,deskinfo 的 Balance 只在结算阶段带 aset
'result.chupai', 'result.bottom', 'result.account'
];
function pick(snap, exclude) {
const out = JSON.parse(JSON.stringify(snap));
exclude.forEach(p => {
const parts = p.split('.');
let o = out;
for (let i = 0; i < parts.length - 1; i++) { o = o && o[parts[i]]; }
if (o) { delete o[parts[parts.length - 1]]; }
});
return out;
}
// 对每个座位、每个 deskinfo 快照点做比对
let compared = 0;
fx.deskinfo.forEach(snapPoint => {
const seat = snapPoint.seat;
// —— 路径 A:增量累积 ——
EQW_GameState.reset();
EQW_GameState.room.mySeat = seat;
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(fx.roomtype);
fx.seats[seat].slice(0, snapPoint.packetIndex).forEach(p => {
EQW_Dispatcher.dispatch({ rpc: p.rpc, data: p.data });
});
const incremental = EQW_GameState.snapshot();
// —— 路径 B:deskinfo 全量重建 ——
EQW_GameState.reset();
EQW_GameState.room.mySeat = seat;
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(fx.roomtype);
EQW_ResyncHandler.handleDeskinfo(snapPoint.info);
const full = EQW_GameState.snapshot();
t.eq('座位' + seat + ' step' + snapPoint.step + ' 增量 == 全量',
pick(incremental, EXCLUDE), pick(full, EXCLUDE));
compared++;
});
t.eq('比对点数量 > 0', compared > 0, true);
// ================= Ruling 3:step3(埋牌)deskinfo 直读断言 =================
// 一致性比对无法单独覆盖 ResyncHandler._applyBuryCards 的字段映射正确性——
// 若增量路径压根不写某字段,两边可能「同为初始值」而比对照样绿。
// 这里独立取 step3 快照,逐字段直读 GameState 并核对具体值。
const step3 = fx.deskinfo.filter(d => d.step === 3)[0];
if (!step3) {
console.warn('[test_consistency] 夹具里没有 step3 快照,Ruling 3 的直读断言无法执行');
} else {
EQW_GameState.reset();
EQW_ResyncHandler.handleDeskinfo(step3.info);
const bc = step3.info.BuryCards;
t.eq('step3 直读 aset.banker', EQW_GameState.aset.banker, bc.banker);
t.eq('step3 直读 aset.call', EQW_GameState.aset.call, bc.call);
t.eq('step3 直读 aset.multiple', EQW_GameState.aset.multiple, bc.multiple);
t.eq('step3 直读 aset.flower', EQW_GameState.aset.flower, bc.flower);
t.eq('step3 直读 my.bottomCards', EQW_GameState.my.bottomCards, bc.bottomcards);
t.eq('step3 直读 turn.countdown', EQW_GameState.turn.countdown, bc.countdown);
t.eq('step3 直读 turn.seat(埋牌由庄家做)', EQW_GameState.turn.seat, bc.banker);
t.eq('step3 直读 aset.step', EQW_GameState.aset.step, step3.info.step);
}
process.exit(t.done('consistency') ? 0 : 1);