Files
erqiwang_youle/client/tests/test_layoutsolver.js
T
joywayerandClaude Opus 5 2d0469e7f3 二七王:前端 LayoutSolver,五型布局求解器
solve 是纯函数(attach 靠 ctx.rects 查目标矩形而非反查引擎),
fan 的 clamp 间距算法只此一处实现;line 带 items 时自行按累计宽度推进,
不走假设等宽的 distribute。未知 kind/缺参/target 缺失一律抛错,不兜底。
apply 是唯一触碰 SpriteManager 的三行封装。

测试里 attach 外侧右上角一条按 AlignmentUtils.alignCornerTopRight 的真实实现
(角对角重合,非贴外侧)修正了期望值,未在求解器里做偏移补偿迁就猜测值。

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

147 lines
8.1 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);
// ============ 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);
});
// ============ 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);