From d30e2131044161069f6d3e4e79cea96c415b26ee Mon Sep 17 00:00:00 2001 From: fightme-cmd <80396935+fightme-cmd@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:12:42 +0200 Subject: [PATCH] Add testable question engine, streak fixes, mobile case grid - New question-generation engine (shuffle-bag preposition selection, per-preposition sentence frames, genitive noun forms) in src/engine.js - Streak fixes: local-date handling, lapse display - Footer copy: "Get in touch" - 2x2 mobile case grid - Vitest suite (60 tests) covering the engine Co-Authored-By: Claude Fable 5 --- .claude/launch.json | 11 + package-lock.json | 1669 ++++++++++++++++++++++++++++++++++++++++++- package.json | 6 +- src/App.jsx | 502 +------------ src/engine.js | 793 ++++++++++++++++++++ src/engine.test.js | 300 ++++++++ 6 files changed, 2795 insertions(+), 486 deletions(-) create mode 100644 .claude/launch.json create mode 100644 src/engine.js create mode 100644 src/engine.test.js diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..7fe06bc --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "beug-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "5199", "--strictPort"], + "port": 5199 + } + ] +} diff --git a/package-lock.json b/package-lock.json index b367195..67eb7be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ }, "devDependencies": { "@vitejs/plugin-react": "^4.3.4", - "vite": "^5.4.11" + "vite": "^5.4.11", + "vitest": "^4.1.10" } }, "node_modules/@babel/code-frame": { @@ -298,6 +299,40 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -587,6 +622,24 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -604,6 +657,24 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -621,6 +692,24 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -739,6 +828,292 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1096,6 +1471,24 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1141,6 +1534,24 @@ "@babel/types": "^7.28.2" } }, + "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/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.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1169,6 +1580,102 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/baseline-browser-mapping": { "version": "2.10.37", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", @@ -1237,6 +1744,16 @@ ], "license": "CC-BY-4.0" }, + "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/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1262,6 +1779,16 @@ } } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.372", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", @@ -1269,6 +1796,13 @@ "dev": true, "license": "ISC" }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1318,6 +1852,44 @@ "node": ">=6" } }, + "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/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1375,6 +1947,267 @@ "node": ">=6" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1397,6 +2230,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1433,6 +2276,27 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "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", @@ -1440,10 +2304,23 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -1504,6 +2381,47 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "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" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", @@ -1568,6 +2486,13 @@ "semver": "bin/semver.js" } }, + "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/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1578,6 +2503,72 @@ "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.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "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.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "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": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1669,6 +2660,676 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@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.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.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 + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "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/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "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 + } + } + }, + "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/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index d21da85..f0a0226 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview --host" + "preview": "vite preview --host", + "test": "vitest run" }, "dependencies": { "react": "^18.3.1", @@ -14,6 +15,7 @@ }, "devDependencies": { "@vitejs/plugin-react": "^4.3.4", - "vite": "^5.4.11" + "vite": "^5.4.11", + "vitest": "^4.1.10" } } diff --git a/src/App.jsx b/src/App.jsx index 9ce7bda..3b56861 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,9 @@ import { useState, useEffect, useCallback, useRef } from "react"; +import { + DEF, INDEF, STRONG, WEAK, PREPS, PREPS_BY_CASE, CL, CASE_C, GENDER_C, + PRONOUNS, POSSESSIVES, POSS_ENDINGS, combinePoss, LEVELS, + wechselExample, createSession, bumpStreak, currentStreak, +} from './engine.js'; // ═══════════════════════════════════════════ // STORAGE (window.storage in Claude, localStorage when deployed) @@ -48,182 +53,6 @@ const Access = { } }; -// ═══════════════════════════════════════════ -// DATA -// ═══════════════════════════════════════════ - -const DEF = { - masculine:{nominative:'der',accusative:'den',dative:'dem',genitive:'des'}, - feminine: {nominative:'die',accusative:'die',dative:'der',genitive:'der'}, - neuter: {nominative:'das',accusative:'das',dative:'dem',genitive:'des'}, - plural: {nominative:'die',accusative:'die',dative:'den',genitive:'der'}, -}; -const INDEF = { - masculine:{nominative:'ein', accusative:'einen',dative:'einem',genitive:'eines'}, - feminine: {nominative:'eine',accusative:'eine', dative:'einer',genitive:'einer'}, - neuter: {nominative:'ein', accusative:'ein', dative:'einem',genitive:'eines'}, - plural: {nominative:'keine',accusative:'keine',dative:'keinen',genitive:'keiner'}, -}; -const STRONG = { - masculine:{nominative:'r',accusative:'n',dative:'m',genitive:'n'}, - feminine: {nominative:'e',accusative:'e',dative:'r',genitive:'r'}, - neuter: {nominative:'s',accusative:'s',dative:'m',genitive:'n'}, - plural: {nominative:'e',accusative:'e',dative:'n',genitive:'r'}, -}; -const WEAK = { - masculine:{nominative:'e',accusative:'n',dative:'n',genitive:'n'}, - feminine: {nominative:'e',accusative:'e',dative:'n',genitive:'n'}, - neuter: {nominative:'e',accusative:'e',dative:'n',genitive:'n'}, - plural: {nominative:'n',accusative:'n',dative:'n',genitive:'n'}, -}; - -const PREPS = { - durch:{c:'accusative',en:'through'}, für:{c:'accusative',en:'for'}, - gegen:{c:'accusative',en:'against'}, ohne:{c:'accusative',en:'without'}, - um:{c:'accusative',en:'around'}, bis:{c:'accusative',en:'until'}, - aus:{c:'dative',en:'out of'}, bei:{c:'dative',en:'at'}, - mit:{c:'dative',en:'with'}, nach:{c:'dative',en:'to/after'}, - seit:{c:'dative',en:'since'}, von:{c:'dative',en:'from'}, - zu:{c:'dative',en:'to'}, außer:{c:'dative',en:'besides'}, - gegenüber:{c:'dative',en:'opposite'}, - trotz:{c:'genitive',en:'despite'}, während:{c:'genitive',en:'during'}, - wegen:{c:'genitive',en:'because of'}, statt:{c:'genitive',en:'instead of'}, - anstatt:{c:'genitive',en:'instead of'}, außerhalb:{c:'genitive',en:'outside'}, - innerhalb:{c:'genitive',en:'inside'}, - an:{c:'wechsel',en:'at/on',m:'to',l:'at'}, - auf:{c:'wechsel',en:'on',m:'onto',l:'on'}, - hinter:{c:'wechsel',en:'behind',m:'behind',l:'behind'}, - in:{c:'wechsel',en:'in/into',m:'into',l:'in'}, - neben:{c:'wechsel',en:'next to',m:'next to',l:'next to'}, - über:{c:'wechsel',en:'over/above',m:'over',l:'above'}, - unter:{c:'wechsel',en:'under',m:'under',l:'under'}, - vor:{c:'wechsel',en:'in front of',m:'in front of',l:'in front of'}, - zwischen:{c:'wechsel',en:'between',m:'between',l:'between'}, -}; -const PREPS_BY_CASE = { - accusative:['durch','für','gegen','ohne','um','bis'], - dative:['aus','bei','mit','nach','seit','von','zu','außer','gegenüber'], - genitive:['trotz','während','wegen','statt','anstatt','außerhalb','innerhalb'], - wechsel:['an','auf','hinter','neben','unter','über','in','vor','zwischen'], -}; -const COMMON_PREPS = ['mit','von','zu','in','an','auf','für','durch','ohne','um','aus','bei','nach','vor','über','wegen']; -const RARE_PREPS = ['gegen','bis','seit','außer','gegenüber','hinter','neben','unter','zwischen','trotz','während','statt','anstatt','außerhalb','innerhalb']; -const ALL_PREPS = [...COMMON_PREPS, ...RARE_PREPS]; - -const WECHSEL_EX = { - an: {noun:'Tür', g:'feminine', en:'door', mot:'to', loc:'at'}, - auf: {noun:'Tisch', g:'masculine', en:'table', mot:'onto', loc:'on'}, - hinter: {noun:'Haus', g:'neuter', en:'house', mot:'behind', loc:'behind'}, - in: {noun:'Park', g:'masculine', en:'park', mot:'into', loc:'in'}, - neben: {noun:'Tisch', g:'masculine', en:'table', mot:'next to', loc:'next to'}, - über: {noun:'Brücke', g:'feminine', en:'bridge', mot:'over', loc:'above'}, - unter: {noun:'Brücke', g:'feminine', en:'bridge', mot:'under', loc:'under'}, - vor: {noun:'Haus', g:'neuter', en:'house', mot:'in front of', loc:'in front of'}, - zwischen: {noun:'Bäume', g:'plural', en:'trees', mot:'between', loc:'between', plDat:'n'}, -}; -function wechselExample(prepKey, isMotion) { - const ex = WECHSEL_EX[prepKey]; - const cas = isMotion ? 'accusative' : 'dative'; - const art = DEF[ex.g][cas]; - let n = ex.noun; - if (ex.g === 'plural' && !isMotion && ex.plDat) n += ex.plDat; - return { de: `${prepKey} ${art} ${n}`, en: `${isMotion ? ex.mot : ex.loc} the ${ex.en}` }; -} - -const NOUNS = [ - {w:'Hund',g:'masculine',en:'dog'},{w:'Mann',g:'masculine',en:'man'}, - {w:'Tisch',g:'masculine',en:'table'},{w:'Park',g:'masculine',en:'park'}, - {w:'Arzt',g:'masculine',en:'doctor'},{w:'Freund',g:'masculine',en:'friend'}, - {w:'Baum',g:'masculine',en:'tree'},{w:'Garten',g:'masculine',en:'garden'}, - {w:'Stuhl',g:'masculine',en:'chair'},{w:'Bruder',g:'masculine',en:'brother'}, - {w:'Lehrer',g:'masculine',en:'teacher'},{w:'Vater',g:'masculine',en:'father'}, - {w:'Frau',g:'feminine',en:'woman'},{w:'Katze',g:'feminine',en:'cat'}, - {w:'Schule',g:'feminine',en:'school'},{w:'Stadt',g:'feminine',en:'city'}, - {w:'Straße',g:'feminine',en:'street'},{w:'Tür',g:'feminine',en:'door'}, - {w:'Schwester',g:'feminine',en:'sister'},{w:'Mutter',g:'feminine',en:'mother'}, - {w:'Tochter',g:'feminine',en:'daughter'},{w:'Musik',g:'feminine',en:'music'}, - {w:'Nacht',g:'feminine',en:'night'},{w:'Woche',g:'feminine',en:'week'}, - {w:'Kind',g:'neuter',en:'child'},{w:'Haus',g:'neuter',en:'house'}, - {w:'Buch',g:'neuter',en:'book'},{w:'Auto',g:'neuter',en:'car'}, - {w:'Mädchen',g:'neuter',en:'girl'},{w:'Fenster',g:'neuter',en:'window'}, - {w:'Hotel',g:'neuter',en:'hotel'},{w:'Museum',g:'neuter',en:'museum'}, - {w:'Büro',g:'neuter',en:'office'},{w:'Restaurant',g:'neuter',en:'restaurant'}, -]; - -const PRONOUNS = [ - {key:'ich', nom:'ich', acc:'mich', dat:'mir', en:'I', enObj:'me', tag:'1st person', num:'sg'}, - {key:'du', nom:'du', acc:'dich', dat:'dir', en:'you', enObj:'you', tag:'2nd informal', num:'sg'}, - {key:'Sie_sg', nom:'Sie', acc:'Sie', dat:'Ihnen', en:'you', enObj:'you', tag:'2nd formal', num:'sg'}, - {key:'er', nom:'er', acc:'ihn', dat:'ihm', en:'he', enObj:'him', tag:'3rd masculine', num:'sg'}, - {key:'sie_sg', nom:'sie', acc:'sie', dat:'ihr', en:'she', enObj:'her', tag:'3rd feminine', num:'sg'}, - {key:'es', nom:'es', acc:'es', dat:'ihm', en:'it', enObj:'it', tag:'3rd neuter', num:'sg'}, - {key:'wir', nom:'wir', acc:'uns', dat:'uns', en:'we', enObj:'us', tag:'1st plural', num:'pl'}, - {key:'ihr', nom:'ihr', acc:'euch', dat:'euch', en:"y'all", enObj:"y'all", tag:'2nd inf plural', num:'pl'}, - {key:'Sie_pl', nom:'Sie', acc:'Sie', dat:'Ihnen', en:'you (formal)', enObj:'you (formal)', tag:'2nd formal pl', num:'pl'}, - {key:'sie_pl', nom:'sie', acc:'sie', dat:'ihnen', en:'they', enObj:'them', tag:'3rd plural', num:'pl'}, -]; - -const POSSESSIVES = [ - {stem:'mein', en:'my'}, - {stem:'dein', en:'your (informal)'}, - {stem:'sein', en:'his / its'}, - {stem:'ihr', en:'her / their'}, - {stem:'unser', en:'our'}, - {stem:'euer', en:"y'all's"}, -]; -const POSS_ENDINGS = { - masculine:{nominative:'', accusative:'en', dative:'em', genitive:'es'}, - feminine: {nominative:'e', accusative:'e', dative:'er', genitive:'er'}, - neuter: {nominative:'', accusative:'', dative:'em', genitive:'es'}, - plural: {nominative:'e', accusative:'e', dative:'en', genitive:'er'}, -}; -const combinePoss = (stem, ending) => (stem === 'euer' && ending) ? 'eur' + ending : stem + ending; - -const CL = {nominative:'Nominativ',accusative:'Akkusativ',dative:'Dativ',genitive:'Genitiv',wechsel:'Wechsel'}; -const CASE_C = {nominative:'#2563eb',accusative:'#dc2626',dative:'#ea580c',genitive:'#16a34a',wechsel:'#7c3aed'}; -const GENDER_C = {masculine:'#16a34a',feminine:'#db2777',neuter:'#2563eb',plural:'#7c3aed'}; - -const VERBS = { - sein: {de:{Er:'ist',Sie:'ist',Wir:'sind',Ich:'bin'}, en:{Er:'is',Sie:'is',Wir:'are',Ich:'am'}}, - stehen: {de:{Er:'steht',Sie:'steht',Wir:'stehen',Ich:'stehe'}, en:{Er:'is standing',Sie:'is standing',Wir:'are standing',Ich:'am standing'}}, - sitzen: {de:{Er:'sitzt',Sie:'sitzt',Wir:'sitzen',Ich:'sitze'}, en:{Er:'is sitting',Sie:'is sitting',Wir:'are sitting',Ich:'am sitting'}}, - warten: {de:{Er:'wartet',Sie:'wartet',Wir:'warten',Ich:'warte'}, en:{Er:'waits',Sie:'waits',Wir:'wait',Ich:'wait'}}, - wohnen: {de:{Er:'wohnt',Sie:'wohnt',Wir:'wohnen',Ich:'wohne'}, en:{Er:'lives',Sie:'lives',Wir:'live',Ich:'live'}}, - gehen: {de:{Er:'geht',Sie:'geht',Wir:'gehen',Ich:'gehe'}, en:{Er:'walks',Sie:'walks',Wir:'walk',Ich:'walk'}}, - laufen: {de:{Er:'läuft',Sie:'läuft',Wir:'laufen',Ich:'laufe'}, en:{Er:'runs',Sie:'runs',Wir:'run',Ich:'run'}}, - fahren: {de:{Er:'fährt',Sie:'fährt',Wir:'fahren',Ich:'fahre'}, en:{Er:'drives',Sie:'drives',Wir:'drive',Ich:'drive'}}, - kommen: {de:{Er:'kommt',Sie:'kommt',Wir:'kommen',Ich:'komme'}, en:{Er:'comes',Sie:'comes',Wir:'come',Ich:'come'}}, - reisen: {de:{Er:'reist',Sie:'reist',Wir:'reisen',Ich:'reise'}, en:{Er:'travels',Sie:'travels',Wir:'travel',Ich:'travel'}}, -}; -const SUBJ_EN = {Er:'He',Sie:'She',Wir:'We',Ich:'I'}; -const MOTION_VS = ['gehen','laufen','fahren','kommen','reisen']; -const LOCATION_VS = ['sein','stehen','sitzen','warten','wohnen']; - -function mkv(stems) { - return { - ich: stems.ich, du: stems.du, - er: stems.er, sie_sg: stems.er, es: stems.er, - wir: stems.wir, sie_pl: stems.wir, Sie_sg: stems.wir, Sie_pl: stems.wir, - ihr: stems.ihr, - }; -} -const VC = { - kommen: {de:mkv({ich:'komme',du:'kommst',er:'kommt',wir:'kommen',ihr:'kommt'}), en:mkv({ich:'come',du:'come',er:'comes',wir:'come',ihr:'come'})}, - sein: {de:mkv({ich:'bin',du:'bist',er:'ist',wir:'sind',ihr:'seid'}), en:mkv({ich:'am',du:'are',er:'is',wir:'are',ihr:'are'})}, - haben: {de:mkv({ich:'habe',du:'hast',er:'hat',wir:'haben',ihr:'habt'}), en:mkv({ich:'have',du:'have',er:'has',wir:'have',ihr:'have'})}, - sehen: {de:mkv({ich:'sehe',du:'siehst',er:'sieht',wir:'sehen',ihr:'seht'}), en:mkv({ich:'see',du:'see',er:'sees',wir:'see',ihr:'see'})}, - kennen: {de:mkv({ich:'kenne',du:'kennst',er:'kennt',wir:'kennen',ihr:'kennt'}), en:mkv({ich:'know',du:'know',er:'knows',wir:'know',ihr:'know'})}, - lieben: {de:mkv({ich:'liebe',du:'liebst',er:'liebt',wir:'lieben',ihr:'liebt'}), en:mkv({ich:'love',du:'love',er:'loves',wir:'love',ihr:'love'})}, - helfen: {de:mkv({ich:'helfe',du:'hilfst',er:'hilft',wir:'helfen',ihr:'helft'}), en:mkv({ich:'help',du:'help',er:'helps',wir:'help',ihr:'help'})}, - geben: {de:mkv({ich:'gebe',du:'gibst',er:'gibt',wir:'geben',ihr:'gebt'}), en:mkv({ich:'give',du:'give',er:'gives',wir:'give',ihr:'give'})}, -}; - -const SG_PRONOUN_KEYS = ['ich','du','er','sie_sg','es']; -const PL_PRONOUN_KEYS = ['wir','ihr','sie_pl']; -const FORMAL_PRONOUN_KEYS = ['Sie_sg','Sie_pl']; -const ALL_PRONOUN_KEYS = PRONOUNS.map(p=>p.key); -const SAFE_SUBJECT_KEYS = ['ich','du','er','wir','ihr']; - const TABS = [ {id:'prep', label:'Prepositions'}, {id:'article', label:'Articles'}, @@ -264,32 +93,6 @@ const STRIPE_PLANS = PAYMENTS_ENABLED ? [ }, ] : []; -const LEVELS = [ - {id:1, tab:'article', name:'Nominativ', desc:'Gender basics: der, die, or das?', cases:['nominative'], preps:[], type:'article', artType:'definite'}, - {id:2, tab:'article', name:'Akkusativ', desc:'durch, für, gegen, ohne, um, bis', cases:['accusative'], preps:PREPS_BY_CASE.accusative, type:'article', artType:'definite'}, - {id:3, tab:'article', name:'Dativ', desc:'mit, von, zu, aus, bei, nach, seit...', cases:['dative'], preps:PREPS_BY_CASE.dative, type:'article', artType:'definite'}, - {id:4, tab:'article', name:'Wechselpräpositionen', desc:'Two-way: motion (Akk) vs location (Dat)', cases:['accusative','dative'], preps:PREPS_BY_CASE.wechsel, type:'article', artType:'definite', twoWay:true}, - {id:4.5, tab:'article', name:'Kontraktionen', desc:'Fused forms in context: ins, am, zum…', cases:['accusative','dative'], type:'chunk', artType:'definite'}, - {id:5, tab:'article', name:'Genitiv', desc:'trotz, während, wegen, statt...', cases:['genitive'], preps:PREPS_BY_CASE.genitive, type:'article', artType:'definite'}, - {id:6, tab:'article', name:'Alles gemischt', desc:'All prepositions, definite articles', cases:['nominative','accusative','dative','genitive'], preps:[...PREPS_BY_CASE.accusative,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.genitive,...PREPS_BY_CASE.wechsel], type:'article', artType:'definite', mixed:true}, - {id:7, tab:'article', name:'ein-Wörter', desc:'Everything with ein/eine/einem...', cases:['nominative','accusative','dative','genitive'], preps:[...PREPS_BY_CASE.accusative,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.genitive,...PREPS_BY_CASE.wechsel], type:'article', artType:'indefinite', mixed:true}, - - {id:8, tab:'prep', name:'Häufige Präpositionen', desc:'16 most common prepositions', preps:COMMON_PREPS, type:'prep'}, - {id:9, tab:'prep', name:'Seltene Präpositionen', desc:'15 less common prepositions', preps:RARE_PREPS, type:'prep'}, - {id:10, tab:'prep', name:'Alle Präpositionen', desc:'All 31 prepositions mixed', preps:ALL_PREPS, type:'prep'}, - {id:18, tab:'prep', name:'Wechsel im Kontext', desc:'Real sentences: Akkusativ or Dativ?', preps:PREPS_BY_CASE.wechsel, type:'wechselContext'}, - {id:19, tab:'prep', name:'Kontraktionen', desc:'Contractions: am, ins, zum, beim…', type:'contraction'}, - - {id:11, tab:'poss', name:'Häufige Possessive', desc:'mein · dein · sein — all cases', possessives:['mein','dein','sein'], cases:['nominative','accusative','dative'], type:'poss'}, - {id:12, tab:'poss', name:'Schwierige Possessive', desc:'ihr · unser · euer — all cases', possessives:['ihr','unser','euer'], cases:['nominative','accusative','dative'], type:'poss'}, - {id:13, tab:'poss', name:'Alle gemischt', desc:'mein, dein, sein, ihr, unser, euer — incl. Genitiv', possessives:['mein','dein','sein','ihr','unser','euer'], cases:['nominative','accusative','dative','genitive'], type:'poss'}, - - {id:14, tab:'pronoun', name:'Singular Pronomen', desc:'ich, du, er, sie, es — all cases', pronouns:SG_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, - {id:15, tab:'pronoun', name:'Plural Pronomen', desc:'wir, ihr, sie — all cases', pronouns:PL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, - {id:16, tab:'pronoun', name:'Sie (formal)', desc:'Formal Sie — singular and plural', pronouns:FORMAL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, - {id:17, tab:'pronoun', name:'Alle gemischt', desc:'All 10 pronouns, all cases', pronouns:ALL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, -]; - // Audio served from /public/audio const AUDIO = { dativSung: '/audio/dativ-blue-danube-sung.m4a', @@ -354,269 +157,6 @@ const LEARN_CARDS = [ }, ]; -// Contractions (preposition + article). pref:true = preferred even in writing. -const CONTRACTIONS = [ - {prep:'an', art:'das', form:'ans', pref:true, c:'wechsel'}, - {prep:'an', art:'dem', form:'am', pref:true, c:'wechsel'}, - {prep:'in', art:'das', form:'ins', pref:true, c:'wechsel'}, - {prep:'in', art:'dem', form:'im', pref:true, c:'wechsel'}, - {prep:'auf',art:'das', form:'aufs',pref:true, c:'wechsel'}, - {prep:'bei',art:'dem', form:'beim',pref:true, c:'dative'}, - {prep:'von',art:'dem', form:'vom', pref:true, c:'dative'}, - {prep:'zu', art:'dem', form:'zum', pref:true, c:'dative'}, - {prep:'zu', art:'der', form:'zur', pref:true, c:'dative'}, - {prep:'durch',art:'das',form:'durchs',pref:false,c:'accusative'}, - {prep:'für',art:'das', form:'fürs',pref:false, c:'accusative'}, - {prep:'um', art:'das', form:'ums', pref:false, c:'accusative'}, - {prep:'über',art:'das',form:'übers',pref:false,c:'wechsel'}, - {prep:'unter',art:'das',form:'unters',pref:false,c:'wechsel'}, - {prep:'vor',art:'das', form:'vors',pref:false, c:'wechsel'}, - {prep:'hinter',art:'das',form:'hinters',pref:false,c:'wechsel'}, -]; - -// Only PREFERRED contractions drive the in-context tests, so the contracted -// form is unambiguously the best answer (the two-word form is the distractor). -const CONTRACTABLE = {}; -CONTRACTIONS.filter(c=>c.pref).forEach(c => { (CONTRACTABLE[c.prep] = CONTRACTABLE[c.prep] || {})[c.art] = c.form; }); -// Prepositions that get the fused "chunk" question in mixed/dedicated lessons. -const CHUNK_PREPS = new Set(Object.keys(CONTRACTABLE)); // an, in, auf, bei, von, zu -const WECHSEL_SET = new Set(PREPS_BY_CASE.wechsel); - -const pick = a => a[Math.floor(Math.random()*a.length)]; -const cap = s => s.charAt(0).toUpperCase() + s.slice(1); - -// ═══════════════════════════════════════════ -// QUESTION GENERATORS -// ═══════════════════════════════════════════ - -function genArticleQ(level) { - const lv = LEVELS.find(l=>l.id===level); - const noun = pick(NOUNS); - const tbl = lv.artType === 'indefinite' ? INDEF : DEF; - let cas, prepKey, tw = null, verb; - if (lv.id === 1) { cas='nominative'; prepKey=null; } - else if (lv.twoWay) { prepKey=pick(lv.preps); tw=Math.random()>0.5; cas=tw?'accusative':'dative'; } - else if (lv.mixed) { - if (Math.random()<0.12) { cas='nominative'; prepKey=null; } - else { prepKey=pick(lv.preps); const pc=PREPS[prepKey].c; - if (pc==='wechsel') { tw=Math.random()>0.5; cas=tw?'accusative':'dative'; } else cas=pc; } - } else { prepKey=pick(lv.preps); cas=lv.cases[0]; } - - // Mixed definite lessons: contraction-capable prep → fused "chunk" question. - if (lv.mixed && lv.artType === 'definite' && prepKey && CHUNK_PREPS.has(prepKey)) { - return makeChunkQ(prepKey, noun, cas, tw); - } - - const ans = tbl[noun.g][cas]; - const _pool = lv.artType === 'indefinite' ? ['ein','eine','einen','einem','eines','einer','keine','keinen','keiner'] : ['der','die','das','den','dem','des']; - const _rem = _pool.filter(a => a !== ans); - const _picked = []; - while (_picked.length < 3 && _rem.length) _picked.push(_rem.splice(Math.floor(Math.random()*_rem.length), 1)[0]); - const choices = [ans, ..._picked].sort(() => Math.random() - 0.5); - let sentence, translation, rule; - if (!prepKey) { - sentence = `_____ ${noun.w} ist hier.`; - translation = `The ${noun.en} is here.`; - rule = 'Subject of the sentence → Nominativ'; - } else { - const subj = pick(['Er','Sie','Wir']); - if (tw===true) verb=pick(MOTION_VS); - else if (tw===false) verb=pick(LOCATION_VS); - else if (cas==='genitive') verb=pick(['warten','wohnen']); - else verb=pick(MOTION_VS); - sentence = `${subj} ${VERBS[verb].de[subj]} ${prepKey} _____ ${noun.w}.`; - let pEn = PREPS[prepKey].en; - if (PREPS[prepKey].c==='wechsel') pEn = tw ? PREPS[prepKey].m : PREPS[prepKey].l; - translation = `${SUBJ_EN[subj]} ${VERBS[verb].en[subj]} ${pEn} the ${noun.en}.`; - rule = PREPS[prepKey].c==='wechsel' - ? (tw ? `„${prepKey}" + motion (wohin?) → Akkusativ` : `„${prepKey}" + location (wo?) → Dativ`) - : `„${prepKey}" → always ${CL[cas]}`; - } - return {type:'article', noun, cas, prepKey, ans, sentence, translation, rule, tw, artType:lv.artType, choices}; -} - -function genPrepQ(level) { - const lv = LEVELS.find(l=>l.id===level); - const prepKey = pick(lv.preps); - const p = PREPS[prepKey]; - const exampleNoun = pick(NOUNS); - const exCase = p.c==='wechsel' ? 'dative' : p.c; - return {type:'prep', prepKey, prepEn:p.en, ans:p.c, - example:`${prepKey} ${DEF[exampleNoun.g][exCase]} ${exampleNoun.w}`, exampleEn:exampleNoun.en}; -} - -function genWechselContextQ(level) { - const lv = LEVELS.find(l=>l.id===level); - const prepKey = pick(lv.preps); - const isMotion = Math.random() > 0.5; - const cas = isMotion ? 'accusative' : 'dative'; - const subj = pick(['Er','Sie','Wir','Ich']); - const verb = isMotion ? pick(MOTION_VS) : pick(LOCATION_VS); - const ex = WECHSEL_EX[prepKey]; - const art = DEF[ex.g][cas]; - let nounStr = ex.noun; - if (ex.g === 'plural' && !isMotion && ex.plDat) nounStr += ex.plDat; - const sentence = `${subj} ${VERBS[verb].de[subj]} ${prepKey} ${art} ${nounStr}.`; - const pEn = isMotion ? ex.mot : ex.loc; - const translation = `${SUBJ_EN[subj]} ${VERBS[verb].en[subj]} ${pEn} the ${ex.en}.`; - return {type:'wechselContext', prepKey, ans:cas, isMotion, verb, sentence, translation, nounEn: ex.en}; -} - -function genPossQ(level) { - const lv = LEVELS.find(l=>l.id===level); - const noun = pick(NOUNS); - const stem = pick(lv.possessives); - const possMeaning = POSSESSIVES.find(p=>p.stem===stem).en; - let cas, prepKey=null, tw=null, verb; - const wantPrep = Math.random() > 0.5; - if (wantPrep) { - const allPreps = lv.cases.includes('genitive') - ? [...PREPS_BY_CASE.accusative,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.wechsel,...PREPS_BY_CASE.genitive] - : [...PREPS_BY_CASE.accusative,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.wechsel]; - prepKey = pick(allPreps); - const pc = PREPS[prepKey].c; - if (pc==='wechsel') { tw=Math.random()>0.5; cas=tw?'accusative':'dative'; } else cas=pc; - if (!lv.cases.includes(cas)) { cas=pick(lv.cases); prepKey=null; tw=null; } - } else { - cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0]; - } - const ans = combinePoss(stem, POSS_ENDINGS[noun.g][cas]); - let sentence, translation, rule; - if (!prepKey) { - if (cas==='nominative') { - sentence = `_____ ${noun.w} ist hier.`; - translation = `${cap(possMeaning)} ${noun.en} is here.`; - } else if (cas==='accusative') { - const v = pick([['liebe','love'],['sehe','see'],['kenne','know'],['höre','hear']]); - sentence = `Ich ${v[0]} _____ ${noun.w}.`; - translation = `I ${v[1]} ${possMeaning} ${noun.en}.`; - } else if (cas==='dative') { - sentence = `Ich helfe _____ ${noun.w}.`; - translation = `I help ${possMeaning} ${noun.en}.`; - } - } else { - const subj='Ich'; - if (tw===true) verb='gehen'; - else if (tw===false) verb='sein'; - else if (PREPS[prepKey].c==='genitive') verb='warten'; - else verb='gehen'; - sentence = `${subj} ${VERBS[verb].de[subj]} ${prepKey} _____ ${noun.w}.`; - let pEn = PREPS[prepKey].en; - if (PREPS[prepKey].c==='wechsel') pEn = tw ? PREPS[prepKey].m : PREPS[prepKey].l; - translation = `${SUBJ_EN[subj]} ${VERBS[verb].en[subj]} ${pEn} ${possMeaning} ${noun.en}.`; - } - rule = `${stem}- + ${CL[cas]} + ${noun.g} → ${ans}`; - return {type:'poss', noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey, tw}; -} - -function genPronounQ(level) { - const lv = LEVELS.find(l=>l.id===level); - const pronKey = pick(lv.pronouns); - const pron = PRONOUNS.find(p=>p.key===pronKey); - const cas = pick(lv.cases); - const rawAns = pron[{nominative:'nom',accusative:'acc',dative:'dat'}[cas]]; - - let sentence, translation, ans; - if (cas === 'nominative') { - const tpl = pick([ - {v:'kommen', dePart:' aus Berlin', enPart:' from Berlin'}, - {v:'sein', dePart:' müde', enPart:' tired'}, - {v:'haben', dePart:' einen Hund', enPart:' a dog'}, - ]); - const vDe = VC[tpl.v].de[pronKey]; - const vEn = VC[tpl.v].en[pronKey]; - sentence = `_____ ${vDe}${tpl.dePart}.`; - translation = `${cap(pron.en)} ${vEn}${tpl.enPart}.`; - ans = cap(rawAns); - } else { - const subjCandidates = SAFE_SUBJECT_KEYS.filter(k => k !== pronKey); - const subjKey = pick(subjCandidates); - const subj = PRONOUNS.find(p=>p.key===subjKey); - let tpl; - if (cas === 'accusative') { - tpl = pick([{v:'sehen'}, {v:'kennen'}, {v:'lieben'}]); - } else { - tpl = pick([{v:'helfen'}, {v:'geben', dePost:' das Buch', enPost:' the book'}]); - } - const sDe = VC[tpl.v].de[subjKey]; - const sEn = VC[tpl.v].en[subjKey]; - const dePost = tpl.dePost || ''; - const enPost = tpl.enPost || ''; - sentence = `${cap(subj.nom)} ${sDe} _____${dePost}.`; - translation = `${cap(subj.en)} ${sEn} ${pron.enObj}${enPost}.`; - ans = rawAns; - } - return {type:'pronoun', pronKey, pron, cas, ans, sentence, translation}; -} - -function genContractionQ() { - const c = pick(CONTRACTIONS); - return {type:'contraction', prep:c.prep, art:c.art, ans:c.form, pref:c.pref, cas:c.c}; -} - -// Build the 4 choices for a fused prep+article question: always a BLEND of -// contracted and two-word forms, so the shape never reveals whether it contracts. -function buildChunkChoices(prepKey, article) { - const map = CONTRACTABLE[prepKey] || {}; - const contracts = !!map[article]; - const correct = contracts ? map[article] : `${prepKey} ${article}`; - const cand = new Set(); - Object.values(map).forEach(f => cand.add(f)); // contraction shapes - Object.keys(map).forEach(a => cand.add(`${prepKey} ${a}`)); // their two-word forms - cand.add(`${prepKey} ${article}`); // two-word of the real article - cand.delete(correct); - const pool = [...cand].sort(() => Math.random() - 0.5); - const choices = [correct, ...pool.slice(0, 3)]; - const filler = ['das','dem','der','die','den'].map(a => `${prepKey} ${a}`); - for (let i = 0; i < filler.length && choices.length < 4; i++) { - if (!choices.includes(filler[i])) choices.push(filler[i]); - } - return { correct, contracts, choices: choices.slice(0, 4).sort(() => Math.random() - 0.5) }; -} - -function pickChunkVerb(prepKey, tw) { - if (PREPS[prepKey].c === 'wechsel') return tw ? pick(MOTION_VS) : pick(LOCATION_VS); - if (prepKey === 'bei') return pick(['sein','wohnen','warten']); - if (prepKey === 'von') return 'kommen'; - if (prepKey === 'zu') return pick(['gehen','fahren','kommen']); - return pick(['gehen','fahren']); -} - -function makeChunkQ(prepKey, noun, cas, tw) { - const article = DEF[noun.g][cas]; - const { correct, contracts, choices } = buildChunkChoices(prepKey, article); - const subj = pick(['Er','Sie','Wir','Ich']); - const verb = pickChunkVerb(prepKey, tw); - const sentence = `${subj} ${VERBS[verb].de[subj]} _____ ${noun.w}.`; - let pEn = PREPS[prepKey].en; - if (PREPS[prepKey].c === 'wechsel') pEn = tw ? PREPS[prepKey].m : PREPS[prepKey].l; - const translation = `${SUBJ_EN[subj]} ${VERBS[verb].en[subj]} ${pEn} the ${noun.en}.`; - const pref = contracts ? (CONTRACTIONS.find(x => x.prep === prepKey && x.art === article)?.pref ?? true) : null; - return { type:'chunk', prepKey, noun, cas, ans:correct, choices, sentence, translation, tw, contracts, pref }; -} - -// Dedicated "Kontraktionen" level: answer ALWAYS contracts (preferred forms only). -function genContractionContextQ() { - const c = pick(CONTRACTIONS.filter(x => x.pref && CHUNK_PREPS.has(x.prep))); - const wechsel = WECHSEL_SET.has(c.prep); - let cas, gender, tw = null; - if (c.art === 'das') { cas='accusative'; gender='neuter'; tw = wechsel ? true : null; } - else if (c.art === 'dem') { cas='dative'; gender=pick(['masculine','neuter']); tw = wechsel ? false : null; } - else { cas='dative'; gender='feminine'; tw = wechsel ? false : null; } - const noun = pick(NOUNS.filter(n => n.g === gender)); - return makeChunkQ(c.prep, noun, cas, tw); -} - -function gen(level) { - const lv = LEVELS.find(l=>l.id===level); - if (lv.type==='chunk') return genContractionContextQ(); - if (lv.type==='contraction') return genContractionQ(); - if (lv.type==='prep') return genPrepQ(level); - if (lv.type==='wechselContext') return genWechselContextQ(level); - if (lv.type==='poss') return genPossQ(level); - if (lv.type==='pronoun') return genPronounQ(level); - return genArticleQ(level); -} // ═══════════════════════════════════════════ // MAIN APP @@ -643,6 +183,7 @@ export default function App() { const [loaded, setLoaded] = useState(false); const [shake, setShake] = useState(false); const iref = useRef(null); + const sessionRef = useRef(null); useEffect(()=>{(async()=>{ const data = await Storage.get(); @@ -682,7 +223,8 @@ export default function App() { const startLv = id => { if (!unlocked(id)) { setPayMsg(''); setScr('paywall'); return; } - setLv(id); setRes([]); setInp(''); setRev(false); setQ(gen(id)); setScr('practice'); + sessionRef.current = createSession(id); + setLv(id); setRes([]); setInp(''); setRev(false); setQ(sessionRef.current.next()); setScr('practice'); }; const openCheckout = plan => { @@ -705,23 +247,20 @@ export default function App() { try { iref.current?.blur(); } catch(e){} setOk(correct); setRev(true); const nr = [...res, correct]; setRes(nr); - const today = new Date().toISOString().slice(0,10); - const ns = {...st}; + let ns = {...st}; ns.ta = (ns.ta||0) + 1; if (correct) ns.tc = (ns.tc||0) + 1; if (!ns.sc[lv]) ns.sc[lv] = {c:0,t:0}; ns.sc[lv] = {c: ns.sc[lv].c + (correct?1:0), t: ns.sc[lv].t + 1}; - if (ns.ld !== today) { - const ld = ns.ld ? new Date(ns.ld) : null; - ns.streak = ld && Math.round((new Date(today)-ld)/864e5)===1 ? (ns.streak||0)+1 : 1; - ns.ld = today; - } + ns = bumpStreak(ns); save(ns); }, [inp, q, res, st, lv, save]); const doNext = useCallback(() => { if (res.length >= ROUND) { setScr('summary'); return; } - setInp(''); setRev(false); setOk(false); setQ(gen(lv)); + setInp(''); setRev(false); setOk(false); + if (!sessionRef.current) sessionRef.current = createSession(lv); + setQ(sessionRef.current.next()); }, [res, lv]); submitRef.current = doSubmit; @@ -772,7 +311,7 @@ export default function App() {
{scr === 'practice' && } {PAYMENTS_ENABLED && hasPaidAccess && Unlocked} - 🔥 {st.streak||0} + 🔥 {currentStreak(st)}
@@ -997,7 +536,7 @@ export default function App() {
{q.prepKey}
{q.prepEn}
{!rev ? ( -
+
{[ ['accusative','Akkusativ','1'],['dative','Dativ','2'], ['genitive','Genitiv','3'],['wechsel','Wechsel','4'], @@ -1158,12 +697,15 @@ function HomeScreen({ onStart }) { function FeatureRequestFooter() { return (
-
Have a feature request? Get in touch. Always looking to improve the tool.
+
+
Have a feature request?
+
Always looking to improve the tool.
+
- Write an email + Get in touch
); @@ -1310,7 +852,7 @@ function PrepFeedback({ q, inp, ok }) {
{q.ans !== 'wechsel' && (
- Example: {q.example} ({q.prepEn} the {q.exampleEn}) + Example: {q.example} ({q.exampleFull ? q.exampleEn : `${q.prepEn} the ${q.exampleEn}`})
)} {q.ans === 'wechsel' && ( @@ -1333,7 +875,7 @@ function WechselContextFeedback({ q, inp, ok }) { „{q.verb}" {q.isMotion ? 'implies motion (wohin?)' : 'implies location (wo?)'} → {CL[q.ans]}
- Wechselpräpositionen take Akkusativ with motion verbs (gehen, fahren, laufen, kommen) and Dativ with location verbs (sein, stehen, sitzen, warten). + Wechselpräpositionen take Akkusativ when something moves toward a destination — wohin? (gehen, fahren, legen, stellen) — and Dativ when it stays put — wo? (sein, stehen, sitzen, liegen).
); diff --git a/src/engine.js b/src/engine.js new file mode 100644 index 0000000..4ceca88 --- /dev/null +++ b/src/engine.js @@ -0,0 +1,793 @@ +// ═══════════════════════════════════════════ +// ENGINE — data, question generators, streaks +// Pure JS, no React. Everything here is unit-testable. +// ═══════════════════════════════════════════ + +export const DEF = { + masculine:{nominative:'der',accusative:'den',dative:'dem',genitive:'des'}, + feminine: {nominative:'die',accusative:'die',dative:'der',genitive:'der'}, + neuter: {nominative:'das',accusative:'das',dative:'dem',genitive:'des'}, + plural: {nominative:'die',accusative:'die',dative:'den',genitive:'der'}, +}; +export const INDEF = { + masculine:{nominative:'ein', accusative:'einen',dative:'einem',genitive:'eines'}, + feminine: {nominative:'eine',accusative:'eine', dative:'einer',genitive:'einer'}, + neuter: {nominative:'ein', accusative:'ein', dative:'einem',genitive:'eines'}, + plural: {nominative:'keine',accusative:'keine',dative:'keinen',genitive:'keiner'}, +}; +export const STRONG = { + masculine:{nominative:'r',accusative:'n',dative:'m',genitive:'n'}, + feminine: {nominative:'e',accusative:'e',dative:'r',genitive:'r'}, + neuter: {nominative:'s',accusative:'s',dative:'m',genitive:'n'}, + plural: {nominative:'e',accusative:'e',dative:'n',genitive:'r'}, +}; +export const WEAK = { + masculine:{nominative:'e',accusative:'n',dative:'n',genitive:'n'}, + feminine: {nominative:'e',accusative:'e',dative:'n',genitive:'n'}, + neuter: {nominative:'e',accusative:'e',dative:'n',genitive:'n'}, + plural: {nominative:'n',accusative:'n',dative:'n',genitive:'n'}, +}; + +export const PREPS = { + durch:{c:'accusative',en:'through'}, für:{c:'accusative',en:'for'}, + gegen:{c:'accusative',en:'against'}, ohne:{c:'accusative',en:'without'}, + um:{c:'accusative',en:'around'}, + bis:{c:'accusative',en:'until', ex:{de:'bis nächsten Montag', en:'until next Monday'}}, + aus:{c:'dative',en:'out of'}, bei:{c:'dative',en:'at'}, + mit:{c:'dative',en:'with'}, nach:{c:'dative',en:'to/after'}, + seit:{c:'dative',en:'since', ex:{de:'seit einer Woche', en:'for/since a week'}}, + von:{c:'dative',en:'from'}, + zu:{c:'dative',en:'to'}, außer:{c:'dative',en:'except'}, + gegenüber:{c:'dative',en:'opposite'}, + trotz:{c:'genitive',en:'despite'}, während:{c:'genitive',en:'during'}, + wegen:{c:'genitive',en:'because of'}, statt:{c:'genitive',en:'instead of'}, + anstatt:{c:'genitive',en:'instead of'}, außerhalb:{c:'genitive',en:'outside'}, + innerhalb:{c:'genitive',en:'inside'}, + an:{c:'wechsel',en:'at/on',m:'to',l:'at'}, + auf:{c:'wechsel',en:'on',m:'onto',l:'on'}, + hinter:{c:'wechsel',en:'behind',m:'behind',l:'behind'}, + in:{c:'wechsel',en:'in/into',m:'into',l:'in'}, + neben:{c:'wechsel',en:'next to',m:'next to',l:'next to'}, + über:{c:'wechsel',en:'over/above',m:'over',l:'above'}, + unter:{c:'wechsel',en:'under',m:'under',l:'under'}, + vor:{c:'wechsel',en:'in front of',m:'in front of',l:'in front of'}, + zwischen:{c:'wechsel',en:'between',m:'between',l:'between'}, +}; +export const PREPS_BY_CASE = { + accusative:['durch','für','gegen','ohne','um','bis'], + dative:['aus','bei','mit','nach','seit','von','zu','außer','gegenüber'], + genitive:['trotz','während','wegen','statt','anstatt','außerhalb','innerhalb'], + wechsel:['an','auf','hinter','neben','unter','über','in','vor','zwischen'], +}; +export const COMMON_PREPS = ['mit','von','zu','in','an','auf','für','durch','ohne','um','aus','bei','nach','vor','über','wegen']; +export const RARE_PREPS = ['gegen','bis','seit','außer','gegenüber','hinter','neben','unter','zwischen','trotz','während','statt','anstatt','außerhalb','innerhalb']; +export const ALL_PREPS = [...COMMON_PREPS, ...RARE_PREPS]; + +// "bis" almost never takes a bare definite article in real German +// (bis nächsten Montag / bis zum Ende) — so it stays in the case-ID +// quizzes but is excluded from fill-the-article sentence levels. +const SENTENCE_ACC_PREPS = ['durch','für','gegen','ohne','um']; + +export const WECHSEL_EX = { + an: {noun:'Tür', g:'feminine', en:'door', mot:'to', loc:'at'}, + auf: {noun:'Tisch', g:'masculine', en:'table', mot:'onto', loc:'on'}, + hinter: {noun:'Haus', g:'neuter', en:'house', mot:'behind', loc:'behind'}, + in: {noun:'Park', g:'masculine', en:'park', mot:'into', loc:'in'}, + neben: {noun:'Tisch', g:'masculine', en:'table', mot:'next to', loc:'next to'}, + über: {noun:'Brücke', g:'feminine', en:'bridge', mot:'over', loc:'above'}, + unter: {noun:'Brücke', g:'feminine', en:'bridge', mot:'under', loc:'under'}, + vor: {noun:'Haus', g:'neuter', en:'house', mot:'in front of', loc:'in front of'}, + zwischen: {noun:'Bäume', g:'plural', en:'trees', mot:'between', loc:'between', plDat:'n'}, +}; +export function wechselExample(prepKey, isMotion) { + const ex = WECHSEL_EX[prepKey]; + const cas = isMotion ? 'accusative' : 'dative'; + const art = DEF[ex.g][cas]; + let n = ex.noun; + if (ex.g === 'plural' && !isMotion && ex.plDat) n += ex.plDat; + return { de: `${prepKey} ${art} ${n}`, en: `${isMotion ? ex.mot : ex.loc} the ${ex.en}` }; +} + +// gen: genitive noun form for masculine/neuter (des Mannes, des Hauses) +export const NOUNS = [ + {w:'Hund',g:'masculine',en:'dog',gen:'Hundes'}, + {w:'Mann',g:'masculine',en:'man',gen:'Mannes'}, + {w:'Tisch',g:'masculine',en:'table',gen:'Tisches'}, + {w:'Park',g:'masculine',en:'park',gen:'Parks'}, + {w:'Arzt',g:'masculine',en:'doctor',gen:'Arztes'}, + {w:'Freund',g:'masculine',en:'friend',gen:'Freundes'}, + {w:'Baum',g:'masculine',en:'tree',gen:'Baumes'}, + {w:'Garten',g:'masculine',en:'garden',gen:'Gartens'}, + {w:'Stuhl',g:'masculine',en:'chair',gen:'Stuhls'}, + {w:'Bruder',g:'masculine',en:'brother',gen:'Bruders'}, + {w:'Lehrer',g:'masculine',en:'teacher',gen:'Lehrers'}, + {w:'Vater',g:'masculine',en:'father',gen:'Vaters'}, + {w:'Zug',g:'masculine',en:'train',gen:'Zuges'}, + {w:'Regen',g:'masculine',en:'rain',gen:'Regens'}, + {w:'Frau',g:'feminine',en:'woman'}, + {w:'Katze',g:'feminine',en:'cat'}, + {w:'Schule',g:'feminine',en:'school'}, + {w:'Stadt',g:'feminine',en:'city'}, + {w:'Straße',g:'feminine',en:'street'}, + {w:'Tür',g:'feminine',en:'door'}, + {w:'Schwester',g:'feminine',en:'sister'}, + {w:'Mutter',g:'feminine',en:'mother'}, + {w:'Tochter',g:'feminine',en:'daughter'}, + {w:'Musik',g:'feminine',en:'music'}, + {w:'Nacht',g:'feminine',en:'night'}, + {w:'Woche',g:'feminine',en:'week'}, + {w:'Arbeit',g:'feminine',en:'work'}, + {w:'Brücke',g:'feminine',en:'bridge'}, + {w:'Kind',g:'neuter',en:'child',gen:'Kindes'}, + {w:'Haus',g:'neuter',en:'house',gen:'Hauses'}, + {w:'Buch',g:'neuter',en:'book',gen:'Buches'}, + {w:'Auto',g:'neuter',en:'car',gen:'Autos'}, + {w:'Mädchen',g:'neuter',en:'girl',gen:'Mädchens'}, + {w:'Fenster',g:'neuter',en:'window',gen:'Fensters'}, + {w:'Hotel',g:'neuter',en:'hotel',gen:'Hotels'}, + {w:'Museum',g:'neuter',en:'museum',gen:'Museums'}, + {w:'Büro',g:'neuter',en:'office',gen:'Büros'}, + {w:'Restaurant',g:'neuter',en:'restaurant',gen:'Restaurants'}, + {w:'Wetter',g:'neuter',en:'weather',gen:'Wetters'}, + {w:'Dach',g:'neuter',en:'roof',gen:'Daches'}, + {w:'Konzert',g:'neuter',en:'concert',gen:'Konzerts'}, +]; +const NOUN_BY_NAME = Object.fromEntries(NOUNS.map(n => [n.w, n])); +export const nounForm = (noun, cas) => + cas === 'genitive' && noun.gen ? noun.gen : noun.w; + +const PERSONS = ['Mann','Arzt','Freund','Bruder','Lehrer','Vater','Frau','Schwester','Mutter','Tochter','Kind','Mädchen']; +const PLACES_IN = ['Park','Garten','Schule','Stadt','Haus','Hotel','Museum','Büro','Restaurant']; + +// Nouns that read naturally with a possessive ("my …") +const POSSESSABLE = new Set([ + ...PERSONS, 'Hund','Katze','Haus','Garten','Auto','Buch','Tisch','Stuhl', + 'Büro','Schule','Stadt','Fenster','Tür','Musik', +]); + +export const PRONOUNS = [ + {key:'ich', nom:'ich', acc:'mich', dat:'mir', en:'I', enObj:'me', tag:'1st person', num:'sg'}, + {key:'du', nom:'du', acc:'dich', dat:'dir', en:'you', enObj:'you', tag:'2nd informal', num:'sg'}, + {key:'Sie_sg', nom:'Sie', acc:'Sie', dat:'Ihnen', en:'you', enObj:'you', tag:'2nd formal', num:'sg'}, + {key:'er', nom:'er', acc:'ihn', dat:'ihm', en:'he', enObj:'him', tag:'3rd masculine', num:'sg'}, + {key:'sie_sg', nom:'sie', acc:'sie', dat:'ihr', en:'she', enObj:'her', tag:'3rd feminine', num:'sg'}, + {key:'es', nom:'es', acc:'es', dat:'ihm', en:'it', enObj:'it', tag:'3rd neuter', num:'sg'}, + {key:'wir', nom:'wir', acc:'uns', dat:'uns', en:'we', enObj:'us', tag:'1st plural', num:'pl'}, + {key:'ihr', nom:'ihr', acc:'euch', dat:'euch', en:"y'all", enObj:"y'all", tag:'2nd inf plural', num:'pl'}, + {key:'Sie_pl', nom:'Sie', acc:'Sie', dat:'Ihnen', en:'you (formal)', enObj:'you (formal)', tag:'2nd formal pl', num:'pl'}, + {key:'sie_pl', nom:'sie', acc:'sie', dat:'ihnen', en:'they', enObj:'them', tag:'3rd plural', num:'pl'}, +]; + +export const POSSESSIVES = [ + {stem:'mein', en:'my'}, + {stem:'dein', en:'your (informal)'}, + {stem:'sein', en:'his / its'}, + {stem:'ihr', en:'her / their'}, + {stem:'unser', en:'our'}, + {stem:'euer', en:"y'all's"}, +]; +export const POSS_ENDINGS = { + masculine:{nominative:'', accusative:'en', dative:'em', genitive:'es'}, + feminine: {nominative:'e', accusative:'e', dative:'er', genitive:'er'}, + neuter: {nominative:'', accusative:'', dative:'em', genitive:'es'}, + plural: {nominative:'e', accusative:'e', dative:'en', genitive:'er'}, +}; +export const combinePoss = (stem, ending) => (stem === 'euer' && ending) ? 'eur' + ending : stem + ending; + +export const CL = {nominative:'Nominativ',accusative:'Akkusativ',dative:'Dativ',genitive:'Genitiv',wechsel:'Wechsel'}; +export const CASE_C = {nominative:'#2563eb',accusative:'#dc2626',dative:'#ea580c',genitive:'#16a34a',wechsel:'#7c3aed'}; +export const GENDER_C = {masculine:'#16a34a',feminine:'#db2777',neuter:'#2563eb',plural:'#7c3aed'}; + +export const VERBS = { + sein: {de:{Er:'ist',Sie:'ist',Wir:'sind',Ich:'bin'}, en:{Er:'is',Sie:'is',Wir:'are',Ich:'am'}}, + stehen: {de:{Er:'steht',Sie:'steht',Wir:'stehen',Ich:'stehe'}, en:{Er:'is standing',Sie:'is standing',Wir:'are standing',Ich:'am standing'}}, + sitzen: {de:{Er:'sitzt',Sie:'sitzt',Wir:'sitzen',Ich:'sitze'}, en:{Er:'is sitting',Sie:'is sitting',Wir:'are sitting',Ich:'am sitting'}}, + liegen: {de:{Er:'liegt',Sie:'liegt',Wir:'liegen',Ich:'liege'}, en:{Er:'is lying',Sie:'is lying',Wir:'are lying',Ich:'am lying'}}, + warten: {de:{Er:'wartet',Sie:'wartet',Wir:'warten',Ich:'warte'},en:{Er:'waits',Sie:'waits',Wir:'wait',Ich:'wait'}}, + wohnen: {de:{Er:'wohnt',Sie:'wohnt',Wir:'wohnen',Ich:'wohne'}, en:{Er:'lives',Sie:'lives',Wir:'live',Ich:'live'}}, + gehen: {de:{Er:'geht',Sie:'geht',Wir:'gehen',Ich:'gehe'}, en:{Er:'walks',Sie:'walks',Wir:'walk',Ich:'walk'}}, + laufen: {de:{Er:'läuft',Sie:'läuft',Wir:'laufen',Ich:'laufe'}, en:{Er:'runs',Sie:'runs',Wir:'run',Ich:'run'}}, + fahren: {de:{Er:'fährt',Sie:'fährt',Wir:'fahren',Ich:'fahre'}, en:{Er:'drives',Sie:'drives',Wir:'drive',Ich:'drive'}}, + kommen: {de:{Er:'kommt',Sie:'kommt',Wir:'kommen',Ich:'komme'}, en:{Er:'comes',Sie:'comes',Wir:'come',Ich:'come'}}, + reisen: {de:{Er:'reist',Sie:'reist',Wir:'reisen',Ich:'reise'}, en:{Er:'travels',Sie:'travels',Wir:'travel',Ich:'travel'}}, + kaufen: {de:{Er:'kauft',Sie:'kauft',Wir:'kaufen',Ich:'kaufe'}, en:{Er:'buys',Sie:'buys',Wir:'buy',Ich:'buy'}}, + sprechen:{de:{Er:'spricht',Sie:'spricht',Wir:'sprechen',Ich:'spreche'}, en:{Er:'talks',Sie:'talks',Wir:'talk',Ich:'talk'}}, + spielen: {de:{Er:'spielt',Sie:'spielt',Wir:'spielen',Ich:'spiele'}, en:{Er:'plays',Sie:'plays',Wir:'play',Ich:'play'}}, + schlafen:{de:{Er:'schläft',Sie:'schläft',Wir:'schlafen',Ich:'schlafe'}, en:{Er:'sleeps',Sie:'sleeps',Wir:'sleep',Ich:'sleep'}}, + bleiben: {de:{Er:'bleibt',Sie:'bleibt',Wir:'bleiben',Ich:'bleibe'}, en:{Er:'stays',Sie:'stays',Wir:'stay',Ich:'stay'}}, + nehmen: {de:{Er:'nimmt',Sie:'nimmt',Wir:'nehmen',Ich:'nehme'}, en:{Er:'takes',Sie:'takes',Wir:'take',Ich:'take'}}, + arbeiten:{de:{Er:'arbeitet',Sie:'arbeitet',Wir:'arbeiten',Ich:'arbeite'}, en:{Er:'works',Sie:'works',Wir:'work',Ich:'work'}}, + springen:{de:{Er:'springt',Sie:'springt',Wir:'springen',Ich:'springe'}, en:{Er:'jumps',Sie:'jumps',Wir:'jump',Ich:'jump'}}, + hängen: {de:{Er:'hängt',Sie:'hängt',Wir:'hängen',Ich:'hänge'}, en:{Er:'hangs',Sie:'hangs',Wir:'hang',Ich:'hang'}}, + legen: {de:{Er:'legt',Sie:'legt',Wir:'legen',Ich:'lege'}, en:{Er:'puts',Sie:'puts',Wir:'put',Ich:'put'}}, + stellen: {de:{Er:'stellt',Sie:'stellt',Wir:'stellen',Ich:'stelle'}, en:{Er:'puts',Sie:'puts',Wir:'put',Ich:'put'}}, +}; +export const SUBJ_EN = {Er:'He',Sie:'She',Wir:'We',Ich:'I'}; + +function mkv(stems) { + return { + ich: stems.ich, du: stems.du, + er: stems.er, sie_sg: stems.er, es: stems.er, + wir: stems.wir, sie_pl: stems.wir, Sie_sg: stems.wir, Sie_pl: stems.wir, + ihr: stems.ihr, + }; +} +export const VC = { + kommen: {de:mkv({ich:'komme',du:'kommst',er:'kommt',wir:'kommen',ihr:'kommt'}), en:mkv({ich:'come',du:'come',er:'comes',wir:'come',ihr:'come'})}, + sein: {de:mkv({ich:'bin',du:'bist',er:'ist',wir:'sind',ihr:'seid'}), en:mkv({ich:'am',du:'are',er:'is',wir:'are',ihr:'are'})}, + haben: {de:mkv({ich:'habe',du:'hast',er:'hat',wir:'haben',ihr:'habt'}), en:mkv({ich:'have',du:'have',er:'has',wir:'have',ihr:'have'})}, + sehen: {de:mkv({ich:'sehe',du:'siehst',er:'sieht',wir:'sehen',ihr:'seht'}), en:mkv({ich:'see',du:'see',er:'sees',wir:'see',ihr:'see'})}, + kennen: {de:mkv({ich:'kenne',du:'kennst',er:'kennt',wir:'kennen',ihr:'kennt'}), en:mkv({ich:'know',du:'know',er:'knows',wir:'know',ihr:'know'})}, + lieben: {de:mkv({ich:'liebe',du:'liebst',er:'liebt',wir:'lieben',ihr:'liebt'}), en:mkv({ich:'love',du:'love',er:'loves',wir:'love',ihr:'love'})}, + helfen: {de:mkv({ich:'helfe',du:'hilfst',er:'hilft',wir:'helfen',ihr:'helft'}), en:mkv({ich:'help',du:'help',er:'helps',wir:'help',ihr:'help'})}, + geben: {de:mkv({ich:'gebe',du:'gibst',er:'gibt',wir:'geben',ihr:'gebt'}), en:mkv({ich:'give',du:'give',er:'gives',wir:'give',ihr:'give'})}, +}; + +export const SG_PRONOUN_KEYS = ['ich','du','er','sie_sg','es']; +export const PL_PRONOUN_KEYS = ['wir','ihr','sie_pl']; +export const FORMAL_PRONOUN_KEYS = ['Sie_sg','Sie_pl']; +export const ALL_PRONOUN_KEYS = PRONOUNS.map(p=>p.key); +const SAFE_SUBJECT_KEYS = ['ich','du','er','wir','ihr']; + +export const LEVELS = [ + {id:1, tab:'article', name:'Nominativ', desc:'Gender basics: der, die, or das?', cases:['nominative'], preps:[], type:'article', artType:'definite'}, + {id:2, tab:'article', name:'Akkusativ', desc:'durch, für, gegen, ohne, um', cases:['accusative'], preps:SENTENCE_ACC_PREPS, type:'article', artType:'definite'}, + {id:3, tab:'article', name:'Dativ', desc:'mit, von, zu, aus, bei, nach, seit...', cases:['dative'], preps:PREPS_BY_CASE.dative, type:'article', artType:'definite'}, + {id:4, tab:'article', name:'Wechselpräpositionen', desc:'Two-way: motion (Akk) vs location (Dat)', cases:['accusative','dative'], preps:PREPS_BY_CASE.wechsel, type:'article', artType:'definite', twoWay:true}, + {id:4.5, tab:'article', name:'Kontraktionen', desc:'Fused forms in context: ins, am, zum…', cases:['accusative','dative'], type:'chunk', artType:'definite'}, + {id:5, tab:'article', name:'Genitiv', desc:'trotz, während, wegen, statt...', cases:['genitive'], preps:PREPS_BY_CASE.genitive, type:'article', artType:'definite'}, + {id:6, tab:'article', name:'Alles gemischt', desc:'All prepositions, definite articles', cases:['nominative','accusative','dative','genitive'], preps:[...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.genitive,...PREPS_BY_CASE.wechsel], type:'article', artType:'definite', mixed:true}, + {id:7, tab:'article', name:'ein-Wörter', desc:'Everything with ein/eine/einem...', cases:['nominative','accusative','dative','genitive'], preps:[...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.genitive,...PREPS_BY_CASE.wechsel], type:'article', artType:'indefinite', mixed:true}, + + {id:8, tab:'prep', name:'Häufige Präpositionen', desc:'16 most common prepositions', preps:COMMON_PREPS, type:'prep'}, + {id:9, tab:'prep', name:'Seltene Präpositionen', desc:'15 less common prepositions', preps:RARE_PREPS, type:'prep'}, + {id:10, tab:'prep', name:'Alle Präpositionen', desc:'All 31 prepositions mixed', preps:ALL_PREPS, type:'prep'}, + {id:18, tab:'prep', name:'Wechsel im Kontext', desc:'Real sentences: Akkusativ or Dativ?', preps:PREPS_BY_CASE.wechsel, type:'wechselContext'}, + {id:19, tab:'prep', name:'Kontraktionen', desc:'Contractions: am, ins, zum, beim…', type:'contraction'}, + + {id:11, tab:'poss', name:'Häufige Possessive', desc:'mein · dein · sein — all cases', possessives:['mein','dein','sein'], cases:['nominative','accusative','dative'], type:'poss'}, + {id:12, tab:'poss', name:'Schwierige Possessive', desc:'ihr · unser · euer — all cases', possessives:['ihr','unser','euer'], cases:['nominative','accusative','dative'], type:'poss'}, + {id:13, tab:'poss', name:'Alle gemischt', desc:'mein, dein, sein, ihr, unser, euer — incl. Genitiv', possessives:['mein','dein','sein','ihr','unser','euer'], cases:['nominative','accusative','dative','genitive'], type:'poss'}, + + {id:14, tab:'pronoun', name:'Singular Pronomen', desc:'ich, du, er, sie, es — all cases', pronouns:SG_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, + {id:15, tab:'pronoun', name:'Plural Pronomen', desc:'wir, ihr, sie — all cases', pronouns:PL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, + {id:16, tab:'pronoun', name:'Sie (formal)', desc:'Formal Sie — singular and plural', pronouns:FORMAL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, + {id:17, tab:'pronoun', name:'Alle gemischt', desc:'All 10 pronouns, all cases', pronouns:ALL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, +]; + +// Contractions (preposition + article). pref:true = preferred even in writing. +export const CONTRACTIONS = [ + {prep:'an', art:'das', form:'ans', pref:true, c:'wechsel'}, + {prep:'an', art:'dem', form:'am', pref:true, c:'wechsel'}, + {prep:'in', art:'das', form:'ins', pref:true, c:'wechsel'}, + {prep:'in', art:'dem', form:'im', pref:true, c:'wechsel'}, + {prep:'auf',art:'das', form:'aufs',pref:true, c:'wechsel'}, + {prep:'bei',art:'dem', form:'beim',pref:true, c:'dative'}, + {prep:'von',art:'dem', form:'vom', pref:true, c:'dative'}, + {prep:'zu', art:'dem', form:'zum', pref:true, c:'dative'}, + {prep:'zu', art:'der', form:'zur', pref:true, c:'dative'}, + {prep:'durch',art:'das',form:'durchs',pref:false,c:'accusative'}, + {prep:'für',art:'das', form:'fürs',pref:false, c:'accusative'}, + {prep:'um', art:'das', form:'ums', pref:false, c:'accusative'}, + {prep:'über',art:'das',form:'übers',pref:false,c:'wechsel'}, + {prep:'unter',art:'das',form:'unters',pref:false,c:'wechsel'}, + {prep:'vor',art:'das', form:'vors',pref:false, c:'wechsel'}, + {prep:'hinter',art:'das',form:'hinters',pref:false,c:'wechsel'}, +]; + +export const CONTRACTABLE = {}; +CONTRACTIONS.filter(c=>c.pref).forEach(c => { (CONTRACTABLE[c.prep] = CONTRACTABLE[c.prep] || {})[c.art] = c.form; }); +export const CHUNK_PREPS = new Set(Object.keys(CONTRACTABLE)); // an, in, auf, bei, von, zu +export const WECHSEL_SET = new Set(PREPS_BY_CASE.wechsel); + +// ═══════════════════════════════════════════ +// SENTENCE FRAMES +// Per-preposition verb/noun pairings so generated sentences are +// semantically plausible ("Er kauft das Geschenk für den Vater", +// not "Wir reisen für den Hund"). +// +// frame: {verbs, nouns, mid?, fixedSubj?, enPrep?, enDet?, post?, enVerb?, poss?} +// mid — object between verb and preposition ({de,en}) +// fixedSubj — literal subject instead of Er/Sie/Wir/Ich +// ({de, en, vkey?, enVkey?} — conjugation keys, default 'Er') +// enPrep — English preposition override (default PREPS[p].en or m/l) +// enDet — English determiner (default 'the'; '' drops it) +// post — trailing text after the noun ({accusative, dative, genitive, en}) +// enVerb — English verb override map (Er/Sie/Wir/Ich) +// poss — false: skip this frame for possessive questions +// Wechsel preps use {motion:[...], location:[...]} instead of a flat list. +// ═══════════════════════════════════════════ +export const PREP_FRAMES = { + durch: [{verbs:['gehen','laufen','fahren'], nouns:['Park','Stadt','Straße','Garten','Tür']}], + für: [{verbs:['kaufen'], mid:{de:'das Geschenk',en:'the gift'}, nouns:[...PERSONS,'Hund','Katze']}], + gegen: [{verbs:['fahren'], nouns:['Baum','Haus','Tür','Fenster','Auto'], enPrep:'into'}], + ohne: [{verbs:['kommen','reisen','gehen'], nouns:[...PERSONS,'Hund','Katze','Buch','Auto']}], + um: [{verbs:['laufen','gehen'], nouns:['Tisch','Haus','Baum','Park','Garten']}], + aus: [{verbs:['kommen','laufen'], nouns:['Haus','Schule','Büro','Museum','Hotel','Stadt','Garten','Restaurant']}], + bei: [{verbs:['wohnen','sein','warten'], nouns:PERSONS}], + mit: [ + {verbs:['sprechen'], nouns:PERSONS}, + {verbs:['spielen'], nouns:['Hund','Katze','Kind']}, + {verbs:['fahren'], nouns:['Auto','Zug']}, + ], + nach: [{verbs:['kommen'], nouns:['Schule','Arbeit','Nacht','Woche'], enPrep:'after', enDet:'', poss:false}], + seit: [{verbs:['wohnen'], mid:{de:'hier',en:'here'}, nouns:['Nacht','Woche'], enPrep:'since', enDet:'that', + enVerb:{Er:'has lived',Sie:'has lived',Wir:'have lived',Ich:'have lived'}, poss:false}], + von: [{verbs:['kommen'], nouns:[...PERSONS,'Arbeit','Schule','Büro']}], + zu: [{verbs:['gehen','fahren'], nouns:[...PERSONS,'Schule','Park','Museum','Büro','Restaurant','Hotel']}], + außer: [{fixedSubj:{de:'Alle',en:'Everyone',vkey:'Wir',enVkey:'Er'}, verbs:['kommen'], nouns:PERSONS, enPrep:'except'}], + gegenüber: [{verbs:['wohnen'], nouns:['Schule','Park','Museum','Hotel','Restaurant']}], + trotz: [{verbs:['schlafen','arbeiten'], nouns:['Musik','Regen','Wetter'], poss:false}], + während: [ + {verbs:['schlafen'], nouns:['Nacht','Konzert'], poss:false}, + {verbs:['arbeiten'], nouns:['Woche','Nacht'], poss:false}, + ], + wegen: [{verbs:['bleiben'], nouns:['Regen','Wetter','Kind','Hund','Katze']}], + statt: [{verbs:['nehmen'], mid:{de:'den Bus',en:'the bus'}, nouns:['Auto','Zug']}], + anstatt: [{verbs:['nehmen'], mid:{de:'den Bus',en:'the bus'}, nouns:['Auto','Zug']}], + außerhalb: [{verbs:['wohnen','arbeiten'], nouns:['Stadt']}], + innerhalb: [{verbs:['wohnen','arbeiten'], nouns:['Stadt']}], + + an: { + motion: [{verbs:['gehen','laufen'], nouns:['Tür','Fenster']}], + location:[{verbs:['stehen','warten','sitzen'], nouns:['Tür','Fenster']}], + }, + auf: { + motion: [ + {verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tisch','Stuhl']}, + {verbs:['gehen'], nouns:['Straße']}, + {verbs:['springen'], fixedSubj:{de:'Die Katze',en:'The cat'}, nouns:['Tisch','Dach']}, + ], + location: [ + {verbs:['sitzen'], nouns:['Stuhl']}, + {verbs:['liegen'], fixedSubj:{de:'Das Buch',en:'The book'}, nouns:['Tisch']}, + {verbs:['stehen','warten'], nouns:['Straße']}, + ], + }, + hinter: { + motion: [{verbs:['gehen','laufen'], nouns:['Haus','Baum','Garten','Schule']}], + location:[{verbs:['stehen','warten'], nouns:['Haus','Baum','Tür','Schule']}], + }, + in: { + motion: [{verbs:['gehen','fahren','kommen','laufen'], nouns:PLACES_IN}], + location: [ + {verbs:['wohnen'], nouns:['Stadt','Haus','Hotel']}, + {verbs:['sein','warten','sitzen','arbeiten'], nouns:PLACES_IN}, + ], + }, + neben: { + motion: [ + {verbs:['stellen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch','Stuhl','Tür']}, + {verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Stuhl','Tisch']}, + ], + location: [ + {verbs:['stehen','sitzen'], nouns:['Tisch','Tür','Fenster']}, + {verbs:['wohnen'], nouns:['Schule','Park','Museum']}, + ], + }, + über: { + motion: [ + {verbs:['gehen','laufen'], nouns:['Straße','Brücke']}, + {verbs:['hängen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch']}, + ], + location:[{verbs:['hängen'], fixedSubj:{de:'Die Lampe',en:'The lamp'}, nouns:['Tisch']}], + }, + unter: { + motion: [ + {verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tisch','Stuhl']}, + {verbs:['laufen'], fixedSubj:{de:'Die Katze',en:'The cat'}, nouns:['Tisch','Baum']}, + ], + location: [ + {verbs:['sitzen','stehen','schlafen'], nouns:['Baum','Brücke']}, + {verbs:['schlafen'], fixedSubj:{de:'Die Katze',en:'The cat'}, nouns:['Tisch','Stuhl']}, + ], + }, + vor: { + motion: [{verbs:['fahren','gehen'], nouns:['Haus','Tür','Schule','Hotel']}], + location:[{verbs:['stehen','warten'], nouns:['Tür','Haus','Schule','Hotel','Museum','Restaurant']}], + }, + zwischen: { + motion: [{verbs:['stellen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch','Stuhl'], + post:{accusative:' und das Fenster', dative:' und dem Fenster', en:' and the window'}}], + location: [{verbs:['stehen','sitzen'], nouns:['Tisch','Stuhl'], + post:{accusative:' und das Fenster', dative:' und dem Fenster', en:' and the window'}}], + }, +}; + +// ═══════════════════════════════════════════ +// RANDOM HELPERS + SESSION STATE +// ═══════════════════════════════════════════ + +export const pick = a => a[Math.floor(Math.random()*a.length)]; +export const cap = s => s.charAt(0).toUpperCase() + s.slice(1); + +const pickAvoid = (arr, avoid) => { + if (!arr.length) return undefined; + if (arr.length === 1) return arr[0]; + const filtered = arr.filter(x => x !== avoid); + return pick(filtered.length ? filtered : arr); +}; + +const shuffle = a => { + const r = [...a]; + for (let i = r.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [r[i], r[j]] = [r[j], r[i]]; + } + return r; +}; + +// Shuffle-bag: deal every item from the pool once before any repeats, +// and never let a refill produce the same item twice in a row. +function drawBag(state, key, pool, avoid) { + let bag = state.bags[key]; + if (!bag || !bag.length) { + bag = shuffle(pool); + if (bag.length > 1 && bag[bag.length - 1] === avoid) { + bag.pop(); + bag.unshift(avoid); + } + state.bags[key] = bag; + } + return bag.pop(); +} + +export function newState() { + return { bags:{}, last:{ prep:null, verb:null, noun:null, subj:null } }; +} + +// A practice session: one shuffle-bag + anti-repeat memory per round. +export function createSession(levelId) { + const state = newState(); + return { next: () => gen(levelId, state), state }; +} + +// ═══════════════════════════════════════════ +// FRAME REALIZATION +// ═══════════════════════════════════════════ + +function framesFor(prepKey, tw) { + const f = PREP_FRAMES[prepKey]; + if (!f) return []; + if (Array.isArray(f)) return f; + return tw ? f.motion : f.location; +} + +// Resolve a frame into concrete sentence parts. Returns null if the +// preposition has no usable frame for the given constraints. +function realizeFrame(prepKey, cas, tw, state, { genderFilter, possOnly } = {}) { + let frames = framesFor(prepKey, tw); + if (possOnly) { + frames = frames + .filter(f => f.poss !== false) + .map(f => ({...f, nouns: f.nouns.filter(n => POSSESSABLE.has(n))})) + .filter(f => f.nouns.length); + } + if (genderFilter) { + frames = frames + .map(f => ({...f, nouns: f.nouns.filter(n => genderFilter.includes(NOUN_BY_NAME[n].g))})) + .filter(f => f.nouns.length); + } + if (!frames.length) return null; + + // Prefer frames that can avoid repeating the previous question's verb. + const fresh = frames.filter(f => f.verbs.some(v => v !== state.last.verb)); + const frame = pick(fresh.length ? fresh : frames); + const nounName = pickAvoid(frame.nouns, state.last.noun); + const noun = NOUN_BY_NAME[nounName]; + const verbKey = pickAvoid(frame.verbs, state.last.verb); + state.last.noun = nounName; + state.last.verb = verbKey; + + let subjDe, subjEn, deVkey, enVkey; + if (frame.fixedSubj) { + subjDe = frame.fixedSubj.de; + subjEn = frame.fixedSubj.en; + deVkey = frame.fixedSubj.vkey || 'Er'; + enVkey = frame.fixedSubj.enVkey || deVkey; + } else { + const subj = pickAvoid(['Er','Sie','Wir','Ich'], state.last.subj); + state.last.subj = subj; + subjDe = subj; subjEn = SUBJ_EN[subj]; + deVkey = subj; enVkey = subj; + } + + const p = PREPS[prepKey]; + let enPrep = frame.enPrep || p.en; + if (p.c === 'wechsel' && !frame.enPrep) enPrep = tw ? p.m : p.l; + + const verbDe = VERBS[verbKey].de[deVkey]; + const verbEn = (frame.enVerb || VERBS[verbKey].en)[enVkey]; + const midDe = frame.mid ? ` ${frame.mid.de}` : ''; + const midEn = frame.mid ? ` ${frame.mid.en}` : ''; + const postDe = frame.post ? (frame.post[cas] || '') : ''; + const postEn = frame.post ? frame.post.en : ''; + const enDet = frame.enDet !== undefined ? frame.enDet : 'the'; + + return { noun, verbKey, subjDe, subjEn, verbDe, verbEn, midDe, midEn, enPrep, postDe, postEn, enDet }; +} + +const enNoun = (r, det) => `${det}${det ? ' ' : ''}${r.noun.en}`; + +// ═══════════════════════════════════════════ +// QUESTION GENERATORS +// ═══════════════════════════════════════════ + +const NOM_TEMPLATES = [ + {de:'ist hier', en:'is here'}, + {de:'ist da', en:'is there'}, + {de:'ist groß', en:'is big'}, + {de:'ist klein', en:'is small'}, +]; + +function articleChoices(ans, artType) { + const pool = artType === 'indefinite' + ? ['ein','eine','einen','einem','eines','einer','keine','keinen','keiner'] + : ['der','die','das','den','dem','des']; + const rem = pool.filter(a => a !== ans); + const picked = []; + while (picked.length < 3 && rem.length) picked.push(rem.splice(Math.floor(Math.random()*rem.length), 1)[0]); + return [ans, ...picked].sort(() => Math.random() - 0.5); +} + +function nominativeArticleQ(lv, state) { + const noun = NOUN_BY_NAME[drawBag(state, 'noun', NOUNS.map(n=>n.w), state.last.noun)]; + state.last.noun = noun.w; + const tbl = lv.artType === 'indefinite' ? INDEF : DEF; + const ans = tbl[noun.g].nominative; + const t = pick(NOM_TEMPLATES); + return { + type:'article', noun, cas:'nominative', prepKey:null, ans, + sentence:`_____ ${noun.w} ${t.de}.`, + translation:`The ${noun.en} ${t.en}.`, + rule:'Subject of the sentence → Nominativ', + tw:null, artType:lv.artType, choices:articleChoices(ans, lv.artType), + }; +} + +export function genArticleQ(level, state = newState()) { + const lv = LEVELS.find(l=>l.id===level); + if (lv.id === 1) return nominativeArticleQ(lv, state); + if (lv.mixed && Math.random() < 0.12) return nominativeArticleQ(lv, state); + + const prepKey = drawBag(state, 'prep', lv.preps, state.last.prep); + state.last.prep = prepKey; + const pc = PREPS[prepKey].c; + let tw = null, cas; + if (pc === 'wechsel') { tw = Math.random() > 0.5; cas = tw ? 'accusative' : 'dative'; } + else cas = pc; + + // Mixed definite lessons: contraction-capable prep → fused "chunk" question. + if (lv.mixed && lv.artType === 'definite' && CHUNK_PREPS.has(prepKey)) { + return makeChunkQ(prepKey, cas, tw, state); + } + + const r = realizeFrame(prepKey, cas, tw, state); + const tbl = lv.artType === 'indefinite' ? INDEF : DEF; + const ans = tbl[r.noun.g][cas]; + const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} _____ ${nounForm(r.noun, cas)}${r.postDe}.`; + const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`; + const rule = pc === 'wechsel' + ? (tw ? `„${prepKey}" + motion (wohin?) → Akkusativ` : `„${prepKey}" + location (wo?) → Dativ`) + : `„${prepKey}" → always ${CL[cas]}`; + return {type:'article', noun:r.noun, cas, prepKey, ans, sentence, translation, rule, tw, artType:lv.artType, choices:articleChoices(ans, lv.artType)}; +} + +export function genPrepQ(level, state = newState()) { + const lv = LEVELS.find(l=>l.id===level); + const prepKey = drawBag(state, 'prep', lv.preps, state.last.prep); + state.last.prep = prepKey; + const p = PREPS[prepKey]; + if (p.ex) { + return {type:'prep', prepKey, prepEn:p.en, ans:p.c, example:p.ex.de, exampleEn:p.ex.en, exampleFull:true}; + } + const exCase = p.c === 'wechsel' ? 'dative' : p.c; + const frames = framesFor(prepKey, false); + const nounName = frames.length ? pick(pick(frames).nouns) : pick(NOUNS).w; + const noun = NOUN_BY_NAME[nounName]; + return {type:'prep', prepKey, prepEn:p.en, ans:p.c, + example:`${prepKey} ${DEF[noun.g][exCase]} ${nounForm(noun, exCase)}`, exampleEn:noun.en}; +} + +export function genWechselContextQ(level, state = newState()) { + const lv = LEVELS.find(l=>l.id===level); + const prepKey = drawBag(state, 'prep', lv.preps, state.last.prep); + state.last.prep = prepKey; + const isMotion = Math.random() > 0.5; + const cas = isMotion ? 'accusative' : 'dative'; + const r = realizeFrame(prepKey, cas, isMotion, state); + const art = DEF[r.noun.g][cas]; + const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} ${art} ${nounForm(r.noun, cas)}${r.postDe}.`; + const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`; + return {type:'wechselContext', prepKey, ans:cas, isMotion, verb:r.verbKey, sentence, translation, nounEn:r.noun.en}; +} + +const POSS_ACC_TEMPLATES = [ + {de:'sehe', en:'see', nouns:[...PERSONS,'Hund','Katze','Haus','Garten','Auto']}, + {de:'kenne', en:'know', nouns:PERSONS}, + {de:'liebe', en:'love', nouns:[...PERSONS,'Hund','Katze','Stadt','Auto']}, + {de:'höre', en:'hear', nouns:['Musik','Hund','Katze','Kind']}, +]; + +export function genPossQ(level, state = newState()) { + const lv = LEVELS.find(l=>l.id===level); + const stem = pickAvoid(lv.possessives, state.last.stem); + state.last.stem = stem; + const possMeaning = POSSESSIVES.find(p=>p.stem===stem).en; + const wantPrep = Math.random() > 0.5; + + if (wantPrep) { + const pool = lv.cases.includes('genitive') + ? [...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.wechsel,...PREPS_BY_CASE.genitive] + : [...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.wechsel]; + // Draw until we find a prep with a possessive-friendly frame. + for (let i = 0; i < pool.length; i++) { + const prepKey = drawBag(state, 'prep', pool, state.last.prep); + const pc = PREPS[prepKey].c; + let tw = null, cas; + if (pc === 'wechsel') { tw = Math.random() > 0.5; cas = tw ? 'accusative' : 'dative'; } else cas = pc; + if (!lv.cases.includes(cas)) continue; + const r = realizeFrame(prepKey, cas, tw, state, { possOnly:true }); + if (!r) continue; + state.last.prep = prepKey; + const ans = combinePoss(stem, POSS_ENDINGS[r.noun.g][cas]); + const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} _____ ${nounForm(r.noun, cas)}${r.postDe}.`; + const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${possMeaning} ${r.noun.en}${r.postEn}.`; + const rule = `${stem}- + ${CL[cas]} + ${r.noun.g} → ${ans}`; + return {type:'poss', noun:r.noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey, tw}; + } + } + + // No preposition: simple subject/object sentence. + const cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0]; + let noun, sentence, translation; + if (cas === 'nominative') { + noun = NOUN_BY_NAME[pickAvoid([...POSSESSABLE], state.last.noun)]; + const t = pick(NOM_TEMPLATES); + sentence = `_____ ${noun.w} ${t.de}.`; + translation = `${cap(possMeaning)} ${noun.en} ${t.en}.`; + } else if (cas === 'accusative') { + const t = pick(POSS_ACC_TEMPLATES); + noun = NOUN_BY_NAME[pickAvoid(t.nouns, state.last.noun)]; + sentence = `Ich ${t.de} _____ ${noun.w}.`; + translation = `I ${t.en} ${possMeaning} ${noun.en}.`; + } else { + noun = NOUN_BY_NAME[pickAvoid(PERSONS, state.last.noun)]; + sentence = `Ich helfe _____ ${noun.w}.`; + translation = `I help ${possMeaning} ${noun.en}.`; + } + state.last.noun = noun.w; + const ans = combinePoss(stem, POSS_ENDINGS[noun.g][cas]); + const rule = `${stem}- + ${CL[cas]} + ${noun.g} → ${ans}`; + return {type:'poss', noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey:null, tw:null}; +} + +export function genPronounQ(level, state = newState()) { + const lv = LEVELS.find(l=>l.id===level); + const pronKey = drawBag(state, 'pron', lv.pronouns, state.last.pron); + state.last.pron = pronKey; + const pron = PRONOUNS.find(p=>p.key===pronKey); + const cas = pick(lv.cases); + const rawAns = pron[{nominative:'nom',accusative:'acc',dative:'dat'}[cas]]; + + let sentence, translation, ans; + if (cas === 'nominative') { + const tpl = pick([ + {v:'kommen', dePart:' aus Berlin', enPart:' from Berlin'}, + {v:'sein', dePart:' müde', enPart:' tired'}, + {v:'haben', dePart:' einen Hund', enPart:' a dog'}, + ]); + const vDe = VC[tpl.v].de[pronKey]; + const vEn = VC[tpl.v].en[pronKey]; + sentence = `_____ ${vDe}${tpl.dePart}.`; + translation = `${cap(pron.en)} ${vEn}${tpl.enPart}.`; + ans = cap(rawAns); + } else { + const subjCandidates = SAFE_SUBJECT_KEYS.filter(k => k !== pronKey); + const subjKey = pick(subjCandidates); + const subj = PRONOUNS.find(p=>p.key===subjKey); + let tpl; + if (cas === 'accusative') { + tpl = pick([{v:'sehen'}, {v:'kennen'}, {v:'lieben'}]); + } else { + tpl = pick([{v:'helfen'}, {v:'geben', dePost:' das Buch', enPost:' the book'}]); + } + const sDe = VC[tpl.v].de[subjKey]; + const sEn = VC[tpl.v].en[subjKey]; + const dePost = tpl.dePost || ''; + const enPost = tpl.enPost || ''; + sentence = `${cap(subj.nom)} ${sDe} _____${dePost}.`; + translation = `${cap(subj.en)} ${sEn} ${pron.enObj}${enPost}.`; + ans = rawAns; + } + return {type:'pronoun', pronKey, pron, cas, ans, sentence, translation}; +} + +export function genContractionQ(state = newState()) { + const form = drawBag(state, 'contr', CONTRACTIONS.map(c=>c.form), state.last.contr); + state.last.contr = form; + const c = CONTRACTIONS.find(x => x.form === form); + return {type:'contraction', prep:c.prep, art:c.art, ans:c.form, pref:c.pref, cas:c.c}; +} + +// Build the 4 choices for a fused prep+article question: always a BLEND of +// contracted and two-word forms, so the shape never reveals whether it contracts. +export function buildChunkChoices(prepKey, article) { + const map = CONTRACTABLE[prepKey] || {}; + const contracts = !!map[article]; + const correct = contracts ? map[article] : `${prepKey} ${article}`; + const cand = new Set(); + Object.values(map).forEach(f => cand.add(f)); // contraction shapes + Object.keys(map).forEach(a => cand.add(`${prepKey} ${a}`)); // their two-word forms + cand.add(`${prepKey} ${article}`); // two-word of the real article + cand.delete(correct); + const pool = [...cand].sort(() => Math.random() - 0.5); + const choices = [correct, ...pool.slice(0, 3)]; + const filler = ['das','dem','der','die','den'].map(a => `${prepKey} ${a}`); + for (let i = 0; i < filler.length && choices.length < 4; i++) { + if (!choices.includes(filler[i])) choices.push(filler[i]); + } + return { correct, contracts, choices: choices.slice(0, 4).sort(() => Math.random() - 0.5) }; +} + +export function makeChunkQ(prepKey, cas, tw, state = newState(), genderFilter) { + const r = realizeFrame(prepKey, cas, tw, state, { genderFilter }); + const article = DEF[r.noun.g][cas]; + const { correct, contracts, choices } = buildChunkChoices(prepKey, article); + const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} _____ ${nounForm(r.noun, cas)}${r.postDe}.`; + const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`; + const pref = contracts ? (CONTRACTIONS.find(x => x.prep === prepKey && x.art === article)?.pref ?? true) : null; + return { type:'chunk', prepKey, noun:r.noun, cas, ans:correct, choices, sentence, translation, tw, contracts, pref }; +} + +// Dedicated "Kontraktionen" level: answer ALWAYS contracts (preferred forms only). +export function genContractionContextQ(state = newState()) { + const prefForms = CONTRACTIONS.filter(x => x.pref && CHUNK_PREPS.has(x.prep)); + const form = drawBag(state, 'contr', prefForms.map(c=>c.form), state.last.contr); + state.last.contr = form; + const c = prefForms.find(x => x.form === form); + const wechsel = WECHSEL_SET.has(c.prep); + let cas, genders, tw = null; + if (c.art === 'das') { cas='accusative'; genders=['neuter']; tw = wechsel ? true : null; } + else if (c.art === 'dem') { cas='dative'; genders=['masculine','neuter']; tw = wechsel ? false : null; } + else { cas='dative'; genders=['feminine']; tw = wechsel ? false : null; } + return makeChunkQ(c.prep, cas, tw, state, genders); +} + +export function gen(level, state = newState()) { + const lv = LEVELS.find(l=>l.id===level); + if (lv.type==='chunk') return genContractionContextQ(state); + if (lv.type==='contraction') return genContractionQ(state); + if (lv.type==='prep') return genPrepQ(level, state); + if (lv.type==='wechselContext') return genWechselContextQ(level, state); + if (lv.type==='poss') return genPossQ(level, state); + if (lv.type==='pronoun') return genPronounQ(level, state); + return genArticleQ(level, state); +} + +// ═══════════════════════════════════════════ +// STREAKS +// Local dates (not UTC) so a late-evening session counts as the +// day the user actually experienced. +// ═══════════════════════════════════════════ + +export function localDateStr(d = new Date()) { + const p = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`; +} +const dayDiff = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / 864e5); + +// Called on every answered question. Idempotent within a day. +export function bumpStreak(st, today = localDateStr()) { + if (st.ld === today) return st; + const streak = st.ld && dayDiff(st.ld, today) === 1 ? (st.streak || 0) + 1 : 1; + return { ...st, streak, ld: today }; +} + +// What to display: a streak is only alive if the last practice was +// today or yesterday — otherwise it shows 0 (it has lapsed). +export function currentStreak(st, today = localDateStr()) { + if (!st?.ld) return 0; + const d = dayDiff(st.ld, today); + return d >= 0 && d <= 1 ? (st.streak || 0) : 0; +} diff --git a/src/engine.test.js b/src/engine.test.js new file mode 100644 index 0000000..9404e5f --- /dev/null +++ b/src/engine.test.js @@ -0,0 +1,300 @@ +import { describe, it, expect } from 'vitest'; +import { + DEF, INDEF, PREPS, PREPS_BY_CASE, COMMON_PREPS, RARE_PREPS, ALL_PREPS, + NOUNS, nounForm, LEVELS, CONTRACTIONS, CONTRACTABLE, CHUNK_PREPS, + POSS_ENDINGS, combinePoss, PRONOUNS, VERBS, PREP_FRAMES, + createSession, gen, genContractionContextQ, newState, buildChunkChoices, + bumpStreak, currentStreak, +} from './engine.js'; + +const runSession = (levelId, n) => { + const s = createSession(levelId); + return Array.from({ length: n }, () => s.next()); +}; + +// ── Grammar data ──────────────────────────────────────────── + +describe('declension tables', () => { + it('definite articles are correct', () => { + expect(DEF.masculine.accusative).toBe('den'); + expect(DEF.masculine.dative).toBe('dem'); + expect(DEF.feminine.dative).toBe('der'); + expect(DEF.neuter.genitive).toBe('des'); + expect(DEF.plural.dative).toBe('den'); + }); + + it('every masculine/neuter noun has a genitive form ending in -s', () => { + for (const n of NOUNS) { + if (n.g === 'masculine' || n.g === 'neuter') { + expect(n.gen, `${n.w} is missing its genitive form`).toBeTruthy(); + expect(n.gen.endsWith('s'), `${n.gen} should end in -s`).toBe(true); + } + } + }); + + it('nounForm applies the genitive only for genitive case', () => { + const haus = NOUNS.find(n => n.w === 'Haus'); + expect(nounForm(haus, 'genitive')).toBe('Hauses'); + expect(nounForm(haus, 'dative')).toBe('Haus'); + const frau = NOUNS.find(n => n.w === 'Frau'); + expect(nounForm(frau, 'genitive')).toBe('Frau'); + }); + + it('every frame references real verbs and nouns', () => { + const nounNames = new Set(NOUNS.map(n => n.w)); + const checkFrames = frames => { + for (const f of frames) { + f.verbs.forEach(v => expect(VERBS[v], `unknown verb ${v}`).toBeTruthy()); + f.nouns.forEach(n => expect(nounNames.has(n), `unknown noun ${n}`).toBe(true)); + } + }; + for (const [prep, f] of Object.entries(PREP_FRAMES)) { + expect(PREPS[prep], `frame for unknown prep ${prep}`).toBeTruthy(); + if (Array.isArray(f)) checkFrames(f); + else { checkFrames(f.motion); checkFrames(f.location); } + } + }); + + it('every sentence-level preposition has a frame', () => { + for (const lv of LEVELS) { + if (lv.type === 'article' && lv.preps) { + for (const p of lv.preps) expect(PREP_FRAMES[p], `no frame for ${p} (level ${lv.id})`).toBeTruthy(); + } + } + }); +}); + +// ── Article questions ─────────────────────────────────────── + +describe('article questions', () => { + const articleLevels = LEVELS.filter(l => l.type === 'article').map(l => l.id); + + it.each(articleLevels)('level %s: answer is always among 4 unique choices', (id) => { + for (const q of runSession(id, 200)) { + expect(q.choices.length).toBe(4); + expect(new Set(q.choices).size).toBe(4); + expect(q.choices).toContain(q.ans); + expect(q.sentence).toContain('_____'); + } + }); + + it('genitive sentences use the genitive noun form (des Hauses, not des Haus)', () => { + for (const q of runSession(5, 200)) { + if (q.type !== 'article') continue; + expect(q.cas).toBe('genitive'); + expect(q.sentence).toContain(nounForm(q.noun, 'genitive')); + if (q.noun.gen) expect(q.sentence).not.toContain(` ${q.noun.w}.`); + } + }); + + it('the answer matches the declension table', () => { + for (const q of runSession(6, 300)) { + if (q.type === 'article') expect(q.ans).toBe(DEF[q.noun.g][q.cas]); + } + }); + + it('never repeats a preposition twice in a row', () => { + for (const id of [2, 3, 4, 5]) { + const qs = runSession(id, 200); + for (let i = 1; i < qs.length; i++) { + if (qs[i].prepKey && qs[i-1].prepKey) { + expect(qs[i].prepKey, `level ${id} repeated ${qs[i].prepKey} at ${i}`).not.toBe(qs[i-1].prepKey); + } + } + } + }); + + it('covers every preposition of the level within one bag cycle', () => { + const lv = LEVELS.find(l => l.id === 3); + const seen = new Set(runSession(3, lv.preps.length).map(q => q.prepKey)); + expect([...seen].sort()).toEqual([...lv.preps].sort()); + }); +}); + +// ── Preposition case quiz ─────────────────────────────────── + +describe('prep case quiz (levels 8/9/10)', () => { + it('every common prep appears within 16 draws — zu, nach, bei included', () => { + const seen = new Set(runSession(8, COMMON_PREPS.length).map(q => q.prepKey)); + expect([...seen].sort()).toEqual([...COMMON_PREPS].sort()); + }); + + it('every prep appears within one cycle on levels 9 and 10', () => { + expect(new Set(runSession(9, RARE_PREPS.length).map(q => q.prepKey)).size).toBe(RARE_PREPS.length); + expect(new Set(runSession(10, ALL_PREPS.length).map(q => q.prepKey)).size).toBe(ALL_PREPS.length); + }); + + it('never shows the same preposition twice in a row', () => { + const qs = runSession(10, 300); + for (let i = 1; i < qs.length; i++) expect(qs[i].prepKey).not.toBe(qs[i-1].prepKey); + }); + + it('answers match PREPS and examples are well-formed', () => { + for (const q of runSession(10, 300)) { + expect(q.ans).toBe(PREPS[q.prepKey].c); + expect(q.example.startsWith(q.prepKey) || q.exampleFull).toBe(true); + } + }); +}); + +// ── Wechsel im Kontext ────────────────────────────────────── + +describe('wechsel in context (level 18)', () => { + it('neben appears (was underrepresented)', () => { + const seen = new Set(runSession(18, 9).map(q => q.prepKey)); + expect(seen.has('neben')).toBe(true); + expect(seen.size).toBe(9); + }); + + it('never repeats prep back-to-back and rarely repeats the verb', () => { + const qs = runSession(18, 300); + let verbRepeats = 0; + for (let i = 1; i < qs.length; i++) { + expect(qs[i].prepKey).not.toBe(qs[i-1].prepKey); + if (qs[i].verb === qs[i-1].verb) verbRepeats++; + } + expect(verbRepeats).toBeLessThanOrEqual(6); // only possible via single-verb frames + }); + + it('case follows motion/location and the sentence article matches', () => { + for (const q of runSession(18, 300)) { + expect(q.ans).toBe(q.isMotion ? 'accusative' : 'dative'); + expect(q.sentence).toContain(` ${q.prepKey} `); + expect(q.sentence.endsWith('.')).toBe(true); + } + }); +}); + +// ── Chunk / contraction questions ─────────────────────────── + +describe('contractions', () => { + it('buildChunkChoices always contains the correct form, 4 unique options', () => { + for (const c of CONTRACTIONS.filter(x => x.pref)) { + const { correct, choices } = buildChunkChoices(c.prep, c.art); + expect(correct).toBe(c.form); + expect(choices).toContain(c.form); + expect(new Set(choices).size).toBe(4); + } + }); + + it('non-contracting articles keep the two-word form', () => { + const { correct, contracts } = buildChunkChoices('an', 'die'); + expect(contracts).toBe(false); + expect(correct).toBe('an die'); + }); + + it('dedicated Kontraktionen level always yields a preferred contraction', () => { + const state = newState(); + for (let i = 0; i < 200; i++) { + const q = genContractionContextQ(state); + expect(q.contracts).toBe(true); + expect(q.pref).toBe(true); + const c = CONTRACTIONS.find(x => x.form === q.ans); + expect(c, `answer ${q.ans} is a known contraction`).toBeTruthy(); + expect(DEF[q.noun.g][q.cas]).toBe(c.art); + expect(q.choices).toContain(q.ans); + } + }); + + it('mixed definite level routes contractable preps to chunk questions', () => { + const qs = runSession(6, 300); + for (const q of qs) { + if (q.prepKey && CHUNK_PREPS.has(q.prepKey)) expect(q.type).toBe('chunk'); + } + expect(qs.some(q => q.type === 'chunk')).toBe(true); + }); +}); + +// ── Possessives & pronouns ────────────────────────────────── + +describe('possessive questions', () => { + it.each([11, 12, 13])('level %s: answer matches stem + ending, genitive nouns inflected', (id) => { + const lv = LEVELS.find(l => l.id === id); + for (const q of runSession(id, 300)) { + expect(lv.possessives).toContain(q.stem); + expect(q.ans).toBe(combinePoss(q.stem, POSS_ENDINGS[q.noun.g][q.cas])); + expect(lv.cases).toContain(q.cas); + expect(q.sentence).toContain('_____'); + if (q.cas === 'genitive') expect(q.sentence).toContain(nounForm(q.noun, 'genitive')); + } + }); + + it('level 13 reaches the genitive', () => { + expect(runSession(13, 300).some(q => q.cas === 'genitive')).toBe(true); + }); + + it('dative helfen template only targets people', () => { + for (const q of runSession(11, 400)) { + if (q.sentence.startsWith('Ich helfe')) { + expect(['masculine','feminine','neuter'].includes(q.noun.g)).toBe(true); + expect(q.noun.en).not.toBe('table'); + expect(q.noun.en).not.toBe('week'); + } + } + }); +}); + +describe('pronoun questions', () => { + it.each([14, 15, 16, 17])('level %s: answer matches the pronoun table', (id) => { + for (const q of runSession(id, 200)) { + const p = PRONOUNS.find(x => x.key === q.pronKey); + const expected = p[{nominative:'nom',accusative:'acc',dative:'dat'}[q.cas]]; + const want = q.cas === 'nominative' ? expected.charAt(0).toUpperCase() + expected.slice(1) : expected; + expect(q.ans).toBe(want); + } + }); +}); + +// ── Every level generates without crashing ────────────────── + +describe('all levels', () => { + it.each(LEVELS.map(l => [l.id]))('level %s generates 100 questions cleanly', (id) => { + for (const q of runSession(id, 100)) { + expect(q).toBeTruthy(); + expect(q.ans).toBeTruthy(); + expect(q.type).toBeTruthy(); + } + }); +}); + +// ── Streaks (multi-day simulation) ────────────────────────── + +describe('streaks', () => { + it('grows by one per consecutive day, no matter how many rounds per day', () => { + let st = { streak: 0, ld: null }; + st = bumpStreak(st, '2026-07-01'); + expect(st.streak).toBe(1); + st = bumpStreak(st, '2026-07-01'); // second round same day + st = bumpStreak(st, '2026-07-01'); + expect(st.streak).toBe(1); + st = bumpStreak(st, '2026-07-02'); + expect(st.streak).toBe(2); + st = bumpStreak(st, '2026-07-03'); + st = bumpStreak(st, '2026-07-03'); + expect(st.streak).toBe(3); + }); + + it('resets after a missed day', () => { + let st = { streak: 5, ld: '2026-07-01' }; + st = bumpStreak(st, '2026-07-04'); + expect(st.streak).toBe(1); + }); + + it('survives month and year boundaries', () => { + let st = bumpStreak({ streak: 0, ld: null }, '2026-01-31'); + st = bumpStreak(st, '2026-02-01'); + expect(st.streak).toBe(2); + st = { streak: 9, ld: '2026-12-31' }; + st = bumpStreak(st, '2027-01-01'); + expect(st.streak).toBe(10); + }); + + it('displays 0 once the streak has lapsed, without needing a new answer', () => { + const st = { streak: 7, ld: '2026-07-01' }; + expect(currentStreak(st, '2026-07-01')).toBe(7); + expect(currentStreak(st, '2026-07-02')).toBe(7); // still alive: practiced yesterday + expect(currentStreak(st, '2026-07-03')).toBe(0); // lapsed + expect(currentStreak({}, '2026-07-03')).toBe(0); + expect(currentStreak(null, '2026-07-03')).toBe(0); + }); +});