1141 lines
54 KiB
GDScript
1141 lines
54 KiB
GDScript
# test_phase3c_editor.gd
|
|
# Headless logic checks for the Phase 3c Editor Tools (Action & Rule Editing):
|
|
#
|
|
# 1. Registries: ActionRegistry / TriggerRegistry template lookups
|
|
# (types/label/icon/target_type/summarize for all 5 actions + 5 triggers)
|
|
# and graceful unknown-type handling.
|
|
# 2. Scene shells instantiate (queue_panel / rule_panel / action_editor /
|
|
# rule_editor / waypoint_context script) and build their UI.
|
|
# 3. ActionEditor: open_new/open_edit pre-fill (walk target, speak text /
|
|
# duration, wait duration), committed/cancelled/target_requested signals,
|
|
# OK-without-target requests a stage target capture.
|
|
# 4. RuleEditor: full vs consequence-only modes, trigger read-only in
|
|
# consequence mode, action add/edit/remove, get_action(), rule id
|
|
# preservation on commit, action_type dropdown sync for action_finished.
|
|
# 5. QueuePanel: set_source/refresh shows all queue actions in order; stage
|
|
# add/edit/delete/clear/reorder flows mutate the rig queue via its API.
|
|
# 6. RulePanel: filtered rule list (by source stickman and by waypoint),
|
|
# edit/delete/add/clear/reorder.
|
|
# 7. WaypointContext: item ids for Edit/Delete/Insert-before/after plus the
|
|
# trigger-rules entry enabled/disabled by rule count.
|
|
# 8. StageDirectorVisuals.hit_test_waypoint_action() returns rig/index/pos.
|
|
# 9. sandbox_stage Phase 3c capture: begin/cancel/resolve + Esc priority;
|
|
# right-click waypoint/rig context entry points.
|
|
# 10. Backward compatibility: pre-existing queues/rules display correctly and
|
|
# editing preserves action types/params and rule ids.
|
|
#
|
|
# Run with:
|
|
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3c_editor.gd --path .
|
|
#
|
|
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
|
|
|
|
extends SceneTree
|
|
|
|
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
|
const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd")
|
|
const QUEUE_PANEL_SCENE := preload("res://scenes/queue_panel.tscn")
|
|
const RULE_PANEL_SCENE := preload("res://scenes/rule_panel.tscn")
|
|
const ACTION_EDITOR_SCENE := preload("res://scenes/action_editor.tscn")
|
|
const RULE_EDITOR_SCENE := preload("res://scenes/rule_editor.tscn")
|
|
const WAYPOINT_CONTEXT := preload("res://scripts/waypoint_context.gd")
|
|
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
|
|
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
# Watchdog: if a runtime error aborts _run before quit(), force a FAIL exit
|
|
# instead of hanging the headless process forever.
|
|
var watchdog := create_timer(180.0)
|
|
watchdog.timeout.connect(func() -> void:
|
|
print("FAIL: watchdog timeout - test run aborted before quit()")
|
|
quit(2))
|
|
|
|
print("")
|
|
print("========================================================")
|
|
print(" PHASE 3c EDITOR TOOLS TEST (headless)")
|
|
print("========================================================")
|
|
|
|
_test_action_registry()
|
|
_test_trigger_registry()
|
|
_test_rule_action_converters()
|
|
await _test_scene_shells()
|
|
await _test_action_editor()
|
|
await _test_rule_editor()
|
|
await _test_queue_panel_flows()
|
|
await _test_rule_panel_flows()
|
|
await _test_stage_capture_and_esc()
|
|
await _test_waypoint_context()
|
|
await _test_visuals_hit_test()
|
|
await _test_stage_context_entries()
|
|
await _test_backward_compat()
|
|
|
|
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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Action registry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_action_registry() -> void:
|
|
print("")
|
|
print("--- ActionRegistry templates ---")
|
|
var types: Array[String] = ACTION_REGISTRY.types()
|
|
_check(types == ["walk_to", "speak", "wait", "ragdoll", "recover"],
|
|
"action types in registry order (got %s)" % str(types))
|
|
_check(types is Array[String], "types() returns a typed Array[String]")
|
|
for t: String in ["walk_to", "speak", "wait", "ragdoll", "recover"]:
|
|
_check(ACTION_REGISTRY.has_type(t), "has_type('%s') true" % t)
|
|
_check(not ACTION_REGISTRY.has_type("bogus"), "has_type('bogus') false")
|
|
|
|
_check(ACTION_REGISTRY.label("walk_to") == "Walk To", "walk_to label == 'Walk To'")
|
|
_check(ACTION_REGISTRY.label("speak") == "Speak", "speak label == 'Speak'")
|
|
_check(ACTION_REGISTRY.label("wait") == "Wait", "wait label == 'Wait'")
|
|
_check(ACTION_REGISTRY.label("ragdoll") == "Ragdoll", "ragdoll label == 'Ragdoll'")
|
|
_check(ACTION_REGISTRY.label("recover") == "Recover", "recover label == 'Recover'")
|
|
_check(ACTION_REGISTRY.label("bogus") == "bogus", "unknown action label falls back to type")
|
|
_check(ACTION_REGISTRY.icon("walk_to") == "🚶", "walk_to icon")
|
|
_check(ACTION_REGISTRY.icon("speak") == "💬", "speak icon")
|
|
_check(ACTION_REGISTRY.icon("wait") == "⏳", "wait icon")
|
|
_check(ACTION_REGISTRY.icon("ragdoll") == "💥", "ragdoll icon")
|
|
_check(ACTION_REGISTRY.icon("recover") == "🔄", "recover icon")
|
|
_check(ACTION_REGISTRY.icon("bogus") == "", "unknown action icon is empty")
|
|
|
|
# Summaries - flat queue action shape.
|
|
_check(ACTION_REGISTRY.summarize({ "type": "walk_to", "target": Vector2(10, -20) }) == "Walk To (10, -20)",
|
|
"walk_to summarize uses target (got '%s')" % ACTION_REGISTRY.summarize({ "type": "walk_to", "target": Vector2(10, -20) }))
|
|
_check(ACTION_REGISTRY.summarize({ "type": "speak", "text": "Hello", "duration": 2.0 }) == "Speak \"Hello\" (2s)",
|
|
"speak summarize text+duration (got '%s')" % ACTION_REGISTRY.summarize({ "type": "speak", "text": "Hello", "duration": 2.0 }))
|
|
_check(ACTION_REGISTRY.summarize({ "type": "wait", "duration": 1.5 }) == "Wait 1.5s",
|
|
"wait summarize duration (got '%s')" % ACTION_REGISTRY.summarize({ "type": "wait", "duration": 1.5 }))
|
|
_check(ACTION_REGISTRY.summarize({ "type": "ragdoll" }) == "Ragdoll", "ragdoll summarize")
|
|
_check(ACTION_REGISTRY.summarize({ "type": "recover" }) == "Recover", "recover summarize")
|
|
_check(ACTION_REGISTRY.summarize({ "type": "mystery" }) == "mystery", "unknown summarize returns type")
|
|
|
|
# Summaries tolerate the rule-action shape (params nested + target actor id).
|
|
_check(ACTION_REGISTRY.summarize({ "type": "speak", "target": 123, "params": { "text": "Hi", "duration": 1.0 } }) == "Speak \"Hi\" (1s)",
|
|
"speak summarize reads rule-action params (got '%s')" % ACTION_REGISTRY.summarize({ "type": "speak", "target": 123, "params": { "text": "Hi", "duration": 1.0 } }))
|
|
|
|
# Parameter templates (extensibility: per-type params).
|
|
_check((ACTION_REGISTRY.ACTION_TEMPLATES["walk_to"]["params"] as Array).size() == 1, "walk_to has 1 param")
|
|
_check((ACTION_REGISTRY.ACTION_TEMPLATES["speak"]["params"] as Array).size() == 2, "speak has 2 params")
|
|
_check((ACTION_REGISTRY.ACTION_TEMPLATES["wait"]["params"] as Array).size() == 1, "wait has 1 param")
|
|
_check((ACTION_REGISTRY.ACTION_TEMPLATES["ragdoll"]["params"] as Array).is_empty(), "ragdoll has no params")
|
|
_check((ACTION_REGISTRY.ACTION_TEMPLATES["recover"]["params"] as Array).is_empty(), "recover has no params")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Trigger registry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_trigger_registry() -> void:
|
|
print("")
|
|
print("--- TriggerRegistry templates ---")
|
|
var types: Array[String] = TRIGGER_REGISTRY.types()
|
|
_check(types == ["arrived_at_waypoint", "action_finished", "speech_finished", "entered_area", "collided"],
|
|
"trigger types in registry order (got %s)" % str(types))
|
|
for t: String in types:
|
|
_check(TRIGGER_REGISTRY.has_type(t), "has_type('%s') true" % t)
|
|
_check(not TRIGGER_REGISTRY.has_type("nope"), "has_type('nope') false")
|
|
_check(TRIGGER_REGISTRY.label("arrived_at_waypoint") == "Arrives at waypoint", "arrived label")
|
|
_check(TRIGGER_REGISTRY.label("action_finished") == "Completes any action", "action_finished label")
|
|
_check(TRIGGER_REGISTRY.label("speech_finished") == "Finishes speaking", "speech_finished label")
|
|
_check(TRIGGER_REGISTRY.label("entered_area") == "Enters trigger area", "entered_area label")
|
|
_check(TRIGGER_REGISTRY.label("collided") == "Collides with something", "collided label")
|
|
_check(TRIGGER_REGISTRY.label("nope") == "nope", "unknown trigger label falls back to type")
|
|
_check(TRIGGER_REGISTRY.target_type("arrived_at_waypoint") == "waypoint", "arrived target_type waypoint")
|
|
_check(TRIGGER_REGISTRY.target_type("action_finished") == "action_type", "action_finished target_type action_type")
|
|
_check(TRIGGER_REGISTRY.target_type("speech_finished") == "none", "speech_finished target_type none")
|
|
_check(TRIGGER_REGISTRY.target_type("entered_area") == "area", "entered_area target_type area")
|
|
_check(TRIGGER_REGISTRY.target_type("collided") == "prop", "collided target_type prop")
|
|
_check(TRIGGER_REGISTRY.target_type("nope") == "none", "unknown trigger target_type none")
|
|
_check(TRIGGER_REGISTRY.icon("entered_area") == "🎯", "entered_area icon")
|
|
_check(TRIGGER_REGISTRY.summarize({ "type": "arrived_at_waypoint" }) == "📍 Arrives at waypoint",
|
|
"arrived summarize (got '%s')" % TRIGGER_REGISTRY.summarize({ "type": "arrived_at_waypoint" }))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Rule-action converters (registry roundtrip)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_rule_action_converters() -> void:
|
|
print("")
|
|
print("--- ActionRegistry to_rule_action / from_rule_action ---")
|
|
var flat_walk := { "type": "walk_to", "target": Vector2(30, 40) }
|
|
var ra_walk := ACTION_REGISTRY.to_rule_action(flat_walk, 999)
|
|
_check(String(ra_walk["type"]) == "walk_to" and int(ra_walk["target"]) == 999,
|
|
"to_rule_action(walk) nests target actor id")
|
|
_check((ra_walk["params"] as Dictionary).get("target", Vector2.ZERO) == Vector2(30, 40),
|
|
"to_rule_action(walk) nests walk position in params")
|
|
var back_walk := ACTION_REGISTRY.from_rule_action(ra_walk)
|
|
_check(String(back_walk["type"]) == "walk_to" and (back_walk["target"] as Vector2) == Vector2(30, 40),
|
|
"from_rule_action roundtrips walk_to")
|
|
|
|
var flat_speak := { "type": "speak", "text": "Yo", "duration": 3.0 }
|
|
var ra_speak := ACTION_REGISTRY.to_rule_action(flat_speak, 5)
|
|
_check(String(ra_speak["params"].get("text", "")) == "Yo" and float(ra_speak["params"].get("duration", 0.0)) == 3.0,
|
|
"to_rule_action(speak) nests text/duration")
|
|
var back_speak := ACTION_REGISTRY.from_rule_action(ra_speak)
|
|
_check(String(back_speak["text"]) == "Yo" and float(back_speak["duration"]) == 3.0,
|
|
"from_rule_action roundtrips speak")
|
|
|
|
var flat_wait := { "type": "wait", "duration": 2.5 }
|
|
var back_wait := ACTION_REGISTRY.from_rule_action(ACTION_REGISTRY.to_rule_action(flat_wait, 5))
|
|
_check(String(back_wait["type"]) == "wait" and float(back_wait["duration"]) == 2.5,
|
|
"from_rule_action roundtrips wait")
|
|
var back_rag := ACTION_REGISTRY.from_rule_action({ "type": "ragdoll", "target": 5, "params": {} })
|
|
_check(String(back_rag["type"]) == "ragdoll" and back_rag.size() == 1,
|
|
"from_rule_action keeps paramless ragdoll minimal")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Scene shells instantiate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_scene_shells() -> void:
|
|
print("")
|
|
print("--- Phase 3c scene shells load + _ready ---")
|
|
_check(load("res://scenes/queue_panel.tscn") != null, "queue_panel.tscn loads")
|
|
_check(load("res://scenes/rule_panel.tscn") != null, "rule_panel.tscn loads")
|
|
_check(load("res://scenes/action_editor.tscn") != null, "action_editor.tscn loads")
|
|
_check(load("res://scenes/rule_editor.tscn") != null, "rule_editor.tscn loads")
|
|
|
|
var qp: QueuePanel = QUEUE_PANEL_SCENE.instantiate()
|
|
root.add_child(qp)
|
|
_check(qp._list != null and qp._title_label != null, "queue_panel builds its UI")
|
|
_check(qp._empty_label.visible, "queue_panel with no rig shows empty label")
|
|
var wp_menu: PopupMenu = WAYPOINT_CONTEXT.new()
|
|
root.add_child(wp_menu)
|
|
_check(wp_menu.item_count == 6, "waypoint context has 6 items (got %d)" % wp_menu.item_count)
|
|
_check(wp_menu.get_item_id(0) == WAYPOINT_CONTEXT.EDIT_WALK, "waypoint item 0 is EDIT_WALK")
|
|
_check(wp_menu.get_item_id(1) == WAYPOINT_CONTEXT.DELETE_WALK, "waypoint item 1 is DELETE_WALK")
|
|
_check(wp_menu.get_item_id(2) == WAYPOINT_CONTEXT.INSERT_BEFORE, "waypoint item 2 is INSERT_BEFORE")
|
|
_check(wp_menu.get_item_id(3) == WAYPOINT_CONTEXT.INSERT_AFTER, "waypoint item 3 is INSERT_AFTER")
|
|
_check(wp_menu.get_item_id(4) == -1, "waypoint item 4 is a separator")
|
|
_check(wp_menu.get_item_id(5) == WAYPOINT_CONTEXT.EDIT_TRIGGER_RULES, "waypoint item 5 is EDIT_TRIGGER_RULES")
|
|
wp_menu.popup_for(Rect2i(10, 10, 0, 0), 2)
|
|
_check(not wp_menu.is_item_disabled(5) and wp_menu.get_item_text(5).contains("2 rules"),
|
|
"trigger-rules entry enabled with count (got '%s')" % wp_menu.get_item_text(5))
|
|
wp_menu.popup_for(Rect2i(10, 10, 0, 0), 0)
|
|
_check(wp_menu.is_item_disabled(5), "trigger-rules entry disabled with no rules")
|
|
qp.queue_free()
|
|
wp_menu.queue_free()
|
|
await process_frame
|
|
|
|
var rp: RulePanel = RULE_PANEL_SCENE.instantiate()
|
|
root.add_child(rp)
|
|
_check(rp._list != null and rp._title_label != null, "rule_panel builds its UI")
|
|
rp.queue_free()
|
|
await process_frame
|
|
var ae: ActionEditor = ACTION_EDITOR_SCENE.instantiate()
|
|
root.add_child(ae)
|
|
_check(ae._type_option != null, "action_editor builds its UI")
|
|
ae.hide()
|
|
var re: RuleEditor = RULE_EDITOR_SCENE.instantiate()
|
|
root.add_child(re)
|
|
_check(re._type_option != null and re._trigger_readonly_label != null, "rule_editor builds its UI")
|
|
re.hide()
|
|
await process_frame
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. ActionEditor standalone flows
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_action_editor() -> void:
|
|
print("")
|
|
print("--- ActionEditor open_new / open_edit / commit ---")
|
|
var ae: ActionEditor = ACTION_EDITOR_SCENE.instantiate()
|
|
root.add_child(ae)
|
|
|
|
# open_new defaults to walk_to with no target.
|
|
ae.open_new()
|
|
_check(ae._mode == "new", "open_new sets mode 'new'")
|
|
_check(ae._current_type() == "walk_to", "open_new selects walk_to (got '%s')" % ae._current_type())
|
|
_check(ae._has_target == false, "open_new has no target")
|
|
_check(ae.title == "Add Action", "open_new title == 'Add Action'")
|
|
|
|
# OK without a target emits target_requested (not committed).
|
|
var committed: Array = []
|
|
var targets: Array = []
|
|
ae.committed.connect(func(a): committed.append(a))
|
|
ae.target_requested.connect(func(): targets.append(true))
|
|
ae._on_ok_pressed()
|
|
_check(targets.size() == 1, "walk_to OK without target emits target_requested")
|
|
_check(committed.is_empty(), "walk_to OK without target does NOT commit")
|
|
|
|
# Set the walk target then commit.
|
|
ae.set_walk_target(Vector2(120, -80))
|
|
_check(ae._has_target, "set_walk_target marks target present")
|
|
_check(ae._target_label != null and ae._target_label.text.contains("120"), "walk target label updates")
|
|
ae._on_ok_pressed()
|
|
_check(committed.size() == 1, "walk_to OK with target commits once")
|
|
if committed.size() == 1:
|
|
var a0: Dictionary = committed[0]
|
|
_check(String(a0["type"]) == "walk_to", "committed walk_to preserves type")
|
|
_check((a0["target"] as Vector2) == Vector2(120, -80), "committed walk_to carries target")
|
|
# A second open_new resets the target.
|
|
ae.open_new()
|
|
_check(ae._has_target == false, "open_new resets the target for a fresh add")
|
|
|
|
# Speak edit pre-fill.
|
|
ae.open_edit({ "type": "speak", "text": "Hello there", "duration": 2.5 })
|
|
_check(ae._mode == "edit", "open_edit sets mode 'edit'")
|
|
_check(ae.title == "Edit Action", "open_edit title == 'Edit Action'")
|
|
_check(ae._current_type() == "speak", "open_edit selects speak")
|
|
_check(ae._text_edit.text == "Hello there", "speak text pre-filled")
|
|
_check(is_equal_approx(ae._duration_spin.value, 2.5), "speak duration pre-filled (%.2f)" % ae._duration_spin.value)
|
|
ae._text_edit.text = "Changed"
|
|
ae._duration_spin.value = 1.0
|
|
ae._on_ok_pressed()
|
|
_check(committed.size() == 2, "speak edit commits")
|
|
if committed.size() == 2:
|
|
var a1: Dictionary = committed[1]
|
|
_check(String(a1["type"]) == "speak" and String(a1["text"]) == "Changed" and float(a1["duration"]) == 1.0,
|
|
"speak edit commits type/text/duration")
|
|
|
|
# Wait edit pre-fill.
|
|
ae.open_edit({ "type": "wait", "duration": 3.0 })
|
|
_check(ae._current_type() == "wait", "open_edit selects wait")
|
|
_check(is_equal_approx(ae._duration_spin.value, 3.0), "wait duration pre-filled")
|
|
ae._duration_spin.value = 4.0
|
|
ae._on_ok_pressed()
|
|
if committed.size() == 3:
|
|
var a2: Dictionary = committed[2]
|
|
_check(String(a2["type"]) == "wait" and float(a2["duration"]) == 4.0, "wait edit commits new duration")
|
|
|
|
# Type switching to a paramless action (ragdoll) then commit.
|
|
ae.open_new()
|
|
var rag_idx: int = ACTION_REGISTRY.types().find("ragdoll")
|
|
ae._type_option.select(rag_idx)
|
|
ae._on_type_selected(rag_idx)
|
|
_check(ae._current_type() == "ragdoll", "type switch to ragdoll")
|
|
ae._on_ok_pressed()
|
|
if committed.size() == 4:
|
|
var a3: Dictionary = committed[3]
|
|
_check(String(a3["type"]) == "ragdoll" and a3.size() == 1, "ragdoll commits type-only")
|
|
|
|
# Cancel via Esc.
|
|
var cancelled: Array = []
|
|
ae.cancelled.connect(func(): cancelled.append(true))
|
|
var esc := InputEventKey.new()
|
|
esc.keycode = KEY_ESCAPE
|
|
esc.pressed = true
|
|
ae._unhandled_input(esc)
|
|
_check(cancelled.size() == 1, "Esc emits cancelled")
|
|
|
|
ae.queue_free()
|
|
await process_frame
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. RuleEditor standalone flows
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_rule_editor() -> void:
|
|
print("")
|
|
print("--- RuleEditor full / consequence-only ---")
|
|
var re: RuleEditor = RULE_EDITOR_SCENE.instantiate()
|
|
root.add_child(re)
|
|
|
|
var sample_rule := {
|
|
"id": 7,
|
|
"trigger": {
|
|
"type": "arrived_at_waypoint",
|
|
"source": -1,
|
|
"target": -1,
|
|
"params": { "waypoint_pos": Vector2(50, 60) },
|
|
},
|
|
"actions": [
|
|
{ "type": "speak", "target": 1, "params": { "text": "hi", "duration": 1.0 } },
|
|
{ "type": "wait", "target": 1, "params": { "duration": 2.0 } },
|
|
],
|
|
}
|
|
var committed: Array = []
|
|
re.committed.connect(func(r): committed.append(r))
|
|
|
|
# Full mode loads trigger + actions + id.
|
|
re.open_full(sample_rule)
|
|
_check(re._mode == "full", "open_full sets mode 'full'")
|
|
_check(re._rule_id == 7, "open_full preserves rule id (got %d)" % re._rule_id)
|
|
_check(re._actions.size() == 2, "open_full loads 2 actions (got %d)" % re._actions.size())
|
|
_check(re._current_type() == "arrived_at_waypoint", "open_full selects trigger type")
|
|
var a0: Dictionary = re.get_action(0)
|
|
_check(String(a0.get("type", "")) == "speak" and String(a0.get("params", {}).get("text", "")) == "hi",
|
|
"get_action(0) returns speak action copy")
|
|
_check(re.get_action(9).is_empty(), "get_action out-of-range returns {}")
|
|
_check(re._type_option.visible, "full mode shows trigger dropdown")
|
|
_check(not re._trigger_readonly_label.visible, "full mode hides read-only trigger label")
|
|
_check(re._trigger_readonly_label.visible == false, "full mode read-only label hidden")
|
|
|
|
# Trigger type change to entered_area via the dropdown + commit.
|
|
var ea_idx: int = TRIGGER_REGISTRY.types().find("entered_area")
|
|
re._type_option.select(ea_idx)
|
|
re._on_type_selected(ea_idx)
|
|
_check(re._current_type() == "entered_area", "trigger switch to entered_area")
|
|
re._on_ok_pressed()
|
|
_check(committed.size() == 1, "full mode commit emits")
|
|
if committed.size() == 1:
|
|
var c0: Dictionary = committed[0]
|
|
_check(int(c0["id"]) == 7, "full commit preserves id")
|
|
_check(String(c0["trigger"].get("type", "")) == "entered_area", "full commit stores new trigger type")
|
|
_check((c0["actions"] as Array).size() == 2, "full commit keeps actions")
|
|
|
|
# action_finished trigger shows the action_type dropdown and syncs on OK.
|
|
var af_rule := {
|
|
"id": 8,
|
|
"trigger": { "type": "action_finished", "source": -1, "target": -1, "params": { "action_type": "speak" } },
|
|
"actions": [],
|
|
}
|
|
re.open_full(af_rule)
|
|
_check(re._current_type() == "action_finished", "action_finished trigger loads")
|
|
_check(re._action_type_option.visible, "action_finished shows the action-type dropdown")
|
|
_check(re._action_type_option.selected == 2, "action_type dropdown pre-fills 'speak' (sel=%d)" % re._action_type_option.selected)
|
|
# Switch to "wait" (index 3: Any/walk/speak/wait) then commit syncs params.
|
|
re._action_type_option.select(3)
|
|
re._on_ok_pressed()
|
|
if committed.size() == 2:
|
|
var c1: Dictionary = committed[1]
|
|
_check(String(c1["trigger"].get("params", {}).get("action_type", "")) == "wait",
|
|
"action_finished commit syncs action_type param (got '%s')" % str(c1["trigger"].get("params", {})))
|
|
|
|
# Consequence-only mode: trigger is read-only, actions editable.
|
|
re.open_consequence(sample_rule)
|
|
_check(re._mode == "consequence", "open_consequence sets mode 'consequence'")
|
|
_check(not re._type_option.visible, "consequence hides trigger dropdown")
|
|
_check(not re._target_row.visible, "consequence hides target row")
|
|
_check(re._trigger_readonly_label.visible, "consequence shows read-only trigger label")
|
|
_check(re._trigger_readonly_label.text.contains("Arrives at waypoint"),
|
|
"read-only label summarizes trigger (got '%s')" % re._trigger_readonly_label.text)
|
|
_check(re._actions.size() == 2, "consequence loads actions")
|
|
|
|
# Consequence commit must NOT change the trigger type even if dropdown differs.
|
|
re._type_option.select(0)
|
|
re._on_ok_pressed()
|
|
if committed.size() == 3:
|
|
var c2: Dictionary = committed[2]
|
|
_check(int(c2["id"]) == 7, "consequence commit preserves id")
|
|
_check(String(c2["trigger"].get("type", "")) == "arrived_at_waypoint",
|
|
"consequence commit leaves trigger type untouched")
|
|
|
|
# set_action appends / replaces.
|
|
re.open_consequence(sample_rule)
|
|
re.set_action(-1, { "type": "ragdoll", "target": 1, "params": {} })
|
|
_check(re._actions.size() == 3, "set_action(-1) appends (got %d)" % re._actions.size())
|
|
re.set_action(0, { "type": "wait", "target": 1, "params": { "duration": 9.0 } })
|
|
_check(re._actions.size() == 3 and float(re.get_action(0).get("params", {}).get("duration", 0.0)) == 9.0,
|
|
"set_action(index) replaces in place")
|
|
re._remove_action(0)
|
|
_check(re._actions.size() == 2, "_remove_action removes (got %d)" % re._actions.size())
|
|
_check(String(re.get_action(0).get("type", "")) == "wait", "removal shifts remaining actions")
|
|
# Add + commit after remove preserves the whole edited action list.
|
|
re._on_ok_pressed()
|
|
if committed.size() == 4:
|
|
var c3: Dictionary = committed[3]
|
|
_check((c3["actions"] as Array).size() == 2, "commit carries edited action list")
|
|
|
|
# Esc emits cancelled.
|
|
var cancelled: Array = []
|
|
re.cancelled.connect(func(): cancelled.append(true))
|
|
var esc := InputEventKey.new()
|
|
esc.keycode = KEY_ESCAPE
|
|
esc.pressed = true
|
|
re._unhandled_input(esc)
|
|
_check(cancelled.size() == 1, "RuleEditor Esc emits cancelled")
|
|
|
|
# Unknown trigger type falls back to a known one.
|
|
re.open_full({ "id": 9, "trigger": { "type": "mystery_trigger" }, "actions": [] })
|
|
_check(re._current_type() == "arrived_at_waypoint", "unknown trigger type falls back to arrived_at_waypoint")
|
|
|
|
re.queue_free()
|
|
await process_frame
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. QueuePanel + stage queue flows
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_queue_panel_flows() -> void:
|
|
print("")
|
|
print("--- QueuePanel + stage queue edit/delete/clear/add/reorder ---")
|
|
var stage := _new_stage()
|
|
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
|
_check(rig != null, "stickman rig spawns for queue flows")
|
|
if rig == null:
|
|
await _free_stage(stage)
|
|
return
|
|
|
|
rig.queue_action({ "type": "walk_to", "target": Vector2(100, -100) })
|
|
rig.queue_action({ "type": "speak", "text": "Hello", "duration": 2.0 })
|
|
rig.queue_action({ "type": "wait", "duration": 3.0 })
|
|
rig.queue_action({ "type": "ragdoll" })
|
|
|
|
# Open queue panel: rows reflect the queue in order.
|
|
stage._open_queue_panel(rig)
|
|
_check(stage._queue_panel.rig == rig, "queue panel rig attached")
|
|
_check(stage._queue_panel._rows.size() == 4, "queue panel shows 4 rows (got %d)" % stage._queue_panel._rows.size())
|
|
_check(not stage._queue_panel._empty_label.visible, "queue panel hides empty label")
|
|
_check(stage._queue_panel._title_label.text.contains(String(rig.name)),
|
|
"queue panel title names the rig (got '%s')" % stage._queue_panel._title_label.text)
|
|
var row_summaries := _queue_panel_summaries(stage._queue_panel)
|
|
_check(row_summaries[0].contains("Walk To") and row_summaries[1].contains("Speak")
|
|
and row_summaries[2].contains("Wait") and row_summaries[3].contains("Ragdoll"),
|
|
"queue rows show action types in order (got %s)" % str(row_summaries))
|
|
|
|
# Edit a speak action through the panel -> ActionEditor queue_edit.
|
|
stage._on_queue_panel_edit_requested(1)
|
|
_check(stage._action_editor_kind == "queue_edit", "edit requested opens queue_edit editor (got '%s')" % stage._action_editor_kind)
|
|
_check(stage._action_editor_index == 1, "queue_edit editor targets index 1")
|
|
_check(stage._action_editor._current_type() == "speak", "queue_edit editor pre-fills speak")
|
|
_check(stage._action_editor._text_edit.text == "Hello", "queue_edit editor pre-fills speak text")
|
|
# Commit an edited speak.
|
|
stage._action_editor._text_edit.text = "Edited!"
|
|
stage._action_editor._duration_spin.value = 1.5
|
|
stage._action_editor._on_ok_pressed()
|
|
var q: Array[Dictionary] = rig.get_queue()
|
|
_check(q.size() == 4, "edit keeps queue size")
|
|
_check(q[1].get("type", "") == "speak" and q[1].get("text", "") == "Edited!" and float(q[1].get("duration", 0.0)) == 1.5,
|
|
"edit replaces action in place with same type + params")
|
|
stage._clear_director_pending()
|
|
|
|
# Edit a walk action enters visual re-placement capture.
|
|
stage._open_queue_panel(rig)
|
|
stage._on_queue_panel_edit_requested(0)
|
|
_check(stage._walk_edit_rig == rig and stage._walk_edit_index == 0, "walk edit arms rig + index")
|
|
_check(stage._capture_kind == stage.CaptureKind.POSITION, "walk edit begins POSITION capture")
|
|
_check(stage._director_visuals._edit_waypoint == Vector2(100, -100), "walk edit highlights the waypoint")
|
|
# Resolve capture at a new position.
|
|
stage._resolve_capture(Vector2(555, -555))
|
|
q = rig.get_queue()
|
|
_check((q[0].get("target", Vector2.ZERO) as Vector2) == Vector2(555, -555), "walk edit updates the action target")
|
|
_check(stage._capture_kind == stage.CaptureKind.NONE, "walk edit clears capture after resolve")
|
|
_check(stage._walk_edit_rig == null, "walk edit clears edit state")
|
|
stage._clear_director_pending()
|
|
|
|
# Cancel capture keeps the waypoint unchanged.
|
|
stage._open_queue_panel(rig)
|
|
stage._on_queue_panel_edit_requested(0)
|
|
stage._cancel_capture()
|
|
q = rig.get_queue()
|
|
_check((q[0].get("target", Vector2.ZERO) as Vector2) == Vector2(555, -555), "cancel walk edit leaves target")
|
|
_check(stage._walk_edit_rig == null, "cancel walk edit clears edit state")
|
|
stage._clear_director_pending()
|
|
|
|
# Add action appends through the ActionEditor.
|
|
stage._open_queue_panel(rig)
|
|
stage._on_queue_panel_add_requested()
|
|
_check(stage._action_editor_kind == "queue_add", "add requested opens queue_add editor")
|
|
# switch to wait and commit
|
|
var wait_idx: int = ACTION_REGISTRY.types().find("wait")
|
|
stage._action_editor._type_option.select(wait_idx)
|
|
stage._action_editor._on_type_selected(wait_idx)
|
|
stage._action_editor._duration_spin.value = 7.0
|
|
stage._action_editor._on_ok_pressed()
|
|
q = rig.get_queue()
|
|
_check(q.size() == 5, "add appends to queue (got %d)" % q.size())
|
|
_check(q[4].get("type", "") == "wait" and float(q[4].get("duration", 0.0)) == 7.0, "added action at tail")
|
|
stage._clear_director_pending()
|
|
|
|
# Delete action with confirmation.
|
|
stage._open_queue_panel(rig)
|
|
stage._on_queue_panel_delete_requested(0)
|
|
_check(stage._confirm_callback.is_valid(), "delete action asks for confirmation")
|
|
stage._on_confirm_confirmed()
|
|
q = rig.get_queue()
|
|
_check(q.size() == 4, "delete removes one action (got %d)" % q.size())
|
|
_check(q[0].get("type", "") == "speak", "delete shifts remaining queue (index 0 now %s)" % String(q[0].get("type", "")))
|
|
stage._clear_director_pending()
|
|
|
|
# Reorder through the panel's drag-commit. The implementation removes then
|
|
# re-inserts the dragged action so a downward move lands it just above the
|
|
# highlighted target row (move 0 -> 2 puts the item at final index 1).
|
|
stage._open_queue_panel(rig)
|
|
var before := rig.get_queue()
|
|
stage._queue_panel._drag_index = 0
|
|
stage._queue_panel._drag_target = 2
|
|
stage._queue_panel._commit_drag()
|
|
q = rig.get_queue()
|
|
_check(q.size() == before.size(), "reorder keeps size")
|
|
_check(q[0].get("type", "") == "wait" and q[1].get("type", "") == "speak",
|
|
"reorder moves the action down to index 1 (got %s)" % str(_type_list(q)))
|
|
stage._clear_director_pending()
|
|
|
|
# Clear with confirmation empties the queue.
|
|
stage._open_queue_panel(rig)
|
|
stage._on_queue_panel_clear_requested()
|
|
stage._on_confirm_confirmed()
|
|
q = rig.get_queue()
|
|
_check(q.is_empty(), "clear queue removes all actions")
|
|
_check(stage._queue_panel._rows.is_empty(), "queue panel refresh shows no rows after clear")
|
|
stage._clear_director_pending()
|
|
|
|
# Empty queue panel shows the empty label.
|
|
stage._open_queue_panel(rig)
|
|
_check(stage._queue_panel._empty_label.visible, "queue panel shows empty label after clear")
|
|
stage._clear_director_pending()
|
|
|
|
await _free_stage(stage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. RulePanel + stage rule flows
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_rule_panel_flows() -> void:
|
|
print("")
|
|
print("--- RulePanel filter/edit/delete/add/clear/reorder ---")
|
|
var stage := _new_stage()
|
|
var rigA: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
|
var rigB: StickmanRig = stage._spawner.spawn("stickman", Vector2(400, 0))
|
|
_check(rigA != null and rigB != null, "two stickmen spawn for rule flows")
|
|
if rigA == null or rigB == null:
|
|
await _free_stage(stage)
|
|
return
|
|
|
|
var wp1 := Vector2(100, -100)
|
|
var wp2 := Vector2(300, -300)
|
|
var rule0 := {
|
|
"id": 0,
|
|
"trigger": { "type": "arrived_at_waypoint", "source": rigA.get_instance_id(), "target": -1, "params": { "waypoint_pos": wp1 } },
|
|
"actions": [{ "type": "speak", "target": rigB.get_instance_id(), "params": { "text": "Hi", "duration": 1.0 } }],
|
|
}
|
|
var rule1 := {
|
|
"id": 1,
|
|
"trigger": { "type": "action_finished", "source": rigA.get_instance_id(), "target": -1, "params": {} },
|
|
"actions": [{ "type": "wait", "target": rigB.get_instance_id(), "params": { "duration": 2.0 } }],
|
|
}
|
|
var rule2 := {
|
|
"id": 2,
|
|
"trigger": { "type": "arrived_at_waypoint", "source": rigB.get_instance_id(), "target": -1, "params": { "waypoint_pos": wp2 } },
|
|
"actions": [{ "type": "recover", "target": rigA.get_instance_id(), "params": {} }],
|
|
}
|
|
var rules_arr0: Array[Dictionary] = [rule0, rule1, rule2]
|
|
stage._event_rules = rules_arr0
|
|
|
|
# Filter by source rig.
|
|
stage._open_rules_panel_for_rig(rigA)
|
|
var ids: Array[int] = []
|
|
for r: Dictionary in stage._rule_panel._rules:
|
|
ids.append(int(r.get("id", -1)))
|
|
_check(ids == [0, 1], "rule panel filtered to source rig ids [0,1] (got %s)" % str(ids))
|
|
_check(stage._rule_panel_filter_ids == [0, 1], "filter-id set matches the display list")
|
|
_check(stage._rule_panel._title_label.text.contains(String(rigA.name)), "rule panel titles the source rig")
|
|
_check(stage._rule_panel._rows.size() == 2, "rule panel builds 2 rows")
|
|
|
|
# Row 0's trigger summary names the rig and the trigger label.
|
|
var trig_text := _rule_panel_trigger_text(stage._rule_panel, 0)
|
|
_check(trig_text.contains("Arrives at waypoint"), "rule row shows trigger label (got '%s')" % trig_text)
|
|
|
|
# Filter by waypoint.
|
|
stage._open_trigger_rules_panel(wp1)
|
|
ids.clear()
|
|
for r: Dictionary in stage._rule_panel._rules:
|
|
ids.append(int(r.get("id", -1)))
|
|
_check(ids == [0], "waypoint filter returns only matching rule (got %s)" % str(ids))
|
|
stage._clear_director_pending()
|
|
|
|
# Edit requested opens the full editor from the panel.
|
|
stage._open_rules_panel_for_rig(rigA)
|
|
stage._on_rule_panel_edit_requested(0)
|
|
_check(stage._rule_editor_from_panel, "edit from panel marks restore flag")
|
|
_check(stage._rule_editor._mode == "full" and stage._rule_editor._rule_id == 0, "edit opens full editor with id 0")
|
|
_check(not stage._rule_panel.visible, "panel hides while editor is open")
|
|
stage._clear_director_pending()
|
|
|
|
# Full editor commit preserves the rule id and updates _event_rules.
|
|
stage._open_rule_editor_full(rule0, false)
|
|
stage._rule_editor._actions[0]["params"]["text"] = "Edited rule action"
|
|
stage._rule_editor._on_ok_pressed()
|
|
# _on_ok_pressed emits committed -> _on_rule_editor_committed (connected).
|
|
_check(stage._event_rules.size() == 3, "rule commit keeps rule count")
|
|
_check(int(stage._event_rules[0].get("id", -1)) == 0, "rule edit preserves id 0")
|
|
_check(String(stage._event_rules[0].get("actions", [])[0].get("params", {}).get("text", "")) == "Edited rule action",
|
|
"rule edit updates the action in the registry")
|
|
_check(String(stage._event_rules[0].get("trigger", {}).get("type", "")) == "arrived_at_waypoint",
|
|
"rule edit preserves the trigger type")
|
|
stage._clear_director_pending()
|
|
|
|
# Consequence-only edit via the rule label path (_begin_edit_rule).
|
|
stage._begin_edit_rule(1)
|
|
_check(stage._rule_editor._mode == "consequence", "label edit opens consequence-only editor")
|
|
_check(stage._rule_editor._rule_id == 1, "label edit loads rule id 1")
|
|
stage._clear_director_pending()
|
|
|
|
# Reorder filtered rules: panel shows rigA rules [0,1]; move 0 below 1.
|
|
stage._open_rules_panel_for_rig(rigA)
|
|
var ordered_ids: Array[int] = [1, 0]
|
|
stage._on_rule_panel_reorder_requested(ordered_ids)
|
|
var order_ids: Array[int] = []
|
|
for r: Dictionary in stage._event_rules:
|
|
order_ids.append(int(r.get("id", -1)))
|
|
# rule2 (source rigB, id 2) stays at its own slot; rigA's rules swap within [0,2].
|
|
_check(order_ids == [1, 0, 2], "filtered reorder preserves unfiltered rule slot (got %s)" % str(order_ids))
|
|
stage._clear_director_pending()
|
|
|
|
# Add Rule from the panel launches the (Phase 4) rule builder for that source.
|
|
stage._open_rules_panel_for_rig(rigA)
|
|
stage._on_rule_panel_add_requested()
|
|
_check(stage._rule_build_from_panel, "add from panel marks rule_build_from_panel")
|
|
_check(int(stage._rule_step) == stage.RuleStep.SELECT_TRIGGER, "add rule opens the builder at SELECT_TRIGGER")
|
|
_check(stage._rule_builder.get("trigger", {}).get("source", -1) == rigA.get_instance_id(),
|
|
"add rule seeds the source rig")
|
|
stage._clear_director_pending()
|
|
_check(stage._rule_build_from_panel == false, "mode-exit clears the panel-restore flag")
|
|
|
|
# Add Rule while filtering by waypoint (no stickman source) shows a toast, no build.
|
|
stage._open_trigger_rules_panel(wp1)
|
|
stage._on_rule_panel_add_requested()
|
|
_check(int(stage._rule_step) == stage.RuleStep.IDLE, "add rule without a source is a no-op")
|
|
stage._clear_director_pending()
|
|
|
|
# Delete rule with confirmation removes it from the registry.
|
|
stage._open_rules_panel_for_rig(rigA)
|
|
stage._on_rule_panel_delete_requested(1)
|
|
stage._on_confirm_confirmed()
|
|
var remaining_ids: Array[int] = []
|
|
for r: Dictionary in stage._event_rules:
|
|
remaining_ids.append(int(r.get("id", -1)))
|
|
_check(remaining_ids == [0, 2], "rule delete removes the filtered rule (got %s)" % str(remaining_ids))
|
|
stage._clear_director_pending()
|
|
|
|
# Clear All removes every *filtered* rule only.
|
|
var rules_arr2: Array[Dictionary] = [rule0, rule1, rule2]
|
|
stage._event_rules = rules_arr2
|
|
stage._open_rules_panel_for_rig(rigA)
|
|
stage._on_rule_panel_clear_requested()
|
|
stage._on_confirm_confirmed()
|
|
remaining_ids.clear()
|
|
for r: Dictionary in stage._event_rules:
|
|
remaining_ids.append(int(r.get("id", -1)))
|
|
_check(remaining_ids == [2], "clear-all removes only filtered rules (got %s)" % str(remaining_ids))
|
|
|
|
await _free_stage(stage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. Capture state + Esc priority
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_stage_capture_and_esc() -> void:
|
|
print("")
|
|
print("--- Capture begin/cancel/resolve + Esc priority ---")
|
|
var stage := _new_stage()
|
|
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
|
if rig == null:
|
|
await _free_stage(stage)
|
|
return
|
|
rig.queue_action({ "type": "walk_to", "target": Vector2(300, -300) })
|
|
|
|
var resolved: Array = []
|
|
var cancelled: Array = []
|
|
# POSITION capture resolves to the (snap-adjusted) click position.
|
|
stage._snap_enabled = false
|
|
stage._begin_capture(stage.CaptureKind.POSITION, "hint", func(v): resolved.append(v), func(): cancelled.append(true))
|
|
_check(stage._capture_kind == stage.CaptureKind.POSITION, "begin_capture sets kind")
|
|
_check(stage._capture_hint == "hint", "begin_capture stores hint")
|
|
_check(stage._capture_callback.is_valid() and stage._capture_cancel.is_valid(), "begin_capture stores callbacks")
|
|
stage._resolve_capture(Vector2(12.0, -34.0))
|
|
_check(stage._capture_kind == stage.CaptureKind.NONE, "resolve clears capture")
|
|
_check(resolved.size() == 1 and (resolved[0] as Vector2) == Vector2(12.0, -34.0), "POSITION resolve returns click")
|
|
_check(cancelled.is_empty(), "resolve does not call cancel")
|
|
|
|
# Cancel invokes the on-cancel callback.
|
|
stage._begin_capture(stage.CaptureKind.POSITION, "hint2", func(v): resolved.append(v), func(): cancelled.append(true))
|
|
stage._cancel_capture()
|
|
_check(cancelled.size() == 1, "cancel invokes on-cancel callback")
|
|
_check(stage._capture_kind == stage.CaptureKind.NONE and stage._capture_hint == "", "cancel clears capture state")
|
|
_check(stage._capture_callback.is_valid() == false, "cancel clears the callback")
|
|
|
|
# WAYPOINT capture resolves when clicking a real waypoint dot.
|
|
rig.queue_action({ "type": "walk_to", "target": Vector2(300, -300) })
|
|
stage._open_queue_panel(rig)
|
|
stage._clear_director_pending()
|
|
stage._begin_capture(stage.CaptureKind.WAYPOINT, "pick wp", func(v): resolved.append(v), func(): cancelled.append(true))
|
|
stage._resolve_capture(Vector2(300, -300))
|
|
_check(resolved.size() == 2 and (resolved[1] as Vector2) == Vector2(300, -300), "WAYPOINT resolve returns waypoint pos")
|
|
# A click away from any waypoint does not resolve and keeps capture active.
|
|
stage._begin_capture(stage.CaptureKind.WAYPOINT, "pick wp", func(v): resolved.append(v), func(): cancelled.append(true))
|
|
stage._resolve_capture(Vector2(9999, 9999))
|
|
_check(resolved.size() == 2, "WAYPOINT resolve on empty space is a no-op")
|
|
_check(stage._capture_kind == stage.CaptureKind.WAYPOINT, "capture stays active after a miss")
|
|
stage._cancel_capture()
|
|
|
|
# STICKMAN capture resolves on a rig's visual AABB.
|
|
stage._begin_capture(stage.CaptureKind.STICKMAN, "pick actor", func(v): resolved.append(v), func(): cancelled.append(true))
|
|
var rig_aabb: Rect2 = STAGE_SELECTION.get_world_aabb(rig)
|
|
stage._resolve_capture(rig_aabb.get_center())
|
|
_check(resolved.size() == 3 and (resolved[2] as Node2D) == rig, "STICKMAN capture resolves the rig")
|
|
stage._cancel_capture()
|
|
|
|
# AREA capture resolves on a spawned TriggerArea.
|
|
var area: Node2D = stage._spawner.spawn("area", Vector2(900, -900))
|
|
_check(area != null, "trigger area spawns")
|
|
stage._begin_capture(stage.CaptureKind.AREA, "pick area", func(v): resolved.append(v), func(): cancelled.append(true))
|
|
stage._resolve_capture(Vector2(900, -900))
|
|
_check(resolved.size() == 4 and (resolved[3] as Node2D) == area, "AREA capture resolves the area")
|
|
|
|
# Esc priority: capture cancels before the rule builder.
|
|
stage._begin_capture(stage.CaptureKind.POSITION, "x", func(v): pass, func(): cancelled.append(true))
|
|
stage._rule_step = stage.RuleStep.TRIGGER_TARGET
|
|
stage._rule_builder = { "trigger": { "type": "arrived_at_waypoint" } }
|
|
var esc := InputEventKey.new()
|
|
esc.keycode = KEY_ESCAPE
|
|
esc.pressed = true
|
|
stage._unhandled_key_input(esc)
|
|
_check(stage._capture_kind == stage.CaptureKind.NONE, "Esc while capture cancels capture first")
|
|
_check(int(stage._rule_step) == stage.RuleStep.TRIGGER_TARGET, "Esc while capture leaves the rule builder untouched")
|
|
# Second Esc then cancels the rule build.
|
|
stage._unhandled_key_input(esc)
|
|
_check(int(stage._rule_step) == stage.RuleStep.IDLE, "second Esc cancels the rule build")
|
|
_check(stage._rule_builder.is_empty(), "second Esc empties the builder")
|
|
|
|
# Esc while in a mode transition closes no editor popups when nothing active (still EDIT).
|
|
stage._clear_director_pending()
|
|
stage._unhandled_key_input(esc)
|
|
_check(int(stage.current_mode) == stage.StageMode.EDIT, "Esc with nothing pending keeps EDIT mode")
|
|
|
|
await _free_stage(stage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 10. Waypoint context through the stage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_waypoint_context() -> void:
|
|
print("")
|
|
print("--- Waypoint context open/insert/delete ---")
|
|
var stage := _new_stage()
|
|
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
|
if rig == null:
|
|
await _free_stage(stage)
|
|
return
|
|
var wpA := Vector2(100, -100)
|
|
var wpB := Vector2(200, -200)
|
|
rig.queue_action({ "type": "walk_to", "target": wpA })
|
|
rig.queue_action({ "type": "walk_to", "target": wpB })
|
|
|
|
# Stage counts rules targeting the waypoint for the context menu.
|
|
var wp_rule := {
|
|
"id": 5,
|
|
"trigger": { "type": "arrived_at_waypoint", "source": rig.get_instance_id(), "target": -1, "params": { "waypoint_pos": wpA } },
|
|
"actions": [],
|
|
}
|
|
var wp_rules: Array[Dictionary] = [wp_rule]
|
|
stage._event_rules = wp_rules
|
|
_check(stage._count_rules_for_waypoint(wpA) == 1, "count rules for waypoint == 1")
|
|
_check(stage._count_rules_for_waypoint(wpB) == 0, "count rules for a different waypoint == 0")
|
|
|
|
# Context menu records rig/index/pos and shows the count.
|
|
stage._open_waypoint_context(wpA, { "rig": rig, "index": 0, "pos": wpA })
|
|
_check(stage._ctx_waypoint_rig == rig and stage._ctx_waypoint_index == 0 and stage._ctx_waypoint_pos == wpA,
|
|
"open_waypoint_context records hit state")
|
|
_check(stage._waypoint_context.visible, "waypoint context menu pops up")
|
|
_check(not stage._waypoint_context.is_item_disabled(5), "waypoint context enables trigger rules with count")
|
|
stage._waypoint_context.hide()
|
|
|
|
# Delete this Walk removes the walk action.
|
|
stage._ctx_waypoint_rig = rig
|
|
stage._ctx_waypoint_index = 0
|
|
stage._on_waypoint_context_id_pressed(WaypointContext.DELETE_WALK)
|
|
var q: Array[Dictionary] = rig.get_queue()
|
|
_check(q.size() == 1 and (q[0].get("target", Vector2.ZERO) as Vector2) == wpB,
|
|
"delete walk removes the action (got %d)" % q.size())
|
|
|
|
# Insert before/after opens the ActionEditor with a queue_insert kind.
|
|
stage._ctx_waypoint_rig = rig
|
|
stage._ctx_waypoint_index = 0
|
|
stage._ctx_waypoint_pos = wpB
|
|
stage._on_waypoint_context_id_pressed(WaypointContext.INSERT_BEFORE)
|
|
_check(stage._action_editor_kind == "queue_insert" and stage._action_editor_index == 0,
|
|
"insert-before opens queue_insert at index 0")
|
|
var wait_idx: int = ACTION_REGISTRY.types().find("wait")
|
|
stage._action_editor._type_option.select(wait_idx)
|
|
stage._action_editor._on_type_selected(wait_idx)
|
|
stage._action_editor._duration_spin.value = 0.5
|
|
stage._action_editor._on_ok_pressed()
|
|
q = rig.get_queue()
|
|
_check(q.size() == 2 and q[0].get("type", "") == "wait", "insert-before inserts ahead of the waypoint walk")
|
|
stage._clear_director_pending()
|
|
|
|
# Insert after a real walk action. After the insert-before above the queue is
|
|
# [wait(0), walkB(1)], so the walk to insert after lives at index 1.
|
|
stage._ctx_waypoint_rig = rig
|
|
stage._ctx_waypoint_index = 1
|
|
stage._ctx_waypoint_pos = wpB
|
|
stage._on_waypoint_context_id_pressed(WaypointContext.INSERT_AFTER)
|
|
_check(stage._action_editor_kind == "queue_insert" and stage._action_editor_index == 2,
|
|
"insert-after opens queue_insert at walk index + 1")
|
|
stage._action_editor._type_option.select(wait_idx)
|
|
stage._action_editor._on_type_selected(wait_idx)
|
|
stage._action_editor._duration_spin.value = 0.25
|
|
stage._action_editor._on_ok_pressed()
|
|
q = rig.get_queue()
|
|
_check(q.size() == 3 and q[2].get("type", "") == "wait", "insert-after appends after the walk")
|
|
stage._clear_director_pending()
|
|
|
|
# Edit this Walk begins the visual walk-edit capture.
|
|
stage._ctx_waypoint_rig = rig
|
|
stage._ctx_waypoint_index = 1
|
|
stage._on_waypoint_context_id_pressed(WaypointContext.EDIT_WALK)
|
|
_check(stage._walk_edit_rig == rig and stage._walk_edit_index == 1, "edit walk arms rig + index")
|
|
stage._cancel_capture()
|
|
|
|
# EDIT_TRIGGER_RULES opens the rule panel filtered to that waypoint.
|
|
stage._ctx_waypoint_rig = rig
|
|
stage._ctx_waypoint_pos = wpA
|
|
stage._on_waypoint_context_id_pressed(WaypointContext.EDIT_TRIGGER_RULES)
|
|
var ids: Array[int] = []
|
|
for r: Dictionary in stage._rule_panel._rules:
|
|
ids.append(int(r.get("id", -1)))
|
|
_check(ids == [5], "edit trigger rules filters to the waypoint rule (got %s)" % str(ids))
|
|
stage._clear_director_pending()
|
|
|
|
await _free_stage(stage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 11. StageDirectorVisuals waypoint hit-testing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_visuals_hit_test() -> void:
|
|
print("")
|
|
print("--- StageDirectorVisuals.hit_test_waypoint_action ---")
|
|
var stage := _new_stage()
|
|
stage._camera.position = Vector2.ZERO
|
|
stage._camera.zoom = Vector2(1.0, 1.0)
|
|
var rigA: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
|
var rigB: StickmanRig = stage._spawner.spawn("stickman", Vector2(500, 0))
|
|
_check(rigA != null and rigB != null, "two rigs spawn for hit testing")
|
|
if rigA == null or rigB == null:
|
|
await _free_stage(stage)
|
|
return
|
|
var pa := Vector2(300, -300)
|
|
var pb := Vector2(400, -400)
|
|
var pc := Vector2(-200, -200)
|
|
rigA.queue_action({ "type": "walk_to", "target": pa })
|
|
rigA.queue_action({ "type": "speak", "text": "hi", "duration": 1.0 })
|
|
rigA.queue_action({ "type": "walk_to", "target": pb })
|
|
rigB.queue_action({ "type": "walk_to", "target": pc })
|
|
|
|
var visuals = stage._director_visuals
|
|
var hit: Dictionary = visuals.hit_test_waypoint_action(pa)
|
|
_check(not hit.is_empty() and hit["rig"] == rigA and int(hit["index"]) == 0 and (hit["pos"] as Vector2) == pa,
|
|
"hit_test_waypoint_action finds rigA walk 0")
|
|
hit = visuals.hit_test_waypoint_action(pb)
|
|
_check(not hit.is_empty() and hit["rig"] == rigA and int(hit["index"]) == 2,
|
|
"hit_test_waypoint_action skips non-walk actions and finds rigA walk 2")
|
|
hit = visuals.hit_test_waypoint_action(pc)
|
|
_check(not hit.is_empty() and hit["rig"] == rigB and int(hit["index"]) == 0,
|
|
"hit_test_waypoint_action finds rigB walk 0")
|
|
_check(visuals.hit_test_waypoint_action(Vector2(99999, 99999)).is_empty(), "hit test misses far away")
|
|
# hit_test_waypoint returns just the position.
|
|
var wp: Vector2 = visuals.hit_test_waypoint(pa)
|
|
_check(wp == pa, "hit_test_waypoint returns the position")
|
|
_check(visuals.hit_test_waypoint(Vector2(99999, 99999)) == Vector2.INF, "hit_test_waypoint returns INF on miss")
|
|
|
|
# set_edit_waypoint/clear highlight state (used by visual walk editing).
|
|
visuals.set_edit_waypoint(pa)
|
|
_check(visuals._edit_waypoint == pa, "set_edit_waypoint stores the position")
|
|
visuals.clear_edit_waypoint()
|
|
_check(not visuals._edit_waypoint.is_finite(), "clear_edit_waypoint resets to INF")
|
|
|
|
await _free_stage(stage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 12. Stage action-popup / rig context entries (Phase 3c entry points)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_stage_context_entries() -> void:
|
|
print("")
|
|
print("--- Stage context-menu entry points (Edit Queue / Edit Rules) ---")
|
|
var stage := _new_stage()
|
|
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
|
if rig == null:
|
|
await _free_stage(stage)
|
|
return
|
|
|
|
# The Director action popup has Edit Queue…/Edit Rules… items.
|
|
var found_edit_queue := false
|
|
var found_edit_rules := false
|
|
for i: int in stage._action_popup.item_count:
|
|
var id: int = stage._action_popup.get_item_id(i)
|
|
if id == stage.ACT_EDIT_QUEUE:
|
|
found_edit_queue = true
|
|
if id == stage.ACT_EDIT_RULES:
|
|
found_edit_rules = true
|
|
_check(found_edit_queue and found_edit_rules, "action popup gains Edit Queue/Rules items")
|
|
|
|
# ACT_EDIT_QUEUE opens the queue panel for the context rig.
|
|
stage._context_rig = rig
|
|
stage._on_action_popup_id_pressed(stage.ACT_EDIT_QUEUE)
|
|
_check(stage._queue_panel.rig == rig, "Edit Queue opens the queue panel for the context rig")
|
|
stage._clear_director_pending()
|
|
|
|
# ACT_EDIT_RULES opens the rule panel filtered to the context rig.
|
|
stage._context_rig = rig
|
|
stage._on_action_popup_id_pressed(stage.ACT_EDIT_RULES)
|
|
_check(stage._rule_panel_source_id == rig.get_instance_id(), "Edit Rules seeds the source filter")
|
|
stage._clear_director_pending()
|
|
|
|
# The stickman right-click context popup has both items.
|
|
var r0: int = stage._rig_context_popup.get_item_id(0)
|
|
var r1: int = stage._rig_context_popup.get_item_id(1)
|
|
_check(r0 == stage.RIG_CTX_EDIT_QUEUE and r1 == stage.RIG_CTX_EDIT_RULES,
|
|
"rig context popup items map to Edit Queue/Rules (got %d,%d)" % [r0, r1])
|
|
stage._panel_rig = rig
|
|
stage._on_rig_context_id_pressed(stage.RIG_CTX_EDIT_QUEUE)
|
|
_check(stage._queue_panel.rig == rig, "rig context Edit Queue opens the queue panel")
|
|
stage._clear_director_pending()
|
|
stage._panel_rig = rig
|
|
stage._on_rig_context_id_pressed(stage.RIG_CTX_EDIT_RULES)
|
|
_check(stage._rule_panel_source_id == rig.get_instance_id(), "rig context Edit Rules seeds the filter")
|
|
|
|
# Mode exit hides the editor popups (no stale exclusive window).
|
|
stage._open_queue_panel(rig)
|
|
stage._open_rules_panel_for_rig(rig)
|
|
stage._open_rule_editor_full({ "id": 1, "trigger": { "type": "action_finished", "source": rig.get_instance_id(), "params": {} }, "actions": [] }, false)
|
|
stage.set_mode(stage.StageMode.PLAY)
|
|
_check(not stage._queue_panel.visible and not stage._rule_panel.visible and not stage._rule_editor.visible,
|
|
"entering PLAY hides Phase 3c popups")
|
|
stage.set_mode(stage.StageMode.EDIT)
|
|
|
|
await _free_stage(stage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 13. Backward compatibility
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_backward_compat() -> void:
|
|
print("")
|
|
print("--- Backward compatibility: existing queues/rules edit without loss ---")
|
|
var stage := _new_stage()
|
|
var rigA: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
|
var rigB: StickmanRig = stage._spawner.spawn("stickman", Vector2(400, 0))
|
|
if rigA == null or rigB == null:
|
|
await _free_stage(stage)
|
|
return
|
|
|
|
# A "pre-existing" queue authored with the legacy Phase 3a append API shows
|
|
# in the panel and every action type/param survives an edit round-trip.
|
|
# (Duration 1.5 is a 0.1-step-representable value, matching what the legacy
|
|
# SpinBox UI could author; the editor's own duration spin uses step 0.1.)
|
|
rigA.queue_action({ "type": "walk_to", "target": Vector2(60, -60) })
|
|
rigA.queue_action({ "type": "speak", "text": "Legacy", "duration": 1.5 })
|
|
rigA.queue_action({ "type": "wait", "duration": 2.0 })
|
|
var snapshot: Array[Dictionary] = rigA.get_queue()
|
|
_check(snapshot.size() == 3, "legacy queue has 3 actions")
|
|
|
|
stage._open_queue_panel(rigA)
|
|
_check(stage._queue_panel._rows.size() == 3, "legacy queue displays 3 rows")
|
|
# Edit the legacy speak action preserving type/params except the edited text.
|
|
stage._on_queue_panel_edit_requested(1)
|
|
_check(stage._action_editor._current_type() == "speak", "legacy speak pre-fills type")
|
|
_check(stage._action_editor._text_edit.text == "Legacy", "legacy speak pre-fills text")
|
|
_check(is_equal_approx(stage._action_editor._duration_spin.value, 1.5), "legacy speak pre-fills duration")
|
|
stage._action_editor._text_edit.text = "Legacy v2"
|
|
stage._action_editor._on_ok_pressed()
|
|
var q: Array[Dictionary] = rigA.get_queue()
|
|
_check(q.size() == 3, "legacy queue size preserved after edit")
|
|
_check(String(q[1].get("text", "")) == "Legacy v2" and float(q[1].get("duration", 0.0)) == 1.5,
|
|
"legacy speak edit keeps duration and replaces text")
|
|
_check((q[0].get("target", Vector2.ZERO) as Vector2) == Vector2(60, -60),
|
|
"legacy walk target preserved")
|
|
stage._clear_director_pending()
|
|
|
|
# A "pre-existing" Phase 4 rule (rule-action shape) displays and edits with
|
|
# its rule id preserved.
|
|
var legacy_rule := {
|
|
"id": 0,
|
|
"trigger": {
|
|
"type": "action_finished",
|
|
"source": rigA.get_instance_id(),
|
|
"target": -1,
|
|
"params": {},
|
|
},
|
|
"actions": [
|
|
{ "type": "speak", "target": rigB.get_instance_id(), "params": { "text": "old", "duration": 1.0 } },
|
|
],
|
|
}
|
|
var legacy_rules: Array[Dictionary] = [legacy_rule]
|
|
stage._event_rules = legacy_rules
|
|
stage._open_rules_panel_for_rig(rigA)
|
|
_check(stage._rule_panel._rows.size() == 1, "legacy rule displays in the panel")
|
|
|
|
# Editing the legacy rule through the full editor preserves its id + trigger.
|
|
stage._on_rule_panel_edit_requested(0)
|
|
_check(stage._rule_editor._rule_id == 0, "legacy rule edit preserves id in editor")
|
|
stage._rule_editor.set_action(0, { "type": "wait", "target": rigB.get_instance_id(), "params": { "duration": 4.0 } })
|
|
stage._rule_editor._on_ok_pressed()
|
|
_check(stage._event_rules.size() == 1, "legacy rule count preserved")
|
|
_check(int(stage._event_rules[0].get("id", -1)) == 0, "legacy rule id preserved on commit")
|
|
var edited_action: Dictionary = (stage._event_rules[0].get("actions", []) as Array)[0]
|
|
_check(String(edited_action.get("type", "")) == "wait" and float(edited_action.get("params", {}).get("duration", 0.0)) == 4.0,
|
|
"legacy rule action type/params updated in place")
|
|
_check(String(stage._event_rules[0].get("trigger", {}).get("type", "")) == "action_finished",
|
|
"legacy rule trigger type preserved")
|
|
stage._clear_director_pending()
|
|
|
|
await _free_stage(stage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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 _queue_panel_summaries(panel: QueuePanel) -> Array[String]:
|
|
var out: Array[String] = []
|
|
for row: PanelContainer in panel._rows:
|
|
var hbox := row.get_child(0) as HBoxContainer
|
|
var summary := hbox.get_child(1) as Label
|
|
out.append(summary.text)
|
|
return out
|
|
|
|
|
|
func _type_list(queue: Array[Dictionary]) -> Array[String]:
|
|
var out: Array[String] = []
|
|
for a: Dictionary in queue:
|
|
out.append(String(a.get("type", "")))
|
|
return out
|
|
|
|
|
|
func _rule_panel_trigger_text(panel: RulePanel, row_index: int) -> String:
|
|
var row: PanelContainer = panel._rows[row_index]
|
|
var hbox := row.get_child(0) as HBoxContainer
|
|
var body := hbox.get_child(1) as VBoxContainer
|
|
return (body.get_child(0) as Label).text
|
|
|
|
|
|
func _check(condition: bool, message: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print("PASS: " + message)
|
|
else:
|
|
_failures += 1
|
|
print("FAIL: " + message)
|