From 835a7f7363a4f440d20032b28a2b2ce47d6752f5 Mon Sep 17 00:00:00 2001 From: "Hermes (scopedvr-dev)" Date: Tue, 20 Oct 2026 10:36:04 +0000 Subject: [PATCH] =?UTF-8?q?U3=20t=5Fa4bf9b8c:=20Objekt-Gruppen=20=E2=80=94?= =?UTF-8?q?=20GroupState=20(group=5Fstate.gd,=20fuell-/datei-agnostisch=20?= =?UTF-8?q?ueber=20get=5Fmeta/set=5Fmeta),=20scene=5Ftree=5Fpanel:=20Grupp?= =?UTF-8?q?en-Ebene=20im=20Baum=20(Name=20+=20(N=20Objekte),=20eingerueckt?= =?UTF-8?q?e=20Mitglieder,=20Klick=20waehlt=20alle=20Mitglieder,=20Gruppen?= =?UTF-8?q?-Knoten=20markiert=20wenn=20alle=20Mitglieder=20gewaehlt),=20zw?= =?UTF-8?q?ei-Wege=20mit=20Auswahl,=20Rendering=20bleibt=20Gruppen-unabhae?= =?UTF-8?q?ngig.=20Neue=20Suite=20tests/test=5Fscene=5Fgroups.gd=20+=20Ren?= =?UTF-8?q?derprobe=20probe=5Fu3=5Fgroups=5Frender.gd.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/svmap/group_state.gd | 111 +++++++++ src/svmap/group_state.gd.uid | 1 + src/ui/panels/scene_tree_panel.gd | 352 +++++++++++++++++++++++++++- tests/probe_u3_groups_render.gd | 97 ++++++++ tests/probe_u3_groups_render.gd.uid | 1 + tests/test_scene_groups.gd | 342 +++++++++++++++++++++++++++ tests/test_scene_groups.gd.uid | 1 + 7 files changed, 904 insertions(+), 1 deletion(-) create mode 100644 src/svmap/group_state.gd create mode 100644 src/svmap/group_state.gd.uid create mode 100644 tests/probe_u3_groups_render.gd create mode 100644 tests/probe_u3_groups_render.gd.uid create mode 100644 tests/test_scene_groups.gd create mode 100644 tests/test_scene_groups.gd.uid diff --git a/src/svmap/group_state.gd b/src/svmap/group_state.gd new file mode 100644 index 0000000..e6c2d01 --- /dev/null +++ b/src/svmap/group_state.gd @@ -0,0 +1,111 @@ +extends RefCounted +class_name GroupState +## Gruppen-ZUSTAND über das Objekt-Feld "group" hinaus (Karte t_a4bf9b8c, U3 — +## Gruppen im Szenen-Baum). GroupOps (src/editor/group_ops.gd) besitzt die +## Zugehörigkeit (Optionales Objekt-Feld "group", int ≥ 0) — diese Datei ergänzt +## NUR ADDITIV, was der Baum additionally braucht: +## +## 1. GRUPPENNAMEN: doc["group_names"] = { "": "Lager", ... } — optionaler +## Ober-Block. Alte Maps ohne diesen Key laden unverändert (V4 prüft nur +## Pflichtblöcke, unbekannte Ober-Keys wandern 1:1 durch _canonicalize in +## SvmapIO und in die sha256-Prüfsumme — gleiche Begründung wie beim +## "group"-Feld in GroupOps). Eintrag fehlt ⇒ Anzeige-Fallback "Gruppe N". +## 2. DRAG-MITGLIDSCHAFT: Objekt in Gruppe ziehen (assign) / heraus ziehen +## (clear für EINZELNE Indizes — GroupOps.ungroup löst dagegen die GANZE +## Gruppe, was beim Drag-out nur eines Objekts nicht gewollt ist). +## +## Bewusst KEINE eigene Undo-Logik: der Aufrufer macht snapshot/commit über +## ActionHistory (["objects"] bzw. ["group_names"]) — jede Aktion bleibt EIN +## Undo-Schritt. + +const NAMES_KEY := "group_names" + + +## Rohe Namen-Tabelle (leer, wenn der Key fehlt — alte Maps). +static func names_dict(doc: Dictionary) -> Dictionary: + var raw: Variant = doc.get(NAMES_KEY, {}) + return raw if raw is Dictionary else {} + + +## Gespeicherter Name einer Gruppe ("" = keiner gesetzt). +static func group_name(doc: Dictionary, gid: int) -> String: + var names := names_dict(doc) + var value: Variant = names.get(str(gid), "") + return str(value) if value is String else "" + + +## Anzeige-Name im Baum: gespeicherter Name oder Fallback "Gruppe N" +## (N = gid + 1 — gleiche Zählung wie in interaction.gd/selection_panel.gd). +static func display_name(doc: Dictionary, gid: int) -> String: + var name := group_name(doc, gid) + return name if not name.is_empty() else ("Gruppe %d" % (gid + 1)) + + +## Namen setzen ("" oder nur Leerzeichen = Eintrag entfernen → Fallback). +## Rückgabe: true, wenn sich am Dokument etwas geändert hat. +static func set_group_name(doc: Dictionary, gid: int, new_name: String) -> bool: + var clean := new_name.strip_edges() + var names := names_dict(doc) + if clean.is_empty(): + if not names.has(str(gid)): + return false + (doc[NAMES_KEY] as Dictionary).erase(str(gid)) + return true + if names.get(str(gid), "") == clean: + return false + if not doc.has(NAMES_KEY) or not (doc[NAMES_KEY] is Dictionary): + doc[NAMES_KEY] = {} + (doc[NAMES_KEY] as Dictionary)[str(gid)] = clean + return true + + +## Namen-Einträge ohne Mitglieder entfernen (Aufräumen nach Löschen/Lösen — +## vom Aufrufer IM SELBEN Undo-Schritt mit-committet). +static func prune_orphans(doc: Dictionary) -> int: + var active := {} + for obj in (doc.get("objects", []) as Array): + var g := GroupOps.group_id(obj) + if g != GroupOps.UNGROUPED: + active[g] = true + var names := names_dict(doc) + var removed := 0 + for key in names.keys(): + if not active.has(int(key)): + names.erase(key) + removed += 1 + if removed > 0: + if (names as Dictionary).is_empty(): + doc.erase(NAMES_KEY) # sauberes Format: leerer Block fliegt raus + elif doc.has(NAMES_KEY): + doc[NAMES_KEY] = names + return removed + + +## Drag IN eine Gruppe: Objekt-Feld "group" auf die Ziel-ID setzen (Objekte, +## die schon diese ID tragen, zählen nicht als Änderung). Nur die GEBENEN +## Indizes — die restliche Gruppe bleibt unberührt. Rückgabe: geänderte Anzahl. +static func assign_group(doc: Dictionary, indices: Array, gid: int) -> int: + if gid < 0: + return 0 + var objects: Array = doc.get("objects", []) + var changed := 0 + for i in indices: + if i is int and i >= 0 and i < objects.size() and (objects[i] is Dictionary): + if GroupOps.group_id(objects[i]) != gid: + objects[i]["group"] = gid + changed += 1 + return changed + + +## Drag AUS der Gruppe: "group"-Feld NUR der gegebenen Indizes entfernen +## (die Gruppe selbst bleibt mit ihren übrigen Mitgliedern bestehen). +## Rückgabe: geänderte Anzahl. +static func clear_group_fields(doc: Dictionary, indices: Array) -> int: + var objects: Array = doc.get("objects", []) + var changed := 0 + for i in indices: + if i is int and i >= 0 and i < objects.size() and (objects[i] is Dictionary): + if GroupOps.group_id(objects[i]) != GroupOps.UNGROUPED: + (objects[i] as Dictionary).erase("group") + changed += 1 + return changed diff --git a/src/svmap/group_state.gd.uid b/src/svmap/group_state.gd.uid new file mode 100644 index 0000000..84d92fe --- /dev/null +++ b/src/svmap/group_state.gd.uid @@ -0,0 +1 @@ +uid://b13m4gw07qdsf diff --git a/src/ui/panels/scene_tree_panel.gd b/src/ui/panels/scene_tree_panel.gd index 60392b5..caca64c 100644 --- a/src/ui/panels/scene_tree_panel.gd +++ b/src/ui/panels/scene_tree_panel.gd @@ -8,6 +8,14 @@ var content_tree: Tree var _tree_select_lock := false # verhindert Rückkopplung Auswahl ↔ Baum var _obj_index_map: Dictionary = {} # obj_index -> TreeItem (schnelle Zwei-Wege-Kopplung) +# U3 (Karte t_a4bf9b8c): Kontextmenü, Umbenennen, Lösch-Rückfrage, Drag&Drop. +var _context_menu: PopupMenu +var _rename_dialog: AcceptDialog +var _rename_field: LineEdit +var _rename_gid := -1 # Gruppe, die das Umbenennen-Dialogfeld betrifft +var _confirm_delete: ConfirmationDialog +var _confirm_gid := -1 # Gruppe, die das Lösch-Rückfragen betrifft + static func spec() -> Dictionary: return {"titel": "Map-Inhalt", "gruppe": "links", "order": 10} @@ -25,6 +33,34 @@ func build() -> void: content_tree.item_selected.connect(_on_tree_selected) add_child(content_tree) + # U3 (Karte t_a4bf9b8c): Rechtsklick-Kontextmenü + Drag&Drop für Gruppen. + content_tree.gui_input.connect(_on_tree_gui_input) + # Tree.set_drag_forwarding: die drei Virtuals (_get_drag_data/_can_drop_data/ + # _drop_data) wohnen am Tree selbst — hier per Callables weitergeleitet. + content_tree.set_drag_forwarding(_tree_drag_data, _tree_can_drop, _tree_drop) + _context_menu = PopupMenu.new() + _context_menu.hide_on_item_selection = true + _context_menu.id_pressed.connect(_on_menu_action) + add_child(_context_menu) + _rename_dialog = AcceptDialog.new() + _rename_dialog.title = "Gruppe umbenennen" + _rename_dialog.ok_button_text = "Übernehmen" + # Hinweis: AcceptDialog kennt in Godot 4.7 weder cancel_button_text noch + # get_cancel_button() — der Default-Abbrechen-Button bleibt, wie er ist. + _rename_field = LineEdit.new() + _rename_field.placeholder_text = "Gruppenname" + _rename_dialog.add_child(_rename_field) + _rename_dialog.confirmed.connect(_on_rename_confirmed) + add_child(_rename_dialog) + _confirm_delete = ConfirmationDialog.new() + _confirm_delete.title = "Gruppe löschen" + _confirm_delete.ok_button_text = "Löschen" + _confirm_delete.cancel_button_text = "Abbrechen" + _confirm_delete.confirmed.connect(_on_delete_confirmed) + add_child(_confirm_delete) + add_child(_muted_label("Rechtsklick: Gruppieren / Auflösen / Umbenennen / Löschen. Objektzeile in eine Gruppe ziehen = aufnehmen, auf „Objekte“ zurückziehen = herauslösen.")) + add_child(_separator()) + ## Der Baum ruft zurück, wenn der Nutzer dort ein Objekt auswählt. ## SELECT_MULTI (Karte t_c46d31c2): einfacher Klick = Ersatz-Auswahl, @@ -143,6 +179,7 @@ func refresh_tree() -> void: # Objekte: Kategorie → Objekte (Karte t_e3c0310a U2 — z. B. Mauern, Barrieren). var obj_item := content_tree.create_item(root) obj_item.set_text(0, "Objekte (%d)" % objects.size()) + obj_item.set_meta("objekte_kopf", true) # Drag-Ziel „aus Gruppe herauslösen“ (U3) var kat_map: Dictionary = {} for i in objects.size(): var obj = objects[i] @@ -197,10 +234,12 @@ func refresh_tree() -> void: if not group_ids.is_empty(): var grp_item := content_tree.create_item(root) grp_item.set_text(0, "Gruppen (%d)" % group_ids.size()) + grp_item.set_meta("objekte_kopf", true) # Drag-Ziel „herauslösen“ (U3) for gid in group_ids: var member_indices: Array = GroupOps.members_of(main.doc, gid) + # U3 (Karte t_a4bf9b8c): gespeicherter Name voran, Fallback "Gruppe N". var g_node := content_tree.create_item(grp_item) - g_node.set_text(0, "Gruppe %d (%d Objekte)" % [int(gid), member_indices.size()]) + g_node.set_text(0, "%s (%d Objekte)" % [GroupState.display_name(main.doc, int(gid)), member_indices.size()]) g_node.set_meta("group_id", int(gid)) for mi in member_indices: var obj: Dictionary = main.doc["objects"][int(mi)] @@ -217,3 +256,314 @@ static func _kat_label(key: String) -> String: if str(g["key"]) == key: return str(g["label"]) return key.capitalize() + + +# ================= U3: Gruppen im Baum (Karte t_a4bf9b8c) ==================== +# Gruppen direkt im Szenen-Baum bilden: Rechtsklick-Menü (Gruppieren/Auflösen/ +# Umbenennen/Löschen), Drag&Drop für die Mitgliedschaft, Gruppennamen im +# Dokument (GroupState, additiv). Jede Aktion = EIN Undo-Schritt über +# main.history — die Mehrfachauswahl-Logik (interaction.gd) wird benutzt, +# nicht kopiert; object_ops.gd nur LESEND. + + +## Rechtsklick im Baum: Zeile unter der Maus finden → Kontextmenü aufmachen. +func _on_tree_gui_input(event: InputEvent) -> void: + if not (event is InputEventMouseButton): + return + var mb := event as InputEventMouseButton + if not mb.pressed or mb.button_index != MOUSE_BUTTON_RIGHT: + return + var item: TreeItem = content_tree.get_item_at_position(mb.position) + if item == null: + return + # Rechtsklick auf eine (noch nicht markierte) Zeile wählt sie an — wie in + # Unity/Godot: das Menü gehört zur angeklickten Zeile. Direkter Ruf von + # _on_tree_selected (item_selected feuert bei .select() nicht selbst). + if item.has_meta("obj_index") and not item.is_selected(0): + content_tree.deselect_all() + item.select(0) + _on_tree_selected() + _open_context_menu(item, Vector2i(content_tree.get_screen_transform() * mb.position)) + + +## Kontextmenü je Zeilen-Typ aufbauen (Klartext-Einträge, je Aktion eine ID). +func _open_context_menu(item: TreeItem, at_screen: Vector2i) -> void: + _context_menu.clear() + var has_entries := false + # Gruppen-Knoten: Auflösen / Umbenennen / Löschen (Rückfrage bei > 1 Objekt). + if item.has_meta("group_id"): + var gid := int(item.get_meta("group_id")) + _context_menu.add_item("⛓ Gruppe auflösen", 1) + _context_menu.set_item_metadata(_context_menu.item_count - 1, {"aktion": "loesen_gruppe", "gid": gid}) + _context_menu.add_item("✂ Umbenennen…", 2) + _context_menu.set_item_metadata(_context_menu.item_count - 1, {"aktion": "umbenennen", "gid": gid}) + _context_menu.add_item("🗑 Gruppe löschen…", 3) + _context_menu.set_item_metadata(_context_menu.item_count - 1, {"aktion": "loeschen_gruppe", "gid": gid}) + has_entries = true + # Objekt-Zeile: Gruppieren (wenn ≥ 2 markiert), Aus Gruppe lösen, Löschen. + if item.has_meta("obj_index"): + var sel: Array = main.interaction.selected_indices + if sel.size() >= 2: + _context_menu.add_item("🔗 Gruppieren (%d Objekte)" % sel.size(), 10) + _context_menu.set_item_metadata(_context_menu.item_count - 1, {"aktion": "gruppieren"}) + has_entries = true + var objects: Array = main.doc.get("objects", []) + var idx := int(item.get_meta("obj_index")) + if idx >= 0 and idx < objects.size() and GroupOps.group_id(objects[idx]) != GroupOps.UNGROUPED: + _context_menu.add_item("⛓ Aus Gruppe lösen", 1) + _context_menu.set_item_metadata(_context_menu.item_count - 1, {"aktion": "loesen_einzeln", "idx": idx}) + has_entries = true + _context_menu.add_item("🗑 Löschen", 3) + _context_menu.set_item_metadata(_context_menu.item_count - 1, {"aktion": "loeschen_einzeln", "idx": idx}) + has_entries = true + if not has_entries: + return + _context_menu.position = at_screen + _context_menu.popup() + + +## Menü-Auswahl → Aktion (Metadaten tragen Ziel; ID → Zeile über get_item_index, +## robust auch nachdem das Menü sich bei hide_on_item_selection schon versteckt). +func _on_menu_action(id: int) -> void: + var menu_index := _context_menu.get_item_index(id) + if menu_index < 0: + return + var meta: Variant = _context_menu.get_item_metadata(menu_index) + if not (meta is Dictionary): + return + var m := meta as Dictionary + match str(m.get("aktion", "")): + "gruppieren": + group_selected() + "loesen_gruppe": + ungroup_group(int(m.get("gid", -1))) + "loesen_einzeln": + ungroup_indices([int(m.get("idx", -1))]) + "umbenennen": + open_rename_dialog(int(m.get("gid", -1))) + "loeschen_gruppe": + delete_group(int(m.get("gid", -1))) + "loeschen_einzeln": + _select_for_action([int(m.get("idx", -1))]) + main.interaction.delete_selected() + + +## Auswahl für eine Aktion setzen (Baum-Markierung + 3D synchron). +func _select_for_action(indices: Array) -> void: + _tree_select_lock = true + main.interaction.select_many(indices) + _tree_select_lock = false + + +## Mehrfachauswahl → Gruppe (nutzt die bestehende interaction-Logik, EIN Undo). +func group_selected() -> void: + var sel: Array = main.interaction.selected_indices + if sel.size() < 2: + main.call("_set_status", "Gruppieren braucht mindestens 2 markierte Objekte — Strg+Klick im Baum oder im Viewport.", true) + return + main.interaction.group_selection() + _refresh_after_group_change() + _select_for_action(sel) # Gruppe bleibt markiert (Mitglieder hängen jetzt unter dem Gruppen-Knoten) + + +## GANZE Gruppe auflösen: Mitglieder anwählen → bestehende Löse-Logik. +func ungroup_group(gid: int) -> void: + var members: Array = GroupOps.members_of(main.doc, gid) + if members.is_empty(): + main.call("_set_status", "Gruppe %d existiert nicht mehr." % (gid + 1), true) + return + _select_for_action(members) + main.interaction.ungroup_selection() + _refresh_after_group_change() + + +## Objekte aus ihrer Gruppe herauslösen (NUR diese Indizes — Drag-out-Pfad). +## EIN Undo-Schritt: snapshot ["objects", "group_names"] vor/nach (verwaiste +## Namen-Einträge werden im selben Schritt mitgeräumt). +func ungroup_indices(indices: Array) -> void: + var before := _snapshot_group_blocks() + var freed := GroupState.clear_group_fields(main.doc, indices) + if freed == 0: + main.call("_set_status", "Die markierten Objekte stecken in keiner Gruppe.") + return + GroupState.prune_orphans(main.doc) + main.history.commit("Objekt aus Gruppe gelöst (%d)" % freed, + before, _snapshot_group_blocks()) + main.call("_refresh_history_buttons") + _refresh_after_group_change() + + +## Snapshot für Gruppen-Undo-Schritte: ["objects", "group_names"]. Fehlt der +## Namen-Block im Dokument (alte Maps), wird er als null SEEDED — ActionHistory +## wendet nur Schlüssel an, die im Snapshot stehen; ohne Seed bliebe der Name +## nach Undo als Leiche stehen (Restore von null löscht den Block sauber). +func _snapshot_group_blocks() -> Dictionary: + var out: Dictionary = main.history.snapshot(["objects", "group_names"]) + if not out.has(GroupState.NAMES_KEY): + out[GroupState.NAMES_KEY] = null + return out + + +## Umbenennen-Kern (testbar ohne Dialog): Name im Dokument setzen, EIN Undo. +func rename_group(gid: int, new_name: String) -> bool: + if gid < 0: + return false + var clean := new_name.strip_edges() + if GroupState.group_name(main.doc, gid) == clean: + return false # Name unverändert — kein leerer Undo-Eintrag + var before := _snapshot_group_blocks() + GroupState.set_group_name(main.doc, gid, new_name) + GroupState.prune_orphans(main.doc) + var after := _snapshot_group_blocks() + var label := ("Gruppe umbenannt in „%s“" % clean) if not clean.is_empty() \ + else ("Gruppenname entfernt (Gruppe %d)" % (gid + 1)) + main.history.commit(label, before, after) + main.call("_refresh_history_buttons") + _refresh_after_group_change() + return true + + +## Umbenennen-Dialog (Rechtsklick am Gruppen-Knoten). +func open_rename_dialog(gid: int) -> void: + var members: Array = GroupOps.members_of(main.doc, gid) + if members.is_empty(): + main.call("_set_status", "Gruppe %d existiert nicht mehr." % (gid + 1), true) + return + _rename_gid = gid + _rename_field.text = GroupState.group_name(main.doc, gid) + _rename_dialog.dialog_text = "Neuer Name für die Gruppe (leer = wieder „Gruppe %d“):" % (gid + 1) + _rename_field.grab_focus() + _rename_dialog.popup_centered(Vector2i(360, 100)) + + +func _on_rename_confirmed() -> void: + if _rename_gid >= 0: + rename_group(_rename_gid, _rename_field.text) + _rename_gid = -1 + + +## Gruppe löschen: Rückfrage bei > 1 Objekt, dann bestehende Lösch-Logik +## (interaction.delete_selected — EIN Undo-Schritt, Marker/3D räumen mit auf). +func delete_group(gid: int) -> void: + var members: Array = GroupOps.members_of(main.doc, gid) + if members.is_empty(): + main.call("_set_status", "Gruppe %d existiert nicht mehr." % (gid + 1), true) + return + if members.size() > 1: + _confirm_gid = gid + _confirm_delete.dialog_text = "Gruppe „%s“ löschen? Die %d Objekte darin werden ALLE gelöscht — Strg+Z holt sie zurück." % [GroupState.display_name(main.doc, gid), members.size()] + _confirm_delete.popup_centered(Vector2i(480, 140)) + return + delete_group_confirmed(gid) + + +## Lösch-Kern NACH der Rückfrage (auch direkt aufrufbar — Tests). +func delete_group_confirmed(gid: int) -> void: + var members: Array = GroupOps.members_of(main.doc, gid) + if members.is_empty(): + return + _select_for_action(members) + main.interaction.delete_selected() # ruft selbst _after_structure_change → Baum neu + + +func _on_delete_confirmed() -> void: + if _confirm_gid >= 0: + delete_group_confirmed(_confirm_gid) + _confirm_gid = -1 + + +# --- Drag & Drop (Mitgliedschaft ändern) ------------------------------------- + +## Zieh-Menge bestimmen: gezogene Zeile ist Teil der Markierung → die GANZE +## Markierung wandert mit; sonst nur die gezogene Zeile. (Testbar ohne echten +## Drag — _tree_drag_data ruft nur dies + set_drag_preview.) +func _collect_drag_indices(item: TreeItem) -> Array: + if item == null or not item.has_meta("obj_index"): + return [] + var idx := int(item.get_meta("obj_index")) + var sel: Array = main.interaction.selected_indices + if idx in sel: + var out: Array = [] + for i in sel: + if i is int: + out.append(int(i)) + return out + return [idx] + + +## Tree-Drag-Start (set_drag_forwarding-Callable): Datenpaket für Drop-Ziele. +## Vorschau-Control wird NICHT in den Baum gehängt (set_drag_preview verlangt +## ein Control außerhalb des Baums). +func _tree_drag_data(from_position: Vector2) -> Variant: + var item: TreeItem = content_tree.get_item_at_position(from_position) + var indices := _collect_drag_indices(item) + if indices.is_empty(): + return null + var label := Label.new() + label.text = "%d Objekt(e) ziehen" % indices.size() + content_tree.set_drag_preview(label) + return {"svmap_tree_objekte": indices} + + +## Drop erlaubt: AUF einen Gruppen-Knoten (aufnehmen) oder AUF den Objekte-Kopf/ +## eine Kategorie-Zeile (herauslösen — nur wenn mindestens ein gezogenes Objekt +## überhaupt gruppiert ist). +func _tree_can_drop(at_position: Vector2, data: Variant) -> bool: + if not (data is Dictionary) or not (data as Dictionary).has("svmap_tree_objekte"): + return false + var item: TreeItem = content_tree.get_item_at_position(at_position) + if item == null: + return false + if item.has_meta("group_id"): + return true + if item.has_meta("objekte_kopf"): + var objects: Array = main.doc.get("objects", []) + for i in (data["svmap_tree_objekte"] as Array): + if i is int and i >= 0 and i < objects.size() \ + and GroupOps.group_id(objects[i]) != GroupOps.UNGROUPED: + return true + return false + + +## Drop ausführen → Mitgliedschaft ändern (EIN Undo-Schritt), Baum/Auswahl neu. +func _tree_drop(at_position: Vector2, data: Variant) -> void: + var item: TreeItem = content_tree.get_item_at_position(at_position) + if item == null: + return + var indices: Array = (data as Dictionary).get("svmap_tree_objekte", []) + if item.has_meta("group_id"): + _apply_membership_drop(indices, int(item.get_meta("group_id"))) + elif item.has_meta("objekte_kopf"): + _apply_membership_drop(indices, -1) + + +## Mitgliedschafts-Kern (testbar): gid ≥ 0 = aufnehmen, gid < 0 = herauslösen. +func _apply_membership_drop(indices: Array, gid: int) -> void: + var before := _snapshot_group_blocks() + var changed := 0 + var label := "" + if gid >= 0: + changed = GroupState.assign_group(main.doc, indices, gid) + label = "%d Objekt(e) in „%s“ gezogen" % [changed, GroupState.display_name(main.doc, gid)] + else: + changed = GroupState.clear_group_fields(main.doc, indices) + label = "%d Objekt(e) aus der Gruppe herausgelöst" % changed + if changed == 0: + main.call("_set_status", "Nichts zu ändern — die Objekte gehören schon genau so zur Gruppe.") + return + GroupState.prune_orphans(main.doc) + main.history.commit(label, before, _snapshot_group_blocks()) + main.call("_refresh_history_buttons") + _refresh_after_group_change() + _select_for_action(indices) # gezogene Objekte bleiben sichtbar markiert + + +## Nach jeder Gruppen-Änderung: Baum neu + Panels synchron (kein 3D-Rebuild — +## Positionen ändern sich beim Gruppieren/Umbenennen/Zuordnen nicht). +func _refresh_after_group_change() -> void: + main.call("_refresh_tree") + var sel: Array = main.interaction.selected_indices + _tree_select_lock = true + mark_tree_selection_multi(sel) + _tree_select_lock = false + main.call("_sync_selection_panel") diff --git a/tests/probe_u3_groups_render.gd b/tests/probe_u3_groups_render.gd new file mode 100644 index 0000000..1f4a189 --- /dev/null +++ b/tests/probe_u3_groups_render.gd @@ -0,0 +1,97 @@ +extends SceneTree +## Render-Probe U3 (Karte t_a4bf9b8c): Gruppen im Szenen-Baum. +## Legt 4 Objekte an, gruppiert 2 davon über den BAUM-WEG (tree_mod.group_selected), +## benennt die Gruppe „Lager“, hängt Objekt 3 per Drag-Kern in die Gruppe und +## rendert die echte Main-UI: links muss die Gruppe mit Namen + 3 Mitgliedern im +## Baum stehen und in 3D markiert sein. +## Start (BRAUCHT Rendering — NICHT --headless): +## xvfb-run godot --path . --rendering-driver opengl3 -s res://tests/probe_u3_groups_render.gd + +var out_dir := "/opt/data/profiles/scopedvr-dev/cache/scratch" + + +func _initialize() -> void: + if OS.get_cmdline_user_args().size() > 0: + out_dir = OS.get_cmdline_user_args()[0] + # Feste Fenstergröße (sonst fallen die Docks im xvfb-Default zusammen). + root.size = Vector2i(1440, 900) + root.mode = Window.MODE_WINDOWED + await process_frame + await process_frame + + var scene: PackedScene = load("res://scenes/main.tscn") + var main = scene.instantiate() + root.add_child(main) + await process_frame + await process_frame + await process_frame + + # Vier Objekte nah an der Kartenmitte (sichtbar aus der Startansicht). + (main.doc["objects"] as Array).append({ + "id": "barrel_001", "pos": [0.0, 0.0, 0.0], "scale": 3.0, "rot_y_deg": 0.0}) + (main.doc["objects"] as Array).append({ + "id": "crate_wood", "pos": [9.0, 0.0, 5.0], "scale": 20.0, "rot_y_deg": 25.0}) + (main.doc["objects"] as Array).append({ + "id": "barrel_001", "pos": [-9.0, 0.0, -5.0], "scale": 3.0, "rot_y_deg": 0.0}) + (main.doc["objects"] as Array).append({ + "id": "crate_wood", "pos": [-6.0, 0.0, 9.0], "scale": 8.0, "rot_y_deg": 0.0}) + main.call("_after_structure_change") + await process_frame + await process_frame + + # U3-Pfade benutzen — nicht GroupOps direkt: + # 1) Mehrfachauswahl im Baum-Weg → group_selected() (nutzt interaction). + var tree_mod = main.get("tree_mod") + main.interaction.call("select_many", [0, 1]) + tree_mod.call("group_selected") + await process_frame + # 2) Gruppe umbenennen (Kern-Funktion, wie der Dialog es ruft). + tree_mod.call("rename_group", 0, "Lager") + # 3) Objekt 2 per Drag-Kern in die Gruppe ziehen. + tree_mod.call("_apply_membership_drop", [2], 0) + # 4) Gruppe auswählen (Klick auf den Gruppen-Knoten simulieren). + var g_node: TreeItem = _find_group_node(tree_mod, "Lager") + if g_node != null: + g_node.select(0) + (tree_mod.get("content_tree") as Tree).item_selected.emit() + main.call("_focus_selection") + await process_frame + await process_frame + await process_frame + # Hinweis-Dialoge (z. B. „Modelle fehlen“ im Klon ohne GLB-Quellen) schließen — + # REKURSIV über den ganzen Fensterbaum und direkt vor dem Screenshot nochmal + # (das Banner erscheint lazy nach dem View-Rebuild, nicht beim Setup). + _windows_zu(root) + await process_frame + await RenderingServer.frame_post_draw + + var img := root.get_texture().get_image() + var out_path := out_dir + "/u3_gruppen_im_baum_beweis.png" + img.save_png(out_path) + var members: Array = GroupOps.members_of(main.doc, 0) + print("PROBE_PNG: ", out_path) + print("PROBE_FERTIG: Gruppe=", GroupState.display_name(main.doc, 0), + " Mitglieder=", members, + " Baumzeile=", str(g_node.get_text(0)) if g_node != null else "FEHLT", + " Auswahl=", main.interaction.get("selected_indices")) + quit(0) + + +func _find_group_node(tree_mod, name_prefix: String) -> TreeItem: + var tree: Tree = tree_mod.get("content_tree") + var root_item: TreeItem = tree.get_root() + for child in root_item.get_children(): + if str(child.get_text(0)).begins_with("Gruppen ("): + for g in child.get_children(): + if str(g.get_text(0)).begins_with(name_prefix): + return g + return null + + +## Alle Fenster/Dialoge im Unterbaum zu-klappen (Beweis-Bild frei räumen). +func _windows_zu(node: Node) -> void: + if node is Window: + (node as Window).hide() + return # Fenster-Inhalte brauchen nicht weiter durchlaufen zu werden + for child in node.get_children(): + _windows_zu(child) diff --git a/tests/probe_u3_groups_render.gd.uid b/tests/probe_u3_groups_render.gd.uid new file mode 100644 index 0000000..d50e297 --- /dev/null +++ b/tests/probe_u3_groups_render.gd.uid @@ -0,0 +1 @@ +uid://xlxxr8guu4o8 diff --git a/tests/test_scene_groups.gd b/tests/test_scene_groups.gd new file mode 100644 index 0000000..15cb54b --- /dev/null +++ b/tests/test_scene_groups.gd @@ -0,0 +1,342 @@ +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) diff --git a/tests/test_scene_groups.gd.uid b/tests/test_scene_groups.gd.uid new file mode 100644 index 0000000..2bb905f --- /dev/null +++ b/tests/test_scene_groups.gd.uid @@ -0,0 +1 @@ +uid://cb3hbjkkpslcl