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
+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)