求解器(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>
342 lines
23 KiB
JavaScript
342 lines
23 KiB
JavaScript
// 五型布局求解器(清单 §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 w/h 运行时注入(ctx.w/ctx.h,供动态文字标签/牌角标等尺寸未知的模板节点用)============
|
||
// 配置无 w、hAlign=right(用得到 sprite.width)、靠 ctx.w 补上 → 正常求解,返回 width 就是 ctx.w
|
||
const wFromCtx = EQW_LayoutSolver.solve(
|
||
{ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', h: 10 },
|
||
{ rects: rects, w: 42 }
|
||
);
|
||
t.eq('ctx.w 补全缺失的 w,正常求解', throws(() => EQW_LayoutSolver.solve(
|
||
{ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', h: 10 }, { rects: rects, w: 42 }
|
||
)), false);
|
||
t.eq('返回的 width 等于 ctx.w', wFromCtx[0].width, 42);
|
||
|
||
// 配置有 w,同时给 ctx.w → ctx.w 优先覆盖配置值
|
||
const wOverride = EQW_LayoutSolver.solve(
|
||
{ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', w: 20, h: 10 },
|
||
{ rects: rects, w: 99 }
|
||
);
|
||
t.eq('ctx.w 覆盖配置里的 w', wOverride[0].width, 99);
|
||
|
||
// 配置无 h、vAlign=middle(用得到 sprite.height)、靠 ctx.h 补上 → 正常求解,返回 height 就是 ctx.h
|
||
const hFromCtx = EQW_LayoutSolver.solve(
|
||
{ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'middle', w: 20 },
|
||
{ rects: rects, h: 33 }
|
||
);
|
||
t.eq('返回的 height 等于 ctx.h', hFromCtx[0].height, 33);
|
||
|
||
// 配置无 w、hAlign=right、ctx.w 也没给 → 仍然抛错(不能因为加了通道就失效)
|
||
t.eq('两处都没给 w 仍抛错', throws(() => EQW_LayoutSolver.solve(
|
||
{ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', h: 10 }, { rects: rects }
|
||
)), true);
|
||
|
||
// ctx.w/ctx.h 对不需要该维度的对齐模式(hAlign=left)不产生影响,仍正常求解
|
||
t.eq('hAlign=left 时给不给 ctx.w 都不影响', throws(() => EQW_LayoutSolver.solve(
|
||
{ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'top' }, { rects: rects, w: 7 }
|
||
)), false);
|
||
|
||
// ============ 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);
|
||
|
||
// ============ 方向 × 对齐基准:写错方向的 anchor 必须抛错,不被框架 default 静默吞掉 ============
|
||
// AlignmentUtils.distributeVertically 只认 top/bottom/center,'left' 会落到 default(居中)——
|
||
// 3 项 32 高 8 间距时算出 y=[-56,-16,24],前两行跑到画布外,界面上却看不出是配置写错了
|
||
const vLine = (anchor) => ({ kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 0, itemWidth: 200, itemHeight: 32, spacing: 8, anchor: anchor });
|
||
const hLine = (anchor) => ({ kind: 'line', direction: 'horizontal', anchorX: 0, anchorY: 0, itemWidth: 200, itemHeight: 32, spacing: 8, anchor: anchor });
|
||
t.eq('竖排 anchor=left 抛错', throws(() => EQW_LayoutSolver.solve(vLine('left'), { count: 3 })), true);
|
||
t.eq('竖排 anchor=right 抛错', throws(() => EQW_LayoutSolver.solve(vLine('right'), { count: 3 })), true);
|
||
t.eq('竖排 anchor=top 正常', throws(() => EQW_LayoutSolver.solve(vLine('top'), { count: 3 })), false);
|
||
t.eq('竖排 anchor=bottom 正常', throws(() => EQW_LayoutSolver.solve(vLine('bottom'), { count: 3 })), false);
|
||
t.eq('竖排 anchor=center 正常', throws(() => EQW_LayoutSolver.solve(vLine('center'), { count: 3 })), false);
|
||
t.eq('竖排 不写 anchor 正常(框架默认 center)', throws(() => EQW_LayoutSolver.solve(vLine(undefined), { count: 3 })), false);
|
||
t.eq('横排 anchor=top 抛错', throws(() => EQW_LayoutSolver.solve(hLine('top'), { count: 3 })), true);
|
||
t.eq('横排 anchor=bottom 抛错', throws(() => EQW_LayoutSolver.solve(hLine('bottom'), { count: 3 })), true);
|
||
t.eq('横排 anchor=left 正常', throws(() => EQW_LayoutSolver.solve(hLine('left'), { count: 3 })), false);
|
||
t.eq('横排 anchor=right 正常', throws(() => EQW_LayoutSolver.solve(hLine('right'), { count: 3 })), false);
|
||
t.eq('未知 direction 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'line', direction: 'diagonal', anchorX: 0, anchorY: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' }, { count: 3 })), true);
|
||
// fan 走同一条 distribute,校验一致
|
||
t.eq('fan 竖排 anchor=left 抛错', throws(() => EQW_LayoutSolver.solve(
|
||
{ kind: 'fan', direction: 'vertical', anchorX: 0, anchorY: 0, maxWidth: 100, itemWidth: 10, itemHeight: 10, spacingMax: 0, spacingMin: -5, anchor: 'left' }, { count: 3 })), true);
|
||
// grid 逐行走水平分布,竖排语义的 anchor 同样拒绝
|
||
t.eq('grid anchor=top 抛错', throws(() => EQW_LayoutSolver.solve(
|
||
{ kind: 'grid', anchorX: 0, anchorY: 0, cols: 2, rows: 2, itemWidth: 10, itemHeight: 10, anchor: 'top' }, { count: 4 })), true);
|
||
// line + items 只实现了水平:竖排显式拒绝,不按水平硬算
|
||
t.eq('line+items 竖排抛错', throws(() => EQW_LayoutSolver.solve(
|
||
{ kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 0, itemHeight: 10, anchor: 'top', items: [{ key: 'A', width: 10 }] }, {})), true);
|
||
|
||
// ============ 对齐基准点必填:框架的 anchorY||0 会把缺失静默变成 0 ============
|
||
t.eq('line 缺 anchorY 抛错', throws(() => EQW_LayoutSolver.solve(
|
||
{ kind: 'line', direction: 'horizontal', anchorX: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' }, { count: 2 })), true);
|
||
t.eq('line 由 ctx.anchorY 补上则正常', EQW_LayoutSolver.solve(
|
||
{ kind: 'line', direction: 'horizontal', anchorX: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' },
|
||
{ count: 2, anchorY: 77 })[0].y, 77);
|
||
t.eq('ctx.anchorX 覆盖配置 anchorX', EQW_LayoutSolver.solve(
|
||
{ kind: 'line', direction: 'horizontal', anchorX: 0, anchorY: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' },
|
||
{ count: 2, anchorX: 500 })[0].x, 500);
|
||
t.eq('fan 缺 anchorX 抛错', throws(() => EQW_LayoutSolver.solve(
|
||
{ kind: 'fan', direction: 'horizontal', anchorY: 0, maxWidth: 100, itemWidth: 10, itemHeight: 10, spacingMax: 0, spacingMin: -5, anchor: 'left' }, { count: 3 })), true);
|
||
|
||
// ============ grid:cols/rows 缺失必须抛错(曾静默返回空数组)============
|
||
// cols*undefined = NaN → count > NaN 恒 false(超载守卫失效)、for(row<undefined) 一次不跑 → 返回 []
|
||
const gridNoRows = { kind: 'grid', anchorX: 0, anchorY: 0, cols: 4, itemWidth: 10, itemHeight: 10, anchor: 'left' };
|
||
t.eq('grid 缺 rows 抛错', throws(() => EQW_LayoutSolver.solve(gridNoRows, { count: 3 })), true);
|
||
t.eq('grid 缺 cols 抛错', throws(() => EQW_LayoutSolver.solve(
|
||
{ kind: 'grid', anchorX: 0, anchorY: 0, rows: 2, itemWidth: 10, itemHeight: 10, anchor: 'left' }, { count: 3 })), true);
|
||
// ctx.rows 运行时注入:行数由实际项数决定的场景
|
||
t.eq('ctx.rows 注入后正常求解', EQW_LayoutSolver.solve(gridNoRows, { count: 6, rows: 2 }).length, 6);
|
||
t.eq('ctx.rows 参与容量计算(满格)', EQW_LayoutSolver.solve(gridNoRows, { count: 8, rows: 2 }).length, 8);
|
||
t.eq('ctx.rows 参与容量计算(超载仍抛错)', throws(() => EQW_LayoutSolver.solve(gridNoRows, { count: 9, rows: 2 })), true);
|
||
t.eq('ctx.rows 覆盖配置 rows', throws(() => EQW_LayoutSolver.solve(
|
||
{ kind: 'grid', anchorX: 0, anchorY: 0, cols: 4, rows: 4, itemWidth: 10, itemHeight: 10, anchor: 'left' },
|
||
{ count: 9, rows: 2 })), true);
|
||
|
||
// ============ point:x/y 缺失抛错 + 运行时注入 ============
|
||
t.eq('point 缺 x/y 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'point', w: 10, h: 10 }, {})), true);
|
||
t.eq('point 缺 y 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'point', x: 1, w: 10, h: 10 }, {})), true);
|
||
t.eq('point 由 ctx.x/ctx.y 注入', EQW_LayoutSolver.solve({ kind: 'point', w: 10, h: 10 }, { x: 5, y: 6 }),
|
||
[{ x: 5, y: 6, width: 10, height: 10 }]);
|
||
t.eq('point 的 ctx.w/ctx.h 也生效', EQW_LayoutSolver.solve({ kind: 'point', x: 1, y: 2 }, { w: 33, h: 44 }),
|
||
[{ x: 1, y: 2, width: 33, height: 44 }]);
|
||
|
||
// ============ 运行时注入通道清单 ============
|
||
t.eq('INJECT_KEYS 覆盖 target/x/y/w/h/anchorX/anchorY/rows/itemWidth/itemHeight',
|
||
EQW_LayoutSolver.INJECT_KEYS.slice().sort(),
|
||
['anchorX', 'anchorY', 'h', 'itemHeight', 'itemWidth', 'rows', 'target', 'w', 'x', 'y']);
|
||
t.eq('ctx.itemWidth 注入生效', EQW_LayoutSolver.solve(
|
||
{ kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 0, itemHeight: 32, spacing: 8, anchor: 'top' },
|
||
{ count: 2, itemWidth: 240 })[0].width, 240);
|
||
// runtime 声明只是给人和守卫看的契约,不影响求解(求解器对所有 INJECT_KEYS 一视同仁)
|
||
t.eq('runtime 声明不影响求解', EQW_LayoutSolver.solve(
|
||
{ kind: 'point', runtime: ['x', 'y'] }, { x: 7, y: 8 }), [{ x: 7, y: 8, width: undefined, height: undefined }]);
|
||
|
||
// ============ apply:精灵数与矩形数不一致必须抛错,不静默截断 ============
|
||
// 少摆的精灵会停在上一手牌的旧坐标上(陈旧精灵 + 陈旧坐标),是最难反查的一类错位
|
||
const moved = [];
|
||
global.SpriteManager = { setPosition: function (id, x, y) { moved.push([id, x, y]); } };
|
||
const applyLine = { kind: 'line', direction: 'horizontal', anchorX: 0, anchorY: 0, itemWidth: 10, itemHeight: 10, spacing: 0, anchor: 'left' };
|
||
moved.length = 0;
|
||
EQW_LayoutSolver.apply(applyLine, [11, 12, 13], { count: 3 });
|
||
t.eq('apply 数量相等时逐个摆位', moved, [[11, 0, 0], [12, 10, 0], [13, 20, 0]]);
|
||
moved.length = 0;
|
||
t.eq('apply 精灵多于矩形抛错', throws(() => EQW_LayoutSolver.apply(applyLine, [11, 12, 13], { count: 2 })), true);
|
||
t.eq('apply 精灵少于矩形抛错', throws(() => EQW_LayoutSolver.apply(applyLine, [11, 12], { count: 3 })), true);
|
||
t.eq('apply 抛错时一个都没摆', moved, []);
|
||
t.eq('apply 未传 spriteIds 抛错', throws(() => EQW_LayoutSolver.apply(applyLine, null, { count: 3 })), true);
|
||
t.eq('apply 空数组 + 零矩形不抛错', throws(() => EQW_LayoutSolver.apply(applyLine, [], { count: 0 })), false);
|
||
|
||
// ============ 纯函数:不修改入参 ============
|
||
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);
|