Critical fix:
- setPlayerData fake state lacked subscribe() -> PlayerInfoView.bindTo threw
TypeError at runtime. Switched to PlayerInfoView.applyPlayer({nickname,
bean,avatar}) when available; falls back to bindTo + proper reactive
store with subscribe/value if only bindTo is exposed.
Runtime validation (per CLAUDE.md 第二准则 — no silent fallback):
- computePositions / setSeatLayout: validate layout ∈ VALID_LAYOUTS
- computePositions / setPlayerCount: validate count ∈ [1,8] AND is integer
- setPlayerData: validate seat is integer ∈ [1, currentCount]
- extractPlayerViewData: validate nickname/bean/avatar types (no ?? 兜底)
- start(): validate no null/undefined entries in playerSlots
Architecture:
- Extracted pure controller logic to room-scene-controller.ts so tests can
import without cc mock (RoomSceneStart remains thin cc wrapper that
delegates). Matches existing pattern of room-scene-layout.ts.
Tests:
- Added 27 new tests (validate* / apply* / extract* / clearSlots)
- 5 original layout tests kept; 254/254 PASS (222 prior + 32 room-scene)
Spec:
- Resolved 2x2/triangle/square contradiction: Success Metrics + 成功标准
both defer coordinates to layout sub-task, consistent with 不覆盖 section.
Co-Authored-By: Claude Code <noreply@anthropic.com>
325 lines
13 KiB
JavaScript
325 lines
13 KiB
JavaScript
// RoomSceneStart 控制层 + 布局算法测试。
|
|
//
|
|
// 文件分为两部分:
|
|
// A) 布局算法:从 room-scene-layout.ts 直接 import(纯函数,无 cc 依赖)。
|
|
// B) 控制器:从 room-scene-controller.ts 直接 import,纯函数 + mock slot 注入。
|
|
//
|
|
// RoomSceneStart.ts 自身依赖 cc,无法在 node + tsx 下加载 — 因此把可测试逻辑
|
|
// 抽到 controller.ts,cc 类只做转发。本文件测试的是真正的行为,不是 mock 出来的。
|
|
//
|
|
// CLAUDE.md 第二准则:不兜底。所有非法输入一律 throw,本文件用 assert.throws 显式断言。
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { computeActiveFlags, computePositions } from '../../YouleNexus/assets/scripts/room-scene-layout.ts';
|
|
import {
|
|
applyPlayerCount,
|
|
applyPlayerData,
|
|
applySeatLayoutPositions,
|
|
clearSlots,
|
|
extractPlayerView,
|
|
extractPlayerViewData,
|
|
validateCount,
|
|
validateLayout,
|
|
validateSeat,
|
|
} from '../../YouleNexus/assets/scripts/room-scene-controller.ts';
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// A) 布局算法(沿用原有测试,加新校验用例)
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
test('computeActiveFlags(2, 8) → 前 2 true, 其余 false', () => {
|
|
assert.deepEqual(
|
|
computeActiveFlags(2, 8),
|
|
[true, true, false, false, false, false, false, false],
|
|
);
|
|
});
|
|
|
|
test('computeActiveFlags(4, 8) → 前 4 true, 其余 false', () => {
|
|
assert.deepEqual(
|
|
computeActiveFlags(4, 8),
|
|
[true, true, true, true, false, false, false, false],
|
|
);
|
|
});
|
|
|
|
test('computeActiveFlags(0, 8) → throw', () => {
|
|
assert.throws(() => computeActiveFlags(0, 8), /count must be 1-8/);
|
|
});
|
|
|
|
test('computeActiveFlags(9, 8) → throw (exceeds 8)', () => {
|
|
assert.throws(() => computeActiveFlags(9, 8), /count must be 1-8/);
|
|
});
|
|
|
|
test('computePositions(horizontal, 4) → 4 玩家水平排列居中', () => {
|
|
const positions = computePositions('horizontal', 4);
|
|
assert.equal(positions.length, 4);
|
|
// 对称居中:首末 + 中间两项 sum = 0
|
|
assert.equal(positions[0].x + positions[3].x, 0);
|
|
assert.equal(positions[1].x + positions[2].x, 0);
|
|
// 全员 y=0
|
|
assert.ok(positions.every((p) => p.y === 0));
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// B) 控制器:输入校验(controller.ts 中的 validate*)
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
test('validateCount(0) → throw', () => {
|
|
assert.throws(() => validateCount(0), /count must be 1-8/);
|
|
});
|
|
|
|
test('validateCount(9) → throw (exceeds MAX_SEATS)', () => {
|
|
assert.throws(() => validateCount(9), /count must be 1-8/);
|
|
});
|
|
|
|
test('validateCount(1.5) → throw (non-integer)', () => {
|
|
assert.throws(() => validateCount(1.5), /count must be an integer/);
|
|
});
|
|
|
|
test('validateCount(NaN) → throw', () => {
|
|
assert.throws(() => validateCount(NaN), /count must be an integer/);
|
|
});
|
|
|
|
test('validateCount(\'4\') → throw (non-number type)', () => {
|
|
assert.throws(() => validateCount('4'), /count must be an integer/);
|
|
});
|
|
|
|
test('validateLayout(\'bogus\') → throw', () => {
|
|
assert.throws(() => validateLayout('bogus'), /layout must be one of/);
|
|
});
|
|
|
|
test('validateLayout(undefined) → throw', () => {
|
|
assert.throws(() => validateLayout(undefined), /layout must be one of/);
|
|
});
|
|
|
|
test('validateSeat(0, 4) → throw (below range)', () => {
|
|
assert.throws(() => validateSeat(0, 4), /seat 0 out of range 1-4/);
|
|
});
|
|
|
|
test('validateSeat(5, 4) → throw (above currentCount)', () => {
|
|
assert.throws(() => validateSeat(5, 4), /seat 5 out of range 1-4/);
|
|
});
|
|
|
|
test('validateSeat(1.5, 4) → throw (non-integer)', () => {
|
|
assert.throws(() => validateSeat(1.5, 4), /seat must be an integer/);
|
|
});
|
|
|
|
test('validateSeat(NaN, 4) → throw', () => {
|
|
assert.throws(() => validateSeat(NaN, 4), /seat must be an integer/);
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// C) 控制器:apply* 行为 + extractPlayerViewData
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
/** Mock 槽位工厂:返回带 active / setPosition / getComponent 的普通对象。 */
|
|
function mockSlot(view = {}) {
|
|
const positions = [];
|
|
let active = true;
|
|
return {
|
|
get active() { return active; },
|
|
set active(v) { active = v; },
|
|
setPosition(x, y, z) { positions.push({ x, y, z }); },
|
|
_positions: positions,
|
|
getComponent(name) {
|
|
return name === 'PlayerInfoView' ? view : null;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Mock PlayerInfoView:记录 applyPlayer / bindTo 调用。 */
|
|
function mockView() {
|
|
const calls = { applyPlayer: [], bindTo: [] };
|
|
return {
|
|
calls,
|
|
applyPlayer(p) { calls.applyPlayer.push(p); },
|
|
bindTo(session) { calls.bindTo.push(session); },
|
|
};
|
|
}
|
|
|
|
test('applyPlayerCount(4) → 前 4 active=true, 其余 false', () => {
|
|
const slots = Array.from({ length: 8 }, () => mockSlot());
|
|
applyPlayerCount(slots, 4);
|
|
assert.deepEqual(
|
|
slots.map((s) => s.active),
|
|
[true, true, true, true, false, false, false, false],
|
|
);
|
|
});
|
|
|
|
test('applyPlayerCount(0) → throw (no slots mutated)', () => {
|
|
const slots = Array.from({ length: 8 }, () => mockSlot());
|
|
assert.throws(() => applyPlayerCount(slots, 0), /count must be 1-8/);
|
|
// throw 之前 active 已被读取但未被赋值;但更关键的:count 校验直接抛错,
|
|
// 没有任何 active flag 写回发生;确认 slots 状态全部不变。
|
|
assert.ok(slots.every((s) => s.active === true));
|
|
});
|
|
|
|
test('applyPlayerCount(1.5) → throw (integer check)', () => {
|
|
const slots = Array.from({ length: 8 }, () => mockSlot());
|
|
assert.throws(() => applyPlayerCount(slots, 1.5), /count must be an integer/);
|
|
});
|
|
|
|
test('applySeatLayoutPositions(horizontal, 3) → 前 3 slot setPosition 被调', () => {
|
|
const slots = Array.from({ length: 8 }, () => mockSlot());
|
|
applySeatLayoutPositions(slots, 'horizontal', 3);
|
|
assert.equal(slots[0]._positions.length, 1);
|
|
assert.equal(slots[1]._positions.length, 1);
|
|
assert.equal(slots[2]._positions.length, 1);
|
|
assert.equal(slots[3]._positions.length, 0, 'count=3,slot[3] 不应被 setPosition');
|
|
// 居中对称
|
|
assert.equal(slots[0]._positions[0].x + slots[2]._positions[0].x, 0);
|
|
});
|
|
|
|
test('applySeatLayoutPositions(\'bogus\', 4) → throw', () => {
|
|
const slots = Array.from({ length: 8 }, () => mockSlot());
|
|
assert.throws(
|
|
() => applySeatLayoutPositions(slots, 'bogus', 4),
|
|
/layout must be one of/,
|
|
);
|
|
});
|
|
|
|
test('applySeatLayoutPositions(horizontal, 9) → throw (count check)', () => {
|
|
const slots = Array.from({ length: 8 }, () => mockSlot());
|
|
assert.throws(
|
|
() => applySeatLayoutPositions(slots, 'horizontal', 9),
|
|
/count must be 1-8/,
|
|
);
|
|
});
|
|
|
|
test('extractPlayerView: slot 无 PlayerInfoView → null', () => {
|
|
const slot = { active: true, setPosition() {}, getComponent: () => null };
|
|
assert.equal(extractPlayerView(slot), null);
|
|
});
|
|
|
|
test('extractPlayerView: slot 本身 null → null', () => {
|
|
assert.equal(extractPlayerView(null), null);
|
|
});
|
|
|
|
test('extractPlayerViewData: 合法三字段 → 返回 PlayerViewData', () => {
|
|
const out = extractPlayerViewData({ nickname: 'A', bean: 100, avatar: 'p.png' });
|
|
assert.deepEqual(out, { nickname: 'A', bean: 100, avatar: 'p.png' });
|
|
});
|
|
|
|
test('extractPlayerViewData: nickname 缺/非 string → throw', () => {
|
|
assert.throws(() => extractPlayerViewData({ bean: 0, avatar: '' }), /nickname must be string/);
|
|
assert.throws(
|
|
() => extractPlayerViewData({ nickname: 42, bean: 0, avatar: '' }),
|
|
/nickname must be string/,
|
|
);
|
|
});
|
|
|
|
test('extractPlayerViewData: bean 缺/NaN/非 number → throw', () => {
|
|
assert.throws(() => extractPlayerViewData({ nickname: 'A', avatar: '' }), /bean must be number/);
|
|
assert.throws(
|
|
() => extractPlayerViewData({ nickname: 'A', bean: NaN, avatar: '' }),
|
|
/bean must be number/,
|
|
);
|
|
assert.throws(
|
|
() => extractPlayerViewData({ nickname: 'A', bean: '0', avatar: '' }),
|
|
/bean must be number/,
|
|
);
|
|
});
|
|
|
|
test('extractPlayerViewData: avatar 缺/非 string → throw', () => {
|
|
assert.throws(
|
|
() => extractPlayerViewData({ nickname: 'A', bean: 0 }),
|
|
/avatar must be string/,
|
|
);
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// D) 控制器:applyPlayerData / clearSlots(集成 view mock)
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
test('applyPlayerData: 合法输入 → view.applyPlayer 被调,bindTo 不被调', () => {
|
|
const view = mockView();
|
|
const slots = Array.from({ length: 8 }, () => mockSlot(view));
|
|
applyPlayerData(slots, 1, 4, { nickname: 'Alice', bean: 100, avatar: 'a.png' });
|
|
assert.equal(view.calls.applyPlayer.length, 1);
|
|
assert.deepEqual(view.calls.applyPlayer[0], { nickname: 'Alice', bean: 100, avatar: 'a.png' });
|
|
assert.equal(view.calls.bindTo.length, 0);
|
|
});
|
|
|
|
test('applyPlayerData: 缺 applyPlayer → 回退到 bindTo + reactive store', () => {
|
|
// 构造只有 bindTo 的 view(模拟某些不带 applyPlayer 的旧版/外部组件)
|
|
const bindCalls = [];
|
|
const view = {
|
|
bindTo(session) {
|
|
bindCalls.push(session);
|
|
// 验证契约 — subscribe 必须存在,不能是 undefined
|
|
assert.equal(typeof session.player.state.subscribe, 'function');
|
|
assert.deepEqual(session.player.state.value, { nickname: 'Bob', bean: 50, avatar: 'b' });
|
|
},
|
|
};
|
|
const slots = Array.from({ length: 8 }, () => mockSlot(view));
|
|
applyPlayerData(slots, 2, 4, { nickname: 'Bob', bean: 50, avatar: 'b' });
|
|
assert.equal(bindCalls.length, 1);
|
|
});
|
|
|
|
test('applyPlayerData: seat=1.5 → throw (不调任何 view 方法)', () => {
|
|
const view = mockView();
|
|
const slots = Array.from({ length: 8 }, () => mockSlot(view));
|
|
assert.throws(() => applyPlayerData(slots, 1.5, 4, { nickname: 'A', bean: 0, avatar: '' }), /seat must be an integer/);
|
|
assert.equal(view.calls.applyPlayer.length, 0);
|
|
assert.equal(view.calls.bindTo.length, 0);
|
|
});
|
|
|
|
test('applyPlayerData: seat=5, currentCount=4 → throw', () => {
|
|
const view = mockView();
|
|
const slots = Array.from({ length: 8 }, () => mockSlot(view));
|
|
assert.throws(() => applyPlayerData(slots, 5, 4, { nickname: 'A', bean: 0, avatar: '' }), /out of range 1-4/);
|
|
assert.equal(view.calls.applyPlayer.length, 0);
|
|
});
|
|
|
|
test('applyPlayerData: data 缺 nickname → throw (不调 view)', () => {
|
|
const view = mockView();
|
|
const slots = Array.from({ length: 8 }, () => mockSlot(view));
|
|
assert.throws(
|
|
() => applyPlayerData(slots, 1, 4, { bean: 0, avatar: '' }),
|
|
/nickname must be string/,
|
|
);
|
|
assert.equal(view.calls.applyPlayer.length, 0);
|
|
});
|
|
|
|
test('applyPlayerData: slot 缺 PlayerInfoView 组件 → throw', () => {
|
|
const slot = { active: true, setPosition() {}, getComponent: () => null };
|
|
const slots = Array.from({ length: 8 }, () => slot);
|
|
assert.throws(
|
|
() => applyPlayerData(slots, 1, 4, { nickname: 'A', bean: 0, avatar: '' }),
|
|
/has no PlayerInfoView component/,
|
|
);
|
|
});
|
|
|
|
test('applyPlayerData: view 既无 applyPlayer 也无 bindTo → throw', () => {
|
|
const slot = { active: true, setPosition() {}, getComponent: () => ({}) };
|
|
const slots = Array.from({ length: 8 }, () => slot);
|
|
assert.throws(
|
|
() => applyPlayerData(slots, 1, 4, { nickname: 'A', bean: 0, avatar: '' }),
|
|
/neither applyPlayer nor bindTo/,
|
|
);
|
|
});
|
|
|
|
test('clearSlots: 当前可见槽位全刷成占位 + applyPlayer 被调 currentCount 次', () => {
|
|
const view = mockView();
|
|
const slots = Array.from({ length: 8 }, () => mockSlot(view));
|
|
clearSlots(slots, 3);
|
|
assert.equal(view.calls.applyPlayer.length, 3, '前 3 个 slot 各调一次');
|
|
assert.ok(
|
|
view.calls.applyPlayer.every(
|
|
(p) => p.nickname === '占位' && p.bean === 0 && p.avatar === '',
|
|
),
|
|
);
|
|
// 三次调用传入的结构应一致(extractPlayerViewData 每次 new 对象,用 deepEqual)
|
|
assert.deepEqual(view.calls.applyPlayer[0], view.calls.applyPlayer[1]);
|
|
assert.deepEqual(view.calls.applyPlayer[1], view.calls.applyPlayer[2]);
|
|
});
|
|
|
|
test('clearSlots: 部分 setPlayerData 已失败后不再继续(不部分清空)', () => {
|
|
// 让 seat=2 的 slot 没 PlayerInfoView → 第一次失败后立即抛错,seat=3/4 不应被调用
|
|
const view = mockView();
|
|
const slots = Array.from({ length: 8 }, (_, i) =>
|
|
i === 1 ? { active: true, setPosition() {}, getComponent: () => null } : mockSlot(view),
|
|
);
|
|
assert.throws(() => clearSlots(slots, 4), /has no PlayerInfoView component/);
|
|
// seat=1 已被调用,然后 seat=2 throw,所以 applyPlayer 只应被调 1 次
|
|
assert.equal(view.calls.applyPlayer.length, 1);
|
|
}); |