From a1ae0bf15bd434424e7fb1da3e046d276463ea3c Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 15 Sep 2026 11:12:35 +0200 Subject: [PATCH 1/2] Extract deal-file parsers from dds_web.js into dds_web_deal_import.js. Separates PBN/LIN/DLM/sol loading from UI and solver glue so the main page script stays focused (#382). Co-authored-by: Cursor --- specs/web.md | 13 +- web/BUILD.bazel | 3 + web/dds_web.html | 1 + web/dds_web.js | 341 +------------------------ web/dds_web_deal_import.js | 357 +++++++++++++++++++++++++++ web/stage_github_pages.py | 1 + web/tests/dds_web_test.mjs | 54 +++- web/tests/test_dds_web_js.py | 2 + web/tests/test_stage_github_pages.py | 3 + web/tests/test_web_html.py | 16 ++ web/tests/web_site.py | 1 + 11 files changed, 442 insertions(+), 350 deletions(-) create mode 100644 web/dds_web_deal_import.js diff --git a/specs/web.md b/specs/web.md index 2e466b05a..838d32bc8 100644 --- a/specs/web.md +++ b/specs/web.md @@ -33,10 +33,12 @@ DOM wiring) with an automated test pyramid. callable surface for the DD table and opening-lead analysis, not a CLI. Shared base flags come from `WASM_LINKOPTS` ([build-system](build-system.md)), including pthreads / `PTHREAD_POOL_SIZE`. -- **The page is a static trio plus JS glue.** `dds_web.html` / `dds_web.css` / - `dds_web.js` load the module (`createDdsModule`), marshal a deal into WASM - memory, call `dds_web_calc_table` and `dds_web_solve_leads` via `ccall`, and - read results with `getValue`. `web_site` (`tests/web_site.py`) stages the site +- **The page is a static site plus JS glue.** `dds_web.html` / `dds_web.css` / + `dds_web.js` / `dds_web_deal_import.js` load the module (`createDdsModule`), + marshal a deal into WASM memory, call `dds_web_calc_table` and + `dds_web_solve_leads` via `ccall`, and read results with `getValue`. Deal-file + parsers (PBN/LIN/DLM/sol) live in `dds_web_deal_import.js`; UI and solver glue + stay in `dds_web.js`. `web_site` (`tests/web_site.py`) stages the site and provides `make_isolated_http_handler` (COOP/COEP) for system/e2e tests and `web/serve_web.py`. Helper scripts `gen_wasm_bin_js.py`, `patch_web_wasm.py`, `verify_wasm_js.py` generate and sanity-check the JS/wasm glue. @@ -91,7 +93,8 @@ DOM wiring) with an automated test pyramid. `web_tests` / `web_system_tests` / `web_e2e_tests` suites; `WASM_WEB_LINKOPTS`. - `web/dds_web_wasm.cpp` — the native WASM bridge (`dds_web_calc_table`, `dds_web_solve_leads`). -- `web/{dds_web.html,dds_web.css,dds_web.js}` — the page and JS glue. +- `web/{dds_web.html,dds_web.css,dds_web.js,dds_web_deal_import.js}` — the page, + deal-file parsers, and JS glue. - `web/coi-serviceworker.js` — COOP/COEP via service worker for hosts without custom headers (GitHub Pages). - `web/stage_github_pages.py` — stage static site + `index.html` for Pages. diff --git a/web/BUILD.bazel b/web/BUILD.bazel index 51237dbb5..35ea674fc 100644 --- a/web/BUILD.bazel +++ b/web/BUILD.bazel @@ -104,6 +104,7 @@ py_test( srcs = ["tests/test_dds_web_js.py"], data = [ "dds_web.js", + "dds_web_deal_import.js", "tests/dds_web_test.mjs", ], ) @@ -121,6 +122,7 @@ py_test( "dds_web.css", "dds_web.html", "dds_web.js", + "dds_web_deal_import.js", "gen_wasm_bin_js.py", "patch_web_wasm.py", "tests/dds_web_wasm_node.mjs", @@ -143,6 +145,7 @@ py_test( "dds_web.css", "dds_web.html", "dds_web.js", + "dds_web_deal_import.js", "gen_wasm_bin_js.py", "patch_web_wasm.py", ], diff --git a/web/dds_web.html b/web/dds_web.html index cc6805756..53033d5be 100644 --- a/web/dds_web.html +++ b/web/dds_web.html @@ -147,6 +147,7 @@

+ diff --git a/web/dds_web.js b/web/dds_web.js index 13d9afd79..ec3f9dde4 100644 --- a/web/dds_web.js +++ b/web/dds_web.js @@ -4,6 +4,7 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT +// Deal-file parsers: web/dds_web_deal_import.js (load before this file). // Unit tests: web/tests/dds_web_test.mjs // Run with: bazelisk test //web:dds_web_js_test // or: python -m unittest web.tests.test_dds_web_js @@ -68,7 +69,6 @@ leadTricksMapFromSolverOutput wasmSolveEnvironmentError formatSolveTimeMs - parseFirstDealFromText importDealFromText chooseDealFile handleDealFileSelected @@ -399,343 +399,8 @@ function fillFormWithTestData(nesw) { updateActionButtons(); } -const SUIT_LETTERS = ["S", "H", "D", "C"]; -const DIR_FROM_LETTER = { N: "north", E: "east", S: "south", W: "west" }; -const LIN_HAND_ORDER = ["south", "west", "north", "east"]; - -/** Sort pips high-to-low using the diagram's pip order. */ -function sortPips(holding) { - return String(holding) - .toUpperCase() - .split("") - .filter((pip) => PIPS.includes(pip)) - .sort((a, b) => PIPS.indexOf(a) - PIPS.indexOf(b)) - .join(""); -} - -/** - * True when a dotted hand has exactly four suit components of legal ranks only. - * A lone "-" is accepted as the PBN void-suit marker. Does not pad or truncate. - */ -function isValidRawHandHolding(dotted) { - const parts = String(dotted).split("."); - if (parts.length !== 4) { - return false; - } - for (const part of parts) { - if (part === "" || part === "-") { - continue; - } - for (const ch of part) { - if (!PIPS.includes(ch.toUpperCase())) { - return false; - } - } - } - return true; -} - -function normalizeHandHolding(dotted) { - if (!isValidRawHandHolding(dotted)) { - throw new Error("Deal has a malformed hand holding."); - } - return String(dotted) - .split(".") - .map((part) => (part === "-" ? "" : sortPips(part))) - .join("."); -} - -function emptySuitHoldings() { - return { S: "", H: "", D: "", C: "" }; -} - -function holdingsToDotted(holdings) { - return SUIT_LETTERS.map((suit) => sortPips(holdings[suit] || "")).join("."); -} - -/** True when the four hands are 13 cards each with no duplicates (full deck). */ -function dealHasUniqueCards(deal) { - const seen = {}; - for (const direction of DIRECTIONS) { - const holding = deal[direction]; - if (!holding) { - return false; - } - const parts = holding.split("."); - let count = 0; - for (let i = 0; i < 4; i++) { - const suit = SUIT_LETTERS[i]; - for (const pip of (parts[i] || "").toUpperCase()) { - if (!PIPS.includes(pip)) { - return false; - } - const key = suit + pip; - if (seen[key]) { - return false; - } - seen[key] = true; - count += 1; - } - } - if (count !== 13) { - return false; - } - } - return Object.keys(seen).length === 52; -} - -function dealFromDirectionMap(byDirection) { - const deal = {}; - for (const direction of DIRECTIONS) { - if (!byDirection[direction]) { - return null; - } - deal[direction] = normalizeHandHolding(byDirection[direction]); - if (deal[direction].replace(/\./g, "").length !== 13) { - return null; - } - } - if (!dealHasUniqueCards(deal)) { - throw new Error("Deal has duplicated cards."); - } - return deal; -} - -function completeMissingHand(byDirection) { - const present = DIRECTIONS.filter((direction) => byDirection[direction]); - if (present.length === 4) { - return byDirection; - } - if (present.length !== 3) { - return null; - } - - const used = {}; - for (const direction of present) { - const parts = byDirection[direction].split("."); - for (let i = 0; i < 4; i++) { - for (const pip of parts[i] || "") { - used[SUIT_LETTERS[i] + pip.toUpperCase()] = true; - } - } - } - - const missing = DIRECTIONS.find((direction) => !byDirection[direction]); - const holdings = emptySuitHoldings(); - for (let i = 0; i < 4; i++) { - const suit = SUIT_LETTERS[i]; - for (const pip of PIPS) { - if (!used[suit + pip]) { - holdings[suit] += pip; - } - } - } - byDirection[missing] = holdingsToDotted(holdings); - return byDirection; -} - -/** - * Parse a PBN remainCards string such as "N:AKQ.... ..." into NESW holdings. - * Later hands are clockwise from the first seat letter; no extra seat letters. - */ -function parsePbnDealString(raw) { - const text = String(raw).trim(); - const match = /^([NESWnesw]):\s*(.+)$/.exec(text); - if (!match) { - return null; - } - - const start = match[1].toUpperCase(); - const hands = match[2].trim().split(/\s+/).filter(Boolean); - if (hands.length !== 4) { - return null; - } - - const startIndex = "NESW".indexOf(start); - const byDirection = {}; - for (let i = 0; i < 4; i++) { - const direction = DIR_FROM_LETTER["NESW"[(startIndex + i) % 4]]; - const holding = normalizeHandHolding(hands[i]); - if (holding.replace(/\./g, "").length !== 13) { - return null; - } - byDirection[direction] = holding; - } - return dealFromDirectionMap(byDirection); -} - -function parseLinHand(raw) { - const holdings = emptySuitHoldings(); - let suit = null; - for (const ch of String(raw)) { - const upper = ch.toUpperCase(); - if (SUIT_LETTERS.includes(upper)) { - suit = upper; - continue; - } - if (PIPS.includes(upper)) { - if (!suit) { - throw new Error("LIN hand has a rank before a suit."); - } - holdings[suit] += upper; - continue; - } - throw new Error("LIN hand has illegal characters."); - } - return holdingsToDotted(holdings); -} - -function parseLinDealPayload(payload) { - // md|,,,[] - // BBO lists hands in fixed South, West, North, East order. The leading - // digit is only the dealer (1=South, 2=West, 3=North, 4=East); it does not - // rotate which hand comes first in the list. - const body = String(payload).replace(/^[1-4]/, ""); - const parts = body.split(","); - // Trailing commas are common in BBO exports; ignore empty trailing slots. - while (parts.length && !String(parts[parts.length - 1]).trim()) { - parts.pop(); - } - if (parts.length < 3 || parts.length > 4) { - return null; - } - - const byDirection = {}; - for (let i = 0; i < 4; i++) { - const raw = (parts[i] || "").trim(); - if (!raw) { - continue; - } - byDirection[LIN_HAND_ORDER[i]] = parseLinHand(raw); - } - const completed = completeMissingHand(byDirection); - return completed ? dealFromDirectionMap(completed) : null; -} - -function parseDlmBoardPayload(letters) { - // 26 letters a-p; each encodes owners of a fixed high/low card pair. - const pairs = [ - ["SA", "SK"], ["SQ", "SJ"], ["ST", "S9"], ["S8", "S7"], - ["S6", "S5"], ["S4", "S3"], ["S2", "HA"], - ["HK", "HQ"], ["HJ", "HT"], ["H9", "H8"], ["H7", "H6"], - ["H5", "H4"], ["H3", "H2"], - ["DA", "DK"], ["DQ", "DJ"], ["DT", "D9"], ["D8", "D7"], - ["D6", "D5"], ["D4", "D3"], ["D2", "CA"], - ["CK", "CQ"], ["CJ", "CT"], ["C9", "C8"], ["C7", "C6"], - ["C5", "C4"], ["C3", "C2"], - ]; - const firstOwner = "NNNNEEEESSSSWWWW"; - const secondOwner = "NESWNESWNESWNESW"; - const byDirection = { - north: emptySuitHoldings(), - east: emptySuitHoldings(), - south: emptySuitHoldings(), - west: emptySuitHoldings(), - }; - - for (let i = 0; i < 26; i++) { - const code = letters.charCodeAt(i) - "a".charCodeAt(0); - if (code < 0 || code > 15) { - return null; - } - const [firstCard, secondCard] = pairs[i]; - const firstDirection = DIR_FROM_LETTER[firstOwner.charAt(code)]; - byDirection[firstDirection][firstCard.charAt(0)] += firstCard.charAt(1); - const secondDirection = DIR_FROM_LETTER[secondOwner.charAt(code)]; - byDirection[secondDirection][secondCard.charAt(0)] += secondCard.charAt(1); - } - - return dealFromDirectionMap({ - north: holdingsToDotted(byDirection.north), - east: holdingsToDotted(byDirection.east), - south: holdingsToDotted(byDirection.south), - west: holdingsToDotted(byDirection.west), - }); -} - -/** - * Parse a sol*.txt style line: four NESW holdings, optional ":results" suffix. - * Example: T5.K4.652.A98542 K6.... AQJ987.8532.84.K:6565... - * An optional leading board number ("1. ") is accepted and ignored. - */ -function parseSolStyleDealLine(line) { - let beforeColon = String(line).split(":")[0].trim(); - if (!beforeColon) { - return null; - } - beforeColon = beforeColon.replace(/^\d+\.\s+/, ""); - const hands = beforeColon.split(/\s+/).filter(Boolean); - if (hands.length !== 4) { - return null; - } - for (const hand of hands) { - if ((hand.match(/\./g) || []).length !== 3) { - return null; - } - } - return parsePbnDealString("N:" + hands.join(" ")); -} - -/** - * Extract the first deal from PBN, LIN, DLM, dtest, or sol-style .txt content. - * @returns {{north:string,east:string,south:string,west:string}} - */ -function parseFirstDealFromText(text) { - const source = String(text == null ? "" : text); - - const pbnTag = /\[Deal\s+"([^"]+)"\s*\]/i.exec(source); - if (pbnTag) { - const deal = parsePbnDealString(pbnTag[1]); - if (deal) { - return deal; - } - } - - const dtestLine = /^PBN\s+\d+\s+\d+\s+\d+\s+\d+\s+"([^"]+)"/im.exec(source); - if (dtestLine) { - const deal = parsePbnDealString(dtestLine[1]); - if (deal) { - return deal; - } - } - - const linMatch = /\bmd\|([^|]+)/i.exec(source); - if (linMatch) { - const deal = parseLinDealPayload(linMatch[1]); - if (deal) { - return deal; - } - } - - const dlmMatch = /Board\s*\d+\s*=\s*([a-p]{26})/i.exec(source); - if (dlmMatch) { - const deal = parseDlmBoardPayload(dlmMatch[1].toLowerCase()); - if (deal) { - return deal; - } - } - - // Bare PBN remainCards line (no tag). - const bare = /^\s*([NESWnesw]:[^\n\r"]+)/m.exec(source); - if (bare) { - const deal = parsePbnDealString(bare[1].trim()); - if (deal) { - return deal; - } - } - - // sol10.txt-style: four NESW holdings, optional CalcTable suffix after ':'. - for (const line of source.split(/\r?\n/)) { - const deal = parseSolStyleDealLine(line); - if (deal) { - return deal; - } - } - - throw new Error( - "No PBN, LIN, DLM, dtest, or sol-style deal found in the file." - ); -} +// Deal-format parsers (PBN/LIN/DLM/sol) live in dds_web_deal_import.js and +// must be loaded first; importDealFromText uses global parseFirstDealFromText. function importDealFromText(text) { try { diff --git a/web/dds_web_deal_import.js b/web/dds_web_deal_import.js new file mode 100644 index 000000000..46f10c74a --- /dev/null +++ b/web/dds_web_deal_import.js @@ -0,0 +1,357 @@ +// Copyright 2020-2026 Adam Wildavsky +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT + +// Pure PBN / LIN / DLM / dtest / sol-style deal parsers for DDS Web. +// Loaded before dds_web.js; exports parseFirstDealFromText on globalThis. + +/* eslint-env es6 */ +/* exported parseFirstDealFromText */ + +"use strict"; + +(function (global) { + const DIRECTIONS = ["north", "east", "south", "west"]; + const PIPS = "AKQJT98765432"; + const SUIT_LETTERS = ["S", "H", "D", "C"]; + const DIR_FROM_LETTER = { N: "north", E: "east", S: "south", W: "west" }; + const LIN_HAND_ORDER = ["south", "west", "north", "east"]; + + /** Sort pips high-to-low using the diagram's pip order. */ + function sortPips(holding) { + return String(holding) + .toUpperCase() + .split("") + .filter((pip) => PIPS.includes(pip)) + .sort((a, b) => PIPS.indexOf(a) - PIPS.indexOf(b)) + .join(""); + } + + /** + * True when a dotted hand has exactly four suit components of legal ranks only. + * A lone "-" is accepted as the PBN void-suit marker. Does not pad or truncate. + */ + function isValidRawHandHolding(dotted) { + const parts = String(dotted).split("."); + if (parts.length !== 4) { + return false; + } + for (const part of parts) { + if (part === "" || part === "-") { + continue; + } + for (const ch of part) { + if (!PIPS.includes(ch.toUpperCase())) { + return false; + } + } + } + return true; + } + + function normalizeHandHolding(dotted) { + if (!isValidRawHandHolding(dotted)) { + throw new Error("Deal has a malformed hand holding."); + } + return String(dotted) + .split(".") + .map((part) => (part === "-" ? "" : sortPips(part))) + .join("."); + } + + function emptySuitHoldings() { + return { S: "", H: "", D: "", C: "" }; + } + + function holdingsToDotted(holdings) { + return SUIT_LETTERS.map((suit) => sortPips(holdings[suit] || "")).join("."); + } + + /** True when the four hands are 13 cards each with no duplicates (full deck). */ + function dealHasUniqueCards(deal) { + const seen = {}; + for (const direction of DIRECTIONS) { + const holding = deal[direction]; + if (!holding) { + return false; + } + const parts = holding.split("."); + let count = 0; + for (let i = 0; i < 4; i++) { + const suit = SUIT_LETTERS[i]; + for (const pip of (parts[i] || "").toUpperCase()) { + if (!PIPS.includes(pip)) { + return false; + } + const key = suit + pip; + if (seen[key]) { + return false; + } + seen[key] = true; + count += 1; + } + } + if (count !== 13) { + return false; + } + } + return Object.keys(seen).length === 52; + } + + function dealFromDirectionMap(byDirection) { + const deal = {}; + for (const direction of DIRECTIONS) { + if (!byDirection[direction]) { + return null; + } + deal[direction] = normalizeHandHolding(byDirection[direction]); + if (deal[direction].replace(/\./g, "").length !== 13) { + return null; + } + } + if (!dealHasUniqueCards(deal)) { + throw new Error("Deal has duplicated cards."); + } + return deal; + } + + function completeMissingHand(byDirection) { + const present = DIRECTIONS.filter((direction) => byDirection[direction]); + if (present.length === 4) { + return byDirection; + } + if (present.length !== 3) { + return null; + } + + const used = {}; + for (const direction of present) { + const parts = byDirection[direction].split("."); + for (let i = 0; i < 4; i++) { + for (const pip of parts[i] || "") { + used[SUIT_LETTERS[i] + pip.toUpperCase()] = true; + } + } + } + + const missing = DIRECTIONS.find((direction) => !byDirection[direction]); + const holdings = emptySuitHoldings(); + for (let i = 0; i < 4; i++) { + const suit = SUIT_LETTERS[i]; + for (const pip of PIPS) { + if (!used[suit + pip]) { + holdings[suit] += pip; + } + } + } + byDirection[missing] = holdingsToDotted(holdings); + return byDirection; + } + + /** + * Parse a PBN remainCards string such as "N:AKQ.... ..." into NESW holdings. + * Later hands are clockwise from the first seat letter; no extra seat letters. + */ + function parsePbnDealString(raw) { + const text = String(raw).trim(); + const match = /^([NESWnesw]):\s*(.+)$/.exec(text); + if (!match) { + return null; + } + + const start = match[1].toUpperCase(); + const hands = match[2].trim().split(/\s+/).filter(Boolean); + if (hands.length !== 4) { + return null; + } + + const startIndex = "NESW".indexOf(start); + const byDirection = {}; + for (let i = 0; i < 4; i++) { + const direction = DIR_FROM_LETTER["NESW"[(startIndex + i) % 4]]; + const holding = normalizeHandHolding(hands[i]); + if (holding.replace(/\./g, "").length !== 13) { + return null; + } + byDirection[direction] = holding; + } + return dealFromDirectionMap(byDirection); + } + + function parseLinHand(raw) { + const holdings = emptySuitHoldings(); + let suit = null; + for (const ch of String(raw)) { + const upper = ch.toUpperCase(); + if (SUIT_LETTERS.includes(upper)) { + suit = upper; + continue; + } + if (PIPS.includes(upper)) { + if (!suit) { + throw new Error("LIN hand has a rank before a suit."); + } + holdings[suit] += upper; + continue; + } + throw new Error("LIN hand has illegal characters."); + } + return holdingsToDotted(holdings); + } + + function parseLinDealPayload(payload) { + // md|,,,[] + // BBO lists hands in fixed South, West, North, East order. The leading + // digit is only the dealer (1=South, 2=West, 3=North, 4=East); it does not + // rotate which hand comes first in the list. + const body = String(payload).replace(/^[1-4]/, ""); + const parts = body.split(","); + // Trailing commas are common in BBO exports; ignore empty trailing slots. + while (parts.length && !String(parts[parts.length - 1]).trim()) { + parts.pop(); + } + if (parts.length < 3 || parts.length > 4) { + return null; + } + + const byDirection = {}; + for (let i = 0; i < 4; i++) { + const raw = (parts[i] || "").trim(); + if (!raw) { + continue; + } + byDirection[LIN_HAND_ORDER[i]] = parseLinHand(raw); + } + const completed = completeMissingHand(byDirection); + return completed ? dealFromDirectionMap(completed) : null; + } + + function parseDlmBoardPayload(letters) { + // 26 letters a-p; each encodes owners of a fixed high/low card pair. + const pairs = [ + ["SA", "SK"], ["SQ", "SJ"], ["ST", "S9"], ["S8", "S7"], + ["S6", "S5"], ["S4", "S3"], ["S2", "HA"], + ["HK", "HQ"], ["HJ", "HT"], ["H9", "H8"], ["H7", "H6"], + ["H5", "H4"], ["H3", "H2"], + ["DA", "DK"], ["DQ", "DJ"], ["DT", "D9"], ["D8", "D7"], + ["D6", "D5"], ["D4", "D3"], ["D2", "CA"], + ["CK", "CQ"], ["CJ", "CT"], ["C9", "C8"], ["C7", "C6"], + ["C5", "C4"], ["C3", "C2"], + ]; + const firstOwner = "NNNNEEEESSSSWWWW"; + const secondOwner = "NESWNESWNESWNESW"; + const byDirection = { + north: emptySuitHoldings(), + east: emptySuitHoldings(), + south: emptySuitHoldings(), + west: emptySuitHoldings(), + }; + + for (let i = 0; i < 26; i++) { + const code = letters.charCodeAt(i) - "a".charCodeAt(0); + if (code < 0 || code > 15) { + return null; + } + const [firstCard, secondCard] = pairs[i]; + const firstDirection = DIR_FROM_LETTER[firstOwner.charAt(code)]; + byDirection[firstDirection][firstCard.charAt(0)] += firstCard.charAt(1); + const secondDirection = DIR_FROM_LETTER[secondOwner.charAt(code)]; + byDirection[secondDirection][secondCard.charAt(0)] += secondCard.charAt(1); + } + + return dealFromDirectionMap({ + north: holdingsToDotted(byDirection.north), + east: holdingsToDotted(byDirection.east), + south: holdingsToDotted(byDirection.south), + west: holdingsToDotted(byDirection.west), + }); + } + + /** + * Parse a sol*.txt style line: four NESW holdings, optional ":results" suffix. + * Example: T5.K4.652.A98542 K6.... AQJ987.8532.84.K:6565... + * An optional leading board number ("1. ") is accepted and ignored. + */ + function parseSolStyleDealLine(line) { + let beforeColon = String(line).split(":")[0].trim(); + if (!beforeColon) { + return null; + } + beforeColon = beforeColon.replace(/^\d+\.\s+/, ""); + const hands = beforeColon.split(/\s+/).filter(Boolean); + if (hands.length !== 4) { + return null; + } + for (const hand of hands) { + if ((hand.match(/\./g) || []).length !== 3) { + return null; + } + } + return parsePbnDealString("N:" + hands.join(" ")); + } + + /** + * Extract the first deal from PBN, LIN, DLM, dtest, or sol-style .txt content. + * @returns {{north:string,east:string,south:string,west:string}} + */ + function parseFirstDealFromText(text) { + const source = String(text == null ? "" : text); + + const pbnTag = /\[Deal\s+"([^"]+)"\s*\]/i.exec(source); + if (pbnTag) { + const deal = parsePbnDealString(pbnTag[1]); + if (deal) { + return deal; + } + } + + const dtestLine = /^PBN\s+\d+\s+\d+\s+\d+\s+\d+\s+"([^"]+)"/im.exec(source); + if (dtestLine) { + const deal = parsePbnDealString(dtestLine[1]); + if (deal) { + return deal; + } + } + + const linMatch = /\bmd\|([^|]+)/i.exec(source); + if (linMatch) { + const deal = parseLinDealPayload(linMatch[1]); + if (deal) { + return deal; + } + } + + const dlmMatch = /Board\s*\d+\s*=\s*([a-p]{26})/i.exec(source); + if (dlmMatch) { + const deal = parseDlmBoardPayload(dlmMatch[1].toLowerCase()); + if (deal) { + return deal; + } + } + + // Bare PBN remainCards line (no tag). + const bare = /^\s*([NESWnesw]:[^\n\r"]+)/m.exec(source); + if (bare) { + const deal = parsePbnDealString(bare[1].trim()); + if (deal) { + return deal; + } + } + + // sol10.txt-style: four NESW holdings, optional CalcTable suffix after ':'. + for (const line of source.split(/\r?\n/)) { + const deal = parseSolStyleDealLine(line); + if (deal) { + return deal; + } + } + + throw new Error( + "No PBN, LIN, DLM, dtest, or sol-style deal found in the file." + ); + } + + global.parseFirstDealFromText = parseFirstDealFromText; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/web/stage_github_pages.py b/web/stage_github_pages.py index badbfdb7e..e65254fef 100644 --- a/web/stage_github_pages.py +++ b/web/stage_github_pages.py @@ -15,6 +15,7 @@ "dds_web.html", "dds_web.css", "dds_web.js", + "dds_web_deal_import.js", "coi-serviceworker.js", "dds_web_wasm.js", "dds_web_wasm.wasm", diff --git a/web/tests/dds_web_test.mjs b/web/tests/dds_web_test.mjs index b17f8cd3c..7c6ef12e3 100644 --- a/web/tests/dds_web_test.mjs +++ b/web/tests/dds_web_test.mjs @@ -1,5 +1,6 @@ /** - * Unit tests for web/dds_web.js (Node built-in test runner). + * Unit tests for web/dds_web.js and web/dds_web_deal_import.js + * (Node built-in test runner). * * Run with: * bazelisk test //web:dds_web_js_test @@ -16,13 +17,13 @@ import { createContext, runInContext } from "node:vm"; const DIRECTIONS = ["north", "east", "south", "west"]; const SUITS = ["spades", "hearts", "diamonds", "clubs"]; -function findDdsWebJsPath() { - if (process.env.DDS_WEB_JS && existsSync(process.env.DDS_WEB_JS)) { - return process.env.DDS_WEB_JS; +function findWebJsPath(fileName, envKey) { + if (process.env[envKey] && existsSync(process.env[envKey])) { + return process.env[envKey]; } const here = dirname(fileURLToPath(import.meta.url)); - const adjacent = join(here, "..", "dds_web.js"); + const adjacent = join(here, "..", fileName); if (existsSync(adjacent)) { return adjacent; } @@ -31,7 +32,7 @@ function findDdsWebJsPath() { if (!base) { continue; } - for (const sub of ["web/dds_web.js", "_main/web/dds_web.js"]) { + for (const sub of [`web/${fileName}`, `_main/web/${fileName}`]) { const candidate = join(base, sub); if (existsSync(candidate)) { return candidate; @@ -39,7 +40,15 @@ function findDdsWebJsPath() { } } - throw new Error("dds_web.js not found"); + throw new Error(`${fileName} not found`); +} + +function findDdsWebJsPath() { + return findWebJsPath("dds_web.js", "DDS_WEB_JS"); +} + +function findDdsWebDealImportJsPath() { + return findWebJsPath("dds_web_deal_import.js", "DDS_WEB_DEAL_IMPORT_JS"); } /** Reject if `promise` does not settle within `ms` (clears the timer either way). */ @@ -226,7 +235,21 @@ function createMockDocument(initialValues = {}) { return documentRef; } +function loadDealImport(extras = {}) { + const code = readFileSync(findDdsWebDealImportJsPath(), "utf8"); + const sandbox = { + console, + Promise, + Error, + ...extras, + }; + const context = createContext(sandbox); + runInContext(code, context, { filename: "dds_web_deal_import.js" }); + return context; +} + function loadDdsWeb(document, extras = {}) { + const importCode = readFileSync(findDdsWebDealImportJsPath(), "utf8"); const code = readFileSync(findDdsWebJsPath(), "utf8"); const sandbox = { document, @@ -246,6 +269,7 @@ function loadDdsWeb(document, extras = {}) { ...extras, }; const context = createContext(sandbox); + runInContext(importCode, context, { filename: "dds_web_deal_import.js" }); runInContext(code, context, { filename: "dds_web.js" }); // Existing tests expect hand edits to schedule immediately; debounce is // covered by dedicated tests that opt into a non-zero delay. @@ -995,6 +1019,7 @@ test("loadDdsModule rejects missing wasm globals", async () => { test("wasmSolveEnvironmentError explains file:// cannot load WASM workers", () => { // Arrange: browser opened as a local file (origin null). + const importCode = readFileSync(findDdsWebDealImportJsPath(), "utf8"); const code = readFileSync(findDdsWebJsPath(), "utf8"); const sandbox = { document: createMockDocument(), @@ -1005,6 +1030,7 @@ test("wasmSolveEnvironmentError explains file:// cannot load WASM workers", () = location: { protocol: "file:" }, }; const context = createContext(sandbox); + runInContext(importCode, context, { filename: "dds_web_deal_import.js" }); runInContext(code, context, { filename: "dds_web.js" }); // Act / Assert @@ -1016,6 +1042,7 @@ test("wasmSolveEnvironmentError explains file:// cannot load WASM workers", () = test("wasmSolveEnvironmentError explains missing SharedArrayBuffer headers", () => { // Arrange: HTTPS page without cross-origin isolation (no SAB). + const importCode = readFileSync(findDdsWebDealImportJsPath(), "utf8"); const code = readFileSync(findDdsWebJsPath(), "utf8"); const sandbox = { document: createMockDocument(), @@ -1027,6 +1054,7 @@ test("wasmSolveEnvironmentError explains missing SharedArrayBuffer headers", () SharedArrayBuffer: undefined, }; const context = createContext(sandbox); + runInContext(importCode, context, { filename: "dds_web_deal_import.js" }); runInContext(code, context, { filename: "dds_web.js" }); // Act @@ -3745,6 +3773,18 @@ const DLM_BOARD_01 = const LIN_DEAL = "pn|a,b,c,d|st||md|3S27AH3489TD5JC45J,S358QKH56D4KAC3QK,S4JH2JQD2678TC678,|rh||ah|Board 1|sv|o|"; +test("dds_web_deal_import.js exports parseFirstDealFromText without dds_web.js", () => { + // Arrange / Act: load only the deal-import script. + const ctx = loadDealImport(); + const deal = ctx.parseFirstDealFromText(`[Deal "${EVERYONE_3N_PBN}"]`); + + // Assert: module is self-contained for file-format parsing. + assert.equal(deal.north, "QT9.A8765432.KJ."); + assert.equal(deal.east, "KJ..A8765432.QT9"); + assert.equal(deal.south, "A8765432.QT9..KJ"); + assert.equal(deal.west, ".KJ.QT9.A8765432"); +}); + function assertImportedDeal(ctx, document, expected) { assert.equal(document.element("north_spades").value, expected.north[0]); assert.equal(document.element("north_hearts").value, expected.north[1]); diff --git a/web/tests/test_dds_web_js.py b/web/tests/test_dds_web_js.py index 673d27e2e..abdb7b0ef 100644 --- a/web/tests/test_dds_web_js.py +++ b/web/tests/test_dds_web_js.py @@ -56,8 +56,10 @@ def test_dds_web_js(self) -> None: test_script = rlocation("web/tests/dds_web_test.mjs") dds_web_js = rlocation("web/dds_web.js") + dds_web_deal_import_js = rlocation("web/dds_web_deal_import.js") env = os.environ.copy() env["DDS_WEB_JS"] = str(dds_web_js) + env["DDS_WEB_DEAL_IMPORT_JS"] = str(dds_web_deal_import_js) try: proc = subprocess.run( [node, "--test", str(test_script)], diff --git a/web/tests/test_stage_github_pages.py b/web/tests/test_stage_github_pages.py index 04a6cf80f..f5fc7739f 100644 --- a/web/tests/test_stage_github_pages.py +++ b/web/tests/test_stage_github_pages.py @@ -29,6 +29,7 @@ def test_stage_copies_site_wasm_coi_and_index(self) -> None: "dds_web.html", "dds_web.css", "dds_web.js", + "dds_web_deal_import.js", "coi-serviceworker.js", "dds_web_wasm.js", "dds_web_wasm.wasm", @@ -45,6 +46,7 @@ def test_stage_copies_site_wasm_coi_and_index(self) -> None: "dds_web.html", "dds_web.css", "dds_web.js", + "dds_web_deal_import.js", "coi-serviceworker.js", "dds_web_wasm.js", "dds_web_wasm.wasm", @@ -76,6 +78,7 @@ def test_stage_fails_when_required_file_missing(self) -> None: def test_deploy_file_list_matches_static_plus_wasm(self) -> None: stage = _load_stage_github_pages() self.assertIn("coi-serviceworker.js", stage.DEPLOY_FILES) + self.assertIn("dds_web_deal_import.js", stage.DEPLOY_FILES) self.assertIn("dds_web_wasm_bin.js", stage.DEPLOY_FILES) self.assertNotIn("index.html", stage.DEPLOY_FILES) diff --git a/web/tests/test_web_html.py b/web/tests/test_web_html.py index 5ca4900af..7ff4acc79 100644 --- a/web/tests/test_web_html.py +++ b/web/tests/test_web_html.py @@ -82,9 +82,25 @@ def test_loads_coi_serviceworker_in_head_before_app_scripts(self) -> None: ) coi_at = text.index('src="coi-serviceworker.js"') wasm_at = text.index('src="dds_web_wasm.js"') + import_at = text.index('src="dds_web_deal_import.js"') app_at = text.index('src="dds_web.js"') self.assertLess(coi_at, wasm_at) self.assertLess(coi_at, app_at) + self.assertLess(import_at, app_at) + + def test_loads_deal_import_script_before_dds_web_js(self) -> None: + # File-format parsers live in dds_web_deal_import.js so dds_web.js stays + # focused on UI and solver glue (#382). + text = HTML_PATH.read_text(encoding="utf-8") + self.assertRegex( + text, + r'\s*', + ) + import_at = text.index('src="dds_web_deal_import.js"') + wasm_at = text.index('src="dds_web_wasm.js"') + app_at = text.index('src="dds_web.js"') + self.assertLess(wasm_at, import_at) + self.assertLess(import_at, app_at) def test_disables_coep_credentialless_before_coi_serviceworker(self) -> None: # coi-serviceworker defaults to COEP: credentialless. Safari / iOS WebKit diff --git a/web/tests/web_site.py b/web/tests/web_site.py index 5f03f3cd6..f7a58d7af 100644 --- a/web/tests/web_site.py +++ b/web/tests/web_site.py @@ -11,6 +11,7 @@ "dds_web.html", "dds_web.css", "dds_web.js", + "dds_web_deal_import.js", "coi-serviceworker.js", ) From 3931d7165b1d29947ccabe914eb0d94cc5cd15d5 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 15 Sep 2026 11:54:00 +0200 Subject: [PATCH 2/2] Split dds_web.js into core deal model, solve session, and UI layers. Keeps file parsers, Card/holdings, WASM queue, and DOM wiring in separate classic scripts so each concern stays clear (#382). Co-authored-by: Cursor --- specs/web.md | 16 +- web/BUILD.bazel | 6 + web/dds_web.html | 2 + web/dds_web.js | 886 +-------------------------- web/dds_web_core.js | 370 +++++++++++ web/dds_web_solve.js | 576 +++++++++++++++++ web/stage_github_pages.py | 2 + web/tests/dds_web_test.mjs | 83 ++- web/tests/test_dds_web_js.py | 4 + web/tests/test_stage_github_pages.py | 6 + web/tests/test_web_html.py | 19 + web/tests/web_site.py | 2 + 12 files changed, 1069 insertions(+), 903 deletions(-) create mode 100644 web/dds_web_core.js create mode 100644 web/dds_web_solve.js diff --git a/specs/web.md b/specs/web.md index 838d32bc8..c780cf758 100644 --- a/specs/web.md +++ b/specs/web.md @@ -34,11 +34,13 @@ DOM wiring) with an automated test pyramid. base flags come from `WASM_LINKOPTS` ([build-system](build-system.md)), including pthreads / `PTHREAD_POOL_SIZE`. - **The page is a static site plus JS glue.** `dds_web.html` / `dds_web.css` / - `dds_web.js` / `dds_web_deal_import.js` load the module (`createDdsModule`), - marshal a deal into WASM memory, call `dds_web_calc_table` and - `dds_web_solve_leads` via `ccall`, and read results with `getValue`. Deal-file - parsers (PBN/LIN/DLM/sol) live in `dds_web_deal_import.js`; UI and solver glue - stay in `dds_web.js`. `web_site` (`tests/web_site.py`) stages the site + `dds_web.js` / `dds_web_deal_import.js` / `dds_web_core.js` / + `dds_web_solve.js` load the module (`createDdsModule`), marshal a deal into + WASM memory, call `dds_web_calc_table` and `dds_web_solve_leads` via `ccall`, + and read results with `getValue`. Deal-file parsers live in + `dds_web_deal_import.js`; the deal model in `dds_web_core.js`; WASM queue / + DD table / leads in `dds_web_solve.js`; UI wiring in `dds_web.js`. `web_site` + (`tests/web_site.py`) stages the site and provides `make_isolated_http_handler` (COOP/COEP) for system/e2e tests and `web/serve_web.py`. Helper scripts `gen_wasm_bin_js.py`, `patch_web_wasm.py`, `verify_wasm_js.py` generate and sanity-check the JS/wasm glue. @@ -93,8 +95,8 @@ DOM wiring) with an automated test pyramid. `web_tests` / `web_system_tests` / `web_e2e_tests` suites; `WASM_WEB_LINKOPTS`. - `web/dds_web_wasm.cpp` — the native WASM bridge (`dds_web_calc_table`, `dds_web_solve_leads`). -- `web/{dds_web.html,dds_web.css,dds_web.js,dds_web_deal_import.js}` — the page, - deal-file parsers, and JS glue. +- `web/{dds_web.html,dds_web.css,dds_web.js,dds_web_deal_import.js,dds_web_core.js,dds_web_solve.js}` + — the page, deal-file parsers, deal model, solver session, and UI glue. - `web/coi-serviceworker.js` — COOP/COEP via service worker for hosts without custom headers (GitHub Pages). - `web/stage_github_pages.py` — stage static site + `index.html` for Pages. diff --git a/web/BUILD.bazel b/web/BUILD.bazel index 35ea674fc..e16a1375a 100644 --- a/web/BUILD.bazel +++ b/web/BUILD.bazel @@ -105,6 +105,8 @@ py_test( data = [ "dds_web.js", "dds_web_deal_import.js", + "dds_web_core.js", + "dds_web_solve.js", "tests/dds_web_test.mjs", ], ) @@ -123,6 +125,8 @@ py_test( "dds_web.html", "dds_web.js", "dds_web_deal_import.js", + "dds_web_core.js", + "dds_web_solve.js", "gen_wasm_bin_js.py", "patch_web_wasm.py", "tests/dds_web_wasm_node.mjs", @@ -146,6 +150,8 @@ py_test( "dds_web.html", "dds_web.js", "dds_web_deal_import.js", + "dds_web_core.js", + "dds_web_solve.js", "gen_wasm_bin_js.py", "patch_web_wasm.py", ], diff --git a/web/dds_web.html b/web/dds_web.html index 53033d5be..c1cf6153a 100644 --- a/web/dds_web.html +++ b/web/dds_web.html @@ -148,6 +148,8 @@

+ + diff --git a/web/dds_web.js b/web/dds_web.js index ec3f9dde4..4b7e8e41c 100644 --- a/web/dds_web.js +++ b/web/dds_web.js @@ -4,7 +4,10 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT -// Deal-file parsers: web/dds_web_deal_import.js (load before this file). +// Layers (load before this UI file): +// web/dds_web_deal_import.js — file-format parsers +// web/dds_web_core.js — Card / holdings / handsToPbn +// web/dds_web_solve.js — WASM queue / DD table / leads // Unit tests: web/tests/dds_web_test.mjs // Run with: bazelisk test //web:dds_web_js_test // or: python -m unittest web.tests.test_dds_web_js @@ -23,16 +26,8 @@ clearTestData rotateClockwise pageLoad - sendJSON - refreshDdTable - refreshOpeningLeadTricks - scheduleDealSolve - setDealSolveDebounceMs - fourthHandFillState updateActionButtons - sanitizeSuitHolding sanitizeHandSuitInputs - suitHoldingHasIllegalChars playIllegalInputBeep handleHandSuitInput handCardHtml @@ -61,20 +56,12 @@ handleResultTableKeyDown selectedContract onContractSelect - openingLeader denominationDisplayHtml contractStatusHtml updateContractStatus - pipFromDdsRank - leadTricksMapFromSolverOutput - wasmSolveEnvironmentError - formatSolveTimeMs importDealFromText chooseDealFile handleDealFileSelected - setDdTableComputingDelayMs - invalidateActiveDdTableRequest - paintStatusFrame */ // It's also useful to pass the code through @@ -82,299 +69,6 @@ "use strict"; -const DIRECTIONS = ["north", "east", "south", "west"]; -const SUITS = ["spades", "hearts", "diamonds", "clubs"]; -const PIPS = "AKQJT98765432"; -const DENOMINATIONS = ["C", "D", "H", "S", "N"]; - -// DDS res_table strain index (S,H,D,C,N) to DDS Web table column key. -const DENOM_TO_STRAIN = { C: 3, D: 2, H: 1, S: 0, N: 4 }; -const DIR_TO_HAND = { north: 0, east: 1, south: 2, west: 3 }; - -let selectedContractState = null; -let leadTricksByCardKey = null; -let leadTricksRequestId = 0; -let ddTableRequestId = 0; -let ddTableComputingTimer = null; -let lastDdTablePbn = null; -let solveQueue = Promise.resolve(); -let dealSolveEpoch = 0; -let dealSolveQueued = false; -let dealSolvePending = false; -// Delay WASM work after hand edits so typing on a complete deal does not -// freeze the UI on every keystroke (sync ccall). Contract clicks stay immediate. -let dealSolveDebounceMs = 250; -let dealSolveDebounceTimer = null; -// Track completeness so the first transition to a full deal solves immediately -// (auto-fill / final pip), while further edits of that deal stay debounced. -let lastDealWasComplete = false; - -function enqueueSolve(task) { - const run = solveQueue.then(task, task); - - // Keep the queue alive after a rejected solve. - solveQueue = run.catch(() => {}); - return run; -} - -function setDealSolveDebounceMs(ms) { - dealSolveDebounceMs = ms; - - // Disabling debounce must not leave a previously scheduled trailing solve - // to fire later with the old delay. - if (ms <= 0 && dealSolveDebounceTimer != null) { - clearTimeout(dealSolveDebounceTimer); - dealSolveDebounceTimer = null; - } -} - -function scheduleDealSolveDebounced() { - if (dealSolveDebounceTimer != null) { - clearTimeout(dealSolveDebounceTimer); - dealSolveDebounceTimer = null; - } - - if (dealSolveDebounceMs <= 0) { - void scheduleDealSolve(); - return; - } - - dealSolveDebounceTimer = setTimeout(() => { - dealSolveDebounceTimer = null; - void scheduleDealSolve(); - }, dealSolveDebounceMs); -} - -// Coalesce DD-table + lead solves onto one queued job so rapid hand edits and -// contract clicks cannot interleave CalcDDtable with SolveBoard, and so -// intermediate schedules do not each add a stale promise-chain callback. -function scheduleDealSolve() { - // A direct schedule (contract click, etc.) supersedes a pending debounced - // hand-edit solve so we do not fire a redundant trailing job afterward. - if (dealSolveDebounceTimer != null) { - clearTimeout(dealSolveDebounceTimer); - dealSolveDebounceTimer = null; - } - - dealSolveEpoch += 1; - dealSolvePending = true; - - if (dealSolveQueued) { - return solveQueue; - } - - dealSolveQueued = true; - return enqueueSolve(async () => { - try { - while (true) { - const epoch = dealSolveEpoch; - dealSolvePending = false; - - await refreshDdTable(); - if (epoch !== dealSolveEpoch) { - // Invalidation alone must not restart; only a newer - // scheduleDealSolve (pending) should continue. A pending - // debounce will start a fresh job when it fires. - if (dealSolvePending) { - continue; - } - break; - } - - if (selectedContractState) { - await refreshOpeningLeadTricks(); - } else if (leadTricksByCardKey) { - leadTricksByCardKey = null; - updateHandCardDisplays(collectHands()); - } - - // Stale+pending → another iteration; else exit (success or - // invalidate). Gate release lives in finally. - if (epoch !== dealSolveEpoch && dealSolvePending) { - continue; - } - break; - } - } finally { - const restart = dealSolvePending; - dealSolveQueued = false; - if (restart) { - void scheduleDealSolve(); - } - } - }); -} - -// Suit glyphs are real text in these custom tags (see dds_web.css for color). -const SUIT_TAGS = { - spades: "spade-suit", - hearts: "heart-suit", - diamonds: "diamond-suit", - clubs: "club-suit" -}; - -const SUIT_GLYPHS = { - spades: "\u2660", - hearts: "\u2665", - diamonds: "\u2666", - clubs: "\u2663" -}; - -const PIP_NAMES = { - A: "ace", - K: "king", - Q: "queen", - J: "jack", - T: "ten", - "9": "nine", - "8": "eight", - "7": "seven", - "6": "six", - "5": "five", - "4": "four", - "3": "three", - "2": "two" -}; - -function suitLetter(suit) { - return suit.charAt(0).toUpperCase(); -} - -function suitFromLetter(letter) { - for (const suit of SUITS) { - if (suitLetter(suit) === letter) { - return suit; - } - } - - return undefined; -} - -function Card(suit, pip) { - if (!SUITS.includes(suit)) { - throw new Error("Invalid card suit: " + suit); - } - - const normalizedPip = String(pip).toUpperCase(); - - if (!PIPS.includes(normalizedPip)) { - throw new Error("Invalid card pip: " + pip); - } - - this.suit = suit; - this.pip = normalizedPip; -} - -Card.prototype.key = function () { - return suitLetter(this.suit) + this.pip; -}; - -Card.prototype.toString = Card.prototype.key; - -Card.fromKey = function (key) { - if (typeof key !== "string" || key.length !== 2) { - throw new Error("Invalid card key: " + key); - } - - const normalized = key.toUpperCase(); - const suit = suitFromLetter(normalized.charAt(0)); - - if (!suit) { - throw new Error("Invalid card key: " + key); - } - - return new Card(suit, normalized.charAt(1)); -}; - -function cardFromKeySafe(key) { - try { - return Card.fromKey(key); - } catch (_err) { - return null; - } -} - -Card.compare = function (left, right) { - return PIPS.indexOf(left.pip) - PIPS.indexOf(right.pip); -}; - -function suitTag(suit) { - return SUIT_TAGS[suit]; -} - -function suitSymbolHtml(suit) { - const tag = suitTag(suit); - - return "<" + tag + ">" + SUIT_GLYPHS[suit] + ""; -} - -let ddsModulePromise = null; - -function wasmSolveEnvironmentError() { - if (typeof location === "undefined" || !location) { - return null; - } - - if (location.protocol === "file:") { - return "Solving needs HTTP with cross-origin isolation. " + - "From the repo root run: python3 web/serve_web.py"; - } - - if (typeof SharedArrayBuffer === "undefined") { - return "Solving needs SharedArrayBuffer (cross-origin isolation). " + - "Serve responses with Cross-Origin-Opener-Policy: same-origin and " + - "Cross-Origin-Embedder-Policy: require-corp " + - "(locally: python3 web/serve_web.py)."; - } - - return null; -} - -function loadDdsModule() { - if (typeof createDdsModule !== "function") { - return Promise.reject(new Error( - "WASM module not found. From the repo root run: ./web/update_wasm.sh" - )); - } - - if (typeof ddsWebWasmBytes !== "function") { - return Promise.reject(new Error( - "WASM bytes not found. From the repo root run: ./web/update_wasm.sh" - )); - } - - const envError = wasmSolveEnvironmentError(); - - if (envError) { - return Promise.reject(new Error(envError)); - } - - if (!ddsModulePromise) { - ddsModulePromise = createDdsModule({ - wasmBinary: ddsWebWasmBytes() - }).catch((error) => { - // Allow retry after transient initialization failures. - ddsModulePromise = null; - throw error; - }); - } - - return ddsModulePromise; -} - -function handsToPbn(hands) { - const handStrings = DIRECTIONS.map((direction) => { - return SUITS.map((suit) => { - return hands[direction] - .filter((card) => card.suit === suit) - .sort(Card.compare) - .map((card) => card.pip) - .join(""); - }).join("."); - }); - return "N:" + handStrings.join(" "); -} - function focusNorthSpades() { // To allow the user to quickly enter a deal @@ -428,26 +122,6 @@ function chooseDealFile() { let dealFileSelectionGeneration = 0; -function invalidateActiveDdTableRequest() { - ddTableRequestId += 1; - leadTricksRequestId += 1; - dealSolveEpoch += 1; - // Drop a coalesced direct-schedule flag so the worker does not immediately - // continue after this invalidate; a following scheduleDealSolve() sets it - // again, while a debounced schedule sets it when its timer fires. - dealSolvePending = false; - clearDdTableComputingTimer(); - if (dealSolveDebounceTimer != null) { - clearTimeout(dealSolveDebounceTimer); - dealSolveDebounceTimer = null; - } - const result = document.getElementById("result"); - // Drop a painted Computing… for the abandoned request; keep solved/error text. - if (result && /Computing/i.test(String(result.innerHTML || ""))) { - result.innerHTML = ""; - } -} - async function handleDealFileSelected(input) { const file = input && input.files && input.files[0]; if (!file) { @@ -649,16 +323,6 @@ function updateDeckStatus(hands) { } } -function openingLeader(declarerDirection) { - const index = DIRECTIONS.indexOf(declarerDirection); - - if (index < 0) { - return null; - } - - return DIRECTIONS[(index + 1) % 4]; -} - const DENOM_TO_SUIT = { C: "clubs", D: "diamonds", @@ -727,48 +391,6 @@ function updateContractStatus() { status.hidden = false; } -function pipFromDdsRank(rank) { - if (rank === 14) { - return "A"; - } - if (rank === 13) { - return "K"; - } - if (rank === 12) { - return "Q"; - } - if (rank === 11) { - return "J"; - } - if (rank === 10) { - return "T"; - } - if (rank >= 2 && rank <= 9) { - return String(rank); - } - - return null; -} - -function leadTricksMapFromSolverOutput(out) { - const map = {}; - const n = out[0] | 0; - - for (let i = 0; i < n; i++) { - const suitIndex = out[1 + 3 * i]; - const rank = out[1 + 3 * i + 1]; - const score = out[1 + 3 * i + 2]; - const suit = SUITS[suitIndex]; - const pip = pipFromDdsRank(rank); - - if (suit && pip) { - map[suitLetter(suit) + pip] = score; - } - } - - return map; -} - function capitalize(word) { return word.charAt(0).toUpperCase() + word.slice(1); } @@ -1375,94 +997,6 @@ function clearResultCellSelection() { void scheduleDealSolve(); } -async function solveOpeningLeadTricks(hands, contract) { - const leader = openingLeader(contract.direction); - const trump = DENOM_TO_STRAIN[contract.denomination]; - const first = DIR_TO_HAND[leader]; - - if (trump == null || first == null) { - throw new Error("Invalid contract for lead analysis"); - } - - const module = await loadDdsModule(); - const pbn = handsToPbn(hands); - const outPtr = module._malloc((1 + 13 * 3) * 4); - - try { - const rc = module.ccall( - "dds_web_solve_leads", - "number", - ["string", "number", "number", "number"], - [pbn, trump, first, outPtr] - ); - - if (rc !== 1) { - throw new Error("DDS lead solve error (code " + rc + ")"); - } - - const n = module.getValue(outPtr, "i32"); - if (n < 0 || n > 13) { - throw new Error( - "DDS lead solve returned invalid card count (" + n + ")" - ); - } - const out = [n]; - - for (let i = 0; i < n; i++) { - const base = outPtr + (1 + 3 * i) * 4; - out.push(module.getValue(base, "i32")); - out.push(module.getValue(base + 4, "i32")); - out.push(module.getValue(base + 8, "i32")); - } - - return leadTricksMapFromSolverOutput(out); - } finally { - module._free(outPtr); - } -} - -async function refreshOpeningLeadTricks() { - const requestId = ++leadTricksRequestId; - const contract = selectedContractState; - const hands = collectHands(); - - if (!contract || inputIsValid(hands).length) { - leadTricksByCardKey = null; - if (requestId === leadTricksRequestId) { - updateHandCardDisplays(hands); - } - return; - } - - try { - const map = await solveOpeningLeadTricks(hands, contract); - - if (requestId !== leadTricksRequestId) { - return; - } - - leadTricksByCardKey = map; - updateHandCardDisplays(collectHands()); - } catch (err) { - if (requestId !== leadTricksRequestId) { - return; - } - - leadTricksByCardKey = null; - updateHandCardDisplays(collectHands()); - - const result = document.getElementById("result"); - - if (result) { - result.innerHTML = err instanceof Error - ? err.message - : err == null - ? "Unknown error" - : String(err); - } - } -} - function contractFromResultCell(cell) { if (!cell) { return null; @@ -1597,63 +1131,6 @@ function updateHandCardCounts(hands) { } } -function fourthHandFillState(hands) { - const handCounts = DIRECTIONS.map((direction) => hands[direction].length); - const fullHands = handCounts.filter((count) => count === 13).length; - const emptyHands = handCounts.filter((count) => count === 0).length; - const partialHands = handCounts.filter((count) => count > 0 && count < 13).length; - - if (fullHands !== 3 || emptyHands !== 1 || partialHands > 0) { - return { canFill: false }; - } - - const emptyHand = DIRECTIONS[handCounts.indexOf(0)]; - const usedCards = {}; - - for (const direction of DIRECTIONS) { - if (direction === emptyHand) { - continue; - } - - for (const card of hands[direction]) { - if (!card || !SUITS.includes(card.suit) || !PIPS.includes(card.pip)) { - return { canFill: false }; - } - - usedCards[card.key()] = true; - } - } - - // Three full hands hold 39 cards; fewer distinct keys means a duplicate, - // so the remaining 13 cannot be dealt to the empty hand. - if (Object.keys(usedCards).length !== 39) { - return { canFill: false }; - } - - return { canFill: true, emptyHand, usedCards }; -} - -function cardsToSuitHoldings(cards) { - const holdings = {}; - - for (const suit of SUITS) { - holdings[suit] = ""; - } - - for (const card of cards) { - holdings[card.suit] += card.pip; - } - - for (const suit of SUITS) { - holdings[suit] = holdings[suit] - .split("") - .sort((a, b) => PIPS.indexOf(a) - PIPS.indexOf(b)) - .join(""); - } - - return holdings; -} - function setHandInputs(direction, holdings) { for (const suit of SUITS) { document.getElementById(direction + "_" + suit).value = holdings[suit]; @@ -1745,18 +1222,6 @@ function undeployCard(card) { return removeCardFromAllHands(card); } -function sortedPipInsertIndex(holding, pip) { - const rank = PIPS.indexOf(pip); - - for (let i = 0; i < holding.length; i++) { - if (PIPS.indexOf(holding.charAt(i)) > rank) { - return i; - } - } - - return holding.length; -} - function addCardToHand(direction, card) { if (!DIRECTIONS.includes(direction) || !card || !card.suit || !card.pip) { return false; @@ -1823,10 +1288,6 @@ function applyFourthHandFill(hands, emptyHand) { return true; } -function allHandsHaveThirteenCards(hands) { - return DIRECTIONS.every((direction) => hands[direction].length === 13); -} - function isHandInput(element) { if (!element) { return false; @@ -1841,80 +1302,6 @@ function isHandInput(element) { return false; } -function sanitizeSuitHolding(value, claimedKeys, suit, maxPips) { - if (value == null) { - return ""; - } - - const pips = []; - const seen = {}; - - for (const ch of String(value)) { - const pip = ch.toUpperCase(); - - if (!PIPS.includes(pip) || seen[pip]) { - continue; - } - - if (claimedKeys && suit) { - const key = new Card(suit, pip).key(); - - if (claimedKeys[key]) { - continue; - } - } - - seen[pip] = true; - pips.push(pip); - } - - pips.sort((left, right) => PIPS.indexOf(left) - PIPS.indexOf(right)); - - if (typeof maxPips === "number" && maxPips >= 0 && pips.length > maxPips) { - return pips.slice(0, maxPips).join(""); - } - - return pips.join(""); -} - -function suitHoldingHasDuplicatePips(value) { - if (value == null) { - return false; - } - - const seen = {}; - - for (const ch of String(value)) { - const pip = ch.toUpperCase(); - - if (!PIPS.includes(pip)) { - continue; - } - - if (seen[pip]) { - return true; - } - - seen[pip] = true; - } - - return false; -} - -function suitHoldingHasIllegalChars(value) { - if (value == null) { - return false; - } - - for (const ch of String(value)) { - if (!PIPS.includes(ch.toUpperCase())) { - return true; - } - } - - return false; -} - function suitHoldingWouldExceedHandLimit(element) { const parsed = parseHandInputId(element && element.id); @@ -2324,268 +1711,3 @@ function pageLoad() { focusNorthSpades(); } -function clear_results() { - var result = document.getElementById("result"); - var result_table = document.getElementById("result-table"); - - clearDdTableComputingTimer(); - lastDdTablePbn = null; - result.innerHTML = ""; - - for (var row = 1; row <= 4; row++) { - for (var column = 1; column <= 5; column++) { - var cell = result_table.rows[row].cells[column]; - cell.innerHTML = ""; - } - } -} - -/** Delay before showing Computing… under the DD matrix (see refreshDdTable). */ -let ddTableComputingDelayMs = 300; - -function setDdTableComputingDelayMs(ms) { - ddTableComputingDelayMs = ms; -} - -function clearDdTableComputingTimer() { - if (ddTableComputingTimer != null) { - clearTimeout(ddTableComputingTimer); - ddTableComputingTimer = null; - } -} - -function scheduleDdTableComputingMessage(requestId, result) { - clearDdTableComputingTimer(); - if (ddTableComputingDelayMs <= 0) { - if (result) { - result.innerHTML = "Computing…"; - } - return; - } - ddTableComputingTimer = setTimeout(() => { - ddTableComputingTimer = null; - if (requestId !== ddTableRequestId || !result) { - return; - } - result.innerHTML = "Computing…"; // horizontal ellipsis - }, ddTableComputingDelayMs); -} - -/** Yield until the browser has painted the current status (needed before sync ccall). */ -function paintStatusFrame() { - // Hidden tabs often pause rAF; do not block the solve queue forever. - if ( - typeof document !== "undefined" - && document.visibilityState === "hidden" - ) { - return Promise.resolve(); - } - if (typeof requestAnimationFrame === "function") { - return new Promise((resolve) => { - let settled = false; - const done = () => { - if (settled) { - return; - } - settled = true; - clearTimeout(fallbackTimer); - resolve(); - }; - // If the tab hides mid-wait, the second rAF may never run. - const fallbackTimer = setTimeout(done, 50); - requestAnimationFrame(() => { - if ( - typeof document !== "undefined" - && document.visibilityState === "hidden" - ) { - done(); - return; - } - requestAnimationFrame(done); - }); - }); - } - return new Promise((resolve) => setTimeout(resolve, 16)); -} - -/** - * Show Computing… and wait for a paint. The WASM ccall is synchronous and - * blocks timers, so this must run before ccall or the message is never seen. - */ -async function showComputingStatus(requestId, result) { - clearDdTableComputingTimer(); - if (requestId !== ddTableRequestId || !result) { - return false; - } - result.innerHTML = "Computing…"; // horizontal ellipsis - await paintStatusFrame(); - return requestId === ddTableRequestId; -} - -/** Format wall elapsed time for the status line (whole milliseconds). */ -function formatSolveTimeMs(elapsedMs) { - return "Solved in " + Math.round(elapsedMs) + " ms."; -} - -async function refreshDdTable() { - const requestId = ++ddTableRequestId; - const result = document.getElementById("result"); - const result_table = document.getElementById("result-table"); - const hands = collectHands(); - - if (!allHandsHaveThirteenCards(hands)) { - if (requestId === ddTableRequestId) { - lastDdTablePbn = null; - clear_results(); - } - return; - } - - const error_message = inputIsValid(hands); - - if (error_message.length) { - if (requestId === ddTableRequestId) { - lastDdTablePbn = null; - clear_results(); - if (result) { - result.innerHTML = error_message; - } - } - return; - } - - const pbn = handsToPbn(hands); - - if (pbn === lastDdTablePbn && ddTableLooksPopulated(result_table)) { - return; - } - - if (requestId !== ddTableRequestId) { - return; - } - - clear_results(); - // Computing… grace / pre-ccall paint tradeoff (intentional until CalcTable - // runs off the main thread): - // The WASM ccall is synchronous and blocks timers and rAF, so a timer-only - // Computing… message can never appear during a long solve. Painting before - // ccall is the only way to show status while the UI is frozen. That means - // uncached solves wait for any remaining grace period and briefly show - // Computing… even when ccall itself would be fast — a minimum-latency tax - // preferred over silent multi-second freezes. Module load time counts - // toward the grace. Tests set the delay to 0. - scheduleDdTableComputingMessage(requestId, result); - const waitStartedAt = performance.now(); - - try { - const module = await loadDdsModule(); - const outPtr = module._malloc(20 * 4); - - try { - const remainingMs = - ddTableComputingDelayMs - (performance.now() - waitStartedAt); - if (remainingMs > 0) { - await new Promise((resolve) => setTimeout(resolve, remainingMs)); - if (requestId !== ddTableRequestId) { - return; - } - } - - // An import/edit during the grace wait can change the diagram while - // this invocation still holds the old PBN; do not solve stale input. - if (handsToPbn(collectHands()) !== pbn) { - clearDdTableComputingTimer(); - if (requestId === ddTableRequestId && result) { - result.innerHTML = ""; - } - return; - } - - if (!(await showComputingStatus(requestId, result))) { - return; - } - - if (handsToPbn(collectHands()) !== pbn) { - clearDdTableComputingTimer(); - if (requestId === ddTableRequestId && result) { - result.innerHTML = ""; - } - return; - } - - const startedAt = performance.now(); - const rc = module.ccall( - "dds_web_calc_table", - "number", - ["string", "number"], - [pbn, outPtr] - ); - const elapsedMs = performance.now() - startedAt; - - if (requestId !== ddTableRequestId) { - return; - } - - clearDdTableComputingTimer(); - - if (rc !== 1) { - lastDdTablePbn = null; - if (result) { - result.innerHTML = "DDS error (code " + rc + ")."; - } - return; - } - - for (var row = 1; row <= 4; row++) { - for (var column = 1; column <= 5; column++) { - const cell = result_table.rows[row].cells[column]; - const denomination = DENOMINATIONS[column - 1]; - const direction = DIRECTIONS[row - 1]; - const strain = DENOM_TO_STRAIN[denomination]; - const hand = DIR_TO_HAND[direction]; - const index = strain * 4 + hand; - cell.innerHTML = module.getValue( - outPtr + index * 4, - "i32" - ); - } - } - - lastDdTablePbn = pbn; - - if (result) { - result.innerHTML = formatSolveTimeMs(elapsedMs); - } - } finally { - module._free(outPtr); - } - } catch (err) { - if (requestId !== ddTableRequestId) { - return; - } - - lastDdTablePbn = null; - clear_results(); - if (result) { - result.innerHTML = err instanceof Error - ? err.message - : err == null - ? "Unknown error" - : String(err); - } - } -} - -function ddTableLooksPopulated(result_table) { - if (!result_table || !result_table.rows || !result_table.rows[1]) { - return false; - } - - const cell = result_table.rows[1].cells[1]; - - return !!(cell && cell.innerHTML && /\d/.test(String(cell.innerHTML))); -} - -function sendJSON() { - return refreshDdTable(); -} diff --git a/web/dds_web_core.js b/web/dds_web_core.js new file mode 100644 index 000000000..863b89bd4 --- /dev/null +++ b/web/dds_web_core.js @@ -0,0 +1,370 @@ +// Copyright 2020-2026 Adam Wildavsky +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT + +// Deal model for DDS Web (Card, holdings, PBN marshal). No DOM or WASM. +// Loaded after dds_web_deal_import.js and before dds_web_solve.js. + +/* eslint-env es6 */ +/* exported DIRECTIONS SUITS PIPS DENOMINATIONS DENOM_TO_STRAIN DIR_TO_HAND + Card handsToPbn openingLeader pipFromDdsRank leadTricksMapFromSolverOutput + fourthHandFillState cardsToSuitHoldings sortedPipInsertIndex + allHandsHaveThirteenCards sanitizeSuitHolding + suitHoldingHasDuplicatePips suitHoldingHasIllegalChars */ + +"use strict"; + +(function (global) { + const DIRECTIONS = ["north", "east", "south", "west"]; + const SUITS = ["spades", "hearts", "diamonds", "clubs"]; + const PIPS = "AKQJT98765432"; + const DENOMINATIONS = ["C", "D", "H", "S", "N"]; + + // DDS res_table strain index (S,H,D,C,N) to DDS Web table column key. + const DENOM_TO_STRAIN = { C: 3, D: 2, H: 1, S: 0, N: 4 }; + const DIR_TO_HAND = { north: 0, east: 1, south: 2, west: 3 }; + + // Suit glyphs are real text in these custom tags (see dds_web.css for color). + const SUIT_TAGS = { + spades: "spade-suit", + hearts: "heart-suit", + diamonds: "diamond-suit", + clubs: "club-suit" + }; + + const SUIT_GLYPHS = { + spades: "\u2660", + hearts: "\u2665", + diamonds: "\u2666", + clubs: "\u2663" + }; + + const PIP_NAMES = { + A: "ace", + K: "king", + Q: "queen", + J: "jack", + T: "ten", + "9": "nine", + "8": "eight", + "7": "seven", + "6": "six", + "5": "five", + "4": "four", + "3": "three", + "2": "two" + }; + + function suitLetter(suit) { + return suit.charAt(0).toUpperCase(); + } + + function suitFromLetter(letter) { + for (const suit of SUITS) { + if (suitLetter(suit) === letter) { + return suit; + } + } + + return undefined; + } + + function Card(suit, pip) { + if (!SUITS.includes(suit)) { + throw new Error("Invalid card suit: " + suit); + } + + const normalizedPip = String(pip).toUpperCase(); + + if (!PIPS.includes(normalizedPip)) { + throw new Error("Invalid card pip: " + pip); + } + + this.suit = suit; + this.pip = normalizedPip; + } + + Card.prototype.key = function () { + return suitLetter(this.suit) + this.pip; + }; + + Card.prototype.toString = Card.prototype.key; + + Card.fromKey = function (key) { + if (typeof key !== "string" || key.length !== 2) { + throw new Error("Invalid card key: " + key); + } + + const normalized = key.toUpperCase(); + const suit = suitFromLetter(normalized.charAt(0)); + + if (!suit) { + throw new Error("Invalid card key: " + key); + } + + return new Card(suit, normalized.charAt(1)); + }; + + function cardFromKeySafe(key) { + try { + return Card.fromKey(key); + } catch (_err) { + return null; + } + } + + Card.compare = function (left, right) { + return PIPS.indexOf(left.pip) - PIPS.indexOf(right.pip); + }; + + function suitTag(suit) { + return SUIT_TAGS[suit]; + } + + function suitSymbolHtml(suit) { + const tag = suitTag(suit); + + return "<" + tag + ">" + SUIT_GLYPHS[suit] + ""; + } + + function handsToPbn(hands) { + const handStrings = DIRECTIONS.map((direction) => { + return SUITS.map((suit) => { + return hands[direction] + .filter((card) => card.suit === suit) + .sort(Card.compare) + .map((card) => card.pip) + .join(""); + }).join("."); + }); + return "N:" + handStrings.join(" "); + } + + function openingLeader(declarerDirection) { + const index = DIRECTIONS.indexOf(declarerDirection); + + if (index < 0) { + return null; + } + + return DIRECTIONS[(index + 1) % 4]; + } + + function pipFromDdsRank(rank) { + if (rank === 14) { + return "A"; + } + if (rank === 13) { + return "K"; + } + if (rank === 12) { + return "Q"; + } + if (rank === 11) { + return "J"; + } + if (rank === 10) { + return "T"; + } + if (rank >= 2 && rank <= 9) { + return String(rank); + } + + return null; + } + + function leadTricksMapFromSolverOutput(out) { + const map = {}; + const n = out[0] | 0; + + for (let i = 0; i < n; i++) { + const suitIndex = out[1 + 3 * i]; + const rank = out[1 + 3 * i + 1]; + const score = out[1 + 3 * i + 2]; + const suit = SUITS[suitIndex]; + const pip = pipFromDdsRank(rank); + + if (suit && pip) { + map[suitLetter(suit) + pip] = score; + } + } + + return map; + } + + function fourthHandFillState(hands) { + const handCounts = DIRECTIONS.map((direction) => hands[direction].length); + const fullHands = handCounts.filter((count) => count === 13).length; + const emptyHands = handCounts.filter((count) => count === 0).length; + const partialHands = handCounts.filter((count) => count > 0 && count < 13).length; + + if (fullHands !== 3 || emptyHands !== 1 || partialHands > 0) { + return { canFill: false }; + } + + const emptyHand = DIRECTIONS[handCounts.indexOf(0)]; + const usedCards = {}; + + for (const direction of DIRECTIONS) { + if (direction === emptyHand) { + continue; + } + + for (const card of hands[direction]) { + if (!card || !SUITS.includes(card.suit) || !PIPS.includes(card.pip)) { + return { canFill: false }; + } + + usedCards[card.key()] = true; + } + } + + // Three full hands hold 39 cards; fewer distinct keys means a duplicate, + // so the remaining 13 cannot be dealt to the empty hand. + if (Object.keys(usedCards).length !== 39) { + return { canFill: false }; + } + + return { canFill: true, emptyHand, usedCards }; + } + + function cardsToSuitHoldings(cards) { + const holdings = {}; + + for (const suit of SUITS) { + holdings[suit] = ""; + } + + for (const card of cards) { + holdings[card.suit] += card.pip; + } + + for (const suit of SUITS) { + holdings[suit] = holdings[suit] + .split("") + .sort((a, b) => PIPS.indexOf(a) - PIPS.indexOf(b)) + .join(""); + } + + return holdings; + } + + function sortedPipInsertIndex(holding, pip) { + const rank = PIPS.indexOf(pip); + + for (let i = 0; i < holding.length; i++) { + if (PIPS.indexOf(holding.charAt(i)) > rank) { + return i; + } + } + + return holding.length; + } + + function allHandsHaveThirteenCards(hands) { + return DIRECTIONS.every((direction) => hands[direction].length === 13); + } + + function sanitizeSuitHolding(value, claimedKeys, suit, maxPips) { + if (value == null) { + return ""; + } + + const pips = []; + const seen = {}; + + for (const ch of String(value)) { + const pip = ch.toUpperCase(); + + if (!PIPS.includes(pip) || seen[pip]) { + continue; + } + + if (claimedKeys && suit) { + const key = new Card(suit, pip).key(); + + if (claimedKeys[key]) { + continue; + } + } + + seen[pip] = true; + pips.push(pip); + } + + pips.sort((left, right) => PIPS.indexOf(left) - PIPS.indexOf(right)); + + if (typeof maxPips === "number" && maxPips >= 0 && pips.length > maxPips) { + return pips.slice(0, maxPips).join(""); + } + + return pips.join(""); + } + + function suitHoldingHasDuplicatePips(value) { + if (value == null) { + return false; + } + + const seen = {}; + + for (const ch of String(value)) { + const pip = ch.toUpperCase(); + + if (!PIPS.includes(pip)) { + continue; + } + + if (seen[pip]) { + return true; + } + + seen[pip] = true; + } + + return false; + } + + function suitHoldingHasIllegalChars(value) { + if (value == null) { + return false; + } + + for (const ch of String(value)) { + if (!PIPS.includes(ch.toUpperCase())) { + return true; + } + } + + return false; + } + + global.DIRECTIONS = DIRECTIONS; + global.SUITS = SUITS; + global.PIPS = PIPS; + global.DENOMINATIONS = DENOMINATIONS; + global.DENOM_TO_STRAIN = DENOM_TO_STRAIN; + global.DIR_TO_HAND = DIR_TO_HAND; + global.SUIT_TAGS = SUIT_TAGS; + global.SUIT_GLYPHS = SUIT_GLYPHS; + global.PIP_NAMES = PIP_NAMES; + global.suitLetter = suitLetter; + global.suitFromLetter = suitFromLetter; + global.Card = Card; + global.cardFromKeySafe = cardFromKeySafe; + global.suitTag = suitTag; + global.suitSymbolHtml = suitSymbolHtml; + global.handsToPbn = handsToPbn; + global.openingLeader = openingLeader; + global.pipFromDdsRank = pipFromDdsRank; + global.leadTricksMapFromSolverOutput = leadTricksMapFromSolverOutput; + global.fourthHandFillState = fourthHandFillState; + global.cardsToSuitHoldings = cardsToSuitHoldings; + global.sortedPipInsertIndex = sortedPipInsertIndex; + global.allHandsHaveThirteenCards = allHandsHaveThirteenCards; + global.sanitizeSuitHolding = sanitizeSuitHolding; + global.suitHoldingHasDuplicatePips = suitHoldingHasDuplicatePips; + global.suitHoldingHasIllegalChars = suitHoldingHasIllegalChars; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/web/dds_web_solve.js b/web/dds_web_solve.js new file mode 100644 index 000000000..827377faf --- /dev/null +++ b/web/dds_web_solve.js @@ -0,0 +1,576 @@ +// Copyright 2020-2026 Adam Wildavsky +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT + +// Solver session for DDS Web: queue, WASM load, DD table, opening leads. +// Loaded after dds_web_core.js and before dds_web.js (UI). + +/* eslint-env es6 */ +/* exported scheduleDealSolve setDealSolveDebounceMs invalidateActiveDdTableRequest + wasmSolveEnvironmentError loadDdsModule refreshDdTable refreshOpeningLeadTricks + clear_results setDdTableComputingDelayMs paintStatusFrame formatSolveTimeMs + sendJSON */ + +"use strict"; + +(function (global) { + + global.selectedContractState = null; + global.leadTricksByCardKey = null; + global.leadTricksRequestId = 0; + global.ddTableRequestId = 0; + global.ddTableComputingTimer = null; + global.lastDdTablePbn = null; + global.solveQueue = Promise.resolve(); + global.dealSolveEpoch = 0; + global.dealSolveQueued = false; + global.dealSolvePending = false; + global.dealSolveDebounceMs = 250; + global.dealSolveDebounceTimer = null; + global.lastDealWasComplete = false; + global.ddsModulePromise = null; + global.ddTableComputingDelayMs = 300; + + function enqueueSolve(task) { + const run = solveQueue.then(task, task); + + // Keep the queue alive after a rejected solve. + solveQueue = run.catch(() => {}); + return run; + } + + function setDealSolveDebounceMs(ms) { + dealSolveDebounceMs = ms; + + // Disabling debounce must not leave a previously scheduled trailing solve + // to fire later with the old delay. + if (ms <= 0 && dealSolveDebounceTimer != null) { + clearTimeout(dealSolveDebounceTimer); + dealSolveDebounceTimer = null; + } + } + + function scheduleDealSolveDebounced() { + if (dealSolveDebounceTimer != null) { + clearTimeout(dealSolveDebounceTimer); + dealSolveDebounceTimer = null; + } + + if (dealSolveDebounceMs <= 0) { + void global.scheduleDealSolve(); + return; + } + + dealSolveDebounceTimer = setTimeout(() => { + dealSolveDebounceTimer = null; + void global.scheduleDealSolve(); + }, dealSolveDebounceMs); + } + + // Coalesce DD-table + lead solves onto one queued job so rapid hand edits and + // contract clicks cannot interleave CalcDDtable with SolveBoard, and so + // intermediate schedules do not each add a stale promise-chain callback. + function scheduleDealSolve() { + // A direct schedule (contract click, etc.) supersedes a pending debounced + // hand-edit solve so we do not fire a redundant trailing job afterward. + if (dealSolveDebounceTimer != null) { + clearTimeout(dealSolveDebounceTimer); + dealSolveDebounceTimer = null; + } + + dealSolveEpoch += 1; + dealSolvePending = true; + + if (dealSolveQueued) { + return solveQueue; + } + + dealSolveQueued = true; + return global.enqueueSolve(async () => { + try { + while (true) { + const epoch = dealSolveEpoch; + dealSolvePending = false; + + await global.refreshDdTable(); + if (epoch !== dealSolveEpoch) { + // Invalidation alone must not restart; only a newer + // scheduleDealSolve (pending) should continue. A pending + // debounce will start a fresh job when it fires. + if (dealSolvePending) { + continue; + } + break; + } + + if (selectedContractState) { + await global.refreshOpeningLeadTricks(); + } else if (leadTricksByCardKey) { + leadTricksByCardKey = null; + global.updateHandCardDisplays(global.collectHands()); + } + + // Stale+pending → another iteration; else exit (success or + // invalidate). Gate release lives in finally. + if (epoch !== dealSolveEpoch && dealSolvePending) { + continue; + } + break; + } + } finally { + const restart = dealSolvePending; + dealSolveQueued = false; + if (restart) { + void global.scheduleDealSolve(); + } + } + }); + } + + function wasmSolveEnvironmentError() { + if (typeof location === "undefined" || !location) { + return null; + } + + if (location.protocol === "file:") { + return "Solving needs HTTP with cross-origin isolation. " + + "From the repo root run: python3 web/serve_web.py"; + } + + if (typeof SharedArrayBuffer === "undefined") { + return "Solving needs SharedArrayBuffer (cross-origin isolation). " + + "Serve responses with Cross-Origin-Opener-Policy: same-origin and " + + "Cross-Origin-Embedder-Policy: require-corp " + + "(locally: python3 web/serve_web.py)."; + } + + return null; + } + + function loadDdsModule() { + if (typeof createDdsModule !== "function") { + return Promise.reject(new Error( + "WASM module not found. From the repo root run: ./web/update_wasm.sh" + )); + } + + if (typeof ddsWebWasmBytes !== "function") { + return Promise.reject(new Error( + "WASM bytes not found. From the repo root run: ./web/update_wasm.sh" + )); + } + + const envError = global.wasmSolveEnvironmentError(); + + if (envError) { + return Promise.reject(new Error(envError)); + } + + if (!ddsModulePromise) { + ddsModulePromise = createDdsModule({ + wasmBinary: ddsWebWasmBytes() + }).catch((error) => { + // Allow retry after transient initialization failures. + ddsModulePromise = null; + throw error; + }); + } + + return ddsModulePromise; + } + + function invalidateActiveDdTableRequest() { + ddTableRequestId += 1; + leadTricksRequestId += 1; + dealSolveEpoch += 1; + // Drop a coalesced direct-schedule flag so the worker does not immediately + // continue after this invalidate; a following scheduleDealSolve() sets it + // again, while a debounced schedule sets it when its timer fires. + dealSolvePending = false; + global.clearDdTableComputingTimer(); + if (dealSolveDebounceTimer != null) { + clearTimeout(dealSolveDebounceTimer); + dealSolveDebounceTimer = null; + } + const result = document.getElementById("result"); + // Drop a painted Computing… for the abandoned request; keep solved/error text. + if (result && /Computing/i.test(String(result.innerHTML || ""))) { + result.innerHTML = ""; + } + } + + async function solveOpeningLeadTricks(hands, contract) { + const leader = openingLeader(contract.direction); + const trump = DENOM_TO_STRAIN[contract.denomination]; + const first = DIR_TO_HAND[leader]; + + if (trump == null || first == null) { + throw new Error("Invalid contract for lead analysis"); + } + + const module = await global.loadDdsModule(); + const pbn = global.handsToPbn(hands); + const outPtr = module._malloc((1 + 13 * 3) * 4); + + try { + const rc = module.ccall( + "dds_web_solve_leads", + "number", + ["string", "number", "number", "number"], + [pbn, trump, first, outPtr] + ); + + if (rc !== 1) { + throw new Error("DDS lead solve error (code " + rc + ")"); + } + + const n = module.getValue(outPtr, "i32"); + if (n < 0 || n > 13) { + throw new Error( + "DDS lead solve returned invalid card count (" + n + ")" + ); + } + const out = [n]; + + for (let i = 0; i < n; i++) { + const base = outPtr + (1 + 3 * i) * 4; + out.push(module.getValue(base, "i32")); + out.push(module.getValue(base + 4, "i32")); + out.push(module.getValue(base + 8, "i32")); + } + + return leadTricksMapFromSolverOutput(out); + } finally { + module._free(outPtr); + } + } + + async function refreshOpeningLeadTricks() { + const requestId = ++leadTricksRequestId; + const contract = selectedContractState; + const hands = global.collectHands(); + + if (!contract || global.inputIsValid(hands).length) { + leadTricksByCardKey = null; + if (requestId === leadTricksRequestId) { + global.updateHandCardDisplays(hands); + } + return; + } + + try { + const map = await global.solveOpeningLeadTricks(hands, contract); + + if (requestId !== leadTricksRequestId) { + return; + } + + leadTricksByCardKey = map; + global.updateHandCardDisplays(global.collectHands()); + } catch (err) { + if (requestId !== leadTricksRequestId) { + return; + } + + leadTricksByCardKey = null; + global.updateHandCardDisplays(global.collectHands()); + + const result = document.getElementById("result"); + + if (result) { + result.innerHTML = err instanceof Error + ? err.message + : err == null + ? "Unknown error" + : String(err); + } + } + } + + function clear_results() { + var result = document.getElementById("result"); + var result_table = document.getElementById("result-table"); + + global.clearDdTableComputingTimer(); + lastDdTablePbn = null; + result.innerHTML = ""; + + for (var row = 1; row <= 4; row++) { + for (var column = 1; column <= 5; column++) { + var cell = result_table.rows[row].cells[column]; + cell.innerHTML = ""; + } + } + } + + /** Delay before showing Computing… under the DD matrix (see refreshDdTable). */ + + function setDdTableComputingDelayMs(ms) { + ddTableComputingDelayMs = ms; + } + + function clearDdTableComputingTimer() { + if (ddTableComputingTimer != null) { + clearTimeout(ddTableComputingTimer); + ddTableComputingTimer = null; + } + } + + function scheduleDdTableComputingMessage(requestId, result) { + global.clearDdTableComputingTimer(); + if (ddTableComputingDelayMs <= 0) { + if (result) { + result.innerHTML = "Computing…"; + } + return; + } + ddTableComputingTimer = setTimeout(() => { + ddTableComputingTimer = null; + if (requestId !== ddTableRequestId || !result) { + return; + } + result.innerHTML = "Computing…"; // horizontal ellipsis + }, ddTableComputingDelayMs); + } + + /** Yield until the browser has painted the current status (needed before sync ccall). */ + function paintStatusFrame() { + // Hidden tabs often pause rAF; do not block the solve queue forever. + if ( + typeof document !== "undefined" + && document.visibilityState === "hidden" + ) { + return Promise.resolve(); + } + if (typeof requestAnimationFrame === "function") { + return new Promise((resolve) => { + let settled = false; + const done = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(fallbackTimer); + resolve(); + }; + // If the tab hides mid-wait, the second rAF may never run. + const fallbackTimer = setTimeout(done, 50); + requestAnimationFrame(() => { + if ( + typeof document !== "undefined" + && document.visibilityState === "hidden" + ) { + done(); + return; + } + requestAnimationFrame(done); + }); + }); + } + return new Promise((resolve) => setTimeout(resolve, 16)); + } + + /** + * Show Computing… and wait for a paint. The WASM ccall is synchronous and + * blocks timers, so this must run before ccall or the message is never seen. + */ + async function showComputingStatus(requestId, result) { + global.clearDdTableComputingTimer(); + if (requestId !== ddTableRequestId || !result) { + return false; + } + result.innerHTML = "Computing…"; // horizontal ellipsis + await global.paintStatusFrame(); + return requestId === ddTableRequestId; + } + + /** Format wall elapsed time for the status line (whole milliseconds). */ + function formatSolveTimeMs(elapsedMs) { + return "Solved in " + Math.round(elapsedMs) + " ms."; + } + + async function refreshDdTable() { + const requestId = ++ddTableRequestId; + const result = document.getElementById("result"); + const result_table = document.getElementById("result-table"); + const hands = global.collectHands(); + + if (!global.allHandsHaveThirteenCards(hands)) { + if (requestId === ddTableRequestId) { + lastDdTablePbn = null; + global.clear_results(); + } + return; + } + + const error_message = global.inputIsValid(hands); + + if (error_message.length) { + if (requestId === ddTableRequestId) { + lastDdTablePbn = null; + global.clear_results(); + if (result) { + result.innerHTML = error_message; + } + } + return; + } + + const pbn = global.handsToPbn(hands); + + if (pbn === lastDdTablePbn && global.ddTableLooksPopulated(result_table)) { + return; + } + + if (requestId !== ddTableRequestId) { + return; + } + + global.clear_results(); + // Computing… grace / pre-ccall paint tradeoff (intentional until CalcTable + // runs off the main thread): + // The WASM ccall is synchronous and blocks timers and rAF, so a timer-only + // Computing… message can never appear during a long solve. Painting before + // ccall is the only way to show status while the UI is frozen. That means + // uncached solves wait for any remaining grace period and briefly show + // Computing… even when ccall itself would be fast — a minimum-latency tax + // preferred over silent multi-second freezes. Module load time counts + // toward the grace. Tests set the delay to 0. + global.scheduleDdTableComputingMessage(requestId, result); + const waitStartedAt = performance.now(); + + try { + const module = await global.loadDdsModule(); + const outPtr = module._malloc(20 * 4); + + try { + const remainingMs = + ddTableComputingDelayMs - (performance.now() - waitStartedAt); + if (remainingMs > 0) { + await new Promise((resolve) => setTimeout(resolve, remainingMs)); + if (requestId !== ddTableRequestId) { + return; + } + } + + // An import/edit during the grace wait can change the diagram while + // this invocation still holds the old PBN; do not solve stale input. + if (global.handsToPbn(global.collectHands()) !== pbn) { + global.clearDdTableComputingTimer(); + if (requestId === ddTableRequestId && result) { + result.innerHTML = ""; + } + return; + } + + if (!(await global.showComputingStatus(requestId, result))) { + return; + } + + if (global.handsToPbn(global.collectHands()) !== pbn) { + global.clearDdTableComputingTimer(); + if (requestId === ddTableRequestId && result) { + result.innerHTML = ""; + } + return; + } + + const startedAt = performance.now(); + const rc = module.ccall( + "dds_web_calc_table", + "number", + ["string", "number"], + [pbn, outPtr] + ); + const elapsedMs = performance.now() - startedAt; + + if (requestId !== ddTableRequestId) { + return; + } + + global.clearDdTableComputingTimer(); + + if (rc !== 1) { + lastDdTablePbn = null; + if (result) { + result.innerHTML = "DDS error (code " + rc + ")."; + } + return; + } + + for (var row = 1; row <= 4; row++) { + for (var column = 1; column <= 5; column++) { + const cell = result_table.rows[row].cells[column]; + const denomination = DENOMINATIONS[column - 1]; + const direction = DIRECTIONS[row - 1]; + const strain = DENOM_TO_STRAIN[denomination]; + const hand = DIR_TO_HAND[direction]; + const index = strain * 4 + hand; + cell.innerHTML = module.getValue( + outPtr + index * 4, + "i32" + ); + } + } + + lastDdTablePbn = pbn; + + if (result) { + result.innerHTML = global.formatSolveTimeMs(elapsedMs); + } + } finally { + module._free(outPtr); + } + } catch (err) { + if (requestId !== ddTableRequestId) { + return; + } + + lastDdTablePbn = null; + global.clear_results(); + if (result) { + result.innerHTML = err instanceof Error + ? err.message + : err == null + ? "Unknown error" + : String(err); + } + } + } + + function ddTableLooksPopulated(result_table) { + if (!result_table || !result_table.rows || !result_table.rows[1]) { + return false; + } + + const cell = result_table.rows[1].cells[1]; + + return !!(cell && cell.innerHTML && /\d/.test(String(cell.innerHTML))); + } + + function sendJSON() { + return global.refreshDdTable(); + } + + global.enqueueSolve = enqueueSolve; + global.setDealSolveDebounceMs = setDealSolveDebounceMs; + global.scheduleDealSolveDebounced = scheduleDealSolveDebounced; + global.scheduleDealSolve = scheduleDealSolve; + global.wasmSolveEnvironmentError = wasmSolveEnvironmentError; + global.loadDdsModule = loadDdsModule; + global.invalidateActiveDdTableRequest = invalidateActiveDdTableRequest; + global.solveOpeningLeadTricks = solveOpeningLeadTricks; + global.refreshOpeningLeadTricks = refreshOpeningLeadTricks; + global.clear_results = clear_results; + global.setDdTableComputingDelayMs = setDdTableComputingDelayMs; + global.clearDdTableComputingTimer = clearDdTableComputingTimer; + global.scheduleDdTableComputingMessage = scheduleDdTableComputingMessage; + global.paintStatusFrame = paintStatusFrame; + global.showComputingStatus = showComputingStatus; + global.formatSolveTimeMs = formatSolveTimeMs; + global.refreshDdTable = refreshDdTable; + global.ddTableLooksPopulated = ddTableLooksPopulated; + global.sendJSON = sendJSON; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/web/stage_github_pages.py b/web/stage_github_pages.py index e65254fef..a73e95723 100644 --- a/web/stage_github_pages.py +++ b/web/stage_github_pages.py @@ -16,6 +16,8 @@ "dds_web.css", "dds_web.js", "dds_web_deal_import.js", + "dds_web_core.js", + "dds_web_solve.js", "coi-serviceworker.js", "dds_web_wasm.js", "dds_web_wasm.wasm", diff --git a/web/tests/dds_web_test.mjs b/web/tests/dds_web_test.mjs index 7c6ef12e3..4a861a47b 100644 --- a/web/tests/dds_web_test.mjs +++ b/web/tests/dds_web_test.mjs @@ -1,6 +1,5 @@ /** - * Unit tests for web/dds_web.js and web/dds_web_deal_import.js - * (Node built-in test runner). + * Unit tests for DDS Web JS layers (Node built-in test runner). * * Run with: * bazelisk test //web:dds_web_js_test @@ -51,6 +50,14 @@ function findDdsWebDealImportJsPath() { return findWebJsPath("dds_web_deal_import.js", "DDS_WEB_DEAL_IMPORT_JS"); } +function findDdsWebCoreJsPath() { + return findWebJsPath("dds_web_core.js", "DDS_WEB_CORE_JS"); +} + +function findDdsWebSolveJsPath() { + return findWebJsPath("dds_web_solve.js", "DDS_WEB_SOLVE_JS"); +} + /** Reject if `promise` does not settle within `ms` (clears the timer either way). */ function withTimeout(promise, ms, message) { let timer; @@ -248,9 +255,43 @@ function loadDealImport(extras = {}) { return context; } +function loadDdsWebCore(extras = {}) { + const code = readFileSync(findDdsWebCoreJsPath(), "utf8"); + const sandbox = { + console, + Promise, + Error, + ...extras, + }; + const context = createContext(sandbox); + runInContext(code, context, { filename: "dds_web_core.js" }); + return context; +} + +function runDdsWebScripts(context) { + runInContext( + readFileSync(findDdsWebDealImportJsPath(), "utf8"), + context, + { filename: "dds_web_deal_import.js" } + ); + runInContext( + readFileSync(findDdsWebCoreJsPath(), "utf8"), + context, + { filename: "dds_web_core.js" } + ); + runInContext( + readFileSync(findDdsWebSolveJsPath(), "utf8"), + context, + { filename: "dds_web_solve.js" } + ); + runInContext( + readFileSync(findDdsWebJsPath(), "utf8"), + context, + { filename: "dds_web.js" } + ); +} + function loadDdsWeb(document, extras = {}) { - const importCode = readFileSync(findDdsWebDealImportJsPath(), "utf8"); - const code = readFileSync(findDdsWebJsPath(), "utf8"); const sandbox = { document, console, @@ -269,8 +310,7 @@ function loadDdsWeb(document, extras = {}) { ...extras, }; const context = createContext(sandbox); - runInContext(importCode, context, { filename: "dds_web_deal_import.js" }); - runInContext(code, context, { filename: "dds_web.js" }); + runDdsWebScripts(context); // Existing tests expect hand edits to schedule immediately; debounce is // covered by dedicated tests that opt into a non-zero delay. if (typeof context.setDealSolveDebounceMs === "function") { @@ -1019,8 +1059,6 @@ test("loadDdsModule rejects missing wasm globals", async () => { test("wasmSolveEnvironmentError explains file:// cannot load WASM workers", () => { // Arrange: browser opened as a local file (origin null). - const importCode = readFileSync(findDdsWebDealImportJsPath(), "utf8"); - const code = readFileSync(findDdsWebJsPath(), "utf8"); const sandbox = { document: createMockDocument(), console, @@ -1030,8 +1068,7 @@ test("wasmSolveEnvironmentError explains file:// cannot load WASM workers", () = location: { protocol: "file:" }, }; const context = createContext(sandbox); - runInContext(importCode, context, { filename: "dds_web_deal_import.js" }); - runInContext(code, context, { filename: "dds_web.js" }); + runDdsWebScripts(context); // Act / Assert assert.match( @@ -1042,8 +1079,6 @@ test("wasmSolveEnvironmentError explains file:// cannot load WASM workers", () = test("wasmSolveEnvironmentError explains missing SharedArrayBuffer headers", () => { // Arrange: HTTPS page without cross-origin isolation (no SAB). - const importCode = readFileSync(findDdsWebDealImportJsPath(), "utf8"); - const code = readFileSync(findDdsWebJsPath(), "utf8"); const sandbox = { document: createMockDocument(), console, @@ -1054,8 +1089,7 @@ test("wasmSolveEnvironmentError explains missing SharedArrayBuffer headers", () SharedArrayBuffer: undefined, }; const context = createContext(sandbox); - runInContext(importCode, context, { filename: "dds_web_deal_import.js" }); - runInContext(code, context, { filename: "dds_web.js" }); + runDdsWebScripts(context); // Act const message = context.wasmSolveEnvironmentError(); @@ -3785,6 +3819,27 @@ test("dds_web_deal_import.js exports parseFirstDealFromText without dds_web.js", assert.equal(deal.west, ".KJ.QT9.A8765432"); }); +test("dds_web_core.js exports Card and handsToPbn without UI or solve", () => { + // Arrange / Act: load only the deal-model script. + const ctx = loadDdsWebCore(); + const card = new ctx.Card("hearts", "K"); + const pbn = ctx.handsToPbn({ + north: cardsFromKeys(ctx, ["SA", "SK", "SQ", "SJ", "ST", "S9", "S8", "S7", "S6", "S5", "S4", "S3", "S2"]), + east: cardsFromKeys(ctx, ["HA", "HK", "HQ", "HJ", "HT", "H9", "H8", "H7", "H6", "H5", "H4", "H3", "H2"]), + south: cardsFromKeys(ctx, ["DA", "DK", "DQ", "DJ", "DT", "D9", "D8", "D7", "D6", "D5", "D4", "D3", "D2"]), + west: cardsFromKeys(ctx, ["CA", "CK", "CQ", "CJ", "CT", "C9", "C8", "C7", "C6", "C5", "C4", "C3", "C2"]), + }); + + // Assert: each hand is a solid suit (dotted voids for the other three). + assert.equal(card.key(), "HK"); + assert.equal( + pbn, + "N:AKQJT98765432... .AKQJT98765432.. ..AKQJT98765432. ...AKQJT98765432" + ); + assert.equal(ctx.openingLeader("south"), "west"); + assert.equal(ctx.pipFromDdsRank(14), "A"); +}); + function assertImportedDeal(ctx, document, expected) { assert.equal(document.element("north_spades").value, expected.north[0]); assert.equal(document.element("north_hearts").value, expected.north[1]); diff --git a/web/tests/test_dds_web_js.py b/web/tests/test_dds_web_js.py index abdb7b0ef..0beea36f4 100644 --- a/web/tests/test_dds_web_js.py +++ b/web/tests/test_dds_web_js.py @@ -57,9 +57,13 @@ def test_dds_web_js(self) -> None: test_script = rlocation("web/tests/dds_web_test.mjs") dds_web_js = rlocation("web/dds_web.js") dds_web_deal_import_js = rlocation("web/dds_web_deal_import.js") + dds_web_core_js = rlocation("web/dds_web_core.js") + dds_web_solve_js = rlocation("web/dds_web_solve.js") env = os.environ.copy() env["DDS_WEB_JS"] = str(dds_web_js) env["DDS_WEB_DEAL_IMPORT_JS"] = str(dds_web_deal_import_js) + env["DDS_WEB_CORE_JS"] = str(dds_web_core_js) + env["DDS_WEB_SOLVE_JS"] = str(dds_web_solve_js) try: proc = subprocess.run( [node, "--test", str(test_script)], diff --git a/web/tests/test_stage_github_pages.py b/web/tests/test_stage_github_pages.py index f5fc7739f..559df80f4 100644 --- a/web/tests/test_stage_github_pages.py +++ b/web/tests/test_stage_github_pages.py @@ -30,6 +30,8 @@ def test_stage_copies_site_wasm_coi_and_index(self) -> None: "dds_web.css", "dds_web.js", "dds_web_deal_import.js", + "dds_web_core.js", + "dds_web_solve.js", "coi-serviceworker.js", "dds_web_wasm.js", "dds_web_wasm.wasm", @@ -47,6 +49,8 @@ def test_stage_copies_site_wasm_coi_and_index(self) -> None: "dds_web.css", "dds_web.js", "dds_web_deal_import.js", + "dds_web_core.js", + "dds_web_solve.js", "coi-serviceworker.js", "dds_web_wasm.js", "dds_web_wasm.wasm", @@ -79,6 +83,8 @@ def test_deploy_file_list_matches_static_plus_wasm(self) -> None: stage = _load_stage_github_pages() self.assertIn("coi-serviceworker.js", stage.DEPLOY_FILES) self.assertIn("dds_web_deal_import.js", stage.DEPLOY_FILES) + self.assertIn("dds_web_core.js", stage.DEPLOY_FILES) + self.assertIn("dds_web_solve.js", stage.DEPLOY_FILES) self.assertIn("dds_web_wasm_bin.js", stage.DEPLOY_FILES) self.assertNotIn("index.html", stage.DEPLOY_FILES) diff --git a/web/tests/test_web_html.py b/web/tests/test_web_html.py index 7ff4acc79..3726a776a 100644 --- a/web/tests/test_web_html.py +++ b/web/tests/test_web_html.py @@ -102,6 +102,25 @@ def test_loads_deal_import_script_before_dds_web_js(self) -> None: self.assertLess(wasm_at, import_at) self.assertLess(import_at, app_at) + def test_loads_core_and_solve_scripts_before_dds_web_js(self) -> None: + # Deal model (core) and WASM/queue (solve) load before UI wiring. + text = HTML_PATH.read_text(encoding="utf-8") + self.assertRegex( + text, + r'\s*', + ) + self.assertRegex( + text, + r'\s*', + ) + import_at = text.index('src="dds_web_deal_import.js"') + core_at = text.index('src="dds_web_core.js"') + solve_at = text.index('src="dds_web_solve.js"') + app_at = text.index('src="dds_web.js"') + self.assertLess(import_at, core_at) + self.assertLess(core_at, solve_at) + self.assertLess(solve_at, app_at) + def test_disables_coep_credentialless_before_coi_serviceworker(self) -> None: # coi-serviceworker defaults to COEP: credentialless. Safari / iOS WebKit # (including Firefox on iPhone) do not honor that value for isolation, so diff --git a/web/tests/web_site.py b/web/tests/web_site.py index f7a58d7af..893436bfd 100644 --- a/web/tests/web_site.py +++ b/web/tests/web_site.py @@ -12,6 +12,8 @@ "dds_web.css", "dds_web.js", "dds_web_deal_import.js", + "dds_web_core.js", + "dds_web_solve.js", "coi-serviceworker.js", )