Add Phase 4 Final Bugs Specification and Tests
- Introduced a new architecture specification for Phase 4 final bugs, detailing scope, decisions, runtime data contracts, action roles, execution lanes, speech ownership, and signal flow. - Implemented changes in `scripts/action_registry.gd`, `scripts/action_editor.gd`, `scripts/sandbox_stage.gd`, `scripts/stage_gizmos.gd`, `scripts/stickman_factory.gd`, and `scripts/stickman_rig.gd` to address identified bugs. - Created a new test suite `tests/test_phase4_final_bugs.gd` to validate the implementation, covering action roles, grid snapping, prop manipulation, reactive runtime lane, spawn pose preservation, and more. - Added a unique identifier for the test file to ensure proper tracking and management.
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
extends SceneTree
|
||||
## Focused Phase 4 Final Bugs regression checks. Run with the project console
|
||||
## executable and `--headless --script res://tests/test_phase4_final_bugs.gd`.
|
||||
|
||||
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
||||
const GIZMOS := preload("res://scripts/stage_gizmos.gd")
|
||||
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
|
||||
const ACTION_EDITOR_SCENE := preload("res://scenes/action_editor.tscn")
|
||||
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
|
||||
const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd")
|
||||
|
||||
var _checks: int = 0
|
||||
var _failures: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_test_action_roles_and_payloads()
|
||||
await _test_action_editor_reactive_option()
|
||||
_test_grid_snap_api()
|
||||
await _test_stage_prop_drag_and_rotation()
|
||||
await _test_reactive_runtime_lane()
|
||||
await _test_spawn_pose_survives_first_solve()
|
||||
await _test_left_walk_finish_pose()
|
||||
await _test_stop_clears_head_reaction()
|
||||
_test_source_contracts()
|
||||
if _failures == 0:
|
||||
print("RESULT: ALL PASSED (%d checks)" % _checks)
|
||||
quit(0)
|
||||
else:
|
||||
print("RESULT: %d failures of %d checks" % [_failures, _checks])
|
||||
quit(1)
|
||||
|
||||
|
||||
func _test_action_roles_and_payloads() -> void:
|
||||
_check(ACTION_REGISTRY.reactive_role({ "type": "walk_to" }) == ACTION_REGISTRY.ReactiveRole.SEQUENTIAL, "walk is sequential")
|
||||
_check(ACTION_REGISTRY.reactive_role({ "type": "speak", "params": {} }) == ACTION_REGISTRY.ReactiveRole.OVERLAY, "old speak rules default overlay")
|
||||
_check(ACTION_REGISTRY.reactive_role({ "type": "speak", "params": { "stop_current": true } }) == ACTION_REGISTRY.ReactiveRole.SEQUENTIAL, "promoted speak is sequential")
|
||||
_check(ACTION_REGISTRY.reactive_role({ "type": "ragdoll" }) == ACTION_REGISTRY.ReactiveRole.TAKEOVER, "ragdoll is takeover")
|
||||
var rule: Dictionary = ACTION_REGISTRY.to_rule_action({ "type": "speak", "text": "Ouch", "duration": 1.0, "stop_current": true }, 42)
|
||||
_check(bool((rule.get("params", {}) as Dictionary).get("stop_current", false)), "stop-current survives rule conversion")
|
||||
var flat: Dictionary = ACTION_REGISTRY.from_rule_action(rule)
|
||||
_check(bool(flat.get("stop_current", false)), "stop-current survives flat conversion")
|
||||
_check(ACTION_REGISTRY.summarize(rule).contains("stops current"), "summary identifies promoted speech")
|
||||
var walk_rule: Dictionary = ACTION_REGISTRY.to_rule_action({ "type": "walk_to", "target": Vector2(123.0, -45.0) }, 42)
|
||||
_check(ACTION_REGISTRY.summarize(walk_rule) == "Walk To (123, -45)",
|
||||
"rule walk summary uses params.target instead of the actor instance id")
|
||||
|
||||
|
||||
func _test_grid_snap_api() -> void:
|
||||
var gizmos := GIZMOS.new()
|
||||
gizmos.set_snap_size(-4.0)
|
||||
_check(is_zero_approx(gizmos.snap_size), "negative snap size clamps to zero")
|
||||
gizmos.set_snap_size(35.0)
|
||||
_check(is_equal_approx(gizmos.snap_size, 35.0), "gizmo stores stage grid snap size")
|
||||
var target := Node2D.new()
|
||||
root.add_child(target)
|
||||
var targets: Array[Node2D] = [target]
|
||||
gizmos.set_targets(targets)
|
||||
gizmos.begin_translate_drag(target, Vector2.ZERO)
|
||||
gizmos.drag_to(Vector2(5.0, 5.0))
|
||||
gizmos.drag_to(Vector2(10.0, 10.0))
|
||||
gizmos.drag_to(Vector2(15.0, 15.0))
|
||||
_check(target.global_position == Vector2.ZERO, "sub-grid drag events accumulate without premature movement")
|
||||
gizmos.drag_to(Vector2(20.0, 20.0))
|
||||
_check(target.global_position == Vector2(35.0, 35.0), "translation snap uses configured grid, not object bounds")
|
||||
target.free()
|
||||
gizmos.free()
|
||||
|
||||
|
||||
func _test_stage_prop_drag_and_rotation() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var stage := STAGE_SCENE.instantiate()
|
||||
root.add_child(stage)
|
||||
await process_frame
|
||||
var prop := stage._spawner.spawn("prop", stage._camera.get_screen_center_position()) as PropBlock
|
||||
_check(prop != null, "stage spawns an editable prop")
|
||||
if prop == null:
|
||||
stage.free()
|
||||
return
|
||||
stage._finalize_placed_node(prop)
|
||||
await physics_frame
|
||||
stage._gizmos.set_snap_size(0.0)
|
||||
var canvas: Transform2D = stage.get_viewport().get_canvas_transform()
|
||||
var press := InputEventMouseButton.new()
|
||||
press.button_index = MOUSE_BUTTON_LEFT
|
||||
press.pressed = true
|
||||
press.position = canvas * prop.global_position
|
||||
var hover_motion := InputEventMouseMotion.new()
|
||||
hover_motion.position = press.position
|
||||
Input.parse_input_event(hover_motion)
|
||||
await process_frame
|
||||
Input.parse_input_event(press)
|
||||
await process_frame
|
||||
_check(stage._gizmos.is_dragging(), "ordinary prop click begins a translate drag")
|
||||
var translated := prop.global_position + Vector2(48.0, 27.0)
|
||||
var motion := InputEventMouseMotion.new()
|
||||
motion.position = canvas * translated
|
||||
motion.relative = motion.position - press.position
|
||||
stage._input(motion)
|
||||
var release := InputEventMouseButton.new()
|
||||
release.button_index = MOUSE_BUTTON_LEFT
|
||||
release.pressed = false
|
||||
release.position = motion.position
|
||||
stage._handle_world_click(release)
|
||||
_check(prop.global_position.is_equal_approx(translated), "prop follows the translate gizmo immediately")
|
||||
var saved_after_drag: Dictionary = stage._authored[prop.get_instance_id()]
|
||||
_check((saved_after_drag["position"] as Vector2).is_equal_approx(translated), "drag commit updates authored prop position")
|
||||
_check(stage._restore_frames_left == 0, "drag start cancels deferred authored restoration")
|
||||
await physics_frame
|
||||
await process_frame
|
||||
_check(prop.global_position.is_equal_approx(translated), "frozen prop keeps its dragged physics transform (got %s expected %s)" % [prop.global_position, translated])
|
||||
|
||||
var rect: Rect2 = STAGE_SELECTION.get_world_aabb(prop)
|
||||
var center: Vector2 = rect.get_center()
|
||||
var radius: float = stage._gizmos._rotate_ring_radius(rect)
|
||||
press.position = canvas * (center + Vector2(radius, 0.0))
|
||||
stage._handle_world_click(press)
|
||||
_check(stage._gizmos.is_dragging(), "prop rotation ring begins a rotate drag")
|
||||
motion.position = canvas * (center + Vector2(0.0, radius))
|
||||
motion.relative = motion.position - press.position
|
||||
stage._input(motion)
|
||||
release.position = motion.position
|
||||
stage._handle_world_click(release)
|
||||
_check(absf(angle_difference(prop.global_rotation, PI * 0.5)) < 0.01, "prop follows the rotation gizmo")
|
||||
await physics_frame
|
||||
await process_frame
|
||||
_check(absf(angle_difference(prop.global_rotation, PI * 0.5)) < 0.01, "frozen prop keeps its rotated physics transform")
|
||||
stage.free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _test_action_editor_reactive_option() -> void:
|
||||
var editor: ActionEditor = ACTION_EDITOR_SCENE.instantiate()
|
||||
root.add_child(editor)
|
||||
await process_frame
|
||||
editor.open_new(false)
|
||||
editor._select_type("speak")
|
||||
editor._rebuild_params("speak")
|
||||
_check(editor._stop_current_check == null, "queue speak has no stop-current checkbox")
|
||||
editor.open_edit({ "type": "speak", "text": "Ouch", "duration": 1.0, "stop_current": true }, true)
|
||||
_check(editor._stop_current_check != null, "rule speak shows stop-current checkbox")
|
||||
_check(editor._stop_current_check.button_pressed, "rule speak checkbox pre-fills saved value")
|
||||
editor.free()
|
||||
|
||||
|
||||
func _test_reactive_runtime_lane() -> void:
|
||||
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data({})
|
||||
root.add_child(rig)
|
||||
await process_frame
|
||||
|
||||
rig.action_queue = [
|
||||
{ "type": "wait", "duration": 10.0 },
|
||||
{ "type": "wait", "duration": 20.0 },
|
||||
]
|
||||
var finished: Array[Dictionary] = []
|
||||
rig.action_finished.connect(func(action: Dictionary, index: int):
|
||||
finished.append({ "action": action, "index": index })
|
||||
)
|
||||
rig.start_queue()
|
||||
rig._update_runner(0.0)
|
||||
_check(rig._action_phase == rig.ActionPhase.WAITING, "authored primary wait starts")
|
||||
|
||||
rig.enqueue_reactive([{ "type": "speak", "text": "One", "duration": 5.0 }])
|
||||
_check(rig._action_phase == rig.ActionPhase.WAITING, "overlay speak leaves primary wait running")
|
||||
_check(String(rig._overlay_speech.get("text", "")) == "One", "overlay speak occupies the single overlay slot")
|
||||
rig.enqueue_reactive([{ "type": "speak", "text": "Two", "duration": 5.0 }])
|
||||
_check(String(rig._overlay_speech.get("text", "")) == "Two", "new overlay speak replaces the previous overlay")
|
||||
_check(finished.is_empty(), "overlay replacement emits no primary completion")
|
||||
|
||||
rig.enqueue_reactive([{ "type": "wait", "duration": 1.0 }])
|
||||
_check(rig._action_phase == rig.ActionPhase.NONE, "sequential reactive action cancels the active primary")
|
||||
_check(finished.is_empty(), "sequential interruption emits no false action_finished")
|
||||
_check(rig._run_queue.size() == 2, "sequential interrupt retains only injected plus authored future")
|
||||
_check(String((rig._run_queue[0]["action"] as Dictionary).get("type", "")) == "wait"
|
||||
and bool(rig._run_queue[0].get("reactive", false)), "injected sequential action leads the runtime lane")
|
||||
_check(int(rig._run_queue[1].get("queue_index", -1)) == 1,
|
||||
"canceled authored action is abandoned and the next authored item is retained")
|
||||
|
||||
# A takeover must discard the canceled/current item and any pending reactive
|
||||
# primary work, while retaining only the authored future.
|
||||
rig.stop_queue()
|
||||
rig.action_queue = [
|
||||
{ "type": "wait", "duration": 10.0 },
|
||||
{ "type": "wait", "duration": 20.0 },
|
||||
]
|
||||
rig.start_queue()
|
||||
rig._update_runner(0.0)
|
||||
rig.enqueue_reactive([
|
||||
{ "type": "wait", "duration": 1.0 },
|
||||
{ "type": "ragdoll" },
|
||||
])
|
||||
_check(rig._run_queue.size() == 2, "takeover retains itself plus authored future only")
|
||||
_check(String((rig._run_queue[0]["action"] as Dictionary).get("type", "")) == "ragdoll",
|
||||
"takeover supersedes earlier same-batch primary work")
|
||||
_check(int(rig._run_queue[1].get("queue_index", -1)) == 1,
|
||||
"takeover does not replay the completed or canceled authored item")
|
||||
_check(rig._overlay_speech.is_empty(), "takeover cancels the active overlay")
|
||||
_check(rig.action_queue.size() == 2, "reactive work never mutates the authored queue")
|
||||
rig.free()
|
||||
|
||||
|
||||
func _test_spawn_pose_survives_first_solve() -> void:
|
||||
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data({})
|
||||
var skeleton := rig.get_node("Skeleton2D") as Skeleton2D
|
||||
var bone_paths: Array[String] = [
|
||||
"Torso",
|
||||
"Torso/Head",
|
||||
"Torso/LeftUpperArm/LeftLowerArm",
|
||||
"Torso/RightUpperArm/RightLowerArm",
|
||||
"Torso/LeftUpperLeg/LeftLowerLeg",
|
||||
"Torso/RightUpperLeg/RightLowerLeg",
|
||||
]
|
||||
var before: Dictionary = {}
|
||||
for path: String in bone_paths:
|
||||
var bone := skeleton.get_node(path) as Bone2D
|
||||
before[path] = { "pos": bone.global_position, "rot": bone.global_rotation }
|
||||
root.add_child(rig)
|
||||
await process_frame
|
||||
await physics_frame
|
||||
await physics_frame
|
||||
for path: String in bone_paths:
|
||||
var solved := skeleton.get_node(path) as Bone2D
|
||||
var expected: Dictionary = before[path]
|
||||
var position_delta: float = solved.global_position.distance_to(expected["pos"])
|
||||
var rotation_delta: float = absf(angle_difference(solved.global_rotation, float(expected["rot"])))
|
||||
_check(position_delta <= 1.0,
|
||||
"spawn solve preserves ghost joint position: %s (delta %.3f)" % [path, position_delta])
|
||||
_check(rotation_delta <= 0.02,
|
||||
"spawn solve preserves ghost bone rotation: %s (delta %.4f)" % [path, rotation_delta])
|
||||
_check(rig._spawn_pose.size() == rig.IK_TARGET_PATHS.size(), "factory captures all six spawn-pose markers")
|
||||
rig.free()
|
||||
|
||||
|
||||
func _test_left_walk_finish_pose() -> void:
|
||||
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data({})
|
||||
root.add_child(rig)
|
||||
await process_frame
|
||||
rig.set_facing_profile(rig.FacingProfile.LEFT)
|
||||
rig._walking = true
|
||||
rig._set_limb_mods_enabled(true)
|
||||
var stopped_frame: Dictionary = {}
|
||||
for joint: String in rig._limb_bones:
|
||||
var bones: Dictionary = rig._limb_bones[joint]
|
||||
(bones["upper"] as Bone2D).rotation += 0.7
|
||||
(bones["lower"] as Bone2D).rotation -= 0.9
|
||||
stopped_frame[joint] = {
|
||||
"upper": (bones["upper"] as Bone2D).rotation,
|
||||
"lower": (bones["lower"] as Bone2D).rotation,
|
||||
}
|
||||
var hand_marker := rig.get_node("IK_Targets/Left_Hand") as Marker2D
|
||||
var stopped_marker_position := hand_marker.position + Vector2(17.0, -23.0)
|
||||
hand_marker.position = stopped_marker_position
|
||||
rig._finish_walk("regression")
|
||||
_check(hand_marker.position.is_equal_approx(stopped_marker_position),
|
||||
"walk finish preserves the current animation marker frame")
|
||||
for joint: String in stopped_frame:
|
||||
var bones: Dictionary = rig._limb_bones[joint]
|
||||
var expected: Dictionary = stopped_frame[joint]
|
||||
_check(absf(angle_difference((bones["upper"] as Bone2D).rotation, float(expected["upper"]))) < 0.001,
|
||||
"LEFT walk finish preserves current upper-limb frame: %s" % joint)
|
||||
_check(absf(angle_difference((bones["lower"] as Bone2D).rotation, float(expected["lower"]))) < 0.001,
|
||||
"LEFT walk finish preserves current lower-limb frame: %s" % joint)
|
||||
for modification: SkeletonModification2DTwoBoneIK in rig._bend_modifications.values():
|
||||
_check(not modification.enabled, "LEFT idle disables mirrored limb solver")
|
||||
var left_arm: Dictionary = rig._limb_bones["LeftArm"]
|
||||
var live_upper := left_arm["upper"] as Bone2D
|
||||
var live_rotation: float = live_upper.rotation + 0.33
|
||||
live_upper.rotation = live_rotation
|
||||
rig._walking = true
|
||||
rig._set_limb_mods_enabled(true)
|
||||
rig._cancel_walking(false)
|
||||
_check(absf(angle_difference(live_upper.rotation, live_rotation)) < 0.001,
|
||||
"ragdoll-style walk cancellation preserves the current solved limb pose")
|
||||
_check((rig._bend_modifications["LeftArm"] as SkeletonModification2DTwoBoneIK).enabled,
|
||||
"ragdoll-style walk cancellation keeps IK live for pose capture")
|
||||
rig.free()
|
||||
|
||||
|
||||
func _test_stop_clears_head_reaction() -> void:
|
||||
var stage := STAGE_SCENE.instantiate()
|
||||
root.add_child(stage)
|
||||
await process_frame
|
||||
var rig := stage._spawner.spawn("stickman", Vector2.ZERO) as StickmanRig
|
||||
stage._finalize_placed_node(rig)
|
||||
var head := rig._reaction_bones.get("Head") as Bone2D
|
||||
var base_rotation: float = head.rotation
|
||||
rig.apply_collision_reaction("head", Vector2.DOWN, 500.0)
|
||||
rig._apply_impact_reactions(0.01)
|
||||
_check(not rig._impact_reactions.is_empty(), "head collision starts a flinch reaction")
|
||||
stage.set_mode(stage.StageMode.PLAY)
|
||||
stage.set_mode(stage.StageMode.EDIT)
|
||||
_check(rig._impact_reactions.is_empty(), "STOP clears the active head reaction")
|
||||
_check(absf(angle_difference(head.rotation, base_rotation)) < 0.001, "STOP restores the head without a recovery tween")
|
||||
var stale_prop := PropBlock.new()
|
||||
var stale_proxy := AnimatableBody2D.new()
|
||||
stale_proxy.set_meta("stickman_rig", rig)
|
||||
stale_proxy.set_meta("part_name", "head")
|
||||
stage._on_prop_collided(stale_proxy, Vector2.DOWN, Vector2.ZERO, stale_prop)
|
||||
_check(rig._impact_reactions.is_empty(), "post-STOP collision callback cannot recreate the flinch")
|
||||
stale_prop.free()
|
||||
stale_proxy.free()
|
||||
stage.free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _test_source_contracts() -> void:
|
||||
var stage_source: String = FileAccess.get_file_as_string("res://scripts/sandbox_stage.gd")
|
||||
var rig_source: String = FileAccess.get_file_as_string("res://scripts/stickman_rig.gd")
|
||||
_check(stage_source.contains("TERRAIN_DRAG_THRESHOLD_PX"), "terrain uses a drag threshold")
|
||||
_check(stage_source.contains("PhysicsServer2D.body_set_state"), "frozen prop commits synchronize physics")
|
||||
_check(stage_source.contains("transform_started.connect"), "gizmo start cancels deferred restoration")
|
||||
_check(stage_source.contains("if current_mode != StageMode.PLAY:"), "collision callbacks are gated to PLAY mode")
|
||||
_check(stage_source.contains("_speak_stop_current_check") and stage_source.contains("\"stop_current\": _speak_stop_current_check.button_pressed"), "legacy rule speak persists stop-current")
|
||||
_check(rig_source.contains("_primary_speech") and rig_source.contains("_overlay_speech"), "rig separates primary and overlay speech")
|
||||
_check(rig_source.contains("_sync_speech_bubble_mirror"), "rig counter-mirrors LEFT speech")
|
||||
_check(rig_source.contains("initialize_spawn_pose_from_skeleton"), "rig captures factory spawn pose")
|
||||
_check(rig_source.contains("bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))"), "spawn endpoints include Bone2D bone_angle")
|
||||
_check(rig_source.contains("_stand_up_generation") and rig_source.contains("_on_stand_up_finished.bind(generation)"), "stale recovery tween callbacks are generation-guarded")
|
||||
|
||||
|
||||
func _check(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print("PASS: %s" % label)
|
||||
else:
|
||||
_failures += 1
|
||||
push_error("FAIL: %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctl5jpr1dvjep
|
||||
Reference in New Issue
Block a user