diff --git a/web/package.json b/web/package.json index e2bce5d..bc2b25f 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "next": "16.2.10", @@ -18,6 +20,7 @@ "@tailwindcss/postcss": "^4.3.3", "eslint": "^9", "eslint-config-next": "16.2.10", - "tailwindcss": "^4.3.3" + "tailwindcss": "^4.3.3", + "vitest": "^4.1.10" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1375759..65bb00b 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: tailwindcss: specifier: ^4.3.3 version: 4.3.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(vite@8.1.5(jiti@2.7.0)) packages: @@ -129,12 +132,21 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -368,6 +380,12 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@next/env@16.2.10': resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==} @@ -442,9 +460,113 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -543,6 +665,15 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -731,6 +862,35 @@ packages: cpu: [x64] os: [win32] + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -787,6 +947,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -853,6 +1017,10 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -958,6 +1126,9 @@ packages: resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1098,10 +1269,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1150,6 +1328,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1618,6 +1801,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1649,6 +1836,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1660,6 +1850,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -1718,6 +1912,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1785,6 +1984,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1792,6 +1994,12 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -1855,10 +2063,25 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1923,6 +2146,90 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + 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.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + 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 + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -1944,6 +2251,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2087,16 +2399,32 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))': dependencies: eslint: 9.39.5(jiti@2.7.0) @@ -2282,6 +2610,13 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + '@next/env@16.2.10': {} '@next/eslint-plugin-next@16.2.10': @@ -2326,8 +2661,63 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oxc-project/types@0.139.0': {} + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rtsao/scc@1.1.0': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -2406,6 +2796,18 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -2573,6 +2975,47 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.5(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(jiti@2.7.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -2661,6 +3104,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} @@ -2721,6 +3166,8 @@ snapshots: caniuse-lite@1.0.30001793: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2887,6 +3334,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -3115,8 +3564,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -3163,6 +3618,9 @@ snapshots: dependencies: is-callable: 1.2.7 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -3612,6 +4070,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.4: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3645,12 +4105,16 @@ snapshots: path-parse@1.0.7: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} picomatch@4.0.4: {} + picomatch@4.0.5: {} + possible-typed-array-names@1.1.0: {} postcss@8.5.19: @@ -3715,6 +4179,27 @@ snapshots: reusify@1.1.0: {} + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3832,10 +4317,16 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + source-map-js@1.2.1: {} stable-hash@0.0.5: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -3912,11 +4403,22 @@ snapshots: tapable@2.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -4028,6 +4530,42 @@ snapshots: dependencies: punycode: 2.3.1 + vite@8.1.5(jiti@2.7.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.19 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@4.1.10(vite@8.1.5(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.1.5(jiti@2.7.0) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4073,6 +4611,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} yallist@3.1.1: {} diff --git a/web/test/champion-data.test.js b/web/test/champion-data.test.js new file mode 100644 index 0000000..b4d01f6 --- /dev/null +++ b/web/test/champion-data.test.js @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getChampionImageUrl, getTodaySeed } from "../lib/champion-data"; +import { AATROX, CHAMPIONS } from "./fixtures/champions"; + +/** + * `champions` is module-private with no reset export, so each group gets a fresh + * module instance instead of adding production code for testability. + */ +async function freshModule({ champions = CHAMPIONS, ok = true, status = 200 } = {}) { + vi.resetModules(); + const fetchMock = vi.fn(async () => ({ + ok, + status, + json: async () => champions, + })); + vi.stubGlobal("fetch", fetchMock); + const mod = await import("../lib/champion-data"); + return { mod, fetchMock }; +} + +/** Load a module instance with its champion list already populated. */ +async function loadedModule(champions = CHAMPIONS) { + const { mod, fetchMock } = await freshModule({ champions }); + await mod.loadChampions(); + return { mod, fetchMock }; +} + +function named(names) { + return names.map((name) => ({ ...AATROX, id: name, name })); +} + +describe("loadChampions", () => { + it("fetches the static champions file and returns the parsed list", async () => { + const { mod, fetchMock } = await freshModule(); + await expect(mod.loadChampions()).resolves.toEqual(CHAMPIONS); + expect(fetchMock).toHaveBeenCalledWith("/champions.json"); + }); + + it("serves later calls from the cache without refetching", async () => { + const { mod, fetchMock } = await loadedModule(); + await mod.loadChampions(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("is single-flight — concurrent callers share one fetch", async () => { + const { mod, fetchMock } = await freshModule(); + const [first, second] = await Promise.all([ + mod.loadChampions(), + mod.loadChampions(), + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(first).toBe(second); + }); + + it("rejects with the status code when the response is not ok", async () => { + const { mod } = await freshModule({ ok: false, status: 404 }); + await expect(mod.loadChampions()).rejects.toThrow( + "Failed to load champions: 404", + ); + }); + + it("clears the in-flight promise on failure so a retry can succeed", async () => { + const { mod, fetchMock } = await freshModule({ ok: false, status: 500 }); + await expect(mod.loadChampions()).rejects.toThrow(); + + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => CHAMPIONS, + }); + + await expect(mod.loadChampions()).resolves.toEqual(CHAMPIONS); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe("searchChampions", () => { + it("returns nothing for an empty query", async () => { + const { mod } = await loadedModule(); + expect(mod.searchChampions("")).toEqual([]); + }); + + it("matches case-insensitively on a substring", async () => { + const { mod } = await loadedModule(); + expect(mod.searchChampions("AATR").map((c) => c.name)).toEqual(["Aatrox"]); + }); + + it("sorts prefix matches ahead of substring matches", async () => { + const { mod } = await loadedModule(named(["Akali", "Kayle", "Kayn"])); + // "Kayle"/"Kayn" start with "ka"; "Akali" only contains it + expect(mod.searchChampions("ka").map((c) => c.name)).toEqual([ + "Kayle", + "Kayn", + "Akali", + ]); + }); + + it("sorts alphabetically within each tier", async () => { + const { mod } = await loadedModule(named(["Azir", "Ahri", "Aatrox"])); + expect(mod.searchChampions("a").map((c) => c.name)).toEqual([ + "Aatrox", + "Ahri", + "Azir", + ]); + }); + + it("caps the result at 8", async () => { + const many = named([ + "Aatrox", "Ahri", "Akali", "Alistar", "Amumu", + "Anivia", "Annie", "Aphelios", "Ashe", "Azir", + ]); + const { mod } = await loadedModule(many); + expect(mod.searchChampions("a")).toHaveLength(8); + }); + + it("filters excluded names case-insensitively", async () => { + const { mod } = await loadedModule(named(["Kayle", "Kayn"])); + expect(mod.searchChampions("ka", ["kAyN"]).map((c) => c.name)).toEqual([ + "Kayle", + ]); + }); + + it("returns an empty list when nothing matches", async () => { + const { mod } = await loadedModule(); + expect(mod.searchChampions("zzzz")).toEqual([]); + }); +}); + +describe("getRandomChampion", () => { + it("is deterministic for a given seed", async () => { + const { mod } = await loadedModule(); + expect(mod.getRandomChampion("2026-07-25")).toBe( + mod.getRandomChampion("2026-07-25"), + ); + }); + + it("always returns a champion from the loaded list", async () => { + const { mod } = await loadedModule(); + for (const seed of ["a", "b", "2026-07-25", "unlimited_1_0.5", ""]) { + expect(CHAMPIONS).toContain(mod.getRandomChampion(seed)); + } + }); + + it("returns null before champions are loaded", async () => { + const { mod } = await freshModule(); + expect(mod.getRandomChampion("2026-07-25")).toBeNull(); + }); +}); + +describe("getTodaySeed", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("formats as YYYY-MM-DD", () => { + vi.setSystemTime(new Date("2026-07-25T09:00:00Z")); + expect(getTodaySeed()).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("reflects the local date, not the UTC date", () => { + // 18:00 UTC is already the next day in UTC+7 and still the same day in UTC, + // so this instant fails any implementation that reaches for UTC getters. + vi.setSystemTime(new Date("2026-07-25T18:00:00Z")); + // en-CA renders as YYYY-MM-DD — an independent derivation of the same date. + expect(getTodaySeed()).toBe(new Date().toLocaleDateString("en-CA")); + }); + + it("zero-pads single-digit months and days", () => { + vi.setSystemTime(new Date(2026, 0, 5, 12, 0, 0)); + expect(getTodaySeed()).toBe("2026-01-05"); + }); + + it("advances by one day across a local midnight", () => { + vi.setSystemTime(new Date(2026, 6, 25, 23, 59, 0)); + const before = getTodaySeed(); + vi.setSystemTime(new Date(2026, 6, 26, 0, 1, 0)); + expect(getTodaySeed()).not.toBe(before); + expect(getTodaySeed()).toBe("2026-07-26"); + }); +}); + +describe("getChampionImageUrl", () => { + it("returns the absolute Data Dragon tile URL", () => { + expect(getChampionImageUrl("Aatrox")).toBe( + "https://ddragon.leagueoflegends.com/cdn/img/champion/tiles/Aatrox_0.jpg", + ); + }); + + it("stays absolute — it must never be rewritten through a base path", () => { + expect(getChampionImageUrl("Belveth")).toMatch(/^https:\/\//); + }); +}); diff --git a/web/test/classic-mode.test.js b/web/test/classic-mode.test.js new file mode 100644 index 0000000..b3df281 --- /dev/null +++ b/web/test/classic-mode.test.js @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { CLASSIC_ATTRIBUTES, compareChampions } from "../lib/classic-mode"; +import { AATROX, AHRI, AKALI, BELVETH } from "./fixtures/champions"; + +/** Pull one attribute's comparison out of the 7-entry result array. */ +function cell(guess, target, key) { + return compareChampions(guess, target).find((r) => r.key === key); +} + +describe("CLASSIC_ATTRIBUTES", () => { + it("exposes 7 attributes with gender first and releaseDate last", () => { + expect(CLASSIC_ATTRIBUTES).toHaveLength(7); + expect(CLASSIC_ATTRIBUTES[0].key).toBe("gender"); + expect(CLASSIC_ATTRIBUTES[6].key).toBe("releaseDate"); + }); + + it("keeps the display order the grid header depends on", () => { + expect(CLASSIC_ATTRIBUTES.map((a) => a.key)).toEqual([ + "gender", + "genre", + "attackType", + "resource", + "region", + "lane", + "releaseDate", + ]); + }); +}); + +describe("compareChampions — result shape", () => { + it("returns one entry per attribute, carrying label and type through", () => { + const results = compareChampions(AHRI, AATROX); + expect(results).toHaveLength(7); + expect(results[0]).toMatchObject({ + key: "gender", + label: "Gender", + type: "exact", + }); + }); +}); + +describe("compareChampions — exact", () => { + it("marks an identical value correct", () => { + expect(cell(AKALI, AHRI, "gender").result).toBe("correct"); + }); + + it("marks a differing value wrong", () => { + expect(cell(AHRI, AATROX, "gender").result).toBe("wrong"); + }); + + it("compares case-insensitively", () => { + const shouty = { ...AATROX, gender: "MALE" }; + expect(cell(shouty, AATROX, "gender").result).toBe("correct"); + }); + + it("treats a missing value as not matching a present one", () => { + expect(cell(BELVETH, AATROX, "resource").result).toBe("wrong"); + }); +}); + +describe("compareChampions — multi", () => { + it("marks identical sets correct", () => { + expect(cell(BELVETH, AATROX, "genre").result).toBe("correct"); + }); + + it("marks intersecting sets partial", () => { + // AKALI lane "top,mid" overlaps AATROX lane "top" + expect(cell(AKALI, AATROX, "lane").result).toBe("partial"); + }); + + it("marks disjoint sets wrong", () => { + expect(cell(AHRI, AATROX, "lane").result).toBe("wrong"); + }); + + it("marks two empty sets correct", () => { + const a = { ...AATROX, lane: "" }; + const b = { ...AATROX, lane: "" }; + expect(cell(a, b, "lane").result).toBe("correct"); + }); + + it("marks one empty set against a populated one wrong", () => { + const empty = { ...AATROX, lane: "" }; + expect(cell(empty, AATROX, "lane").result).toBe("wrong"); + }); + + it("ignores order and whitespace when comparing sets", () => { + const spaced = { ...AKALI, lane: " mid , top " }; + expect(cell(spaced, AKALI, "lane").result).toBe("correct"); + }); +}); + +describe("compareChampions — year", () => { + it("marks the same year correct with no direction hint", () => { + const twin = { ...AHRI, releaseDate: 2013 }; + const result = cell(twin, AATROX, "releaseDate"); + expect(result.result).toBe("correct"); + expect(result.direction).toBeUndefined(); + }); + + it("points up when the guess is older than the target", () => { + expect(cell(AHRI, AATROX, "releaseDate")).toMatchObject({ + result: "wrong", + direction: "up", + }); + }); + + it("points down when the guess is newer than the target", () => { + expect(cell(BELVETH, AATROX, "releaseDate")).toMatchObject({ + result: "wrong", + direction: "down", + }); + }); + + it("gives no direction when either year is unparseable", () => { + const undated = { ...AHRI, releaseDate: "" }; + const result = cell(undated, AATROX, "releaseDate"); + expect(result.result).toBe("wrong"); + expect(result.direction).toBeUndefined(); + }); + + it("renders a missing year as ?", () => { + const undated = { ...AHRI, releaseDate: "" }; + expect(cell(undated, AATROX, "releaseDate").guessValue).toBe("?"); + }); + + it("passes the year through unformatted", () => { + expect(cell(AHRI, AATROX, "releaseDate")).toMatchObject({ + guessValue: 2011, + targetValue: 2013, + }); + }); +}); + +describe("compareChampions — value formatting", () => { + it("capitalizes gender", () => { + expect(cell(AATROX, AATROX, "gender").guessValue).toBe("Male"); + }); + + it("renders attackType close as Melee and range as Ranged", () => { + expect(cell(AATROX, AATROX, "attackType").guessValue).toBe("Melee"); + expect(cell(AHRI, AHRI, "attackType").guessValue).toBe("Ranged"); + }); + + it("splits hyphenated regions into capitalized words", () => { + expect(cell(AKALI, AKALI, "region").guessValue).toBe("Shadow Isles"); + }); + + it("title-cases comma lists and rejoins them with a space", () => { + expect(cell(AHRI, AHRI, "genre").guessValue).toBe("Mage, Assassin"); + expect(cell(AKALI, AKALI, "lane").guessValue).toBe("Top, Mid"); + }); + + it("renders an empty value as an em dash", () => { + expect(cell(BELVETH, BELVETH, "resource").guessValue).toBe("—"); + }); + + it("formats the target value the same way as the guess value", () => { + expect(cell(AHRI, AKALI, "region")).toMatchObject({ + guessValue: "Ionia", + targetValue: "Shadow Isles", + }); + }); +}); diff --git a/web/test/fixtures/champions.js b/web/test/fixtures/champions.js new file mode 100644 index 0000000..3e4b07f --- /dev/null +++ b/web/test/fixtures/champions.js @@ -0,0 +1,66 @@ +/** + * Hand-written champion fixtures, deliberately small so the weekly champions.json + * sync can never break the suite. Shapes mirror the real data exactly: + * `releaseDate` is a number, `attackType` is "close" | "range". + * + * Chosen to exercise every compareChampions branch against AATROX as target: + * AHRI — disjoint genre, disjoint lane, older year (direction "up") + * AKALI — disjoint genre, overlapping lane (partial), hyphenated region + * BELVETH — identical genre (correct), disjoint lane, empty resource ("—"), + * newer year (direction "down") + */ + +export const AATROX = { + id: "Aatrox", + name: "Aatrox", + title: "the Darkin Blade", + resource: "Blood Well", + genre: "Fighter", + gender: "male", + attackType: "close", + releaseDate: 2013, + region: "runeterra", + lane: "top", +}; + +export const AHRI = { + id: "Ahri", + name: "Ahri", + title: "the Nine-Tailed Fox", + resource: "Mana", + genre: "Mage,Assassin", + gender: "female", + attackType: "range", + releaseDate: 2011, + region: "ionia", + lane: "mid", +}; + +export const AKALI = { + id: "Akali", + name: "Akali", + title: "the Rogue Assassin", + resource: "Energy", + genre: "Assassin", + gender: "female", + attackType: "close", + releaseDate: 2010, + region: "shadow-isles", + lane: "top,mid", +}; + +export const BELVETH = { + id: "Belveth", + name: "Bel'Veth", + title: "the Empress of the Void", + resource: "", + genre: "Fighter", + gender: "female", + attackType: "close", + releaseDate: 2022, + region: "void", + lane: "jungle", +}; + +/** Load order matters for getRandomChampion index assertions. */ +export const CHAMPIONS = [AATROX, AHRI, AKALI, BELVETH]; diff --git a/web/test/game-engine.test.js b/web/test/game-engine.test.js new file mode 100644 index 0000000..0d658d2 --- /dev/null +++ b/web/test/game-engine.test.js @@ -0,0 +1,357 @@ +import { describe, expect, it } from "vitest"; +import { + clearExpiredCache, + clearUnlimitedState, + createGame, + getOrCreateUnlimitedSeed, + loadUnlimitedStats, + saveUnlimitedStats, + submitGuess, +} from "../lib/game-engine"; +import { AATROX, AHRI } from "./fixtures/champions"; + +/** Minimal stand-in for compareChampions — the engine only stores what this returns. */ +const compareFn = (guess, target) => [ + { key: "name", result: guess.name === target.name ? "correct" : "wrong" }, +]; + +/** Synthesize a distinct non-target champion. */ +function other(name) { + return { ...AHRI, id: name, name }; +} + +function daily(overrides = {}) { + return createGame({ + target: AATROX, + compareFn, + maxGuesses: 6, + mode: "daily", + seed: "2026-07-25", + ...overrides, + }); +} + +function unlimited(overrides = {}) { + return createGame({ + target: AATROX, + compareFn, + maxGuesses: 0, + mode: "unlimited", + seed: "unlimited_123", + ...overrides, + }); +} + +describe("createGame", () => { + it("starts empty and open", () => { + const game = daily(); + expect(game.guesses).toEqual([]); + expect(game.results).toEqual([]); + expect(game.isOver).toBe(false); + expect(game.isWon).toBe(false); + }); + + it("carries config through onto the game object", () => { + expect(daily()).toMatchObject({ + target: AATROX, + maxGuesses: 6, + mode: "daily", + seed: "2026-07-25", + }); + }); + + it("defaults to 6 guesses in daily mode when maxGuesses is omitted", () => { + const game = createGame({ target: AATROX, compareFn }); + expect(game.maxGuesses).toBe(6); + expect(game.mode).toBe("daily"); + }); +}); + +describe("submitGuess", () => { + it("appends the guess and its comparison result", () => { + const game = submitGuess(daily(), AHRI); + expect(game.guesses).toEqual([AHRI]); + expect(game.results).toHaveLength(1); + }); + + it("does not mutate the game it was given", () => { + const before = daily(); + submitGuess(before, AHRI); + expect(before.guesses).toHaveLength(0); + expect(before.results).toHaveLength(0); + }); + + it("returns fresh array identities so reassignment is observable", () => { + const before = daily(); + const after = submitGuess(before, AHRI); + expect(after).not.toBe(before); + expect(after.guesses).not.toBe(before.guesses); + expect(after.results).not.toBe(before.results); + }); + + it("rejects a duplicate guess with null", () => { + const game = submitGuess(daily(), AHRI); + expect(submitGuess(game, AHRI)).toBeNull(); + }); + + it("rejects any guess once the game is over", () => { + const game = submitGuess(daily(), AATROX); + expect(game.isOver).toBe(true); + expect(submitGuess(game, AHRI)).toBeNull(); + }); + + it("wins on the correct champion", () => { + expect(submitGuess(daily(), AATROX)).toMatchObject({ + isWon: true, + isOver: true, + }); + }); + + it("still counts a win made with the final allowed guess", () => { + let game = daily(); + for (const name of ["a", "b", "c", "d", "e"]) { + game = submitGuess(game, other(name)); + } + expect(game.isOver).toBe(false); + + game = submitGuess(game, AATROX); + expect(game).toMatchObject({ isWon: true, isOver: true }); + expect(game.guesses).toHaveLength(6); + }); + + it("ends the game as a loss at maxGuesses wrong guesses", () => { + let game = daily(); + for (const name of ["a", "b", "c", "d", "e", "f"]) { + game = submitGuess(game, other(name)); + } + expect(game).toMatchObject({ isOver: true, isWon: false }); + expect(game.guesses).toHaveLength(6); + }); + + it("never ends on guess count when maxGuesses is 0", () => { + let game = unlimited(); + for (let i = 0; i < 10; i++) { + game = submitGuess(game, other(`c${i}`)); + } + expect(game.isOver).toBe(false); + expect(game.guesses).toHaveLength(10); + }); +}); + +describe("persistence — storage key contract", () => { + it("writes daily state to exactly loldle_daily_", () => { + submitGuess(daily(), AHRI); + expect(localStorage.getItem("loldle_daily_2026-07-25")).not.toBeNull(); + }); + + it("writes unlimited state to exactly loldle_unlimited_current", () => { + submitGuess(unlimited(), AHRI); + expect(localStorage.getItem("loldle_unlimited_current")).not.toBeNull(); + }); + + it("ignores the seed when keying unlimited state", () => { + submitGuess(unlimited({ seed: "unlimited_999" }), AHRI); + expect(localStorage.getItem("loldle_unlimited_current")).not.toBeNull(); + expect(localStorage.getItem("loldle_unlimited_unlimited_999")).toBeNull(); + }); + + it("stores the target name, guesses, results and flags", () => { + submitGuess(daily(), AHRI); + const saved = JSON.parse(localStorage.getItem("loldle_daily_2026-07-25")); + expect(saved).toMatchObject({ + targetName: "Aatrox", + guesses: [AHRI], + isOver: false, + isWon: false, + }); + expect(saved.results).toHaveLength(1); + }); +}); + +describe("persistence — restore on createGame", () => { + it("restores an in-progress game for the same target", () => { + submitGuess(daily(), AHRI); + const restored = daily(); + expect(restored.guesses).toEqual([AHRI]); + expect(restored.results).toHaveLength(1); + }); + + it("restores the over and won flags", () => { + submitGuess(daily(), AATROX); + expect(daily()).toMatchObject({ isOver: true, isWon: true }); + }); + + it("discards saved state when the target changed — the date-rollover case", () => { + submitGuess(daily(), AHRI); + const fresh = createGame({ + target: { ...AHRI, name: "Ahri" }, + compareFn, + maxGuesses: 6, + mode: "daily", + seed: "2026-07-25", + }); + expect(fresh.guesses).toEqual([]); + expect(fresh.isOver).toBe(false); + }); + + it("starts fresh when storage holds malformed JSON", () => { + localStorage.setItem("loldle_daily_2026-07-25", "{not json"); + expect(daily().guesses).toEqual([]); + }); +}); + +describe("clearExpiredCache", () => { + it("removes yesterday's daily entry but keeps today's", () => { + localStorage.setItem("loldle_daily_2026-07-24", "{}"); + localStorage.setItem("loldle_daily_2026-07-25", "{}"); + + clearExpiredCache("2026-07-25"); + + expect(localStorage.getItem("loldle_daily_2026-07-24")).toBeNull(); + expect(localStorage.getItem("loldle_daily_2026-07-25")).not.toBeNull(); + }); + + it("leaves unlimited keys untouched", () => { + localStorage.setItem("loldle_unlimited_current", "{}"); + localStorage.setItem("loldle_unlimited_stats", "{}"); + localStorage.setItem("loldle_unlimited_seed", "s"); + + clearExpiredCache("2026-07-25"); + + expect(localStorage.getItem("loldle_unlimited_current")).not.toBeNull(); + expect(localStorage.getItem("loldle_unlimited_stats")).not.toBeNull(); + expect(localStorage.getItem("loldle_unlimited_seed")).not.toBeNull(); + }); + + it("removes every stale daily entry, not just the first", () => { + localStorage.setItem("loldle_daily_2026-07-20", "{}"); + localStorage.setItem("loldle_daily_2026-07-21", "{}"); + localStorage.setItem("loldle_daily_2026-07-22", "{}"); + + clearExpiredCache("2026-07-25"); + + expect(localStorage.length).toBe(0); + }); + + it("ignores unrelated keys", () => { + localStorage.setItem("unrelated", "keep"); + clearExpiredCache("2026-07-25"); + expect(localStorage.getItem("unrelated")).toBe("keep"); + }); +}); + +describe("unlimited seed", () => { + it("creates and persists a seed on first call", () => { + const seed = getOrCreateUnlimitedSeed(); + expect(seed).toMatch(/^unlimited_/); + expect(localStorage.getItem("loldle_unlimited_seed")).toBe(seed); + }); + + it("returns the same seed on subsequent calls", () => { + expect(getOrCreateUnlimitedSeed()).toBe(getOrCreateUnlimitedSeed()); + }); + + it("clearUnlimitedState drops both the seed and the current game", () => { + submitGuess(unlimited(), AHRI); + getOrCreateUnlimitedSeed(); + + clearUnlimitedState(); + + expect(localStorage.getItem("loldle_unlimited_current")).toBeNull(); + expect(localStorage.getItem("loldle_unlimited_seed")).toBeNull(); + }); + + it("clearUnlimitedState preserves accumulated stats", () => { + localStorage.setItem("loldle_unlimited_stats", '{"gamesPlayed":3}'); + clearUnlimitedState(); + expect(loadUnlimitedStats().gamesPlayed).toBe(3); + }); +}); + +describe("saveUnlimitedStats", () => { + /** + * Play an unlimited game to completion; `won` picks the ending. + * Clears saved state first, mirroring handleNewGame — otherwise createGame + * restores the previous finished game and submitGuess refuses to run. + */ + function finish(won, wrongGuesses = 0) { + clearUnlimitedState(); + let game = unlimited(); + for (let i = 0; i < wrongGuesses; i++) { + game = submitGuess(game, other(`c${i}`)); + } + if (won) game = submitGuess(game, AATROX); + else game = { ...game, isOver: true }; + return game; + } + + it("counts a played game", () => { + saveUnlimitedStats(finish(false)); + expect(loadUnlimitedStats().gamesPlayed).toBe(1); + }); + + it("counts a win and records its guess count", () => { + saveUnlimitedStats(finish(true, 2)); + expect(loadUnlimitedStats()).toMatchObject({ + gamesPlayed: 1, + gamesWon: 1, + guessDistribution: { 3: 1 }, + }); + }); + + it("does not count a win when the game was lost", () => { + saveUnlimitedStats(finish(false)); + expect(loadUnlimitedStats()).toMatchObject({ + gamesPlayed: 1, + gamesWon: 0, + guessDistribution: {}, + }); + }); + + it("accumulates across games", () => { + saveUnlimitedStats(finish(true, 1)); + saveUnlimitedStats(finish(true, 1)); + saveUnlimitedStats(finish(false)); + + expect(loadUnlimitedStats()).toMatchObject({ + gamesPlayed: 3, + gamesWon: 2, + guessDistribution: { 2: 2 }, + }); + }); + + it("stamps lastPlayed", () => { + saveUnlimitedStats(finish(true, 0)); + expect(typeof loadUnlimitedStats().lastPlayed).toBe("number"); + }); + + it("is a no-op for daily mode", () => { + const game = submitGuess(daily(), AATROX); + saveUnlimitedStats(game); + expect(localStorage.getItem("loldle_unlimited_stats")).toBeNull(); + }); + + it("is a no-op for an unfinished game", () => { + saveUnlimitedStats(submitGuess(unlimited(), AHRI)); + expect(localStorage.getItem("loldle_unlimited_stats")).toBeNull(); + }); +}); + +describe("loadUnlimitedStats", () => { + it("returns zeroed stats when nothing is stored", () => { + expect(loadUnlimitedStats()).toEqual({ + gamesPlayed: 0, + gamesWon: 0, + guessDistribution: {}, + lastPlayed: null, + }); + }); + + it("returns zeroed stats when storage holds malformed JSON", () => { + localStorage.setItem("loldle_unlimited_stats", "not json at all"); + expect(loadUnlimitedStats()).toMatchObject({ + gamesPlayed: 0, + gamesWon: 0, + }); + }); +}); diff --git a/web/test/setup-local-storage.js b/web/test/setup-local-storage.js new file mode 100644 index 0000000..0bf8ec5 --- /dev/null +++ b/web/test/setup-local-storage.js @@ -0,0 +1,37 @@ +import { beforeEach, vi } from "vitest"; + +/** + * Minimal spec-shaped localStorage stub. + * `key(i)` and `length` are required: clearExpiredCache iterates by index. + */ +class LocalStorageStub { + #store = new Map(); + + get length() { + return this.#store.size; + } + + key(i) { + return [...this.#store.keys()][i] ?? null; + } + + getItem(k) { + return this.#store.has(k) ? this.#store.get(k) : null; + } + + setItem(k, v) { + this.#store.set(k, String(v)); + } + + removeItem(k) { + this.#store.delete(k); + } + + clear() { + this.#store.clear(); + } +} + +beforeEach(() => { + vi.stubGlobal("localStorage", new LocalStorageStub()); +}); diff --git a/web/vitest.config.js b/web/vitest.config.js new file mode 100644 index 0000000..b4c77f4 --- /dev/null +++ b/web/vitest.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + setupFiles: ["./test/setup-local-storage.js"], + include: ["test/**/*.test.js"], + }, +});