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() ## 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")