225 lines
11 KiB
GDScript
225 lines
11 KiB
GDScript
extends SceneTree
|
||
## Editor-Tests (headless): Karte t_97ea59c5 — Bauen-Modus (Modular-Builder).
|
||
## Ausführen: godot --headless --path . --script res://tests/test_bauen.gd
|
||
## Exit 0 = alles grün. Läuft OHNE UI (BuildOps/SvmapIO/ActionHistory) —
|
||
## GUI-Feinheiten (Ghost, Panel) verifiziert der Desktop-Smoke (--bauen-demo).
|
||
|
||
var failures := 0
|
||
var checks := 0
|
||
|
||
|
||
func _init() -> void:
|
||
_cleanup()
|
||
_run_tests()
|
||
_cleanup()
|
||
if failures == 0:
|
||
print("ALL OK: %d checks" % checks)
|
||
quit(0)
|
||
else:
|
||
print("FAILED: %d/%d checks" % [failures, checks])
|
||
quit(1)
|
||
|
||
|
||
func _cleanup() -> void:
|
||
for path in ["user://test_bauen.svmap", "user://test_bauen_legacy.svmap"]:
|
||
if FileAccess.file_exists(path):
|
||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||
|
||
|
||
func _check(cond: bool, label: String) -> void:
|
||
checks += 1
|
||
if cond:
|
||
print(" ok: %s" % label)
|
||
else:
|
||
failures += 1
|
||
print(" FAIL: %s" % label)
|
||
|
||
|
||
func _run_tests() -> void:
|
||
_test_catalog()
|
||
_test_make_piece_isolation()
|
||
_test_piece_meshes()
|
||
_test_snap()
|
||
_test_bake_and_ray_pick()
|
||
_test_undo_redo()
|
||
_test_save_load_roundtrip()
|
||
_test_legacy_map_without_gebaeude()
|
||
|
||
|
||
# ------------------------------------------------------------------ Tests ---
|
||
|
||
func _test_catalog() -> void:
|
||
print("Katalog")
|
||
_check(BuildOps.KINDS.size() == 10, "10 Bauteil-Typen im Katalog")
|
||
for kind_id in BuildOps.KINDS.keys():
|
||
var kind: Dictionary = BuildOps.KINDS[kind_id]
|
||
_check(str(kind.get("label", "")).length() > 3, "Label für %s" % str(kind_id))
|
||
_check((kind.get("punkte", []) as Array).size() >= 2, "Punkte für %s" % str(kind_id))
|
||
_check((kind.get("raster", "") == "ecke") or (kind.get("raster", "") == "mitte"),
|
||
"Raster-Typ für %s" % str(kind_id))
|
||
# Abnahme-Typen vorhanden: Fundament/Wand/Dach/Treppe/Tür
|
||
for need in ["fundament", "wand", "dach_giebel", "treppe", "tuer"]:
|
||
_check(BuildOps.KINDS.has(need), "Katalog enthält %s" % need)
|
||
|
||
|
||
func _test_make_piece_isolation() -> void:
|
||
print("make_piece")
|
||
var teil := BuildOps.make_piece("wand", Vector3(2.0, 0.2, 4.0), 90.0)
|
||
_check(not teil.is_empty(), "Bauteil erzeugt")
|
||
_check(str(teil["id"]) == "wand", "id übernommen")
|
||
# pos kommt aus einem Vector3 (float32!) — Vergleiche mit Toleranz.
|
||
_check(absf(float(teil["pos"][0]) - 2.0) < 0.001 and absf(float(teil["pos"][1]) - 0.2) < 0.001 \
|
||
and absf(float(teil["pos"][2]) - 4.0) < 0.001, "Position übernommen")
|
||
_check(float(teil["rot"]) == 90.0, "Drehung übernommen")
|
||
# Punkte sind KOPIEN (kein Aliasing mit dem Katalog)
|
||
var before_x: float = (BuildOps.KINDS["wand"]["punkte"] as Array)[1][0]
|
||
(teil["punkte"] as Array)[1][0] = 99.0
|
||
_check(float((BuildOps.KINDS["wand"]["punkte"] as Array)[1][0]) == before_x,
|
||
"Katalog-Punkte bleiben unangetastet (keine Alias-Falle)")
|
||
var tuer := BuildOps.make_piece("tuer", Vector3.ZERO, 0.0)
|
||
_check((tuer["details"] as Array).size() == 1, "Tür-Wand hat 1 Öffnung")
|
||
_check(int(((tuer["details"] as Array)[0] as Dictionary)["typ"]) == BuildOps.DETAIL_DOOR,
|
||
"Öffnung ist eine Tür")
|
||
_check(BuildOps.make_piece("gibts_nicht", Vector3.ZERO, 0.0).is_empty(),
|
||
"Unbekannte ID → leeres Teil (kein Crash)")
|
||
|
||
|
||
func _test_piece_meshes() -> void:
|
||
print("Meshes")
|
||
for kind_id in BuildOps.KINDS.keys():
|
||
var mesh := BuildOps.piece_mesh(BuildOps.make_piece(str(kind_id), Vector3.ZERO, 0.0))
|
||
_check(mesh != null and mesh.get_surface_count() > 0, "Mesh für %s" % str(kind_id))
|
||
var wand := BuildOps.piece_mesh(BuildOps.make_piece("wand", Vector3.ZERO, 0.0))
|
||
var tuer := BuildOps.piece_mesh(BuildOps.make_piece("tuer", Vector3.ZERO, 0.0))
|
||
var wand_tris := int(wand.get_faces().size() / 3)
|
||
var tuer_tris := int(tuer.get_faces().size() / 3)
|
||
_check(wand_tris > 0 and tuer_tris > 0, "Wand/Tür-Meshes haben Dreiecke")
|
||
_check(wand_tris != tuer_tris, "Tür-Öffnung verändert das Wand-Mesh (%d vs %d Tris)" % [wand_tris, tuer_tris])
|
||
# Fundament: 2×2×0,2 m — AABB prüfen (lokale Geometrie, Ursprung Fußpunkt)
|
||
var f := BuildOps.piece_mesh(BuildOps.make_piece("fundament", Vector3.ZERO, 0.0))
|
||
var size := f.get_aabb().size
|
||
_check(absf(size.x - 2.0) < 0.01 and absf(size.z - 2.0) < 0.01 and absf(size.y - 0.2) < 0.01,
|
||
"Fundament-AABB 2×0,2×2 m (ist %.2f×%.2f×%.2f)" % [size.x, size.y, size.z])
|
||
|
||
|
||
func _test_snap() -> void:
|
||
print("Raster-Snap")
|
||
# "ecke" (Wand) → gerade Meter; "mitte" (Fundament) → ungerade Meter.
|
||
var w := BuildOps.snapped_pos("wand", Vector3(2.7, 0.11, -3.4))
|
||
_check(w.x == 2.0 and w.z == -4.0, "Wand rastet auf Zellen-Ecken (2, −4)")
|
||
_check(absf(w.y - 0.10) < 0.001, "Y rastet auf 5 cm")
|
||
var f := BuildOps.snapped_pos("fundament", Vector3(2.7, 0.0, 3.2))
|
||
_check(f.x == 3.0 and f.z == 3.0, "Fundament rastet auf Zellen-Mitte (3, 3)")
|
||
# Verbund: Fundament Mitte (3,3) hat Ecken bei 2 und 4 → Wand auf (2, y, 4)
|
||
# grenzt bündig. (Konvention, dokumentiert in build_ops.gd.)
|
||
|
||
|
||
func _test_bake_and_ray_pick() -> void:
|
||
print("Baken + Ray-Pick")
|
||
var doc := SvmapDocument.create_new("Bautest", 256.0, "mittag")
|
||
# Kleines Haus: Fundament → 3 Wände + Tür-Wand → 2 Giebelwände → Dach + Treppe
|
||
var pieces: Array = BuildOps.ensure_block(doc)
|
||
pieces.append(BuildOps.make_piece("fundament", Vector3(3.0, 0.0, 3.0), 0.0))
|
||
pieces.append(BuildOps.make_piece("wand", Vector3(2.0, 0.2, 2.0), 0.0)) # Süd
|
||
pieces.append(BuildOps.make_piece("wand", Vector3(2.0, 0.2, 4.0), 0.0)) # Nord
|
||
pieces.append(BuildOps.make_piece("wand", Vector3(2.0, 0.2, 4.0), 90.0)) # West
|
||
pieces.append(BuildOps.make_piece("tuer", Vector3(4.0, 0.2, 4.0), 90.0)) # Ost + Tür
|
||
pieces.append(BuildOps.make_piece("giebelwand", Vector3(2.0, 2.2, 2.0), 0.0))
|
||
pieces.append(BuildOps.make_piece("giebelwand", Vector3(2.0, 2.2, 4.0), 0.0))
|
||
pieces.append(BuildOps.make_piece("dach_giebel", Vector3(3.0, 2.2, 3.0), 0.0))
|
||
pieces.append(BuildOps.make_piece("treppe", Vector3(10.0, 0.0, 9.0), 0.0))
|
||
var baked := BuildOps.bake_faces(doc)
|
||
var tris := int((baked["faces"] as PackedVector3Array).size() / 3)
|
||
_check(tris > 100, "Haus gebacken: %d Dreiecke" % tris)
|
||
_check((baked["offsets"] as Array).size() == 9, "Offsets je Teil (9)")
|
||
_check(int(baked["offsets"][0]) == 0, "erstes Teil startet bei Dreieck 0")
|
||
_check(int(baked["offsets"][8]) < tris, "letztes Teil liegt im Bereich")
|
||
# Ray von oben → trifft Dach (Teil-Index 7)
|
||
var pick := BuildOps.ray_pick(baked, Vector3(3.0, 50.0, 3.0), Vector3(0.0, -1.0, 0.0))
|
||
_check(not pick.is_empty(), "Ray von oben trifft das Haus")
|
||
_check(int(pick["piece"]) >= 0 and int(pick["piece"]) < 9, "Treffer gehört zu einem Teil (Index %d)" % int(pick["piece"]))
|
||
# Ray von der Seite auf Wandhöhe, daneben der Türöffnung → Teil 4 (Tür-Wand)
|
||
var side := BuildOps.ray_pick(baked, Vector3(50.0, 1.0, 3.6), Vector3(-1.0, 0.0, 0.0))
|
||
_check(not side.is_empty() and int(side["piece"]) == 4,
|
||
"Seiten-Ray trifft die Tür-Wand (Index %s)" % str(side.get("piece", "?")))
|
||
# … und DURCH die Türöffnung hindurch auf die West-Wand (Teil 3)
|
||
var door := BuildOps.ray_pick(baked, Vector3(50.0, 1.0, 3.0), Vector3(-1.0, 0.0, 0.0))
|
||
_check(not door.is_empty() and int(door["piece"]) == 3,
|
||
"Ray durch die Türöffnung trifft die West-Wand (Index %s)" % str(door.get("piece", "?")))
|
||
# Leer → kein Treffer
|
||
_check(BuildOps.ray_pick({"faces": PackedVector3Array(), "offsets": []},
|
||
Vector3.ZERO, Vector3.DOWN).is_empty(), "Leeres Baken → kein Treffer")
|
||
# AABB eines Teils liegt an der richtigen Stelle (Dach über 2,2 m)
|
||
var aabb := BuildOps.piece_aabb(pieces[7])
|
||
_check(aabb.position.y > 2.0, "Dach-AABB liegt über den Wänden (y=%.2f)" % aabb.position.y)
|
||
|
||
|
||
func _test_undo_redo() -> void:
|
||
print("Undo/Redo")
|
||
var doc := SvmapDocument.create_new("Undo", 256.0, "mittag")
|
||
var hist := ActionHistory.new()
|
||
hist.begin(doc)
|
||
# Teil 1: erster Snapshot MUSS den (leeren) Block enthalten, damit Strg+Z
|
||
# das erste Bauteil komplett entfernen kann.
|
||
var before: Dictionary = hist.snapshot(["gebaeude"])
|
||
_check(before.has("gebaeude"), "Snapshot enthält den leeren gebaeude-Block")
|
||
BuildOps.ensure_block(doc).append(BuildOps.make_piece("fundament", Vector3.ZERO, 0.0))
|
||
hist.commit("Fundament (2×2 m) gesetzt", before, hist.snapshot(["gebaeude"]))
|
||
# Teil 2 + 3
|
||
for i in 2:
|
||
before = hist.snapshot(["gebaeude"])
|
||
BuildOps.ensure_block(doc).append(BuildOps.make_piece("wand", Vector3(float(i) * 2.0, 0.2, 0.0), 0.0))
|
||
hist.commit("Wand (2 m) gesetzt", before, hist.snapshot(["gebaeude"]))
|
||
_check(BuildOps.piece_count(doc) == 3, "3 Bauteile gesetzt")
|
||
hist.undo()
|
||
_check(BuildOps.piece_count(doc) == 2, "Undo → 2 Bauteile")
|
||
hist.undo()
|
||
hist.undo()
|
||
_check(BuildOps.piece_count(doc) == 0, "3× Undo → alles weg (inkl. erstes Teil)")
|
||
hist.redo()
|
||
hist.redo()
|
||
_check(BuildOps.piece_count(doc) == 2, "2× Redo → 2 Bauteile wieder da")
|
||
# Löschen + Undo (wie build_tool.delete_selected)
|
||
before = hist.snapshot(["gebaeude"])
|
||
(BuildOps.pieces(doc) as Array).remove_at(0)
|
||
hist.commit("Bauteil gelöscht", before, hist.snapshot(["gebaeude"]))
|
||
_check(BuildOps.piece_count(doc) == 1, "Löschen → 1 Teil")
|
||
hist.undo()
|
||
_check(BuildOps.piece_count(doc) == 2, "Undo holt gelöschtes Teil zurück")
|
||
|
||
|
||
func _test_save_load_roundtrip() -> void:
|
||
print("Speichern/Laden")
|
||
var doc := SvmapDocument.create_new("Bauen-Save", 256.0, "mittag")
|
||
var pieces: Array = BuildOps.ensure_block(doc)
|
||
pieces.append(BuildOps.make_piece("fundament", Vector3(3.0, 0.0, 3.0), 0.0))
|
||
pieces.append(BuildOps.make_piece("tuer", Vector3(2.0, 0.2, 2.0), 90.0))
|
||
pieces.append(BuildOps.make_piece("dach_pyramide", Vector3(3.0, 2.2, 3.0), 0.0))
|
||
var save := SvmapIO.save_to_file("user://test_bauen.svmap", doc)
|
||
_check(bool(save["ok"]), "Map mit Bauteilen speichert (ohne Fehler)")
|
||
var load := SvmapIO.load_from_file("user://test_bauen.svmap")
|
||
_check(bool(load["ok"]), "Map mit Bauteilen lädt (V1–V15 + Prüfsumme grün)")
|
||
var loaded: Dictionary = load["data"]
|
||
_check(BuildOps.piece_count(loaded) == 3, "3 Bauteile überleben .svmap")
|
||
var src: Dictionary = pieces[1]
|
||
var dst: Dictionary = (BuildOps.pieces(loaded) as Array)[1]
|
||
_check(str(dst["id"]) == "tuer" and float(dst["rot"]) == 90.0, "Bauteil-Felder identisch")
|
||
_check((dst["punkte"] as Array) == (src["punkte"] as Array), "Punkte byte-gleich über den Roundtrip")
|
||
_check(((dst["details"] as Array)[0] as Dictionary)["w"] == 1.0, "Tür-Öffnung überlebt den Roundtrip")
|
||
# Gebackenes Mesh aus GELADENEN Teilen = wie vor dem Speichern (deterministisch)
|
||
var before_faces: PackedVector3Array = BuildOps.bake_faces(doc)["faces"]
|
||
var after_faces: PackedVector3Array = BuildOps.bake_faces(loaded)["faces"]
|
||
_check(before_faces == after_faces, "Baken ist deterministisch (vor/nach Speichern identisch)")
|
||
|
||
|
||
func _test_legacy_map_without_gebaeude() -> void:
|
||
print("Legacy-Maps")
|
||
var doc := SvmapDocument.create_new("Legacy", 256.0, "mittag")
|
||
doc.erase("gebaeude") # alte Map ohne die Sektion
|
||
var save := SvmapIO.save_to_file("user://test_bauen_legacy.svmap", doc)
|
||
_check(bool(save["ok"]), "Map OHNE gebaeude speichert")
|
||
var load := SvmapIO.load_from_file("user://test_bauen_legacy.svmap")
|
||
_check(bool(load["ok"]), "Map OHNE gebaeude lädt")
|
||
_check(BuildOps.piece_count(load["data"]) == 0, "Legacy-Map: 0 Bauteile, kein Crash")
|
||
if FileAccess.file_exists("user://test_bauen_legacy.svmap"):
|
||
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_bauen_legacy.svmap"))
|