Implement production vertical slice foundation

This commit is contained in:
chindit committed 2026-10-02 15:27:48 +02:00
1 parent 9d4a89f355
commit a18e82eb44
104 files changed
+10595 -268

No files matched your search

+10
View File
@@ -8,3 +8,13 @@
Les runners historiques à la racine de `tests/` couvrent les POC. Les suites de
production devront être ajoutées à `scripts/quality_gate.sh`; créer un fichier
de test qui n'est jamais exécuté n'est pas une validation.
Suites de production actuelles :
- `unit/run_dive_foundation_tests.gd` : règles résolues, ticks, ressources,
pression, collisions, alertes et états terminaux ;
- `unit/run_vehicle_application_tests.gd` : validation et séquençage des
commandes de pilotage et de caméra ;
- `integration/run_vertical_slice_foundation.gd` : composition, mouvement
inertiel, caméra indépendante, cockpit 3D fixe, instruments physiques, couche
d'alerte, pause des ticks et remappage avec conflit.
@@ -0,0 +1,212 @@
extends SceneTree
const VerticalSliceRoot = preload(
"res://game/composition/vertical_slice/vertical_slice_root.gd"
)
const MainMenuScreen = preload("res://game/presentation/main_menu/main_menu_screen.gd")
const CockpitHud = preload("res://game/presentation/cockpit/cockpit_hud.gd")
const PauseMenu = preload("res://game/presentation/pause/pause_menu.gd")
const ControlsSettingsScreen = preload(
"res://game/presentation/settings/controls_settings_screen.gd"
)
const ProductionInputAdapter = preload(
"res://game/infrastructure/input/production_input_adapter.gd"
)
const TEST_COUNT := 40
var _failures: Array[String] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
change_scene_to_file(
"res://game/composition/vertical_slice/vertical_slice_root.tscn"
)
await process_frame
await physics_frame
var composition := current_scene as VerticalSliceRoot
_expect(composition != null, "production composition loads")
if composition != null:
await _test_vehicle_slice(composition)
_expect(
int(ProjectSettings.get_setting("physics/common/physics_ticks_per_second")) == 60,
"project physics runs at 60 Hz"
)
var project_features: PackedStringArray = ProjectSettings.get_setting(
"application/config/features"
)
_expect("Forward Plus" in project_features, "production project uses Forward+")
var exit_code := 0
if _failures.is_empty():
print("Vertical slice foundation integration: %d passed, 0 failed." % TEST_COUNT)
else:
for failure: String in _failures:
push_error(failure)
print("Vertical slice foundation integration: %d failed." % _failures.size())
exit_code = 1
Input.action_release(ProductionInputAdapter.ACTION_FORWARD)
Input.action_release(ProductionInputAdapter.ACTION_RIGHT)
if current_scene != null:
current_scene.queue_free()
await process_frame
await process_frame
quit(exit_code)
func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
var menu := composition.active_screen() as MainMenuScreen
_expect(menu != null, "composition starts on main menu")
if menu == null:
return
var continue_button := menu.get_node("%ContinueButton") as Button
_expect(continue_button.disabled, "continue is disabled without save")
var new_game_button := menu.get_node("%NewGameButton") as Button
new_game_button.pressed.emit()
await process_frame
await physics_frame
var actor = composition.submarine_actor()
var camera = composition.camera_controller()
var instruments = composition.cockpit_instrument_panel()
var adapter := composition.input_adapter() as ProductionInputAdapter
var hud := composition.active_screen() as CockpitHud
_expect(composition.active_world() != null, "vehicle test world is assembled")
_expect(actor != null, "production submarine actor is assembled")
_expect(camera != null, "cockpit camera is attached to the submarine")
_expect(instruments != null, "physical cockpit instruments are assembled")
_expect(hud != null, "critical HUD overlay is assembled")
_expect(composition.latest_update() != null, "dive session starts with a typed update")
if actor == null or camera == null or instruments == null or adapter == null or hud == null:
return
_expect(instruments.get_node("%WindowFrame") is MeshInstance3D, "cockpit has a physical porthole frame")
_expect(instruments.get_node("%Dashboard") is Node3D, "cockpit has a physical dashboard")
_expect(instruments.get_node("%SonarScreen") is MeshInstance3D, "cockpit reserves a physical sonar display")
_expect("kWh" in instruments.resources_text() and "kW" in instruments.resources_text(), "battery instrument has exact units")
_expect("L/min" in instruments.resources_text(), "oxygen instrument has volume and consumption")
_expect("bar" in instruments.integrity_text(), "hull instrument has current and certified pressure")
_expect(not hud.alert_overlay_visible(), "normal telemetry stays on physical instruments only")
var start_position: Vector3 = actor.global_position
var starting_battery: float = composition.latest_update().snapshot.battery.remaining_kwh
Input.action_press(ProductionInputAdapter.ACTION_FORWARD)
for frame in 35:
await physics_frame
Input.action_release(ProductionInputAdapter.ACTION_FORWARD)
_expect(actor.global_position.distance_to(start_position) > 0.1, "keyboard propulsion moves the submarine")
_expect(actor.velocity.length() > 0.1, "propulsion builds inertial velocity")
_expect(
composition.latest_update().snapshot.battery.remaining_kwh < starting_battery,
"propulsion effort drains the domain battery"
)
var start_heading: float = actor.rotation.y
Input.action_press(ProductionInputAdapter.ACTION_RIGHT)
for frame in 24:
await physics_frame
Input.action_release(ProductionInputAdapter.ACTION_RIGHT)
_expect(absf(actor.rotation.y - start_heading) > 0.01, "keyboard steering changes submarine heading")
_expect(absf(actor.current_yaw_velocity_rps()) > 0.01, "yaw keeps angular inertia after release")
for frame in 90:
await physics_frame
var settled_heading: float = actor.rotation.y
var initial_look_yaw: float = camera.look_yaw_radians()
var cockpit_transform: Transform3D = instruments.global_transform
var mouse_motion := InputEventMouseMotion.new()
mouse_motion.relative = Vector2(40.0, -20.0)
adapter._unhandled_input(mouse_motion)
await physics_frame
_expect(absf(camera.look_yaw_radians() - initial_look_yaw) > 0.01, "mouse rotates the cockpit camera")
_expect(absf(actor.rotation.y - settled_heading) < 0.001, "mouse does not steer the submarine")
_expect(instruments.global_transform.is_equal_approx(cockpit_transform), "cockpit remains fixed while the player looks around")
var paused_sequence: int = composition.latest_update().snapshot.tick_sequence
adapter.pause_requested.emit()
await process_frame
_expect(paused, "pause menu suspends the scene tree")
var pause_menu := composition.active_overlay() as PauseMenu
_expect(pause_menu != null, "pause overlay is displayed")
await process_frame
await process_frame
_expect(
composition.latest_update().snapshot.tick_sequence == paused_sequence,
"dive ticks are frozen while paused"
)
var controls_button := pause_menu.get_node("%ControlsButton") as Button
controls_button.pressed.emit()
await process_frame
var controls := composition.active_overlay() as ControlsSettingsScreen
_expect(controls != null, "controls settings open over the paused dive")
_expect(adapter.binding_snapshots().size() == 9, "all gameplay keyboard bindings are exposed")
var rebind_results: Array = []
adapter.rebind_result_available.connect(
func(result) -> void: rebind_results.append(result)
)
adapter.begin_rebind(ProductionInputAdapter.ACTION_FORWARD)
adapter._unhandled_input(_pressed_key(KEY_UP))
_expect(not rebind_results.is_empty() and rebind_results.back().accepted, "free key remapping is accepted")
_expect(
_binding_for(adapter, ProductionInputAdapter.ACTION_FORWARD) == "Up",
"binding snapshot reflects the remapped key"
)
adapter.begin_rebind(ProductionInputAdapter.ACTION_BACKWARD)
adapter._unhandled_input(_pressed_key(KEY_UP))
_expect(
not rebind_results.back().accepted
and rebind_results.back().conflicting_action_id == ProductionInputAdapter.ACTION_FORWARD,
"binding conflict identifies the existing action"
)
var swap_result = adapter.resolve_pending_conflict(true)
_expect(swap_result.accepted, "conflicting bindings can be swapped")
_expect(
_binding_for(adapter, ProductionInputAdapter.ACTION_BACKWARD) == "Up",
"requested action receives the conflicting key"
)
adapter.restore_default_bindings()
_expect(
_binding_for(adapter, ProductionInputAdapter.ACTION_FORWARD) == "W",
"default bindings can be restored"
)
adapter.pause_requested.emit()
await process_frame
_expect(composition.active_overlay() is PauseMenu, "escape from settings returns to pause")
adapter.pause_requested.emit()
await process_frame
_expect(not paused, "second escape resumes the dive")
await physics_frame
_expect(
composition.latest_update().snapshot.tick_sequence > paused_sequence,
"fixed dive ticks resume after pause"
)
func _pressed_key(keycode: Key) -> InputEventKey:
var event := InputEventKey.new()
event.physical_keycode = keycode
event.pressed = true
return event
func _binding_for(adapter: ProductionInputAdapter, action_id: StringName) -> String:
for binding in adapter.binding_snapshots():
if binding.action_id == action_id:
return binding.binding_text
return ""
func _expect(condition: bool, label: String) -> void:
if not condition:
_failures.append(label)
+335
View File
@@ -0,0 +1,335 @@
extends SceneTree
const DiveDifficultyProfile = preload("res://game/domain/rules/dive_difficulty_profile.gd")
const DiveUpgradeEffects = preload("res://game/domain/rules/dive_upgrade_effects.gd")
const DiveAccessibilityAdjustments = preload(
"res://game/domain/rules/dive_accessibility_adjustments.gd"
)
const DiveRulesResolver = preload("res://game/domain/rules/dive_rules_resolver.gd")
const DiveSession = preload("res://game/domain/dive/dive_session.gd")
const DiveTick = preload("res://game/domain/dive/dive_tick.gd")
const DiveTelemetrySample = preload("res://game/domain/dive/dive_telemetry_sample.gd")
const CollisionObservation = preload("res://game/domain/dive/collision_observation.gd")
const ToolPowerDemand = preload("res://game/domain/dive/tool_power_demand.gd")
const EjectionIntent = preload("res://game/domain/dive/ejection_intent.gd")
const ReturnRouteSample = preload("res://game/domain/dive/return_route_sample.gd")
const DiveStep = preload("res://game/domain/dive/dive_step.gd")
const DiveAlertLevel = preload("res://game/domain/dive/dive_alert_level.gd")
const DiveTerminalReason = preload("res://game/domain/dive/dive_terminal_reason.gd")
const DiveEvent = preload("res://game/domain/dive/dive_event.gd")
const ReturnEstimate = preload("res://game/domain/dive/return_estimate.gd")
const SESSION_ID := &"unit-dive"
const TEST_COUNT := 21
var _failures: Array[String] = []
func _init() -> void:
_test_rules_resolve_difficulty_upgrades_and_accessibility()
_test_initial_snapshot_has_explicit_units()
_test_sixty_ticks_drain_one_second_of_oxygen()
_test_idle_does_not_drain_battery()
_test_propulsion_and_tool_power_are_summed()
_test_certified_depth_is_safe()
_test_excess_depth_damages_hull()
_test_crush_grace_delays_catastrophic_damage()
_test_collision_is_idempotent()
_test_collisions_are_ordered_and_cooled_down()
_test_invalid_sequence_is_rejected_without_mutation()
_test_oversized_tick_is_rejected()
_test_alert_transition_is_emitted_once()
_test_empty_battery_is_not_terminal()
_test_ejection_hold_resets_on_release()
_test_ejection_terminates_after_three_seconds()
_test_confirmed_ejection_terminates_immediately()
_test_oxygen_depletion_terminates_dive()
_test_hull_destruction_terminates_dive()
_test_return_estimate_uses_real_kwh()
_test_session_has_no_internal_clock()
if _failures.is_empty():
print("Dive foundation unit tests: %d passed, 0 failed." % TEST_COUNT)
quit(0)
else:
for failure: String in _failures:
push_error(failure)
print("Dive foundation unit tests: %d failed." % _failures.size())
quit(1)
func _test_rules_resolve_difficulty_upgrades_and_accessibility() -> void:
var resolver := DiveRulesResolver.new()
var expert = resolver.resolve(DiveDifficultyProfile.expert())
_expect_near(expert.oxygen_consumption_l_per_min, 36.0, 0.001, "expert oxygen")
_expect_near(expert.longitudinal_power_kw, 2070.0, 0.001, "expert battery")
_expect_near(expert.pressure_damage_multiplier, 1.35, 0.001, "expert pressure")
_expect_near(expert.crush_grace_seconds, 1.125, 0.001, "expert crush grace")
var upgraded = resolver.resolve(
DiveDifficultyProfile.standard(),
DiveUpgradeEffects.new(25.0, 120.0, 20.0, 40.0),
DiveAccessibilityAdjustments.new(0.5, 0.75, 0.5)
)
_expect_near(upgraded.battery_capacity_kwh, 125.0, 0.001, "upgrade battery capacity")
_expect_near(upgraded.oxygen_capacity_l, 600.0, 0.001, "upgrade oxygen capacity")
_expect_near(upgraded.hull_max_integrity, 120.0, 0.001, "upgrade hull capacity")
_expect_near(upgraded.certified_depth_m, 100.0, 0.001, "upgrade certified depth")
_expect_near(upgraded.oxygen_consumption_l_per_min, 15.0, 0.001, "accessible oxygen")
_expect_near(upgraded.longitudinal_power_kw, 1350.0, 0.001, "accessible battery")
func _test_initial_snapshot_has_explicit_units() -> void:
var snapshot = _session().current_snapshot()
_expect_near(snapshot.battery.remaining_kwh, 100.0, 0.001, "initial battery kWh")
_expect_near(snapshot.oxygen.remaining_l, 480.0, 0.001, "initial oxygen litres")
_expect_near(snapshot.oxygen.consumption_l_per_min, 30.0, 0.001, "oxygen L/min")
_expect_near(snapshot.hull.certified_pressure_bar, 7.0, 0.001, "certified pressure")
_expect_near(snapshot.depth.certified_m, 60.0, 0.001, "certified depth")
func _test_sixty_ticks_drain_one_second_of_oxygen() -> void:
var session := _session()
for sequence in 60:
var update = session.advance(_step(sequence, 1.0 / 60.0))
_expect(update.accepted, "60 Hz oxygen step %d accepted" % sequence)
_expect_near(session.current_snapshot().oxygen.remaining_l, 479.5, 0.001, "one second oxygen")
_expect(session.current_snapshot().tick_sequence == 59, "last 60 Hz sequence is 59")
func _test_idle_does_not_drain_battery() -> void:
var session := _session()
var update = session.advance(_step(0, 0.1))
_expect(update.accepted, "idle tick accepted")
_expect_near(update.snapshot.battery.remaining_kwh, 100.0, 0.001, "idle battery")
_expect_near(update.snapshot.battery.current_power_kw, 0.0, 0.001, "idle power")
func _test_propulsion_and_tool_power_are_summed() -> void:
var session := _session()
var tools: Array[ToolPowerDemand] = [ToolPowerDemand.new(&"cutter", 30.0)]
var update = session.advance(_step(0, 1.0 / 60.0, 24.0, 0.5, [], tools))
_expect(update.accepted, "powered tick accepted")
_expect_near(update.snapshot.battery.current_power_kw, 930.0, 0.001, "combined power")
_expect_near(
update.snapshot.battery.remaining_kwh,
100.0 - 930.0 / 3600.0 / 60.0,
0.00001,
"combined energy drain"
)
func _test_certified_depth_is_safe() -> void:
var session := _session()
var update = session.advance(_step(0, 0.1, 60.0))
_expect_near(update.snapshot.hull.current_integrity, 100.0, 0.001, "certified hull")
_expect_near(update.snapshot.hull.exterior_pressure_bar, 7.0, 0.001, "current pressure")
func _test_excess_depth_damages_hull() -> void:
var session := _session()
var update = session.advance(_step(0, 0.1, 70.0))
_expect(update.snapshot.hull.current_integrity < 100.0, "excess depth damages hull")
_expect(_event_count(update.events, DiveEvent.Kind.HULL_DAMAGED) == 1, "pressure damage event")
func _test_crush_grace_delays_catastrophic_damage() -> void:
var session := _session()
var update
for sequence in 14:
update = session.advance(_step(sequence, 0.1, 80.0))
_expect_near(update.snapshot.hull.current_integrity, 100.0, 0.001, "crush grace protects hull")
update = session.advance(_step(14, 0.1, 80.0))
_expect(update.snapshot.hull.current_integrity < 100.0, "crush damage starts after grace")
func _test_collision_is_idempotent() -> void:
var session := _session()
var first_collisions: Array[CollisionObservation] = [
CollisionObservation.new(&"impact-a", 0, 0, 6.5),
]
var first = session.advance(_step(0, 0.1, 20.0, 0.0, first_collisions))
_expect_near(first.snapshot.hull.current_integrity, 88.0, 0.001, "first collision damage")
var repeated_collisions: Array[CollisionObservation] = [
CollisionObservation.new(&"impact-a", 1, 1, 6.5),
]
var repeated = session.advance(_step(1, 0.1, 20.0, 0.0, repeated_collisions))
_expect(repeated.accepted, "repeated collision tick accepted")
_expect_near(repeated.snapshot.hull.current_integrity, 88.0, 0.001, "collision idempotence")
func _test_collisions_are_ordered_and_cooled_down() -> void:
var session := _session()
var collisions: Array[CollisionObservation] = [
CollisionObservation.new(&"late", 5, 0, 9.0),
CollisionObservation.new(&"early", 1, 0, 4.5),
]
var update = session.advance(_step(0, 0.1, 20.0, 0.0, collisions))
_expect_near(update.snapshot.hull.current_integrity, 96.0, 0.001, "ordered collision cooldown")
_expect(_event_count(update.events, DiveEvent.Kind.HULL_DAMAGED) == 1, "one hull damage event")
func _test_invalid_sequence_is_rejected_without_mutation() -> void:
var session := _session()
var before = session.current_snapshot()
var update = session.advance(_step(2, 1.0 / 60.0))
_expect(not update.accepted, "out-of-order tick rejected")
_expect(update.snapshot == before, "rejected tick preserves snapshot")
_expect(update.snapshot.tick_sequence == -1, "rejected tick preserves sequence")
func _test_oversized_tick_is_rejected() -> void:
var session := _session()
var update = session.advance(_step(0, 0.11))
_expect(not update.accepted, "oversized tick rejected")
_expect_near(update.snapshot.oxygen.remaining_l, 480.0, 0.001, "oversized tick consumes nothing")
func _test_alert_transition_is_emitted_once() -> void:
var session := DiveSession.new(SESSION_ID, _standard_rules(), -1.0, 144.01)
var first = session.advance(_step(0, 0.1))
_expect(first.snapshot.alerts.oxygen == DiveAlertLevel.Value.ATTENTION, "oxygen attention")
_expect(_alert_event_count(first.events, &"oxygen") == 1, "oxygen alert transition emitted")
var second = session.advance(_step(1, 0.1))
_expect(_alert_event_count(second.events, &"oxygen") == 0, "oxygen alert not repeated")
func _test_empty_battery_is_not_terminal() -> void:
var session := DiveSession.new(SESSION_ID, _standard_rules(), 0.0)
var update = session.advance(_step(0, 0.1))
_expect(update.accepted, "empty battery tick accepted")
_expect(
update.snapshot.terminal_reason == DiveTerminalReason.Value.NONE,
"empty battery not terminal"
)
_expect(update.snapshot.alerts.battery == DiveAlertLevel.Value.FAILURE, "battery failure alert")
_expect(update.snapshot.oxygen.remaining_l < 480.0, "oxygen continues with empty battery")
func _test_ejection_hold_resets_on_release() -> void:
var session := _session()
for sequence in 20:
session.advance(_step(sequence, 0.1, 20.0, 0.0, [], [], true))
var released = session.advance(_step(20, 0.1, 20.0, 0.0, [], [], false))
_expect_near(released.snapshot.ejection.held_seconds, 0.0, 0.001, "ejection resets")
_expect(not released.snapshot.is_terminal(), "released ejection is not terminal")
func _test_ejection_terminates_after_three_seconds() -> void:
var session := _session()
var update
for sequence in 30:
update = session.advance(_step(sequence, 0.1, 20.0, 0.0, [], [], true))
_expect(
update.snapshot.terminal_reason == DiveTerminalReason.Value.VOLUNTARY_EJECTION,
"three-second ejection terminates"
)
_expect(_event_count(update.events, DiveEvent.Kind.DIVE_TERMINATED) == 1, "terminal event emitted")
var rejected = session.advance(_step(30, 0.1))
_expect(not rejected.accepted, "terminal session rejects later ticks")
func _test_confirmed_ejection_terminates_immediately() -> void:
var session := _session()
var step := _step(0, 0.1)
step.ejection_intent = EjectionIntent.new(false, true)
var update = session.advance(step)
_expect(
update.snapshot.terminal_reason == DiveTerminalReason.Value.VOLUNTARY_EJECTION,
"confirmed pause-menu ejection terminates immediately"
)
func _test_oxygen_depletion_terminates_dive() -> void:
var session := DiveSession.new(SESSION_ID, _standard_rules(), -1.0, 0.01)
var update = session.advance(_step(0, 0.1))
_expect_near(update.snapshot.oxygen.remaining_l, 0.0, 0.001, "oxygen reaches zero")
_expect(
update.snapshot.terminal_reason == DiveTerminalReason.Value.OXYGEN_DEPLETED,
"oxygen terminal reason"
)
func _test_hull_destruction_terminates_dive() -> void:
var session := DiveSession.new(SESSION_ID, _standard_rules(), -1.0, -1.0, 1.0)
var collisions: Array[CollisionObservation] = [
CollisionObservation.new(&"fatal-impact", 0, 0, 8.0),
]
var update = session.advance(_step(0, 0.1, 20.0, 0.0, collisions))
_expect_near(update.snapshot.hull.current_integrity, 0.0, 0.001, "hull reaches zero")
_expect(
update.snapshot.terminal_reason == DiveTerminalReason.Value.HULL_DESTROYED,
"hull terminal reason"
)
func _test_return_estimate_uses_real_kwh() -> void:
var session := _session()
var route := ReturnRouteSample.new(true, 600.0, 0.0)
var update = session.advance(_step(0, 0.1, 20.0, 0.0, [], [], false, route))
_expect_near(update.snapshot.return_estimate.required_kwh, 50.0, 0.001, "return kWh")
_expect_near(update.snapshot.return_estimate.margin_kwh, 50.0, 0.001, "return margin")
_expect(update.snapshot.return_estimate.status == ReturnEstimate.Status.SAFE, "return safe")
func _test_session_has_no_internal_clock() -> void:
var session := _session()
var before = session.current_snapshot()
var after = session.current_snapshot()
_expect(after == before, "session cannot advance without an explicit DiveStep")
_expect_near(after.oxygen.remaining_l, 480.0, 0.001, "no implicit oxygen drain")
func _standard_rules():
return DiveRulesResolver.new().resolve(DiveDifficultyProfile.standard())
func _session() -> DiveSession:
return DiveSession.new(SESSION_ID, _standard_rules())
func _step(
sequence: int,
delta_seconds: float,
depth_m: float = 20.0,
longitudinal_effort: float = 0.0,
collisions: Array[CollisionObservation] = [],
tools: Array[ToolPowerDemand] = [],
ejection_active: bool = false,
return_route: ReturnRouteSample = null
) -> DiveStep:
return DiveStep.new(
DiveTick.new(SESSION_ID, sequence, delta_seconds),
DiveTelemetrySample.new(depth_m, longitudinal_effort),
collisions,
tools,
EjectionIntent.new(ejection_active),
return_route if return_route != null else ReturnRouteSample.new()
)
func _event_count(events: Array[DiveEvent], kind: DiveEvent.Kind) -> int:
var count := 0
for event: DiveEvent in events:
if event.kind == kind:
count += 1
return count
func _alert_event_count(events: Array[DiveEvent], subject: StringName) -> int:
var count := 0
for event: DiveEvent in events:
if event.kind == DiveEvent.Kind.ALERT_LEVEL_CHANGED and event.subject == subject:
count += 1
return count
func _expect(condition: bool, label: String) -> void:
if not condition:
_failures.append(label)
func _expect_near(actual: float, expected: float, tolerance: float, label: String) -> void:
if absf(actual - expected) > tolerance:
_failures.append("%s: expected %.5f, got %.5f" % [label, expected, actual])
@@ -0,0 +1,79 @@
extends SceneTree
const VehicleCommand = preload("res://game/application/vehicle/vehicle_command.gd")
const CameraLookCommand = preload(
"res://game/application/vehicle/camera_look_command.gd"
)
const PilotUseCase = preload("res://game/application/vehicle/pilot_use_case.gd")
const VehicleMotionPort = preload(
"res://game/application/interfaces/vehicle_motion_port.gd"
)
const TEST_COUNT := 10
var _failures: Array[String] = []
class RecordingMotionPort extends VehicleMotionPort:
var commands: Array[VehicleCommand] = []
func apply_command(command: VehicleCommand) -> void:
commands.append(command)
func _init() -> void:
_test_vehicle_command_contract()
_test_camera_command_contract()
_test_pilot_use_case_orders_commands()
if _failures.is_empty():
print("Vehicle application unit tests: %d passed, 0 failed." % TEST_COUNT)
quit(0)
else:
for failure: String in _failures:
push_error(failure)
print("Vehicle application unit tests: %d failed." % _failures.size())
quit(1)
func _test_vehicle_command_contract() -> void:
var neutral: VehicleCommand = VehicleCommand.neutral(0)
_expect(neutral.validation_error().is_empty(), "neutral command is valid")
_expect(
VehicleCommand.new(-1, 0.0, 0.0, 0.0, false).validation_error()
== "vehicle command sequence is negative",
"negative sequence is rejected"
)
_expect(
not VehicleCommand.new(0, 1.01, 0.0, 0.0, false).validation_error().is_empty(),
"non-normalized axis is rejected"
)
func _test_camera_command_contract() -> void:
_expect(CameraLookCommand.new(0.2, -0.1).validation_error().is_empty(), "finite camera look is valid")
_expect(
not CameraLookCommand.new(NAN, 0.0).validation_error().is_empty(),
"non-finite camera look is rejected"
)
func _test_pilot_use_case_orders_commands() -> void:
var port := RecordingMotionPort.new()
var pilot := PilotUseCase.new(port)
var first = pilot.execute(VehicleCommand.new(0, 1.0, 0.0, 0.0, false))
_expect(first.accepted, "first consecutive vehicle command is accepted")
_expect(port.commands.size() == 1 and port.commands[0].longitudinal == 1.0, "accepted command reaches motion port")
var skipped = pilot.execute(VehicleCommand.neutral(2))
_expect(not skipped.accepted, "skipped vehicle sequence is rejected")
_expect(port.commands.size() == 1, "rejected command does not reach motion port")
var retry = pilot.execute(VehicleCommand.neutral(1))
_expect(retry.accepted and port.commands.size() == 2, "expected sequence remains retryable")
func _expect(condition: bool, label: String) -> void:
if not condition:
_failures.append(label)