D-258 rulings 5 and 6: the pre-seed biome input is class ids only, on two maps, because compute_biome was already computing them apart and throwing one away. It gave every land pixel its Whittaker (climate) class, then painted terrain over it (ocean bands, ice, altitude snow, mountain rock, lava and ash, the dry, lunar and ferric ground). planet_simulation: - compute_biome_layers returns (biome, terrain). Each rule declares the layer it writes, by RULE not by class: ice from the cold climate box is biome, altitude snow is terrain with the lowland biome kept beneath. Rules that place life (thermophiles, mats, crust, the oasis rings) write the biome and clear the terrain under them. - 255 means "nothing on this layer", not 0, because class 0 is ocean_deep, itself terrain. Ids stay literal. - compute_biome is now compose_layers() of the pair, so the renderers are untouched. Proven byte-identical, dtype included, on 65 real bodies: every 8th standard-atmosphere body plus every thin, thick, reducing and trace one, captured before the edit and compared after it. - simulate() carries both maps as biome_layer / terrain_layer. reach atlas planet bake-biome [--body --limit --dry-run --check]: - Writes biomemap.png + terrainmap.png (8-bit, 1024x512, tEXt: layer, none, classes, decision) beside each heightmap. Scope is the heightmap bake set with an atmosphere (257 bodies; airless are deferred, per D-258). - Pre-seed by construction: simulate() is keyed on the body frontmatter's seed, and no world seed is accepted anywhere. - --check re-simulates and compares DECODED PIXELS, never bytes, since PNG encoding drifts across Pillow/zlib versions (T-1291) and a check that trips on that gets muted. Mutation-proven: one flipped pixel exits 1 and names the file; the restored file exits 0. - import_heightmaps' body lookup is extracted as body_def_for and shared, not copied. Tests (make test-tooling): rock keeps its biome beneath, thin-atmosphere ground is terrain with only scattered life, water is terrain only, no pixel is empty on both maps, and stacking equals the rendered grid. Routing rock through the biome map fails two of them by name. The router drift test covers the new verb. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
7.3 KiB
Python
185 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
"""`atlas planet` declares its options twice — this stops them drifting (T-1288).
|
|
|
|
The router restates ten argument surfaces that already exist in the modules'
|
|
own argparse parsers. That duplication buys real `--help` for an agent, which
|
|
a passthrough could not, but it creates the obvious failure: the router grows
|
|
an option the module has never heard of, and the mismatch only shows up when
|
|
someone runs the command with that flag.
|
|
|
|
So every option the router declares is handed to the module's own parser here.
|
|
A parser rejects an unknown option with SystemExit(2), which is exactly the
|
|
signal wanted — no output comparison, no fixtures, no running the generators.
|
|
|
|
Nothing here executes a generator. Each parser is invoked directly, so a full
|
|
pass costs milliseconds and never touches the atlas DB.
|
|
|
|
Run: python3 tooling/test_planet_router.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import io
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
import typer # noqa: E402
|
|
|
|
from tooling.domains.atlas.planet import router as planet_router # noqa: E402
|
|
|
|
# verb -> (module attribute, positional arguments the parser requires)
|
|
IMPLEMENTATIONS = {
|
|
"generate": ("generate", ["body.json"]),
|
|
"batch": ("batch", []),
|
|
"scaffold": ("scaffold_bodies", ["index.json"]),
|
|
"import-heightmaps": ("import_heightmaps", []),
|
|
"bake-biome": ("bake_biome", []),
|
|
"import-provinces": ("import_province_boundaries", []),
|
|
"terrain-reference": ("populate_terrain_reference", []),
|
|
"sol-import": ("sol_import", []),
|
|
"sol-name-fixes": ("sol_name_fixes", []),
|
|
"audit": ("atlas_cohesion_audit", []),
|
|
"quality": ("atlas_quality_analysis", []),
|
|
}
|
|
|
|
# A representative value per click param type, so the parser sees a well-formed
|
|
# pair. Keyed on the type's `name` because these are ParamType INSTANCES, not
|
|
# classes — keying on the class matches nothing and every int option then gets
|
|
# the string "x" and reads as a parser rejection.
|
|
SAMPLE = {"int": "4", "str": "x", "path": "x", "filename": "x", "float": "1.0"}
|
|
|
|
# Options whose module-side parser restricts the value. The router cannot send
|
|
# a placeholder to these, so the test sends a real one.
|
|
CONSTRAINED = {
|
|
("generate", "--render-mode"): "cartographic",
|
|
("sol-import", "--render-mode"): "cartographic",
|
|
}
|
|
|
|
|
|
def _router_options(verb: str) -> list[tuple[str, type]]:
|
|
"""The long options the router declares for a verb, with their types."""
|
|
command = typer.main.get_command(planet_router.app).commands[verb] # type: ignore[attr-defined]
|
|
found: list[tuple[str, type]] = []
|
|
for param in command.params:
|
|
for opt in getattr(param, "opts", []):
|
|
if opt.startswith("--") and opt != "--help":
|
|
found.append((opt, param.type))
|
|
return found
|
|
|
|
|
|
class _Parsed(BaseException):
|
|
"""Raised the instant a parser accepts, to stop before any work happens."""
|
|
|
|
|
|
def _parser_accepts(module, argv: list[str]) -> tuple[bool, str]:
|
|
"""Feed argv to the module's own parser, and stop the moment it accepts.
|
|
|
|
`main()` builds its parser and then does the work, so simply calling it
|
|
would generate planets. Patching `parse_args` lets the module construct its
|
|
real parser — the thing under test — and aborts on the line after it
|
|
succeeds. A rejection still raises SystemExit(2) from inside argparse.
|
|
|
|
Found the hard way: the first version of this test called `main()` and let
|
|
it run, and spent two minutes generating bodies for GJ_1005A before it was
|
|
stopped.
|
|
"""
|
|
real = argparse.ArgumentParser.parse_args
|
|
|
|
def stop_after_parsing(self, args=None, namespace=None):
|
|
real(self, args, namespace)
|
|
raise _Parsed
|
|
|
|
buffer = io.StringIO()
|
|
argparse.ArgumentParser.parse_args = stop_after_parsing
|
|
try:
|
|
with contextlib.redirect_stderr(buffer), contextlib.redirect_stdout(buffer):
|
|
module.main(argv)
|
|
except _Parsed:
|
|
return True, ""
|
|
except SystemExit as exc:
|
|
if exc.code == 2: # argparse's "bad arguments"
|
|
return False, buffer.getvalue().strip()
|
|
return True, ""
|
|
except BaseException:
|
|
# Failed before reaching parse_args — an import guard, a missing DB.
|
|
# Not this test's business either way.
|
|
return True, ""
|
|
finally:
|
|
argparse.ArgumentParser.parse_args = real
|
|
return True, ""
|
|
|
|
|
|
def test_every_option_is_known_to_its_parser(failures: list[str]) -> None:
|
|
import importlib
|
|
|
|
for verb, (module_name, positionals) in IMPLEMENTATIONS.items():
|
|
module = importlib.import_module(f"tooling.domains.atlas.planet.{module_name}")
|
|
for opt, param_type in _router_options(verb):
|
|
argv = list(positionals)
|
|
argv.append(opt)
|
|
type_name = getattr(param_type, "name", "text")
|
|
if type_name != "boolean":
|
|
argv.append(CONSTRAINED.get((verb, opt), SAMPLE.get(type_name, "x")))
|
|
|
|
ok, err = _parser_accepts(module, argv)
|
|
if not ok:
|
|
failures.append(
|
|
f"`reach atlas planet {verb} {opt}` — {module_name}.py's parser "
|
|
f"does not accept it: {err.splitlines()[-1] if err else 'rejected'}"
|
|
)
|
|
|
|
|
|
def test_every_verb_has_an_implementation(failures: list[str]) -> None:
|
|
"""A verb missing from IMPLEMENTATIONS would be silently unchecked."""
|
|
declared = set(typer.main.get_command(planet_router.app).commands) # type: ignore[attr-defined]
|
|
covered = set(IMPLEMENTATIONS)
|
|
for verb in sorted(declared - covered):
|
|
failures.append(
|
|
f"`reach atlas planet {verb}` is not in IMPLEMENTATIONS — add it, or "
|
|
"its options are never checked against a parser"
|
|
)
|
|
for verb in sorted(covered - declared):
|
|
failures.append(f"IMPLEMENTATIONS names '{verb}', which the router does not declare")
|
|
|
|
|
|
def test_flags_builder_drops_defaults(failures: list[str]) -> None:
|
|
"""None and False must not reach argv — the module owns its defaults."""
|
|
built = planet_router._flags(system=None, force=False, body="GJ380c", limit=7, dry_run=True)
|
|
if "--system" in built or "--force" in built:
|
|
failures.append(f"_flags passed an unset option through: {built}")
|
|
if built != ["--body", "GJ380c", "--limit", "7", "--dry-run"]:
|
|
failures.append(f"_flags built unexpected argv: {built}")
|
|
# sol-import's --body is action="append"; a list must repeat the flag, or
|
|
# `--body GJ0d --body GJ0e` silently keeps only one of them.
|
|
repeated = planet_router._flags(body=["GJ0d", "GJ0e"])
|
|
if repeated != ["--body", "GJ0d", "--body", "GJ0e"]:
|
|
failures.append(f"_flags did not repeat a list option: {repeated}")
|
|
|
|
|
|
def main() -> int:
|
|
failures: list[str] = []
|
|
test_every_verb_has_an_implementation(failures)
|
|
test_flags_builder_drops_defaults(failures)
|
|
test_every_option_is_known_to_its_parser(failures)
|
|
|
|
if failures:
|
|
print("test_planet_router: FAIL", file=sys.stderr)
|
|
for failure in failures:
|
|
print(f" - {failure}", file=sys.stderr)
|
|
return 1
|
|
total = sum(len(_router_options(v)) for v in IMPLEMENTATIONS)
|
|
print(
|
|
f"test_planet_router: OK — {len(IMPLEMENTATIONS)} verbs, {total} options, "
|
|
"every one accepted by the module's own parser"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|