Files
erqiwang_youle/client/tests/test_layoutsolver.js
T
joywayerandClaude Opus 5 91f0eb116c 二七王:LayoutSolver attach 支持运行时 target 注入,按对齐模式校验 w/h
配置落地后暴露两个缺口:叫分档位/花色图标/牌角标等 attach 目标要运行时
才确定,配置写不出固定 target,需要 ctx.target 覆盖/补全(优先于 n.target,
两者皆无则抛错);纯文字标签没有固定 w/h,此前缺失时会静默算出 NaN 坐标。
改为按本次实际走的对齐分支(hAlign/vAlign/corner)逐一核对 AlignmentUtils
源码确认是否用到 sprite.width/height,只在用得到时才要求对应字段是数字,
不用得到时(如 left/top/bottom、topLeft)不强制,避免逼纯文字配置编造假值。

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

221 lines
14 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.
// 五型布局求解器(清单 §6.1 / §6.2)
const { load, throws } = require('./_load');
const t = require('./_assert')();
load('client/js/gameabc-framework/ui/AlignmentUtils.js');
load('client/js/01_SubGame/codes/ui/LayoutSolver.js');
// ============ point ============
const point = { kind: 'point', x: 486, y: 4, w: 276, h: 68 };
t.eq('point 返回单元素数组', EQW_LayoutSolver.solve(point, {}), [{ x: 486, y: 4, width: 276, height: 68 }]);
// ============ line 等距 ============
// 底牌区:8 张 110×190,spacing -36,anchor center,基准 (655,110)
// totalWidth = 8*110 + 7*(-36) = 880 - 252 = 628;startX = 655 - 314 = 341
const line8 = { kind: 'line', direction: 'horizontal', anchorX: 655, anchorY: 110,
itemWidth: 110, itemHeight: 190, spacing: -36, anchor: 'center' };
const r1 = EQW_LayoutSolver.solve(line8, { count: 8 });
t.eq('line 张数', r1.length, 8);
t.eq('line 首张 x', r1[0].x, 341);
t.eq('line 次张 x', r1[1].x, 341 + 110 - 36);
t.eq('line y 恒定', r1[7].y, 110);
t.eq('line 带上尺寸', { w: r1[0].width, h: r1[0].height }, { w: 110, h: 190 });
// ============ line + items 逐项不等宽 ============
// 底栏功能钮组:宽 74/87/93/78,spacing 15,anchor right,基准 x=1250
// totalWidth = 74+87+93+78 + 3*15 = 332 + 45 = 377;startX = 1250 - 377 = 873
const lineItems = { kind: 'line', direction: 'horizontal', anchorX: 1250, anchorY: 672,
itemHeight: 30, spacing: 15, anchor: 'right',
items: [{ key: 'A', width: 74 }, { key: 'B', width: 87 },
{ key: 'C', width: 93 }, { key: 'D', width: 78 }] };
const r2 = EQW_LayoutSolver.solve(lineItems, {});
t.eq('items 项数(count 由 items 决定)', r2.length, 4);
t.eq('items 首项 x', r2[0].x, 873);
t.eq('items 第二项 x', r2[1].x, 873 + 74 + 15);
t.eq('items 第三项 x', r2[2].x, 873 + 74 + 15 + 87 + 15);
t.eq('items 逐项宽度', r2.map(r => r.width), [74, 87, 93, 78]);
t.eq('items 末项右边缘贴基准', r2[3].x + r2[3].width, 1250);
// ============ line 垂直 ============
const lineV = { kind: 'line', direction: 'vertical', anchorX: 100, anchorY: 0,
itemWidth: 200, itemHeight: 32, spacing: 8, anchor: 'top' };
const r3 = EQW_LayoutSolver.solve(lineV, { count: 3 });
t.eq('垂直 y 递增', r3.map(r => r.y), [0, 40, 80]);
t.eq('垂直 x 恒定', r3.map(r => r.x), [100, 100, 100]);
// ============ fan 自适应压缩 ============
// 手牌单排:maxWidth 1215,itemWidth 110,spacingMin -78,spacingMax 0
// 36 张:raw = (1215-110)/35 - 110 = 31.571... - 110 = -78.43 → clamp 到 -78
const fan = { kind: 'fan', direction: 'horizontal', anchorX: 640, anchorY: 455,
maxWidth: 1215, itemWidth: 110, itemHeight: 190,
spacingMax: 0, spacingMin: -78, anchor: 'center', overlapFrom: 'left' };
const f36 = EQW_LayoutSolver.solve(fan, { count: 36 });
t.eq('fan 36 张间距取下限', f36[1].x - f36[0].x, 110 - 78);
// 少量牌时 raw 为正 → clamp 到 spacingMax
const f2 = EQW_LayoutSolver.solve(fan, { count: 2 });
t.eq('fan 2 张间距取上限', f2[1].x - f2[0].x, 110 + 0);
// count <= 1 的边界
const f1 = EQW_LayoutSolver.solve(fan, { count: 1 });
t.eq('fan 单张', f1.length, 1);
t.eq('fan 单张居中', f1[0].x, 640 - 55);
t.eq('fan 零张', EQW_LayoutSolver.solve(fan, { count: 0 }), []);
// clamp 中间值:raw 落在 [min,max] 内时原样取用
// maxWidth 500、itemWidth 100、count 5:raw = (500-100)/4 - 100 = 0 → 落在 [-60, 10] 内
const fanMid = { kind: 'fan', direction: 'horizontal', anchorX: 0, anchorY: 0,
maxWidth: 500, itemWidth: 100, itemHeight: 100,
spacingMax: 10, spacingMin: -60, anchor: 'left' };
const fm = EQW_LayoutSolver.solve(fanMid, { count: 5 });
t.eq('fan clamp 中间值', fm[1].x - fm[0].x, 100 + 0);
// ============ grid ============
// 叫分 14 档:7 列 2 行,74×56,间距 7/24,anchor center,基准 (640,160)
// 行宽 = 7*74 + 6*7 = 518 + 42 = 560;startX = 640 - 280 = 360
const grid = { kind: 'grid', anchorX: 640, anchorY: 160, cols: 7, rows: 2,
itemWidth: 74, itemHeight: 56, spacingX: 7, spacingY: 24,
anchor: 'center', fillOrder: 'row' };
const g = EQW_LayoutSolver.solve(grid, { count: 14 });
t.eq('grid 项数', g.length, 14);
t.eq('grid 首项 x', g[0].x, 360);
t.eq('grid 首项 y', g[0].y, 160);
t.eq('grid 第2项 x', g[1].x, 360 + 74 + 7);
t.eq('grid 第8项换行 x', g[7].x, 360);
t.eq('grid 第8项 y', g[7].y, 160 + 56 + 24);
// count 少于满格时只出 count 个
t.eq('grid 不足一行', EQW_LayoutSolver.solve(grid, { count: 3 }).length, 3);
// 边界:count 恰好等于容量(cols*rows=14)时正常返回,不抛错
t.eq('grid 恰好满格不抛错', EQW_LayoutSolver.solve(grid, { count: 14 }).length, 14);
// count 超出容量:显式抛错,不静默截断多出的项
t.eq('grid 超载抛错', throws(() => EQW_LayoutSolver.solve(grid, { count: 15 })), true);
// fillOrder 校验:目前只支持行优先(YAGNI,未实现列优先),不写等同 row
const gridNoFillOrder = { kind: 'grid', anchorX: 640, anchorY: 160, cols: 7, rows: 2,
itemWidth: 74, itemHeight: 56, spacingX: 7, spacingY: 24, anchor: 'center' };
t.eq('grid 不写 fillOrder 正常', EQW_LayoutSolver.solve(gridNoFillOrder, { count: 14 }).length, 14);
t.eq('grid fillOrder=row 正常', EQW_LayoutSolver.solve(grid, { count: 14 }).length, 14);
const gridColumnOrder = { kind: 'grid', anchorX: 640, anchorY: 160, cols: 7, rows: 2,
itemWidth: 74, itemHeight: 56, spacingX: 7, spacingY: 24, anchor: 'center', fillOrder: 'column' };
t.eq('grid fillOrder=column 抛错', throws(() => EQW_LayoutSolver.solve(gridColumnOrder, { count: 14 })), true);
// ============ attach 内部对齐(corner 为空)============
const rects = { TARGET: { x: 100, y: 200, width: 80, height: 40 } };
const attachIn = { kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top',
offsetX: 8, offsetY: -4, w: 20, h: 10 };
const a1 = EQW_LayoutSolver.solve(attachIn, { rects: rects });
t.eq('attach 内部右上', a1, [{ x: 100 + 80 - 20 + 8, y: 200 - 4, width: 20, height: 10 }]);
// ============ attach 贴外侧角 ============
// 注意:AlignmentUtils.alignCornerTopRight(client/js/gameabc-framework/ui/AlignmentUtils.js:292-298)
// 的真实语义是【精灵的右上角与目标的右上角重合】,即
// x = target.x + target.width - sprite.width + offsetX
// y = target.y + offsetY
// 这与「贴在目标外侧右上角」(x = target.x + target.width + offsetX, y = target.y - sprite.height + offsetY)
// 不是一回事——它是角对角重合(精灵会与目标重叠在右上角内侧),不是贴到外侧。
// 期望值以框架实际实现为准:x = 100 + 80 - 20 + 4 = 164,y = 200 + 6 = 206。
const attachCorner = { kind: 'attach', target: 'TARGET', corner: 'topRight',
offsetX: 4, offsetY: 6, w: 20, h: 10 };
const a2 = EQW_LayoutSolver.solve(attachCorner, { rects: rects });
t.eq('attach 外侧右上角', a2, [{ x: 100 + 80 - 20 + 4, y: 200 + 6, width: 20, height: 10 }]);
// 四个角都要能解(通用检查:长度与类型)
['topLeft', 'topRight', 'bottomLeft', 'bottomRight'].forEach(c => {
const r = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: c, w: 20, h: 10 }, { rects: rects });
t.eq('attach 角 ' + c + ' 可解', r.length === 1 && typeof r[0].x === 'number', true);
});
// 四角精确坐标核对:target={x:100,y:200,width:80,height:40},sprite={w:20,h:10},offset 缺省=0
// topLeft(AlignmentUtils.js:266-272):x=target.x=100,y=target.y=200
const cTopLeft = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'topLeft', w: 20, h: 10 }, { rects: rects });
t.eq('attach 角 topLeft 精确坐标', cTopLeft, [{ x: 100, y: 200, width: 20, height: 10 }]);
// topRight(AlignmentUtils.js:292-298):x=target.x+target.width-sprite.width=100+80-20=160,y=target.y=200
const cTopRight = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'topRight', w: 20, h: 10 }, { rects: rects });
t.eq('attach 角 topRight 精确坐标', cTopRight, [{ x: 160, y: 200, width: 20, height: 10 }]);
// bottomLeft(AlignmentUtils.js:318-324):x=target.x=100,y=target.y+target.height-sprite.height=200+40-10=230
const cBottomLeft = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'bottomLeft', w: 20, h: 10 }, { rects: rects });
t.eq('attach 角 bottomLeft 精确坐标', cBottomLeft, [{ x: 100, y: 230, width: 20, height: 10 }]);
// bottomRight(AlignmentUtils.js:344-350):x=target.x+target.width-sprite.width=100+80-20=160,y=同 bottomLeft=230
const cBottomRight = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'bottomRight', w: 20, h: 10 }, { rects: rects });
t.eq('attach 角 bottomRight 精确坐标', cBottomRight, [{ x: 160, y: 230, width: 20, height: 10 }]);
// ============ attach:按实际对齐模式校验 w/h(缺失且用得到时抛错,用不到时不强制) ============
// hAlign=right 用到 sprite.width(AlignmentUtils.js:111),缺 w 抛错;hAlign=left(101行)不用,缺 w 不抛错
t.eq('attach hAlign=right 缺 w 抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', h: 10 }, { rects: rects })), true);
t.eq('attach hAlign=left 缺 w 不抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'top', h: 10 }, { rects: rects })), false);
// vAlign=middle 用到 sprite.height(127行),缺 h 抛错;vAlign=top(122行)不用,缺 h 不抛错
t.eq('attach vAlign=middle 缺 h 抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'middle', w: 20 }, { rects: rects })), true);
t.eq('attach vAlign=top 缺 h 不抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'top', w: 20 }, { rects: rects })), false);
// vAlign=bottom(132行)用的是 target.height,不是 sprite.height,缺 h 不应抛错——这条容易想当然写错
t.eq('attach vAlign=bottom 缺 h 不抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'bottom', w: 20 }, { rects: rects })), false);
// corner=topLeft(266-272行)不用 w/h,都缺不抛错;corner=bottomRight(344-350行)两者都用,缺一即抛错
t.eq('attach corner=topLeft 缺 w/h 不抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'topLeft' }, { rects: rects })), false);
t.eq('attach corner=bottomRight 缺 w 抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'bottomRight', h: 10 }, { rects: rects })), true);
t.eq('attach corner=bottomRight 缺 h 抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'bottomRight', w: 20 }, { rects: rects })), true);
// ============ attach target 运行时注入(ctx.target,供叫分档位/花色图标/牌角标等模板节点用)============
const rects2 = { TARGET: { x: 100, y: 200, width: 80, height: 40 }, OTHER: { x: 5, y: 6, width: 10, height: 10 } };
// ctx.target 覆盖配置里写死的 target
const attachOverride = { kind: 'attach', target: 'TARGET', corner: 'topLeft' };
const aOverride = EQW_LayoutSolver.solve(attachOverride, { rects: rects2, target: 'OTHER' });
t.eq('ctx.target 覆盖配置 target', { x: aOverride[0].x, y: aOverride[0].y }, { x: 5, y: 6 });
// 配置无 target(模板节点)时,ctx.target 生效
const attachTemplate = { kind: 'attach', corner: 'topLeft' };
const aFromCtxOnly = EQW_LayoutSolver.solve(attachTemplate, { rects: rects, target: 'TARGET' });
t.eq('配置无 target 时 ctx.target 生效', { x: aFromCtxOnly[0].x, y: aFromCtxOnly[0].y }, { x: 100, y: 200 });
// 配置与 ctx 都没给 target:抛错
t.eq('target 与 ctx.target 都缺失抛错',
throws(() => EQW_LayoutSolver.solve(attachTemplate, { rects: rects })), true);
// ============ bySeat 合并:列出的覆盖、未列出的继承 ============
const bySeat = {
kind: 'fan', direction: 'horizontal', itemWidth: 90, itemHeight: 155,
maxWidth: 170, spacingMax: -35, spacingMin: -62,
bySeat: {
SELF: { anchorX: 650, anchorY: 265, anchor: 'center', overlapFrom: 'left' },
RIGHT: { anchorX: 1022, anchorY: 120, anchor: 'center', overlapFrom: 'left' },
LEFT: { anchorX: 258, anchorY: 120, anchor: 'center', overlapFrom: 'right' }
}
};
const sSelf = EQW_LayoutSolver.solve(bySeat, { seat: 'SELF', count: 1 });
const sLeft = EQW_LayoutSolver.solve(bySeat, { seat: 'LEFT', count: 1 });
t.eq('bySeat SELF 用自己的锚点', sSelf[0].x, 650 - 45);
t.eq('bySeat LEFT 用自己的锚点', sLeft[0].x, 258 - 45);
t.eq('bySeat 继承 base 的尺寸', sLeft[0].width, 90);
// ============ 显式失败:不兜底 ============
t.eq('未知 kind 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'blob' }, {})), true);
t.eq('attach target 缺失抛错', throws(() => EQW_LayoutSolver.solve(attachIn, { rects: {} })), true);
t.eq('attach 未给 rects 抛错', throws(() => EQW_LayoutSolver.solve(attachIn, {})), true);
t.eq('bySeat 缺 seat 抛错', throws(() => EQW_LayoutSolver.solve(bySeat, { count: 1 })), true);
t.eq('bySeat 未知 seat 抛错', throws(() => EQW_LayoutSolver.solve(bySeat, { seat: 'TOP', count: 1 })), true);
t.eq('node 为空抛错', throws(() => EQW_LayoutSolver.solve(null, {})), true);
t.eq('未知 corner 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'middle', w: 1, h: 1 }, { rects: rects })), true);
// ============ 纯函数:不修改入参 ============
const before = JSON.stringify(bySeat);
EQW_LayoutSolver.solve(bySeat, { seat: 'SELF', count: 3 });
t.eq('solve 不修改配置对象', JSON.stringify(bySeat), before);
process.exit(t.done('layoutsolver') ? 0 : 1);