1import test from 'node:test';2import assert from 'node:assert/strict';3import { createTurnActions } from '../src/app/turns.js';45function harness(result = { accepted: true }, stage = null) {6 const events = [];7 let state = { won: false };8 const game = {9 getState: () => state,10 step: () => { events.push('step'); return result; },11 wait: () => { events.push('wait'); return result; },12 reset: () => { events.push('reset'); return { accepted: true, kind: 'reset' }; },13 undo: () => { events.push('undo'); return result; },14 };15 const juice = {16 syncState: () => events.push('sync'),17 beginInput: () => events.push('begin'),18 consume: () => events.push('consume'),19 frame: () => ({ win: null }),20 };21 const actions = createTurnActions({22 getGame: () => game,23 getStage: () => stage,24 getJuice: () => juice,25 getNow: () => 10,26 setFeedback: message => events.push(`feedback:${message}`),27 clearFeedback: () => events.push('clear'),28 updateUI: () => events.push('ui'),29 render: () => events.push('render'),30 paintBoardFrame: () => events.push('paint'),31 animateBoard: duration => events.push(`animate:${duration || 650}`),32 playSound: sound => events.push(`sound:${sound}`),33 playTurnSounds: () => events.push('turn-sound'),34 announceTurn: () => events.push('announce'),35 onRestart: () => events.push('restart-complete'),36 onUndo: () => events.push('undo-complete'),37 });38 return { actions, events };39}4041test('turn actions keep engine, presentation, and shell lifecycle ordered', () => {42 const { actions, events } = harness();43 actions.move(1, 0);44 assert.deepEqual(events, ['sync', 'begin', 'step', 'consume', 'clear', 'turn-sound', 'announce', 'ui', 'render', 'paint', 'animate:650']);45});4647test('blocked actions report feedback without running accepted turn effects', () => {48 const { actions, events } = harness({ accepted: false, blocked: { reason: 'bounds' } });49 actions.move(1, 0);50 assert.deepEqual(events, ['sync', 'begin', 'step', 'consume', 'sound:blockedBounds', 'feedback:BLOCKED / try another sentence', 'paint', 'animate:180']);51});5253test('wait, undo, and restart use the same adapter boundary', () => {54 const { actions, events } = harness();55 actions.wait();56 actions.undo();57 actions.restart();58 assert.deepEqual(events, [59 'sync', 'begin', 'wait', 'consume', 'clear', 'turn-sound', 'announce', 'ui', 'render', 'paint', 'animate:650',60 'sync', 'undo', 'consume', 'undo-complete', 'clear', 'sound:undo', 'announce', 'ui', 'render', 'paint', 'animate:300',61 'sync', 'reset', 'consume', 'sync', 'clear', 'sound:restart', 'announce', 'ui', 'render', 'restart-complete', 'paint', 'animate:300',62 ]);63});6465test('undo and restart preserve an active proof scene while returning from win', () => {66 const proofEvents = [];67 const proofStage = { scene: 'proof', show: scene => proofEvents.push(`show:${scene}`) };68 const proof = harness({ accepted: true }, proofStage);69 proof.actions.undo();70 proof.actions.restart();71 assert.equal(proofEvents.length, 0);7273 const winEvents = [];74 const winStage = { scene: 'win', show: scene => winEvents.push(`show:${scene}`) };75 const win = harness({ accepted: true }, winStage);76 win.actions.undo();77 win.actions.restart();78 assert.deepEqual(winEvents, ['show:proof', 'show:proof']);79});