refactor(player-board): extract auto-tick into pure helper + tests

Pull the bus-driven auto-tick effect body out of PlayerBoard.svelte
into `src/lib/auto-tick.js` so the dedup-by-`at` invariant — the one
that already caught a P0 — is unit-testable without mounting Svelte.
The effect is now a thin wrapper that calls `processAutoTick()` and
applies the returned `{crossed, lastHandledAt, changed}`.

8 vitest cases cover NEW draw, dedup on same `at`, re-cross after
manual untick, mode=master/player ignored (timestamp still advances),
off-board number, null lastDraw, and null grid.
This commit is contained in:
2026-04-28 10:03:47 +07:00
parent 00fbc97a55
commit 3e6cb90a08
4 changed files with 217 additions and 19 deletions
+2
View File
@@ -22,6 +22,7 @@
|------|---------|
| `src/lib/game-logic.js` | Stateless utilities: generateGrid (constraint-aware picker — exact 5 per row & per col, ascending-sorted columns, soft "no 3 consecutive filled cols per row" via rejection sampling), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. |
| `src/lib/call-bus.svelte.js` | Pub/sub for master draws → player auto-tick. Reactive `bus.lastDrawn` slot (emits `{ num, at }`). Used in `mode: "both"` to auto-mark master-called numbers on player board. |
| `src/lib/auto-tick.js` | Pure `processAutoTick({grid, crossed, lastDraw, lastHandledAt, mode})` extracted from PlayerBoard's bus-driven $effect. Owns the dedup-by-`at` invariant: `lastHandledAt` advances on every NEW timestamp (even no-op draws) so reactive re-runs from `crossed`/`grid` changes never re-fire a stale draw. |
| `src/lib/vietnamese-number.js` | `numberToVietnamese(n)` — pure utility mapping 0..90 to spoken Vietnamese, with tonal exceptions (15 → "mười lăm", 21 → "hai mươi mốt", 25 → "hai mươi lăm"). Out-of-range falls back to `String(n)`. |
| `src/lib/voice.js` | Bundled-MP3 playback. Exports `playNumber(n)`, `playWaiting(n)` (sequences cho + N), `playBingo()`, `cancelPlayback()`. Lazy `<audio>` cache, cancel-then-play, token-based cancel ensures stale promises can't resume after a new event. Reads active voice from `settings.voice`; URLs go through `import { base } from "$app/paths"` for basePath safety. |
| `src/lib/audio-manifest.js` | Re-exports `static/audio/manifest.json` as `VOICES` (array) + `VOICE_IDS` (Set) + `DEFAULT_VOICE`. Manifest is generated by `scripts/generate-audio.py`. |
@@ -38,6 +39,7 @@
| `src/lib/game-logic.test.js` | 27 unit tests: generateGrid shape (9×9, 5 per row/col, no duplicates), column ranges & ascending sort, no-3-consecutive soft constraint, row completion, waiting number detection, persistence (saveGrid/loadGrid/saveCrossedState/loadCrossedState with validators). |
| `src/lib/settings-store.test.js` | 31 unit tests: defaults (incl. voice keys), loadSettings (restore 8 keys, apply CSS vars, toggle dark class, handle empty/corrupt), saveSettings, resetSettings, theme toggle (auto → OS pref detection), master mode, auto-call + speed, color validation, voice round-trip + invalid-id fallback. |
| `src/lib/vietnamese-number.test.js` | 40 unit tests: ones (09), teens (1019 incl. mười lăm), 2090 incl. mốt and lăm exceptions, out-of-range fall-through. |
| `src/lib/auto-tick.test.js` | 8 unit tests for `processAutoTick`: NEW draw flips cell, dedup on same `at`, re-cross after manual untick, mode=master/player ignored (timestamp still advances), off-board number no-op, null lastDraw, null grid + empty crossed. |
### Configuration & PWA
| File | Purpose |
+13 -19
View File
@@ -1,7 +1,7 @@
<script>
import { processAutoTick } from "$lib/auto-tick.js";
import { bus, resetBus } from "$lib/call-bus.svelte.js";
import {
findUncrossedCell,
generateGrid,
getWaitingNumber,
isRowComplete,
@@ -149,25 +149,19 @@
return () => window.removeEventListener("keydown", onKey);
});
// Auto-tick on master draw (only in "both" mode). The effect tracks
// `bus.lastDrawn` reactively, but `grid`/`crossed` are read inside a
// peek — we only want to fire when a NEW draw arrives, not when the
// user manually unticks, clears, or regenerates. Comparing
// `drawn.at` against the last-handled timestamp blocks re-marks.
// Auto-tick on master draw (only in "both" mode). Reads `bus.lastDrawn`
// reactively; the dedup-by-`at` invariant lives in `processAutoTick` —
// see `auto-tick.test.js` for the full case matrix.
$effect(() => {
const drawn = bus.lastDrawn;
if (!drawn) return;
if (drawn.at === lastHandledDrawAt) return;
lastHandledDrawAt = drawn.at;
if (settings.mode !== "both") return;
if (!grid || crossed.length === 0) return;
const target = findUncrossedCell(grid, crossed, drawn.num);
if (!target) return;
crossed = crossed.map((row, ri) =>
ri === target.row
? row.map((v, ci) => (ci === target.col ? true : v))
: row,
);
const result = processAutoTick({
grid,
crossed,
lastDraw: bus.lastDrawn,
lastHandledAt: lastHandledDrawAt,
mode: settings.mode,
});
lastHandledDrawAt = result.lastHandledAt;
if (result.changed) crossed = result.crossed;
});
function handleGenerate() {
+52
View File
@@ -0,0 +1,52 @@
/**
* Pure helper for the master→player auto-tick path. Extracted from
* PlayerBoard.svelte so the dedup-by-`at` invariant is unit-testable
* without mounting the component.
* @module lib/auto-tick
*/
import { findUncrossedCell } from "$lib/game-logic.js";
/**
* Decide what `crossed` should become given a new bus draw.
*
* Always advance `lastHandledAt` to the draw's timestamp on a NEW draw,
* even when no cell ends up flipped (mode mismatch, number off-board,
* already crossed). This blocks reactive re-runs caused by `crossed` /
* `grid` changes (manual untick, clear, regen) from re-firing the same
* draw — only a fresh `at` should ever advance state.
*
* @param {object} args
* @param {number[][] | null} args.grid
* @param {boolean[][]} args.crossed
* @param {{ num: number, at: number } | null} args.lastDraw
* @param {number} args.lastHandledAt
* @param {"player" | "master" | "both"} args.mode
* @returns {{ crossed: boolean[][], lastHandledAt: number, changed: boolean }}
*/
export function processAutoTick({
grid,
crossed,
lastDraw,
lastHandledAt,
mode,
}) {
if (!lastDraw) return { crossed, lastHandledAt, changed: false };
if (lastDraw.at === lastHandledAt) {
return { crossed, lastHandledAt, changed: false };
}
// From here on, the draw is consumed: lastHandledAt advances.
const advanced = lastDraw.at;
if (mode !== "both") return { crossed, lastHandledAt: advanced, changed: false };
if (!grid || crossed.length === 0) {
return { crossed, lastHandledAt: advanced, changed: false };
}
const target = findUncrossedCell(grid, crossed, lastDraw.num);
if (!target) return { crossed, lastHandledAt: advanced, changed: false };
const updated = crossed.map((row, ri) =>
ri === target.row
? row.map((v, ci) => (ci === target.col ? true : v))
: row,
);
return { crossed: updated, lastHandledAt: advanced, changed: true };
}
+150
View File
@@ -0,0 +1,150 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { processAutoTick } from "./auto-tick.js";
/**
* Build a minimal grid where row 0 col 2 = 42 and row 2 col 5 = 17.
* Other cells are 0 (empty).
*/
function makeGrid() {
const grid = Array.from({ length: 9 }, () => new Array(9).fill(0));
grid[0][2] = 42;
grid[2][5] = 17;
return grid;
}
function makeCrossed() {
return Array.from({ length: 9 }, () => new Array(9).fill(false));
}
describe("processAutoTick", () => {
it("crosses the cell on a NEW draw when mode=both", () => {
const grid = makeGrid();
const crossed = makeCrossed();
const result = processAutoTick({
grid,
crossed,
lastDraw: { num: 42, at: 1000 },
lastHandledAt: 0,
mode: "both",
});
expect(result.changed).toBe(true);
expect(result.lastHandledAt).toBe(1000);
expect(result.crossed[0][2]).toBe(true);
// Original input not mutated (immutable update).
expect(crossed[0][2]).toBe(false);
});
it("ignores a re-fire with the same `at` (dedup guard)", () => {
const crossed = makeCrossed();
const result = processAutoTick({
grid: makeGrid(),
crossed,
lastDraw: { num: 42, at: 5000 },
lastHandledAt: 5000,
mode: "both",
});
expect(result.changed).toBe(false);
expect(result.lastHandledAt).toBe(5000);
expect(result.crossed).toBe(crossed);
});
it("re-crosses on a NEW `at` after a manual untick", () => {
// Step 1: auto-tick fires.
const grid = makeGrid();
const first = processAutoTick({
grid,
crossed: makeCrossed(),
lastDraw: { num: 42, at: 1000 },
lastHandledAt: 0,
mode: "both",
});
expect(first.crossed[0][2]).toBe(true);
// Step 2: user manually unticks (simulated by editing).
const manualUntick = first.crossed.map((row) => row.slice());
manualUntick[0][2] = false;
// Step 3: same number arrives with NEW timestamp → re-crosses.
const second = processAutoTick({
grid,
crossed: manualUntick,
lastDraw: { num: 42, at: 2000 },
lastHandledAt: first.lastHandledAt,
mode: "both",
});
expect(second.changed).toBe(true);
expect(second.lastHandledAt).toBe(2000);
expect(second.crossed[0][2]).toBe(true);
});
it("ignores draws when mode=master but still consumes the timestamp", () => {
const crossed = makeCrossed();
const result = processAutoTick({
grid: makeGrid(),
crossed,
lastDraw: { num: 42, at: 4000 },
lastHandledAt: 0,
mode: "master",
});
expect(result.changed).toBe(false);
expect(result.crossed).toBe(crossed);
expect(result.lastHandledAt).toBe(4000);
});
it("ignores draws when mode=player but still consumes the timestamp", () => {
const crossed = makeCrossed();
const result = processAutoTick({
grid: makeGrid(),
crossed,
lastDraw: { num: 42, at: 9000 },
lastHandledAt: 0,
mode: "player",
});
expect(result.changed).toBe(false);
expect(result.crossed).toBe(crossed);
// lastHandledAt still advances — solo player switching to "both"
// mid-game shouldn't replay a stale draw.
expect(result.lastHandledAt).toBe(9000);
});
it("no-ops when the number is not on the grid", () => {
const crossed = makeCrossed();
const result = processAutoTick({
grid: makeGrid(),
crossed,
lastDraw: { num: 88, at: 3000 },
lastHandledAt: 0,
mode: "both",
});
expect(result.changed).toBe(false);
expect(result.crossed).toBe(crossed);
expect(result.lastHandledAt).toBe(3000);
});
it("returns unchanged state when lastDraw is null", () => {
const crossed = makeCrossed();
const result = processAutoTick({
grid: makeGrid(),
crossed,
lastDraw: null,
lastHandledAt: 1234,
mode: "both",
});
expect(result.changed).toBe(false);
expect(result.crossed).toBe(crossed);
expect(result.lastHandledAt).toBe(1234);
});
it("no-ops when grid is null or crossed is empty", () => {
const result = processAutoTick({
grid: null,
crossed: [],
lastDraw: { num: 42, at: 7000 },
lastHandledAt: 0,
mode: "both",
});
expect(result.changed).toBe(false);
expect(result.lastHandledAt).toBe(7000);
});
});