379 lines
17 KiB
GDScript
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://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()
|