scopedvr-map-editor/tests/test_library.gd
Hermes (godot-vr) 3a5c9d44bb Objekt-Bibliothek: Gruppen-Browser mit Thumbnails, Snap-to-Ground, Raster-Snap, Tests
- Bibliothek-Browser: 11 etablierte Objekte in deutschen Klapp-Gruppen
  (Kisten & Barrieren / Bauwerke / Natur, neue IDs wandern nach Sonstiges)
- 128-px-Thumbnails einmalig gerendert (tools/gen_thumbs.gd, xvfb) und
  committed; fehlendes PNG = sauberer Text-Fallback (LibraryBrowser)
- Snap to Ground: ein Klick setzt ALLE schwebenden Objekte auf Terrain-Höhe,
  EIN Undo-Eintrag (ObjectOps.snap_to_ground)
- Raster-Snap (1 m): Toggle — rastet Platzieren und Ziehen auf ganze Meter
- Deutsche Namen nach LibraryBrowser verschoben (headless testbar)
- tests/test_library.gd: 47 Checks (Bibliothek, Gruppen, Thumbnails, Snap,
  CRUD+Undo über alle Objekte, svmap-Roundtrip byte-identisch, Poly-Budget)
- Desktop-Smoke: --library-demo erzeugt Screenshots (lokal via xvfb verifiziert)
- UI-Layout: linke Spalte min 909→797 px, Statusleiste wieder vollständig sichtbar
2026-10-15 12:18:11 +00:00

307 lines
14 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_3a998ae4 — Objekt-Bibliothek, Snap-to-Ground,
## Raster-Snap, svmap-Roundtrip mit Objekten.
## Ausführen: godot --headless --path . --script res://tests/test_library.gd
## Exit 0 = alles grün. Läuft OHNE UI (LibraryBrowser/ObjectOps/SvmapIO/SvmapDocument).
## UI-Funktionen (Gruppen-Aufklappen, Thumbnail-Anzeige) werden im Desktop-Smoke
## verifiziert (--library-demo), 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 _run_tests() -> void:
_test_manifest_load()
_test_groups()
_test_thumbnails()
_test_grid_snap()
_test_snap_to_ground()
_test_snap_to_ground_undo()
_test_crud_undo_all_objects()
_test_svmap_roundtrip_objects()
# ------------------------------------------------------- Bibliothek laden ----
func _load_manifest() -> Dictionary:
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH))
return parsed if parsed is Dictionary else {}
func _test_manifest_load() -> void:
print("Testgruppe: Bibliothek laden (library/objects.json)")
var manifest := _load_manifest()
_check(not manifest.is_empty(), "Manifest lädt und ist ein Objekt")
var lib: Dictionary = manifest.get("objects", {})
_check(lib.size() == 11, "Katalog hat die 11 etablierten Objekte (ist %d)" % lib.size())
var all_ok := true
for oid in lib.keys():
var entry: Dictionary = lib[oid]
if int(entry.get("poly", 0)) <= 0:
all_ok = false
var col: Dictionary = entry.get("collision", {})
if str(col.get("shape", "")).is_empty():
all_ok = false
_check(all_ok, "jedes Objekt hat poly > 0 + Kollider-Eintrag")
# Deutsche Namen decken ALLE IDs ab (Bibliothek-Browser zeigt nichts Englisches).
var names_ok := lib.keys().all(func(oid) -> bool: return LibraryBrowser.NAMES.has(oid))
_check(names_ok, "deutsche Namen für alle IDs vorhanden")
_check(LibraryBrowser.object_label("crate_wood") == "Holzkiste", "object_label: crate_wood → Holzkiste")
_check(LibraryBrowser.object_label("gibts_nicht") == "Gibts Nicht", "object_label: unbekannte ID fällt auf capitalize zurück")
func _test_groups() -> void:
print("Testgruppe: Gruppen (Gruppe/Einzelobjekt im Browser)")
var manifest := _load_manifest()
var lib: Dictionary = manifest.get("objects", {})
var groups := LibraryBrowser.groups_in_order(manifest)
var seen := {}
var all_in := true
for group in groups:
_check(not str(group["label"]).is_empty(), "Gruppe „%s“ hat Label" % str(group["key"]))
for oid in (group["ids"] as Array):
if seen.has(oid):
all_in = false
seen[oid] = true
_check(all_in, "keine ID in zwei Gruppen")
_check(seen.size() == lib.size(), "alle %d IDs landen in einer Gruppe (ist %d)" % [lib.size(), seen.size()])
_check(LibraryBrowser.group_of("tree_pine") == "natur", "group_of: tree_pine → natur")
_check(LibraryBrowser.group_of("crate_wood") == "kisten", "group_of: crate_wood → kisten")
_check(LibraryBrowser.group_of("fremdes_ding") == "sonstiges", "group_of: unbekannte ID → sonstiges")
# Gruppen, die im Katalog leer sind, werden nicht angezeigt (außer Sonstiges).
var labels: Array = []
for group in groups:
labels.append(str(group["label"]))
var no_empty := true
for group in groups:
if str(group["key"]) != "sonstiges" and (group["ids"] as Array).is_empty():
no_empty = false
_check(no_empty, "leere feste Gruppen tauchen nicht auf")
func _test_thumbnails() -> void:
print("Testgruppe: Thumbnails (128 px, einmalig gerendert + committed)")
var manifest := _load_manifest()
var lib: Dictionary = manifest.get("objects", {})
var missing: Array = []
var wrong_size := false
for oid in lib.keys():
var path := LibraryBrowser.thumb_path(str(oid))
if not FileAccess.file_exists(path):
missing.append(str(oid))
continue
var img := Image.load_from_file(path)
if img == null or img.get_width() != 128 or img.get_height() != 128:
wrong_size = true
_check(missing.is_empty(), "Thumbnail für jedes Objekt vorhanden (fehlend: %s)" % str(missing))
_check(not wrong_size, "alle Thumbnails sind 128×128")
var tex := LibraryBrowser.load_thumb("crate_wood", 40)
_check(tex != null, "load_thumb liefert Textur")
var fallback := LibraryBrowser.load_thumb("gibts_nicht")
_check(fallback == null, "load_thumb: fehlendes Thumbnail → null (Button fällt aufs Text-Layout zurück)")
# ------------------------------------------------------------ Raster-Snap ----
func _test_grid_snap() -> void:
print("Testgruppe: Raster-Snap (1 m)")
_check(ObjectOps.snap_coord(10.4, true) == 10.0, "10.4 rastet auf 10")
_check(ObjectOps.snap_coord(10.6, true) == 11.0, "10.6 rastet auf 11")
_check(ObjectOps.snap_coord(-0.6, true) == -1.0, "-0.6 rastet auf -1")
_check(ObjectOps.snap_coord(-0.4, true) == 0.0, "-0.4 rastet auf 0")
_check(ObjectOps.snap_coord(10.4, false) == 10.4, "aus = unverändert")
_check(ObjectOps.snap_coord(10.4, true, 4.0) == 12.0, "4-m-Raster: 10.4 → 12")
# ---------------------------------------------------------- Snap to Ground ---
## Alle Heightmap-Samples auf einen 16-bit-Wert setzen → Terrain überall
## gleich hoch (h = min + v/65535 × Spannweite). Für Snap-Tests ohne
## Terrain-Werkzeug. uint16 little-endian (Low-Byte zuerst, map_format.md).
func _flatten_terrain(doc: Dictionary, v: int) -> void:
var terrain: Dictionary = doc["terrain"]
var res := int(terrain["resolution"])
var bytes := PackedByteArray()
bytes.resize(res * res * 2)
for i in res * res:
bytes[i * 2] = v & 0xFF
bytes[i * 2 + 1] = (v >> 8) & 0xFF
terrain["heightmap_r16_b64"] = Marshalls.raw_to_base64(bytes)
func _test_snap_to_ground() -> void:
print("Testgruppe: Snap to Ground (alle schwebenden Objekte auf Terrain-Höhe)")
var doc := SvmapDocument.create_new("Snap-Test", 1024.0, "mittag")
(doc["objects"] as Array).append({"id": "crate_wood", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0})
(doc["objects"] as Array).append({"id": "tree_pine", "pos": [-5.0, 0.0, 3.0], "scale": 1.0, "rot_y_deg": 45.0})
_check(ObjectOps.snap_to_ground(doc) == 0, "flaches Terrain: alles steht schon (0 Änderungen)")
_flatten_terrain(doc, 65535) # Terrain überall auf max_height (50 m)
var h := SvmapDocument.terrain_height_at(doc, 10.0, 10.0)
_check(absf(h - 50.0) < 0.01, "Terrain-Höhe nach Heben: %.1f m" % h)
var changed := ObjectOps.snap_to_ground(doc)
_check(changed == 2, "2 schwebende Objekte erkannt")
var objects: Array = doc["objects"]
_check(absf(float(objects[0]["pos"][1]) - 50.0) < 0.01, "Objekt 1 steht auf dem Boden")
_check(absf(float(objects[1]["pos"][1]) - 50.0) < 0.01, "Objekt 2 steht auf dem Boden")
_check(ObjectOps.snap_to_ground(doc) == 0, "zweiter Klick: nichts mehr zu tun")
# Teilweise schwebend: nur das schwebende wird erfasst.
objects[0]["pos"][1] = 50.0
objects[1]["pos"][1] = 60.0
_check(ObjectOps.snap_to_ground(doc) == 1, "nur das schwebende Objekt wird gesnapt")
func _test_snap_to_ground_undo() -> void:
print("Testgruppe: Snap to Ground = EIN Undo-Eintrag")
var doc := SvmapDocument.create_new("Snap-Undo", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
(doc["objects"] as Array).append({"id": "bunker_concrete", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
(doc["objects"] as Array).append({"id": "fence_metal", "pos": [4.0, 0.0, -2.0], "scale": 1.0, "rot_y_deg": 90.0})
_flatten_terrain(doc, 32768) # Terrain auf ~25 m
var before: Dictionary = hist.snapshot(["objects"])
var changed := ObjectOps.snap_to_ground(doc)
hist.commit("Alle Objekte auf Boden gesetzt (%d)" % changed, before, hist.snapshot(["objects"]))
var objects: Array = doc["objects"]
_check(changed == 2, "beide Objekte gesnapt")
_check(absf(float(objects[0]["pos"][1]) - 25.0) < 0.05, "Objekt 1 auf Terrain-Höhe")
hist.undo()
_check(absf(float(objects[0]["pos"][1]) - 0.0) < 0.0001, "undo: beide wieder schwebend (1 Schritt)")
_check(absf(float(objects[1]["pos"][1]) - 0.0) < 0.0001, "undo: Objekt 2 auch")
hist.redo()
_check(absf(float(objects[0]["pos"][1]) - 25.0) < 0.05 and absf(float(objects[1]["pos"][1]) - 25.0) < 0.05, "redo: wieder auf dem Boden")
# ------------------------------------------- setzen/drehen/verschieben/löschen —
func _test_crud_undo_all_objects() -> void:
print("Testgruppe: JEDES Bibliotheks-Objekt — setzen/drehen/verschieben/löschen + je Undo")
var manifest := _load_manifest()
var lib: Dictionary = manifest.get("objects", {})
var doc := SvmapDocument.create_new("CRUD-Test", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var all_ok := true
var first := true
for oid in lib.keys():
var x := randf() * 100.0 - 50.0
var z := randf() * 100.0 - 50.0
var y := SvmapDocument.terrain_height_at(doc, x, z)
# setzen (+ Undo)
var before := hist.snapshot(["objects"])
var obj := {"id": oid, "pos": [x, y, z], "scale": 1.0, "rot_y_deg": 0.0}
(doc["objects"] as Array).append(obj)
hist.commit("Test: %s platzieren" % oid, before, hist.snapshot(["objects"]))
var objects: Array = doc["objects"]
if objects.size() != 1 or absf(float(objects[0]["pos"][1]) - y) > 0.0001:
all_ok = false
hist.undo()
if not (doc["objects"] as Array).is_empty():
all_ok = false
hist.redo()
if (doc["objects"] as Array).size() != 1:
all_ok = false
# drehen (+ Undo)
before = hist.snapshot(["objects"])
objects[0]["rot_y_deg"] = 90.0
hist.commit("Test: drehen", before, hist.snapshot(["objects"]), "rot:0")
hist.undo()
if absf(float(objects[0]["rot_y_deg"]) - 0.0) > 0.0001:
all_ok = false
hist.redo()
if absf(float(objects[0]["rot_y_deg"]) - 90.0) > 0.0001:
all_ok = false
# verschieben (+ Undo)
before = hist.snapshot(["objects"])
objects[0]["pos"] = [x + 3.0, y, z + 3.0]
hist.commit("Test: verschieben", before, hist.snapshot(["objects"]), "move:0")
hist.undo()
if absf(float(objects[0]["pos"][0]) - x) > 0.0001:
all_ok = false
hist.redo()
if absf(float(objects[0]["pos"][0]) - (x + 3.0)) > 0.0001:
all_ok = false
# löschen (+ Undo)
before = hist.snapshot(["objects"])
(doc["objects"] as Array).remove_at(0)
hist.commit("Test: löschen", before, hist.snapshot(["objects"]))
hist.undo()
if (doc["objects"] as Array).size() != 1:
all_ok = false
hist.redo()
if not (doc["objects"] as Array).is_empty():
all_ok = false
if first:
first = false
_check(all_ok, "alle 11 Objekte: setzen/drehen/verschieben/löschen je mit Undo+Redo sauber")
# -------------------------------------------------------- svmap-Roundtrip ----
func _test_svmap_roundtrip_objects() -> void:
print("Testgruppe: svmap-Roundtrip — Objekte (ID + Pos/Rot/Scale) überleben Speichern/Laden")
var doc := SvmapDocument.create_new("Roundtrip", 1024.0, "abend")
_flatten_terrain(doc, 20000) # Terrain leicht erhöht (~15 m)
var objects: Array = doc["objects"]
objects.append({"id": "watchtower_wood", "pos": [10.0, SvmapDocument.terrain_height_at(doc, 10.0, 6.0), 6.0], "scale": 1.0, "rot_y_deg": 30.0})
objects.append({"id": "crate_wood", "pos": [-8.0, SvmapDocument.terrain_height_at(doc, -8.0, -4.0), -4.0], "scale": 2.0, "rot_y_deg": 0.0})
objects.append({"id": "tree_pine", "pos": [-20.0, SvmapDocument.terrain_height_at(doc, -20.0, 18.0), 18.0], "scale": 1.0, "rot_y_deg": 75.0})
objects.append({"id": "rock_large", "pos": [24.0, SvmapDocument.terrain_height_at(doc, 24.0, -18.0), -18.0], "scale": 0.5, "rot_y_deg": 180.0})
objects.append({"id": "bunker_concrete", "pos": [0.0, SvmapDocument.terrain_height_at(doc, 0.0, 40.0), 40.0], "scale": 1.0, "rot_y_deg": 270.0})
var manifest := _load_manifest()
SvmapIO.sign_map(doc) # Prüfsumme setzen → validate_map (inkl. V13) prüfbar
_check(SvmapIO.validate_map(doc, manifest).is_empty(), "Map mit 5 Objekten validiert sauber (V1–V14)")
var save1 := SvmapIO.save_to_file("user://test_library_roundtrip.svmap", doc)
_check(save1["ok"], "save_to_file ok: %s" % str(save1["errors"]))
var load1 := SvmapIO.load_from_file("user://test_library_roundtrip.svmap")
_check(load1["ok"], "load_from_file ok: %s" % str(load1["errors"]))
var doc2: Dictionary = load1["data"]
var objs2: Array = doc2["objects"]
var same := objs2.size() == objects.size()
if same:
for i in objects.size():
var a: Dictionary = objects[i]
var b: Dictionary = objs2[i]
if str(a["id"]) != str(b["id"]):
same = false
for k in 3:
if absf(float(a["pos"][k]) - float(b["pos"][k])) > 0.0001:
same = false
if absf(float(a["rot_y_deg"]) - float(b["rot_y_deg"])) > 0.0001:
same = false
if absf(float(a["scale"]) - float(b["scale"])) > 0.0001:
same = false
_check(same, "alle Objekte identisch zurück (ID, Pos, Rot, Scale)")
_check(SvmapIO.validate_map(doc2, manifest).is_empty(), "geladene Map validiert erneut sauber")
var save2 := SvmapIO.save_to_file("user://test_library_roundtrip2.svmap", doc2)
_check(save2["ok"], "zweites Speichern ok")
var text1 := FileAccess.get_file_as_string("user://test_library_roundtrip.svmap")
var text2 := FileAccess.get_file_as_string("user://test_library_roundtrip2.svmap")
_check(text1 == text2, "save→load→save byte-identisch")
# Poly-Budget-Rechnung kommt aus dem Katalog (Σ poly × scale²).
var expected := 0.0
for o in objects:
expected += float(manifest["objects"][str(o["id"])]["poly"]) * float(o["scale"]) * float(o["scale"])
_check(absf(SvmapDocument.poly_usage(doc, manifest) - expected) < 0.01,
"Poly-Budget aus Katalog: %.0f (erwartet %.0f)" % [SvmapDocument.poly_usage(doc, manifest), expected])
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_library_roundtrip.svmap"))
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_library_roundtrip2.svmap"))