picker/tests/logic.html
David 35a4dc2dbf
Some checks failed
Publish Docker image / image (push) Has been cancelled
fixes
2026-10-09 13:55:15 +02:00

334 lines
12 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Picker logic tests</title>
<style>body{font:14px monospace;background:#111;color:#ddd;padding:1rem}.pass{color:#6f6}.fail{color:#f66}</style>
</head>
<body>
<h1>Picker logic tests</h1>
<pre id="out">running…</pre>
<script type="module">
// Drives the state machine with simulated time. Open in a browser
// (served over http) or headless: results are written into #out.
import { Game, TIMING } from '../js/game.js';
import { planRound, makeHighlightSchedule } from '../js/modes.js';
import { randomInt } from '../js/random.js';
import { createInput } from '../js/input.js';
import { createReveal } from '../js/reveals.js';
const results = [];
function test(name, fn) {
try {
fn();
results.push(['pass', name]);
} catch (e) {
results.push(['fail', `${name}: ${e.message}`]);
}
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'assertion failed');
}
function setup(settings = {}) {
const events = [];
const s = { mode: 'one', pickN: 2, teams: 2, ...settings };
const game = new Game({ emit: (type, data) => events.push({ type, data }), getSettings: () => s, isReduced: () => false });
let now = performance.now() + 10;
const h = {
game,
events,
get now() { return now; },
down(id, x = 100 + Math.random() * 200, y = 100 + Math.random() * 300) { game.fingerDown(id, x, y, now); },
up(id) { game.fingerUp(id, now); },
run(ms) {
const end = now + ms;
while (now < end) {
now = Math.min(end, now + 16);
game.update(now);
}
},
until(phase, max = 30000) {
const end = now + max;
while (game.phase !== phase && now < end) {
now += 16;
game.update(now);
}
assert(game.phase === phase, `expected phase ${phase}, got ${game.phase}`);
},
count: (type) => events.filter((e) => e.type === type).length,
};
return h;
}
test('pick one: full flow reaches a result with one valid winner', () => {
const h = setup();
['a', 'b', 'c'].forEach((id) => h.down(id));
h.run(100);
assert(h.game.phase === 'gathering', 'gathering');
h.run(TIMING.stableMs);
assert(h.game.phase === 'countdown', 'countdown after stable period, got ' + h.game.phase);
h.until('result');
assert(h.count('beat') === TIMING.beats, 'beats');
assert(h.count('tick') > 8, 'ticks');
assert(h.game.winners.length === 1 && ['a', 'b', 'c'].includes(h.game.winners[0]), 'winner');
const reveal = h.events.find((e) => e.type === 'reveal');
assert(['flood', 'confetti', 'spotlight', 'shockwave'].includes(reveal.data.style), 'style ' + reveal.data.style);
});
test('outcome is decided before the animation and the animation lands on it', () => {
for (let i = 0; i < 30; i++) {
const h = setup();
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
h.until('suspense');
const planned = h.game.plan.winners[0];
const last = h.game.schedule[h.game.schedule.length - 1];
assert(last.ids[0] === planned, 'schedule ends on planned winner');
assert(h.game.schedule[h.game.schedule.length - 2].ids[0] !== planned, 'penultimate step differs');
h.until('result');
assert(h.game.winners[0] === planned, 'revealed winner = planned');
}
});
test('one finger is not enough', () => {
const h = setup();
h.down('a');
h.run(6000);
assert(h.game.phase === 'idle', h.game.phase);
});
test('joining during the countdown resets to gathering without chicken', () => {
const h = setup();
h.down('a');
h.down('b');
h.until('countdown');
h.down('c');
assert(h.game.phase === 'gathering', h.game.phase);
assert(h.count('chicken') === 0, 'no chicken');
h.run(TIMING.stableMs - 200);
assert(h.game.phase === 'gathering', 'stable timer restarted');
h.until('result');
});
test('lifting during the countdown triggers chicken, then resumes', () => {
const h = setup();
['a', 'b', 'c'].forEach((id) => h.down(id));
h.until('countdown');
h.up('b');
assert(h.game.phase === 'chicken', h.game.phase);
assert(h.count('chicken') === 1, 'chicken event');
h.run(TIMING.chickenMs + 50);
assert(h.game.phase === 'gathering', 'back to gathering, got ' + h.game.phase);
h.until('result');
assert(['a', 'c'].includes(h.game.winners[0]), 'winner among remaining');
});
test('lifting during suspense aborts with chicken', () => {
const h = setup();
['a', 'b', 'c'].forEach((id) => h.down(id));
h.until('suspense');
h.up('a');
assert(h.game.phase === 'chicken' && h.game.plan === null, h.game.phase);
});
test('late joiners during suspense are spectators', () => {
const h = setup();
['a', 'b'].forEach((id) => h.down(id));
h.until('suspense');
h.down('late');
assert(h.game.rings.get('late').late, 'late flag');
h.until('result');
assert(h.game.winners[0] !== 'late', 'spectator never wins');
assert(!h.game.players.includes('late'), 'not a player');
});
test('result persists after lifting; restart only after grace and all fingers up', () => {
const h = setup();
['a', 'b'].forEach((id) => h.down(id));
h.until('result');
h.up('a');
h.run(3000);
assert(h.game.phase === 'result', 'still result while b holds');
h.down('x');
assert(h.game.phase === 'result' && h.game.rings.get('x').late, 'tap while someone holds is ignored');
h.up('x');
h.up('b');
assert(h.game.phase === 'result', 'stays after all lift');
assert(h.game.canRestart(h.now), 'can restart');
h.down('n1');
assert(h.game.phase === 'idle' && h.count('restart') === 1, 'restarted');
assert(h.game.rings.size === 1 && !h.game.rings.get('n1').late, 'tapping finger joins the next round');
});
test('no instant restart right after the reveal', () => {
const h = setup();
['a', 'b'].forEach((id) => h.down(id));
h.until('result');
h.up('a');
h.up('b');
h.down('a2');
assert(h.game.phase === 'result', 'grace period protects the result');
});
test('pick N clamps to fingers - 1 and picks distinct winners', () => {
const h = setup({ mode: 'pickN', pickN: 5 });
['a', 'b', 'c'].forEach((id) => h.down(id));
h.until('result');
assert(h.game.winners.length === 2, 'clamped to 2, got ' + h.game.winners.length);
assert(new Set(h.game.winners).size === 2, 'distinct');
});
test('teams are balanced', () => {
for (let i = 0; i < 20; i++) {
const plan = planRound('teams', ['a', 'b', 'c', 'd', 'e', 'f', 'g'], { teams: 3 });
const sizes = [0, 0, 0];
for (const t of plan.teams.values()) sizes[t]++;
assert(Math.max(...sizes) - Math.min(...sizes) <= 1, 'sizes ' + sizes);
}
const small = planRound('teams', ['a', 'b'], { teams: 4 });
assert(small.teamCount === 2, 'team count capped by fingers');
});
test('teams mode reaches result', () => {
const h = setup({ mode: 'teams', teams: 2 });
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
h.until('result');
assert(h.game.revealStyle === 'teams', 'teams style');
});
test('order assigns 1..N exactly once, #1 first', () => {
const h = setup({ mode: 'order' });
['a', 'b', 'c', 'd', 'e'].forEach((id) => h.down(id));
h.until('suspense');
const first = h.game.plan.order[0];
h.until('result');
const nums = [...h.game.orderNums.values()].map((o) => o.n).sort();
assert(nums.join() === '1,2,3,4,5', nums.join());
assert(h.game.orderNums.get(first).n === 1, 'planned #1');
assert(h.count('orderStep') === 5, 'steps');
});
test('elimination knocks out N-1 and the planned survivor remains', () => {
const h = setup({ mode: 'elimination' });
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
h.until('suspense');
const order = h.game.plan.eliminationOrder.slice();
// Knocked-out players lift their finger, as asked.
h.game.emit = ((orig) => (type, data) => { orig(type, data); if (type === 'eliminate') h.game.fingerUp(data.id, h.now); })(h.game.emit);
h.until('result');
const outs = h.events.filter((e) => e.type === 'eliminate').map((e) => e.data.id);
assert(outs.join() === order.slice(0, 3).join(), `outs ${outs} vs ${order}`);
assert(h.game.winners[0] === order[3], 'survivor');
});
test('elimination: a remaining player who lets go is out', () => {
const h = setup({ mode: 'elimination' });
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
h.until('suspense');
const survivor = h.game.plan.eliminationOrder[3];
const coward = h.game.plan.eliminationOrder[2];
h.up(coward);
assert(h.game.phase === 'elimPause', h.game.phase);
assert(h.events.find((e) => e.type === 'eliminate').data.chickened, 'chickened');
h.until('result');
assert(h.game.winners[0] === survivor, 'survivor still wins');
assert(h.count('eliminate') === 3, 'three outs');
});
test('two players: highlight alternates and lands on the planned one', () => {
const rings = [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 100, y: 0 }];
for (const target of ['a', 'b']) {
const s = makeHighlightSchedule(rings, [target], 2700);
assert(s[s.length - 1].ids[0] === target, 'lands');
for (let i = 1; i < s.length; i++) assert(s[i].ids[0] !== s[i - 1].ids[0], 'alternates');
}
});
test('randomInt is uniform (chi-square, 5 buckets)', () => {
const N = 50000;
const k = 5;
const c = new Array(k).fill(0);
for (let i = 0; i < N; i++) c[randomInt(k)]++;
const e = N / k;
const chi = c.reduce((s, x) => s + ((x - e) ** 2) / e, 0);
assert(chi < 18.47, `chi² ${chi.toFixed(2)} (p<0.001 threshold 18.47) counts ${c}`);
});
test('pick one is fair across fingers', () => {
const ids = ['a', 'b', 'c'];
const c = { a: 0, b: 0, c: 0 };
for (let i = 0; i < 30000; i++) c[planRound('one', ids, {}).winners[0]]++;
for (const id of ids) assert(Math.abs(c[id] / 30000 - 1 / 3) < 0.015, JSON.stringify(c));
});
test('unknown pointer ids and double lifts are harmless', () => {
const h = setup();
h.up('nope');
h.down('a');
h.up('a');
h.up('a');
h.run(500);
assert(h.game.phase === 'idle', h.game.phase);
assert(h.game.rings.size === 0, 'pruned');
});
test('recycled pointerIds still restart after a round (input -> game)', () => {
const h = setup();
const canvas = document.createElement('canvas');
document.body.append(canvas);
createInput(canvas, {
debug: false,
onDown: (id, x, y) => h.game.fingerDown(id, x, y, h.now),
onMove: (id, x, y) => h.game.fingerMove(id, x, y),
onUp: (id) => h.game.fingerUp(id, h.now),
});
const fire = (type, pointerId, x = 50, y = 50) =>
canvas.dispatchEvent(new PointerEvent(type, { pointerId, pointerType: 'touch', clientX: x, clientY: y, bubbles: true, cancelable: true }));
fire('pointerdown', 1, 50, 50);
fire('pointerdown', 2, 250, 250);
h.until('result');
fire('pointerup', 1);
fire('pointerup', 2);
h.run(TIMING.restartGraceMs + 100);
assert(h.game.canRestart(h.now), 'can restart');
fire('pointerdown', 1); // the browser reuses pointerId 1 for the tap
assert(h.game.phase === 'idle' && h.count('restart') === 1, 'tap with a recycled pointerId restarts, phase ' + h.game.phase);
fire('pointerup', 1);
h.run(500);
assert(h.game.rings.size === 0, 'tap ring removed');
canvas.remove();
});
test('reveal follows a resize (entering fullscreen) without leaving an unpainted strip', () => {
const c = document.createElement('canvas');
c.width = 100;
c.height = 200;
const g = c.getContext('2d');
const view = { w: 100, h: 100, R: 10 };
const ring = { x: 50, y: 50 };
const fx = createReveal('flood', { targets: [{ ring, color: '#ff0000' }], view, start: 0, palette: ['#ff0000'] });
view.h = 200; // screen grew after the reveal started
fx.drawBack(g, 5000);
const px = g.getImageData(50, 190, 1, 1).data;
assert(px[0] > 100 && px[3] === 255, 'bottom painted, got ' + Array.from(px));
});
// Service worker: parses, and every precached file exists.
try {
const src = await (await fetch('../sw.js')).text();
new Function(src); // syntax check only, not executed
const shell = [...src.matchAll(/'(\.\/[^']*)'/g)].map((m) => m[1]);
const missing = [];
for (const u of shell) if (!(await fetch(new URL('../' + u, location.href), { cache: 'no-store' })).ok) missing.push(u);
results.push(missing.length ? ['fail', 'sw.js precache missing: ' + missing] : ['pass', `sw.js parses; all ${shell.length} precached files exist`]);
} catch (e) {
results.push(['fail', 'sw.js: ' + e.message]);
}
const out = document.getElementById('out');
const failed = results.filter((r) => r[0] === 'fail').length;
out.innerHTML = results.map(([cls, msg]) => `<span class="${cls}">${cls.toUpperCase()}</span> ${msg}`).join('\n') + `\n\n${results.length - failed}/${results.length} passed`;
document.title = failed ? `FAIL ${failed}` : 'ALL PASS';
</script>
</body>
</html>