// 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); });