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

161 lines
7.3 KiB
TypeScript

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { Board } from '../src/board';
import { Game } from '../src/game';
import { PointerInput } from '../src/input';
import { fitBoard } from '../src/layout';
import { loadPack, parsePack } from '../src/levels';
import type { Painter } from '../src/painter';
import { hint, solve } from '../src/solver';
import { THEME } from '../src/theme';
import type { CellDef } from '../src/types';
import { BoardView } from '../src/view';
const pack = parsePack(JSON.parse(readFileSync(new URL('../levels/pack-3-parity.json', import.meta.url), 'utf8')));
const levels = loadPack(pack);
const c = (id: string, x: number, parity?: 'odd' | 'even'): CellDef => ({ id, x, y: 0, w: 1, h: 1, parity });
test('奇偶字段校验:仅接受 odd/even,不能同时填数字线索', () => {
for (const invalid of ['奇', 'ODD', 1, null]) {
assert.throws(() => Board.fromCells([{ ...c('a', 0), parity: invalid } as CellDef]), /parity 必须/);
}
assert.throws(() => Board.fromCells([{ ...c('a', 0, 'odd'), clue: 1 }]), /不能同时填写/);
assert.equal(Board.fromCells([c('a', 0)]).cells[0].parity, null);
});
test('无线索 1:偶格也允许试起手,但第 1 步必定冲突;撤销后清除', () => {
const b = Board.fromCells([c('a', 0, 'even'), c('b', 1, 'odd')]);
const game = new Game(b);
const events = game.apply({ type: 'start', cell: 0 });
assert.ok(events.some(e => e.type === 'step' && e.conflict && !e.clueHit));
assert.equal(game.isConflict(0), true);
game.apply({ type: 'undo' });
assert.equal(game.hasConflict(), false);
assert.equal(game.status, 'empty');
assert.deepEqual(solve(b), [[1, 0]], '求解器只给满足奇偶的起点');
assert.deepEqual(solve(b, { starts: [0] }), []);
assert.equal(solve(b, { ignoreClues: true }).length, 2);
});
test('奇偶步号独立校验:与完整路径逐条过滤结果一致', () => {
// 三角邻接来自大块,防止测试仅覆盖奇偶天然交替的小方格。
const geometry = pack.levels[0].cells.map(({ clue, parity, ...cell }) => cell);
const free = solve(Board.fromCells(geometry), { ignoreClues: true });
// 穷举每格 空/奇/偶 的 3^4 组条件,加一个固定数字交叉验证。
for (let mask = 0; mask < 3 ** geometry.length; mask++) {
let value = mask;
const defs = geometry.map(cell => {
const digit = value % 3;
value = Math.floor(value / 3);
return { ...cell, ...(digit ? { parity: digit === 1 ? 'odd' as const : 'even' as const } : {}) };
});
for (const fixed of [false, true]) {
const cells: CellDef[] = defs.map((cell, i) => fixed && i === 0 ? { ...geometry[i], clue: 1 } : cell);
const expected = free.filter(path => cells.every((cell, i) => {
const step = path.indexOf(i) + 1;
return (cell.clue === undefined || step === cell.clue) &&
(cell.parity === undefined || step % 2 === (cell.parity === 'odd' ? 1 : 0));
}));
const keys = (paths: number[][]) => paths.map(p => p.join(',')).sort();
assert.deepEqual(keys(solve(Board.fromCells(cells))), keys(expected), `${mask}/${fixed}`);
}
}
});
test('体验关:每条奇偶线索都必要,唯一解能经真实规则过关', () => {
for (const lv of levels) {
const [path] = solve(lv.board, { limit: 2 });
const game = new Game(lv.board);
const parityCells = lv.def.cells.filter(cell => cell.parity);
assert.ok(parityCells.length > 0);
for (const cell of parityCells) {
const defs = lv.def.cells.map(({ parity, ...rest }) => rest.id === cell.id ? rest : { ...rest, parity });
assert.equal(solve(Board.fromCells(defs), { limit: 2 }).length, 2, `${lv.def.id}/${cell.id} 必须参与唯一性`);
}
path.forEach((cell, i) => {
const events = game.apply({ type: i === 0 ? 'start' : 'append', cell });
if (lv.board.cells[cell].parity !== null) {
assert.ok(events.some(e => e.type === 'step' && e.clueHit && !e.conflict));
}
});
assert.equal(game.isCleared, true, lv.def.id);
}
});
test('走错偶格不会过关;滑回撤销、提示退回分叉后能重走通关', () => {
const b = levels[0].board;
const game = new Game(b);
const input = new PointerInput(game);
const idx = (id: string) => b.cellById(id).index;
input.down(idx('start'));
input.move(idx('a'));
const events = input.move(idx('b')); // 第 3 步踩偶
assert.ok(events.some(e => e.type === 'step' && e.conflict));
assert.equal(game.isConflict(idx('b')), true);
const solution = solve(b, { limit: 1 })[0];
assert.deepEqual(hint(solution, game.path), { kind: 'backtrack', cell: idx('a'), undoCount: 1 });
input.move(idx('big')); // 填满仍有奇偶冲突
assert.equal(game.remaining, 0);
assert.equal(game.isCleared, false);
input.move(idx('b'));
input.move(idx('a'));
assert.equal(game.hasConflict(), false);
assert.deepEqual(hint(solution, game.path), { kind: 'next', cell: idx('big') });
input.move(idx('big'));
input.move(idx('b'));
assert.equal(game.isCleared, true);
});
function drawing(game: Game) {
const view = new BoardView(game);
view.layout = fitBoard(game.board, 350, 400);
const labels: { text: string; x: number; y: number; size: number }[] = [];
const fills: string[] = [];
const painter: Painter = {
fillRoundRect: (_r, _radius, color) => { fills.push(color); },
strokeRoundRect: () => {},
fillCircle: () => {},
text: (text, x, y, size) => { labels.push({ text, x, y, size }); },
};
view.update(0.05);
view.draw(painter);
assert.ok(labels.every(l => [l.x, l.y, l.size].every(Number.isFinite)));
return { labels: labels.map(l => l.text), fills };
}
test('渲染:奇偶未走显示文字、走对保留标记、撤销恢复;冲突标明要求', () => {
const b = levels[0].board;
const g = new Game(b);
assert.ok(drawing(g).labels.includes('偶'));
for (const id of ['start', 'a', 'b']) g.apply({ type: g.path.length ? 'append' : 'start', cell: b.cellById(id).index });
const conflict = drawing(g);
assert.ok(conflict.labels.includes('3') && conflict.labels.includes('偶'));
assert.ok(conflict.fills.includes(THEME.conflict));
assert.ok(!conflict.labels.includes('null'));
g.apply({ type: 'undo' });
assert.ok(drawing(g).labels.includes('偶'));
g.apply({ type: 'append', cell: b.cellById('big').index });
g.apply({ type: 'append', cell: b.cellById('b').index });
assert.ok(drawing(g).labels.includes('偶'));
assert.ok(!drawing(g).fills.includes(THEME.conflict));
});
test('3×3 大连块与异形:奇偶冲突角标在形内,绘制不崩溃', () => {
for (const cell of [
{ id: 'big', x: 0, y: 0, w: 3, h: 3, parity: 'even' as const },
{ id: 'poly', x: 0, y: 0, w: 2, h: 2, shape: [[0, 0], [0, 1], [1, 1]] as [number, number][], parity: 'even' as const },
]) {
const game = new Game(Board.fromCells([cell]));
game.apply({ type: 'start', cell: 0 });
assert.equal(game.isConflict(0), true);
assert.equal(game.stepOf(0), 1, '计步与占用面积无关');
const result = drawing(game);
assert.ok(result.labels.includes('1') && result.labels.includes('偶'));
assert.ok(result.fills.includes(THEME.conflict));
game.apply({ type: 'reset' });
assert.equal(game.hasConflict(), false);
assert.ok(drawing(game).labels.includes('偶'));
}
});