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('偶')); } });