extends SceneTree var failures: Array[String] = [] var started: Array[StringName] = [] var judged: Array[Dictionary] = [] func _init() -> void: _run.call_deferred() func _run() -> void: var fixture := await _controller_fixture() if fixture.is_empty(): _finish() return var controller: Node = fixture["controller"] var combo: Node = fixture["combo"] var state: Node = fixture["state"] controller.connect("action_started", _on_action_started) _event_bus().connect("judgement_made", _on_judgement_made) controller.call("submit_intent", _intent(&"A", &"a", &"pressed", "perfect", 20)) _expect(started.has(&"ground_attack_left_1"), "A after_tap should execute the normal A action first") _expect_array(combo.call("get_slots"), [&"A"], "A after_tap should enter ComboWindow") controller.call("_tick_charge_hold", 0.2) controller.call("_reset_to_idle", false) controller.call("_tick_charge_hold", 0.0) _expect_equal(state.call("build_context").get("action_phase"), &"Charging", "A after_tap should enter Charging after hold threshold") controller.call("_tick_charge_hold", 0.5) controller.call("submit_intent", _intent(&"A", &"a", &"released", "perfect", 21)) _expect(started.has(&"jian_yu_left_lv2"), "A release should select a left blade rain action by time spent charging") controller.call("_reset_to_idle") combo.call("clear", &"test-reset") started.clear() judged.clear() controller.call("submit_intent", _intent(&"A", &"a", &"pressed", "perfect", 30)) controller.call("_tick_charge_hold", 0.2) controller.call("_reset_to_idle", false) controller.call("_tick_charge_hold", 0.0) _expect_equal(state.call("build_context").get("action_phase"), &"Charging", "Held A should be charging before SP arrives") controller.call("submit_intent", _intent(&"SP", &"space", &"pressed", "perfect", 31)) _expect(started.has(&"dash_slash_left"), "SP during a held-A charge should break the charge into the [A][sp] dash") _expect(not started.has(&"jian_yu_left_lv1"), "SP during a held-A charge must not cast blade rain") controller.call("submit_intent", _intent(&"A", &"a", &"released", "perfect", 32)) _expect(not started.has(&"jian_yu_left_lv1"), "Releasing A after the dash derive must not cast blade rain") controller.call("_reset_to_idle") combo.call("clear", &"test-reset") started.clear() judged.clear() controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 22)) _expect_array(combo.call("get_slots"), [&"S"], "S should enter ComboWindow as a normal down action") _expect(started.has(&"block_start"), "S should start block_start") _expect_int(int(judged[judged.size() - 1].get("beat")), 22, "S should emit judgement feedback using its timestamp") controller.call("_reset_to_idle") combo.call("clear", &"test-reset") started.clear() judged.clear() controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 24)) controller.call("submit_intent", _intent(&"S", &"s", &"released", "perfect", 24)) _expect(started.has(&"block_start"), "S release should not add a second action") _expect_array(combo.call("get_slots"), [&"S"], "S release should not add another ComboWindow entry") _expect_int(judged.size(), 1, "S release should not be judged as a second press") controller.call("_tick_charge_hold", 1.0) controller.call("_reset_to_idle", false) controller.call("_tick_charge_hold", 1.0) _expect_equal(state.call("build_context").get("action_phase"), &"Neutral", "Released S should cancel charge tracking instead of entering Charging later") controller.call("_reset_to_idle") started.clear() judged.clear() controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 25)) _expect(started.has(&"block_start"), "Second timed S should trigger block_start again") _expect_array(combo.call("get_slots"), [&"S", &"S"], "Repeated S should remain visible as two timed block inputs") _expect_int(int(judged[judged.size() - 1].get("beat")), 25, "Repeated S should judge the second press") controller.call("_reset_to_idle") combo.call("clear", &"test-reset") combo.call("record", &"S") combo.call("record", &"SP") started.clear() judged.clear() controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 26)) _expect(started.has(&"block_start"), "S should remain a plain block input after earlier S/SP slots") _expect_array(combo.call("get_slots"), [&"S", &"SP", &"S"], "S should append normally without blade-chain clearing") controller.call("_reset_to_idle") combo.call("clear", &"test-reset") started.clear() judged.clear() controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 27)) controller.call("_tick_charge_hold", 1.0) controller.call("_reset_to_idle", false) controller.call("_tick_charge_hold", 0.0) _expect_equal(state.call("build_context").get("action_phase"), &"Charging", "Held S should enter Charging only while the key remains held") controller.call("_tick_charge_hold", 1.0) controller.call("submit_intent", _intent(&"SP", &"space", &"pressed", "perfect", 28)) _expect(started.has(&"zhan_bo_lv3"), "Held S then SP should release blade wave by time spent charging") fixture["root"].free() _finish() func _controller_fixture() -> Dictionary: var combo_script: Script = load("res://scenes/components/combo_window.gd") var resolver_script: Script = load("res://scenes/combat/action_resolver.gd") var state_script: Script = load("res://scenes/components/state_machine.gd") var energy_script: Script = load("res://scenes/components/energy_component.gd") var controller_script: Script = load("res://scenes/components/action_controller.gd") if combo_script == null or resolver_script == null or state_script == null or energy_script == null or controller_script == null: failures.append("Phase 3 component scripts should load") return {} var fixture_root := Node.new() root.add_child(fixture_root) var combo: Node = combo_script.new() combo.name = "ComboWindow" fixture_root.add_child(combo) var resolver: Node = resolver_script.new() resolver.name = "ActionResolver" fixture_root.add_child(resolver) var state: Node = state_script.new() state.name = "StateMachine" fixture_root.add_child(state) var energy: Node = energy_script.new() energy.name = "EnergyComponent" fixture_root.add_child(energy) var controller: Node = controller_script.new() controller.name = "ActionController" controller.set("combo_window_path", NodePath("../ComboWindow")) controller.set("action_resolver_path", NodePath("../ActionResolver")) controller.set("state_machine_path", NodePath("../StateMachine")) fixture_root.add_child(controller) await process_frame energy.call("set_values", 99, 99) return { "root": fixture_root, "combo": combo, "controller": controller, "state": state, } func _intent(symbol: StringName, rhythm_action: StringName, event_type: StringName, label: String, beat: int) -> RefCounted: var intent_script: Script = load("res://scenes/components/input_intent.gd") var intent: RefCounted = intent_script.call("create", symbol, rhythm_action, event_type, float(Time.get_ticks_msec())) intent.set("judgement", { "label": label, "diff": 0.0, "abs_diff": 0.0, "nearest_beat": beat, }) return intent func _on_action_started(action: Resource, _intent) -> void: started.append(StringName(str(action.get("id")))) func _on_judgement_made(quality: StringName, offset_ms: float, beat_index: int) -> void: judged.append({"quality": quality, "offset_ms": offset_ms, "beat": beat_index}) func _event_bus() -> Node: var bus := root.get_node_or_null("EventBus") if bus == null: bus = load("res://autoload/event_bus.gd").new() bus.name = "EventBus" root.add_child(bus) return bus func _expect(condition: bool, label: String) -> void: if not condition: failures.append(label) 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_int(actual: int, expected: int, label: String) -> void: if actual != expected: failures.append("%s: expected %d, got %d" % [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_equal(actual: Variant, expected: Variant, label: String) -> void: if actual != expected: failures.append("%s: expected %s, got %s" % [label, str(expected), str(actual)]) func _finish() -> void: if failures.is_empty(): print("PASS charge hold gate") quit(0) else: for failure: String in failures: push_error(failure) quit(1)