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