extends Node
## GATE — EDITING COLLECTIONS (#120 F1, docs/node-tree-and-stacks.md §2.4). Every action
## is one gesture (one Ctrl+Z), and the outliner shows the room's tree.
## E1 the panel's + Collection makes a journalled collection row; Ctrl+Z removes it
## E2 from the selection (a shape): the collection holds it, its row lists the shape
## E3 Shift+M links the shape into a second collection too: a row under both
## E4 M moves it: out of every other collection, into the one picked
## E5 Ctrl+Alt+G takes it out of one collection only
## E6 rename (col.set) — what the panel's _on_item_edited calls with the row's text; a
## headless Tree cannot fake the edit itself
## E7 X on a collection: its child collection and members move up; Ctrl+Z restores
## E8 Delete Hierarchy: an object only in the subtree is deleted, one also linked
## elsewhere survives; one Ctrl+Z brings everything back
## E9 dragging a member row onto a collection row MOVES it there; Ctrl+drag links
## E10 dragging a collection row onto another nests it; onto the map row, top level
## E11 an object row dragged onto a collection becomes its member
## E12 a derived row (Unsorted) carries nothing to drop
## E13 F2 opens the in-place editor on a collection row, and refuses other rows
## E14 hiding a collection row hides its members' brushes (inherited); unhiding
## brings them back
##
## RUN AS A SCENE: "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_collection_edit.tscn
var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var panel: Node
func _ok(what: String, cond: bool, note: Variant = "") -> void:
checks += 1
if not cond:
fails += 1
print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
func _ready() -> void:
var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
gs.route("world:level:*", 1, "peer")
var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
if keet != null:
keet.current_member_id = "gate-author"
get_tree().root.get_node("/root/GameMode").set_mode("build")
map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
add_child(map)
for i in 6:
await get_tree().process_frame
await _run()
get_tree().root.get_node("/root/GameMode").set_mode("play")
if checks < 33:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE collection_edit: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
func _fold() -> void:
persist.refold()
panel.refresh()
await get_tree().process_frame
func _tree() -> Dictionary:
return persist.collection_tree()["cols"]
func _rows() -> Dictionary:
return panel.get("_items")
func _members(col: String) -> Array:
var t := _tree()
return t[col]["members"] if t.has(col) else []
func _select_row(id: String) -> void:
var tree: Tree = panel.get("_tree")
tree.deselect_all()
var it: TreeItem = _rows().get(id, null)
if it != null:
it.select(0)
func _run() -> void:
persist = map.get("_persist")
panel = map.get("_outliner")
_ok("setup: journal and outliner", persist != null and panel != null)
if persist == null or panel == null:
return
var shape: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, Vector3(20, 0, 20))),
"size": [2, 1, 2], "material_id": 2, "level_id": 0})
await _fold()
# ---- E1 ----
var n0: int = (persist.gestures() as Array).size()
panel.call("_on_new_collection")
# Named first since 2026-10-18 (the prompt): confirm it.
var q: Node = map.get("_q_menu")
if q != null and q.name_open():
q.submit_name("Collection")
await get_tree().process_frame
await get_tree().process_frame
await get_tree().process_frame
await _fold()
var made: Array = _tree().keys()
_ok("E1 + Collection makes one collection", made.size() == 1, made)
var a: String = made[0] if made.size() == 1 else ""
_ok("E1 ... as one gesture", (persist.gestures() as Array).size() == n0 + 1)
_ok("E1 ... and its row is in the outliner", _rows().has("col:" + a), "col:" + a)
persist.undo_last()
await _fold()
_ok("E1 Ctrl+Z removes it", _tree().is_empty() and not _rows().has("col:" + a))
# ---- E2 ----
map.select_shape(shape)
a = CollectionOps.create_from_selection(map, "A")
await _fold()
_ok("E2 the new collection holds the shape", _members(a) == ["g:" + shape], _members(a))
_ok("E2 its row lists the shape", _rows().has("col:%s/g:%s" % [a, shape]))
# ---- E3 ----
var b: String = CollectionOps.create_empty(map, "B")
map.select_shape(shape)
CollectionOps.link_selection(map, b)
await _fold()
_ok("E3 Shift+M: the shape is in A and in B", _members(a).has("g:" + shape) and _members(b).has("g:" + shape))
_ok("E3 ... a row under each", _rows().has("col:%s/g:%s" % [a, shape]) and _rows().has("col:%s/g:%s" % [b, shape]))
# ---- E4 ----
var c: String = CollectionOps.create_empty(map, "C")
map.select_shape(shape)
CollectionOps.move_selection(map, c)
await _fold()
_ok("E4 M: the shape is only in C now", _members(c) == ["g:" + shape] and not _members(a).has("g:" + shape)
and not _members(b).has("g:" + shape), [_members(a), _members(b), _members(c)])
# ---- E5 ----
map.select_shape(shape)
CollectionOps.link_selection(map, a)
map.select_shape(shape)
CollectionOps.unlink_selection(map, c)
await _fold()
_ok("E5 Ctrl+Alt+G takes it out of C only", _members(a).has("g:" + shape) and not _members(c).has("g:" + shape))
# ---- E6 ----
CollectionOps.rename(map, a, "Hall")
await _fold()
_ok("E6 the collection is renamed", String(_tree()[a]["name"]) == "Hall", _tree()[a]["name"])
_ok("E6 ... and its row says so", (_rows().get("col:" + a) as TreeItem).get_text(0).begins_with("Hall"))
# ---- E7 ----
# Nest B under A, with a spawn point in B; delete A.
CollectionOps.reparent(map, b, a)
persist.begin_gesture("Link")
persist.record("col.link", {"target": b, "members": ["obj:scene:SpawnPoint1"]})
persist.end_gesture()
await _fold()
_select_row("col:" + a)
panel.delete_selected()
await _fold()
var t7 := _tree()
_ok("E7 X on A: A is gone", not t7.has(a))
_ok("E7 ... its child B moved up to the top", t7.has(b) and String(t7[b]["parent"]) == "", t7.get(b, {}))
_ok("E7 ... nothing was deleted", map.object_node("scene:SpawnPoint1") != null
and (persist.live_shapes() as Dictionary).has(shape))
persist.undo_last()
await _fold()
_ok("E7 Ctrl+Z: A is back with B under it", _tree().has(a) and String(_tree()[b]["parent"]) == a)
# ---- E8 ----
# The shape is in A and (linked) in C; the spawn point only in B under A.
map.select_shape(shape)
CollectionOps.link_selection(map, c)
await _fold()
CollectionOps.remove(map, a, true)
await _fold()
_ok("E8 Delete Hierarchy: A and B are gone", not _tree().has(a) and not _tree().has(b))
_ok("E8 ... the spawn point (only in B) is deleted", map.object_node("scene:SpawnPoint1") == null)
_ok("E8 ... the shape (also in C) survives", (persist.live_shapes() as Dictionary).has(shape))
persist.undo_last()
await _fold()
_ok("E8 one Ctrl+Z brings A, B and the spawn point back", _tree().has(a) and _tree().has(b)
and map.object_node("scene:SpawnPoint1") != null)
# ---- E9 ---- (the shape is in A and C now)
await _fold()
_select_row("col:%s/g:%s" % [c, shape])
var data: Dictionary = panel.drag_data_for_selection()
_ok("E9 a member row carries its member", data["members"] == ["g:" + shape], data)
panel.drop_on("col:" + b, data, false)
await _fold()
_ok("E9 drop on B moves it: only in B", _members(b).has("g:" + shape) and not _members(a).has("g:" + shape)
and not _members(c).has("g:" + shape), [_members(a), _members(b), _members(c)])
_select_row("col:%s/g:%s" % [b, shape])
panel.drop_on("col:" + c, panel.drag_data_for_selection(), true)
await _fold()
_ok("E9 Ctrl+drop on C links it: in B and C", _members(b).has("g:" + shape) and _members(c).has("g:" + shape))
# ---- E10 ----
_select_row("col:" + c)
panel.drop_on("col:" + b, panel.drag_data_for_selection(), false)
await _fold()
_ok("E10 C dropped on B nests under B", String(_tree()[c]["parent"]) == b, _tree()[c]["parent"])
_select_row("col:" + c)
panel.drop_on("node:.", panel.drag_data_for_selection(), false)
await _fold()
_ok("E10 ... and on the map row goes back to the top", String(_tree()[c]["parent"]) == "", _tree()[c]["parent"])
_select_row("col:" + b)
_ok("E10 a collection cannot be dropped on itself", not panel.can_drop_on("col:" + b, panel.drag_data_for_selection()))
# ---- E11 ----
_select_row("obj:scene:SpawnPoint2")
panel.drop_on("col:" + c, panel.drag_data_for_selection(), false)
await _fold()
_ok("E11 an object row dropped on C is C's member", _members(c).has("obj:scene:SpawnPoint2"), _members(c))
# ---- E12 ----
_select_row("derived:rest")
var dr: Dictionary = panel.drag_data_for_selection()
_ok("E12 Unsorted carries nothing", (dr["members"] as Array).is_empty() and (dr["cols"] as Array).is_empty()
and (dr["object_dots"] as Array).is_empty(), dr)
# ---- E13 ----
_select_row("col:" + c)
var tree: Tree = panel.get("_tree")
# The in-place editor is up: Tree::is_editing() is popup_editor->is_visible()
# (tree.cpp), not bound to GDScript, so ask the internal Popup itself. get_edited()
# is only set when the edit commits.
var started: bool = panel.rename_selected()
var editing := false
for ch: Node in tree.get_children(true):
if ch is Popup and (ch as Popup).visible:
editing = true
_ok("E13 F2 opens the editor on a collection row", started and editing, [started, editing])
_select_row("obj:scene:SpawnPoint2")
_ok("E13 ... and refuses an object row", not panel.rename_selected())
# ---- E14 ---- (the shape is in B and C)
var level: Node = map.get("level")
var pieces: PackedInt32Array = level.get_group_brush_ids(shape)
panel.model.set_row_hidden("col:" + b, true)
panel.model.apply()
await _fold()
var hid: PackedInt32Array = level.get_hidden_brush_ids()
var mrow: Dictionary = {}
for r: Dictionary in panel.model.rows():
if String(r["id"]) == "col:%s/g:%s" % [b, shape]:
mrow = r
_ok("E14 hiding B hides the shape's row (inherited, not its own)", bool(mrow.get("hidden", false))
and not bool(mrow.get("self_hidden", true)), mrow.get("hidden"))
_ok("E14 ... and its brushes in the level", Array(pieces).all(func(x: int) -> bool: return hid.has(x)), [pieces, hid])
panel.model.set_row_hidden("col:" + b, false)
panel.model.apply()
panel.model.save(String(panel.get("_save_key"))) # leave the saved view as found
_ok("E14 unhiding brings them back", (level.get_hidden_brush_ids() as PackedInt32Array).is_empty())