diff --git a/.github/workflows/browser-pages.yml b/.github/workflows/browser-pages.yml new file mode 100644 index 00000000..ca380a1b --- /dev/null +++ b/.github/workflows/browser-pages.yml @@ -0,0 +1,87 @@ +name: Build and deploy phone scanner +on: + push: + branches: [browser-scanner] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gridpuzzle-browser-pages + cancel-in-progress: true +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.14' + - uses: actions/setup-node@v6 + with: + node-version: '22' + - run: python -m pip install -e '.[dev]' + - name: Native adapter and full solver regressions + run: python -m pytest -q tests -m 'not slow' + - name: Geometry and model tests + run: node --test web/tests/*.test.js + - name: Build self-hosted application + run: python scripts/build_web.py + - name: Retain build for independent testing + uses: actions/upload-artifact@v7 + with: + name: scanner-static-build + path: _site + retention-days: 7 + - name: Install browser test runtime + run: | + npm install --no-save --package-lock=false --ignore-scripts playwright@1.63.0 + npx playwright install --with-deps chromium webkit + - name: Cross-tab solver update regressions + run: node scripts/solver_update_regressions.cjs + - name: Chromium and mobile WebKit acceptance tests + run: node scripts/browser_smoke.cjs + - name: Scanner variation, review and real-newspaper regressions + run: | + node scripts/browser_regressions.cjs + node scripts/newspaper_regressions.cjs + - name: Upload screenshots and test report + if: always() + uses: actions/upload-artifact@v7 + with: + name: browser-test-report + path: browser-artifacts + if-no-files-found: ignore + - name: Upload static Pages site + uses: actions/upload-pages-artifact@v3 + with: + path: _site + configure: + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + steps: + - name: Check Pages configuration + id: pages + uses: actions/configure-pages@v6 + - name: Explain the one-time repository setting + if: failure() + run: | + echo '::error::Pages is not enabled for Actions. A repository owner must set Settings > Pages > Source to GitHub Actions, then re-run this job. The workflow token cannot grant itself repository administration permission.' + deploy: + needs: [build, configure] + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + # The default github-pages environment may be restricted to the default + # branch. This branch has its own Pages environment so the deployment can + # originate from browser-scanner without weakening that default policy. + environment: + name: gridpuzzle-browser-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Publish the tested branch artifact + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml new file mode 100644 index 00000000..45f4454e --- /dev/null +++ b/.github/workflows/browser-tests.yml @@ -0,0 +1,23 @@ +name: Browser branch tests +on: + pull_request: +permissions: + contents: read +concurrency: + group: browser-tests-${{ github.event.pull_request.number }} + cancel-in-progress: true +jobs: + unit: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Browser model, lifecycle and safety unit tests + run: node --test web/tests/*.test.js + - name: Check every browser module parses + run: find web -name '*.js' -not -path '*/vendor/*' -exec node --check {} \; + - name: Explain full-browser coverage + run: echo 'The browser-scanner push workflow is the single full Chromium/WebKit + Python deployment gate; PR CI intentionally avoids duplicating that expensive suite.' diff --git a/.gitignore b/.gitignore index df8c5edd..3d71f075 100644 --- a/.gitignore +++ b/.gitignore @@ -136,5 +136,11 @@ test.py mypuz/ _test/ solve*.ipynb + +# browser scanner build and acceptance outputs +node_modules/ +_site/ +_preview/ +browser-artifacts/ # ruff .ruff_cache/ diff --git a/Examples/BrowserScanner/Newspaper/2026-09-07-str8ts.webp b/Examples/BrowserScanner/Newspaper/2026-09-07-str8ts.webp new file mode 100644 index 00000000..47ddcbf8 Binary files /dev/null and b/Examples/BrowserScanner/Newspaper/2026-09-07-str8ts.webp differ diff --git a/Examples/BrowserScanner/Newspaper/2026-09-07-sudoku.webp b/Examples/BrowserScanner/Newspaper/2026-09-07-sudoku.webp new file mode 100644 index 00000000..61b6b2b1 Binary files /dev/null and b/Examples/BrowserScanner/Newspaper/2026-09-07-sudoku.webp differ diff --git a/Examples/BrowserScanner/Newspaper/README.md b/Examples/BrowserScanner/Newspaper/README.md new file mode 100644 index 00000000..d18509ab --- /dev/null +++ b/Examples/BrowserScanner/Newspaper/README.md @@ -0,0 +1,11 @@ +# Real newspaper scanner fixtures + +These are user-provided newspaper puzzle photographs from 2026-09-07, cropped to the puzzle grid and downsampled to keep the repository and browser regression suite small. They are real newsprint images, not generated OCR fixtures. + +- `2026-09-07-sudoku.webp`: shaded 9×9 Sudoku with paper texture and uneven illumination. +- `2026-09-07-str8ts.webp`: 9×9 Str8ts with solid black separators, including numbered black cells. +- `ground-truth.json`: hand-checked printed clues and Str8ts black-cell geometry. + +`scripts/newspaper_regressions.cjs` runs the production scanner and self-hosted Tesseract.js pipeline against both images in Chromium and WebKit. Wrong, missing, or invented clues are acceptable only when the corresponding cell is explicitly flagged for review. The suite also enforces minimum correct-transcription counts and exact Str8ts black-cell geometry. + +The images live outside `web/`, so they are not shipped in the GitHub Pages application or included in the offline PWA bundle. diff --git a/Examples/BrowserScanner/Newspaper/ground-truth.json b/Examples/BrowserScanner/Newspaper/ground-truth.json new file mode 100644 index 00000000..c81b506e --- /dev/null +++ b/Examples/BrowserScanner/Newspaper/ground-truth.json @@ -0,0 +1,37 @@ +{ + "fixtures": [ + { + "name": "2026-09-07-sudoku", + "image": "2026-09-07-sudoku.webp", + "type": "sudoku", + "cells": [ + null, null, null, null, 8, null, null, 3, null, + 8, null, null, 7, null, null, null, null, null, + null, null, null, 9, null, 5, null, null, 4, + null, null, null, 2, 9, null, 7, null, null, + 7, null, null, 8, 3, null, null, 2, 5, + null, 1, null, null, null, null, 4, 9, null, + null, null, null, null, null, null, null, 5, null, + null, 8, null, null, 4, null, null, null, null, + 2, 3, null, null, null, 7, null, null, null + ] + }, + { + "name": "2026-09-07-str8ts", + "image": "2026-09-07-str8ts.webp", + "type": "str8ts", + "black": [2, 3, 7, 8, 18, 19, 22, 23, 30, 35, 38, 42, 45, 50, 57, 58, 61, 62, 72, 73, 77, 78], + "cells": [ + 9, null, "#", 6, null, null, null, "#", "#", + null, null, null, 1, 4, null, 6, 5, null, + "#", "#", null, 2, "#", "#", null, null, null, + 3, null, null, "#", 9, null, null, null, 5, + null, null, "#", 8, null, null, "#", null, null, + "#", null, null, null, null, 1, null, null, null, + null, 7, null, 9, "#", null, 3, 2, "#", + null, null, null, 4, null, 5, null, null, 8, + 7, "#", null, null, null, "#", "#", null, null + ] + } + ] +} diff --git a/benchmarks/review3_browser.json b/benchmarks/review3_browser.json new file mode 100644 index 00000000..848f271f --- /dev/null +++ b/benchmarks/review3_browser.json @@ -0,0 +1,263 @@ +[ + { + "browser": "chromium", + "version": "153.0.8010.12", + "checks": [ + "invalid imports rejected atomically; poisoned saved session recovers", + "390px phone layout", + "actual Python 3.14 WASM Sudoku solution", + "browser solver: killersudoku", + "browser solver: futoshiki", + "browser solver: kenken", + "browser solver: latinsquare", + "browser solver: diagonallatinsquare", + "browser solver: pandiagonallatinsquare", + "browser solver: hidato", + "browser solver: numbrix", + "browser solver: kakuro", + "browser solver: slitherlink", + "cell editing, stale-result invalidation and undo", + "type override preserves transcription", + "worker cancellation and clean restart", + "pagehide terminates and resets the interpreter reference", + "reload retains unconfirmed recognition flags", + "camera permission fallback", + "invalid scan box settings rejected before OCR and persistence", + "cancel during actual OCR language initialization; workers stop; fresh scan succeeds", + "real printed-photo OCR, auto grid size, confidence handling", + "photo review, exact solution and overlay", + "adjusted crop invalidates old photo overlay", + "verified offline readiness and poisoned-cache recovery", + "origin-offline reload and Python solve", + "origin-offline photo recognition" + ], + "errors": [], + "external": [], + "scan": { + "type": "sudoku", + "recognized": 30, + "correct": 30, + "unsafe": [], + "corrections": [], + "uncertain": [], + "cells": [ + 5, + 3, + null, + null, + 7, + null, + null, + null, + null, + 6, + null, + null, + 1, + 9, + 5, + null, + null, + null, + null, + 9, + 8, + null, + null, + null, + null, + 6, + null, + 8, + null, + null, + null, + 6, + null, + null, + null, + 3, + 4, + null, + null, + 8, + null, + 3, + null, + null, + 1, + 7, + null, + null, + null, + 2, + null, + null, + null, + 6, + null, + 6, + null, + null, + null, + null, + 2, + 8, + null, + null, + null, + null, + 4, + 1, + 9, + null, + null, + 5, + null, + null, + null, + null, + 8, + null, + null, + 7, + 9 + ] + }, + "manualCorrections": 0, + "offlineMethod": "Origin server stopped and verified unreachable; cache:no-store fetch proves service-worker cache use.", + "ok": true + }, + { + "browser": "webkit", + "version": "26.6", + "checks": [ + "invalid imports rejected atomically; poisoned saved session recovers", + "390px phone layout", + "actual Python 3.14 WASM Sudoku solution", + "browser solver: killersudoku", + "browser solver: futoshiki", + "browser solver: kenken", + "browser solver: latinsquare", + "browser solver: diagonallatinsquare", + "browser solver: pandiagonallatinsquare", + "browser solver: hidato", + "browser solver: numbrix", + "browser solver: kakuro", + "browser solver: slitherlink", + "cell editing, stale-result invalidation and undo", + "type override preserves transcription", + "worker cancellation and clean restart", + "pagehide terminates and resets the interpreter reference", + "reload retains unconfirmed recognition flags", + "camera permission fallback", + "invalid scan box settings rejected before OCR and persistence", + "cancel during actual OCR language initialization; workers stop; fresh scan succeeds", + "real printed-photo OCR, auto grid size, confidence handling", + "photo review, exact solution and overlay", + "adjusted crop invalidates old photo overlay", + "verified offline readiness and poisoned-cache recovery", + "origin-offline reload and Python solve", + "origin-offline photo recognition" + ], + "errors": [], + "external": [], + "scan": { + "type": "sudoku", + "recognized": 30, + "correct": 30, + "unsafe": [], + "corrections": [], + "uncertain": [ + 25, + 27 + ], + "cells": [ + 5, + 3, + null, + null, + 7, + null, + null, + null, + null, + 6, + null, + null, + 1, + 9, + 5, + null, + null, + null, + null, + 9, + 8, + null, + null, + null, + null, + 6, + null, + 8, + null, + null, + null, + 6, + null, + null, + null, + 3, + 4, + null, + null, + 8, + null, + 3, + null, + null, + 1, + 7, + null, + null, + null, + 2, + null, + null, + null, + 6, + null, + 6, + null, + null, + null, + null, + 2, + 8, + null, + null, + null, + null, + 4, + 1, + 9, + null, + null, + 5, + null, + null, + null, + null, + 8, + null, + null, + 7, + 9 + ] + }, + "manualCorrections": 0, + "offlineMethod": "Origin server stopped and verified unreachable; cache:no-store fetch proves service-worker cache use.", + "ok": true + } +] \ No newline at end of file diff --git a/benchmarks/review3_browser.md b/benchmarks/review3_browser.md new file mode 100644 index 00000000..0cadca39 --- /dev/null +++ b/benchmarks/review3_browser.md @@ -0,0 +1,25 @@ +# Review-three browser safety and lifecycle verification + +The browser changes remain on the scanner branch. The native exact-partition and uncapped parallel-error fixes from master cb0819f are included without changing the solver's deduction hierarchy, exact matching/guarantees, branch ordering or positive-cap semantics. + +## Fixes + +- Validate dimensions before allocation and validate box/nested metadata before rendering or persistence. Invalid imported puzzles do not commit state. Malformed saved sessions fall back safely. Scan settings are captured once, checked before explicit-type OCR, and checked again before committing automatic-type results. +- Build in a staged directory and publish only after success. Only the designated repository _site or a new/owned external directory can be replaced. Source paths, Git metadata, repository ancestors, symlinks and unowned existing directories are refused. Older unmarked output directories must be moved aside once; they are never silently deleted. +- Ordinary asset requests, offline readiness and preparation share digest verification. Wrong cached bytes are evicted and refetched; failed network verification is never cached. Readiness checks actual assets sequentially. Cache quota failure does not break a verified online response. +- Every scan owns an OCR host from the beginning of worker initialization. Stop can terminate raw Tesseract children while language loading is pending; cancellation acknowledgements cannot be confused with late progress. Posting failures preserve their original error and clean up workers. Recognition has a whole-task timeout as well as per-worker fallback deadlines. +- Separate task control, edit snapshots, camera/photo flow and offline controls; format first-party sources. Compute grayscale once and run threshold/region preparation off the interface thread. + +## Concurrent work preserved + +The review branch merges scanner commit 9ea64d8 rather than overwriting it. Its grid-stroke/corner rejection is retained inside the new image worker, and guided clue review, nested input validation, recognition timeout, initial offline-button state and scanner variation tests are preserved. Confidence thresholds are not lowered. + +## Completed verification + +GitHub Actions run 34062123312 passed the combined non-slow Python suite, browser unit/lifecycle tests, build and both actual Chromium/mobile-WebKit browser suites. Earlier run 34061886549 additionally exercised the installed wheel for the same native/build code. Final normal PR CI also runs Linux/Windows wheel tests, forward compatibility, both browser suites and the combined regression suite. + +`review3_browser.json` records 27 checks per engine, actual Python and OCR WASM, all eleven families, malformed-import and saved-state recovery, cancellation during real language initialization followed by a fresh scan, poisoned-cache recovery, and offline reload/solving/recognition with the origin server stopped. Both engines read all 30 baseline clues correctly without corrections; WebKit still conservatively flags two correct readings for review. + +`review3_recognition.json` retains the separate generated scan-variation results and guided-review checks from the concurrent patch. Generated fixtures are not a representative real-world recognition benchmark. Physical-phone autofocus, camera behaviour, installation and storage eviction still require hardware testing. The 32 slow tests and full long-running corpora were not rerun. No universal speedup is claimed. + +Temporary source-application scripts and write-enabled validation workflows have been removed. Permanent test workflows remain read-only. diff --git a/benchmarks/review3_recognition.json b/benchmarks/review3_recognition.json new file mode 100644 index 00000000..7930d464 --- /dev/null +++ b/benchmarks/review3_recognition.json @@ -0,0 +1,123 @@ +[ + { + "browser": "chromium", + "version": "153.0.8010.12", + "scans": [ + { + "name": "baseline", + "givens": 30, + "correct": 30, + "discrepancies": [], + "unsafe": [], + "flagged": 0, + "elapsedMs": 1308 + }, + { + "name": "serif", + "givens": 30, + "correct": 30, + "discrepancies": [], + "unsafe": [], + "flagged": 4, + "elapsedMs": 1025 + }, + { + "name": "shifted", + "givens": 30, + "correct": 30, + "discrepancies": [], + "unsafe": [], + "flagged": 2, + "elapsedMs": 984 + }, + { + "name": "perspective-shadow", + "givens": 30, + "correct": 30, + "discrepancies": [], + "unsafe": [], + "flagged": 2, + "elapsedMs": 1001 + }, + { + "name": "four-by-four", + "givens": 12, + "correct": 12, + "discrepancies": [], + "unsafe": [], + "flagged": 0, + "elapsedMs": 798 + } + ], + "checks": [ + "malformed imports rejected without modifying the board", + "save-and-next confirms only the edited cell", + "blank photo rejected without inventing clues" + ], + "errors": [], + "ok": true + }, + { + "browser": "webkit", + "version": "26.6", + "scans": [ + { + "name": "baseline", + "givens": 30, + "correct": 30, + "discrepancies": [], + "unsafe": [], + "flagged": 2, + "elapsedMs": 1372 + }, + { + "name": "serif", + "givens": 30, + "correct": 30, + "discrepancies": [], + "unsafe": [], + "flagged": 2, + "elapsedMs": 1179 + }, + { + "name": "shifted", + "givens": 30, + "correct": 29, + "discrepancies": [ + 0 + ], + "unsafe": [], + "flagged": 4, + "elapsedMs": 1136 + }, + { + "name": "perspective-shadow", + "givens": 30, + "correct": 28, + "discrepancies": [ + 0, + 4 + ], + "unsafe": [], + "flagged": 6, + "elapsedMs": 1147 + }, + { + "name": "four-by-four", + "givens": 12, + "correct": 12, + "discrepancies": [], + "unsafe": [], + "flagged": 0, + "elapsedMs": 744 + } + ], + "checks": [ + "malformed imports rejected without modifying the board", + "save-and-next confirms only the edited cell", + "blank photo rejected without inventing clues" + ], + "errors": [], + "ok": true + } +] \ No newline at end of file diff --git a/gridsolver/grid_classes/str8ts.py b/gridsolver/grid_classes/str8ts.py new file mode 100644 index 00000000..f5ecaef9 --- /dev/null +++ b/gridsolver/grid_classes/str8ts.py @@ -0,0 +1,207 @@ +"""Str8ts puzzle grid: row/column uniqueness plus consecutive streets.""" + +from collections.abc import Iterable, Sequence +from numbers import Integral + +from gridsolver.abstract_grids.grid import Grid, TechniqueProfile +from gridsolver.grid_classes.compact_grid import CompactGrid, _rectangular_rows +from gridsolver.rules.straights import ConsecutiveSetRule +from gridsolver.rules.unique import ElementsAtMostOnce + + +type BoardCell = tuple[int, int] + + +def _cell(raw: object, rows: int, cols: int, description: str) -> BoardCell: + if ( + isinstance(raw, (str, bytes, bytearray)) + or not isinstance(raw, Sequence) + or len(raw) != 2 + or any(isinstance(v, bool) or not isinstance(v, Integral) for v in raw) + ): + raise TypeError(f"Invalid {description} {raw!r}") + row, col = map(int, raw) + if not (0 <= row < rows and 0 <= col < cols): + raise ValueError(f"{description} {(row, col)} is outside a {rows}x{cols} board") + return row, col + + +def _cells( + raw: Iterable[BoardCell], rows: int, cols: int, description: str +) -> frozenset[BoardCell]: + if isinstance(raw, (str, bytes, bytearray)): + raise TypeError(f"{description} must be coordinate pairs") + singular = description[:-1] if description.endswith("s") else description + return frozenset(_cell(item, rows, cols, singular) for item in raw) + + +class Str8ts(CompactGrid): + """Square Str8ts board with optional numbered black cells. + + White cells form horizontal/vertical streets. Every street contains a + consecutive set in arbitrary order. All numbered cells, including clues + printed on black cells, remain unique within their row and column. + """ + + technique_profile = TechniqueProfile.RULES_ONLY + + def __init__( + self, + board_rows: int = 9, + board_cols: int | None = None, + *, + black: Iterable[BoardCell] = (), + numbered_black: Iterable[BoardCell] = (), + ) -> None: + for name, value in ( + ("board_rows", board_rows), + ("board_cols", board_cols if board_cols is not None else board_rows), + ): + if isinstance(value, bool) or not isinstance(value, Integral): + raise TypeError(f"{name} must be an integer") + board_rows = int(board_rows) + board_cols = board_rows if board_cols is None else int(board_cols) + if board_rows != board_cols or not 2 <= board_rows <= 9: + raise ValueError("Str8ts requires a square board from 2x2 through 9x9") + + black_cells = _cells(black, board_rows, board_cols, "black cells") + numbered = _cells( + numbered_black, board_rows, board_cols, "numbered black cells" + ) + if not numbered <= black_cells: + raise ValueError("Numbered black cells must also be black cells") + + all_cells = frozenset( + (row, col) + for row in range(board_rows) + for col in range(board_cols) + ) + white = all_cells - black_cells + if not white: + raise ValueError("Str8ts requires at least one white cell") + active = tuple(sorted(white | numbered)) + super().__init__(active, max_elem=board_rows) + self.board_rows = board_rows + self.board_cols = board_cols + self.black = black_cells + self.numbered_black = numbered + self.white_cells = white + + rules = [] + for row in range(board_rows): + group = [self.compact_cell(cell) for cell in active if cell[0] == row] + if len(group) > 1: + rules.append(ElementsAtMostOnce(self, cells=group)) + for col in range(board_cols): + group = [self.compact_cell(cell) for cell in active if cell[1] == col] + if len(group) > 1: + rules.append(ElementsAtMostOnce(self, cells=group)) + + streets: list[tuple[BoardCell, ...]] = [] + for row in range(board_rows): + run: list[BoardCell] = [] + for col in range(board_cols + 1): + cell = (row, col) + if col < board_cols and cell in white: + run.append(cell) + else: + if len(run) > 1: + street = tuple(run) + streets.append(street) + rules.append( + ConsecutiveSetRule( + self, [self.compact_cell(x) for x in street] + ) + ) + run = [] + for col in range(board_cols): + run = [] + for row in range(board_rows + 1): + cell = (row, col) + if row < board_rows and cell in white: + run.append(cell) + else: + if len(run) > 1: + street = tuple(run) + streets.append(street) + rules.append( + ConsecutiveSetRule( + self, [self.compact_cell(x) for x in street] + ) + ) + run = [] + self.streets = tuple(streets) + self.add_rules_checked(rules) + + def _copy_extra_state_to(self, result: Grid) -> None: + super()._copy_extra_state_to(result) + result.board_rows = self.board_rows + result.board_cols = self.board_cols + result.black = self.black + result.numbered_black = self.numbered_black + result.white_cells = self.white_cells + result.streets = self.streets + + @classmethod + def from_board( + cls, + board: Sequence[Sequence[object]], + *, + black: Iterable[BoardCell] = (), + ) -> "Str8ts": + rows = _rectangular_rows(board, "Str8ts board") + n = len(rows) + if len(rows[0]) != n: + raise ValueError("Str8ts requires a square board") + explicit_black = set(_cells(black, n, n, "black cells")) + givens: dict[BoardCell, int] = {} + for row, values in enumerate(rows): + for col, raw in enumerate(values): + key = (row, col) + if raw is None: + continue + if isinstance(raw, str): + token = raw.strip() + if token.upper() in {"#", "B"}: + explicit_black.add(key) + continue + if token in {"", ".", "0"}: + continue + if not token.isascii() or not token.isdigit(): + raise ValueError( + f"Cannot parse Str8ts value {raw!r} at {key}" + ) + value = int(token) + elif isinstance(raw, bool) or not isinstance(raw, Integral): + raise TypeError( + f"Str8ts value at {key} must be an integer, blank, or #" + ) + else: + value = int(raw) + if value == 0: + continue + if not 1 <= value <= n: + raise ValueError( + f"Str8ts value {value} at {key} is outside 1..{n}" + ) + givens[key] = value + numbered = set(givens) & explicit_black + grid = cls(n, n, black=explicit_black, numbered_black=numbered) + grid.load_key_values(givens) + return grid + + def format_solution(self, values: Sequence[int]) -> str: + keyed = self.values_by_key(values) + lines = [] + for row in range(self.board_rows): + rendered = [] + for col in range(self.board_cols): + key = (row, col) + if key in self.black and key not in keyed: + rendered.append("#") + elif key in self.black: + rendered.append(f"[{keyed[key]}]") + else: + rendered.append(str(keyed[key])) + lines.append(" ".join(rendered)) + return "\n".join(lines) diff --git a/gridsolver/rules/straights.py b/gridsolver/rules/straights.py new file mode 100644 index 00000000..e6e09577 --- /dev/null +++ b/gridsolver/rules/straights.py @@ -0,0 +1,82 @@ +"""Consecutive-set rule used by Str8ts streets.""" + +from collections.abc import Iterable, MutableSequence + +from gridsolver.abstract_grids.gridsize_container import GridSizeContainer +from gridsolver.rules.rules import Guarantee, InvalidGrid, Rule, RuleAlwaysSatisfied + + +class ConsecutiveSetRule(Rule): + """Require cells to contain distinct consecutive values in any order. + + A length-k street must be exactly one of the intervals + ``{a, ..., a+k-1}``. Candidate pruning keeps only values supported by at + least one perfect matching to a feasible interval. Street sizes in + Str8ts are at most nine, so the exact matching check is tiny. + """ + + __slots__ = () + + def __init__(self, gsz: GridSizeContainer, cells: Iterable[int]) -> None: + super().__init__(gsz, cells, None) + self.cells = tuple(sorted(self.cells)) + if self.len_cells > self._max_elem: + raise ValueError("A consecutive set cannot be longer than its value domain") + + @staticmethod + def _matching_exists(options: tuple[frozenset[int], ...]) -> bool: + if any(not values for values in options): + return False + order = tuple(sorted(range(len(options)), key=lambda i: len(options[i]))) + + def visit(position: int, used: int) -> bool: + if position == len(order): + return True + for value in options[order[position]]: + bit = 1 << (value - 1) + if not used & bit and visit(position + 1, used | bit): + return True + return False + + return visit(0, 0) + + def apply( + self, + known: MutableSequence[int], + candidates: tuple[set[int], ...], + guarantees: Iterable[Guarantee] | None = None, + ) -> tuple[bool, None, None]: + if self.len_cells <= 1: + raise RuleAlwaysSatisfied() + + fixed = [known[cell] for cell in self.cells] + if all(value > 0 for value in fixed): + if ( + len(set(fixed)) != self.len_cells + or max(fixed) - min(fixed) != self.len_cells - 1 + ): + raise InvalidGrid() + raise RuleAlwaysSatisfied() + + supported = {cell: set() for cell in self.cells} + for lower in range(1, self._max_elem - self.len_cells + 2): + interval = frozenset(range(lower, lower + self.len_cells)) + options = tuple( + frozenset(candidates[cell] & interval) for cell in self.cells + ) + if not self._matching_exists(options): + continue + for index, cell in enumerate(self.cells): + for value in options[index]: + forced = list(options) + forced[index] = frozenset((value,)) + if self._matching_exists(tuple(forced)): + supported[cell].add(value) + + if any(not values for values in supported.values()): + raise InvalidGrid() + for cell, values in supported.items(): + candidates[cell].intersection_update(values) + if not candidates[cell]: + raise InvalidGrid() + return False, None, None diff --git a/gridsolver/web_api.py b/gridsolver/web_api.py new file mode 100644 index 00000000..2e13042b --- /dev/null +++ b/gridsolver/web_api.py @@ -0,0 +1,255 @@ +"""Data-only browser boundary. No eval, module loading, or weaker solver mode. + +Coordinates at this boundary are zero-based, ROW-MAJOR flat indexes. The +existing engine remains column-major internally; never serialize it by list(). +Null means an empty cell. '#' means blocked. Slitherlink zero is a real clue. +""" +from __future__ import annotations + +import json +import time +from collections import namedtuple +from collections.abc import Mapping + +from gridsolver.abstract_grids.grid import Grid +from gridsolver.grid_classes.sudoku import Sudoku +from gridsolver.grid_classes.killer_sudoku import KillerSudoku +from gridsolver.grid_classes.kenken import Kenken +from gridsolver.grid_classes.futoshiki import Futoshiki +from gridsolver.grid_classes.latins_square import ( + LatinSquare, DiagonalLatinSquare, PandiagonalLatinSquare, +) +from gridsolver.grid_classes.path_puzzles import Hidato, Numbrix +from gridsolver.grid_classes.kakuro import Kakuro +from gridsolver.grid_classes.slitherlink import Slitherlink +from gridsolver.grid_classes.str8ts import Str8ts +from gridsolver.solver.solver import solve + +TYPES = ( + 'sudoku', 'killersudoku', 'futoshiki', 'kenken', 'latinsquare', + 'diagonallatinsquare', 'pandiagonallatinsquare', 'hidato', 'numbrix', + 'kakuro', 'slitherlink', 'str8ts', +) +_ALLOWED = {'version', 'type', 'rows', 'cols', 'boxRows', 'boxCols', + 'cells', 'cages', 'inequalities', 'clues', 'black'} +_Cage = namedtuple('BrowserCage', 'mytarget cells operator') + + +def _integer(value, name, lo, hi): + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f'{name} must be an integer') + if not lo <= value <= hi: + raise ValueError(f'{name} must be between {lo} and {hi}') + return value + + +def _array(value, name, maximum): + if not isinstance(value, list) or len(value) > maximum: + raise ValueError(f'{name} must be an array of at most {maximum} entries') + return value + + +def _object(value, allowed, name): + if not isinstance(value, Mapping): + raise ValueError(f'{name} must be an object') + unknown = set(value) - allowed + if unknown: + raise ValueError(f'Unsupported {name} fields: {sorted(unknown)}') + return value + + +def build_grid(payload): + """Validate JSON-shaped data and construct the existing puzzle classes.""" + p = _object(payload, _ALLOWED, 'puzzle') + _integer(p.get('version', 1), 'version', 1, 1) + kind = p.get('type') + if kind not in TYPES: + raise ValueError('Choose a supported puzzle type; Automatic is a scanner setting') + rows = _integer(p.get('rows'), 'rows', 1, 25) + cols = _integer(p.get('cols'), 'cols', 1, 25) + count = rows * cols + raw = _array(p.get('cells'), 'cells', count) + if len(raw) != count: + raise ValueError(f'Expected exactly {count} row-major cells') + cages = _array(p.get('cages', []), 'cages', count) + inequalities = _array(p.get('inequalities', []), 'inequalities', 2 * count) + clues = _array(p.get('clues', []), 'clues', count) + black_raw = _array(p.get('black', []), 'black', count) + black_cells = {_integer(i, 'Black cell', 0, count - 1) for i in black_raw} + if len(black_cells) != len(black_raw): + raise ValueError('Black cells must be distinct') + if black_cells and kind != 'str8ts': + raise ValueError('Black-cell metadata is only supported for Str8ts') + if cages and kind not in ('killersudoku', 'kenken'): + raise ValueError('Cages are only supported for Killer Sudoku and KenKen') + if inequalities and kind != 'futoshiki': + raise ValueError('Inequalities require Futoshiki') + if clues and kind != 'kakuro': + raise ValueError('Across/down clues require Kakuro') + dense = kind not in ('hidato', 'numbrix', 'kakuro', 'slitherlink', 'str8ts') + if dense and rows != cols: + raise ValueError('This puzzle type requires a square board') + blocked = {i for i, v in enumerate(raw) if v == '#'} + if blocked and kind not in ('hidato', 'kakuro', 'str8ts'): + raise ValueError('Blocked cells are only supported in Hidato, Kakuro and Str8ts') + if kind == 'str8ts': + if rows != cols or rows > 9: + raise ValueError('Str8ts requires a square board no larger than 9x9') + if blocked - black_cells: + raise ValueError('Every # Str8ts cell must be listed in black') + if any(i in black_cells and raw[i] is None for i in range(count)): + raise ValueError('A Str8ts black cell must contain # or a numbered clue') + maximum = 4 if kind == 'slitherlink' else ( + count - len(blocked) if kind in ('hidato', 'numbrix') else + 9 if kind == 'kakuro' else rows + ) + values = [] + for i, value in enumerate(raw): + if value is None or value == '#': + values.append(value) + else: + values.append(_integer(value, f'Cell {i + 1}', + 0 if kind == 'slitherlink' else 1, maximum)) + + def coord(i): + return divmod(i, cols) + + if kind in ('sudoku', 'killersudoku'): + br = _integer(p.get('boxRows', 3), 'boxRows', 1, rows) + bc = _integer(p.get('boxCols', 3), 'boxCols', 1, cols) + if br * bc != rows or rows % br or cols % bc: + raise ValueError('Box dimensions must tile the board and contain one of each value') + cls = Sudoku if kind == 'sudoku' else KillerSudoku + grid = cls(rows_in_box=br, cols_in_box=bc, box_rows=rows // br, box_cols=cols // bc) + elif kind == 'kenken': + grid = Kenken(n=rows) + elif kind == 'futoshiki': + grid = Futoshiki(rows) + elif kind in ('latinsquare', 'diagonallatinsquare', 'pandiagonallatinsquare'): + grid = {'latinsquare': LatinSquare, 'diagonallatinsquare': DiagonalLatinSquare, + 'pandiagonallatinsquare': PandiagonalLatinSquare}[kind](rows) + elif kind in ('hidato', 'numbrix'): + cls = Hidato if kind == 'hidato' else Numbrix + grid = cls.from_board([values[r * cols:(r + 1) * cols] for r in range(rows)]) + elif kind == 'slitherlink': + grid = Slitherlink([values[r * cols:(r + 1) * cols] for r in range(rows)]) + elif kind == 'str8ts': + numbered = {i for i in black_cells if isinstance(values[i], int)} + grid = Str8ts(rows, cols, black=[coord(i) for i in black_cells], + numbered_black=[coord(i) for i in numbered]) + grid.load_key_values({coord(i): value for i, value in enumerate(values) + if isinstance(value, int)}) + else: + white = set(range(count)) - blocked + runs, seen = [], set() + for clue in clues: + _object(clue, {'cell', 'across', 'down'}, 'Kakuro clue') + at = _integer(clue.get('cell'), 'Clue cell', 0, count - 1) + if at not in blocked or at in seen: + raise ValueError('Each Kakuro clue must occupy a distinct blocked cell') + seen.add(at) + if clue.get('across') is None and clue.get('down') is None: + raise ValueError('A clue needs an across or down target') + r, c = coord(at) + for direction, dr, dc in (('across', 0, 1), ('down', 1, 0)): + target = clue.get(direction) + if target is None: + continue + target = _integer(target, f'{direction} target', 1, 45) + cells = [] + rr, cc = r + dr, c + dc + while 0 <= rr < rows and 0 <= cc < cols and rr * cols + cc in white: + cells.append((rr, cc)) + rr, cc = rr + dr, cc + dc + runs.append((target, cells)) + grid = Kakuro(rows, cols, [coord(i) for i in white], runs) + grid.load_key_values({coord(i): v for i, v in enumerate(values) + if v is not None and v != '#'}) + if kind in ('killersudoku', 'kenken'): + covered, entries = set(), [] + for cage in cages: + _object(cage, {'cells', 'target', 'op'}, 'cage') + indices = _array(cage.get('cells'), 'cage cells', count) + if not indices: + raise ValueError('Cages must not be empty') + indices = [_integer(i, 'Cage cell', 0, count - 1) for i in indices] + area = set(indices) + if len(area) != len(indices) or covered & area: + raise ValueError('Cages may not overlap or repeat cells') + reached, pending = {indices[0]}, [indices[0]] + while pending: + r, c = coord(pending.pop()) + for rr, cc in ((r-1, c), (r+1, c), (r, c-1), (r, c+1)): + nxt = rr * cols + cc + if 0 <= rr < rows and 0 <= cc < cols and nxt in area - reached: + reached.add(nxt) + pending.append(nxt) + if reached != area: + raise ValueError('Cage cells must be orthogonally connected') + covered |= area + target = _integer(cage.get('target'), 'Cage target', 1, 10**12) + op = cage.get('op', '+') + if op == '=' and len(area) == 1: + op = '+' + if op not in ('+', '-', '*', '/') or (kind == 'killersudoku' and op != '+'): + raise ValueError('Unsupported cage operator') + if op in ('-', '/') and len(area) != 2: + raise ValueError('Difference and division cages require exactly two cells') + cells = [coord(i) for i in indices] + entries.append((target, cells) if kind == 'killersudoku' else _Cage(target, cells, op)) + if covered != set(range(count)): + raise ValueError(f'Cages must cover every cell; {count - len(covered)} cells need a cage') + if kind == 'killersudoku': + grid.ext_sum_cells(entries) + else: + grid.ext_target_cells(entries) + if kind == 'futoshiki': + pairs = [] + for item in inequalities: + _object(item, {'less', 'greater'}, 'inequality') + a = _integer(item.get('less'), 'Smaller cell', 0, count - 1) + b = _integer(item.get('greater'), 'Larger cell', 0, count - 1) + pairs.append((coord(a), coord(b))) + grid.ext_ineqs(pairs) + if dense: + # Bypass family-specific text/cage loaders, NOT the constraint engine. + Grid.load(grid, [0 if value is None else value for value in values], row_wise=True) + return grid + + +def solve_payload(payload): + """Finish a search capped at two; unfinished searches never imply uniqueness. + + The worker owns deadlines and cancellation by terminating its interpreter. + Both results are independently validated by the existing solver.solve(). + """ + started = time.perf_counter() + grid = build_grid(payload) + solutions = solve(grid, processes=0, max_sols=2, log_level=-1) + rendered = [] + rows, cols = payload['rows'], payload['cols'] + for solution in sorted(solutions, key=lambda s: tuple(s)): + if isinstance(grid, Slitherlink): + rendered.append({'cells': list(payload['cells']), + 'edges': [list(edge) for edge in sorted(grid.selected_edges(solution))]}) + elif hasattr(grid, 'values_by_key'): + keyed = grid.values_by_key(solution) + rendered.append({'cells': [keyed.get((r, c), '#') for r in range(rows) for c in range(cols)], + 'edges': []}) + else: + rendered.append({'cells': [solution[r, c] for r in range(rows) for c in range(cols)], + 'edges': []}) + return {'status': ('no-solution', 'unique', 'multiple')[len(rendered)], + 'solutions': rendered, 'elapsed': time.perf_counter() - started, + 'complete': True, 'countIsLowerBound': len(rendered) == 2} + + +def solve_json(text): + if not isinstance(text, str) or len(text) > 200_000: + return json.dumps({'status': 'invalid', 'message': 'Puzzle data is too large'}) + try: + payload = json.loads(text) + result = solve_payload(payload) + except (TypeError, ValueError, KeyError) as exc: + result = {'status': 'invalid', 'message': str(exc)} + return json.dumps(result) diff --git a/scripts/browser_regressions.cjs b/scripts/browser_regressions.cjs new file mode 100644 index 00000000..cc14876c --- /dev/null +++ b/scripts/browser_regressions.cjs @@ -0,0 +1,651 @@ +/* Additional real-browser scanner and input-boundary regressions. */ +const { chromium, webkit } = require("playwright"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const BASE = "http://127.0.0.1:8766/GridPuzzle/"; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +fs.mkdirSync("_preview", { recursive: true }); +fs.mkdirSync("browser-artifacts", { recursive: true }); +if (!fs.existsSync("_preview/GridPuzzle")) + fs.symlinkSync(path.resolve("_site"), "_preview/GridPuzzle", "dir"); +const server = spawn( + "python", + [ + "-m", + "http.server", + "8766", + "--bind", + "127.0.0.1", + "--directory", + "_preview", + ], + { stdio: "ignore" }, +); +const reports = []; + +// Draw known clues without reading any production OCR output. The perspective +// fixture is a projective transform of the entire image, not just a CSS tilt. +async function fixture(options) { + const { demo } = await import("./model.js"); + const { homography, project } = await import("./geometry.js"); + const small = options.small || options.path, + n = small ? 4 : 9, + boxRows = small ? 2 : 3, + boxCols = small ? 2 : 3; + const cells = options.path + ? [1, null, null, 4, 8, null, 6, null, null, 10, null, 12, 16, null, null, null] + : small + ? [1, null, 3, 4, 3, 4, null, 2, 2, 1, 4, null, null, 3, 2, 1] + : demo().cells; + const c = document.createElement("canvas"); + c.width = c.height = 660; + const ctx = c.getContext("2d"), + cw = 576 / n; + ctx.fillStyle = "white"; + if (!options.transparent) ctx.fillRect(0, 0, 660, 660); + ctx.strokeStyle = "black"; + for (let i = 0; i <= n; i++) { + ctx.lineWidth = i % boxCols === 0 ? 5 : 2; + ctx.beginPath(); + ctx.moveTo(42 + i * cw, 42); + ctx.lineTo(42 + i * cw, 618); + ctx.stroke(); + ctx.lineWidth = i % boxRows === 0 ? 5 : 2; + ctx.beginPath(); + ctx.moveTo(42, 42 + i * cw); + ctx.lineTo(618, 42 + i * cw); + ctx.stroke(); + } + ctx.font = `${small ? 70 : 38}px ${options.font || "Arial"}`; + ctx.fillStyle = "black"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + cells.forEach((v, i) => { + if (v !== null) + ctx.fillText( + String(v), + 42 + ((i % n) + 0.5) * cw + (options.shiftX || 0), + 42 + (Math.floor(i / n) + 0.5) * cw + 1 + (options.shiftY || 0), + ); + }); + let output = c; + if (options.perspective) { + output = document.createElement("canvas"); + output.width = output.height = 760; + const out = output.getContext("2d"), + image = out.createImageData(760, 760), + source = ctx.getImageData(0, 0, 660, 660); + const m = homography([ + { x: 52, y: 95 }, + { x: 660, y: 30 }, + { x: 724, y: 659 }, + { x: 19, y: 712 }, + ]); + const [a, b, k, d, e, f, g, h] = m, + I = a * e - b * d; + const inverse = [ + e - f * h, + k * h - b, + b * f - k * e, + f * g - d, + a - k * g, + k * d - a * f, + d * h - e * g, + b * g - a * h, + ].map((x) => x / I); + for (let y = 0; y < 760; y++) + for (let x = 0; x < 760; x++) { + const p = project(inverse, x, y), + at = (y * 760 + x) * 4; + const value = + p.x < 0 || p.y < 0 || p.x > 1 || p.y > 1 + ? 255 + : source.data[ + (Math.round(p.y * 659) * 660 + Math.round(p.x * 659)) * 4 + ]; + const shaded = value * (0.6 + (0.4 * x) / 759); + image.data[at] = image.data[at + 1] = image.data[at + 2] = shaded; + image.data[at + 3] = 255; + } + out.putImageData(image, 0, 0); + } + if (options.binary) { + // Match the production rectification size exactly so interpolation cannot + // hide a threshold-zero regression by introducing intermediate greys. + output = document.createElement("canvas"); + output.width = output.height = n * 100; + const out = output.getContext("2d"); + out.fillStyle = "white"; + out.fillRect(0, 0, output.width, output.height); + out.fillStyle = "black"; + for (let i = 0; i <= n; i++) { + const at = Math.min(output.width - 1, i * 100); + out.fillRect(at, 0, 2, output.height); + out.fillRect(0, at, output.width, 2); + } + out.font = "52px Arial"; + out.textAlign = "center"; + out.textBaseline = "middle"; + cells.forEach((v, i) => { + if (v !== null) out.fillText(String(v), (i % n + 0.5) * 100, (Math.floor(i / n) + 0.5) * 100); + }); + const pixels = out.getImageData(0, 0, output.width, output.height); + for (let i = 0; i < pixels.data.length; i += 4) + pixels.data[i] = pixels.data[i + 1] = pixels.data[i + 2] = pixels.data[i] < 128 ? 0 : 255; + out.putImageData(pixels, 0, 0); + } + return { image: output.toDataURL("image/png").split(",")[1], cells, n, type: options.path ? "numbrix" : "sudoku" }; +} +async function ready(page) { + await page.waitForSelector('body[data-ready="true"]'); + await page.evaluate(async () => { + window.testState = (await import("./app.js")).getState; + }); +} +async function scan(page, name, options) { + const f = await page.evaluate(fixture, options); + await page.evaluate(async () => { + const app = await import("./app.js"), + { makePuzzle } = await import("./model.js"); + app.loadPuzzle(makePuzzle()); + }); + await page.selectOption("#puzzle-type", f.type === "sudoku" ? "auto" : f.type); + await page.locator("#auto-solve").evaluate((el) => { + el.checked = false; + }); + const start = Date.now(); + if (options.fallback) + await page.evaluate(() => { + window.testImageBitmap = window.createImageBitmap; + window.createImageBitmap = undefined; + }); + try { + await page.setInputFiles("#photo-file", { + name: `${name}.png`, + mimeType: "image/png", + buffer: Buffer.from(f.image, "base64"), + }); + await page.waitForFunction( + () => + /^(Grid found\.|Set the four crop corners\.)$/.test( + document.querySelector("#status-text").textContent, + ), + null, + { timeout: 20000 }, + ); + } finally { + if (options.fallback) + await page.evaluate(() => { + window.createImageBitmap = window.testImageBitmap; + delete window.testImageBitmap; + }); + } + assert.equal( + Number(await page.inputValue("#rows")), + f.n, + `${name}: detected rows`, + ); + assert.equal( + Number(await page.inputValue("#cols")), + f.n, + `${name}: detected columns`, + ); + if (options.layout) { + await page.click("#clean-view"); + await page.selectOption("#edit-tool", "value"); + assert.equal(await page.inputValue("#rows"), "4", "Board preserves detected rows"); + assert.equal(await page.inputValue("#cols"), "4", "Board preserves detected columns"); + assert.equal(await page.inputValue("#box-rows"), "2", "Board preserves detected boxes"); + if (!await page.locator("#rows").isVisible()) + await page.getByText("Grid size & settings", { exact: true }).click(); + await page.fill("#rows", ""); + await page.fill("#box-rows", "1"); + await page.fill("#box-cols", "4"); + await page.click("#clean-view"); + assert.equal(await page.inputValue("#rows"), "", "incomplete layout input remains editable"); + assert.equal(await page.inputValue("#box-rows"), "1"); + assert.equal(await page.inputValue("#box-cols"), "4"); + await page.fill("#rows", "4"); + } + await page.click("#read-photo"); + await page.waitForFunction(() => !window.testState().busy, null, { + timeout: 120000, + }); + const s = await page.evaluate(() => window.testState()); + assert.equal(s.puzzle.type, f.type, `${name}: unexpected puzzle type`); + const wrong = f.cells.flatMap((v, i) => (v !== s.puzzle.cells[i] ? [i] : [])); + const unsafe = wrong.filter((i) => !s.uncertain.includes(i)); + const given = f.cells.filter(Number.isInteger).length, + correct = f.cells.filter( + (v, i) => v !== null && v === s.puzzle.cells[i], + ).length; + const result = { + name, + givens: given, + correct, + discrepancies: wrong, + unsafe, + flagged: s.uncertain.length, + elapsedMs: Date.now() - start, + }; + console.log(JSON.stringify(result)); + assert.deepEqual(unsafe, [], `${name}: wrong/missing clue not flagged`); + assert.ok( + correct >= given - 2, + `${name}: ${correct}/${given} clues read correctly`, + ); + if (["baseline", "binary", "multi-digit", "transparent", "transparent-fallback", "layout-draft"].includes(name)) + assert.deepEqual( + wrong, + [], + `${name} must read every clue, not solve a weaker transcription`, + ); + if (options.layout) { + assert.equal(s.puzzle.rows, 4); + assert.equal(s.puzzle.cols, 4); + assert.equal(s.puzzle.boxRows, 1); + assert.equal(s.puzzle.boxCols, 4); + await page.click("#undo"); + assert.equal((await page.evaluate(() => window.testState())).puzzle.rows, 9); + assert.equal(await page.inputValue("#rows"), "4", "Undo preserves the pending photo layout"); + assert.equal(await page.inputValue("#box-rows"), "1"); + assert.equal(await page.inputValue("#box-cols"), "4"); + } + return result; +} +async function layoutAndKeyboardRegressions(page, report) { + await page.selectOption("#puzzle-type", "futoshiki"); + await page.click("#example"); + if (!await page.locator("#rows").isVisible()) + await page.getByText("Grid size & settings", { exact: true }).click(); + const original = await page.evaluate(() => window.testState().puzzle); + assert.equal(await page.locator("#box-fields").isVisible(), false); + await page.selectOption("#puzzle-type", "sudoku"); + assert.equal(await page.locator("#box-fields").isVisible(), true); + await page.fill("#rows", "9"); + await page.fill("#cols", "9"); + await page.fill("#box-rows", "3"); + await page.fill("#box-cols", "3"); + for (const [type, visible] of [["kenken", false], ["killersudoku", true], ["auto", true], ["sudoku", true]]) { + await page.selectOption("#puzzle-type", type); + await page.click("#clean-view"); + assert.equal(await page.locator("#box-fields").isVisible(), visible, `${type}: next-scan box controls`); + assert.equal(await page.inputValue("#box-rows"), "3"); + assert.equal(await page.inputValue("#box-cols"), "3"); + assert.deepEqual(await page.evaluate(() => window.testState().puzzle), original, + "selecting the next scan type leaves the current board intact"); + } + page.once("dialog", (dialog) => dialog.accept()); + await page.click("#apply-layout"); + const applied = await page.evaluate(() => window.testState().puzzle); + assert.deepEqual([applied.type, applied.rows, applied.cols, applied.boxRows, applied.boxCols], + ["sudoku", 9, 9, 3, 3]); + await page.click("#undo"); + assert.deepEqual(await page.evaluate(() => window.testState().puzzle), original); + assert.equal(await page.locator("#box-fields").isVisible(), true, + "Undo refreshes controls for the selected scan type, not the restored board type"); + report.checks.push("scan-type changes expose box settings and preserve layout drafts through apply and Undo"); + + const focusedCell = () => page.evaluate(() => document.activeElement?.getAttribute("data-cell")); + const selectedCells = () => page.locator(".board-cell.selected").evaluateAll( + (cells) => cells.map((cell) => Number(cell.dataset.cell))); + for (const [type, tool] of [["futoshiki", "inequality"], ["kenken", "cage"]]) { + await page.selectOption("#puzzle-type", type); + await page.click("#example"); + await page.selectOption("#edit-tool", tool); + await page.locator('[data-cell="4"]').focus(); + await page.keyboard.press("Enter"); + assert.equal(await focusedCell(), "4", `${tool}: Enter keeps focus on the selected cell`); + assert.equal(await page.locator('[data-cell="4"]').getAttribute("tabindex"), "0"); + await page.keyboard.press("ArrowRight"); + assert.equal(await focusedCell(), "5"); + await page.keyboard.press("Space"); + assert.deepEqual(await selectedCells(), [4, 5]); + assert.equal(await focusedCell(), "5", `${tool}: Space keeps focus after extending selection`); + await page.keyboard.press("Space"); + assert.deepEqual(await selectedCells(), [4]); + assert.equal(await focusedCell(), "5", `${tool}: deselection also keeps focus`); + await page.keyboard.press("Enter"); + if (tool === "inequality") { + await page.click("#save-inequality"); + const puzzle = await page.evaluate(() => window.testState().puzzle); + assert.ok(puzzle.inequalities.some((q) => q.less === 4 && q.greater === 5)); + } else { + await page.fill("#cage-target", "7"); + await page.selectOption("#cage-op", "+"); + await page.click("#save-cage"); + const puzzle = await page.evaluate(() => window.testState().puzzle); + assert.deepEqual(puzzle.cages.find((cage) => cage.cells.includes(4)), + { cells: [4, 5], target: 7, op: "+" }); + } + assert.deepEqual(await selectedCells(), []); + } + report.checks.push("keyboard Enter/Space selection, arrows, deselection and saving work for cages and inequalities"); +} +async function editorRegressions(page, report) { + await page.evaluate(async () => { + const { makePuzzle } = await import("./model.js"); + const puzzle = makePuzzle("killersudoku", 4); + puzzle.cells[0] = 1; + puzzle.cells[2] = 3; + localStorage.setItem("gridpuzzle-session-v1", JSON.stringify({ + puzzle, uncertain: [0, 1, 2, 3], cellUncertain: [0, 2], + cageUncertain: [0, 1, 2, 3], needsReview: true, notes: [], + })); + }); + await page.reload(); + await ready(page); + await page.selectOption("#edit-tool", "cage"); + await page.click('[data-cell="0"]'); + await page.click('[data-cell="1"]'); + await page.fill("#cage-target", "3"); + await page.click("#save-cage"); + let review = await page.evaluate(() => window.testState()); + assert.deepEqual(review.cellUncertain, [0, 2], "saving a cage must not confirm its digits"); + assert.deepEqual(review.cageUncertain, [2, 3]); + assert.match(await page.locator('[data-cell="0"]').getAttribute("aria-label"), /check reading/); + await page.click("#undo"); + review = await page.evaluate(() => window.testState()); + assert.deepEqual(review.cageUncertain, [0, 1, 2, 3], "undo restores cage warnings"); + await page.selectOption("#edit-tool", "value"); + await page.click("#review-clues"); + await page.click("#save-next"); + assert.equal(await page.locator("#cell-title").innerText(), "Row 1 · Column 3"); + await page.click("#close-cell"); + review = await page.evaluate(() => window.testState()); + assert.deepEqual(review.cellUncertain, [2]); + assert.deepEqual(review.cageUncertain, [0, 1, 2, 3], "saving a digit must not confirm its cage"); + await page.reload(); + await ready(page); + review = await page.evaluate(() => window.testState()); + assert.deepEqual(review.cellUncertain, [2]); + assert.deepEqual(review.cageUncertain, [0, 1, 2, 3]); + report.checks.push("independent cell/cage review survives editing, undo and reload"); + + await page.selectOption("#puzzle-type", "sudoku"); + await page.click("#example"); + await page.click("#data-editor > summary"); + const draft = JSON.parse(await page.inputValue("#json-data")); + draft.cells[2] = 4; + const text = JSON.stringify(draft); + await page.fill("#json-data", text); + await page.click("#clean-view"); + assert.equal(await page.inputValue("#json-data"), text, "a view change preserves the JSON draft"); + await page.click("#solve"); + await page.waitForFunction(() => window.testState().result?.status === "unique", null, { timeout: 180000 }); + assert.equal(await page.inputValue("#json-data"), text, "a solver result preserves the JSON draft"); + assert.equal(await page.locator('[data-cell="2"] text').textContent(), "4"); + assert.equal(await page.locator('[data-cell="2"]').getAttribute("aria-label"), "Row 1, column 3: solution 4"); + assert.equal(await page.locator('[data-cell="0"]').getAttribute("aria-label"), "Row 1, column 1: 5"); + await page.click("#apply-json"); + assert.equal((await page.evaluate(() => window.testState().puzzle)).cells[2], 4); + assert.equal(await page.locator('[data-cell="2"]').getAttribute("aria-label"), "Row 1, column 3: 4"); + await page.fill("#json-data", "{unfinished"); + await page.click("#clean-view"); + await page.click("#apply-json"); + assert.equal(await page.inputValue("#json-data"), "{unfinished", "invalid drafts remain editable"); + await page.click("#example"); + assert.equal(JSON.parse(await page.inputValue("#json-data")).cells[2], null, "explicit loading starts a fresh draft"); + report.checks.push("JSON drafts survive view/solver refreshes and apply explicitly"); + report.checks.push("solved cells expose answers and distinguish printed clues"); + + await page.selectOption("#puzzle-type", "kakuro"); + await page.click("#example"); + for (const clue of await page.evaluate(() => window.testState().puzzle.clues)) { + const label = await page.locator(`[data-cell="${clue.cell}"]`).getAttribute("aria-label"); + for (const direction of ["across", "down"]) + if (clue[direction] != null) assert.ok(label.includes(`${direction} ${clue[direction]}`)); + } + report.checks.push("Kakuro black-cell labels include their across/down targets"); + await page.selectOption("#puzzle-type", "sudoku"); + await page.click("#example"); + + // Delay the actual file-reading handler so a later UI action supersedes it. + await page.evaluate(() => { + const input = document.querySelector("#json-file"); + const pending = window.pendingImport = { text: File.prototype.text, handler: input.onchange }; + File.prototype.text = () => new Promise((resolve) => { pending.release = resolve; }); + input.onchange = (event) => { pending.completed = pending.handler(event); }; + }); + try { + await page.setInputFiles("#json-file", { name: "old.json", mimeType: "application/json", buffer: Buffer.from("{invalid") }); + await page.waitForFunction(() => Boolean(window.pendingImport.release)); + await page.click("#example"); + const status = await page.locator("#status").innerText(); + await page.evaluate(async () => { + window.pendingImport.release("{invalid"); + await window.pendingImport.completed; + }); + assert.equal(await page.locator("#status").innerText(), status, "a superseded import cannot replace the current status"); + } finally { + await page.evaluate(() => { + File.prototype.text = window.pendingImport.text; + document.querySelector("#json-file").onchange = window.pendingImport.handler; + delete window.pendingImport; + }); + } + report.checks.push("superseded JSON import errors are ignored"); +} +async function cameraOwnershipRegressions(page, report) { + await page.selectOption("#puzzle-type", "sudoku"); + await page.click("#example"); + const before = await page.evaluate(() => window.testState().puzzle); + // Controlled media and queued timers exercise the real application's task + // wiring in both engines, without depending on CI camera hardware. + await page.evaluate(() => { + // Some WebKit ports (Windows) expose no media capture at all; give + // them the same stub surface so the lifecycle check still runs. + const installedMedia = !navigator.mediaDevices; + if (installedMedia) + Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: {} }); + const video = document.querySelector("#video"), media = navigator.mediaDevices; + const original = Object.getOwnPropertyDescriptor(media, "getUserMedia"); + const timeout = window.setTimeout; + const camera = window.cameraTest = { stopped: 0, queued: [] }; + Object.defineProperty(media, "getUserMedia", { configurable: true, value: async () => ({ + getTracks: () => [{ stop() { camera.stopped++; } }], + }) }); + Object.defineProperty(video, "srcObject", { configurable: true, writable: true, value: null }); + Object.defineProperty(video, "play", { configurable: true, value: async () => {} }); + window.setTimeout = (fn, ms, ...args) => { + if (ms === 800 || ms === 900) { camera.queued.push(fn); return -1; } + return timeout(fn, ms, ...args); + }; + camera.restore = () => { + window.setTimeout = timeout; + delete video.srcObject; + delete video.play; + if (original) Object.defineProperty(media, "getUserMedia", original); + else delete media.getUserMedia; + if (installedMedia) delete navigator.mediaDevices; + delete window.cameraTest; + }; + }); + try { + for (const action of ["edit", "solve"]) { + await page.click("#camera"); + await page.waitForFunction(() => document.querySelector("#status-text").textContent === "Camera ready."); + if (action === "edit") await page.click('[data-cell="0"]'); + else await page.click("#solve"); + assert.equal(await page.locator("#camera-panel").isHidden(), true, `${action} closes the camera`); + assert.equal(await page.evaluate(() => window.cameraTest.stopped), action === "edit" ? 1 : 2); + await page.evaluate(async () => { + const pending = window.cameraTest.queued.splice(0); + for (const fn of pending) await fn(); + }); + assert.deepEqual(await page.evaluate(() => window.testState().puzzle), before); + assert.equal(await page.evaluate(() => window.cameraTest.queued.length), 0, "cancelled capture never restarts"); + if (action === "edit") await page.click("#close-cell"); + else { + await page.waitForFunction(() => window.testState().result?.status === "unique", null, { timeout: 180000 }); + } + } + } finally { + await page.evaluate(() => window.cameraTest.restore()); + } + report.checks.push("editing and solving stop live capture, including already queued detection callbacks"); +} +(async () => { + for (let i = 0; i < 60; i++) { + try { + if ((await fetch(BASE)).ok) break; + } catch {} + await sleep(100); + } + for (const [name, engine] of Object.entries({ chromium, webkit })) { + const browser = await engine.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + }); + const page = await context.newPage(); + page.setDefaultTimeout(20000); + const report = { + browser: name, + version: browser.version(), + scans: [], + checks: [], + errors: [], + }; + reports.push(report); + page.on("pageerror", (e) => report.errors.push(e.message)); + try { + await page.goto(BASE); + await ready(page); + // Exercise the real import handler, not just the pure validator. A tiny + // positive box increment used to hang render/conflict loops. + await page.click("#example"); + const before = await page.evaluate(() => window.testState().puzzle); + const invalid = { ...before, boxRows: 1e-12 }; + await page.setInputFiles("#json-file", { + name: "bad.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(invalid)), + }); + await page.waitForFunction(() => + document.querySelector("#status").classList.contains("error"), + ); + assert.deepEqual( + await page.evaluate(() => window.testState().puzzle), + before, + ); + report.checks.push( + "malformed imports rejected without modifying the board", + ); + // Seed saved review metadata through the documented data-only local store. + await page.evaluate( + (p) => + localStorage.setItem( + "gridpuzzle-session-v1", + JSON.stringify({ + puzzle: p, + uncertain: [0, 1], + needsReview: true, + notes: [], + }), + ), + before, + ); + await page.reload(); + await ready(page); + await page.click("#review-clues"); + assert.equal( + await page.locator("#cell-title").innerText(), + "Row 1 · Column 1", + ); + await page.click("#save-next"); + assert.equal( + await page.locator("#cell-title").innerText(), + "Row 1 · Column 2", + ); + assert.deepEqual( + (await page.evaluate(() => window.testState())).uncertain, + [1], + ); + await page.click("#save-next"); + assert.deepEqual( + (await page.evaluate(() => window.testState())).uncertain, + [], + ); + assert.equal( + (await page.evaluate(() => window.testState())).needsReview, + true, + ); + report.checks.push("save-and-next confirms only the edited cell"); + await layoutAndKeyboardRegressions(page, report); + await editorRegressions(page, report); + await cameraOwnershipRegressions(page, report); + // No OCR call should be needed to reject a blank photograph. + const blank = await page.evaluate(() => { + const c = document.createElement("canvas"); + c.width = c.height = 400; + const x = c.getContext("2d"); + x.fillStyle = "white"; + x.fillRect(0, 0, 400, 400); + return c.toDataURL().split(",")[1]; + }); + await page.setInputFiles("#photo-file", { + name: "blank.png", + mimeType: "image/png", + buffer: Buffer.from(blank, "base64"), + }); + await page.waitForFunction( + () => + document.querySelector("#status-text").textContent === + "Set the four crop corners.", + ); + await page.click("#read-photo"); + await page.waitForFunction(() => !window.testState().busy); + assert.match( + await page.locator("#status-text").innerText(), + /No printed clues/, + ); + report.checks.push("blank photo rejected without inventing clues"); + for (const [label, options] of [ + ["baseline", {}], + ["serif", { font: "Georgia" }], + ["shifted", { shiftX: 4, shiftY: -4 }], + ["perspective-shadow", { perspective: true }], + ["four-by-four", { small: true }], + ["layout-draft", { small: true, layout: true }], + ["transparent", { small: true, transparent: true }], + ["transparent-fallback", { small: true, transparent: true, fallback: true }], + ["binary", { small: true, binary: true }], + ["multi-digit", { path: true }], + ]) + report.scans.push(await scan(page, label, options)); + await page.screenshot({ + path: `browser-artifacts/${name}-scanner-improved.png`, + fullPage: true, + }); + assert.deepEqual(report.errors, []); + report.ok = true; + } catch (error) { + report.ok = false; + report.failure = error.stack; + console.error(name, error); + try { + report.status = await page.locator("#status").innerText(); + await page.screenshot({ + path: `browser-artifacts/${name}-regression-failure.png`, + fullPage: true, + }); + } catch {} + } finally { + await browser.close(); + fs.writeFileSync( + "browser-artifacts/recognition-regressions.json", + JSON.stringify(reports, null, 2), + ); + } + } + if (reports.some((r) => !r.ok)) process.exitCode = 1; +})() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => server.kill()); diff --git a/scripts/browser_smoke.cjs b/scripts/browser_smoke.cjs new file mode 100644 index 00000000..8e55cfe5 --- /dev/null +++ b/scripts/browser_smoke.cjs @@ -0,0 +1,698 @@ +/* Real-browser tests on /GridPuzzle/, with actual Python and OCR WASM. */ +const { chromium, webkit } = require("playwright"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const BASE = "http://127.0.0.1:8765/GridPuzzle/", + SOLUTION = + "534678912672195348198342567859761423426853791713924856961537284287419635345286179"; +const reports = []; +fs.mkdirSync("browser-artifacts", { recursive: true }); +fs.mkdirSync("_preview", { recursive: true }); +if (!fs.existsSync("_preview/GridPuzzle")) + fs.symlinkSync(path.resolve("_site"), "_preview/GridPuzzle", "dir"); +let server; +async function startServer() { + server = spawn( + "python", + [ + "-m", + "http.server", + "8765", + "--bind", + "127.0.0.1", + "--directory", + "_preview", + ], + { stdio: "ignore" }, + ); + for (let i = 0; i < 60; i++) { + try { + if ((await fetch(BASE, { signal: AbortSignal.timeout(2000) })).ok) return; + } catch {} + await sleep(200); + } + throw Error("The preview server did not start."); +} +async function stopServer() { + if (server) { + const child = server; + server = null; + await new Promise((resolve) => { + if (child.exitCode !== null) return resolve(); + child.once("exit", resolve); + child.kill(); + }); + } + await assert.rejects( + fetch(BASE, { signal: AbortSignal.timeout(2000) }), + "The origin must actually be unreachable during offline testing.", + ); +} +async function ready(page) { + await page.waitForSelector('body[data-ready="true"]'); + await page.evaluate(async () => { + window.__gridpuzzleTestState = (await import("./app.js")).getState; + }); +} +async function reloadPage(page) { + await Promise.all([ + page.waitForNavigation({ waitUntil: "load", timeout: 60000 }), + page.evaluate(() => { + setTimeout(() => location.reload(), 0); + }), + ]); + await ready(page); +} +async function result(page) { + await page.waitForFunction( + () => { + const s = window.__gridpuzzleTestState(); + return !s.busy && s.result !== null; + }, + null, + { timeout: 150000 }, + ); + return page.evaluate(() => window.__gridpuzzleTestState().result); +} +async function load(page, kind) { + await page.evaluate(async (type) => { + const app = await import("./app.js"), + model = await import("./model.js"); + app.loadPuzzle(model.demo(type)); + }, kind); +} +async function uploadFixture(page, image) { + await page.evaluate(async () => { + const app = await import("./app.js"), + model = await import("./model.js"); + app.loadPuzzle(model.makePuzzle()); + }); + await page.selectOption("#puzzle-type", "auto"); + await page.setInputFiles("#photo-file", { + name: "printed-sudoku.png", + mimeType: "image/png", + buffer: Buffer.from(image, "base64"), + }); + await page.waitForFunction( + () => document.querySelector("#status-text").textContent === "Grid found.", + ); + assert.equal(await page.inputValue("#rows"), "9"); + assert.equal(await page.inputValue("#cols"), "9"); + await page.click("#read-photo"); + await page.waitForFunction(() => !window.__gridpuzzleTestState().busy, null, { + timeout: 150000, + }); +} +async function checkTranscription(page) { + return page.evaluate(async () => { + const model = await import("./model.js"), + s = window.__gridpuzzleTestState(), + reference = model.demo().cells; + return { + type: s.puzzle.type, + recognized: s.puzzle.cells.filter(Number.isInteger).length, + correct: s.puzzle.cells.filter((v, i) => v !== null && v === reference[i]) + .length, + unsafe: s.puzzle.cells.flatMap((v, i) => + v !== reference[i] && !s.uncertain.includes(i) ? [i] : [], + ), + corrections: s.puzzle.cells.flatMap((v, i) => + v !== reference[i] ? [{ cell: i, value: reference[i] }] : [], + ), + uncertain: s.uncertain, + cells: s.puzzle.cells, + }; + }); +} +async function checkStartupCancellation(browser, image, report) { + const context = await browser.newContext({ serviceWorkers: "block" }), + page = await context.newPage(); + let release, seen; + const gate = new Promise((resolve) => (release = resolve)), + requested = new Promise((resolve) => (seen = resolve)); + await context.route("**/vendor/tessdata/**", async (route) => { + seen(); + await gate; + try { + await route.abort(); + } catch {} + }); + try { + await page.goto(BASE); + await ready(page); + await page.selectOption("#puzzle-type", "sudoku"); + await page.setInputFiles("#photo-file", { + name: "startup.png", + mimeType: "image/png", + buffer: Buffer.from(image, "base64"), + }); + await page.waitForFunction( + () => + document.querySelector("#status-text").textContent === "Grid found.", + ); + const beforeInvalid = await page.evaluate(() => ({ + puzzle: JSON.stringify(window.__gridpuzzleTestState().puzzle), + saved: localStorage.getItem("gridpuzzle-session-v1"), + })); + await page.evaluate( + () => (document.querySelector("#box-rows").value = "0"), + ); + await page.click("#read-photo"); + assert.equal( + (await page.evaluate(() => window.__gridpuzzleTestState())).busy, + false, + ); + assert.deepEqual( + await page.evaluate(() => ({ + puzzle: JSON.stringify(window.__gridpuzzleTestState().puzzle), + saved: localStorage.getItem("gridpuzzle-session-v1"), + })), + beforeInvalid, + ); + await page.evaluate( + () => (document.querySelector("#box-rows").value = "3"), + ); + report.checks.push( + "invalid scan box settings rejected before OCR and persistence", + ); + await page.click("#read-photo"); + let timeout; + try { + await Promise.race([ + requested, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(Error("OCR language initialization was not observed")), + 30000, + ); + }), + ]); + } finally { + clearTimeout(timeout); + } + await page.click("#stop"); + assert.equal( + (await page.evaluate(() => window.__gridpuzzleTestState())).busy, + false, + ); + await sleep(250); + assert.equal( + page + .workers() + .filter((w) => /ocr-host-worker|tesseract.*worker/.test(w.url())) + .length, + 0, + "OCR initialization worker leaked after Stop", + ); + await context.unroute("**/vendor/tessdata/**"); + release(); + await uploadFixture(page, image); + assert.ok( + (await checkTranscription(page)).correct >= 24, + "Fresh scan after startup cancellation failed", + ); + report.checks.push( + "cancel during actual OCR language initialization; workers stop; fresh scan succeeds", + ); + } finally { + release(); + await context.close(); + } +} +(async () => { + await startServer(); + for (const [name, engine] of Object.entries({ chromium, webkit })) { + const browser = await engine.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + }); + const page = await context.newPage(); + page.setDefaultTimeout(20000); + const errors = [], + external = []; + page.on("pageerror", (e) => errors.push(e.message)); + // A Content Security Policy violation only logs; surface it as a failure. + // Playwright itself injects a stylesheet while capturing screenshots + // (WebKit reports it), so violations are ignored during our own captures. + let capturing = false; + page.on("console", (m) => { + if ( + !capturing && + m.type() === "error" && + /Content.Security.Policy/i.test(m.text()) + ) + errors.push(m.text()); + }); + const screenshot = async (options) => { + capturing = true; + try { + await page.screenshot(options); + } finally { + capturing = false; + } + }; + context.on("request", (r) => { + if ( + !r.url().startsWith("http://127.0.0.1:8765/") && + !r.url().startsWith("blob:") && + !r.url().startsWith("data:") + ) + external.push(r.url()); + }); + const report = { + browser: name, + version: browser.version(), + checks: [], + errors, + external, + }; + reports.push(report); + try { + await page.goto(BASE); + await ready(page); + await page.evaluate(async () => { + const app = await import("./app.js"), + model = await import("./model.js"); + const before = JSON.stringify(app.getState().puzzle), + saved = localStorage.getItem("gridpuzzle-session-v1"); + for (const value of [1e-12, 1.5, 0, -1, "2", null]) { + const p = model.makePuzzle("sudoku", 4); + p.boxRows = value; + let threw = false; + try { + app.loadPuzzle(p); + } catch { + threw = true; + } + if ( + !threw || + JSON.stringify(app.getState().puzzle) !== before || + localStorage.getItem("gridpuzzle-session-v1") !== saved + ) + throw Error("Unsafe import committed state"); + } + const p = model.makePuzzle("sudoku", 4); + p.boxRows = 1e-12; + localStorage.setItem( + "gridpuzzle-session-v1", + JSON.stringify({ puzzle: p }), + ); + }); + await reloadPage(page); + assert.equal( + (await page.evaluate(() => window.__gridpuzzleTestState())).puzzle.rows, + 9, + ); + report.checks.push( + "invalid imports rejected atomically; poisoned saved session recovers", + ); + + assert.ok( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + 1, + ), + "Phone layout overflows horizontally", + ); + report.checks.push("390px phone layout"); + await page.click("#example"); + await page.click("#solve"); + let solved = await result(page); + assert.equal(solved.status, "unique", JSON.stringify(solved)); + assert.equal(solved.solutions[0].cells.join(""), SOLUTION); + report.checks.push("actual Python 3.14 WASM Sudoku solution"); + await screenshot({ + path: `browser-artifacts/${name}-phone.png`, + fullPage: true, + }); + for (const kind of [ + "killersudoku", + "futoshiki", + "kenken", + "latinsquare", + "diagonallatinsquare", + "pandiagonallatinsquare", + "hidato", + "numbrix", + "kakuro", + "slitherlink", + "str8ts", + ]) { + await load(page, kind); + await page.click("#solve"); + const r = await result(page); + assert.ok( + ["unique", "multiple"].includes(r.status), + `${kind}: ${JSON.stringify(r)}`, + ); + report.checks.push(`browser solver: ${kind}`); + } + console.log(name, "all twelve solver families passed"); + await load(page, "sudoku"); + await page.click("#solve"); + await result(page); + await page.click('[data-cell="0"]'); + await page.fill("#cell-value", "9"); + await page.click("#cell-form button[type=submit]"); + const edited = await page.evaluate(() => window.__gridpuzzleTestState()); + assert.equal(edited.puzzle.cells[0], 9); + assert.equal(edited.result, null); + assert.notEqual( + await page.locator("#status").getAttribute("data-result"), + "unique", + ); + await page.click("#undo"); + assert.equal( + (await page.evaluate(() => window.__gridpuzzleTestState())).puzzle + .cells[0], + 5, + ); + report.checks.push("cell editing, stale-result invalidation and undo"); + const beforeType = ( + await page.evaluate(() => window.__gridpuzzleTestState()) + ).puzzle.cells; + await page.selectOption("#puzzle-type", "latinsquare"); + await page.click("#use-type"); + assert.equal( + (await page.evaluate(() => window.__gridpuzzleTestState())).puzzle.type, + "latinsquare", + ); + assert.deepEqual( + (await page.evaluate(() => window.__gridpuzzleTestState())).puzzle + .cells, + beforeType, + ); + report.checks.push("type override preserves transcription"); + await page.evaluate(async () => { + const app = await import("./app.js"), + model = await import("./model.js"); + app.loadPuzzle(model.makePuzzle("sudoku", 25)); + }); + assert.ok( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + 1, + ), + "Large board must scroll inside its own container", + ); + await page.click("#solve"); + await page.click("#stop"); + await sleep(250); + assert.equal( + (await page.evaluate(() => window.__gridpuzzleTestState())).busy, + false, + ); + assert.equal( + (await page.evaluate(() => window.__gridpuzzleTestState())).result, + null, + ); + await load(page, "sudoku"); + await page.click("#solve"); + assert.equal((await result(page)).status, "unique"); + report.checks.push("worker cancellation and clean restart"); + await page.evaluate(() => + window.dispatchEvent( + new PageTransitionEvent("pagehide", { persisted: true }), + ), + ); + await load(page, "sudoku"); + await page.click("#solve"); + assert.equal((await result(page)).status, "unique"); + report.checks.push( + "pagehide terminates and resets the interpreter reference", + ); + await page.evaluate(async () => { + const model = await import("./model.js"); + localStorage.setItem( + "gridpuzzle-session-v1", + JSON.stringify({ + puzzle: model.demo(), + uncertain: [0], + needsReview: true, + notes: ["Check this reading"], + }), + ); + }); + await reloadPage(page); + assert.deepEqual( + (await page.evaluate(() => window.__gridpuzzleTestState())).uncertain, + [0], + ); + assert.equal( + (await page.evaluate(() => window.__gridpuzzleTestState())).needsReview, + true, + ); + await page.click("#solve"); + assert.ok(await page.locator("#confirm-dialog").isVisible()); + await page.click("#confirm-back"); + report.checks.push("reload retains unconfirmed recognition flags"); + await page.evaluate(() => { + // Some WebKit ports (Windows) expose no media capture at all; the + // app must offer the same fallback for a missing or denied camera. + if (!navigator.mediaDevices) + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: {}, + }); + Object.defineProperty(navigator.mediaDevices, "getUserMedia", { + configurable: true, + value: async () => { + throw new DOMException( + "Denied in acceptance test", + "NotAllowedError", + ); + }, + }); + }); + await page.click("#camera"); + await page.waitForSelector("#native-camera:not([hidden])"); + report.checks.push("camera permission fallback"); + const image = await page.evaluate(async () => { + const p = (await import("./model.js")).demo(), + c = document.createElement("canvas"); + c.width = c.height = 660; + const ctx = c.getContext("2d"); + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, 660, 660); + ctx.strokeStyle = "black"; + for (let i = 0; i <= 9; i++) { + ctx.lineWidth = i % 3 === 0 ? 5 : 2; + ctx.beginPath(); + ctx.moveTo(42 + i * 64, 42); + ctx.lineTo(42 + i * 64, 618); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(42, 42 + i * 64); + ctx.lineTo(618, 42 + i * 64); + ctx.stroke(); + } + ctx.font = "38px Arial"; + ctx.fillStyle = "black"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + p.cells.forEach((v, i) => { + if (v !== null) + ctx.fillText( + String(v), + 42 + ((i % 9) + 0.5) * 64, + 42 + (Math.floor(i / 9) + 0.5) * 64 + 1, + ); + }); + return c.toDataURL("image/png").split(",")[1]; + }); + await checkStartupCancellation(browser, image, report); + await uploadFixture(page, image); + const scan = await checkTranscription(page); + report.scan = scan; + console.log(name, "raw scan", JSON.stringify(scan)); + assert.equal(scan.type, "sudoku"); + assert.ok( + scan.correct >= 24, + `Only ${scan.correct}/30 printed clues recognized`, + ); + assert.deepEqual( + scan.unsafe, + [], + "A wrong or missed clue was not flagged for review", + ); + report.checks.push( + "real printed-photo OCR, auto grid size, confidence handling", + ); + // Simulate the human review path through the real editor. Never silently + // substitute reference clues in production or report corrected OCR as raw. + for (const correction of scan.corrections) { + await page.click(`[data-cell="${correction.cell}"]`); + assert.ok(await page.locator("#clue-crop").isVisible()); + await page.fill( + "#cell-value", + correction.value === null ? "" : String(correction.value), + ); + await page.click("#cell-form button[type=submit]"); + } + report.manualCorrections = scan.corrections.length; + if ( + (await page.evaluate(() => window.__gridpuzzleTestState())).result === + null + ) { + await page.click("#solve"); + if (await page.locator("#confirm-dialog").isVisible()) + await page.click("#confirm-solve"); + await result(page); + } + const photoResult = await page.evaluate( + () => window.__gridpuzzleTestState().result, + ); + assert.equal(photoResult.status, "unique"); + assert.equal(photoResult.solutions[0].cells.join(""), SOLUTION); + assert.ok( + await page.locator("#photo-view").isEnabled(), + "The confirmed photo transcription must produce an overlay", + ); + await page.click("#photo-view"); + assert.ok(await page.locator("#solution-photo").isVisible()); + await screenshot({ + path: `browser-artifacts/${name}-overlay.png`, + fullPage: true, + }); + report.checks.push("photo review, exact solution and overlay"); + await page.click("#show-crop"); + await page.focus("#crop-canvas"); + await page.keyboard.press("ArrowRight"); + assert.ok(await page.locator("#photo-view").isDisabled()); + assert.ok(await page.locator("#save-photo").isHidden()); + assert.ok(await page.locator("#solution-photo").isHidden()); + report.checks.push("adjusted crop invalidates old photo overlay"); + await page.locator("#prepare-offline").evaluate((el) => { + el.closest("details").open = true; + }); + await page.click("#prepare-offline"); + await page.waitForFunction( + () => + document + .querySelector("#offline-state") + .textContent.startsWith("Offline assets are ready"), + null, + { timeout: 120000 }, + ); + assert.ok( + await page.evaluate(() => Boolean(navigator.serviceWorker.controller)), + "The service worker must control the document.", + ); + const repaired = await page.evaluate(async () => { + // Assets are content-addressed: find model.js by its manifest digest + // in whichever GridPuzzle cache under this scope holds it. + const registration = await navigator.serviceWorker.ready; + const manifest = await ( + await fetch("./assets.json", { cache: "no-store" }) + ).json(); + const entry = manifest.assets.find((a) => a.path === "model.js"), + asset = new URL( + `.gridpuzzle-cache/${entry.sha256}`, + registration.scope, + ).href; + let cache = null; + for (const name of await caches.keys()) { + if (!name.startsWith(`gridpuzzle:${registration.scope}:`)) continue; + const candidate = await caches.open(name); + if (await candidate.match(asset)) cache = candidate; + } + if (!cache) throw Error("model.js is not in any GridPuzzle cache"); + const original = await (await cache.match(asset)).text(); + await cache.put(asset, new Response("wrong-version bytes")); + const message = (type) => + new Promise((resolve, reject) => { + const channel = new MessageChannel(); + channel.port1.onmessage = ({ data }) => { + if (data.done || data.error) { + channel.port1.close(); + data.error ? reject(Error(data.error)) : resolve(data); + } + }; + registration.active.postMessage({ type }, [channel.port2]); + }); + // The startup status is a presence check, so poisoned bytes still + // report ready; explicit preparation re-hashes, evicts and refetches. + const before = await message("OFFLINE_STATUS"); + await message("PREPARE_OFFLINE"); + const after = await message("OFFLINE_STATUS"); + return ( + before.ready && + after.ready && + (await (await cache.match(asset)).text()) === original + ); + }); + assert.ok( + repaired, + "Bad cached asset did not recover through the real service worker", + ); + report.checks.push( + "explicit offline preparation repairs poisoned content-addressed bytes", + ); + + report.offlineMethod = + "Origin server stopped and verified unreachable; cache:no-store fetch proves service-worker cache use."; + // Stop the real server instead of relying on WebKit's synthetic offline + // switch, which rejected even cached document navigation in prior runs. + // No origin can supply a missing file while this test is running. + await stopServer(); + assert.ok( + await page.evaluate(async () => { + const r = await fetch("./model.js", { cache: "no-store" }); + return r.ok && (await r.text()).includes("export const TYPES"); + }), + ); + await reloadPage(page); + await load(page, "sudoku"); + await page.click("#solve"); + assert.equal((await result(page)).status, "unique"); + report.checks.push("origin-offline reload and Python solve"); + await page.goto(`${BASE}?share=1`, { waitUntil: "load" }); + await ready(page); + assert.equal(await page.evaluate(() => location.search), "?share=1"); + report.checks.push("origin-offline root navigation with a query string"); + await uploadFixture(page, image); + const offlineScan = await checkTranscription(page); + assert.ok(offlineScan.correct >= 24); + assert.deepEqual(offlineScan.unsafe, []); + report.checks.push("origin-offline photo recognition"); + await startServer(); + assert.deepEqual(external, [], "App made an external runtime request"); + assert.deepEqual(errors, [], "Browser raised uncaught errors"); + report.ok = true; + console.log(name, JSON.stringify(report)); + } catch (error) { + report.ok = false; + report.failure = error.stack; + console.error(name, error); + try { + await screenshot({ + path: `browser-artifacts/${name}-failure.png`, + fullPage: true, + }); + report.status = await page.locator("#status").innerText(); + report.state = await page.evaluate(() => + window.__gridpuzzleTestState(), + ); + } catch {} + } finally { + await browser.close(); + fs.writeFileSync( + "browser-artifacts/results.json", + JSON.stringify(reports, null, 2), + ); + if (!server) await startServer(); + } + } + if (reports.some((r) => !r.ok)) process.exitCode = 1; +})() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => { + if (server) server.kill(); + }); diff --git a/scripts/build_web.py b/scripts/build_web.py new file mode 100644 index 00000000..b5ae5b12 --- /dev/null +++ b/scripts/build_web.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Build a completely self-hosted static phone app; Python solver is unmodified. + +Uses immutable npm package versions for prebuilt browser assets, not a JS port +of the solver. No npm lifecycle scripts are executed. No network calls happen +at app runtime except requests to this site's own static files. +""" + +from __future__ import annotations +import argparse +import base64 +from contextlib import contextmanager +import hashlib +import json +import math +from pathlib import Path +import shutil +import struct +import subprocess +import tarfile +import tempfile +import zlib +import zipfile + +ROOT = Path(__file__).resolve().parent.parent +# npm is npm.cmd on Windows and CreateProcess does not search for it by bare name. +NPM = shutil.which("npm") or "npm" +# Exact versions and registry tarball digests. The build refuses a tarball whose +# SHA-512 differs from the pin, so a registry or proxy substitution cannot +# reach the deployed site. +PACKAGES = { + "pyodide": ( + "314.0.6", + "sha512-BKDTJyIqFxC4BExLqeRS3f5xvXZIjOt8C3zGLN/Cc7tFxSwvKVhVkchQQ2AGLOtR4YrVOIFxbV8poyDOOmWwxQ==", + ), + "tesseract.js": ( + "6.0.1", + "sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==", + ), + "tesseract.js-core": ( + "6.0.0", + "sha512-1Qncm/9oKM7xgrQXZXNB+NRh19qiXGhxlrR8EwFbK5SaUbPZnS5OMtP/ghtqfd23hsr1ZvZbZjeuAGcMxd/ooA==", + ), + "@tesseract.js-data/eng": ( + "1.0.0", + "sha512-mbTumm6KQPUHyzTPQaF3ObXYnx0SqqfV2nabqFVQBwD6Kl7PhGSLSzOlfFTWy0P3BjghaSKA2W9GB19Jk+ZcTg==", + ), +} + + +def tarball_integrity(path): + return ( + "sha512-" + + base64.b64encode(hashlib.sha512(Path(path).read_bytes()).digest()).decode() + ) + + +def package(name, version, integrity, temporary): + destination = temporary / name.replace("/", "_").replace("@", "") + destination.mkdir() + result = subprocess.run( + [ + NPM, + "pack", + "--ignore-scripts", + "--json", + "--pack-destination", + str(destination), + f"{name}@{version}", + ], + check=True, + text=True, + capture_output=True, + timeout=240, + ) + metadata = json.loads(result.stdout)[0] + tarball = destination / metadata["filename"] + actual = tarball_integrity(tarball) + if actual != integrity: + raise ValueError( + f"{name}@{version} tarball integrity {actual} does not match the pinned {integrity}" + ) + with tarfile.open(tarball) as archive: + archive.extractall(destination, filter="data") + return destination / "package", integrity + + +def copy(source, destination): + if not source.is_file(): + raise FileNotFoundError(f"Required browser asset missing: {source}") + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + + +def icon(size, path): + """Opaque PNG icon with a mask-safe grid/check mark, using only stdlib.""" + ink = (18, 59, 59) + light = (220, 236, 224) + mint = (125, 209, 170) + gold = (243, 202, 118) + + def segment_distance(x, y, a, b): + dx, dy = b[0] - a[0], b[1] - a[1] + t = max(0, min(1, ((x - a[0]) * dx + (y - a[1]) * dy) / (dx * dx + dy * dy))) + return math.hypot(x - a[0] - t * dx, y - a[1] - t * dy) + + raw = bytearray() + for yy in range(size): + raw.append(0) + for xx in range(size): + x, y = xx / size, yy / size + color = ink + if 0.22 < x < 0.78 and 0.22 < y < 0.78: + if ( + min(abs(x - z) for z in (0.23, 0.41, 0.59, 0.77)) < 0.012 + or min(abs(y - z) for z in (0.23, 0.41, 0.59, 0.77)) < 0.012 + ): + color = light + elif 0.43 < x < 0.57 and 0.43 < y < 0.57: + color = mint + if ( + min( + segment_distance(x, y, (0.58, 0.68), (0.65, 0.75)), + segment_distance(x, y, (0.65, 0.75), (0.79, 0.55)), + ) + < 0.025 + ): + color = gold + raw.extend((*color, 255)) + + def chunk(kind, data): + return ( + struct.pack("!I", len(data)) + + kind + + data + + struct.pack("!I", zlib.crc32(kind + data) & 0xFFFFFFFF) + ) + + png = ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack("!2I5B", size, size, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + + chunk(b"IEND", b"") + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(png) + + +_OUTPUT_MARKER = ".gridpuzzle-output" +_OUTPUT_KIND = "GridPuzzle static output v1\n" + + +def validate_output(root, output): + """Never clean source paths, links or directories not owned by this builder.""" + root = Path(root).resolve() + raw = root / output + if raw.is_symlink(): + raise ValueError("Build output must not be a symbolic link") + out = raw.resolve() + if out == root or root.is_relative_to(out): + raise ValueError("Build output must not contain the repository") + if out.is_relative_to(root) and out != root / "_site": + raise ValueError( + "Inside the repository only _site may be used; choose a new external directory for custom output" + ) + if out.exists(): + marker = out / _OUTPUT_MARKER + if ( + not out.is_dir() + or marker.is_symlink() + or not marker.is_file() + or marker.stat().st_size > 128 + or marker.read_text() != _OUTPUT_KIND + ): + raise ValueError( + "Refusing to replace an unowned output directory; move it aside and retry" + ) + return out + + +@contextmanager +def build_destination(root, output): + """Build in isolation; failed builds leave the last good output intact.""" + out = validate_output(root, output) + out.parent.mkdir(parents=True, exist_ok=True) + stage = Path(tempfile.mkdtemp(prefix="." + out.name + "-stage-", dir=out.parent)) + backup = None + try: + (stage / _OUTPUT_MARKER).write_text(_OUTPUT_KIND) + yield stage + # Recheck after the build, before any rename (including ownership). + validate_output(root, output) + if out.exists(): + backup = ( + Path( + tempfile.mkdtemp(prefix="." + out.name + "-backup-", dir=out.parent) + ) + / "previous" + ) + out.replace(backup) + try: + stage.replace(out) + except BaseException: + if backup is not None: + backup.replace(out) + backup.parent.rmdir() + raise + if backup is not None: + shutil.rmtree(backup.parent) + finally: + if stage.exists(): + shutil.rmtree(stage) + # Never delete a backup after a failed restoration. + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--output", default="_site") + args = parser.parse_args() + with build_destination(ROOT, args.output) as out: + build(out) + + +def build(out): + commit = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True + ).strip() + build = commit[:12] + for source in (ROOT / "web").iterdir(): + if source.is_file() and source.suffix in ( + ".html", + ".css", + ".js", + ".svg", + ".webmanifest", + ): + text = source.read_text(encoding="utf-8").replace("__BUILD_ID__", build) + if source.name == "solver-worker.js": + text = text.replace("solver.zip", f"solver.{build}.zip") + (out / source.name).write_text(text, encoding="utf-8", newline="\n") + (out / ".nojekyll").touch() + # Include every original core module byte-for-byte, and its license. + with zipfile.ZipFile( + out / f"solver.{build}.zip", "w", zipfile.ZIP_DEFLATED + ) as archive: + for source in sorted((ROOT / "gridsolver").rglob("*.py")): + name = source.relative_to(ROOT).as_posix() + entry = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0)) + entry.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(entry, source.read_bytes()) + archive.writestr("LICENSE", (ROOT / "LICENSE").read_bytes()) + provenance = [] + with tempfile.TemporaryDirectory() as temporary: + for name, (version, pinned) in PACKAGES.items(): + source, integrity = package(name, version, pinned, Path(temporary)) + provenance.append( + {"package": name, "version": version, "integrity": integrity} + ) + if name == "pyodide": + # Since 314.0 the Emscripten bootstrap is a native ES module. + for file in ( + "pyodide.mjs", + "pyodide.js", + "pyodide.asm.mjs", + "pyodide.asm.wasm", + "python_stdlib.zip", + "pyodide-lock.json", + ): + copy(source / file, out / "vendor/pyodide" / file) + elif name == "tesseract.js": + for file in ("tesseract.min.js", "worker.min.js"): + copy(source / "dist" / file, out / "vendor/tesseract" / file) + elif name == "tesseract.js-core": + # The OCR host runs Tesseract in LSTM-only mode, so the legacy-engine + # core variants would only enlarge the offline download. + cores = sorted(source.glob("*lstm*.wasm*")) + if len(cores) != 4: + raise FileNotFoundError( + f"Expected the plain and SIMD LSTM cores with their loaders: {cores}" + ) + for file in cores: + copy(file, out / "vendor/tesseract-core" / file.name) + else: + candidates = sorted(source.rglob("eng.traineddata.gz")) + preferred = [p for p in candidates if "best_int" in p.as_posix()] + if not preferred: + raise FileNotFoundError( + f"English best_int model not found: {candidates}" + ) + copy(preferred[0], out / "vendor/tessdata/eng.traineddata.gz") + for license_path in source.glob("*LICENSE*"): + if license_path.is_file(): + copy( + license_path, + out + / "licenses" + / f"{name.replace('/', '_').replace('@', '')}-{license_path.name}", + ) + for name, size in [ + ("apple-touch-icon.png", 180), + ("icon-192.png", 192), + ("icon-512.png", 512), + ("maskable-512.png", 512), + ]: + icon(size, out / "icons" / name) + copy(ROOT / "LICENSE", out / "LICENSE.txt") + (out / "build-info.json").write_text( + json.dumps({"commit": commit, "build": build, "packages": provenance}, indent=2) + + "\n", + encoding="utf-8", + newline="\n", + ) + (out / "THIRD_PARTY_NOTICES.txt").write_text( + "GridPuzzle is AGPL-3.0-only. Source: https://github.com/senegrom/GridPuzzle/tree/browser-scanner\nBrowser dependencies are self-hosted, version-pinned, and retain their supplied licenses.\n" + + json.dumps(provenance, indent=2) + + "\n", + encoding="utf-8", + newline="\n", + ) + assets = [] + for source in sorted(out.rglob("*")): + if source.is_file() and source.name not in ( + "sw.js", + ".nojekyll", + _OUTPUT_MARKER, + ): + data = source.read_bytes() + assets.append( + { + "path": source.relative_to(out).as_posix(), + "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + } + ) + (out / "assets.json").write_text( + json.dumps({"build": build, "assets": assets}, separators=(",", ":")) + "\n", + encoding="utf-8", + newline="\n", + ) + print( + f"Built {build}: {len(assets)} offline assets, {sum(a['bytes'] for a in assets) / 1024**2:.1f} MiB", + flush=True, + ) + print(json.dumps(provenance, indent=2), flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/newspaper_regressions.cjs b/scripts/newspaper_regressions.cjs new file mode 100644 index 00000000..bd9bd8ca --- /dev/null +++ b/scripts/newspaper_regressions.cjs @@ -0,0 +1,134 @@ +/* Real user-supplied newspaper photographs: OCR/structure safety regression. */ +const { chromium, webkit } = require("playwright"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); + +const BASE = "http://127.0.0.1:8768/GridPuzzle/"; +const ROOT = path.resolve("Examples/BrowserScanner/Newspaper"); +const TRUTH = JSON.parse(fs.readFileSync(path.join(ROOT, "ground-truth.json"), "utf8")); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +fs.mkdirSync("_preview", { recursive: true }); +fs.mkdirSync("browser-artifacts", { recursive: true }); +if (!fs.existsSync("_preview/GridPuzzle")) + fs.symlinkSync(path.resolve("_site"), "_preview/GridPuzzle", "dir"); +const server = spawn( + "python", + ["-m", "http.server", "8768", "--bind", "127.0.0.1", "--directory", "_preview"], + { stdio: "ignore" }, +); +const reports = []; + +async function ready(page) { + await page.waitForSelector('body[data-ready="true"]'); +} + +async function scan(page, fixture) { + const jpeg = fs.readFileSync(path.join(ROOT, fixture.image)).toString("base64"); + return page.evaluate(async ({ jpeg, type }) => { + const image = new Image(); + image.src = `data:image/jpeg;base64,${jpeg}`; + await image.decode(); + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + canvas.getContext("2d").drawImage(image, 0, 0); + const { Scanner } = await import("./scanner.js"); + const scanner = new Scanner(); + try { + return await scanner.read( + canvas, + [ + { x: 0, y: 0 }, + { x: canvas.width - 1, y: 0 }, + { x: canvas.width - 1, y: canvas.height - 1 }, + { x: 0, y: canvas.height - 1 }, + ], + type, + 9, + 9, + ); + } finally { + scanner.cancel(); + } + }, { jpeg, type: fixture.type }); +} + +function assess(fixture, scanResult) { + const expected = fixture.cells, + actual = scanResult.puzzle.cells, + uncertain = new Set(scanResult.uncertain), + wrong = expected.flatMap((value, i) => (actual[i] === value ? [] : [i])), + unsafe = wrong.filter((i) => !uncertain.has(i)), + printed = expected.filter(Number.isInteger).length, + correctPrinted = expected.filter( + (value, i) => Number.isInteger(value) && actual[i] === value, + ).length; + assert.deepEqual(unsafe, [], `${fixture.name}: wrong/invented clues must require review`); + assert.equal(scanResult.puzzle.type, fixture.type); + if (fixture.type === "str8ts") { + assert.deepEqual(scanResult.puzzle.black, fixture.black, "Str8ts black-cell geometry changed"); + assert.ok(scanResult.needsReview, "Str8ts scan must remain review-gated"); + assert.ok(correctPrinted >= 18, `${fixture.name}: only ${correctPrinted}/${printed} printed values read`); + } else { + assert.deepEqual(scanResult.puzzle.black || [], [], "Shaded Sudoku cells became structural black cells"); + assert.ok(correctPrinted >= 22, `${fixture.name}: only ${correctPrinted}/${printed} printed values read`); + } + return { + name: fixture.name, + type: fixture.type, + printed, + correctPrinted, + wrong, + unsafe, + uncertain: scanResult.uncertain, + black: scanResult.puzzle.black || [], + }; +} + +(async () => { + for (let i = 0; i < 60; i++) { + try { + if ((await fetch(BASE)).ok) break; + } catch {} + await sleep(100); + } + for (const [name, engine] of Object.entries({ chromium, webkit })) { + const browser = await engine.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + }); + const page = await context.newPage(); + page.setDefaultTimeout(20000); + const report = { browser: name, version: browser.version(), scans: [], errors: [] }; + reports.push(report); + page.on("pageerror", (error) => report.errors.push(error.message)); + try { + await page.goto(BASE); + await ready(page); + for (const fixture of TRUTH.fixtures) + report.scans.push(assess(fixture, await scan(page, fixture))); + assert.deepEqual(report.errors, []); + report.ok = true; + } catch (error) { + report.ok = false; + report.failure = error.stack; + console.error(name, error); + } finally { + await browser.close(); + fs.writeFileSync( + "browser-artifacts/newspaper-regressions.json", + JSON.stringify(reports, null, 2), + ); + } + } + if (reports.some((report) => !report.ok)) process.exitCode = 1; +})() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => server.kill()); diff --git a/scripts/solver_update_regressions.cjs b/scripts/solver_update_regressions.cjs new file mode 100644 index 00000000..0c699639 --- /dev/null +++ b/scripts/solver_update_regressions.cjs @@ -0,0 +1,141 @@ +/* Real service-worker clients and cache storage across a two-tab update. */ +const { chromium, webkit } = require("playwright"); +const { createServer } = require("node:http"); +const { createHash } = require("node:crypto"); +const fs = require("node:fs"); +const assert = require("node:assert/strict"); +const source = fs.readFileSync("web/sw.js", "utf8"); +const first = "111111111111", second = "222222222222"; +const reports = []; +let build = first, requests = []; +function files() { + return { + "index.html": "Solver update regression", + "solver-worker.js": `let release; + self.onmessage=async({data})=>{ + try { + if(data==="release") { release(); return; } + if(data==="start") await new Promise(resolve=>{ + release=resolve; + self.postMessage({loading:true}); + }); + self.postMessage({debug:{controllerState:self.navigator.serviceWorker?.controller?.state, url:self.location.href}}); + const response=await fetch("./solver.${build}.zip"); + self.postMessage({status:response.status,body:await response.text()}); + } catch(error) { self.postMessage({error:error.message}); } + };`, + [`solver.${build}.zip`]: `verified solver ${build}`, + }; +} +const server = createServer((request, response) => { + const pathname = new URL(request.url, "http://localhost").pathname; + const path = pathname.replace(/^\/GridPuzzle\//, "") || "index.html"; + requests.push({ build, path }); + const assets = files(); + let body; + if (path === "sw.js") body = source.replace("__BUILD_ID__", build); + else if (path === "assets.json") body = JSON.stringify({ build, assets: Object.entries(assets).map(([path, data]) => ({ + path, bytes: Buffer.byteLength(data), sha256: createHash("sha256").update(data).digest("hex"), + })) }); + else body = assets[path]; + if (body === undefined) { response.writeHead(404); response.end("Not found"); return; } + response.writeHead(200, { + "content-type": path.endsWith(".js") ? "application/javascript" : path.endsWith(".html") ? "text/html" : "application/octet-stream", + "cache-control": "no-store", + }); + response.end(body); +}); +async function stopServer() { + if (!server.listening) return; + await new Promise((resolve) => { + server.close(resolve); + server.closeAllConnections(); + }); +} +(async () => { + for (const [name, engine] of Object.entries({ chromium, webkit })) { + build = first; + requests = []; + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}/GridPuzzle/`; + const browser = await engine.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + const report = { browser: name, version: browser.version(), errors: [] }; + reports.push(report); + page.on("pageerror", (error) => report.errors.push(error.message)); + try { + await page.goto(base); + await page.evaluate(async () => { + await navigator.serviceWorker.register("./sw.js"); + await navigator.serviceWorker.ready; + }); + await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller)); + // Model an existing installed app: its document navigation, not just + // a subsequent claim(), must already have gone through the old worker. + await page.reload(); + await page.waitForFunction(() => navigator.serviceWorker.controller?.state === "activated"); + await page.evaluate(() => { + window.results = []; + window.workerDebug = []; + window.originalController = navigator.serviceWorker.controller; + window.solver = new Worker("./solver-worker.js", { type: "module" }); + window.solver.onmessage = ({ data }) => { + if (data.loading) window.loading = true; + else if (data.debug) window.workerDebug.push(data.debug); + else window.results.push(data); + }; + window.solver.postMessage("probe"); + }); + await page.waitForFunction(() => window.results.length === 1); + assert.deepEqual(await page.evaluate(() => window.results[0]), { status: 200, body: `verified solver ${first}` }); + assert.equal(requests.filter((request) => request.path === `solver.${first}.zip`).length, 1, "the pre-update worker uses the verified cache, not the origin"); + await page.evaluate(() => { window.results = []; window.workerDebug = []; window.solver.postMessage("start"); }); + // Pause in the worker itself: an outstanding fetch through the old + // service worker would intentionally delay activation until it finishes. + await page.waitForFunction(() => window.loading === true); + const other = await context.newPage(); + await other.goto(base); + build = second; + await other.evaluate(async () => (await navigator.serviceWorker.getRegistration()).update()); + await other.waitForFunction(async () => Boolean((await navigator.serviceWorker.getRegistration()).waiting)); + await other.evaluate(async () => (await navigator.serviceWorker.getRegistration()).waiting.postMessage({ type: "ACTIVATE" })); + await page.waitForFunction(() => navigator.serviceWorker.controller && navigator.serviceWorker.controller !== window.originalController); + // Resume as soon as control changes to cover requests racing activation. + await page.evaluate(() => window.solver.postMessage("release")); + await page.waitForFunction(() => window.results.length === 1); + // controllerchange precedes the activate event's waitUntil work. Read + // diagnostic metadata only once that migration has actually completed. + await page.waitForFunction(() => navigator.serviceWorker.controller?.state === "activated"); + report.retained = await page.evaluate(async () => { + const key = (await caches.keys()).find((key) => key.endsWith("meta:222222222222")); + const cache = await caches.open(key); + return (await cache.match(new URL(".retained-solvers.json", location.href))).json(); + }); + assert.deepEqual(await page.evaluate(() => window.results[0]), { status: 200, body: `verified solver ${first}` }); + // Prove the same old URL remains available without any network fallback. + await stopServer(); + await assert.rejects(fetch(base, { signal: AbortSignal.timeout(2000) }), "the origin is actually unreachable"); + await page.evaluate(() => window.solver.postMessage("again")); + await page.waitForFunction(() => window.results.length === 2); + assert.deepEqual(await page.evaluate(() => window.results[1]), { status: 200, body: `verified solver ${first}` }); + assert.deepEqual(report.errors, []); + report.ok = true; + report.checks = ["another tab activates while an old solver initializes", "old worker fetches its exact verified archive online and offline"]; + } catch (error) { + report.ok = false; + report.failure = error.stack; + report.workerDebug = await page.evaluate(() => window.workerDebug).catch(() => null); + console.error(name, error); + console.error(JSON.stringify({ retained: report.retained, workerDebug: report.workerDebug, requests })); + } finally { + await browser.close(); + await stopServer(); + } + } + if (reports.some((report) => !report.ok)) process.exitCode = 1; +})().catch((error) => { console.error(error); process.exitCode = 1; }).finally(() => { + fs.mkdirSync("browser-artifacts", { recursive: true }); + fs.writeFileSync("browser-artifacts/solver-update-regressions.json", JSON.stringify(reports, null, 2)); + server.close(); +}); diff --git a/tests/test_str8ts.py b/tests/test_str8ts.py new file mode 100644 index 00000000..20275df0 --- /dev/null +++ b/tests/test_str8ts.py @@ -0,0 +1,66 @@ +from gridsolver.grid_classes.str8ts import Str8ts +from gridsolver.solver.solver import solve + + +def newspaper_grid(): + board = [ + [9, None, "#", 6, None, None, None, "#", "#"], + [None, None, None, 1, 4, None, 6, 5, None], + ["#", "#", None, 2, "#", "#", None, None, None], + [3, None, None, "#", 9, None, None, None, 5], + [None, None, "#", 8, None, None, "#", None, None], + ["#", None, None, None, None, 1, None, None, None], + [None, 7, None, 9, "#", None, 3, 2, "#"], + [None, None, None, 4, None, 5, None, None, 8], + [7, "#", None, None, None, "#", "#", None, None], + ] + black = { + (0, 2), (0, 3), (0, 7), (0, 8), + (2, 0), (2, 1), (2, 4), (2, 5), + (3, 3), (3, 8), (4, 2), (4, 6), + (5, 0), (5, 5), (6, 3), (6, 4), (6, 7), (6, 8), + (8, 0), (8, 1), (8, 5), (8, 6), + } + return Str8ts.from_board(board, black=black) + + +def test_newspaper_str8ts_is_unique(): + grid = newspaper_grid() + solutions = solve(grid, processes=0, max_sols=2, log_level=-1) + assert len(solutions) == 1 + values = grid.values_by_key(next(iter(solutions))) + expected = [ + [9, 8, None, 6, 5, 3, 4, None, None], + [8, 9, 3, 1, 4, 2, 6, 5, 7], + [None, None, 1, 2, None, None, 5, 7, 6], + [3, 4, 2, None, 9, 8, 7, 6, 5], + [4, 5, None, 8, 7, 9, None, 3, 2], + [None, 6, 5, 7, 8, 1, 2, 4, 3], + [5, 7, 6, 9, None, 4, 3, 2, None], + [6, 3, 7, 4, 2, 5, 1, 9, 8], + [7, None, 4, 5, 3, None, None, 8, 9], + ] + for row in range(9): + for col in range(9): + if expected[row][col] is not None: + assert values[(row, col)] == expected[row][col] + + +def test_numbered_black_cells_break_streets_but_count_for_uniqueness(): + grid = Str8ts.from_board( + [[1, 2, 3], [2, 3, 1], [3, 1, None]], + black={(1, 1)}, + ) + solutions = solve(grid, processes=0, max_sols=2, log_level=-1) + assert len(solutions) == 1 + assert grid.values_by_key(next(iter(solutions)))[(2, 2)] == 2 + assert (1, 1) in grid.numbered_black + + +def test_malformed_numbered_black_is_rejected(): + try: + Str8ts(3, black={(1, 1)}, numbered_black={(0, 0)}) + except ValueError as exc: + assert "also be black" in str(exc) + else: + raise AssertionError("expected invalid numbered-black cell") diff --git a/tests/test_web_api.py b/tests/test_web_api.py new file mode 100644 index 00000000..3cbaf3ed --- /dev/null +++ b/tests/test_web_api.py @@ -0,0 +1,112 @@ +"""Native tests for the same adapter shipped inside the browser archive.""" +import json +import pytest +from gridsolver.web_api import build_grid, solve_payload, solve_json + + +def puzzle(kind='sudoku', rows=4, cols=None, cells=None, **extra): + cols = rows if cols is None else cols + return dict(version=1, type=kind, rows=rows, cols=cols, + boxRows=2, boxCols=2, + cells=[None] * (rows * cols) if cells is None else cells, **extra) + + +SQUARE = [1,2,3,4, 3,4,1,2, 2,1,4,3, 4,3,2,1] + + +def test_row_major_and_unchanged_input(): + p = puzzle(cells=SQUARE.copy()) + p['cells'][6] = None + before = json.dumps(p) + result = solve_payload(p) + assert result['status'] == 'unique' + assert result['solutions'][0]['cells'] == SQUARE + assert json.dumps(p) == before + + +def test_multiple_and_no_solution(): + assert solve_payload(puzzle())['status'] == 'multiple' + p = puzzle(cells=SQUARE.copy()) + p['cells'][1] = 1 + assert solve_payload(p)['status'] == 'no-solution' + + +@pytest.mark.parametrize('kind', ['latinsquare', 'futoshiki', 'killersudoku', 'kenken']) +def test_dense_families(kind): + extra = {} + if kind in ('killersudoku', 'kenken'): + extra['cages'] = [{'cells': [i], 'target': n, 'op': '+'} for i, n in enumerate(SQUARE)] + if kind == 'futoshiki': + extra['inequalities'] = [{'less': 0, 'greater': 1}] + p = puzzle(kind, cells=SQUARE.copy(), **extra) + p['cells'][0] = None + assert solve_payload(p)['solutions'][0]['cells'] == SQUARE + + +def test_path_blocked_and_rectangular(): + p = puzzle('hidato', 2, 3, [1, '#', 5, 2, None, 4]) + result = solve_payload(p) + assert result['status'] == 'unique' + assert result['solutions'][0]['cells'] == [1, '#', 5, 2, 3, 4] + p = puzzle('numbrix', 2, 3, [1, 2, 3, 6, None, 4]) + assert solve_payload(p)['solutions'][0]['cells'] == [1, 2, 3, 6, 5, 4] + + +def test_slitherlink_zero_and_edge_encoding(): + assert solve_payload(puzzle('slitherlink', 1, cells=[0]))['status'] == 'no-solution' + result = solve_payload(puzzle('slitherlink', 1, cells=[4])) + assert result['status'] == 'unique' + assert result['solutions'][0]['edges'] == [['H', 0, 0], ['H', 1, 0], ['V', 0, 0], ['V', 0, 1]] + + +def test_kakuro(): + p = puzzle('kakuro', 3, cells=['#','#','#', '#',1,None, '#',None,None], clues=[ + {'cell': 1, 'down': 4}, {'cell': 2, 'down': 6}, + {'cell': 3, 'across': 3}, {'cell': 6, 'across': 7}]) + result = solve_payload(p) + assert result['status'] == 'unique' + assert result['solutions'][0]['cells'] == ['#','#','#', '#',1,2, '#',3,4] + + +@pytest.mark.parametrize('bad', [True, -1, 0, 26, 3.5, '4', None]) +def test_bad_dimensions(bad): + with pytest.raises(ValueError): + build_grid(puzzle(rows=4) | {'rows': bad}) + + +@pytest.mark.parametrize('change', [ + {'type': 'auto'}, {'cells': [True] * 16}, {'cells': [0] * 16}, + {'rows': 3}, {'version': 2}, {'diagonal': True}, + {'inequalities': [{'less': 0, 'greater': 1}]}, + {'cells': ['#'] * 16}, {'boxRows': 3}, + {'cages': [{'cells': [0], 'target': 1}]}, +]) +def test_reject_silently_ignored_or_malformed_data(change): + with pytest.raises(ValueError): + build_grid(puzzle() | change) + + +def test_missing_and_overlapping_cages(): + for cages in ([], [{'cells': [0], 'target': 1}], + [{'cells': [0,0], 'target': 3}]): + with pytest.raises(ValueError): + build_grid(puzzle('killersudoku', cages=cages)) + + +def test_invalid_json_and_executable_text(): + assert json.loads(solve_json('import os'))['status'] == 'invalid' + assert json.loads(solve_json('[]'))['status'] == 'invalid' + assert json.loads(solve_json('x' * 200001))['status'] == 'invalid' + + +def test_browser_str8ts_numbered_black_cell(): + from gridsolver.web_api import solve_payload + p = { + "version": 1, "type": "str8ts", "rows": 3, "cols": 3, + "black": [4], + "cells": [1, 2, 3, 2, 3, 1, 3, 1, None], + "cages": [], "inequalities": [], "clues": [], + } + result = solve_payload(p) + assert result["status"] == "unique" + assert result["solutions"][0]["cells"] == [1,2,3,2,3,1,3,1,2] diff --git a/tests/test_web_review3.py b/tests/test_web_review3.py new file mode 100644 index 00000000..19f23ceb --- /dev/null +++ b/tests/test_web_review3.py @@ -0,0 +1,74 @@ +import json +from pathlib import Path +import pytest +from gridsolver.web_api import build_grid +from scripts.build_web import build_destination, validate_output + +FIXTURES = json.loads( + (Path(__file__).parents[1] / "web/tests/fixtures/payloads.json").read_text() +) + + +@pytest.mark.parametrize("fixture", FIXTURES, ids=lambda f: f["name"]) +def test_shared_payload_contract(fixture): + if fixture["solver"]: + build_grid(fixture["payload"]) + else: + with pytest.raises(ValueError): + build_grid(fixture["payload"]) + + +@pytest.mark.parametrize( + "name", + ["web", "gridsolver", ".git", "tests", "scripts", ".", "..", "web/generated"], +) +def test_builder_refuses_source_paths_without_deleting(name, tmp_path): + root = tmp_path / "repo" + root.mkdir() + (root / "web").mkdir() + sentinel = root / "web/source.js" + sentinel.write_text("keep") + with pytest.raises(ValueError), build_destination(root, name): + pytest.fail("unsafe output accepted") + assert sentinel.read_text() == "keep" + + +def test_builder_refuses_unowned_existing_directory(tmp_path): + root = tmp_path / "repo" + root.mkdir() + out = root / "_site" + out.mkdir() + (out / "sentinel").write_text("keep") + with pytest.raises(ValueError), build_destination(root, "_site"): + pass + assert (out / "sentinel").read_text() == "keep" + + +def test_failed_build_preserves_previous_output_and_success_replaces_it(tmp_path): + root = tmp_path / "repo" + root.mkdir() + with build_destination(root, "_site") as out: + (out / "index.html").write_text("old") + with pytest.raises(RuntimeError), build_destination(root, "_site") as out: + (out / "index.html").write_text("partial") + raise RuntimeError("build failed") + assert (root / "_site/index.html").read_text() == "old" + with build_destination(root, "_site") as out: + (out / "index.html").write_text("new") + assert (root / "_site/index.html").read_text() == "new" + assert not list(root.glob("._site-stage-*")) + assert not list(root.glob("._site-backup-*")) + + +def test_builder_rejects_symbolic_output(tmp_path): + root = tmp_path / "repo" + root.mkdir() + target = tmp_path / "target" + target.mkdir() + try: + (root / "_site").symlink_to(target, target_is_directory=True) + except OSError: + pytest.skip("symlink creation is unavailable") + with pytest.raises(ValueError): + validate_output(root, "_site") + assert target.is_dir() diff --git a/web/README.md b/web/README.md new file mode 100644 index 00000000..6b71cb0f --- /dev/null +++ b/web/README.md @@ -0,0 +1,99 @@ +# GridPuzzle phone scanner + +The `browser-scanner` branch provides an installable, camera-first static web app at: + +https://senegrom.github.io/GridPuzzle/ + +Recognition and the complete Python GridPuzzle solver run on-device. Photographs are not uploaded to a recognition service or remote solver. Nothing in the deployment workflow merges this branch into `master`. + +## Build and deploy + +Requirements: Python 3.14+, Node.js 22+, and network access while building the pinned browser packages. + +```sh +python -m pip install -e '.[dev]' +python -m pytest -q tests -m 'not slow' +node --test web/tests/*.test.js +python scripts/build_web.py +python -m http.server 8000 --directory _site +``` + +Camera permissions require localhost or HTTPS. The existing `Build and deploy phone scanner` workflow is the single expensive deployment gate: it builds `_site`, runs Python and browser unit tests, executes the real Chromium/mobile-WebKit Python and OCR acceptance suites (including the real-newspaper fixtures), uploads that tested artifact, and deploys it through `gridpuzzle-browser-pages`. + +The app is a multi-file static site, not a Python server. Runtime Python, OCR, English training data and icons are self-hosted. The build verifies every npm tarball against a pinned SHA-512 integrity value before unpacking it and ships only the LSTM Tesseract cores the bundled model uses. `build-info.json` records the exact source commit and package integrity metadata; `assets.json` records SHA-256 digests. + +## Features + +- Rear-facing live camera with manual shutter and optional stable-grid capture. +- Photo-library import and a native camera-file fallback for denied/unavailable live camera access. +- Four draggable crop corners, rotation, projective straightening, automatic continuous-grid size detection, explicit dimensions and puzzle-type selection. +- Local printed-clue OCR with confidence/review flags and guided **Review highlighted clues → Save & next**. +- All twelve solver families: Sudoku, Killer Sudoku, Futoshiki, KenKen, Latin square, diagonal Latin square, pandiagonal Latin square, Hidato, Numbrix, Kakuro, Slitherlink and Str8ts. +- Str8ts support includes solid black street separators and numbered black cells. Numbered black cells constrain row/column uniqueness but do not join a street. +- Editors for values/blocked or black cells, cages, inequalities and Kakuro directional clues, plus undo and validated JSON import/export. +- A strict Python data boundary and the full Python 3.14 solver through Pyodide in a cancellable worker. Browser solving uses sequential search capped at two solutions to distinguish no/unique/multiple solutions without unsupported browser multiprocessing. +- Clean-board and captured-photo overlays, including Slitherlink edges, plus PNG overlay export. +- Local puzzle/settings persistence. Recognition uncertainty is persisted atomically; photographs and solver results are not. +- Installable PWA icons, hash-verified offline preparation and a request for persistent browser storage. A banner at the top of the page announces a ready update; nothing reloads until the user chooses to. + +## Recognition trust model + +Automatic recognition is a proposal, not proof. A faint or cropped clue can look like an intentionally blank cell, so an **automatically identified puzzle type always requires one rules confirmation**, including boxed Sudoku. Structural families such as Str8ts remain review-gated. An explicitly selected type represents a separate user decision, but uncertainty flags still block silent trust of suspect readings. + +A unique solution verifies only the transcribed rules and clues. It does not prove the photograph was read correctly. + +Newsprint handling now uses solid-cell statistics to distinguish true black separators from gray Sudoku shading, connected-component cleanup to suppress paper/halftone specks, and local per-digit Otsu binarization before the bounded Tesseract atlas call. Every single-glyph digit is then re-read on its own, once from its binary crop and once from its grayscale crop, and the three readings vote: unanimity clears the review flag even at low individual scores, any disagreement keeps it. The two user-provided newspaper crops are retained under `Examples/BrowserScanner/Newspaper/` and are not shipped in the PWA bundle. + +On the 2026-09-07 real newspaper regressions, Chromium 153 and WebKit 26.6 both read the shaded Sudoku **24/24**. They both read the Str8ts **19/20** printed values, with the one missed clue explicitly flagged for review; both detect the Str8ts black-cell layout exactly and produce **zero unsafe unflagged discrepancies**. Generated regressions remain useful secondary baselines: Chromium reads all tested generated variants exactly, while the current WebKit perspective/shadow case reads 29/30 with the miss flagged. + +Cage boundaries/targets, inequalities, Kakuro directions and path-puzzle identification remain experimental and require review. Handwriting, alphabetic large-grid clues, arbitrary publisher layouts and invisible variant rules are not promised. Physical-iPhone autofocus/exposure, installed-mode camera behaviour, storage eviction and airplane-mode use still require hardware testing. + +Digit cleanup retains neighbouring glyphs in multi-digit numbers and preserves ink in pure black-and-white scans. Invalid Str8ts values and Kakuro targets become highlighted blanks for correction. An incompatible cage reading leaves its cells uncovered; missing or ambiguous targets remain unset. These incomplete structures are editable, and Solve requires their correction rather than accepting an invented operator or target. + +## Data contract + +```json +{ + "version": 1, + "type": "str8ts", + "rows": 4, + "cols": 4, + "cells": [1, null, "#", 4, 2, 3, 4, 1, 3, 4, 1, 2, 4, 1, 2, 3], + "black": [2], + "cages": [], + "inequalities": [], + "clues": [] +} +``` + +Cells are zero-based row-major. `null` is blank; `"#"` is a blocked/blank-black cell where the family supports it; Slitherlink `0` is a real face clue. Str8ts uses `black` as a distinct list of black-cell indices; an index in `black` may contain either `"#"` or an integer printed on that black cell. Cages use `{ "cells": [0,1], "target": 3, "op": "+" }`; inequalities use `{ "less": 0, "greater": 1 }`; a Kakuro clue on a blocked cell can use `{ "cell": 0, "across": 16, "down": 23 }`. + +The browser rejects malformed dimensions, boxes, Str8ts black metadata, overlapping/disconnected cages, invalid cage arity/operators, nonadjacent inequalities and empty Kakuro clue objects before they can become solve requests. Incomplete cage coverage and missing OCR targets remain editable states, but **Solve** performs a solve-ready check before Pyodide starts. The Python adapter remains the authoritative final boundary. + +The 25×25 browser limit is a phone resource policy, not a native solver limit. Str8ts itself is limited to square 2×2 through 9×9 boards. A deadline/cancellation means unfinished, never unsatisfiable or unique. + +## Offline behaviour + +Offline requests are scoped to `/GridPuzzle/`. Root navigation maps to cached `index.html` even when a bookmark/share URL includes query parameters. Runtime assets live in one content-addressed cache keyed by SHA-256, and each build's asset list is stored separately, so an update reuses unchanged verified bytes instead of downloading the whole Pyodide/Tesseract bundle again. Every downloaded asset is digest-verified before it is stored. + +The startup status is a cheap presence check. **Download for offline use** performs full sequential digest verification, evicts/refetches anything that fails, and asks the browser for persistent storage. Ordinary requests trust bytes already verified before write, so large WASM files are not re-hashed on every fetch. If the browser evicts the asset list, in-scope requests fall back to the network and restore it online. Cache quota failure does not break a verified online response. + +## Testing + +- `tests/test_web_api.py` verifies the Python/browser data contract; `tests/test_str8ts.py` verifies Str8ts street semantics and the uniquely solved newspaper puzzle. +- `web/tests/` covers geometry, classification, OCR mapping/preprocessing, cache recovery, worker lifecycle, malformed input, type confirmation, structural validation, keyboard boundaries, no-op edit guards and service-worker routing. +- `scripts/browser_smoke.cjs` exercises all twelve puzzle families through the real Python/Pyodide solver in Chromium and WebKit. +- `scripts/browser_regressions.cjs` exercises generated OCR/perspective/review regressions. +- `scripts/newspaper_regressions.cjs` runs the production scanner/Tesseract pipeline against the two real user-provided newspaper crops in Chromium and WebKit. Any wrong, missed or invented clue that is not review-flagged fails the deployment. +- The newspaper images and hand-checked ground truth live in `Examples/BrowserScanner/Newspaper/`, outside `web/`, so they do not inflate the deployed/offline bundle. +- Normal Linux/Windows CI and forward compatibility remain independent from the single full Pages/browser gate. + +Generated fixtures are regression baselines, not substitutes for real-device testing. + +## Input, build and lifecycle hardening + +The page declares a same-origin Content Security Policy and a no-referrer policy; every runtime asset is self-hosted. Builds are staged before publication. Inside the repository, only `_site` is accepted as output; custom external outputs must be new or builder-owned. Source directories, Git metadata, repository ancestors and symbolic output links are refused, and a failed build preserves the previous good output. + +Task/deadline ownership, edit snapshots, camera/photo flow and offline controls are separate modules. Grayscale/threshold/region preparation runs off the UI thread. Each OCR scan owns a dedicated host that can terminate raw Tesseract workers even while language initialization is pending. Stale task generations cannot replace a newer puzzle. + +Live moving-camera AR and step-by-step deduction explanations are not included in this branch. diff --git a/web/TESTING.md b/web/TESTING.md new file mode 100644 index 00000000..951d3f13 --- /dev/null +++ b/web/TESTING.md @@ -0,0 +1,46 @@ +# Browser acceptance and recognition measurements + +The deployment build tests the actual Python solver and OCR WebAssembly in both Chromium and mobile WebKit; it does not substitute a JavaScript solver or mocked OCR. Browser versions and raw scan measurements are recorded in the uploaded report artifact. + +## Recognition is measured before correction + +Every single-glyph digit is read three times: in the sparse-text atlas, and on its own as a single character from its binary crop and from its grayscale crop. The readings vote; unanimity of at least two readers clears the review flag, any disagreement or a lone reading keeps it. Across eight fonts in Chromium and WebKit this raised clean correct readings from 364 to 453 of 480 digits and removed the only unflagged misread. + +Generated acceptance fixtures record raw cells, confidence/review flags and discrepancies **before** manual correction. A wrong or missed clue without a review flag fails. Generated fixtures are baselines, not claims about arbitrary photographs, handwriting or publisher styles. + +The generated browser suite also requires exact transcription of a binary 4×4 Sudoku and a Numbrix grid with multi-digit clues. Unit regressions verify complete glyph grouping with speckle removal, threshold-boundary pixels in both polarities, and correction of invalid Str8ts/Kakuro readings and incompatible KenKen cages without weakening import or solve validation. + +The deployment also runs two real user-provided newspaper crops from `Examples/BrowserScanner/Newspaper/` through the same production scanner and self-hosted Tesseract.js path. `newspaper-regressions.json` compares the raw transcription with hand-checked `ground-truth.json` and fails on any unsafe unflagged discrepancy or incorrect Str8ts black-cell geometry. + +For the 2026-09-07 fixtures, Chromium 153.0.8010.12 and WebKit 26.6 both produce: + +- shaded Sudoku: **24/24** printed values correct, no structural black cells, no unsafe discrepancies; +- Str8ts: **19/20** printed values correct, exact 22-cell black layout, with the one missed white-cell clue flagged for review and no unsafe discrepancies. + +The same run's generated suite reads all baseline, serif, shifted and 4×4 values exactly in both browsers. Chromium also reads the perspective/shadow case 30/30; WebKit reads it 29/30 and flags the miss. Production code never substitutes fixture answers. + +Automatic classification is treated as a trust boundary: automatically identified puzzles remain `needsReview` until their rules are confirmed. Str8ts black cells are structural data and remain review-gated even when OCR is otherwise clean. + +## Offline test method + +The preview is served under `/GridPuzzle/`, matching Pages. After hash-verified offline preparation, the test stops the HTTP server and verifies that the origin is unreachable. The page then reloads, starts a fresh Python worker, solves, imports a photo and performs fresh OCR while the origin remains unavailable. + +Unit tests verify that `/GridPuzzle/?query=...` navigation maps to cached `index.html`, while real subpaths are not silently rewritten. Explicit offline preparation re-hashes the complete asset set; startup status is only a presence check. Assets are content-addressed, so updates reuse unchanged verified bytes. + +This is not a physical-iPhone airplane-mode, autofocus, installed-camera or storage-eviction test. Those require hardware. + +## Other assertions + +Coverage includes all twelve solver families, phone layouts, malformed imports, Str8ts black metadata, early cage/Kakuro validation, solve-ready checks, clue editing, stale-result invalidation, undo, no-op removal guards, bounded keyboard navigation, type changes preserving clues, cancellation/restart, pagehide cleanup, persistent scan uncertainty, denied-camera fallback, photo-overlay invalidation, cache recovery and absence of external runtime requests. + +Editor regressions check independent numeric/cage warnings through both edit forms, Undo and reload; JSON drafts through view changes and asynchronous solver results; accessible labels for solved cells and Kakuro targets; and superseded JSON import errors. Unit tests also exercise delayed photo decode failures after cancellation or replacement, while preserving errors from the active import. + +The browser suite also requires exact transcription of transparent PNGs through both image decode paths, and verifies detected/manual scan dimensions through Board refreshes, editing-tool changes and Undo. Controlled service-worker tests cover activation in another tab, a click racing activation, and initial installation without an unnecessary reload. + +Layout/editor coverage includes changing scan families while retaining an existing board, correcting and applying Sudoku box dimensions, and keeping the controls available in automatic mode and after Undo. Keyboard regressions select, extend, deselect and save both cages and inequalities with Enter, Space and arrow keys, checking focus after each board redraw. + +Camera lifecycle tests cover cancellation while permission, video playback or grid detection is pending. Chromium and WebKit tests also exercise the application's cancellation wiring with controlled media, verifying that editing and solving stop capture and ignore queued detection callbacks. + +Both browsers exercise a real two-tab service-worker update while an old dedicated solver worker is initializing, then request its original verified archive online and with the origin server stopped. This focused lifecycle fixture controls the initialization delay; the full solver and OCR checks above still use the production runtimes. Unit tests cover changed and reused archive bytes, repeated updates, workers that are not enumerable yet, and cleanup after the owning clients close. Old solver archives are retained for the tabs and workers present at activation and pruned at a later activation once those clients have gone. + +The `Build and deploy phone scanner` workflow is the single full Chromium/WebKit deployment gate. Lightweight PR browser CI runs unit/parse checks; normal Linux/Windows CI and forward compatibility remain independent. diff --git a/web/app.js b/web/app.js new file mode 100644 index 00000000..57381e4b --- /dev/null +++ b/web/app.js @@ -0,0 +1,1154 @@ +import { nextReviewCell } from "./model.js"; +import { createTaskController } from "./task-controller.js"; +import { captureEdit, restoreEdit, rememberEdit } from "./edit-history.js"; +import { setupPhotoFlow } from "./photo-flow.js"; +import { setupOffline } from "./offline.js"; +import { + TYPES, + makePuzzle, + demo, + clone, + checkShape, + conflicts, + isCage, + boxShape, + moveIndex, + hasCageRemoval, + hasInequalityRemoval, + checkSolveReady, +} from "./model.js"; +import { Scanner } from "./scanner.js"; +import { homography, project } from "./geometry.js"; +import { saveSession, restoreSession } from "./session.js"; + +const $ = (id) => document.getElementById(id), + NS = "http://www.w3.org/2000/svg", + scanner = new Scanner(); +const state = { + puzzle: makePuzzle(), + layout: null, + uncertain: new Set(), + cageUncertain: new Set(), + needsReview: false, + notes: [], + result: null, + solution: 0, + photo: null, + rectified: null, + puzzleSource: null, + photoSource: null, + corners: null, + photoRows: 0, + photoCols: 0, + view: "board", + selected: [], + history: [], +}; +let worker = null, + editing = 0, + focused = 0, + renderedJson = ""; +const storage = { + get: (key) => { + try { + return JSON.parse(localStorage.getItem(key)); + } catch { + return null; + } + }, + set: (key, value) => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* Private/storage-full mode must not break solving. */ + } + }, +}; +for (const [value, label] of Object.entries(TYPES)) { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + $("puzzle-type").append(option); +} +const applyType = document.createElement("button"); +applyType.id = "use-type"; +applyType.className = "text-button"; +applyType.hidden = true; +$("type-help").after(applyType); +// Version 1 preferences could hold a puzzle type written by board loading +// rather than chosen by the user, so only the explicit settings migrate and +// the scan type starts over at automatic detection. +const legacyPrefs = storage.get("gridpuzzle-settings-v1"), + prefs = + storage.get("gridpuzzle-settings-v2") || + (legacyPrefs && typeof legacyPrefs === "object" + ? { ...legacyPrefs, type: "auto" } + : null); +if (prefs) { + if (prefs.type === "auto" || Object.hasOwn(TYPES, prefs.type)) + $("puzzle-type").value = prefs.type; + for (const id of ["auto-capture", "auto-solve"]) + if (typeof prefs[id] === "boolean") $(id).checked = prefs[id]; + if (["0", "30", "90", "300"].includes(prefs.limit)) + $("time-limit").value = prefs.limit; +} +function savePrefs() { + storage.set("gridpuzzle-settings-v2", { + type: $("puzzle-type").value, + "auto-capture": $("auto-capture").checked, + "auto-solve": $("auto-solve").checked, + limit: $("time-limit").value, + }); +} +for (const id of ["puzzle-type", "auto-capture", "auto-solve", "time-limit"]) + $(id).addEventListener("change", savePrefs); +const layoutFields = { + rows: "rows", cols: "cols", boxRows: "box-rows", boxCols: "box-cols", +}; +function setLayout(layout) { + state.layout = Object.fromEntries( + Object.entries(layoutFields).map(([key, id]) => { + $(id).value = layout[key]; + return [key, layout[key]]; + }), + ); +} +for (const [key, id] of Object.entries(layoutFields)) + $(id).addEventListener("input", () => { + // Keep incomplete/invalid input editable until Read or Apply validates it. + state.layout[key] = $(id).value; + }); +function typeControl() { + const type = $("puzzle-type").value; + applyType.hidden = type === "auto" || type === state.puzzle.type; + applyType.textContent = `Use ${TYPES[type] || "this type"} for the current board`; + // These controls configure the next scan/layout. Automatic detection may + // propose Sudoku even when the current board belongs to another family. + $("box-fields").hidden = !["auto", "sudoku", "killersudoku"].includes(type); +} +function reviewCells() { + return new Set([...state.uncertain, ...state.cageUncertain]); +} +$("puzzle-type").addEventListener("change", typeControl); +function status(text, detail = "", kind = "info", progress = null) { + delete $("status").dataset.result; + $("status").className = `status ${kind}`; + $("status-text").textContent = text; + $("status-detail").textContent = detail; + $("progress").hidden = !tasks.busy; + if (progress === null) $("progress").removeAttribute("value"); + else $("progress").value = progress; +} +function fail(error) { + if (error?.name !== "AbortError") + status( + error?.message || String(error), + "Nothing was uploaded or sent to a remote solver.", + "error", + ); +} +function remember() { + rememberEdit(state); +} + +function persist() { + saveSession(storage, state); +} +const tasks = createTaskController({ + $, + scanner, + status, + onStop: (wasBusy) => { + stopCamera(); + if (wasBusy && worker) { + worker.terminate(); + worker = null; + } + }, +}); +const stopTask = (message) => tasks.stop(message); +const begin = () => tasks.begin(); +const finish = () => tasks.finish(); +function invalidate() { + stopTask(); + state.result = null; + state.solution = 0; + state.view = "board"; + status( + "Puzzle changed.", + "Solve again to check the updated clues and rules.", + ); +} +function mutate(fn) { + const previous = captureEdit(state), + history = [...state.history]; + remember(); + invalidate(); + try { + fn(); + checkShape(state.puzzle); + } catch (error) { + restoreEdit(state, previous); + state.history = history; + render(); + throw error; + } + persist(); + render(); +} +function normalized(p) { + checkShape(p); + return { + ...clone(p), + boxRows: p.boxRows === undefined ? 3 : p.boxRows, + boxCols: p.boxCols === undefined ? 3 : p.boxCols, + cages: clone(p.cages || []), + inequalities: clone(p.inequalities || []), + clues: clone(p.clues || []), + black: clone(p.black || []), + }; +} +export function loadPuzzle(payload) { + const p = normalized(payload); + remember(); + invalidate(); + stopCamera(); + state.puzzle = p; + setLayout(p); + state.uncertain.clear(); + state.cageUncertain.clear(); + state.needsReview = false; + state.notes = []; + state.photo = + state.rectified = + state.puzzleSource = + state.photoSource = + state.corners = + null; + $("photo-panel").hidden = true; + state.selected = []; + focused = 0; + persist(); + render({ replaceDraft: true }); + status( + "Puzzle loaded.", + `${TYPES[p.type]} · Tap any cell to edit its printed clue.`, + ); +} +export function getState() { + return { + puzzle: clone(state.puzzle), + result: clone(state.result), + uncertain: [...reviewCells()], + cellUncertain: [...state.uncertain], + cageUncertain: [...state.cageUncertain], + needsReview: state.needsReview, + busy: tasks.busy, + }; +} +function svg(tag, attrs = {}, text = null) { + const node = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) + if (k === "style") + // CSSOM writes are allowed under the strict style-src policy; a style + // attribute set through setAttribute is not. + for (const declaration of String(v).split(";")) { + const at = declaration.indexOf(":"); + if (at > 0) + node.style.setProperty( + declaration.slice(0, at).trim(), + declaration.slice(at + 1).trim(), + ); + } + else node.setAttribute(k, String(v)); + if (text !== null) node.textContent = String(text); + return node; +} +function drawBoard() { + const p = state.puzzle, + board = $("board"), + size = 72, + margin = 5, + sol = state.result?.solutions?.[state.solution], + bad = conflicts(p), + flagged = reviewCells(); + focused = Math.min(focused, p.cells.length - 1); + board.style.minWidth = `${Math.max(240, p.cols * 34)}px`; + board.replaceChildren(); + board.setAttribute( + "viewBox", + `-${margin} -${margin} ${p.cols * size + 2 * margin} ${p.rows * size + 2 * margin}`, + ); + const cages = new Map(); + p.cages.forEach((c, k) => c.cells.forEach((i) => cages.set(i, k))); + for (let i = 0; i < p.cells.length; i++) { + const r = Math.floor(i / p.cols), + c = i % p.cols, + x = c * size, + y = r * size, + given = p.cells[i], + value = sol?.cells[i] ?? given, + isBlack = given === "#" || (p.type === "str8ts" && (p.black || []).includes(i)); + const classes = ["board-cell"]; + if (isBlack) classes.push("blocked"); + else if (given === null && Number.isInteger(value)) classes.push("answer"); + if (flagged.has(i)) classes.push("uncertain"); + if (bad.has(i)) classes.push("conflict"); + if (state.selected.includes(i)) classes.push("selected"); + const clue = + p.type === "kakuro" && given === "#" + ? p.clues.find((q) => q.cell === i) + : null; + const description = + given === "#" + ? [ + "blocked", + clue?.across != null ? `across ${clue.across}` : "", + clue?.down != null ? `down ${clue.down}` : "", + ].filter(Boolean).join(", ") + : value === null + ? "blank" + : `${isBlack ? "black clue " : given === null ? "solution " : ""}${value}`; + const review = [ + state.uncertain.has(i) ? "check reading" : "", + state.cageUncertain.has(i) ? "check cage" : "", + ].filter(Boolean); + const g = svg("g", { + class: classes.join(" "), + "data-cell": i, + role: "button", + tabindex: i === focused ? 0 : -1, + "aria-label": [`Row ${r + 1}, column ${c + 1}: ${description}`, ...review].join(", "), + }); + g.append( + svg("rect", { x, y, width: size, height: size, class: "cell-hit" }), + ); + if (given === "#" && p.type === "kakuro") { + if (clue) { + g.append(svg("path", { d: `M${x},${y}l72,72`, stroke: "#829b91" })); + if (clue.across != null) + g.append( + svg( + "text", + { x: x + 51, y: y + 24, class: "kakuro-clue" }, + clue.across, + ), + ); + if (clue.down != null) + g.append( + svg( + "text", + { x: x + 21, y: y + 59, class: "kakuro-clue" }, + clue.down, + ), + ); + } + } else if (Number.isInteger(value)) + g.append( + svg( + "text", + { + x: x + 36, + y: y + 47, + style: `font-size:${value >= 100 ? 22 : 30}px`, + }, + value, + ), + ); + board.append(g); + } + if ( + ["sudoku", "killersudoku"].includes(p.type) && + Number.isInteger(p.boxRows) && + Number.isInteger(p.boxCols) && + p.boxRows > 0 && + p.boxCols > 0 + ) { + for (let r = 0; r <= p.rows; r += p.boxRows) + board.append( + svg("path", { + d: `M0 ${r * size}H${p.cols * size}`, + class: "box-line", + }), + ); + for (let c = 0; c <= p.cols; c += p.boxCols) + board.append( + svg("path", { + d: `M${c * size} 0V${p.rows * size}`, + class: "box-line", + }), + ); + } + if (isCage(p.type)) + p.cages.forEach((cage, k) => { + for (const i of cage.cells) { + const r = Math.floor(i / p.cols), + c = i % p.cols, + x = c * size, + y = r * size; + let d = ""; + if (r === 0 || cages.get(i - p.cols) !== k) + d += `M${x + 4} ${y + 4}h64`; + if (c === p.cols - 1 || cages.get(i + 1) !== k) + d += `M${x + 68} ${y + 4}v64`; + if (r === p.rows - 1 || cages.get(i + p.cols) !== k) + d += `M${x + 4} ${y + 68}h64`; + if (c === 0 || cages.get(i - 1) !== k) d += `M${x + 4} ${y + 4}v64`; + board.append( + svg("path", { + d, + class: "cage-line", + "stroke-dasharray": p.type === "killersudoku" ? "3 3" : "none", + }), + ); + } + const i = Math.min(...cage.cells), + text = `${cage.target ?? "?"}${p.type === "kenken" ? { "*": "×", "/": "÷" }[cage.op] || cage.op || "+" : ""}`; + board.append( + svg( + "text", + { + x: (i % p.cols) * size + 8, + y: Math.floor(i / p.cols) * size + 17, + "font-size": 13, + fill: "#45665e", + "pointer-events": "none", + }, + text, + ), + ); + }); + for (const q of p.inequalities) { + const ar = Math.floor(q.less / p.cols), + ac = q.less % p.cols, + br = Math.floor(q.greater / p.cols), + bc = q.greater % p.cols, + x = ((ac + bc + 1) * size) / 2, + y = ((ar + br + 1) * size) / 2; + board.append( + svg("rect", { + x: x - 10, + y: y - 13, + width: 20, + height: 26, + fill: "#fff", + "pointer-events": "none", + }), + ); + board.append( + svg( + "text", + { x, y: y + 8, class: "inequality" }, + ar === br ? (ac < bc ? "<" : ">") : ar < br ? "⌃" : "⌄", + ), + ); + } + if (p.type === "slitherlink") { + for (const [orientation, r, c] of sol?.edges || []) + board.append( + svg("line", { + x1: c * size, + y1: r * size, + x2: (c + (orientation === "H" ? 1 : 0)) * size, + y2: (r + (orientation === "V" ? 1 : 0)) * size, + class: "loop-edge", + }), + ); + for (let r = 0; r <= p.rows; r++) + for (let c = 0; c <= p.cols; c++) + board.append( + svg("circle", { + cx: c * size, + cy: r * size, + r: 3, + fill: "#173536", + "pointer-events": "none", + }), + ); + } +} +function canOverlay() { + return !!( + state.photo && + state.corners && + state.rectified && + state.puzzleSource === state.photoSource && + state.result?.solutions?.length && + state.photoRows === state.puzzle.rows && + state.photoCols === state.puzzle.cols + ); +} +function clearPhotoMapping() { + state.rectified = null; + state.photoSource = null; + state.photoRows = state.photoCols = 0; + state.view = "board"; + $("photo-view").disabled = true; + $("save-photo").hidden = true; + $("solution-photo").hidden = true; + $("board-scroll").hidden = false; + $("clean-view").setAttribute("aria-pressed", "true"); + $("photo-view").setAttribute("aria-pressed", "false"); +} +function drawOverlay() { + if (!canOverlay()) return; + const out = $("solution-photo"), + ctx = out.getContext("2d"), + p = state.puzzle, + sol = state.result.solutions[state.solution]; + out.width = state.photo.width; + out.height = state.photo.height; + ctx.drawImage(state.photo, 0, 0); + const m = homography(state.corners), + point = (r, c) => project(m, c / p.cols, r / p.rows); + ctx.strokeStyle = "#078772"; + ctx.lineWidth = Math.max(3, out.width / 180); + ctx.lineCap = "round"; + if (p.type === "slitherlink") + for (const [o, r, c] of sol.edges) { + const a = point(r, c), + b = point(r + (o === "V" ? 1 : 0), c + (o === "H" ? 1 : 0)); + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + else + for (let i = 0; i < p.cells.length; i++) + if (p.cells[i] === null && Number.isInteger(sol.cells[i])) { + const r = Math.floor(i / p.cols), + c = i % p.cols, + a = point(r + 0.5, c + 0.5), + b = point(r + 0.5, c + 1.1), + height = point(r + 1, c + 0.5), + font = Math.max( + 10, + Math.min( + Math.hypot(a.x - b.x, a.y - b.y), + Math.hypot(a.x - height.x, a.y - height.y), + ) * 1.05, + ); + ctx.font = `650 ${font}px -apple-system,Arial,sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.lineWidth = Math.max(2, font * 0.1); + ctx.strokeStyle = "#ffffffee"; + ctx.strokeText(String(sol.cells[i]), a.x, a.y); + ctx.fillStyle = "#067c6b"; + ctx.fillText(String(sol.cells[i]), a.x, a.y); + } +} +function render({ replaceDraft = false } = {}) { + const p = state.puzzle; + $("board-meta").textContent = + `${TYPES[p.type]} · ${p.rows} × ${p.cols} · ${p.cells.filter(Number.isInteger).length} printed clues`; + // The pending photo/layout can differ from the board currently displayed. + setLayout(state.layout || p); + $("undo").disabled = !state.history.length; + typeControl(); + for (const option of $("edit-tool").options) + option.disabled = + (option.value === "cage" && !isCage(p.type)) || + (option.value === "inequality" && p.type !== "futoshiki"); + if ($("edit-tool").selectedOptions[0]?.disabled) + $("edit-tool").value = "value"; + $("cage-editor").hidden = $("edit-tool").value !== "cage"; + $("inequality-editor").hidden = $("edit-tool").value !== "inequality"; + $("cage-op").disabled = p.type === "killersudoku"; + // Refresh pristine data, but do not discard a draft on a view change or an + // asynchronous solver result. Explicit puzzle loading starts a new draft. + if (replaceDraft || $("json-data").value === renderedJson) { + renderedJson = JSON.stringify(p, null, 2); + $("json-data").value = renderedJson; + } + drawBoard(); + const overlay = canOverlay(); + $("photo-view").disabled = !overlay; + $("save-photo").hidden = !overlay; + $("show-crop").hidden = !state.photo; + if (!overlay) state.view = "board"; + $("board-scroll").hidden = state.view === "photo"; + $("solution-photo").hidden = state.view !== "photo"; + $("clean-view").setAttribute("aria-pressed", String(state.view === "board")); + $("photo-view").setAttribute("aria-pressed", String(state.view === "photo")); + if (overlay) drawOverlay(); + $("next-solution").hidden = (state.result?.solutions?.length || 0) < 2; + const review = reviewCells().size || state.needsReview; + $("review-note").hidden = !review; + $("review-clues").hidden = !state.uncertain.size; + $("review-clues").textContent = + `Review ${state.uncertain.size} highlighted clues`; + const sourceAvailable = + state.rectified && state.puzzleSource === state.photoSource; + const checkMessage = state.uncertain.size + ? `${state.uncertain.size} cells need checking. ${sourceAvailable ? "Tap a highlighted cell to compare it with the photograph." : "Check the highlighted clues against the original puzzle. Photos are not retained after closing the app."}` + : "Confirm the puzzle type and structural clues."; + const cageMessage = state.cageUncertain.size + ? `${state.cageUncertain.size} cells need cage review. Choose Cages under Editing to check their boundaries, targets and operators.` + : ""; + $("review-note").textContent = [checkMessage, cageMessage, ...state.notes].filter(Boolean).join("\n"); + $("solve").textContent = review ? "Check & solve →" : "Solve puzzle →"; +} +const boxDefault = boxShape; +applyType.onclick = () => { + try { + const next = clone(state.puzzle), + type = $("puzzle-type").value; + if (!Object.hasOwn(TYPES, type)) + throw Error("Select an explicit puzzle type."); + if ( + (next.cages.length && !isCage(type)) || + (next.inequalities.length && type !== "futoshiki") || + (next.clues.length && type !== "kakuro") || + ((next.black || []).length && type !== "str8ts") + ) + throw Error( + "This board has structural clues for a different puzzle type. Remove those constraints explicitly or start a blank board; they will not be silently discarded.", + ); + if (type === "killersudoku" && next.cages.some((c) => c.op && c.op !== "+")) + throw Error( + "Killer Sudoku cages must be sums. Correct the operators before changing the type.", + ); + next.type = type; + checkShape(next); + mutate(() => { + state.puzzle = next; + if (!isCage(type)) state.cageUncertain.clear(); + state.needsReview = Boolean(state.photo); + state.notes = [ + `Rules changed to ${TYPES[type]}. Printed clues have been kept.`, + ]; + state.selected = []; + }); + status( + `Using ${TYPES[type]}.`, + "Printed values are unchanged. Check the rules before solving.", + ); + } catch (error) { + fail(error); + } +}; +function openCell(i) { + stopTask(tasks.busy ? "Stopped for editing." : null); + editing = i; + focused = i; + const p = state.puzzle, + r = Math.floor(i / p.cols), + c = i % p.cols; + $("cell-title").textContent = `Row ${r + 1} · Column ${c + 1}`; + $("cell-value").value = Number.isInteger(p.cells[i]) ? p.cells[i] : ""; + $("blocked-cell").checked = p.cells[i] === "#" || (p.type === "str8ts" && (p.black || []).includes(i)); + $("block-option").hidden = !["hidato", "kakuro", "str8ts"].includes(p.type); + $("cell-error").textContent = ""; + const clue = p.clues.find((q) => q.cell === i); + $("across-value").value = clue?.across ?? ""; + $("down-value").value = clue?.down ?? ""; + blockInputs(); + $("clue-crop").hidden = !( + state.rectified && + state.puzzleSource === state.photoSource && + state.photoRows === p.rows && + state.photoCols === p.cols + ); + if (!$("clue-crop").hidden) { + const out = $("clue-crop"), + ctx = out.getContext("2d"), + cw = state.rectified.width / p.cols, + ch = state.rectified.height / p.rows; + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, 180, 180); + ctx.drawImage(state.rectified, c * cw, r * ch, cw, ch, 0, 0, 180, 180); + } + $("save-next").hidden = !state.uncertain.size; + $("review-position").hidden = !state.uncertain.size; + $("review-position").textContent = + `${state.uncertain.size} readings left to check. Saving confirms only this cell.`; + $("cell-dialog").showModal(); + $("cell-value").focus(); + $("cell-value").select(); +} +function blockInputs() { + $("cell-value").disabled = $("blocked-cell").checked && state.puzzle.type !== "str8ts"; + $("kakuro-inputs").hidden = + state.puzzle.type !== "kakuro" || !$("blocked-cell").checked; +} +$("blocked-cell").onchange = blockInputs; +function numberInput(id) { + const text = $(id).value.trim(); + if (!text) return null; + if (!/^\d{1,12}$/.test(text)) + throw Error("Use a whole number, or leave the field blank."); + return Number(text); +} +function saveCell(advance = false) { + try { + const next = clone(state.puzzle), + blocked = !$("block-option").hidden && $("blocked-cell").checked; + const entered = numberInput("cell-value"); + if (next.type === "str8ts") { + next.black = (next.black || []).filter((i) => i !== editing); + if (blocked) next.black.push(editing); + next.black.sort((a, b) => a - b); + next.cells[editing] = blocked ? (entered ?? "#") : entered; + } else next.cells[editing] = blocked ? "#" : entered; + next.clues = next.clues.filter((q) => q.cell !== editing); + if (blocked && next.type === "kakuro") { + const across = numberInput("across-value"), + down = numberInput("down-value"); + if ( + (across !== null && (across < 1 || across > 45)) || + (down !== null && (down < 1 || down > 45)) + ) + throw Error("Kakuro targets must be from 1 to 45."); + if (across !== null || down !== null) + next.clues.push({ cell: editing, across, down }); + } + checkShape(next); + mutate(() => { + state.puzzle = next; + state.uncertain.delete(editing); + }); + $("cell-dialog").close(); + status("Clue saved.", "The previous solution has been cleared."); + if (advance) { + const next = nextReviewCell(state.uncertain, editing); + if (next !== null) openCell(next); + else + status( + "Highlighted readings checked.", + "Confirm the puzzle type and any structural clues, then solve.", + ); + } + } catch (e) { + $("cell-error").textContent = e.message; + } +} +$("review-clues").onclick = () => { + const cell = nextReviewCell(state.uncertain); + if (cell !== null) openCell(cell); +}; +$("save-next").onclick = () => saveCell(true); +$("cell-form").onsubmit = (e) => { + e.preventDefault(); + saveCell(); +}; +$("clear-cell").onclick = () => { + const keepBlack = + state.puzzle.type === "str8ts" && (state.puzzle.black || []).includes(editing); + $("cell-value").value = ""; + $("blocked-cell").checked = keepBlack; + $("across-value").value = $("down-value").value = ""; + saveCell(); +}; +$("close-cell").onclick = () => $("cell-dialog").close(); +function cellAction(i) { + const tool = $("edit-tool").value; + if (tool === "value") return openCell(i); + stopTask(); + focused = i; + if (state.selected.includes(i)) + state.selected = state.selected.filter((x) => x !== i); + else { + if (tool === "inequality" && state.selected.length === 2) + state.selected = []; + state.selected.push(i); + } + drawBoard(); + // Redrawing replaces the selected SVG node. Keep keyboard navigation on + // that cell so arrows and Enter/Space can extend or change the selection. + $("board").querySelector(`[data-cell="${focused}"]`)?.focus(); + status( + `${state.selected.length} cells selected.`, + tool === "cage" + ? "Enter the target and save the cage." + : "Select the smaller cell first, then the larger adjacent cell.", + ); +} +$("board").onclick = (e) => { + const cell = e.target.closest("[data-cell]"); + if (cell) cellAction(Number(cell.dataset.cell)); +}; +$("board").onkeydown = (e) => { + const cell = e.target.closest("[data-cell]"); + if (!cell) return; + const i = Number(cell.dataset.cell); + if (["Enter", " "].includes(e.key)) { + e.preventDefault(); + cellAction(i); + return; + } + if (e.key.startsWith("Arrow")) { + e.preventDefault(); + const next = moveIndex(i, e.key, state.puzzle.rows, state.puzzle.cols); + if (next === i) return; + focused = next; + drawBoard(); + $("board").querySelector(`[data-cell="${focused}"]`)?.focus(); + } +}; +$("edit-tool").onchange = () => { + state.selected = []; + render(); +}; +$("clear-selection").onclick = () => { + state.selected = []; + drawBoard(); +}; +$("save-cage").onclick = () => { + try { + const target = numberInput("cage-target"); + if (!target || !state.selected.length) + throw Error("Select cage cells and enter a positive target."); + const cells = [...state.selected], + op = state.puzzle.type === "killersudoku" ? "+" : $("cage-op").value; + mutate(() => { + state.puzzle.cages = state.puzzle.cages.filter( + (q) => !q.cells.some((i) => cells.includes(i)), + ); + state.puzzle.cages.push({ + cells: cells.sort((a, b) => a - b), + target, + op, + }); + cells.forEach((i) => state.cageUncertain.delete(i)); + state.selected = []; + }); + status( + "Cage saved.", + "Every cell must belong to exactly one cage before solving.", + ); + } catch (e) { + fail(e); + } +}; +$("remove-cage").onclick = () => { + if (!hasCageRemoval(state.puzzle, state.selected)) return; + mutate(() => { + state.puzzle.cages = state.puzzle.cages.filter( + (q) => !q.cells.some((i) => state.selected.includes(i)), + ); + state.selected = []; + }); +}; +$("save-inequality").onclick = () => { + try { + if (state.selected.length !== 2) + throw Error("Select the smaller cell and its larger neighbour."); + const [less, greater] = state.selected, + p = state.puzzle; + if ( + Math.abs(Math.floor(less / p.cols) - Math.floor(greater / p.cols)) + + Math.abs((less % p.cols) - (greater % p.cols)) !== + 1 + ) + throw Error("Inequality cells must share a side."); + mutate(() => { + p.inequalities = p.inequalities.filter( + (q) => + ![less, greater].includes(q.less) || + ![less, greater].includes(q.greater), + ); + p.inequalities.push({ less, greater }); + state.selected = []; + }); + } catch (e) { + fail(e); + } +}; +$("remove-inequality").onclick = () => { + if (!hasInequalityRemoval(state.puzzle, state.selected)) return; + mutate(() => { + state.puzzle.inequalities = state.puzzle.inequalities.filter( + (q) => + !( + state.selected.includes(q.less) && state.selected.includes(q.greater) + ), + ); + state.selected = []; + }); +}; +$("undo").onclick = () => { + const previous = state.history.pop(); + if (!previous) return; + invalidate(); + restoreEdit(state, previous); + persist(); + render(); + status("Last edit undone."); +}; +$("stop").onclick = () => stopTask("Stopped."); +function requestSolve() { + try { + checkSolveReady(state.puzzle); + if (reviewCells().size || state.needsReview) { + $("confirm-text").textContent = + `${TYPES[state.puzzle.type]} · ${state.puzzle.rows} × ${state.puzzle.cols}. ${reviewCells().size} cells were highlighted for review.`; + $("confirm-dialog").showModal(); + } else solveNow(); + } catch (e) { + fail(e); + } +} +function solveNow() { + try { + checkSolveReady(state.puzzle); + } catch (e) { + fail(e); + return; + } + state.uncertain.clear(); + state.cageUncertain.clear(); + state.needsReview = false; + state.notes = []; + state.result = null; + state.solution = 0; + state.view = "board"; + persist(); + render(); + const id = begin(); + if (!worker) + worker = new Worker(new URL("./solver-worker.js", import.meta.url), { + type: "module", + }); + tasks.setDeadline(() => { + if (id === tasks.id) + stopTask("Runtime loading timed out. Go online and retry."); + }, 180000); + worker.onmessage = ({ data: m }) => { + if (m.id !== tasks.id) return; + if (m.type === "status") { + status(m.message, "Stop cancels this task."); + if (m.message.startsWith("Solving")) { + tasks.clearDeadline(); + const seconds = Number($("time-limit").value); + if (seconds > 0) + tasks.setDeadline(() => { + if (id === tasks.id) + stopTask( + "Search limit reached. Increase the limit to continue from a fresh search.", + ); + }, seconds * 1000); + } + return; + } + finish(); + state.result = m.result; + render(); + const r = m.result; + if (r.status === "unique") + status( + "Solved · unique solution", + `Completed and validated in ${r.elapsed.toFixed(2)} seconds. Original clues are preserved.`, + ); + else if (r.status === "multiple") + status( + "More than one solution", + `At least two valid solutions exist. Check for a missed clue or an incorrect puzzle type. Use “Other solution” to compare.`, + "warning", + ); + else if (r.status === "no-solution") + status( + "No solution to these clues.", + "Check the transcription, puzzle type and structural clues. This does not prove the photograph is wrong.", + "warning", + ); + else + status( + r.status === "invalid" + ? "Check the puzzle data." + : "The solver could not finish.", + r.message || "Please retry.", + "error", + ); + $("status").dataset.result = r.status; + }; + worker.onerror = (e) => { + if (id !== tasks.id) return; + worker.terminate(); + worker = null; + finish(); + status( + "The solver stopped unexpectedly.", + e.message || + "The phone may have run out of memory. Retry with other tabs closed.", + "error", + ); + }; + status("Starting the on-device solver…", "The first load downloads Python."); + worker.postMessage({ id, puzzle: clone(state.puzzle) }); +} +$("solve").onclick = requestSolve; +$("confirm-solve").onclick = () => { + $("confirm-dialog").close(); + solveNow(); +}; +$("confirm-back").onclick = () => $("confirm-dialog").close(); +$("next-solution").onclick = () => { + if (state.result?.solutions?.length) { + state.solution = (state.solution + 1) % state.result.solutions.length; + render(); + } +}; +$("clean-view").onclick = () => { + state.view = "board"; + render(); +}; +$("photo-view").onclick = () => { + state.view = "photo"; + render(); +}; +$("example").onclick = () => { + try { + loadPuzzle( + demo( + $("puzzle-type").value === "auto" ? "sudoku" : $("puzzle-type").value, + ), + ); + } catch (e) { + fail(e); + } +}; +$("new-board").onclick = () => { + try { + const type = + $("puzzle-type").value === "auto" ? "sudoku" : $("puzzle-type").value, + n = ["sudoku", "killersudoku"].includes(type) + ? 9 + : type === "kenken" + ? 6 + : 5; + loadPuzzle(makePuzzle(type, n)); + } catch (e) { + fail(e); + } +}; +$("apply-layout").onclick = () => { + try { + const rows = Number($("rows").value), + cols = Number($("cols").value), + type = + $("puzzle-type").value === "auto" + ? state.puzzle.type + : $("puzzle-type").value; + const next = makePuzzle(type, rows, cols); + next.boxRows = Number($("box-rows").value); + next.boxCols = Number($("box-cols").value); + checkShape(next); + if ( + type === state.puzzle.type && + rows === state.puzzle.rows && + cols === state.puzzle.cols + ) { + mutate(() => { + state.puzzle.boxRows = next.boxRows; + state.puzzle.boxCols = next.boxCols; + }); + } else if ( + confirm( + "Changing the board type or dimensions here clears existing clues. To keep clues while changing only the rules, use the button below the puzzle-type selector. Clear this board?", + ) + ) + loadPuzzle(next); + } catch (e) { + fail(e); + } +}; +function download(blob, name) { + const a = document.createElement("a"), + url = URL.createObjectURL(blob); + a.href = url; + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 3000); +} +$("export-json").onclick = () => + download( + new Blob([JSON.stringify(state.puzzle, null, 2)], { + type: "application/json", + }), + `gridpuzzle-${state.puzzle.type}.json`, + ); +$("save-photo").onclick = () => { + if (!canOverlay()) { + status("Read the adjusted crop before exporting an overlay."); + return; + } + drawOverlay(); + $("solution-photo").toBlob((blob) => { + if (blob) download(blob, "gridpuzzle-solution.png"); + }); +}; +$("import-json").onclick = () => $("json-file").click(); +$("json-file").onchange = async (e) => { + let id = tasks.id; + try { + const file = e.target.files[0]; + if (!file) return; + if (file.size > 200000) + throw Error("Puzzle files must be smaller than 200 KB."); + stopTask(); + id = tasks.id; + const parsed = JSON.parse(await file.text()); + if (id === tasks.id) loadPuzzle(parsed); + } catch (error) { + if (id === tasks.id) fail(error); + } finally { + e.target.value = ""; + } +}; +$("apply-json").onclick = () => { + try { + if ($("json-data").value.length > 200000) + throw Error("Puzzle data is too large."); + loadPuzzle(JSON.parse($("json-data").value)); + } catch (e) { + fail(e); + } +}; + +const { stopCamera } = setupPhotoFlow({ + $, + state, + scanner, + stopTask, + invalidate, + begin, + finish, + fail, + render, + status, + remember, + persist, + drawBoard, + clearPhotoMapping, + solveNow, + boxDefault, + setLayout, + getJobId: () => tasks.id, + setDeadline: (callback, ms) => tasks.setDeadline(callback, ms), +}); +window.addEventListener("pagehide", () => { + stopCamera(); + stopTask(); + if (worker) { + worker.terminate(); + worker = null; + } +}); + +setupOffline($); +try { + const saved = restoreSession(storage); + if (saved) { + state.puzzle = normalized(saved.puzzle); + state.uncertain = new Set(saved.uncertain); + state.cageUncertain = new Set(saved.cageUncertain); + state.needsReview = saved.needsReview; + state.notes = saved.notes; + } +} catch { + /* Ignore malformed/old autosaves. */ +} +render(); +$("build-label").textContent = "Browser scanner · __BUILD_ID__"; +document.body.dataset.ready = "true"; diff --git a/web/edit-history.js b/web/edit-history.js new file mode 100644 index 00000000..e2e5a2e2 --- /dev/null +++ b/web/edit-history.js @@ -0,0 +1,26 @@ +import { clone } from "./model.js"; +export function captureEdit(state) { + return { + puzzle: clone(state.puzzle), + layout: state.layout ? clone(state.layout) : null, + uncertain: [...state.uncertain], + cageUncertain: [...(state.cageUncertain || [])], + needsReview: state.needsReview, + notes: [...state.notes], + source: state.puzzleSource, + }; +} +export function restoreEdit(state, snapshot) { + state.puzzle = snapshot.puzzle; + state.layout = snapshot.layout || null; + state.uncertain = new Set(snapshot.uncertain); + state.cageUncertain = new Set(snapshot.cageUncertain || []); + state.needsReview = snapshot.needsReview; + state.notes = snapshot.notes; + state.puzzleSource = snapshot.source; + state.selected = []; +} +export function rememberEdit(state) { + state.history.push(captureEdit(state)); + if (state.history.length > 30) state.history.shift(); +} diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 00000000..c2b26cd3 --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1 @@ + diff --git a/web/geometry-worker.js b/web/geometry-worker.js new file mode 100644 index 00000000..d7756501 --- /dev/null +++ b/web/geometry-worker.js @@ -0,0 +1,25 @@ +import { findGrid, warp, estimateGrid, sharpness } from "./geometry.js"; +import { prepareScan } from "./scan-analysis.js"; +self.onmessage = ({ data }) => { + try { + let result; + if (data.op === "detect") + result = { ...findGrid(data.image), sharpness: sharpness(data.image) }; + else if (data.op === "warp" || data.op === "prepare") { + const image = warp(data.image, data.corners, data.width, data.height); + result = + data.op === "prepare" + ? prepareScan(image, data.type, data.rows, data.cols) + : { image, meta: estimateGrid(image) }; + } else throw Error("Unknown image task"); + const transfer = result.image + ? [ + result.image.data.buffer, + ...(result.mask ? [result.mask.buffer, result.g.buffer] : []), + ] + : []; + self.postMessage({ result }, transfer); + } catch (error) { + self.postMessage({ error: error.message }); + } +}; diff --git a/web/geometry.js b/web/geometry.js new file mode 100644 index 00000000..d185db08 --- /dev/null +++ b/web/geometry.js @@ -0,0 +1,297 @@ +// Dependency-free image geometry. All expensive calls run in a Web Worker. +export function gray(image) { + const out = new Uint8Array(image.width * image.height), + d = image.data; + for (let i = 0; i < out.length; i++) + out[i] = (77 * d[i * 4] + 150 * d[i * 4 + 1] + 29 * d[i * 4 + 2]) >> 8; + return out; +} +export function threshold(image, window = 25, bias = 12) { + return thresholdGray(gray(image), image.width, image.height, window, bias); +} +export function thresholdGray(g, w, h, window = 25, bias = 12) { + const sum = new Float64Array((w + 1) * (h + 1)); + for (let y = 0; y < h; y++) { + let row = 0; + for (let x = 0; x < w; x++) { + row += g[y * w + x]; + sum[(y + 1) * (w + 1) + x + 1] = sum[y * (w + 1) + x + 1] + row; + } + } + const out = new Uint8Array(w * h), + half = window >> 1; + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const a = Math.max(0, x - half), + b = Math.min(w, x + half + 1), + c = Math.max(0, y - half), + d = Math.min(h, y + half + 1); + const mean = + (sum[d * (w + 1) + b] - + sum[c * (w + 1) + b] - + sum[d * (w + 1) + a] + + sum[c * (w + 1) + a]) / + ((b - a) * (d - c)); + out[y * w + x] = g[y * w + x] < Math.min(215, mean - bias) ? 1 : 0; + } + return out; +} +export function polygonArea(p) { + return ( + Math.abs( + p.reduce((s, a, i) => { + const b = p[(i + 1) % p.length]; + return s + a.x * b.y - a.y * b.x; + }, 0), + ) / 2 + ); +} +export function validQuad(p, w, h) { + if ( + !Array.isArray(p) || + p.length !== 4 || + p.some( + (q) => + !Number.isFinite(q.x) || + !Number.isFinite(q.y) || + q.x < 0 || + q.y < 0 || + q.x > w - 1 || + q.y > h - 1, + ) + ) + return false; + const cross = p.map((a, i) => { + const b = p[(i + 1) % 4], + c = p[(i + 2) % 4]; + return (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x); + }); + return cross.every((n) => n > 1) && polygonArea(p) > w * h * 0.005; +} +// Homography maps unit-square coordinates to four clockwise image corners. +export function homography(p) { + const [a, b, c, d] = p, + dx1 = b.x - c.x, + dx2 = d.x - c.x, + dx3 = a.x - b.x + c.x - d.x, + dy1 = b.y - c.y, + dy2 = d.y - c.y, + dy3 = a.y - b.y + c.y - d.y; + const det = dx1 * dy2 - dx2 * dy1; + let g = 0, + h = 0; + if (Math.abs(dx3) + Math.abs(dy3) > 1e-9) { + if (Math.abs(det) < 1e-9) throw Error("The crop corners are degenerate."); + g = (dx3 * dy2 - dx2 * dy3) / det; + h = (dx1 * dy3 - dx3 * dy1) / det; + } + return [ + b.x - a.x + g * b.x, + d.x - a.x + h * d.x, + a.x, + b.y - a.y + g * b.y, + d.y - a.y + h * d.y, + a.y, + g, + h, + ]; +} +export function project(m, u, v) { + const z = m[6] * u + m[7] * v + 1; + if (Math.abs(z) < 1e-10) throw Error("Invalid perspective."); + return { + x: (m[0] * u + m[1] * v + m[2]) / z, + y: (m[3] * u + m[4] * v + m[5]) / z, + }; +} +export function warp(image, corners, width = 900, height = 900) { + if (!validQuad(corners, image.width, image.height)) + throw Error("Keep the four crop corners clockwise without crossing."); + width = Math.max(32, Math.min(1600, Math.round(width))); + height = Math.max(32, Math.min(1600, Math.round(height))); + const m = homography(corners), + out = new Uint8ClampedArray(width * height * 4), + iw = image.width, + ih = image.height; + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) { + const pt = project(m, x / (width - 1), y / (height - 1)); + const sx = Math.max(0, Math.min(iw - 1, pt.x)), + sy = Math.max(0, Math.min(ih - 1, pt.y)); + const x0 = Math.floor(sx), + y0 = Math.floor(sy), + x1 = Math.min(iw - 1, x0 + 1), + y1 = Math.min(ih - 1, y0 + 1), + fx = sx - x0, + fy = sy - y0; + const k = (y * width + x) * 4; + for (let ch = 0; ch < 3; ch++) + out[k + ch] = + (1 - fy) * + ((1 - fx) * image.data[(y0 * iw + x0) * 4 + ch] + + fx * image.data[(y0 * iw + x1) * 4 + ch]) + + fy * + ((1 - fx) * image.data[(y1 * iw + x0) * 4 + ch] + + fx * image.data[(y1 * iw + x1) * 4 + ch]); + out[k + 3] = 255; + } + return { width, height, data: out }; +} +function groups(values, cutoff) { + const out = []; + let start = -1; + for (let i = 0; i <= values.length; i++) { + if (i < values.length && values[i] > cutoff) { + if (start < 0) start = i; + } else if (start >= 0) { + out.push({ at: (start + i - 1) / 2, width: i - start }); + start = -1; + } + } + return out; +} +export function gridLines(image, mask) { + const w = image.width, + h = image.height, + b = mask ?? threshold(image), + x = new Float64Array(w), + y = new Float64Array(h); + for (let r = 0; r < h; r++) + for (let c = 0; c < w; c++) { + x[c] += b[r * w + c] / h; + y[r] += b[r * w + c] / w; + } + return { x: groups(x, 0.47), y: groups(y, 0.47) }; +} +function regular(lines, length) { + if (lines.length < 4 || lines.length > 26) return 0; + const gap = (lines.at(-1).at - lines[0].at) / (lines.length - 1); + if (lines[0].at > length * 0.1 || lines.at(-1).at < length * 0.9) return 0; + return lines + .slice(1) + .every((l, i) => Math.abs(l.at - lines[i].at - gap) < gap * 0.23) + ? lines.length - 1 + : 0; +} +export function estimateGrid(image, mask) { + const lines = gridLines(image, mask), + cols = regular(lines.x, image.width), + rows = regular(lines.y, image.height); + let boxes = false; + if (rows === cols && [4, 6, 9, 16, 25].includes(rows)) { + const mid = lines.x.slice(1, -1), + thin = Math.min(...mid.map((l) => l.width)); + boxes = mid.some((l) => l.width > thin * 1.45 && l.width >= 3); + } + return { rows, cols, boxes, lines }; +} +export function findGrid(image) { + const w = image.width, + h = image.height, + b = threshold(image), + seen = new Uint8Array(b.length), + queue = new Int32Array(b.length); + let best = null, + score = 0; + for (let i = 0; i < b.length; i++) { + if (!b[i] || seen[i]) continue; + let head = 0, + tail = 1; + queue[0] = i; + seen[i] = 1; + let minx = w, + maxx = 0, + miny = h, + maxy = 0, + minsum = Infinity, + maxsum = -Infinity, + mindiff = Infinity, + maxdiff = -Infinity; + let tl, tr, br, bl; + while (head < tail) { + const k = queue[head++], + x = k % w, + y = Math.floor(k / w); + minx = Math.min(minx, x); + maxx = Math.max(maxx, x); + miny = Math.min(miny, y); + maxy = Math.max(maxy, y); + if (x + y < minsum) { + minsum = x + y; + tl = { x, y }; + } + if (x + y > maxsum) { + maxsum = x + y; + br = { x, y }; + } + if (x - y < mindiff) { + mindiff = x - y; + bl = { x, y }; + } + if (x - y > maxdiff) { + maxdiff = x - y; + tr = { x, y }; + } + for (const j of [ + x > 0 ? k - 1 : -1, + x < w - 1 ? k + 1 : -1, + y > 0 ? k - w : -1, + y < h - 1 ? k + w : -1, + ]) + if (j >= 0 && b[j] && !seen[j]) { + seen[j] = 1; + queue[tail++] = j; + } + } + const area = (maxx - minx) * (maxy - miny), + corners = [tl, tr, br, bl]; + if ( + area > w * h * 0.07 && + tail > 150 && + maxx - minx > w * 0.15 && + maxy - miny > h * 0.15 && + validQuad(corners, w, h) && + polygonArea(corners) > area * 0.5 && + area > score + ) { + score = area; + best = corners; + } + } + if (!best) + return { + corners: [ + { x: w * 0.08, y: h * 0.08 }, + { x: w * 0.92, y: h * 0.08 }, + { x: w * 0.92, y: h * 0.92 }, + { x: w * 0.08, y: h * 0.92 }, + ], + confidence: 0, + rows: 0, + cols: 0, + boxes: false, + }; + const small = warp(image, best, 540, 540), + estimated = estimateGrid(small); + return { + corners: best, + confidence: estimated.rows && estimated.cols ? 0.94 : 0.45, + ...estimated, + lines: undefined, + }; +} +export function sharpness(image) { + const g = gray(image), + w = image.width, + h = image.height; + let sum = 0, + count = 0; + for (let y = 1; y < h - 1; y += 2) + for (let x = 1; x < w - 1; x += 2) { + const i = y * w + x, + v = 4 * g[i] - g[i - 1] - g[i + 1] - g[i - w] - g[i + w]; + sum += v * v; + count++; + } + return sum / Math.max(count, 1); +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..52731cfd --- /dev/null +++ b/web/index.html @@ -0,0 +1,87 @@ + + + + + + + + + + + + + GridPuzzle · Scan & solve + + + + + + +
+ GridPuzzleSCAN & SOLVEOn-device solving +
+ +
+
+

LESS COPYING. MORE DISCOVERY.

+

From paper
to solved.

+

Point your camera at a puzzle. Check the clues.
Let the complete GridPuzzle engine do the rest.

+
+
+
+
01

Bring a puzzle

+
+ + + +

Choose a type for the next scan, or apply it to the current board below. Automatic cannot infer invisible variant rules.

+
+
+ Grid size & settings +
+
+ + + + +

The full deduction hierarchy is retained. Search never runs on the interface thread.

+
+
+ Save, import & install +
+ + +

First use needs an internet connection.

+

On iPhone: Safari → Share → Add to Home Screen → Open as Web App. Photos stay on this device and are not uploaded. Your last puzzle is saved locally; photographs are not saved.

+ +
+
+
+
02

Your puzzle

+
Ready when you are.Scan a puzzle, load an example, or tap a cell to enter clues.
+ + +
+ + +
+ +
Original clueSolutionCheck reading
+ + +
+

A unique solution verifies these clues—not the accuracy of the photograph’s transcription.

+
Advanced puzzle data

A data-only format for all eleven families. Cells are zero-based row-major indexes; null is blank, # is blocked, and Slitherlink 0 is a clue.

+
+
+ +
+

Edit clue

+

Solve this transcription?

A solver cannot prove that a photograph was read correctly. Check the highlighted clues and confirm the puzzle type and any extra rules.

+ + + + diff --git a/web/manifest.webmanifest b/web/manifest.webmanifest new file mode 100644 index 00000000..bc39ef10 --- /dev/null +++ b/web/manifest.webmanifest @@ -0,0 +1 @@ +{"id":"./","name":"GridPuzzle · Scan & solve","short_name":"GridPuzzle","description":"Scan and solve grid puzzles privately on your phone.","start_url":"./","scope":"./","display":"standalone","background_color":"#f5f5ee","theme_color":"#123b3b","icons":[{"src":"./icons/icon-192.png","sizes":"192x192","type":"image/png","purpose":"any"},{"src":"./icons/icon-512.png","sizes":"512x512","type":"image/png","purpose":"any"},{"src":"./icons/maskable-512.png","sizes":"512x512","type":"image/png","purpose":"maskable"}]} diff --git a/web/model.js b/web/model.js new file mode 100644 index 00000000..c81861d3 --- /dev/null +++ b/web/model.js @@ -0,0 +1,332 @@ +export const TYPES = Object.freeze({ + sudoku: "Sudoku", + killersudoku: "Killer Sudoku", + futoshiki: "Futoshiki", + kenken: "KenKen", + latinsquare: "Latin square", + diagonallatinsquare: "Diagonal Latin square", + pandiagonallatinsquare: "Pandiagonal Latin square", + hidato: "Hidato", + numbrix: "Numbrix", + kakuro: "Kakuro", + slitherlink: "Slitherlink", + str8ts: "Str8ts", +}); +export const clone = (value) => JSON.parse(JSON.stringify(value)); +export const isCage = (type) => ["killersudoku", "kenken"].includes(type); +function dimension(n) { + if (!Number.isInteger(n) || n < 1 || n > 25) + throw Error("Board dimensions must be whole numbers from 1 to 25."); + return n; +} +export function boxShape(n) { + dimension(n); + let r = Math.floor(Math.sqrt(n)); + while (n % r) r--; + return [r, n / r]; +} +export function checkDimensions(rows, cols = rows) { + dimension(rows); + dimension(cols); +} +export function makePuzzle(type = "sudoku", rows = 9, cols = rows) { + dimension(rows); + dimension(cols); + if (!Object.hasOwn(TYPES, type)) + throw Error("Choose a supported puzzle type."); + const [boxRows, boxCols] = boxShape(rows); + return { + version: 1, + type, + rows, + cols, + boxRows, + boxCols, + cells: Array(rows * cols).fill(null), + cages: [], + inequalities: [], + clues: [], + black: [], + }; +} +function adjacent(a,b,cols){ + return Math.abs(Math.floor(a/cols)-Math.floor(b/cols))+Math.abs((a%cols)-(b%cols))===1; +} +export function moveIndex(index, key, rows, cols) { + let r = Math.floor(index / cols), + c = index % cols; + if (key === "ArrowLeft") c = Math.max(0, c - 1); + else if (key === "ArrowRight") c = Math.min(cols - 1, c + 1); + else if (key === "ArrowUp") r = Math.max(0, r - 1); + else if (key === "ArrowDown") r = Math.min(rows - 1, r + 1); + else return index; + return r * cols + c; +} +export function hasCageRemoval(p, cells) { + const selected = new Set(cells); + return ( + selected.size > 0 && + Boolean(p?.cages?.some((cage) => cage.cells?.some((i) => selected.has(i)))) + ); +} +export function hasInequalityRemoval(p, cells) { + const selected = new Set(cells); + return ( + selected.size === 2 && + Boolean( + p?.inequalities?.some( + (q) => selected.has(q.less) && selected.has(q.greater), + ), + ) + ); +} +export function checkShape(p) { + if ( + !p || + typeof p !== "object" || + Array.isArray(p) || + !Object.hasOwn(TYPES, p.type) + ) + throw Error("Choose a supported puzzle type."); + for (const k of ["rows", "cols"]) + if (!Number.isInteger(p[k]) || p[k] < 1 || p[k] > 25) + throw Error("Board dimensions must be whole numbers from 1 to 25."); + if (!Array.isArray(p.cells) || p.cells.length !== p.rows * p.cols) + throw Error("The number of cells does not match the board dimensions."); + if ( + !["hidato", "numbrix", "kakuro", "slitherlink"].includes(p.type) && + p.rows !== p.cols + ) + throw Error("This type needs a square grid."); + const allowed = new Set([ + "version", "type", "rows", "cols", "boxRows", "boxCols", + "cells", "cages", "inequalities", "clues", "black", + ]); + for (const key of Object.keys(p)) + if (!allowed.has(key)) throw Error(`Unsupported puzzle field: ${key}`); + if (p.version !== undefined && p.version !== 1) + throw Error("Unsupported puzzle format version."); + const black = new Set(p.black || []); + if (!Array.isArray(p.black || []) || black.size !== (p.black || []).length || [...black].some((i) => !Number.isInteger(i) || i < 0 || i >= p.cells.length)) + throw Error("Invalid black-cell metadata."); + if (p.type !== "str8ts" && black.size) + throw Error("Black-cell metadata is only supported for Str8ts."); + if (p.type === "str8ts" && (p.rows !== p.cols || p.rows > 9)) + throw Error("Str8ts requires a square board no larger than 9 × 9."); + const maximum = + p.type === "slitherlink" + ? 4 + : ["hidato", "numbrix"].includes(p.type) + ? p.cells.filter((v) => v !== "#").length + : p.type === "kakuro" + ? 9 + : p.rows; + p.cells.forEach((v, i) => { + if (v === null) return; + if (v === "#" && (["hidato", "kakuro"].includes(p.type) || (p.type === "str8ts" && black.has(i)))) return; + if ( + !Number.isInteger(v) || + v < (p.type === "slitherlink" ? 0 : 1) || + v > maximum + ) + throw Error(`Cell ${i + 1} is outside the allowed range.`); + }); + if (p.type === "str8ts") { + for (const i of black) if (p.cells[i] === null) throw Error("A Str8ts black cell must contain # or a numbered clue."); + p.cells.forEach((v, i) => { if (v === "#" && !black.has(i)) throw Error("Every # Str8ts cell must be listed as black."); }); + } + for (const key of ["boxRows", "boxCols"]) + if (p[key] !== undefined) dimension(p[key]); + if (["sudoku", "killersudoku"].includes(p.type)) { + const br = p.boxRows ?? 3, bc = p.boxCols ?? 3; + if (br * bc !== p.rows || p.rows % br || p.cols % bc) + throw Error("Box dimensions must tile the board and contain one of each value."); + } + for (const key of ["cages", "inequalities", "clues"]) { + const limit = (key === "inequalities" ? 2 : 1) * p.cells.length; + if (p[key] !== undefined && (!Array.isArray(p[key]) || p[key].length > limit)) + throw Error(`Invalid ${key}.`); + } + if ((p.cages || []).length && !isCage(p.type)) + throw Error("Cages require Killer Sudoku or KenKen."); + if ((p.inequalities || []).length && p.type !== "futoshiki") + throw Error("Inequalities require Futoshiki."); + if ((p.clues || []).length && p.type !== "kakuro") + throw Error("Across/down clues require Kakuro."); + const object = (value, allowedFields, name) => { + if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((k) => !allowedFields.includes(k))) + throw Error(`Invalid ${name} fields.`); + }; + const index = (i) => Number.isInteger(i) && i >= 0 && i < p.cells.length; + const covered = new Set(); + for (const cage of p.cages || []) { + object(cage, ["cells", "target", "op"], "cage"); + if (!Array.isArray(cage.cells) || !cage.cells.length || cage.cells.length > p.cells.length || cage.cells.some((i) => !index(i)) || new Set(cage.cells).size !== cage.cells.length) + throw Error("Invalid cage cells."); + if (cage.cells.some(i=>covered.has(i))) throw Error("Cages may not overlap."); + cage.cells.forEach(i=>covered.add(i)); + const area=new Set(cage.cells), reached=new Set([cage.cells[0]]), pending=[cage.cells[0]]; + while(pending.length){const at=pending.pop();for(const other of area)if(!reached.has(other)&&adjacent(at,other,p.cols)){reached.add(other);pending.push(other);}} + if(reached.size!==area.size) throw Error("Cage cells must be orthogonally connected."); + if (cage.target != null && (!Number.isSafeInteger(cage.target) || cage.target < 1 || cage.target > 1e12)) + throw Error("Invalid cage target."); + const op=cage.op??"+"; + if (!["+", "-", "*", "/", "="].includes(op)) throw Error("Invalid cage operator."); + if(p.type==="killersudoku"&&op!=="+") throw Error("Killer Sudoku cages must be sums."); + if(["-","/"].includes(op)&&cage.cells.length!==2) throw Error("Difference and division cages require exactly two cells."); + if(op==="="&&cage.cells.length!==1) throw Error("A = cage must contain exactly one cell."); + } + for (const q of p.inequalities || []) { + object(q, ["less", "greater"], "inequality"); + if (!index(q.less) || !index(q.greater) || !adjacent(q.less,q.greater,p.cols)) + throw Error("Inequality cells must share a side."); + } + const clueCells = new Set(); + for (const q of p.clues || []) { + object(q, ["cell", "across", "down"], "Kakuro clue"); + if (!index(q.cell) || p.cells[q.cell] !== "#" || clueCells.has(q.cell)) + throw Error("Each Kakuro clue needs a distinct blocked cell."); + if(q.across==null&&q.down==null) throw Error("A Kakuro clue needs an across or down target."); + clueCells.add(q.cell); + for (const direction of ["across", "down"]) + if (q[direction] != null && (!Number.isInteger(q[direction]) || q[direction] < 1 || q[direction] > 45)) + throw Error("Kakuro targets must be from 1 to 45."); + } + return p; +} +// The editor allows useful incomplete states; Solve needs the structure the +// Python adapter will demand, reported here with a local message before the +// interpreter loads. +export function checkSolveReady(p) { + checkShape(p); + if (isCage(p.type)) { + const covered = new Set(); + for (const cage of p.cages || []) { + if (cage.target == null) + throw Error("Every cage needs a target before solving."); + for (const i of cage.cells) covered.add(i); + } + if (covered.size !== p.cells.length) + throw Error( + `Cages must cover every cell before solving; ${p.cells.length - covered.size} cells still need a cage.`, + ); + } + if (p.type === "kakuro") { + const white = new Set( + p.cells.flatMap((value, i) => (value === "#" ? [] : [i])), + ); + if (!white.size) throw Error("Kakuro needs at least one white cell."); + const coverage = new Map( + [...white].map((i) => [i, { across: 0, down: 0 }]), + ); + for (const clue of p.clues || []) { + const r = Math.floor(clue.cell / p.cols), + c = clue.cell % p.cols; + for (const [direction, dr, dc] of [ + ["across", 0, 1], + ["down", 1, 0], + ]) { + if (clue[direction] == null) continue; + const run = []; + for ( + let rr = r + dr, cc = c + dc; + rr >= 0 && + rr < p.rows && + cc >= 0 && + cc < p.cols && + white.has(rr * p.cols + cc); + rr += dr, cc += dc + ) + run.push(rr * p.cols + cc); + if (run.length < 2 || run.length > 9) + throw Error( + `Each Kakuro ${direction} clue must start a run of 2 to 9 white cells.`, + ); + for (const i of run) coverage.get(i)[direction]++; + } + } + const incomplete = [...coverage.values()].filter( + (count) => count.across !== 1 || count.down !== 1, + ).length; + if (incomplete) + throw Error( + `Every Kakuro white cell needs exactly one across and one down run; ${incomplete} cells are incomplete.`, + ); + } + return p; +} +export function conflicts(p) { + checkShape(p); + const bad = new Set(); + const unique = (indices) => { + const seen = new Map(); + for (const i of indices) { + const v = p.cells[i]; + if (!Number.isInteger(v)) continue; + if (seen.has(v)) { bad.add(i); bad.add(seen.get(v)); } + else seen.set(v, i); + } + }; + const all = Array.from({ length: p.cells.length }, (_, i) => i); + if (["hidato", "numbrix"].includes(p.type)) unique(all); + else if (!["kakuro", "slitherlink"].includes(p.type)) { + for (let r = 0; r < p.rows; r++) unique(all.filter((i) => Math.floor(i / p.cols) === r)); + for (let c = 0; c < p.cols; c++) unique(all.filter((i) => i % p.cols === c)); + if (["sudoku", "killersudoku"].includes(p.type)) { + const br = p.boxRows === undefined ? 3 : p.boxRows, bc = p.boxCols === undefined ? 3 : p.boxCols; + for (let r = 0; r < p.rows; r += br) for (let c = 0; c < p.cols; c += bc) + unique(all.filter((i) => Math.floor(i / p.cols) >= r && Math.floor(i / p.cols) < r + br && i % p.cols >= c && i % p.cols < c + bc)); + } + if (["diagonallatinsquare", "pandiagonallatinsquare"].includes(p.type)) { + const offsets = p.type === "pandiagonallatinsquare" ? Array.from({ length: p.rows }, (_, i) => i) : [0]; + for (const k of offsets) { + unique(all.filter((i) => i % p.cols === (Math.floor(i / p.cols) + k) % p.cols)); + unique(all.filter((i) => i % p.cols === (p.cols - 1 - Math.floor(i / p.cols) + k) % p.cols)); + } + } + } + for (const q of p.inequalities || []) + if (Number.isInteger(p.cells[q.less]) && Number.isInteger(p.cells[q.greater]) && p.cells[q.less] >= p.cells[q.greater]) { bad.add(q.less); bad.add(q.greater); } + return bad; +} +export function demo(type = "sudoku") { + if (type === "sudoku") { + const p = makePuzzle(); + p.cells = [..."530070000600195000098000060800060003400803001700020006060000280000419005000080079"].map((v) => +v || null); + return p; + } + if (type === "str8ts") { const p = makePuzzle(type, 3); p.black = [4]; p.cells = [1,2,3,2,3,1,3,1,null]; return p; } + if (type === "slitherlink") { const p = makePuzzle(type, 2); p.cells = [2, 2, 2, 2]; return p; } + if (type === "kakuro") { + const p = makePuzzle(type, 3); p.cells = ["#", "#", "#", "#", 1, null, "#", null, null]; + p.clues = [{ cell: 1, down: 4 }, { cell: 2, down: 6 }, { cell: 3, across: 3 }, { cell: 6, across: 7 }]; return p; + } + if (["hidato", "numbrix"].includes(type)) { const p = makePuzzle(type, 3); p.cells = [1, null, 3, null, 5, null, 7, null, 9]; return p; } + const n = type === "pandiagonallatinsquare" ? 5 : 4, p = makePuzzle(type, n); + const solution = type === "pandiagonallatinsquare" + ? Array.from({ length: 25 }, (_, i) => ((2 * Math.floor(i / 5) + (i % 5)) % 5) + 1) + : type === "diagonallatinsquare" + ? [1,2,3,4,3,4,1,2,4,3,2,1,2,1,4,3] + : [1,2,3,4,3,4,1,2,2,1,4,3,4,3,2,1]; + p.cells = solution.map((v, i) => (i % n === 0 ? null : v)); + if (isCage(type)) p.cages = Array.from({ length: n }, (_, r) => ({ cells: Array.from({ length: n }, (_, c) => r*n+c), target:(n*(n+1))/2, op:"+" })); + if (type === "futoshiki") p.inequalities = [{ less: 0, greater: 1 }]; + return p; +} +export function classify({ rows, cols, values = [], signs = 0, labels = 0, operators = 0, black = 0, blackNumbers = 0, triangles = 0, boxes = false, dots = false }) { + if (black && blackNumbers >= 2 && rows === cols && rows <= 9) return { type:"str8ts", review:true, reason:"Multiple centered digits on black cells suggest Str8ts. Check every black cell and printed digit." }; + if (black && triangles) return { type:"kakuro", review:true, reason:"Cross-sum layout detected. Check black cells and both clue directions." }; + if (signs) return { type:"futoshiki", review:true, reason:"Inequalities detected. Check the direction of every sign." }; + if (labels > 1) return { type:operators?"kenken":"killersudoku", review:true, reason:"Cages detected. Check every boundary, target and operator." }; + if (rows === cols && boxes && !black) + return { type:"sudoku", review:true, reason:"Sudoku box pattern detected. Confirm the type once because faint or cropped clues and extra variant rules may not be visible to automatic recognition." }; + if (black || values.some((n) => Number.isInteger(n) && n > Math.max(rows, cols))) + return { type:black?"hidato":"numbrix", review:true, reason:"Number-path layout: confirm Hidato (diagonals allowed) or Numbrix (orthogonal only)." }; + if (dots && values.some(Number.isInteger) && values.filter(Number.isInteger).every((n) => n <= 4)) + return { type:"slitherlink", review:true, reason:"Loop layout suggested. Check the dimensions and clues, including zeroes." }; + return { type:rows===cols?"sudoku":"numbrix", review:true, reason:"The rules are ambiguous from the grid alone. Choose the correct type before solving." }; +} +export function nextReviewCell(indices, after = -1) { + const ordered = [...indices].sort((a, b) => a - b); + return ordered.find((i) => i > after) ?? ordered[0] ?? null; +} diff --git a/web/ocr-host-worker.js b/web/ocr-host-worker.js new file mode 100644 index 00000000..84c61cac --- /dev/null +++ b/web/ocr-host-worker.js @@ -0,0 +1,106 @@ +/* Classic, per-scan host: owns raw OCR workers from construction onward. */ +const children = new Set(), + NativeWorker = self.Worker; +self.Worker = class extends NativeWorker { + constructor(...args) { + super(...args); + children.add(this); + } + terminate() { + children.delete(this); + super.terminate(); + } +}; +function stopChildren() { + for (const child of children) child.terminate(); +} +const local = (path) => new URL(path, self.location.href).href; +self.onmessage = async ({ data }) => { + if (data.cancel) { + stopChildren(); + self.postMessage({ cancelled: true }); + self.close(); + return; + } + let phase = "atlas"; + try { + importScripts(local("./vendor/tesseract/tesseract.min.js")); + const worker = await self.Tesseract.createWorker("eng", 1, { + workerPath: local("./vendor/tesseract/worker.min.js"), + corePath: local("./vendor/tesseract-core/"), + langPath: local("./vendor/tessdata/").replace(/\/$/, ""), + workerBlobURL: false, + errorHandler: (error) => { + stopChildren(); + self.postMessage({ error: String(error) }); + }, + logger: (m) => { + if (m.status === "recognizing text" && phase === "atlas") + self.postMessage({ + type: "progress", + message: "Reading printed clues…", + progress: m.progress, + }); + }, + }); + await worker.setParameters({ + tessedit_pageseg_mode: "11", + tessedit_char_whitelist: "0123456789<>^vV+-xX*/=×÷", + user_defined_dpi: "300", + }); + const { data: result } = await worker.recognize( + new Uint8Array(data.png), + {}, + { text: true, blocks: true }, + ); + // Second pass: every digit crop on its own as a single character. The + // readings are independent of the atlas layout and vote in the scanner. + const singles = [], + samples = Array.isArray(data.singles) ? data.singles : []; + if (samples.length) { + phase = "singles"; + await worker.setParameters({ + tessedit_pageseg_mode: "10", + tessedit_char_whitelist: "0123456789", + }); + for (let i = 0; i < samples.length; i++) { + const { data: read } = await worker.recognize( + samples[i].png, + {}, + { text: true, blocks: true }, + ); + const symbols = (read.blocks || []).flatMap((b) => + (b.paragraphs || []).flatMap((p) => + (p.lines || []).flatMap((l) => + (l.words || []).flatMap((w) => w.symbols || []), + ), + ), + ); + singles.push({ + index: samples[i].index, + kind: samples[i].kind, + text: (symbols.length + ? symbols.map((s) => s.text).join("") + : read.text || "" + ).replace(/\s/g, ""), + confidence: symbols.length + ? Math.min(...symbols.map((s) => s.confidence)) + : read.confidence || 0, + }); + if (i % 8 === 7) + self.postMessage({ + type: "progress", + message: "Checking printed clues…", + progress: (i + 1) / samples.length, + }); + } + } + result.singles = singles; + await worker.terminate(); + self.postMessage({ result }); + } catch (error) { + self.postMessage({ error: error.message || String(error) }); + } finally { + stopChildren(); + } +}; diff --git a/web/ocr-map.js b/web/ocr-map.js new file mode 100644 index 00000000..08456612 --- /dev/null +++ b/web/ocr-map.js @@ -0,0 +1,139 @@ +// Bound the atlas raster footprint for mobile canvas memory. A compact +// multi-column layout works with sparse-text recognition; symbol boxes keep +// neighboring slots separate even when the recognizer merges a whole row. +export function atlasLayout(count) { + if (!Number.isInteger(count) || count < 1 || count > 3000) + throw Error("Invalid recognition region count."); + const columns = Math.min(12, count), + rows = Math.ceil(count / columns); + const tile = Math.min( + 112, + Math.floor(Math.sqrt(8_000_000 / (columns * rows))), + ); + if (tile < 64) + throw Error( + "Too many potential clues. Choose the puzzle type explicitly, or crop a smaller grid.", + ); + return { columns, rows, tile }; +} + +export function mapAtlas(data, count, columns, tile) { + const readings = Array.from({ length: count }, () => ({ + text: "", + confidence: 0, + parts: [], + review: false, + })); + const words = (data.blocks || []).flatMap((b) => + (b.paragraphs || []).flatMap((p) => + (p.lines || []).flatMap((l) => l.words || []), + ), + ); + function affected(box) { + const indices = []; + if ( + !box || + !["x0", "y0", "x1", "y1"].every((k) => Number.isFinite(box[k])) || + box.x1 <= box.x0 || + box.y1 <= box.y0 + ) + return indices; + for ( + let row = Math.max(0, Math.floor(box.y0 / tile)); + row <= Math.floor((box.y1 - 1) / tile); + row++ + ) + for ( + let col = Math.max(0, Math.floor(box.x0 / tile)); + col <= Math.min(columns - 1, Math.floor((box.x1 - 1) / tile)); + col++ + ) { + const i = row * columns + col; + if (i < count) indices.push(i); + } + return indices; + } + for (const word of words) { + const symbols = word.symbols?.length ? word.symbols : [word]; + const wordIsOneClue = affected(word.bbox).length === 1; + for (const symbol of symbols) { + const b = symbol.bbox, + cells = affected(b), + text = (symbol.text || "").replace(/\s/g, ""); + if (!text) continue; + if (cells.length !== 1) { + for (const i of cells) readings[i].review = true; + continue; + } + const entry = readings[cells[0]]; + let confidence = Number.isFinite(symbol.confidence) + ? symbol.confidence + : 0; + // Preserve doubt when a one-clue word score is lower than its symbol score. + if (wordIsOneClue && Number.isFinite(word.confidence)) + confidence = Math.min(confidence, word.confidence); + entry.parts.push({ text, confidence, x: b.x0, y: b.y0 }); + } + } + for (const r of readings) { + r.parts.sort((a, b) => a.x - b.x || a.y - b.y); + r.text = r.parts.map((p) => p.text).join(""); + r.confidence = + r.parts.length && !r.review + ? Math.min(...r.parts.map((p) => p.confidence)) + : 0; + delete r.parts; + } + return readings; +} + +// Independent readings of one digit vote. Unanimity of at least two readers +// is treated as confident even when their individual scores are low, since +// they fail in different ways; any disagreement keeps the review flag. +export function voteDigit(readings) { + const present = readings.filter( + (r) => r && typeof r.text === "string" && /^\d+$/.test(r.text), + ); + if (!present.length) return { text: "", confidence: 0, unanimous: false }; + const support = new Map(); + for (const r of present) { + const s = support.get(r.text) || { count: 0, confidence: 0 }; + s.count++; + s.confidence = Math.max( + s.confidence, + Number.isFinite(r.confidence) ? r.confidence : 0, + ); + support.set(r.text, s); + } + const [text, best] = [...support.entries()].sort( + (a, b) => b[1].count - a[1].count || b[1].confidence - a[1].confidence, + )[0]; + return { + text, + confidence: best.confidence, + unanimous: best.count === present.length && present.length >= 2, + }; +} + +// Solid rules and faint L-shaped grid corners are not cage labels. The edge +// score is measured against the glyph's bounding box, never the cage mask. +export function isGridStroke({ + kind, + width, + height, + ink, + edgeInk = 0, + regionWidth, + cellHeight, +}) { + if (kind !== "label" || ink <= 0) return false; + const bar = + width >= regionWidth * 0.85 && + height <= cellHeight * 0.15 && + ink >= width * height * 0.7; + const corner = + width >= regionWidth * 0.5 && + height <= cellHeight * 0.25 && + edgeInk >= ink * 0.9; + return bar || corner; +} diff --git a/web/offline.js b/web/offline.js new file mode 100644 index 00000000..1ae6f377 --- /dev/null +++ b/web/offline.js @@ -0,0 +1,109 @@ +export function setupOffline($) { + function offlineMessage(worker, type) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + const timeout = setTimeout(() => { + channel.port1.close(); + reject( + Error("Offline preparation did not finish. Go online and retry."), + ); + }, 300000); + channel.port1.onmessage = ({ data: m }) => { + if (m.progress !== undefined) + $("offline-state").textContent = + `Downloading offline assets: ${m.progress} / ${m.total}`; + if (m.done || m.error) { + clearTimeout(timeout); + channel.port1.close(); + m.error ? reject(Error(m.error)) : resolve(m); + } + }; + worker.postMessage({ type }, [channel.port2]); + }); + } + if ("serviceWorker" in navigator) { + navigator.serviceWorker + .register("./sw.js") + .then(async (registration) => { + // The button inside the collapsed offline section is easy to miss, + // so a banner at the top of the page mirrors it: a phone that never + // updates keeps every old recognition bug. + const updateButton = $("update-app"), + banner = $("update-banner"), + bannerButton = $("update-banner-button"); + let controller = navigator.serviceWorker.controller, + needsReload = false, + reloadRequested = false; + const offerUpdate = () => { + const available = Boolean(registration.waiting) || needsReload; + updateButton.hidden = !available; + banner.hidden = !available; + updateButton.textContent = registration.waiting + ? "Update app & reload" + : "Reload updated app"; + bannerButton.textContent = updateButton.textContent; + }; + navigator.serviceWorker.addEventListener("controllerchange", () => { + const next = navigator.serviceWorker.controller; + if (controller && next !== controller) needsReload = true; + controller = next; + if (reloadRequested) location.reload(); + else offerUpdate(); + }); + updateButton.onclick = bannerButton.onclick = () => { + // Another tab may already have activated the waiting worker. Keep + // this tab's work until its user chooses to reload the updated app. + const waiting = registration.waiting; + if (!waiting) { + location.reload(); + return; + } + reloadRequested = true; + updateButton.disabled = bannerButton.disabled = true; + waiting.postMessage({ type: "ACTIVATE" }); + }; + offerUpdate(); + registration.addEventListener("updatefound", () => + registration.installing?.addEventListener("statechange", offerUpdate), + ); + const ready = await navigator.serviceWorker.ready; + $("prepare-offline").disabled = false; + $("prepare-offline").onclick = async () => { + const button = $("prepare-offline"); + button.disabled = true; + try { + await offlineMessage(ready.active, "PREPARE_OFFLINE"); + let persistent = false; + try { + persistent = Boolean(await navigator.storage?.persist?.()); + } catch { + /* Persistence is a request, never a requirement. */ + } + $("offline-state").textContent = persistent + ? "Offline assets are ready on this device, and the browser granted persistent storage." + : "Offline assets are ready on this device. Browser storage can still be cleared or evicted."; + } catch (e) { + $("offline-state").textContent = e.message; + } finally { + button.disabled = false; + } + }; + offlineMessage(ready.active, "OFFLINE_STATUS") + .then((m) => { + if (m.ready) + $("offline-state").textContent = + "Offline assets are ready on this device."; + }) + .catch(() => {}); + }) + .catch((e) => { + $("prepare-offline").disabled = true; + $("offline-state").textContent = + `Offline caching unavailable: ${e.message}`; + }); + } else { + $("prepare-offline").disabled = true; + $("offline-state").textContent = + "This browser does not support offline caching."; + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000..9324992b --- /dev/null +++ b/web/package.json @@ -0,0 +1 @@ +{"name":"gridpuzzle-browser","private":true,"type":"module","scripts":{"test":"node --test tests/*.test.js"}} diff --git a/web/photo-flow.js b/web/photo-flow.js new file mode 100644 index 00000000..703e4fed --- /dev/null +++ b/web/photo-flow.js @@ -0,0 +1,535 @@ +import { TYPES, checkShape, makePuzzle } from "./model.js"; +import { validQuad } from "./geometry.js"; + +export function setupPhotoFlow({ + $, + state, + scanner, + stopTask, + invalidate, + begin, + finish, + fail, + render, + status, + remember, + persist, + drawBoard, + clearPhotoMapping, + solveNow, + boxDefault, + setLayout, + getJobId, + setDeadline, +}) { + let stream = null, + cameraEpoch = 0, + drag = -1; + function stopCamera() { + cameraEpoch++; + if (stream) for (const track of stream.getTracks()) track.stop(); + stream = null; + $("video").srcObject = null; + $("camera-panel").hidden = true; + } + function frame(video, max = 1600) { + if (!video.videoWidth) throw Error("The camera is not ready yet."); + const scale = Math.min( + 1, + max / Math.max(video.videoWidth, video.videoHeight), + ), + c = document.createElement("canvas"); + c.width = Math.round(video.videoWidth * scale); + c.height = Math.round(video.videoHeight * scale); + c.getContext("2d").drawImage(video, 0, 0, c.width, c.height); + return c; + } + async function openCamera() { + stopTask(); + stopCamera(); + const epoch = cameraEpoch; + try { + if (!navigator.mediaDevices?.getUserMedia) + throw Error("Live camera access needs HTTPS and a compatible browser."); + status("Opening camera…", "Please allow camera access."); + const acquired = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + facingMode: { ideal: "environment" }, + width: { ideal: 1920 }, + height: { ideal: 1440 }, + }, + }); + if (epoch !== cameraEpoch) { + acquired.getTracks().forEach((t) => t.stop()); + return; + } + stream = acquired; + $("camera-panel").hidden = false; + $("video").srcObject = stream; + await $("video").play(); + if (epoch !== cameraEpoch) return; + $("camera-panel").scrollIntoView({ behavior: "smooth", block: "start" }); + status("Camera ready.", "Capture manually or hold a clear grid steady."); + let stable = 0, + previous = null; + const loop = async () => { + if (epoch !== cameraEpoch || !stream) return; + try { + if ($("auto-capture").checked) { + const small = frame($("video"), 480), + found = await scanner.detect(small); + if (epoch !== cameraEpoch) return; + const movement = previous + ? Math.max( + ...found.corners.map((p, i) => + Math.hypot( + p.x - previous.corners[i].x, + p.y - previous.corners[i].y, + ), + ), + ) + : Infinity; + if ( + found.confidence > 0.85 && + found.sharpness > 100 && + movement < small.width * 0.018 && + found.rows === previous?.rows && + found.cols === previous?.cols + ) + stable++; + else stable = 0; + previous = found; + $("camera-help").textContent = stable + ? `Grid found. Hold steady… ${stable}/3` + : "Keep the entire grid in view. Hold steady or tap Capture."; + if (stable >= 3) { + takePhoto(true); + return; + } + } + } catch (error) { + if (error.name !== "AbortError") + $("camera-help").textContent = + "Automatic capture is unavailable. Tap Capture to continue."; + } + if (epoch === cameraEpoch) setTimeout(loop, 800); + }; + setTimeout(loop, 900); + } catch (e) { + if (epoch !== cameraEpoch) return; + stopCamera(); + $("native-camera").hidden = false; + status( + "Live camera could not open.", + `${e.name === "NotAllowedError" ? "Camera permission was denied." : e.message} Choose a photo or use the phone’s camera app instead.`, + "warning", + ); + } + } + $("camera").onclick = openCamera; + $("close-camera").onclick = stopCamera; + function takePhoto(auto = false) { + try { + const canvas = frame($("video")); + stopCamera(); + void acceptPhoto(canvas, auto); + } catch (e) { + fail(e); + } + } + $("take-photo").onclick = () => takePhoto(); + $("choose-photo").onclick = () => $("photo-file").click(); + $("native-camera").onclick = () => $("native-file").click(); + const MAX_SIDE = 1600, + JPEG_FRAME_MARKERS = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, + 0xcf, + ]); + // Stored pixel dimensions from a PNG header or the first JPEG frame header; + // null for other formats. Orientation metadata is not applied here. + function sniffDimensions(bytes) { + if ( + bytes.length >= 24 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 + ) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length); + return { width: view.getUint32(16), height: view.getUint32(20) }; + } + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null; + for (let i = 2; i + 9 < bytes.length; ) { + if (bytes[i] !== 0xff) { + i++; + continue; + } + const marker = bytes[i + 1]; + if (marker === 0xff) { + i++; + continue; + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) { + i += 2; + continue; + } + if (marker === 0xd9 || marker === 0xda) return null; + const length = (bytes[i + 2] << 8) | bytes[i + 3]; + if (length < 2) return null; + if (JPEG_FRAME_MARKERS.has(marker)) + return { + height: (bytes[i + 5] << 8) | bytes[i + 6], + width: (bytes[i + 7] << 8) | bytes[i + 8], + }; + i += 2 + length; + } + return null; + } + function fit(width, height) { + const scale = Math.min(1, MAX_SIDE / Math.max(width, height)); + return [ + Math.max(1, Math.round(width * scale)), + Math.max(1, Math.round(height * scale)), + ]; + } + function draw(source, width, height) { + const c = document.createElement("canvas"); + c.width = width; + c.height = height; + const ctx = c.getContext("2d"); + // Geometry and OCR consume RGB. Transparent PNG backgrounds should behave + // like white paper in both the ImageBitmap and Image decode paths. + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, width, height); + ctx.drawImage(source, 0, 0, width, height); + return c; + } + async function decodeFile(file) { + if (file.size > 30 * 1024 * 1024) + throw Error("Please choose a photo smaller than 30 MB."); + const head = new Uint8Array(await file.slice(0, 512 * 1024).arrayBuffer()), + dimensions = sniffDimensions(head), + pixels = dimensions ? dimensions.width * dimensions.height : null; + if (dimensions && (dimensions.width < 1 || dimensions.height < 1)) + throw Error("The image is empty."); + if (pixels > 120e6) + throw Error( + "This photo is too large to decode safely on a phone. Use a smaller camera resolution or crop it first.", + ); + if (dimensions && typeof createImageBitmap === "function") { + // Decode straight to the working size instead of materializing a + // full-resolution phone photograph. Only one side is requested so the + // aspect ratio survives EXIF rotation; the final fit happens on canvas. + let bitmap = null; + try { + bitmap = await createImageBitmap(file, { + resizeWidth: fit(dimensions.width, dimensions.height)[0], + resizeQuality: "high", + imageOrientation: "from-image", + }); + } catch { + bitmap = null; + } + if (bitmap) + try { + return draw(bitmap, ...fit(bitmap.width, bitmap.height)); + } finally { + bitmap.close?.(); + } + } + // A full decode is the only remaining route; refuse sizes that can + // exhaust phone memory instead of crashing the page. + if (pixels > 24e6 || (!dimensions && file.size > 10 * 1024 * 1024)) + throw Error( + "This browser cannot downscale this large photo safely. Crop it in your photo app first, then try again.", + ); + const url = URL.createObjectURL(file); + try { + const img = new Image(); + img.src = url; + await img.decode(); + if (!img.naturalWidth || !img.naturalHeight) + throw Error("The image is empty."); + return draw(img, ...fit(img.naturalWidth, img.naturalHeight)); + } finally { + URL.revokeObjectURL(url); + } + } + for (const id of ["photo-file", "native-file"]) + $(id).onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + stopCamera(); + stopTask(); + const epoch = getJobId(); + try { + const canvas = await decodeFile(file); + if (epoch === getJobId()) await acceptPhoto(canvas); + } catch (error) { + if (epoch === getJobId()) fail(error); + } finally { + e.target.value = ""; + } + }; + function drawCrop() { + if (!state.photo || !state.corners) return; + const out = $("crop-canvas"), + ctx = out.getContext("2d"); + out.width = state.photo.width; + out.height = state.photo.height; + ctx.drawImage(state.photo, 0, 0); + const radius = Math.max(15, out.width / 32); + ctx.beginPath(); + state.corners.forEach((p, i) => + i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y), + ); + ctx.closePath(); + ctx.strokeStyle = "#0cbb94"; + ctx.lineWidth = Math.max(3, out.width / 220); + ctx.stroke(); + state.corners.forEach((p, i) => { + ctx.beginPath(); + ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); + ctx.fillStyle = "#123b3b"; + ctx.fill(); + ctx.strokeStyle = "#fff"; + ctx.lineWidth = radius / 10; + ctx.stroke(); + ctx.fillStyle = "#fff"; + ctx.font = `bold ${radius}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(i + 1, p.x, p.y); + }); + } + async function acceptPhoto(canvas, auto = false) { + invalidate(); + state.history = []; + state.puzzleSource = null; + state.photo = canvas; + clearPhotoMapping(); + state.corners = null; + state.result = null; + $("photo-panel").hidden = false; + render(); + const id = begin(); + status("Finding the grid…", "Photo processing stays on this device."); + try { + const found = await scanner.detect(canvas); + if (id !== getJobId()) return; + state.corners = found.corners; + finish(); + if (found.rows && found.cols) { + const b = boxDefault(found.rows); + setLayout({ rows: found.rows, cols: found.cols, boxRows: b[0], boxCols: b[1] }); + } + drawCrop(); + status( + found.confidence > 0.8 ? "Grid found." : "Set the four crop corners.", + found.rows + ? `Detected ${found.rows} × ${found.cols}. Check the corners, then read the puzzle.` + : "Drag the numbered handles. Set rows and columns in Grid size & settings.", + ); + $("photo-panel").scrollIntoView({ block: "start", behavior: "smooth" }); + if (auto && found.confidence > 0.85) await readPhoto(); + } catch (e) { + if (id === getJobId()) { + finish(); + fail(e); + } + } + } + $("detect-photo").onclick = () => { + if (state.photo) void acceptPhoto(state.photo); + }; + $("rotate-photo").onclick = () => { + if (!state.photo) return; + const c = document.createElement("canvas"); + c.width = state.photo.height; + c.height = state.photo.width; + const ctx = c.getContext("2d"); + ctx.translate(c.width, 0); + ctx.rotate(Math.PI / 2); + ctx.drawImage(state.photo, 0, 0); + void acceptPhoto(c); + }; + $("hide-photo").onclick = () => ($("photo-panel").hidden = true); + $("show-crop").onclick = () => { + $("photo-panel").hidden = false; + drawCrop(); + $("photo-panel").scrollIntoView({ block: "start", behavior: "smooth" }); + }; + $("crop-canvas").style.maxHeight = "none"; + $("crop-canvas").tabIndex = 0; + $("crop-canvas").title = + "Drag corners, or press 1–4 to select a corner and use arrow keys."; + function cropPoint(e) { + const b = $("crop-canvas").getBoundingClientRect(); + return { + x: ((e.clientX - b.left) * $("crop-canvas").width) / b.width, + y: ((e.clientY - b.top) * $("crop-canvas").height) / b.height, + }; + } + $("crop-canvas").onpointerdown = (e) => { + if (!state.corners) return; + const pt = cropPoint(e), + dist = state.corners.map((p) => Math.hypot(p.x - pt.x, p.y - pt.y)); + drag = dist.indexOf(Math.min(...dist)); + if (dist[drag] > state.photo.width * 0.15) { + drag = -1; + return; + } + stopTask(); + $("crop-canvas").setPointerCapture(e.pointerId); + e.preventDefault(); + }; + $("crop-canvas").onpointermove = (e) => { + if (drag < 0) return; + const pt = cropPoint(e); + state.corners[drag] = { + x: Math.max(0, Math.min(state.photo.width - 1, pt.x)), + y: Math.max(0, Math.min(state.photo.height - 1, pt.y)), + }; + clearPhotoMapping(); + drawCrop(); + }; + $("crop-canvas").onpointerup = $("crop-canvas").onpointercancel = () => { + drag = -1; + }; + let keyboardCorner = 0; + $("crop-canvas").onkeydown = (e) => { + if (!state.corners) return; + if (/^[1-4]$/.test(e.key)) { + keyboardCorner = Number(e.key) - 1; + return; + } + const delta = { + ArrowLeft: [-1, 0], + ArrowRight: [1, 0], + ArrowUp: [0, -1], + ArrowDown: [0, 1], + }[e.key]; + if (delta) { + e.preventDefault(); + stopTask(); + const p = state.corners[keyboardCorner], + step = e.shiftKey ? 10 : 1; + p.x = Math.max(0, Math.min(state.photo.width - 1, p.x + delta[0] * step)); + p.y = Math.max( + 0, + Math.min(state.photo.height - 1, p.y + delta[1] * step), + ); + clearPhotoMapping(); + drawCrop(); + } + }; + async function readPhoto() { + if (!state.photo || !state.corners) return; + const rows = Number($("rows").value), + cols = Number($("cols").value), + type = $("puzzle-type").value; + if ( + !Number.isInteger(rows) || + !Number.isInteger(cols) || + rows < 1 || + cols < 1 || + rows > 25 || + cols > 25 + ) { + fail(Error("Set rows and columns to whole numbers from 1 to 25.")); + return; + } + if (!validQuad(state.corners, state.photo.width, state.photo.height)) { + fail( + Error( + "The crop corners must surround the grid clockwise without crossing.", + ), + ); + return; + } + const boxRows = Number($("box-rows").value), + boxCols = Number($("box-cols").value); + try { + if (type !== "auto") { + const layout = makePuzzle(type, rows, cols); + layout.boxRows = boxRows; + layout.boxCols = boxCols; + checkShape(layout); + } + } catch (error) { + fail(error); + return; + } + clearPhotoMapping(); + state.result = null; + $("next-solution").hidden = true; + drawBoard(); + const id = begin(); + setDeadline(() => { + if (id === getJobId()) + stopTask( + "Recognition timed out. Check the connection and try a clearer photograph.", + ); + }, 120000); + try { + const found = await scanner.read( + state.photo, + state.corners, + type, + rows, + cols, + (text, p) => { + if (id === getJobId()) status(text, "", "info", p); + }, + ); + if (id !== getJobId()) return; + // Snapshot settings belong to this scan. Validate the complete candidate + // before committing history, state or autosave, including automatic type. + if (["sudoku", "killersudoku"].includes(found.puzzle.type)) { + found.puzzle.boxRows = boxRows; + found.puzzle.boxCols = boxCols; + } + checkShape(found.puzzle); + finish(); + remember(); + state.puzzle = found.puzzle; + state.uncertain = new Set(found.cellUncertain ?? found.uncertain); + state.cageUncertain = new Set(found.cageUncertain || []); + state.needsReview = found.needsReview; + state.notes = found.notes; + state.rectified = found.rectified; + state.puzzleSource = state.photoSource = id; + state.photoRows = rows; + state.photoCols = cols; + state.selected = []; + persist(); + render({ replaceDraft: true }); + $("photo-panel").hidden = true; + status( + "Puzzle read.", + `${type === "auto" ? `${TYPES[state.puzzle.type]} suggested` : TYPES[state.puzzle.type]}. Check highlighted cells and the puzzle rules.`, + ); + $("board-title").scrollIntoView({ behavior: "smooth", block: "start" }); + if ( + $("auto-solve").checked && + !state.uncertain.size && + !state.cageUncertain.size && + !state.needsReview && + state.puzzle.cells.some(Number.isInteger) + ) + solveNow(); + } catch (e) { + if (id === getJobId()) { + finish(); + fail(e); + } + } + } + $("read-photo").onclick = () => void readPhoto(); + document.addEventListener("visibilitychange", () => { + if (document.hidden) stopCamera(); + }); + + return { stopCamera }; +} diff --git a/web/scan-analysis.js b/web/scan-analysis.js new file mode 100644 index 00000000..ea60ed43 --- /dev/null +++ b/web/scan-analysis.js @@ -0,0 +1,297 @@ +import { isGridStroke } from "./ocr-map.js"; +import { isCage } from "./model.js"; +import { gray, thresholdGray, estimateGrid } from "./geometry.js"; + +function fraction(mask, w, h, x, y, rw, rh) { + let sum = 0, + n = 0; + for (let yy = Math.max(0, Math.floor(y)); yy < Math.min(h, y + rh); yy++) + for (let xx = Math.max(0, Math.floor(x)); xx < Math.min(w, x + rw); xx++) { + sum += mask[yy * w + xx]; + n++; + } + return sum / Math.max(1, n); +} + +function mean(grayImage, w, h, x, y, rw, rh) { + let sum = 0, + n = 0; + for (let yy = Math.max(0, Math.floor(y)); yy < Math.min(h, y + rh); yy++) + for (let xx = Math.max(0, Math.floor(x)); xx < Math.min(w, x + rw); xx++) { + sum += grayImage[yy * w + xx]; + n++; + } + return sum / Math.max(1, n); +} + +// Solid Str8ts/Kakuro/Hidato blocks are much darker than the bright-cell +// population even under an illumination gradient. Newspaper Sudoku shading is +// halftone grey and must not become a black structural cell merely because the +// lower corner of the photograph is in shadow. +export function detectBlackCells(g, w, h, rows, cols) { + const cw = w / cols, + ch = h / rows, + dark = new Uint8Array(g.length), + means = [], + darkFractions = []; + for (let i = 0; i < g.length; i++) dark[i] = g[i] < 125 ? 1 : 0; + for (let i = 0; i < rows * cols; i++) { + const c = i % cols, + r = Math.floor(i / cols), + x = (c + 0.16) * cw, + y = (r + 0.16) * ch, + rw = 0.68 * cw, + rh = 0.68 * ch; + means.push(mean(g, w, h, x, y, rw, rh)); + darkFractions.push(fraction(dark, w, h, x, y, rw, rh)); + } + const sorted = [...means].sort((a, b) => a - b), + brightReference = sorted[Math.floor((sorted.length - 1) * 0.8)] || 255, + meanCutoff = Math.min(105, brightReference * 0.48); + return means.map( + (value, i) => value < meanCutoff && darkFractions[i] > 0.65, + ); +} + +function numberBounds(mask, w, h) { + const seen = new Uint8Array(mask.length), + stack = [], + parts = []; + for (let start = 0; start < mask.length; start++) { + if (!mask[start] || seen[start]) continue; + let area = 0, + minx = w, + miny = h, + maxx = -1, + maxy = -1; + seen[start] = 1; + stack.push(start); + while (stack.length) { + const at = stack.pop(), + y = Math.floor(at / w), + x = at % w; + area++; + minx = Math.min(minx, x); + miny = Math.min(miny, y); + maxx = Math.max(maxx, x); + maxy = Math.max(maxy, y); + for (let dy = -1; dy <= 1; dy++) + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const xx = x + dx, + yy = y + dy; + if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue; + const next = yy * w + xx; + if (mask[next] && !seen[next]) { + seen[next] = 1; + stack.push(next); + } + } + } + parts.push({ area, minx, miny, maxx, maxy }); + } + parts.sort((a, b) => b.area - a.area); + const anchor = parts.find( + (part) => + part.area >= Math.max(4, w * h * 0.003) && + part.maxy - part.miny + 1 >= h * 0.25, + ); + if (!anchor) return null; + const bounds = { ...anchor }, + height = anchor.maxy - anchor.miny + 1; + // Neighbouring digits are separate components too. Keep substantial glyphs + // on the same line while excluding the small dots of halftone/newsprint. + const pending = parts.filter((part) => { + const partHeight = part.maxy - part.miny + 1, + overlap = Math.min(anchor.maxy, part.maxy) - Math.max(anchor.miny, part.miny) + 1; + return part !== anchor && + part.area >= Math.max(4, w * h * 0.003, anchor.area * 0.1) && + partHeight >= height * 0.55 && partHeight <= height * 1.6 && + overlap >= Math.min(height, partHeight) * 0.6; + }); + for (let changed = true; changed;) { + changed = false; + for (let i = pending.length - 1; i >= 0; i--) { + const part = pending[i], + gap = Math.max(part.minx - bounds.maxx - 1, bounds.minx - part.maxx - 1); + if (gap > height) continue; + bounds.minx = Math.min(bounds.minx, part.minx); + bounds.maxx = Math.max(bounds.maxx, part.maxx); + bounds.miny = Math.min(bounds.miny, part.miny); + bounds.maxy = Math.max(bounds.maxy, part.maxy); + bounds.area += part.area; + pending.splice(i, 1); + changed = true; + } + } + return bounds; +} + +export function prepareScan(image, type, rows, cols) { + const w = image.width, + h = image.height, + cw = w / cols, + ch = h / rows, + g = gray(image), + mask = thresholdGray(g, w, h), + black = detectBlackCells(g, w, h, rows, cols), + anyBlack = black.some(Boolean), + entries = []; + + function region(kind, cell, x, y, rw, rh, invert = false, other = null) { + x = Math.max(0, Math.round(x)); + y = Math.max(0, Math.round(y)); + rw = Math.max(1, Math.min(w - x, Math.round(rw))); + rh = Math.max(1, Math.min(h - y, Math.round(rh))); + const local = new Uint8Array(rw * rh); + let minx = rw, + miny = rh, + maxx = -1, + maxy = -1, + ink = 0; + for (let yy = 0; yy < rh; yy++) + for (let xx = 0; xx < rw; xx++) { + const val = invert + ? g[(y + yy) * w + x + xx] > 135 + : mask[(y + yy) * w + x + xx]; + local[yy * rw + xx] = val ? 1 : 0; + if (val) { + minx = Math.min(minx, xx); + miny = Math.min(miny, yy); + maxx = Math.max(maxx, xx); + maxy = Math.max(maxy, yy); + ink++; + } + } + // Filter speckle without cutting a multi-digit clue into a single glyph. + if (["value", "blackvalue"].includes(kind)) { + const part = numberBounds(local, rw, rh); + if (!part) return; + ({ minx, miny, maxx, maxy } = part); + ink = part.area; + } + if ( + ink < Math.max(4, rw * rh * 0.008) || + maxy - miny < Math.max(2, rh * 0.1) + ) + return; + if (kind === "label" && (maxy >= rh - 2 || maxy - miny < 3)) return; + let edgeInk = 0; + if (kind === "label") { + const band = Math.max(1, Math.round(ch * 0.03)); + for (let yy = miny; yy <= maxy; yy++) + for (let xx = minx; xx <= maxx; xx++) + if (yy < miny + band || xx < minx + band) + edgeInk += mask[(y + yy) * w + x + xx]; + } + if ( + isGridStroke({ + kind, + width: maxx - minx + 1, + height: maxy - miny + 1, + ink, + edgeInk, + regionWidth: rw, + cellHeight: ch, + }) + ) + return; + if (kind === "hsign" && maxx - minx < (maxy - miny) * 0.3) return; + if (kind === "vsign" && maxy - miny < (maxx - minx) * 0.3) return; + entries.push({ + kind, + cell, + other, + x: x + minx, + y: y + miny, + w: maxx - minx + 1, + h: maxy - miny + 1, + invert, + text: "", + confidence: 0, + }); + } + + for (let r = 0; r < rows; r++) + for (let c = 0; c < cols; c++) { + const i = r * cols + c; + if (black[i] && ["auto", "kakuro", "hidato", "str8ts"].includes(type)) { + if (type === "auto" || type === "str8ts") + region( + "blackvalue", + i, + (c + 0.16) * cw, + (r + 0.16) * ch, + 0.68 * cw, + 0.68 * ch, + true, + ); + if (type === "auto" || type === "kakuro") { + region( + "across", + i, + (c + 0.48) * cw, + (r + 0.04) * ch, + 0.46 * cw, + 0.4 * ch, + true, + ); + region( + "down", + i, + (c + 0.05) * cw, + (r + 0.55) * ch, + 0.4 * cw, + 0.4 * ch, + true, + ); + } + } else + region( + "value", + i, + (c + 0.14) * cw, + (r + 0.16) * ch, + 0.72 * cw, + 0.72 * ch, + ); + // Structural probes are expensive and can turn a shadow into false + // cage/sign evidence. Once a true solid block is present, the black-cell + // families provide the useful structural signal instead. + if ((type === "auto" && !anyBlack) || isCage(type)) + region( + "label", + i, + (c + 0.04) * cw, + (r + 0.015) * ch, + 0.7 * cw, + 0.255 * ch, + ); + if ((type === "auto" && !anyBlack) || type === "futoshiki") { + if (c < cols - 1) + region( + "hsign", + i, + (c + 0.82) * cw, + (r + 0.25) * ch, + 0.36 * cw, + 0.5 * ch, + false, + i + 1, + ); + if (r < rows - 1) + region( + "vsign", + i, + (c + 0.25) * cw, + (r + 0.82) * ch, + 0.5 * cw, + 0.36 * ch, + false, + i + cols, + ); + } + } + + return { image, meta: estimateGrid(image, mask), mask, g, black, entries }; +} diff --git a/web/scanner.js b/web/scanner.js new file mode 100644 index 00000000..f5524779 --- /dev/null +++ b/web/scanner.js @@ -0,0 +1,536 @@ +import { makePuzzle, classify, conflicts, isCage } from "./model.js"; +import { mapAtlas, atlasLayout, voteDigit } from "./ocr-map.js"; +const aborted = () => new DOMException("Scan cancelled", "AbortError"); +export function imageOf(canvas) { + return canvas + .getContext("2d", { willReadFrequently: true }) + .getImageData(0, 0, canvas.width, canvas.height); +} +export function canvasOf(image) { + const c = document.createElement("canvas"); + c.width = image.width; + c.height = image.height; + c.getContext("2d").putImageData( + new ImageData(image.data, image.width, image.height), + 0, + 0, + ); + return c; +} +function fraction(mask, w, h, x, y, rw, rh) { + let sum = 0, + n = 0; + for (let yy = Math.max(0, Math.floor(y)); yy < Math.min(h, y + rh); yy++) + for (let xx = Math.max(0, Math.floor(x)); xx < Math.min(w, x + rw); xx++) { + sum += mask[yy * w + xx]; + n++; + } + return sum / Math.max(1, n); +} +function otsuThreshold(g, width, x, y, w, h) { + const histogram = new Uint32Array(256); + let total = 0, + sum = 0; + for (let yy = y; yy < y + h; yy++) + for (let xx = x; xx < x + w; xx++) { + const value = g[yy * width + xx]; + histogram[value]++; + total++; + sum += value; + } + let background = 0, + backgroundSum = 0, + best = -1, + threshold = 127; + for (let value = 0; value < 256; value++) { + background += histogram[value]; + if (!background) continue; + const foreground = total - background; + if (!foreground) break; + backgroundSum += value * histogram[value]; + const meanBackground = backgroundSum / background, + meanForeground = (sum - backgroundSum) / foreground, + score = background * foreground * (meanBackground - meanForeground) ** 2; + if (score > best) { + best = score; + threshold = value; + } + } + return threshold; +} +export function digitCrop(entry, g, imageWidth, imageHeight, cellWidth, cellHeight, cols) { + const pad = Math.max(2, Math.round(Math.min(cellWidth, cellHeight) * 0.05)), + row = Math.floor(entry.cell / cols), + col = entry.cell % cols, + minX = Math.max(0, Math.round((col + 0.08) * cellWidth)), + maxX = Math.min(imageWidth, Math.round((col + 0.92) * cellWidth)), + minY = Math.max(0, Math.round((row + 0.08) * cellHeight)), + maxY = Math.min(imageHeight, Math.round((row + 0.92) * cellHeight)), + x = Math.max(minX, entry.x - pad), + y = Math.max(minY, entry.y - pad), + right = Math.min(maxX, entry.x + entry.w + pad), + bottom = Math.min(maxY, entry.y + entry.h + pad), + width = Math.max(1, right - x), + height = Math.max(1, bottom - y), + threshold = otsuThreshold(g, imageWidth, x, y, width, height), + canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"), + pixels = context.createImageData(width, height); + for (let yy = 0; yy < height; yy++) + for (let xx = 0; xx < width; xx++) { + const source = g[(y + yy) * imageWidth + x + xx], + foreground = entry.invert ? source > threshold : source <= threshold, + value = foreground ? 0 : 255, + at = 4 * (yy * width + xx); + pixels.data[at] = pixels.data[at + 1] = pixels.data[at + 2] = value; + pixels.data[at + 3] = 255; + } + context.putImageData(pixels, 0, 0); + return canvas; +} +const SAMPLE_HEIGHT = 64, + SAMPLE_PAD = 16, + SAMPLE_GRAY_LIMIT = 150; +// Grayscale counterpart of digitCrop: same bounds, no binarization, dark +// digit on light ground for black-cell clues too. +export function grayCrop(entry, g, imageWidth, imageHeight, cellWidth, cellHeight, cols) { + const pad = Math.max(2, Math.round(Math.min(cellWidth, cellHeight) * 0.05)), + row = Math.floor(entry.cell / cols), + col = entry.cell % cols, + minX = Math.max(0, Math.round((col + 0.08) * cellWidth)), + maxX = Math.min(imageWidth, Math.round((col + 0.92) * cellWidth)), + minY = Math.max(0, Math.round((row + 0.08) * cellHeight)), + maxY = Math.min(imageHeight, Math.round((row + 0.92) * cellHeight)), + x = Math.max(minX, entry.x - pad), + y = Math.max(minY, entry.y - pad), + width = Math.max(1, Math.min(maxX, entry.x + entry.w + pad) - x), + height = Math.max(1, Math.min(maxY, entry.y + entry.h + pad) - y), + canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"), + pixels = context.createImageData(width, height); + for (let yy = 0; yy < height; yy++) + for (let xx = 0; xx < width; xx++) { + const source = g[(y + yy) * imageWidth + x + xx], + value = entry.invert ? 255 - source : source, + at = 4 * (yy * width + xx); + pixels.data[at] = pixels.data[at + 1] = pixels.data[at + 2] = value; + pixels.data[at + 3] = 255; + } + context.putImageData(pixels, 0, 0); + return canvas; +} +function sampleOf(source) { + const scale = SAMPLE_HEIGHT / source.height, + canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(source.width * scale)) + 2 * SAMPLE_PAD; + canvas.height = SAMPLE_HEIGHT + 2 * SAMPLE_PAD; + const context = canvas.getContext("2d"); + context.fillStyle = "#fff"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + context.drawImage(source, SAMPLE_PAD, SAMPLE_PAD, canvas.width - 2 * SAMPLE_PAD, SAMPLE_HEIGHT); + return canvas.toDataURL("image/png"); +} +// Single-glyph digits get independent single-character readings of their +// binary and grayscale crops. Wide crops are multi-digit clues, which the +// single-character mode cannot read; they keep the atlas reading. +export function digitSamples(entries, crops, g, w, h, cw, ch, cols) { + const digits = [...crops.keys()].filter((i) => { + const crop = crops.get(i); + return crop.width <= crop.height * 0.85; + }); + const withGray = digits.length <= SAMPLE_GRAY_LIMIT, + singles = []; + for (const i of digits) { + singles.push({ index: i, kind: "binary", png: sampleOf(crops.get(i)) }); + if (withGray) + singles.push({ + index: i, + kind: "gray", + png: sampleOf(grayCrop(entries[i], g, w, h, cw, ch, cols)), + }); + } + return singles; +} +// The atlas reading and the single-character readings vote per digit. A +// digit nobody could read keeps the atlas result, which downstream flags. +export function applyDigitVotes(entries, singles = []) { + const byIndex = new Map(); + for (const single of singles) { + if (!Number.isInteger(single?.index) || !entries[single.index]) continue; + if (!byIndex.has(single.index)) byIndex.set(single.index, []); + byIndex.get(single.index).push(single); + } + for (const [i, reads] of byIndex) { + const entry = entries[i], + vote = voteDigit([{ text: entry.text, confidence: entry.confidence }, ...reads]); + if (!vote.text) continue; + entry.text = vote.text; + entry.confidence = vote.unanimous ? Math.max(90, vote.confidence) : 0; + } +} +function componentsForCages(mask, w, h, rows, cols, type) { + const cw = w / cols, + ch = h / rows, + parent = Array.from({ length: rows * cols }, (_, i) => i), + root = (i) => { + while (parent[i] !== i) { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + return i; + }; + function boundary(r, c, vertical) { + const x = vertical ? (c + 1) * cw : c * cw + 0.2 * cw, + y = vertical ? r * ch + 0.2 * ch : (r + 1) * ch; + const band = Math.max(1, Math.min(cw, ch) * 0.023), + offset = Math.min(cw, ch) * 0.075; + const strip = (d) => + vertical + ? fraction(mask, w, h, x + d - band / 2, y, band, ch * 0.6) + : fraction(mask, w, h, x, y + d - band / 2, cw * 0.6, band); + if (type === "killersudoku") + return Math.max(strip(-offset), strip(offset)) > 0.19; + return Math.min(strip(-band * 1.2), strip(band * 1.2)) > 0.3; + } + for (let r = 0; r < rows; r++) + for (let c = 0; c < cols; c++) { + const i = r * cols + c; + if (c < cols - 1 && !boundary(r, c, true)) parent[root(i)] = root(i + 1); + if (r < rows - 1 && !boundary(r, c, false)) + parent[root(i)] = root(i + cols); + } + const groups = new Map(); + for (let i = 0; i < parent.length; i++) { + const k = root(i); + if (!groups.has(k)) groups.set(k, []); + groups.get(k).push(i); + } + return [...groups.values()]; +} +// OCR proposals must remain editable without relaxing the import/solver contract. +export function puzzleFromReadings({ entries, black, meta, mask, width, height }, type, rows, cols) { + const valueEntries = entries.filter((e) => ["value", "blackvalue"].includes(e.kind)), + values = Array(rows * cols).fill(null), + blackValueCells = new Set(), + uncertain = new Set(), + cageUncertain = new Set(); + for (const e of valueEntries) { + if (/^\d{1,3}$/.test(e.text)) values[e.cell] = +e.text; + if (e.kind === "blackvalue" && values[e.cell] !== null) blackValueCells.add(e.cell); + if (values[e.cell] === null || e.confidence < 85) uncertain.add(e.cell); + } + const labels = entries.filter( + (e) => e.kind === "label" && /^\d{1,12}[+\-xX*\/÷×=]?$/.test(e.text), + ); + const signs = entries.filter( + (e) => ["hsign", "vsign"].includes(e.kind) && /^[<>^vV]$/.test(e.text), + ); + const triangles = entries.filter( + (e) => ["across", "down"].includes(e.kind) && /^\d{1,2}$/.test(e.text), + ); + const suggested = classify({ + rows, + cols, + values, + signs: signs.length, + labels: labels.length, + operators: labels.filter((e) => /[+\-xX*\/÷×=]/.test(e.text)).length, + black: black.filter(Boolean).length, + blackNumbers: blackValueCells.size, + triangles: triangles.length, + boxes: meta.boxes, + dots: !meta.rows && !meta.cols, + }); + const chosen = type === "auto" ? suggested.type : type, + puzzle = makePuzzle(chosen, rows, cols), + notes = []; + const max = + chosen === "slitherlink" + ? 4 + : ["hidato", "numbrix"].includes(chosen) + ? rows * cols - + (chosen === "hidato" ? black.filter(Boolean).length : 0) + : chosen === "kakuro" + ? 9 + : rows; + if (chosen === "str8ts") puzzle.black = black.flatMap((v, i) => v ? [i] : []); + puzzle.cells = values.map((v, i) => { + if (v !== null && (v > max || v < (chosen === "slitherlink" ? 0 : 1))) { + uncertain.add(i); + v = null; + } + if (black[i] && chosen === "str8ts") { uncertain.add(i); return v ?? "#"; } + if (black[i] && ["hidato", "kakuro"].includes(chosen)) return "#"; + return v; + }); + if (chosen === "futoshiki") + puzzle.inequalities = signs.map((e) => { + const smallerFirst = ["<", "^"].includes(e.text); + uncertain.add(e.cell); + return { + less: smallerFirst ? e.cell : e.other, + greater: smallerFirst ? e.other : e.cell, + }; + }); + if (chosen === "kakuro") { + for (let i = 0; i < puzzle.cells.length; i++) + if (puzzle.cells[i] === "#") { + const clue = { cell: i }; + for (const d of ["across", "down"]) { + const e = triangles.find((e) => e.cell === i && e.kind === d); + if (e && +e.text >= 1 && +e.text <= 45) clue[d] = +e.text; + else if (e) notes.push("A Kakuro target could not be read. Check the highlighted black cells."); + } + if (clue.across || clue.down) puzzle.clues.push(clue); + uncertain.add(i); + } + } + if (isCage(chosen)) { + const areas = componentsForCages(mask, width, height, rows, cols, chosen); + puzzle.cages = areas.flatMap((cells) => { + const matches = labels + .filter((e) => cells.includes(e.cell)) + .sort((a, b) => a.cell - b.cell), + text = matches[0]?.text || "", + target = Number.parseInt(text, 10), + op = + chosen === "killersudoku" + ? "+" + : text.match(/[+\-xX*\/÷×=]/)?.[0] || "+"; + if (matches.length !== 1) + notes.push( + `A cage covering ${cells.length} cells needs its boundary/target checked.`, + ); + cells.forEach((i) => cageUncertain.add(i)); + const operator = op.replace(/[xX×]/, "*").replace("÷", "/"); + if ((["-", "/"].includes(operator) && cells.length !== 2) || + (operator === "=" && cells.length !== 1)) { + // Do not invent a different operator or partition to make bad OCR + // valid. Leave these cells uncovered so Solve requires a cage edit. + notes.push(`A cage covering ${cells.length} cells has an incompatible “${text}” reading. Check its boundary, target and operator.`); + return []; + } + return [{ + cells, + target: matches.length === 1 && Number.isSafeInteger(target) && target > 0 ? target : null, + op: operator, + }]; + }); + } + conflicts(puzzle).forEach((i) => uncertain.add(i)); + const needsReview = + (type === "auto" && suggested.review) || + isCage(chosen) || + ["futoshiki", "kakuro", "hidato", "numbrix", "slitherlink", "str8ts"].includes( + chosen, + ); + if (type === "auto") notes.unshift(suggested.reason); + if (isCage(chosen)) + notes.unshift( + "Cage recognition is experimental. Check the entire partition: missing boundaries can merge cages.", + ); + if (chosen === "str8ts") notes.unshift("Str8ts black cells may be blank or numbered; check every black cell before solving."); + // A scan that marks printed cells but reads none of them is an engine or + // version problem rather than a review task; say so, with the evidence a + // remote diagnosis needs. + const readDigits = valueEntries.filter((e) => values[e.cell] !== null).length; + if (valueEntries.length >= 3 && readDigits === 0) + notes.unshift( + `Recognition found ${valueEntries.length} printed marks but could not read any digit (${entries.filter((e) => e.text).length} of ${entries.length} regions returned text; app build __BUILD_ID__). Install the app update if one is offered, then scan again; otherwise retake the photo straight on, in even light.`, + ); + return { + puzzle, + uncertain: [...new Set([...uncertain, ...cageUncertain])], + cellUncertain: [...uncertain], + cageUncertain: [...cageUncertain], + needsReview, + notes: [...new Set(notes)].slice(0, 8), + }; +} +export class Scanner { + constructor() { + this.epoch = 0; + this.jobs = new Set(); + } + cancel() { + this.epoch++; + for (const job of this.jobs) job.cancel(); + this.jobs.clear(); + } + _request(path, payload, onProgress = () => {}, type = "module") { + return new Promise((resolve, reject) => { + const worker = new Worker(new URL(path, import.meta.url), { type }); + let settled = false; + const end = (error, result, cancel = false) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + this.jobs.delete(job); + worker.onmessage = worker.onerror = null; + if (cancel && type === "classic") { + // The host can terminate its raw child even while createWorker is + // still awaiting engine/language initialization. Bound host cleanup + // too, including a stalled importScripts before any child exists. + const kill = setTimeout(() => worker.terminate(), 100); + worker.onmessage = ({ data }) => { + if (!data?.cancelled) return; // Ignore progress queued before Stop. + clearTimeout(kill); + worker.terminate(); + }; + try { + worker.postMessage({ cancel: true }); + } catch { + clearTimeout(kill); + worker.terminate(); + } + } else worker.terminate(); + error ? reject(error) : resolve(result); + }; + const job = { cancel: () => end(aborted(), null, true) }; + const timeout = setTimeout( + () => + end( + Error("Image processing timed out. Go online and retry."), + null, + true, + ), + 180000, + ); + this.jobs.add(job); + worker.onmessage = ({ data }) => { + if (data.type === "progress") { + onProgress(data.message, data.progress); + return; + } + end(data.error ? Error(data.error) : null, data.result); + }; + worker.onerror = (e) => + end(Error(e.message || "Image processing failed"), null, true); + try { + worker.postMessage(payload); + } catch (error) { + end(error, null, true); + } + }); + } + geometry(op, options) { + return this._request("geometry-worker.js", { op, ...options }); + } + detect(canvas) { + return this.geometry("detect", { image: imageOf(canvas) }); + } + async read(canvas, corners, type, rows, cols, onProgress = () => {}) { + this.cancel(); + const epoch = this.epoch, + check = () => { + if (epoch !== this.epoch) throw aborted(); + }; + onProgress("Straightening the photograph…", null); + const { image, meta, mask, g, black, entries } = await this.geometry( + "prepare", + { + image: imageOf(canvas), + corners, + width: Math.min(1500, cols * 100), + height: Math.min(1500, rows * 100), + type, + rows, + cols, + }, + ); + check(); + const w = image.width, + h = image.height, + cw = w / cols, + ch = h / rows, + rectified = canvasOf(image); + if (!entries.length) + throw Error( + "No printed clues found. Adjust the crop, dimensions or lighting.", + ); + // One bounded atlas call for every region, plus cheap single-character + // re-reads of the digits so that three readings can vote. The sparse-text + // mode and character boxes preserve the original clue slots. + const { tile, columns, rows: atlasRows } = atlasLayout(entries.length), + atlas = document.createElement("canvas"); + atlas.width = columns * tile; + atlas.height = atlasRows * tile; + const ctx = atlas.getContext("2d"); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, atlas.width, atlas.height); + const bw = canvasOf({ + width: w, + height: h, + data: new Uint8ClampedArray(image.data.length), + }), + bd = bw.getContext("2d").createImageData(w, h); + for (let i = 0; i < mask.length; i++) { + const v = mask[i] ? 0 : 255; + bd.data[4 * i] = bd.data[4 * i + 1] = bd.data[4 * i + 2] = v; + bd.data[4 * i + 3] = 255; + } + bw.getContext("2d").putImageData(bd, 0, 0); + const crops = new Map(); + entries.forEach((e, i) => { + const isDigit = ["value", "blackvalue"].includes(e.kind), + source = isDigit + ? digitCrop(e, g, w, h, cw, ch, cols) + : e.invert + ? rectified + : bw, + sx = isDigit ? 0 : e.x, + sy = isDigit ? 0 : e.y, + sw = isDigit ? source.width : e.w, + sh = isDigit ? source.height : e.h, + scale = Math.min((tile * 74) / 112 / sw, (tile * 72) / 112 / sh), + dw = sw * scale, + dh = sh * scale, + x = (i % columns) * tile + (tile - dw) / 2, + y = Math.floor(i / columns) * tile + (tile - dh) / 2; + ctx.save(); + if (!isDigit && e.invert) ctx.filter = "invert(1)"; + ctx.drawImage(source, sx, sy, sw, sh, x, y, dw, dh); + ctx.restore(); + if (isDigit) crops.set(i, source); + }); + const singles = digitSamples(entries, crops, g, w, h, cw, ch, cols); + check(); + onProgress("Loading printed-clue recognition…", null); + const blob = await new Promise((resolve, reject) => + atlas.toBlob( + (value) => + value + ? resolve(value) + : reject(Error("Could not encode the OCR atlas.")), + "image/png", + ), + ); + check(); + const png = await blob.arrayBuffer(); + check(); + const data = await this._request( + "ocr-host-worker.js", + { png, singles }, + onProgress, + "classic", + ); + check(); + const readings = mapAtlas(data, entries.length, columns, tile); + entries.forEach((e, i) => { + e.text = readings[i].text; + e.confidence = readings[i].confidence; + }); + applyDigitVotes(entries, data.singles); + return { + ...puzzleFromReadings({ entries, black, meta, mask, width: w, height: h }, type, rows, cols), + rectified, + entries, + }; + } +} diff --git a/web/session.js b/web/session.js new file mode 100644 index 00000000..8869c26d --- /dev/null +++ b/web/session.js @@ -0,0 +1,54 @@ +import { checkShape, clone } from "./model.js"; +const KEY = "gridpuzzle-session-v1"; + +// Persist the transcription AND its uncertainty atomically. Never serialize +// photographs/canvases, workers, solutions or history. An app reload must not +// turn an unconfirmed OCR reading into a trusted clue. +export function saveSession(storage, state) { + storage.set(KEY, { + puzzle: clone(state.puzzle), + // Keep the combined list for older installations. New sessions distinguish + // cell readings from cage structure so either can be reviewed independently. + uncertain: [...new Set([...state.uncertain, ...(state.cageUncertain || [])])], + cellUncertain: [...state.uncertain], + cageUncertain: [...(state.cageUncertain || [])], + needsReview: Boolean(state.needsReview), + notes: [...state.notes], + }); +} +export function restoreSession(storage) { + const saved = storage.get(KEY); + const puzzle = saved?.puzzle ?? storage.get("gridpuzzle-puzzle-v1"); + if (!puzzle) return null; + try { + checkShape(puzzle); + } catch { + return null; + } // Malformed/legacy state must not prevent startup. + const indices = (values) => Array.isArray(values) + ? [ + ...new Set( + values.filter( + (i) => Number.isInteger(i) && i >= 0 && i < puzzle.cells.length, + ), + ), + ] + : []; + // Legacy flags have no reason attached: retain them as cell warnings rather + // than guessing that a cage edit is enough to confirm an unread digit. + const uncertain = indices(Array.isArray(saved?.cellUncertain) ? saved.cellUncertain : saved?.uncertain), + cageUncertain = indices(saved?.cageUncertain); + const notes = Array.isArray(saved?.notes) + ? saved.notes + .filter((x) => typeof x === "string") + .slice(0, 8) + .map((x) => x.slice(0, 500)) + : []; + return { + puzzle: clone(puzzle), + uncertain, + cageUncertain, + needsReview: Boolean(saved?.needsReview) || uncertain.length > 0 || cageUncertain.length > 0, + notes, + }; +} diff --git a/web/solver-worker.js b/web/solver-worker.js new file mode 100644 index 00000000..8c4fca42 --- /dev/null +++ b/web/solver-worker.js @@ -0,0 +1,38 @@ +// No shared-memory headers, multiprocessing, Python rewriting, or remote solver. +let runtime; +self.onmessage = async ({ data: { id, puzzle } }) => { + const status = (message) => self.postMessage({ id, type: "status", message }); + try { + if (!runtime) { + status("Loading Python on this device…"); + const { loadPyodide } = await import("./vendor/pyodide/pyodide.mjs"); + runtime = await loadPyodide({ + indexURL: new URL("./vendor/pyodide/", self.location.href).href, + stdout: () => {}, + stderr: () => {}, + }); + status("Loading the complete GridPuzzle solver…"); + const response = await fetch("./solver.zip"); + if (!response.ok) + throw Error( + `Solver download failed (${response.status}). Go online and retry.`, + ); + runtime.unpackArchive(await response.arrayBuffer(), "zip"); + runtime.runPython("from gridsolver.web_api import solve_json"); + } + status("Solving and checking uniqueness…"); + runtime.globals.set("_browser_payload", JSON.stringify(puzzle)); + const result = JSON.parse( + runtime.runPython("solve_json(_browser_payload)"), + ); + runtime.globals.delete("_browser_payload"); + self.postMessage({ id, type: "result", result }); + } catch (error) { + runtime = null; + self.postMessage({ + id, + type: "result", + result: { status: "error", message: error.message || String(error) }, + }); + } +}; diff --git a/web/style.css b/web/style.css new file mode 100644 index 00000000..d5fce1cc --- /dev/null +++ b/web/style.css @@ -0,0 +1,722 @@ +:root { + color-scheme: light; + --ink: #173536; + --muted: #5d6b6a; + --teal: #087d70; + --paper: #f5f5ee; + --line: #dce4de; + --gold: #d9a441; + --soft: #eaf4ec; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 16px; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); +} +button, +input, +select, +textarea { + font: inherit; +} +button, +a, +input, +select, +summary { + -webkit-tap-highlight-color: transparent; +} +button { + border: 1px solid var(--line); + background: #fff; + color: var(--ink); + border-radius: 12px; + padding: 12px 16px; + min-height: 46px; + cursor: pointer; + font-weight: 600; + touch-action: manipulation; +} +button:hover { + border-color: var(--teal); + background: #f0f7f1; +} +button:disabled { + opacity: 0.45; + cursor: default; +} +.primary { + background: var(--ink); + border-color: var(--ink); + color: #fff; +} +.primary:hover { + background: #24504e; + color: #fff; +} +.danger { + border-color: #d49183; + color: #a23d2b; +} +.text-button { + border: 0; + background: none; + padding-left: 0; + font-size: 0.86rem; + text-align: left; +} +.masthead { + max-width: 1220px; + margin: auto; + display: flex; + align-items: center; + justify-content: space-between; + padding: max(28px, calc(env(safe-area-inset-top) + 16px)) 32px 4px; +} +.brand { + display: flex; + gap: 12px; + text-decoration: none; + color: var(--ink); + font-size: 1.25rem; + font-weight: 750; + align-items: center; + letter-spacing: -0.6px; +} +.brand small { + display: block; + font-size: 0.57rem; + letter-spacing: 2.3px; + color: var(--muted); + margin-top: 4px; +} +.privacy-pill { + font-size: 0.73rem; + font-weight: 650; + padding: 9px 12px; + border-radius: 99px; + background: #e3ecdf; +} +.privacy-pill:before { + content: "●"; + color: var(--teal); + margin-right: 7px; + font-size: 0.65rem; +} +main { + max-width: 1220px; + margin: auto; + padding: 0 32px; +} +.update-banner { + box-sizing: border-box; + width: min(100% - 64px, 1156px); + margin: 4px auto 0; + padding: 12px 16px; + border-radius: 12px; + background: #fff3d1; + border: 1px solid #e8c56a; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + font-weight: 600; +} +.intro { + padding: 48px 0 35px; +} +.eyebrow { + font-size: 0.66rem; + font-weight: 750; + letter-spacing: 2.3px; + color: var(--teal); +} +h1 { + font-size: clamp(2.8rem, 5.4vw, 4.6rem); + font-weight: 650; + letter-spacing: -3.2px; + line-height: 1.04; + margin: 18px 0; +} +h1 em { + font-weight: 550; + color: var(--teal); + font-family: Georgia, serif; +} +.intro > p:last-child { + color: var(--muted); + line-height: 1.65; + font-size: 0.98rem; +} +.workspace { + display: grid; + grid-template-columns: minmax(270px, 340px) minmax(0, 1fr); + gap: 24px; + align-items: start; +} +/* A large SVG must scroll INSIDE its card, never widen the mobile viewport. + The default grid-item min-width:auto otherwise propagates its 850px width. */ +.card { + min-width: 0; + background: #fff; + border: 1px solid var(--line); + border-radius: 22px; + padding: 24px; + box-shadow: 0 8px 28px #17353605; +} +.section-heading { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 22px; +} +.section-heading h2 { + font-size: 1.12rem; + letter-spacing: -0.4px; + margin: 0; +} +.section-heading > div { + flex: 1; + min-width: 0; +} +.section-heading p { + margin: 6px 0 0; +} +.step { + font-size: 0.7rem; + color: var(--teal); + font-weight: 750; + background: var(--soft); + padding: 8px; + border-radius: 50%; +} +.compact { + padding: 8px 11px; + font-size: 0.8rem; +} +.capture-actions { + display: grid; + gap: 10px; +} +.capture-actions .primary { + min-height: 58px; + display: flex; + gap: 10px; + align-items: center; + justify-content: center; +} +.field { + display: grid; + gap: 7px; + margin: 19px 0 8px; + font-size: 0.82rem; + font-weight: 600; +} +input, +select, +textarea { + border: 1px solid var(--line); + border-radius: 9px; + background: #fbfcf9; + color: var(--ink); + padding: 11px; + min-height: 46px; + width: 100%; + min-width: 0; +} +select { + padding-right: 22px; +} +textarea { + font-family: ui-monospace, monospace; + font-size: 0.77rem; + line-height: 1.5; + resize: vertical; +} +.muted { + color: var(--muted); + font-size: 0.79rem; + line-height: 1.55; +} +.inline-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin: 12px 0; +} +.inline-buttons > button { + flex: 1; + font-size: 0.8rem; + min-width: 90px; +} +details { + border-top: 1px solid var(--line); + margin-top: 20px; + padding-top: 17px; +} +summary { + cursor: pointer; + font-weight: 600; + font-size: 0.85rem; + min-height: 32px; +} +.fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin: 12px 0; +} +.fields label { + font-size: 0.78rem; + font-weight: 600; + display: grid; + gap: 6px; +} +.check { + display: flex; + gap: 10px; + font-size: 0.8rem; + align-items: center; + line-height: 1.4; + margin: 16px 0; +} +.check input { + width: 19px; + height: 19px; + min-height: 19px; + accent-color: var(--teal); + flex-shrink: 0; +} +.status { + border-radius: 12px; + background: #f2f6ef; + padding: 14px 16px; + display: grid; + gap: 6px; + margin-bottom: 18px; + overflow-wrap: anywhere; +} +.status strong { + font-size: 0.89rem; +} +.status span { + font-size: 0.77rem; + color: var(--muted); + line-height: 1.5; +} +.status.error { + background: #fff0eb; +} +.status.warning { + background: #fff8e9; +} +progress { + width: 100%; + height: 9px; + accent-color: var(--teal); +} +[hidden] { + display: none !important; +} +.board-toolbar { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: end; + margin: 14px 0; +} +.board-toolbar label { + display: grid; + gap: 4px; + font-size: 0.7rem; + color: var(--muted); +} +.board-toolbar select { + font-size: 0.78rem; + max-width: 150px; + min-height: 42px; + padding: 8px; +} +.view-tabs { + display: flex; + gap: 3px; +} +.view-tabs button { + min-height: 42px; + font-size: 0.76rem; + padding: 8px 11px; +} +.view-tabs [aria-pressed="true"] { + background: var(--soft); + border-color: #bdcfc2; +} +.board-scroll { + width: 100%; + max-width: 100%; + min-width: 0; + overflow: auto; + border-radius: 9px; + border: 1px solid #b8c9bf; + background: white; +} +#board { + width: 100%; + display: block; + min-width: 240px; + max-height: 760px; +} +.cell-hit { + fill: #fff; + stroke: #becdc5; + stroke-width: 1; +} +.board-cell { + cursor: pointer; + outline: none; +} +.board-cell:focus .cell-hit { + stroke: var(--teal); + stroke-width: 4; +} +.board-cell text { + pointer-events: none; + fill: var(--ink); + font-size: 30px; + text-anchor: middle; + font-weight: 630; +} +.board-cell.answer text { + fill: var(--teal); + font-weight: 500; +} +.board-cell.uncertain .cell-hit { + fill: #fff0cc; +} +.board-cell.conflict .cell-hit { + fill: #ffdcd1; +} +.board-cell.selected .cell-hit { + fill: #bde2d5; + stroke: var(--teal); + stroke-width: 3; +} +.board-cell.blocked .cell-hit { + fill: #173536; +} +.board-cell.blocked > text { + fill: white; +} +.board-cell .kakuro-clue { + font-size: 19px; + fill: white; +} +.board-cell .cage-label { + font-size: 14px; + fill: #687b73; + font-weight: 600; + text-anchor: start; +} +.box-line { + stroke: var(--ink); + stroke-width: 3; + pointer-events: none; + fill: none; +} +.cage-line { + stroke: #55776d; + stroke-width: 1.5; + fill: none; + pointer-events: none; +} +.inequality { + fill: var(--ink); + font-size: 22px; + text-anchor: middle; + pointer-events: none; +} +.loop-edge { + stroke: var(--teal); + stroke-width: 6; + stroke-linecap: round; + pointer-events: none; +} +.legend { + display: flex; + gap: 13px; + flex-wrap: wrap; + font-size: 0.65rem; + color: var(--muted); + margin: 14px 0; +} +.legend span { + display: flex; + align-items: center; + gap: 5px; +} +.legend i { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; +} +.given-dot { + background: var(--ink); +} +.answer-dot { + background: var(--teal); +} +.review-dot { + background: var(--gold); +} +.solve-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 18px; +} +.solve-actions .primary { + flex: 1; + display: flex; + justify-content: space-between; + gap: 18px; + min-width: 160px; +} +.solve-actions > button { + font-size: 0.86rem; +} +.small-note { + font-size: 0.71rem; + color: var(--muted); + line-height: 1.5; +} +.review-note { + padding: 12px; + border-left: 3px solid var(--gold); + font-size: 0.79rem; + line-height: 1.6; + background: #fff9eb; + white-space: pre-line; +} +.subeditor { + background: #f4f8f1; + padding: 12px; + border-radius: 12px; + margin-bottom: 14px; +} +.subeditor p { + font-size: 0.8rem; + margin: 0; + line-height: 1.5; +} +.viewfinder { + position: relative; + background: var(--ink); + border-radius: 12px; + overflow: hidden; +} +.viewfinder video { + display: block; + width: 100%; + max-height: 65vh; + object-fit: contain; +} +.camera-guide { + position: absolute; + inset: 12%; + border: 2px dashed #ffffffb8; + border-radius: 10px; + pointer-events: none; +} +#crop-canvas, +#solution-photo { + width: 100%; + height: auto; + display: block; + border-radius: 10px; +} +#crop-canvas { + touch-action: none; + max-height: 75vh; + object-fit: contain; +} +dialog { + border: 1px solid var(--line); + border-radius: 20px; + max-width: 420px; + width: calc(100% - 32px); + padding: 24px; + color: var(--ink); + box-shadow: 0 20px 100px #0003; +} +dialog::backdrop { + background: #132c3377; + backdrop-filter: blur(3px); +} +dialog h2 { + font-size: 1.2rem; +} +dialog p { + font-size: 0.86rem; + line-height: 1.55; +} +dialog .section-heading { + justify-content: space-between; +} +#clue-crop { + display: block; + width: 140px; + height: 140px; + border-radius: 12px; + margin: 12px auto; + border: 1px solid var(--line); + image-rendering: auto; +} +.error-text { + color: #a23d2b; +} +footer { + display: flex; + gap: 20px; + flex-wrap: wrap; + justify-content: space-between; + padding: 32px 0 28px; + color: var(--muted); + font-size: 0.68rem; +} +footer a { + color: var(--ink); + text-underline-offset: 3px; +} +button:focus-visible, +select:focus-visible, +input:focus-visible, +summary:focus-visible { + outline: 3px solid #74b5a6; + outline-offset: 3px; +} +@media (max-width: 760px) { + .masthead { + padding: max(20px, calc(env(safe-area-inset-top) + 10px)) 18px 0; + } + main { + padding: 0 14px; + } + .intro { + padding: 26px 4px 18px; + } + h1 { + font-size: 3rem; + letter-spacing: -2px; + } + .intro br:not(.desktop) { + display: none; + } + .intro .desktop { + display: none; + } + .intro > p:last-child { + font-size: 0.85rem; + } + .eyebrow { + font-size: 0.6rem; + } + .workspace { + grid-template-columns: minmax(0, 1fr); + gap: 16px; + } + .card { + padding: 18px; + border-radius: 18px; + } + .capture-actions { + grid-template-columns: 1.3fr 1fr; + } + .capture-actions .primary { + min-height: 52px; + font-size: 0.82rem; + } + .capture-actions > button { + padding: 10px 8px; + font-size: 0.82rem; + } + .section-heading { + margin-bottom: 14px; + } + .capture > .field { + margin-top: 15px; + } + .capture > details { + margin-top: 14px; + padding-top: 12px; + } + .privacy-pill { + font-size: 0.65rem; + } + .solve-actions { + position: sticky; + bottom: 0; + padding: 10px 0 max(10px, env(safe-area-inset-bottom)); + background: #ffffffed; + backdrop-filter: blur(8px); + z-index: 2; + } + .board-toolbar { + gap: 5px; + } + .legend { + gap: 10px; + } + .brand { + font-size: 1.1rem; + } + .brand img { + width: 32px; + height: 32px; + } + .intro h1 br { + display: none; + } + footer { + padding-bottom: max(24px, env(safe-area-inset-bottom)); + } +} +@media (prefers-reduced-motion: no-preference) { + button { + transition: + background 0.12s, + border-color 0.12s; + } +} +@media print { + header, + .capture, + .intro, + .solve-actions, + .board-toolbar, + details, + .status, + footer, + .legend, + .small-note { + display: none !important; + } + .workspace { + display: block; + } + .card { + border: 0; + box-shadow: none; + } + main { + padding: 0; + } + #board { + max-height: none; + } +} diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 00000000..e654bf16 --- /dev/null +++ b/web/sw.js @@ -0,0 +1,189 @@ +/* GridPuzzle owns only caches under this service-worker scope. */ +const VERSION="__BUILD_ID__"; +const PREFIX=`gridpuzzle:${self.registration.scope}:`; +const META=PREFIX+`meta:${VERSION}`; +const CONTENT=PREFIX+"content-v1"; +const url=path=>new URL(path,self.registration.scope).href; +const scopeURL=new URL(self.registration.scope); +const RETAINED=url(".retained-solvers.json"); +const isSolver=asset=>/^solver\.[a-f0-9]{12}\.zip$/.test(asset.path); + +function validateManifest(data,build=VERSION){ + if(data?.build!==build||!Array.isArray(data.assets))throw Error("Update the app before downloading offline assets."); + const seen=new Set(); + for(const asset of data.assets){ + if(!asset||typeof asset.path!=="string"||seen.has(asset.path)||!url(asset.path).startsWith(self.registration.scope)||!/^[a-f0-9]{64}$/.test(asset.sha256))throw Error("Invalid offline asset manifest."); + seen.add(asset.path); + } + return data.assets; +} +function assetKey(asset){return url(`.gridpuzzle-cache/${asset.sha256}`);} +async function digest(response){ + const bytes=await response.clone().arrayBuffer(); + const hash=await crypto.subtle.digest("SHA-256",bytes); + return [...new Uint8Array(hash)].map(v=>v.toString(16).padStart(2,"0")).join(""); +} +async function matchesAsset(response,asset){return !!response?.ok&&(await digest(response))===asset.sha256;} +async function contentCache(){return caches.open(CONTENT);} +function injectedCache(value){return !!value&&typeof value.match==="function"&&typeof value.put==="function";} +async function manifest({network=false}={}){ + const cache=await caches.open(META),key=url("assets.json"); + let response=await cache.match(key); + if(!response&&network){ + // Browsers may evict this cache under storage pressure. Restore the list + // for this exact build rather than failing every request until a reinstall. + response=await fetch(new Request(key,{cache:"reload"})); + if(!response.ok)throw Error("Could not load the offline manifest."); + const assets=validateManifest(await response.clone().json()); + try{await cache.put(key,response.clone());}catch{} + return assets; + } + if(!response)throw Error("The offline asset list is missing. Reload online."); + return validateManifest(await response.clone().json()); +} +async function verifiedAsset(cacheOrAsset,assetOrOptions={},maybeOptions={}){ + const injected=injectedCache(cacheOrAsset); + const cache=injected?cacheOrAsset:await contentCache(); + const asset=injected?assetOrOptions:cacheOrAsset; + const options=injected?maybeOptions:assetOrOptions; + const {network=true,requireStorage=true,verifyStored=false,trustStored=false}=options||{}; + const key=assetKey(asset); + let response=await cache.match(key); + if(response&&verifyStored&&!trustStored&&!(await matchesAsset(response,asset))){await cache.delete(key);response=null;} + if(response)return response; + if(!network)return null; + response=await fetch(new Request(url(asset.path),{cache:"reload"})); + if(!response.ok)throw Error(`Could not download ${asset.path}. Stay online and retry.`); + if(!(await matchesAsset(response,asset)))throw Error(`Asset changed during download: ${asset.path}. Update the app and retry.`); + try{await cache.put(key,response.clone());}catch(error){if(requireStorage)throw error;} + return response; +} +async function offlineReadyFast(cacheOrAssets,maybeAssets){ + const injected=injectedCache(cacheOrAssets),cache=injected?cacheOrAssets:await contentCache(),assets=injected?maybeAssets:cacheOrAssets; + for(const asset of assets)if(!(await cache.match(assetKey(asset))))return false; + return true; +} +async function offlineReadyVerified(cacheOrAssets,maybeAssets){ + const injected=injectedCache(cacheOrAssets),cache=injected?cacheOrAssets:await contentCache(),assets=injected?maybeAssets:cacheOrAssets; + for(const asset of assets)if(!(await verifiedAsset(cache,asset,{network:false,verifyStored:true})))return false; + return true; +} +function routeAsset(request,target){ + const rootNavigation=request.mode==="navigate"&&target.origin===scopeURL.origin&&target.pathname===scopeURL.pathname; + return rootNavigation?url("index.html"):target.href; +} +async function pruneContent(assets){ + const keep=new Set(assets.map(assetKey)),cache=await contentCache(); + for(const request of await cache.keys())if(!keep.has(request.url))await cache.delete(request); +} +async function retainedSolvers(cache){ + try{ + const response=await (cache||await caches.open(META)).match(RETAINED); + const assets=response?await response.json():[]; + validateManifest({build:VERSION,assets}); + return assets.filter(asset=>isSolver(asset)&&Array.isArray(asset.clients)&&asset.clients.every(id=>typeof id==="string")); + }catch{return [];} +} +async function historicalSolver(key){ + const match=key.slice(self.registration.scope.length).match(/^solver\.([a-f0-9]{12})\.zip$/); + if(!match)return null; + const name=PREFIX+`meta:${match[1]}`; + if(!(await caches.keys()).includes(name))return null; + try{ + const response=await (await caches.open(name)).match(url("assets.json")); + return response?validateManifest(await response.json(),match[1]).find(asset=>url(asset.path)===key):null; + }catch{return null;} +} +async function preserveActiveSolvers(){ + // A worker that started before another tab activated this update still + // fetches its embedded solver..zip after Python finishes loading. + // Include the owning tabs: worker enumeration varies by engine and a worker + // still loading its script may not be enumerable yet. Later tabs/workers + // must not prolong retention of unrelated old archives. + const clients=await self.clients.matchAll({type:"all",includeUncontrolled:true}); + const alive=new Set(clients.filter(client=>(client.type==="window"&&client.url.startsWith(self.registration.scope))||new URL(client.url).pathname===new URL(url("solver-worker.js")).pathname).map(client=>client.id)); + const previous=(await caches.keys()).filter(key=>key.startsWith(PREFIX+"meta:")&&key!==META); + const keep=new Map(); + // Preserve owners recorded by earlier updates before adding the outgoing + // build, so another update cannot reset an old archive's client lifetime. + for(const key of previous){ + const meta=await caches.open(key); + for(const asset of await retainedSolvers(meta)){ + const owners=asset.clients.filter(id=>alive.has(id)); + keep.set(asset.path,{...asset,clients:owners}); + } + } + for(const key of previous){ + const meta=await caches.open(key),response=await meta.match(url("assets.json")); + if(!response)continue; + try{ + const assets=validateManifest(await response.json(),key.slice((PREFIX+"meta:").length)); + for(const asset of assets)if(isSolver(asset)&&!keep.has(asset.path))keep.set(asset.path,{...asset,clients:[...alive]}); + }catch{/* Damaged old metadata must not prevent a verified update. */} + } + const retained=[...keep.values()].filter(asset=>asset.clients.length); + await (await caches.open(META)).put(RETAINED,new Response(JSON.stringify(retained))); + // An existing worker may still route through its previous controller. + // Keep that controller's manifest while its archive has live owners too. + const retainedPaths=new Set(retained.map(asset=>asset.path)); + for(const key of previous)if(!retainedPaths.has(`solver.${key.slice((PREFIX+"meta:").length)}.zip`))await caches.delete(key); + return retained; +} + +self.addEventListener("install",event=>event.waitUntil((async()=>{ + const response=await fetch(new Request(url("assets.json"),{cache:"reload"})); + if(!response.ok)throw Error("Could not load the offline manifest."); + const assets=validateManifest(await response.clone().json()),meta=await caches.open(META),cache=await contentCache(); + await meta.put(url("assets.json"),response); + const shell=assets.filter(a=>a.path.startsWith("icons/")||(!a.path.includes("/")&&!a.path.endsWith(".zip"))||(a.path.startsWith("solver.")&&a.path.endsWith(".zip"))); + for(const asset of shell)await verifiedAsset(cache,asset,{verifyStored:true}); +})())); +self.addEventListener("activate",event=>event.waitUntil((async()=>{ + const assets=await manifest(),retained=await preserveActiveSolvers(); + await pruneContent([...assets,...retained]); + await self.clients.claim(); +})())); +self.addEventListener("fetch",event=>{ + const request=event.request,target=new URL(request.url); + if(request.method!=="GET"||target.origin!==self.location.origin||!request.url.startsWith(self.registration.scope)||request.headers.has("range"))return; + event.respondWith((async()=>{ + let assets; + // Without a usable asset list (evicted, or this worker outlived its build) + // the page must still load from the network instead of failing every request. + try{assets=await manifest({network:true});}catch{return fetch(request);} + if(target.href===url("assets.json"))return (await (await caches.open(META)).match(url("assets.json")))||fetch(request); + const key=routeAsset(request,target),asset=assets.find(a=>url(a.path)===key); + if(!asset){ + // controllerchange can precede activation's migration work. The old + // manifest is already available before the retention index is written. + const retained=await historicalSolver(key); + return (retained&&await verifiedAsset(retained,{network:false}))||fetch(request); + } + return verifiedAsset(asset,{requireStorage:false,trustStored:true}); + })()); +}); +let downloading=false; +self.addEventListener("message",event=>{ + if(event.data?.type==="ACTIVATE"){self.skipWaiting();return;} + const port=event.ports[0];if(!port)return; + event.waitUntil((async()=>{ + try{ + const assets=await manifest({network:event.data?.type==="PREPARE_OFFLINE"}); + if(event.data?.type==="OFFLINE_STATUS"){ + port.postMessage({done:true,ready:await offlineReadyFast(assets)});return; + } + if(event.data?.type!=="PREPARE_OFFLINE")throw Error("Unknown offline task"); + if(downloading)throw Error("Offline preparation is already running in another tab."); + downloading=true; + try{ + const cache=await contentCache(); + for(let i=0;i { + clearTimeout(deadline); + deadline = null; + }; + const finish = () => { + busy = false; + clearInterval(timer); + timer = null; + clearDeadline(); + $("stop").hidden = true; + $("solve").disabled = false; + $("progress").hidden = true; + $("status").setAttribute("aria-busy", "false"); + }; + const stop = (message = null) => { + id++; + scanner.cancel(); + onStop(busy); + finish(); + if (message) + status( + message, + "Search unfinished. No claim about uniqueness or impossibility has been made.", + "warning", + ); + }; + return { + get id() { + return id; + }, + get busy() { + return busy; + }, + stop, + finish, + clearDeadline, + setDeadline(callback, ms) { + clearDeadline(); + deadline = setTimeout(callback, ms); + }, + begin() { + stop(); + busy = true; + started = performance.now(); + $("stop").hidden = false; + $("solve").disabled = true; + $("status").setAttribute("aria-busy", "true"); + timer = setInterval(() => { + $("status-detail").textContent = + `${((performance.now() - started) / 1000).toFixed(1)} seconds elapsed · Stop cancels this task.`; + }, 500); + return id; + }, + }; +} diff --git a/web/tests/cache-recovery.test.js b/web/tests/cache-recovery.test.js new file mode 100644 index 00000000..91059320 --- /dev/null +++ b/web/tests/cache-recovery.test.js @@ -0,0 +1,180 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; +import { webcrypto, createHash } from "node:crypto"; +// Contract of the content-addressed store: ordinary reads trust bytes that +// were digest-verified before being written; readiness checks and offline +// preparation re-hash, evict wrong bytes and refetch; failed network +// verification is never cached; storage failure never blocks a verified +// online response. +function makeCache() { + const entries = new Map(); + return { + entries, + match: async (key) => entries.get(String(key))?.clone(), + put: async (key, value) => entries.set(String(key), value.clone()), + delete: async (key) => entries.delete(String(key)), + }; +} +function harness() { + const calls = [], + hooks = { respond: null }, + stores = new Map(); + const cache = makeCache(), + entries = cache.entries; + const source = fs.readFileSync(new URL("../sw.js", import.meta.url), "utf8"); + const context = vm.createContext({ + URL, + Request, + Response, + Uint8Array, + crypto: webcrypto, + self: { + registration: { scope: "https://example.test/GridPuzzle/" }, + location: { origin: "https://example.test" }, + addEventListener() {}, + }, + caches: { + open: async (name) => { + if (!stores.has(name)) stores.set(name, makeCache()); + return stores.get(name); + }, + keys: async () => [...stores.keys()], + delete: async (name) => stores.delete(name), + }, + fetch: async (request) => { + calls.push(request.url); + return hooks.respond?.(request) ?? new Response("correct"); + }, + }); + vm.runInContext( + source + + "\nglobalThis.api={verifiedAsset,offlineReadyFast,offlineReadyVerified,assetKey,routeAsset,manifest};", + context, + ); + const asset = { + path: "runtime.wasm", + sha256: createHash("sha256").update("correct").digest("hex"), + }; + return { + entries, + calls, + hooks, + stores, + cache, + asset, + key: String(context.api.assetKey(asset)), + ...context.api, + }; +} +const manifestResponse = (build, assets) => + new Response(JSON.stringify({ build, assets }), { + headers: { "content-type": "application/json" }, + }); +test("an evicted asset list is restored online and stays unavailable offline", async () => { + const h = harness(); + h.hooks.respond = (request) => + request.url.endsWith("/assets.json") + ? manifestResponse("__BUILD_ID__", [h.asset]) + : null; + await assert.rejects(h.manifest(), /missing/); + assert.equal(h.calls.length, 0, "offline lookups never fetch"); + assert.deepEqual(await h.manifest({ network: true }), [h.asset]); + assert.equal(h.calls.length, 1); + assert.deepEqual(await h.manifest(), [h.asset], "the restored list is cached"); + assert.equal(h.calls.length, 1); +}); +test("a worker never adopts another build's asset list", async () => { + const h = harness(); + h.hooks.respond = (request) => + request.url.endsWith("/assets.json") ? manifestResponse("other", []) : null; + await assert.rejects(h.manifest({ network: true }), /Update the app/); + await assert.rejects(h.manifest(), /missing/, "nothing was stored"); +}); +test("assets are keyed by content digest inside the worker scope", () => { + const h = harness(); + assert.equal( + h.key, + `https://example.test/GridPuzzle/.gridpuzzle-cache/${h.asset.sha256}`, + ); +}); +test("root navigation ignores query strings but not subpaths", () => { + const h = harness(), + root = new URL("https://example.test/GridPuzzle/?share=1"), + sub = new URL("https://example.test/GridPuzzle/help?share=1"), + other = new URL("https://example.test/Other/"); + assert.equal( + h.routeAsset({ mode: "navigate" }, root), + "https://example.test/GridPuzzle/index.html", + ); + assert.equal(h.routeAsset({ mode: "navigate" }, sub), sub.href); + assert.equal(h.routeAsset({ mode: "navigate" }, other), other.href); + assert.equal(h.routeAsset({ mode: "cors" }, root), root.href); +}); +test("verified readiness evicts a poisoned asset; preparation refetches it once", async () => { + const h = harness(); + h.entries.set(h.key, new Response("wrong version")); + assert.equal( + await h.offlineReadyFast(h.cache, [h.asset]), + true, + "the cheap check only tests presence", + ); + assert.equal(await h.offlineReadyVerified(h.cache, [h.asset]), false); + assert.equal(h.entries.has(h.key), false); + assert.equal(h.calls.length, 0); + assert.equal( + await (await h.verifiedAsset(h.cache, h.asset)).text(), + "correct", + ); + assert.equal(h.calls.length, 1); + assert.equal(await h.offlineReadyVerified(h.cache, [h.asset]), true); + await h.cache.delete(h.key); + assert.equal(await h.offlineReadyFast(h.cache, [h.asset]), false); + assert.equal(await h.offlineReadyVerified(h.cache, [h.asset]), false); +}); +test("ordinary reads trust stored bytes; verified reads repair them without a status check first", async () => { + const h = harness(); + h.entries.set(h.key, new Response("bad")); + assert.equal(await (await h.verifiedAsset(h.cache, h.asset)).text(), "bad"); + assert.equal(h.calls.length, 0); + assert.equal( + await ( + await h.verifiedAsset(h.cache, h.asset, { verifyStored: true }) + ).text(), + "correct", + ); + assert.equal(h.calls.length, 1); + await h.verifiedAsset(h.cache, h.asset, { verifyStored: true }); + assert.equal(h.calls.length, 1); +}); +test("mismatched network bytes are never cached and a corrected retry succeeds", async () => { + const h = harness(), + wrong = { ...h.asset, sha256: "0".repeat(64) }; + await assert.rejects(h.verifiedAsset(h.cache, wrong), /Asset changed/); + assert.equal(h.entries.size, 0); + await h.verifiedAsset(h.cache, h.asset); + assert.equal(h.calls.length, 2); + assert.equal(h.entries.has(h.key), true); +}); +test("offline-only lookups never touch the network", async () => { + const h = harness(); + assert.equal( + await h.verifiedAsset(h.cache, h.asset, { network: false }), + null, + ); + assert.equal(h.calls.length, 0); +}); +test("cache quota errors do not block verified online responses", async () => { + const h = harness(); + h.cache.put = async () => { + throw Error("quota"); + }; + assert.equal( + await ( + await h.verifiedAsset(h.cache, h.asset, { requireStorage: false }) + ).text(), + "correct", + ); + await assert.rejects(h.verifiedAsset(h.cache, h.asset), /quota/); +}); diff --git a/web/tests/camera-lifecycle.test.js b/web/tests/camera-lifecycle.test.js new file mode 100644 index 00000000..0b60e8e8 --- /dev/null +++ b/web/tests/camera-lifecycle.test.js @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { setupPhotoFlow } from "../photo-flow.js"; +import { createTaskController } from "../task-controller.js"; + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +function camera(t, { permission = false, playback = false } = {}) { + const nodes = new Map(), timers = new Map(), statuses = []; + const acquired = deferred(), played = deferred(), detected = deferred(); + let serial = 0, stopped = 0, captures = 0; + const $ = (id) => { + if (!nodes.has(id)) nodes.set(id, { style: {}, hidden: true, setAttribute() {}, scrollIntoView() {} }); + return nodes.get(id); + }; + for (const key of ["document", "navigator", "setTimeout", "clearTimeout", "setInterval", "clearInterval"]) { + const original = Object.getOwnPropertyDescriptor(globalThis, key); + t.after(() => original ? Object.defineProperty(globalThis, key, original) : delete globalThis[key]); + } + const stream = { getTracks: () => [{ stop() { stopped++; } }] }; + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { + mediaDevices: { getUserMedia: () => acquired.promise }, + } }); + globalThis.document = { + addEventListener() {}, + createElement: () => ({ width: 0, height: 0, getContext: () => ({ drawImage() {} }) }), + }; + globalThis.setTimeout = (fn, ms) => { const id = ++serial; timers.set(id, { fn, ms }); return id; }; + globalThis.clearTimeout = (id) => timers.delete(id); + globalThis.setInterval = () => ++serial; + globalThis.clearInterval = () => {}; + Object.assign($("video"), { videoWidth: 640, videoHeight: 640, play: () => played.promise }); + $("auto-capture").checked = true; + const scanner = { cancel() {}, detect: () => detected.promise }; + const tasks = createTaskController({ $, scanner, status() {}, onStop: () => flow.stopCamera() }); + const flow = setupPhotoFlow({ + $, state: {}, scanner, stopTask: tasks.stop, + status: (text) => statuses.push(text), + invalidate: () => { captures++; }, fail: (error) => { throw error; }, + }); + if (!permission) acquired.resolve(stream); + if (!playback) played.resolve(); + return { + $, tasks, statuses, + get stopped() { return stopped; }, get captures() { return captures; }, + open: () => $("camera").onclick(), + allow: () => acquired.resolve(stream), play: () => played.resolve(), + detect: () => detected.resolve({ corners: [], rows: 4, cols: 4, confidence: .99, sharpness: 200 }), + async tick() { + const next = [...timers].find(([, timer]) => [800, 900].includes(timer.ms)); + if (!next) return; + timers.delete(next[0]); + await next[1].fn(); + }, + get pending() { return timers.size; }, + }; +} +test("cancelling while camera permission is pending stops the acquired stream", async (t) => { + const h = camera(t, { permission: true }), open = h.open(); + h.tasks.stop(); + h.allow(); + await open; + assert.equal(h.stopped, 1); + assert.equal(h.$("camera-panel").hidden, true); + assert.equal(h.statuses.includes("Camera ready."), false); +}); +test("late playback cannot announce camera readiness after editing takes over", async (t) => { + const h = camera(t, { playback: true }), open = h.open(); + await Promise.resolve(); + h.tasks.stop(); + h.play(); + await open; + assert.equal(h.statuses.includes("Camera ready."), false); + assert.equal(h.pending, 0); +}); +test("a queued camera loop cannot capture after a solve begins", async (t) => { + const h = camera(t); + await h.open(); + const id = h.tasks.begin(); + await h.tick(); + assert.equal(h.stopped, 1); + assert.equal(h.captures, 0); + assert.equal(h.tasks.id, id); + assert.equal(h.tasks.busy, true); + assert.equal(h.pending, 0); + h.tasks.finish(); +}); +test("a late camera detector cannot continue capture after cancellation", async (t) => { + const h = camera(t); + await h.open(); + const detection = h.tick(); + h.tasks.stop(); + h.detect(); + await detection; + assert.equal(h.captures, 0); + assert.equal(h.pending, 0); +}); diff --git a/web/tests/classification.test.js b/web/tests/classification.test.js new file mode 100644 index 00000000..a1660d96 --- /dev/null +++ b/web/tests/classification.test.js @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { classify } from "../model.js"; +test("Clear box geometry wins over an out-of-range OCR transcription", () => { + assert.equal( + classify({ rows: 9, cols: 9, boxes: true, values: [1, 38, 9] }).type, + "sudoku", + ); +}); +test("Structural cage and blocked-cell cues still override box geometry", () => { + assert.equal( + classify({ rows: 9, cols: 9, boxes: true, labels: 4 }).type, + "killersudoku", + ); + assert.equal( + classify({ rows: 9, cols: 9, boxes: true, black: 4 }).type, + "hidato", + ); +}); + +test("multiple centered black digits distinguish Str8ts from a stray Kakuro read", () => { + assert.equal( + classify({ rows: 9, cols: 9, black: 22, blackNumbers: 4, triangles: 1 }).type, + "str8ts", + ); + assert.equal( + classify({ rows: 9, cols: 9, black: 22, blackNumbers: 1, triangles: 4 }).type, + "kakuro", + ); +}); diff --git a/web/tests/controllers.test.js b/web/tests/controllers.test.js new file mode 100644 index 00000000..07acd96d --- /dev/null +++ b/web/tests/controllers.test.js @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createTaskController } from "../task-controller.js"; +import { captureEdit, restoreEdit, rememberEdit } from "../edit-history.js"; +import { makePuzzle } from "../model.js"; +import { prepareScan } from "../scan-analysis.js"; +test("task generations invalidate earlier jobs and finish clears deadlines", async () => { + const nodes = new Map(), + events = [], + $ = (id) => { + if (!nodes.has(id)) nodes.set(id, { setAttribute() {}, hidden: false }); + return nodes.get(id); + }; + const tasks = createTaskController({ + $, + scanner: { cancel: () => events.push("cancel") }, + status: () => {}, + onStop: (busy) => events.push(busy), + }); + const first = tasks.begin(); + assert.ok(tasks.busy); + tasks.setDeadline(() => events.push("expired"), 5); + tasks.finish(); + await new Promise((r) => setTimeout(r, 15)); + assert.ok(!events.includes("expired")); + assert.equal(tasks.busy, false); + const second = tasks.begin(); + assert.ok(second > first); + tasks.stop(); + assert.ok(tasks.id > second); + assert.equal(tasks.busy, false); +}); +test("edit snapshots detach values and restore review metadata", () => { + const state = { + puzzle: makePuzzle(), + uncertain: new Set([0]), + needsReview: true, + notes: ["review"], + puzzleSource: 7, + history: [], + selected: [0], + }; + const snapshot = captureEdit(state); + rememberEdit(state); + state.puzzle.cells[0] = 9; + state.uncertain.clear(); + assert.equal(snapshot.puzzle.cells[0], null); + restoreEdit(state, snapshot); + assert.ok(state.uncertain.has(0)); + assert.equal(state.puzzleSource, 7); + assert.equal(state.puzzle.cells[0], null); +}); +test("off-thread scan preparation handles a whole image without DOM access", () => { + const width = 100, + height = 100, + image = { + width, + height, + data: new Uint8ClampedArray(width * height * 4).fill(255), + }; + const result = prepareScan(image, "sudoku", 4, 4); + assert.equal(result.entries.length, 0); + assert.equal(result.mask.length, width * height); + assert.equal(result.g.length, width * height); + assert.equal(result.black.length, 16); +}); +test("undo retains a detached pending photo layout distinct from the board", () => { + const state = { + puzzle: makePuzzle("sudoku", 9), + layout: { rows: "4", cols: "4", boxRows: "1", boxCols: "4" }, + uncertain: new Set(), needsReview: false, notes: [], + }; + const snapshot = captureEdit(state); + state.layout.rows = "6"; + state.puzzle = makePuzzle("sudoku", 4); + restoreEdit(state, snapshot); + assert.equal(state.puzzle.rows, 9); + assert.deepEqual(state.layout, { rows: "4", cols: "4", boxRows: "1", boxCols: "4" }); +}); diff --git a/web/tests/digit-votes.test.js b/web/tests/digit-votes.test.js new file mode 100644 index 00000000..a92b30a2 --- /dev/null +++ b/web/tests/digit-votes.test.js @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { voteDigit } from "../ocr-map.js"; +import { applyDigitVotes } from "../scanner.js"; + +test("unanimous readers are confident even at low individual scores", () => { + assert.deepEqual( + voteDigit([ + { text: "5", confidence: 40 }, + { text: "5", confidence: 70 }, + { text: "5", confidence: 60 }, + ]), + { text: "5", confidence: 70, unanimous: true }, + ); + assert.deepEqual( + voteDigit([ + { text: "12", confidence: 88 }, + { text: "12", confidence: 75 }, + ]), + { text: "12", confidence: 88, unanimous: true }, + ); +}); + +test("disagreement picks the majority, then confidence, and stays flagged", () => { + assert.deepEqual( + voteDigit([ + { text: "5", confidence: 99 }, + { text: "6", confidence: 80 }, + { text: "6", confidence: 50 }, + ]), + { text: "6", confidence: 80, unanimous: false }, + ); + assert.deepEqual( + voteDigit([ + { text: "3", confidence: 60 }, + { text: "8", confidence: 90 }, + ]), + { text: "8", confidence: 90, unanimous: false }, + ); +}); + +test("a lone reading and non-digit readings never count as agreement", () => { + assert.deepEqual( + voteDigit([ + { text: "5", confidence: 99 }, + { text: "", confidence: 0 }, + { text: "", confidence: 0 }, + ]), + { text: "5", confidence: 99, unanimous: false }, + ); + assert.deepEqual( + voteDigit([{ text: "-", confidence: 90 }, { text: "", confidence: 0 }, null]), + { text: "", confidence: 0, unanimous: false }, + ); +}); + +test("votes rewrite only digits that had single readings, keeping unread atlas results", () => { + const entries = [ + { kind: "value", cell: 0, text: "5", confidence: 40 }, + { kind: "value", cell: 1, text: "3", confidence: 95 }, + { kind: "value", cell: 2, text: "", confidence: 0 }, + { kind: "label", cell: 3, text: "12+", confidence: 90 }, + ]; + applyDigitVotes(entries, [ + { index: 0, kind: "binary", text: "5", confidence: 50 }, + { index: 0, kind: "gray", text: "5", confidence: 30 }, + { index: 1, kind: "binary", text: "8", confidence: 90 }, + { index: 1, kind: "gray", text: "8", confidence: 70 }, + { index: 2, kind: "binary", text: "", confidence: 0 }, + { index: 2, kind: "gray", text: "", confidence: 0 }, + { index: 9, kind: "binary", text: "7", confidence: 99 }, + ]); + assert.deepEqual( + entries.map((e) => [e.text, e.confidence]), + [["5", 90], ["8", 0], ["", 0], ["12+", 90]], + ); +}); diff --git a/web/tests/final-hardening.test.js b/web/tests/final-hardening.test.js new file mode 100644 index 00000000..09cf557d --- /dev/null +++ b/web/tests/final-hardening.test.js @@ -0,0 +1,93 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { + makePuzzle, + checkSolveReady, + moveIndex, + hasCageRemoval, + hasInequalityRemoval, +} from "../model.js"; + +const read = (name) => + fs.readFileSync(new URL(`../${name}`, import.meta.url), "utf8"); + +test("board navigation never wraps across rows or columns", () => { + assert.equal(moveIndex(8, "ArrowRight", 9, 9), 8); + assert.equal(moveIndex(9, "ArrowLeft", 9, 9), 9); + assert.equal(moveIndex(4, "ArrowUp", 9, 9), 4); + assert.equal(moveIndex(76, "ArrowDown", 9, 9), 76); + assert.equal(moveIndex(10, "ArrowRight", 9, 9), 11); + assert.equal(moveIndex(10, "ArrowDown", 9, 9), 19); + assert.equal(moveIndex(10, "Enter", 9, 9), 10); +}); + +test("removal guards depend on puzzle state, not on editor text", () => { + const p = { cages: [{ cells: [0, 1] }], inequalities: [{ less: 4, greater: 5 }] }; + assert.equal(hasCageRemoval(p, []), false); + assert.equal(hasCageRemoval(p, [7]), false); + assert.equal(hasCageRemoval(p, [1]), true); + assert.equal(hasInequalityRemoval(p, [4]), false); + assert.equal(hasInequalityRemoval(p, [4, 6]), false); + assert.equal(hasInequalityRemoval(p, [4, 5]), true); + assert.equal(hasCageRemoval(makePuzzle("sudoku", 4), [0]), false); +}); + +test("solve-ready cages require targets and complete coverage", () => { + const p = makePuzzle("kenken", 4); + p.cages = [{ cells: [0], target: 1, op: "=" }]; + assert.throws(() => checkSolveReady(p), /cover every cell/); + p.cages = Array.from({ length: 16 }, (_, i) => ({ + cells: [i], + target: (i % 4) + 1, + op: "=", + })); + assert.doesNotThrow(() => checkSolveReady(p)); + p.cages[0].target = null; + assert.throws(() => checkSolveReady(p), /target/); + assert.doesNotThrow(() => checkSolveReady(makePuzzle("sudoku", 4))); +}); + +test("solve-ready Kakuro localizes missing and too-short runs before Python loads", () => { + const p = makePuzzle("kakuro", 3); + p.cells = ["#", "#", "#", "#", null, null, "#", null, null]; + p.clues = [ + { cell: 1, down: 4 }, + { cell: 2, down: 6 }, + { cell: 3, across: 3 }, + { cell: 6, across: 7 }, + ]; + assert.doesNotThrow(() => checkSolveReady(p)); + p.clues = p.clues.filter((q) => q.cell !== 6); + assert.throws(() => checkSolveReady(p), /exactly one across and one down run/); + const q = makePuzzle("kakuro", 2); + q.cells = ["#", null, "#", "#"]; + q.clues = [{ cell: 0, across: 1 }]; + assert.throws(() => checkSolveReady(q), /2 to 9/); +}); + +test("production handlers use the shared helpers and solve-ready gate", () => { + const source = read("app.js"); + assert.match(source, /moveIndex\(i, e\.key, state\.puzzle\.rows, state\.puzzle\.cols\)/); + assert.match(source, /hasCageRemoval\(state\.puzzle, state\.selected\)/); + assert.match(source, /hasInequalityRemoval\(state\.puzzle, state\.selected\)/); + assert.equal((source.match(/checkSolveReady\(state\.puzzle\)/g) || []).length, 2); +}); + +test("loading a board keeps the scan type preference; settings migrate to v2", () => { + const source = read("app.js"); + assert.equal(source.includes('$("puzzle-type").value = p.type'), false); + assert.match(source, /storage\.set\("gridpuzzle-settings-v2"/); + assert.match(source, /storage\.get\("gridpuzzle-settings-v1"\)/); +}); + +test("core HTML owns the safe-area and security polish without patch files", () => { + const html = read("index.html"), + css = read("style.css"); + assert.match(html, /http-equiv="Content-Security-Policy"/); + assert.match(html, /name="referrer" content="no-referrer"/); + assert.doesNotMatch(html, /accessibility\.js|polish\.css/); + assert.match(css, /safe-area-inset-top/); + assert.equal(fs.existsSync(new URL("../accessibility.js", import.meta.url)), false); + assert.equal(fs.existsSync(new URL("../polish.css", import.meta.url)), false); +}); diff --git a/web/tests/fixtures/payloads.json b/web/tests/fixtures/payloads.json new file mode 100644 index 00000000..7e3c2094 --- /dev/null +++ b/web/tests/fixtures/payloads.json @@ -0,0 +1,308 @@ +[ + { + "name": "valid empty editor", + "payload": { + "version": 1, + "type": "sudoku", + "rows": 4, + "cols": 4, + "boxRows": 2, + "boxCols": 2, + "cells": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "cages": [], + "inequalities": [], + "clues": [] + }, + "editor": true, + "solver": true + }, + { + "name": "tiny box", + "payload": { + "version": 1, + "type": "sudoku", + "rows": 4, + "cols": 4, + "boxRows": 1e-12, + "boxCols": 2, + "cells": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "cages": [], + "inequalities": [], + "clues": [] + }, + "editor": false, + "solver": false + }, + { + "name": "fractional box", + "payload": { + "version": 1, + "type": "sudoku", + "rows": 4, + "cols": 4, + "boxRows": 2, + "boxCols": 1.5, + "cells": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "cages": [], + "inequalities": [], + "clues": [] + }, + "editor": false, + "solver": false + }, + { + "name": "null box", + "payload": { + "version": 1, + "type": "sudoku", + "rows": 4, + "cols": 4, + "boxRows": null, + "boxCols": 2, + "cells": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "cages": [], + "inequalities": [], + "clues": [] + }, + "editor": false, + "solver": false + }, + { + "name": "wrong tiling", + "payload": { + "version": 1, + "type": "sudoku", + "rows": 4, + "cols": 4, + "boxRows": 3, + "boxCols": 2, + "cells": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "cages": [], + "inequalities": [], + "clues": [] + }, + "editor": false, + "solver": false + }, + { + "name": "zero rows", + "payload": { + "version": 1, + "type": "sudoku", + "rows": 0, + "cols": 4, + "boxRows": 2, + "boxCols": 2, + "cells": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "cages": [], + "inequalities": [], + "clues": [] + }, + "editor": false, + "solver": false + }, + { + "name": "huge rows", + "payload": { + "version": 1, + "type": "sudoku", + "rows": 1000000000, + "cols": 4, + "boxRows": 2, + "boxCols": 2, + "cells": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "cages": [], + "inequalities": [], + "clues": [] + }, + "editor": false, + "solver": false + }, + { + "name": "string box", + "payload": { + "version": 1, + "type": "sudoku", + "rows": 4, + "cols": 4, + "boxRows": 2, + "boxCols": "2", + "cells": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "cages": [], + "inequalities": [], + "clues": [] + }, + "editor": false, + "solver": false + }, + { + "name": "incomplete cages are editable, not solve-ready", + "payload": { + "version": 1, + "type": "killersudoku", + "rows": 4, + "cols": 4, + "boxRows": 2, + "boxCols": 2, + "cells": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "cages": [], + "inequalities": [], + "clues": [] + }, + "editor": true, + "solver": false + } +] diff --git a/web/tests/input-safety.test.js b/web/tests/input-safety.test.js new file mode 100644 index 00000000..dbf1a366 --- /dev/null +++ b/web/tests/input-safety.test.js @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { checkShape, conflicts, makePuzzle, boxShape } from "../model.js"; +import { restoreSession } from "../session.js"; +const fixtures = JSON.parse( + fs.readFileSync(new URL("./fixtures/payloads.json", import.meta.url), "utf8"), +); +for (const fixture of fixtures) + test(fixture.name, () => { + if (fixture.editor) assert.doesNotThrow(() => checkShape(fixture.payload)); + else { + assert.throws(() => checkShape(fixture.payload)); + assert.throws(() => conflicts(fixture.payload)); + assert.equal( + restoreSession({ get: () => ({ puzzle: fixture.payload }) }), + null, + ); + } + }); +test("dimensions are rejected before allocation and box calculation", () => { + for (const n of [0, -1, 1e-12, 1e9, Infinity, NaN, "9", null]) { + assert.throws(() => makePuzzle("sudoku", n)); + assert.throws(() => boxShape(n)); + } +}); diff --git a/web/tests/model.test.js b/web/tests/model.test.js new file mode 100644 index 00000000..ba2c2996 --- /dev/null +++ b/web/tests/model.test.js @@ -0,0 +1,138 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + makePuzzle, + checkShape, + conflicts, + demo, + TYPES, + classify, +} from "../model.js"; +import { + homography, + project, + warp, + validQuad, + threshold, + findGrid, + estimateGrid, +} from "../geometry.js"; + +test("All family demos have bounded, valid row-major shapes", () => { + for (const type of Object.keys(TYPES)) { + const p = demo(type); + assert.equal(checkShape(p), p); + assert.equal(p.cells.length, p.rows * p.cols); + } +}); +test("Zero is preserved only for Slitherlink", () => { + const p = makePuzzle("slitherlink", 1); + p.cells = [0]; + assert.equal(checkShape(p).cells[0], 0); + p.type = "sudoku"; + assert.throws(() => checkShape(p)); +}); +test("Bad input is rejected before rendering", () => { + for (const p of [ + null, + {}, + { ...makePuzzle(), type: "bad" }, + { ...demo(), rows: 26 }, + { ...demo(), cells: [true] }, + { ...demo(), extra: "ignored" }, + ]) + assert.throws(() => checkShape(p)); +}); +test("Duplicate clues mark BOTH cells", () => { + const p = makePuzzle("sudoku", 4); + p.cells[0] = p.cells[1] = 2; + assert.deepEqual([...conflicts(p)].sort(), [0, 1]); +}); +test("Ambiguous path rules require confirmation", () => { + const a = classify({ rows: 5, cols: 5, values: [1, 25] }); + assert.equal(a.type, "numbrix"); + assert.equal(a.review, true); +}); +test("Visible inequalities are not treated as Sudoku", () => { + assert.equal(classify({ rows: 5, cols: 5, signs: 3 }).type, "futoshiki"); +}); +test("Projective corner correspondence and affine identity", () => { + const q = [ + { x: 2, y: 3 }, + { x: 97, y: 8 }, + { x: 89, y: 94 }, + { x: 9, y: 82 }, + ], + m = homography(q); + for (const [i, [u, v]] of [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ].entries()) { + const p = project(m, u, v); + assert.ok(Math.abs(p.x - q[i].x) < 1e-7); + assert.ok(Math.abs(p.y - q[i].y) < 1e-7); + } + assert.equal(validQuad(q, 100, 100), true); + assert.equal(validQuad([q[0], q[2], q[1], q[3]], 100, 100), false); +}); +function image(w, h, value = 255) { + const data = new Uint8ClampedArray(w * h * 4).fill(value); + for (let i = 3; i < data.length; i += 4) data[i] = 255; + return { width: w, height: h, data }; +} +test("Uniform white has no false ink or confident grid", () => { + const a = image(120, 120); + assert.equal( + threshold(a).reduce((a, b) => a + b, 0), + 0, + ); + assert.equal(findGrid(a).confidence, 0); +}); +test("Warp preserves orientation", () => { + const a = image(40, 40); + for (let i = 0; i < 40 * 40; i++) { + a.data[4 * i] = i % 40; + a.data[4 * i + 1] = Math.floor(i / 40); + } + const out = warp( + a, + [ + { x: 0, y: 0 }, + { x: 39, y: 0 }, + { x: 39, y: 39 }, + { x: 0, y: 39 }, + ], + 40, + 40, + ); + assert.deepEqual(out.data, a.data); +}); +test("Synthetic connected 9x9 grid is detected", () => { + const a = image(420, 420); + for (let y = 20; y <= 398; y++) + for (let x = 20; x <= 398; x++) { + const vx = (x - 20) % 42, + vy = (y - 20) % 42; + if (vx < 2 || vy < 2) { + const i = (y * 420 + x) * 4; + a.data[i] = a.data[i + 1] = a.data[i + 2] = 0; + } + } + const found = findGrid(a); + assert.ok(found.confidence > 0.8); + assert.equal(found.rows, 9); + assert.equal(found.cols, 9); +}); + +test("Construction validates type before allocating", () => + assert.throws(() => makePuzzle("bad"))); + + +test("Str8ts black cells can be blank or numbered", () => { + const p = makePuzzle("str8ts", 3); + p.black = [4]; p.cells[4] = 3; assert.equal(checkShape(p), p); + p.cells[4] = "#"; assert.equal(checkShape(p), p); + assert.equal(classify({rows:9,cols:9,values:[9,1,4],black:12,blackNumbers:2,triangles:0,boxes:false}).type, "str8ts"); +}); diff --git a/web/tests/newspaper-analysis.test.js b/web/tests/newspaper-analysis.test.js new file mode 100644 index 00000000..54377864 --- /dev/null +++ b/web/tests/newspaper-analysis.test.js @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { detectBlackCells, prepareScan } from "../scan-analysis.js"; + +function grayscale(cells, n = 3, size = 30) { + const width = n * size, + height = n * size, + g = new Uint8Array(width * height); + for (let r = 0; r < n; r++) + for (let c = 0; c < n; c++) + for (let y = r * size; y < (r + 1) * size; y++) + for (let x = c * size; x < (c + 1) * size; x++) + g[y * width + x] = cells[r * n + c]; + return { g, width, height }; +} + +test("solid black cells survive while halftone/shadow-level grey does not", () => { + const { g, width, height } = grayscale([ + 220, 215, 205, + 190, 115, 185, + 175, 40, 180, + ]); + assert.deepEqual( + detectBlackCells(g, width, height, 3, 3), + [false, false, false, false, false, false, false, true, false], + ); +}); + +test("dim white-on-black newspaper digits still produce a Str8ts OCR region", () => { + const width = 90, height = 90, + data = new Uint8ClampedArray(width * height * 4).fill(255); + for (let y = 30; y < 60; y++) + for (let x = 30; x < 60; x++) { + const at = 4 * (y * width + x); + data[at] = data[at + 1] = data[at + 2] = 25; + } + for (let y = 37; y < 54; y++) + for (let x = 42; x < 48; x++) { + const at = 4 * (y * width + x); + data[at] = data[at + 1] = data[at + 2] = 150; + } + for (let i = 3; i < data.length; i += 4) data[i] = 255; + const result = prepareScan({ width, height, data }, "str8ts", 3, 3); + assert.equal(result.black[4], true); + assert.ok(result.entries.some((e) => e.kind === "blackvalue" && e.cell === 4)); +}); diff --git a/web/tests/ocr-map.test.js b/web/tests/ocr-map.test.js new file mode 100644 index 00000000..edd5fe26 --- /dev/null +++ b/web/tests/ocr-map.test.js @@ -0,0 +1,72 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mapAtlas, atlasLayout } from "../ocr-map.js"; +const data = (words) => ({ + blocks: [{ paragraphs: [{ lines: [{ words }] }] }], +}); +const symbol = (text, x0, x1, confidence = 98) => ({ + text, + confidence, + bbox: { x0, x1, y0: 20, y1: 90 }, +}); + +test("A whole atlas row recognized as one word still maps each digit to its cell", () => { + const word = { + ...symbol("538", 25, 310, 72), + symbols: [ + symbol("5", 25, 75), + symbol("3", 137, 188), + symbol("8", 249, 310), + ], + }; + assert.deepEqual( + mapAtlas(data([word]), 3, 3, 112).map((x) => x.text), + ["5", "3", "8"], + ); + assert.deepEqual( + mapAtlas(data([word]), 3, 3, 112).map((x) => x.confidence), + [98, 98, 98], + ); +}); +test("Multidigit and operator clues stay together INSIDE their own atlas tile", () => { + const word = { + ...symbol("12+7", 10, 180), + symbols: [ + symbol("1", 10, 26), + symbol("2", 30, 51), + symbol("+", 62, 82), + symbol("7", 138, 180), + ], + }; + assert.deepEqual( + mapAtlas(data([word]), 2, 2, 112).map((x) => x.text), + ["12+", "7"], + ); +}); +test("Unsegmented cross-tile words and crossing symbols require review", () => { + const result = mapAtlas(data([symbol("123456", 20, 210)]), 2, 2, 112); + assert.ok( + result.every((r) => r.review && r.confidence === 0 && r.text === ""), + ); +}); +test("Missing output remains unread, not guessed", () => { + assert.deepEqual(mapAtlas({}, 1, 1, 112), [ + { text: "", confidence: 0, review: false }, + ]); +}); +test("A low-confidence one-digit word remains uncertain despite a confident symbol", () => { + const word = { + ...symbol("4", 20, 80, 40), + symbols: [symbol("4", 20, 80, 99)], + }; + assert.equal(mapAtlas(data([word]), 1, 1, 112)[0].confidence, 40); +}); +test("Compact atlas layout stays within the mobile raster budget", () => { + assert.deepEqual(atlasLayout(30), { columns: 12, rows: 3, tile: 112 }); + for (const n of [81, 256, 625, 1200, 1800]) { + const a = atlasLayout(n); + assert.ok(a.columns * a.rows * a.tile * a.tile <= 8_000_000); + assert.ok(a.tile >= 64); + } + for (const n of [0, -1, NaN, 2.5, 3001]) assert.throws(() => atlasLayout(n)); +}); diff --git a/web/tests/ocr-review-regressions.test.js b/web/tests/ocr-review-regressions.test.js new file mode 100644 index 00000000..638b4211 --- /dev/null +++ b/web/tests/ocr-review-regressions.test.js @@ -0,0 +1,140 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { prepareScan } from "../scan-analysis.js"; +import { digitCrop, puzzleFromReadings } from "../scanner.js"; +import { checkShape, checkSolveReady } from "../model.js"; + +test("number crops retain two and three separate glyphs while excluding speckles", () => { + for (const glyphs of [[124, 157], [124, 145, 166]]) { + const width = 300, height = 300, + data = new Uint8ClampedArray(width * height * 4).fill(255); + const ink = (x, y, w, h) => { + for (let yy = y; yy < y + h; yy++) + for (let xx = x; xx < x + w; xx++) { + const at = 4 * (yy * width + xx); + data[at] = data[at + 1] = data[at + 2] = 0; + } + }; + glyphs.forEach((x, i) => ink(x, 125, i === 0 ? 5 : 9, 35)); + ink(116, 117, 2, 2); + ink(180, 182, 3, 3); + const { entries } = prepareScan({ width, height, data }, "numbrix", 3, 3), + clue = entries.find((e) => e.cell === 4 && e.kind === "value"); + assert.ok(clue); + assert.equal(clue.x, glyphs[0]); + assert.equal(clue.x + clue.w, glyphs.at(-1) + 9); + assert.equal(clue.y, 125); + assert.equal(clue.h, 35); + } +}); + +test("binarization retains every foreground pixel at the threshold in both polarities", () => { + const original = globalThis.document; + globalThis.document = { + createElement() { + const canvas = {}; + canvas.getContext = () => ({ + createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }), + putImageData: (pixels) => { canvas.pixels = pixels.data; }, + }); + return canvas; + }, + }; + try { + for (const [dark, light, invert] of [[0, 255, false], [25, 240, false], [0, 255, true], [25, 150, true]]) { + const g = new Uint8Array(10000).fill(invert ? dark : light); + for (let y = 28; y < 68; y++) + for (let x = 40; x < 52; x++) g[y * 100 + x] = invert ? light : dark; + const crop = digitCrop({ cell: 0, x: 40, y: 28, w: 12, h: 40, invert }, g, 100, 100, 100, 100, 1); + assert.equal(crop.pixels.filter((v, i) => i % 4 === 0 && v === 0).length, 480); + assert.ok(crop.pixels.every((v, i) => i % 4 !== 3 || v === 255)); + } + } finally { + if (original === undefined) delete globalThis.document; + else globalThis.document = original; + } +}); + +function proposal(type, entries, blackCells = []) { + return puzzleFromReadings({ + width: 300, height: 300, + mask: new Uint8Array(90000), + meta: { rows: 3, cols: 3 }, + black: Array.from({ length: 9 }, (_, i) => blackCells.includes(i)), + entries: entries.map((e) => ({ confidence: 95, ...e })), + }, type, 3, 3); +} + +test("out-of-range Str8ts black readings become editable flagged black cells", () => { + for (const text of ["0", "4", "99", "999"]) { + const result = proposal("str8ts", [ + { kind: "blackvalue", cell: 4, text }, + { kind: "value", cell: 0, text: "1" }, + ], [4]); + assert.doesNotThrow(() => checkShape(result.puzzle)); + assert.deepEqual(result.puzzle.black, [4]); + assert.equal(result.puzzle.cells[4], "#"); + assert.equal(result.puzzle.cells[0], 1); + assert.ok(result.uncertain.includes(4)); + assert.equal(result.needsReview, true); + assert.throws(() => checkShape({ ...result.puzzle, cells: result.puzzle.cells.map((v, i) => i === 4 ? +text : v) })); + } + assert.equal(proposal("str8ts", [{ kind: "blackvalue", cell: 4, text: "2" }], [4]).puzzle.cells[4], 2); +}); + +test("a bad Kakuro direction preserves other targets and can be corrected before solving", () => { + const result = proposal("kakuro", [ + { kind: "across", cell: 3, text: "99" }, + { kind: "down", cell: 1, text: "4" }, + { kind: "down", cell: 2, text: "6" }, + { kind: "across", cell: 6, text: "7" }, + ], [0, 1, 2, 3, 6]); + assert.doesNotThrow(() => checkShape(result.puzzle)); + assert.equal(result.puzzle.clues.length, 3); + assert.ok(result.uncertain.includes(3)); + assert.equal(result.needsReview, true); + assert.throws(() => checkSolveReady(result.puzzle), /incomplete/); + result.puzzle.clues.push({ cell: 3, across: 3 }); + assert.doesNotThrow(() => checkSolveReady(result.puzzle)); +}); + +test("incompatible cage OCR stays reviewable without inventing an operator or allowing Solve", () => { + for (const text of ["2-", "2/", "2÷", "2="]) { + const result = proposal("kenken", [{ kind: "label", cell: 0, text }]); + assert.doesNotThrow(() => checkShape(result.puzzle)); + assert.deepEqual(result.puzzle.cages, []); + assert.equal(result.uncertain.length, 9); + assert.equal(result.needsReview, true); + assert.ok(result.notes.some((note) => note.includes(text))); + assert.throws(() => checkSolveReady(result.puzzle), /cover every cell/); + result.puzzle.cages = [0, 1, 2].map((r) => ({ cells: [r * 3, r * 3 + 1, r * 3 + 2], target: 6, op: "+" })); + assert.doesNotThrow(() => checkSolveReady(result.puzzle)); + } +}); + +test("zero and ambiguous cage targets remain incomplete until corrected", () => { + for (const entries of [ + [{ kind: "label", cell: 0, text: "0+" }], + [{ kind: "label", cell: 0, text: "3+" }, { kind: "label", cell: 4, text: "6+" }], + ]) { + const result = proposal("kenken", entries); + assert.doesNotThrow(() => checkShape(result.puzzle)); + assert.equal(result.puzzle.cages[0].target, null); + assert.throws(() => checkSolveReady(result.puzzle), /target/); + } +}); + +test("cage OCR retains the separate reasons a cell needs review", () => { + const result = proposal("kenken", [ + { kind: "label", cell: 0, text: "12+" }, + { kind: "value", cell: 0, text: "1", confidence: 60 }, + { kind: "value", cell: 2, text: "" }, + { kind: "value", cell: 4, text: "2" }, + ]); + assert.deepEqual(result.cellUncertain, [0, 2]); + assert.equal(result.cageUncertain.length, 9); + assert.equal(result.uncertain.length, 9); + assert.equal(result.puzzle.cells[0], 1); + assert.equal(result.puzzle.cells[2], null); + assert.equal(result.puzzle.cells[4], 2); +}); diff --git a/web/tests/offline-update.test.js b/web/tests/offline-update.test.js new file mode 100644 index 00000000..474ab7fa --- /dev/null +++ b/web/tests/offline-update.test.js @@ -0,0 +1,123 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { setupOffline } from "../offline.js"; + +function environment(t, { controlled = true, waiting = true } = {}) { + const originals = ["navigator", "location", "MessageChannel"].map((key) => + [key, Object.getOwnPropertyDescriptor(globalThis, key)]); + t.after(() => { + for (const [key, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } + }); + const requests = []; + const worker = () => ({ postMessage(message, ports) { + if (ports) ports[0].respond({ done: true, ready: false }); + else requests.push(message.type); + } }); + const oldWorker = worker(), newWorker = worker(); + const registration = Object.assign(new EventTarget(), { + active: oldWorker, waiting: waiting ? newWorker : null, + installing: new EventTarget(), + }); + globalThis.MessageChannel = class { + constructor() { + this.port1 = { close() {} }; + this.port2 = { respond: (data) => queueMicrotask(() => this.port1.onmessage({ data })) }; + } + }; + return { + registration, newWorker, requests, + async tab() { + const nodes = new Map(); + const $ = (id) => { + if (!nodes.has(id)) nodes.set(id, { hidden: true, disabled: false, textContent: "" }); + return nodes.get(id); + }; + let reloads = 0; + const serviceWorker = Object.assign(new EventTarget(), { + controller: controlled ? oldWorker : null, + register: async () => registration, + ready: Promise.resolve(registration), + }); + const enter = () => { + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { serviceWorker } }); + globalThis.location = { reload: () => { reloads++; } }; + }; + enter(); + setupOffline($); + await new Promise((resolve) => setImmediate(resolve)); + return { + button: $("update-app"), + node: $, + get reloads() { return reloads; }, + click() { enter(); $("update-app").onclick(); }, + activate(next = newWorker) { + enter(); + serviceWorker.controller = next; + serviceWorker.dispatchEvent(new Event("controllerchange")); + }, + }; + }, + }; +} + +test("updating one tab leaves other tabs a working explicit reload", async (t) => { + const env = environment(t), first = await env.tab(), second = await env.tab(); + assert.equal(first.button.hidden, false); + assert.equal(second.button.hidden, false); + first.click(); + assert.deepEqual(env.requests, ["ACTIVATE"]); + env.registration.waiting = null; + env.registration.active = env.newWorker; + first.activate(); + second.activate(); + assert.equal(first.reloads, 1); + assert.equal(second.reloads, 0, "other tabs keep their unfinished work"); + assert.equal(second.button.hidden, false); + assert.equal(second.button.disabled, false); + assert.equal(second.button.textContent, "Reload updated app"); + assert.doesNotThrow(() => second.click()); + assert.equal(second.reloads, 1); + assert.deepEqual(env.requests, ["ACTIVATE"]); +}); + +test("the update click handles a worker activated before controllerchange arrives", async (t) => { + const env = environment(t), tab = await env.tab(); + env.registration.waiting = null; + env.registration.active = env.newWorker; + assert.doesNotThrow(() => tab.click()); + assert.equal(tab.reloads, 1); + assert.deepEqual(env.requests, []); +}); + +test("initial service-worker control does not request an unnecessary reload", async (t) => { + const env = environment(t, { controlled: false, waiting: false }), tab = await env.tab(); + tab.activate(env.registration.active); + assert.equal(tab.button.hidden, true); + assert.equal(tab.reloads, 0); + env.registration.waiting = env.newWorker; + env.registration.dispatchEvent(new Event("updatefound")); + env.registration.installing.dispatchEvent(new Event("statechange")); + assert.equal(tab.button.hidden, false); + assert.equal(tab.button.textContent, "Update app & reload"); + env.registration.waiting = null; + tab.activate(); + assert.equal(tab.button.textContent, "Reload updated app"); + assert.equal(tab.reloads, 0); +}); + +test("the top-of-page banner mirrors the update control", async (t) => { + const env = environment(t, { controlled: false, waiting: false }), tab = await env.tab(); + const $ = (id) => tab.node(id); + assert.equal($("update-banner").hidden, true); + env.registration.waiting = env.newWorker; + env.registration.dispatchEvent(new Event("updatefound")); + env.registration.installing.dispatchEvent(new Event("statechange")); + assert.equal($("update-banner").hidden, false); + assert.equal($("update-banner-button").textContent, "Update app & reload"); + $("update-banner-button").onclick(); + assert.deepEqual(env.requests, ["ACTIVATE"]); + assert.equal($("update-banner-button").disabled, true); +}); diff --git a/web/tests/photo-flow.test.js b/web/tests/photo-flow.test.js new file mode 100644 index 00000000..796b7199 --- /dev/null +++ b/web/tests/photo-flow.test.js @@ -0,0 +1,71 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { setupPhotoFlow } from "../photo-flow.js"; + +function deferred() { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +function photoImports(t) { + const nodes = new Map(), queue = [], errors = []; + const $ = (id) => { + if (!nodes.has(id)) nodes.set(id, { style: {}, hidden: false }); + return nodes.get(id); + }; + const originals = ["document", "Image"].map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]); + t.after(() => { + for (const [key, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } + }); + globalThis.document = { addEventListener() {} }; + globalThis.Image = class { + decode() { + const next = queue.shift(); + next.started.resolve(); + return next.decode.promise; + } + }; + let epoch = 0; + const stopTask = () => { epoch++; }; + setupPhotoFlow({ + $, state: {}, scanner: {}, stopTask, + getJobId: () => epoch, + fail: (error) => errors.push(error.message), + }); + return { + errors, stopTask, + async choose(id = "photo-file") { + const request = { started: deferred(), decode: deferred() }; + queue.push(request); + const input = { files: [new Blob(["undecodable image"])], value: "photo" }; + const completed = $(id).onchange({ target: input }); + await request.started.promise; + return { completed, reject: request.decode.reject, input }; + }, + }; +} +test("active photo decode failures still report an error", async (t) => { + const imports = photoImports(t), first = await imports.choose(); + first.reject(Error("Image cannot be decoded")); + await first.completed; + assert.deepEqual(imports.errors, ["Image cannot be decoded"]); + assert.equal(first.input.value, ""); +}); +test("cancelling a photo decode suppresses its delayed error", async (t) => { + const imports = photoImports(t), first = await imports.choose(); + imports.stopTask(); + first.reject(Error("Obsolete photo error")); + await first.completed; + assert.deepEqual(imports.errors, []); +}); +test("an older native-photo failure cannot replace a newer import error", async (t) => { + const imports = photoImports(t), first = await imports.choose("native-file"), second = await imports.choose(); + second.reject(Error("Current photo error")); + await second.completed; + first.reject(Error("Obsolete camera photo error")); + await first.completed; + assert.deepEqual(imports.errors, ["Current photo error"]); +}); diff --git a/web/tests/review-followup.test.js b/web/tests/review-followup.test.js new file mode 100644 index 00000000..9eddcf17 --- /dev/null +++ b/web/tests/review-followup.test.js @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {classify,makePuzzle,checkShape} from '../model.js'; + +test('automatically identified boxed Sudoku still requires one rules confirmation',()=>{ + const result=classify({rows:9,cols:9,boxes:true,values:[5,3,7]}); + assert.equal(result.type,'sudoku');assert.equal(result.review,true); +}); +test('browser validation rejects disconnected/overlapping/bad-arity cages early',()=>{ + const p=makePuzzle('kenken',4);p.cages=[{cells:[0,2],target:3,op:'+'}];assert.throws(()=>checkShape(p),/connected/); + p.cages=[{cells:[0,1],target:3,op:'+'},{cells:[1,2],target:4,op:'+'}];assert.throws(()=>checkShape(p),/overlap/); + p.cages=[{cells:[0,1,2],target:1,op:'-'}];assert.throws(()=>checkShape(p),/exactly two/); + p.cages=[{cells:[0,1],target:1,op:'='}];assert.throws(()=>checkShape(p),/exactly one/); +}); +test('Kakuro clue objects need at least one direction',()=>{ + const p=makePuzzle('kakuro',3);p.cells[0]='#';p.clues=[{cell:0}];assert.throws(()=>checkShape(p),/across or down/); +}); diff --git a/web/tests/scan-diagnostics.test.js b/web/tests/scan-diagnostics.test.js new file mode 100644 index 00000000..be6504ac --- /dev/null +++ b/web/tests/scan-diagnostics.test.js @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { puzzleFromReadings } from "../scanner.js"; + +function readings(entries, type = "sudoku") { + return puzzleFromReadings( + { + entries, + black: Array(81).fill(false), + meta: { boxes: true, rows: 9, cols: 9 }, + mask: new Uint8Array(900 * 900), + width: 900, + height: 900, + }, + type, + 9, + 9, + ); +} +const value = (cell, text, confidence = 90) => ({ + kind: "value", cell, text, confidence, x: 10, y: 10, w: 20, h: 40, +}); + +test("marked cells that read no digit at all produce a diagnostic note with the build", () => { + const r = readings([value(4, ""), value(7, ""), value(9, "", 0)]); + assert.match(r.notes[0], /found 3 printed marks but could not read any digit/); + assert.match(r.notes[0], /0 of 3 regions returned text; app build/); + assert.deepEqual(r.cellUncertain, [4, 7, 9]); + assert.deepEqual(r.puzzle.cells.filter(Number.isInteger), []); +}); + +test("partial or complete readings carry no diagnostic note", () => { + assert.equal(readings([value(4, "8"), value(7, ""), value(9, "")]).notes.some((n) => /could not read any digit/.test(n)), false); + assert.equal(readings([value(4, ""), value(7, "")]).notes.some((n) => /could not read any digit/.test(n)), false); +}); diff --git a/web/tests/scanner-regressions.test.js b/web/tests/scanner-regressions.test.js new file mode 100644 index 00000000..44758a79 --- /dev/null +++ b/web/tests/scanner-regressions.test.js @@ -0,0 +1,103 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + checkShape, + makePuzzle, + boxShape, + demo, + TYPES, + nextReviewCell, +} from "../model.js"; +import { isGridStroke } from "../ocr-map.js"; + +test("Only solid crop-spanning grid strokes are excluded from cage-label OCR", () => { + const bar = { + kind: "label", + width: 70, + height: 7, + ink: 470, + regionWidth: 70, + cellHeight: 100, + }; + assert.equal(isGridStroke(bar), true); + for (const change of [ + { kind: "value" }, + { kind: "hsign" }, + { height: 20 }, + { width: 25 }, + { ink: 200 }, + ]) + assert.equal(isGridStroke({ ...bar, ...change }), false); +}); +test("Invalid dimensions are rejected before allocating a board or finding box factors", () => { + for (const bad of [0, -1, 1.5, 26, 1e12, NaN, Infinity, "9", true]) { + assert.throws(() => makePuzzle("sudoku", bad)); + assert.throws(() => makePuzzle("sudoku", 9, bad)); + assert.throws(() => boxShape(bad)); + } +}); +test("Every family demo still passes render-boundary validation", () => { + for (const type of Object.keys(TYPES)) + assert.equal(checkShape(demo(type)).type, type); +}); +test("Imports cannot use fractional, zero or huge steps for box rendering", () => { + for (const key of ["boxRows", "boxCols"]) + for (const value of [0, -1, 1e-12, NaN, Infinity, 26, true, "3"]) + assert.throws(() => checkShape({ ...demo(), [key]: value })); + assert.throws(() => checkShape({ ...demo(), boxRows: 2 })); + assert.doesNotThrow(() => checkShape(makePuzzle("sudoku", 4))); +}); +test("Nested arrays and clue fields are validated before rendering", () => { + const p = makePuzzle("kenken", 4); + for (const cage of [ + { cells: Array(10000).fill(0), target: 4 }, + { cells: [0, 0], target: 4 }, + { cells: [0], target: "4" }, + { cells: [0], target: 4, op: "unknown" }, + { cells: [0], target: 4, extra: true }, + ]) + assert.throws(() => checkShape({ ...p, cages: [cage] })); + assert.doesNotThrow(() => + checkShape({ ...p, cages: [{ cells: [0], target: null, op: "+" }] }), + ); + assert.throws(() => + checkShape({ ...demo(), cages: [{ cells: [0], target: 1 }] }), + ); + assert.throws(() => + checkShape({ + ...makePuzzle("futoshiki", 4), + inequalities: [{ less: 3, greater: 4 }], + }), + ); + assert.throws(() => + checkShape({ ...demo("kakuro"), clues: [{ cell: 4, across: 7 }] }), + ); +}); +test("Review visits remaining cells in board order and wraps without confirming skipped cells", () => { + const pending = new Set([9, 0, 4]); + assert.equal(nextReviewCell(pending), 0); + assert.equal(nextReviewCell(pending, 0), 4); + assert.equal(nextReviewCell(pending, 9), 0); + assert.equal(pending.size, 3); + assert.equal(nextReviewCell([]), null); +}); + +test("Faint grid corners are rejected without suppressing sparse real label text", () => { + const corner = { + kind: "label", + width: 42, + height: 20, + ink: 100, + edgeInk: 95, + regionWidth: 70, + cellHeight: 100, + }; + assert.equal(isGridStroke(corner), true); + for (const change of [ + { edgeInk: 60 }, + { width: 15 }, + { height: 35 }, + { kind: "value" }, + ]) + assert.equal(isGridStroke({ ...corner, ...change }), false); +}); diff --git a/web/tests/session.test.js b/web/tests/session.test.js new file mode 100644 index 00000000..01880786 --- /dev/null +++ b/web/tests/session.test.js @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { makePuzzle } from "../model.js"; +import { saveSession, restoreSession } from "../session.js"; +import { captureEdit, restoreEdit } from "../edit-history.js"; +function store() { + const data = new Map(); + return { get: (k) => data.get(k), set: (k, v) => data.set(k, v), data }; +} +test("Reload preserves uncertain clues and rule confirmation without saving photographs", () => { + const storage = store(), + p = makePuzzle(); + p.cells[0] = 7; + saveSession(storage, { + puzzle: p, + uncertain: new Set([0, 3]), + needsReview: true, + notes: ["Check type"], + photo: { private: "IMAGE DATA" }, + result: { status: "unique" }, + }); + const restored = restoreSession(storage); + assert.deepEqual(restored.uncertain, [0, 3]); + assert.equal(restored.needsReview, true); + assert.equal(restored.puzzle.cells[0], 7); + const raw = JSON.stringify([...storage.data.values()]); + assert.ok(!raw.includes("IMAGE DATA")); + assert.ok(!raw.includes("unique")); +}); +test("Confirmed clues stay confirmed", () => { + const storage = store(); + saveSession(storage, { + puzzle: makePuzzle(), + uncertain: new Set(), + needsReview: false, + notes: [], + }); + assert.equal(restoreSession(storage).needsReview, false); +}); +test("Metadata is bounded and cannot reference nonexistent cells", () => { + const storage = store(); + storage.set("gridpuzzle-session-v1", { + puzzle: makePuzzle(), + uncertain: [0, 0, -1, 900, true, "2"], + notes: [false, "ok"], + needsReview: false, + }); + assert.deepEqual(restoreSession(storage).uncertain, [0]); + assert.deepEqual(restoreSession(storage).notes, ["ok"]); + assert.equal(restoreSession(storage).needsReview, true); +}); +test("Existing data-only autosaves migrate without executing any content", () => { + const storage = store(); + storage.set("gridpuzzle-puzzle-v1", makePuzzle()); + assert.equal(restoreSession(storage).puzzle.type, "sudoku"); + storage.set("gridpuzzle-puzzle-v1", { type: "__import__" }); + assert.equal(restoreSession(storage), null); +}); +test("cage and cell warnings remain independent through autosave and undo", () => { + const storage = store(), state = { + puzzle: makePuzzle("killersudoku", 4), + uncertain: new Set([0, 2]), + cageUncertain: new Set([0, 1]), + needsReview: true, + notes: [], + puzzleSource: 8, + }; + const before = captureEdit(state); + state.cageUncertain.delete(0); + saveSession(storage, state); + const restored = restoreSession(storage); + assert.deepEqual(restored.uncertain, [0, 2]); + assert.deepEqual(restored.cageUncertain, [1]); + // Older versions still see every warning in their combined list. + assert.deepEqual(storage.get("gridpuzzle-session-v1").uncertain, [0, 2, 1]); + state.uncertain.clear(); + restoreEdit(state, before); + assert.deepEqual([...state.uncertain], [0, 2]); + assert.deepEqual([...state.cageUncertain], [0, 1]); +}); +test("legacy cage sessions retain ambiguous flags as cell warnings", () => { + const storage = store(); + storage.set("gridpuzzle-session-v1", { + puzzle: makePuzzle("killersudoku", 4), + uncertain: [0, 1], + needsReview: true, + }); + assert.deepEqual(restoreSession(storage).uncertain, [0, 1]); + assert.deepEqual(restoreSession(storage).cageUncertain, []); +}); diff --git a/web/tests/solver-update.test.js b/web/tests/solver-update.test.js new file mode 100644 index 00000000..77e6ca18 --- /dev/null +++ b/web/tests/solver-update.test.js @@ -0,0 +1,145 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; +import { webcrypto, createHash } from "node:crypto"; + +const source = fs.readFileSync(new URL("../sw.js", import.meta.url), "utf8"); +const scope = "https://example.test/GridPuzzle/"; +const prefix = `gridpuzzle:${scope}:`; +function updates({ identical = false } = {}) { + const stores = new Map(), requests = []; + let build = null, clients = [], offline = false; + const body = (version) => identical ? "same solver" : `solver ${version}`; + const assets = (version) => [{ + path: `solver.${version}.zip`, + sha256: createHash("sha256").update(body(version)).digest("hex"), + }]; + const key = (request) => typeof request === "string" ? request : request.url; + const caches = { + open: async (name) => { + if (!stores.has(name)) { + const entries = new Map(); + stores.set(name, { + match: async (request) => entries.get(key(request))?.clone(), + put: async (request, response) => { entries.set(key(request), response.clone()); }, + delete: async (request) => entries.delete(key(request)), + keys: async () => [...entries.keys()].map((url) => new Request(url)), + }); + } + return stores.get(name); + }, + keys: async () => [...stores.keys()], + delete: async (name) => stores.delete(name), + }; + const network = async (request) => { + const path = request.url.slice(scope.length); + requests.push(path); + if (offline) throw TypeError("Network offline"); + if (path === "assets.json") return new Response(JSON.stringify({ build, assets: assets(build) })); + return path === `solver.${build}.zip` + ? new Response(body(build)) : new Response("Not found", { status: 404 }); + }; + return { + stores, requests, + offline(value) { offline = value; }, + workers(ids) { clients = ids.map((id) => ({ id, url: scope + "solver-worker.js" })); }, + tabs(ids) { clients = ids.map((id) => ({ id, type: "window", url: scope })); }, + async activate(version, installed) { + build = version; + const listeners = {}; + vm.runInNewContext(source.replace("__BUILD_ID__", version), { + URL, Request, Response, Uint8Array, crypto: webcrypto, caches, fetch: network, + self: { + registration: { scope }, location: { origin: new URL(scope).origin }, + clients: { claim: async () => {}, matchAll: async () => clients }, + skipWaiting() {}, addEventListener: (type, listener) => { listeners[type] = listener; }, + }, + }); + const fetch = async (path) => { + let response; + listeners.fetch({ request: new Request(scope + path), respondWith(promise) { response = promise; } }); + return response; + }; + for (const type of ["install", "activate"]) { + if (type === "activate" && installed) await installed(fetch); + let done; + listeners[type]({ waitUntil(promise) { done = promise; } }); + await done; + } + return fetch; + }, + }; +} +const first = "111111111111", second = "222222222222", third = "333333333333", fourth = "444444444444"; +test("an old archive resolves before the new controller's activation migration finishes", async () => { + const h = updates(); + await h.activate(first); + h.workers(["old-worker"]); + await h.activate(second, async (fetch) => { + const count = h.requests.length; + assert.equal((await fetch(`solver.${first}.zip`)).status, 200); + assert.equal(h.requests.length, count); + }); +}); +for (const identical of [false, true]) + test(`an initializing old solver keeps its archive after activation (${identical ? "reused" : "changed"} bytes)`, async () => { + const h = updates({ identical }); + const oldFetch = await h.activate(first); + h.workers(["old-worker"]); + const fetch = await h.activate(second); + const count = h.requests.length; + assert.equal((await fetch(`solver.${first}.zip`)).status, 200); + assert.equal((await oldFetch(`solver.${first}.zip`)).status, 200, "an old controller can still route an existing worker"); + h.offline(true); + assert.equal((await fetch(`solver.${first}.zip`)).status, 200); + assert.equal((await fetch(`solver.${second}.zip`)).status, 200); + assert.equal(h.requests.length, count, "all archives come from verified storage"); + assert.equal(h.stores.has(prefix + `meta:${first}`), true, "keep the manifest for an existing worker's previous controller"); + }); +test("later updates retain live owners without extending closed workers' archive lifetimes", async () => { + const h = updates(); + await h.activate(first); + h.workers(["old-worker"]); + await h.activate(second); + h.workers(["old-worker", "new-worker"]); + let fetch = await h.activate(third); + assert.equal((await fetch(`solver.${first}.zip`)).status, 200); + h.workers(["new-worker"]); + fetch = await h.activate(fourth); + assert.equal((await fetch(`solver.${first}.zip`)).status, 404, "a newer worker cannot keep an obsolete archive alive"); + assert.equal((await fetch(`solver.${third}.zip`)).status, 200); + const content = h.stores.get(prefix + "content-v1"); + const oldDigest = createHash("sha256").update(`solver ${first}`).digest("hex"); + assert.equal(await content.match(scope + ".gridpuzzle-cache/" + oldDigest), undefined); + assert.equal(h.stores.has(prefix + `meta:${first}`), false); +}); +test("an update with no existing solver clients releases previous archives", async () => { + const h = updates(); + await h.activate(first); + const fetch = await h.activate(second); + assert.equal((await fetch(`solver.${first}.zip`)).status, 404); + assert.equal((await h.stores.get(prefix + "content-v1").keys()).length, 1); +}); +test("an existing tab protects a worker that is not enumerable yet", async () => { + const h = updates(); + await h.activate(first); + h.tabs(["old-tab"]); + let fetch = await h.activate(second); + h.offline(true); + assert.equal((await fetch(`solver.${first}.zip`)).status, 200); + h.offline(false); + h.tabs(["new-tab"]); + fetch = await h.activate(third); + assert.equal((await fetch(`solver.${first}.zip`)).status, 404); +}); +test("damaged previous metadata cannot prevent a verified update from activating", async () => { + const h = updates(); + await h.activate(first); + const old = h.stores.get(prefix + `meta:${first}`); + await old.put(scope + "assets.json", new Response("invalid JSON")); + await old.put(scope + ".retained-solvers.json", new Response("{}")); + h.workers(["old-worker"]); + const fetch = await h.activate(second); + assert.equal((await fetch(`solver.${second}.zip`)).status, 200); +}); diff --git a/web/tests/worker-lifecycle.test.js b/web/tests/worker-lifecycle.test.js new file mode 100644 index 00000000..2da4668b --- /dev/null +++ b/web/tests/worker-lifecycle.test.js @@ -0,0 +1,142 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; +import { Scanner } from "../scanner.js"; +import { gray, threshold, thresholdGray } from "../geometry.js"; + +test("OCR cancellation terminates a child before initialization resolves", async () => { + let child, + terminated = 0; + const messages = []; + class Worker { + constructor() { + child = this; + } + terminate() { + terminated++; + } + } + const self = { + Worker, + location: { href: "https://example.test/GridPuzzle/ocr-host-worker.js" }, + postMessage: (m) => messages.push(m), + close() {}, + }; + const context = vm.createContext({ + self, + URL, + Uint8Array, + importScripts() { + self.Tesseract = { + createWorker: () => { + new self.Worker(); + return new Promise(() => {}); + }, + }; + }, + }); + vm.runInContext( + fs.readFileSync(new URL("../ocr-host-worker.js", import.meta.url), "utf8"), + context, + ); + void self.onmessage({ data: { png: new ArrayBuffer(0) } }); + assert.ok(child); + await self.onmessage({ data: { cancel: true } }); + assert.equal(terminated, 1); + assert.equal(messages.at(-1).cancelled, true); +}); +test("scan cancellation rejects a pending worker request immediately", async () => { + const old = globalThis.Worker; + let instance; + globalThis.Worker = class { + constructor() { + instance = this; + } + postMessage(m) { + this.last = m; + } + terminate() { + this.stopped = true; + } + }; + try { + const scanner = new Scanner(), + promise = scanner._request("ocr-host-worker.js", {}, () => {}, "classic"); + scanner.cancel(); + await assert.rejects(promise, { name: "AbortError" }); + assert.equal(scanner.jobs.size, 0); + assert.equal(instance.last.cancel, true); + instance.onmessage({ data: { cancelled: true } }); + assert.ok(instance.stopped); + } finally { + globalThis.Worker = old; + } +}); +test("shared grayscale threshold is byte-for-byte identical", () => { + const image = { + width: 41, + height: 37, + data: Uint8ClampedArray.from( + { length: 41 * 37 * 4 }, + (_, i) => (i * 71) % 256, + ), + }; + assert.deepEqual( + threshold(image), + thresholdGray(gray(image), image.width, image.height), + ); +}); + +test("late OCR progress is not a cancellation acknowledgement", async () => { + const old = globalThis.Worker; + let instance; + globalThis.Worker = class { + constructor() { + instance = this; + } + postMessage() {} + terminate() { + this.stopped = true; + } + }; + try { + const scanner = new Scanner(), + promise = scanner._request("ocr-host-worker.js", {}, () => {}, "classic"); + scanner.cancel(); + await assert.rejects(promise, { name: "AbortError" }); + instance.onmessage({ data: { type: "progress", progress: 0.5 } }); + assert.ok(!instance.stopped); + instance.onmessage({ data: { cancelled: true } }); + assert.ok(instance.stopped); + } finally { + globalThis.Worker = old; + } +}); +test("postMessage failure cannot retain workers or mask the original error", async () => { + const old = globalThis.Worker, + original = new DOMException("cannot clone", "DataCloneError"); + let instance; + globalThis.Worker = class { + constructor() { + instance = this; + } + postMessage() { + throw original; + } + terminate() { + this.stopped = true; + } + }; + try { + const scanner = new Scanner(); + await assert.rejects( + scanner._request("ocr-host-worker.js", {}, () => {}, "classic"), + (e) => e === original, + ); + assert.equal(scanner.jobs.size, 0); + assert.ok(instance.stopped); + } finally { + globalThis.Worker = old; + } +});