Add headless regression tests for Phase 4b features

- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions.
- Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates.
- Add terrain drag-painting tests to verify correct block placement behavior and conflict handling.
- Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
This commit is contained in:
2026-09-04 15:08:08 -04:00
parent d77b5e5441
commit 1f91f3d2e5
54 changed files with 7960 additions and 162 deletions
+289
View File
@@ -0,0 +1,289 @@
# test_phase3b_library.gd
# Headless tests for Phase 3b Asset Library (no pixel rendering):
# 1. StickmanLibrary.scan() returns 3 entries (test/basic/break) sorted by
# display name; empty stickman_name falls back to the filename basename.
# 2. Corrupt / body-parts-less .stk files are skipped (scan dir override).
# 3. make_entry() valid path -> name+data; missing/corrupt path -> {}.
# 4. PropLibrary.get_entries() -> 4 templates; get_default_id() == "crate".
# 5. StageSpawner registry ids == [ground, ramp, step, prop, stickman, area].
# 6. _spawn_prop honors selected_prop_id (crate polygon / ball circle).
# 7. _spawn_stickman honors selected_stickman_path (basic.stk spawns non-null).
# 8. ThumbnailCache key/png formatting.
# 9. AssetSelector pagination math (PAGE_SIZE == 12; page_bounds slicing).
# 10. Scene load checks (sandbox_stage.tscn + asset_selector.tscn parse/load).
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3b_library.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STICKMAN_LIBRARY := preload("res://scripts/stickman_library.gd")
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
const ASSET_SELECTOR := preload("res://scripts/asset_selector.gd")
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const RIG := preload("res://scripts/stickman_rig.gd")
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const ASSET_SELECTOR_SCENE := preload("res://scenes/asset_selector.tscn")
const TEST_DIR := "user://phase3b_test"
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 3b ASSET LIBRARY TEST (headless)")
print("========================================================")
_test_scan()
_test_corrupt_skip()
_test_make_entry()
_test_prop_library()
_test_spawner_registry()
_test_spawn_prop()
_test_spawn_stickman()
_test_thumbnail_cache()
_test_pagination()
_test_scene_loads()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _test_scan() -> void:
print("")
print("--- StickmanLibrary.scan() ---")
var lib = STICKMAN_LIBRARY.new()
var entries: Array[Dictionary] = lib.scan()
_check(entries.size() == 3, "scan returns 3 entries (got %d)" % entries.size())
var names: Array[String] = []
for e: Dictionary in entries:
names.append(String(e["name"]))
_check(names == ["Basic", "break", "test"],
"entries sorted by display name (got %s)" % str(names))
_check(String(_by_path(entries, "basic.stk")["name"]) == "Basic",
"basic.stk display name is 'Basic' (stickman_name)")
_check(String(_by_path(entries, "test.stk")["name"]) == "test",
"test.stk falls back to filename basename")
_check(String(_by_path(entries, "break.stk")["name"]) == "break",
"break.stk falls back to filename basename")
_check(not _by_path(entries, "basic.stk").is_empty(), "basic.stk entry has data")
var found: Dictionary = lib.find_by_path("res://stickmen/basic.stk")
_check(not found.is_empty() and String(found["name"]) == "Basic",
"find_by_path resolves basic.stk")
func _test_corrupt_skip() -> void:
print("")
print("--- Corrupt / body-parts-less .stk skipped ---")
_ensure_test_dir()
_write_file(TEST_DIR + "/good.stk", "{\"stickman_name\":\"Good\",\"body_parts\":{}}")
_write_file(TEST_DIR + "/corrupt.stk", "{ not valid json !!!")
_write_file(TEST_DIR + "/nobody.stk", "{\"version\":\"1.5\"}")
var lib = STICKMAN_LIBRARY.new()
var entries: Array[Dictionary] = lib.scan(TEST_DIR)
_check(entries.size() == 1,
"scan skips corrupt + body-parts-less files (got %d)" % entries.size())
if entries.size() == 1:
_check(String(entries[0]["name"]) == "Good",
"only the valid file survives (name='%s')" % String(entries[0]["name"]))
_clean_test_dir()
func _test_make_entry() -> void:
print("")
print("--- make_entry() ---")
var lib = STICKMAN_LIBRARY.new()
var entry := lib.make_entry("res://stickmen/basic.stk")
_check(not entry.is_empty(), "make_entry on valid path returns an entry")
_check(String(entry["name"]) == "Basic", "make_entry name == 'Basic'")
_check(not (entry["data"] as Dictionary).is_empty(), "make_entry carries parsed data")
_check(lib.make_entry("res://stickmen/does_not_exist.stk").is_empty(),
"make_entry on missing path returns {}")
_ensure_test_dir()
_write_file(TEST_DIR + "/bad.stk", "nope nope")
_check(lib.make_entry(TEST_DIR + "/bad.stk").is_empty(),
"make_entry on corrupt file returns {}")
_clean_test_dir()
func _test_prop_library() -> void:
print("")
print("--- PropLibrary ---")
var entries: Array[Dictionary] = PROP_LIBRARY.get_entries()
_check(entries.size() == 4, "4 prop templates (got %d)" % entries.size())
_check(PROP_LIBRARY.get_default_id() == "crate", "default prop id == 'crate'")
var ids: Array[String] = PROP_LIBRARY.get_ids()
_check(ids == ["crate", "ball", "plank", "triangle"],
"prop ids in order (got %s)" % str(ids))
var crate: Dictionary = PROP_LIBRARY.get_entry("crate")
_check(int(crate["material_preset"]) == PROP_BLOCK.MaterialPreset.WOOD,
"crate material preset == WOOD")
_check(String(crate["material_label"]) == "Wood", "crate material label == 'Wood'")
var ball: Dictionary = PROP_LIBRARY.get_entry("ball")
_check(int(ball["material_preset"]) == PROP_BLOCK.MaterialPreset.RUBBER,
"ball material preset == RUBBER")
_check(PROP_LIBRARY.get_entry("nope").is_empty(), "unknown prop id returns {}")
func _test_spawner_registry() -> void:
print("")
print("--- StageSpawner registry ---")
var world := Node2D.new()
root.add_child(world)
var spawner = STAGE_SPAWNER.new(world)
var ids: Array[String] = spawner.get_spawnable_ids()
_check(ids == ["ground", "ramp", "step", "prop", "stickman", "area"],
"registry ids == [ground, ramp, step, prop, stickman, area] (got %s)" % str(ids))
_check(not ids.has("crate") and not ids.has("ball"),
"crate/ball registry entries removed")
_check(spawner.get_selected_prop_id() == "crate", "default selected_prop_id == 'crate'")
_check(spawner.get_selected_stickman_path() == "res://stickmen/test.stk",
"default selected_stickman_path == test.stk")
world.queue_free()
func _test_spawn_prop() -> void:
print("")
print("--- _spawn_prop honors selected_prop_id ---")
var world := Node2D.new()
root.add_child(world)
var spawner = STAGE_SPAWNER.new(world)
var crate: Node2D = spawner.spawn("prop", Vector2.ZERO)
_check(crate != null, "prop spawns (default crate)")
_check(crate is PROP_BLOCK, "default prop is a PropBlock")
_check((crate as PROP_BLOCK).shape_type == PROP_BLOCK.ShapeType.POLYGON,
"default crate is a POLYGON prop")
spawner.selected_prop_id = "ball"
var ball: Node2D = spawner.spawn("prop", Vector2(100, 0))
_check(ball != null, "ball prop spawns after selected_prop_id = 'ball'")
_check((ball as PROP_BLOCK).shape_type == PROP_BLOCK.ShapeType.CIRCLE,
"ball prop is a CIRCLE prop")
world.queue_free()
func _test_spawn_stickman() -> void:
print("")
print("--- _spawn_stickman honors selected_stickman_path ---")
var world := Node2D.new()
root.add_child(world)
var spawner = STAGE_SPAWNER.new(world)
spawner.selected_stickman_path = "res://stickmen/basic.stk"
var rig: Node2D = spawner.spawn("stickman", Vector2.ZERO)
_check(rig != null, "stickman spawns from basic.stk")
_check(rig is RIG, "basic.stk spawns a StickmanRig")
world.queue_free()
func _test_thumbnail_cache() -> void:
print("")
print("--- ThumbnailCache key/png formatting ---")
var cache = THUMBNAIL_CACHE.new()
var mtime: int = FileAccess.get_modified_time("res://stickmen/test.stk")
var key := cache.stickman_key("res://stickmen/test.stk")
_check(key == "test_%d" % mtime, "stickman_key embeds basename + mtime (got '%s')" % key)
_check(cache.stickman_png(key) == "user://thumbnails/stickmen/" + key + ".png",
"stickman_png path formatting (got '%s')" % cache.stickman_png(key))
_check(cache.prop_png("crate") == "user://thumbnails/props/crate_v" + str(THUMBNAIL_CACHE.PROP_VERSION) + ".png",
"prop_png embeds id + version (got '%s')" % cache.prop_png("crate"))
_check(THUMBNAIL_CACHE.PROP_VERSION == 1, "PROP_VERSION == 1")
_check(cache.load_png("user://does_not_exist_phase3b.png") == null,
"load_png on missing file returns null")
func _test_pagination() -> void:
print("")
print("--- AssetSelector pagination ---")
_check(ASSET_SELECTOR.PAGE_SIZE == 12, "PAGE_SIZE == 12 (got %d)" % ASSET_SELECTOR.PAGE_SIZE)
_check(ASSET_SELECTOR.COLUMNS == 4 and ASSET_SELECTOR.ROWS == 3,
"COLUMNS=4, ROWS=3")
var b0: Dictionary = ASSET_SELECTOR.page_bounds(20, 0)
_check(int(b0["start"]) == 0 and int(b0["end"]) == 12 and int(b0["page_count"]) == 2,
"page 0 slices [0,12) of 20, page_count=2")
var b1: Dictionary = ASSET_SELECTOR.page_bounds(20, 1)
_check(int(b1["start"]) == 12 and int(b1["end"]) == 20,
"page 1 slices [12,20) of 20")
var b4: Dictionary = ASSET_SELECTOR.page_bounds(4, 0)
_check(int(b4["start"]) == 0 and int(b4["end"]) == 4 and int(b4["page_count"]) == 1,
"4 entries -> single page")
func _test_scene_loads() -> void:
print("")
print("--- Scene load checks ---")
_check(load("res://scenes/sandbox_stage.tscn") != null, "sandbox_stage.tscn loads")
_check(load("res://scenes/asset_selector.tscn") != null, "asset_selector.tscn loads")
var selector: AssetSelector = ASSET_SELECTOR_SCENE.instantiate() as AssetSelector
_check(selector != null, "asset_selector.tscn instantiates as AssetSelector")
root.add_child(selector)
_check(selector._title_label != null and selector._grid != null,
"asset_selector unique-name markers resolve")
_check(selector._browse_button != null and selector._refresh_button != null,
"asset_selector footer buttons resolve")
selector.queue_free()
await process_frame
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
_check(stage._selector != null, "stage builds the AssetSelector")
_check(stage._stickman_library != null, "stage builds the StickmanLibrary")
_check(stage._stickman_thumb != null and stage._prop_thumb != null,
"stage builds the thumbnail renderers")
_check(stage._spawner.get_spawnable_ids() == ["ground", "ramp", "step", "prop", "stickman", "area"],
"stage spawner registry excludes crate/ball")
stage.queue_free()
await process_frame
func _by_path(entries: Array[Dictionary], suffix: String) -> Dictionary:
for e: Dictionary in entries:
if String(e["path"]).ends_with(suffix):
return e
return {}
func _ensure_test_dir() -> void:
DirAccess.make_dir_recursive_absolute(TEST_DIR)
func _write_file(path: String, text: String) -> void:
var f := FileAccess.open(path, FileAccess.WRITE)
if f != null:
f.store_string(text)
f.close()
func _clean_test_dir() -> void:
var dir := DirAccess.open(TEST_DIR)
if dir != null:
dir.list_dir_begin()
var fname := dir.get_next()
while fname != "":
if not dir.current_is_dir():
DirAccess.remove_absolute(TEST_DIR + "/" + fname)
fname = dir.get_next()
dir.list_dir_end()
DirAccess.remove_absolute(TEST_DIR)
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://dpqa4ekcjcnp5
+380
View File
@@ -0,0 +1,380 @@
# test_phase3b_ui_fixes.gd
# Headless regression suite for the Phase 3b Selector UI fix pass (5 bugs):
#
# Bug 1 (popup lost on window resize): AssetSelector connects
# get_tree().root.size_changed -> _on_root_size_changed, which re-centers via
# popup_centered() when visible (and no-ops while hidden).
# * The size_changed connection exists after _ready.
# * Calling _on_root_size_changed() while hidden leaves it hidden (no-op).
# * Calling _on_root_size_changed() while visible re-centers without error
# and keeps the popup visible. (A real OS window resize cannot be fired
# headless; exercising the handler is the intended coverage.)
#
# Bug 2 (selector backdrop): SandboxStage builds `_selector_dim` (a full-rect
# ColorRect at SELECTOR_DIM_ALPHA = 0.5 with MOUSE_FILTER_IGNORE) on the UI
# CanvasLayer BELOW the AssetSelector; _open_selector() shows it,
# _close_selector() hides it.
# * _selector_dim is created, starts hidden, is a sibling of the selector
# under the same UI layer at a lower index, has the dim color + ignore
# mouse filter, becomes visible after _open_selector(...) and hidden again
# after _close_selector().
#
# Bug 3 (direct-popup position): SandboxStage._world_to_screen(world_pos) is
# the pure inverse of Camera2D.get_global_mouse_position():
# screen = (world - camera.position) * camera.zoom + viewport_size * 0.5
# * Hand-computed known-value checks for two camera setups.
# * Engine round-trip: _world_to_screen(camera.get_global_mouse_position())
# returns the viewport mouse position.
#
# Bug 4 (selector pre-highlight removed): AssetSelector.open(kind, entries) no
# longer takes selected_path/selected_id and never highlights a cell.
# * The removed API is gone (_is_selected method, _selected_path/_selected_id
# members, selected-stylebox tokens in the source).
# * open() has arity 2.
# * Opening 2+ entries builds one cell per entry and NO cell carries a
# stylebox override (the old highlight applied one to the "selected" cell).
# * On the full SandboxStage, _open_selector("stickman") runs the real scan
# (3 stickmen, no single-item skip), populates the grid, and still shows no
# pre-highlight. When the selected file is missing the fall-back-to-first
# entry logic still runs before open() and no highlight appears.
#
# Bug 5 (cancel / popup_hide state): _on_selector_cancelled() is idempotent
# (guard `if not _selector_open: return`) and _close_selector() flips
# _selector_open=false BEFORE hiding the selector.
# * After open -> cancel: _selector_open false, _placement_id "", palette
# buttons unpressed, selector + dim hidden.
# * Calling _on_selector_cancelled() a second time does not error and leaves
# the state clean (this mirrors the popup_hide re-entry the fix guards
# against; popup_hide itself cannot fire headless).
# * _close_selector() is itself idempotent.
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3b_ui_fixes.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const ASSET_SELECTOR_SCENE := preload("res://scenes/asset_selector.tscn")
## Button stylebox states the old pre-highlight could have overridden on a cell.
const BUTTON_STYLEBOX_STATES: Array[String] = [
"normal", "hover", "pressed", "focus", "disabled", "selected",
]
const FAKE_ENTRIES: Array[Dictionary] = [
{ "path": "res://fake_one.stk", "name": "One" },
{ "path": "res://fake_two.stk", "name": "Two" },
{ "path": "res://fake_three.stk", "name": "Three" },
]
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
# Watchdog: if a runtime error aborts _run before quit(), force a FAIL exit
# instead of hanging the headless process forever.
var watchdog := create_timer(120.0)
watchdog.timeout.connect(func() -> void:
print("FAIL: watchdog timeout - test run aborted before quit()")
quit(2))
print("")
print("========================================================")
print(" PHASE 3b SELECTOR UI FIX REGRESSION TEST (headless)")
print("========================================================")
await _test_bug1_root_resize_handler()
await _test_bug2_dim_state()
await _test_bug3_world_to_screen()
await _test_bug4_selector_prehighlight_removed()
await _test_bug4_stage_grid_no_prehighlight()
await _test_bug5_cancel_state_idempotency()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
# ---------------------------------------------------------------------------
# Bug 1: root-window resize re-centers the selector popup
# ---------------------------------------------------------------------------
func _test_bug1_root_resize_handler() -> void:
print("")
print("--- Bug 1: selector re-centers on root window resize ---")
var sel = ASSET_SELECTOR_SCENE.instantiate()
root.add_child(sel)
_check(sel.has_method("_on_root_size_changed"),
"AssetSelector exposes _on_root_size_changed")
_check(get_root().size_changed.is_connected(Callable(sel, "_on_root_size_changed")),
"root.size_changed is connected to _on_root_size_changed")
# Hidden: handler must be a no-op (no popup, stays hidden).
sel._on_root_size_changed()
_check(not sel.visible,
"resize handler is a no-op while the selector is hidden")
# Visible: handler re-centers via popup_centered() without error and keeps
# the popup visible (real OS resizes cannot fire headless).
sel.open("stickman", FAKE_ENTRIES)
_check(sel.visible, "selector is visible after open()")
sel._on_root_size_changed()
_check(sel.visible,
"resize handler runs without error while visible and keeps the popup visible")
sel.hide()
sel.queue_free()
await process_frame
# ---------------------------------------------------------------------------
# Bug 2: selector dim backdrop state
# ---------------------------------------------------------------------------
func _test_bug2_dim_state() -> void:
print("")
print("--- Bug 2: _selector_dim backdrop state ---")
var stage := _new_stage()
_check(stage._selector_dim != null, "stage builds _selector_dim")
if stage._selector_dim == null:
await _free_stage(stage)
return
_check(not stage._selector_dim.visible, "dim starts hidden")
_check(stage._selector_dim.get_parent() == stage._selector.get_parent(),
"dim and selector share the same UI layer parent")
_check(stage._selector_dim.get_index() < stage._selector.get_index(),
"dim is added below the selector (renders behind the popup)")
_check(stage._selector_dim.mouse_filter == Control.MOUSE_FILTER_IGNORE,
"dim never intercepts mouse events")
_check(is_equal_approx(stage._selector_dim.color.a, stage.SELECTOR_DIM_ALPHA),
"dim alpha == SELECTOR_DIM_ALPHA (%.2f)" % stage.SELECTOR_DIM_ALPHA)
# 'prop' always has 4 templates so _open_selector never single-item-skips.
stage._open_selector("prop")
_check(stage._selector_dim.visible, "dim becomes visible after _open_selector()")
stage._close_selector()
_check(not stage._selector_dim.visible, "dim hidden again after _close_selector()")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Bug 3: _world_to_screen pure math
# ---------------------------------------------------------------------------
func _test_bug3_world_to_screen() -> void:
print("")
print("--- Bug 3: _world_to_screen math ---")
var stage := _new_stage()
var cam: Camera2D = stage._camera
cam.make_current()
var vp_size: Vector2 = get_root().get_visible_rect().size
# Known-value checks across two camera setups and several world points.
_assert_world_to_screen(stage, cam, vp_size,
Vector2(120.0, -300.0), Vector2(1.5, 1.5),
[Vector2(40.0, -60.0), Vector2(-800.0, 200.0), Vector2(0.0, 0.0)])
_assert_world_to_screen(stage, cam, vp_size,
Vector2(-25.0, 40.0), Vector2(0.5, 0.5),
[Vector2(333.0, -777.0), Vector2(-10.5, 12.25)])
# Engine round-trip: inverse of Camera2D.get_global_mouse_position().
cam.position = Vector2(120.0, -300.0)
cam.zoom = Vector2(1.5, 1.5)
var mouse: Vector2 = get_root().get_mouse_position()
var gmp: Vector2 = cam.get_global_mouse_position()
var screen_of_gmp: Vector2 = stage._world_to_screen(gmp)
_check(screen_of_gmp.distance_to(mouse) < 0.1,
"round-trip _world_to_screen(camera.get_global_mouse_position()) ~= viewport mouse (dist %.5f)"
% screen_of_gmp.distance_to(mouse))
await _free_stage(stage)
func _assert_world_to_screen(stage: Node2D, cam: Camera2D, vp_size: Vector2,
cam_pos: Vector2, cam_zoom: Vector2, points: Array) -> void:
cam.position = cam_pos
cam.zoom = cam_zoom
for p in points:
var w: Vector2 = p as Vector2
var expected: Vector2 = (w - cam_pos) * cam_zoom + vp_size * 0.5
var actual: Vector2 = stage._world_to_screen(w)
_check(expected.distance_to(actual) < 0.01,
"cam %s zoom %s: _world_to_screen(%s) == %s (got %s)"
% [cam_pos, cam_zoom, w, expected, actual])
# ---------------------------------------------------------------------------
# Bug 4: AssetSelector pre-highlight removed (direct instance)
# ---------------------------------------------------------------------------
func _test_bug4_selector_prehighlight_removed() -> void:
print("")
print("--- Bug 4: AssetSelector pre-highlight removed ---")
var sel = ASSET_SELECTOR_SCENE.instantiate()
root.add_child(sel)
# Removed API is gone.
_check(not sel.has_method("_is_selected"), "pre-highlight helper _is_selected() removed")
_check(sel.get("_selected_path") == null, "member _selected_path removed")
_check(sel.get("_selected_id") == null, "member _selected_id removed")
# Source-presence: none of the deleted tokens may lurk in the script.
var file := FileAccess.open("res://scripts/asset_selector.gd", FileAccess.READ)
_check(file != null, "asset_selector.gd is readable")
if file != null:
var text := file.get_as_text()
file.close()
_check(not text.contains("_is_selected"), "source contains no _is_selected token")
_check(not text.contains("_selected_path"), "source contains no _selected_path token")
_check(not text.contains("_selected_id"), "source contains no _selected_id token")
# open() signature is (kind, entries) - no selected_path/selected_id args.
_check(_method_arg_count(sel, "open") == 2,
"open() takes exactly 2 args (got %d)" % _method_arg_count(sel, "open"))
# Populating 2+ entries builds a cell per entry with NO pre-highlight stylebox.
sel.open("stickman", FAKE_ENTRIES)
_check(sel.visible, "open() pops the selector")
_check(sel._grid.get_child_count() == FAKE_ENTRIES.size(),
"grid holds one cell per entry (got %d)" % sel._grid.get_child_count())
_check(not _any_cell_has_stylebox_override(sel),
"no cell carries a pre-highlight stylebox override")
sel.hide()
sel.queue_free()
await process_frame
# ---------------------------------------------------------------------------
# Bug 4 on the full stage: real scan populates the grid with no pre-highlight
# ---------------------------------------------------------------------------
func _test_bug4_stage_grid_no_prehighlight() -> void:
print("")
print("--- Bug 4: full-stage selector grid has no pre-highlight ---")
var stage := _new_stage()
# Selected file missing -> fall-back-to-first-entry still runs before open().
stage._spawner.selected_stickman_path = "res://stickmen/does_not_exist.stk"
stage._open_selector("stickman")
_check(stage._selector_open, "stage selector opens from a palette toggle")
_check(stage._spawner.get_selected_stickman_path().ends_with("basic.stk"),
"missing selected file falls back to the first scanned entry (got '%s')"
% stage._spawner.get_selected_stickman_path())
# Real scan has 3 stickmen -> no single-item auto-select skip.
var grid: GridContainer = stage._selector._grid
_check(grid.get_child_count() == 3,
"real scan populates 3 stickman cells (got %d)" % grid.get_child_count())
_check(not stage._selector.has_method("_is_selected"),
"stage selector exposes no pre-highlight API")
_check(not _any_cell_has_stylebox_override(stage._selector),
"no stickman cell carries a pre-highlight stylebox override")
stage._on_selector_cancelled()
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Bug 5: cancel / popup_hide state + idempotency
# ---------------------------------------------------------------------------
func _test_bug5_cancel_state_idempotency() -> void:
print("")
print("--- Bug 5: _on_selector_cancelled state + idempotency ---")
var stage := _new_stage()
var stickman_btn: Button = stage._palette_buttons["stickman"]
stage._open_selector("stickman")
_check(stage._selector_open, "selector is open after _open_selector()")
_check(stage._selector.visible, "selector popup visible while open")
_check(stage._selector_dim.visible, "dim visible while open")
_check(stickman_btn.button_pressed, "stickman palette button pressed while open")
stage._on_selector_cancelled()
_check(not stage._selector_open, "cancel clears _selector_open")
_check(stage._placement_id == "", "cancel clears _placement_id")
_check(stage._selector_kind == "", "cancel clears _selector_kind")
_check(not stickman_btn.button_pressed, "cancel unpresses the palette button")
_check(not stage._selector.visible, "cancel hides the selector popup")
_check(not stage._selector_dim.visible, "cancel hides the dim backdrop")
# popup_hide cannot fire headless; a second direct invocation exercises the
# idempotency guard that the popup_hide -> _on_selector_cancelled path relies
# on (_close_selector flips _selector_open=false before hide()).
stage._on_selector_cancelled()
_check(not stage._selector_open, "second cancel does not reopen state")
_check(stage._placement_id == "", "second cancel leaves placement clean")
_check(not stickman_btn.button_pressed, "second cancel leaves palette clean")
_check(not stage._selector.visible and not stage._selector_dim.visible,
"second cancel leaves selector + dim hidden")
# _close_selector itself is idempotent (same ordering guarantees).
stage._close_selector()
_check(not stage._selector_open and not stage._selector.visible,
"_close_selector() twice is a clean no-op")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _new_stage() -> Node2D:
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
stage._snap_enabled = false
return stage
func _free_stage(stage: Node2D) -> void:
stage.queue_free()
await process_frame
## True when any cell Button in the selector's grid has a stylebox override for a
## standard button state (the old "currently selected asset gets a highlight
## stylebox border" behavior would have left exactly such an override).
func _any_cell_has_stylebox_override(sel) -> bool:
var grid: GridContainer = sel._grid
for child: Node in grid.get_children():
var cell := child as Button
if cell == null:
continue
for state: String in BUTTON_STYLEBOX_STATES:
if cell.has_theme_stylebox_override(state):
return true
return false
## Number of declared parameters for `method_name` on an Object (via reflection).
func _method_arg_count(obj: Object, method_name: String) -> int:
for m: Dictionary in obj.get_method_list():
if String(m["name"]) == method_name:
return int((m["args"] as Array).size())
return -1
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://c9p4m6q2xk8wv
+547
View File
@@ -0,0 +1,547 @@
# test_phase4b1_fixes.gd
# Headless regression suite for the Phase 4b.1 fix pass (spec §7 D1 + Bug 2-5):
#
# Bug 1 (D1 - block-unit paint stride): terrain drag-painting quantizes to the
# active template's ACTUAL footprint (the sanitized AABB, e.g. Ground = 192x32,
# NOT the raw 200x32 template and NOT the 16-px grid):
# * horizontal ground run tiles at the 192px stride (end-to-end, no
# interior overlap, no gaps);
# * vertical ground run tiles at the 32px stride;
# * free diagonal tiles corner-to-corner (no interior overlap between
# consecutive blocks);
# * a second drag crossing an existing same-type block classifies skip
# (Case B), including the transient in-drag `_drag_painted` set;
# * a cell occupied by a conflicting prop classifies blocked and is skipped
# while its empty neighbours still spawn.
#
# Bug 2 (persistent guide line): StagePlacementOverlay.clear_terrain_guide /
# clear_action clear the visible flags AND request a redraw so the erased
# line is actually removed (source-presence check: is_queued_for_redraw()
# does not exist in Godot 4.4, so the redraw request is verified statically).
#
# Bug 4 (single-placement guide circles): a single click (anchor == target)
# never shows the guide line on the placement overlay.
#
# Bug 3 (waypoint jitter): a waypoint on a nav-mesh target terminates through
# the nav branch with exactly one `arrived`, position snapped to the final
# root target and stable for 60+ physics frames; an off-mesh waypoint still
# reaches arrival through direct mode.
#
# Bug 5 (cursor ghost regression): _spawn_ghost creates a cursor-following
# ghost for a terrain placement id (block-unit snapped), drag begin frees
# it, drag end re-arms it while the tool is still active, and
# set_placement_mode("") frees it.
#
# Run with (either console):
# & "C:\Godot4\Godot_v4.4-stable_win64_console.exe" --headless --script res://tests/test_phase4b1_fixes.gd --path .
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b1_fixes.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
const TERRAIN_BLOCK := preload("res://scripts/terrain_block.gd")
const OVERLAY_SCRIPT := preload("res://scripts/stage_placement_overlay.gd")
const RIG := preload("res://scripts/stickman_rig.gd")
# The Ground registry template is authored 200x32 but StageSpawner sanitizes
# every terrain polygon onto the 16px TERRAIN_GRID_SIZE before placement, so the
# ACTUAL spawned footprint (and therefore the D1 paint stride) is 192x32.
const GROUND_STRIDE_X := 192.0
const GROUND_STRIDE_Y := 32.0
const GROUND_HALF_X := 96.0
const GROUND_HALF_Y := 16.0
const SETTLE_FRAMES := 60
const MAX_WALK_FRAMES := 360
var _checks := 0
var _failures := 0
var _arrived_count := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 4b.1 FIX REGRESSION TEST (headless)")
print("========================================================")
_test_bug1_horizontal_run()
_test_bug1_vertical_run()
_test_bug1_diagonal_run()
_test_bug1_same_type_skip()
_test_bug1_conflicting_prop()
_test_bug2_overlay_clear()
_test_bug4_single_click_guide()
await _test_bug3_walk_termination()
await _test_bug5_ghost_lifecycle()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
# ---------------------------------------------------------------------------
# Bug 1 (D1): block-stride quantization
# ---------------------------------------------------------------------------
func _test_bug1_horizontal_run() -> void:
print("")
print("--- Bug 1: horizontal ground run tiles at the 192px stride ---")
var stage := _new_stage()
var world: Node2D = stage._world
stage.set_placement_mode("ground")
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0)))
var expected_cells: Array[Vector2i] = [Vector2i(0, 0), Vector2i(1, 0), Vector2i(2, 0)]
_check(stage._drag_cells == expected_cells,
"drag (0,0)->(2,0) path is 3 block cells (got %s)" % str(stage._drag_cells))
_check(stage._classify_cell(Vector2i(0, 0)) == 1 and stage._classify_cell(Vector2i(2, 0)) == 1,
"empty horizontal cells classify empty (1)")
var before := _terrain_count(world)
stage._commit_terrain_drag()
var after := _terrain_count(world)
_check(after - before == 3, "horizontal run committed 3 blocks (got %d)" % (after - before))
var blocks := _terrain_blocks(world)
blocks.sort_custom(func(a, b): return (a as TerrainBlock).position.x < (b as TerrainBlock).position.x)
_check(blocks.size() == 3, "3 ground blocks present (got %d)" % blocks.size())
var xs: Array[float] = []
for b: TerrainBlock in blocks:
xs.append(b.position.x)
var expected_xs: Array[float] = [0.0, GROUND_STRIDE_X, 2.0 * GROUND_STRIDE_X]
_check(xs == expected_xs,
"block centers at x = 0/192/384 (got %s)" % str(xs))
# End-to-end adjacency: each consecutive pair has zero gap and NO strict
# interior overlap (the edge/corner contact that used to double-stamp).
var adj_ok := true
var gap_ok := true
for i: int in range(blocks.size() - 1):
var a := _block_world_aabb(blocks[i])
var b := _block_world_aabb(blocks[i + 1])
if _strict_overlap(a, b):
adj_ok = false
if not is_equal_approx(b.position.x - a.end.x, 0.0):
gap_ok = false
_check(adj_ok, "horizontal consecutive blocks have no interior overlap")
_check(gap_ok, "horizontal consecutive blocks abut edge-to-edge (zero gap)")
var total_span := _block_world_aabb(blocks[2]).end.x - _block_world_aabb(blocks[0]).position.x
_check(is_equal_approx(total_span, 3.0 * GROUND_STRIDE_X),
"3-block run spans exactly %dpx (got %.1f)" % [3 * GROUND_STRIDE_X, total_span])
_free_stage(stage)
func _test_bug1_vertical_run() -> void:
print("")
print("--- Bug 1: vertical ground run tiles at the 32px stride ---")
var stage := _new_stage()
var world: Node2D = stage._world
stage.set_placement_mode("ground")
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(0, 3)))
var expected_cells: Array[Vector2i] = [Vector2i(0, 0), Vector2i(0, 1), Vector2i(0, 2), Vector2i(0, 3)]
_check(stage._drag_cells == expected_cells,
"drag (0,0)->(0,3) path is 4 block cells (got %s)" % str(stage._drag_cells))
var before := _terrain_count(world)
stage._commit_terrain_drag()
var delta := _terrain_count(world) - before
_check(delta == 4, "vertical run committed 4 blocks (got %d)" % delta)
var blocks := _terrain_blocks(world)
blocks.sort_custom(func(a, b): return (a as TerrainBlock).position.y < (b as TerrainBlock).position.y)
var ys: Array[float] = []
for b: TerrainBlock in blocks:
ys.append(b.position.y)
var expected_ys: Array[float] = [0.0, 32.0, 64.0, 96.0]
_check(ys == expected_ys,
"block centers at y = 0/32/64/96 (got %s)" % str(ys))
var adj_ok := true
var gap_ok := true
for i: int in range(blocks.size() - 1):
var a := _block_world_aabb(blocks[i])
var b := _block_world_aabb(blocks[i + 1])
if _strict_overlap(a, b):
adj_ok = false
if not is_equal_approx(b.position.y - a.end.y, 0.0):
gap_ok = false
_check(adj_ok, "vertical consecutive blocks have no interior overlap")
_check(gap_ok, "vertical consecutive blocks abut edge-to-edge (zero gap)")
_free_stage(stage)
func _test_bug1_diagonal_run() -> void:
print("")
print("--- Bug 1: free diagonal tiles corner-to-corner (no interior overlap) ---")
var stage := _new_stage()
var world: Node2D = stage._world
stage.set_placement_mode("ground")
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(2, 2)))
var expected_cells: Array[Vector2i] = [Vector2i(0, 0), Vector2i(1, 1), Vector2i(2, 2)]
_check(stage._drag_cells == expected_cells,
"diagonal drag path is 3 staircase cells (got %s)" % str(stage._drag_cells))
var before := _terrain_count(world)
stage._commit_terrain_drag()
var delta := _terrain_count(world) - before
_check(delta == 3, "diagonal run committed 3 blocks (got %d)" % delta)
var blocks := _terrain_blocks(world)
var no_overlap := true
for i: int in range(blocks.size()):
for j: int in range(i + 1, blocks.size()):
if _strict_overlap(_block_world_aabb(blocks[i]), _block_world_aabb(blocks[j])):
no_overlap = false
_check(no_overlap, "diagonal blocks share at most a corner - no interior overlap anywhere")
_free_stage(stage)
func _test_bug1_same_type_skip() -> void:
print("")
print("--- Bug 1: second drag over an existing same-type block skips (Case B) ---")
var stage := _new_stage()
var world: Node2D = stage._world
stage.set_placement_mode("ground")
# First drag commits cells (0,0),(1,0),(2,0).
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0)))
stage._commit_terrain_drag()
_check(_terrain_count(world) == 3, "first drag committed 3 blocks")
# Second drag starts ON the existing block at cell (2,0) and extends to (4,0).
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(4, 0)))
_check(stage._classify_cell(Vector2i(2, 0)) == 2,
"occupied same-type cell (2,0) classifies skip (2) (got %d)" % stage._classify_cell(Vector2i(2, 0)))
_check(stage._classify_cell(Vector2i(3, 0)) == 1 and stage._classify_cell(Vector2i(4, 0)) == 1,
"extension cells (3,0),(4,0) classify empty (1)")
var before := _terrain_count(world)
stage._commit_terrain_drag()
var delta := _terrain_count(world) - before
_check(delta == 2, "Case B second drag committed only the 2 empty cells (got %d)" % delta)
# Transient in-drag self-overlap set participates in classification.
stage.set_placement_mode("ground")
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(40, 0)))
stage._drag_painted[Vector2i(40, 0)] = true
_check(stage._classify_cell(Vector2i(40, 0)) == 2,
"in-drag _drag_painted cell classifies same-type skip (2) (got %d)" % stage._classify_cell(Vector2i(40, 0)))
stage._cancel_terrain_drag()
_free_stage(stage)
func _test_bug1_conflicting_prop() -> void:
print("")
print("--- Bug 1: conflicting prop cell classifies blocked and is skipped ---")
var stage := _new_stage()
var world: Node2D = stage._world
stage.set_placement_mode("ground")
# Crate sits at cell (3,0) center (600,0) (48x48 prop inside the 200x32 cell).
var crate: Node2D = stage._spawner.spawn("prop", stage._terrain_cell_center(Vector2i(3, 0)))
_check(crate != null, "crate prop spawns at cell (3,0)")
stage._rebuild_grid_cells()
_check(stage._classify_cell(Vector2i(3, 0)) == 3,
"conflicting prop cell (3,0) classifies blocked (3) (got %d)" % stage._classify_cell(Vector2i(3, 0)))
_check(stage._classify_cell(Vector2i(2, 0)) == 1,
"empty neighbour cell (2,0) classifies empty (1)")
var before := _terrain_count(world)
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(4, 0)))
_check(stage._drag_cells.size() == 3, "conflict drag path has 3 cells (got %d)" % stage._drag_cells.size())
stage._commit_terrain_drag()
var delta := _terrain_count(world) - before
_check(delta == 2, "conflict drag committed only the 2 empty edge cells (got %d)" % delta)
# The middle cell must not contain any freshly committed ground block.
var blocked_cell_clean := true
for b: TerrainBlock in _terrain_blocks(world):
if _strict_overlap(_block_world_aabb(b), _crate_aabb(crate)):
blocked_cell_clean = false
_check(blocked_cell_clean, "no committed ground block overlaps the crate cell")
_free_stage(stage)
# ---------------------------------------------------------------------------
# Bug 2 (overlay clear redraw) + Bug 4 (single-click guide suppression)
# ---------------------------------------------------------------------------
func _test_bug2_overlay_clear() -> void:
print("")
print("--- Bug 2: overlay clear requests a redraw + resets visible flags ---")
# Source-presence check: clear_terrain_guide() and clear_action() must both
# call queue_redraw() so the erased line is removed (Godot 4.4 exposes no
# is_queued_for_redraw(), so this is the headless-observable regression).
var file := FileAccess.open("res://scripts/stage_placement_overlay.gd", FileAccess.READ)
_check(file != null, "stage_placement_overlay.gd is readable")
if file != null:
var text := file.get_as_text()
file.close()
_check(_func_body_contains(text, "clear_terrain_guide", "queue_redraw()"),
"clear_terrain_guide() body contains queue_redraw()")
_check(_func_body_contains(text, "clear_action", "queue_redraw()"),
"clear_action() body contains queue_redraw()")
# Behavioral flags: clearing hides the guide/trajectory state.
var overlay: Node2D = OVERLAY_SCRIPT.new()
root.add_child(overlay)
overlay.set_process(false)
overlay.set_terrain_guide(Vector2(0.0, 0.0), Vector2(400.0, 0.0))
_check(overlay.terrain_guide_visible, "set_terrain_guide marks the guide visible")
overlay.clear_terrain_guide()
_check(not overlay.terrain_guide_visible, "clear_terrain_guide hides the guide")
overlay.set_action_trajectory(Vector2(0.0, 0.0), Vector2(300.0, 0.0), true)
_check(overlay.action_visible, "set_action_trajectory marks the action visible")
overlay.clear_action()
_check(not overlay.action_visible, "clear_action hides the action trajectory")
overlay.free()
func _test_bug4_single_click_guide() -> void:
print("")
print("--- Bug 4: single-click drag never shows the guide line ---")
var stage := _new_stage()
var world: Node2D = stage._world
var overlay = stage._placement_overlay
_check(overlay != null, "placement overlay is built")
stage.set_placement_mode("ground")
_check(not overlay.terrain_guide_visible, "guide hidden before any drag")
# Single click (anchor == target): _begin_terrain_drag -> _update_terrain_drag
# with the same cell must NOT call set_terrain_guide.
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(20, 0)))
_check(overlay.terrain_guide_visible == false,
"single-click begin keeps guide hidden (anchor == target)")
var before := _terrain_count(world)
stage._commit_terrain_drag()
_check(_terrain_count(world) - before == 1, "single click committed exactly 1 block")
_check(not overlay.terrain_guide_visible, "guide still hidden after single-click commit")
# Multi-cell drag DOES show the guide while dragging...
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(25, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(27, 0)))
_check(overlay.terrain_guide_visible, "multi-cell drag shows the guide")
# ...and retracting to the anchor again hides it (target == anchor).
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(25, 0)))
_check(not overlay.terrain_guide_visible, "retracting to the anchor hides the guide")
# ...and commit clears it.
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(27, 0)))
stage._commit_terrain_drag()
_check(not overlay.terrain_guide_visible, "commit clears the guide")
_free_stage(stage)
# ---------------------------------------------------------------------------
# Bug 3: nav-mode termination (waypoint jitter fix)
# ---------------------------------------------------------------------------
func _test_bug3_walk_termination() -> void:
print("")
print("--- Bug 3: nav-mode termination + off-mesh direct still works ---")
# On-mesh waypoint near the slab's right edge (the former oscillation zone):
# the rig must terminate exactly once through the nav branch and stay put.
await _walk_case("on-mesh (near slab edge)", Vector2(88.0, -8.0), "nav")
# Off-mesh waypoint in open space: direct mode must still reach arrival.
await _walk_case("off-mesh (open space)", Vector2(250.0, 0.0), "direct")
func _on_arrived(_target: Vector2) -> void:
_arrived_count += 1
func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> void:
var stage := _new_stage()
var ground = stage._spawner.spawn("ground", Vector2(0, 0))
_check(ground != null, "%s: ground spawns" % label)
stage._rebake_navigation()
var rig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "%s: stickman spawns" % label)
if rig == null:
_free_stage(stage)
return
_arrived_count = 0
rig.arrived.connect(_on_arrived)
rig.walk_to(target_feet, 200.0)
_check(not rig._walk_done, "%s: walk not done immediately" % label)
var frames := 0
var mode_flipped := false
while not rig._walk_done and frames < MAX_WALK_FRAMES:
if rig._walk_mode_latched and rig._walk_mode != expected_mode:
mode_flipped = true
await physics_frame
frames += 1
_check(rig._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_WALK_FRAMES, frames])
_check(_arrived_count == 1, "%s: exactly one arrived emission (got %d)" % [label, _arrived_count])
_check(rig._walk_mode_latched, "%s: steering mode latched" % label)
_check(rig._walk_mode == expected_mode,
"%s: latched to '%s' (got '%s')" % [label, expected_mode, rig._walk_mode])
_check(not mode_flipped, "%s: latched mode never flipped mid-walk" % label)
# Position snapped to the exact final root target (feet + FOOT_OFFSET).
var final_root: Vector2 = target_feet + RIG.FOOT_OFFSET
_check(rig.global_position.distance_to(final_root) <= 0.01,
"%s: position snapped to final root (dist=%.3f)" % [label, rig.global_position.distance_to(final_root)])
_check(not rig.is_walking(), "%s: is_walking false after arrival" % label)
# Post-arrival stability: no position change for SETTLE_FRAMES physics frames.
var p0: Vector2 = rig.global_position
var stable := true
for i: int in SETTLE_FRAMES:
await physics_frame
if rig.global_position != p0:
stable = false
break
_check(stable and rig.global_position == p0,
"%s: position unchanged for %d frames after arrival" % [label, SETTLE_FRAMES])
_free_stage(stage)
# ---------------------------------------------------------------------------
# Bug 5: terrain cursor ghost lifecycle
# ---------------------------------------------------------------------------
func _test_bug5_ghost_lifecycle() -> void:
print("")
print("--- Bug 5: terrain cursor ghost spawn / free / re-arm ---")
var stage := _new_stage()
var world: Node2D = stage._world
var holder: Node2D = stage._ghost_holder
_check(stage._ghost == null, "no ghost before placement armed")
stage.set_placement_mode("ground")
_check(stage._ghost != null, "_spawn_ghost creates a ghost for terrain placement")
var ghost: Node2D = stage._ghost
_check(ghost is TERRAIN_BLOCK, "terrain ghost is a TerrainBlock")
_check(ghost.get_parent() == holder, "ghost is parented to the ghost holder, not World")
_check(world.get_children().has(ghost) == false, "ghost is not a selectable World child")
_check(is_equal_approx(ghost.modulate.a, 0.5), "ghost is translucent (alpha 0.5)")
# Block-unit snapped ghost positioning (strides, not the 16-px grid/cursor).
var raw_pos: Vector2 = stage._camera.get_global_mouse_position() \
+ stage._spawner.get_spawn_offset("ground")
var expected_cell: Vector2i = stage._world_to_terrain_cell(raw_pos)
var expected_pos: Vector2 = stage._terrain_cell_center(expected_cell)
stage._update_ghost_position()
_check(ghost.position.distance_to(expected_pos) <= 0.01,
"ghost positioned at the block-unit cell center (got %s, expected %s)" % [ghost.position, expected_pos])
_check(is_equal_approx(fmod(absf(ghost.position.x), GROUND_STRIDE_X), 0.0),
"ghost x is a multiple of the %dpx stride (got %.1f)" % [GROUND_STRIDE_X, ghost.position.x])
_check(is_equal_approx(fmod(absf(ghost.position.y), GROUND_STRIDE_Y), 0.0),
"ghost y is a multiple of the 32px stride (got %.1f)" % ghost.position.y)
# Drag begin frees the single cursor ghost and builds per-cell ghosts.
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(30, 0)))
_check(stage._ghost == null, "drag begin frees the single ghost")
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(32, 0)))
_check(stage._ghost_array.size() == 3,
"drag path shows 3 per-cell ghosts (got %d)" % stage._ghost_array.size())
# Commit re-arms the single ghost (LMB repeated placement preserved).
var before := _terrain_count(world)
stage._commit_terrain_drag()
_check(_terrain_count(world) - before == 3, "ghost lifecycle drag committed 3 blocks")
_check(stage._ghost != null, "drag end re-arms the cursor ghost while tool is active")
_check(stage._ghost_array.is_empty(), "per-cell ghosts freed after commit")
_check(stage._ghost.get_parent() == holder, "re-armed ghost lives in the ghost holder")
# set_placement_mode("") frees the ghost (queue_free) and empties the holder
# at the end of the current frame.
var held_ghost: Node2D = stage._ghost
stage.set_placement_mode("")
_check(stage._ghost == null, "set_placement_mode('') frees the ghost")
_check(held_ghost != null and held_ghost.is_queued_for_deletion(),
"freed ghost is queued for deletion")
await process_frame
_check(holder.get_child_count() == 0, "ghost holder is empty after clearing placement")
_free_stage(stage)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _new_stage() -> Node2D:
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
stage._snap_enabled = false
return stage
func _free_stage(stage: Node2D) -> void:
stage.queue_free()
await process_frame
func _terrain_count(world: Node2D) -> int:
var n := 0
for child: Node in world.get_children():
if child is TERRAIN_BLOCK:
n += 1
return n
func _terrain_blocks(world: Node2D) -> Array:
var out: Array = []
for child: Node in world.get_children():
if child is TERRAIN_BLOCK:
out.append(child)
return out
func _block_world_aabb(block: Node2D) -> Rect2:
return STAGE_SELECTION.get_world_aabb(block)
func _crate_aabb(crate: Node2D) -> Rect2:
return STAGE_SELECTION.get_world_aabb(crate)
## Strict interior overlap - a shared edge or a shared corner is NOT an overlap
## (mirrors the fixed `_aabb_overlaps` in sandbox_stage.gd).
func _strict_overlap(a: Rect2, b: Rect2) -> bool:
return a.position.x < b.end.x and b.position.x < a.end.x \
and a.position.y < b.end.y and b.position.y < a.end.y
func _func_body_contains(text: String, func_name: String, needle: String) -> bool:
var marker := "func " + func_name
var start := text.find(marker)
if start == -1:
return false
var end := text.find("\nfunc ", start + marker.length())
if end == -1:
end = text.length()
return text.substr(start, end - start).contains(needle)
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://dn748uqvslgmk
+446
View File
@@ -0,0 +1,446 @@
# test_phase4b2_fixes.gd
# Headless regression suite for the Phase 4b.2 fix pass (four Sandbox Stage
# Builder bugs):
#
# Bug 1 (stale yellow hover box after Delete): delete_selected() now calls
# StageSelection.clear_hover() (new) after clear_selection() so the gizmo
# layer drops the stale highlight, and StageGizmos._draw() skips any hovered
# node that is queued for deletion.
# * delete_selected() leaves the selection's hovered node null and emits
# hover_changed(null), which the stage forwards to StageGizmos.set_hover
# (gizmo _hovered becomes null).
# * clear_hover() with no hover is a silent no-op (no signal emission).
# * StageGizmos._draw() source contains the is_queued_for_deletion() guard
# (draw output is not observable headless, so the guard is asserted as a
# source-presence check, matching the repo's established pattern).
#
# Bug 2 ('Back to actions' dead-end): _on_trigger_popup_id_pressed(TRIG_BACK)
# now _reset_rule_builder()s and re-opens the action popup (at the recorded
# session anchor) when _context_rig is valid, instead of cancel-hiding
# everything.
# * After Back the rule-builder state is IDLE/empty AND _action_popup is
# visible again (PopupMenu.visible IS observable headless).
#
# Bug 3 (head mirror jitter): master_rig.tscn's Head LookAt modification is a
# full-range (-180..180), non-inverted, non-local-space constraint.
# * Constraint flags/band are asserted directly on the instantiated rig.
# * The Head marker is settled at the walk-left pose (-100,-614), then
# snapped to STAND_POSE (100,-614) (the snap_to_standing() scenario on
# PLAY/EDIT exit). With the FIXED constraint the head bone + Body/Head
# visual rotation show zero frame-to-frame motion; the OLD (invert +
# localspace + 55..305) constraint exhibits a ~0.8 rad one-frame flip
# (this suite's regression discriminator).
#
# Bug 4 (reactive badge accumulation): StickmanRig.enqueue_reactive() tags
# injected actions reactive=true, and the new clear_reactive_actions()
# (no-op while EXECUTING) drops them back to the authored queue.
# * One enqueue+clear cycle restores the authored queue exactly.
# * Repeated cycles never accumulate (stable counts, no 1,2,3 build-up).
# * clear_reactive_actions() is a no-op while the runner is EXECUTING.
# * queue_changed is emitted on every actual queue mutation.
#
# Run with (either console):
# & "C:\Godot4\Godot_v4.4-stable_win64_console.exe" --headless --script res://tests/test_phase4b2_fixes.gd --path .
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b2_fixes.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const RIG := preload("res://scripts/stickman_rig.gd")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
# Physics frames / thresholds for the Bug 3 head-jitter measurement.
const WARMUP_FRAMES := 30
const MEASURE_FRAMES := 60
## A single-frame rotation step above this (radians) is treated as a mirror
## flip / oscillation event. The fixed constraint produces 0.0; the old
## constraint produced ~0.8 rad on the STAND_POSE snap.
const FLIP_THRESHOLD_RAD := 0.5
## Generous ceiling used by the "standing still" assertion; fixed = 0.0.
const STILL_MAX_STEP_RAD := 0.25
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 4b.2 FIX REGRESSION TEST (headless)")
print("========================================================")
await _test_bug1_delete_clears_hover()
_test_bug1_gizmo_draw_guard_source()
_test_bug2_back_to_actions()
await _test_bug3_head_lookat()
await _test_bug4_reactive_actions()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
# ---------------------------------------------------------------------------
# Bug 1: stale yellow hover box after Delete
# ---------------------------------------------------------------------------
func _test_bug1_delete_clears_hover() -> void:
print("")
print("--- Bug 1: delete_selected clears the stale hover highlight ---")
var stage := _new_stage()
var world: Node2D = stage._world
var obj: Node2D = stage._spawner.spawn("ground", Vector2(0, 0))
_check(obj != null, "ground block spawns for hover/delete test")
_check(world.get_children().has(obj), "spawned block is a direct World child")
# Put the selection AND the hover on the same block, exactly like hovering a
# selected object and pressing Delete.
stage._selection.select_only(obj)
stage._selection.update_hover(STAGE_SELECTION.get_world_aabb(obj).get_center())
_check(stage._selection._hovered == obj,
"update_hover targets the spawned block (hover set)")
_check(stage._gizmos._hovered == obj,
"gizmo layer mirrors the hover via hover_changed -> set_hover")
# Watch the hover signal during the delete.
var hover_payloads: Array = []
stage._selection.hover_changed.connect(func(n): hover_payloads.append(n))
stage.delete_selected()
_check(stage._selection._hovered == null,
"selection hover is null after delete_selected (no stale hover)")
_check(stage._gizmos._hovered == null,
"gizmo hover target cleared after delete_selected (yellow box disappears)")
_check(stage._selection.get_selected().is_empty(),
"selection is empty after delete_selected")
_check(not hover_payloads.is_empty() and hover_payloads[-1] == null,
"clear_hover emitted hover_changed(null) during delete")
# clear_hover() with nothing hovered must be a silent no-op.
var noop_payloads: Array = []
stage._selection.hover_changed.connect(func(n): noop_payloads.append(n))
stage._selection.clear_hover()
_check(noop_payloads.is_empty(),
"clear_hover with no hover emits nothing (idempotent)")
await _free_stage(stage)
func _test_bug1_gizmo_draw_guard_source() -> void:
print("")
print("--- Bug 1: StageGizmos._draw() skips queued-for-deletion hovers ---")
# Drawing output is not observable headless, so assert the guard that makes
# a deleted-but-still-referenced hover node disappear from the draw pass.
# This mirrors the source-presence checks used elsewhere in the repo.
var file := FileAccess.open("res://scripts/stage_gizmos.gd", FileAccess.READ)
_check(file != null, "stage_gizmos.gd is readable")
if file != null:
var text := file.get_as_text()
file.close()
_check(_func_body_contains(text, "_draw", "is_queued_for_deletion()"),
"StageGizmos._draw() body contains the is_queued_for_deletion() hover guard")
# ---------------------------------------------------------------------------
# Bug 2: 'Back to actions' must return to the action popup
# ---------------------------------------------------------------------------
func _test_bug2_back_to_actions() -> void:
print("")
print("--- Bug 2: trigger-popup Back resets builder and re-opens actions ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman spawns for the popup flow")
if rig == null:
await _free_stage(stage)
return
stage._context_rig = rig
# "⚡ When..." from the action popup opens the trigger sub-menu and arms the
# rule builder (RuleStep.SELECT_TRIGGER = 1).
stage._on_action_popup_id_pressed(stage.ACT_WHEN)
_check(int(stage._rule_step) == 1,
"After When... the builder sits at SELECT_TRIGGER (got %d)" % int(stage._rule_step))
_check(not stage._rule_builder.is_empty() and stage._rule_builder.has("trigger"),
"After When... a trigger shell exists in the rule builder")
_check(stage._rule_context_rig == rig,
"rule context rig mirrors _context_rig")
_check(stage._trigger_popup.visible,
"trigger popup is open after When...")
# Press '⬅ Back to actions' (TRIG_BACK = 5).
stage._on_trigger_popup_id_pressed(stage.TRIG_BACK)
_check(int(stage._rule_step) == 0,
"Back resets the rule builder to IDLE (got %d)" % int(stage._rule_step))
_check(stage._rule_builder.is_empty(),
"Back empties the rule builder dict")
_check(stage._rule_context_rig == null,
"Back clears the rule context rig")
_check(stage._rule_hint.is_empty(),
"Back clears the rule hint text")
_check(stage._action_popup.visible,
"Back re-opens the ACTION popup (no dead-end)")
_check(stage._context_rig == rig,
"Back keeps _context_rig so further actions target the same stickman")
# When the context rig is gone, Back must not crash and must not re-open the
# action popup over nothing.
stage._context_rig = null
stage._action_popup.hide()
stage._on_trigger_popup_id_pressed(stage.TRIG_BACK)
_check(stage._action_popup.visible == false,
"Back without a valid context rig leaves the action popup closed")
_check(int(stage._rule_step) == 0,
"Back without a valid context rig still resets the builder")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Bug 3: head LookAt mirror jitter
# ---------------------------------------------------------------------------
func _test_bug3_head_lookat() -> void:
print("")
print("--- Bug 3: Head LookAt full-range constraint + no mirror jitter ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the head test")
if rig == null:
await _free_stage(stage)
return
var skeleton := rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
var stack: SkeletonModificationStack2D = skeleton.modification_stack
_check(stack != null, "rig skeleton carries a modification stack")
_check(stack != null and stack.enabled,
"rig modification stack is enabled after _ready")
var look_at: SkeletonModification2DLookAt = null
if stack != null:
for i: int in stack.modification_count:
var mod = stack.get_modification(i)
if mod is SkeletonModification2DLookAt:
look_at = mod as SkeletonModification2DLookAt
break
_check(look_at != null, "Head SkeletonModification2DLookAt exists in the stack")
if look_at == null:
await _free_stage(stage)
return
# 1) The scene fix: full-range, non-inverted, non-local-space constraint.
_check(is_equal_approx(look_at.constraint_angle_min, -180.0),
"LookAt constraint_angle_min is -180 (got %.3f)" % look_at.constraint_angle_min)
_check(is_equal_approx(look_at.constraint_angle_max, 180.0),
"LookAt constraint_angle_max is 180 (got %.3f)" % look_at.constraint_angle_max)
_check(not look_at.constraint_angle_invert,
"LookAt constraint_angle_invert is false")
_check(not look_at.constraint_in_localspace,
"LookAt constraint_in_localspace is false")
_check(look_at.constraint_angle_max - look_at.constraint_angle_min >= 359.0,
"LookAt constraint spans a full 360-degree band (got %.1f deg)"
% (look_at.constraint_angle_max - look_at.constraint_angle_min))
var head_bone := skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
var head_body := rig.get_node_or_null(NodePath("Body/Head")) as Node2D
var head_target := rig.get_node_or_null(NodePath("IK_Targets/Head")) as Marker2D
_check(head_bone != null and head_body != null and head_target != null,
"head bone / Body/Head visual / Head aim marker all resolve")
# 2) Standing-still check at STAND_POSE: no frame-to-frame motion at all.
var stand_pos: Vector2 = (RIG.STAND_POSE["Head"] as Dictionary)["pos"]
head_target.position = stand_pos
for i: int in WARMUP_FRAMES:
await physics_frame
var still := await _measure_head(head_bone, head_body, MEASURE_FRAMES)
_check(still["flips"] == 0,
"STAND_POSE: no oscillation events over %d frames" % MEASURE_FRAMES)
_check(still["max_step"] <= STILL_MAX_STEP_RAD,
"STAND_POSE: per-frame head motion <= %.2f rad (max %.4f)"
% [STILL_MAX_STEP_RAD, still["max_step"]])
# 3) PLAY/EDIT exit discriminator: settle on the walk-LEFT pose, then snap
# the aim marker back to STAND_POSE (what snap_to_standing() does). The
# fixed constraint stays still; the old 55..305/invert/localspace constraint
# jumped ~0.8 rad in a single frame (the mirror-jitter regression).
head_target.position = Vector2(-100.0, -614.0) # walk-left aim pose
for i: int in WARMUP_FRAMES:
await physics_frame
head_target.position = stand_pos
var snap := await _measure_head(head_bone, head_body, MEASURE_FRAMES)
_check(snap["flips"] == 0,
"STAND_POSE snap: no mirror-flip frame (got %d > threshold)" % snap["flips"])
_check(snap["max_step"] <= FLIP_THRESHOLD_RAD,
"STAND_POSE snap: max one-frame rotation %.3f rad stays under the %.2f rad flip threshold"
% [snap["max_step"], FLIP_THRESHOLD_RAD])
await _free_stage(stage)
## Awaits `frames` physics frames, tracking the head bone + Body/Head visual
## rotation. Returns {flips, max_step}: `flips` counts frames whose wrapped
## step exceeds FLIP_THRESHOLD_RAD; `max_step` is the largest wrapped step.
func _measure_head(head_bone: Bone2D, head_body: Node2D, frames: int) -> Dictionary:
var flips := 0
var max_step := 0.0
var prev_bone := head_bone.global_rotation
var prev_body := head_body.global_rotation
for i: int in frames:
await physics_frame
var step := absf(wrapf(head_bone.global_rotation - prev_bone, -PI, PI))
var body_step := absf(wrapf(head_body.global_rotation - prev_body, -PI, PI))
max_step = maxf(max_step, maxf(step, body_step))
if step > FLIP_THRESHOLD_RAD or body_step > FLIP_THRESHOLD_RAD:
flips += 1
prev_bone = head_bone.global_rotation
prev_body = head_body.global_rotation
return { "flips": flips, "max_step": max_step }
# ---------------------------------------------------------------------------
# Bug 4: reactive badge accumulation across Play sessions
# ---------------------------------------------------------------------------
func _test_bug4_reactive_actions() -> void:
print("")
print("--- Bug 4: enqueue_reactive tags + clear_reactive_actions restores ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the queue test")
if rig == null:
await _free_stage(stage)
return
# Authored sequential queue (what the director builds in EDIT).
rig.queue_action({ "type": "speak", "text": "authored-a", "duration": 2.0 })
rig.queue_action({ "type": "wait", "duration": 1.0 })
_check(rig.queue_size() == 2, "authored queue holds 2 actions")
# Cycle 1: a reactive action is injected (rule firing during PLAY), which
# auto-starts the runner; leaving PLAY stops the queue first, then
# clear_reactive_actions() (the exact _enter_edit_shared() ordering).
var qc1: Array = []
rig.queue_changed.connect(func(): qc1.append(true))
var r1: Array[Dictionary] = [{ "type": "speak", "text": "reactive-1", "duration": 1.0 }]
rig.enqueue_reactive(r1)
_check(rig.queue_size() == 3,
"reactive injection grows the queue to 3 (got %d)" % rig.queue_size())
_check(_count_reactive(rig) == 1,
"injected action is tagged reactive=true")
_check(rig.is_queue_running(),
"enqueue_reactive auto-resumes the runner from IDLE")
_check(qc1.size() == 1, "enqueue_reactive emits queue_changed")
rig.stop_queue()
rig.clear_reactive_actions()
_check(rig.queue_size() == 2,
"stop + clear restores the authored queue (2, got %d)" % rig.queue_size())
_check(_action_types(rig) == ["speak", "wait"],
"queue types after clear are exactly the authored actions (got %s)"
% str(_action_types(rig)))
_check(qc1.size() == 2, "clear_reactive_actions emits queue_changed on change")
# Cycle 2: a bigger reactive burst then stop + clear again - counts must
# stay stable (no 1,2,3 accumulation across repeated Play sessions).
var r2: Array[Dictionary] = [
{ "type": "wait", "duration": 0.5 },
{ "type": "ragdoll" },
]
rig.enqueue_reactive(r2)
_check(rig.queue_size() == 4,
"second injection grows to 4 (got %d)" % rig.queue_size())
_check(_count_reactive(rig) == 2,
"second burst tags both actions reactive")
rig.stop_queue()
rig.clear_reactive_actions()
_check(rig.queue_size() == 2,
"second stop + clear restores 2 authored actions - no accumulation (got %d)" % rig.queue_size())
_check(_action_types(rig) == ["speak", "wait"],
"queue types stay stable across two reactive cycles (got %s)"
% str(_action_types(rig)))
# clear_reactive_actions() must be a no-op while the runner is executing.
rig.start_queue()
_check(rig.is_queue_running(), "runner is EXECUTING after start_queue")
var r3: Array[Dictionary] = [{ "type": "recover" }]
rig.enqueue_reactive(r3)
_check(rig.queue_size() == 3,
"reactive action can still append while the runner is executing")
var qc3: Array = []
rig.queue_changed.connect(func(): qc3.append(true))
rig.clear_reactive_actions()
_check(rig.queue_size() == 3 and _count_reactive(rig) == 1,
"clear_reactive_actions is a no-op while EXECUTING (reactive action stays)")
_check(qc3.is_empty(),
"no queue_changed emitted by the EXECUTING no-op")
rig.stop_queue()
_check(not rig.is_queue_running(), "runner returns to IDLE after stop_queue")
rig.clear_reactive_actions()
_check(rig.queue_size() == 2 and _count_reactive(rig) == 0,
"after stop, clear drops the injected reactive action (queue 2)")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _count_reactive(rig: StickmanRig) -> int:
var n := 0
for a: Dictionary in rig.get_queue():
if bool(a.get("reactive", false)):
n += 1
return n
func _action_types(rig: StickmanRig) -> Array[String]:
var out: Array[String] = []
for a: Dictionary in rig.get_queue():
out.append(str(a.get("type", "")))
return out
func _new_stage() -> Node2D:
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
stage._snap_enabled = false
return stage
func _free_stage(stage: Node2D) -> void:
stage.queue_free()
await process_frame
func _func_body_contains(text: String, func_name: String, needle: String) -> bool:
var marker := "func " + func_name
var start := text.find(marker)
if start == -1:
return false
var end := text.find("\nfunc ", start + marker.length())
if end == -1:
end = text.length()
return text.substr(start, end - start).contains(needle)
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://dt1qgbwe4ti46
+101
View File
@@ -0,0 +1,101 @@
# test_phase4b_grid_dirty.gd
# Headless checks for the Phase 4b grid spatial dictionary lifecycle and the
# WS3 TriggerArea-move redraw bugfix:
# 1. After placing a block, its cells are in the dictionary.
# 2. Moving a block via transform_committed moves its occupancy (old cells gone).
# 3. Deleting a block removes its cells (no stale entries).
# 4. _on_transform_committed marks StageDirectorVisuals dirty (TriggerArea move
# refreshes the dashed rule connector).
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b_grid_dirty.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
const TRIGGER_AREA := preload("res://scripts/trigger_area.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 4b GRID DICT / DIRTY-FLAG TEST (headless)")
print("========================================================")
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
var world: Node2D = stage._world
# --- Place a ground block at cell (0,0) through the spawner path ---
var block = stage._spawner.spawn("ground", stage._terrain_cell_center(Vector2i(0, 0)))
_check(block != null, "ground block spawns")
_check((block as TerrainBlock).spawn_id == "ground", "terrain block carries spawn_id 'ground'")
stage._rebuild_grid_cells()
var cell00 := Vector2i(0, 0)
_check(not stage._grid_cells.get(cell00, []).is_empty(),
"cell (0,0) occupied after place")
# --- Move it +400 x via the committed-transform path ---
block.position += Vector2(400.0, 0.0)
var moved_nodes: Array[Node2D] = [block as Node2D]
stage._on_transform_committed(moved_nodes)
_check(stage._grid_cells.get(cell00, []).is_empty(),
"old cell (0,0) no longer occupied after move")
var new_cell := Vector2i(25, 0)
_check(not stage._grid_cells.get(new_cell, []).is_empty(),
"new cell (%s) occupied after move" % new_cell)
# --- Delete it via the stage delete path ---
stage._selection.select_only(block)
stage.delete_selected()
_check(stage._grid_cells.get(new_cell, []).is_empty(),
"cell (%s) cleared after delete (no stale entries)" % new_cell)
await process_frame
# --- TriggerArea move -> director visuals mark_dirty ---
var visuals = stage._director_visuals
visuals._dirty = false
var area: Node2D = TRIGGER_AREA.new()
area.position = Vector2(0.0, 0.0)
world.add_child(area)
var rules_arr: Array[Dictionary] = [{
"id": 1,
"trigger": { "type": "entered_area", "source": 0, "target": area.get_instance_id(), "params": {} },
"actions": [],
}]
visuals.set_rules(rules_arr)
visuals._dirty = false
area.position += Vector2(120.0, -40.0)
var moved_area: Array[Node2D] = [area as Node2D]
stage._on_transform_committed(moved_area)
_check(visuals._dirty == true,
"_on_transform_committed marks director visuals dirty (TriggerArea move)")
stage.queue_free()
await process_frame
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://c0r76v20va6yt
+159
View File
@@ -0,0 +1,159 @@
# test_phase4b_logic.gd
# Headless sanity checks for Phase 4b pure logic (no rendering):
# 1. sandbox_theme.json parses and drives the stage's accent/popup/grid values.
# 2. Bresenham cell pathing matches hand-computed staircase runs.
# 3. AABB -> grid-cell rasterization covers the expected cells.
# 4. The three-state occupancy query (empty / same-type skip / conflicting).
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b_logic.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 4b LOGIC TEST (headless)")
print("========================================================")
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
_test_theme(stage)
_test_bresenham(stage)
_test_rasterize(stage)
_test_classify(stage)
stage.queue_free()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _test_theme(stage: Node2D) -> void:
print("")
print("--- Theme loading ---")
_check(stage._theme_grid_default == 15.0,
"theme grid.snap_size seeds default grid (%.1f)" % stage._theme_grid_default)
_check(stage._action_popup_font_size == 24,
"theme action_popup_font_size == 24 (got %d)" % stage._action_popup_font_size)
_check(stage._accent_edit.to_html(false) == "22c6ff",
"theme edit_accent parses to 22c6ff (got %s)" % stage._accent_edit.to_html(false))
_check(stage._accent_direct.to_html(false) == "ffb300",
"theme direct_accent parses to ffb300 (got %s)" % stage._accent_direct.to_html(false))
func _test_bresenham(stage: Node2D) -> void:
print("")
print("--- Bresenham pathing ---")
# Horizontal run: (0,0)->(3,0) yields 4 cells on the row.
var cells: Array[Vector2i] = stage._bresenham_cells(Vector2i(0, 0), Vector2i(3, 0))
_check(cells.size() == 4, "horizontal (0,0)->(3,0) has 4 cells (got %d)" % cells.size())
_check(_cells_on_row(cells, 0), "horizontal run stays on row y=0")
# Diagonal: (0,0)->(3,3) yields exactly 4 staircase cells.
var diag: Array[Vector2i] = stage._bresenham_cells(Vector2i(0, 0), Vector2i(3, 3))
_check(diag.size() == 4, "diagonal (0,0)->(3,3) has 4 cells (got %d)" % diag.size())
_check(_is_staircase(diag), "diagonal run is a monotonic staircase")
# Single cell: anchor == target yields one cell.
var single: Array[Vector2i] = stage._bresenham_cells(Vector2i(2, 2), Vector2i(2, 2))
_check(single.size() == 1, "anchor==target yields 1 cell (got %d)" % single.size())
func _test_rasterize(stage: Node2D) -> void:
print("")
print("--- AABB rasterization ---")
# A single 16x16 cell at the origin maps to exactly one cell.
var one: Array[Vector2i] = stage._rasterize_aabb_to_cells(Rect2(0.0, 0.0, 16.0, 16.0))
_check(one.size() == 1 and one[0] == Vector2i(0, 0),
"16x16 AABB at origin rasterizes to cell (0,0)")
# A ground block spans 200x32 centered at origin; with grid 16 that touches
# 14 columns (x -7..6) x 2 rows (y -1..0) = 28 cells.
var aabb := Rect2(Vector2(-100.0, -16.0), Vector2(200.0, 32.0))
var cells: Array[Vector2i] = stage._rasterize_aabb_to_cells(aabb)
_check(cells.size() == 28, "ground AABB rasterizes to 28 cells (got %d)" % cells.size())
_check(cells.has(Vector2i(-7, -1)) and cells.has(Vector2i(6, 0)),
"ground rasterization covers its corner cells")
# Empty / degenerate AABB yields no cells.
_check(stage._rasterize_aabb_to_cells(Rect2()).is_empty(), "empty AABB yields no cells")
func _test_classify(stage: Node2D) -> void:
print("")
print("--- Three-state occupancy query ---")
# Spawn two ground blocks at distinct cells into the World; classify.
var world: Node2D = stage._world
var ground_a := TERRAIN_UTILS.spawn_block(world, PackedVector2Array([
Vector2(-100, -16), Vector2(100, -16), Vector2(100, 16), Vector2(-100, 16),
]), STAGE_SPAWNER.TERRAIN_GRID_SIZE)
ground_a.spawn_id = "ground"
ground_a.position = stage._terrain_cell_center(Vector2i(0, 0))
stage._rebuild_grid_cells()
stage.set_placement_mode("ground")
var center_cell := Vector2i(0, 0)
var far_cell := Vector2i(20, 20)
_check(stage._classify_cell(center_cell) == 2,
"same-type overlap classified as skip (2)")
_check(stage._classify_cell(far_cell) == 1,
"empty cell classified as empty (1)")
stage.set_placement_mode("")
# A conflicting object (a crate with real polygon geometry) overlapping a
# cell returns 3.
var crate := PROP_BLOCK.new()
crate.name = "Crate"
crate.polygon_points = PackedVector2Array([
Vector2(-24, -24), Vector2(24, -24), Vector2(24, 24), Vector2(-24, 24),
])
crate.position = stage._terrain_cell_center(Vector2i(0, 0))
world.add_child(crate)
stage._rebuild_grid_cells()
stage.set_placement_mode("ground")
_check(stage._classify_cell(center_cell) == 3,
"conflicting object classified as blocked (3)")
stage.set_placement_mode("")
crate.queue_free()
ground_a.queue_free()
func _cells_on_row(cells: Array[Vector2i], y: int) -> bool:
for c: Vector2i in cells:
if c.y != y:
return false
return true
func _is_staircase(cells: Array[Vector2i]) -> bool:
for i: int in range(1, cells.size()):
var d := cells[i] - cells[i - 1]
if absi(d.x) != 1 or absi(d.y) != 1:
return false
return true
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://jwc6ei4kgbw4
+466
View File
@@ -0,0 +1,466 @@
# test_phase4b_popup_anchor.gd
# Headless regression suite for the Phase 4b rule-builder popup-anchor fix
# (SandboxStage session popup anchor):
#
# Bug (popup jumps / follows the live mouse across the When->Back cycle):
# The rule-builder menus ("⚡ When..." -> trigger sub-menu -> "⬅ Back to
# actions" -> ...) previously recomputed their popup screen rect from the
# live mouse position on every hop, so the menus could land far away from
# the stickman whose action popup opened the session.
#
# Fix: scripts/sandbox_stage.gd now keeps session anchor state
# `_popup_anchor: Rect2i` / `_popup_anchor_set: bool` plus helpers
# `_set_popup_anchor(rect)`, `_clear_popup_anchor()`, and
# `_popup_anchor_rect()` (self-records from `_mouse_popup_rect()` when
# unset). The first context menu of a session records the anchor:
# `_handle_direct_click` records its rect and `_begin_edit_rule` records
# the mouse rect. Every child popup in the session reuses it
# (`ACT_WHEN`, `_open_rule_action_popup`, `_open_rule_more_popup`,
# `TRIG_BACK`). It is cleared on `_finalize_rule()`,
# `_cancel_rule_build()`, and `_clear_director_pending()`, but NOT by
# `_reset_rule_builder()` (TRIG_BACK re-opens the action popup at the same
# anchor) and NOT by `RULE_MORE_ADD`.
#
# Coverage:
# * Anchor primitives + `_popup_anchor_rect()` self-recording when unset.
# * Direct first menu records the anchor from the stickman's screen rect.
# * When -> Back -> When -> Back keeps the stored anchor IDENTICAL on every
# hop (the OLD code recomputed from the live mouse each hop; the anchor
# here is chosen far from the headless mouse rect so a recompute would
# visibly change it).
# * `_cancel_rule_build()` clears the anchor.
# * `_finalize_rule()` clears the anchor.
# * `_clear_director_pending()` (mode exit) clears the anchor.
# * `_begin_edit_rule(id)` records a fresh anchor.
# * `_reset_rule_builder()` does NOT clear the anchor (TRIG_BACK invariant).
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b_popup_anchor.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
# Watchdog: if a runtime error aborts _run before quit(), force a FAIL exit
# instead of hanging the headless process forever.
var watchdog := create_timer(120.0)
watchdog.timeout.connect(func() -> void:
print("FAIL: watchdog timeout - test run aborted before quit()")
quit(2))
print("")
print("========================================================")
print(" PHASE 4b POPUP-ANCHOR REGRESSION TEST (headless)")
print("========================================================")
_test_anchor_primitives_and_self_record()
_test_direct_first_menu_records_anchor()
await _test_when_back_cycle_keeps_anchor()
_test_cancel_clears_anchor()
_test_confirm_clears_anchor()
_test_mode_exit_clears_anchor()
await _test_edit_rule_records_fresh_anchor()
_test_reset_preserves_anchor()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
# ---------------------------------------------------------------------------
# Anchor state primitives + lazy self-record
# ---------------------------------------------------------------------------
func _test_anchor_primitives_and_self_record() -> void:
print("")
print("--- Anchor primitives + self-recording on first use ---")
var stage := _new_stage()
_check(not stage._popup_anchor_set,
"session starts with _popup_anchor_set == false")
_check(stage._popup_anchor == Rect2i(),
"session starts with an empty _popup_anchor rect")
stage._set_popup_anchor(Rect2i(123, 456, 0, 0))
_check(stage._popup_anchor_set,
"_set_popup_anchor() marks the anchor as set")
_check(stage._popup_anchor == Rect2i(123, 456, 0, 0),
"_set_popup_anchor() stores the given rect exactly")
stage._clear_popup_anchor()
_check(not stage._popup_anchor_set,
"_clear_popup_anchor() clears the set flag")
_check(stage._popup_anchor == Rect2i(),
"_clear_popup_anchor() zeroes the stored rect")
# Lazy self-record: first use when unset records the mouse rect.
var mouse_rect: Rect2i = stage._mouse_popup_rect()
var recorded: Rect2i = stage._popup_anchor_rect()
_check(recorded == mouse_rect,
"_popup_anchor_rect() when unset returns the current mouse rect")
_check(stage._popup_anchor_set,
"_popup_anchor_rect() when unset records the anchor as set")
_check(stage._popup_anchor == mouse_rect,
"self-recorded anchor matches the mouse rect exactly")
# A second call reuses the stored value instead of re-reading the mouse.
var recorded2: Rect2i = stage._popup_anchor_rect()
_check(recorded2 == stage._popup_anchor,
"subsequent _popup_anchor_rect() calls reuse the stored anchor")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Direct first menu records the anchor from the stickman's screen rect
# ---------------------------------------------------------------------------
func _test_direct_first_menu_records_anchor() -> void:
print("")
print("--- Direct first menu records the session anchor ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the direct-click test")
if rig == null:
await _free_stage(stage)
return
# Deterministic camera so the expected screen rect is known exactly.
stage._camera.position = Vector2(3000.0, -400.0)
stage._camera.zoom = Vector2(0.5, 0.5)
# The click routes through the real _selection.hit_test path: click the
# center of the rig's world AABB.
var aabb: Rect2 = STAGE_SELECTION.get_world_aabb(rig)
var click_pos: Vector2 = aabb.get_center()
stage._handle_direct_click(click_pos)
var expected := Rect2i(
Vector2i(stage._world_to_screen(rig.global_position)) + Vector2i(24, 0),
Vector2i.ZERO)
_check(stage._context_rig == rig,
"direct click selects the rig under the cursor (context rig set)")
_check(stage._popup_anchor_set,
"direct first menu records the session anchor (set flag on)")
_check(stage._popup_anchor == expected,
"direct first menu records the stickman screen rect + 24px offset (got %s)"
% str(stage._popup_anchor))
# The anchor must be DISTINCT from the headless mouse rect, otherwise the
# When/Back hop discriminator could not tell reuse from recompute.
_check(stage._popup_anchor != stage._mouse_popup_rect(),
"recorded anchor differs from the live mouse rect (discriminator valid)")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# When -> Back -> When -> Back keeps the stored anchor identical on every hop
# ---------------------------------------------------------------------------
func _test_when_back_cycle_keeps_anchor() -> void:
print("")
print("--- When/Back cycle reuses the stored anchor on every hop ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the When/Back cycle")
if rig == null:
await _free_stage(stage)
return
stage._camera.position = Vector2(3000.0, -400.0)
stage._camera.zoom = Vector2(0.5, 0.5)
# Session start mirrors the real UI flow: a Direct click on the stickman
# opens the ACTION popup and records the session anchor at the stickman's
# screen rect (far from the live mouse). Then the user presses "⚡ When...".
var aabb: Rect2 = STAGE_SELECTION.get_world_aabb(rig)
stage._handle_direct_click(aabb.get_center())
var expected: Rect2i = Rect2i(
Vector2i(stage._world_to_screen(rig.global_position)) + Vector2i(24, 0),
Vector2i.ZERO)
_check(stage._context_rig == rig,
"direct click arms the rig before the When/Back cycle")
_check(stage._popup_anchor_set and stage._popup_anchor == expected,
"session anchor recorded by the direct first menu before the hops")
stage._on_action_popup_id_pressed(stage.ACT_WHEN)
_check(stage._popup_anchor_set,
"When... keeps the recorded session anchor (set flag on)")
var r1: Rect2i = stage._popup_anchor
_check(stage._popup_anchor == expected,
"When... does not re-record over the direct-click anchor")
_check(stage._trigger_popup.visible,
"trigger popup is open after When...")
_check(int(stage._rule_step) == 1,
"After When... the builder sits at SELECT_TRIGGER (got %d)"
% int(stage._rule_step))
# Back to the action popup.
stage._on_trigger_popup_id_pressed(stage.TRIG_BACK)
_check(stage._popup_anchor_set,
"TRIG_BACK keeps the session anchor set (no clear on reset)")
_check(stage._popup_anchor == r1,
"TRIG_BACK leaves the stored anchor unchanged (hop 1)")
_check(int(stage._rule_step) == 0,
"TRIG_BACK resets the builder to IDLE (got %d)" % int(stage._rule_step))
_check(stage._rule_builder.is_empty(),
"TRIG_BACK empties the rule builder dict")
_check(stage._action_popup.visible,
"TRIG_BACK re-opens the ACTION popup (no dead-end)")
# When... again from the action popup.
stage._on_action_popup_id_pressed(stage.ACT_WHEN)
_check(stage._popup_anchor_set,
"second When... still has the anchor set")
_check(stage._popup_anchor == r1,
"second When... reuses the stored anchor (hop 2, not the live mouse)")
_check(int(stage._rule_step) == 1,
"second When... returns to SELECT_TRIGGER (got %d)" % int(stage._rule_step))
_check(stage._trigger_popup.visible,
"trigger popup re-opens after the second When...")
# Back again.
stage._on_trigger_popup_id_pressed(stage.TRIG_BACK)
_check(stage._popup_anchor == r1,
"second TRIG_BACK keeps the identical stored anchor (hop 3)")
_check(stage._popup_anchor_set,
"second TRIG_BACK leaves the anchor set (session still active)")
_check(int(stage._rule_step) == 0,
"second TRIG_BACK resets the builder again (got %d)"
% int(stage._rule_step))
# Discriminator sanity: the recorded anchor is NOT the live mouse rect, so
# any code path that recomputed the rect per hop would have changed r1.
_check(stage._popup_anchor != stage._mouse_popup_rect(),
"anchor stayed distinct from the live mouse rect the whole cycle")
_check(stage._popup_anchor == r1,
"anchor is byte-for-byte identical across the whole When/Back cycle")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Cancel clears the anchor
# ---------------------------------------------------------------------------
func _test_cancel_clears_anchor() -> void:
print("")
print("--- _cancel_rule_build() clears the session anchor ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the cancel test")
if rig == null:
await _free_stage(stage)
return
stage._context_rig = rig
# Enter a rule build (SELECT_TRIGGER) through the real handler.
stage._set_popup_anchor(Rect2i(900, 700, 0, 0))
stage._on_action_popup_id_pressed(stage.ACT_WHEN)
_check(stage._popup_anchor_set,
"When... keeps the pre-recorded anchor (set flag on)")
_check(int(stage._rule_step) == 1,
"rule build sits at SELECT_TRIGGER before cancel (got %d)"
% int(stage._rule_step))
stage._cancel_rule_build()
_check(not stage._popup_anchor_set,
"cancel clears _popup_anchor_set")
_check(stage._popup_anchor == Rect2i(),
"cancel zeroes the stored anchor")
_check(int(stage._rule_step) == 0,
"cancel resets the builder to IDLE (got %d)" % int(stage._rule_step))
_check(stage._rule_builder.is_empty(),
"cancel empties the rule builder dict")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Confirm clears the anchor
# ---------------------------------------------------------------------------
func _test_confirm_clears_anchor() -> void:
print("")
print("--- _finalize_rule() clears the session anchor ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the confirm test")
if rig == null:
await _free_stage(stage)
return
# A mid-flight rule build whose trigger references the live rig (real rule
# builds always carry a valid source; this also keeps the director visuals'
# rule-draw path resolvable after _finalize_rule -> set_rules).
stage._rule_builder = {
"trigger": { "type": "action_finished", "source": rig.get_instance_id(), "target": -1, "params": {} },
"actions": [
{ "type": "speak", "target": rig.get_instance_id(), "params": { "text": "hi", "duration": 1.0 } },
],
}
stage._rule_step = 1 # a builder is mid-flight (SELECT_TRIGGER)
stage._set_popup_anchor(Rect2i(400, 300, 0, 0))
_check(stage._popup_anchor_set, "anchor is set before confirm")
var before: int = stage._event_rules.size()
stage._finalize_rule()
_check(not stage._popup_anchor_set,
"confirm clears _popup_anchor_set")
_check(stage._popup_anchor == Rect2i(),
"confirm zeroes the stored anchor")
_check(stage._event_rules.size() == before + 1,
"confirm finalized one new rule (got %d -> %d)"
% [before, stage._event_rules.size()])
_check(not stage._event_rules.is_empty()
and String(stage._event_rules[-1].get("trigger", {}).get("type", "")) == "action_finished",
"finalized rule carries the in-progress trigger")
_check(int(stage._rule_step) == 0,
"confirm resets the builder to IDLE (got %d)" % int(stage._rule_step))
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Mode exit clears the anchor
# ---------------------------------------------------------------------------
func _test_mode_exit_clears_anchor() -> void:
print("")
print("--- _clear_director_pending() clears the session anchor ---")
var stage := _new_stage()
stage._pending_walk_target = true
stage._set_popup_anchor(Rect2i(777, 888, 0, 0))
_check(stage._popup_anchor_set, "anchor is set before mode exit")
stage._clear_director_pending()
_check(not stage._popup_anchor_set,
"mode exit clears _popup_anchor_set")
_check(stage._popup_anchor == Rect2i(),
"mode exit zeroes the stored anchor")
_check(not stage._pending_walk_target,
"mode exit still cancels a pending walk target")
_check(stage._context_rig == null,
"mode exit still clears the context rig")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Edit-rule entry records a fresh anchor
# ---------------------------------------------------------------------------
func _test_edit_rule_records_fresh_anchor() -> void:
print("")
print("--- _begin_edit_rule() records a fresh anchor ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the edit-rule test")
if rig == null:
await _free_stage(stage)
return
# An existing authored rule referencing the rig as its source (the rule
# label click path populates _event_rules the same way).
var stored_rule := {
"id": 0,
"trigger": {
"type": "action_finished",
"source": rig.get_instance_id(),
"target": -1,
"params": {},
},
"actions": [
{ "type": "speak", "target": rig.get_instance_id(), "params": { "text": "hi", "duration": 1.0 } },
],
}
var rules_arr: Array[Dictionary] = [stored_rule]
stage._event_rules = rules_arr
# A stale anchor from a previous session must be replaced, not reused.
stage._set_popup_anchor(Rect2i(1, 2, 0, 0))
stage._begin_edit_rule(0)
var mouse_rect: Rect2i = stage._mouse_popup_rect()
_check(stage._popup_anchor_set,
"edit-rule entry records the anchor (set flag on)")
_check(stage._popup_anchor == mouse_rect,
"edit-rule entry records a FRESH mouse rect (got %s, mouse %s)"
% [str(stage._popup_anchor), str(mouse_rect)])
_check(stage._rule_editing_id == 0,
"edit-rule entry arms _rule_editing_id (got %d)" % stage._rule_editing_id)
_check(int(stage._rule_step) == 3,
"edit-rule entry sits at SELECT_ACTION (got %d)" % int(stage._rule_step))
_check(stage._rule_context_rig == rig,
"edit-rule entry resolves the rule source rig")
_check(stage._rule_action_popup.visible,
"edit-rule entry opens the rule-action popup")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# _reset_rule_builder preserves the anchor (TRIG_BACK invariant)
# ---------------------------------------------------------------------------
func _test_reset_preserves_anchor() -> void:
print("")
print("--- _reset_rule_builder() preserves the session anchor ---")
var stage := _new_stage()
# TRIG_BACK calls _reset_rule_builder() and THEN re-opens the action popup at
# _popup_anchor_rect(), so a reset that cleared the anchor would re-record
# from the live mouse and lose the session position.
stage._set_popup_anchor(Rect2i(555, 444, 0, 0))
stage._reset_rule_builder()
_check(stage._popup_anchor_set,
"reset keeps _popup_anchor_set true (TRIG_BACK must preserve the anchor)")
_check(stage._popup_anchor == Rect2i(555, 444, 0, 0),
"reset leaves the stored anchor untouched")
_check(int(stage._rule_step) == 0,
"reset still returns the builder to IDLE (got %d)" % int(stage._rule_step))
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _new_stage() -> Node2D:
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
stage._snap_enabled = false
return stage
func _free_stage(stage: Node2D) -> void:
stage.queue_free()
await process_frame
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://bcedsl4sq2xxs
+260
View File
@@ -0,0 +1,260 @@
# test_phase4b_stage.gd
# Headless logic checks for the Phase 4b mode-switcher / toolbar / stage state:
# 1. StageMode enum values (EDIT=0, DIRECT=1, PLAY=2).
# 2. mode_changed emits the right int per transition; toolbar/control visibility
# and grid visibility switch per mode; badge text updates.
# 3. Esc from DIRECT returns to EDIT.
# 4. Entering PLAY/DIRECT/EDIT clears pending director/rule-builder state.
# 5. Theme fallback for a missing / malformed theme file (via a copy path).
# 6. RMB ends placement; LMB keeps repeated placement active (script-level).
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b_stage.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 4b STAGE/UI LOGIC TEST (headless)")
print("========================================================")
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
_test_mode_enum(stage)
_test_mode_transitions(stage)
_test_status_bar(stage)
_test_esc_from_direct(stage)
_test_mode_clears_pending(stage)
_test_theme_fallback(stage)
_test_placement_buttons(stage)
stage.queue_free()
await process_frame
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _test_mode_enum(stage: Node2D) -> void:
print("")
print("--- StageMode enum values ---")
_check(int(stage.StageMode.EDIT) == 0, "StageMode.EDIT == 0")
_check(int(stage.StageMode.DIRECT) == 1, "StageMode.DIRECT == 1")
_check(int(stage.StageMode.PLAY) == 2, "StageMode.PLAY == 2")
_check(stage._mode_buttons.size() == 3, "3-segment mode switcher built (got %d)" % stage._mode_buttons.size())
_check(not (stage._mode_buttons[0] as Button).visible or true,
"mode segments exist in top bar")
func _test_mode_transitions(stage: Node2D) -> void:
print("")
print("--- Mode transitions (visibility / badge / grid / signals) ---")
var emitted: Array[int] = []
stage.mode_changed.connect(func(m): emitted.append(m))
# Startup defaults: EDIT active (current_mode EDIT).
_check(int(stage.current_mode) == stage.StageMode.EDIT, "starts in EDIT")
_check((stage._mode_buttons[0] as Button).button_pressed,
"EDIT segment is visually pressed at startup")
_check((stage._palette_buttons["ground"] as Button).visible,
"EDIT shows palette buttons")
_check(stage._grid.visible, "EDIT shows grid")
_check(stage._mode_badge_label.text == "✏️ EDIT", "badge says EDIT at startup")
_check(stage._direct_hint_label.visible == false, "direct hint hidden at startup")
stage.set_mode(stage.StageMode.DIRECT)
_check(int(stage.current_mode) == stage.StageMode.DIRECT, "DIRECT entered")
_check(emitted == [1], "mode_changed emitted 1 for DIRECT")
_check((stage._palette_buttons["ground"] as Button).visible == false,
"DIRECT hides palette buttons")
_check(stage._grid.visible == false, "DIRECT hides grid")
_check(stage._direct_hint_label.visible, "DIRECT shows director hint")
_check(stage._mode_badge_label.text == "🎬 DIRECTING", "badge says DIRECTING")
_check(stage._mode_frame.visible, "amber DIRECT frame visible")
_check((stage._mode_buttons[1] as Button).button_pressed,
"DIRECT segment is visually pressed")
stage.set_mode(stage.StageMode.PLAY)
_check(int(stage.current_mode) == stage.StageMode.PLAY, "PLAY entered")
_check(emitted == [1, 2], "mode_changed emitted 2 for PLAY")
_check((stage._palette_buttons["ground"] as Button).visible == false,
"PLAY hides palette buttons")
_check(stage._direct_hint_label.visible == false, "PLAY hides direct hint")
_check(stage._mode_badge_label.text == "▶️ SIMULATING", "badge says SIMULATING")
_check((stage._mode_buttons[2] as Button).button_pressed,
"PLAY segment is visually pressed")
stage.set_mode(stage.StageMode.EDIT)
_check(emitted == [1, 2, 0], "mode_changed emitted 0 for EDIT")
_check((stage._palette_buttons["ground"] as Button).visible,
"back in EDIT shows palette again")
_check(stage._grid.visible, "back in EDIT shows grid again")
_check((stage._mode_buttons[0] as Button).button_pressed,
"EDIT segment pressed again")
func _test_status_bar(stage: Node2D) -> void:
print("")
print("--- Bottom status bar cursor coords (spec §2.1) ---")
_check(stage._status_cursor_coords != null, "status cursor-coords label exists")
var coords: Label = stage._status_cursor_coords
# Deterministic format check: the reader writes "X: <int> Y: <int>" per frame.
stage._update_cursor_coords()
var text: String = coords.text
_check(text.begins_with("X: ") and text.contains(" Y: "),
"cursor coords text formatted as 'X: n Y: n' (got '%s')" % text)
var parts := text.split(" ")
_check(parts.size() >= 2 and parts[1].begins_with("Y: "),
"cursor coords carries a Y component (got '%s')" % text)
func _test_esc_from_direct(stage: Node2D) -> void:
print("")
print("--- Esc from DIRECT -> EDIT ---")
stage.set_mode(stage.StageMode.DIRECT)
var esc := InputEventKey.new()
esc.keycode = KEY_ESCAPE
esc.pressed = true
stage._unhandled_key_input(esc)
_check(int(stage.current_mode) == stage.StageMode.EDIT,
"Esc while DIRECT returns to EDIT (mode=%d)" % int(stage.current_mode))
func _test_mode_clears_pending(stage: Node2D) -> void:
print("")
print("--- Mode transitions clear pending director/rule state ---")
# Enter PLAY mid-walk-pick from DIRECT.
stage.set_mode(stage.StageMode.DIRECT)
stage._pending_walk_target = true
stage.set_mode(stage.StageMode.PLAY)
_check(stage._pending_walk_target == false,
"pending walk target cleared entering PLAY from DIRECT")
# Enter EDIT mid-rule-build from DIRECT.
stage.set_mode(stage.StageMode.DIRECT)
stage._rule_step = 3 # RuleStep.TRIGGER_TARGET
stage._rule_builder = { "trigger": { "type": "entered_area" } }
stage._rule_hint = "Click the trigger area"
stage.set_mode(stage.StageMode.EDIT)
_check(stage._rule_step == 0,
"rule builder reset entering EDIT from DIRECT (step=%d)" % stage._rule_step)
_check(stage._rule_builder.is_empty(), "rule builder dict cleared entering EDIT")
# Enter PLAY mid-rule-build from DIRECT.
stage.set_mode(stage.StageMode.DIRECT)
stage._rule_step = 5 # RuleStep.ACTION_POSITION
stage._rule_builder = { "trigger": { "type": "arrived_at_waypoint" } }
stage.set_mode(stage.StageMode.PLAY)
_check(stage._rule_step == 0,
"rule builder reset entering PLAY from DIRECT (step=%d)" % stage._rule_step)
# Enter DIRECT from PLAY clears stale placement.
stage.set_mode(stage.StageMode.PLAY)
stage.set_placement_mode("ground")
stage.set_mode(stage.StageMode.DIRECT)
_check(stage._placement_id == "",
"entering DIRECT clears an armed placement (got '%s')" % stage._placement_id)
func _test_theme_fallback(stage: Node2D) -> void:
print("")
print("--- Theme fallback (missing / malformed JSON file) ---")
# Live theme applied to popups: the real sandbox_theme.json drives the action
# popup font size (spec §5.1: an override actually reaches the PopupMenu).
_check(stage._action_popup.get_theme_font_size("font_size") == stage._action_popup_font_size,
"action popup font-size override wired to theme (%d)" % stage._action_popup_font_size)
# These instances are NOT added to the tree, so _ready()/_load_theme() do not
# run and the scalar vars still hold their declaration defaults. Exercising
# the missing/malformed branches on them proves the fallback leaves those
# defaults untouched (no crash, no sentinel values).
var stage2: Node2D = STAGE_SCENE.instantiate()
stage2._load_theme("user://does_not_exist_phase4b.json")
_check(stage2._theme_grid_default == stage2.DEFAULT_GRID_SIZE,
"missing theme -> grid default %.1f" % stage2._theme_grid_default)
_check(stage2._action_popup_font_size == 24,
"missing theme -> default popup font size %d" % stage2._action_popup_font_size)
_check(stage2._theme.is_empty(), "missing theme leaves _theme empty")
stage2.free()
# Malformed-file fallback.
var mal := FileAccess.open("user://malformed_theme_phase4b.json", FileAccess.WRITE)
if mal != null:
mal.store_string("{ not valid json !!!")
mal.close()
var stage3: Node2D = STAGE_SCENE.instantiate()
stage3._load_theme("user://malformed_theme_phase4b.json")
_check(stage3._theme_grid_default == stage3.DEFAULT_GRID_SIZE,
"malformed theme -> grid default %.1f" % stage3._theme_grid_default)
_check(stage3._action_popup_font_size == 24,
"malformed theme -> default popup font size %d" % stage3._action_popup_font_size)
_check(stage3._theme.is_empty(), "malformed theme leaves _theme empty")
stage3.free()
DirAccess.remove_absolute("user://malformed_theme_phase4b.json")
func _test_placement_buttons(stage: Node2D) -> void:
print("")
print("--- Placement button semantics (RMB ends, LMB keeps tool) ---")
# LMB repeated placement: set_placement_mode arms a palette button and stays armed.
stage.set_mode(stage.StageMode.EDIT)
stage.set_placement_mode("ground")
_check(stage._placement_id == "ground", "ground placement armed")
_check((stage._palette_buttons["ground"] as Button).button_pressed,
"ground palette button pressed when armed")
# A successful terrain drag commit keeps the tool armed (LMB repeated placement).
var world: Node2D = stage._world
var before := _terrain_child_count(world)
var start_cell := Vector2i(60, 60)
var end_cell := Vector2i(62, 60)
stage._begin_terrain_drag(stage._terrain_cell_center(start_cell))
stage._update_terrain_drag(stage._terrain_cell_center(end_cell))
stage._commit_terrain_drag()
var after := _terrain_child_count(world)
_check(stage._placement_id == "ground",
"LMB drag commit keeps placement armed (got '%s')" % stage._placement_id)
_check(after - before >= 1, "drag commit placed %d new terrain block(s)" % (after - before))
_check(stage._terrain_dragging == false, "drag ends after commit")
# RMB ends placement + unpresses the palette button.
var rmb := InputEventMouseButton.new()
rmb.button_index = MOUSE_BUTTON_RIGHT
rmb.pressed = true
stage._handle_world_click(rmb)
_check(stage._placement_id == "", "RMB clears placement (got '%s')" % stage._placement_id)
_check((stage._palette_buttons["ground"] as Button).button_pressed == false,
"ground palette button unpressed after RMB")
# RMB with nothing armed is a no-op (still EDIT).
stage._handle_world_click(rmb)
_check(int(stage.current_mode) == stage.StageMode.EDIT,
"RMB with no placement leaves mode untouched")
func _terrain_child_count(world: Node2D) -> int:
var n := 0
for child: Node in world.get_children():
if child is TerrainBlock:
n += 1
return n
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://cinfvn7tmkn1q
+120
View File
@@ -0,0 +1,120 @@
# test_phase4b_terrain.gd
# Headless checks for Phase 4b terrain drag-painting batch commit semantics
# (spec §2.6.4/2.6.5 + §5.5/5.6):
# 1. Dragging across an EMPTY open region commits one block per path cell.
# 2. Dragging over an existing same-type block commits ZERO new blocks
# (same-type overlap skip; advisory dictionary, no double-create).
# 3. A cell occupied by a conflicting prop is skipped while its empty
# neighbours still spawn (Case C).
# 4. A drag never leaves ghost terrain behind in the World.
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b_terrain.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const TERRAIN_BLOCK := preload("res://scripts/terrain_block.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 4b TERRAIN DRAG COMMIT TEST (headless)")
print("========================================================")
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
var world: Node2D = stage._world
# --- Empty-region drag: 3 path cells -> 3 blocks ---
var before := _terrain_count(world)
stage.set_mode(stage.StageMode.EDIT)
stage.set_placement_mode("ground")
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0)))
_check(stage._drag_cells.size() == 3, "path has 3 cells (got %d)" % stage._drag_cells.size())
stage._commit_terrain_drag()
var after_empty := _terrain_count(world)
_check(after_empty - before == 3, "empty drag committed 3 blocks (got %d)" % (after_empty - before))
# --- Same-type overlap drag: over a region now occupied by ground -> 0 ---
var b2 := _terrain_count(world)
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0)))
stage._commit_terrain_drag()
var a2 := _terrain_count(world)
_check(a2 - b2 == 0, "same-type overlap drag committed 0 blocks (got %d)" % (a2 - b2))
# --- Conflicting-object skip: put a crate in a far empty region, drag over it ---
# With block stride (Ground = 200x32), the crate's 48x48 AABB covers exactly
# one block cell (70,0), not a 16-px span.
var crate := _spawn_crate(world, stage._terrain_cell_center(Vector2i(70, 0)))
stage._rebuild_grid_cells()
var b3 := _terrain_count(world)
# Drag cells (65,0)..(75,0): the middle cell (70,0) conflict-red, edges empty.
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(65, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(75, 0)))
_check(stage._drag_cells.size() == 11, "conflict drag path has 11 cells (got %d)" % stage._drag_cells.size())
var cls_edge: int = stage._classify_cell(Vector2i(65, 0))
var cls_mid: int = stage._classify_cell(Vector2i(70, 0))
_check(cls_mid == 3, "crate cell classified conflict (3) (got %d)" % cls_mid)
_check(cls_edge == 1, "edge cell (65,0) empty (1) (got %d)" % cls_edge)
stage._commit_terrain_drag()
var a3 := _terrain_count(world)
# Path length 11 minus the single crate cell that gets skipped = 10 commits.
_check(a3 - b3 == 10, "conflict drag committed only empty cells (expected 10, got %d)" % (a3 - b3))
# --- No ghost terrain leaked into World ---
var ghost_blocks := 0
for child: Node in world.get_children():
if child is TERRAIN_BLOCK:
ghost_blocks += 1
_check(ghost_blocks == a3, "World contains only committed blocks (got %d, expected %d)" % [ghost_blocks, a3])
crate.queue_free()
stage.queue_free()
await process_frame
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _spawn_crate(world: Node2D, pos: Vector2) -> Node2D:
var crate = preload("res://scripts/prop_block.gd").new()
crate.name = "Crate"
crate.polygon_points = PackedVector2Array([
Vector2(-24, -24), Vector2(24, -24), Vector2(24, 24), Vector2(-24, 24),
])
crate.position = pos
world.add_child(crate)
return crate
func _terrain_count(world: Node2D) -> int:
var n := 0
for child: Node in world.get_children():
if child is TERRAIN_BLOCK:
n += 1
return n
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://dvc538j0obier
+117
View File
@@ -0,0 +1,117 @@
# test_phase4b_walk.gd
# Headless regression checks for the Phase 4b walk-waypoint arrival-jitter fix
# (spec §3.4 / §5.9):
# 1. On-mesh waypoint: steering mode latches "nav" once, exactly one `arrived`
# fires, and the rig position is unchanged for 60+ physics frames after.
# 2. Off-mesh waypoint: mode latches "direct" once, one arrive, stable.
# 3. No vertical jitter: the root's final resting Y equals the snapped target.
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b_walk.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const RIG := preload("res://scripts/stickman_rig.gd")
const SETTLE_FRAMES := 60
const MAX_FRAMES := 300
var _checks := 0
var _failures := 0
var _arrived_count := 0
func _initialize() -> void:
call_deferred("_run")
func _on_arrived(_target: Vector2) -> void:
_arrived_count += 1
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 4b WALK JITTER / LATCH TEST (headless)")
print("========================================================")
# On-mesh waypoint (x=50 inside the 200-wide ground slab).
await _walk_case("on-mesh", Vector2(50.0, 0.0), "nav")
# Off-mesh waypoint (x=200 outside the slab edge at x=100).
await _walk_case("off-mesh", Vector2(200.0, 0.0), "direct")
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> void:
print("")
print("--- %s waypoint (%s) ---" % [label, target_feet])
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
var ground = stage._spawner.spawn("ground", Vector2(0, 0))
stage._rebake_navigation()
var rig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "%s: stickman spawns" % label)
if rig == null:
stage.queue_free()
await process_frame
return
_arrived_count = 0
rig.arrived.connect(_on_arrived)
rig.walk_to(target_feet, 200.0)
_check(not rig._walk_mode_latched, "%s: mode not latched before map sync" % label)
var frames := 0
while not rig._walk_done and frames < MAX_FRAMES:
await physics_frame
frames += 1
_check(rig._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_FRAMES, frames])
_check(_arrived_count == 1, "%s: exactly one arrived signal (got %d)" % [label, _arrived_count])
_check(rig._walk_mode_latched, "%s: mode is latched" % label)
_check(rig._walk_mode == expected_mode,
"%s: mode latched to '%s' (got '%s')" % [label, expected_mode, rig._walk_mode])
# Final position equals the snapped root target (feet target + FOOT_OFFSET).
var final_root: Vector2 = target_feet + RIG.FOOT_OFFSET
_check(rig.global_position.distance_to(final_root) <= 0.01,
"%s: position snapped to final root (dist=%.3f)" % [label, rig.global_position.distance_to(final_root)])
# Body-bob settle: Torso marker restored to STAND_POSE (0,10).
var torso = rig.get_node_or_null(NodePath("IK_Targets/Torso"))
if torso != null:
var stand_torso: Dictionary = RIG.STAND_POSE["Torso"]
var expected_pos: Vector2 = stand_torso.get("pos", Vector2(0, 10))
_check((torso as Node2D).position.distance_to(expected_pos) <= 1.0,
"%s: Torso marker restored to standing pose (dist=%.3f)" % [label, (torso as Node2D).position.distance_to(expected_pos)])
else:
_check(false, "%s: IK_Targets/Torso missing" % label)
# Post-arrival stability for 60 physics frames.
var p0: Vector2 = rig.global_position
var settled := true
for i in SETTLE_FRAMES:
await physics_frame
if rig.global_position != p0:
settled = false
break
_check(settled and rig.global_position == p0,
"%s: position unchanged for %d frames after arrival" % [label, SETTLE_FRAMES])
_check(not rig.is_walking(), "%s: is_walking false after arrival" % label)
stage.queue_free()
await process_frame
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://die6ipduc15sl