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
+61 -16
View File
@@ -14,6 +14,7 @@ extends RefCounted
const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
const PROP_UTILS := preload("res://scripts/prop_utils.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd")
const TRIGGER_AREA := preload("res://scripts/trigger_area.gd")
@@ -36,7 +37,9 @@ const TERRAIN_GRID_SIZE: float = 16.0
var _world: Node2D
var _registry: Array[Dictionary] = []
var _stickman_data: Dictionary = {}
var selected_stickman_path: String = DEFAULT_STICKMAN_PATH
var selected_prop_id: String = "crate"
var _stickman_cache: Dictionary = {}
# ---------------------------------------------------------------------------
# Lifecycle
@@ -44,8 +47,8 @@ var _stickman_data: Dictionary = {}
func _init(world: Node2D) -> void:
_world = world
_stickman_data = STICKMAN_FACTORY.load_stk(DEFAULT_STICKMAN_PATH)
if _stickman_data.is_empty():
_stickman_cache[DEFAULT_STICKMAN_PATH] = STICKMAN_FACTORY.load_stk(DEFAULT_STICKMAN_PATH)
if (_stickman_cache[DEFAULT_STICKMAN_PATH] as Dictionary).is_empty():
push_warning("StageSpawner: failed to load default stickman '%s'." % DEFAULT_STICKMAN_PATH)
_build_registry()
@@ -72,6 +75,47 @@ func get_spawn_offset(id: String) -> Vector2:
return entry.get("spawn_offset", Vector2.ZERO)
func get_selected_stickman_path() -> String:
return selected_stickman_path
func get_selected_prop_id() -> String:
return selected_prop_id
## True when `id` names a terrain template (ground/ramp/step).
func is_terrain_id(id: String) -> bool:
var entry := _find_entry(id)
return String(entry.get("kind", "")) == "terrain"
## Local-space AABB of a terrain template's centered polygon (before placement),
## used to size ghosts and rasterize the terrain-painting occupancy cells.
## The AABB is computed over the SAME sanitized geometry that `_spawn_terrain`
## actually places (TerrainUtils.sanitize_points at TERRAIN_GRID_SIZE), so the
## D1 paint stride matches the real block footprint (e.g. the 200px-wide Ground
## template sanitizes to a 192px footprint and therefore a 192px stride, which
## makes horizontal runs tile edge-to-edge with no gaps).
func get_template_aabb(id: String) -> Rect2:
var entry := _find_entry(id)
if entry.is_empty() or String(entry.get("kind", "")) != "terrain":
return Rect2()
var template: PackedVector2Array = entry["points"]
if template.is_empty():
return Rect2()
var center := _points_center(template)
var centered := PackedVector2Array()
for p: Vector2 in template:
centered.append(p - center)
var cleaned := TERRAIN_UTILS.sanitize_points(centered, TERRAIN_GRID_SIZE)
if cleaned.is_empty():
return Rect2()
var rect := Rect2(cleaned[0], Vector2.ZERO)
for p: Vector2 in cleaned:
rect = rect.expand(p)
return rect
## Spawn the registry type at `world_position`; returns null + push_warning on
## an unknown id.
func spawn(id: String, world_position: Vector2) -> Node2D:
@@ -167,13 +211,7 @@ func _build_registry() -> void:
"spawn_offset": Vector2.ZERO,
},
{
"id": "crate", "label": "Crate", "kind": "prop",
"payload": PROP_UTILS.create_box(), "preset": PROP_BLOCK.MaterialPreset.WOOD,
"spawn_offset": Vector2.ZERO,
},
{
"id": "ball", "label": "Ball", "kind": "prop",
"payload": PROP_UTILS.create_ball(), "preset": PROP_BLOCK.MaterialPreset.RUBBER,
"id": "prop", "label": "Prop", "kind": "prop",
"spawn_offset": Vector2.ZERO,
},
{
@@ -214,13 +252,16 @@ func _spawn_terrain(entry: Dictionary, world_position: Vector2) -> TerrainBlock:
float(entry.get("width", 2.0))
)
block.position = world_position
block.spawn_id = String(entry.get("id", ""))
return block
func _spawn_prop(entry: Dictionary, world_position: Vector2) -> PropBlock:
var payload: Dictionary = entry["payload"]
var preset: int = int(entry.get("preset", PROP_BLOCK.MaterialPreset.WOOD))
return PROP_UTILS.spawn_prop(_world, world_position, payload, preset, Vector2.ZERO)
var t: Dictionary = PROP_LIBRARY.get_entry(selected_prop_id)
if t.is_empty():
push_warning("StageSpawner: unknown selected prop '%s'." % selected_prop_id)
return null
return PROP_UTILS.spawn_prop(_world, world_position, t["payload"], int(t["material_preset"]), Vector2.ZERO)
func _spawn_area(world_position: Vector2) -> Node2D:
@@ -232,10 +273,14 @@ func _spawn_area(world_position: Vector2) -> Node2D:
func _spawn_stickman(world_position: Vector2) -> StickmanRig:
if _stickman_data.is_empty():
push_warning("StageSpawner: no stickman data loaded; check '%s'." % DEFAULT_STICKMAN_PATH)
var data: Dictionary = _stickman_cache.get(selected_stickman_path, {})
if data.is_empty():
data = STICKMAN_FACTORY.load_stk(selected_stickman_path)
_stickman_cache[selected_stickman_path] = data
if data.is_empty():
push_warning("StageSpawner: no stickman data for '%s'." % selected_stickman_path)
return null
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data(_stickman_data)
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data(data)
if rig == null:
push_warning("StageSpawner: failed to spawn stickman.")
return null