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://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": "crate_wood", "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": "crate_wood", "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)