test: vitest setup + 38 tests for game-logic and settings-store

Adds vitest + happy-dom as devDependencies and npm scripts test
(one-shot) and test:watch. No SvelteKit/vite reconfig needed —
vitest auto-picks up the existing vite plugin chain.

game-logic.test.js (26 tests): generateGrid shape invariants
(9x9, exact 5/row, exact 5/col, no duplicates) over 200 random
trials; per-column number ranges (col 0 = 1-9, col 8 = 80-90);
ascending-within-column rule; isRowComplete/getWaitingNumber edge
cases; full save/load roundtrip for the persistence layer
including corrupt-JSON and wrong-shape rejection.

settings-store.test.js (12 tests): defaults frozen, load with
valid/invalid/corrupt payloads, hex regex rejects shorthand,
save persists to localStorage and pushes CSS var, reset returns
to brown default. Uses happy-dom env for localStorage and
documentElement.

code-standards.md: documents the rune globals declaration and the
.svelte.js convention for rune-using non-component modules.
This commit is contained in:
2026-04-27 00:13:58 +07:00
parent 839afd9201
commit f2dff7b879
5 changed files with 834 additions and 30 deletions
+20 -6
View File
@@ -9,12 +9,18 @@
## Svelte 5 Runes & JavaScript
### Rune Globals & eslint
Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`, `$bindable`, `$inspect`, `$host`) are declared as readonly globals in `eslint.config.mjs` (lines 1622) to suppress "undefined variable" linter errors. They are usable in `.svelte` and `.svelte.js` files without import.
### State & Reactivity
- **$state**: For mutable local UI state (grid, crossed, booleans). `let grid = $state(...)`
- **$derived**: For computed values that auto-update when dependencies change. `const rowCompleteness = $derived(grid && crossed.length ? [...] : [])`
- **$effect**: For side effects (localStorage sync, detecting completed rows). Replaces React useEffect.
- **$props**: For destructuring component props. `let { storagePrefix = "loto" } = $props()`
### Reactive Store Files (`.svelte.js`)
Use the `.svelte.js` extension for modules that export rune-based reactive state (e.g., `settings-store.svelte.js`). This signals to bundlers and linters that the file uses Svelte 5 reactivity at module scope.
### Plain References (No Reactivity)
- Use regular `let` or `const` for timers, Sets, Maps (not reactive). Example: `let toastTimer = null; const celebratedRows = new Set();`
@@ -157,12 +163,20 @@ Use inline event handlers (`onclick`, `onkeydown`). Svelte 5 handles click deleg
<div onkeydown={(e) => e.key === "Escape" && dismiss()}>Dialog</div>
```
## Testing (Not Currently Implemented)
## Testing
Future tests should follow:
- Unit: test game logic (generateGrid, isRowComplete, getWaitingNumber) in isolation.
- Component: render PlayerBoard, mock localStorage, verify toast/popup.
- E2E: player flow (generate → click → bingo).
### Unit Tests (Implemented)
- **Framework**: Vitest 4.1.5 with happy-dom
- **Test Files**: `src/lib/game-logic.test.js` (26 tests), `src/lib/settings-store.test.js` (12 tests) — 38 total passing
- **Coverage**: Game logic (generateGrid shape/constraints, isRowComplete, getWaitingNumber, persistence with validators), settings (load/save/reset with error handling)
- **Scripts**: `npm test` (run once), `npm run test:watch` (continuous)
- **Pattern**: Use vitest's `describe` / `it` blocks, `expect()` assertions. Test both happy path and error cases (corrupt JSON, missing localStorage).
### Component Tests (Planned)
Future: render PlayerBoard, mock localStorage, verify toast/popup behaviors.
### E2E Tests (Planned)
Future: Playwright for player flow (generate → click → bingo) and host flow (draw → master board updates).
## Configuration
@@ -177,4 +191,4 @@ Set in `.env.local` (not committed).
- **adapter-static**: Generates static HTML + JS in `build/`.
- **basePath**: Dual-mode: `""` (Cloudflare, dev) or `/loto` (GitHub Pages via `BUILD_PROFILE=gh`).
Last reviewed: 2026-04-26
Last reviewed: 2026-04-27
+455 -22
View File
@@ -16,9 +16,11 @@
"eslint": "^9",
"eslint-plugin-svelte": "^2",
"globals": "^15",
"happy-dom": "20.9.0",
"svelte": "^5",
"tailwindcss": "^4",
"vite": "^7"
"vite": "^7",
"vitest": "4.1.5"
},
"optionalDependencies": {
"lightningcss-linux-x64-gnu": "1.32.0"
@@ -1578,6 +1580,17 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -1585,6 +1598,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1599,6 +1619,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1606,6 +1636,136 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.5",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.5",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -1669,6 +1829,16 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -1707,6 +1877,16 @@
"node": ">=6"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1761,6 +1941,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -1865,6 +2052,26 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
@@ -2206,6 +2413,16 @@
"node": ">=4.0"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2216,6 +2433,16 @@
"node": ">=0.10.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2336,6 +2563,24 @@
"dev": true,
"license": "ISC"
},
"node_modules/happy-dom": {
"version": "20.9.0",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz",
"integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.18.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -2974,6 +3219,13 @@
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2981,6 +3233,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -3173,6 +3438,13 @@
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -3198,6 +3470,20 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -3359,6 +3645,23 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -3394,17 +3697,14 @@
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
"node": ">=14.0.0"
}
},
"node_modules/totalist": {
@@ -3438,6 +3738,13 @@
"node": ">= 0.8.0"
}
},
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -3548,19 +3855,6 @@
}
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitefu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
@@ -3581,6 +3875,106 @@
}
}
},
"node_modules/vitest": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3597,6 +3991,23 @@
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -3607,6 +4018,28 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+6 -2
View File
@@ -9,7 +9,9 @@
"build": "vite build",
"build:gh": "BUILD_PROFILE=gh vite build",
"preview": "vite preview",
"lint": "eslint ."
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest"
},
"optionalDependencies": {
"lightningcss-linux-x64-gnu": "1.32.0"
@@ -23,8 +25,10 @@
"eslint": "^9",
"eslint-plugin-svelte": "^2",
"globals": "^15",
"happy-dom": "20.9.0",
"svelte": "^5",
"tailwindcss": "^4",
"vite": "^7"
"vite": "^7",
"vitest": "4.1.5"
}
}
+244
View File
@@ -0,0 +1,244 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import {
generateGrid,
getWaitingNumber,
isRowComplete,
loadCrossedState,
loadGrid,
saveCrossedState,
saveGrid,
} from "./game-logic.js";
const NUM_ROWS = 9;
const NUM_COLS = 9;
const NUM_PER_ROW = 5;
/** Column N (0-indexed) holds numbers in this tens range. */
const COL_RANGE = [
[1, 9],
[10, 19],
[20, 29],
[30, 39],
[40, 49],
[50, 59],
[60, 69],
[70, 79],
[80, 90],
];
/** @param {number[][]} grid */
function rowSums(grid) {
return grid.map((r) => r.filter((n) => n > 0).length);
}
/** @param {number[][]} grid */
function colSums(grid) {
return Array.from({ length: NUM_COLS }, (_, c) =>
grid.reduce((s, r) => s + (r[c] > 0 ? 1 : 0), 0),
);
}
describe("generateGrid — shape invariants", () => {
it("returns a 9x9 matrix", () => {
const g = generateGrid();
expect(g).toHaveLength(NUM_ROWS);
for (const row of g) expect(row).toHaveLength(NUM_COLS);
});
it("each row has exactly 5 non-zero numbers", () => {
for (let trial = 0; trial < 200; trial++) {
const sums = rowSums(generateGrid());
expect(sums).toEqual(Array(NUM_ROWS).fill(NUM_PER_ROW));
}
});
it("each column has exactly 5 non-zero numbers", () => {
for (let trial = 0; trial < 200; trial++) {
const sums = colSums(generateGrid());
expect(sums).toEqual(Array(NUM_COLS).fill(NUM_PER_ROW));
}
});
it("never produces duplicates in a single card", () => {
for (let trial = 0; trial < 50; trial++) {
const flat = generateGrid().flat().filter((n) => n > 0);
expect(new Set(flat).size).toBe(flat.length);
}
});
});
describe("generateGrid — column number ranges (lô tô hội chợ Tân Tân)", () => {
it("each non-zero cell sits in its column's tens range", () => {
for (let trial = 0; trial < 50; trial++) {
const g = generateGrid();
for (let r = 0; r < NUM_ROWS; r++) {
for (let c = 0; c < NUM_COLS; c++) {
const n = g[r][c];
if (n === 0) continue;
const [lo, hi] = COL_RANGE[c];
expect(n, `row=${r} col=${c} num=${n}`).toBeGreaterThanOrEqual(lo);
expect(n, `row=${r} col=${c} num=${n}`).toBeLessThanOrEqual(hi);
}
}
}
});
it("numbers within each column are sorted ascending top-to-bottom", () => {
for (let trial = 0; trial < 50; trial++) {
const g = generateGrid();
for (let c = 0; c < NUM_COLS; c++) {
const colNums = g.map((r) => r[c]).filter((n) => n > 0);
const sorted = [...colNums].sort((a, b) => a - b);
expect(colNums).toEqual(sorted);
}
}
});
it("col 0 only holds numbers from 1-9 (5 per card)", () => {
const g = generateGrid();
const col0 = g.map((r) => r[0]).filter((n) => n > 0);
expect(col0).toHaveLength(5);
for (const n of col0) expect(n).toBeGreaterThanOrEqual(1);
for (const n of col0) expect(n).toBeLessThanOrEqual(9);
});
it("col 8 only holds numbers from 80-90 (5 per card)", () => {
const g = generateGrid();
const col8 = g.map((r) => r[8]).filter((n) => n > 0);
expect(col8).toHaveLength(5);
for (const n of col8) expect(n).toBeGreaterThanOrEqual(80);
for (const n of col8) expect(n).toBeLessThanOrEqual(90);
});
});
describe("isRowComplete", () => {
/** @param {number[]} nums */
function rowOf(nums) {
return nums;
}
it("returns true when every number in the row is crossed", () => {
const grid = [rowOf([0, 1, 0, 2, 0, 3, 0, 4, 5])];
const crossed = [[false, true, false, true, false, true, false, true, true]];
expect(isRowComplete(grid, crossed, 0)).toBe(true);
});
it("returns false when at least one number is uncrossed", () => {
const grid = [rowOf([0, 1, 0, 2, 0, 3, 0, 4, 5])];
const crossed = [[false, true, false, false, false, true, false, true, true]];
expect(isRowComplete(grid, crossed, 0)).toBe(false);
});
it("returns false for an all-zero row (no numbers, not a win)", () => {
const grid = [rowOf([0, 0, 0, 0, 0, 0, 0, 0, 0])];
const crossed = [[false, false, false, false, false, false, false, false, false]];
expect(isRowComplete(grid, crossed, 0)).toBe(false);
});
it("ignores 0 cells when checking crossed state", () => {
const grid = [rowOf([0, 7, 0, 0, 0, 0, 0, 0, 0])];
// 7 is crossed; the zeros are not (and shouldn't matter)
const crossed = [[false, true, false, false, false, false, false, false, false]];
expect(isRowComplete(grid, crossed, 0)).toBe(true);
});
});
describe("saveGrid / loadGrid roundtrip", () => {
beforeEach(() => localStorage.clear());
it("persists and reloads a generated 9x9 grid by prefix", () => {
const grid = generateGrid();
saveGrid(grid, "loto");
const loaded = loadGrid("loto");
expect(loaded).toEqual(grid);
});
it("returns null when no grid is stored under the prefix", () => {
expect(loadGrid("loto")).toBeNull();
});
it("isolates grids by prefix (player vs master card)", () => {
const a = generateGrid();
const b = generateGrid();
saveGrid(a, "loto");
saveGrid(b, "loto_master_card");
expect(loadGrid("loto")).toEqual(a);
expect(loadGrid("loto_master_card")).toEqual(b);
});
it("rejects a stored grid with wrong row count (corrupt shape → null)", () => {
localStorage.setItem("loto_grid", JSON.stringify([[1, 2, 3]]));
expect(loadGrid("loto")).toBeNull();
});
it("rejects a stored grid containing non-numbers (validator catches it)", () => {
const bad = Array.from({ length: 9 }, () => Array(9).fill("x"));
localStorage.setItem("loto_grid", JSON.stringify(bad));
expect(loadGrid("loto")).toBeNull();
});
it("returns null for corrupt JSON without throwing", () => {
localStorage.setItem("loto_grid", "{not json");
expect(() => loadGrid("loto")).not.toThrow();
expect(loadGrid("loto")).toBeNull();
});
});
describe("saveCrossedState / loadCrossedState roundtrip", () => {
beforeEach(() => localStorage.clear());
/** @returns {boolean[][]} */
function freshCrossed() {
return Array.from({ length: 9 }, () => Array(9).fill(false));
}
it("persists and reloads a crossed-state matrix", () => {
const c = freshCrossed();
c[0][0] = true;
c[8][8] = true;
saveCrossedState(c, "loto");
expect(loadCrossedState("loto")).toEqual(c);
});
it("returns null when nothing stored", () => {
expect(loadCrossedState("loto")).toBeNull();
});
it("rejects non-boolean matrix (validator catches it)", () => {
const bad = Array.from({ length: 9 }, () => Array(9).fill(0));
localStorage.setItem("loto_crossed", JSON.stringify(bad));
expect(loadCrossedState("loto")).toBeNull();
});
it("rejects wrong-shape matrix (8 rows instead of 9)", () => {
const bad = Array.from({ length: 8 }, () => Array(9).fill(false));
localStorage.setItem("loto_crossed", JSON.stringify(bad));
expect(loadCrossedState("loto")).toBeNull();
});
});
describe("getWaitingNumber", () => {
it("returns the single uncrossed number when exactly one remains", () => {
const grid = [[0, 1, 0, 2, 0, 3, 0, 4, 5]];
const crossed = [[false, true, false, false, false, true, false, true, true]];
expect(getWaitingNumber(grid, crossed, 0)).toBe(2);
});
it("returns null when more than one number remains", () => {
const grid = [[0, 1, 0, 2, 0, 3, 0, 4, 5]];
const crossed = [[false, true, false, false, false, false, false, true, true]];
expect(getWaitingNumber(grid, crossed, 0)).toBeNull();
});
it("returns null when zero numbers remain (row complete)", () => {
const grid = [[0, 1, 0, 2, 0, 3, 0, 4, 5]];
const crossed = [[false, true, false, true, false, true, false, true, true]];
expect(getWaitingNumber(grid, crossed, 0)).toBeNull();
});
it("returns null for an empty row", () => {
const grid = [[0, 0, 0, 0, 0, 0, 0, 0, 0]];
const crossed = [[false, false, false, false, false, false, false, false, false]];
expect(getWaitingNumber(grid, crossed, 0)).toBeNull();
});
});
+109
View File
@@ -0,0 +1,109 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import {
DEFAULT_SETTINGS,
loadSettings,
resetSettings,
saveSettings,
settings,
} from "./settings-store.svelte.js";
const KEY = "loto_settings";
beforeEach(() => {
localStorage.clear();
// Restore in-memory state to defaults between tests so order doesn't matter.
settings.emptyCellColor = DEFAULT_SETTINGS.emptyCellColor;
document.documentElement.style.removeProperty("--empty-cell-bg");
});
describe("settings-store — defaults", () => {
it("DEFAULT_SETTINGS is frozen", () => {
expect(Object.isFrozen(DEFAULT_SETTINGS)).toBe(true);
});
it("default empty-cell color is brown (#7a4a2b)", () => {
expect(DEFAULT_SETTINGS.emptyCellColor).toBe("#7a4a2b");
});
});
describe("settings-store — loadSettings", () => {
it("uses defaults when localStorage is empty and applies CSS var", () => {
loadSettings();
expect(settings.emptyCellColor).toBe(DEFAULT_SETTINGS.emptyCellColor);
expect(
document.documentElement.style.getPropertyValue("--empty-cell-bg"),
).toBe(DEFAULT_SETTINGS.emptyCellColor);
});
it("reads a previously-saved valid color", () => {
localStorage.setItem(KEY, JSON.stringify({ emptyCellColor: "#abcdef" }));
loadSettings();
expect(settings.emptyCellColor).toBe("#abcdef");
expect(
document.documentElement.style.getPropertyValue("--empty-cell-bg"),
).toBe("#abcdef");
});
it("ignores an invalid stored color and keeps the in-memory default", () => {
localStorage.setItem(KEY, JSON.stringify({ emptyCellColor: "not-a-color" }));
loadSettings();
expect(settings.emptyCellColor).toBe(DEFAULT_SETTINGS.emptyCellColor);
});
it("ignores a wrong-shape JSON payload", () => {
localStorage.setItem(KEY, JSON.stringify({ unrelated: 42 }));
loadSettings();
expect(settings.emptyCellColor).toBe(DEFAULT_SETTINGS.emptyCellColor);
});
it("survives corrupt JSON without throwing", () => {
localStorage.setItem(KEY, "{not json");
expect(() => loadSettings()).not.toThrow();
expect(settings.emptyCellColor).toBe(DEFAULT_SETTINGS.emptyCellColor);
});
it("rejects 3-digit hex shorthand (#fff is not valid per regex)", () => {
localStorage.setItem(KEY, JSON.stringify({ emptyCellColor: "#fff" }));
loadSettings();
expect(settings.emptyCellColor).toBe(DEFAULT_SETTINGS.emptyCellColor);
});
it("accepts uppercase hex", () => {
localStorage.setItem(KEY, JSON.stringify({ emptyCellColor: "#ABCDEF" }));
loadSettings();
expect(settings.emptyCellColor).toBe("#ABCDEF");
});
});
describe("settings-store — saveSettings", () => {
it("persists current in-memory settings to localStorage", () => {
settings.emptyCellColor = "#112233";
saveSettings();
const raw = localStorage.getItem(KEY);
expect(raw).not.toBeNull();
expect(JSON.parse(/** @type {string} */ (raw))).toEqual({
emptyCellColor: "#112233",
});
});
it("pushes the color to the document CSS var", () => {
settings.emptyCellColor = "#445566";
saveSettings();
expect(
document.documentElement.style.getPropertyValue("--empty-cell-bg"),
).toBe("#445566");
});
});
describe("settings-store — resetSettings", () => {
it("returns settings to defaults and persists the reset", () => {
settings.emptyCellColor = "#000000";
saveSettings();
resetSettings();
expect(settings.emptyCellColor).toBe(DEFAULT_SETTINGS.emptyCellColor);
expect(JSON.parse(/** @type {string} */ (localStorage.getItem(KEY)))).toEqual({
emptyCellColor: DEFAULT_SETTINGS.emptyCellColor,
});
});
});