OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / outliner_panel_gate.gd

316 lines · gd ref:
extends SceneTree
## THE OUTLINER, IN THE REAL SCENE — hyper-games #28.
##
##     "$HYPER_TPS_GODOT" --headless --script scenes/test/outliner_panel_gate.gd
##
## outliner_model_gate proves the tree over a hand-built level. This proves the
## WIRING, which is where every bug in this area has actually lived (#28 §1 makes
## the same observation about the keys): that the map registers the panel at all,
## that it does so after the import rather than over an empty level, and that the
## rows it produces describe START.MAP rather than nothing.
##
## Registration is the specific thing worth asserting. It happens at the tail of
## _import_from, which is deferred — a panel registered a frame too early builds
## a tree over zero brushes, shows "Unsorted (0)", and never revisits it.

const MAP_SCENE := "res://scenes/maps/map_dc_world.tscn"
const IMPORT_TIMEOUT_MS := 90000

var _map: Node = null
var _failures: Array[String] = []
var _t0 := 0


func _check(label: String, ok: bool, detail: String) -> void:
	if not ok:
		_failures.append("%s (%s)" % [label, detail])
	print("  %-52s %s  %s" % [label, "OK  " if ok else "FAIL", detail])


func _initialize() -> void:
	_t0 = Time.get_ticks_msec()
	var packed: PackedScene = load(MAP_SCENE)
	_map = packed.instantiate()
	root.add_child(_map)


func _process(_delta: float) -> bool:
	var lvl: Node = _map.get("level")
	var ready: bool = lvl != null and is_instance_valid(lvl) \
		and lvl.get_brush_count() > 0
	if not ready:
		if Time.get_ticks_msec() - _t0 > IMPORT_TIMEOUT_MS:
			print("FAIL — the level never imported")
			quit(1)
			return true
		return false

	print("outliner panel gate (START.MAP, %d brushes)" % lvl.get_brush_count())
	# BY PATH, not by the autoload's global name: a `--script` SceneTree gate does
	# not get autoload identifiers at compile time, though the node itself is
	# there (its own log prints during boot). Every gate that needs one is a
	# .tscn for this reason; reaching for the node keeps this one a script.
	var dock: Node = root.get_node_or_null("/root/BuildDock")
	_check("BuildDock exists", dock != null, "/root/BuildDock")
	_check("the map registered the outliner",
		dock != null and dock.has_panel("outliner"), "has_panel(outliner)")
	# RIGHT, alone — the organizer is a standing view and everything else is a
	# tool for the minute. Sharing an edge meant the tree scrolled off as soon as
	# a second panel opened.
	_check("and put it on the RIGHT",
		dock != null and String(dock._panels["outliner"]["side"]) == "right",
		String(dock._panels["outliner"]["side"]) if dock != null else "?")
	var others: Array[String] = []
	# Since the editor redesign E1 the TOP (bar) and BOTTOM (texture strip) regions hold
	# panels too; what must stay true is that the RIGHT column is the organizer's alone.
	for pid: String in dock._panels:
		if pid != "outliner" and String(dock._panels[pid]["side"]) == "right":
			others.append(pid)
	_check("nothing else is on the RIGHT", others.is_empty(),
		"stray: %s" % str(others))
	var panel: Node = _map.get("_outliner")
	_check("and kept a handle to refresh it",
		panel != null and is_instance_valid(panel), "_outliner")
	if panel == null:
		return _finish()

	var rows: Array = panel.model.rows()
	_check("the tree is not empty", rows.size() > 2, "%d rows" % rows.size())

	# THE COUNTS DESCRIBE THE REAL LEVEL. A tree built a frame early would be
	# structurally perfect and entirely about nothing, which no assertion on
	# shape alone would catch.
	var covered := 0
	var names: Array[String] = []
	for r: Dictionary in rows:
		if int(r["kind"]) != OutlinerModel.Kind.NODE:
			covered += int(r["count"])
			names.append("%s(%d)" % [String(r["name"]), int(r["count"])])
	_check("every live brush is in exactly one row",
		covered == lvl.get_brush_count(),
		"%d covered vs %d live" % [covered, lvl.get_brush_count()])
	print("    groups: %s" % ", ".join(names))

	# START.MAP is imported WITH sky brushes (map_dc_level ships that way), so the
	# derived group is the one that proves the tree is reading the real store.
	var sky := 0
	for r: Dictionary in rows:
		if String(r["id"]) == "derived:sky":
			sky = int(r["count"])
	_check("the Sky group found the authored boundary", sky == 19, "%d (expect 19)" % sky)

	# The DCTerrain row exists in the world scene and is headless-freed, so the
	# outliner must not have choked on a node that went away under it.
	var has_terrain := false
	for r: Dictionary in rows:
		if String(r["id"]) == "node:DCTerrain":
			has_terrain = true
	print("    DCTerrain row present: %s (headless frees it, so either is fine)"
		% str(has_terrain))

	# ---- INTERACTION, which is what the first version never tested ---------
	# Registration and row counts both passed while the eye did nothing and G
	# ignored the selection. Shape is not behaviour.

	# (a) SELECTING A GROUP ROW MUST GIVE MoveOperator SOMETHING IT RECOGNISES.
	# select_brushes set _sel_kind = "", and MoveOperator gates its selection
	# branch on _sel_kind == "brush", so G fell through to the crosshair and
	# refused outright when you were not aiming at geometry.
	var grp: Dictionary = {}
	for r: Dictionary in rows:
		if int(r["kind"]) == OutlinerModel.Kind.GROUP \
				and (r["brushes"] as PackedInt32Array).size() > 1:
			grp = r
			break
	if grp.is_empty():
		_check("a multi-brush group row exists to select", false, "none found")
		return _finish()
	var members: PackedInt32Array = grp["brushes"]
	panel._select_in_viewport(grp)
	_check("selecting a group selects every member",
		(_map.get("_sel_brushes") as Array).size() == members.size(),
		"%d of %d" % [(_map.get("_sel_brushes") as Array).size(), members.size()])
	_check("and the kind is one MoveOperator accepts",
		String(_map.get("_sel_kind")) == "brush",
		"'%s'" % String(_map.get("_sel_kind")))
	_check("and the grab point is the selection, not stale",
		(_map.get("_sel_point") as Vector3) != Vector3.ZERO,
		str(_map.get("_sel_point")))

	# THE ACTUAL FAILURE, asserted end to end: G with NOTHING under the crosshair.
	# Headless has no camera, so _crosshair_hit is empty — which is precisely the
	# case that used to refuse.
	var mv := MoveOperator.new()
	mv.map = _map
	mv.level = lvl
	_map.add_child(mv)
	_check("G activates from the outliner selection alone", mv.on_activate(),
		"no crosshair needed")

	# (b) THE EYE MUST BE REACHABLE. It was drawn as text and toggled on
	# `get_selected_column() == COL_EYE` while the column was set unselectable —
	# so nothing could ever reach the toggle. Assert the property whose absence
	# caused that: the cell can actually be interacted with.
	var item: TreeItem = panel._items.get(String(grp["id"]), null)
	_check("the eye cell exists on a group row", item != null, String(grp["id"]))
	if item != null:
		_check("it is a CHECK cell",
			item.get_cell_mode(panel.COL_EYE) == TreeItem.CELL_MODE_CHECK, "check")
		_check("and it is editable — i.e. clickable at all",
			item.is_editable(panel.COL_EYE), "editable")

	# And the toggle does what the box says.
	var hidden_before: int = (lvl.get_hidden_brush_ids() as PackedInt32Array).size()
	panel._toggle(grp)
	var hidden_after: int = (lvl.get_hidden_brush_ids() as PackedInt32Array).size()
	_check("toggling the eye hides the group's brushes",
		hidden_after == hidden_before + members.size(),
		"%d -> %d (+%d expected)" % [hidden_before, hidden_after, members.size()])
	panel._toggle(_row_now(panel, String(grp["id"])))
	_check("and toggling back restores them",
		(lvl.get_hidden_brush_ids() as PackedInt32Array).size() == hidden_before,
		"%d" % (lvl.get_hidden_brush_ids() as PackedInt32Array).size())

	# ---- group transform + select all ------------------------------------
	# The organizer must be able to MOVE a group numerically, not only with G:
	# a drag cannot put something at an exact coordinate.
	panel._select_in_viewport(grp)
	var centre_before: Vector3 = _map.selection_centre()
	_check("the panel reports the selection's centre",
		centre_before != Vector3.ZERO, str(centre_before))
	var moved: int = _map.translate_selection(Vector3(0, 3.0, 0))
	_check("translate_selection moves every selected brush",
		moved == members.size(), "%d of %d" % [moved, members.size()])
	var centre_after: Vector3 = _map.selection_centre()
	_check("and the centre follows by exactly the delta",
		(centre_after - centre_before).is_equal_approx(Vector3(0, 3.0, 0)),
		"%s -> %s" % [str(centre_before), str(centre_after)])
	# Put it back, so the gate leaves the level as it found it.
	_map.translate_selection(Vector3(0, -3.0, 0))
	_check("and it is reversible",
		_map.selection_centre().is_equal_approx(centre_before),
		str(_map.selection_centre()))

	# GRID-RATCHETED SCALE. Typing an extent scales the group, and the result must
	# land on whole grid steps — geometry off the lattice is geometry nothing else
	# will ever align to, which is why the create tool snaps in the first place.
	panel._select_in_viewport(grp)
	var g: float = _map.grid_step()
	var gu: float = float(_map.grid_units())
	var size_before: Vector3 = _map.selection_size()
	# Ask, IN UNITS, for an extent deliberately BETWEEN two grid steps.
	var asked: float = (size_before.x / _map.world_scale()) * 1.5 + gu * 0.37
	# SET THE VALUE AND LET THE SIGNAL FIRE — which is what the UI does. The first
	# version of this set the box with _syncing on and then called the handler by
	# hand, so it proved the handler and never the WIRING. Reported from the game:
	# "we have size bounds of selection but it doesnt actually allow you to change
	# the size", against a gate that was green.
	panel._size[0].value = asked
	var size_after: Vector3 = _map.selection_size()
	var steps: float = size_after.x / g
	_check("the scaled extent lands on a whole grid step",
		absf(steps - roundf(steps)) < 1.0e-3,
		"%.4f m = %.3f steps of %.3f" % [size_after.x, steps, g])
	_check("and it actually changed size", absf(size_after.x - size_before.x) > 1.0e-3,
		"%.3f -> %.3f (asked %.3f)" % [size_before.x, size_after.x, asked])
	_check("the size spinbox steps by the grid",
		absf(panel._size[0].step - gu) < 1.0e-6, "%.1f units" % panel._size[0].step)

	# ---- THE REPORTED SCENARIO: an ARCH group, not the Sky group ----------
	# "I try to enter a number and it reverts back to its current size." The
	# checks above use Sky — 19 brushes spanning 42 m. An arch is 9 brushes
	# spanning ~4 m and includes a CLEARANCE volume, and it is the case that was
	# actually reported. Build one and drive its size box the way the UI does.
	var aop := preload("res://scenes/maps/operators/create_box_operator.gd").new()
	aop.map = _map
	aop.level = lvl
	_map.add_child(aop)
	aop._shape = 2
	aop._size_steps = 19
	aop._normal = Vector3.UP
	aop._axis = 1
	aop._view = Vector3(0, 0, 1)
	aop._origin = Vector3(-47.936, 4.928, 81.312)
	aop._valid = true
	aop.confirm()
	panel.refresh()

	var arch_row: Dictionary = {}
	for r: Dictionary in (panel.model.rows() as Array):
		if String(r["id"]).begins_with("group:"):
			arch_row = r
	_check("the arch appears as a group row", not arch_row.is_empty(),
		String(arch_row.get("name", "none")))
	if not arch_row.is_empty():
		var arow: TreeItem = panel._items.get(String(arch_row["id"]), null)
		if arow != null:
			arow.select(panel.COL_NAME)      # as clicking it does
		panel._select_in_viewport(arch_row)
		var a_before: Vector3 = _map.selection_size()
		print("    arch selection size %s, %d brush(es)"
			% [str(a_before), (_map.get("_sel_brushes") as Array).size()])
		_check("the arch's size is non-zero", a_before.length() > 0.1, str(a_before))
		# THE BOX IS IN QUAKE UNITS, not metres — the vocabulary the create tool
		# and the .MAP already use. Assert that first, or a units/metres mix-up
		# reads as a wildly wrong scale rather than as a unit error.
		var ws: float = _map.world_scale()
		_check("the size box reads in UNITS, not metres",
			absf(panel._size[2].value - a_before.z / ws) < 1.0,
			"box %.1f vs %.1f units (%.3f m)"
			% [panel._size[2].value, a_before.z / ws, a_before.z])
		_check("and one arrow click is one grid step",
			absf(panel._size[2].step - float(_map.grid_units())) < 1e-6,
			"step %.1f, grid %d units" % [panel._size[2].step, _map.grid_units()])
		# Type a number, let the signal fire, exactly as the UI does.
		var target: float = (a_before.z / ws) * 3.0
		panel._size[2].value = target
		var a_after: Vector3 = _map.selection_size()
		print("    asked %.0f units on Z -> %s m   (status: %s)"
			% [target, str(a_after), panel._status.text])
		_check("THE ARCH ACTUALLY RESIZED", a_after.z > a_before.z * 2.0,
			"%.3f -> %.3f" % [a_before.z, a_after.z])
		_check("and the box holds the new size, not the old",
			absf(panel._size[2].value - a_after.z / ws) < 12.0,
			"box %.1f vs actual %.1f units" % [panel._size[2].value, a_after.z / ws])

		# ---- AND IT SURVIVES A FOLD -----------------------------------
		# THE TEST WHOSE ABSENCE SHIPPED THE BUG. Everything above passed while
		# the resize reverted in the game, because nothing here re-folded. A fold
		# rewinds to the base plane sets and replays the journal, so an edit that
		# is not decoded by LevelPersistence._to_record simply ceases to exist —
		# and brush.scale had no case there, so records() dropped it and the
		# geometry snapped back. That is what "I enter a number and it reverts"
		# actually was.
		var persist: Node = _map.get("_persist")
		persist.refold()
		var a_folded: Vector3 = _map.selection_size()
		_check("THE RESIZE SURVIVES A JOURNAL FOLD",
			absf(a_folded.z - a_after.z) < 0.3,
			"%.3f -> %.3f after refold" % [a_after.z, a_folded.z])

	_map.select_all_brushes()
	_check("Select all selects every live brush",
		(_map.get("_sel_brushes") as Array).size() == lvl.get_brush_count(),
		"%d of %d" % [(_map.get("_sel_brushes") as Array).size(), lvl.get_brush_count()])
	_check("and it is still a kind G accepts",
		String(_map.get("_sel_kind")) == "brush", String(_map.get("_sel_kind")))

	return _finish()


## The row as the model reports it NOW — _toggle reads self_hidden, so passing
## the stale Dictionary back would toggle in the same direction twice.
func _row_now(panel: Node, id: String) -> Dictionary:
	for r: Dictionary in (panel.model.rows() as Array):
		if String(r["id"]) == id:
			return r
	return {}


func _finish() -> bool:
	print("\nRESULT: %s" % ("PASS" if _failures.is_empty() else "FAIL"))
	for f: String in _failures:
		print("  - %s" % f)
	quit(0 if _failures.is_empty() else 1)
	return true