Files
stickman/tests/test_phase4b2_fixes.gd
T
ryan 1f91f3d2e5 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.
2026-09-04 15:08:08 -04:00

447 lines
18 KiB
GDScript

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