test(assets): atlas determinism smoke test (#847)
Adds tests/run-atlas-determinism — imports generate_atlas as a module and calls process_body() twice with seed=42 and dry_run=True, comparing the returned markers dicts as JSON. No wiki files are written. Guardrail against determinism regressions in terrain analysis, city placement, A* road routing, infrastructure MST, and gate terminal placement. GJ892f (domed, population 300, 1 city) is the smallest well-exercised case. Makefile target: make test-atlas-determinism. Wired into tests/run-all alongside run-ipc-integration and run-visual. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
Executable
+136
@@ -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
|
||||
Reference in New Issue
Block a user