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