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://core/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://core/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()