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)