diff --git a/src/game/engine/tests/integration/test_p2_44_ai_promotion_end_to_end.gd b/src/game/engine/tests/integration/test_p2_44_ai_promotion_end_to_end.gd new file mode 100644 index 00000000..25bc10bd --- /dev/null +++ b/src/game/engine/tests/integration/test_p2_44_ai_promotion_end_to_end.gd @@ -0,0 +1,120 @@ +extends GutTest +## p2-44 — End-to-end GUT verification that AI-owned units promote and that +## the `EventBus.unit_promoted` signal fires through the live dispatch path. +## +## Headless-compatible: no rendering, no UI. Drives the dispatch entry point +## directly with a synthetic `PromotionPicked` action JSON, mirroring the +## same envelope `AiTurnBridge` ships from Rust. Asserts: +## 1. `unit.promote(promo_id)` applied (veteran_level += 1, promo_ids += id) +## 2. `EventBus.unit_promoted` emitted exactly once with (unit, promo_id) +## 3. `dispatch_action` returns true for the PromotionPicked variant +## +## This locks the GDScript half of the p2-44 chain — the Rust picker side is +## covered by 5 unit tests in `mc-ai::tactical::promotion::tests`. The full +## chain (build_tactical_state -> Rust pick_promotion -> dispatch) is gated +## by the apricot 10-seed batch counting `unit_promoted` rows in events.jsonl. + +const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") +const DispatchScript: GDScript = preload( + "res://engine/src/modules/ai/ai_turn_bridge_dispatch.gd" +) + +var _emitted: Array[Dictionary] = [] + + +func before_each() -> void: + _emitted = [] + if EventBus.unit_promoted.is_connected(_on_unit_promoted): + EventBus.unit_promoted.disconnect(_on_unit_promoted) + EventBus.unit_promoted.connect(_on_unit_promoted) + + +func after_each() -> void: + if EventBus.unit_promoted.is_connected(_on_unit_promoted): + EventBus.unit_promoted.disconnect(_on_unit_promoted) + + +func _on_unit_promoted(unit: Variant, promotion: String) -> void: + _emitted.append({"unit": unit, "promotion": promotion}) + + +func _make_ai_unit(uid: int) -> Unit: + var unit: Unit = UnitScript.new("dwarf_warrior", 0, Vector2i(3, 3)) + unit.id = "ai_unit_%d" % uid + unit.hp = 10 + unit.max_hp = 10 + unit.xp = 12 + unit.veteran_level = 0 + return unit + + +func test_dispatch_promotion_picked_promotes_and_emits() -> void: + # Arrange — synthetic AI unit eligible for tier-1 promotion. + var unit: Unit = _make_ai_unit(7) + var index_maps: Dictionary = {"units": {7: unit}, "cities": {}} + var prev_level: int = unit.veteran_level + var action_str: String = JSON.stringify({ + "PromotionPicked": {"unit_id": 7, "promotion_id": "shock"}, + }) + + # Act — drive the same entry point AiTurnBridge uses on AI turns. + var ok: bool = DispatchScript.dispatch_action( + action_str, null, index_maps, "" + ) + + # Assert — return value, mutation, signal. + assert_true(ok, "dispatch_action should accept PromotionPicked variant") + assert_eq( + unit.veteran_level, prev_level + 1, + "veteran_level must increment on dispatch" + ) + assert_true( + unit.promo_ids.has("shock"), + "promo_ids must contain the picked promotion" + ) + assert_eq( + _emitted.size(), 1, + "EventBus.unit_promoted must fire exactly once per dispatch" + ) + if _emitted.size() == 1: + assert_eq( + _emitted[0]["promotion"], "shock", + "signal payload promotion id must match dispatched id" + ) + assert_eq( + _emitted[0]["unit"], unit, + "signal payload unit must be the same RefCounted instance" + ) + + +func test_dispatch_rejects_dead_unit() -> void: + # A dead AI unit must not promote — guards against late dispatch races. + var unit: Unit = _make_ai_unit(11) + unit.hp = 0 + var index_maps: Dictionary = {"units": {11: unit}, "cities": {}} + var action_str: String = JSON.stringify({ + "PromotionPicked": {"unit_id": 11, "promotion_id": "shock"}, + }) + + var ok: bool = DispatchScript.dispatch_action( + action_str, null, index_maps, "" + ) + + assert_false(ok, "dispatch_action must reject dead-unit promotion") + assert_eq(unit.veteran_level, 0, "dead unit must not promote") + assert_eq(_emitted.size(), 0, "no signal must fire for dead-unit dispatch") + + +func test_dispatch_rejects_empty_promotion_id() -> void: + var unit: Unit = _make_ai_unit(13) + var index_maps: Dictionary = {"units": {13: unit}, "cities": {}} + var action_str: String = JSON.stringify({ + "PromotionPicked": {"unit_id": 13, "promotion_id": ""}, + }) + + var ok: bool = DispatchScript.dispatch_action( + action_str, null, index_maps, "" + ) + + assert_false(ok, "empty promotion_id must be rejected") + assert_eq(_emitted.size(), 0, "no signal for empty promotion_id")