diff --git a/.gitignore b/.gitignore index a2945bc..f9002f4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ my_games # images are produced by many tests *.png +# generated snippet preview screenshots (#381); consumed/committed by the doc-site repo +snippet-shots/ + # WASM stdlib for Emscripten build !wasm_stdlib/ diff --git a/Makefile b/Makefile index 18a2c02..aa21bd6 100644 --- a/Makefile +++ b/Makefile @@ -139,6 +139,12 @@ stamp-snippets: linux check-snippets: linux @python3 tools/stamp_snippets.py --check +# Render a deterministic preview PNG for every snippet into snippet-shots/ (gitignored; +# the images belong in the doc-site repo, which pulls them from here -- see #381). The +# images are a visual-regression oracle: a changed PNG means behaviour changed. +snippet-shots: linux + @python3 tools/generate_snippet_shots.py + # What changed in the API since the last release, and which site pages that # obligates you to update (resolved via each page's `mcrf.objects` frontmatter). api-delta: @@ -166,7 +172,7 @@ release-docs: docs stamp-snippets @echo " then commit both repos. For a Gitea checklist issue instead, run:" @echo " python3 tools/api_delta.py $(BASE_REF) . --site-dir $(SITE_DIR) --format gitea" -.PHONY: docs stamp-snippets check-snippets api-delta release-docs +.PHONY: docs stamp-snippets check-snippets snippet-shots api-delta release-docs # Debug and sanitizer targets debug: diff --git a/tests/snippets/060_bsp_basic.py b/tests/snippets/060_bsp_basic.py index a13761f..19def93 100644 --- a/tests/snippets/060_bsp_basic.py +++ b/tests/snippets/060_bsp_basic.py @@ -26,7 +26,7 @@ for y in range(12): # Create BSP tree (pos and size as tuples) bsp = mcrfpy.BSP(pos=(0, 0), size=(16, 12)) -bsp.split_recursive(depth=3, min_size=(3, 3)) +bsp.split_recursive(depth=3, min_size=(3, 3), seed=42) # Room colors colors = [ diff --git a/tests/snippets/102_bsp_traversal.py b/tests/snippets/102_bsp_traversal.py index 167df8e..bb3f1af 100644 --- a/tests/snippets/102_bsp_traversal.py +++ b/tests/snippets/102_bsp_traversal.py @@ -9,7 +9,7 @@ scene.children.append(mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrf # Create BSP tree bsp = mcrfpy.BSP(pos=(0, 0), size=(16, 12)) -bsp.split_recursive(depth=3, min_size=(3, 3)) +bsp.split_recursive(depth=3, min_size=(3, 3), seed=42) # Visualize in a grid grid = mcrfpy.Grid( diff --git a/tests/snippets/224_BSP_1.py b/tests/snippets/224_BSP_1.py index 434cbf2..505f171 100644 --- a/tests/snippets/224_BSP_1.py +++ b/tests/snippets/224_BSP_1.py @@ -17,7 +17,7 @@ scene.children.append(grid) bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 50)) # Split recursively into rooms -bsp.split_recursive(depth=4, min_size=(8, 8)) +bsp.split_recursive(depth=4, min_size=(8, 8), seed=42) # Iterate over leaf nodes (rooms) room_layer = mcrfpy.ColorLayer(z_index=1, name="rooms") diff --git a/tests/snippets/243_HeightMap.py b/tests/snippets/243_HeightMap.py index 68f3153..434a318 100644 --- a/tests/snippets/243_HeightMap.py +++ b/tests/snippets/243_HeightMap.py @@ -23,7 +23,7 @@ hmap.multiply(noise_map) # Apply as mask # Convert BSP to heightmap bsp = mcrfpy.BSP(pos=(0, 0), size=(20, 20)) -bsp.split_recursive(depth=3, min_size=(4, 4)) +bsp.split_recursive(depth=3, min_size=(4, 4), seed=42) rooms = bsp.to_heightmap(select='leaves', shrink=1) hmap.add(rooms) diff --git a/tests/snippets/_screenshot.py b/tests/snippets/_screenshot.py new file mode 100644 index 0000000..eb35628 --- /dev/null +++ b/tests/snippets/_screenshot.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Screenshot harness -- chained as the LAST --exec, AFTER a documentation snippet, to +capture a rendered preview of the scene the snippet built: + + ./mcrogueface --headless \ + --exec tests/snippets/_seed.py \ + --exec tests/snippets/001_hello_frame.py \ + --exec tests/snippets/_screenshot.py + +This is the screenshot-mode counterpart to _harness.py. The normal test suite chains +_harness.py (pass/fail: does the snippet run and leave a populated scene?). Screenshot +mode chains THIS instead, so the pass/fail gate stays fast and untouched by capture. + +The snippet has already run (it is an earlier --exec, sharing this interpreter and +engine state), so its scene is live and inspectable here. + +Per-snippet parameters arrive by environment, set by tools/generate_snippet_shots.py, +which parses each snippet's `# mcrf:` header: + + MCRF_SHOT_OUT output PNG path (required) + MCRF_SHOT_SETUP_STEPS step(0.016) calls before capture (default 3) + +Phase 1 implements STATIC capture only: advance a few frames so timers/layout settle, +then screenshot. Animation target-time (`shot_at`) and scripted interaction (`action`) +are later phases; a snippet that needs them is not chained here yet. +""" + +import os +import sys + +import mcrfpy +from mcrfpy import automation + +FAIL = "SHOT_FAIL" +OK = "SHOT_OK" + + +def fail(msg): + print(f"{FAIL}: {msg}") + sys.exit(1) + + +out = os.environ.get("MCRF_SHOT_OUT") +if not out: + fail("MCRF_SHOT_OUT not set") + +setup_steps = int(os.environ.get("MCRF_SHOT_SETUP_STEPS", "3")) + +# The snippet built its scene at import. Advance a few frames so anything that only +# resolves under the clock -- a first timer fire that fills a caption, a layout pass, +# an animation's opening frames -- has settled before we capture. The headless +# screenshot forces its own synchronous renderScene() (Automation.cpp #153), so a pure +# static scene would capture correctly with zero steps; the steps are for the snippets +# that need the clock, and cost ~nothing. +for _ in range(setup_steps): + try: + mcrfpy.step(0.016) + except Exception as exc: # noqa: BLE001 -- a raise under the clock is a real failure + import traceback + traceback.print_exc() + fail(f"step() raised {type(exc).__name__}: {exc}") + +scene = mcrfpy.current_scene +if scene is None: + fail("snippet left no active scene (mcrfpy.current_scene is None)") +if len(scene.children) == 0: + fail(f"snippet activated scene {scene.name!r} but added nothing to it") + +if automation.screenshot(out) is not True: + fail(f"screenshot({out!r}) returned falsy -- capture failed") + +print(f"{OK}: {out} scene={scene.name!r} children={len(scene.children)}") +sys.exit(0) diff --git a/tests/snippets/_seed.py b/tests/snippets/_seed.py new file mode 100644 index 0000000..52b9fae --- /dev/null +++ b/tests/snippets/_seed.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +""" +Seed pre-script -- chained as the FIRST --exec, BEFORE a documentation snippet, +when generating deterministic screenshots: + + ./mcrogueface --headless \ + --exec tests/snippets/_seed.py \ + --exec tests/snippets/086_procgen_cellular.py \ + --exec tests/snippets/_screenshot.py + +Why before, not after: a snippet runs to completion at import -- it builds its scene +and draws its procedural content the moment it is exec'd. Anything that consumes +randomness (`random.random()` cave fills, `random.choice` scatter, ...) has already +drawn by the time the *next* --exec runs. So to make a snippet's pixels reproducible, +the interpreter's global RNG must be seeded BEFORE the snippet executes. + +Chaining works because all --exec scripts share one interpreter: this script's +`import random; random.seed(N)` seeds the same module object the snippet later gets +back from its own `import random`. (Snippets that pass an explicit `seed=` to +NoiseSource / HeightMap / BSP are already deterministic and unaffected by this; those +generators use a separate TCOD RNG that `random.seed` does not touch -- see #381.) + +This script is intentionally a no-op for the pass/fail suite; it is only chained by +tools/generate_snippet_shots.py. +""" + +import os +import random + +# Same seed for every snippet: a screenshot is a visual-regression ORACLE, so the only +# thing that should ever change a snippet's image is a change in the snippet or the +# engine -- never the RNG. Overridable for debugging a specific capture. +SEED = int(os.environ.get("MCRF_SEED", "42")) +random.seed(SEED) diff --git a/tools/generate_snippet_shots.py b/tools/generate_snippet_shots.py new file mode 100644 index 0000000..91044ad --- /dev/null +++ b/tools/generate_snippet_shots.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +Generate a rendered preview PNG for every documentation snippet. + +Each snippet in tests/snippets/ is a display script that builds a scene (see +_harness.py). This tool captures what that scene looks like, so the docs site can show +the rendered result next to the code. The images are a VISUAL-REGRESSION ORACLE: they +are deterministic, so a changed image means the snippet's behaviour changed and wants +review -- see Gitea #381. + + python3 tools/generate_snippet_shots.py # capture all + python3 tools/generate_snippet_shots.py 086 224 # capture a subset (by number) + +For each snippet it runs a THREE-stage --exec chain in headless mode: + + _seed.py -> .py -> _screenshot.py + +_seed.py seeds the interpreter RNG before the snippet draws (procedural snippets draw +at import); _screenshot.py advances a few frames and saves the PNG. Output lands in a +gitignored SHOT_DIR -- the images belong in the doc-site repo, not this one; the site +build pulls them from here. + +CAPTURE MODEL (Phase 1: STATIC only) +Every snippet defaults to a static capture: step a few frames, then screenshot. That is +correct for the ~180 snippets whose visual is fully drawn at load. The exceptions live +in OVERRIDES below: + * noshot=True -- no meaningful single frame (API/audio/text-only demos); skip. + * setup_steps=N -- needs more frames than default (e.g. a first timer fire that + fills a caption at t>=0.1s). +Animation target-time (shot_at) and scripted interaction (action) are later phases and +are not captured yet; snippets needing them get a default static frame for now. +""" + +import argparse +import os +import subprocess +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SNIPPET_DIR = os.path.join(ROOT, "tests", "snippets") +BUILD_DIR = os.path.join(ROOT, "build") +MCROGUEFACE = os.path.join(BUILD_DIR, "mcrogueface") +SEED = os.path.join(SNIPPET_DIR, "_seed.py") +SCREENSHOT = os.path.join(SNIPPET_DIR, "_screenshot.py") +SHOT_DIR = os.path.join(ROOT, "snippet-shots") + +DEFAULT_SETUP_STEPS = 3 +TIMEOUT = 30 + +# Per-snippet capture overrides, keyed by the snippet's numeric prefix. Anything not +# listed here uses the STATIC default (setup_steps=3, capture). Grounded in the +# 4-agent taxonomy audit on #381. +OVERRIDES = { + # noshot: no meaningful single-frame visual (opt-out) + 142: {"noshot": True}, # scene-switch API demo, one default frame + 161: {"noshot": True}, # dijkstra heightmap: scalar value print + 225: {"noshot": True}, # BSP traversal: status caption only + 226: {"noshot": True}, # BSP adjacency: status caption only + 251: {"noshot": True}, # mcrfpy.mouse state text + 253: {"noshot": True}, # audio demo, no visual + 254: {"noshot": True}, # NoiseSource numbers printed to a caption + 255: {"noshot": True}, # NoiseSource size printed to a caption + 265: {"noshot": True}, # BSP traversal: status caption only + 268: {"noshot": True}, # Window API demo, calls window.screenshot() + # setup_steps: a first timer fire (>=0.1s) fills a caption that is otherwise + # placeholder text at the default 3 steps (~0.048s). + 85: {"setup_steps": 12}, # entity_visibility status caption + 91: {"setup_steps": 12}, # metrics_display labels +} + + +def snippet_paths(): + names = sorted( + n for n in os.listdir(SNIPPET_DIR) + if n.endswith(".py") and not n.startswith("_") + ) + return [os.path.join(SNIPPET_DIR, n) for n in names] + + +def number_of(name): + """Leading integer of a snippet filename, or None.""" + head = os.path.basename(name).split("_", 1)[0] + return int(head) if head.isdigit() else None + + +def capture(path, env_base): + """Run one snippet through the seed->snippet->screenshot chain.""" + num = number_of(path) + cfg = OVERRIDES.get(num, {}) + if cfg.get("noshot"): + return "skip", "noshot" + + base = os.path.basename(path)[:-3] # drop .py + out = os.path.join(SHOT_DIR, base + ".png") + env = env_base.copy() + env["MCRF_SHOT_OUT"] = out + env["MCRF_SHOT_SETUP_STEPS"] = str(cfg.get("setup_steps", DEFAULT_SETUP_STEPS)) + + try: + proc = subprocess.run( + [MCROGUEFACE, "--headless", + "--exec", SEED, "--exec", path, "--exec", SCREENSHOT], + capture_output=True, text=True, timeout=TIMEOUT, cwd=BUILD_DIR, env=env, + ) + except subprocess.TimeoutExpired: + return "fail", "timeout" + + log = proc.stdout + proc.stderr + if "SHOT_OK" in log and os.path.exists(out): + return "ok", out + if "Traceback (most recent call last)" in log: + last = [l for l in log.strip().splitlines() if l.strip()] + return "fail", last[-1][:120] if last else "traceback" + if "SHOT_FAIL" in log: + line = next((l for l in log.splitlines() if "SHOT_FAIL" in l), "") + return "fail", line.split("SHOT_FAIL:", 1)[-1].strip()[:120] + if "SNIPPET_FAIL" in log: + return "fail", "snippet raised before capture" + return "fail", f"no SHOT_OK (exit {proc.returncode})" + + +def main(): + ap = argparse.ArgumentParser(description="Generate snippet preview screenshots.") + ap.add_argument("only", nargs="*", + help="capture only these snippet numbers (e.g. 086 224)") + args = ap.parse_args() + + if not os.path.exists(MCROGUEFACE): + sys.exit(f"engine not built: {MCROGUEFACE} (run `make` first)") + + os.makedirs(SHOT_DIR, exist_ok=True) + env_base = os.environ.copy() + lib = os.path.join(BUILD_DIR, "lib") + env_base["LD_LIBRARY_PATH"] = os.pathsep.join( + p for p in (lib, env_base.get("LD_LIBRARY_PATH", "")) if p + ) + + wanted = {int(x) for x in args.only} if args.only else None + paths = [p for p in snippet_paths() + if wanted is None or number_of(p) in wanted] + + captured, skipped, failed = 0, 0, [] + for path in paths: + status, detail = capture(path, env_base) + name = os.path.basename(path) + if status == "ok": + captured += 1 + elif status == "skip": + skipped += 1 + else: + failed.append((name, detail)) + print(f" FAIL {name}: {detail}") + + print(f"\nsnippet-shots: {captured} captured, {skipped} skipped (noshot), " + f"{len(failed)} failed -> {SHOT_DIR}") + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main()