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.
557 lines
29 KiB
GDScript
557 lines
29 KiB
GDScript
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)
|