scrabble-solver 2.16.7 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/bunx.yml +13 -10
- package/.github/workflows/e2e.yml +7 -9
- package/.github/workflows/npx.yml +13 -10
- package/.nx/workspace-data/d/server-process.json +2 -2
- package/.nx/workspace-data/file-map.json +796 -816
- package/.nx/workspace-data/lockfile-dependencies.hash +1 -1
- package/.nx/workspace-data/lockfile-nodes.hash +1 -1
- package/.nx/workspace-data/nx_files.nxt +0 -0
- package/.nx/workspace-data/parsed-lock-file.dependencies.json +218 -1298
- package/.nx/workspace-data/parsed-lock-file.nodes.json +770 -2669
- package/.nx/workspace-data/project-graph.json +1083 -4104
- package/.nx/workspace-data/source-maps.json +128 -0
- package/.oxlintrc.json +13 -10
- package/CLAUDE.md +36 -28
- package/README.md +17 -1
- package/bun.lock +233 -528
- package/e2e/app.spec.ts +124 -0
- package/e2e/bugs/112-character-bonus.spec.ts +25 -0
- package/e2e/bugs/129-esc-sidebar.spec.ts +17 -0
- package/e2e/bugs/2-q-tile-in-english.spec.ts +19 -0
- package/e2e/bugs/3-x-tile-in-polish.spec.ts +19 -0
- package/e2e/bugs/30-click-inside-radio-icon.spec.ts +25 -0
- package/e2e/bugs/333-disabled-radio-cursor.spec.ts +18 -0
- package/e2e/bugs/342-results-sorting.spec.ts +30 -0
- package/e2e/bugs/352-dictionary-race-condition.spec.ts +27 -0
- package/e2e/bugs/363-dictionary-input-unique-words.spec.ts +27 -0
- package/e2e/bugs/380-transliteration-when-pasting.spec.ts +55 -0
- package/e2e/bugs/398-digraphs-in-created-words.spec.ts +23 -0
- package/e2e/bugs/401-board-state-on-game-change.spec.ts +24 -0
- package/e2e/features/198-highlight-unreachable-cells.spec.ts +85 -0
- package/e2e/features/91-hover-remaining-tile-highlights.spec.ts +76 -0
- package/e2e/features/hidden-coordinates-migration.spec.ts +27 -0
- package/e2e/features/solver-paths.spec.ts +45 -0
- package/e2e/lib/actions.ts +119 -0
- package/e2e/lib/selectors.ts +71 -0
- package/e2e/tsconfig.json +10 -0
- package/lerna.json +1 -1
- package/package.json +16 -18
- package/packages/configs/package.json +3 -3
- package/packages/constants/package.json +1 -1
- package/packages/dictionaries/README.md +1 -1
- package/packages/dictionaries/build/Dictionaries.d.ts +2 -2
- package/packages/dictionaries/build/Dictionaries.js +6 -6
- package/packages/dictionaries/build/lib/DiskCache.d.ts +11 -4
- package/packages/dictionaries/build/lib/DiskCache.js +27 -10
- package/packages/dictionaries/build/lib/LayeredCache.d.ts +4 -4
- package/packages/dictionaries/build/lib/LayeredCache.js +3 -3
- package/packages/dictionaries/build/lib/MemoryCache.d.ts +4 -4
- package/packages/dictionaries/build/lib/MemoryCache.js +2 -2
- package/packages/dictionaries/build/lib/createCacheTimestampComparator.d.ts +2 -2
- package/packages/dictionaries/build/lib/downloadDictionary.d.ts +2 -2
- package/packages/dictionaries/build/lib/downloadDictionary.js +4 -3
- package/packages/dictionaries/build/lib/getDictionaryFilepath.d.ts +3 -1
- package/packages/dictionaries/build/lib/getDictionaryFilepath.js +8 -3
- package/packages/dictionaries/package.json +7 -6
- package/packages/dictionaries/src/Dictionaries.ts +12 -12
- package/packages/dictionaries/src/lib/DiskCache.test.ts +85 -0
- package/packages/dictionaries/src/lib/DiskCache.ts +34 -14
- package/packages/dictionaries/src/lib/LayeredCache.ts +7 -7
- package/packages/dictionaries/src/lib/MemoryCache.ts +6 -6
- package/packages/dictionaries/src/lib/createCacheTimestampComparator.ts +2 -2
- package/packages/dictionaries/src/lib/downloadDictionary.ts +5 -4
- package/packages/dictionaries/src/lib/getDictionaryFilepath.ts +7 -2
- package/packages/gaddag/build/Gaddag.d.ts +39 -0
- package/packages/gaddag/build/Gaddag.js +128 -0
- package/packages/gaddag/build/buildGaddag.d.ts +10 -0
- package/packages/gaddag/build/buildGaddag.js +360 -0
- package/packages/gaddag/build/constants.d.ts +12 -0
- package/packages/gaddag/build/constants.js +15 -0
- package/packages/gaddag/build/index.d.ts +3 -0
- package/packages/gaddag/build/index.js +13 -0
- package/packages/logger/package.json +1 -1
- package/packages/scrabble-solver/.next/BUILD_ID +1 -1
- package/packages/scrabble-solver/.next/build-manifest.json +22 -20
- package/packages/scrabble-solver/.next/cache/.previewinfo +1 -1
- package/packages/scrabble-solver/.next/cache/.rscinfo +1 -1
- package/packages/scrabble-solver/.next/cache/.tsbuildinfo +1 -1
- package/packages/scrabble-solver/.next/cache/webpack/client-production/0.pack +0 -0
- package/packages/scrabble-solver/.next/cache/webpack/client-production/index.pack +0 -0
- package/packages/scrabble-solver/.next/cache/webpack/client-production/index.pack.old +0 -0
- package/packages/scrabble-solver/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/packages/scrabble-solver/.next/cache/webpack/edge-server-production/index.pack.old +0 -0
- package/packages/scrabble-solver/.next/cache/webpack/server-production/0.pack +0 -0
- package/packages/scrabble-solver/.next/cache/webpack/server-production/index.pack +0 -0
- package/packages/scrabble-solver/.next/cache/webpack/server-production/index.pack.old +0 -0
- package/packages/scrabble-solver/.next/diagnostics/framework.json +1 -1
- package/packages/scrabble-solver/.next/dynamic-css-manifest.json +8 -1
- package/packages/scrabble-solver/.next/next-minimal-server.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/next-server.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/prerender-manifest.json +4 -4
- package/packages/scrabble-solver/.next/react-loadable-manifest.json +176 -1
- package/packages/scrabble-solver/.next/required-server-files.js +3 -2
- package/packages/scrabble-solver/.next/required-server-files.json +3 -2
- package/packages/scrabble-solver/.next/routes-manifest.json +1 -1
- package/packages/scrabble-solver/.next/server/chunks/30.js +1 -1
- package/packages/scrabble-solver/.next/server/chunks/309.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/316.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/344.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/371.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/392.js +1 -1
- package/packages/scrabble-solver/.next/server/chunks/465.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/546.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/623.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/624.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/641.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/671.js +1 -1
- package/packages/scrabble-solver/.next/server/chunks/692.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/705.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/712.js +1 -1
- package/packages/scrabble-solver/.next/server/chunks/846.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/853.js +15 -0
- package/packages/scrabble-solver/.next/server/chunks/886.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/916.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/953.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/961.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/968.js +1 -0
- package/packages/scrabble-solver/.next/server/dynamic-css-manifest.js +1 -1
- package/packages/scrabble-solver/.next/server/middleware-build-manifest.js +1 -1
- package/packages/scrabble-solver/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/packages/scrabble-solver/.next/server/pages/404.html +28 -1
- package/packages/scrabble-solver/.next/server/pages/500.html +28 -1
- package/packages/scrabble-solver/.next/server/pages/_app.js +1 -1
- package/packages/scrabble-solver/.next/server/pages/_app.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/_document.js +28 -1
- package/packages/scrabble-solver/.next/server/pages/_document.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/_error.js +28 -1
- package/packages/scrabble-solver/.next/server/pages/_error.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale]/[word].js +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale]/[word].js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale].js +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale].js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/solve.js +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/solve.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/verify.js +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/verify.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/visit.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/index.html +28 -1
- package/packages/scrabble-solver/.next/server/pages/index.js +30 -1
- package/packages/scrabble-solver/.next/server/pages/index.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/index.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/not-found.html +28 -1
- package/packages/scrabble-solver/.next/server/pages/not-found.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages-manifest.json +1 -1
- package/packages/scrabble-solver/.next/server/webpack-api-runtime.js +1 -1
- package/packages/scrabble-solver/.next/server/webpack-runtime.js +1 -1
- package/packages/scrabble-solver/.next/static/FL82obwzFHRzvV6z76I-U/_buildManifest.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/101-829ab8a8d310e7a0.js +3 -0
- package/packages/scrabble-solver/.next/static/chunks/106.e414e88694add27f.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/127.125d896a19061d71.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/18.8b91e137d239e15c.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/277.6785dfaf22fdb0b7.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/307.67f52f13e3d60d4e.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/341.79535ba85bf1986f.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/363.01eed3233f92b6a4.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/422.1559ae6756912ca5.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/541.dcd9e8b804f88da5.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/583.7a46807be14c377e.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/631.98d42abad0a60af6.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/663.d2c9ca36b26695b8.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/711.ba7b8f900dfd40d0.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/744.b06c9a3de4a1d877.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/820.97b82f751ca49579.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/865.a9b563a45848a83c.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/912.ff8037ba9d4db5c8.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/939.19b33a3d3c351d73.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/990.61296e5870b91f11.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/framework-626cbd48ac5aca57.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/main-b92e08f3fc96dd8d.js +5 -0
- package/packages/scrabble-solver/.next/static/chunks/pages/_app-ba5d628e81a15461.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/pages/{_error-afeda7c5231b6c3c.js → _error-f0290a98eb3ed0ec.js} +1 -1
- package/packages/scrabble-solver/.next/static/chunks/pages/index-5f9fa6ac3cd3d97b.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/pages/not-found-23d61fd3ed791cad.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/webpack-e34c3ae12275e978.js +1 -0
- package/packages/scrabble-solver/.next/static/css/2d03f0da2c678dfc.css +1 -0
- package/packages/scrabble-solver/.next/static/css/7b6724d2df70f48f.css +1 -0
- package/packages/scrabble-solver/.next/static/css/abc2425d3ba607bc.css +1 -0
- package/packages/scrabble-solver/.next/static/css/b736be3bd02b5d99.css +1 -0
- package/packages/scrabble-solver/.next/static/css/b74cd7de82dabcdd.css +1 -0
- package/packages/scrabble-solver/.next/static/css/c403ce739a9c0d83.css +1 -0
- package/packages/scrabble-solver/.next/static/css/ca311e6c030663d4.css +1 -0
- package/packages/scrabble-solver/.next/static/css/dee878eb64099d92.css +1 -0
- package/packages/scrabble-solver/.next/static/css/fbfdce70731d0d2b.css +1 -0
- package/packages/scrabble-solver/.next/trace +21 -21
- package/packages/scrabble-solver/.next/trace-build +1 -1
- package/packages/scrabble-solver/empty-module.js +2 -0
- package/packages/scrabble-solver/next.config.js +13 -8
- package/packages/scrabble-solver/package.json +34 -15
- package/packages/scrabble-solver/public/llms.txt +11 -0
- package/packages/scrabble-solver/public/robots.txt +4 -0
- package/packages/scrabble-solver/public/service-worker.js +2 -2
- package/packages/scrabble-solver/scripts/generate-scss-types.ts +112 -0
- package/packages/scrabble-solver/src/@types/scss.d.ts +6 -9
- package/packages/scrabble-solver/src/@types/svg.d.ts +1 -1
- package/packages/scrabble-solver/src/components/Alert/Alert.module.scss.d.ts +13 -0
- package/packages/scrabble-solver/src/components/Alert/Alert.tsx +4 -1
- package/packages/scrabble-solver/src/components/Badge/Badge.module.scss.d.ts +6 -0
- package/packages/scrabble-solver/src/components/Board/Board.module.scss +10 -0
- package/packages/scrabble-solver/src/components/Board/Board.module.scss.d.ts +17 -0
- package/packages/scrabble-solver/src/components/Board/Board.tsx +14 -26
- package/packages/scrabble-solver/src/components/Board/BoardPure.tsx +54 -49
- package/packages/scrabble-solver/src/components/Board/components/Actions/Actions.module.scss +18 -18
- package/packages/scrabble-solver/src/components/Board/components/Actions/Actions.module.scss.d.ts +7 -0
- package/packages/scrabble-solver/src/components/Board/components/Actions/Actions.tsx +6 -3
- package/packages/scrabble-solver/src/components/Board/components/Actions/lib.ts +3 -1
- package/packages/scrabble-solver/src/components/Board/components/Cell/Cell.module.scss.d.ts +13 -0
- package/packages/scrabble-solver/src/components/Board/components/Cell/Cell.tsx +32 -34
- package/packages/scrabble-solver/src/components/Board/components/InputPrompt/InputPrompt.module.scss.d.ts +10 -0
- package/packages/scrabble-solver/src/components/Board/components/InputPrompt/InputPrompt.tsx +1 -1
- package/packages/scrabble-solver/src/components/Board/components/ToggleDirectionButton/ToggleDirectionButton.module.scss.d.ts +8 -0
- package/packages/scrabble-solver/src/components/Board/components/ToggleDirectionButton/ToggleDirectionButton.tsx +2 -2
- package/packages/scrabble-solver/src/components/Board/getBoardBackground.ts +175 -0
- package/packages/scrabble-solver/src/components/Board/hooks/index.ts +0 -1
- package/packages/scrabble-solver/src/components/Board/hooks/useBoardStyle.ts +10 -20
- package/packages/scrabble-solver/src/components/Board/hooks/useFloatingActions.ts +3 -6
- package/packages/scrabble-solver/src/components/Board/hooks/useGrid.ts +23 -9
- package/packages/scrabble-solver/src/components/Board/selectors.ts +1 -1
- package/packages/scrabble-solver/src/components/Button/Button.module.scss.d.ts +12 -0
- package/packages/scrabble-solver/src/components/Dictionary/Dictionary.module.scss +8 -22
- package/packages/scrabble-solver/src/components/Dictionary/Dictionary.module.scss.d.ts +14 -0
- package/packages/scrabble-solver/src/components/Dictionary/Dictionary.tsx +1 -10
- package/packages/scrabble-solver/src/components/DictionaryInput/DictionaryInput.module.scss.d.ts +7 -0
- package/packages/scrabble-solver/src/components/DictionaryInput/DictionaryInput.tsx +1 -1
- package/packages/scrabble-solver/src/components/EmptyState/EmptyState.module.scss.d.ts +7 -0
- package/packages/scrabble-solver/src/components/EmptyState/EmptyState.tsx +1 -1
- package/packages/scrabble-solver/src/components/IconButton/IconButton.module.scss.d.ts +8 -0
- package/packages/scrabble-solver/src/components/Key/Key.module.scss.d.ts +6 -0
- package/packages/scrabble-solver/src/components/Keys/Arrows/Arrows.module.scss.d.ts +11 -0
- package/packages/scrabble-solver/src/components/Keys/Arrows/Arrows.tsx +4 -1
- package/packages/scrabble-solver/src/components/Keys/index.tsx +1 -1
- package/packages/scrabble-solver/src/components/Loading/Loading.module.scss.d.ts +9 -0
- package/packages/scrabble-solver/src/components/Loading/Loading.tsx +1 -1
- package/packages/scrabble-solver/src/components/Logo/Logo.tsx +4 -9
- package/packages/scrabble-solver/src/components/Logo/LogoBlueprint.tsx +1 -0
- package/packages/scrabble-solver/src/components/Modal/Modal.module.scss.d.ts +15 -0
- package/packages/scrabble-solver/src/components/Modal/Modal.tsx +16 -3
- package/packages/scrabble-solver/src/components/Modal/components/Section/Section.module.scss.d.ts +7 -0
- package/packages/scrabble-solver/src/components/NavButtons/NavButtons.module.scss +20 -0
- package/packages/scrabble-solver/src/components/NavButtons/NavButtons.module.scss.d.ts +13 -0
- package/packages/scrabble-solver/src/components/NavButtons/NavButtons.tsx +29 -45
- package/packages/scrabble-solver/src/components/NotFound/NotFound.module.scss.d.ts +9 -0
- package/packages/scrabble-solver/src/components/NotFound/NotFound.tsx +1 -1
- package/packages/scrabble-solver/src/components/PlainTiles/PlainTiles.module.scss.d.ts +11 -0
- package/packages/scrabble-solver/src/components/PlainTiles/PlainTiles.tsx +0 -1
- package/packages/scrabble-solver/src/components/PlainTiles/Tile.tsx +3 -4
- package/packages/scrabble-solver/src/components/PlainTiles/lib.ts +4 -19
- package/packages/scrabble-solver/src/components/PlainTiles/types.ts +0 -1
- package/packages/scrabble-solver/src/components/Progress/Progress.module.scss +7 -0
- package/packages/scrabble-solver/src/components/Progress/Progress.module.scss.d.ts +6 -0
- package/packages/scrabble-solver/src/components/Progress/Progress.tsx +2 -11
- package/packages/scrabble-solver/src/components/Rack/Rack.module.scss +3 -0
- package/packages/scrabble-solver/src/components/Rack/Rack.module.scss.d.ts +10 -0
- package/packages/scrabble-solver/src/components/Rack/Rack.tsx +8 -11
- package/packages/scrabble-solver/src/components/Rack/components/InputPrompt/InputPrompt.module.scss +2 -0
- package/packages/scrabble-solver/src/components/Rack/components/InputPrompt/InputPrompt.module.scss.d.ts +7 -0
- package/packages/scrabble-solver/src/components/Rack/components/InputPrompt/InputPrompt.tsx +1 -13
- package/packages/scrabble-solver/src/components/Rack/components/InputPrompt/lib.ts +1 -1
- package/packages/scrabble-solver/src/components/Rack/components/RackTile/RackTile.module.scss.d.ts +6 -0
- package/packages/scrabble-solver/src/components/Rack/components/RackTile/RackTile.tsx +5 -4
- package/packages/scrabble-solver/src/components/Rack/selectors.ts +1 -1
- package/packages/scrabble-solver/src/components/Radio/Radio.module.scss.d.ts +12 -0
- package/packages/scrabble-solver/src/components/Results/Cell.tsx +3 -12
- package/packages/scrabble-solver/src/components/Results/Header.tsx +9 -18
- package/packages/scrabble-solver/src/components/Results/HeaderButton.tsx +4 -5
- package/packages/scrabble-solver/src/components/Results/Result.tsx +4 -15
- package/packages/scrabble-solver/src/components/Results/Results.module.scss +4 -7
- package/packages/scrabble-solver/src/components/Results/Results.module.scss.d.ts +27 -0
- package/packages/scrabble-solver/src/components/Results/Results.tsx +14 -5
- package/packages/scrabble-solver/src/components/Results/SolveButton.tsx +1 -1
- package/packages/scrabble-solver/src/components/Results/types.ts +3 -3
- package/packages/scrabble-solver/src/components/ResultsInput/ResultsInput.module.scss.d.ts +7 -0
- package/packages/scrabble-solver/src/components/ResultsInput/ResultsInput.tsx +1 -1
- package/packages/scrabble-solver/src/components/Solver/Solver.module.scss +6 -5
- package/packages/scrabble-solver/src/components/Solver/Solver.module.scss.d.ts +20 -0
- package/packages/scrabble-solver/src/components/Solver/Solver.tsx +23 -24
- package/packages/scrabble-solver/src/components/Solver/components/InsertButton/InsertButton.module.scss.d.ts +7 -0
- package/packages/scrabble-solver/src/components/Solver/components/InsertButton/InsertButton.tsx +1 -1
- package/packages/scrabble-solver/src/components/Solver/components/ResultCandidatePicker/ResultCandidatePicker.module.scss.d.ts +18 -0
- package/packages/scrabble-solver/src/components/Solver/components/ResultCandidatePicker/ResultCandidatePicker.tsx +3 -1
- package/packages/scrabble-solver/src/components/Spinner/Spinner.module.scss.d.ts +10 -0
- package/packages/scrabble-solver/src/components/Tile/Tile.module.scss +3 -0
- package/packages/scrabble-solver/src/components/Tile/Tile.module.scss.d.ts +19 -0
- package/packages/scrabble-solver/src/components/Tile/Tile.tsx +5 -14
- package/packages/scrabble-solver/src/components/Tile/TilePure.tsx +2 -12
- package/packages/scrabble-solver/src/components/Tooltip/Tooltip.module.scss.d.ts +7 -0
- package/packages/scrabble-solver/src/components/Tooltip/Tooltip.tsx +14 -3
- package/packages/scrabble-solver/src/components/Tooltip/useTooltip.ts +2 -1
- package/packages/scrabble-solver/src/hooks/useColumns.ts +4 -7
- package/packages/scrabble-solver/src/hooks/useDeferredRender.ts +20 -0
- package/packages/scrabble-solver/src/hooks/useDirection.ts +4 -3
- package/packages/scrabble-solver/src/hooks/useLanguage.ts +2 -2
- package/packages/scrabble-solver/src/hooks/useLocalStorage.ts +32 -8
- package/packages/scrabble-solver/src/hooks/useMedia.ts +26 -26
- package/packages/scrabble-solver/src/i18n/constants.ts +2 -15
- package/packages/scrabble-solver/src/i18n/i18n.ts +18 -17
- package/packages/scrabble-solver/src/i18n/languages/english.json +0 -1
- package/packages/scrabble-solver/src/i18n/languages/french.json +0 -1
- package/packages/scrabble-solver/src/i18n/languages/german.json +0 -1
- package/packages/scrabble-solver/src/i18n/languages/persian.json +0 -1
- package/packages/scrabble-solver/src/i18n/languages/polish.json +0 -1
- package/packages/scrabble-solver/src/i18n/languages/romanian.json +0 -1
- package/packages/scrabble-solver/src/i18n/languages/spanish.json +0 -1
- package/packages/scrabble-solver/src/i18n/languages/turkish.json +0 -1
- package/packages/scrabble-solver/src/i18n/localeIcons.ts +24 -0
- package/packages/scrabble-solver/src/lib/localeTransliterate.test.ts +5 -0
- package/packages/scrabble-solver/src/lib/localeTransliterate.ts +26 -6
- package/packages/scrabble-solver/src/lib/waitForIdleOrFirstIntent.ts +33 -0
- package/packages/scrabble-solver/src/modals/DictionaryModal/DictionaryModal.module.scss.d.ts +8 -0
- package/packages/scrabble-solver/src/modals/DictionaryModal/DictionaryModal.tsx +3 -1
- package/packages/scrabble-solver/src/modals/KeyMapModal/KeyMapModal.tsx +3 -1
- package/packages/scrabble-solver/src/modals/KeyMapModal/components/Mapping/Mapping.module.scss.d.ts +11 -0
- package/packages/scrabble-solver/src/modals/MenuModal/MenuModal.module.scss +5 -0
- package/packages/scrabble-solver/src/modals/MenuModal/MenuModal.module.scss.d.ts +10 -0
- package/packages/scrabble-solver/src/modals/MenuModal/MenuModal.tsx +16 -5
- package/packages/scrabble-solver/src/modals/RemainingTilesModal/RemainingTilesModal.module.scss +4 -1
- package/packages/scrabble-solver/src/modals/RemainingTilesModal/RemainingTilesModal.module.scss.d.ts +9 -0
- package/packages/scrabble-solver/src/modals/RemainingTilesModal/RemainingTilesModal.tsx +4 -6
- package/packages/scrabble-solver/src/modals/RemainingTilesModal/components/Character/Character.module.scss +1 -0
- package/packages/scrabble-solver/src/modals/RemainingTilesModal/components/Character/Character.module.scss.d.ts +12 -0
- package/packages/scrabble-solver/src/modals/RemainingTilesModal/components/Character/Character.tsx +5 -12
- package/packages/scrabble-solver/src/modals/ResultsModal/ResultsModal.module.scss.d.ts +8 -0
- package/packages/scrabble-solver/src/modals/ResultsModal/ResultsModal.tsx +8 -4
- package/packages/scrabble-solver/src/modals/SettingsModal/SettingsModal.tsx +2 -2
- package/packages/scrabble-solver/src/modals/SettingsModal/components/AutoGroupTilesSetting/AutoGroupTilesSetting.module.scss.d.ts +7 -0
- package/packages/scrabble-solver/src/modals/SettingsModal/components/AutoGroupTilesSetting/AutoGroupTilesSetting.tsx +1 -1
- package/packages/scrabble-solver/src/modals/SettingsModal/components/ConfigSetting/ConfigSetting.module.scss.d.ts +7 -0
- package/packages/scrabble-solver/src/modals/SettingsModal/components/ConfigSetting/ConfigSetting.tsx +1 -1
- package/packages/scrabble-solver/src/modals/SettingsModal/components/HighlightUnreachableCellsSetting/HighlightUnreachableCellsSetting.module.scss.d.ts +7 -0
- package/packages/scrabble-solver/src/modals/SettingsModal/components/HighlightUnreachableCellsSetting/HighlightUnreachableCellsSetting.tsx +1 -1
- package/packages/scrabble-solver/src/modals/SettingsModal/components/InputModeSetting/InputModeSetting.module.scss.d.ts +7 -0
- package/packages/scrabble-solver/src/modals/SettingsModal/components/InputModeSetting/InputModeSetting.tsx +1 -1
- package/packages/scrabble-solver/src/modals/SettingsModal/components/LocaleSetting/LocaleSetting.module.scss.d.ts +8 -0
- package/packages/scrabble-solver/src/modals/SettingsModal/components/LocaleSetting/LocaleSetting.tsx +24 -21
- package/packages/scrabble-solver/src/modals/SettingsModal/components/RemoveCellFiltersSetting/RemoveCellFiltersSetting.module.scss.d.ts +7 -0
- package/packages/scrabble-solver/src/modals/SettingsModal/components/RemoveCellFiltersSetting/RemoveCellFiltersSetting.tsx +1 -1
- package/packages/scrabble-solver/src/modals/SettingsModal/components/ShowCoordinatesSetting/ShowCoordinatesSetting.module.scss.d.ts +7 -0
- package/packages/scrabble-solver/src/modals/SettingsModal/components/ShowCoordinatesSetting/ShowCoordinatesSetting.tsx +1 -5
- package/packages/scrabble-solver/src/modals/WordsModal/WordsModal.module.scss.d.ts +11 -0
- package/packages/scrabble-solver/src/modals/WordsModal/WordsModal.tsx +4 -2
- package/packages/scrabble-solver/src/modals/preload.ts +24 -0
- package/packages/scrabble-solver/src/pages/_app.tsx +5 -8
- package/packages/scrabble-solver/src/pages/_document.tsx +91 -5
- package/packages/scrabble-solver/src/pages/api/dictionary/[locale]/[word].ts +2 -2
- package/packages/scrabble-solver/src/pages/api/dictionary/[locale]/index.ts +80 -3
- package/packages/scrabble-solver/src/pages/api/solve.ts +3 -3
- package/packages/scrabble-solver/src/pages/api/verify.ts +12 -3
- package/packages/scrabble-solver/src/pages/index.module.scss +1 -0
- package/packages/scrabble-solver/src/pages/index.module.scss.d.ts +11 -0
- package/packages/scrabble-solver/src/pages/index.tsx +98 -38
- package/packages/scrabble-solver/src/pages/not-found.tsx +1 -1
- package/packages/scrabble-solver/src/parameters/index.ts +24 -93
- package/packages/scrabble-solver/src/sdk/solve.ts +13 -7
- package/packages/scrabble-solver/src/sdk/verify.ts +12 -6
- package/packages/scrabble-solver/src/service-worker/appShell.test.ts +86 -0
- package/packages/scrabble-solver/src/service-worker/appShell.ts +39 -0
- package/packages/scrabble-solver/src/service-worker/index.ts +4 -5
- package/packages/scrabble-solver/src/serviceWorkerManager.ts +5 -19
- package/packages/scrabble-solver/src/solver-worker/client.test.ts +151 -0
- package/packages/scrabble-solver/src/solver-worker/client.ts +106 -0
- package/packages/scrabble-solver/src/solver-worker/dictionaries/constants.ts +9 -0
- package/packages/scrabble-solver/src/solver-worker/dictionaries/getDictionary.ts +21 -0
- package/packages/scrabble-solver/src/solver-worker/dictionaries/index.ts +2 -0
- package/packages/scrabble-solver/src/solver-worker/dictionaries/revalidateDictionary.test.ts +199 -0
- package/packages/scrabble-solver/src/solver-worker/dictionaries/revalidateDictionary.ts +86 -0
- package/packages/scrabble-solver/src/solver-worker/dictionaries/revalidatedAt.ts +43 -0
- package/packages/scrabble-solver/src/solver-worker/getGaddag.test.ts +109 -0
- package/packages/scrabble-solver/src/solver-worker/getGaddag.ts +62 -0
- package/packages/scrabble-solver/src/solver-worker/index.ts +1 -0
- package/packages/scrabble-solver/src/solver-worker/index.worker.ts +124 -0
- package/packages/scrabble-solver/src/solver-worker/messages.ts +20 -0
- package/packages/scrabble-solver/src/state/actions.ts +1 -1
- package/packages/scrabble-solver/src/state/app/index.ts +4 -0
- package/packages/scrabble-solver/src/state/app/initialState.ts +6 -0
- package/packages/scrabble-solver/src/state/app/selectors.ts +5 -0
- package/packages/scrabble-solver/src/state/app/slice.ts +20 -0
- package/packages/scrabble-solver/src/state/app/types.ts +4 -0
- package/packages/scrabble-solver/src/state/board/initialState.ts +1 -2
- package/packages/scrabble-solver/src/state/i18n/index.ts +4 -0
- package/packages/scrabble-solver/src/state/i18n/initialState.ts +5 -0
- package/packages/scrabble-solver/src/state/i18n/selectors.ts +3 -0
- package/packages/scrabble-solver/src/state/i18n/slice.ts +16 -0
- package/packages/scrabble-solver/src/state/i18n/types.ts +7 -0
- package/packages/scrabble-solver/src/state/index.ts +3 -0
- package/packages/scrabble-solver/src/state/lib.ts +1 -1
- package/packages/scrabble-solver/src/state/localStorage.ts +86 -29
- package/packages/scrabble-solver/src/state/rack/initialState.ts +1 -2
- package/packages/scrabble-solver/src/state/rack/slice.ts +4 -1
- package/packages/scrabble-solver/src/state/results/lib.ts +5 -5
- package/packages/scrabble-solver/src/state/results/selectors.ts +1 -1
- package/packages/scrabble-solver/src/state/sagas.ts +139 -13
- package/packages/scrabble-solver/src/state/selectors.ts +1 -1
- package/packages/scrabble-solver/src/state/settings/initialState.ts +7 -10
- package/packages/scrabble-solver/src/state/settings/selectors.ts +7 -2
- package/packages/scrabble-solver/src/state/settings/slice.ts +5 -2
- package/packages/scrabble-solver/src/state/store.ts +4 -0
- package/packages/scrabble-solver/src/state/types.ts +4 -0
- package/packages/scrabble-solver/src/state/useTypedStore.ts +6 -0
- package/packages/scrabble-solver/src/styles/_tokens.scss +1 -0
- package/packages/scrabble-solver/src/styles/global.scss +9 -0
- package/packages/scrabble-solver/src/styles/variables.module.scss +0 -21
- package/packages/scrabble-solver/src/styles/variables.module.scss.d.ts +27 -0
- package/packages/scrabble-solver/src/styles/variables.scss +46 -2
- package/packages/scrabble-solver/src/types/api.ts +1 -2
- package/packages/scrabble-solver/src/types/index.ts +1 -2
- package/packages/scrabble-solver/tsconfig.json +4 -0
- package/packages/scrabble-solver/tsconfig.tsbuildinfo +1 -1
- package/packages/solver/README.md +24 -0
- package/packages/solver/benchmarks/index.ts +15 -0
- package/packages/solver/benchmarks/lib.ts +31 -0
- package/packages/solver/benchmarks/renderChart.ts +165 -0
- package/packages/solver/benchmarks/results/chart.svg +65 -0
- package/packages/solver/benchmarks/runBenchmarks.ts +66 -0
- package/packages/solver/benchmarks/scenarios.ts +64 -0
- package/packages/solver/benchmarks/types.ts +16 -0
- package/packages/solver/benchmarks/updateReadme.ts +53 -0
- package/packages/solver/build/MoveGenerator.d.ts +116 -0
- package/packages/solver/build/MoveGenerator.js +686 -0
- package/packages/solver/build/index.d.ts +0 -13
- package/packages/solver/build/index.js +1 -27
- package/packages/solver/build/solve.d.ts +2 -2
- package/packages/solver/build/solve.js +3 -19
- package/packages/solver/package.json +7 -6
- package/packages/solver/src/MoveGenerator.test.ts +1009 -0
- package/packages/solver/src/MoveGenerator.ts +926 -0
- package/packages/solver/src/ReferenceSolver.ts +401 -0
- package/packages/solver/src/index.ts +0 -13
- package/packages/solver/src/solve.test.ts +21 -21
- package/packages/solver/src/solve.ts +4 -22
- package/packages/solver/tsconfig.json +1 -1
- package/packages/types/build/Cell.d.ts +1 -0
- package/packages/types/build/Cell.js +3 -0
- package/packages/types/build/Direction.d.ts +1 -0
- package/packages/types/build/Result.d.ts +3 -2
- package/packages/types/build/Result.js +11 -6
- package/packages/types/build/ResultJson.d.ts +5 -4
- package/packages/types/build/ShowCoordinates.d.ts +1 -2
- package/packages/types/build/TextDirection.d.ts +1 -0
- package/packages/types/build/index.d.ts +2 -6
- package/packages/types/build/index.js +1 -9
- package/packages/types/build/readResultCells.d.ts +5 -0
- package/packages/types/build/readResultCells.js +75 -0
- package/packages/types/package.json +4 -3
- package/packages/types/src/Cell.ts +4 -0
- package/packages/types/src/Direction.ts +1 -0
- package/packages/types/src/Result.ts +15 -6
- package/packages/types/src/ResultJson.ts +5 -5
- package/packages/types/src/ShowCoordinates.ts +1 -2
- package/packages/types/src/TextDirection.ts +1 -0
- package/packages/types/src/index.ts +2 -6
- package/packages/types/src/readResultCells.test.ts +140 -0
- package/packages/types/src/readResultCells.ts +90 -0
- package/packages/word-definitions/package.json +2 -2
- package/packages/word-lists/package.json +2 -2
- package/playwright-report/index.html +49 -0
- package/playwright.config.ts +30 -0
- package/screencast.gif +0 -0
- package/scripts/record-screencast.ts +347 -0
- package/test-results/.last-run.json +4 -0
- package/tsconfig.json +1 -1
- package/babel.config.js +0 -4
- package/cypress/e2e/app.spec.cy.ts +0 -123
- package/cypress/e2e/bugs/112-character-bonus.ts +0 -40
- package/cypress/e2e/bugs/129-esc-sidebar.ts +0 -23
- package/cypress/e2e/bugs/2-q-tile-in-english.ts +0 -33
- package/cypress/e2e/bugs/3-x-tile-in-polish.ts +0 -33
- package/cypress/e2e/bugs/30-click-inside-radio-icon.ts +0 -26
- package/cypress/e2e/bugs/333-disabled-radio-cursor.ts +0 -24
- package/cypress/e2e/bugs/342-results-sorting.ts +0 -48
- package/cypress/e2e/bugs/348-results-word-padding.ts +0 -74
- package/cypress/e2e/bugs/352-dictionary-race-condition.ts +0 -29
- package/cypress/e2e/bugs/363-dictionary-input-unique-words.ts +0 -41
- package/cypress/e2e/bugs/380-transliteration-when-pasting.ts +0 -75
- package/cypress/e2e/bugs/398-digraphs-in-created-words.ts +0 -38
- package/cypress/e2e/bugs/401-board-state-on-game-change.ts +0 -36
- package/cypress/e2e/bugs.spec.cy.ts +0 -13
- package/cypress/e2e/features/198-highlight-unreachable-cells.ts +0 -101
- package/cypress/e2e/features/91-hover-remaining-tile-highlights.ts +0 -90
- package/cypress/e2e/features.spec.cy.ts +0 -2
- package/cypress/support/commands.ts +0 -4
- package/cypress/support/e2e.ts +0 -1
- package/cypress/support/index.ts +0 -1
- package/cypress/support/lib/actions.ts +0 -85
- package/cypress/support/lib/selectors.ts +0 -70
- package/cypress/tsconfig.json +0 -10
- package/cypress.config.ts +0 -17
- package/packages/scrabble-solver/.next/server/chunks/318.js +0 -3
- package/packages/scrabble-solver/.next/server/chunks/791.js +0 -15
- package/packages/scrabble-solver/.next/static/chunks/framework-3cf15b8041c59dd3.js +0 -1
- package/packages/scrabble-solver/.next/static/chunks/main-852475ca502b4e49.js +0 -5
- package/packages/scrabble-solver/.next/static/chunks/pages/_app-a5bbe02710f13d05.js +0 -7
- package/packages/scrabble-solver/.next/static/chunks/pages/index-ec0f6aeeaf7be227.js +0 -1
- package/packages/scrabble-solver/.next/static/chunks/pages/not-found-9e076427bad69d90.js +0 -1
- package/packages/scrabble-solver/.next/static/chunks/webpack-1a7a166b34ed8d24.js +0 -1
- package/packages/scrabble-solver/.next/static/css/0af35adb4bab56f0.css +0 -2
- package/packages/scrabble-solver/.next/static/css/a78e04fe95452380.css +0 -1
- package/packages/scrabble-solver/.next/static/vYpMmuC_yEurZGcyGVnLf/_buildManifest.js +0 -1
- package/packages/scrabble-solver/src/app-layout/AppLayoutContext.tsx +0 -25
- package/packages/scrabble-solver/src/app-layout/index.ts +0 -1
- package/packages/scrabble-solver/src/app-layout/useAppLayoutValue.ts +0 -112
- package/packages/scrabble-solver/src/components/Board/hooks/useBackgroundImage.tsx +0 -279
- package/packages/scrabble-solver/src/components/index.ts +0 -26
- package/packages/scrabble-solver/src/hooks/index.ts +0 -12
- package/packages/scrabble-solver/src/hooks/useOnWindowResize.ts +0 -11
- package/packages/scrabble-solver/src/hooks/useViewportSize.ts +0 -17
- package/packages/scrabble-solver/src/i18n/index.ts +0 -2
- package/packages/scrabble-solver/src/icons/Flag.svg +0 -4
- package/packages/scrabble-solver/src/icons/Star.svg +0 -4
- package/packages/scrabble-solver/src/icons/index.ts +0 -49
- package/packages/scrabble-solver/src/lib/getTileSizes.ts +0 -13
- package/packages/scrabble-solver/src/lib/index.ts +0 -24
- package/packages/scrabble-solver/src/modals/index.ts +0 -7
- package/packages/scrabble-solver/src/sdk/getDictionary.ts +0 -9
- package/packages/scrabble-solver/src/service-worker/average.ts +0 -7
- package/packages/scrabble-solver/src/service-worker/dictionaries/constants.ts +0 -5
- package/packages/scrabble-solver/src/service-worker/dictionaries/getDictionary.ts +0 -20
- package/packages/scrabble-solver/src/service-worker/dictionaries/index.ts +0 -2
- package/packages/scrabble-solver/src/service-worker/dictionaries/revalidateDictionary.ts +0 -33
- package/packages/scrabble-solver/src/service-worker/getTrie.ts +0 -24
- package/packages/scrabble-solver/src/service-worker/routeSolveRequests.ts +0 -91
- package/packages/scrabble-solver/src/service-worker/routeVerifyRequests.ts +0 -37
- package/packages/solver/build/areDigraphsValid.d.ts +0 -2
- package/packages/solver/build/areDigraphsValid.js +0 -17
- package/packages/solver/build/fillPattern.d.ts +0 -6
- package/packages/solver/build/fillPattern.js +0 -52
- package/packages/solver/build/generateEndIndices.d.ts +0 -2
- package/packages/solver/build/generateEndIndices.js +0 -17
- package/packages/solver/build/generateHorizontalPatterns.d.ts +0 -2
- package/packages/solver/build/generateHorizontalPatterns.js +0 -16
- package/packages/solver/build/generatePattern.d.ts +0 -7
- package/packages/solver/build/generatePattern.js +0 -20
- package/packages/solver/build/generatePatterns.d.ts +0 -2
- package/packages/solver/build/generatePatterns.js +0 -11
- package/packages/solver/build/generateStartIndices.d.ts +0 -2
- package/packages/solver/build/generateStartIndices.js +0 -16
- package/packages/solver/build/generateVectors.d.ts +0 -7
- package/packages/solver/build/generateVectors.js +0 -7
- package/packages/solver/build/generateVerticalPatterns.d.ts +0 -2
- package/packages/solver/build/generateVerticalPatterns.js +0 -16
- package/packages/solver/build/getCellsScore.d.ts +0 -2
- package/packages/solver/build/getCellsScore.js +0 -18
- package/packages/solver/build/getPatternHash.d.ts +0 -2
- package/packages/solver/build/getPatternHash.js +0 -14
- package/packages/solver/build/getPatternScore.d.ts +0 -2
- package/packages/solver/build/getPatternScore.js +0 -22
- package/packages/solver/build/getUniquePatterns.d.ts +0 -2
- package/packages/solver/build/getUniquePatterns.js +0 -17
- package/packages/solver/src/areDigraphsValid.ts +0 -18
- package/packages/solver/src/fillPattern.test.ts +0 -107
- package/packages/solver/src/fillPattern.ts +0 -68
- package/packages/solver/src/generateEndIndices.test.ts +0 -23
- package/packages/solver/src/generateEndIndices.ts +0 -19
- package/packages/solver/src/generateHorizontalPatterns.test.ts +0 -31
- package/packages/solver/src/generateHorizontalPatterns.ts +0 -15
- package/packages/solver/src/generatePattern.ts +0 -33
- package/packages/solver/src/generatePatterns.test.ts +0 -13
- package/packages/solver/src/generatePatterns.ts +0 -11
- package/packages/solver/src/generateStartIndices.test.ts +0 -19
- package/packages/solver/src/generateStartIndices.ts +0 -17
- package/packages/solver/src/generateVectors.test.ts +0 -12
- package/packages/solver/src/generateVectors.ts +0 -10
- package/packages/solver/src/generateVerticalPatterns.test.ts +0 -32
- package/packages/solver/src/generateVerticalPatterns.ts +0 -15
- package/packages/solver/src/getCellsScore.ts +0 -21
- package/packages/solver/src/getPatternHash.test.ts +0 -17
- package/packages/solver/src/getPatternHash.ts +0 -12
- package/packages/solver/src/getPatternScore.test.ts +0 -204
- package/packages/solver/src/getPatternScore.ts +0 -23
- package/packages/solver/src/getUniquePatterns.ts +0 -19
- package/packages/types/build/CollisionJson.d.ts +0 -2
- package/packages/types/build/FinalPattern.d.ts +0 -6
- package/packages/types/build/FinalPattern.js +0 -14
- package/packages/types/build/HorizontalPattern.d.ts +0 -5
- package/packages/types/build/HorizontalPattern.js +0 -34
- package/packages/types/build/Pattern.d.ts +0 -20
- package/packages/types/build/Pattern.js +0 -50
- package/packages/types/build/PatternJson.d.ts +0 -6
- package/packages/types/build/VerticalPattern.d.ts +0 -5
- package/packages/types/build/VerticalPattern.js +0 -34
- package/packages/types/src/CollisionJson.ts +0 -3
- package/packages/types/src/FinalPattern.ts +0 -14
- package/packages/types/src/HorizontalPattern.ts +0 -37
- package/packages/types/src/Pattern.ts +0 -71
- package/packages/types/src/PatternJson.ts +0 -7
- package/packages/types/src/VerticalPattern.ts +0 -37
- /package/{cypress/support → e2e}/lib/index.ts +0 -0
- /package/packages/scrabble-solver/.next/static/{vYpMmuC_yEurZGcyGVnLf → FL82obwzFHRzvV6z76I-U}/_ssgManifest.js +0 -0
- /package/packages/scrabble-solver/src/{service-worker → solver-worker}/dictionaries/expirationManager.ts +0 -0
- /package/packages/scrabble-solver/src/{service-worker → solver-worker}/dictionaries/getDictionaryUrl.ts +0 -0
- /package/packages/types/build/{CollisionJson.js → Direction.js} +0 -0
- /package/packages/types/build/{PatternJson.js → TextDirection.js} +0 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { cacheAppShell, respondToNavigation } from './appShell';
|
|
4
|
+
|
|
5
|
+
const entries = new Map<string, Response>();
|
|
6
|
+
|
|
7
|
+
let respond: () => Promise<Response> = () => Promise.resolve(html('shell'));
|
|
8
|
+
|
|
9
|
+
const globals = { caches: globalThis.caches, fetch: globalThis.fetch };
|
|
10
|
+
|
|
11
|
+
Object.assign(globalThis, {
|
|
12
|
+
caches: createCacheStorage(entries),
|
|
13
|
+
fetch: () => respond(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
Object.assign(globalThis, globals);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('respondToNavigation', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
entries.clear();
|
|
23
|
+
respond = () => Promise.resolve(html('shell'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('serves the network response while online', async () => {
|
|
27
|
+
await cacheAppShell();
|
|
28
|
+
respond = () => Promise.resolve(html('deployed shell'));
|
|
29
|
+
const response = await respondToNavigation({ request: navigation('/') });
|
|
30
|
+
expect(await response.text()).toEqual('deployed shell');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('serves the shell cached on install when the network is gone', async () => {
|
|
34
|
+
await cacheAppShell();
|
|
35
|
+
respond = () => Promise.reject(new Error('offline'));
|
|
36
|
+
const response = await respondToNavigation({ request: navigation('/') });
|
|
37
|
+
expect(await response.text()).toEqual('shell');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('serves the shell for any navigated path', async () => {
|
|
41
|
+
await cacheAppShell();
|
|
42
|
+
respond = () => Promise.reject(new Error('offline'));
|
|
43
|
+
const response = await respondToNavigation({ request: navigation('/not-found') });
|
|
44
|
+
expect(await response.text()).toEqual('shell');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The cached shell links to the chunks precached next to it, so a navigation
|
|
49
|
+
* must not replace it with HTML from a build this worker did not precache.
|
|
50
|
+
*/
|
|
51
|
+
it('keeps the installed shell when a newer one is served from the network', async () => {
|
|
52
|
+
await cacheAppShell();
|
|
53
|
+
respond = () => Promise.resolve(html('deployed shell'));
|
|
54
|
+
await respondToNavigation({ request: navigation('/') });
|
|
55
|
+
|
|
56
|
+
respond = () => Promise.reject(new Error('offline'));
|
|
57
|
+
const response = await respondToNavigation({ request: navigation('/') });
|
|
58
|
+
expect(await response.text()).toEqual('shell');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rethrows when there is nothing to fall back on', async () => {
|
|
62
|
+
respond = () => Promise.reject(new Error('offline'));
|
|
63
|
+
const error = await respondToNavigation({ request: navigation('/') }).catch((reason: Error) => reason);
|
|
64
|
+
expect(error).toEqual(new Error('offline'));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function navigation(url: string): Request {
|
|
69
|
+
return new Request(new URL(url, 'https://scrabble-solver.org'));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function html(body: string): Response {
|
|
73
|
+
return new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createCacheStorage(cacheEntries: Map<string, Response>): CacheStorage {
|
|
77
|
+
return {
|
|
78
|
+
open: (name: string) =>
|
|
79
|
+
Promise.resolve({
|
|
80
|
+
add: async (url: string) => {
|
|
81
|
+
cacheEntries.set(`${name} ${url}`, await globalThis.fetch(url));
|
|
82
|
+
},
|
|
83
|
+
match: (url: string) => Promise.resolve(cacheEntries.get(`${name} ${url}`)?.clone()),
|
|
84
|
+
}),
|
|
85
|
+
} as unknown as CacheStorage;
|
|
86
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { NavigationRoute, registerRoute } from 'workbox-routing';
|
|
2
|
+
|
|
3
|
+
const APP_SHELL_CACHE = 'app-shell';
|
|
4
|
+
const APP_SHELL_URL = '/';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Caching the shell on install instead of on navigation pins it to the same build
|
|
8
|
+
* as the chunks precached alongside it, and covers the first visit, whose
|
|
9
|
+
* navigation happens before this worker controls the page.
|
|
10
|
+
*/
|
|
11
|
+
export async function cacheAppShell(): Promise<void> {
|
|
12
|
+
const cache = await caches.open(APP_SHELL_CACHE);
|
|
13
|
+
await cache.add(APP_SHELL_URL);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function routeNavigations(): void {
|
|
17
|
+
registerRoute(new NavigationRoute(respondToNavigation, { denylist: [/^\/api\//] }));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A deploy has to reach online users on their next navigation (see the revalidation
|
|
22
|
+
* headers in next.config.js), so the shell only stands in when the network is gone.
|
|
23
|
+
* Solving keeps working from there, off the dictionary the solver worker holds in
|
|
24
|
+
* the Cache API.
|
|
25
|
+
*/
|
|
26
|
+
export async function respondToNavigation({ request }: { request: Request }): Promise<Response> {
|
|
27
|
+
try {
|
|
28
|
+
return await fetch(request);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const cache = await caches.open(APP_SHELL_CACHE);
|
|
31
|
+
const appShell = await cache.match(APP_SHELL_URL);
|
|
32
|
+
|
|
33
|
+
if (!appShell) {
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return appShell;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { routeVerifyRequests } from './routeVerifyRequests';
|
|
5
|
+
import { cacheAppShell, routeNavigations } from './appShell';
|
|
7
6
|
|
|
8
7
|
declare const self: ServiceWorkerGlobalScope;
|
|
9
8
|
|
|
10
|
-
self.addEventListener('install', () => {
|
|
9
|
+
self.addEventListener('install', (event) => {
|
|
10
|
+
event.waitUntil(cacheAppShell());
|
|
11
11
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
12
12
|
self.skipWaiting();
|
|
13
13
|
});
|
|
@@ -19,5 +19,4 @@ self.addEventListener('activate', () => {
|
|
|
19
19
|
|
|
20
20
|
cleanupOutdatedCaches();
|
|
21
21
|
precacheAndRoute(self.__WB_MANIFEST);
|
|
22
|
-
|
|
23
|
-
routeVerifyRequests();
|
|
22
|
+
routeNavigations();
|
|
@@ -1,26 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
let serviceWorker: Workbox | null = null;
|
|
1
|
+
import { waitForIdle } from '@/lib/waitForIdleOrFirstIntent';
|
|
4
2
|
|
|
5
3
|
export const registerServiceWorker = async () => {
|
|
6
4
|
if (!globalThis.navigator || !('serviceWorker' in globalThis.navigator)) {
|
|
7
5
|
return;
|
|
8
6
|
}
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
await
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export const getServiceWorker = async (): Promise<Workbox | null> => {
|
|
15
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
16
|
-
return null;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (serviceWorker) {
|
|
20
|
-
return serviceWorker;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
await registerServiceWorker();
|
|
24
|
-
|
|
25
|
-
return serviceWorker;
|
|
8
|
+
// Registration waits for load+idle so the install's precache downloads never compete with page resources
|
|
9
|
+
await waitForIdle();
|
|
10
|
+
const { Workbox } = await import('workbox-window');
|
|
11
|
+
await new Workbox('/service-worker.js').register();
|
|
26
12
|
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { type ResultJson } from '@scrabble-solver/types';
|
|
2
|
+
import { afterAll, beforeEach, describe, expect, it } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import type * as clientModule from './client';
|
|
5
|
+
import { type SolverWorkerRequest, type SolverWorkerResponse } from './messages';
|
|
6
|
+
|
|
7
|
+
type Listener = (event: { data: SolverWorkerResponse }) => void;
|
|
8
|
+
|
|
9
|
+
const posted: SolverWorkerRequest[] = [];
|
|
10
|
+
let listeners: Listener[] = [];
|
|
11
|
+
let errorListeners: (() => void)[] = [];
|
|
12
|
+
let terminated = 0;
|
|
13
|
+
let timeouts: (() => void)[] = [];
|
|
14
|
+
|
|
15
|
+
class FakeWorker {
|
|
16
|
+
public postMessage(message: SolverWorkerRequest): void {
|
|
17
|
+
posted.push(message);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public addEventListener(type: string, listener: Listener & (() => void)): void {
|
|
21
|
+
if (type === 'message') {
|
|
22
|
+
listeners.push(listener);
|
|
23
|
+
} else {
|
|
24
|
+
errorListeners.push(listener);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public terminate(): void {
|
|
29
|
+
terminated += 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The client reads these off the global scope on every call, so stubbing them
|
|
34
|
+
// here is what makes the worker round-trip and its timeout observable.
|
|
35
|
+
const globals = { caches: globalThis.caches, clearTimeout, setTimeout, Worker: globalThis.Worker };
|
|
36
|
+
|
|
37
|
+
Object.assign(globalThis, {
|
|
38
|
+
caches: {},
|
|
39
|
+
clearTimeout: () => undefined,
|
|
40
|
+
setTimeout: (callback: () => void) => timeouts.push(callback),
|
|
41
|
+
Worker: FakeWorker,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterAll(() => {
|
|
45
|
+
Object.assign(globalThis, globals);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const { prefetchDictionary, solveLocally, verifyLocally }: typeof clientModule = await import('./client');
|
|
49
|
+
|
|
50
|
+
const solvePayload = { board: [], characters: [], game: 'scrabble', locale: 'en-US' } as never;
|
|
51
|
+
const verifyPayload = { board: [], game: 'scrabble', locale: 'en-US' } as never;
|
|
52
|
+
|
|
53
|
+
describe('solver worker client', () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
posted.length = 0;
|
|
56
|
+
timeouts = [];
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('resolves a solve with the worker results', async () => {
|
|
60
|
+
const solve = solveLocally(solvePayload);
|
|
61
|
+
respond({ data: createResults('ab'), id: lastRequestId(), outcome: 'answered' });
|
|
62
|
+
|
|
63
|
+
expect(await solve).toEqual(createResults('ab'));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('resolves to undefined when the worker cannot answer, so the caller falls back to the server', async () => {
|
|
67
|
+
const solve = solveLocally(solvePayload);
|
|
68
|
+
respond({ id: lastRequestId(), outcome: 'unavailable' });
|
|
69
|
+
|
|
70
|
+
expect(await solve).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('answers a superseded solve with the newer solve results', async () => {
|
|
74
|
+
const stale = solveLocally(solvePayload);
|
|
75
|
+
const staleId = lastRequestId();
|
|
76
|
+
const latest = solveLocally(solvePayload);
|
|
77
|
+
const latestId = lastRequestId();
|
|
78
|
+
|
|
79
|
+
respond({ id: staleId, outcome: 'superseded' });
|
|
80
|
+
respond({ data: createResults('cd'), id: latestId, outcome: 'answered' });
|
|
81
|
+
|
|
82
|
+
expect(await stale).toEqual(createResults('cd'));
|
|
83
|
+
expect(await latest).toEqual(createResults('cd'));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('resolves to undefined when the worker never answers', async () => {
|
|
87
|
+
const solve = solveLocally(solvePayload);
|
|
88
|
+
expect(timeouts).toHaveLength(1);
|
|
89
|
+
timeouts[0]();
|
|
90
|
+
|
|
91
|
+
expect(await solve).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('ignores a late answer to a request that already timed out', async () => {
|
|
95
|
+
const solve = solveLocally(solvePayload);
|
|
96
|
+
const id = lastRequestId();
|
|
97
|
+
timeouts[0]();
|
|
98
|
+
respond({ data: createResults('ef'), id, outcome: 'answered' });
|
|
99
|
+
|
|
100
|
+
expect(await solve).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('resolves a verify with the worker words', async () => {
|
|
104
|
+
const verify = verifyLocally(verifyPayload);
|
|
105
|
+
respond({ data: { invalidWords: ['zz'], validWords: ['ab'] }, id: lastRequestId(), outcome: 'answered' });
|
|
106
|
+
|
|
107
|
+
expect(await verify).toEqual({ invalidWords: ['zz'], validWords: ['ab'] });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('does not wait for a prefetch to answer', () => {
|
|
111
|
+
prefetchDictionary('pl-PL' as never);
|
|
112
|
+
|
|
113
|
+
expect(posted[posted.length - 1].type).toBe('prefetch');
|
|
114
|
+
expect(timeouts).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('settles in-flight requests and discards the worker when it fails', async () => {
|
|
118
|
+
const solve = solveLocally(solvePayload);
|
|
119
|
+
const verify = verifyLocally(verifyPayload);
|
|
120
|
+
const failuresBefore = terminated;
|
|
121
|
+
|
|
122
|
+
for (const listener of errorListeners) {
|
|
123
|
+
listener();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
expect(await solve).toBeUndefined();
|
|
127
|
+
expect(await verify).toBeUndefined();
|
|
128
|
+
expect(terminated).toBe(failuresBefore + 1);
|
|
129
|
+
|
|
130
|
+
listeners = [];
|
|
131
|
+
errorListeners = [];
|
|
132
|
+
const restarted = solveLocally(solvePayload);
|
|
133
|
+
respond({ data: createResults('gh'), id: lastRequestId(), outcome: 'answered' });
|
|
134
|
+
|
|
135
|
+
expect(await restarted).toEqual(createResults('gh'));
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
function respond(response: SolverWorkerResponse): void {
|
|
140
|
+
for (const listener of listeners) {
|
|
141
|
+
listener({ data: response });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function lastRequestId(): number {
|
|
146
|
+
return posted[posted.length - 1].id;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function createResults(word: string): ResultJson[] {
|
|
150
|
+
return [{ blankIndices: [], id: 0, isHorizontal: true, points: 1, tiles: word.split(''), x: 0, y: 0 }];
|
|
151
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type Locale, type ResultJson } from '@scrabble-solver/types';
|
|
2
|
+
|
|
3
|
+
import { type SolveRequestPayload, type VerifyRequestPayload } from '@/types';
|
|
4
|
+
|
|
5
|
+
import { type SolverWorkerRequest, type SolverWorkerResponse, type VerifyResult } from './messages';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A worker that never answers - killed under memory pressure, or a message lost
|
|
9
|
+
* with it - must not hold the UI in a loading state with no server fallback.
|
|
10
|
+
*/
|
|
11
|
+
const REQUEST_TIMEOUT = 30 * 1000;
|
|
12
|
+
|
|
13
|
+
interface PendingRequest {
|
|
14
|
+
resolve: (response: SolverWorkerResponse) => void;
|
|
15
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let worker: Worker | undefined;
|
|
19
|
+
let nextId = 0;
|
|
20
|
+
let latestSolveId = 0;
|
|
21
|
+
let latestSolve: Promise<ResultJson[] | undefined> = Promise.resolve(undefined);
|
|
22
|
+
const pending = new Map<number, PendingRequest>();
|
|
23
|
+
|
|
24
|
+
export function solveLocally(payload: SolveRequestPayload): Promise<ResultJson[] | undefined> {
|
|
25
|
+
const id = ++nextId;
|
|
26
|
+
const solve = readSolveResponse(id, payload);
|
|
27
|
+
latestSolveId = id;
|
|
28
|
+
latestSolve = solve;
|
|
29
|
+
return solve;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function verifyLocally(payload: VerifyRequestPayload): Promise<VerifyResult | undefined> {
|
|
33
|
+
const response = await request({ id: ++nextId, type: 'verify', payload });
|
|
34
|
+
return response.outcome === 'answered' ? (response.data as VerifyResult) : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function prefetchDictionary(locale: Locale): void {
|
|
38
|
+
getWorker()?.postMessage({ id: ++nextId, locale, type: 'prefetch' } satisfies SolverWorkerRequest);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readSolveResponse(id: number, payload: SolveRequestPayload): Promise<ResultJson[] | undefined> {
|
|
42
|
+
const response = await request({ id, type: 'solve', payload });
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The worker skipped this solve for a newer one; answer with the newer result
|
|
46
|
+
* rather than an empty list a caller could render as "no moves found".
|
|
47
|
+
*/
|
|
48
|
+
if (response.outcome === 'superseded' && id !== latestSolveId) {
|
|
49
|
+
return latestSolve;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return response.outcome === 'answered' ? (response.data as ResultJson[]) : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function request(message: SolverWorkerRequest): Promise<SolverWorkerResponse> {
|
|
56
|
+
const targetWorker = getWorker();
|
|
57
|
+
|
|
58
|
+
if (!targetWorker) {
|
|
59
|
+
return Promise.resolve({ id: message.id, outcome: 'unavailable' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const timeout = setTimeout(() => settle({ id: message.id, outcome: 'unavailable' }), REQUEST_TIMEOUT);
|
|
64
|
+
pending.set(message.id, { resolve, timeout });
|
|
65
|
+
targetWorker.postMessage(message);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function settle(response: SolverWorkerResponse): void {
|
|
70
|
+
const entry = pending.get(response.id);
|
|
71
|
+
|
|
72
|
+
if (!entry) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pending.delete(response.id);
|
|
77
|
+
clearTimeout(entry.timeout);
|
|
78
|
+
entry.resolve(response);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getWorker(): Worker | undefined {
|
|
82
|
+
if (typeof Worker === 'undefined' || !('caches' in globalThis)) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!worker) {
|
|
87
|
+
worker = new Worker(new URL('./index.worker.ts', import.meta.url));
|
|
88
|
+
worker.addEventListener('message', ({ data }: MessageEvent<SolverWorkerResponse>) => settle(data));
|
|
89
|
+
worker.addEventListener('error', handleWorkerFailure);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return worker;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Settles every in-flight request as locally unanswerable - callers fall back
|
|
97
|
+
* to the server - and discards the worker so the next call starts a fresh one.
|
|
98
|
+
*/
|
|
99
|
+
function handleWorkerFailure(): void {
|
|
100
|
+
for (const id of pending.keys()) {
|
|
101
|
+
settle({ id, outcome: 'unavailable' });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
worker?.terminate();
|
|
105
|
+
worker = undefined;
|
|
106
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const DICTIONARY_CACHE = 'dictionary-api-cache';
|
|
2
|
+
|
|
3
|
+
export const REVALIDATED_AT_CACHE = 'dictionary-revalidated-at';
|
|
4
|
+
|
|
5
|
+
export const DAY = 24 * 60 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
// Staleness is handled by conditional revalidation; expiration only cleans up
|
|
8
|
+
// dictionaries of locales the user no longer plays.
|
|
9
|
+
export const DICTIONARY_CACHE_MAX_AGE = 30 * DAY;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Locale } from '@scrabble-solver/types';
|
|
2
|
+
|
|
3
|
+
import { DICTIONARY_CACHE } from './constants';
|
|
4
|
+
import { getDictionaryUrl } from './getDictionaryUrl';
|
|
5
|
+
import { resetRevalidationThrottle } from './revalidateDictionary';
|
|
6
|
+
|
|
7
|
+
export async function getDictionary(locale: Locale): Promise<Response | undefined> {
|
|
8
|
+
const cache = await caches.open(DICTIONARY_CACHE);
|
|
9
|
+
return cache.match(getDictionaryUrl(locale));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function deleteDictionary(locale: Locale): Promise<void> {
|
|
13
|
+
const cache = await caches.open(DICTIONARY_CACHE);
|
|
14
|
+
await cache.delete(getDictionaryUrl(locale));
|
|
15
|
+
/**
|
|
16
|
+
* Reset last: resetting first leaves a window in which a revalidation starts,
|
|
17
|
+
* reads the doomed entry's ETag, takes the 304 it answers with, and arms the
|
|
18
|
+
* throttle against a dictionary this call is about to delete.
|
|
19
|
+
*/
|
|
20
|
+
await resetRevalidationThrottle(locale);
|
|
21
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { Locale } from '@scrabble-solver/types';
|
|
2
|
+
import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { DICTIONARY_CACHE, REVALIDATED_AT_CACHE } from './constants';
|
|
5
|
+
import type * as getDictionaryModule from './getDictionary';
|
|
6
|
+
import { getDictionaryUrl } from './getDictionaryUrl';
|
|
7
|
+
import type * as revalidateDictionaryModule from './revalidateDictionary';
|
|
8
|
+
|
|
9
|
+
const HOUR = 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
const fetchCalls: { cache: RequestCache | undefined; ifNoneMatch: string | undefined; url: string }[] = [];
|
|
12
|
+
const updatedTimestamps: string[] = [];
|
|
13
|
+
let respond: () => Promise<Response> = () => Promise.resolve(notModified());
|
|
14
|
+
|
|
15
|
+
await mock.module('./expirationManager', () => ({
|
|
16
|
+
expirationManager: {
|
|
17
|
+
expireEntries: () => Promise.resolve(),
|
|
18
|
+
updateTimestamp: (url: string) => {
|
|
19
|
+
updatedTimestamps.push(url);
|
|
20
|
+
return Promise.resolve();
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const globals = { caches: globalThis.caches, fetch: globalThis.fetch };
|
|
26
|
+
|
|
27
|
+
Object.assign(globalThis, {
|
|
28
|
+
caches: createCacheStorage(),
|
|
29
|
+
fetch: (url: string, { cache, headers }: RequestInit) => {
|
|
30
|
+
fetchCalls.push({ cache, ifNoneMatch: (headers as Record<string, string>)['If-None-Match'], url });
|
|
31
|
+
return respond();
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
Object.assign(globalThis, globals);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const { resetRevalidationThrottle, revalidateDictionary }: typeof revalidateDictionaryModule =
|
|
40
|
+
await import('./revalidateDictionary');
|
|
41
|
+
const { deleteDictionary }: typeof getDictionaryModule = await import('./getDictionary');
|
|
42
|
+
|
|
43
|
+
describe('revalidateDictionary', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
fetchCalls.length = 0;
|
|
46
|
+
updatedTimestamps.length = 0;
|
|
47
|
+
respond = () => Promise.resolve(notModified());
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `no-store` would look equivalent, but makes the browser send
|
|
52
|
+
* `Cache-Control: no-cache`, which Next.js answers with the whole body.
|
|
53
|
+
*/
|
|
54
|
+
it('sends a conditional request the HTTP cache cannot answer', async () => {
|
|
55
|
+
const locale = await unthrottled(Locale.EN_US);
|
|
56
|
+
await cacheDictionary(locale, 'dictionary', '"v1"');
|
|
57
|
+
await revalidateDictionary(locale);
|
|
58
|
+
expect(fetchCalls).toEqual([{ cache: 'no-cache', ifNoneMatch: '"v1"', url: '/api/dictionary/en-US' }]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('keeps the cached body on 304', async () => {
|
|
62
|
+
const locale = await unthrottled(Locale.EN_GB);
|
|
63
|
+
await cacheDictionary(locale, 'dictionary', '"v1"');
|
|
64
|
+
await revalidateDictionary(locale);
|
|
65
|
+
expect(await readCachedDictionary(locale)).toBe('dictionary');
|
|
66
|
+
expect(updatedTimestamps).toEqual([getDictionaryUrl(locale)]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('replaces the cached body on 200', async () => {
|
|
70
|
+
const locale = await unthrottled(Locale.DE_DE);
|
|
71
|
+
await cacheDictionary(locale, 'old', '"v1"');
|
|
72
|
+
respond = () => Promise.resolve(new Response('new', { headers: { etag: '"v2"' } }));
|
|
73
|
+
await revalidateDictionary(locale);
|
|
74
|
+
expect(await readCachedDictionary(locale)).toBe('new');
|
|
75
|
+
expect(updatedTimestamps).toEqual([getDictionaryUrl(locale)]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('omits If-None-Match when nothing is cached', async () => {
|
|
79
|
+
const locale = await unthrottled(Locale.ES_ES);
|
|
80
|
+
respond = () => Promise.resolve(new Response('dictionary'));
|
|
81
|
+
await revalidateDictionary(locale);
|
|
82
|
+
expect(fetchCalls[0].ifNoneMatch).toBeUndefined();
|
|
83
|
+
expect(await readCachedDictionary(locale)).toBe('dictionary');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rejects a failed response without arming the throttle', async () => {
|
|
87
|
+
const locale = await unthrottled(Locale.FA_IR);
|
|
88
|
+
respond = () => Promise.resolve(new Response(null, { status: 503, statusText: 'Service Unavailable' }));
|
|
89
|
+
const failure = await revalidateDictionary(locale).catch((error: Error) => error.message);
|
|
90
|
+
expect(failure).toBe('HTTP 503: Service Unavailable');
|
|
91
|
+
respond = () => Promise.resolve(notModified());
|
|
92
|
+
await revalidateDictionary(locale);
|
|
93
|
+
expect(fetchCalls).toHaveLength(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('revalidates at most once per interval', async () => {
|
|
97
|
+
const locale = await unthrottled(Locale.FR_FR);
|
|
98
|
+
await revalidateDictionary(locale);
|
|
99
|
+
await revalidateDictionary(locale);
|
|
100
|
+
expect(fetchCalls).toHaveLength(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('shares one request between concurrent callers', async () => {
|
|
104
|
+
const locale = await unthrottled(Locale.EN_US);
|
|
105
|
+
await Promise.all([revalidateDictionary(locale), revalidateDictionary(locale), revalidateDictionary(locale)]);
|
|
106
|
+
expect(fetchCalls).toHaveLength(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('persists the throttle, so a later page load can honour it', async () => {
|
|
110
|
+
const locale = await unthrottled(Locale.PL_PL);
|
|
111
|
+
await revalidateDictionary(locale);
|
|
112
|
+
expect(await readPersistedRevalidatedAt(locale)).toBeGreaterThan(Date.now() - HOUR);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* This locale and the next are used nowhere else: the worker is created per
|
|
117
|
+
* page, so only a locale unseen in memory exercises a fresh page's read.
|
|
118
|
+
*/
|
|
119
|
+
it('honours a throttle persisted by an earlier page load', async () => {
|
|
120
|
+
await persistRevalidatedAt(Locale.RO_RO, Date.now() - 1000);
|
|
121
|
+
await revalidateDictionary(Locale.RO_RO);
|
|
122
|
+
expect(fetchCalls).toHaveLength(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('revalidates when a throttle persisted by an earlier page load has expired', async () => {
|
|
126
|
+
await persistRevalidatedAt(Locale.TR_TR, Date.now() - 2 * HOUR);
|
|
127
|
+
await revalidateDictionary(Locale.TR_TR);
|
|
128
|
+
expect(fetchCalls).toHaveLength(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('lets the next access revalidate after the dictionary is deleted', async () => {
|
|
132
|
+
const locale = await unthrottled(Locale.DE_DE);
|
|
133
|
+
await revalidateDictionary(locale);
|
|
134
|
+
await revalidateDictionary(locale);
|
|
135
|
+
await deleteDictionary(locale);
|
|
136
|
+
await revalidateDictionary(locale);
|
|
137
|
+
expect(fetchCalls).toHaveLength(2);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('does not arm the throttle when the dictionary is deleted mid-revalidation', async () => {
|
|
141
|
+
const locale = await unthrottled(Locale.EN_GB);
|
|
142
|
+
let release: (response: Response) => void = () => undefined;
|
|
143
|
+
respond = () => new Promise((resolve) => (release = resolve));
|
|
144
|
+
const revalidation = revalidateDictionary(locale);
|
|
145
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
146
|
+
await resetRevalidationThrottle(locale);
|
|
147
|
+
release(notModified());
|
|
148
|
+
await revalidation;
|
|
149
|
+
respond = () => Promise.resolve(notModified());
|
|
150
|
+
await revalidateDictionary(locale);
|
|
151
|
+
expect(fetchCalls).toHaveLength(2);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/** Throttle state outlives a test, so tests sharing a locale start it un-throttled. */
|
|
156
|
+
async function unthrottled(locale: Locale): Promise<Locale> {
|
|
157
|
+
await resetRevalidationThrottle(locale);
|
|
158
|
+
return locale;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function cacheDictionary(locale: Locale, body: string, etag: string): Promise<void> {
|
|
162
|
+
const cache = await caches.open(DICTIONARY_CACHE);
|
|
163
|
+
await cache.put(getDictionaryUrl(locale), new Response(body, { headers: { etag } }));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function readCachedDictionary(locale: Locale): Promise<string | undefined> {
|
|
167
|
+
const cache = await caches.open(DICTIONARY_CACHE);
|
|
168
|
+
return (await cache.match(getDictionaryUrl(locale)))?.text();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function persistRevalidatedAt(locale: Locale, timestamp: number): Promise<void> {
|
|
172
|
+
const cache = await caches.open(REVALIDATED_AT_CACHE);
|
|
173
|
+
await cache.put(getDictionaryUrl(locale), new Response(String(timestamp)));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function readPersistedRevalidatedAt(locale: Locale): Promise<number> {
|
|
177
|
+
const cache = await caches.open(REVALIDATED_AT_CACHE);
|
|
178
|
+
return Number(await (await cache.match(getDictionaryUrl(locale)))?.text());
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function notModified(): Response {
|
|
182
|
+
return new Response(null, { status: 304 });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function createCacheStorage(): CacheStorage {
|
|
186
|
+
const entries = new Map<string, Response>();
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
open: (name: string) =>
|
|
190
|
+
Promise.resolve({
|
|
191
|
+
delete: (url: string) => Promise.resolve(entries.delete(`${name} ${url}`)),
|
|
192
|
+
match: (url: string) => Promise.resolve(entries.get(`${name} ${url}`)?.clone()),
|
|
193
|
+
put: (url: string, response: Response) => {
|
|
194
|
+
entries.set(`${name} ${url}`, response);
|
|
195
|
+
return Promise.resolve();
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
} as unknown as CacheStorage;
|
|
199
|
+
}
|