extends SceneTree ## Editor-Tests (headless): Karte t_ffa207b1 — M4 Bauteile (jetzt normale Objekte) + Spawnpunkte ## spiegeln (Spec /opt/data/scopedvr/KARTEN_MIRROR_vc27.md, Mathe aus M1 ## symmetry_core.gd). Ausführen: ## godot --headless --path . --script res://tests/test_build_spawn_mirror.gd ## Exit 0 = alles grün. Läuft OHNE UI (BauOps/SpawnOps-Statics + ## ActionHistory) — GUI-Feinheiten (Statuszeile, Ghost) verifiziert der ## Desktop-Smoke, nicht hier. ## ## Abdeckung (Karten-Auftrag): ## · Bau-Teil setzen → Zwilling vorhanden (Punkt, Achse X, Achse Z) ## · Bau-Teil entfernen → beide weg ## · Verschieben/Drehen/Anheben spiegelt den Zwilling mit ## · Spawn setzen → gespiegelter Spawn (Gegner-Team, faire Gegenstelle) ## · Spawn nahe der Grenze → V9-Meldung statt Speichern ## · Modus AUS = Gegenprobe (Verhalten exakt wie bisher) ## · EIN Undo-Schritt nimmt Original + Zwilling zurück var failures := 0 var checks := 0 const OFF := SymmetryCore.Mode.OFF const POINT := SymmetryCore.Mode.POINT const AXIS_X := SymmetryCore.Mode.AXIS_X const AXIS_Z := SymmetryCore.Mode.AXIS_Z # spawn_tool.gd hat bewusst KEINEN class_name (Node-Werkzeug) — Statics via preload. const SpawnTool := preload("res://core/editor/spawn_tool.gd") func _init() -> void: _run_tests() if failures == 0: print("ALL OK: %d checks" % checks) quit(0) else: print("FAILED: %d/%d checks" % [failures, checks]) quit(1) 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_build_place_point() _test_build_place_axes() _test_build_replace_follows() _test_build_off_gegenprobe() _test_build_one_undo_step() _test_spawn_add_mirror() _test_spawn_move_delete_mirror() _test_spawn_v9_bounds() _test_spawn_off_gegenprobe() _test_spawn_roundtrip_safe() func _new_doc() -> Dictionary: # 256-m-Karte: bounds ±128 zentriert → Kartenmitte (0, 0). return SvmapDocument.create_new("Sym-M4-Test", 256.0, "mittag") func _pos3(p: Dictionary) -> Vector3: var pos: Array = p.get("pos", [0.0, 0.0, 0.0]) return Vector3(float(pos[0]), float(pos[1]), float(pos[2])) func _near(a: float, b: float) -> bool: return absf(a - b) < 0.001 # ---------------------------------------- Bauteile (normale Objekte) -------- func _flach() -> Callable: return func(_x: float, _z: float) -> float: return 0.0 ## Wand per echter Snap-Logik setzen (Hit knapp neben der Kantenmitte). func _setze(doc: Dictionary, oid: String, hit: Vector3, mode: int, gid: int, spiegel := -1) -> Dictionary: var snap := BuildSnap.berechne(oid, hit, 0.0, BuildSnap.sammle_teile(doc), _flach()) return BauOps.setze_teil(doc, oid, snap, gid, mode, spiegel) func _test_build_place_point() -> void: print("Testgruppe: Bauteil setzen → Zwilling (Punktspiegelung)") var doc := _new_doc() var gid := BauOps.gruppe_neu(doc) var r := _setze(doc, "bau_wand", Vector3(11.0, 0.0, 20.1), POINT, gid, gid + 1) var objs: Array = doc["objects"] _check(objs.size() == 2 and int(r["twin"]) == 1, "Original + Zwilling im Objekt-Block") var orig: Dictionary = objs[0] var twin: Dictionary = objs[1] _check(str(twin["id"]) == "bau_wand", "Zwilling hat gleiche ID") var op := _pos3(orig) var tp := _pos3(twin) _check(_near(tp.x, -op.x) and _near(tp.z, -op.z) and _near(tp.y, op.y), "Zwilling-Position punktgespiegelt") _check(_near(fposmod(float(twin["rot_y_deg"]) - float(orig["rot_y_deg"]), 360.0), 180.0), "Zwilling um 180° gedreht") var key := SymmetryCore.sym_of(orig) _check(not key.is_empty() and SymmetryCore.sym_of(twin) == key, "gleiche sym-Kennung") _check(GroupOps.group_id(orig) == gid and GroupOps.group_id(twin) == gid + 1, "Zwilling bekommt das Spiegel-Gebäude") func _test_build_place_axes() -> void: print("Testgruppe: Bauteil setzen → Zwilling (Achsen X / Z)") for mode in [AXIS_X, AXIS_Z]: var doc := _new_doc() var gid := BauOps.gruppe_neu(doc) _setze(doc, "bau_fundament", Vector3(11.0, 0.0, 21.0), mode, gid, gid + 1) var objs: Array = doc["objects"] _check(objs.size() == 2, "Modus %d: Zwilling da" % mode) var op := _pos3(objs[0]) var tp := _pos3(objs[1]) if mode == AXIS_X: _check(_near(tp.x, -op.x) and _near(tp.z, op.z), "Achse X: x gespiegelt, z gleich") else: _check(_near(tp.z, -op.z) and _near(tp.x, op.x), "Achse Z: z gespiegelt, x gleich") func _test_build_replace_follows() -> void: print("Testgruppe: Tür einsetzen → Zwilling bekommt auch die Tür") var doc := _new_doc() var gid := BauOps.gruppe_neu(doc) _setze(doc, "bau_wand", Vector3(11.0, 0.0, 20.1), POINT, gid, gid + 1) var r := _setze(doc, "bau_wand_tuer", Vector3(11.0, 1.5, 20.1), POINT, gid, gid + 1) var objs: Array = doc["objects"] _check(bool(r["ersetzt"]) and objs.size() == 2, "ersetzt statt anhängen") _check(str(objs[0]["id"]) == "bau_wand_tuer" and str(objs[1]["id"]) == "bau_wand_tuer", "Original UND Zwilling haben die Tür") func _test_build_off_gegenprobe() -> void: print("Testgruppe: Symmetrie AUS = ein Teil, kein Zwilling") var doc := _new_doc() var gid := BauOps.gruppe_neu(doc) var r := _setze(doc, "bau_wand", Vector3(11.0, 0.0, 20.1), OFF, gid) _check((doc["objects"] as Array).size() == 1 and int(r["twin"]) == -1, "kein Zwilling im Modus AUS") _check(SymmetryCore.sym_of((doc["objects"] as Array)[0]).is_empty(), "keine sym-Kennung") func _test_build_one_undo_step() -> void: print("Testgruppe: EIN Undo-Schritt nimmt Original + Zwilling zurück") var doc := _new_doc() var hist := ActionHistory.new() hist.begin(doc) var before := hist.snapshot(["objects", "group_names"]) if not before.has("group_names"): before["group_names"] = null var gid := BauOps.gruppe_neu(doc) _setze(doc, "bau_wand", Vector3(11.0, 0.0, 20.1), POINT, gid, gid + 1) hist.commit("Wand bauen", before, hist.snapshot(["objects", "group_names"])) _check((doc["objects"] as Array).size() == 2, "nach dem Setzen: 2 Teile") hist.undo() _check((doc["objects"] as Array).is_empty(), "EIN Undo: beide weg") hist.redo() _check((doc["objects"] as Array).size() == 2, "Redo: beide wieder da") # -------------------------------------------------------- Spawns: setzen ----- func _test_spawn_add_mirror() -> void: print("Testgruppe: Spawn setzen → gespiegelter Spawn (Gegner-Team)") var doc := _new_doc() var point := SpawnOps.add_zone(doc, 0, -100.0, 40.0) var pid := str(point["id"]) var res: Dictionary = SpawnTool.spawn_mirror_add(doc, 0, pid, POINT) _check(bool(res["ok"]), "Spiegelung ok: %s" % str(res["status"])) _check(SpawnOps.count_points(doc, 1) == 5, "Team Rot: 4 Default + 1 Zwilling") var twin := _spawn_by_id(doc, 1, spawn_partner_id(doc, pid)) _check(not twin.is_empty(), "gespiegelter Spawn vorhanden") _check(_near(_pos3(twin).x, 100.0) and _near(_pos3(twin).z, -40.0), "Zwilling-Position gespiegelt (100, −40)") _check(_near(_pos3(twin).y, _pos3(point).y), "Zwilling-y = Terrain-Höhe am gespiegelten Punkt") _check(_near(SpawnOps.zone_radius(doc, str(twin["id"])), SpawnOps.zone_radius(doc, pid)), "Zwilling-Radius = Original-Radius") _check(spawn_partner_id(doc, pid) == str(twin["id"]) and spawn_partner_id(doc, str(twin["id"])) == pid, "Partner-Map verlinkt beide Richtungen") # Achse X: z bleibt gleich. var doc2 := _new_doc() var p2 := SpawnOps.add_zone(doc2, 1, 100.0, 40.0) SpawnTool.spawn_mirror_add(doc2, 1, str(p2["id"]), AXIS_X) var twin2 := _spawn_by_id(doc2, 0, spawn_partner_id(doc2, str(p2["id"]))) _check(_near(_pos3(twin2).x, -100.0) and _near(_pos3(twin2).z, 40.0), "Achse X: x gespiegelt, z bleibt") # Auf der Mitte: nicht gedoppelt. var doc3 := _new_doc() var p3 := SpawnOps.add_zone(doc3, 0, 0.0, 0.0) var res3: Dictionary = SpawnTool.spawn_mirror_add(doc3, 0, str(p3["id"]), POINT) _check(str(res3["status"]).contains("nicht gedoppelt"), "Spawn auf der Mitte: Meldung") _check(SpawnOps.count_points(doc3, 1) == 4, "Spawn auf der Mitte: kein Zwilling") # ------------------------------------------- Spawns: verschieben / löschen ---- func _test_spawn_move_delete_mirror() -> void: print("Testgruppe: Spawn verschieben/löschen — Zwilling folgt") var doc := _new_doc() var point := SpawnOps.add_zone(doc, 0, -100.0, 40.0) var pid := str(point["id"]) SpawnTool.spawn_mirror_add(doc, 0, pid, POINT) var twin_id := spawn_partner_id(doc, pid) # Verschieben des Originals → Zwilling auf gespiegelte NEUE Position. SpawnOps.move_point(doc, 0, pid, -60.0, 20.0) var msg: String = SpawnTool.spawn_mirror_move(doc, 0, pid, POINT) _check(msg.contains("mitverschoben"), "Verschieben gespiegelt: %s" % msg) var twin := _spawn_by_id(doc, 1, twin_id) _check(_near(_pos3(twin).x, 60.0) and _near(_pos3(twin).z, -20.0), "Zwilling auf gespiegelter neuer Position (60, −20)") # Löschen des Originals (macht delete_selected) → Zwilling weg + Map sauber. SpawnOps.remove_point(doc, 0, pid) var del_msg: String = SpawnTool.spawn_mirror_delete(doc, pid, POINT) _check(del_msg.contains("mit entfernt"), "Löschen gespiegelt: %s" % del_msg) _check(_spawn_by_id(doc, 1, twin_id).is_empty(), "Zwilling-Spawn weg") _check(SpawnOps.count_points(doc, 0) == 4 and SpawnOps.count_points(doc, 1) == 4, "beide Teams wieder bei 4 Default-Zonen") _check(SpawnTool.spawn_mirror_partner(doc, pid).is_empty(), "Partner-Map-Einträge entfernt (kein Geist)") # Zwilling löschen (andere Richtung) → Original mit weg. var doc2 := _new_doc() var p2 := SpawnOps.add_zone(doc2, 1, 100.0, -40.0) var pid2 := str(p2["id"]) SpawnTool.spawn_mirror_add(doc2, 1, pid2, POINT) var orig_id := spawn_partner_id(doc2, pid2) SpawnTool.spawn_mirror_delete(doc2, pid2, POINT) _check(_spawn_by_id(doc2, 0, orig_id).is_empty(), "Zwilling gelöscht → Original mit weg") # --------------------------------------------------------- Spawns: V9 -------- func _test_spawn_v9_bounds() -> void: print("Testgruppe: Spawn nahe der Grenze → V9-Meldung statt Speichern") # Original außerhalb der Grenzen (z = 130 > 128 + 1): der gespiegelte # Zwilling läge ebenfalls draußen → Meldung, NICHT speichern. var doc := _new_doc() var point := SpawnOps.add_zone(doc, 0, -100.0, 130.0) var pid := str(point["id"]) var res: Dictionary = SpawnTool.spawn_mirror_add(doc, 0, pid, POINT) _check(not bool(res["ok"]), "ok=false — Zwilling NICHT gespeichert") _check(str(res["status"]).contains("V9"), "Status nennt V9") _check(str(res["status"]).contains("Match-Grenzen"), "Status nennt die Match-Grenzen") _check(SpawnOps.count_points(doc, 1) == 4, "Team Rot unverändert (4 Default)") _check(SpawnTool.spawn_mirror_partner(doc, pid).is_empty(), "kein halber Partner-Link") # Grenz-Nähe INNERHALB: spiegeln bleibt erlaubt (Toleranz ±1 m). var doc2 := _new_doc() var nah := SpawnOps.add_zone(doc2, 0, -100.0, 127.5) var res2: Dictionary = SpawnTool.spawn_mirror_add(doc2, 0, str(nah["id"]), POINT) _check(bool(res2["ok"]), "nahe der Grenze (|z| ≤ 129): gespiegelt ok") _check(SpawnOps.count_points(doc2, 1) == 5, "Grenz-Nähe: Zwilling vorhanden") # ---------------------------------------------------- Spawns: Modus AUS ------ func _test_spawn_off_gegenprobe() -> void: print("Testgruppe: Modus AUS = Verhalten wie bisher (Spawns)") var doc := _new_doc() var point := SpawnOps.add_zone(doc, 0, -100.0, 40.0) var pid := str(point["id"]) var res: Dictionary = SpawnTool.spawn_mirror_add(doc, 0, pid, OFF) _check(bool(res["ok"]) and str(res["status"]) == "", "OFF: still nichts gespiegelt") _check(SpawnOps.count_points(doc, 1) == 4, "OFF: Team Rot unverändert") SpawnOps.move_point(doc, 0, pid, -60.0, 20.0) _check(SpawnTool.spawn_mirror_move(doc, 0, pid, OFF) == "", "OFF: Verschieben spiegelt nicht") SpawnOps.remove_point(doc, 0, pid) var del_res: String = SpawnTool.spawn_mirror_delete(doc, pid, OFF) _check(del_res == "" and SpawnOps.count_points(doc, 0) == 4, "OFF: Löschen entfernt nur das Original") ## design_notes bleibt Loader-frei + rundtrip-sicher (Radius-Muster). func _test_spawn_roundtrip_safe() -> void: print("Testgruppe: Spawn-Spiegelung überlebt Speichern/Laden") var doc := _new_doc() (doc["info"] as Dictionary)["author"] = "Sly" var point := SpawnOps.add_zone(doc, 0, -100.0, 40.0) var pid := str(point["id"]) SpawnTool.spawn_mirror_add(doc, 0, pid, POINT) var save: Dictionary = SvmapIO.save_to_file("user://test_build_spawn_mirror.svmap", doc) _check(bool(save["ok"]), "save_to_file ok: %s" % str(save["errors"])) var load: Dictionary = SvmapIO.load_from_file("user://test_build_spawn_mirror.svmap") _check(bool(load["ok"]), "load_from_file ok (Prüfsumme stimmt)") var back: Dictionary = load["data"] _check(SpawnOps.count_points(back, 1) == 5, "Re-Import: gespiegelter Spawn noch da") var notes: Dictionary = back.get("design_notes", {}) _check((notes.get(SpawnTool.SPAWN_MIRROR_KEY, {}) as Dictionary).has(pid), "Re-Import: Partner-Link in design_notes überlebt") # ------------------------------------------------------------------ Helfer --- func _spawn_by_id(doc: Dictionary, team: int, pid: String) -> Dictionary: if pid.is_empty(): return {} for p in SpawnOps.points(doc, team): if p is Dictionary and str(p.get("id", "")) == pid: return p as Dictionary return {} func spawn_partner_id(doc: Dictionary, pid: String) -> String: return SpawnTool.spawn_mirror_partner(doc, pid)