## Visual test capture engine — boots the entry's "scene" (default main.tscn), ## runs a named scenario or flow, captures viewport PNGs for golden comparison ## or ad-hoc inspection. ## ## Usage: ## godot --rendering-driver opengl3 --fixed-fps 60 --resolution 960x540 \ ## --path client -s res://tests/visual_capture.gd -- \ ## --scenario fog_3state --output /abs/path ## ## godot ... -- --flow flow_dialogue --output /abs/path --interval 3 ## godot ... -- --list ## ## T-1120: --scenario atlas_matrix --output /abs/path (SR_LIVE=1 SR_PORT=N ## required — reads tests/atlas_shots.json instead of tests/visual.json's ## scenarios{}, loops every body/zoom/overlay-set combo in ONE Godot boot). ## ## Follows gen_client_fixtures.gd pattern: extends SceneTree, _init -> _run, quit(). ## Requires a real rendering driver (not --headless) for shader execution. extends SceneTree # Load VisualScenarios explicitly — class_name registry not populated in -s mode. # Must create an instance: static methods on loaded GDScript resources aren't # callable via variable (only via class_name, which isn't available in -s mode). var _scenarios: RefCounted = null var _output_dir: String = "" var _scenario: String = "" var _flow: String = "" var _interval: float = 3.0 var _list_mode: bool = false var _config: Dictionary = {} var _is_live: bool = false func _init(): _run.call_deferred() func _run(): # gdlint:disable=max-returns # Parse CLI args (after -- separator) _parse_args() # Load scenarios script and create instance (class_name not available in -s mode) var scenarios_script: GDScript = load("res://tests/visual_scenarios.gd") _scenarios = scenarios_script.new() # Load config _config = _load_config() if _config.is_empty(): push_error("visual_capture: failed to load tests/visual.json") quit(1) return # List mode: print scenario/flow names and exit if _list_mode: _print_list() quit() return # Validate mode if _scenario.is_empty() and _flow.is_empty(): push_error("visual_capture: specify --scenario NAME or --flow NAME (or --list)") quit(1) return if _output_dir.is_empty(): push_error("visual_capture: --output DIR is required") quit(1) return # Ensure output directory exists DirAccess.make_dir_recursive_absolute(_output_dir) # Per-entry overrides (T-1088, design §10.2 — both additive; existing entries # without these keys behave byte-identically): # "scene": alternate root scene (default res://scenes/main.tscn) # "env": env vars set before the scene boots (e.g. SR_AUTOPILOT) — OS-level, # so the scene's _ready() reads them exactly like shell-exported vars; # run-visual has no per-scenario env mechanism, this is it. var entry_cfg := _entry_config() var env_vars: Dictionary = entry_cfg.get("env", {}) for env_key: String in env_vars: OS.set_environment(env_key, str(env_vars[env_key])) # Load root scene (autoloads already initialized from project.godot) var scene_path: String = entry_cfg.get("scene", "res://scenes/main.tscn") var main_scene = load(scene_path) if main_scene == null: push_error("visual_capture: failed to load %s" % scene_path) quit(1) return var main_node = main_scene.instantiate() # Live mode: configure server port BEFORE main.gd._ready() calls connect_to_sim() _is_live = OS.get_environment("SR_LIVE") == "1" if _is_live: var sim_bridge := root.get_node("/root/SimBridge") var port_env := OS.get_environment("SR_PORT") if port_env.is_empty(): push_error("visual_capture: SR_LIVE=1 but SR_PORT not set") quit(1) return sim_bridge.server_port = int(port_env) print("visual_capture: live mode — server port %d" % sim_bridge.server_port) root.add_child(main_node) # Wait for NoiseTexture2D async generation var fog_overlay = main_node.get_node_or_null("World/FogOverlay") if fog_overlay and not fog_overlay._noise_ready: print("visual_capture: waiting for fog noise texture...") await fog_overlay.fog_noise_ready print("visual_capture: fog noise ready") # Set deterministic shader time BEFORE settle frames # Autoload globals not available as identifiers in -s mode — use tree path. root.get_node("/root/FogState").override_time = 1.0 # Settle frames (camera smoothing, fog uniform init, UI layout) var settle_count: int = _config.get("settle_frames", 30) print("visual_capture: settling %d frames..." % settle_count) for i in range(settle_count): await process_frame # Live mode: wait for server connection and first snapshot if _is_live: var sim_bridge := root.get_node("/root/SimBridge") var game_state := root.get_node("/root/GameState") print("visual_capture: waiting for server connection...") var max_frames := 300 # 5 seconds at 60fps var waited := 0 while sim_bridge.state != sim_bridge.ConnectionState.CONNECTED: if sim_bridge.state == sim_bridge.ConnectionState.ERROR: push_error("visual_capture: server connection failed") quit(1) return await process_frame waited += 1 if waited >= max_frames: push_error("visual_capture: connection timeout after %d frames" % waited) quit(1) return print("visual_capture: connected after %d frames" % waited) # Wait for first snapshot from server waited = 0 while game_state.current_tick == 0: await process_frame waited += 1 if waited >= max_frames: push_error("visual_capture: no snapshot after %d frames" % waited) quit(1) return print( "visual_capture: first snapshot tick=%d (%d frames)" % [game_state.current_tick, waited] ) if not _scenario.is_empty(): await _run_scenario(main_node) elif not _flow.is_empty(): await _run_flow(main_node) quit() func _run_scenario(_main_node: Node) -> void: # T-1120: atlas_matrix is not a tests/visual.json scenario entry — it drives # its own config (tests/atlas_shots.json) and loops many captures per Godot # boot instead of one. Dispatch before the tests/visual.json lookup below so # it never needs a (meaningless) scenarios{} entry there. if _scenario == "atlas_matrix": await _run_atlas_matrix() return var scenarios: Dictionary = _config.get("scenarios", {}) if not scenarios.has(_scenario): push_error("visual_capture: unknown scenario '%s'" % _scenario) quit(1) return var scenario_cfg: Dictionary = scenarios[_scenario] var ticks: int = scenario_cfg.get("ticks", 5) print("visual_capture: scenario '%s' — %d ticks" % [_scenario, ticks]) # Apply scenario setup hook (pass root for autoload access) if not _scenarios.apply_setup(_scenario, root): push_warning("visual_capture: no setup hook for '%s'" % _scenario) # Advance simulation ticks (each process_frame polls one snapshot) for i in range(ticks): await process_frame # Post-tick setup (e.g. zone tint patching) _scenarios.post_setup(_scenario, root) # Replay snapshot: inject a real server snapshot through the FULL client pipeline. # Loads MessagePack bytes (exact wire format from server), decodes via Protocol.gd, # then applies through GameState → FogState → shader — same path as live game. var replay_path: String = scenario_cfg.get("replay_snapshot", "") if not replay_path.is_empty(): var project_root := ProjectSettings.globalize_path("res://") var repo_root := project_root.rstrip("/").get_base_dir() var abs_path := repo_root.path_join(replay_path) var rf := FileAccess.open(abs_path, FileAccess.READ) if rf == null: push_error("visual_capture: cannot open replay snapshot %s" % abs_path) quit(1) return var replay_bytes := rf.get_buffer(rf.get_length()) rf.close() # Decode through Protocol.decode_snapshot() — same as live IPC receive path. # This exercises: msgpack decode → entity decode → tile_kind→type mapping → etc. # class_name `Protocol` isn't resolvable in -s mode (see header note); # instantiate the script and call the static decoder on the instance, # then free it — the decoded Dictionary is independent of the node. var protocol_node: Node = load("res://scripts/protocol/protocol.gd").new() var replay_data: Variant = protocol_node.decode_snapshot(replay_bytes) protocol_node.free() if replay_data == null or not replay_data is Dictionary: push_error("visual_capture: Protocol.decode_snapshot failed for %s" % abs_path) quit(1) return print( ( "visual_capture: replaying %s (%d bytes, tick=%s, %d tiles)" % [ replay_path, replay_bytes.size(), str(replay_data.get("tick", "?")), replay_data.get("visible_tiles", []).size() ] ) ) var game_state := root.get_node("/root/GameState") var fog_state := root.get_node("/root/FogState") game_state.apply_snapshot(replay_data) fog_state.update_from_state() # Extra frames for fog uniform propagation for i in range(4): await process_frame # Extra frames for state propagation + viewport texture lag await process_frame await process_frame # Capture await _capture("%s/%s.png" % [_output_dir, _scenario]) func _run_flow(_main_node: Node) -> void: var flows: Dictionary = _config.get("flows", {}) if not flows.has(_flow): push_error("visual_capture: unknown flow '%s'" % _flow) quit(1) return var flow_cfg: Dictionary = flows[_flow] var steps: Array = flow_cfg.get("steps", []) var interval: float = flow_cfg.get("interval", _interval) print("visual_capture: flow '%s' — %d steps, %.1fs interval" % [_flow, steps.size(), interval]) # Create output subdirectory for flow frames var flow_dir := "%s/%s" % [_output_dir, _flow] DirAccess.make_dir_recursive_absolute(flow_dir) var manifest_lines: PackedStringArray = [] var frame_idx: int = 0 for step in steps: if not step is Dictionary: continue var action: String = step.get("action", "wait") var label: String = step.get("label", action) # Apply action _scenarios.apply_flow_action(action, root) # Wait for interval (timer yields back to main loop — frames keep rendering) await create_timer(interval).timeout # Extra frames for viewport texture to catch up await process_frame await process_frame # Capture frame var frame_name := "%s_%03d.png" % [_flow, frame_idx] await _capture("%s/%s" % [flow_dir, frame_name]) # Build manifest line var total_seconds: int = int((frame_idx + 1) * interval) var timecode := "%d:%02d" % [total_seconds / 60, total_seconds % 60] manifest_lines.append("%03d %s %s" % [frame_idx, timecode, label]) frame_idx += 1 # Write manifest sidecar var manifest_path := "%s/%s_manifest.txt" % [flow_dir, _flow] var f := FileAccess.open(manifest_path, FileAccess.WRITE) if f: f.store_string("\n".join(manifest_lines) + "\n") f.close() print("visual_capture: manifest -> %s" % manifest_path) ## T-1120: Atlas screenshot capture matrix — ONE Godot boot, opens the live ## Atlas app once (same opener as the atlas_gen_open scenario precedent: ## HudGroups.open_app("implant/map")), then loops bodies × zoom × overlay-set ## combos from tests/atlas_shots.json, capturing a PNG per combo. ## ## Live-mode only (SR_LIVE=1) — atlas data (heightmap/markers/generation ## layers) all come from server/data/systems.db via the running server, same ## as every other Atlas path. Requires _run()'s live-mode server-connection ## wait to have already completed before this is called. ## ## GOLDEN PROVENANCE (T-1121): the 12 curated atlas_* goldens promoted from ## this matrix are machine-local — captured/verified exact-match on one box, ## and goldens are known NOT to port across GPU/font stacks at the current ## tolerance. A golden failing on a different machine is expected drift, not ## a regression, until T-1121 settles the canonical capture box. See the ## _comment_atlas_goldens note in tests/visual.json. func _run_atlas_matrix() -> void: var matrix_cfg := _load_atlas_shots_config() if matrix_cfg.is_empty(): push_error("visual_capture: failed to load tests/atlas_shots.json") quit(1) return var bodies: Array = matrix_cfg.get("bodies", []) var shots: Array = matrix_cfg.get("shots", []) if bodies.is_empty() or shots.is_empty(): push_error("visual_capture: tests/atlas_shots.json has no bodies/shots") quit(1) return var body_lookup: Dictionary = {} for b: Dictionary in bodies: body_lookup[str(b.get("body_id", ""))] = b var gen_timeout: float = float(matrix_cfg.get("gen_layers_timeout_seconds", 15.0)) var city_timeout: float = float(matrix_cfg.get("city_names_timeout_seconds", 15.0)) print( ( "visual_capture: atlas_matrix — %d bodies, %d shots, output %s" % [bodies.size(), shots.size(), _output_dir] ) ) # Open the Atlas the same way a player would (precedent: atlas_gen_open # scenario, visual_scenarios.gd) — HudGroups is the production opener. var hud_groups: Node = root.get_node("/root/HudGroups") var registry: Node = root.get_node("/root/ImplantRegistry") hud_groups.open_app("implant/map") var app: Variant = registry.get_app_instance("implant/map") if app == null: push_error("visual_capture: atlas app not instantiated") quit(1) return # First body establishes the "regional" screen via nav.push (matches the # production entry path — SystemScreen -> nav.push("regional", ...)). # Subsequent bodies call RegionalScreen.enter() DIRECTLY instead of # nav.push/replace: ImplantApp._on_screen_changed() only calls enter() when # the screen id CHANGES (`if _current_screen_id != new_id`), so pushing or # replacing "regional" -> "regional" again for body #2 onward would be a # silent no-op that leaves the viewer showing body #1 (found via reading # implant_app.gd — not previously documented in the recon blueprint). var first_body_id: String = str(shots[0].get("body_id", "")) var first_body: Dictionary = body_lookup.get(first_body_id, {}) app.nav.push( "regional", {"body": first_body, "system": _system_dict_for(first_body)} ) var viewer: Variant = app._regional_screen._viewer if viewer == null: push_error("visual_capture: regional screen has no viewer") quit(1) return var current_body_id: String = "" var ready_bodies: Array = [] var no_layers_bodies: Array = [] for shot: Dictionary in shots: var body_id: String = str(shot.get("body_id", "")) var body: Dictionary = body_lookup.get(body_id, {}) if body.is_empty(): push_warning("visual_capture: shot references unknown body_id '%s'" % body_id) continue # Only re-enter (reload heightmap/markers/generation layers) when the # body actually changes — shots are grouped per-body in atlas_shots.json, # so this only fires once per body, not once per shot. if body_id != current_body_id: current_body_id = body_id app._regional_screen.enter( {"body": body, "system": _system_dict_for(body)} ) var layers_ready := await _wait_for_atlas_layers_ready(body_id, gen_timeout) if layers_ready: ready_bodies.append(body_id) else: no_layers_bodies.append(body_id) await _wait_for_city_names(viewer, city_timeout) # Settle frames: heightmap/marker draw + overlay queue_redraw calls. for i in range(4): await process_frame # Apply overlay set: clear every gen_* toggle, then enable this shot's set. _apply_overlay_set(viewer, shot.get("overlays", [])) # Apply zoom. Some bodies have "fit" shots ordered AFTER a 2.0/4.0 shot # in atlas_shots.json (e.g. set B's zfit comes after z2.0/z4.0), so this # can't assume show_body()/enter()'s one-time internal _fit_to_view() # call is still the current view — every "fit" shot recomputes it. var zoom_val: Variant = shot.get("zoom", "fit") if zoom_val is String and zoom_val == "fit": _apply_fit_zoom(viewer) else: _apply_centered_zoom(viewer, float(zoom_val)) await process_frame await process_frame var filename: String = str(shot.get("filename", "")) if filename.is_empty(): push_warning("visual_capture: shot for '%s' has no filename, skipping" % body_id) continue await _capture("%s/%s" % [_output_dir, filename]) print( ( "visual_capture: atlas_matrix done — %d/%d bodies had Ready generation layers" % [ready_bodies.size(), bodies.size()] ) ) if not no_layers_bodies.is_empty(): print("visual_capture: atlas_matrix — no Ready layers for: %s" % str(no_layers_bodies)) ## Build the {system_id, proper_name} dict AtlasViewer.show_body()/enter() ## expects, from an atlas_shots.json body entry (which already carries ## system_id/system_proper_name inline — no separate system lookup needed). func _system_dict_for(body: Dictionary) -> Dictionary: return { "system_id": str(body.get("system_id", "")), "proper_name": str(body.get("system_proper_name", "")), } ## Clear every toggleable overlay (the "always" group — terrain/infrastructure/ ## etc. — is never in _overlay_visibility's toggle set and set_overlay_visible ## is a no-op for locked ids, so this only ever touches gen_*/population_density/ ## production_zones/corp_presence toggles) then enable exactly the requested set. func _apply_overlay_set(viewer: Variant, overlay_ids: Array) -> void: for def: Dictionary in viewer.get_overlay_defs(): var overlay_id: String = str(def.get("id", "")) if str(def.get("group", "")) == "toggle": viewer.set_overlay_visible(overlay_id, overlay_ids.has(overlay_id)) for overlay_id: String in overlay_ids: viewer.set_overlay_visible(overlay_id, true) ## Zoom to `zoom`, re-centering the offset the same way _fit_to_view() centers ## it (formula read from atlas_viewer.gd) — reusing the current (fit) offset ## verbatim at a different zoom would leave most of a 4x-zoomed texture off- ## screen, since that offset was computed to center the much-smaller fit-zoom ## texture. Uses only AtlasViewer's public surface (get_heightmap_texture(), ## get_rect(), set_view()) — no private field access. func _apply_centered_zoom(viewer: Variant, zoom: float) -> void: var tex: Texture2D = viewer.get_heightmap_texture() if tex == null: viewer.set_view(zoom, viewer.get_view_offset()) return var tex_w: float = float(tex.get_width()) var tex_h: float = float(tex.get_height()) var sz: Vector2 = viewer.get_rect().size if sz == Vector2.ZERO: sz = Vector2(1280.0, 720.0) var scaled: Vector2 = Vector2(tex_w, tex_h) * zoom var offset: Vector2 = (sz - scaled) * 0.5 + Vector2(0, 20) viewer.set_view(zoom, offset) ## Recomputes AtlasViewer's default "fit" zoom+offset (same formula as its ## private _fit_to_view(), read from atlas_viewer.gd: fit zoom = min(avail.x/ ## tex_w, avail.y/tex_h) * 0.92, avail = viewport size minus a 60px header ## allowance) using only public surface — needed because some shots order a ## "fit" zoom AFTER a 2.0/4.0 shot for the same body (set B), so the harness ## can't rely on show_body()'s one-time internal _fit_to_view() call still ## being the current view. Does NOT replicate _fit_to_view()'s MIN_ZOOM/ ## MAX_ZOOM clamp — set_view() already clamps internally (and MIN_ZOOM/ ## MAX_ZOOM aren't reachable here anyway: AtlasViewer is a class_name type, ## unresolvable by identifier in -s mode per this file's header note). func _apply_fit_zoom(viewer: Variant) -> void: var tex: Texture2D = viewer.get_heightmap_texture() if tex == null: viewer.set_view(1.0, Vector2.ZERO) return var tex_w: float = float(tex.get_width()) var tex_h: float = float(tex.get_height()) var sz: Vector2 = viewer.get_rect().size if sz == Vector2.ZERO: sz = Vector2(1280.0, 720.0) var avail: Vector2 = sz - Vector2(0, 60) var fit_x: float = avail.x / tex_w var fit_y: float = avail.y / tex_h var fit_zoom: float = minf(fit_x, fit_y) * 0.92 var scaled: Vector2 = Vector2(tex_w, tex_h) * fit_zoom var offset: Vector2 = (sz - scaled) * 0.5 + Vector2(0, 20) viewer.set_view(fit_zoom, offset) ## Poll SimBridge.atlas_layers_received until this body's response is Ready, ## or the timeout elapses. Returns true on Ready, false on timeout — caller ## captures either way and logs which bodies never reached Ready (per the ## ticket: "capture anyway + log a warning naming the body"). ## AtlasViewer already re-polls Pending responses internally ## (_schedule_gen_retry, GEN_RETRY_DELAY/GEN_MAX_RETRIES ~10s ceiling) via its ## own atlas_layers_received connection — this just watches the same signal ## from the outside to know when to stop waiting and capture. ## ## State is carried in a single-element Array (`state[0]`/`state[1]`), NOT ## plain bool locals — GDScript lambda closures capture value-type locals ## (bool/int/float) BY VALUE at closure-creation time, so a `func() -> void: ## received = true` assignment inside the lambda does NOT propagate back to ## an outer `var received := false`. Confirmed by direct test (godot4 ## --headless -s closure_test.gd): a bool set true inside a lambda reads back ## false outside it. Arrays/Dictionaries are reference types, so mutating ## through a captured reference DOES propagate — this was the actual reason ## every body in the first real run timed out at exactly `timeout_seconds` ## despite the server reaching Ready in ~2s (verified via a standalone probe ## script against a live --test-mode server). func _wait_for_atlas_layers_ready(body_id: String, timeout_seconds: float) -> bool: var sim_bridge := root.get_node("/root/SimBridge") var state: Array = [false, false] # [received, is_ready] var handler := func(response: Dictionary) -> void: if str(response.get("body_id", "")) != body_id: return if str(response.get("status", "")) == "Ready": state[0] = true state[1] = true elif str(response.get("status", "")) not in ["Pending", ""]: # NotFound/Error — stop waiting, this body will never reach Ready. state[0] = true state[1] = false sim_bridge.atlas_layers_received.connect(handler) var elapsed := 0.0 var frame_budget := 1.0 / 60.0 while not state[0] and elapsed < timeout_seconds: await process_frame elapsed += frame_budget sim_bridge.atlas_layers_received.disconnect(handler) if not state[0]: push_warning( ( "visual_capture: atlas_matrix — no Ready/terminal status for '%s' after %.1fs, capturing terrain-only" % [body_id, timeout_seconds] ) ) return false if not state[1]: push_warning("visual_capture: atlas_matrix — '%s' returned a non-Ready terminal status" % body_id) return state[1] ## Poll until city names have arrived for the viewer's current body, or the ## timeout elapses (city dots/labels are optional dressing on the shot, not a ## hard requirement — captures proceed either way). func _wait_for_city_names(viewer: Variant, timeout_seconds: float) -> void: var elapsed := 0.0 var frame_budget := 1.0 / 60.0 while viewer.has_pending_city_names_request() and elapsed < timeout_seconds: await process_frame elapsed += frame_budget func _capture(path: String) -> void: # Wait for GPU to finish rendering current frame await RenderingServer.frame_post_draw var image := root.get_viewport().get_texture().get_image() if image == null: push_error("visual_capture: viewport image is null — GPU rendering may not be available") quit(1) return var err := image.save_png(path) if err != OK: push_error("visual_capture: failed to save PNG to %s (error %d)" % [path, err]) quit(1) return print("visual_capture: captured -> %s (%dx%d)" % [path, image.get_width(), image.get_height()]) ## Config entry for the active scenario or flow ({} when the name is unknown — ## the mode runners report that error themselves). func _entry_config() -> Dictionary: var section: Dictionary = {} if not _scenario.is_empty(): section = _config.get("scenarios", {}) return section.get(_scenario, {}) if not _flow.is_empty(): section = _config.get("flows", {}) return section.get(_flow, {}) return {} func _load_config() -> Dictionary: # Config is at tests/visual.json relative to repo root. # Repo root = parent of Godot project root (client/). var project_root := ProjectSettings.globalize_path("res://") var repo_root := project_root.rstrip("/").get_base_dir() var config_path := repo_root.path_join("tests/visual.json") var f := FileAccess.open(config_path, FileAccess.READ) if f == null: push_error("visual_capture: cannot open %s" % config_path) return {} var json_text := f.get_as_text() f.close() var parsed = JSON.parse_string(json_text) if parsed == null or not parsed is Dictionary: push_error("visual_capture: invalid JSON in %s" % config_path) return {} return parsed ## T-1120: atlas_shots.json — same load shape as _load_config() (tests/visual.json), ## a sibling file in the same tests/ directory, so the repo_root derivation is ## identical. Kept as a separate function (not folded into _load_config()) since ## the two files have unrelated schemas — merging them would make _config's ## Dictionary shape ambiguous depending on which mode is active. func _load_atlas_shots_config() -> Dictionary: var project_root := ProjectSettings.globalize_path("res://") var repo_root := project_root.rstrip("/").get_base_dir() var config_path := repo_root.path_join("tests/atlas_shots.json") var f := FileAccess.open(config_path, FileAccess.READ) if f == null: push_error("visual_capture: cannot open %s" % config_path) return {} var json_text := f.get_as_text() f.close() var parsed = JSON.parse_string(json_text) if parsed == null or not parsed is Dictionary: push_error("visual_capture: invalid JSON in %s" % config_path) return {} return parsed func _parse_args() -> void: var args := OS.get_cmdline_user_args() var i := 0 while i < args.size(): match args[i]: "--scenario": i += 1 if i < args.size(): _scenario = args[i] "--flow": i += 1 if i < args.size(): _flow = args[i] "--output": i += 1 if i < args.size(): _output_dir = args[i] "--interval": i += 1 if i < args.size(): _interval = float(args[i]) "--list": _list_mode = true _: push_warning("visual_capture: unknown arg '%s'" % args[i]) i += 1 func _print_list() -> void: var scenarios: Dictionary = _config.get("scenarios", {}) var flows: Dictionary = _config.get("flows", {}) print("scenarios:") for name in scenarios: # Skip "_"-prefixed comment keys and any non-Dictionary value — a bare # string here crashed --list once (PR #180: a provenance _comment key # placed inside scenarios{}; now top-level, but stay robust). if String(name).begins_with("_") or not (scenarios[name] is Dictionary): continue var desc: String = scenarios[name].get("description", "") print(" %s — %s" % [name, desc]) # T-1120: atlas_matrix isn't a tests/visual.json scenarios{} entry (it reads # tests/atlas_shots.json instead — see _run_atlas_matrix()), so it can't be # discovered by iterating _config above. Listed by hand so --list stays a # complete index of every --scenario value this script accepts. print(" atlas_matrix — T-1120 Atlas screenshot capture matrix (tests/atlas_shots.json, live-mode only)") print("flows:") for name in flows: var steps: Array = flows[name].get("steps", []) print(" %s — %d steps" % [name, steps.size()])