Validate POC A and POC B foundations
This commit is contained in:
1 parent
44fd4b4b43
commit
c38e09a663
47 files changed
+4990
-35
No files matched your search
@@ -0,0 +1,10 @@
|
||||
# Test organization
|
||||
|
||||
- `unit/` : domaine pur, sans scène ni I/O.
|
||||
- `integration/` : assemblage Godot minimal et signaux.
|
||||
- `contract/` : ressources, contenu, sauvegardes et migrations.
|
||||
- `fixtures/` : données de référence versionnées et anonymes.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,64 @@
|
||||
extends SceneTree
|
||||
|
||||
const SEARCH_ROOTS := [
|
||||
"res://src",
|
||||
"res://game",
|
||||
"res://content",
|
||||
"res://scenes",
|
||||
"res://tests",
|
||||
]
|
||||
|
||||
var _failures: Array[String] = []
|
||||
var _loaded_resource_count := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
for root: String in SEARCH_ROOTS:
|
||||
if DirAccess.dir_exists_absolute(root):
|
||||
_load_directory(root)
|
||||
|
||||
if _failures.is_empty():
|
||||
print("Resource loading: %d passed, 0 failed." % _loaded_resource_count)
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in _failures:
|
||||
push_error(failure)
|
||||
print("Resource loading: %d failed." % _failures.size())
|
||||
quit(1)
|
||||
|
||||
|
||||
func _load_directory(directory_path: String) -> void:
|
||||
var directory := DirAccess.open(directory_path)
|
||||
if directory == null:
|
||||
_failures.append("cannot open %s" % directory_path)
|
||||
return
|
||||
|
||||
var subdirectories := directory.get_directories()
|
||||
for subdirectory: String in subdirectories:
|
||||
_load_directory(directory_path.path_join(subdirectory))
|
||||
|
||||
var files := directory.get_files()
|
||||
for file_name: String in files:
|
||||
if not (
|
||||
file_name.ends_with(".gd")
|
||||
or file_name.ends_with(".tscn")
|
||||
or file_name.ends_with(".tres")
|
||||
or file_name.ends_with(".res")
|
||||
):
|
||||
continue
|
||||
var resource_path := directory_path.path_join(file_name)
|
||||
var resource := ResourceLoader.load(resource_path)
|
||||
if resource == null:
|
||||
_failures.append("cannot load %s" % resource_path)
|
||||
continue
|
||||
if resource is PackedScene:
|
||||
var instance := (resource as PackedScene).instantiate()
|
||||
if instance == null:
|
||||
_failures.append("cannot instantiate %s" % resource_path)
|
||||
continue
|
||||
instance.free()
|
||||
_loaded_resource_count += 1
|
||||
@@ -0,0 +1,136 @@
|
||||
extends SceneTree
|
||||
|
||||
const SonarModelScript = preload("res://src/sonar_model.gd")
|
||||
|
||||
var _last_cells: Array[Dictionary] = []
|
||||
var _last_previous_cells: Array[Dictionary] = []
|
||||
var _progress_cells: Array[Dictionary] = []
|
||||
var _failures: Array[String] = []
|
||||
var _completion_count := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
change_scene_to_file("res://scenes/poc_b.tscn")
|
||||
await process_frame
|
||||
await physics_frame
|
||||
var submarine := current_scene.get_node("Submarine") as CharacterBody3D
|
||||
var sonar := current_scene.get_node("SonarSystem")
|
||||
var hud := current_scene.get_node("SonarHud")
|
||||
sonar.ping_progress.connect(_on_ping_progress)
|
||||
sonar.ping_completed.connect(_on_ping_completed)
|
||||
|
||||
_expect(absf(float(sonar.ping_duration()) - 2.5) < 0.001, "scan lasts 2.5 seconds")
|
||||
_expect(float(sonar.max_range) >= 95.0, "bathymetric range reaches 95 metres")
|
||||
_expect(float(sonar.horizontal_fov_degrees) >= 76.0, "scan covers a wider frontal area")
|
||||
sonar.request_ping()
|
||||
for frame in 3:
|
||||
await physics_frame
|
||||
_expect(_completion_count == 0, "scan does not return immediately")
|
||||
for frame in 157:
|
||||
await physics_frame
|
||||
|
||||
_expect(not _last_cells.is_empty(), "calibration scan returns structured relief cells")
|
||||
_expect(
|
||||
_bathymetry_cell_count(_last_cells) > 500,
|
||||
"scan reconstructs a dense bathymetric surface"
|
||||
)
|
||||
_expect(
|
||||
_last_cells[0].has("row") and _last_cells[0].has("column"),
|
||||
"each return keeps its grid coordinates"
|
||||
)
|
||||
_expect(
|
||||
_last_cells[0].has("elevation"),
|
||||
"bathymetric samples encode normalized elevation"
|
||||
)
|
||||
_expect(_metal_cell_count(_last_cells) > 0, "calibration scan detects the metal object")
|
||||
_expect(
|
||||
int(hud.relief_renderer().current_face_count()) > 0,
|
||||
"relief cells produce connected facets"
|
||||
)
|
||||
hud.set_expanded(true)
|
||||
_expect(hud.is_expanded(), "sonar can expand without pausing the submarine")
|
||||
hud.set_expanded(false)
|
||||
|
||||
var first_scan_size := _last_cells.size()
|
||||
for frame in 16:
|
||||
await physics_frame
|
||||
submarine.controls_enabled = false
|
||||
submarine.rotation.y = 0.35
|
||||
sonar.request_ping()
|
||||
for frame in 3:
|
||||
await physics_frame
|
||||
_expect(
|
||||
_last_previous_cells.size() == first_scan_size,
|
||||
"new scan keeps exactly one previous relief during replacement"
|
||||
)
|
||||
_expect(
|
||||
_progress_cells.size() < first_scan_size,
|
||||
"replacement starts near the submarine instead of appearing at once"
|
||||
)
|
||||
for frame in 157:
|
||||
await physics_frame
|
||||
_expect(_completion_count == 2, "second scan completes after progressive replacement")
|
||||
|
||||
var exit_code := 0
|
||||
if _failures.is_empty():
|
||||
print("POC B integration: 14 passed, 0 failed.")
|
||||
else:
|
||||
for failure in _failures:
|
||||
push_error(failure)
|
||||
print("POC B integration: %d failed." % _failures.size())
|
||||
exit_code = 1
|
||||
|
||||
# AudioStreamGeneratorPlayback is reference-counted by the active player.
|
||||
# Stop it and release the scene before quitting so the integration suite also
|
||||
# verifies a clean ObjectDB shutdown.
|
||||
var audio_player := current_scene.get_node_or_null("SonarAudio") as AudioStreamPlayer
|
||||
if audio_player != null:
|
||||
audio_player.stop()
|
||||
audio_player.stream = null
|
||||
await process_frame
|
||||
current_scene.queue_free()
|
||||
await process_frame
|
||||
quit(exit_code)
|
||||
|
||||
|
||||
func _on_ping_progress(
|
||||
current_cells: Array[Dictionary],
|
||||
previous_cells: Array[Dictionary],
|
||||
_progress: float
|
||||
) -> void:
|
||||
_progress_cells = current_cells.duplicate(true)
|
||||
_last_previous_cells = previous_cells.duplicate(true)
|
||||
|
||||
|
||||
func _on_ping_completed(
|
||||
current_cells: Array[Dictionary],
|
||||
previous_cells: Array[Dictionary]
|
||||
) -> void:
|
||||
_last_cells = current_cells.duplicate(true)
|
||||
_last_previous_cells = previous_cells.duplicate(true)
|
||||
_completion_count += 1
|
||||
|
||||
|
||||
func _metal_cell_count(cells: Array[Dictionary]) -> int:
|
||||
var count := 0
|
||||
for cell: Dictionary in cells:
|
||||
if int(cell["material"]) == SonarModelScript.MaterialCategory.METAL:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _bathymetry_cell_count(cells: Array[Dictionary]) -> int:
|
||||
var count := 0
|
||||
for cell: Dictionary in cells:
|
||||
if String(cell.get("sample_type", "")) == "bathymetry":
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
_failures.append(label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4r5708tp4mtm
|
||||
@@ -0,0 +1,141 @@
|
||||
extends SceneTree
|
||||
|
||||
const SurvivalModelScript = preload("res://src/survival_model.gd")
|
||||
const SonarModelScript = preload("res://src/sonar_model.gd")
|
||||
|
||||
var _failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_test_oxygen_drains_while_idle()
|
||||
_test_battery_only_drains_under_thrust()
|
||||
_test_nominal_depth_is_safe()
|
||||
_test_excess_depth_damages_hull()
|
||||
_test_collision_damage_is_applied()
|
||||
_test_battery_depletion_is_not_immediate_death()
|
||||
_test_alert_thresholds()
|
||||
_test_return_estimate()
|
||||
_test_critical_debug_state()
|
||||
_test_sonar_deduplicates_nearby_echoes()
|
||||
_test_sonar_keeps_strongest_echo()
|
||||
_test_sonar_intensity_falls_with_distance()
|
||||
_test_sonar_does_not_reveal_unobserved_space()
|
||||
|
||||
if _failures.is_empty():
|
||||
print("Prototype tests: 13 passed, 0 failed.")
|
||||
quit(0)
|
||||
else:
|
||||
for failure in _failures:
|
||||
push_error(failure)
|
||||
print("POC A tests: %d failed." % _failures.size())
|
||||
quit(1)
|
||||
|
||||
|
||||
func _test_oxygen_drains_while_idle() -> void:
|
||||
var model = SurvivalModelScript.new()
|
||||
model.tick(10.0, 0.0, 12.0)
|
||||
_expect_near(model.oxygen, 590.0, 0.001, "oxygen drains while idle")
|
||||
|
||||
|
||||
func _test_battery_only_drains_under_thrust() -> void:
|
||||
var model = SurvivalModelScript.new()
|
||||
model.tick(10.0, 0.0, 12.0)
|
||||
_expect_near(model.battery, 100.0, 0.001, "battery remains stable at idle")
|
||||
model.tick(10.0, 1.0, 12.0)
|
||||
_expect_near(model.battery, 94.0, 0.001, "battery drains under full thrust")
|
||||
|
||||
|
||||
func _test_nominal_depth_is_safe() -> void:
|
||||
var model = SurvivalModelScript.new()
|
||||
model.tick(30.0, 0.0, 60.0)
|
||||
_expect_near(model.hull, 100.0, 0.001, "nominal depth does not damage hull")
|
||||
|
||||
|
||||
func _test_excess_depth_damages_hull() -> void:
|
||||
var model = SurvivalModelScript.new()
|
||||
model.tick(2.0, 0.0, 74.0)
|
||||
_expect(model.hull < 100.0, "depth above critical damages hull")
|
||||
|
||||
|
||||
func _test_collision_damage_is_applied() -> void:
|
||||
var model = SurvivalModelScript.new()
|
||||
model.tick(0.1, 0.0, 12.0, 12.0)
|
||||
_expect_near(model.hull, 88.0, 0.001, "collision damage is applied exactly once")
|
||||
|
||||
|
||||
func _test_battery_depletion_is_not_immediate_death() -> void:
|
||||
var model = SurvivalModelScript.new()
|
||||
model.battery = 0.0
|
||||
_expect(model.loss_reason().is_empty(), "empty battery is not immediate death")
|
||||
|
||||
|
||||
func _test_alert_thresholds() -> void:
|
||||
var model = SurvivalModelScript.new()
|
||||
_expect(
|
||||
model.alert_level(30.0, 100.0) == SurvivalModel.AlertLevel.CAUTION,
|
||||
"30 percent is caution"
|
||||
)
|
||||
_expect(
|
||||
model.alert_level(15.0, 100.0) == SurvivalModel.AlertLevel.RETURN,
|
||||
"15 percent recommends return"
|
||||
)
|
||||
_expect(
|
||||
model.alert_level(5.0, 100.0) == SurvivalModel.AlertLevel.CRITICAL,
|
||||
"5 percent is critical"
|
||||
)
|
||||
|
||||
|
||||
func _test_return_estimate() -> void:
|
||||
var model = SurvivalModelScript.new()
|
||||
var short_trip: float = model.estimated_return_energy(100.0, 0.0)
|
||||
var long_trip: float = model.estimated_return_energy(300.0, 20.0)
|
||||
_expect(long_trip > short_trip, "return estimate grows with distance and ascent")
|
||||
|
||||
|
||||
func _test_critical_debug_state() -> void:
|
||||
var model = SurvivalModelScript.new()
|
||||
model.force_critical_state()
|
||||
_expect(model.oxygen == 30.0, "critical debug state lowers oxygen")
|
||||
_expect(model.battery == 12.0, "critical debug state lowers battery")
|
||||
_expect(model.hull == 28.0, "critical debug state lowers hull")
|
||||
|
||||
|
||||
func _test_sonar_deduplicates_nearby_echoes() -> void:
|
||||
var model = SonarModelScript.new()
|
||||
model.record_echo(Vector3(1.0, 2.0, 3.0), 0.7, SonarModelScript.MaterialCategory.ROCK)
|
||||
model.record_echo(Vector3(1.1, 2.1, 3.1), 0.5, SonarModelScript.MaterialCategory.ROCK)
|
||||
_expect(model.observed_point_count() == 1, "nearby sonar echoes share one spatial cell")
|
||||
|
||||
|
||||
func _test_sonar_keeps_strongest_echo() -> void:
|
||||
var model = SonarModelScript.new()
|
||||
model.record_echo(Vector3.ZERO, 0.25, SonarModelScript.MaterialCategory.ROCK)
|
||||
model.record_echo(Vector3(0.1, 0.1, 0.1), 0.85, SonarModelScript.MaterialCategory.METAL)
|
||||
var point: Dictionary = model.observed_points()[0]
|
||||
_expect_near(float(point["intensity"]), 0.85, 0.001, "stronger sonar echo replaces weak echo")
|
||||
_expect(
|
||||
int(point["material"]) == SonarModelScript.MaterialCategory.METAL,
|
||||
"stronger sonar echo updates material"
|
||||
)
|
||||
|
||||
|
||||
func _test_sonar_intensity_falls_with_distance() -> void:
|
||||
var model = SonarModelScript.new()
|
||||
var near_echo: float = model.calculate_intensity(10.0, 100.0, 1.0, 1.0)
|
||||
var far_echo: float = model.calculate_intensity(80.0, 100.0, 1.0, 1.0)
|
||||
_expect(near_echo > far_echo, "sonar intensity falls with distance")
|
||||
|
||||
|
||||
func _test_sonar_does_not_reveal_unobserved_space() -> void:
|
||||
var model = SonarModelScript.new()
|
||||
_expect(model.observed_points().is_empty(), "unobserved sonar space starts empty")
|
||||
|
||||
|
||||
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 %.3f, got %.3f" % [label, expected, actual])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjmonaev2kyor
|
||||
Reference in new issue
Block a user