mirror of
https://github.com/tiennm99/loto.git
synced 2026-09-05 14:23:29 +00:00
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:
@@ -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 (0–9), teens (10–19 incl. mười lăm), 20–90 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
@@ -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() {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user