Files
Fighting_Rthythm_game/tests/test_action_controller_flow.gd
2026-07-02 09:47:52 -07:00

212 lines
9.8 KiB
GDScript

extends SceneTree
const InputIntentScript := preload("res://scenes/components/input_intent.gd")
var failures: Array[String] = []
var rejected: Array[StringName] = []
var started: Array[StringName] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
var scene: PackedScene = load("res://scenes/characters/player.tscn")
var player := scene.instantiate()
root.add_child(player)
await process_frame
var controller := player.get_node_or_null("ActionController")
_expect_bool(controller != null, true, "Player should have ActionController")
if controller == null:
player.free()
_finish()
return
controller.connect("action_rejected", _on_action_rejected)
controller.connect("action_started", _on_action_started)
player.get_node("ComboWindow").record(&"S")
var miss: RefCounted = InputIntentScript.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec()))
miss.judgement = {"label": "miss", "diff": 0.0}
controller.call("submit_intent", miss)
_expect_array(player.call("get_combo_slots"), [&"S", &"Ø"], "Miss intent should enter ComboWindow as an explicit placeholder")
_expect_string(str(rejected[rejected.size() - 1]), "miss", "Miss intent rejection reason")
await create_timer(0.4).timeout
await process_frame
_expect_array(player.call("get_combo_slots"), [&"S", &"Ø"], "Miss intent should not clear ComboWindow by itself")
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("EnergyComponent").set_current(10)
controller.call("_reset_to_idle")
rejected.clear()
controller.call("submit_intent", _perfect_intent(&"A", &"a"))
var startup_miss: RefCounted = InputIntentScript.create(&"D", &"d", &"pressed", float(Time.get_ticks_msec()))
startup_miss.judgement = {"label": "miss", "diff": 0.0}
controller.call("submit_intent", startup_miss)
_expect_array(player.call("get_combo_slots"), [&"A", &"Ø"], "Miss during an action should enter ComboWindow immediately")
if rejected.is_empty():
failures.append("Miss during an action should be rejected immediately: no rejection emitted")
else:
_expect_string(str(rejected[rejected.size() - 1]), "miss", "Miss during an action should be rejected immediately")
await create_timer(0.4).timeout
await process_frame
_expect_array(player.call("get_combo_slots"), [&"A", &"Ø"], "Miss during an action should not clear ComboWindow by itself")
player.get_node("ComboWindow").clear(&"test-reset")
started.clear()
player.get_node("EnergyComponent").set_current(0)
var first: RefCounted = InputIntentScript.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec()))
first.judgement = {"label": "perfect", "diff": 0.0}
controller.call("submit_intent", first)
_expect_int(player.call("get_energy"), 0, "A reward should wait until active phase")
var second: RefCounted = InputIntentScript.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec()))
second.judgement = {"label": "perfect", "diff": 0.0}
controller.call("submit_intent", second)
_expect_array(player.call("get_combo_slots"), [&"A"], "Input during startup should wait outside ComboWindow")
_expect_int(started.size(), 1, "Second input during startup should not start a second action immediately")
await create_timer(0.2).timeout
await physics_frame
_expect_int(player.call("get_energy"), 1, "A reward should apply once active phase begins")
await create_timer(0.48).timeout
await physics_frame
_expect_array(player.call("get_combo_slots"), [&"A", &"A"], "Pending input should enter ComboWindow only when cancel window opens")
_expect_bool(started.size() >= 2, true, "Pending input should start after phase completes or cancel window opens")
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("EnergyComponent").set_current(10)
controller.call("_reset_to_idle")
rejected.clear()
started.clear()
controller.call("submit_intent", _perfect_intent(&"A", &"a"))
controller.call("submit_intent", _perfect_intent(&"D", &"d"))
controller.call("submit_intent", _perfect_intent(&"S", &"s"))
_expect_array(player.call("get_combo_slots"), [&"A"], "Rapid inputs should not advance ComboWindow at raw input speed")
_expect_bool(rejected.has(&"replaced"), true, "Rapid inputs should replace the single pending intent")
await create_timer(0.5).timeout
await physics_frame
_expect_array(player.call("get_combo_slots"), [&"A", &"S"], "Only the latest pending intent should enter ComboWindow at cancel time")
_expect_int(started.size(), 2, "Replaced rapid inputs should start only the initial action and the latest pending fallback")
_expect_bool(started.has(&"skill_s"), true, "Latest pending S should fall back to normal S when A+S is unresolved")
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("EnergyComponent").set_current(10)
controller.call("_reset_to_idle")
started.clear()
controller.call("submit_intent", _perfect_intent(&"A", &"a"))
controller.call("submit_intent", _perfect_intent(&"SP", &"space"))
await create_timer(0.55).timeout
await physics_frame
_expect_array(player.call("get_combo_slots"), [&"A", &"SP"], "Clear-window action should show its full input pattern")
await create_timer(0.2).timeout
await physics_frame
_expect_array(player.call("get_combo_slots"), [&"A", &"SP"], "Clear-window action should not clear from a stale display timer before it finishes")
await create_timer(0.4).timeout
await physics_frame
_expect_array(player.call("get_combo_slots"), [], "Clear-window action should clear when its action finishes")
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("EnergyComponent").set_current(10)
player.get_node("ChargeComponent").cancel()
controller.call("_reset_to_idle")
started.clear()
player.call("_on_input_intent_created", _perfect_intent(&"D", &"d"))
player.call("_on_input_intent_created", _release_intent(&"D", &"d"))
player.call("_on_input_intent_created", _perfect_intent(&"D", &"d"))
player.call("_on_input_intent_created", _release_intent(&"D", &"d"))
await create_timer(0.55).timeout
await physics_frame
_expect_array(player.call("get_combo_slots"), [&"D", &"D"], "Released pending D should still be adjudicated into DD at cancel time")
await create_timer(0.8).timeout
await process_frame
_expect_bool(player.call("is_charge_active"), false, "Released pending D should not leave player stuck charging")
_expect_int(int(round(float(player.call("get_charge")))), 0, "Released pending D should not accumulate charge")
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("EnergyComponent").set_current(10)
controller.call("_reset_to_idle")
started.clear()
controller.call("submit_intent", _perfect_intent(&"S", &"s"))
controller.call("_reset_to_idle")
controller.call("submit_intent", _perfect_intent(&"SP", &"space"))
controller.call("_reset_to_idle")
controller.call("submit_intent", _perfect_intent(&"S", &"s"))
controller.call("_reset_to_idle")
controller.call("submit_intent", _perfect_intent(&"SP", &"space"))
_expect_bool(started.has(&"skill_s_projectile_1"), true, "First S+SP should start projectile")
_expect_bool(started.count(&"skill_s_projectile_1") >= 2, true, "Second S+SP should restart projectile chain")
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("EnergyComponent").set_current(10)
controller.call("_reset_to_idle")
started.clear()
rejected.clear()
player.get_node("ComboWindow").record(&"A")
player.get_node("ComboWindow").record(&"D")
controller.call("submit_intent", _perfect_intent(&"S", &"s"))
controller.call("_reset_to_idle")
controller.call("submit_intent", _perfect_intent(&"SP", &"space"))
_expect_bool(started.has(&"skill_s_projectile_1"), true, "S+SP should start projectile from the trailing two slots")
_expect_bool(not rejected.has(&"unresolved"), true, "Trailing S+SP should not be rejected as unresolved")
var context: Dictionary = controller.call("_resolver_context")
_expect_bool(context.has("state"), true, "Resolver context should include state")
_expect_bool(context.has("blade_chain_active"), true, "Resolver context should include blade_chain_active")
_expect_bool(context.has("counter_ready"), true, "Resolver context should include counter_ready")
_expect_bool(context.has("burst_action_id"), true, "Resolver context should include burst_action_id")
player.free()
_finish()
func _on_action_rejected(_intent, reason: StringName) -> void:
rejected.append(reason)
func _on_action_started(action: Resource, _intent) -> void:
started.append(StringName(str(action.get("id"))))
func _expect_bool(actual: bool, expected: bool, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_string(actual: String, expected: String, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_array(actual: Array, expected: Array, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_int(actual: int, expected: int, label: String) -> void:
if actual != expected:
failures.append("%s: expected %d, got %d" % [label, expected, actual])
func _perfect_intent(symbol: StringName, rhythm_action: StringName) -> RefCounted:
var intent: RefCounted = InputIntentScript.create(symbol, rhythm_action, &"pressed", float(Time.get_ticks_msec()))
intent.judgement = {"label": "perfect", "diff": 0.0}
return intent
func _release_intent(symbol: StringName, rhythm_action: StringName) -> RefCounted:
var intent: RefCounted = InputIntentScript.create(symbol, rhythm_action, &"released", float(Time.get_ticks_msec()))
intent.judgement = {"label": "perfect", "diff": 0.0}
return intent
func _finish() -> void:
if failures.is_empty():
print("PASS action controller flow")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)