Files
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

342 lines
23 KiB
JavaScript
Raw Permalink 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 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);