Files
NumberSwipe_New/modes/01-number-stroke/tests/reserved-step.test.ts
T
cabinw 8026e4628d 初始提交:三玩法 Web 原型与设计文档
- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试
- 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本
- 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试
- 产品文档、UI 设计规范、各玩法 GDD / HANDOFF
- 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
2026-09-16 14:30:59 +08:00

160 lines
7.0 KiB
TypeScript
Raw 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.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Board } from '../src/board';
import { Game } from '../src/game';
import { cellRect, fitBoard, type Rect } from '../src/layout';
import type { Painter } from '../src/painter';
import { THEME } from '../src/theme';
import type { CellDef } from '../src/types';
import { BoardView } from '../src/view';
// a(1) b c(3)
// d e f
// 正解可为 a b c f e d;错误分支 a d e 在第 3 步使用了 c 预定的数字。
function setup(withNumber = true, parity?: 'odd' | 'even') {
const cells: CellDef[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((id, i) => ({
id, x: i % 3, y: Math.floor(i / 3), w: 1, h: 1,
clue: i === 0 ? 1 : withNumber && i === 2 ? 3 : undefined,
parity: i === 4 ? parity : undefined,
}));
return new Game(Board.fromCells(cells));
}
function startWrongBranch(game: Game) {
game.apply({ type: 'start', cell: 0 });
game.apply({ type: 'append', cell: 3 });
return game.apply({ type: 'append', cell: 4 });
}
test('固定数字出现于普通格时立即标错;未走的固定数字格保持未走状态', () => {
const game = setup();
const events = startWrongBranch(game);
assert.ok(events.some(e => e.type === 'step' && e.conflict && !e.clueHit));
assert.equal(game.isConflict(4), true);
assert.equal(game.hasConflict(), true);
assert.equal(game.isConflict(3), false, '第 2 步没有固定位置,不按隐藏答案标错');
assert.equal(game.stepOf(2), 0);
assert.equal(game.isConflict(2), false);
assert.equal(game.isCleared, false);
});
test('数字位置错误优先于奇偶命中,不播放踩对线索反馈', () => {
const game = setup(true, 'odd');
const events = startWrongBranch(game);
assert.equal(game.clueAt(4).parity, 'odd');
assert.equal(game.stepOf(4), 3);
assert.ok(events.some(e => e.type === 'step' && e.conflict && !e.clueHit));
assert.equal(game.isConflict(4), true);
});
test('撤销或多步回退后清除占号冲突,可改走正确数字格通关', () => {
for (const action of ['undo', 'rewind'] as const) {
const game = setup();
startWrongBranch(game);
game.apply(action === 'undo' ? { type: 'undo' } : { type: 'rewind', cell: 0 });
assert.equal(game.hasConflict(), false);
game.apply({ type: 'rewind', cell: 0 });
for (const cell of [1, 2, 5, 4, 3]) {
const events = game.apply({ type: 'append', cell });
assert.ok(events.every(e => e.type !== 'step' || !e.conflict));
}
assert.equal(game.isCleared, true);
}
});
test('补数字会立即检查已有路线,并在重开后继续约束该步的位置', () => {
const game = setup(false);
startWrongBranch(game);
assert.equal(game.hasConflict(), false);
game.apply({ type: 'reveal', cell: 2, step: 3 });
assert.equal(game.stepOf(2), 0);
assert.equal(game.isConflict(4), true, '补出的 3 尚未被踩到,别处的第 3 步已应判错');
game.apply({ type: 'reset' });
assert.equal(game.hasConflict(), false);
assert.ok(startWrongBranch(game).some(e => e.type === 'step' && e.conflict));
game.apply({ type: 'reset' });
for (const cell of [0, 1, 2, 5, 4, 3]) game.apply({ type: game.path.length ? 'append' : 'start', cell });
assert.equal(game.isCleared, true);
assert.equal(new Game(game.board).clueAt(2).clue, null);
});
test('非固定数字的奇偶要求不占用所有同类步号', () => {
const game = setup(false, 'odd');
game.apply({ type: 'start', cell: 0 });
game.apply({ type: 'append', cell: 1 });
game.apply({ type: 'append', cell: 2 });
assert.equal(game.hasConflict(), false, '另一个普通格上出现奇数 3 是允许的');
});
test('冲突只标红格,进出连线保持紫色,不显示 ×;本格线索不符仍显示原要求', () => {
for (const parity of [undefined, 'odd', 'even'] as const) {
const game = setup(true, parity);
const view = new BoardView(game, { reducedMotion: true });
view.layout = fitBoard(game.board, 350, 400);
for (const cell of [0, 3, 4, 5]) game.apply({ type: game.path.length ? 'append' : 'start', cell }).forEach(e => view.onEvent(e));
const texts: string[] = [];
const fills: string[] = [];
const bridges: string[] = [];
let badges = 0;
const painter: Painter = {
fillRoundRect: (_r, radius, color) => { fills.push(color); if (radius === 0) bridges.push(color); },
strokeRoundRect: () => {}, fillCircle: () => { badges++; }, text: text => { texts.push(text); },
};
view.draw(painter);
assert.ok(!texts.includes('×'));
if (parity === 'even') assert.ok(texts.includes('偶'));
assert.equal(badges, parity === 'even' ? 1 : 0);
assert.ok(!texts.includes(''), '新增冲突不能留下空角标');
assert.ok(fills.includes(THEME.conflict));
assert.deepEqual(bridges, [THEME.visited, THEME.visited, THEME.visited], '错误格进出两侧连线均保持紫色');
}
});
function frame(view: BoardView): Rect[] {
const cells: Rect[] = [];
view.draw({
fillRoundRect: rect => { cells.push(rect); },
strokeRoundRect: () => {}, fillCircle: () => {}, text: () => {},
});
return cells.slice(0, view.game.n); // 底板首先按格序绘制
}
function settle(view: BoardView): void {
for (let i = 0; i < 120; i++) view.update(1 / 60);
}
test('落错时只晃动出错格,动画结束复位;撤销和重开立即停止晃动', () => {
for (const cancel of ['undo', 'reset', 'finish'] as const) {
const game = setup();
const view = new BoardView(game);
view.layout = fitBoard(game.board, 350, 400);
for (const cell of [0, 3]) game.apply({ type: game.path.length ? 'append' : 'start', cell }).forEach(e => view.onEvent(e));
settle(view);
const before = frame(view);
game.apply({ type: 'append', cell: 4 }).forEach(e => view.onEvent(e));
view.update(1 / 60);
const shaken = frame(view);
assert.ok(Math.abs(shaken[4].x - before[4].x) > 0.1, '错误格应横向晃动');
assert.equal(shaken[4].w, before[4].w, '错误反馈只晃动,不叠加落子缩放');
for (const cell of [0, 1, 2, 3, 5]) assert.deepEqual(shaken[cell], before[cell], `其他格 ${cell} 不晃动`);
if (cancel === 'finish') settle(view);
else game.apply({ type: cancel }).forEach(e => view.onEvent(e));
assert.deepEqual(frame(view)[4], before[4], `${cancel} 后立即恢复原位置`);
}
});
test('补数字发现旧路径的错误也会晃动;减弱动态时保留红色但不晃动', () => {
for (const reducedMotion of [false, true]) {
const game = setup(false);
const view = new BoardView(game, { reducedMotion });
view.layout = fitBoard(game.board, 350, 400);
for (const cell of [0, 3, 4]) game.apply({ type: game.path.length ? 'append' : 'start', cell }).forEach(e => view.onEvent(e));
settle(view);
game.apply({ type: 'reveal', cell: 2, step: 3 }).forEach(e => view.onEvent(e));
view.update(1 / 60);
const original = cellRect(game.board, 4, view.layout);
assert.equal(game.isConflict(4), true);
if (reducedMotion) assert.deepEqual(frame(view)[4], original);
else assert.ok(Math.abs(frame(view)[4].x - original.x) > 0.1);
}
});