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