scopedvr-map-editor/tests/test_ux.gd
Hermes godot-vr 595d1217d9 test_ux: Undo-Ketten-Zählung korrigiert — 4 Commits brauchen 4 Undos (Karte t_f6f29d86)
Der Off-by-one im Test erwartete nach 2 Undos den Zustand nach move1,
obwohl 3 Züge davorliegen (Zug, Kanten-Clamp, zurück ins Feld). Der
Code war korrekt (EINE Aktion = EIN Undo-Schritt), der Test zählte
falsch. Jetzt wird jeder Schritt einzeln verifiziert inkl. geclamptem
Kanten-Zustand. Suite: 253 Checks grün.
2026-10-15 15:34:46 +00:00

557 lines
29 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_40d855d0 — UX-Fundament (Undo/Redo-Historie,
## Terrain-Höhen-Abfrage, Platzhalter-Meshes).
## Ausführen: godot --headless --path . --script res://tests/test_ux.gd
## Exit 0 = alles grün. Läuft OHNE UI (ActionHistory/SvmapDocument/MeshShapes).
## GUI-Funktionen (Ghost-Preview sichtbar, Auswahl-Färbung) werden im
## Desktop-Smoke auf Slys PC verifiziert (Screenshot), nicht hier.
var failures := 0
var checks := 0
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 _obj(id: String, pos: Array, rot := 0.0) -> Dictionary:
return {"id": id, "pos": pos, "scale": 1.0, "rot_y_deg": float(rot)}
func _place(hist: ActionHistory, doc: Dictionary, id: String, pos: Array) -> void:
var before := hist.snapshot(["objects"])
(doc["objects"] as Array).append(_obj(id, pos))
hist.commit("Test: %s platzieren" % id, before, hist.snapshot(["objects"]))
func _delete_at(hist: ActionHistory, doc: Dictionary, idx: int) -> void:
var before := hist.snapshot(["objects"])
(doc["objects"] as Array).remove_at(idx)
hist.commit("Test: löschen", before, hist.snapshot(["objects"]))
func _move_obj(hist: ActionHistory, doc: Dictionary, idx: int, pos: Array) -> void:
var before := hist.snapshot(["objects"])
(doc["objects"] as Array)[idx]["pos"] = pos
hist.commit("Test: verschieben", before, hist.snapshot(["objects"]), "move:%d" % idx)
func _rotate_obj(hist: ActionHistory, doc: Dictionary, idx: int, deg: float) -> void:
var before := hist.snapshot(["objects"])
(doc["objects"] as Array)[idx]["rot_y_deg"] = float(deg)
hist.commit("Test: drehen", before, hist.snapshot(["objects"]), "rot:%d" % idx)
func _run_tests() -> void:
_test_undo_roundtrip()
_test_merge_and_truncation()
_test_alias_safety()
_test_history_cap_and_clear()
_test_terrain_height()
_test_mesh_shapes()
_test_object_ops()
_test_group_ops()
_test_group_move_rotate_undo()
_test_group_svmap_roundtrip()
_test_rubberband_pick()
## Abnahme-Kriterium: Undo-Roundtrip (setzen → undo → redo) — und analog
## für löschen/verschieben/drehen.
func _test_undo_roundtrip() -> void:
print("Testgruppe: Undo/Redo-Roundtrip")
var doc := SvmapDocument.create_new("UX-Test", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var objects: Array = doc["objects"]
_check(objects.is_empty(), "Start: Objekt-Liste leer")
_place(hist, doc, "crate_wood", [10.0, 0.0, 10.0])
_check(objects.size() == 1, "setzen: 1 Objekt")
_check(hist.can_undo() and not hist.can_redo(), "History: undo möglich, redo nicht")
var desc := hist.undo()
_check(objects.is_empty(), "undo: Objekt weg (setzen→undo)")
_check(hist.can_redo() and not hist.can_undo(), "nach undo: redo möglich, undo verbraucht")
_check(desc == "Test: crate_wood platzieren", "undo-Desc stimmt")
desc = hist.redo()
_check(objects.size() == 1 and str(objects[0]["id"]) == "crate_wood", "redo: Objekt identisch zurück (setzen→undo→redo)")
_check(desc == "Test: crate_wood platzieren", "redo-Desc stimmt")
_check(hist.can_undo() and not hist.can_redo(), "nach redo: Zustand konsistent")
_delete_at(hist, doc, 0)
_check(objects.is_empty(), "löschen: Liste leer")
hist.undo()
_check(objects.size() == 1, "löschen→undo: Objekt zurück")
hist.redo()
_check(objects.is_empty(), "löschen→undo→redo: wieder weg")
_place(hist, doc, "crate_metal", [0.0, 0.0, 0.0])
_check(objects.size() == 1, "weiterbearbeiten: Platzieren nach fertigem Roundtrip klappt")
_move_obj(hist, doc, 0, [5.0, 0.0, 7.5])
_check(float(objects[0]["pos"][0]) == 5.0, "verschieben: neue Position im Doc")
hist.undo()
_check(objects.size() == 1 and float(objects[0]["pos"][0]) == 0.0, "verschieben→undo: zurück auf Platzierungsposition")
hist.redo()
_check(float(objects[0]["pos"][0]) == 5.0 and float(objects[0]["pos"][2]) == 7.5, "verschieben→undo→redo: Position wiederhergestellt")
_rotate_obj(hist, doc, 0, 90.0)
hist.undo()
_check(absf(float(objects[0]["rot_y_deg"]) - 0.0) < 0.0001, "drehen→undo: 0°")
hist.redo()
_check(absf(float(objects[0]["rot_y_deg"]) - 90.0) < 0.0001, "drehen→undo→redo: 90°")
## Mausrad/leichtes Drehen und Verschieben in Ticks dürfen die History NICHT
## fluten — gleicher merge_tag pro Objekt = EIN History-Eintrag.
func _test_merge_and_truncation() -> void:
print("Testgruppe: Merge-Tags (Mausrad-Ticks → 1 Schritt) + Redo-Ast")
var doc := SvmapDocument.create_new("Merge-Test", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var objects: Array = doc["objects"]
_place(hist, doc, "watchtower_wood", [0.0, 0.0, 0.0])
var entries_after_place := hist.entries.size()
for i in 8:
var before := hist.snapshot(["objects"])
objects[0]["rot_y_deg"] = float((i + 1) * 15)
hist.commit("Objekt gedreht", before, hist.snapshot(["objects"]), "rot:0")
_check(hist.entries.size() == entries_after_place + 1, "8 Dreh-Ticks → EIN zusätzlicher History-Eintrag (geflutet wären 8)")
_check(absf(float(objects[0]["rot_y_deg"]) - 120.0) < 0.0001, "Endposition 120° im Doc")
# innerster Zustand nach Merge-Undo = Zustand VOR der ersten Drehung (0°)
hist.undo()
_check(absf(float(objects[0]["rot_y_deg"]) - 0.0) < 0.0001, "Merge-Undo springt auf 0° (nicht auf Zwischenstufe 105°)")
hist.redo()
_check(absf(float(objects[0]["rot_y_deg"]) - 120.0) < 0.0001, "Merge-Redo wieder 120°")
# Neuer Schritt verwirft den Redo-Zweig
_place(hist, doc, "bush_small", [1.0, 0.0, 1.0])
_check(not hist.can_redo(), "neuer Schritt: Redo-Zweig verworfen")
hist.undo()
_check(objects.size() == 1, "undo nach Zweig: nur watchtower bleibt")
## Snapshot VOR Mutation darf nach Mutation unverändert bleiben (sonst
## „rettet“ undo versehentlich eine Mutation — Alias-Falle bei Godot-Dicts).
func _test_alias_safety() -> void:
print("Testgruppe: Alias-Sicherheit der Snapshots")
var doc := SvmapDocument.create_new("Alias", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var before := hist.snapshot(["objects"])
(doc["objects"] as Array).append(_obj("crate_metal", [1.0, 0.0, 2.0]))
hist.commit("alias-test", before, hist.snapshot(["objects"]))
var stored_before: Array = hist.entries[0]["before"]["objects"]
_check(stored_before.is_empty(), "before-Snapshot: leer")
# Kopie zum Gegenprobe-Mutieren: räumen wir DIESE leeren, muss das Doc
# unberührt bleiben — nur Beweis, dass Mutationen an Kopien nichts anrichten.
var probe_copy: Array = (hist.entries[0]["after"]["objects"] as Array).duplicate()
probe_copy.clear()
_check((doc["objects"] as Array).size() == 1, "Mutation an einer Kopie ändert Doc nicht")
# Doc nach dem commit mutieren (2 Objekte) — die History-Einträge müssen
# davon NICHT betroffen sein, sonst „rettet“ undo die Mutation.
var doc_objects: Array = doc["objects"]
doc_objects.append({"id": "fence_metal", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
hist.undo()
_check((doc["objects"] as Array).is_empty(), "undo trotz Doc-Mutation: sauber auf before (leer)")
hist.redo()
_check((doc["objects"] as Array).size() == 1, "redo: Snapshot-Zustand (1 Objekt), nicht der mutierte (2)")
func _test_history_cap_and_clear() -> void:
print("Testgruppe: Kapazität + begin() setzt zurück")
var doc := SvmapDocument.create_new("Cap", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var objects: Array = doc["objects"]
for i in ActionHistory.MAX_STEPS + 20:
_place(hist, doc, "crate_wood", [float(i), 0.0, 0.0])
_check(hist.entries.size() == ActionHistory.MAX_STEPS, "Cap: History hält sich an MAX_STEPS")
_check(objects.size() == ActionHistory.MAX_STEPS + 20, "Doc fuellt sich voll — Cap betrifft nur History-Tiefe")
hist.undo()
_check(objects.size() == ActionHistory.MAX_STEPS + 19, "auch nach Cap-Kippen funktioniert undo")
var doc2 := SvmapDocument.create_new("Frisch", 1024.0, "nacht")
hist.begin(doc2)
_check(not hist.can_undo() and not hist.can_redo(), "begin() mit neuer Map: History leer")
_check(hist.doc == doc2, "begin() hängt an der neuen Map-Referenz")
## Flache neue Map → Terrain-Höhe überall = min_height. Synthetischer Gradient →
## bilinear korrekt (roher uint16-Wert → Meter).
func _test_terrain_height() -> void:
print("Testgruppe: Terrain-Höhen-Abfrage")
var doc := SvmapDocument.create_new("Flach", 1024.0, "mittag")
_check(absf(SvmapDocument.terrain_height_at(doc, 0.0, 0.0) - 0.0) < 0.001, "flache Map: Höhe 0 m")
_check(absf(SvmapDocument.terrain_height_at(doc, 500.0, -500.0) - 0.0) < 0.001, "flache Map: auch am Rand 0 m")
# Synthetischer Gradient: res=17, size=160 → 10 m Raster; Raw-Wert = Zeile*655 (ROH skaliert)
var res := 17
var bytes := PackedByteArray()
bytes.resize(res * res * 2)
var i := 0
while i < res * res:
var row := i / res
var raw := int(round(float(row) / float(res - 1) * 65535.0))
bytes[i * 2] = raw & 0xFF
bytes[i * 2 + 1] = (raw >> 8) & 0xFF
i += 1
var slope := {
"type": "heightmap", "size_x_m": 160.0, "size_z_m": 160.0,
"min_height_m": 0.0, "max_height_m": 100.0, "resolution": res,
"heightmap_r16_b64": Marshalls.raw_to_base64(bytes),
}
var doc2 := {"terrain": slope}
_check(absf(SvmapDocument.terrain_height_at(doc2, 0.0, -80.0) - 0.0) < 0.5, "Gradient: Nordkante (z=-80) ≈ 0 m")
var mid := SvmapDocument.terrain_height_at(doc2, 0.0, 0.0)
_check(absf(mid - 50.0) < 1.0, "Gradient: Mitte (z=0) ≈ 50 m (ist %.1f)" % mid)
_check(absf(SvmapDocument.terrain_height_at(doc2, 0.0, 80.0) - 100.0) < 0.5, "Gradient: Südkante (z=+80) ≈ 100 m")
_check(absf(SvmapDocument.terrain_height_at(doc2, 0.0, 160.0) - 100.0) < 0.5, "Hinter der Kante wird geclampt (100 m)")
_check(absf(SvmapDocument.terrain_height_at(doc2, 5000.0, -900.0) - 0.0) < 0.6, "Außerhalb-Query wird geclampt (keine Abstürze)")
func _test_mesh_shapes() -> void:
print("Testgruppe: Platzhalter-Meshes (Bibliothek/Kollider)")
var b := MeshShapes.make_placeholder("box", {"size": [2.0, 4.0, 6.0]}, 1.0)
_check(b is BoxMesh, "box → BoxMesh")
_check((b as BoxMesh).size == Vector3(2, 4, 6), "box: Größe 1:1 aus dem Kollider")
var scaled := MeshShapes.make_placeholder("box", {"size": [2.0, 4.0, 6.0]}, 2.0)
_check((scaled as BoxMesh).size.is_equal_approx(Vector3(4, 8, 12)), "box: scale=2 verdoppelt alle Achsen")
var sp := MeshShapes.make_placeholder("sphere", {"radius": 1.5}, 1.0)
_check(sp is SphereMesh and absf((sp as SphereMesh).radius - 1.5) < 0.001, "sphere → SphereMesh mit Radius")
var cyl := MeshShapes.make_placeholder("cylinder", {"radius": 0.4, "height": 12.0}, 1.0)
_check(cyl is CylinderMesh and absf((cyl as CylinderMesh).height - 12.0) < 0.001, "cylinder → CylinderMesh mit Höhe")
_check(absf(MeshShapes.half_height(b) - 2.0) < 0.001, "half_height(box 4 m) = 2 m — Fußpunkt-Konvention")
_check(absf(MeshShapes.half_height(sp) - 1.5) < 0.001, "half_height(sphere) = radius")
_check(absf(MeshShapes.half_height(cyl) - 6.0) < 0.001, "half_height(cylinder 12 m) = 6 m")
# ----------------------------- Objekt-Ops: Duplizieren/Höhe/Spiegeln -------
## (Karte t_7567a06f — pure Funktionen in ObjectOps, UI-Wiring wird im
## Desktop-Smoke verifiziert; hier Headless-Beweis der Logik + Undo-Roundtrips.)
func _test_object_ops() -> void:
print("Testgruppe: Objekt-Ops (Duplizieren · Anheben/Absenken · Spiegeln)")
var doc := SvmapDocument.create_new("Objekt-Ops-Test", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var objects: Array = doc["objects"]
# --- Duplizieren:stelle diagonal versetzt, Raster-Snap, Undo fähig ---
_place(hist, doc, "crate_wood", [10.0, 0.0, 10.0])
objects[0]["rot_y_deg"] = 30.0
var new_idx := ObjectOps.duplicate_object(doc, 0, true)
_check(new_idx == 1, "duplizieren: neuer Index 1")
_check(objects.size() == 2, "duplizieren: 1 → 2 Objekte")
_check(str(objects[1]["id"]) == "crate_wood", "duplizieren: gleiche ID")
_check(float(objects[1]["pos"][0]) == 11.0 and float(objects[1]["pos"][2]) == 11.0,
"duplizieren: diagonal +1 m versetzt (Raster-Snap 1 m)")
_check(absf(float(objects[1]["rot_y_deg"]) - 30.0) < 0.0001, "duplizieren: Drehung übernommen")
_check(float(objects[1]["pos"][1]) == float(objects[0]["pos"][1]), "duplizieren: Höhe übernommen")
# Kopie ohne Raster: Snap aus → exakt +1/+1 auch auf 0,5-m-Fraktionen.
objects[0]["pos"] = [10.4, 0.0, 10.4]
var idx2 := ObjectOps.duplicate_object(doc, 0, false)
_check(absf(float(objects[idx2]["pos"][0]) - 11.4) < 0.0001, "duplizieren ohne Snap: Fraktion bleibt (+1.0)")
# Fraktionsobjekt wieder aufräumen für die History-Tests:
objects.remove_at(idx2)
# Duplizieren + Undo → Kopie weg; Redo → wieder da (EIN Undo-Schritt).
var before := hist.snapshot(["objects"])
var idx3 := ObjectOps.duplicate_object(doc, 0, true)
hist.commit("Test: duplizieren", before, hist.snapshot(["objects"]))
_check(objects.size() == 3, "duplizieren über History: Kopie hängt an (3 = Original + Raster-Kopie + neue Kopie)")
hist.undo()
_check(objects.size() == 2, "duplizieren→undo: Kopie weg (2 bleiben)")
hist.redo()
_check(objects.size() == 3, "duplizieren→undo→redo: Kopie wieder da")
_check(absf(float(objects[2]["pos"][0]) - 11.0) < 0.0001, "duplizieren→redo: Position identisch")
# Ungültige Indizes schaden nichts:
_check(ObjectOps.duplicate_object(doc, 99, true) == -1, "duplizieren: falscher Index → -1")
_check(objects.size() == 3, "duplizieren: falscher Index ändert nichts")
# --- Anheben/Absenken: 0,25-m-Schritte, Deckel = bounds.max, Boden-Stopp ---
var h0: float = SvmapDocument.terrain_height_at(doc, 10.0, 10.0)
var up := ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M)
_check(absf(up - ObjectOps.LIFT_STEP_M) < 0.0001, "anheben: 0,25 m angewendet")
_check(absf(float(objects[0]["pos"][1]) - (h0 + 0.25)) < 0.0001, "anheben: y = Boden + 0,25 m")
ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M)
_check(absf(float(objects[0]["pos"][1]) - (h0 + 0.5)) < 0.0001, "anheben x2: 0,5 m")
var down := ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M)
_check(absf(down + 0.25) < 0.0001, "absenken: −0,25 m")
var stop := ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M * 10)
_check(absf(stop + 0.25) < 0.0001, "absenken: stoppt genau am Terrain (kein Begraben)")
_check(ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M) == 0.0, "absenken am Boden: 0.0 (UI erklärt statt commit)")
_check(absf(float(objects[0]["pos"][1]) - h0) < 0.001, "nach Absenken-Stopp: exakt Boden")
# Deckel: bounds.max.y = 80 (Neue-Map) → Großsprung wird auf 80 gedeckelt.
var cap := ObjectOps.lift_object(doc, 0, 1000.0)
_check(absf(float(objects[0]["pos"][1]) - 80.0) < 0.0001, "anheben: Höchstgrenze bounds.max.y (80 m)")
_check(absf(cap - (80.0 - h0)) < 0.0001, "anheben: angewendet = Rest bis zum Deckel")
_check(ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M) == 0.0, "anheben am Deckel: 0.0")
# Im Boden versteckt (nach Terrain-Formen): Absenken bewegt NICHT,
# Anheben hebt schrittweise aus dem Boden.
objects[0]["pos"] = [10.0, -3.0, 10.0]
_check(ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M) == 0.0, "verborgen: absenken = 0.0")
var out := ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M)
_check(absf(out - ObjectOps.LIFT_STEP_M) < 0.0001, "versteckt: anheben hebt schrittweise (0,25 m ab −3 m)")
_check(absf(float(objects[0]["pos"][1]) + 2.75) < 0.0001, "versteckt: y steigt vom Untergrund aus (kein Sprung-Ruckel)")
# lift_offset (Offset der UI-Anzeige): angehoben → +0,25, am Boden → 0.
objects[0]["pos"] = [10.0, h0 + 0.25, 10.0]
_check(absf(ObjectOps.lift_offset(doc, 0) - 0.25) < 0.001, "lift_offset: +0,25 m sichtbar")
objects[0]["pos"] = [10.0, h0, 10.0]
_check(absf(ObjectOps.lift_offset(doc, 0)) < 0.001, "lift_offset: am Boden = 0")
# Merge-Tags wie beim Drehen: mehrere Lift-Ticks = EIN History-Eintrag.
var entries_before := hist.entries.size()
for i in 5:
var b := hist.snapshot(["objects"])
var y2 := float(objects[0]["pos"][1])
objects[0]["pos"] = [10.0, y2 + ObjectOps.LIFT_STEP_M, 10.0]
hist.commit("Objekt angehoben", b, hist.snapshot(["objects"]), "lift:0")
_check(hist.entries.size() == entries_before + 1, "5 Lift-Ticks → EIN History-Eintrag (Merge)")
hist.undo()
_check(absf(float(objects[0]["pos"][1]) - h0) < 0.001, "Merge-Undo: alle Stufen auf einmal weg")
# --- Spiegeln: Rotation wird mitgespiegelt, Position bleibt ---
objects[0]["rot_y_deg"] = 30.0
ObjectOps.mirror_object(objects[0], "x")
_check(absf(float(objects[0]["rot_y_deg"]) - 330.0) < 0.0001, "spiegeln X: 30° → 330° (−30°)")
ObjectOps.mirror_object(objects[0], "x")
_check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln X: selbstinvers (330° → 30°)")
_check(float(objects[0]["pos"][0]) == 10.0, "spiegeln: Position unverändert (am Objekt, nicht quer über die Map)")
objects[0]["rot_y_deg"] = 30.0
ObjectOps.mirror_object(objects[0], "z")
_check(absf(float(objects[0]["rot_y_deg"]) - 150.0) < 0.0001, "spiegeln Z: 30° → 150° (180° − rot)")
ObjectOps.mirror_object(objects[0], "z")
_check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln Z: selbstinvers (150° → 30°)")
# Spiegeln über History: ein Eintrag, Undo stellt die alte Richtung her.
objects[0]["rot_y_deg"] = 30.0
var before_m := hist.snapshot(["objects"])
ObjectOps.mirror_object(doc["objects"][0], "x")
hist.commit("Test: spiegeln", before_m, hist.snapshot(["objects"]))
hist.undo()
_check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln→undo: alte Richtung zurück")
# --- svmap-Roundtrip: Höhe + gespiegelte Rotation bleiben 1:1 erhalten ---
objects[0]["rot_y_deg"] = 210.0
objects[0]["pos"] = [5.0, h0 + 1.75, -6.0]
var path := "/tmp/t7567_roundtrip.svmap"
var res := SvmapIO.save_to_file(path, doc)
_check(res["ok"], "svmap-Roundtrip: angesenkte/gesp. Map speichert (V1–V14 ok)")
var loaded := SvmapIO.load_from_file(path)
_check(loaded["ok"], "svmap-Roundtrip: laden ok (Prüfsumme intakt)")
if loaded["ok"]:
var lo: Array = loaded["data"]["objects"]
_check(lo.size() == objects.size(), "svmap-Roundtrip: Objekt-Anzahl gleich")
_check(absf(float(lo[0]["pos"][1]) - (h0 + 1.75)) < 0.0001, "svmap-Roundtrip: angehobene Höhe bleibt (KEIN Extra-Format-Feld — pos.y ist frei)")
_check(absf(float(lo[0]["rot_y_deg"]) - 210.0) < 0.0001, "svmap-Roundtrip: gespiegelte Rotation bleibt")
_check(str(lo[1]["id"]) == "crate_wood", "svmap-Roundtrip: Kopie bleibt")
## Gruppen-Logik (Karte t_f6f29d86): gruppieren/lösen, Mitglieder, IDs.
func _test_group_ops() -> void:
print("Testgruppe: Gruppen (gruppieren/lösen, Mitglieder, IDs)")
var doc := SvmapDocument.create_new("Gruppen", 256.0, "mittag")
var objects: Array = doc["objects"]
objects.append(_obj("crate_wood", [0.0, 0.0, 0.0]))
objects.append(_obj("crate_metal", [4.0, 0.0, 0.0]))
objects.append(_obj("tree_pine", [40.0, 0.0, 40.0]))
_check(GroupOps.group_id(objects[0]) == GroupOps.UNGROUPED, "frisches Objekt: ungruppiert")
_check(GroupOps.next_group_id(doc) == 0, "erste freie Gruppen-ID = 0")
var gid := GroupOps.group(doc, [0, 1])
_check(gid == 0, "gruppieren(2 Objekte) → Gruppe 0")
_check(GroupOps.group_id(objects[0]) == 0 and GroupOps.group_id(objects[1]) == 0, "beide Mitglieder tragen die ID")
_check(GroupOps.group_id(objects[2]) == GroupOps.UNGROUPED, "Drittes Objekt bleibt draußen")
_check(GroupOps.members_of(doc, 0) == [0, 1], "members_of liefert [0, 1]")
_check(GroupOps.next_group_id(doc) == 1, "nächste freie ID = 1")
_check(GroupOps.group(doc, [2]) == GroupOps.UNGROUPED, "einzelnes Objekt kann NICHT gruppiert werden (< 2)")
# Cross-Gruppierung: Objekt 2 kommt zur bestehenden Auswahl — verlässt
# seine alte Bindung nicht, weil es keine hatte; neue Gruppe = neue ID.
var gid2 := GroupOps.group(doc, [1, 2])
_check(gid2 == 1, "zweite Gruppe → ID 1")
_check(GroupOps.group_id(objects[0]) == 0, "Objekt 0 bleibt in Gruppe 0")
_check(GroupOps.group_id(objects[1]) == 1 and GroupOps.group_id(objects[2]) == 1, "Objekt 1+2 wandern in Gruppe 1")
_check(GroupOps.active_group_ids(doc) == [0, 1], "aktive Gruppen: [0, 1]")
# Lösen über EINEM Mitglied löst die GANZE Gruppe.
var freed := GroupOps.ungroup(doc, [1])
_check(freed == 2, "lösen über Objekt 1 befreit beide Mitglieder von Gruppe 1")
_check(GroupOps.group_id(objects[1]) == GroupOps.UNGROUPED and GroupOps.group_id(objects[2]) == GroupOps.UNGROUPED, "Feld entfernt (nicht nur -1)")
_check(GroupOps.active_group_ids(doc) == [0], "nur Gruppe 0 übrig")
# Robustheit: kaputte Werte (float von JSON, Unsinn) → ungruppiert.
objects[2]["group"] = 0.0
_check(GroupOps.group_id(objects[2]) == 0, "float-Gruppen-ID (aus JSON) wird erkannt")
objects[2]["group"] = "Quatsch"
_check(GroupOps.group_id(objects[2]) == GroupOps.UNGROUPED, "Unsinn-Wert → ungruppiert")
objects[2].erase("group")
## Gruppen-Verschieben/Drehen + Undo (Karte t_f6f29d86): EINE Aktion =
## EIN Undo-Schritt; Terrain-Clamp pro Objekt; Anordnung bleibt erhalten.
func _test_group_move_rotate_undo() -> void:
print("Testgruppe: Gruppen-Verschieben/Drehen + ActionHistory")
var doc := SvmapDocument.create_new("Zug", 256.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var objects: Array = doc["objects"]
objects.append(_obj("crate_wood", [0.0, 0.0, 0.0]))
objects.append(_obj("crate_metal", [6.0, 0.0, 2.0]))
objects.append(_obj("tree_pine", [60.0, 0.0, 60.0]))
var gid := GroupOps.group(doc, [0, 1])
_check(gid == 0, "Vorbereitung: Objekte 0+1 = Gruppe 0")
# Gemeinsames Verschieben — EIN commit.
var before := hist.snapshot(["objects"])
GroupOps.move_members(doc, [0, 1], Vector3(4.0, 0.0, -3.0))
hist.commit("Gruppe verschoben (2 Objekte)", before, hist.snapshot(["objects"]))
_check(absf(float(objects[0]["pos"][0]) - 4.0) < 0.001, "Mitglied 0: x = 4 (gezogen)")
_check(absf(float(objects[1]["pos"][0]) - 10.0) < 0.001, "Mitglied 1: x = 10 (gemeinsam gezogen)")
_check(absf(float(objects[1]["pos"][2]) - -1.0) < 0.001, "Mitglied 1: z = -1 (Delta geteilt)")
_check(absf(float(objects[2]["pos"][0]) - 60.0) < 0.001, "Fremdes Objekt 2: unbewegt")
# Terrain-Clamp: Delta über die Map-Kante → X/Z geclampt, Y = Terrainhöhe.
var before2 := hist.snapshot(["objects"])
GroupOps.move_members(doc, [0, 1], Vector3(400.0, 0.0, 0.0))
hist.commit("Gruppe an die Kante gezogen", before2, hist.snapshot(["objects"]))
_check(float(objects[0]["pos"][0]) <= 128.0, "Clamp: x bleibt innerhalb der Map (≤ 128)")
_check(absf(float(objects[0]["pos"][1]) - 0.0) < 0.001, "Terrain-Clamp: y = Terrainhöhe an neuer Stelle (flache Map: 0)")
# Rotation um den Gruppen-Mittelpunkt: Anordnung (Abstände) bleibt.
var before3 := hist.snapshot(["objects"])
GroupOps.move_members(doc, [0, 1], Vector3(-100.0, 0.0, 0.0)) # zurück ins Feld
hist.commit("Gruppe zurück ins Feld", before3, hist.snapshot(["objects"]))
var pos0_before := Vector3(float(objects[0]["pos"][0]), 0.0, float(objects[0]["pos"][2]))
var pos1_before := Vector3(float(objects[1]["pos"][0]), 0.0, float(objects[1]["pos"][2]))
var dist_before := pos0_before.distance_to(pos1_before)
var center := GroupOps.centroid(doc, [0, 1])
var before4 := hist.snapshot(["objects"])
GroupOps.rotate_members(doc, [0, 1], 90.0)
hist.commit("Gruppe gedreht", before4, hist.snapshot(["objects"]))
var dist_after := Vector3(float(objects[0]["pos"][0]), 0.0, float(objects[0]["pos"][2])) \
.distance_to(Vector3(float(objects[1]["pos"][0]), 0.0, float(objects[1]["pos"][2])))
_check(absf(dist_after - dist_before) < 0.01, "Drehen: Abstand der Mitglieder bleibt (Anordnung erhalten)")
var rot0 := float(objects[0]["rot_y_deg"])
_check(absf(rot0 - 90.0) < 0.001, "Drehen: rot_y_deg += 90")
var center_after := GroupOps.centroid(doc, [0, 1])
_check(center_after.distance_to(center) < 0.5, "Drehen: Schwerpunkt bleibt (Rotation um die Mitte)")
# UND JETZT das Kern-Versprechen: EINE Aktion = EIN Undo-Schritt.
# 4 Commits (Zug, Kanten-Clamp, zurück ins Feld, Drehen) → 4 Undos bis zum Anfang.
var steps_before := hist.entries.size()
hist.undo() # nimmt die Rotation zurück → Zustand nach "zurück ins Feld"
_check(absf(float(objects[0]["rot_y_deg"]) - 0.0) < 0.001, "undo: Rotation zurück")
_check(GroupOps.group_id(objects[0]) == 0 and GroupOps.group_id(objects[1]) == 0, "undo: Gruppen-Bindung bleibt (Feld reist im objects-Snapshot)")
_check(hist.entries.size() == steps_before, "kein Eintrag verlorengegangen")
hist.undo() # nimmt "zurück ins Feld" zurück → geclampte Kanten-Position
_check(float(objects[0]["pos"][0]) <= 128.0 and absf(float(objects[0]["pos"][1]) - 0.0) < 0.001,
"undo: Kanten-Zug zurück (geclampter Zustand, EIN Schritt)")
hist.undo() # nimmt den Kanten-Zug zurück → Zustand nach move1 (4/-3)
_check(absf(float(objects[0]["pos"][0]) - 4.0) < 0.001 and absf(float(objects[0]["pos"][2]) - -3.0) < 0.001,
"undo: Zug komplett zurück (beide Mitglieder EIN Schritt)")
hist.redo() # setzt den Kanten-Zug wieder ein
_check(float(objects[0]["pos"][0]) <= 128.0 and absf(float(objects[0]["pos"][1]) - 0.0) < 0.001,
"redo: Zug wieder da (geclampt auf die gleiche Stelle)")
_check(GroupOps.group_id(objects[1]) == 0, "redo: Gruppe bleibt gebunden")
## Persistenz-ENTSCHIEDUNG (Karte t_f6f29d86): Gruppen als optionales
## Objekt-Feld "group" im svmap-Format — Roundtrip über Datei + Prüfsumme,
## alte Maps ohne Feld bleiben ladbar.
func _test_group_svmap_roundtrip() -> void:
print("Testgruppe: Gruppen im svmap-Roundtrip (optionales Feld)")
var doc := SvmapDocument.create_new("Gruppen-Map", 256.0, "mittag")
var objects: Array = doc["objects"]
objects.append(_obj("crate_wood", [0.0, 0.0, 0.0]))
objects.append(_obj("crate_metal", [4.0, 0.0, 0.0]))
GroupOps.group(doc, [0, 1])
var save := SvmapIO.save_to_file("user://test_groups.svmap", doc)
_check(save["ok"], "Speichern mit Gruppen-Feld ok (Validierung akzeptiert Zusatzfeld): %s" % str(save["errors"]))
var load := SvmapIO.load_from_file("user://test_groups.svmap")
_check(load["ok"], "Laden mit Gruppen-Feld ok (Prüfsumme deckt Feld ab): %s" % str(load["errors"]))
var doc2: Dictionary = load["data"]
_check(int(GroupOps.group_id(doc2["objects"][0])) == 0, "Gruppe 0 überlebt Speichern→Laden (float → int)")
_check(int(GroupOps.group_id(doc2["objects"][1])) == 0, "Mitglied 2 auch")
_check(GroupOps.members_of(doc2, 0) == [0, 1], "Mitglieder nach Laden: [0, 1]")
# ALTE Map (ohne group-Feld): lädt unverändert, alles ungruppiert.
var old_doc := SvmapDocument.create_new("Alte Map", 256.0, "nacht")
var old_save := SvmapIO.save_to_file("user://test_old.svmap", old_doc)
_check(old_save["ok"], "alte Map (ohne Feld) speichert")
var old_load := SvmapIO.load_from_file("user://test_old.svmap")
_check(old_load["ok"], "alte Map lädt 1:1 (Loader-Kompatibilität)")
_check(GroupOps.active_group_ids(old_load["data"]).is_empty(), "alte Map: keine Gruppen (Feld abwesend)")
# Neue Map mit Feld auf ALTEM Pfad: kein format_version-Bump nötig.
_check(int(doc2["format_version"]) == SvmapIO.FORMAT_VERSION, "format_version bleibt 1 (additive Erweiterung)")
for path in ["user://test_groups.svmap", "user://test_old.svmap"]:
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
## Rubberband (Karte t_f6f29d86): Projektion + Rahmen-Auswahl — reine
## Mathe ohne Viewport (Transform + Projektionsmatrix, wie Kamera.unproject).
func _test_rubberband_pick() -> void:
print("Testgruppe: Rahmen-Ausziehen (Rubberband-Projektion)")
var size := Vector2(800.0, 600.0)
var xform := Transform3D(Basis.IDENTITY, Vector3(0.0, 12.0, 18.0))
xform = xform.looking_at(Vector3.ZERO, Vector3.UP)
var proj := Projection.create_perspective(75.0, size.x / size.y, 0.05, 4000.0)
var positions := [
Vector3(-6.0, 0.0, 0.0), # Objekt A (links)
Vector3(0.0, 0.0, 0.0), # Objekt B (Mitte)
Vector3(6.0, 0.0, 0.0), # Objekt C (rechts)
]
var proj_a := _project(xform, proj, size, positions[0])
var proj_c := _project(xform, proj, size, positions[2])
_check(proj_a.x < proj_c.x, "Projektion: links < rechts (Kamera-Basistest)")
# Rahmen über die linke Hälfte → nur Objekt A.
var half_rect := SelectionMath.rect_from(Vector2(0, 0), Vector2(400, 600))
var left := SelectionMath.pick_in_rect_projection(xform, proj, size, positions, half_rect)
_check(left == [0], "linke Hälfte: nur Objekt A im Rahmen")
# Rahmen über ALLES → alle drei, Reihenfolge = Objekt-Reihenfolge.
var all_rect := SelectionMath.rect_from(proj_a - Vector2(40, 40), proj_c + Vector2(40, 40))
var all := SelectionMath.pick_in_rect_projection(xform, proj, size, positions, all_rect)
_check(all == [0, 1, 2], "großer Rahmen: alle drei Objekte")
# Kamera-Rückseite wird ignoriert (kein Spiegel-Treffer hinter der Kamera).
var back: Vector3 = xform.origin + xform.basis.z.normalized() * 5.0 # +Z = HINTER der Kamera
_check(SelectionMath.pick_in_rect_projection(xform, proj, size, [back], Rect2(0, 0, 800, 600)).is_empty(),
"Objekt hinter der Kamera: nie im Rahmen")
## Spiegel der Formel aus selection_math.gd — nur für den Projektions-Basistest.
func _project(xform: Transform3D, proj: Projection, size: Vector2, p: Vector3) -> Vector2:
var view: Vector3 = xform.affine_inverse() * p
var clip: Vector4 = proj * Vector4(view.x, view.y, view.z, 1.0)
var ndc := Vector2(clip.x / clip.w, clip.y / clip.w)
return Vector2((ndc.x * 0.5 + 0.5) * size.x, (1.0 - (ndc.y * 0.5 + 0.5)) * size.y)