diff --git a/docs/code-standards.md b/docs/code-standards.md index aaae4d4..afbb20a 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -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 16–22) 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
e.key === "Escape" && dismiss()}>Dialog
``` -## 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 diff --git a/package-lock.json b/package-lock.json index 06a1a38..b9d7401 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 437bba0..b04e278 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/lib/game-logic.test.js b/src/lib/game-logic.test.js new file mode 100644 index 0000000..1da2263 --- /dev/null +++ b/src/lib/game-logic.test.js @@ -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(); + }); +}); diff --git a/src/lib/settings-store.test.js b/src/lib/settings-store.test.js new file mode 100644 index 0000000..1cf1340 --- /dev/null +++ b/src/lib/settings-store.test.js @@ -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, + }); + }); +});