scopedvr-map-editor/tests/test_build_spawn_mirror.gd
Hermes (scopedvr-dev) c15c606311 t_ffa207b1: M4 Bau-Teile + Spawnpunkte spiegeln (Spec vc27)
- BuildOps.place_mirrored / update_mirrored / remove_mirrored /
  inside_match_bounds: pure Statics in build_ops.gd, sym-Kopplung aus M1,
  gleiche Snap-Regeln (Paritaet bleibt), tiefe Kopien (kein Aliasing).
- V9-Grenzen scharf: laege der Zwilling ausserhalb der Match-Grenzen
  (Loader-Regel _inside_bounds ±1 m), wird er NICHT gespeichert und der
  Status meldet V9 im Klartext — auch kein halber sym-Link.
- SpawnTool-Spiegelung im GEGNER-Team (Blau West <-> Rot Ost = faire
  Gegenstelle, Radius 1:1), Partner-Map in design_notes.spawn_mirror
  (Loader-frei, rundtrip-sicher). Auf der Mitte: nicht gedoppelt.
- build_tool.gd: duenne Hooks an den 6 Klick-Mutationen + demo_place —
  EIN Undo-Schritt nimmt Original UND Zwilling zurueck.
- spawn_tool.gd: Hooks in setzen/verschieben/loeschen/demo_place/demo_move;
  _sym_mode() defensiv ueber main.get() (Mock-Mains ohne sym_mode -> OFF,
  statt SCRIPT ERROR — Lektion aus test_shortcuts).
- Neue Suite tests/test_build_spawn_mirror.gd (76 Checks ALL OK) +
  Probe tests/probe_m4_mirror_render.gd (2 Beweis-Bilder).
- Regression: 19/20 Suiten gruen; test_glb_view 15/25 FAIL pre-existing
  (per git-stash gegen 437f6bf verifiziert, Ursache unabhaengig vom Diff).
2026-10-20 06:47:58 +00:00

440 lines
20 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends SceneTree
## Editor-Tests (headless): Karte t_ffa207b1 — M4 Bau-Teile + 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 (BuildOps/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://src/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_snap_parity()
_test_build_remove()
_test_build_update_follows()
_test_build_v9_bounds()
_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 _piece_at(doc: Dictionary, index: int) -> Dictionary:
var list := BuildOps.pieces(doc)
if index < 0 or index >= list.size() or not (list[index] is Dictionary):
return {}
return list[index] as Dictionary
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
# ------------------------------------------------------- Bau-Teile: setzen ---
func _test_build_place_point() -> void:
print("Testgruppe: Bau-Teil setzen → Zwilling (Punktspiegelung)")
var doc := _new_doc()
var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.2, 20.0), 90.0)
BuildOps.ensure_block(doc).append(teil)
var msg: String = BuildOps.place_mirrored(doc, teil, POINT)
_check(msg == "", "Punktspiegelung: still gespiegelt (keine Meldung)")
_check(BuildOps.piece_count(doc) == 2, "2 Bauteile nach dem Setzen")
var twin := _piece_at(doc, 1)
_check(str(twin.get("id", "")) == "wand", "Zwilling hat gleiche kind_id")
_check(_near(_pos3(twin).x, -10.0) and _near(_pos3(twin).y, 0.2) and _near(_pos3(twin).z, -20.0),
"Zwilling-Position gespiegelt (−10, 0.2, −20)")
_check(_near(float(twin.get("rot", 0.0)), 270.0), "Zwilling-Drehung 90° + 180° = 270°")
var key := SymmetryCore.sym_of(teil)
_check(not key.is_empty() and SymmetryCore.sym_of(twin) == key,
"beide Teile tragen dieselbe sym-Kennung")
# V16-Realität: GENAU 2 Einträge pro Kennung.
var n := 0
for t in BuildOps.pieces(doc):
if SymmetryCore.sym_of(t as Dictionary) == key:
n += 1
_check(n == 2, "sym-Kennung verbindet genau 2 Teile")
# Zwilling ist eine tiefe Kopie (keine Alias-Falle bei punkte).
var first_x: float = (teil["punkte"] as Array)[0][0]
(teil["punkte"] as Array)[0][0] = 123.0
_check(_near(float((twin["punkte"] as Array)[0][0]), first_x),
"Zwilling-Punkte sind Kopien (kein Aliasing)")
func _test_build_place_axes() -> void:
print("Testgruppe: Bau-Teil setzen → Zwilling (Achse X / Achse Z)")
var doc := _new_doc()
var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0)
BuildOps.ensure_block(doc).append(teil)
BuildOps.place_mirrored(doc, teil, AXIS_X)
var twin := _piece_at(doc, 1)
_check(_near(_pos3(twin).x, -10.0) and _near(_pos3(twin).z, 20.0),
"Achse X: x gespiegelt, z bleibt")
_check(_near(float(twin.get("rot", 0.0)), 270.0), "Achse X: Drehung −90° = 270°")
var doc2 := _new_doc()
var teil2 := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0)
BuildOps.ensure_block(doc2).append(teil2)
BuildOps.place_mirrored(doc2, teil2, AXIS_Z)
var twin2 := _piece_at(doc2, 1)
_check(_near(_pos3(twin2).x, 10.0) and _near(_pos3(twin2).z, -20.0),
"Achse Z: z gespiegelt, x bleibt")
_check(_near(float(twin2.get("rot", 0.0)), 270.0), "Achse Z: Drehung −90° = 270°")
# Auf der Mitte: nicht gedoppelt (Spec vc27).
var doc3 := _new_doc()
var mitte := BuildOps.make_piece("wand", Vector3(0.0, 0.0, 0.0), 0.0)
BuildOps.ensure_block(doc3).append(mitte)
var msg: String = BuildOps.place_mirrored(doc3, mitte, POINT)
_check(msg.contains("nicht gedoppelt"), "Bauteil auf der Mitte: Meldung statt Zwilling")
_check(BuildOps.piece_count(doc3) == 1, "Auf der Mitte: nur 1 Bauteil")
_check(SymmetryCore.sym_of(mitte).is_empty(), "Auf der Mitte: kein sym-Link")
## Gleiche Snap-Regeln: Spiegelung erhält die Raster-Parität
## (Raster „ecke" = gerade Meter, „mitte" = ungerade Meter) bei zentrierter Karte.
func _test_build_snap_parity() -> void:
print("Testgruppe: Snap-Parität bleibt erhalten")
var doc := _new_doc()
var ecke := BuildOps.make_piece("wand", Vector3(2.0, 0.0, 4.0), 0.0)
BuildOps.ensure_block(doc).append(ecke)
BuildOps.place_mirrored(doc, ecke, POINT)
_check(_near(_pos3(_piece_at(doc, 1)).x, -2.0) and _near(_pos3(_piece_at(doc, 1)).z, -4.0),
"ecke-Teil: gerade Meter bleiben gerade (−2, −4)")
var doc2 := _new_doc()
var mitte := BuildOps.make_piece("fundament", Vector3(3.0, 0.0, 3.0), 0.0)
BuildOps.ensure_block(doc2).append(mitte)
BuildOps.place_mirrored(doc2, mitte, POINT)
_check(_near(_pos3(_piece_at(doc2, 1)).x, -3.0) and _near(_pos3(_piece_at(doc2, 1)).z, -3.0),
"mitte-Teil: ungerade Meter bleiben ungerade (−3, −3)")
# ----------------------------------------------------- Bau-Teile: entfernen ---
func _test_build_remove() -> void:
print("Testgruppe: Bau-Teil entfernen → beide weg")
var doc := _new_doc()
var teil := BuildOps.make_piece("boden", Vector3(11.0, 0.0, 21.0), 0.0)
BuildOps.ensure_block(doc).append(teil)
BuildOps.place_mirrored(doc, teil, POINT)
_check(BuildOps.piece_count(doc) == 2, "Vorbereitung: 2 Bauteile")
var msg: String = BuildOps.remove_mirrored(doc, 0, POINT)
_check(BuildOps.piece_count(doc) == 0, "Original löschen → Zwilling auch weg")
_check(msg.contains("Zwilling"), "Status meldet den mitgelöschten Zwilling")
# Umgekehrt: Zwilling löschen (Index 1) → Original mit weg.
var doc2 := _new_doc()
var teil2 := BuildOps.make_piece("boden", Vector3(11.0, 0.0, 21.0), 0.0)
BuildOps.ensure_block(doc2).append(teil2)
BuildOps.place_mirrored(doc2, teil2, POINT)
BuildOps.remove_mirrored(doc2, 1, POINT)
_check(BuildOps.piece_count(doc2) == 0, "Zwilling löschen → Original auch weg")
# Paar + Fremdteil: Löschung des Fremdteils lässt das Paar unangetastet.
var doc3 := _new_doc()
var a := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 0.0), 0.0)
BuildOps.ensure_block(doc3).append(a)
BuildOps.place_mirrored(doc3, a, POINT)
var fremd := BuildOps.make_piece("boden", Vector3(30.0, 0.0, 0.0), 0.0)
BuildOps.ensure_block(doc3).append(fremd)
BuildOps.remove_mirrored(doc3, 2, POINT)
_check(BuildOps.piece_count(doc3) == 2, "Fremdteil weg, Paar bleibt (2 Teile)")
_check(not _piece_at(doc3, 0).is_empty() and not _piece_at(doc3, 1).is_empty(),
"Paar-Indizes unverändert verschoben")
# ------------------------------------- Bau-Teile: verschieben/drehen/anheben ---
func _test_build_update_follows() -> void:
print("Testgruppe: Verschieben/Drehen/Anheben — Zwilling folgt")
var doc := _new_doc()
var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0)
BuildOps.ensure_block(doc).append(teil)
BuildOps.place_mirrored(doc, teil, POINT)
# Verschieben (Pfeiltaste-Muster): Original auf neue Position, dann sync.
teil["pos"] = [14.0, 0.0, 20.0]
BuildOps.update_mirrored(doc, 0, POINT)
var twin := _piece_at(doc, 1)
_check(_near(_pos3(twin).x, -14.0) and _near(_pos3(twin).z, -20.0),
"Zwilling folgt der verschobenen Position gespiegelt")
# Drehen: +90° am Original → Zwilling = gespiegelte NEUE Drehung.
teil["rot"] = 180.0
BuildOps.update_mirrored(doc, 0, POINT)
_check(_near(float((_piece_at(doc, 1) as Dictionary).get("rot", 0.0)), 0.0),
"Zwilling-Drehung: 180° + 180° = 0° (modulo 360)")
# Anheben: y wandert 1:1 mit (Höhe ist ein Skalar).
teil["pos"] = [14.0, 2.4, 20.0]
BuildOps.update_mirrored(doc, 0, POINT)
_check(_near(_pos3(_piece_at(doc, 1)).y, 2.4), "Zwilling-y folgt 1:1 (Skalar)")
# Teil OHNE sym-Partner (außerhalb des Modus gebaut) bleibt unberührt.
var doc2 := _new_doc()
var solo := BuildOps.make_piece("wand", Vector3(6.0, 0.0, 0.0), 0.0)
BuildOps.ensure_block(doc2).append(solo)
_check(BuildOps.update_mirrored(doc2, 0, POINT) == "", "Teil ohne sym: kein Zwilling, keine Meldung")
_check(BuildOps.piece_count(doc2) == 1, "Teil ohne sym: nichts wurde gedoppelt")
# ------------------------------------------------------- Bau-Teile: V9 -------
## V9-Realität (deshalb so getestet): bei zentrierten bounds bildet die
## Spiegelung gültiger Elemente bildet IMMER auf gültige Stellen ab — der V9-Fall
## entsteht, wenn das ORIGINAL bereits außerhalb liegt (Editor erzwingt die
## Grenzen beim Nudge/Baken nicht) oder Grenzen asymmetrisch sind. Dann
## verweigert der Zwilling die Speicherung mit Klartext-Meldung.
func _test_build_v9_bounds() -> void:
print("Testgruppe: Bau-Teil — V9-Grenzen scharf")
var doc := _new_doc()
# Original außerhalb (z = 200 > 128 + 1 Toleranz): auch der Zwilling läge
# außerhalb → Meldung statt still zu speichern.
var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 200.0), 0.0)
BuildOps.ensure_block(doc).append(teil)
var msg: String = BuildOps.place_mirrored(doc, teil, POINT)
_check(msg.contains("V9"), "V9-Meldung statt still zu speichern")
_check(msg.contains("Match-Grenzen"), "Meldung nennt die Match-Grenzen")
_check(BuildOps.piece_count(doc) == 1, "V9-Fall: NUR das Original gespeichert")
_check(SymmetryCore.sym_of(teil).is_empty(), "V9-Fall: kein halber sym-Link zurückbleibt")
# Grenz-Nähe INNERHALB (Toleranz ±1 m): spiegeln bleibt erlaubt.
var doc2 := _new_doc()
var nah := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 127.5), 0.0)
BuildOps.ensure_block(doc2).append(nah)
_check(BuildOps.place_mirrored(doc2, nah, POINT) == "",
"nahe der Grenze (|z| ≤ 128 + 1): gespiegelt ok")
_check(BuildOps.piece_count(doc2) == 2, "Grenz-Nähe: Zwilling vorhanden")
# update_mirrored: Original aus den Grenzen verschoben → Zwilling bleibt unverändert.
var doc3 := _new_doc()
var t3 := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 30.0), 0.0)
BuildOps.ensure_block(doc3).append(t3)
BuildOps.place_mirrored(doc3, t3, POINT)
t3["pos"] = [10.0, 85.0, 30.0] # y = 85 > 80 + 1 → gespiegelt ebenfalls draußen
var msg3: String = BuildOps.update_mirrored(doc3, 0, POINT)
_check(msg3.contains("V9"), "update: V9-Meldung statt Zwilling verschieben")
_check(_near(_pos3(_piece_at(doc3, 1)).z, -30.0) and _near(_pos3(_piece_at(doc3, 1)).y, 0.0),
"update-V9: Zwilling blieb UNVERÄNDERT (−30 / y 0)")
# ------------------------------------------------- Bau-Teile: Modus AUS -----
func _test_build_off_gegenprobe() -> void:
print("Testgruppe: Modus AUS = Verhalten wie bisher (Bau-Teile)")
var doc := _new_doc()
var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0)
BuildOps.ensure_block(doc).append(teil)
_check(BuildOps.place_mirrored(doc, teil, OFF) == "", "OFF: keine Meldung, kein Zwilling")
_check(BuildOps.piece_count(doc) == 1, "OFF: nur 1 Bauteil")
_check(SymmetryCore.sym_of(teil).is_empty(), "OFF: kein sym-Feld gesetzt")
teil["pos"] = [12.0, 0.0, 20.0]
BuildOps.update_mirrored(doc, 0, OFF)
_check(BuildOps.piece_count(doc) == 1, "OFF: Verschieben dupliziert nicht")
var msg: String = BuildOps.remove_mirrored(doc, 0, OFF)
_check(BuildOps.piece_count(doc) == 0 and msg == "", "OFF: Löschen entfernt genau 1 Teil")
## EIN Undo-Schritt: Original + Zwilling springen zusammen zurück.
func _test_build_one_undo_step() -> void:
print("Testgruppe: Bau-Teile — EIN Undo-Schritt für beide Seiten")
var doc := _new_doc()
var hist := ActionHistory.new()
hist.begin(doc)
var before: Dictionary = hist.snapshot(["gebaeude"])
var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0)
BuildOps.ensure_block(doc).append(teil)
BuildOps.place_mirrored(doc, teil, POINT)
hist.commit("Wand gesetzt", before, hist.snapshot(["gebaeude"]))
_check(BuildOps.piece_count(doc) == 2, "commit: beide Teile drin")
hist.undo()
_check(BuildOps.piece_count(doc) == 0, "EIN undo: Original UND Zwilling weg")
hist.redo()
_check(BuildOps.piece_count(doc) == 2, "EIN 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)