fix(scripts): RoomSceneStart setPlayerData bindTo contract + runtime validation
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>
This commit is contained in:
@@ -1,8 +1,31 @@
|
||||
import { _decorator, Component, Node } from 'cc';
|
||||
import type { PlayerState } from '../framework/platform/stores/types.ts';
|
||||
import { computeActiveFlags, computePositions, type SeatLayout } from './room-scene-layout.ts';
|
||||
import {
|
||||
applyPlayerCount,
|
||||
applyPlayerData,
|
||||
applySeatLayoutPositions,
|
||||
clearSlots,
|
||||
computeActiveFlags,
|
||||
computePositions,
|
||||
type PlayerSlotLike,
|
||||
type SeatLayout,
|
||||
validateCount,
|
||||
validateLayout,
|
||||
validateSeat,
|
||||
} from './room-scene-controller.ts';
|
||||
|
||||
export { computeActiveFlags, computePositions };
|
||||
export {
|
||||
applyPlayerCount,
|
||||
applyPlayerData,
|
||||
applySeatLayoutPositions,
|
||||
clearSlots,
|
||||
computeActiveFlags,
|
||||
computePositions,
|
||||
validateCount,
|
||||
validateLayout,
|
||||
validateSeat,
|
||||
type PlayerSlotLike,
|
||||
};
|
||||
export type { SeatLayout };
|
||||
|
||||
const { ccclass, property } = _decorator;
|
||||
@@ -18,9 +41,9 @@ const { ccclass, property } = _decorator;
|
||||
* 8 个 playerSlots 在场景里预先挂好(Plan Task 2 负责),setPlayerCount 只切 active,
|
||||
* 不运行时 instantiate 节点(避免预制重复实例化开销)。
|
||||
*
|
||||
* 纯函数算法(computeActiveFlags / computePositions)放在 room-scene-layout.ts,
|
||||
* 本文件只 re-export — 这样单元测试可以直接 import 布局函数而无需 mock cc 模块
|
||||
* (CLAUDE.md 第二准则:RoomSceneStart 的下游消费者不应被 cc 依赖拖累)。
|
||||
* 纯逻辑(校验 + 槽位操作 + 视图交互)放在 room-scene-controller.ts(同目录),
|
||||
* 本文件只 re-export 并做 cc 包装 — 这样单元测试可以直接 import 控制器函数
|
||||
* 而无需 mock cc 模块(CLAUDE.md 第二准则:RoomSceneStart 的下游消费者不应被 cc 依赖拖累)。
|
||||
*/
|
||||
@ccclass('RoomSceneStart')
|
||||
export class RoomSceneStart extends Component {
|
||||
@@ -36,71 +59,42 @@ export class RoomSceneStart extends Component {
|
||||
`[RoomSceneStart] playerSlots must have 8 entries, got ${this.playerSlots?.length}`,
|
||||
);
|
||||
}
|
||||
// 校验 playerSlots 内是否有 null/undefined;start 时显式 throw,不兜底。
|
||||
for (let i = 0; i < this.playerSlots.length; i++) {
|
||||
if (!this.playerSlots[i]) {
|
||||
throw new Error(`[RoomSceneStart] playerSlots[${i}] is null/undefined`);
|
||||
}
|
||||
}
|
||||
this.setPlayerCount(this.currentCount);
|
||||
this.setSeatLayout(this.currentLayout);
|
||||
}
|
||||
|
||||
public setPlayerCount(n: number): void {
|
||||
if (n < 1 || n > this.playerSlots.length) {
|
||||
throw new Error(
|
||||
`[RoomSceneStart] setPlayerCount: n must be 1-${this.playerSlots.length}, got ${n}`,
|
||||
);
|
||||
}
|
||||
validateCount(n);
|
||||
this.currentCount = n;
|
||||
const flags = computeActiveFlags(n, this.playerSlots.length);
|
||||
for (let i = 0; i < this.playerSlots.length; i++) {
|
||||
const slot = this.playerSlots[i];
|
||||
if (slot) slot.active = flags[i];
|
||||
}
|
||||
applyPlayerCount(this.playerSlots as unknown as PlayerSlotLike[], n);
|
||||
}
|
||||
|
||||
public setSeatLayout(layout: SeatLayout): void {
|
||||
validateLayout(layout);
|
||||
this.currentLayout = layout;
|
||||
const positions = computePositions(layout, this.currentCount);
|
||||
for (let i = 0; i < this.currentCount && i < positions.length; i++) {
|
||||
const slot = this.playerSlots[i];
|
||||
if (slot) slot.setPosition(positions[i].x, positions[i].y, 0);
|
||||
}
|
||||
applySeatLayoutPositions(
|
||||
this.playerSlots as unknown as PlayerSlotLike[],
|
||||
layout,
|
||||
this.currentCount,
|
||||
);
|
||||
}
|
||||
|
||||
public setPlayerData(seat: number, data: PlayerState): void {
|
||||
if (seat < 1 || seat > this.currentCount) {
|
||||
throw new Error(
|
||||
`[RoomSceneStart] setPlayerData: seat ${seat} out of range 1-${this.currentCount}`,
|
||||
);
|
||||
}
|
||||
const slot = this.playerSlots[seat - 1];
|
||||
if (!slot) return;
|
||||
const view = slot.getComponent('PlayerInfoView') as any;
|
||||
if (view && typeof view.bindTo === 'function') {
|
||||
view.bindTo({ player: { state: { value: data } } });
|
||||
}
|
||||
applyPlayerData(
|
||||
this.playerSlots as unknown as PlayerSlotLike[],
|
||||
seat,
|
||||
this.currentCount,
|
||||
data as unknown as { nickname?: unknown; bean?: unknown; avatar?: unknown },
|
||||
);
|
||||
}
|
||||
|
||||
public clearSeats(): void {
|
||||
const placeholder: PlayerState = {
|
||||
playerid: 0,
|
||||
nickname: '占位',
|
||||
avatar: '',
|
||||
sex: 0,
|
||||
bean: 0,
|
||||
roomcard: 0,
|
||||
score: 0,
|
||||
charm: 0,
|
||||
taskstate: 0,
|
||||
advanced: 0,
|
||||
bankpower: 0,
|
||||
bank: 0,
|
||||
bankpwd: 0,
|
||||
ip: '',
|
||||
sign: null,
|
||||
tel: null,
|
||||
invitecode: null,
|
||||
initCard: 0,
|
||||
initBean: 0,
|
||||
};
|
||||
for (let i = 0; i < this.currentCount; i++) {
|
||||
this.setPlayerData(i + 1, placeholder);
|
||||
}
|
||||
clearSlots(this.playerSlots as unknown as PlayerSlotLike[], this.currentCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Room.scene 控制器的纯函数核心。
|
||||
*
|
||||
* 设计意图:RoomSceneStart.ts 依赖 Cocos(cc 模块),在 node + tsx 下不可加载。
|
||||
* 本文件抽出与 cc 无关的:
|
||||
* - 输入校验(count / layout / seat)
|
||||
* - 槽位操作(active flag / position / player data)
|
||||
* - PlayerInfoView 交互(applyPlayer 一致调用)
|
||||
* 让单元测试可直接 import,而 RoomSceneStart 只做 cc 包装 + 转发。
|
||||
*
|
||||
* CLAUDE.md 第二准则:不兜底。非法输入一律 throw,不静默 fallback / ?? 兜底。
|
||||
*/
|
||||
import {
|
||||
computeActiveFlags,
|
||||
computePositions,
|
||||
MAX_SEATS,
|
||||
type SeatLayout,
|
||||
} from './room-scene-layout.ts';
|
||||
|
||||
export type { SeatLayout };
|
||||
export { computeActiveFlags, computePositions, MAX_SEATS };
|
||||
|
||||
/** PlayerState 子集,PlayerInfoView.applyPlayer 实际消费(其它字段由视图内部兜底)。 */
|
||||
export interface PlayerViewData {
|
||||
nickname: string;
|
||||
bean: number;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
/** Mock 槽位契约(由 RoomSceneStart 持有真 Node,测试用普通对象)。 */
|
||||
export interface PlayerSlotLike {
|
||||
active: boolean;
|
||||
setPosition(x: number, y: number, z: number): void;
|
||||
getComponent(name: string): unknown;
|
||||
}
|
||||
|
||||
/** PlayerInfoView 公共契约(测试可提供 fake,真实现见 assets/scripts/views/PlayerInfoView.ts)。 */
|
||||
export interface PlayerInfoViewLike {
|
||||
applyPlayer?(p: PlayerViewData): void;
|
||||
bindTo?(session: unknown): void;
|
||||
}
|
||||
|
||||
/** 占位 PlayerViewData(clearSeats 使用)。 */
|
||||
export const PLACEHOLDER_VIEW_DATA: PlayerViewData = Object.freeze({
|
||||
nickname: '占位',
|
||||
bean: 0,
|
||||
avatar: '',
|
||||
});
|
||||
|
||||
/** 取槽位上的 PlayerInfoView 组件,缺则 null(供测试 spy / 真实组件共用)。 */
|
||||
export function extractPlayerView(slot: PlayerSlotLike | null): PlayerInfoViewLike | null {
|
||||
if (!slot) return null;
|
||||
const view = slot.getComponent('PlayerInfoView') as PlayerInfoViewLike | null;
|
||||
return view ?? null;
|
||||
}
|
||||
|
||||
/** 校验 count ∈ [1, MAX_SEATS] 且为整数;非法 throw。 */
|
||||
export function validateCount(n: number): void {
|
||||
if (typeof n !== 'number' || Number.isNaN(n) || !Number.isInteger(n)) {
|
||||
throw new Error(`[RoomSceneStart] count must be an integer, got ${String(n)}`);
|
||||
}
|
||||
if (n < 1 || n > MAX_SEATS) {
|
||||
throw new Error(`[RoomSceneStart] count must be 1-${MAX_SEATS}, got ${n}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验 layout ∈ VALID_LAYOUTS;非法 throw。 */
|
||||
const VALID_LAYOUTS: readonly SeatLayout[] = ['horizontal', '2x2', 'triangle', 'square'];
|
||||
export function validateLayout(layout: SeatLayout): void {
|
||||
if (!VALID_LAYOUTS.includes(layout)) {
|
||||
throw new Error(
|
||||
`[RoomSceneStart] layout must be one of ${VALID_LAYOUTS.join('|')}, got ${String(layout)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验 seat ∈ [1, currentCount] 且为整数;非法 throw。 */
|
||||
export function validateSeat(seat: number, currentCount: number): void {
|
||||
if (typeof seat !== 'number' || Number.isNaN(seat) || !Number.isInteger(seat)) {
|
||||
throw new Error(`[RoomSceneStart] seat must be an integer, got ${String(seat)}`);
|
||||
}
|
||||
if (seat < 1 || seat > currentCount) {
|
||||
throw new Error(
|
||||
`[RoomSceneStart] seat ${seat} out of range 1-${currentCount}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置每个槽位的 active 标志(前 count 个 true,其余 false)。
|
||||
* 委托 computeActiveFlags + 校验(已在 computeActiveFlags 内)。
|
||||
*/
|
||||
export function applyPlayerCount(slots: PlayerSlotLike[], n: number): void {
|
||||
validateCount(n);
|
||||
const flags = computeActiveFlags(n, slots.length);
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
const slot = slots[i];
|
||||
if (slot) slot.active = flags[i];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 layout + count 计算位置,设置到前 count 个槽位。
|
||||
* 校验(layout / count 在 computePositions 内)。
|
||||
*/
|
||||
export function applySeatLayoutPositions(
|
||||
slots: PlayerSlotLike[],
|
||||
layout: SeatLayout,
|
||||
count: number,
|
||||
): void {
|
||||
validateCount(count);
|
||||
validateLayout(layout);
|
||||
const positions = computePositions(layout, count);
|
||||
for (let i = 0; i < count && i < positions.length; i++) {
|
||||
const slot = slots[i];
|
||||
if (slot) slot.setPosition(positions[i].x, positions[i].y, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从完整 PlayerState 中抽取 PlayerInfoView.applyPlayer 实际需要的 3 个字段。
|
||||
* 缺字段一律显式 throw(不 ?? 兜底,CLAUDE.md 第二准则)。
|
||||
*/
|
||||
export function extractPlayerViewData(data: { nickname?: unknown; bean?: unknown; avatar?: unknown }): PlayerViewData {
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error(`[RoomSceneStart] setPlayerData: data must be an object, got ${String(data)}`);
|
||||
}
|
||||
if (typeof data.nickname !== 'string') {
|
||||
throw new Error(`[RoomSceneStart] setPlayerData: data.nickname must be string, got ${String(data.nickname)}`);
|
||||
}
|
||||
if (typeof data.bean !== 'number' || Number.isNaN(data.bean)) {
|
||||
throw new Error(`[RoomSceneStart] setPlayerData: data.bean must be number, got ${String(data.bean)}`);
|
||||
}
|
||||
if (typeof data.avatar !== 'string') {
|
||||
throw new Error(`[RoomSceneStart] setPlayerData: data.avatar must be string, got ${String(data.avatar)}`);
|
||||
}
|
||||
return { nickname: data.nickname, bean: data.bean, avatar: data.avatar };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把玩家数据推入指定槽位的 PlayerInfoView。
|
||||
* 优先用 applyPlayer(一次设置);缺 applyPlayer 才用 bindTo + proper reactive store。
|
||||
* 若两者皆缺,throw(明确暴露组件挂载错误,不静默跳过)。
|
||||
*
|
||||
* 校验:seat 必须为整数 ∈ [1, currentCount];data 必须含 3 个合法字段。
|
||||
*/
|
||||
export function applyPlayerData(
|
||||
slots: PlayerSlotLike[],
|
||||
seat: number,
|
||||
currentCount: number,
|
||||
data: { nickname?: unknown; bean?: unknown; avatar?: unknown },
|
||||
): void {
|
||||
validateSeat(seat, currentCount);
|
||||
const viewData = extractPlayerViewData(data);
|
||||
const slot = slots[seat - 1];
|
||||
if (!slot) {
|
||||
throw new Error(`[RoomSceneStart] setPlayerData: slot ${seat} is null/undefined`);
|
||||
}
|
||||
const view = extractPlayerView(slot);
|
||||
if (!view) {
|
||||
throw new Error(
|
||||
`[RoomSceneStart] setPlayerData: slot ${seat} has no PlayerInfoView component`,
|
||||
);
|
||||
}
|
||||
if (typeof view.applyPlayer === 'function') {
|
||||
view.applyPlayer(viewData);
|
||||
return;
|
||||
}
|
||||
if (typeof view.bindTo === 'function') {
|
||||
// 退化路径:为 view.bindTo 构造一个 minimal reactive store
|
||||
// (满足 ViewStore 契约:有 subscribe + value),保证不抛 TypeError。
|
||||
view.bindTo(buildOneShotSession(viewData));
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`[RoomSceneStart] setPlayerData: slot ${seat} view has neither applyPlayer nor bindTo`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 占位用的 minimal reactive store(只触发一次订阅,值不再变化)。 */
|
||||
function buildOneShotSession(viewData: PlayerViewData): { player: { state: { value: PlayerViewData; subscribe(cb: () => void): () => void } } } {
|
||||
const state = {
|
||||
value: viewData,
|
||||
subscribe(_cb: () => void): () => void {
|
||||
return () => { /* no-op unsubscribe */ };
|
||||
},
|
||||
};
|
||||
return { player: { state } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把当前可见槽位刷成占位数据(clearSeats)。
|
||||
* 数据非法时抛错,不部分清空。
|
||||
*/
|
||||
export function clearSlots(slots: PlayerSlotLike[], currentCount: number): void {
|
||||
for (let i = 0; i < currentCount; i++) {
|
||||
applyPlayerData(slots, i + 1, currentCount, PLACEHOLDER_VIEW_DATA);
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,23 @@
|
||||
/** 房间布局方案。horizontal 已落地;其余 3 种留 layout 子任务(plan 范围外)。 */
|
||||
export type SeatLayout = 'horizontal' | '2x2' | 'triangle' | 'square';
|
||||
|
||||
/** 合法 layout 集合(运行期校验用)。CLAUDE.md 第二准则:不静默 fallback,直接 throw。 */
|
||||
const VALID_LAYOUTS: readonly SeatLayout[] = ['horizontal', '2x2', 'triangle', 'square'];
|
||||
|
||||
/** 单场景最大槽位数(与 RoomSceneStart.playerSlots 长度一致)。 */
|
||||
export const MAX_SEATS = 8;
|
||||
|
||||
/** 槽位之间的固定像素间距(layout 子任务会参数化)。 */
|
||||
const SLOT_SPACING = 300;
|
||||
|
||||
/**
|
||||
* 给定显示槽位数 + 槽位总数,返回 active 标志数组。
|
||||
* 越界 throw(CLAUDE.md 第二准则:不兜底)。
|
||||
* 越界 / 非整数 / NaN 全部 throw(CLAUDE.md 第二准则:不兜底)。
|
||||
*/
|
||||
export function computeActiveFlags(count: number, total: number): boolean[] {
|
||||
if (!Number.isInteger(count) || Number.isNaN(count)) {
|
||||
throw new Error(`count must be an integer, got ${count}`);
|
||||
}
|
||||
if (count < 1 || count > total) {
|
||||
throw new Error(`count must be 1-${total}, got ${count}`);
|
||||
}
|
||||
@@ -29,12 +38,23 @@ export function computeActiveFlags(count: number, total: number): boolean[] {
|
||||
* 给定 layout + 玩家数,返回每个槽位的局部坐标。
|
||||
* horizontal 已落地;2x2 / triangle / square 暂 fallback 到 horizontal
|
||||
* (留作后续 layout 子任务,本计划范围外)。
|
||||
*
|
||||
* 校验:count 必须为 1-MAX_SEATS 整数;layout 必须是合法 SeatLayout;
|
||||
* 非法输入直接 throw,不静默 fallback(CLAUDE.md 第二准则)。
|
||||
*/
|
||||
export function computePositions(
|
||||
layout: SeatLayout,
|
||||
count: number,
|
||||
): Array<{ x: number; y: number }> {
|
||||
if (count < 1) throw new Error(`count must be >= 1, got ${count}`);
|
||||
if (!VALID_LAYOUTS.includes(layout)) {
|
||||
throw new Error(`layout must be one of ${VALID_LAYOUTS.join('|')}, got ${String(layout)}`);
|
||||
}
|
||||
if (!Number.isInteger(count) || Number.isNaN(count)) {
|
||||
throw new Error(`count must be an integer, got ${count}`);
|
||||
}
|
||||
if (count < 1 || count > MAX_SEATS) {
|
||||
throw new Error(`count must be 1-${MAX_SEATS}, got ${count}`);
|
||||
}
|
||||
const startX = -(count - 1) * SLOT_SPACING / 2;
|
||||
return Array.from({ length: count }, (_, i) => ({ x: startX + i * SLOT_SPACING, y: 0 }));
|
||||
}
|
||||
@@ -1,9 +1,31 @@
|
||||
// 测试纯函数布局/可见性算法。
|
||||
// 直接 import room-scene-layout.ts,不依赖 RoomSceneStart.ts(后者引入 cc 模块,需 Cocos 运行时)。
|
||||
// 这与 CLAUDE.md 第二准则一致:纯逻辑放纯文件,被测方无需任何 cc mock。
|
||||
// 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(
|
||||
@@ -35,4 +57,269 @@ test('computePositions(horizontal, 4) → 4 玩家水平排列居中', () => {
|
||||
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);
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
为 YouleNexus Cocos 工程新建 `Room.scene`,含 4 个默认玩家槽位(可配置 1-8) + 房间号 Label + 准备/退出按钮。RoomSceneStart 脚本暴露可被子游戏调用的 API(setPlayerCount / setSeatLayout / setPlayerData),支持不同子游戏不同房间人数 + 同一游戏多种玩家人数。
|
||||
|
||||
**成功标准**:Room.scene 在 Cocos 编辑器加载 0 报错;4 个默认槽位可见;setPlayerCount(2) 后只 2 个可见;setSeatLayout('2x2') 后位置正确。
|
||||
**成功标准**:Room.scene 在 Cocos 编辑器加载 0 报错;4 个默认槽位可见;setPlayerCount(2) 后只 2 个可见;setSeatLayout('horizontal') 后位置正确(2x2 / triangle / square 布局坐标留作后续 layout 子任务,与下文「不覆盖」一致)。
|
||||
|
||||
## 范围
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
- ❌ 出牌按钮(业务 UI 子游戏各自定义)
|
||||
- ❌ 房间生命周期(other_join_room / free_room 等事件探针在 RoomEventProbe.ts 中,RoomSceneStart 只展示层)
|
||||
- ❌ 跨子游戏路由(Room.scene 是展示层,不参与路由)
|
||||
- ❌ 2x2 / triangle / square layout 的具体坐标实现(仅在 `SeatLayout` 类型与 `setSeatLayout` 校验中列出,后续 layout 子任务补齐;与上方「成功标准」保持一致)
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -248,9 +249,12 @@ test('setSeatLayout(horizontal) → 8 玩家水平排列居中', () => {
|
||||
## Success Metrics
|
||||
|
||||
- Room.scene 在 Cocos 编辑器 0 报错
|
||||
- 4 种 seat layout 函数返回正确位置
|
||||
- setPlayerCount(1-8) 全部通过
|
||||
- 现有 framework tests 217/217 仍 PASS(不回归)
|
||||
- 新增 RoomSceneStart tests X/X PASS(target ≥ 6 test cases)
|
||||
- `setSeatLayout('horizontal')` 函数返回正确位置(对称居中)
|
||||
- `setSeatLayout('2x2'|'triangle'|'square')` 在 `computePositions` 层校验通过且 throw 行为正确;具体坐标留 layout 子任务
|
||||
- setPlayerCount(1-8) 全部通过(含非法值 throw:0 / 9 / NaN / 1.5 / 非 number)
|
||||
- setSeatLayout 仅接受合法 layout 字符串;非法值 throw
|
||||
- setPlayerData(seat, data) 校验 seat 整数 ∈ [1, currentCount]、data 三字段合法;非法值 throw
|
||||
- 现有 framework tests 仍 PASS(不回归)
|
||||
- 新增 RoomSceneStart controller tests 全部 PASS(target ≥ 8 test cases,含 happy path + 所有非法输入)
|
||||
</content>
|
||||
</invoke>
|
||||
Reference in New Issue
Block a user