OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_collection_edit.gd

258 lines · gd ref:
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())