import test from 'node:test'; import assert from 'node:assert/strict'; import { FLOWER_LEVEL } from '../src/flower-study.js'; import { Game, solveRotations } from '../src/core.js'; import { layout, center } from '../src/geometry.js'; // Exercise actual pointer handlers and animation frames: model-only queue tests // cannot catch an application that drops clicks before they reach TapQueue. test('flower controls retain the main game rapid-tap behaviour', async t => { const globals = ['document', 'performance', 'devicePixelRatio', 'matchMedia', 'Image', 'ResizeObserver', 'requestAnimationFrame', 'cancelAnimationFrame']; const saved = new Map(globals.map(k => [k, Object.getOwnPropertyDescriptor(globalThis, k)])); const elements = new Map(), frames = new Map(), actions = []; let now = 0, frameId = 0; const rect = { left: 0, top: 0, width: 700, height: 640 }; const view = layout(FLOWER_LEVEL.cells, rect.width, rect.height); const context = new Proxy({}, { get: (o, k) => o[k] ?? (() => {}), set: (o, k, v) => (o[k] = v, true) }); const get = id => { if (!elements.has(id)) elements.set(id, { checked: false, disabled: false, textContent: '', getBoundingClientRect: () => rect, getContext: () => context, addEventListener(name, fn) { this[name] = fn; }, click() { if (!this.disabled) this.onclick?.(); }, }); return elements.get(id); }; const originalTap = Game.prototype.tap; const clickTile = id => { const p = center(FLOWER_LEVEL.tiles.find(t => t.id === id), view); get('board').pointerup({ clientX: p.x, clientY: p.y }); }; const advance = () => { now += 30000; for (const [id, callback] of [...frames]) { frames.delete(id); callback(now); } }; const remaining = () => Number(get('remaining').textContent); const reset = () => { get('reset').click(); actions.length = 0; }; try { const replacements = { document: { getElementById: get, addEventListener() {} }, performance: { now: () => now }, devicePixelRatio: 1, matchMedia: () => ({ matches: false }), Image: class {}, ResizeObserver: class { constructor(callback) { this.callback = callback; } observe() { this.callback(); } }, requestAnimationFrame: callback => { frames.set(++frameId, callback); return frameId; }, cancelAnimationFrame: id => frames.delete(id), }; for (const [key, value] of Object.entries(replacements)) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); Game.prototype.tap = function(id) { const result = originalTap.call(this, id); actions.push({ id, status: result.status }); return result; }; await import('../labs/flower/app.js'); await t.test('a rapid burst runs in click order against the latest board and ignores repeat clicks', () => { const firstWave = solveRotations(FLOWER_LEVEL).actions.slice(0, 8).map(a => a.id); for (const id of firstWave) { clickTile(id); clickTile(id); } assert.equal(remaining(), 9, 'first departure starts immediately'); assert.match(get('progress').textContent, /待出发 7/); assert.equal(get('pattern-only').disabled, true); for (let i = 0; i < firstWave.length; i++) advance(); assert.deepEqual(actions, firstWave.map(id => ({ id, status: 'exit' }))); assert.equal(remaining(), 2, 'dependent departures re-evaluate after preceding pieces leave'); assert.doesNotMatch(get('progress').textContent, /待出发/); get('undo').click(); assert.equal(remaining(), 3); advance(); assert.equal(remaining(), 3, 'undo does not replay an old queued click'); }); await t.test('a queued collision cancels later clicks and leaves failed probes undoable', () => { reset(); for (const id of ['petal-0', 'petal-1', 'petal-2']) clickTile(id); advance(); assert.deepEqual(actions, [{ id: 'petal-0', status: 'exit' }, { id: 'petal-1', status: 'blocked' }]); assert.doesNotMatch(get('progress').textContent, /待出发/); advance(); assert.equal(remaining(), 9); get('undo').click(); assert.equal(remaining(), 9, 'undo the failed probe first'); get('undo').click(); assert.equal(remaining(), 10); }); await t.test('reset discards animation and pending input without leaking into the restarted level', () => { reset(); for (const id of ['petal-0', 'petal-2', 'bloom-2']) clickTile(id); reset(); advance(); assert.equal(remaining(), 10); assert.deepEqual(actions, []); assert.doesNotMatch(get('progress').textContent, /待出发/); }); await t.test('clicks during a board turn wait for it to finish and then dispatch normally', () => { reset(); get('right').click(); clickTile('petal-0'); clickTile('petal-2'); assert.equal(remaining(), 10); assert.match(get('progress').textContent, /待出发 2/); advance(); assert.equal(remaining(), 9); advance(); advance(); assert.equal(remaining(), 8); assert.deepEqual(actions, [{ id: 'petal-0', status: 'exit' }, { id: 'petal-2', status: 'exit' }]); assert.equal(get('right').disabled, false); }); } finally { Game.prototype.tap = originalTap; for (const [key, descriptor] of saved) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else delete globalThis[key]; } } });