Files
Ransom-s-wake/tests/unit/run_site_domain_tests.gd
T
OlivierandClaude Opus 5.5 682e1f18e5 perf(sites): validate the site once and hand collision the field array
DiveSite.configure() builds the height field once (TerrainHeightField
validates the data) instead of running the definition validation and
then the field construction again; the composition root relies on the
error returned by configure(). The collision shape takes the field's
row-major elevations directly instead of copying 263k cells one by one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:52:04 +02:00

203 lines
8.6 KiB
GDScript

extends SceneTree
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd")
const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd")
const SitePlacement = preload("res://game/domain/sites/site_placement.gd")
const SiteRoute = preload("res://game/domain/sites/site_route.gd")
const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd")
const TEST_COUNT := 39
var _failures: Array[String] = []
func _init() -> void:
_test_terrain_data_validation()
_test_height_field_sampling()
_test_site_definition_validation()
if _failures.is_empty():
print("Site domain unit tests: %d passed, 0 failed." % TEST_COUNT)
quit(0)
else:
for failure: String in _failures:
push_error(failure)
print("Site domain unit tests: %d failed." % _failures.size())
quit(1)
func _make_terrain(columns: int, rows: int) -> SiteTerrainData:
var data := SiteTerrainData.new()
data.columns = columns
data.rows = rows
data.cell_size_m = 2.0
data.origin_x_m = -10.0
data.origin_z_m = 100.0
# Packed arrays are passed by value in GDScript: build locally, then assign.
var elevations := PackedFloat32Array()
var materials := PackedByteArray()
elevations.resize(columns * rows)
materials.resize(columns * rows)
for row in rows:
for column in columns:
var index := row * columns + column
# Plane sloping down toward +Z (south): -20 at row 0, -2 m per row.
elevations[index] = -20.0 - 2.0 * float(row)
materials[index] = (
SonarMaterial.Value.ROCK if column == 0 else SonarMaterial.Value.SAND
)
data.elevations_m = elevations
data.materials = materials
return data
func _test_terrain_data_validation() -> void:
var data := _make_terrain(3, 4)
_expect(data.validation_error().is_empty(), "regular terrain data is valid")
_expect(data.index_of(2, 3) == 11, "index is row-major with columns along X")
var tiny := _make_terrain(1, 4)
_expect(not tiny.validation_error().is_empty(), "terrain needs at least two columns")
var mismatched := _make_terrain(3, 4)
mismatched.elevations_m.resize(5)
_expect(not mismatched.validation_error().is_empty(), "elevation array must match the grid")
var non_finite := _make_terrain(3, 4)
non_finite.elevations_m[0] = INF
_expect(not non_finite.validation_error().is_empty(), "elevations must be finite")
var metal_terrain := _make_terrain(3, 4)
metal_terrain.materials[1] = SonarMaterial.Value.METAL
_expect(not metal_terrain.validation_error().is_empty(), "terrain cells are rock or sand only")
var bad_cell := _make_terrain(3, 4)
bad_cell.cell_size_m = 0.0
_expect(not bad_cell.validation_error().is_empty(), "cell size must be positive")
var bad_reflectivity := _make_terrain(3, 4)
bad_reflectivity.sand_reflectivity = 1.5
_expect(not bad_reflectivity.validation_error().is_empty(), "reflectivity stays within 0..1")
_expect(TerrainHeightField.from_data(bad_reflectivity) == null, "invalid data yields no field")
func _test_height_field_sampling() -> void:
var data := _make_terrain(3, 4)
var field := TerrainHeightField.from_data(data)
_expect(field != null, "valid data yields a field")
if field == null:
return
_expect(field.elevations_m() == data.elevations_m, "elevations are exposed in row-major data order")
_expect(field.bounds_m().is_equal_approx(Rect2(-10.0, 100.0, 4.0, 6.0)), "bounds cover (columns-1) by (rows-1) cells")
_expect(field.cell_world_position(2, 3).is_equal_approx(Vector3(-6.0, -26.0, 106.0)), "cell position follows origin and cell size")
_expect(is_equal_approx(field.elevation_at(-10.0, 100.0), -20.0), "elevation at a vertex is exact")
_expect(is_equal_approx(field.elevation_at(-9.0, 101.0), -21.0), "elevation is bilinear between vertices")
_expect(is_equal_approx(field.elevation_at(-50.0, 1000.0), -26.0), "sampling outside clamps to the nearest edge")
_expect(field.material_at(-10.4, 100.0) == SonarMaterial.Value.ROCK, "material is the nearest cell")
_expect(field.material_at(-6.0, 103.0) == SonarMaterial.Value.SAND, "material of an interior cell")
_expect(field.normal_at_cell(1, 1).is_equal_approx(Vector3(0.0, 1.0, 1.0).normalized()), "normal leans against the downhill direction")
_expect(not field.contains(-11.0, 100.0) and field.contains(-8.0, 103.0), "contains follows the bounds")
_expect(is_equal_approx(field.reflectivity_for(SonarMaterial.Value.SAND), 0.45), "sand reflectivity comes from the data")
func _make_placement(id: String, kind: int, position: Vector3) -> SitePlacement:
var placement := SitePlacement.new()
placement.placement_id = StringName(id)
placement.kind = kind
placement.position_m = position
placement.footprint_m = Vector3(4.0, 2.0, 3.0)
return placement
func _make_route(id: String, waypoints: PackedVector3Array, maximum_depth: float) -> SiteRoute:
var route := SiteRoute.new()
route.route_id = StringName(id)
route.waypoints_m = waypoints
route.maximum_depth_m = maximum_depth
return route
func _make_definition() -> DiveSiteDefinition:
var definition := DiveSiteDefinition.new()
definition.site_id = &"test_site"
definition.display_name = "Site de test"
definition.terrain = _make_terrain(3, 4)
definition.playable_bounds_m = Rect2(-10.0, 100.0, 4.0, 6.0)
definition.spawn_position_m = Vector3(-8.0, -10.0, 102.0)
definition.spawn_yaw_radians = PI * 0.5
var placements_list: Array[SitePlacement] = [
_make_placement("target", SitePlacement.Kind.WRECK_TARGET, Vector3(-8.0, -23.0, 103.0)),
_make_placement("anchor", SitePlacement.Kind.VESSEL_ANCHOR, Vector3(-8.0, -20.0, 100.5)),
]
definition.placements = placements_list
var routes_list: Array[SiteRoute] = [
_make_route(
"safe",
PackedVector3Array([Vector3(-8.0, -12.0, 101.0), Vector3(-8.0, -14.0, 103.0)]),
20.0
),
]
definition.routes = routes_list
return definition
func _test_site_definition_validation() -> void:
var definition := _make_definition()
_expect(definition.validation_error().is_empty(), "complete definition is valid")
_expect(definition.height_field() != null, "definition exposes its height field")
_expect(definition.placement(&"target").kind == SitePlacement.Kind.WRECK_TARGET, "placement lookup by id")
_expect(definition.placement(&"missing") == null, "unknown placement is null")
_expect(definition.placements_of_kind(SitePlacement.Kind.VESSEL_ANCHOR).size() == 1, "placements filter by kind")
_expect(definition.route(&"safe").waypoints_m.size() == 2, "route lookup by id")
_expect(definition.spawn_transform().origin.is_equal_approx(Vector3(-8.0, -10.0, 102.0)), "spawn transform origin")
_expect(
(definition.spawn_transform().basis * Vector3.FORWARD).is_equal_approx(Vector3.LEFT),
"spawn yaw rotates the forward axis about Y"
)
_expect(definition.placement(&"target").is_sonar_metal(), "wrecks read as metal on sonar")
_expect(not definition.placement(&"anchor").is_sonar_metal(), "anchor is not a sonar body")
var no_id := _make_definition()
no_id.site_id = &""
_expect(not no_id.validation_error().is_empty(), "site id is required")
var no_terrain := _make_definition()
no_terrain.terrain = null
_expect(not no_terrain.validation_error().is_empty(), "terrain is required")
var bounds_outside := _make_definition()
bounds_outside.playable_bounds_m = Rect2(-20.0, 100.0, 4.0, 6.0)
_expect(not bounds_outside.validation_error().is_empty(), "playable bounds must lie inside the terrain")
var spawn_outside := _make_definition()
spawn_outside.spawn_position_m = Vector3(50.0, -10.0, 102.0)
_expect(not spawn_outside.validation_error().is_empty(), "spawn must lie inside the bounds")
var duplicate_ids := _make_definition()
duplicate_ids.placements.append(
_make_placement("target", SitePlacement.Kind.WRECK_DECOY, Vector3(-7.0, -23.0, 104.0))
)
_expect(not duplicate_ids.validation_error().is_empty(), "placement ids are unique")
var placement_outside := _make_definition()
placement_outside.placements.append(
_make_placement("far", SitePlacement.Kind.DEBRIS, Vector3(80.0, -23.0, 104.0))
)
_expect(not placement_outside.validation_error().is_empty(), "placements must lie inside the bounds")
var empty_route := _make_definition()
empty_route.routes.append(_make_route("empty", PackedVector3Array(), 20.0))
_expect(not empty_route.validation_error().is_empty(), "routes need at least two waypoints")
var deep_route := _make_definition()
deep_route.routes.append(
_make_route("deep", PackedVector3Array([Vector3(-8.0, -12.0, 101.0), Vector3(-8.0, -30.0, 103.0)]), 20.0)
)
_expect(not deep_route.validation_error().is_empty(), "route waypoints respect the route maximum depth")
func _expect(condition: bool, label: String) -> void:
if not condition:
_failures.append(label)