Add regression tests for prop attachment, centering, and editor UX
- Implemented test_phase9a_prop_attach.gd to validate anchor schema, alignment math, and preview isolation. - Implemented test_phase9b_prop_centering.gd to check canonical origin presentation, bounds, centering, and save threshold. - Implemented test_phase9c_prop_editor_ux.gd to assess the user experience in the prop editor, including anchor renaming and UI responsiveness. - Added corresponding .uid files for each test. - Created sync_sources.py script to manage markdown file synchronization with the notebook. - Added .gitignore for virtual environment files.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
extends SceneTree
|
||||
## Phase 9a editor-only regression: anchor schema, alignment math, and preview isolation.
|
||||
|
||||
const ANCHORS := preload("res://scripts/prop_anchor_data.gd")
|
||||
const PROP_FILE := preload("res://scripts/prop_file.gd")
|
||||
const PREVIEW_SCENE := preload("res://scenes/prop_attach_preview.tscn")
|
||||
const EDITOR_SCENE := preload("res://scenes/prop_editor.tscn")
|
||||
const OVERLAY := preload("res://scripts/prop_anchor_overlay.gd")
|
||||
|
||||
var _checks: int = 0
|
||||
var _failures: int = 0
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
_test_anchor_round_trip()
|
||||
_test_anchor_validation_and_presets()
|
||||
_test_alignment_and_mirror()
|
||||
_test_anchor_rotation_overlay()
|
||||
await _test_private_preview()
|
||||
await _test_editor_anchor_inspector_constructs()
|
||||
print("RESULT: %s (%d assertions)" % ["ALL PASSED" if _failures == 0 else "%d FAILED" % _failures, _checks])
|
||||
quit(0 if _failures == 0 else 1)
|
||||
|
||||
func _check(label: String, condition: bool) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print("PASS: " + label)
|
||||
else:
|
||||
_failures += 1
|
||||
print("FAIL: " + label)
|
||||
|
||||
func _test_anchor_round_trip() -> void:
|
||||
var raw := {"primary_grip": {"role": "grip", "x": 12.0, "y": -8.0, "rotation_deg": 90.0, "z_index_offset": 2, "allowed_hands": "right_only"}}
|
||||
var normalized: Dictionary = ANCHORS.normalize_anchors(raw)
|
||||
_check("legacy/default origin is synthesized", normalized.has("origin") and normalized["origin"].get("position") == Vector2.ZERO)
|
||||
_check("grip remains compatible only with right", ANCHORS.compatible_grip_ids(normalized, ANCHORS.Hand.RIGHT).has("primary_grip") and not ANCHORS.compatible_grip_ids(normalized, ANCHORS.Hand.LEFT).has("primary_grip"))
|
||||
var file_data: Dictionary = PROP_FILE.to_file_dict("Grip", 0, [], normalized)
|
||||
var loaded: Dictionary = PROP_FILE.from_file_dict(file_data)
|
||||
_check("file anchors round trip", loaded.get("anchors", {}).get("primary_grip", {}).get("position") == Vector2(12.0, -8.0))
|
||||
|
||||
func _test_anchor_validation_and_presets() -> void:
|
||||
var raw := {"origin": {"role": "grip", "x": 50}, "Bad ID!!": {"role": "pivot", "position": Vector2(4, 5), "rotation_deg": 721.0, "z_index_offset": 99}}
|
||||
var normalized: Dictionary = ANCHORS.normalize_anchors(raw, false)
|
||||
_check("origin is immutable canonical pivot", normalized["origin"].get("role") == "pivot" and normalized["origin"].get("position") == Vector2.ZERO)
|
||||
_check("IDs normalize and non-origin pivots downgrade", normalized.has("bad_id") and normalized["bad_id"].get("role") == "custom")
|
||||
_check("rotation and z sanitize", is_equal_approx(float(normalized["bad_id"].get("rotation_deg")), 1.0) and int(normalized["bad_id"].get("z_index_offset")) == 5)
|
||||
_check("duplicate ID gets suffix", ANCHORS.normalize_id("bad id", normalized) == "bad_id_2")
|
||||
for preset: String in ["one_handed", "two_handed", "tool_nozzle", "melee_edge"]:
|
||||
var layout: Dictionary = ANCHORS.preset_layout(preset, Rect2(-50, -10, 100, 20))
|
||||
_check("preset %s retains origin" % preset, layout.has("origin") and layout["origin"].get("role") == "pivot")
|
||||
|
||||
func _test_alignment_and_mirror() -> void:
|
||||
var anchor := {"position": Vector2(20.0, 5.0), "rotation_deg": 30.0}
|
||||
var target := Transform2D(deg_to_rad(70.0), Vector2(300.0, 200.0))
|
||||
var solved := ANCHORS.solve_attachment_transform(target, anchor, Vector2(2.0, 3.0), false)
|
||||
var effective := ANCHORS.local_transform(anchor)
|
||||
_check("attachment maps anchor origin to wrist", solved * effective.origin == target.origin)
|
||||
_check("attachment keeps positive determinant", solved.determinant() > 0.0)
|
||||
var mirrored := ANCHORS.local_transform(anchor, true)
|
||||
_check("mirror reflects anchor X without mutation", mirrored.origin == Vector2(-20.0, 5.0) and anchor.get("position") == Vector2(20.0, 5.0))
|
||||
|
||||
func _test_anchor_rotation_overlay() -> void:
|
||||
var overlay := OVERLAY.new()
|
||||
overlay.set_anchor_data({"grip": {"role": "grip", "position": Vector2(10.0, 10.0), "rotation_deg": 0.0}})
|
||||
overlay.select_anchor("grip")
|
||||
_check("rotation dot starts outside translate marker", overlay.begin_pointer(Vector2(10.0, 38.0), 1.0) and int(overlay.get("_drag_mode")) == 2)
|
||||
overlay.update_pointer(Vector2(110.0, 36.8), 1.0, false, 1, true)
|
||||
overlay.end_pointer()
|
||||
var grip: Dictionary = overlay.get_anchor_data()["grip"]
|
||||
_check("rotation dot Ctrl snaps to 15 degrees", is_equal_approx(float(grip.get("rotation_deg")), 15.0))
|
||||
overlay.select_anchor("origin")
|
||||
_check("fixed origin has no rotation drag", not overlay.begin_pointer(Vector2(0.0, 28.0), 1.0) and int(overlay.get("_drag_mode")) != 2)
|
||||
|
||||
func _test_private_preview() -> void:
|
||||
var preview := PREVIEW_SCENE.instantiate() as Control
|
||||
root.add_child(preview)
|
||||
await process_frame
|
||||
await process_frame
|
||||
var anchors := ANCHORS.normalize_anchors({"grip": {"role": "grip", "x": 0.0, "y": 0.0, "rotation_deg": 0.0, "z_index_offset": 1, "allowed_hands": "both"}})
|
||||
var shapes: Array[Dictionary] = [{"shape_type": 0, "points": PackedVector2Array([Vector2(-10, -10), Vector2(10, -10), Vector2(0, 15)]), "fill_color": "#ff0000", "closed": true}]
|
||||
preview.call("set_document", shapes, anchors, "grip")
|
||||
_check("preview creates a private rig viewport", bool(preview.call("is_preview_available")))
|
||||
var rig_root := preview.get("_rig_root") as Node2D
|
||||
var prop := preview.get("_prop") as Node2D
|
||||
_check("preview rig keeps positive scale", rig_root != null and rig_root.scale.x > 0.0)
|
||||
_check("preview is visual-only", prop != null and prop.get_child_count() == 1 and not (prop.get_child(0) is CollisionObject2D))
|
||||
preview.call("set_facing_profile", 0)
|
||||
var rig := preview.get("_rig") as Node2D
|
||||
_check("left reflection is only on wrapper", rig != null and rig.scale.x > 0.0 and rig_root != null and rig_root.scale.x < 0.0)
|
||||
preview.call("set_preview_hand", 0)
|
||||
_check("secondary/any compatible grip can use left", int(preview.get("_hand")) == 0)
|
||||
preview.call("set_document", shapes, ANCHORS.normalize_anchors({"grip": {"role": "grip", "allowed_hands": "right_only"}}), "grip")
|
||||
_check("right-only grip forces compatible hand", int(preview.get("_hand")) == 1)
|
||||
var clamped := preview.call("_clamp_target", Vector2.ZERO) as Vector2
|
||||
_check("reach clamp returns finite target", is_finite(clamped.x) and is_finite(clamped.y))
|
||||
preview.call("reset_arm_pose")
|
||||
_check("reset disables animation", not bool(preview.get("_animate")))
|
||||
preview.queue_free()
|
||||
|
||||
func _test_editor_anchor_inspector_constructs() -> void:
|
||||
var editor := EDITOR_SCENE.instantiate() as Control
|
||||
editor.size = Vector2(1200.0, 800.0)
|
||||
root.add_child(editor)
|
||||
await process_frame
|
||||
await process_frame
|
||||
var role := editor.get("_anchor_role") as OptionButton
|
||||
var x_spin := editor.get("_anchor_x") as SpinBox
|
||||
var rename := editor.get("_rename_anchor_button") as Button
|
||||
var delete_button := editor.get("_delete_anchor_button") as Button
|
||||
var anchor_list := editor.get("_anchor_list") as ItemList
|
||||
_check("editor constructs an empty editable-anchor inspector", role != null and x_spin != null and anchor_list != null)
|
||||
_check("no-anchor option fields are disabled", role != null and role.disabled)
|
||||
_check("no-anchor numeric fields use SpinBox editable gate", x_spin != null and not x_spin.editable)
|
||||
_check("no-anchor rename/delete actions are disabled", rename != null and delete_button != null and rename.disabled and delete_button.disabled)
|
||||
_check("canonical origin is hidden from editable anchor list", anchor_list != null and anchor_list.item_count == 0)
|
||||
var workspace := editor.get_node_or_null("MainLayout/CanvasPanel/CanvasArea/CanvasWorkspace") as HSplitContainer
|
||||
var main_split := editor.get_node_or_null("MainLayout") as HSplitContainer
|
||||
_check("preview workspace is a bidirectional split", workspace != null and workspace.get_child_count() == 2 and workspace.get_child(1).custom_minimum_size.x >= 360.0)
|
||||
_check("inspector is user-resizable", main_split != null and main_split.get_child_count() == 2)
|
||||
var canvas := editor.get("_canvas") as PropCanvas
|
||||
canvas.add_primitive("box", Vector2(0.0, 80.0), {"size_x": 160.0, "size_y": 45.0, "fill_color": "#9b6b34"})
|
||||
canvas.add_primitive("circle", Vector2(55.0, 0.0), {"radius": 18.0, "fill_color": "#ffffff"})
|
||||
var anchors := ANCHORS.normalize_anchors({"grip": {"role": "grip", "position": Vector2.ZERO, "z_index_offset": 1, "allowed_hands": "both"}})
|
||||
editor.set("_anchors", anchors)
|
||||
canvas.set_anchor_data(anchors)
|
||||
editor.call("_select_anchor", "grip")
|
||||
editor.call("_on_attach_mode_pressed")
|
||||
editor.call("_sync_attach_preview")
|
||||
await process_frame
|
||||
var preview := editor.get("_attach_preview") as Control
|
||||
preview.call("_update_prop_transform")
|
||||
var prop := preview.get("_prop") as Node2D if preview != null else null
|
||||
_check("editor document flow builds every preview shape", prop != null and prop.visible and prop.get_child_count() == 2)
|
||||
var prop_bounds := preview.call("get_prop_global_bounds") as Rect2
|
||||
var view_bounds := preview.call("get_preview_world_rect") as Rect2
|
||||
var wrist := preview.call("get_solved_wrist_transform") as Transform2D
|
||||
var grip: Dictionary = anchors["grip"]
|
||||
_check("preview prop has finite visible global bounds", prop_bounds.size.x > 0.0 and prop_bounds.size.y > 0.0 and is_finite(prop_bounds.position.x) and is_finite(prop_bounds.end.y))
|
||||
_check("selected grip maps exactly to solved wrist", (prop.global_transform * (grip.get("position", Vector2.ZERO) as Vector2)).is_equal_approx(wrist.origin))
|
||||
var before_motion := prop.global_position
|
||||
preview.call("set_arm_animation_enabled", true)
|
||||
await process_frame
|
||||
preview.call("_update_prop_transform")
|
||||
var moved_wrist := preview.call("get_solved_wrist_transform") as Transform2D
|
||||
_check("held prop follows solved wrist during IK motion", prop.global_position.distance_to(before_motion) > 0.001 and prop.global_position.is_equal_approx(moved_wrist.origin))
|
||||
_check("framed viewport intersects held prop", view_bounds.intersects(prop_bounds))
|
||||
_check("preview preserves authored polygon and circle colors", prop.get_child(0) is Polygon2D and (prop.get_child(0) as Polygon2D).color == Color.from_string("#9b6b34", Color.GRAY) and prop.get_child(1) is Polygon2D and (prop.get_child(1) as Polygon2D).color == Color.WHITE)
|
||||
var rotation_spin := editor.get("_anchor_rotation") as SpinBox
|
||||
var z_spin := editor.get("_anchor_z") as SpinBox
|
||||
rotation_spin.value = 47.0
|
||||
z_spin.value = 3.0
|
||||
await process_frame
|
||||
var edited_anchors: Dictionary = editor.get("_anchors")
|
||||
var edited_grip: Dictionary = edited_anchors["grip"]
|
||||
_check("inspector rotation and z persist to selected anchor", is_equal_approx(float(edited_grip.get("rotation_deg")), 47.0) and int(edited_grip.get("z_index_offset")) == 3)
|
||||
_check("inspector refresh retains rotation and z values", is_equal_approx(rotation_spin.value, 47.0) and is_equal_approx(z_spin.value, 3.0))
|
||||
var round_trip: Dictionary = PROP_FILE.from_file_dict(PROP_FILE.to_file_dict("Grip", 0, [], edited_anchors))
|
||||
_check("rotation and z save/load round trip", is_equal_approx(float(round_trip["anchors"]["grip"].get("rotation_deg")), 47.0) and int(round_trip["anchors"]["grip"].get("z_index_offset")) == 3)
|
||||
_check("preview reacts to z edit", prop.z_index == 3)
|
||||
var preview_style := preview.get_theme_stylebox("panel") as StyleBoxFlat
|
||||
var inspector := editor.get_node_or_null("MainLayout/InspectorColumn") as PanelContainer
|
||||
var inspector_style := inspector.get_theme_stylebox("panel") as StyleBoxFlat if inspector != null else null
|
||||
_check("preview and inspector have distinct surfaces without blue frame", preview_style != null and inspector_style != null and preview_style.bg_color != inspector_style.bg_color and preview_style.border_width_left == 0)
|
||||
var playback := preview.get("_walk_playback_button") as Button
|
||||
var reset_arm := preview.get("_reset_arm_button") as Button
|
||||
var reset_view := preview.get("_reset_view_button") as Button
|
||||
var hand_option := preview.get("_hand_option") as OptionButton
|
||||
_check("preview footer controls own full hit rectangles", _has_full_hit_rect(playback) and _has_full_hit_rect(reset_arm) and _has_full_hit_rect(reset_view))
|
||||
_check("preview header options own full hit rectangles", _has_full_hit_rect(hand_option))
|
||||
preview.call("set_preview_motion", 2)
|
||||
preview.call("toggle_walk_playback")
|
||||
_check("walk playback pauses through its button state", playback != null and playback.text == "Resume Walk" and bool(preview.get("_walk_paused")))
|
||||
editor.queue_free()
|
||||
|
||||
func _has_full_hit_rect(control: Control) -> bool:
|
||||
return control != null and control.mouse_filter == Control.MOUSE_FILTER_STOP and control.size.x >= control.custom_minimum_size.x and control.size.y >= control.custom_minimum_size.y and Rect2(Vector2.ZERO, control.size).has_point(Vector2(control.size.x - 2.0, control.size.y * 0.5))
|
||||
Reference in New Issue
Block a user