test(@projects/@magic-civilization): add end-to-end ai promotion gut test

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-06 12:29:30 -07:00
parent 8204c095b4
commit bd6a34c603

View file

@ -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")