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