diff --git a/.github/workflows/ci_linux.yml b/.github/workflows/ci_linux.yml index 471e2aeb..11dae6cd 100644 --- a/.github/workflows/ci_linux.yml +++ b/.github/workflows/ci_linux.yml @@ -31,3 +31,13 @@ jobs: # 6️⃣ Run all tests (including Python) - name: Run all tests run: bazel test --verbose_failures //... + + # 7️⃣ Upload test logs + - name: Upload test logs - Linux + if: always() + uses: actions/upload-artifact@v6 + with: + name: bazel-test-logs-linux + path: bazel-testlogs/ + if-no-files-found: ignore + retention-days: 30 diff --git a/.github/workflows/ci_macos.yml b/.github/workflows/ci_macos.yml index 7211123c..90a6d117 100644 --- a/.github/workflows/ci_macos.yml +++ b/.github/workflows/ci_macos.yml @@ -30,3 +30,13 @@ jobs: # Run all tests (including Python) - name: Run all tests run: bazelisk test --verbose_failures //... + + # Upload test logs + - name: Upload test logs - macOS + if: always() + uses: actions/upload-artifact@v6 + with: + name: bazel-test-logs-macos + path: bazel-testlogs/ + if-no-files-found: ignore + retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/ci_wasm.yml b/.github/workflows/ci_wasm.yml index 397285c8..8e5bf543 100644 --- a/.github/workflows/ci_wasm.yml +++ b/.github/workflows/ci_wasm.yml @@ -29,4 +29,14 @@ jobs: # 4️⃣ Smoke test: run solve_board under Node.js — pass if it does not crash - name: Smoke test solve_board_wasm - run: node bazel-bin/examples/wasm/solve_board.js \ No newline at end of file + run: node bazel-bin/examples/wasm/solve_board.js + + # 5️⃣ Upload test logs + - name: Upload test logs - WASM + if: always() + uses: actions/upload-artifact@v6 + with: + name: bazel-test-logs-wasm + path: bazel-testlogs/ + if-no-files-found: ignore + retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/ci_windows.yml b/.github/workflows/ci_windows.yml index 9c309941..d228b98d 100644 --- a/.github/workflows/ci_windows.yml +++ b/.github/workflows/ci_windows.yml @@ -26,3 +26,13 @@ jobs: # Run all tests (including Python) - name: Run all tests run: bazel test --verbose_failures //... + + # Upload test logs + - name: Upload test logs - Windows + if: always() + uses: actions/upload-artifact@v6 + with: + name: bazel-test-logs-windows + path: bazel-testlogs/ + if-no-files-found: ignore + retention-days: 30 \ No newline at end of file diff --git a/web/BUILD.bazel b/web/BUILD.bazel index 6a439057..9c2d2e8f 100644 --- a/web/BUILD.bazel +++ b/web/BUILD.bazel @@ -74,6 +74,18 @@ py_test( ], ) +py_test( + name = "dds_mvp_js_test", + size = "small", + timeout = "short", + main = "tests/test_dds_mvp_js.py", + srcs = ["tests/test_dds_mvp_js.py"], + data = [ + "dds_mvp.js", + "tests/dds_mvp_test.mjs", + ], +) + # Stages wasm artifacts and runs Node smoke (~1–2s; wasm build is a separate analysis action). py_test( name = "dds_mvp_wasm_system_test", @@ -123,6 +135,7 @@ py_test( test_suite( name = "web_tests", tests = [ + ":dds_mvp_js_test", ":dds_mvp_wasm_test", ":wasm_scripts_test", ], diff --git a/web/dds_mvp.js b/web/dds_mvp.js index abdeb066..bbd143b0 100644 --- a/web/dds_mvp.js +++ b/web/dds_mvp.js @@ -4,7 +4,10 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT -// TODO: Add tests for the exported functions. +// Unit tests: web/tests/dds_mvp_test.mjs +// Run with: bazel test //web:dds_mvp_js_test +// or: python -m unittest web.tests.test_dds_mvp_js +// or: node --test web/tests/dds_mvp_test.mjs // ESLint configuration // https://eslint.org/demo diff --git a/web/tests/dds_mvp_test.mjs b/web/tests/dds_mvp_test.mjs new file mode 100644 index 00000000..d19fba6c --- /dev/null +++ b/web/tests/dds_mvp_test.mjs @@ -0,0 +1,235 @@ +/** + * Unit tests for web/dds_mvp.js (Node built-in test runner). + * + * Run with: + * bazel test //web:dds_mvp_js_test + * or python -m unittest web.tests.test_dds_mvp_js + * or node --test web/tests/dds_mvp_test.mjs + */ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createContext, runInContext } from "node:vm"; + +const DIRECTIONS = ["north", "east", "south", "west"]; +const SUITS = ["spades", "hearts", "diamonds", "clubs"]; + +function findWebRoot() { + const here = dirname(fileURLToPath(import.meta.url)); + const adjacent = join(here, ".."); + if (existsSync(join(adjacent, "dds_mvp.js"))) { + return adjacent; + } + + for (const base of [process.env.TEST_SRCDIR, process.env.RUNFILES_DIR]) { + if (!base) { + continue; + } + for (const sub of ["web", "_main/web"]) { + const candidate = join(base, sub); + if (existsSync(join(candidate, "dds_mvp.js"))) { + return candidate; + } + } + } + + throw new Error("dds_mvp.js not found"); +} + +function createMockDocument(initialValues = {}) { + const store = new Map(); + + const makeElement = (id) => { + const element = { + id, + value: initialValues[id] ?? "", + innerHTML: "", + }; + store.set(id, element); + return element; + }; + + for (const direction of DIRECTIONS) { + for (const suit of SUITS) { + makeElement(`${direction}_${suit}`); + } + } + makeElement("valid-pips"); + makeElement("result"); + + const rows = []; + for (let row = 0; row < 5; row++) { + const cells = []; + for (let column = 0; column < 6; column++) { + cells.push({ innerHTML: "" }); + } + rows.push({ cells }); + } + store.set("result-table", { rows }); + + return { + getElementById(id) { + return store.get(id) ?? null; + }, + element(id) { + return store.get(id); + }, + setValue(id, value) { + store.get(id).value = value; + }, + values() { + const out = {}; + for (const [id, element] of store) { + if (id.includes("_")) { + out[id] = element.value; + } + } + return out; + }, + }; +} + +function loadDdsMvp(document) { + const webRoot = findWebRoot(); + const code = readFileSync(join(webRoot, "dds_mvp.js"), "utf8"); + const sandbox = { + document, + console, + Promise, + Error, + }; + const context = createContext(sandbox); + runInContext(code, context, { filename: "dds_mvp.js" }); + return context; +} + +test("handsToPbn formats part-score deal", () => { + const document = createMockDocument(); + const ctx = loadDdsMvp(document); + ctx.fillFormWithPartScoreTestData(); + const pbn = ctx.handsToPbn(ctx.collectHands()); + assert.equal( + pbn, + "N:AQ85.AK976.5.J87 JT.QJ5432.Q9.KQ9 972..JT863.A6432 K643.T8.AK742.T5" + ); +}); + +test("inputIsValid rejects incomplete deal", () => { + const ctx = loadDdsMvp(createMockDocument()); + assert.equal( + ctx.inputIsValid({ N: ["SA"], E: [], S: [], W: [] }), + "Please enter 13 cards per hand." + ); +}); + +test("inputIsValid rejects invalid pip", () => { + const ctx = loadDdsMvp(createMockDocument()); + const hands = { + N: ["S1", "S2", "S3", "S4", "S5", "S6", "S7", "S8", "S9", "ST", "SJ", "SQ", "SK"], + E: ["HA", "H2", "H3", "H4", "H5", "H6", "H7", "H8", "H9", "HT", "HJ", "HQ", "HK"], + S: ["DA", "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D9", "DT", "DJ", "DQ", "DK"], + W: ["CA", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "C9", "CT", "CJ", "CQ", "CK"], + }; + assert.match(ctx.inputIsValid(hands), /^Please use only these pips:/); +}); + +test("inputIsValid rejects duplicate cards", () => { + const ctx = loadDdsMvp(createMockDocument()); + const hands = { + N: ["SA", "SA", "S2", "S3", "S4", "S5", "S6", "S7", "S8", "S9", "ST", "SJ", "SQ"], + E: ["HA", "H2", "H3", "H4", "H5", "H6", "H7", "H8", "H9", "HT", "HJ", "HQ", "HK"], + S: ["DA", "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D9", "DT", "DJ", "DQ", "DK"], + W: ["CA", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "C9", "CT", "CJ", "CQ", "CK"], + }; + const message = ctx.inputIsValid(hands); + assert.match(message, /^Duplicated card/); + assert.match(message, /♠A/); +}); + +test("inputIsValid accepts part-score deal", () => { + const document = createMockDocument(); + const ctx = loadDdsMvp(document); + ctx.fillFormWithPartScoreTestData(); + assert.equal(ctx.inputIsValid(ctx.collectHands()), ""); +}); + +test("collectHands reads suit holdings from inputs", () => { + const document = createMockDocument({ + north_spades: "AKQ", + north_hearts: "JT", + north_diamonds: "987", + north_clubs: "65432", + east_spades: "", + east_hearts: "AKQ", + east_diamonds: "", + east_clubs: "JT98765432", + south_spades: "JT98765432", + south_hearts: "", + south_diamonds: "AKQ", + south_clubs: "", + west_spades: "", + west_hearts: "98765432", + west_diamonds: "JT", + west_clubs: "AKQ", + }); + const ctx = loadDdsMvp(document); + const hands = ctx.collectHands(); + assert.equal(hands.N.length, 13); + assert.equal(hands.E.length, 13); + assert.equal(hands.S.length, 13); + assert.equal(hands.W.length, 13); + assert.ok(hands.N.includes("SA")); + assert.ok(hands.N.includes("SK")); + assert.ok(hands.E.includes("CJ")); +}); + +test("clearTestData clears all hand inputs", () => { + const document = createMockDocument({ north_spades: "AKQ", west_clubs: "JT" }); + const ctx = loadDdsMvp(document); + ctx.clearTestData(); + assert.equal(document.element("north_spades").value, ""); + assert.equal(document.element("west_clubs").value, ""); +}); + +test("rotateClockwise shifts holdings west to north", () => { + const document = createMockDocument(); + let index = 1; + for (const direction of DIRECTIONS) { + for (const suit of SUITS) { + document.setValue(`${direction}_${suit}`, String(index)); + index += 1; + } + } + const ctx = loadDdsMvp(document); + ctx.rotateClockwise(); + assert.equal(document.element("north_spades").value, "13"); + assert.equal(document.element("north_hearts").value, "14"); + assert.equal(document.element("north_diamonds").value, "15"); + assert.equal(document.element("north_clubs").value, "16"); + assert.equal(document.element("east_spades").value, "1"); +}); + +test("fillFormWithPartScoreTestData populates inputs", () => { + const document = createMockDocument(); + const ctx = loadDdsMvp(document); + ctx.fillFormWithPartScoreTestData(); + assert.equal(document.element("north_spades").value, "AQ85"); + assert.equal(document.element("west_clubs").value, "T5"); +}); + +test("pageLoad shows valid pips", () => { + const document = createMockDocument(); + const ctx = loadDdsMvp(document); + ctx.pageLoad(); + assert.equal(document.element("valid-pips").innerHTML, "AKQJT98765432"); +}); + +test("loadDdsModule rejects missing wasm globals", async () => { + const ctx = loadDdsMvp(createMockDocument()); + await assert.rejects( + () => ctx.loadDdsModule(), + /WASM module not found/ + ); +}); diff --git a/web/tests/test_dds_mvp_js.py b/web/tests/test_dds_mvp_js.py new file mode 100644 index 00000000..32beed3c --- /dev/null +++ b/web/tests/test_dds_mvp_js.py @@ -0,0 +1,55 @@ +"""Unit tests for web/dds_mvp.js via Node's built-in test runner. + +Run with: bazel test //web:dds_mvp_js_test +or: python -m unittest web.tests.test_dds_mvp_js +or: node --test web/tests/dds_mvp_test.mjs +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import unittest +from pathlib import Path + +TESTS_ROOT = Path(__file__).resolve().parent + + +def _runfiles_root() -> Path: + for key in ("RUNFILES_DIR", "TEST_SRCDIR"): + if key in os.environ: + return Path(os.environ[key]) + return TESTS_ROOT.parent.parent + + +def rlocation(relpath: str) -> Path: + root = _runfiles_root() + for candidate in (root / relpath, root / "_main" / relpath): + if candidate.exists(): + return candidate + raise FileNotFoundError(relpath) + + +@unittest.skipUnless(shutil.which("node"), "node not found") +class DdsMvpJsTest(unittest.TestCase): + def test_dds_mvp_js(self) -> None: + node = shutil.which("node") + assert node is not None + + test_script = rlocation("web/tests/dds_mvp_test.mjs") + proc = subprocess.run( + [node, "--test", str(test_script)], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + proc.returncode, + 0, + msg=proc.stdout + proc.stderr, + ) + + +if __name__ == "__main__": + unittest.main()