OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_outliner_sync.gd

343 lines · gd ref:
extends Node
## GATE — THE OUTLINER FOLLOWS THE VIEWPORT (#120 outliner audit, 2026-10-17).
## The tree's selection must never disagree with the map's: Delete and the fields act
## on the tree's rows, so a row the viewport has moved away from is a row that deletes
## the wrong thing.
##   S1  click a group row, then click a wall in the viewport: the group row is no
##       longer selected, and the outliner's Delete does NOT delete the whole group
##       (audit bug 1: _on_map_selection followed objects only, P:96-97)
##   S2  the row's OWN selection survives a refresh (every fold rebuilds the tree)
##   S3  EVERY row that covers something selects exactly that in the viewport
##       (user 2026-10-17: "selecting something in the organizer panel does not actually
##       select it in the 3D view"): an object row its object, a shape row its shape, a
##       group / collection / brush row its brushes
##   S4  Ctrl+click on a selected group row DESELECTS it: nothing stays selected in the
##       viewport. Godot leaves the Tree's cursor on the row just deselected
##       (tree.cpp item_deselected, SELECT_MULTI), and the panel read the cursor.
##   S5  Ctrl+click one of two selected object rows leaves exactly the other
##   S6  an object row, then Ctrl+click a group row: the map holds objects OR brushes,
##       so the group wins; the tree must show the group row, not go blank
##   S7  clicking a row's EYE hides it and does NOT select it (Blender's eye never
##       selects). Tree selects a selectable cell BEFORE it toggles a check cell
##       (tree.cpp, the `c.selectable` block runs first), so the eye cell must not be one.
##   S8  clicking a row that covers nothing (the level's own row) clears the viewport,
##       so the tree and the map never name different things
##   S9  VIEWPORT -> TREE (audit bug 4): a viewport click on a plain brush lights that
##       brush's row (its group opened); a click on a shape's piece lights the shape row
##
## RUN AS A SCENE: "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_outliner_sync.tscn

var fails := 0
var checks := 0
var _cam: Camera3D = null


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")
	var map: Node3D = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
	add_child(map)
	_cam = Camera3D.new()
	add_child(_cam)
	_cam.current = true
	for i in 6:
		await get_tree().process_frame
	await _run(map)
	get_tree().root.get_node("/root/GameMode").set_mode("play")
	if checks < 25:
		print("  **FAIL** only %d checks ran -- setup died before the assertions" % checks)
		fails += 1
	print("\nGATE outliner_sync: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
	get_tree().quit(0 if fails == 0 else 1)


func _frames(n: int) -> void:
	for i in n:
		await get_tree().process_frame


## A row CLICK: in SELECT_MULTI a programmatic select() emits nothing
## (scene/gui/tree.cpp Tree::item_selected); the mouse path emits multi_selected.
func _click_row(panel: Node, id: String) -> void:
	var tree: Tree = panel.get("_tree")
	var it: TreeItem = (panel.get("_items") as Dictionary).get(id, null)
	tree.deselect_all()
	if it != null:
		it.select(0)
		tree.multi_selected.emit(it, 0, true)
	await _frames(3)


func _selected_ids(panel: Node) -> Array:
	var out: Array = []
	var tree: Tree = panel.get("_tree")
	var it := tree.get_next_selected(null)
	while it != null:
		var r: Variant = it.get_metadata(0)
		if r is Dictionary:
			out.append(String((r as Dictionary)["id"]))
		it = tree.get_next_selected(it)
	return out


## A VIEWPORT click on brush `b`, through the map's own pick (gate_shape_parts' way):
## aim straight down at the top of its bounds and click the screen centre.
func _click_brush(map: Node3D, b: int) -> void:
	map.select_brushes(PackedInt32Array([b]))
	var c: Vector3 = map.selection_centre()
	var top: Vector3 = c + Vector3(0, map.selection_size().y * 0.5, 0)
	map.clear_selection()
	_cam.global_position = top + Vector3(0, 0.5, 0.01)
	_cam.look_at(top, Vector3.UP)
	await _frames(2)
	map._pick_element(false, get_viewport().get_visible_rect().size * 0.5)
	await _frames(3)


func _run(map: Node3D) -> void:
	var panel: Node = map.get("_outliner")
	var level: Node = map.get("level")
	_ok("map_dev_test has an Outliner", panel != null)
	if panel == null:
		return
	panel.refresh()
	var rest: Dictionary = {}
	for r: Dictionary in panel.model.rows():
		if String(r["id"]) == "derived:rest":
			rest = r
	var members: PackedInt32Array = rest.get("brushes", PackedInt32Array())
	_ok("setup: an Unsorted group with 2+ brushes", members.size() >= 2, members.size())
	if members.size() < 2:
		return

	# ---- S2 first: our own row click, then a refresh ----
	await _click_row(panel, "derived:rest")
	_ok("setup: clicking the group row selects its brushes",
		(map.get("_sel_brushes") as Array).size() == members.size(), (map.get("_sel_brushes") as Array).size())
	panel.refresh()
	await _frames(3)
	_ok("S2 the row's own selection survives a refresh", _selected_ids(panel) == ["derived:rest"], _selected_ids(panel))

	# ---- S1 then a viewport click elsewhere ----
	await _click_brush(map, members[0])
	var sel: Array = map.get("_sel_brushes")
	_ok("setup: the viewport click picked something smaller than the group",
		sel.size() >= 1 and sel.size() < members.size() or String(map.get("_sel_shape")) != "", sel)
	_ok("S1 the group row is no longer selected", not _selected_ids(panel).has("derived:rest"), _selected_ids(panel))
	var live0: int = (level.get_live_brush_ids() as PackedInt32Array).size()
	panel.delete_selected()
	await _frames(2)
	var live1: int = (level.get_live_brush_ids() as PackedInt32Array).size()
	_ok("S1 the outliner's Delete does not delete the whole group", live1 >= live0 - 1,
		"%d -> %d (group of %d)" % [live0, live1, members.size()])
	if live1 < live0:                 # it deleted the one brush the tree now names
		map.get("_persist").undo_last()
		map.get("_persist").refold()
		panel.refresh()
		await _frames(2)

	# ---- S3 every row ----
	var shape: String = map.get("_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})
	map.get("_persist").refold()
	# A room collection (#120 F1) holding the shape and a spawn point.
	var cdot: String = CollectionOps.create_empty(map, "S3")
	map.get("_persist").begin_gesture("Link")
	map.get("_persist").record("col.link", {"target": cdot, "members": ["g:" + shape, "obj:scene:SpawnPoint1"]})
	map.get("_persist").end_gesture()
	map.get("_persist").refold()
	var cid := "col:" + cdot
	panel.refresh()
	await _frames(2)
	var bad: Array = []
	var tried := 0
	var kinds := {}
	for r: Dictionary in panel.model.rows():
		var want := _expect(r)
		if want == "":
			continue
		tried += 1
		kinds[want.get_slice(":", 0) + ("/col" if String(r["id"]) == cid else "")] = true
		await _click_row(panel, String(r["id"]))
		var got := _got(map)
		if got != want:
			bad.append("%s: want %s got %s" % [r["id"], want, got])
	_ok("S3 rows tried include an object, a shape, a group and the collection",
		kinds.has("obj") and kinds.has("shape") and kinds.has("brushes") and kinds.has("brushes/col"), "%d rows %s" % [tried, str(kinds.keys())])
	_ok("S3 every row selects what it covers", bad.is_empty(), "\n      ".join(bad))

	# ---- S4 Ctrl+click deselect of a group row ----
	panel.refresh()
	await _click_row(panel, "derived:rest")
	_ok("setup: the group is selected", _got(map).begins_with("brushes:"), _got(map))
	await _ctrl_click_off(panel, "derived:rest")
	_ok("S4 Ctrl+click deselects the group in the viewport", _got(map) == "nothing", _got(map))
	_ok("S4 ... and in the tree", _selected_ids(panel).is_empty(), _selected_ids(panel))
	panel.refresh()
	await _frames(2)
	_ok("S4 ... and a refresh does not bring the row back", _selected_ids(panel).is_empty(), _selected_ids(panel))

	# ---- S5 Ctrl+click one of two object rows ----
	var two: Array = []
	for r: Dictionary in panel.model.rows():
		if String(r.get("object_dot", "")) != "" and two.size() < 2:
			two.append(r)
	if two.size() == 2:
		await _click_row(panel, String(two[0]["id"]))
		var it2: TreeItem = (panel.get("_items") as Dictionary)[String(two[1]["id"])]
		var tree: Tree = panel.get("_tree")
		it2.select(0)
		tree.multi_selected.emit(it2, 0, true)
		await _frames(3)
		_ok("setup: two objects selected", map.selected_objects().size() == 2, map.selected_objects())
		await _ctrl_click_off(panel, String(two[1]["id"]))
		_ok("S5 Ctrl+click leaves exactly the other object", map.selected_objects() == [String(two[0]["object_dot"])],
			map.selected_objects())


	# ---- S6 object row + Ctrl+click a group row ----
	panel.refresh()
	if two.size() >= 1:
		await _click_row(panel, String(two[0]["id"]))
		var git: TreeItem = (panel.get("_items") as Dictionary).get("derived:rest", null)
		var tree6: Tree = panel.get("_tree")
		git.select(0)
		tree6.multi_selected.emit(git, 0, true)
		await _frames(4)
		_ok("S6 the viewport holds the group", _got(map).begins_with("brushes:"), _got(map))
		_ok("S6 the tree shows the group row (only)", _selected_ids(panel) == ["derived:rest"], _selected_ids(panel))


	# ---- S7 a real mouse click on a row's eye ----
	panel.refresh()
	await _frames(2)
	map.clear_selection()
	await _frames(3)
	# The headless window is 64 x 64: a click at the panel's real position would land
	# outside it (the dock puts the Tree ~1100 px down). Size the root so the event reaches it.
	get_tree().root.size = Vector2i(1920, 2400)
	# The organizer is an AREA since #124 B1, 380 px tall by default: give it the room to
	# show the row, or the row sits below its visible part (a player scrolls; a gate sizes).
	var dock: Node = get_tree().root.get_node("/root/BuildDock")
	dock.set_pref("area.organizer", 1600)
	dock.call("_apply_sizes")
	await _frames(3)
	var tree7: Tree = panel.get("_tree")
	var it7: TreeItem = (panel.get("_items") as Dictionary).get("derived:rest", null)
	tree7.scroll_to_item(it7)
	await _frames(2)
	var rect := tree7.get_item_area_rect(it7, 1)
	print("  (S7 root %s, tree at %s size %s, eye rect %s)" % [get_tree().root.size, tree7.global_position, tree7.size, rect])
	var local := rect.position + Vector2(6, rect.size.y * 0.5)
	var at: Vector2 = tree7.get_global_transform() * local
	var under: TreeItem = tree7.get_item_at_position(local)
	print("  (S7 under the click: %s col %d; eye checked %s, selectable %s)" % [
		under.get_text(0) if under != null else "none", tree7.get_column_at_position(local),
		it7.is_checked(1), it7.is_selectable(1)])
	var mm := InputEventMouseMotion.new()
	mm.position = at
	mm.global_position = at
	get_viewport().push_input(mm)
	await _frames(1)
	print("  (S7 the control under %s: %s)" % [at, get_viewport().gui_get_hovered_control()])
	for pressed: bool in [true, false]:
		var mb := InputEventMouseButton.new()
		mb.button_index = MOUSE_BUTTON_LEFT
		mb.pressed = pressed
		mb.position = at
		mb.global_position = at
		get_viewport().push_input(mb)
		await _frames(2)
	await _frames(3)
	var hidden7: bool = panel.model.hidden_rows.has("derived:rest")
	_ok("S7 the eye click hid the group", hidden7, panel.model.hidden_rows.keys())
	_ok("S7 ... and selected nothing", _got(map) == "nothing" and _selected_ids(panel).is_empty(),
		"%s %s" % [_got(map), _selected_ids(panel)])
	# Leave the saved outliner as found: the eye click SAVED it (model.save) with this
	# gate's hidden row in it. (Collections are journal records now, not saved here.)
	panel.model.hidden_rows.erase("derived:rest")
	panel.model.apply()
	panel.model.save(String(panel.get("_save_key")))


	# ---- S8 a row that covers nothing ----
	panel.refresh()
	await _click_row(panel, "derived:rest")
	_ok("setup: the group is selected again", _got(map).begins_with("brushes:"), _got(map))
	await _click_row(panel, "node:DCLevel")
	_ok("S8 the level's row clears the viewport selection", _got(map) == "nothing", _got(map))
	_ok("S8 ... and the tree shows that row", _selected_ids(panel) == ["node:DCLevel"], _selected_ids(panel))


	# ---- S9 viewport -> tree ----
	panel.refresh()
	var rest9: Array = []
	for r: Dictionary in panel.model.rows():
		if String(r["id"]) == "derived:rest":
			rest9 = Array(r["brushes"] as PackedInt32Array)
	await _click_brush(map, int(rest9[0]))
	var picked: Array = map.get("_sel_brushes")
	var key9 := String(level.get_brush_key(int(picked[0]))) if picked.size() == 1 else "?"
	_ok("S9 a viewport click on a brush lights exactly its brush row",
		_selected_ids(panel).size() == 1 and String(_selected_ids(panel)[0]).ends_with("/brush:" + key9),
		"%s for %s" % [_selected_ids(panel), key9])
	var piece: int = (level.get_group_brush_ids(shape) as PackedInt32Array)[0]
	map.set_select_mode("brush")            # brush mode picks the SHAPE (face mode picks a part)
	await _click_brush(map, piece)
	_ok("setup: the click selected the shape", String(map.get("_sel_shape")) == shape,
		"%s (mode %s, piece %d of shape %s)" % [_got(map), map.get("_sel_mode"), piece, shape])
	# The shape's row is wherever it is LISTED: in its collection since the organizer is
	# one tree (#124 B4), not also as "group:<shape>".
	var shape_row := String(panel.call("_group_id", shape))
	_ok("S9 a viewport click on a shape lights the shape's row", _selected_ids(panel) == [shape_row],
		[_selected_ids(panel), shape_row])
	map.set_select_mode("face")             # (a mode switch clears the selection)


## Ctrl+click on a selected row, as Tree does it (tree.cpp, SELECT_MULTI + command):
## TreeItem.deselect, which moves the Tree's cursor ONTO that row, then multi_selected false.
func _ctrl_click_off(panel: Node, id: String) -> void:
	var tree: Tree = panel.get("_tree")
	var it: TreeItem = (panel.get("_items") as Dictionary).get(id, null)
	if it != null:
		it.deselect(0)
		tree.multi_selected.emit(it, 0, false)
	await _frames(3)


## What a click on row `r` must leave selected, in _got's terms; "" = covers nothing.
func _expect(r: Dictionary) -> String:
	if String(r.get("object_dot", "")) != "":
		return "obj:" + String(r["object_dot"])
	if String(r.get("shape_dot", "")) != "":
		return "shape:" + String(r["shape_dot"])
	var ids: PackedInt32Array = r.get("brushes", PackedInt32Array())
	if ids.is_empty():
		return ""
	var a: Array = Array(ids)
	a.sort()
	return "brushes:" + str(a)


func _got(map: Node3D) -> String:
	var objs: Array = map.selected_objects()
	if objs.size() == 1:
		return "obj:" + String(objs[0])
	if String(map.get("_sel_shape")) != "":
		return "shape:" + String(map.get("_sel_shape"))
	var a: Array = (map.get("_sel_brushes") as Array).duplicate()
	a.sort()
	return "brushes:" + str(a) if not a.is_empty() else "nothing"