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:
@@ -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)
|
||||
Reference in New Issue
Block a user