From d5725485d0b51656091cd306e20da3331921ef21 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sun, 13 Sep 2026 23:57:28 +0200 Subject: [PATCH 01/12] Add an Import deal button for PBN, LIN, DLM, and dtest hand lists. Load the first deal from the chosen file into the diagram so users need not retype holdings. Co-authored-by: Cursor --- web/dds_web.html | 6 + web/dds_web.js | 287 +++++++++++++++++++++++++++++++++++++ web/tests/dds_web_test.mjs | 116 +++++++++++++++ web/tests/test_web_html.py | 10 ++ 4 files changed, 419 insertions(+) diff --git a/web/dds_web.html b/web/dds_web.html index 29c1bbd65..04fdac340 100644 --- a/web/dds_web.html +++ b/web/dds_web.html @@ -34,6 +34,12 @@

+ + diff --git a/web/dds_web.js b/web/dds_web.js index ae1bb1ac8..d3e20f63a 100644 --- a/web/dds_web.js +++ b/web/dds_web.js @@ -67,6 +67,10 @@ leadTricksMapFromSolverOutput wasmSolveEnvironmentError formatSolveTimeMs + parseFirstDealFromText + importDealFromText + chooseDealFile + handleDealFileSelected */ // It's also useful to pass the code through @@ -388,6 +392,289 @@ 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(""); +} + +function normalizeHandHolding(dotted) { + const parts = String(dotted).split("."); + while (parts.length < 4) { + parts.push(""); + } + return parts.slice(0, 4).map(sortPips).join("."); +} + +function emptySuitHoldings() { + return { S: "", H: "", D: "", C: "" }; +} + +function holdingsToDotted(holdings) { + return SUIT_LETTERS.map((suit) => sortPips(holdings[suit] || "")).join("."); +} + +function dealFromDirectionMap(byDirection) { + const deal = {}; + for (const direction of DIRECTIONS) { + if (!byDirection[direction]) { + return null; + } + deal[direction] = normalizeHandHolding(byDirection[direction]); + } + 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 (suit && PIPS.includes(upper)) { + holdings[suit] += upper; + } + } + return holdingsToDotted(holdings); +} + +function parseLinDealPayload(payload) { + // md|,,,[] + const body = String(payload).replace(/^\d/, ""); + const parts = body.split(","); + if (parts.length < 3) { + 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 owners = [ + firstOwner.charAt(code), + secondOwner.charAt(code), + ]; + const cards = [firstCard, secondCard]; + for (let j = 0; j < 2; j++) { + const direction = DIR_FROM_LETTER[owners[j]]; + byDirection[direction][cards[j].charAt(0)] += cards[j].charAt(1); + } + } + + return dealFromDirectionMap({ + north: holdingsToDotted(byDirection.north), + east: holdingsToDotted(byDirection.east), + south: holdingsToDotted(byDirection.south), + west: holdingsToDotted(byDirection.west), + }); +} + +/** + * Extract the first deal from PBN, LIN, DLM, or dtest .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; + } + } + + throw new Error("No PBN, LIN, DLM, or dtest deal found in the file."); +} + +function importDealFromText(text) { + try { + const deal = parseFirstDealFromText(text); + fillFormWithTestData([ + deal.north, + deal.east, + deal.south, + deal.west, + ]); + return ""; + } catch (err) { + return err && err.message ? err.message : "Could not import deal."; + } +} + +function chooseDealFile() { + const input = document.getElementById("import-deal-file"); + if (!input) { + return; + } + input.value = ""; + input.click(); +} + +async function handleDealFileSelected(input) { + const file = input && input.files && input.files[0]; + if (!file) { + return; + } + + const result = document.getElementById("result"); + try { + const text = await file.text(); + const err = importDealFromText(text); + if (err && result) { + result.innerHTML = err; + } + } catch (err) { + if (result) { + result.innerHTML = err && err.message + ? err.message + : "Could not read the selected file."; + } + } +} + function fillFormWithGrandSlamTestData() { fillFormWithTestData([ "AKQJ.AKQJ.T98.T9", diff --git a/web/tests/dds_web_test.mjs b/web/tests/dds_web_test.mjs index 88f890c16..c35e6aed6 100644 --- a/web/tests/dds_web_test.mjs +++ b/web/tests/dds_web_test.mjs @@ -3548,3 +3548,119 @@ test("handleHandSuitClick does not steal focus from a hand-card click", () => { // Assert assert.equal(focused, false); }); + +const GRAND_SLAM_PBN = + "N:AKQJ.AKQJ.T98.T9 5432.5432.32.432 T98.T9.AKQJ.AKQJ 76.876.7654.8765"; +const EVERYONE_3N_PBN = + 'N:QT9.A8765432.KJ. KJ..A8765432.QT9 A8765432.QT9..KJ .KJ.QT9.A8765432'; +const LIST1_PBN = + "N:Q87.T8.AKJT64.J6 964.AJ765.Q73.74 AKJT2.Q943..AK95 53.K2.9852.QT832"; +const DLM_BOARD_01 = + "Board 01=fnbkmmincldklcfcofoiefnapm018"; +const LIN_DEAL = + "pn|a,b,c,d|st||md|3S27AH3489TD5JC45J,S358QKH56D4KAC3QK,S4JH2JQD2678TC678,|rh||ah|Board 1|sv|o|"; + +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]); + assert.equal(document.element("north_diamonds").value, expected.north[2]); + assert.equal(document.element("north_clubs").value, expected.north[3]); + assert.equal(document.element("east_spades").value, expected.east[0]); + assert.equal(document.element("east_hearts").value, expected.east[1]); + assert.equal(document.element("east_diamonds").value, expected.east[2]); + assert.equal(document.element("east_clubs").value, expected.east[3]); + assert.equal(document.element("south_spades").value, expected.south[0]); + assert.equal(document.element("south_hearts").value, expected.south[1]); + assert.equal(document.element("south_diamonds").value, expected.south[2]); + assert.equal(document.element("south_clubs").value, expected.south[3]); + assert.equal(document.element("west_spades").value, expected.west[0]); + assert.equal(document.element("west_hearts").value, expected.west[1]); + assert.equal(document.element("west_diamonds").value, expected.west[2]); + assert.equal(document.element("west_clubs").value, expected.west[3]); + assert.equal(ctx.inputIsValid(ctx.collectHands()), ""); +} + +test("parseFirstDealFromText reads a PBN Deal tag", () => { + const ctx = loadDdsWeb(createMockDocument()); + const deal = ctx.parseFirstDealFromText(`[Deal "${EVERYONE_3N_PBN}"]`); + 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"); +}); + +test("parseFirstDealFromText uses the first PBN deal when several are present", () => { + const ctx = loadDdsWeb(createMockDocument()); + const text = [ + `[Deal "${LIST1_PBN}"]`, + `[Deal "${EVERYONE_3N_PBN}"]`, + ].join("\n"); + const deal = ctx.parseFirstDealFromText(text); + assert.equal(deal.north, "Q87.T8.AKJT64.J6"); + assert.equal(deal.west, "53.K2.9852.QT832"); +}); + +test("parseFirstDealFromText reads a dtest .txt PBN line", () => { + const ctx = loadDdsWeb(createMockDocument()); + const text = + "NUMBER 1 \n" + + `PBN 1 0 2 0 "${LIST1_PBN}" \n` + + "TABLE 11 2 11 1\n"; + const deal = ctx.parseFirstDealFromText(text); + assert.equal(deal.north, "Q87.T8.AKJT64.J6"); + assert.equal(deal.east, "964.AJ765.Q73.74"); + assert.equal(deal.south, "AKJT2.Q943..AK95"); + assert.equal(deal.west, "53.K2.9852.QT832"); +}); + +test("parseFirstDealFromText reads a LIN md| deal and fills the omitted hand", () => { + const ctx = loadDdsWeb(createMockDocument()); + const deal = ctx.parseFirstDealFromText(LIN_DEAL); + assert.equal(deal.south, "A72.T9843.J5.J54"); + assert.equal(deal.west, "KQ853.65.AK4.KQ3"); + assert.equal(deal.north, "J4.QJ2.T8762.876"); + assert.equal(deal.east, "T96.AK7.Q93.AT92"); +}); + +test("parseFirstDealFromText uses the first LIN deal when several are present", () => { + const ctx = loadDdsWeb(createMockDocument()); + const second = + "md|3S6HKQ65432DAT32C6,SK952H87D965CAJT8,SAQJ73HAJ9DK84C32,|"; + const deal = ctx.parseFirstDealFromText(LIN_DEAL + "\n" + second); + assert.equal(deal.north, "J4.QJ2.T8762.876"); +}); + +test("parseFirstDealFromText reads the first DLM board", () => { + const ctx = loadDdsWeb(createMockDocument()); + const text = [ + "[DOCUMENT]", + "From board=1", + "To board=2", + DLM_BOARD_01, + "Board 02=aaaaaaaeeeeeeeiiiiiiimmmmmmm000", + ].join("\r\n"); + const deal = ctx.parseFirstDealFromText(text); + assert.equal(deal.north, "T53.AJ7.AT.AQ762"); + assert.equal(deal.east, "AKJ9.Q.QJ65.KJT8"); + assert.equal(deal.south, "872.T9543.K9732."); + assert.equal(deal.west, "Q64.K862.84.9543"); +}); + +test("importDealFromText loads a PBN deal into the diagram", () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document); + const err = ctx.importDealFromText(`[Deal "${GRAND_SLAM_PBN}"]`); + assert.equal(err, ""); + assertImportedDeal(ctx, document, { + north: ["AKQJ", "AKQJ", "T98", "T9"], + east: ["5432", "5432", "32", "432"], + south: ["T98", "T9", "AKQJ", "AKQJ"], + west: ["76", "876", "7654", "8765"], + }); +}); + +test("importDealFromText reports when no deal is found", () => { + const ctx = loadDdsWeb(createMockDocument()); + const err = ctx.importDealFromText("not a bridge deal file"); + assert.match(err, /deal/i); +}); diff --git a/web/tests/test_web_html.py b/web/tests/test_web_html.py index 1d73b50c7..998c7a34f 100644 --- a/web/tests/test_web_html.py +++ b/web/tests/test_web_html.py @@ -39,6 +39,16 @@ def test_html_contains_utf8_suit_glyphs(self) -> None: for glyph in ("♠", "♥", "♦", "♣"): self.assertIn(glyph, text) + def test_import_deal_button_and_file_input(self) -> None: + text = HTML_PATH.read_text(encoding="utf-8") + self.assertIn('onclick="chooseDealFile()"', text) + self.assertRegex( + text, + r']*id="import-deal-file"[^>]*type="file"', + ) + self.assertIn("handleDealFileSelected(this)", text) + self.assertIn(".pbn,.lin,.dlm,.txt", text) + class DdsWebHtmlCoiTest(unittest.TestCase): def test_loads_coi_serviceworker_in_head_before_app_scripts(self) -> None: From 5049d41e2b1a3948179491748a657db75dd3b917 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sun, 13 Sep 2026 23:59:38 +0200 Subject: [PATCH 02/12] Accept sol-style .txt deals that omit the PBN seat letter. Files like sol10.txt start with four NESW holdings and an optional :results suffix. Co-authored-by: Cursor --- web/dds_web.js | 31 ++++++++++++++++++++++++++++++- web/tests/dds_web_test.mjs | 27 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/web/dds_web.js b/web/dds_web.js index d3e20f63a..7ad431186 100644 --- a/web/dds_web.js +++ b/web/dds_web.js @@ -579,7 +579,28 @@ function parseDlmBoardPayload(letters) { } /** - * Extract the first deal from PBN, LIN, DLM, or dtest .txt content. + * Parse a sol*.txt style line: four NESW holdings, optional ":results" suffix. + * Example: T5.K4.652.A98542 K6.... AQJ987.8532.84.K:6565... + */ +function parseSolStyleDealLine(line) { + const beforeColon = String(line).split(":")[0].trim(); + if (!beforeColon) { + return null; + } + 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) { @@ -626,6 +647,14 @@ function parseFirstDealFromText(text) { } } + // 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, or dtest deal found in the file."); } diff --git a/web/tests/dds_web_test.mjs b/web/tests/dds_web_test.mjs index c35e6aed6..845454b67 100644 --- a/web/tests/dds_web_test.mjs +++ b/web/tests/dds_web_test.mjs @@ -3659,6 +3659,33 @@ test("importDealFromText loads a PBN deal into the diagram", () => { }); }); +test("parseFirstDealFromText reads a sol-style .txt line without a seat letter", () => { + const ctx = loadDdsWeb(createMockDocument()); + const text = + "T5.K4.652.A98542 K6.QJT976.QT7.Q6 432.A.AKJ93.JT73 AQJ987.8532.84.K:65658888888843433232\n" + + "T98.AKQT4.K853.8 Q6532.8.AJ2.9753 AK.76532.96.QJ62 J74.J9.QT74.AKT4:66769999333376769999\n"; + const deal = ctx.parseFirstDealFromText(text); + assert.equal(deal.north, "T5.K4.652.A98542"); + assert.equal(deal.east, "K6.QJT976.QT7.Q6"); + assert.equal(deal.south, "432.A.AKJ93.JT73"); + assert.equal(deal.west, "AQJ987.8532.84.K"); +}); + +test("importDealFromText loads the first sol-style deal into the diagram", () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document); + const err = ctx.importDealFromText( + "T5.K4.652.A98542 K6.QJT976.QT7.Q6 432.A.AKJ93.JT73 AQJ987.8532.84.K:65658888888843433232\n" + ); + assert.equal(err, ""); + assertImportedDeal(ctx, document, { + north: ["T5", "K4", "652", "A98542"], + east: ["K6", "QJT976", "QT7", "Q6"], + south: ["432", "A", "AKJ93", "JT73"], + west: ["AQJ987", "8532", "84", "K"], + }); +}); + test("importDealFromText reports when no deal is found", () => { const ctx = loadDdsWeb(createMockDocument()); const err = ctx.importDealFromText("not a bridge deal file"); From 7279653e1f7d0cd5b4c7f7d0e863cbdcd78a0f30 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 14 Sep 2026 00:19:38 +0200 Subject: [PATCH 03/12] =?UTF-8?q?Paint=20Computing=E2=80=A6=20after=20300m?= =?UTF-8?q?s=20before=20the=20blocking=20DD-table=20ccall.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A delayed timer alone never runs during sync WASM, so the status must be painted first or long solves only show the final Solved line. Co-authored-by: Cursor --- web/dds_web.js | 83 ++++++++++++++++++++++++++++++++++++-- web/tests/dds_web_test.mjs | 64 ++++++++++++++++++++++++++++- 2 files changed, 143 insertions(+), 4 deletions(-) diff --git a/web/dds_web.js b/web/dds_web.js index 7ad431186..af8b7dfc9 100644 --- a/web/dds_web.js +++ b/web/dds_web.js @@ -71,6 +71,7 @@ importDealFromText chooseDealFile handleDealFileSelected + setDdTableComputingDelayMs */ // It's also useful to pass the code through @@ -91,6 +92,7 @@ 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; @@ -2518,6 +2520,7 @@ function clear_results() { var result = document.getElementById("result"); var result_table = document.getElementById("result-table"); + clearDdTableComputingTimer(); lastDdTablePbn = null; result.innerHTML = ""; @@ -2529,6 +2532,63 @@ function clear_results() { } } +/** Delay before showing Computing… under the DD matrix (avoids fast-solve flash). */ +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() { + if (typeof requestAnimationFrame === "function") { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(resolve); + }); + }); + } + 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."; @@ -2572,15 +2632,30 @@ async function refreshDdTable() { } clear_results(); - if (result) { - result.innerHTML = "Computing…"; // horizontal ellipsis - } + // Keep the status blank during a short grace period. Then paint Computing… + // before the blocking WASM ccall — timers cannot fire while ccall runs, so + // a delayed message alone is never visible during a long sync solve. + 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; + } + } + + if (!(await showComputingStatus(requestId, result))) { + return; + } + const startedAt = performance.now(); const rc = module.ccall( "dds_web_calc_table", @@ -2594,6 +2669,8 @@ async function refreshDdTable() { return; } + clearDdTableComputingTimer(); + if (rc !== 1) { lastDdTablePbn = null; if (result) { diff --git a/web/tests/dds_web_test.mjs b/web/tests/dds_web_test.mjs index 845454b67..077e8c749 100644 --- a/web/tests/dds_web_test.mjs +++ b/web/tests/dds_web_test.mjs @@ -234,6 +234,9 @@ function loadDdsWeb(document, extras = {}) { Error, setTimeout, clearTimeout, + requestAnimationFrame(cb) { + return setTimeout(cb, 0); + }, performance: { now() { return 0; @@ -248,6 +251,10 @@ function loadDdsWeb(document, extras = {}) { if (typeof context.setDealSolveDebounceMs === "function") { context.setDealSolveDebounceMs(0); } + // Same for the Computing… grace period: most tests want an immediate solve. + if (typeof context.setDdTableComputingDelayMs === "function") { + context.setDdTableComputingDelayMs(0); + } return context; } @@ -1518,6 +1525,59 @@ test("formatSolveTimeMs rounds wall time to whole milliseconds", () => { assert.equal(ctx.formatSolveTimeMs(41.9), "Solved in 42 ms."); }); +test("refreshDdTable shows Computing under the matrix only after 300 ms, painted before ccall", async () => { + // Arrange: WASM ccall is sync and blocks timers, so Computing… must be + // painted before ccall — after the 300 ms grace — or the user never sees it. + let ccallSawComputing = false; + const document = createMockDocument(); + const ctx = loadDdsWeb(document, { + requestAnimationFrame(cb) { + return setTimeout(cb, 0); + }, + }); + ctx.setDdTableComputingDelayMs(300); + ctx.loadDdsModule = async () => ({ + _malloc: () => 0, + _free() {}, + ccall() { + ccallSawComputing = /Computing/i.test( + document.element("result").innerHTML + ); + return 1; + }, + getValue() { + return 7; + }, + }); + ctx.fillFormWithTestData([ + "AQ85.AK976.5.J87", + "JT.QJ5432.Q9.KQ9", + "972..JT863.A6432", + "K643.T8.AK742.T5", + ]); + // fillForm schedules a solve; wait for it so it does not race the Act call. + await new Promise((resolve) => setTimeout(resolve, 350)); + // Force a fresh solve (same PBN would otherwise short-circuit as cached). + document.element("result-table").rows[1].cells[1].innerHTML = ""; + + // Act + const solve = ctx.refreshDdTable(); + assert.equal(document.element("result").innerHTML, ""); + + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal( + document.element("result").innerHTML, + "", + "Computing… must wait for the 300 ms grace" + ); + + await solve; + + // Assert + assert.equal(ccallSawComputing, true); + assert.match(document.element("result").innerHTML, /^Solved in \d+ ms\.$/); +}); + test("refreshDdTable shows wall solve time in ms after a successful solve", async () => { // Arrange: full part-score deal; mock WASM and a clock that advances 12.4 ms. let clock = 1000; @@ -1529,7 +1589,6 @@ test("refreshDdTable shows wall solve time in ms after a successful solve", asyn }, }, }); - ctx.fillFormWithPartScoreTestData(); ctx.loadDdsModule = async () => ({ _malloc: () => 0, _free() {}, @@ -1541,6 +1600,9 @@ test("refreshDdTable shows wall solve time in ms after a successful solve", asyn return 7; }, }); + ctx.fillFormWithPartScoreTestData(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); // Act await ctx.refreshDdTable(); From 9e434883c42165054536528ee597724bc00226c3 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 14 Sep 2026 00:35:42 +0200 Subject: [PATCH 04/12] Address Copilot review on deal import and Computing grace races. Pin LIN md| hands as fixed S/W/N/E (dealer digit is not a rotation), abandon stale PBNs before ccall after the grace wait, and mention sol-style in the import error. Co-authored-by: Cursor --- web/dds_web.js | 21 ++++++++++-- web/tests/dds_web_test.mjs | 68 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/web/dds_web.js b/web/dds_web.js index af8b7dfc9..e38593e83 100644 --- a/web/dds_web.js +++ b/web/dds_web.js @@ -516,7 +516,10 @@ function parseLinHand(raw) { function parseLinDealPayload(payload) { // md|,,,[] - const body = String(payload).replace(/^\d/, ""); + // 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(","); if (parts.length < 3) { return null; @@ -657,7 +660,9 @@ function parseFirstDealFromText(text) { } } - throw new Error("No PBN, LIN, DLM, or dtest deal found in the file."); + throw new Error( + "No PBN, LIN, DLM, dtest, or sol-style deal found in the file." + ); } function importDealFromText(text) { @@ -2652,10 +2657,22 @@ async function refreshDdTable() { } } + // 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(); + return; + } + if (!(await showComputingStatus(requestId, result))) { return; } + if (handsToPbn(collectHands()) !== pbn) { + clearDdTableComputingTimer(); + return; + } + const startedAt = performance.now(); const rc = module.ccall( "dds_web_calc_table", diff --git a/web/tests/dds_web_test.mjs b/web/tests/dds_web_test.mjs index 077e8c749..adc352a98 100644 --- a/web/tests/dds_web_test.mjs +++ b/web/tests/dds_web_test.mjs @@ -3684,6 +3684,20 @@ test("parseFirstDealFromText reads a LIN md| deal and fills the omitted hand", ( assert.equal(deal.east, "T96.AK7.Q93.AT92"); }); +test("parseFirstDealFromText keeps LIN hands in S,W,N,E order for every dealer digit", () => { + // BBO md| hands are always South, West, North, East; the leading digit is + // only the dealer (1=S … 4=E), not a rotation of the hand list. + const ctx = loadDdsWeb(createMockDocument()); + const hands = + "SQ953HJ84D6CQ9843,S64HA96DT2CAKJ652,ST82HT5DAKJ743CT7,"; + for (const dealer of ["1", "2", "3", "4"]) { + const deal = ctx.parseFirstDealFromText("md|" + dealer + hands); + assert.equal(deal.south, "Q953.J84.6.Q9843", "dealer " + dealer); + assert.equal(deal.west, "64.A96.T2.AKJ652", "dealer " + dealer); + assert.equal(deal.north, "T82.T5.AKJ743.T7", "dealer " + dealer); + } +}); + test("parseFirstDealFromText uses the first LIN deal when several are present", () => { const ctx = loadDdsWeb(createMockDocument()); const second = @@ -3752,4 +3766,58 @@ test("importDealFromText reports when no deal is found", () => { const ctx = loadDdsWeb(createMockDocument()); const err = ctx.importDealFromText("not a bridge deal file"); assert.match(err, /deal/i); + assert.match(err, /sol/i); +}); + +test("refreshDdTable abandons a stale PBN after the Computing grace period", async () => { + // Arrange: a slow grace period so an import can change the diagram mid-wait. + const document = createMockDocument(); + const ctx = loadDdsWeb(document, { + requestAnimationFrame(cb) { + return setTimeout(cb, 0); + }, + }); + ctx.setDdTableComputingDelayMs(80); + const seenPbn = []; + ctx.loadDdsModule = async () => ({ + _malloc: () => 0, + _free() {}, + ccall(_name, _ret, _args, args) { + seenPbn.push(args[0]); + return 1; + }, + getValue() { + return 9; + }, + }); + ctx.fillFormWithTestData([ + "AQ85.AK976.5.J87", + "JT.QJ5432.Q9.KQ9", + "972..JT863.A6432", + "K643.T8.AK742.T5", + ]); + await new Promise((resolve) => setTimeout(resolve, 120)); + document.element("result-table").rows[1].cells[1].innerHTML = ""; + seenPbn.length = 0; + + // Act: start a solve, then import a different deal during the grace wait. + const first = ctx.refreshDdTable(); + await new Promise((resolve) => setTimeout(resolve, 20)); + ctx.importDealFromText( + '[Deal "N:AKQJ.AKQJ.T98.T9 5432.5432.32.432 T98.T9.AKQJ.AKQJ 76.876.7654.8765"]' + ); + await first; + await new Promise((resolve) => setTimeout(resolve, 120)); + + // Assert: WASM must not run for the pre-import PBN after the diagram changed. + assert.ok( + seenPbn.every((pbn) => !pbn.includes("AQ85")), + "stale part-score PBN must not be solved after import; saw " + + JSON.stringify(seenPbn) + ); + assert.ok( + seenPbn.some((pbn) => pbn.includes("AKQJ")), + "imported deal should still be solved; saw " + JSON.stringify(seenPbn) + ); + assert.equal(document.element("north_spades").value, "AKQJ"); }); From 9dd35f256e246316a98e0cc679750251ced992e8 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 14 Sep 2026 09:35:38 +0200 Subject: [PATCH 05/12] Reject duplicate-card imports and harden deal-file / Computing races. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate a full unique deck before filling the diagram, ignore superseded file reads, and clear Computing… when abandoning a stale PBN mid-grace. Co-authored-by: Cursor --- web/dds_web.js | 64 +++++++++++++++++++ web/tests/dds_web_test.mjs | 122 +++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/web/dds_web.js b/web/dds_web.js index e38593e83..f54c87e33 100644 --- a/web/dds_web.js +++ b/web/dds_web.js @@ -424,6 +424,37 @@ 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) { @@ -431,6 +462,12 @@ function dealFromDirectionMap(byDirection) { 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; } @@ -689,20 +726,41 @@ function chooseDealFile() { input.click(); } +let dealFileSelectionGeneration = 0; + async function handleDealFileSelected(input) { const file = input && input.files && input.files[0]; if (!file) { return; } + const selectionGeneration = ++dealFileSelectionGeneration; const result = document.getElementById("result"); try { const text = await file.text(); + // A newer pick (or cleared selection) supersedes this read. + if ( + selectionGeneration !== dealFileSelectionGeneration + || !input.files + || input.files[0] !== file + ) { + return; + } const err = importDealFromText(text); if (err && result) { result.innerHTML = err; + } else if (result) { + // Import itself succeeded; do not leave a prior error message. + result.innerHTML = ""; } } catch (err) { + if ( + selectionGeneration !== dealFileSelectionGeneration + || !input.files + || input.files[0] !== file + ) { + return; + } if (result) { result.innerHTML = err && err.message ? err.message @@ -2661,6 +2719,9 @@ async function refreshDdTable() { // this invocation still holds the old PBN; do not solve stale input. if (handsToPbn(collectHands()) !== pbn) { clearDdTableComputingTimer(); + if (requestId === ddTableRequestId && result) { + result.innerHTML = ""; + } return; } @@ -2670,6 +2731,9 @@ async function refreshDdTable() { if (handsToPbn(collectHands()) !== pbn) { clearDdTableComputingTimer(); + if (requestId === ddTableRequestId && result) { + result.innerHTML = ""; + } return; } diff --git a/web/tests/dds_web_test.mjs b/web/tests/dds_web_test.mjs index adc352a98..78b2b6c1a 100644 --- a/web/tests/dds_web_test.mjs +++ b/web/tests/dds_web_test.mjs @@ -3769,6 +3769,128 @@ test("importDealFromText reports when no deal is found", () => { assert.match(err, /sol/i); }); +test("importDealFromText rejects a duplicated card without changing the diagram", () => { + // Arrange: SA appears in both North and East (S5 missing). + const document = createMockDocument(); + const ctx = loadDdsWeb(document); + document.setValue("north_spades", "T"); + + // Act + const err = ctx.importDealFromText( + '[Deal "N:AKQJ.AKQJ.T98.T9 A432.5432.32.432 T98.T9.AKQJ.AKQJ 76.876.7654.8765"]' + ); + + // Assert + assert.notEqual(err, ""); + assert.match(err, /deal|card|duplicate/i); + assert.equal(document.element("north_spades").value, "T"); +}); + +test("handleDealFileSelected imports through the file input path", async () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document); + const input = { + files: [ + { + text: async () => + '[Deal "N:AKQJ.AKQJ.T98.T9 5432.5432.32.432 T98.T9.AKQJ.AKQJ 76.876.7654.8765"]', + }, + ], + }; + + await ctx.handleDealFileSelected(input); + + assert.equal(document.element("north_spades").value, "AKQJ"); + assert.equal(document.element("west_clubs").value, "8765"); + // A trailing solve may paint Computing…; import itself must not leave an error. + assert.doesNotMatch( + document.element("result").innerHTML, + /PBN|LIN|DLM|sol-style|Could not|duplicated/i + ); +}); + +test("handleDealFileSelected reports an import error through the file input path", async () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document); + ctx.setDealSolveDebounceMs(500); + const input = { + files: [ + { + text: async () => "not a bridge deal file", + }, + ], + }; + + await ctx.handleDealFileSelected(input); + + assert.match(document.element("result").innerHTML, /PBN|LIN|DLM|sol-style/i); +}); + +test("handleDealFileSelected ignores a superseded slower file read", async () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document); + let releaseSlow; + const slowText = new Promise((resolve) => { + releaseSlow = resolve; + }); + const slowFile = { + text: async () => slowText, + }; + const fastFile = { + text: async () => + '[Deal "N:AKQJ.AKQJ.T98.T9 5432.5432.32.432 T98.T9.AKQJ.AKQJ 76.876.7654.8765"]', + }; + const input = { files: [slowFile] }; + + const first = ctx.handleDealFileSelected(input); + input.files = [fastFile]; + await ctx.handleDealFileSelected(input); + releaseSlow( + '[Deal "N:AQ85.AK976.5.J87 JT.QJ5432.Q9.KQ9 972..JT863.A6432 K643.T8.AK742.T5"]' + ); + await first; + + assert.equal(document.element("north_spades").value, "AKQJ"); +}); + +test("refreshDdTable clears Computing when abandoning a stale PBN", async () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document, { + requestAnimationFrame(cb) { + return setTimeout(cb, 0); + }, + }); + ctx.setDdTableComputingDelayMs(80); + ctx.loadDdsModule = async () => ({ + _malloc: () => 0, + _free() {}, + ccall() { + return 1; + }, + getValue() { + return 9; + }, + }); + ctx.fillFormWithTestData([ + "AQ85.AK976.5.J87", + "JT.QJ5432.Q9.KQ9", + "972..JT863.A6432", + "K643.T8.AK742.T5", + ]); + await new Promise((resolve) => setTimeout(resolve, 120)); + document.element("result-table").rows[1].cells[1].innerHTML = ""; + + const first = ctx.refreshDdTable(); + await new Promise((resolve) => setTimeout(resolve, 20)); + // Edit mid-grace without scheduling an immediate trailing solve. + ctx.setDealSolveDebounceMs(500); + document.setValue("north_spades", "AQ8"); + document.setValue("north_hearts", "5AK976"); + await first; + + assert.doesNotMatch(document.element("result").innerHTML, /Computing/i); +}); + test("refreshDdTable abandons a stale PBN after the Computing grace period", async () => { // Arrange: a slow grace period so an import can change the diagram mid-wait. const document = createMockDocument(); From d8e63573ba94d9fbf20ac3e3dbc23a1990330b2f Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 14 Sep 2026 09:45:42 +0200 Subject: [PATCH 06/12] Harden deal import validation and DD-table status races. Reject malformed hand holdings instead of silently normalizing them, invalidate in-flight DD solves when a file import fails, and skip the rAF paint wait when the tab is hidden so solving cannot hang. Co-authored-by: Cursor --- web/dds_web.js | 48 ++++++++++++++-- web/tests/dds_web_test.mjs | 111 +++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 6 deletions(-) diff --git a/web/dds_web.js b/web/dds_web.js index f54c87e33..161846d9b 100644 --- a/web/dds_web.js +++ b/web/dds_web.js @@ -408,12 +408,31 @@ function sortPips(holding) { .join(""); } -function normalizeHandHolding(dotted) { +/** + * True when a dotted hand has exactly four suit components of legal ranks only. + * Does not pad, truncate, or strip illegal characters. + */ +function isValidRawHandHolding(dotted) { const parts = String(dotted).split("."); - while (parts.length < 4) { - parts.push(""); + if (parts.length !== 4) { + return false; + } + for (const part of parts) { + for (const ch of part) { + if (!PIPS.includes(ch.toUpperCase())) { + return false; + } + } } - return parts.slice(0, 4).map(sortPips).join("."); + return true; +} + +function normalizeHandHolding(dotted) { + if (!isValidRawHandHolding(dotted)) { + throw new Error("Deal has a malformed hand holding."); + } + const parts = String(dotted).split("."); + return parts.map(sortPips).join("."); } function emptySuitHoldings() { @@ -728,6 +747,11 @@ function chooseDealFile() { let dealFileSelectionGeneration = 0; +function invalidateActiveDdTableRequest() { + ddTableRequestId += 1; + clearDdTableComputingTimer(); +} + async function handleDealFileSelected(input) { const file = input && input.files && input.files[0]; if (!file) { @@ -747,8 +771,12 @@ async function handleDealFileSelected(input) { return; } const err = importDealFromText(text); - if (err && result) { - result.innerHTML = err; + if (err) { + // Do not let an in-flight grace/paint overwrite the import failure. + invalidateActiveDdTableRequest(); + if (result) { + result.innerHTML = err; + } } else if (result) { // Import itself succeeded; do not leave a prior error message. result.innerHTML = ""; @@ -761,6 +789,7 @@ async function handleDealFileSelected(input) { ) { return; } + invalidateActiveDdTableRequest(); if (result) { result.innerHTML = err && err.message ? err.message @@ -2628,6 +2657,13 @@ function scheduleDdTableComputingMessage(requestId, result) { /** 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) => { requestAnimationFrame(() => { diff --git a/web/tests/dds_web_test.mjs b/web/tests/dds_web_test.mjs index 78b2b6c1a..e1cdbb249 100644 --- a/web/tests/dds_web_test.mjs +++ b/web/tests/dds_web_test.mjs @@ -3943,3 +3943,114 @@ test("refreshDdTable abandons a stale PBN after the Computing grace period", asy ); assert.equal(document.element("north_spades").value, "AKQJ"); }); + +test("importDealFromText rejects a hand with more than four suit components", () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document); + document.setValue("north_spades", "T"); + + const err = ctx.importDealFromText( + '[Deal "N:AKQJ.AKQJ.T98.T9.2 5432.5432.32.432 T98.T9.AKQJ.AKQJ 76.876.7654.8765"]' + ); + + assert.notEqual(err, ""); + assert.match(err, /deal|hand|suit|invalid|malformed/i); + assert.equal(document.element("north_spades").value, "T"); +}); + +test("importDealFromText rejects a hand with an illegal rank character", () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document); + document.setValue("north_spades", "T"); + + // X would be stripped by sortPips, leaving a 13-card looking hand. + const err = ctx.importDealFromText( + '[Deal "N:AKQJ.AKQJ.T98X.T9 5432.5432.32.432 T98.T9.AKQJ.AKQJ 76.876.7654.8765"]' + ); + + assert.notEqual(err, ""); + assert.match(err, /deal|hand|rank|pip|invalid|malformed/i); + assert.equal(document.element("north_spades").value, "T"); +}); + +test("handleDealFileSelected keeps an import error over a stale Computing status", async () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document, { + requestAnimationFrame(cb) { + return setTimeout(cb, 0); + }, + }); + ctx.setDdTableComputingDelayMs(80); + ctx.loadDdsModule = async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return { + _malloc: () => 0, + _free() {}, + ccall() { + return 1; + }, + getValue() { + return 9; + }, + }; + }; + ctx.fillFormWithTestData([ + "AQ85.AK976.5.J87", + "JT.QJ5432.Q9.KQ9", + "972..JT863.A6432", + "K643.T8.AK742.T5", + ]); + await new Promise((resolve) => setTimeout(resolve, 120)); + document.element("result-table").rows[1].cells[1].innerHTML = ""; + + const first = ctx.refreshDdTable(); + await new Promise((resolve) => setTimeout(resolve, 20)); + await ctx.handleDealFileSelected({ + files: [{ text: async () => "not a bridge deal file" }], + }); + await first; + await new Promise((resolve) => setTimeout(resolve, 100)); + + assert.match(document.element("result").innerHTML, /PBN|LIN|DLM|sol-style/i); + assert.doesNotMatch(document.element("result").innerHTML, /Computing|Solved/i); +}); + +test("refreshDdTable still solves when the tab is hidden", async () => { + const document = createMockDocument(); + document.visibilityState = "hidden"; + let rAFScheduled = false; + const ctx = loadDdsWeb(document, { + requestAnimationFrame() { + rAFScheduled = true; + // Never invoke the callback — hidden tabs may pause rAF. + return 1; + }, + }); + ctx.setDdTableComputingDelayMs(0); + let solved = false; + ctx.loadDdsModule = async () => ({ + _malloc: () => 0, + _free() {}, + ccall() { + solved = true; + return 1; + }, + getValue() { + return 9; + }, + }); + ctx.fillFormWithTestData([ + "AQ85.AK976.5.J87", + "JT.QJ5432.Q9.KQ9", + "972..JT863.A6432", + "K643.T8.AK742.T5", + ]); + await withTimeout( + ctx.refreshDdTable(), + 500, + "refreshDdTable hung while visibilityState was hidden" + ); + + assert.equal(solved, true); + assert.equal(rAFScheduled, false); +}); From a292db1308ae3f2231733b19c0f780955b9de317 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 14 Sep 2026 09:57:43 +0200 Subject: [PATCH 07/12] Tighten LIN/sol import parsing and cancel stale Computing timers. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept optional sol board-number prefixes, reject illegal LIN characters, and invalidate in-flight DD-table requests on every diagram edit so a delayed Computing… message cannot outlive the previous deal. Co-authored-by: Cursor --- web/dds_web.js | 15 ++++++-- web/tests/dds_web_test.mjs | 70 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/web/dds_web.js b/web/dds_web.js index 161846d9b..5938f4056 100644 --- a/web/dds_web.js +++ b/web/dds_web.js @@ -563,9 +563,14 @@ function parseLinHand(raw) { suit = upper; continue; } - if (suit && PIPS.includes(upper)) { + 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); } @@ -642,12 +647,14 @@ function parseDlmBoardPayload(letters) { /** * 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) { - const beforeColon = String(line).split(":")[0].trim(); + 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; @@ -2499,6 +2506,10 @@ function updateActionButtons(activeElement) { const dealComplete = allHandsHaveThirteenCards(hands) && inputIsValid(hands).length === 0; + // Any diagram change supersedes an in-flight DD-table request so a delayed + // Computing… timer cannot paint for the previous PBN during debounce. + invalidateActiveDdTableRequest(); + // Debounce only while the deal stays solvable so typing on a complete deal // does not sync-ccall on every keystroke. Incomplete/invalid edits (and the // first transition to a complete deal) schedule immediately: clear/error diff --git a/web/tests/dds_web_test.mjs b/web/tests/dds_web_test.mjs index e1cdbb249..3c30056f2 100644 --- a/web/tests/dds_web_test.mjs +++ b/web/tests/dds_web_test.mjs @@ -4054,3 +4054,73 @@ test("refreshDdTable still solves when the tab is hidden", async () => { assert.equal(solved, true); assert.equal(rAFScheduled, false); }); + +test("parseFirstDealFromText accepts an optional sol-style board-number prefix", () => { + const ctx = loadDdsWeb(createMockDocument()); + const deal = ctx.parseFirstDealFromText( + "1. T5.K4.652.A98542 K6.QJT976.QT7.Q6 432.A.AKJ93.JT73 AQJ987.8532.84.K:6565\n" + ); + assert.equal(deal.north, "T5.K4.652.A98542"); + assert.equal(deal.west, "AQJ987.8532.84.K"); +}); + +test("parseFirstDealFromText rejects a LIN hand with an illegal character", () => { + const ctx = loadDdsWeb(createMockDocument()); + assert.throws( + () => + ctx.parseFirstDealFromText( + "md|3SQ953HJ84D6CQ9843,S64HA96DT2CAKJ652,ST82HT5DAKJ743CT7X,|" + ), + /LIN|malformed|illegal|invalid|character/i + ); +}); + +test("updateActionButtons clears a pending Computing timer from a prior solve", async () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document, { + requestAnimationFrame(cb) { + return setTimeout(cb, 0); + }, + }); + ctx.setDdTableComputingDelayMs(80); + let releaseModule; + ctx.loadDdsModule = () => + new Promise((resolve) => { + releaseModule = resolve; + }); + ctx.fillFormWithTestData([ + "AQ85.AK976.5.J87", + "JT.QJ5432.Q9.KQ9", + "972..JT863.A6432", + "K643.T8.AK742.T5", + ]); + await new Promise((resolve) => setTimeout(resolve, 120)); + document.element("result-table").rows[1].cells[1].innerHTML = ""; + document.element("result").innerHTML = ""; + + const first = ctx.refreshDdTable(); + await new Promise((resolve) => setTimeout(resolve, 20)); + // Keep the deal complete so only a debounced trailing solve is scheduled. + ctx.setDealSolveDebounceMs(500); + document.setValue("north_spades", "AQ58"); + ctx.updateActionButtons(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + assert.doesNotMatch( + document.element("result").innerHTML, + /Computing/i, + "stale Computing timer must not paint after a diagram edit" + ); + + releaseModule({ + _malloc: () => 0, + _free() {}, + ccall() { + return 1; + }, + getValue() { + return 9; + }, + }); + await first; +}); From f5b046fb442efd4b131df3d00759833539fd6b7e Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 14 Sep 2026 10:06:03 +0200 Subject: [PATCH 08/12] Clear Computing and pending debounces when invalidating DD requests. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep solved/error status text, but drop a painted Computing… message and cancel a trailing deal-solve debounce so import failures stay authoritative. Co-authored-by: Cursor --- web/dds_web.js | 10 ++++++ web/tests/dds_web_test.mjs | 72 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/web/dds_web.js b/web/dds_web.js index 5938f4056..00596517d 100644 --- a/web/dds_web.js +++ b/web/dds_web.js @@ -72,6 +72,7 @@ chooseDealFile handleDealFileSelected setDdTableComputingDelayMs + invalidateActiveDdTableRequest */ // It's also useful to pass the code through @@ -757,6 +758,15 @@ let dealFileSelectionGeneration = 0; function invalidateActiveDdTableRequest() { ddTableRequestId += 1; 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) { diff --git a/web/tests/dds_web_test.mjs b/web/tests/dds_web_test.mjs index 3c30056f2..a890f5d49 100644 --- a/web/tests/dds_web_test.mjs +++ b/web/tests/dds_web_test.mjs @@ -4124,3 +4124,75 @@ test("updateActionButtons clears a pending Computing timer from a prior solve", }); await first; }); + +test("invalidateActiveDdTableRequest clears painted Computing but keeps other status", () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document); + document.element("result").innerHTML = "Computing…"; + + ctx.invalidateActiveDdTableRequest(); + + assert.equal(document.element("result").innerHTML, ""); + + document.element("result").innerHTML = "Solved in 12 ms."; + ctx.invalidateActiveDdTableRequest(); + assert.equal(document.element("result").innerHTML, "Solved in 12 ms."); + + document.element("result").innerHTML = "No PBN, LIN, DLM, dtest, or sol-style deal found in the file."; + ctx.invalidateActiveDdTableRequest(); + assert.match(document.element("result").innerHTML, /PBN|LIN|DLM|sol-style/); +}); + +test("failed file import cancels a pending debounced solve", async () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document, { + requestAnimationFrame(cb) { + return setTimeout(cb, 0); + }, + }); + let ccallCount = 0; + ctx.loadDdsModule = async () => ({ + _malloc: () => 0, + _free() {}, + ccall() { + ccallCount += 1; + return 1; + }, + getValue() { + return 9; + }, + }); + ctx.fillFormWithTestData([ + "AQ85.AK976.5.J87", + "JT.QJ5432.Q9.KQ9", + "972..JT863.A6432", + "K643.T8.AK742.T5", + ]); + await new Promise((resolve) => setTimeout(resolve, 80)); + ccallCount = 0; + + // Arm a trailing solve, then fail a file import before it fires. + ctx.setDealSolveDebounceMs(100); + document.setValue("north_spades", "AQ58"); + ctx.updateActionButtons(); + assert.notEqual( + document.element("result").innerHTML, + "sentinel", + "precondition: debounce arming does not require a status sentinel" + ); + + await ctx.handleDealFileSelected({ + files: [{ text: async () => "not a bridge deal file" }], + }); + const statusAfterImport = document.element("result").innerHTML; + assert.match(statusAfterImport, /PBN|LIN|DLM|sol-style/i); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + assert.equal( + document.element("result").innerHTML, + statusAfterImport, + "debounced solve must not overwrite the import error" + ); + assert.equal(ccallCount, 0, "debounced solve must not run after import failure"); +}); From 49dcf411b942ddbbf0f0247bd2472f73140938e5 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 14 Sep 2026 10:24:49 +0200 Subject: [PATCH 09/12] Stop aborted solve jobs and accept PBN void dashes. Treat "-" as a void suit in imported holdings, give paintStatusFrame a hidden-tab/timeout fallback, and bump the deal-solve epoch on invalidate so an aborted refresh cannot continue into lead solves or overwrite errors. Co-authored-by: Cursor --- web/dds_web.js | 61 +++++++++++++++++++++++++---- web/tests/dds_web_test.mjs | 80 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 8 deletions(-) diff --git a/web/dds_web.js b/web/dds_web.js index 00596517d..42279605e 100644 --- a/web/dds_web.js +++ b/web/dds_web.js @@ -73,6 +73,7 @@ handleDealFileSelected setDdTableComputingDelayMs invalidateActiveDdTableRequest + paintStatusFrame */ // It's also useful to pass the code through @@ -98,6 +99,7 @@ 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; @@ -154,6 +156,7 @@ function scheduleDealSolve() { } dealSolveEpoch += 1; + dealSolvePending = true; if (dealSolveQueued) { return solveQueue; @@ -164,11 +167,18 @@ function scheduleDealSolve() { try { while (true) { const epoch = dealSolveEpoch; + dealSolvePending = false; await refreshDdTable(); if (epoch !== dealSolveEpoch) { - continue; + // Invalidation alone must not restart work; only a newer + // scheduleDealSolve (pending) should continue. A pending + // debounce will start a fresh job when it fires. + if (dealSolvePending) { + continue; + } + break; } if (selectedContractState) { @@ -179,7 +189,10 @@ function scheduleDealSolve() { } if (epoch !== dealSolveEpoch) { - continue; + if (dealSolvePending) { + continue; + } + break; } // Release the gate only once the epoch is stable; if a schedule @@ -189,14 +202,22 @@ function scheduleDealSolve() { if (epoch !== dealSolveEpoch) { dealSolveQueued = true; - continue; + if (dealSolvePending) { + continue; + } + break; } break; } } catch (err) { - dealSolveQueued = false; throw err; + } finally { + const restart = dealSolvePending; + dealSolveQueued = false; + if (restart) { + void scheduleDealSolve(); + } } }); } @@ -411,7 +432,7 @@ function sortPips(holding) { /** * True when a dotted hand has exactly four suit components of legal ranks only. - * Does not pad, truncate, or strip illegal characters. + * A lone "-" is accepted as the PBN void-suit marker. Does not pad or truncate. */ function isValidRawHandHolding(dotted) { const parts = String(dotted).split("."); @@ -419,6 +440,9 @@ function isValidRawHandHolding(dotted) { return false; } for (const part of parts) { + if (part === "" || part === "-") { + continue; + } for (const ch of part) { if (!PIPS.includes(ch.toUpperCase())) { return false; @@ -432,8 +456,10 @@ function normalizeHandHolding(dotted) { if (!isValidRawHandHolding(dotted)) { throw new Error("Deal has a malformed hand holding."); } - const parts = String(dotted).split("."); - return parts.map(sortPips).join("."); + return String(dotted) + .split(".") + .map((part) => (part === "-" ? "" : sortPips(part))) + .join("."); } function emptySuitHoldings() { @@ -757,6 +783,7 @@ let dealFileSelectionGeneration = 0; function invalidateActiveDdTableRequest() { ddTableRequestId += 1; + dealSolveEpoch += 1; clearDdTableComputingTimer(); if (dealSolveDebounceTimer != null) { clearTimeout(dealSolveDebounceTimer); @@ -2687,8 +2714,26 @@ function paintStatusFrame() { } 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(() => { - requestAnimationFrame(resolve); + if ( + typeof document !== "undefined" + && document.visibilityState === "hidden" + ) { + done(); + return; + } + requestAnimationFrame(done); }); }); } diff --git a/web/tests/dds_web_test.mjs b/web/tests/dds_web_test.mjs index a890f5d49..4104a8793 100644 --- a/web/tests/dds_web_test.mjs +++ b/web/tests/dds_web_test.mjs @@ -4196,3 +4196,83 @@ test("failed file import cancels a pending debounced solve", async () => { ); assert.equal(ccallCount, 0, "debounced solve must not run after import failure"); }); + +test("importDealFromText accepts a PBN void suit marked with a dash", () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document); + const err = ctx.importDealFromText( + '[Deal "N:QT9.A8765432.KJ.- KJ.-.A8765432.QT9 A8765432.QT9.-.KJ -.KJ.QT9.A8765432"]' + ); + assert.equal(err, ""); + assert.equal(document.element("north_clubs").value, ""); + assert.equal(document.element("east_hearts").value, ""); + assert.equal(document.element("west_spades").value, ""); +}); + +test("paintStatusFrame resolves if the tab hides before the second animation frame", async () => { + const document = createMockDocument(); + document.visibilityState = "visible"; + let frames = 0; + const ctx = loadDdsWeb(document, { + requestAnimationFrame(cb) { + frames += 1; + if (frames === 1) { + document.visibilityState = "hidden"; + setTimeout(cb, 0); + return 1; + } + // A hung second frame would block the solve queue without a fallback. + return 2; + }, + }); + + await withTimeout( + ctx.paintStatusFrame(), + 200, + "paintStatusFrame hung after the tab became hidden mid-wait" + ); +}); + +test("failed file import stops an in-flight solve job from continuing", async () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document, { + requestAnimationFrame(cb) { + return setTimeout(cb, 0); + }, + }); + ctx.setDdTableComputingDelayMs(80); + let ccallCount = 0; + ctx.loadDdsModule = async () => { + await new Promise((resolve) => setTimeout(resolve, 40)); + return { + _malloc: () => 0, + _free() {}, + ccall() { + ccallCount += 1; + return 1; + }, + getValue() { + return 9; + }, + }; + }; + ctx.fillFormWithTestData([ + "AQ85.AK976.5.J87", + "JT.QJ5432.Q9.KQ9", + "972..JT863.A6432", + "K643.T8.AK742.T5", + ]); + document.element("result-table").rows[1].cells[1].innerHTML = "9"; + ctx.onContractSelect("north", "C"); + + const solve = ctx.scheduleDealSolve(); + await new Promise((resolve) => setTimeout(resolve, 20)); + await ctx.handleDealFileSelected({ + files: [{ text: async () => "not a bridge deal file" }], + }); + await withTimeout(solve, 500, "solve job did not finish after import failure"); + await new Promise((resolve) => setTimeout(resolve, 120)); + + assert.match(document.element("result").innerHTML, /PBN|LIN|DLM|sol-style/i); + assert.equal(ccallCount, 0, "solve job must not continue after import failure"); +}); From b46912140d2fda50489d894dfcd21efc3242b617 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 14 Sep 2026 11:32:52 +0200 Subject: [PATCH 10/12] Clear dealSolvePending when invalidating an in-flight solve. A coalesced direct schedule left the pending flag set across invalidate, so the worker could resume immediately and overwrite a debounce or import error. Co-authored-by: Cursor --- web/dds_web.js | 4 +++ web/tests/dds_web_test.mjs | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/web/dds_web.js b/web/dds_web.js index 42279605e..fff743c63 100644 --- a/web/dds_web.js +++ b/web/dds_web.js @@ -784,6 +784,10 @@ let dealFileSelectionGeneration = 0; function invalidateActiveDdTableRequest() { ddTableRequestId += 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); diff --git a/web/tests/dds_web_test.mjs b/web/tests/dds_web_test.mjs index 4104a8793..1e72838c3 100644 --- a/web/tests/dds_web_test.mjs +++ b/web/tests/dds_web_test.mjs @@ -4276,3 +4276,57 @@ test("failed file import stops an in-flight solve job from continuing", async () assert.match(document.element("result").innerHTML, /PBN|LIN|DLM|sol-style/i); assert.equal(ccallCount, 0, "solve job must not continue after import failure"); }); + +test("invalidateActiveDdTableRequest clears dealSolvePending so a coalesced job stops", async () => { + // Arrange: a queued direct solve sets dealSolvePending while the worker is + // mid-refresh; invalidation must clear that flag or the worker continues + // immediately and can overwrite an import error. + const document = createMockDocument(); + const ctx = loadDdsWeb(document, { + requestAnimationFrame(cb) { + return setTimeout(cb, 0); + }, + }); + let releaseModule; + let ccallCount = 0; + ctx.loadDdsModule = () => + new Promise((resolve) => { + releaseModule = () => + resolve({ + _malloc: () => 0, + _free() {}, + ccall() { + ccallCount += 1; + return 1; + }, + getValue() { + return 9; + }, + }); + }); + ctx.fillFormWithTestData([ + "AQ85.AK976.5.J87", + "JT.QJ5432.Q9.KQ9", + "972..JT863.A6432", + "K643.T8.AK742.T5", + ]); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const solve = ctx.scheduleDealSolve(); + await new Promise((resolve) => setTimeout(resolve, 10)); + // Coalesce another direct schedule while the first refresh is still waiting. + ctx.scheduleDealSolve(); + await ctx.handleDealFileSelected({ + files: [{ text: async () => "not a bridge deal file" }], + }); + releaseModule(); + await withTimeout(solve, 500, "solve job did not finish after invalidate"); + await new Promise((resolve) => setTimeout(resolve, 30)); + + assert.match(document.element("result").innerHTML, /PBN|LIN|DLM|sol-style/i); + assert.equal( + ccallCount, + 0, + "coalesced pending flag must not restart work after invalidate" + ); +}); From d58d213a4906fe0460592af71eae9e7cf1e691b6 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 14 Sep 2026 11:46:32 +0200 Subject: [PATCH 11/12] Harden sol/LIN parsing and invalidate in-flight lead solves. Require whitespace after an optional sol board-number prefix, reject LIN payloads with more than four hands, bump leadTricksRequestId on invalidate, and reset the Computing warm-up flag in the grace-period test. Co-authored-by: Cursor --- web/dds_web.js | 9 ++++- web/tests/dds_web_test.mjs | 83 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/web/dds_web.js b/web/dds_web.js index fff743c63..6b054554e 100644 --- a/web/dds_web.js +++ b/web/dds_web.js @@ -609,7 +609,11 @@ function parseLinDealPayload(payload) { // rotate which hand comes first in the list. const body = String(payload).replace(/^[1-4]/, ""); const parts = body.split(","); - if (parts.length < 3) { + // 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; } @@ -681,7 +685,7 @@ function parseSolStyleDealLine(line) { if (!beforeColon) { return null; } - beforeColon = beforeColon.replace(/^\d+\.\s*/, ""); + beforeColon = beforeColon.replace(/^\d+\.\s+/, ""); const hands = beforeColon.split(/\s+/).filter(Boolean); if (hands.length !== 4) { return null; @@ -783,6 +787,7 @@ 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 diff --git a/web/tests/dds_web_test.mjs b/web/tests/dds_web_test.mjs index 1e72838c3..11178b7ce 100644 --- a/web/tests/dds_web_test.mjs +++ b/web/tests/dds_web_test.mjs @@ -1557,6 +1557,7 @@ test("refreshDdTable shows Computing under the matrix only after 300 ms, painted ]); // fillForm schedules a solve; wait for it so it does not race the Act call. await new Promise((resolve) => setTimeout(resolve, 350)); + ccallSawComputing = false; // Force a fresh solve (same PBN would otherwise short-circuit as cached). document.element("result-table").rows[1].cells[1].innerHTML = ""; @@ -4064,6 +4065,35 @@ test("parseFirstDealFromText accepts an optional sol-style board-number prefix", assert.equal(deal.west, "AQJ987.8532.84.K"); }); +test("parseFirstDealFromText does not treat a leading numeric pip as a board number", () => { + const ctx = loadDdsWeb(createMockDocument()); + // Without requiring whitespace after ".", the "2." spade holding is + // mistaken for a board-number prefix and the line fails to parse. + const deal = ctx.parseFirstDealFromText( + "2543.5432.32.432 AKQJT9876.AKQJ.. .T9876.AKQJT987. ..654.AKQJT98765\n" + ); + assert.equal(deal.north, "5432.5432.32.432"); + assert.equal(deal.east, "AKQJT9876.AKQJ.."); +}); + +test("parseFirstDealFromText rejects a LIN deal with more than four hands", () => { + const ctx = loadDdsWeb(createMockDocument()); + // Four valid S,W,N,E hands plus a fifth garbage hand must not silently + // import only the first four. + const fourHands = + "S27AH3489TD5JC45J,S358QKH56D4KAC3QK,S4JH2JQD2678TC678,ST96HAK7DQ93CAT92"; + const deal = ctx.parseFirstDealFromText("md|3" + fourHands + "|"); + assert.equal(deal.north, "J4.QJ2.T8762.876"); + + assert.throws( + () => + ctx.parseFirstDealFromText( + "md|3" + fourHands + ",SEXTRA|" + ), + /PBN|LIN|DLM|sol-style|malformed|hands/i + ); +}); + test("parseFirstDealFromText rejects a LIN hand with an illegal character", () => { const ctx = loadDdsWeb(createMockDocument()); assert.throws( @@ -4330,3 +4360,56 @@ test("invalidateActiveDdTableRequest clears dealSolvePending so a coalesced job "coalesced pending flag must not restart work after invalidate" ); }); + +test("failed file import invalidates an in-flight opening-lead solve", async () => { + const document = createMockDocument(); + const ctx = loadDdsWeb(document, { + requestAnimationFrame(cb) { + return setTimeout(cb, 0); + }, + }); + ctx.loadDdsModule = async () => ({ + _malloc: () => 0, + _free() {}, + ccall() { + return 1; + }, + getValue() { + return 9; + }, + }); + ctx.fillFormWithTestData([ + "AQ85.AK976.5.J87", + "JT.QJ5432.Q9.KQ9", + "972..JT863.A6432", + "K643.T8.AK742.T5", + ]); + await new Promise((resolve) => setTimeout(resolve, 80)); + document.element("result-table").rows[1].cells[1].innerHTML = "9"; + + let releaseLead; + let leadStarted = false; + ctx.solveOpeningLeadTricks = () => + new Promise((resolve, reject) => { + leadStarted = true; + releaseLead = () => reject(new Error("stale lead failure")); + }); + ctx.handleResultTableClick({ + target: { + closest() { + return document.element("result-table").rows[1].cells[1]; + }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 40)); + assert.equal(leadStarted, true); + + await ctx.handleDealFileSelected({ + files: [{ text: async () => "not a bridge deal file" }], + }); + releaseLead(); + await new Promise((resolve) => setTimeout(resolve, 40)); + + assert.match(document.element("result").innerHTML, /PBN|LIN|DLM|sol-style/i); + assert.doesNotMatch(document.element("result").innerHTML, /stale lead failure/i); +}); From 4660c4bcdc2536e841b2e81e4287d23eaaf65945 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 14 Sep 2026 11:53:55 +0200 Subject: [PATCH 12/12] =?UTF-8?q?Document=20the=20sync-WASM=20Computing?= =?UTF-8?q?=E2=80=A6=20latency=20tradeoff.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timers cannot fire during the blocking ccall, so painting Computing… before the call is required and imposes a deliberate minimum delay until CalcTable can move off the main thread. Co-authored-by: Cursor --- web/dds_web.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/web/dds_web.js b/web/dds_web.js index 6b054554e..912803926 100644 --- a/web/dds_web.js +++ b/web/dds_web.js @@ -2681,7 +2681,7 @@ function clear_results() { } } -/** Delay before showing Computing… under the DD matrix (avoids fast-solve flash). */ +/** Delay before showing Computing… under the DD matrix (see refreshDdTable). */ let ddTableComputingDelayMs = 300; function setDdTableComputingDelayMs(ms) { @@ -2806,9 +2806,15 @@ async function refreshDdTable() { } clear_results(); - // Keep the status blank during a short grace period. Then paint Computing… - // before the blocking WASM ccall — timers cannot fire while ccall runs, so - // a delayed message alone is never visible during a long sync solve. + // 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();