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,446 @@
|
||||
# test_phase4b2_fixes.gd
|
||||
# Headless regression suite for the Phase 4b.2 fix pass (four Sandbox Stage
|
||||
# Builder bugs):
|
||||
#
|
||||
# Bug 1 (stale yellow hover box after Delete): delete_selected() now calls
|
||||
# StageSelection.clear_hover() (new) after clear_selection() so the gizmo
|
||||
# layer drops the stale highlight, and StageGizmos._draw() skips any hovered
|
||||
# node that is queued for deletion.
|
||||
# * delete_selected() leaves the selection's hovered node null and emits
|
||||
# hover_changed(null), which the stage forwards to StageGizmos.set_hover
|
||||
# (gizmo _hovered becomes null).
|
||||
# * clear_hover() with no hover is a silent no-op (no signal emission).
|
||||
# * StageGizmos._draw() source contains the is_queued_for_deletion() guard
|
||||
# (draw output is not observable headless, so the guard is asserted as a
|
||||
# source-presence check, matching the repo's established pattern).
|
||||
#
|
||||
# Bug 2 ('Back to actions' dead-end): _on_trigger_popup_id_pressed(TRIG_BACK)
|
||||
# now _reset_rule_builder()s and re-opens the action popup (at the recorded
|
||||
# session anchor) when _context_rig is valid, instead of cancel-hiding
|
||||
# everything.
|
||||
# * After Back the rule-builder state is IDLE/empty AND _action_popup is
|
||||
# visible again (PopupMenu.visible IS observable headless).
|
||||
#
|
||||
# Bug 3 (head mirror jitter): master_rig.tscn's Head LookAt modification is a
|
||||
# full-range (-180..180), non-inverted, non-local-space constraint.
|
||||
# * Constraint flags/band are asserted directly on the instantiated rig.
|
||||
# * The Head marker is settled at the walk-left pose (-100,-614), then
|
||||
# snapped to STAND_POSE (100,-614) (the snap_to_standing() scenario on
|
||||
# PLAY/EDIT exit). With the FIXED constraint the head bone + Body/Head
|
||||
# visual rotation show zero frame-to-frame motion; the OLD (invert +
|
||||
# localspace + 55..305) constraint exhibits a ~0.8 rad one-frame flip
|
||||
# (this suite's regression discriminator).
|
||||
#
|
||||
# Bug 4 (reactive badge accumulation): StickmanRig.enqueue_reactive() tags
|
||||
# injected actions reactive=true, and the new clear_reactive_actions()
|
||||
# (no-op while EXECUTING) drops them back to the authored queue.
|
||||
# * One enqueue+clear cycle restores the authored queue exactly.
|
||||
# * Repeated cycles never accumulate (stable counts, no 1,2,3 build-up).
|
||||
# * clear_reactive_actions() is a no-op while the runner is EXECUTING.
|
||||
# * queue_changed is emitted on every actual queue mutation.
|
||||
#
|
||||
# Run with (either console):
|
||||
# & "C:\Godot4\Godot_v4.4-stable_win64_console.exe" --headless --script res://tests/test_phase4b2_fixes.gd --path .
|
||||
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b2_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 RIG := preload("res://scripts/stickman_rig.gd")
|
||||
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
|
||||
|
||||
# Physics frames / thresholds for the Bug 3 head-jitter measurement.
|
||||
const WARMUP_FRAMES := 30
|
||||
const MEASURE_FRAMES := 60
|
||||
## A single-frame rotation step above this (radians) is treated as a mirror
|
||||
## flip / oscillation event. The fixed constraint produces 0.0; the old
|
||||
## constraint produced ~0.8 rad on the STAND_POSE snap.
|
||||
const FLIP_THRESHOLD_RAD := 0.5
|
||||
## Generous ceiling used by the "standing still" assertion; fixed = 0.0.
|
||||
const STILL_MAX_STEP_RAD := 0.25
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("")
|
||||
print("========================================================")
|
||||
print(" PHASE 4b.2 FIX REGRESSION TEST (headless)")
|
||||
print("========================================================")
|
||||
|
||||
await _test_bug1_delete_clears_hover()
|
||||
_test_bug1_gizmo_draw_guard_source()
|
||||
|
||||
_test_bug2_back_to_actions()
|
||||
|
||||
await _test_bug3_head_lookat()
|
||||
|
||||
await _test_bug4_reactive_actions()
|
||||
|
||||
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: stale yellow hover box after Delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _test_bug1_delete_clears_hover() -> void:
|
||||
print("")
|
||||
print("--- Bug 1: delete_selected clears the stale hover highlight ---")
|
||||
var stage := _new_stage()
|
||||
var world: Node2D = stage._world
|
||||
var obj: Node2D = stage._spawner.spawn("ground", Vector2(0, 0))
|
||||
_check(obj != null, "ground block spawns for hover/delete test")
|
||||
_check(world.get_children().has(obj), "spawned block is a direct World child")
|
||||
|
||||
# Put the selection AND the hover on the same block, exactly like hovering a
|
||||
# selected object and pressing Delete.
|
||||
stage._selection.select_only(obj)
|
||||
stage._selection.update_hover(STAGE_SELECTION.get_world_aabb(obj).get_center())
|
||||
_check(stage._selection._hovered == obj,
|
||||
"update_hover targets the spawned block (hover set)")
|
||||
_check(stage._gizmos._hovered == obj,
|
||||
"gizmo layer mirrors the hover via hover_changed -> set_hover")
|
||||
|
||||
# Watch the hover signal during the delete.
|
||||
var hover_payloads: Array = []
|
||||
stage._selection.hover_changed.connect(func(n): hover_payloads.append(n))
|
||||
|
||||
stage.delete_selected()
|
||||
_check(stage._selection._hovered == null,
|
||||
"selection hover is null after delete_selected (no stale hover)")
|
||||
_check(stage._gizmos._hovered == null,
|
||||
"gizmo hover target cleared after delete_selected (yellow box disappears)")
|
||||
_check(stage._selection.get_selected().is_empty(),
|
||||
"selection is empty after delete_selected")
|
||||
_check(not hover_payloads.is_empty() and hover_payloads[-1] == null,
|
||||
"clear_hover emitted hover_changed(null) during delete")
|
||||
|
||||
# clear_hover() with nothing hovered must be a silent no-op.
|
||||
var noop_payloads: Array = []
|
||||
stage._selection.hover_changed.connect(func(n): noop_payloads.append(n))
|
||||
stage._selection.clear_hover()
|
||||
_check(noop_payloads.is_empty(),
|
||||
"clear_hover with no hover emits nothing (idempotent)")
|
||||
|
||||
await _free_stage(stage)
|
||||
|
||||
|
||||
func _test_bug1_gizmo_draw_guard_source() -> void:
|
||||
print("")
|
||||
print("--- Bug 1: StageGizmos._draw() skips queued-for-deletion hovers ---")
|
||||
# Drawing output is not observable headless, so assert the guard that makes
|
||||
# a deleted-but-still-referenced hover node disappear from the draw pass.
|
||||
# This mirrors the source-presence checks used elsewhere in the repo.
|
||||
var file := FileAccess.open("res://scripts/stage_gizmos.gd", FileAccess.READ)
|
||||
_check(file != null, "stage_gizmos.gd is readable")
|
||||
if file != null:
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
_check(_func_body_contains(text, "_draw", "is_queued_for_deletion()"),
|
||||
"StageGizmos._draw() body contains the is_queued_for_deletion() hover guard")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 2: 'Back to actions' must return to the action popup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _test_bug2_back_to_actions() -> void:
|
||||
print("")
|
||||
print("--- Bug 2: trigger-popup Back resets builder and re-opens actions ---")
|
||||
var stage := _new_stage()
|
||||
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
||||
_check(rig != null, "stickman spawns for the popup flow")
|
||||
if rig == null:
|
||||
await _free_stage(stage)
|
||||
return
|
||||
stage._context_rig = rig
|
||||
|
||||
# "⚡ When..." from the action popup opens the trigger sub-menu and arms the
|
||||
# rule builder (RuleStep.SELECT_TRIGGER = 1).
|
||||
stage._on_action_popup_id_pressed(stage.ACT_WHEN)
|
||||
_check(int(stage._rule_step) == 1,
|
||||
"After When... the builder sits at SELECT_TRIGGER (got %d)" % int(stage._rule_step))
|
||||
_check(not stage._rule_builder.is_empty() and stage._rule_builder.has("trigger"),
|
||||
"After When... a trigger shell exists in the rule builder")
|
||||
_check(stage._rule_context_rig == rig,
|
||||
"rule context rig mirrors _context_rig")
|
||||
_check(stage._trigger_popup.visible,
|
||||
"trigger popup is open after When...")
|
||||
|
||||
# Press '⬅ Back to actions' (TRIG_BACK = 5).
|
||||
stage._on_trigger_popup_id_pressed(stage.TRIG_BACK)
|
||||
_check(int(stage._rule_step) == 0,
|
||||
"Back resets the rule builder to IDLE (got %d)" % int(stage._rule_step))
|
||||
_check(stage._rule_builder.is_empty(),
|
||||
"Back empties the rule builder dict")
|
||||
_check(stage._rule_context_rig == null,
|
||||
"Back clears the rule context rig")
|
||||
_check(stage._rule_hint.is_empty(),
|
||||
"Back clears the rule hint text")
|
||||
_check(stage._action_popup.visible,
|
||||
"Back re-opens the ACTION popup (no dead-end)")
|
||||
_check(stage._context_rig == rig,
|
||||
"Back keeps _context_rig so further actions target the same stickman")
|
||||
|
||||
# When the context rig is gone, Back must not crash and must not re-open the
|
||||
# action popup over nothing.
|
||||
stage._context_rig = null
|
||||
stage._action_popup.hide()
|
||||
stage._on_trigger_popup_id_pressed(stage.TRIG_BACK)
|
||||
_check(stage._action_popup.visible == false,
|
||||
"Back without a valid context rig leaves the action popup closed")
|
||||
_check(int(stage._rule_step) == 0,
|
||||
"Back without a valid context rig still resets the builder")
|
||||
|
||||
await _free_stage(stage)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 3: head LookAt mirror jitter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _test_bug3_head_lookat() -> void:
|
||||
print("")
|
||||
print("--- Bug 3: Head LookAt full-range constraint + no mirror jitter ---")
|
||||
var stage := _new_stage()
|
||||
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
||||
_check(rig != null, "stickman rig spawns for the head test")
|
||||
if rig == null:
|
||||
await _free_stage(stage)
|
||||
return
|
||||
|
||||
var skeleton := rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
|
||||
var stack: SkeletonModificationStack2D = skeleton.modification_stack
|
||||
_check(stack != null, "rig skeleton carries a modification stack")
|
||||
_check(stack != null and stack.enabled,
|
||||
"rig modification stack is enabled after _ready")
|
||||
|
||||
var look_at: SkeletonModification2DLookAt = null
|
||||
if stack != null:
|
||||
for i: int in stack.modification_count:
|
||||
var mod = stack.get_modification(i)
|
||||
if mod is SkeletonModification2DLookAt:
|
||||
look_at = mod as SkeletonModification2DLookAt
|
||||
break
|
||||
_check(look_at != null, "Head SkeletonModification2DLookAt exists in the stack")
|
||||
if look_at == null:
|
||||
await _free_stage(stage)
|
||||
return
|
||||
|
||||
# 1) The scene fix: full-range, non-inverted, non-local-space constraint.
|
||||
_check(is_equal_approx(look_at.constraint_angle_min, -180.0),
|
||||
"LookAt constraint_angle_min is -180 (got %.3f)" % look_at.constraint_angle_min)
|
||||
_check(is_equal_approx(look_at.constraint_angle_max, 180.0),
|
||||
"LookAt constraint_angle_max is 180 (got %.3f)" % look_at.constraint_angle_max)
|
||||
_check(not look_at.constraint_angle_invert,
|
||||
"LookAt constraint_angle_invert is false")
|
||||
_check(not look_at.constraint_in_localspace,
|
||||
"LookAt constraint_in_localspace is false")
|
||||
_check(look_at.constraint_angle_max - look_at.constraint_angle_min >= 359.0,
|
||||
"LookAt constraint spans a full 360-degree band (got %.1f deg)"
|
||||
% (look_at.constraint_angle_max - look_at.constraint_angle_min))
|
||||
|
||||
var head_bone := skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
|
||||
var head_body := rig.get_node_or_null(NodePath("Body/Head")) as Node2D
|
||||
var head_target := rig.get_node_or_null(NodePath("IK_Targets/Head")) as Marker2D
|
||||
_check(head_bone != null and head_body != null and head_target != null,
|
||||
"head bone / Body/Head visual / Head aim marker all resolve")
|
||||
|
||||
# 2) Standing-still check at STAND_POSE: no frame-to-frame motion at all.
|
||||
var stand_pos: Vector2 = (RIG.STAND_POSE["Head"] as Dictionary)["pos"]
|
||||
head_target.position = stand_pos
|
||||
for i: int in WARMUP_FRAMES:
|
||||
await physics_frame
|
||||
var still := await _measure_head(head_bone, head_body, MEASURE_FRAMES)
|
||||
_check(still["flips"] == 0,
|
||||
"STAND_POSE: no oscillation events over %d frames" % MEASURE_FRAMES)
|
||||
_check(still["max_step"] <= STILL_MAX_STEP_RAD,
|
||||
"STAND_POSE: per-frame head motion <= %.2f rad (max %.4f)"
|
||||
% [STILL_MAX_STEP_RAD, still["max_step"]])
|
||||
|
||||
# 3) PLAY/EDIT exit discriminator: settle on the walk-LEFT pose, then snap
|
||||
# the aim marker back to STAND_POSE (what snap_to_standing() does). The
|
||||
# fixed constraint stays still; the old 55..305/invert/localspace constraint
|
||||
# jumped ~0.8 rad in a single frame (the mirror-jitter regression).
|
||||
head_target.position = Vector2(-100.0, -614.0) # walk-left aim pose
|
||||
for i: int in WARMUP_FRAMES:
|
||||
await physics_frame
|
||||
head_target.position = stand_pos
|
||||
var snap := await _measure_head(head_bone, head_body, MEASURE_FRAMES)
|
||||
_check(snap["flips"] == 0,
|
||||
"STAND_POSE snap: no mirror-flip frame (got %d > threshold)" % snap["flips"])
|
||||
_check(snap["max_step"] <= FLIP_THRESHOLD_RAD,
|
||||
"STAND_POSE snap: max one-frame rotation %.3f rad stays under the %.2f rad flip threshold"
|
||||
% [snap["max_step"], FLIP_THRESHOLD_RAD])
|
||||
|
||||
await _free_stage(stage)
|
||||
|
||||
|
||||
## Awaits `frames` physics frames, tracking the head bone + Body/Head visual
|
||||
## rotation. Returns {flips, max_step}: `flips` counts frames whose wrapped
|
||||
## step exceeds FLIP_THRESHOLD_RAD; `max_step` is the largest wrapped step.
|
||||
func _measure_head(head_bone: Bone2D, head_body: Node2D, frames: int) -> Dictionary:
|
||||
var flips := 0
|
||||
var max_step := 0.0
|
||||
var prev_bone := head_bone.global_rotation
|
||||
var prev_body := head_body.global_rotation
|
||||
for i: int in frames:
|
||||
await physics_frame
|
||||
var step := absf(wrapf(head_bone.global_rotation - prev_bone, -PI, PI))
|
||||
var body_step := absf(wrapf(head_body.global_rotation - prev_body, -PI, PI))
|
||||
max_step = maxf(max_step, maxf(step, body_step))
|
||||
if step > FLIP_THRESHOLD_RAD or body_step > FLIP_THRESHOLD_RAD:
|
||||
flips += 1
|
||||
prev_bone = head_bone.global_rotation
|
||||
prev_body = head_body.global_rotation
|
||||
return { "flips": flips, "max_step": max_step }
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 4: reactive badge accumulation across Play sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _test_bug4_reactive_actions() -> void:
|
||||
print("")
|
||||
print("--- Bug 4: enqueue_reactive tags + clear_reactive_actions restores ---")
|
||||
var stage := _new_stage()
|
||||
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
||||
_check(rig != null, "stickman rig spawns for the queue test")
|
||||
if rig == null:
|
||||
await _free_stage(stage)
|
||||
return
|
||||
|
||||
# Authored sequential queue (what the director builds in EDIT).
|
||||
rig.queue_action({ "type": "speak", "text": "authored-a", "duration": 2.0 })
|
||||
rig.queue_action({ "type": "wait", "duration": 1.0 })
|
||||
_check(rig.queue_size() == 2, "authored queue holds 2 actions")
|
||||
|
||||
# Cycle 1: a reactive action is injected (rule firing during PLAY), which
|
||||
# auto-starts the runner; leaving PLAY stops the queue first, then
|
||||
# clear_reactive_actions() (the exact _enter_edit_shared() ordering).
|
||||
var qc1: Array = []
|
||||
rig.queue_changed.connect(func(): qc1.append(true))
|
||||
var r1: Array[Dictionary] = [{ "type": "speak", "text": "reactive-1", "duration": 1.0 }]
|
||||
rig.enqueue_reactive(r1)
|
||||
_check(rig.queue_size() == 3,
|
||||
"reactive injection grows the queue to 3 (got %d)" % rig.queue_size())
|
||||
_check(_count_reactive(rig) == 1,
|
||||
"injected action is tagged reactive=true")
|
||||
_check(rig.is_queue_running(),
|
||||
"enqueue_reactive auto-resumes the runner from IDLE")
|
||||
_check(qc1.size() == 1, "enqueue_reactive emits queue_changed")
|
||||
|
||||
rig.stop_queue()
|
||||
rig.clear_reactive_actions()
|
||||
_check(rig.queue_size() == 2,
|
||||
"stop + clear restores the authored queue (2, got %d)" % rig.queue_size())
|
||||
_check(_action_types(rig) == ["speak", "wait"],
|
||||
"queue types after clear are exactly the authored actions (got %s)"
|
||||
% str(_action_types(rig)))
|
||||
_check(qc1.size() == 2, "clear_reactive_actions emits queue_changed on change")
|
||||
|
||||
# Cycle 2: a bigger reactive burst then stop + clear again - counts must
|
||||
# stay stable (no 1,2,3 accumulation across repeated Play sessions).
|
||||
var r2: Array[Dictionary] = [
|
||||
{ "type": "wait", "duration": 0.5 },
|
||||
{ "type": "ragdoll" },
|
||||
]
|
||||
rig.enqueue_reactive(r2)
|
||||
_check(rig.queue_size() == 4,
|
||||
"second injection grows to 4 (got %d)" % rig.queue_size())
|
||||
_check(_count_reactive(rig) == 2,
|
||||
"second burst tags both actions reactive")
|
||||
rig.stop_queue()
|
||||
rig.clear_reactive_actions()
|
||||
_check(rig.queue_size() == 2,
|
||||
"second stop + clear restores 2 authored actions - no accumulation (got %d)" % rig.queue_size())
|
||||
_check(_action_types(rig) == ["speak", "wait"],
|
||||
"queue types stay stable across two reactive cycles (got %s)"
|
||||
% str(_action_types(rig)))
|
||||
|
||||
# clear_reactive_actions() must be a no-op while the runner is executing.
|
||||
rig.start_queue()
|
||||
_check(rig.is_queue_running(), "runner is EXECUTING after start_queue")
|
||||
var r3: Array[Dictionary] = [{ "type": "recover" }]
|
||||
rig.enqueue_reactive(r3)
|
||||
_check(rig.queue_size() == 3,
|
||||
"reactive action can still append while the runner is executing")
|
||||
var qc3: Array = []
|
||||
rig.queue_changed.connect(func(): qc3.append(true))
|
||||
rig.clear_reactive_actions()
|
||||
_check(rig.queue_size() == 3 and _count_reactive(rig) == 1,
|
||||
"clear_reactive_actions is a no-op while EXECUTING (reactive action stays)")
|
||||
_check(qc3.is_empty(),
|
||||
"no queue_changed emitted by the EXECUTING no-op")
|
||||
rig.stop_queue()
|
||||
_check(not rig.is_queue_running(), "runner returns to IDLE after stop_queue")
|
||||
rig.clear_reactive_actions()
|
||||
_check(rig.queue_size() == 2 and _count_reactive(rig) == 0,
|
||||
"after stop, clear drops the injected reactive action (queue 2)")
|
||||
|
||||
await _free_stage(stage)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _count_reactive(rig: StickmanRig) -> int:
|
||||
var n := 0
|
||||
for a: Dictionary in rig.get_queue():
|
||||
if bool(a.get("reactive", false)):
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
func _action_types(rig: StickmanRig) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
for a: Dictionary in rig.get_queue():
|
||||
out.append(str(a.get("type", "")))
|
||||
return out
|
||||
|
||||
|
||||
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 _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