diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1bc06c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dev dependencies + run: pip install -r requirements-dev.txt + - name: Rubric tests + run: python3 test_generation.py + - name: Unit tests + run: python3 -m pytest -q diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..0b3e93c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +# Development / test dependencies. +# pip install -r requirements-dev.txt +pytest>=8,<9 +coverage>=7,<8 diff --git a/test_artifacts.py b/test_artifacts.py new file mode 100644 index 0000000..6f51235 --- /dev/null +++ b/test_artifacts.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Guard that the committed artifacts stay in sync. + +``data.json`` (read by the tests), ``data.min.json`` (embedded in the page), +and ``index.html`` (shipped UI) are generated together but committed as static +files, so they can silently drift if only one is regenerated. These tests fail +loudly when that happens. +""" +from __future__ import annotations + +import base64 +import json +import re +import shutil +from pathlib import Path + +import build_index as bi + +ROOT = Path(__file__).resolve().parent + + +def _load(name: str) -> dict: + return json.loads((ROOT / name).read_text(encoding="utf-8")) + + +def test_data_json_matches_min_json(): + assert _load("data.json") == _load("data.min.json") + + +def test_index_html_embeds_current_min_json(): + html = (ROOT / "index.html").read_text(encoding="utf-8") + m = re.search(r'id="DATA_B64">([^<]+)<', html) + assert m, "DATA_B64 payload not found in index.html" + embedded = json.loads(base64.b64decode(m.group(1).strip()).decode("utf-8")) + assert embedded == _load("data.min.json") + + +def test_index_html_has_no_drift(tmp_path, monkeypatch): + # Regenerating index.html from the committed data.min.json must reproduce + # the committed index.html byte-for-byte. + shutil.copy(ROOT / "data.min.json", tmp_path / "data.min.json") + monkeypatch.setattr(bi, "ROOT", tmp_path) + bi.main() + regenerated = (tmp_path / "index.html").read_text(encoding="utf-8") + committed = (ROOT / "index.html").read_text(encoding="utf-8") + assert regenerated == committed diff --git a/test_common.py b/test_common.py new file mode 100644 index 0000000..c331e11 --- /dev/null +++ b/test_common.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Tests for common.py invariants + test-suite determinism.""" +from __future__ import annotations + +import os +import subprocess +import sys + +from common import DATE_KIND_LABELS, DATE_KIND_TO_YMD, DATE_KINDS + + +def test_date_kinds_matches_labels(): + assert set(DATE_KINDS) == set(DATE_KIND_LABELS) + + +def test_to_ymd_covers_every_date_kind_except_year(): + # ``year`` is special-cased in generate_from; every other date kind must + # have a random_ymd key, else generate_from silently falls through to + # emitting the raw title. + assert set(DATE_KIND_TO_YMD) == set(DATE_KINDS) - {"year"} + + +def test_to_ymd_keys_exist_in_random_ymd(): + from test_generation import random_ymd + + sample = random_ymd(2010) + for key in DATE_KIND_TO_YMD.values(): + assert key in sample, key + + +def _stable_hash_under_seed(seed_value: str) -> str: + env = dict(os.environ, PYTHONHASHSEED=seed_value) + code = "from test_generation import stable_hash; print(stable_hash('IMG XXXX'))" + out = subprocess.check_output([sys.executable, "-c", code], env=env, text=True) + return out.strip() + + +def test_stable_hash_is_reproducible_across_processes(): + # The whole point of stable_hash: independent of PYTHONHASHSEED salting. + assert _stable_hash_under_seed("0") == _stable_hash_under_seed("12345") diff --git a/test_generation.py b/test_generation.py index e6f520f..268dc89 100644 --- a/test_generation.py +++ b/test_generation.py @@ -2,6 +2,7 @@ """Shared generation logic (Python mirror of index.html JS) + rubric tests.""" from __future__ import annotations +import hashlib import json import random import re @@ -15,6 +16,16 @@ ROOT = Path(__file__).resolve().parent +def stable_hash(s: str) -> int: + """Process-stable hash of a string. + + ``hash()`` is salted per interpreter run (``PYTHONHASHSEED``), so seeding an + RNG with it makes ``run_tests(seed=...)`` non-reproducible run-to-run. This + derives a deterministic integer from the string instead. + """ + return int.from_bytes(hashlib.blake2b(s.encode("utf-8"), digest_size=8).digest(), "big") + + def pad(n: int, w: int) -> str: s = str(int(n)) width = max(0, int(w or 0)) @@ -286,7 +297,7 @@ def run_tests(seed: int = 42, rounds: int = 5) -> int: for e in pool: for i in range(rounds): try: - s = generate_from(e, True, random.Random(seed + i * 17 + hash(e["title"]) % 1000)) + s = generate_from(e, True, random.Random(seed + i * 17 + stable_hash(e["title"]) % 1000)) validate_output(e, s) except Exception as ex: failures.append(f"{kind} | {e['title'][:50]} | {ex}") @@ -294,7 +305,7 @@ def run_tests(seed: int = 42, rounds: int = 5) -> int: # Rubric R3: every entry generates without exception; no leftover placeholders for non-fixed for e in entries: try: - s = generate_from(e, True, random.Random(hash(e["title"]) % (10**6))) + s = generate_from(e, True, random.Random(stable_hash(e["title"]) % (10**6))) if e["template"]["kind"] != "fixed": assert "{date}" not in s and "YYYYMMDD" not in s if e["template"]["kind"] == "month_dd_yyyy":