Files
erqiwang_youle/client/tests/test_constants.js
T
joywayerandClaude Opus 5 92edb6a3f5 二七王:布局求解器补齐显式失败与运行时注入契约,配置补 runtime/targetKind 声明
求解器(ui/LayoutSolver.js):
- 按方向校验 anchor:竖排只收 top/bottom/center、横排只收 left/right/center,
  未知 direction 一并拒绝。框架 AlignmentUtils 的 switch 对写错方向的 anchor 落到
  default(居中),笔误会被静默吞掉——ROOM_CATEGORY_COLUMN 就是这么把前两行摆到画布外的。
- grid 校验合并 ctx 后的 cols/rows 必须是数字:缺 rows 时 capacity=NaN 让超载守卫失效、
  循环一次不跑,静默返回空数组(一个矩形都不产出)。
- line/fan/grid 要求 anchorX/anchorY 必须是数字(框架的 anchorY||0 会把缺失变成 0);
  point 要求 x/y 必须是数字。
- apply() 精灵数与矩形数不等时抛错,不再静默截断:截断会让多余精灵停在上一手牌的旧坐标上。
- 统一运行时注入:ctx 里的 INJECT_KEYS(target/x/y/w/h/anchorX/anchorY/rows/
  itemWidth/itemHeight)覆盖配置同名字段,优先级 ctx > bySeat > base。
- line + items 竖排显式拒绝(只实现了水平)。

配置:
- ROOM_CATEGORY_COLUMN anchor 由 'left' 改为 'top'(竖排语义)。
- ROOM_OPTION_OVERFLOW_GRID 补 rows(runtime)、槽尺寸与 anchor 沿用 ROOM_OPTION_ROW,
  cols 直接引用 ROOM_OPTION_MAX_PER_ROW;ROOM_OPTION_ROW 补上注释里已声明的 itemHeight。
- 所有依赖运行时注入的节点加 runtime 声明,所有 attach 节点加 targetKind
  (sprite / layout / platform),显式区分「忘了写」与「故意延后到运行时」。

守卫(tests/test_constants.js):
- 求解冒烟改为只对 runtime 声明过的键注入假值,没声明却缺字段的照常抛错变红。
- 补矩形数量断言(line/fan/grid 按项数、point/attach 恒 1)——此前只查数组与坐标类型,
  空数组照样通过,「少画了几张牌」对守卫完全不可见。
- attach.target 改为只在 targetKind 指定的那一个命名空间里校验存在。
- 补 runtime 声明自身的合法性检查与布局节点总数(62)钉死。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:24:16 +08:00

258 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 常量层机械守卫:号段合规、ID 不重复、布局引用可解析
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/config/Layers.js');
load('client/js/01_SubGame/codes/config/Groups.js');
load('client/js/01_SubGame/codes/config/ImageResources.js');
load('client/js/01_SubGame/codes/config/SoundResources.js');
load('client/js/01_SubGame/codes/config/Sprites_Table.js');
load('client/js/01_SubGame/codes/config/Sprites_Cards.js');
load('client/js/01_SubGame/codes/config/Sprites_Action.js');
load('client/js/01_SubGame/codes/config/Sprites_Result.js');
load('client/js/01_SubGame/codes/config/Sprites_CreateRoom.js');
load('client/js/01_SubGame/codes/config/LayoutConstants.js');
load('client/js/01_SubGame/codes/config/Layout_Table.js');
load('client/js/01_SubGame/codes/config/Layout_Cards.js');
load('client/js/01_SubGame/codes/config/Layout_Action.js');
load('client/js/01_SubGame/codes/config/Layout_Result.js');
load('client/js/01_SubGame/codes/config/Layout_CreateRoom.js');
load('client/js/01_SubGame/codes/config/AnimConstants.js');
load('client/js/01_SubGame/codes/core/SpriteIndex.js');
// ---- 图层号段:101–200 / 301–400 / 501–600 / 701+(client 02 §1)----
// 平台图层 27(建房)是引用而非子游戏自建,单独排除
const layerVals = Object.keys(EQW_Layers).filter(k => k !== 'PLATFORM_CREATE_ROOM').map(k => EQW_Layers[k]);
const layerOk = layerVals.every(v =>
(v >= 101 && v <= 200) || (v >= 301 && v <= 400) || (v >= 501 && v <= 600) || v >= 701);
t.eq('图层落在子游戏段', layerOk, true);
t.eq('图层 27 是平台的建房界面', EQW_Layers.PLATFORM_CREATE_ROOM, 27);
// ---- 群组 ≥201 ----
const groupVals = Object.keys(EQW_Groups).map(k => EQW_Groups[k]);
t.eq('群组均 >= 201', groupVals.every(v => v >= 201), true);
// ---- 图片资源 ≥501;声音 ≥101 ----
const imgVals = Object.keys(EQW_Images).map(k => EQW_Images[k]);
t.eq('图片资源均 >= 501', imgVals.every(v => v >= 501), true);
const soundVals = Object.keys(EQW_Sounds).map(k => EQW_Sounds[k]);
t.eq('声音资源均 >= 101', soundVals.every(v => v >= 101), true); // T-20 未定时为空数组,恒真
// ---- 精灵 ID 1001–2999(段内 3000 已被平台占用,清单 §0.3)----
const index = EQW_SpriteIndex.build(EQW_Sprites);
const spriteVals = Object.keys(index).map(k => index[k]);
t.eq('精灵数量 > 200', spriteVals.length > 200, true);
const spriteOk = spriteVals.every(v => v >= 1001 && v <= 2999);
if (!spriteOk) { console.log('越界精灵: ' + JSON.stringify(spriteVals.filter(v => v < 1001 || v > 2999))); }
t.eq('精灵落在 1001-2999', spriteOk, true);
// ---- 各类 ID 全局不重复 ----
function dupsOf(vals) {
const seen = {}, dups = [];
vals.forEach(v => { if (seen[v]) { dups.push(v); } seen[v] = true; });
return dups;
}
t.eq('精灵 ID 无重复', dupsOf(spriteVals), []);
t.eq('图层 ID 无重复', dupsOf(layerVals), []);
t.eq('群组 ID 无重复', dupsOf(groupVals), []);
t.eq('图片资源 ID 无重复', dupsOf(imgVals), []);
// ---- attach.target 的来源判别:targetKind 指明去哪个命名空间取矩形 ----
// 'sprite' 子游戏精灵键名(EQW_Sprites 索引)
// 'layout' EQW_Layout 的布局节点名(矩形来自该节点的求解结果,可能是其中第 N 项)
// 'platform' 平台精灵键名(如平台头像框,落在框架保留段 1–1000,不参与子游戏号段校验)
// 只在【对应的那一个】命名空间里校验存在性——查三者并集范围太宽,几乎任何标识符都能"解析"。
const NAMESPACES = { sprite: index, layout: EQW_Layout, platform: EQW_PlatformSprites };
const resolvable = {};
Object.keys(NAMESPACES).forEach(kind => {
Object.keys(NAMESPACES[kind]).forEach(k => { resolvable[k] = true; });
});
// 收集所有布局节点,供解析检查与求解冒烟共用
const allNodes = []; // { path, node }
function collectNodes(node, path) {
if (!node || typeof node !== 'object' || Array.isArray(node)) { return; }
if (node.kind) { allNodes.push({ path, node }); }
Object.keys(node).forEach(k => {
if (k !== 'bySeat' && node[k] && typeof node[k] === 'object' && !Array.isArray(node[k])) {
collectNodes(node[k], path + '.' + k);
}
});
}
Object.keys(EQW_Layout).forEach(k => collectNodes(EQW_Layout[k], k));
// 每个 attach 节点必须声明合法 targetKind;非 attach 节点不得声明(避免复制粘贴留下的无意义字段)
const kindDecl = [];
allNodes.forEach(entry => {
const tk = entry.node.targetKind;
if (entry.node.kind === 'attach') {
if (!NAMESPACES.hasOwnProperty(tk)) { kindDecl.push(entry.path + ' -> targetKind=' + tk); }
} else if (typeof tk !== 'undefined') {
kindDecl.push(entry.path + ' -> 非 attach 却声明了 targetKind=' + tk);
}
});
if (kindDecl.length) { console.log('targetKind 声明有问题的节点:\n ' + kindDecl.join('\n ')); }
t.eq('attach 节点均声明合法 targetKind', kindDecl, []);
// 配置里写死的 target 必须存在于 targetKind 指定的那个命名空间
const missing = [];
allNodes.forEach(entry => {
const ns = NAMESPACES[entry.node.targetKind];
if (!ns) { return; } // targetKind 非法已由上一条断言报出
const targets = [];
if (entry.node.target) { targets.push([entry.path, entry.node.target]); }
if (entry.node.bySeat) {
Object.keys(entry.node.bySeat).forEach(s => {
const v = entry.node.bySeat[s];
if (v && v.target) { targets.push([entry.path + '.bySeat.' + s, v.target]); }
});
}
targets.forEach(pair => {
if (!Object.prototype.hasOwnProperty.call(ns, pair[1])) {
missing.push(pair[0] + ' -> ' + pair[1] + '(不在 ' + entry.node.targetKind + ' 命名空间里)');
}
});
});
if (missing.length) { console.log('无法解析的 attach.target:\n ' + missing.join('\n ')); }
t.eq('attach.target 在其 targetKind 命名空间内可解析', missing, []);
// ---- 求解冒烟:每个布局节点都真的能被求解器算出来 ----
// 这是配置与求解器之间契约的最强守卫——字段名写错、缺 w/h、kind 不认识、
// bySeat 缺座位,全都会在这里当场抛错,而不是等到界面上静默摆歪。
load('client/js/gameabc-framework/ui/AlignmentUtils.js');
load('client/js/01_SubGame/codes/ui/LayoutSolver.js');
// 合成一个万能 ctx:所有可解析的键都给一个矩形,count 给一个合理值
const fakeRects = {};
Object.keys(resolvable).forEach(k => { fakeRects[k] = { x: 100, y: 100, width: 80, height: 40 }; });
// 运行时注入字段的假值表:【只对节点 runtime 里显式声明过的键】注入。
// 没声明却缺字段的节点,求解器照常抛错、本守卫照常变红——这正是 runtime 声明的意义:
// 区分「故意延后到运行时」与「忘了写」,守卫不再无差别地自动补齐缺失字段。
const RUNTIME_FAKE = {
target: 'TOP_INFO_BG', x: 10, y: 20, w: 60, h: 24,
anchorX: 30, anchorY: 40, rows: 2, itemWidth: 150, itemHeight: 32
};
// 求解器新开了注入通道却忘了给假值 → 这里立刻红
t.eq('运行时假值表覆盖全部可注入字段',
EQW_LayoutSolver.INJECT_KEYS.filter(k => !RUNTIME_FAKE.hasOwnProperty(k)), []);
t.eq('假值表里的 target 是真实可解析键', resolvable[RUNTIME_FAKE.target], true);
// bySeat 覆盖后的有效配置(bySeat 可覆盖包括 kind 在内的任意字段)
function effectiveNode(node, seat) {
if (!node.bySeat || !seat) { return node; }
const out = {};
Object.keys(node).forEach(k => { if (k !== 'bySeat') { out[k] = node[k]; } });
Object.keys(node.bySeat[seat]).forEach(k => { out[k] = node.bySeat[seat][k]; });
return out;
}
// runtime 声明本身要合法:必须是字符串数组、键名在求解器的可注入清单里、
// 且该字段在配置里确实留空(配置已经写了值还声明 runtime,说明声明是过期的)
const runtimeDecl = [];
allNodes.forEach(entry => {
const seats = entry.node.bySeat ? Object.keys(entry.node.bySeat) : [null];
seats.forEach(seat => {
const eff = effectiveNode(entry.node, seat);
const label = entry.path + (seat ? '[' + seat + ']' : '');
if (typeof eff.runtime === 'undefined') { return; }
if (!Array.isArray(eff.runtime)) { runtimeDecl.push(label + ' -> runtime 不是数组'); return; }
eff.runtime.forEach(k => {
if (EQW_LayoutSolver.INJECT_KEYS.indexOf(k) < 0) {
runtimeDecl.push(label + ' -> runtime 含不可注入的字段: ' + k);
} else if (typeof eff[k] !== 'undefined') {
runtimeDecl.push(label + ' -> runtime 声明了 ' + k + ',但配置里已写了值');
}
});
});
});
if (runtimeDecl.length) { console.log('runtime 声明有问题的节点:\n ' + runtimeDecl.join('\n ')); }
t.eq('runtime 声明合法且确实留空', runtimeDecl, []);
const SMOKE_COUNT = 8;
const solveFailures = [];
allNodes.forEach(entry => {
// 有 bySeat 的按三个显示位各求一次,没有的求一次
const seats = entry.node.bySeat ? Object.keys(entry.node.bySeat) : [null];
seats.forEach(seat => {
const eff = effectiveNode(entry.node, seat);
const ctx = { rects: fakeRects };
if (seat) { ctx.seat = seat; }
(eff.runtime || []).forEach(k => { ctx[k] = RUNTIME_FAKE[k]; });
// 期望产出多少个矩形——只断言"是数组、坐标是数字"的话,返回空数组也照样通过,
// 「少画了几张牌」这类缺陷对守卫完全不可见(曾经 grid 缺 rows 就是这么漏过去的)
let expected;
if (eff.kind === 'grid') {
const rows = (typeof ctx.rows === 'number') ? ctx.rows : eff.rows;
const capacity = eff.cols * rows;
ctx.count = Math.min(SMOKE_COUNT, capacity); // 不制造超载(超载抛错另有专门用例)
expected = ctx.count;
} else if (eff.kind === 'line' || eff.kind === 'fan') {
ctx.count = SMOKE_COUNT;
expected = eff.items ? eff.items.length : SMOKE_COUNT;
} else {
expected = 1; // point / attach 恒为 1
}
const label = entry.path + (seat ? '[' + seat + ']' : '');
try {
const r = EQW_LayoutSolver.solve(entry.node, ctx);
if (!Array.isArray(r)) { solveFailures.push(label + ' -> 返回值不是数组'); return; }
if (r.length !== expected) {
solveFailures.push(label + ' -> 矩形数 ' + r.length + ',期望 ' + expected);
return;
}
r.forEach((rect, i) => {
if (typeof rect.x !== 'number' || isNaN(rect.x) ||
typeof rect.y !== 'number' || isNaN(rect.y)) {
solveFailures.push(label + ' -> 第' + i + '项坐标非法: ' + JSON.stringify(rect));
}
});
} catch (e) {
solveFailures.push(label + ' -> ' + e.message);
}
});
});
if (solveFailures.length) { console.log('求解失败的节点:\n ' + solveFailures.join('\n ')); }
t.eq('全部布局节点可求解、矩形数与坐标都对', solveFailures, []);
// 钉住节点总数:松下界(如 >40)删掉整个 View 都还能通过,任何意外的增删都要在这里可见。
// 增删布局节点时,连同本数字一起改——改动是有意的就顺手更新,不是有意的就红给你看
t.eq('布局节点总数', allNodes.length, 62);
// ---- 布局文件是纯数据:不含函数 ----
const funcs = [];
function checkPure(node, path) {
if (!node || typeof node !== 'object') { return; }
Object.keys(node).forEach(k => {
const v = node[k];
if (typeof v === 'function') { funcs.push(path + '.' + k); }
else if (v && typeof v === 'object') { checkPure(v, path + '.' + k); }
});
}
Object.keys(EQW_Layout).forEach(k => checkPure(EQW_Layout[k], k));
t.eq('布局配置不含函数', funcs, []);
// ---- 每个布局节点的 kind 合法 ----
const badKinds = [];
function checkKind(node, path) {
if (!node || typeof node !== 'object') { return; }
if (node.kind && ['point', 'line', 'fan', 'grid', 'attach'].indexOf(node.kind) < 0) {
badKinds.push(path + ' -> ' + node.kind);
}
Object.keys(node).forEach(k => {
if (node[k] && typeof node[k] === 'object' && !Array.isArray(node[k])) { checkKind(node[k], path + '.' + k); }
});
}
Object.keys(EQW_Layout).forEach(k => checkKind(EQW_Layout[k], k));
t.eq('布局 kind 全部合法', badKinds, []);
// ---- 牌尺寸档引用的资源键存在 ----
['L', 'M', 'S'].forEach(size => {
t.eq('CARD_SIZE.' + size + ' 的资源存在', EQW_Images.hasOwnProperty(EQW_Layout.CARD_SIZE[size].res), true);
});
process.exit(t.done('constants') ? 0 : 1);