334 lines
12 KiB
HTML
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>
|