scopedvr-map-editor/tests/test_scene_groups.gd
2026-11-01 19:34:05 +01:00

342 lines
16 KiB
GDScript

extends SceneTree
## test_scene_groups (Karte t_a4bf9b8c, U3 Gruppen im Szenen-Baum): prüft
## Gruppieren/Auflösen/Umbenennen per Baum, Drag-Mitgliedschaft, Gruppenbewegung,
## Ein-Undo-Schritt je Aktion, Alte-Map-Kompatibilität und ID-Eindeutigkeit.
##
## Geprüft wird:
## T1 Mehrfachauswahl → „Gruppieren“ (Baum-Weg): Doc-Feld, EIN Undo-Schritt.
## T2 Umbenennen: Name im Doc (additiv), Baum-Zeile zeigt ihn, EIN Undo-Schritt
## (Undo entfernt den Namen auch von NIE-GESPEICHERTEM Zustand, Redo holt ihn).
## T3 Drag IN Gruppe: Mitgliedschaft ändert sich, EIN Undo-Schritt, can_drop
## nur an erlaubten Zielen.
## T4 Drag OUT (auf „Objekte“): nur die gezogenen Indizes lösen sich.
## T5 Gruppenbewegung: alle Mitglieder gleichmäßig (Abstände erhalten), EIN Undo.
## T6 Gruppe auflösen + Gruppe löschen (Rückfrage-Pfad) — je EIN Undo-Schritt,
## Undo holt gelöschte Objekte samt Gruppenbindung zurück.
## T7 Alte Karte ohne Gruppenzustand: Roundtrip unverändert (kein group-Feld,
## kein group_names-Block); Map MIT Gruppen: Roundtrip erhält alles.
## T8 Gruppen-IDs bleiben eindeutig.
var _fails := 0
var _oks := 0
var main
var tree_mod
func _check(cond: bool, what: String) -> void:
if cond:
_oks += 1
print(" ok: ", what)
else:
_fails += 1
print(" FAIL: ", what)
func _init() -> void:
print("Testgruppe: Gruppen im Szenen-Baum (U3)")
var scene: PackedScene = load("res://core/scenes/main.tscn")
main = scene.instantiate()
root.add_child(main)
await process_frame
await process_frame
await process_frame
tree_mod = main.get("tree_mod")
_check(tree_mod != null, "tree_mod aus der Registry geladen")
_check(tree_mod.get("content_tree") != null, "content_tree im Modul vorhanden")
_check(tree_mod.get("_context_menu") != null, "Kontextmenü im Baum angelegt (U3)")
# Drei Objekte ins Doc (gleiche Basis wie test_scene_tree).
(main.doc["objects"] as Array).append({
"id": "barrel_001", "pos": [4.0, 0.0, 4.0], "scale": 1.0, "rot_y_deg": 0.0})
(main.doc["objects"] as Array).append({
"id": "box_001", "pos": [-6.0, 0.0, 2.0], "scale": 2.0, "rot_y_deg": 10.0})
(main.doc["objects"] as Array).append({
"id": "fremd_objekt_xyz", "pos": [0.0, 0.0, 8.0], "scale": 1.0, "rot_y_deg": 0.0})
main.call("_after_structure_change")
await process_frame
await _t1_gruppieren()
await _t2_umbenennen()
await _t3_drag_rein()
await _t4_drag_raus()
await _t5_gruppenbewegung()
await _t6_loesen_und_loeschen()
await _t7_alte_karte_roundtrip()
await _t8_ids_eindeutig()
_finish()
func _history_count() -> int:
return (main.history.get("entries") as Array).size()
## History-Index (Position des zuletzt angewandten Eintrags). Nach einem Undo
## ersetzt der nächste Commit den verworfenen Redo-Zweig — der Eintrags-Zähler
## wächst dann NICHT, aber der Index springt +1. „EIN Undo-Schritt“ prüfen wir
## deshalb über das Index-Delta (präzise über beide Fälle).
func _history_index() -> int:
return int(main.history.get("index"))
func _find_child(item: TreeItem, prefix: String) -> TreeItem:
for child in item.get_children():
if str(child.get_text(0)).begins_with(prefix):
return child
return null
func _group_node(gid: int) -> TreeItem:
var tree: Tree = tree_mod.get("content_tree")
var grp_head := _find_child(tree.get_root(), "Gruppen (")
if grp_head == null:
return null
for child in grp_head.get_children():
if child.has_meta("group_id") and int(child.get_meta("group_id")) == gid:
return child
return null
func _t1_gruppieren() -> void:
print("== T1 Gruppieren (Mehrfachauswahl → Baum) ==")
var before_steps := _history_count()
main.interaction.call("select_many", [0, 2])
tree_mod.call("group_selected")
await process_frame
var ids: Array = GroupOps.active_group_ids(main.doc)
_check(ids.size() == 1 and int(ids[0]) == 0, "T1 Gruppe 0 im Doc angelegt (ids=%s)" % str(ids))
_check(_history_count() == before_steps + 1,
"T1 GENAU EIN Undo-Schritt (%d → %d)" % [before_steps, _history_count()])
var node := _group_node(0)
_check(node != null and str(node.get_text(0)).contains("(2 Objekte)"),
"T1 Baum zeigt Gruppen-Knoten mit 2 Mitgliedern")
# Ein Objekt allein kann nicht gruppieren (Klartext-Meldung, kein Crash).
var steps_now := _history_count()
main.interaction.call("clear_selection", true)
main.interaction.call("select_object", 0, true)
tree_mod.call("group_selected")
_check(_history_count() == steps_now, "T1 Gruppieren mit < 2 Objekten = kein Undo-Schritt")
func _t2_umbenennen() -> void:
print("== T2 Umbenennen ==")
var before_steps := _history_count()
var changed: bool = tree_mod.call("rename_group", 0, "Lager")
_check(changed, "T2 rename_group meldet Änderung")
_check(GroupState.group_name(main.doc, 0) == "Lager", "T2 Name im Dokument (group_names-Block)")
var node := _group_node(0)
_check(node != null and str(node.get_text(0)).begins_with("Lager "),
"T2 Baum-Zeile zeigt den Namen („%s“)" % (str(node.get_text(0)) if node != null else "?"))
_check(_history_count() == before_steps + 1,
"T2 GENAU EIN Undo-Schritt (%d → %d)" % [before_steps, _history_count()])
# Undo muss den NIE-GESPEICHERTEN Namen-Block entfernen (null-Seed).
main.call("_do_undo")
await process_frame
_check(not main.doc.has("group_names"), "T2 Undo entfernt group_names-Block komplett (alte-Map-Semantik)")
_check(GroupOps.active_group_ids(main.doc).size() == 1, "T2 Undo lässt die GRUPPE selbst bestehen")
main.call("_do_redo")
await process_frame
_check(GroupState.group_name(main.doc, 0) == "Lager", "T2 Redo stellt den Namen wieder her")
# Umbenennen auf denselben Namen = kein neuer Undo-Eintrag.
var steps_now := _history_count()
tree_mod.call("rename_group", 0, "Lager")
_check(_history_count() == steps_now, "T2 Rename auf denselben Namen = kein Undo-Schritt")
func _t3_drag_rein() -> void:
print("== T3 Drag in Gruppe ==")
var data: Dictionary = {"svmap_tree_objekte": [1]}
# can_drop: Gruppen-Knoten ja, Objekte-Kopf mit ungruppiertem Objekt nein.
var g_node := _group_node(0)
_check(g_node != null and tree_mod.call("_tree_can_drop", Vector2.ZERO, data) == false,
"T3 can_drop braucht die ECHTE Position (Vector2.ZERO = keine Zeile) — kein Blind-True")
_check(tree_mod.call("_tree_can_drop", Vector2(0, 0), {"falsch": 1}) == false,
"T3 fremdes Datenpaket wird abgelehnt")
var before_steps := _history_count()
tree_mod.call("_apply_membership_drop", [1], 0)
await process_frame
var members: Array = GroupOps.members_of(main.doc, 0)
_check(members == ([0, 1, 2] as Array), "T3 Objekt 1 ist jetzt Mitglied (members=%s)" % str(members))
_check(_history_count() == before_steps + 1,
"T3 GENAU EIN Undo-Schritt (%d → %d)" % [before_steps, _history_count()])
var node := _group_node(0)
_check(node != null and str(node.get_text(0)).contains("(3 Objekte)"),
"T3 Baum zeigt 3 Mitglieder")
# Drag-Start (Zeile finden + Menge): Ziehmenge = ganze Markierung, wenn die
# Zeile Teil der Auswahl ist — sonst nur die Zeile.
main.interaction.call("select_many", [1, 2])
var drag_item: TreeItem = null
for child in node.get_children():
if child.has_meta("obj_index") and int(child.get_meta("obj_index")) == 1:
drag_item = child
_check(drag_item != null, "T3 gezogene Zeile (Objekt 1) unter dem Gruppen-Knoten gefunden")
if drag_item != null:
var picked: Array = tree_mod.call("_collect_drag_indices", drag_item)
_check(picked == ([1, 2] as Array), "T3 Ziehmenge = ganze Markierung [1, 2] (hat %s)" % str(picked))
func _t4_drag_raus() -> void:
print("== T4 Drag aus Gruppe (auf „Objekte“) ==")
# can_drop auf Objekte-Kopf: nur erlaubt, wenn EIN gezogenes Objekt gruppiert ist.
var objects: Array = main.doc.get("objects", [])
_check(GroupOps.group_id(objects[0]) == 0, "T4 Vorbedingung: Objekt 0 ist in Gruppe 0")
var before_steps := _history_count()
tree_mod.call("_apply_membership_drop", [0], -1)
await process_frame
_check(GroupOps.group_id(objects[0]) == GroupOps.UNGROUPED, "T4 Objekt 0 ist ungruppiert")
_check(GroupOps.members_of(main.doc, 0) == ([1, 2] as Array), "T4 Gruppe 0 bleibt mit 1+2 bestehen")
_check(_history_count() == before_steps + 1,
"T4 GENAU EIN Undo-Schritt (%d → %d)" % [before_steps, _history_count()])
# Undo holt Objekt 0 in die Gruppe zurück.
main.call("_do_undo")
await process_frame
_check(GroupOps.members_of(main.doc, 0) == ([0, 1, 2] as Array), "T4 Undo stellt die Mitgliedschaft wieder her")
func _t5_gruppenbewegung() -> void:
print("== T5 Gruppenbewegung (Abstände erhalten, EIN Undo) ==")
var members: Array = GroupOps.members_of(main.doc, 0)
var objects: Array = main.doc.get("objects", [])
var pos_before: Dictionary = {}
for i in members:
pos_before[int(i)] = Vector3(
float((objects[i] as Dictionary)["pos"][0]),
float((objects[i] as Dictionary)["pos"][1]),
float((objects[i] as Dictionary)["pos"][2]))
var dist_before: float = pos_before[1].distance_to(pos_before[2])
main.interaction.call("select_many", members)
var before_steps := _history_count()
var before_index := _history_index()
main.interaction.call("nudge_selection", Vector3(4.0, 0.0, -2.0))
await process_frame
var moved: Dictionary = {}
for i in members:
var p: Array = (main.doc["objects"][int(i)] as Dictionary)["pos"]
moved[int(i)] = Vector3(float(p[0]), float(p[1]), float(p[2]))
var delta: Vector3 = moved[1] - pos_before[1]
var dist_after: float = moved[1].distance_to(moved[2])
_check(delta.length() > 0.5, "T5 Mitglieder sind tatsächlich gewandert (%.2f m)" % delta.length())
_check(absf((moved[2] - pos_before[2]).length() - delta.length()) < 0.001,
"T5 ALLE Mitglieder um denselben Betrag verschoben")
_check(absf(dist_after - dist_before) < 0.001,
"T5 Abstand erhalten (%.3f m → %.3f m)" % [dist_before, dist_after])
_check(_history_index() == before_index + 1,
"T5 GENAU EIN Undo-Schritt (Index %d → %d, Einträge %d)" % [before_index, _history_index(), _history_count()])
main.call("_do_undo")
await process_frame
var objects2: Array = main.doc.get("objects", [])
var restored: bool = true
for i in members:
var p: Array = (objects2[int(i)] as Dictionary)["pos"]
if Vector3(float(p[0]), float(p[1]), float(p[2])).distance_to(pos_before[int(i)]) > 0.001:
restored = false
_check(restored, "T5 EIN Undo stellt alle Positionen exakt wieder her")
func _t6_loesen_und_loeschen() -> void:
print("== T6 Auflösen + Löschen (Rückfrage-Pfad) ==")
var before_steps := _history_count()
var before_index := _history_index()
tree_mod.call("ungroup_group", 0)
await process_frame
_check(GroupOps.active_group_ids(main.doc).is_empty(), "T6 Gruppe 0 komplett aufgelöst")
var field_free := true
for obj in (main.doc["objects"] as Array):
if (obj as Dictionary).has("group"):
field_free = false
_check(field_free, "T6 kein Objekt trägt noch ein group-Feld")
_check(_history_index() == before_index + 1,
"T6 Auflösen = GENAU EIN Undo-Schritt (Index %d → %d, Einträge %d)" % [before_index, _history_index(), _history_count()])
# Neu gruppieren, dann löschen (Rückfrage bei > 1 Objekt → Dialog; Kern direkt).
main.interaction.call("select_many", [0, 1])
tree_mod.call("group_selected")
await process_frame
var ids: Array = GroupOps.active_group_ids(main.doc)
_check(ids.size() == 1, "T6 Gruppe neu angelegt (ids=%s)" % str(ids))
var gid := int(ids[0])
var before_del := _history_count()
tree_mod.call("delete_group_confirmed", gid)
await process_frame
_check((main.doc["objects"] as Array).size() == 1, "T6 beide Gruppen-Mitglieder gelöscht (1 Objekt übrig)")
_check(_history_count() == before_del + 1,
"T6 Löschen = GENAU EIN Undo-Schritt (%d → %d)" % [before_del, _history_count()])
main.call("_do_undo")
await process_frame
_check((main.doc["objects"] as Array).size() == 3, "T6 EIN Undo holt beide Objekte zurück")
var back_ids: Array = GroupOps.active_group_ids(main.doc)
_check(back_ids.size() == 1 and GroupOps.members_of(main.doc, int(back_ids[0])).size() == 2,
"T6 Undo stellt auch die Gruppen-Bindung wieder her")
# Aufräumen für T7/T8: Gruppe lösen, alle Objekte einzeln.
tree_mod.call("ungroup_group", int(back_ids[0]))
func _t7_alte_karte_roundtrip() -> void:
print("== T7 Alte Karte ohne Gruppenzustand lädt unverändert ==")
var doc := SvmapDocument.create_new("U3-Alte-Karte", 256.0, "mittag")
var save1 := SvmapIO.save_to_file("user://test_scene_groups_alt.svmap", doc)
_check(save1["ok"], "T7 alte Karte speichert ok: %s" % str(save1["errors"]))
var load1 := SvmapIO.load_from_file("user://test_scene_groups_alt.svmap")
_check(load1["ok"], "T7 alte Karte lädt ok: %s" % str(load1["errors"]))
var loaded: Dictionary = load1["data"]
_check(not loaded.has("group_names"), "T7 geladene Map hat KEINEN group_names-Block")
var no_group_field := true
for obj in (loaded["objects"] as Array):
if (obj as Dictionary).has("group"):
no_group_field = false
_check(no_group_field, "T7 kein Objekt trägt ein group-Feld")
# Anzeige-Fallback ohne Namen: "Gruppe 1" statt Crash.
_check(GroupState.display_name(loaded, 0) == "Gruppe 1", "T7 Anzeige-Fallback „Gruppe 1“ ohne Namen")
# Map MIT Gruppen: Roundtrip erhält group-Feld UND Namen.
var doc2: Dictionary = SvmapDocument.create_new("U3-Mit-Gruppen", 256.0, "mittag")
(doc2["objects"] as Array).append({
"id": "barrel_001", "pos": [4.0, 0.0, 4.0], "scale": 1.0, "rot_y_deg": 0.0})
(doc2["objects"] as Array).append({
"id": "box_001", "pos": [-6.0, 0.0, 2.0], "scale": 2.0, "rot_y_deg": 10.0})
GroupOps.group(doc2, [0, 1])
GroupState.set_group_name(doc2, 0, "Lager")
var save2 := SvmapIO.save_to_file("user://test_scene_groups_neu.svmap", doc2)
_check(save2["ok"], "T7 Map mit Gruppen speichert ok: %s" % str(save2["errors"]))
var load2 := SvmapIO.load_from_file("user://test_scene_groups_neu.svmap")
_check(load2["ok"], "T7 Map mit Gruppen lädt ok: %s" % str(load2["errors"]))
var loaded2: Dictionary = load2["data"]
_check(GroupState.group_name(loaded2, 0) == "Lager", "T7 Gruppenname überlebt Roundtrip")
_check(GroupOps.group_id(loaded2["objects"][0]) == 0
and GroupOps.group_id(loaded2["objects"][1]) == 0,
"T7 group-Felder überleben Roundtrip")
func _t8_ids_eindeutig() -> void:
print("== T8 Gruppen-IDs bleiben eindeutig ==")
var objects: Array = main.doc.get("objects", [])
_check(objects.size() == 3, "T8 Vorbedingung: 3 Objekte im Doc")
var seen: Dictionary = {}
# Mehrere Gruppen nacheinander anlegen — jede ID muss neu sein.
var g1: int = GroupOps.group(main.doc, [0, 1])
seen[g1] = true
_check(g1 >= 0, "T8 Gruppe 1 angelegt (id %d)" % g1)
var g2: int = GroupOps.group(main.doc, [2])
_check(g2 < 0, "T8 Gruppe braucht ≥ 2 Objekte (id %d)" % g2)
# Objekt 2 ist nicht gruppiert → mit Objekt 0 gruppieren → verlässt Gruppe 1.
var g2b: int = GroupOps.group(main.doc, [2, 0])
_check(g2b != g1, "T8 zweite Gruppe bekommt eine NEUE ID (%d ≠ %d)" % [g2b, g1])
seen[g2b] = true
var active: Array = GroupOps.active_group_ids(main.doc)
var unique_ids: Dictionary = {}
for g in active:
unique_ids[int(g)] = true
_check((active as Array).size() == unique_ids.size(),
"T8 aktive IDs sind eindeutig (ids=%s)" % str(active))
_check(GroupOps.next_group_id(main.doc) > int(active.max()), "T8 nächste ID liegt über allen aktiven")
# Aufräumen: alles ungruppiert für den restlichen Lauf.
GroupState.clear_group_fields(main.doc, [0, 1, 2])
GroupState.prune_orphans(main.doc)
_check(GroupOps.active_group_ids(main.doc).is_empty(), "T8 Aufräumen: keine Gruppen mehr aktiv")
func _finish() -> void:
print("CHECKS: %d FAILURES: %d" % [_oks + _fails, _fails])
if _fails > 0:
print("SCENE_GROUPS_TEST_FAIL")
quit(1)
else:
print("ALL OK: %d checks" % (_oks + _fails))
quit(0)