- BauKatalog: 12 Teile (Fundament, Boden, Wände mit Tür/Fenster/Scharte, Brüstung, Pfeiler, Giebel, Dächer, Treppe) auf Basis der CC0-Geometrie des Modular Builder 0.2.0, Wandhöhe 3 m - BuildSnap: Wände auf Zellkanten, Böden in Zellmitte, Höhe aus Ebenen statt Klickhöhe, Ersetzen (Tür in Wand) - Gebäude kopieren/auswählen/löschen im Bauen-Tab, Kopien bekommen eigene Gruppe - Kollision serverkonsistent über Trimesh-Dateien (Türen/Fenster echte Löcher), tools/gen_bauteile.gd erzeugt GLB, Kollision, Registry-Fragment - Alte gebaeude-Sektion wird beim Laden migriert, altes Bauen-Werkzeug entfernt, Resize behält Gruppennamen - Tests: test_bauen (258 Checks), Playtest läuft Treppe/Tür Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
299 lines
14 KiB
GDScript
299 lines
14 KiB
GDScript
extends SceneTree
|
||
## Editor-Tests (headless): Karte t_f5935023 — M1 Symmetrie-Fundament.
|
||
## Ausführen: godot --headless --path . --script res://tests/test_symmetry_core.gd
|
||
## Exit 0 = alles grün. Läuft OHNE UI (nur SymmetryCore/SvmapIO/SvmapDocument) —
|
||
## Toolbar/Statuszeile verifiziert der GUI-Smoke (--sym-demo kommt mit M2/M3).
|
||
|
||
var failures := 0
|
||
var checks := 0
|
||
|
||
|
||
func _init() -> void:
|
||
# user:// persistiert über Godot-Läufe — vor UND nach dem Test aufräumen
|
||
# (Lektion aus t_5badf371: Stale-Reste maskierten sonst Verifikationen).
|
||
_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_sym.svmap", "user://test_sym_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 _errors_contain(errors: Array, needle: String) -> bool:
|
||
for e in errors:
|
||
if str(e).contains(needle):
|
||
return true
|
||
return false
|
||
|
||
|
||
func _run_tests() -> void:
|
||
_test_modes_and_labels()
|
||
_test_center()
|
||
_test_mirror_point_double_identity()
|
||
_test_mirror_rot_double_identity()
|
||
_test_maps_to_self()
|
||
_test_cell_mapping()
|
||
_test_partner_link()
|
||
_test_validator_sym()
|
||
_test_roundtrip_with_sym()
|
||
_test_legacy_map_unchanged()
|
||
|
||
|
||
# ------------------------------------------------------------------ Tests ---
|
||
|
||
func _test_modes_and_labels() -> void:
|
||
print("== Modus-Katalog ==")
|
||
_check(SymmetryCore.Mode.OFF == 0, "OFF = 0")
|
||
_check(SymmetryCore.Mode.POINT == 1, "POINT = 1 (Standard, Sly 20.10.)")
|
||
_check(SymmetryCore.Mode.AXIS_X == 2, "AXIS_X = 2")
|
||
_check(SymmetryCore.Mode.AXIS_Z == 3, "AXIS_Z = 3")
|
||
_check(SymmetryCore.MODE_LABELS.size() == 4, "4 Modus-Labels (Aus/Punkt/Achse X/Achse Z)")
|
||
_check(SymmetryCore.mode_label(SymmetryCore.Mode.POINT) == "Punkt", "Label für POINT")
|
||
_check(SymmetryCore.mode_label(9) == "Achse Z", "Unbekannter Modus clampt auf letzten Label")
|
||
|
||
|
||
func _test_center() -> void:
|
||
print("== Kartenmitte ==")
|
||
var doc := SvmapDocument.create_new("SymTest", 1024.0, "mittag")
|
||
var c := SymmetryCore.map_center(doc)
|
||
_check(c.is_zero_approx(), "Zentrierte Map: Mitte = (0, 0) (bounds ±size/2)")
|
||
# Fehlende bounds → Fallback (0, 0), kein Crash.
|
||
_check(SymmetryCore.map_center({}).is_zero_approx(), "Defektes doc → Mitte (0, 0), kein Crash")
|
||
# Asymmetrische bounds: Mitte = (min + max) / 2.
|
||
var doc2 := doc.duplicate(true)
|
||
doc2["bounds"] = {"min": [-100.0, -10.0, -50.0], "max": [300.0, 80.0, 50.0]}
|
||
var c2 := SymmetryCore.map_center(doc2)
|
||
_check(absf(c2.x - 100.0) < 0.001 and absf(c2.y - 0.0) < 0.001,
|
||
"Asymmetrische bounds: Mitte (100, 0) korrekt")
|
||
|
||
|
||
func _test_mirror_point_double_identity() -> void:
|
||
print("== Zweimal spiegeln = Ausgangslage (Position, alle 3 Modi) ==")
|
||
var doc := SvmapDocument.create_new("SymTest", 1024.0, "mittag")
|
||
var center := SymmetryCore.map_center(doc)
|
||
var probe: Vector3 = Vector3(123.5, 42.0, -77.25)
|
||
for mode in [SymmetryCore.Mode.POINT, SymmetryCore.Mode.AXIS_X, SymmetryCore.Mode.AXIS_Z]:
|
||
var once := SymmetryCore.mirror_point(mode, probe, center)
|
||
var twice := SymmetryCore.mirror_point(mode, once, center)
|
||
_check(twice.distance_to(probe) < 0.0001,
|
||
"Modus %s: mirror(mirror(p)) == p (p=%s)" % [SymmetryCore.mode_label(mode), probe])
|
||
_check(absf(once.y - probe.y) < 0.0001,
|
||
"Modus %s: y bleibt unverändert" % SymmetryCore.mode_label(mode))
|
||
# Mitte bleibt Mitte (Punktspiegelung).
|
||
var mid := Vector3(0.0, 5.0, 0.0)
|
||
var mid_m := SymmetryCore.mirror_point(SymmetryCore.Mode.POINT, mid, center)
|
||
_check(mid_m.distance_to(mid) < 0.0001, "Mitte bleibt unter Punktspiegelung fix")
|
||
# Achse X: z unverändert; Achse Z: x unverändert.
|
||
var px := SymmetryCore.mirror_point(SymmetryCore.Mode.AXIS_X, probe, center)
|
||
_check(absf(px.z - probe.z) < 0.0001, "Achse X: z unverändert")
|
||
var pz := SymmetryCore.mirror_point(SymmetryCore.Mode.AXIS_Z, probe, center)
|
||
_check(absf(pz.x - probe.x) < 0.0001, "Achse Z: x unverändert")
|
||
# OFF = Identität.
|
||
_check(SymmetryCore.mirror_point(SymmetryCore.Mode.OFF, probe, center).distance_to(probe) < 0.0001,
|
||
"OFF: Position unverändert")
|
||
|
||
|
||
func _test_mirror_rot_double_identity() -> void:
|
||
print("== Zweimal spiegeln = Ausgangslage (Drehung, modulo 360) ==")
|
||
var rots := [0.0, 45.0, 90.0, 179.0, -90.0, 359.0, -359.0]
|
||
for mode in [SymmetryCore.Mode.POINT, SymmetryCore.Mode.AXIS_X, SymmetryCore.Mode.AXIS_Z]:
|
||
for r in rots:
|
||
var r_in: float = r
|
||
var once := SymmetryCore.mirror_rot_y(mode, r_in)
|
||
var twice := SymmetryCore.mirror_rot_y(mode, once)
|
||
var diff: float = absf(fposmod(twice - r_in + 180.0, 360.0) - 180.0)
|
||
_check(diff < 0.0001, "Modus %s: rot %.1f → zweimal → gleich (mod 360)" % [
|
||
SymmetryCore.mode_label(mode), r_in])
|
||
# Einzelfälle laut Spec: Punkt 0° → 180°, Achse 90° → −90° (≡ 270°).
|
||
_check(absf(SymmetryCore.mirror_rot_y(SymmetryCore.Mode.POINT, 0.0) - 180.0) < 0.0001,
|
||
"Spec: Punkt 0° → 180°")
|
||
_check(absf(fposmod(SymmetryCore.mirror_rot_y(SymmetryCore.Mode.AXIS_X, 90.0) + 90.0, 360.0)) < 0.0001,
|
||
"Spec: Achse X 90° → −90° (≡ 270°)")
|
||
_check(absf(SymmetryCore.mirror_rot_y(SymmetryCore.Mode.OFF, 42.0) - 42.0) < 0.0001,
|
||
"OFF: Drehung unverändert")
|
||
|
||
|
||
func _test_maps_to_self() -> void:
|
||
print("== Achse/Zentrum: nicht gedoppelt ==")
|
||
var center := Vector2.ZERO
|
||
_check(SymmetryCore.maps_to_self(SymmetryCore.Mode.POINT, Vector3(0.01, 5.0, 0.01), center),
|
||
"Punkt: Zentrum (±0.01 m) → maps_to_self")
|
||
_check(not SymmetryCore.maps_to_self(SymmetryCore.Mode.POINT, Vector3(10.0, 5.0, 0.0), center),
|
||
"Punkt: 10 m neben der Mitte → wird gedoppelt")
|
||
_check(SymmetryCore.maps_to_self(SymmetryCore.Mode.AXIS_X, Vector3(0.0, 5.0, 123.0), center),
|
||
"Achse X: auf der Achse (x = 0) → maps_to_self")
|
||
_check(not SymmetryCore.maps_to_self(SymmetryCore.Mode.AXIS_X, Vector3(7.0, 5.0, 123.0), center),
|
||
"Achse X: 7 m neben der Achse → wird gedoppelt")
|
||
_check(SymmetryCore.maps_to_self(SymmetryCore.Mode.AXIS_Z, Vector3(5.0, 5.0, 0.0), center),
|
||
"Achse Z: auf der Achse (z = 0) → maps_to_self")
|
||
_check(SymmetryCore.maps_to_self(SymmetryCore.Mode.OFF, Vector3(9.0, 5.0, 9.0), center),
|
||
"OFF: nichts wird gedoppelt → maps_to_self")
|
||
|
||
|
||
func _test_cell_mapping() -> void:
|
||
print("== Zell-Abbildung (Terrain-Raster) ==")
|
||
var res := 9
|
||
# Punkt: (i,j) → (res−1−i, res−1−j); zweimal = Ausgangszelle.
|
||
var c := SymmetryCore.mirror_cell(SymmetryCore.Mode.POINT, 2, 7, res)
|
||
_check(c == Vector2i(6, 1), "Punkt: (2,7) → (6,1) bei res=9")
|
||
_check(SymmetryCore.mirror_cell(SymmetryCore.Mode.POINT, c.x, c.y, res) == Vector2i(2, 7),
|
||
"Punkt: zweimal spiegeln = Ausgangszelle")
|
||
# Achse X: nur i spiegelt; Achse Z: nur j spiegelt.
|
||
_check(SymmetryCore.mirror_cell(SymmetryCore.Mode.AXIS_X, 2, 7, res) == Vector2i(6, 7),
|
||
"Achse X: (2,7) → (6,7)")
|
||
_check(SymmetryCore.mirror_cell(SymmetryCore.Mode.AXIS_Z, 2, 7, res) == Vector2i(2, 1),
|
||
"Achse Z: (2,7) → (2,1)")
|
||
# Rastermitte (ungerade res): mittlere Zelle bleibt fix.
|
||
var mid_i := (res - 1) / 2
|
||
_check(SymmetryCore.mirror_cell(SymmetryCore.Mode.POINT, mid_i, mid_i, res) == Vector2i(mid_i, mid_i),
|
||
"Rastermitte (ungerade res) bleibt unter Punktspiegelung fix")
|
||
_check(SymmetryCore.mirror_cell(SymmetryCore.Mode.AXIS_X, mid_i, 3, res) == Vector2i(mid_i, 3),
|
||
"Achse X: Mittelspalte bleibt fix")
|
||
_check(SymmetryCore.mirror_cell(SymmetryCore.Mode.AXIS_Z, 3, mid_i, res) == Vector2i(3, mid_i),
|
||
"Achse Z: Mittelzeile bleibt fix")
|
||
# Gerade res: keine fixe Zelle.
|
||
_check(SymmetryCore.mirror_cell(SymmetryCore.Mode.POINT, 3, 3, 8) == Vector2i(4, 4),
|
||
"Gerade res=8: (3,3) → (4,4)")
|
||
# OFF + Degenerate (res < 2) → unverändert, kein Crash.
|
||
_check(SymmetryCore.mirror_cell(SymmetryCore.Mode.OFF, 1, 2, 9) == Vector2i(1, 2), "OFF: Zelle unverändert")
|
||
_check(SymmetryCore.mirror_cell(SymmetryCore.Mode.POINT, 0, 0, 1) == Vector2i(0, 0),
|
||
"res=1: Degenerate-Fall, kein Crash")
|
||
_check(SymmetryCore.mirror_cell(SymmetryCore.Mode.POINT, 0, 0, 0) == Vector2i(0, 0),
|
||
"res=0: Degenerate-Fall, kein Crash")
|
||
|
||
|
||
func _test_partner_link() -> void:
|
||
print("== Partner-Link (sym-Feld) ==")
|
||
var obj := {"id": "baum", "pos": [1.0, 0.0, 2.0], "rot_y_deg": 0.0}
|
||
_check(SymmetryCore.sym_of(obj) == "", "Objekt ohne sym → leere Kennung")
|
||
_check(SymmetryCore.sym_of({"id": "x", "sym": 42}) == "", "Defektes sym (Zahl) → leer, kein Crash")
|
||
var key := SymmetryCore.next_sym_key({"objects": []})
|
||
_check(key == "sym_1", "Erste freie Kennung = sym_1")
|
||
SymmetryCore.set_sym(obj, key)
|
||
_check(SymmetryCore.sym_of(obj) == key, "set_sym setzt die Kennung")
|
||
_check(obj.has("sym"), "sym-Feld vorhanden")
|
||
SymmetryCore.clear_sym(obj)
|
||
_check(not obj.has("sym"), "clear_sym entfernt das Feld komplett (wie alte Karten)")
|
||
var doc := {"objects": [
|
||
{"id": "a", "pos": [0.0, 0.0, 0.0], "sym": "sym_1"},
|
||
{"id": "b", "pos": [0.0, 0.0, 0.0]},
|
||
{"id": "c", "pos": [0.0, 0.0, 0.0], "sym": "sym_2"},
|
||
{"id": "bau_wand", "pos": [0.0, 0.0, 0.0], "sym": "sym_3"},
|
||
]}
|
||
_check(SymmetryCore.next_sym_key(doc) == "sym_4",
|
||
"next_sym_key überspringt belegte Kennungen (Bauteile sind normale Objekte)")
|
||
_check(SymmetryCore.find_partner_index(doc["objects"], "sym_2") == 2,
|
||
"find_partner_index findet den Partner")
|
||
_check(SymmetryCore.find_partner_index(doc["objects"], "sym_2", 2) == -1,
|
||
"find_partner_index schließt den Index selbst aus")
|
||
_check(SymmetryCore.find_partner_index(doc["objects"], "sym_3") == 3,
|
||
"find_partner_index findet auch Bauteile")
|
||
_check(SymmetryCore.find_partner_index(doc["objects"], "") == -1,
|
||
"Leere Kennung → kein Partner")
|
||
|
||
|
||
func _test_validator_sym() -> void:
|
||
print("== Validator V16 (sym-Feld an Objekten) ==")
|
||
var doc := SvmapDocument.create_new("SymTest", 1024.0, "mittag")
|
||
doc["objects"] = [{
|
||
"id": "air_defense_001", "pos": [100.0, 0.0, 100.0], "rot_y_deg": 0.0, "scale": 1.0,
|
||
"sym": "sym_1",
|
||
}]
|
||
var errors := SvmapIO._validate_core(doc)
|
||
_check(errors.is_empty(), "Objekt mit gültiger sym-Kennung → keine Fehler (%s)" % [str(errors)])
|
||
# sym = kein String → V16.
|
||
doc["objects"][0]["sym"] = 42
|
||
errors = SvmapIO._validate_core(doc)
|
||
_check(_errors_contain(errors, "V16") and _errors_contain(errors, "muss ein String sein"),
|
||
"sym als Zahl → V16 'muss ein String sein'")
|
||
# sym leer → V16.
|
||
doc["objects"][0]["sym"] = " "
|
||
errors = SvmapIO._validate_core(doc)
|
||
_check(_errors_contain(errors, "V16") and _errors_contain(errors, "nicht leer"),
|
||
"sym leer/Whitespace → V16 'darf nicht leer sein'")
|
||
# sym zu lang → V16.
|
||
doc["objects"][0]["sym"] = "s".repeat(SvmapIO.SYM_KEY_MAX_LEN + 1)
|
||
errors = SvmapIO._validate_core(doc)
|
||
_check(_errors_contain(errors, "V16") and _errors_contain(errors, "länger"),
|
||
"sym > %d Zeichen → V16" % SvmapIO.SYM_KEY_MAX_LEN)
|
||
# Drei Objekte mit derselben Kennung → V16 (Paar = genau 2).
|
||
doc["objects"] = [
|
||
{"id": "air_defense_001", "pos": [100.0, 0.0, 100.0], "rot_y_deg": 0.0, "scale": 1.0, "sym": "sym_9"},
|
||
{"id": "air_defense_001", "pos": [-100.0, 0.0, -100.0], "rot_y_deg": 0.0, "scale": 1.0, "sym": "sym_9"},
|
||
{"id": "air_defense_001", "pos": [0.0, 0.0, 100.0], "rot_y_deg": 0.0, "scale": 1.0, "sym": "sym_9"},
|
||
]
|
||
errors = SvmapIO._validate_core(doc)
|
||
_check(_errors_contain(errors, "V16") and _errors_contain(errors, "genau 2"),
|
||
"3 Objekte mit gleicher Kennung → V16 'genau 2'")
|
||
# Zwei Objekte (echtes Paar) → ok.
|
||
doc["objects"].remove_at(2)
|
||
errors = SvmapIO._validate_core(doc)
|
||
_check(errors.is_empty(), "2 Objekte mit gleicher Kennung (Paar) → keine Fehler")
|
||
|
||
|
||
func _test_roundtrip_with_sym() -> void:
|
||
print("== Roundtrip: speichern → laden → sym bleibt ==")
|
||
var doc := SvmapDocument.create_new("SymRT", 1024.0, "abend")
|
||
doc["objects"] = [
|
||
{"id": "air_defense_001", "pos": [100.0, 0.0, 100.0], "rot_y_deg": 45.0, "scale": 1.0, "sym": "sym_1"},
|
||
{"id": "air_defense_001", "pos": [-100.0, 0.0, -100.0], "rot_y_deg": 225.0, "scale": 1.0, "sym": "sym_1"},
|
||
]
|
||
doc["gebaeude"] = [
|
||
{"id": "wand", "pos": [2.0, 0.0, 4.0], "rot": 90.0, "punkte": [], "sym": "sym_2"},
|
||
{"id": "wand", "pos": [-2.0, 0.0, -4.0], "rot": 270.0, "punkte": [], "sym": "sym_2"},
|
||
]
|
||
var save := SvmapIO.save_to_file("user://test_sym.svmap", doc)
|
||
_check(save["ok"], "save_to_file mit sym ok: %s" % str(save["errors"]))
|
||
var load := SvmapIO.load_from_file("user://test_sym.svmap")
|
||
_check(load["ok"], "load_from_file mit sym ok: %s" % str(load["errors"]))
|
||
var doc2: Dictionary = load["data"]
|
||
_check((doc2["objects"] as Array).size() == 2, "2 Objekte im Roundtrip")
|
||
_check(str(doc2["objects"][0]["sym"]) == "sym_1", "Objekt-sym im Roundtrip erhalten")
|
||
_check(str(doc2["objects"][1]["sym"]) == "sym_1", "Partner-sym im Roundtrip erhalten")
|
||
_check((doc2["gebaeude"] as Array).size() == 2, "2 Bau-Teile im Roundtrip")
|
||
_check(str(doc2["gebaeude"][0]["sym"]) == "sym_2", "Bau-Teil-sym im Roundtrip erhalten")
|
||
# Re-Save → byte-identisch (Kanonisierung stabil).
|
||
var save2 := SvmapIO.save_to_file("user://test_sym2.svmap", doc2)
|
||
_check(save2["ok"], "Re-Save ok")
|
||
var t1 := FileAccess.get_file_as_string("user://test_sym.svmap")
|
||
var t2 := FileAccess.get_file_as_string("user://test_sym2.svmap")
|
||
_check(t1 == t2, "Re-Save ist byte-identisch (Prüfsumme über sym stabil)")
|
||
|
||
|
||
func _test_legacy_map_unchanged() -> void:
|
||
print("== Alte Karte (ohne sym) lädt unverändert ==")
|
||
var doc := SvmapDocument.create_new("Legacy", 512.0, "nacht")
|
||
doc["objects"] = [
|
||
{"id": "air_defense_001", "pos": [50.0, 0.0, 50.0], "rot_y_deg": 10.0, "scale": 1.0},
|
||
]
|
||
var save := SvmapIO.save_to_file("user://test_sym_legacy.svmap", doc)
|
||
_check(save["ok"], "Legacy-save ok: %s" % str(save["errors"]))
|
||
var legacy_text := FileAccess.get_file_as_string("user://test_sym_legacy.svmap")
|
||
var load := SvmapIO.load_from_file("user://test_sym_legacy.svmap")
|
||
_check(load["ok"], "Legacy-load ok")
|
||
var doc2: Dictionary = load["data"]
|
||
_check((doc2["objects"] as Array).size() == 1, "Legacy: 1 Objekt")
|
||
_check(not doc2["objects"][0].has("sym"), "Legacy: kein sym-Feld entstanden")
|
||
# Roundtrip byte-identisch → alte Karten speichern sich unverändert weiter.
|
||
var save2 := SvmapIO.save_to_file("user://test_sym_legacy2.svmap", doc2)
|
||
_check(save2["ok"], "Legacy re-save ok")
|
||
var text2 := FileAccess.get_file_as_string("user://test_sym_legacy2.svmap")
|
||
_check(legacy_text == text2, "Legacy: speichern→laden→speichern byte-identisch")
|