diff --git a/Makefile b/Makefile index 8251587d6..7b3fcd506 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,7 @@ help: @echo " make check-star-map Assert star_map_data.json is up to date (part of pre-pr-client)" @echo " make economy-db Import economics data into systems.db (TOML/JSON → SQLite)" @echo " make atlas-generate Generate atlas city/road/rail markers for all inhabited bodies" + @echo " make test-atlas-determinism Determinism smoke test for generate_atlas.py (#847)" @echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)" @echo " make golden-diff Show diff if golden file output has changed" @echo " make golden-update Regenerate golden file and stage for commit" @@ -223,6 +224,9 @@ test-ipc-integration: test-ipc-benchmark: tests/run-ipc-benchmark +test-atlas-determinism: ## Determinism smoke test for generate_atlas.py (#847) + tests/run-atlas-determinism + # --- Clean --- clean-imports: diff --git a/tests/run-all b/tests/run-all index 6d7706667..9c4b80842 100755 --- a/tests/run-all +++ b/tests/run-all @@ -1,6 +1,7 @@ #!/usr/bin/env bash # tests/run-all: Run all test suites in order (D-030) -# Invokes run-rust, run-godot, run-ipc-fixtures, run-ipc-protocol, run-ipc-integration. +# Invokes run-rust, run-godot, run-ipc-fixtures, run-ipc-protocol, +# run-ipc-integration, run-visual, run-atlas-determinism. # Exit: 0 = all suites pass, non-zero = any suite failed # Stdout: {"suite":"all","total":N,"passed":N,"failed":N,"duration_ms":N,"suites":[...]} set -euo pipefail @@ -26,6 +27,7 @@ SUITES=( run-ipc-protocol run-ipc-integration run-visual + run-atlas-determinism ) START_MS=$(date +%s%3N) diff --git a/tests/run-atlas-determinism b/tests/run-atlas-determinism new file mode 100755 index 000000000..14353c35f --- /dev/null +++ b/tests/run-atlas-determinism @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# tests/run-atlas-determinism: Determinism smoke test for generate_atlas.py (#847) +# +# Imports generate_atlas as a Python module, calls process_body() twice with +# seed=42 and dry_run=True, compares the returned markers dicts as JSON. +# No wiki files are written or modified. +# +# Purpose: cheap guardrail against determinism regressions in terrain analysis, +# city placement, A* road routing, infrastructure MST, and gate terminal +# placement. GJ892f is a domed body (population=300, 1 city) — the smallest +# well-exercised case in the atlas pipeline. +# +# Spec ref: #847 +# Exit: 0 = deterministic (pass), non-zero = failure +# Stdout: {"suite":"atlas-determinism","total":1,"passed":N,"failed":N,"duration_ms":N} +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +START_MS=$(date +%s%3N) + +# Write the comparison script to a real file so that the generate_atlas venv +# bootstrap (os.execv) can re-exec it from the venv Python when needed. +# A heredoc (python3 - <<'EOF') does not work after os.execv because stdin +# has already been consumed. +HELPER=$(mktemp /tmp/atlas_det_helper.XXXXXX.py) +trap "rm -f '$HELPER'" EXIT + +cat > "$HELPER" << 'PYEOF' +"""Atlas determinism helper — called by tests/run-atlas-determinism (#847). + +Imports generate_atlas as a module and calls process_body() twice with +dry_run=True. Compares the returned markers dicts as JSON. Exits 0 if +identical, 1 if they differ, 2 on setup/import failure. +""" +import json +import os +import sqlite3 +import sys +from pathlib import Path + +REPO_ROOT = Path(os.environ["SR_REPO_ROOT"]) +sys.path.insert(0, str(REPO_ROOT / "tooling" / "planet-gen")) + +try: + import generate_atlas +except ImportError as e: + print(f"SKIP: generate_atlas import failed (missing deps?): {e}", file=sys.stderr) + sys.exit(2) + +DB_PATH = REPO_ROOT / "server" / "data" / "systems.db" +BODY_ID = "GJ892f" # domed, pop=300 → exactly 1 city; minimal and fast +SEED = 42 + +if not DB_PATH.exists(): + print(f"SKIP: systems.db not found: {DB_PATH}", file=sys.stderr) + sys.exit(2) + +conn = sqlite3.connect(str(DB_PATH)) +conn.execute("PRAGMA foreign_keys=ON") +row = conn.execute(""" + SELECT b.body_id, b.system_id, b.terrain_reference, b.population, + b.settlement_pattern, b.planet_class, b.economic_role, + COALESCE(b.cultural_corridor, s.cultural_corridor) + FROM bodies b JOIN star_systems s ON b.system_id = s.system_id + WHERE b.body_id = ? +""", (BODY_ID,)).fetchone() +conn.close() + +if not row: + print(f"SKIP: {BODY_ID} not found in systems.db", file=sys.stderr) + sys.exit(2) + +body_info = dict(zip( + ("body_id", "system_id", "terrain_reference", "population", + "settlement_pattern", "planet_class", "economic_role", "cultural_corridor"), + row, +)) +body_info["population"] = body_info["population"] or 0 + +kwargs = dict( + body_info=body_info, + seed=SEED, + noise_factor=0.25, + dry_run=True, # no files written + force=True, # skip the already-populated check + verbose=False, +) + +a = generate_atlas.process_body(**kwargs) +b = generate_atlas.process_body(**kwargs) + +if a["status"] != "generated": + print(f"FAIL: run 1 status={a['status']} — {a.get('message', '')}", file=sys.stderr) + sys.exit(1) +if b["status"] != "generated": + print(f"FAIL: run 2 status={b['status']} — {b.get('message', '')}", file=sys.stderr) + sys.exit(1) + +json_a = json.dumps(a["markers"], indent=2) +json_b = json.dumps(b["markers"], indent=2) + +if json_a == json_b: + print(f"PASS: {BODY_ID} markers identical on two runs (seed={SEED})", file=sys.stderr) + sys.exit(0) +else: + import difflib + diff = "\n".join(list(difflib.unified_diff( + json_a.splitlines(), json_b.splitlines(), lineterm="", + fromfile="run1", tofile="run2", + ))[:40]) + print(f"FAIL: {BODY_ID} markers differ between run 1 and run 2 (seed={SEED})", file=sys.stderr) + print(diff, file=sys.stderr) + sys.exit(1) +PYEOF + +set +e +SR_REPO_ROOT="$REPO_ROOT" python3 "$HELPER" 2>&1 >&2 +EXIT_CODE=$? +set -e + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +# Exit code 2 = setup failure / missing deps → count as 0 tests (skip, not fail) +if [[ $EXIT_CODE -eq 0 ]]; then + PASSED=1; FAILED=0; TOTAL=1 +elif [[ $EXIT_CODE -eq 2 ]]; then + PASSED=0; FAILED=0; TOTAL=0 + echo " [atlas-determinism] SKIPPED (import failure or missing DB)" >&2 +else + PASSED=0; FAILED=1; TOTAL=1 +fi + +printf '{"suite":"atlas-determinism","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \ + "$TOTAL" "$PASSED" "$FAILED" "$DURATION_MS" +exit $EXIT_CODE