Files
stickman/tests/test_phase3c_editor.gd
T

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)