Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci_linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions .github/workflows/ci_macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 11 additions & 1 deletion .github/workflows/ci_wasm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
10 changes: 10 additions & 0 deletions .github/workflows/ci_windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions web/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -123,6 +135,7 @@ py_test(
test_suite(
name = "web_tests",
tests = [
":dds_mvp_js_test",
":dds_mvp_wasm_test",
":wasm_scripts_test",
],
Expand Down
5 changes: 4 additions & 1 deletion web/dds_mvp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
235 changes: 235 additions & 0 deletions web/tests/dds_mvp_test.mjs
Original file line number Diff line number Diff line change
@@ -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, /&spades;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/
);
});
55 changes: 55 additions & 0 deletions web/tests/test_dds_mvp_js.py
Original file line number Diff line number Diff line change
@@ -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()
Loading