scopedvr-map-editor/tests/test_symmetry_onclick.gd
slyphax 491a47d3f1 Editor-Bibliothek auf das Military-Desert-Pack umgestellt
- 423 Objekte in 19 deutschen Kategorien (Panzer, Radfahrzeuge, Flugzeuge, Gebaeude,
  Zelte, Tuerme, Mauern, Sperren, Kisten, Technik, Felsen, Boden, Decals ...);
  Gruppen "Bauwerke" und "Kisten & Barrieren" samt Test-Asset-Namenslisten entfernt
- LibraryBrowser: Gruppen/Namen aus dem Manifest (kategorie, name_de), unbekannte
  Kategorien bekommen automatisch eine eigene Gruppe
- Thumbs neu, library/ synchronisiert (inkl. neuer Kollision)
- Neue Identitaetsprobe: Kollision ALLER Objekte Editor = Client = Server
- Tests auf Pack-Objekte umgestellt

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 16:31:28 +01:00

379 lines
17 KiB
GDScript

extends SceneTree
## M5-Tests (headless): Karte t_60014669 — Ein-Klick „Karte symmetrisch machen“
## + Symmetrie-Check mit Klartext-Bericht. Ausführen:
## godot --headless --path . --script res://tests/test_symmetry_onclick.gd
## Exit 0 = alles grün; zusätzlich nach Parse Error/SCRIPT ERROR greifen.
## Die Arbeit lebt in SymmetryOps (statisch); das Panel (Registry-Weg) wird
## mit einem Mock-main gefahren — kein echtes UI nötig.
var failures := 0
var checks := 0
## spawn_tool.gd bewusst OHNE class_name (M4-Entscheidung) → preload.
const SpawnToolScript := preload("res://src/editor/spawn_tool.gd")
func _init() -> void:
_test_analyze_asym_map()
_test_check_klartext()
_test_apply_and_check_zero()
_test_halves_cell_equal()
_test_source_flip()
_test_legacy_without_sym()
_test_second_apply_nothing()
_test_undo_single_step()
_test_mode_off()
_after_frames()
func _after_frames() -> void:
await process_frame
await process_frame
_test_panel_flow()
_test_panel_off_and_choice()
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)
# ------------------------------------------------------------ Test-Karten ---
func _obj(id: String, pos: Array, rot := 0.0) -> Dictionary:
return {"id": id, "pos": pos, "scale": 1.0, "rot_y_deg": float(rot)}
## Synthetische, bewusst ASYMMETRISCHE Karte (Modus Punkt):
## - Hügel + Sandfleck nur auf der linken Hälfte,
## - Objekt A links ohne Zwilling, Objekt B rechts (Widerspruch: falsche Drehung),
## - Objekt C rechts OHNE Gegenstück (wird zurückgespiegelt),
## - Default-Spawns sind punkt-symmetrisch; ein roter Punkt fehlt (B1) und
## ein zusätzlicher blauer Punkt sitzt solo links.
func _asym_doc() -> Dictionary:
var doc := SvmapDocument.create_new("M5Test", 1024.0, "mittag")
var terrain: Dictionary = doc["terrain"]
var res := int(terrain["resolution"])
var heights := SvmapIO.decode_heights(terrain)
for j in res:
for i in 60:
heights[j * res + i] = 8.0 # Hügel nur links (i < 60 < Mitte 64)
terrain["heightmap_r16_b64"] = TerrainField.encode(heights,
float(terrain["min_height_m"]), float(terrain["max_height_m"]))
var mats := SvmapIO.decode_materials(terrain)
var mres := SvmapIO.material_resolution(terrain) # feines Raster (8 Punkte je Höhenzelle)
var k := (mres - 1) / (res - 1)
for j in range(60 * k, 70 * k):
for i in range(10 * k, 20 * k):
mats[j * mres + i] = 1 # Sandfleck nur links
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
(doc["objects"] as Array).append(_obj("box_001", [-100.0, 0.0, 40.0], 30.0)) # A (0)
(doc["objects"] as Array).append(_obj("box_001", [100.0, 0.0, -40.0], 10.0)) # B (1) Widerspruch
(doc["objects"] as Array).append(_obj("box_001", [200.0, 0.0, 0.0], 0.0)) # C (2) solo rechts
SpawnOps.remove_point(doc, 1, "B1") # roter Punkt weg → blauer A4 solo
var zones: Dictionary = doc["spawn_zones"]
((zones["0"] as Dictionary)["points"] as Array).append(
{"id": "A9", "pos": [-150.0, 0.0, 100.0]}) # extra blau solo links
return doc
func _mirror_cell_equal(doc: Dictionary, mode: int) -> bool:
var terrain: Dictionary = doc.get("terrain", {})
var res := int(terrain.get("resolution", 0))
if res < 2:
return true
var heights := SvmapIO.decode_heights(terrain)
for j in res:
for i in res:
var mc := SymmetryCore.mirror_cell(mode, i, j, res)
if mc.x == i and mc.y == j:
continue
if absf(heights[j * res + i] - heights[mc.y * res + mc.x]) > 0.0001:
return false
# Material auf seinem eigenen (feinen) Raster (Karte 24.10.).
var mres := SvmapIO.material_resolution(terrain)
var mats := SvmapIO.decode_materials(terrain)
for j in mres:
for i in mres:
var mc := SymmetryCore.mirror_cell(mode, i, j, mres)
if mats[j * mres + i] != mats[mc.y * mres + mc.x]:
return false
return true
func _hill_at(doc: Dictionary, x: float, z: float) -> float:
return SvmapDocument.terrain_height_at(doc, x, z)
func _doc_str(doc: Dictionary) -> String:
return var_to_str(doc)
# ---------------------------------------------------------------- Analyse ---
func _test_analyze_asym_map() -> void:
print("== Analyse der asymmetrischen Testkarte ==")
var doc := _asym_doc()
var an := SymmetryOps.analyze(doc, SymmetryCore.Mode.POINT)
var objects: Dictionary = an["objects"]
_check((objects["pairs"] as Array).size() == 1, "Objekte: 1 Paar gefunden (A↔B)")
_check(int(objects["diff"]) == 1, "Objekte: das Paar widerspricht (Drehung 30° vs 10°)")
_check((objects["singles_high"] as Array) == [2], "Objekte: C (rechts) ist zwillinglos")
_check((objects["singles_low"] as Array).is_empty(), "Objekte: links kein Zwillingloser")
var spawns: Dictionary = an["spawns"]
_check((spawns["pairs"] as Array).size() == 3, "Spawns: 3 Default-Paare (A2↔B3, A3↔B2, A1↔B4)")
_check(int(spawns["diff"]) == 0, "Spawns: Default-Paare passen exakt")
_check((spawns["singles_low"] as Array).size() == 2, "Spawns: 2 Solo-Punkte links (A4 + A9)")
_check((an["terrain_cells"] as Array).size() > 0, "Boden: abweichende Zellen erkannt")
_check(int(an["conflicts"]) == int(objects["diff"]) + int(spawns["diff"])
+ (an["terrain_cells"] as Array).size() + (an["material_cells"] as Array).size(),
"Widerspruchs-Zahl = Objekt- + Spawn- + Boden-Widersprüche (Höhe + Material)")
_check(int(an["adds"]) == 3, "3 fehlende Zwillinge (C, A4, A9)")
_check(int(an["total_deviations"]) == int(an["_dev_total"]),
"Abweichungs-Zähler konsistent")
func _test_check_klartext() -> void:
print("== Klartext-Bericht des Checks ==")
var doc := _asym_doc()
var rep := SymmetryOps.check(doc, SymmetryCore.Mode.POINT)
_check(not bool(rep["ok"]), "asymmetrische Karte: Check meldet nicht ok")
var text := str(rep["text"])
_check(text.contains("Stellen weichen ab: "),
"Bericht nennt die GESAMT-Zahl zuerst (ist: %s …)" % text.left(24))
_check(int(rep["analyze"]["total_deviations"]) > 3,
"Hügel allein erzeugt viele Boden-Abweichungen (ehrlich gezählt)")
_check(text.contains("Objekt #0"), "Bericht nennt das widersprechende Objekt (Objekt #0)")
_check(text.contains("Boden ("), "Bericht nennt Boden-Zellen mit Koordinaten")
_check(text.contains("hat keinen Zwilling"), "Bericht nennt fehlende Zwillinge")
_check(text.length() < 600, "Bericht bleibt kurz (Beispiele gekappt)")
# ------------------------------------------------------------ Anwenden ------
func _test_apply_and_check_zero() -> void:
print("== Ein-Klick: asymmetrische Karte → 0 Abweichungen ==")
var doc := _asym_doc()
var res := SymmetryOps.apply(doc, SymmetryCore.Mode.POINT, false)
_check(bool(res["ok"]), "apply läuft durch")
var c: Dictionary = res["counts"]
_check(int(c["objects_fixed"]) == 1, "1 Objekt-Widerspruch zugunsten links entschieden")
_check(int(c["objects_added"]) == 1, "1 Objekt ergänzt (C zurückgespiegelt)")
_check(int(c["spawns_added"]) == 2, "2 Spawn-Punkte ergänzt (A4, A9)")
_check(int(c["terrain_cells"]) > 0, "Bodenstellen angeglichen")
var report := str(res["report"])
_check(report.contains("Fertig ✓"), "Klartext-Erfolgsmeldung (Fertig ✓)")
_check(report.contains("links gewonnen"), "Bericht nennt die gewinnende Hälfte")
_check(report.contains("Strg+Z"), "Bericht nennt das Rückgängig-Machen")
var rep := SymmetryOps.check(doc, SymmetryCore.Mode.POINT)
_check(bool(rep["ok"]), "Check NACH dem Anwenden: 0 Abweichungen")
_check(str(rep["text"]) == "Alles symmetrisch ✓", "Klartext: „Alles symmetrisch ✓“")
# Widerspruch zugunsten der Quelle: B trägt jetzt den exakten Spiegel von A.
var objs: Array = doc["objects"]
_check(absf(float((objs[1] as Dictionary)["rot_y_deg"]) - 210.0) < 0.0001,
"B wurde angepasst: Drehung 210° = Spiegel von A (30°)")
_check(str((objs[1] as Dictionary)["id"]) == "box_001", "B behält Slot (nichts gelöscht)")
_check(SymmetryCore.sym_of(objs[0]) != "" and SymmetryCore.sym_of(objs[0]) == SymmetryCore.sym_of(objs[1]),
"A + B tragen jetzt dieselbe sym-Kennung")
_check(SymmetryCore.sym_of(objs[2]) != "" and SymmetryCore.sym_of(objs[2]) != "",
"C + sein neuer Zwilling sind verkoppelt")
_check((objs as Array).size() == 4, "4 Objekte (A, B, C + C-Zwilling)")
# C-Zwilling: exakter Spiegel (-200, 0, 0).
_check(absf(float((objs[3] as Dictionary)["pos"][0]) + 200.0) < 0.0001
and absf(float((objs[3] as Dictionary)["pos"][2])) < 0.0001,
"C-Zwilling liegt exakt gespiegelt bei (-200, 0)")
# Spawn-Paarung: A4 ↔ neuer roter Punkt, A9 ↔ neuer roter Punkt.
var m1 := SpawnToolScript.spawn_mirror_partner(doc, "A4")
var m2 := SpawnToolScript.spawn_mirror_partner(doc, "A9")
_check(m1 != "" and m2 != "" and m1 != m2, "A4 und A9 haben eigene Spawn-Partner")
# Validator zufrieden (V16: eine Kennung = genau 2 Elemente).
var errors: PackedStringArray = SvmapIO.validate_map(doc, {})
var sym_errors: Array = []
for e in errors:
if str(e).contains("V16") or str(e).contains("sym"):
sym_errors.append(e)
_check(sym_errors.is_empty(), "V16/Symmetrie-Validator: keine Fehler")
func _test_halves_cell_equal() -> void:
print("== Boden: beide Hälften zellgenau gleich ==")
var doc := _asym_doc()
SymmetryOps.apply(doc, SymmetryCore.Mode.POINT, false)
_check(_mirror_cell_equal(doc, SymmetryCore.Mode.POINT),
"Höhen UND Material paarweise identisch (alle Zell-Paare)")
_check(absf(_hill_at(doc, -300.0, 0.0) - _hill_at(doc, 300.0, 0.0)) < 0.001,
"Weltprobe: Hügelhöhe links == rechts")
# Auch im Achsen-Modus X zellgenau.
var docx := SvmapDocument.create_new("M5AxX", 512.0, "mittag")
var terr: Dictionary = docx["terrain"]
var res := int(terr["resolution"])
var hs := SvmapIO.decode_heights(terr)
for j in res:
for i in 100:
hs[j * res + i] = 5.0
terr["heightmap_r16_b64"] = TerrainField.encode(hs,
float(terr["min_height_m"]), float(terr["max_height_m"]))
SymmetryOps.apply(docx, SymmetryCore.Mode.AXIS_X, false)
_check(_mirror_cell_equal(docx, SymmetryCore.Mode.AXIS_X), "Achse X: Zellgleichheit")
func _test_source_flip() -> void:
print("== Quell-Hälfte rechts gewinnt bei Widerspruch ==")
var doc := _asym_doc()
var res := SymmetryOps.apply(doc, SymmetryCore.Mode.POINT, true)
_check(bool(res["ok"]), "apply (rechts gewinnt) läuft")
var objs: Array = doc["objects"]
# Quelle = rechts: A bekommt den Spiegel von B (Punkt-Modus: 10° + 180° = 190°).
_check(absf(float((objs[0] as Dictionary)["rot_y_deg"]) - 190.0) < 0.0001,
"A wurde angepasst: Spiegel von B (190°) — rechts gewinnt")
var rep := SymmetryOps.check(doc, SymmetryCore.Mode.POINT)
_check(bool(rep["ok"]), "Check danach trotzdem 0 Abweichungen")
# Der Hügel links wird zugunsten der rechten (flachen) Seite geglättet.
_check(_hill_at(doc, -300.0, 0.0) < 0.5, "Boden: rechts gewinnt → Hügel links geglättet")
func _test_legacy_without_sym() -> void:
print("== Alte Karte ohne sym-Feld: läuft und verknüpft ==")
var doc := SvmapDocument.create_new("M5Alt", 512.0, "mittag")
(doc["objects"] as Array).append(_obj("box_001", [-80.0, 0.0, 20.0], 45.0))
(doc["objects"] as Array).append(_obj("box_001", [80.0, 0.0, -20.0], 225.0))
var an := SymmetryOps.analyze(doc, SymmetryCore.Mode.POINT)
_check((an["objects"]["pairs"] as Array).size() == 1
and int(an["objects"]["diff"]) == 0, "Spiegelndes Paar erkannt, kein Widerspruch")
var res := SymmetryOps.apply(doc, SymmetryCore.Mode.POINT, false)
_check(int(res["counts"]["objects_linked"]) == 1, "Paar ohne sym wurde verknüpft")
_check(SymmetryCore.sym_of((doc["objects"] as Array)[0])
== SymmetryCore.sym_of((doc["objects"] as Array)[1]), "beide tragen dieselbe Kennung")
var rep := SymmetryOps.check(doc, SymmetryCore.Mode.POINT)
_check(bool(rep["ok"]), "alte Karte: danach symmetrisch ✓")
func _test_second_apply_nothing() -> void:
print("== Zweiter Klick: nichts zu tun, keine unnötige Änderung ==")
var doc := _asym_doc()
SymmetryOps.apply(doc, SymmetryCore.Mode.POINT, false)
var before := _doc_str(doc)
var res := SymmetryOps.apply(doc, SymmetryCore.Mode.POINT, false)
_check(str(res["report"]).contains("Nichts zu tun"), "Bericht: nichts zu tun")
_check(_doc_str(doc) == before, "doc dabei UNVERÄNDERT (byte-gleich)")
func _test_undo_single_step() -> void:
print("== EIN Undo-Schritt stellt den Ausgangszustand VOLLSTÄNDIG her ==")
var doc := _asym_doc()
var hist := ActionHistory.new()
hist.begin(doc)
var pre := _doc_str(doc)
var before: Dictionary = hist.snapshot(doc.keys())
# design_notes fehlt evtl. (alte Karte) — als null seeden, damit das Undo
# den vom Vorgang neu angelegten Block auch wieder ENTFERNT (U3-Lektion).
if not before.has("design_notes"):
before["design_notes"] = null
SymmetryOps.apply(doc, SymmetryCore.Mode.POINT, false)
hist.commit("Karte symmetrisch machen", before, hist.snapshot(doc.keys()))
_check(hist.entries.size() == 1, "EIN Historie-Eintrag für den ganzen Vorgang")
_check(_doc_str(doc) != pre, "doc hat sich geändert (Sinnprobe)")
hist.undo()
_check(_doc_str(doc) == pre, "Ein Strg+Z: doc VOLLSTÄNDIG wie vorher (alle Blöcke)")
func _test_mode_off() -> void:
print("== Modus AUS: nichts ändert sich ==")
var doc := _asym_doc()
var before := _doc_str(doc)
var res := SymmetryOps.apply(doc, SymmetryCore.Mode.OFF, false)
_check(not bool(res["ok"]), "apply verweigert bei Modus AUS")
_check(_doc_str(doc) == before, "Modus AUS: doc unverändert")
var rep := SymmetryOps.check(doc, SymmetryCore.Mode.OFF)
_check(not bool(rep["ok"]) and str(rep["text"]).contains("Aus"),
"Check bei AUS: ehrlicher Hinweis statt „symmetrisch“")
# --------------------------------------------------------- Panel (Registry) --
## Mock-main: Panel braucht doc/sym_mode/history + Status-Rufe.
class ProbeMain:
var doc: Dictionary = {}
var manifest: Dictionary = {}
var sym_mode: int = SymmetryCore.Mode.POINT
var history: ActionHistory = ActionHistory.new()
var dirty := false
var last_status := ""
func _set_status(msg: String, _err := false) -> void:
last_status = msg
func _after_structure_change() -> void:
dirty = true
func _make_panel(doc: Dictionary, mode: int) -> VBoxContainer:
var probe := ProbeMain.new()
probe.doc = doc
probe.sym_mode = mode
probe.history.begin(doc)
var panel := VBoxContainer.new()
panel.set_script(load("res://src/ui/panels/symmetry_panel.gd"))
root.add_child(panel)
panel.setup(probe)
panel.call("build")
return panel
func _test_panel_flow() -> void:
print("== Panel: Ein-Klick-Ablauf über die Registry ==")
var doc := _asym_doc()
var panel := _make_panel(doc, SymmetryCore.Mode.POINT)
panel.call("_on_apply_pressed")
var dlg: ConfirmationDialog = panel.get("conflict_dialog")
var report: Label = panel.get("report_label")
_check(dlg.dialog_text.contains("unterscheiden sich") or dlg.dialog_text.contains("Bodenstellen"),
"Widerspruchs-Dialog nennt die Zahlen (Klartext)")
_check(str(dlg.dialog_text).contains("NICHTS gelöscht"),
"Dialog sagt: nichts wird gelöscht")
# Sly bestätigt mit der gewählten Quelle (links) → anwenden.
panel.call("_on_conflict_confirmed")
_check((panel.get("main") as ProbeMain).history.entries.size() == 1,
"ganzer Vorgang = EIN Undo-Schritt (Panel-Pfad)")
_check(str(report.text).contains("Fertig ✓"), "Bericht-Label zeigt den Klartext-Erfolg")
_check(str((panel.get("main") as ProbeMain).last_status).length() > 0,
"Statuszeile wurde bedient")
# Check-Knopf: danach „Alles symmetrisch ✓“.
panel.call("_on_check_pressed")
_check(str(report.text) == "Alles symmetrisch ✓", "Check-Bericht im Panel: Alles symmetrisch ✓")
panel.free() # sofort frei geben: sonst hängt der exklusive Dialog im Fenster
func _test_panel_off_and_choice() -> void:
print("== Panel: Modus AUS tut nichts; Dialog erlaubt Seitenwechsel ==")
var doc := _asym_doc()
var before := _doc_str(doc)
var panel := _make_panel(doc, SymmetryCore.Mode.OFF)
panel.call("_on_apply_pressed")
_check(str((panel.get("main") as ProbeMain).last_status).contains("Aus"),
"Modus AUS: Klartext-Hinweis statt Aktion")
_check(_doc_str(doc) == before, "Modus AUS: doc unverändert")
_check((panel.get("main") as ProbeMain).history.entries.size() == 0,
"Modus AUS: kein Historie-Eintrag")
panel.free()
# Dialog: „Andere Hälfte als Quelle“ → rechts gewinnt.
var doc2 := _asym_doc()
var panel2 := _make_panel(doc2, SymmetryCore.Mode.POINT)
panel2.call("_on_apply_pressed")
panel2.call("_on_conflict_other", "other_half")
var objs: Array = doc2["objects"]
_check(absf(float((objs[0] as Dictionary)["rot_y_deg"]) - 190.0) < 0.0001,
"Dialog-Wechsel: Quelle rechts hat gewonnen (A = 190°)")
_check((panel2.get("main") as ProbeMain).history.entries.size() == 1,
"Seitenwechsel-Pfad: ebenfalls EIN Undo-Schritt")
panel2.free()