- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions. - Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates. - Add terrain drag-painting tests to verify correct block placement behavior and conflict handling. - Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
467 lines
18 KiB
GDScript
467 lines
18 KiB
GDScript
# test_phase4b_popup_anchor.gd
|
|
# Headless regression suite for the Phase 4b rule-builder popup-anchor fix
|
|
# (SandboxStage session popup anchor):
|
|
#
|
|
# Bug (popup jumps / follows the live mouse across the When->Back cycle):
|
|
# The rule-builder menus ("⚡ When..." -> trigger sub-menu -> "⬅ Back to
|
|
# actions" -> ...) previously recomputed their popup screen rect from the
|
|
# live mouse position on every hop, so the menus could land far away from
|
|
# the stickman whose action popup opened the session.
|
|
#
|
|
# Fix: scripts/sandbox_stage.gd now keeps session anchor state
|
|
# `_popup_anchor: Rect2i` / `_popup_anchor_set: bool` plus helpers
|
|
# `_set_popup_anchor(rect)`, `_clear_popup_anchor()`, and
|
|
# `_popup_anchor_rect()` (self-records from `_mouse_popup_rect()` when
|
|
# unset). The first context menu of a session records the anchor:
|
|
# `_handle_direct_click` records its rect and `_begin_edit_rule` records
|
|
# the mouse rect. Every child popup in the session reuses it
|
|
# (`ACT_WHEN`, `_open_rule_action_popup`, `_open_rule_more_popup`,
|
|
# `TRIG_BACK`). It is cleared on `_finalize_rule()`,
|
|
# `_cancel_rule_build()`, and `_clear_director_pending()`, but NOT by
|
|
# `_reset_rule_builder()` (TRIG_BACK re-opens the action popup at the same
|
|
# anchor) and NOT by `RULE_MORE_ADD`.
|
|
#
|
|
# Coverage:
|
|
# * Anchor primitives + `_popup_anchor_rect()` self-recording when unset.
|
|
# * Direct first menu records the anchor from the stickman's screen rect.
|
|
# * When -> Back -> When -> Back keeps the stored anchor IDENTICAL on every
|
|
# hop (the OLD code recomputed from the live mouse each hop; the anchor
|
|
# here is chosen far from the headless mouse rect so a recompute would
|
|
# visibly change it).
|
|
# * `_cancel_rule_build()` clears the anchor.
|
|
# * `_finalize_rule()` clears the anchor.
|
|
# * `_clear_director_pending()` (mode exit) clears the anchor.
|
|
# * `_begin_edit_rule(id)` records a fresh anchor.
|
|
# * `_reset_rule_builder()` does NOT clear the anchor (TRIG_BACK invariant).
|
|
#
|
|
# Run with:
|
|
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b_popup_anchor.gd --path .
|
|
#
|
|
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
|
|
|
|
extends SceneTree
|
|
|
|
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
|
|
const 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(120.0)
|
|
watchdog.timeout.connect(func() -> void:
|
|
print("FAIL: watchdog timeout - test run aborted before quit()")
|
|
quit(2))
|
|
|
|
print("")
|
|
print("========================================================")
|
|
print(" PHASE 4b POPUP-ANCHOR REGRESSION TEST (headless)")
|
|
print("========================================================")
|
|
|
|
_test_anchor_primitives_and_self_record()
|
|
_test_direct_first_menu_records_anchor()
|
|
await _test_when_back_cycle_keeps_anchor()
|
|
_test_cancel_clears_anchor()
|
|
_test_confirm_clears_anchor()
|
|
_test_mode_exit_clears_anchor()
|
|
await _test_edit_rule_records_fresh_anchor()
|
|
_test_reset_preserves_anchor()
|
|
|
|
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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Anchor state primitives + lazy self-record
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_anchor_primitives_and_self_record() -> void:
|
|
print("")
|
|
print("--- Anchor primitives + self-recording on first use ---")
|
|
var stage := _new_stage()
|
|
|
|
_check(not stage._popup_anchor_set,
|
|
"session starts with _popup_anchor_set == false")
|
|
_check(stage._popup_anchor == Rect2i(),
|
|
"session starts with an empty _popup_anchor rect")
|
|
|
|
stage._set_popup_anchor(Rect2i(123, 456, 0, 0))
|
|
_check(stage._popup_anchor_set,
|
|
"_set_popup_anchor() marks the anchor as set")
|
|
_check(stage._popup_anchor == Rect2i(123, 456, 0, 0),
|
|
"_set_popup_anchor() stores the given rect exactly")
|
|
|
|
stage._clear_popup_anchor()
|
|
_check(not stage._popup_anchor_set,
|
|
"_clear_popup_anchor() clears the set flag")
|
|
_check(stage._popup_anchor == Rect2i(),
|
|
"_clear_popup_anchor() zeroes the stored rect")
|
|
|
|
# Lazy self-record: first use when unset records the mouse rect.
|
|
var mouse_rect: Rect2i = stage._mouse_popup_rect()
|
|
var recorded: Rect2i = stage._popup_anchor_rect()
|
|
_check(recorded == mouse_rect,
|
|
"_popup_anchor_rect() when unset returns the current mouse rect")
|
|
_check(stage._popup_anchor_set,
|
|
"_popup_anchor_rect() when unset records the anchor as set")
|
|
_check(stage._popup_anchor == mouse_rect,
|
|
"self-recorded anchor matches the mouse rect exactly")
|
|
|
|
# A second call reuses the stored value instead of re-reading the mouse.
|
|
var recorded2: Rect2i = stage._popup_anchor_rect()
|
|
_check(recorded2 == stage._popup_anchor,
|
|
"subsequent _popup_anchor_rect() calls reuse the stored anchor")
|
|
|
|
await _free_stage(stage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Direct first menu records the anchor from the stickman's screen rect
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_direct_first_menu_records_anchor() -> void:
|
|
print("")
|
|
print("--- Direct first menu records the session anchor ---")
|
|
var stage := _new_stage()
|
|
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
|
_check(rig != null, "stickman rig spawns for the direct-click test")
|
|
if rig == null:
|
|
await _free_stage(stage)
|
|
return
|
|
|
|
# Deterministic camera so the expected screen rect is known exactly.
|
|
stage._camera.position = Vector2(3000.0, -400.0)
|
|
stage._camera.zoom = Vector2(0.5, 0.5)
|
|
|
|
# The click routes through the real _selection.hit_test path: click the
|
|
# center of the rig's world AABB.
|
|
var aabb: Rect2 = STAGE_SELECTION.get_world_aabb(rig)
|
|
var click_pos: Vector2 = aabb.get_center()
|
|
stage._handle_direct_click(click_pos)
|
|
|
|
var expected := Rect2i(
|
|
Vector2i(stage._world_to_screen(rig.global_position)) + Vector2i(24, 0),
|
|
Vector2i.ZERO)
|
|
|
|
_check(stage._context_rig == rig,
|
|
"direct click selects the rig under the cursor (context rig set)")
|
|
_check(stage._popup_anchor_set,
|
|
"direct first menu records the session anchor (set flag on)")
|
|
_check(stage._popup_anchor == expected,
|
|
"direct first menu records the stickman screen rect + 24px offset (got %s)"
|
|
% str(stage._popup_anchor))
|
|
|
|
# The anchor must be DISTINCT from the headless mouse rect, otherwise the
|
|
# When/Back hop discriminator could not tell reuse from recompute.
|
|
_check(stage._popup_anchor != stage._mouse_popup_rect(),
|
|
"recorded anchor differs from the live mouse rect (discriminator valid)")
|
|
|
|
await _free_stage(stage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When -> Back -> When -> Back keeps the stored anchor identical on every hop
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_when_back_cycle_keeps_anchor() -> void:
|
|
print("")
|
|
print("--- When/Back cycle reuses the stored anchor on every hop ---")
|
|
var stage := _new_stage()
|
|
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
|
_check(rig != null, "stickman rig spawns for the When/Back cycle")
|
|
if rig == null:
|
|
await _free_stage(stage)
|
|
return
|
|
|
|
stage._camera.position = Vector2(3000.0, -400.0)
|
|
stage._camera.zoom = Vector2(0.5, 0.5)
|
|
|
|
# Session start mirrors the real UI flow: a Direct click on the stickman
|
|
# opens the ACTION popup and records the session anchor at the stickman's
|
|
# screen rect (far from the live mouse). Then the user presses "⚡ When...".
|
|
var aabb: Rect2 = STAGE_SELECTION.get_world_aabb(rig)
|
|
stage._handle_direct_click(aabb.get_center())
|
|
var expected: Rect2i = Rect2i(
|
|
Vector2i(stage._world_to_screen(rig.global_position)) + Vector2i(24, 0),
|
|
Vector2i.ZERO)
|
|
_check(stage._context_rig == rig,
|
|
"direct click arms the rig before the When/Back cycle")
|
|
_check(stage._popup_anchor_set and stage._popup_anchor == expected,
|
|
"session anchor recorded by the direct first menu before the hops")
|
|
|
|
stage._on_action_popup_id_pressed(stage.ACT_WHEN)
|
|
_check(stage._popup_anchor_set,
|
|
"When... keeps the recorded session anchor (set flag on)")
|
|
var r1: Rect2i = stage._popup_anchor
|
|
_check(stage._popup_anchor == expected,
|
|
"When... does not re-record over the direct-click anchor")
|
|
_check(stage._trigger_popup.visible,
|
|
"trigger popup is open after When...")
|
|
_check(int(stage._rule_step) == 1,
|
|
"After When... the builder sits at SELECT_TRIGGER (got %d)"
|
|
% int(stage._rule_step))
|
|
|
|
# Back to the action popup.
|
|
stage._on_trigger_popup_id_pressed(stage.TRIG_BACK)
|
|
_check(stage._popup_anchor_set,
|
|
"TRIG_BACK keeps the session anchor set (no clear on reset)")
|
|
_check(stage._popup_anchor == r1,
|
|
"TRIG_BACK leaves the stored anchor unchanged (hop 1)")
|
|
_check(int(stage._rule_step) == 0,
|
|
"TRIG_BACK resets the builder to IDLE (got %d)" % int(stage._rule_step))
|
|
_check(stage._rule_builder.is_empty(),
|
|
"TRIG_BACK empties the rule builder dict")
|
|
_check(stage._action_popup.visible,
|
|
"TRIG_BACK re-opens the ACTION popup (no dead-end)")
|
|
|
|
# When... again from the action popup.
|
|
stage._on_action_popup_id_pressed(stage.ACT_WHEN)
|
|
_check(stage._popup_anchor_set,
|
|
"second When... still has the anchor set")
|
|
_check(stage._popup_anchor == r1,
|
|
"second When... reuses the stored anchor (hop 2, not the live mouse)")
|
|
_check(int(stage._rule_step) == 1,
|
|
"second When... returns to SELECT_TRIGGER (got %d)" % int(stage._rule_step))
|
|
_check(stage._trigger_popup.visible,
|
|
"trigger popup re-opens after the second When...")
|
|
|
|
# Back again.
|
|
stage._on_trigger_popup_id_pressed(stage.TRIG_BACK)
|
|
_check(stage._popup_anchor == r1,
|
|
"second TRIG_BACK keeps the identical stored anchor (hop 3)")
|
|
_check(stage._popup_anchor_set,
|
|
"second TRIG_BACK leaves the anchor set (session still active)")
|
|
_check(int(stage._rule_step) == 0,
|
|
"second TRIG_BACK resets the builder again (got %d)"
|
|
% int(stage._rule_step))
|
|
|
|
# Discriminator sanity: the recorded anchor is NOT the live mouse rect, so
|
|
# any code path that recomputed the rect per hop would have changed r1.
|
|
_check(stage._popup_anchor != stage._mouse_popup_rect(),
|
|
"anchor stayed distinct from the live mouse rect the whole cycle")
|
|
_check(stage._popup_anchor == r1,
|
|
"anchor is byte-for-byte identical across the whole When/Back cycle")
|
|
|
|
await _free_stage(stage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cancel clears the anchor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_cancel_clears_anchor() -> void:
|
|
print("")
|
|
print("--- _cancel_rule_build() clears the session anchor ---")
|
|
var stage := _new_stage()
|
|
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
|
_check(rig != null, "stickman rig spawns for the cancel test")
|
|
if rig == null:
|
|
await _free_stage(stage)
|
|
return
|
|
stage._context_rig = rig
|
|
|
|
# Enter a rule build (SELECT_TRIGGER) through the real handler.
|
|
stage._set_popup_anchor(Rect2i(900, 700, 0, 0))
|
|
stage._on_action_popup_id_pressed(stage.ACT_WHEN)
|
|
_check(stage._popup_anchor_set,
|
|
"When... keeps the pre-recorded anchor (set flag on)")
|
|
_check(int(stage._rule_step) == 1,
|
|
"rule build sits at SELECT_TRIGGER before cancel (got %d)"
|
|
% int(stage._rule_step))
|
|
|
|
stage._cancel_rule_build()
|
|
_check(not stage._popup_anchor_set,
|
|
"cancel clears _popup_anchor_set")
|
|
_check(stage._popup_anchor == Rect2i(),
|
|
"cancel zeroes the stored anchor")
|
|
_check(int(stage._rule_step) == 0,
|
|
"cancel resets the builder to IDLE (got %d)" % int(stage._rule_step))
|
|
_check(stage._rule_builder.is_empty(),
|
|
"cancel empties the rule builder dict")
|
|
|
|
await _free_stage(stage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Confirm clears the anchor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_confirm_clears_anchor() -> void:
|
|
print("")
|
|
print("--- _finalize_rule() clears the session anchor ---")
|
|
var stage := _new_stage()
|
|
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
|
_check(rig != null, "stickman rig spawns for the confirm test")
|
|
if rig == null:
|
|
await _free_stage(stage)
|
|
return
|
|
|
|
# A mid-flight rule build whose trigger references the live rig (real rule
|
|
# builds always carry a valid source; this also keeps the director visuals'
|
|
# rule-draw path resolvable after _finalize_rule -> set_rules).
|
|
stage._rule_builder = {
|
|
"trigger": { "type": "action_finished", "source": rig.get_instance_id(), "target": -1, "params": {} },
|
|
"actions": [
|
|
{ "type": "speak", "target": rig.get_instance_id(), "params": { "text": "hi", "duration": 1.0 } },
|
|
],
|
|
}
|
|
stage._rule_step = 1 # a builder is mid-flight (SELECT_TRIGGER)
|
|
stage._set_popup_anchor(Rect2i(400, 300, 0, 0))
|
|
_check(stage._popup_anchor_set, "anchor is set before confirm")
|
|
|
|
var before: int = stage._event_rules.size()
|
|
stage._finalize_rule()
|
|
_check(not stage._popup_anchor_set,
|
|
"confirm clears _popup_anchor_set")
|
|
_check(stage._popup_anchor == Rect2i(),
|
|
"confirm zeroes the stored anchor")
|
|
_check(stage._event_rules.size() == before + 1,
|
|
"confirm finalized one new rule (got %d -> %d)"
|
|
% [before, stage._event_rules.size()])
|
|
_check(not stage._event_rules.is_empty()
|
|
and String(stage._event_rules[-1].get("trigger", {}).get("type", "")) == "action_finished",
|
|
"finalized rule carries the in-progress trigger")
|
|
_check(int(stage._rule_step) == 0,
|
|
"confirm resets the builder to IDLE (got %d)" % int(stage._rule_step))
|
|
|
|
await _free_stage(stage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mode exit clears the anchor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_mode_exit_clears_anchor() -> void:
|
|
print("")
|
|
print("--- _clear_director_pending() clears the session anchor ---")
|
|
var stage := _new_stage()
|
|
|
|
stage._pending_walk_target = true
|
|
stage._set_popup_anchor(Rect2i(777, 888, 0, 0))
|
|
_check(stage._popup_anchor_set, "anchor is set before mode exit")
|
|
|
|
stage._clear_director_pending()
|
|
_check(not stage._popup_anchor_set,
|
|
"mode exit clears _popup_anchor_set")
|
|
_check(stage._popup_anchor == Rect2i(),
|
|
"mode exit zeroes the stored anchor")
|
|
_check(not stage._pending_walk_target,
|
|
"mode exit still cancels a pending walk target")
|
|
_check(stage._context_rig == null,
|
|
"mode exit still clears the context rig")
|
|
|
|
await _free_stage(stage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Edit-rule entry records a fresh anchor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_edit_rule_records_fresh_anchor() -> void:
|
|
print("")
|
|
print("--- _begin_edit_rule() records a fresh anchor ---")
|
|
var stage := _new_stage()
|
|
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
|
_check(rig != null, "stickman rig spawns for the edit-rule test")
|
|
if rig == null:
|
|
await _free_stage(stage)
|
|
return
|
|
|
|
# An existing authored rule referencing the rig as its source (the rule
|
|
# label click path populates _event_rules the same way).
|
|
var stored_rule := {
|
|
"id": 0,
|
|
"trigger": {
|
|
"type": "action_finished",
|
|
"source": rig.get_instance_id(),
|
|
"target": -1,
|
|
"params": {},
|
|
},
|
|
"actions": [
|
|
{ "type": "speak", "target": rig.get_instance_id(), "params": { "text": "hi", "duration": 1.0 } },
|
|
],
|
|
}
|
|
var rules_arr: Array[Dictionary] = [stored_rule]
|
|
stage._event_rules = rules_arr
|
|
|
|
# A stale anchor from a previous session must be replaced, not reused.
|
|
stage._set_popup_anchor(Rect2i(1, 2, 0, 0))
|
|
stage._begin_edit_rule(0)
|
|
var mouse_rect: Rect2i = stage._mouse_popup_rect()
|
|
_check(stage._popup_anchor_set,
|
|
"edit-rule entry records the anchor (set flag on)")
|
|
_check(stage._popup_anchor == mouse_rect,
|
|
"edit-rule entry records a FRESH mouse rect (got %s, mouse %s)"
|
|
% [str(stage._popup_anchor), str(mouse_rect)])
|
|
_check(stage._rule_editing_id == 0,
|
|
"edit-rule entry arms _rule_editing_id (got %d)" % stage._rule_editing_id)
|
|
_check(int(stage._rule_step) == 3,
|
|
"edit-rule entry sits at SELECT_ACTION (got %d)" % int(stage._rule_step))
|
|
_check(stage._rule_context_rig == rig,
|
|
"edit-rule entry resolves the rule source rig")
|
|
_check(stage._rule_action_popup.visible,
|
|
"edit-rule entry opens the rule-action popup")
|
|
|
|
await _free_stage(stage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _reset_rule_builder preserves the anchor (TRIG_BACK invariant)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _test_reset_preserves_anchor() -> void:
|
|
print("")
|
|
print("--- _reset_rule_builder() preserves the session anchor ---")
|
|
var stage := _new_stage()
|
|
|
|
# TRIG_BACK calls _reset_rule_builder() and THEN re-opens the action popup at
|
|
# _popup_anchor_rect(), so a reset that cleared the anchor would re-record
|
|
# from the live mouse and lose the session position.
|
|
stage._set_popup_anchor(Rect2i(555, 444, 0, 0))
|
|
stage._reset_rule_builder()
|
|
_check(stage._popup_anchor_set,
|
|
"reset keeps _popup_anchor_set true (TRIG_BACK must preserve the anchor)")
|
|
_check(stage._popup_anchor == Rect2i(555, 444, 0, 0),
|
|
"reset leaves the stored anchor untouched")
|
|
_check(int(stage._rule_step) == 0,
|
|
"reset still returns the builder to IDLE (got %d)" % int(stage._rule_step))
|
|
|
|
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 _check(condition: bool, message: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print("PASS: " + message)
|
|
else:
|
|
_failures += 1
|
|
print("FAIL: " + message)
|