Skip to content
Merged
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
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Development / test dependencies.
# pip install -r requirements-dev.txt
pytest>=8,<9
coverage>=7,<8
46 changes: 46 additions & 0 deletions test_artifacts.py
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions test_common.py
Original file line number Diff line number Diff line change
@@ -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")
15 changes: 13 additions & 2 deletions test_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -286,15 +297,15 @@ 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}")

# 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":
Expand Down
Loading