OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_script_host.gd

169 lines · gd ref:
extends SceneTree
## Gate: ScriptHost compiles, runs, replaces and unloads a runtime GDScript.
##
## Pure: no room, no peer, no mesh. Same reason SkyDefaults and TerrainEditOrder
## are their own files -- the rule should be assertable without infrastructure.

var ok := true

func _p(pass_: bool, msg: String) -> void:
	if pass_:
		print("ok   %s" % msg)
	else:
		print("FAIL: %s" % msg)
		ok = false


func _initialize() -> void:
	var Host: GDScript = load("res://autoloads/script_host.gd")
	var host: Node = Host.new()
	host.name = "ScriptHost"
	# Stub AssetSync: the arrival path is testable with no overlay, room or mesh.
	# Injected BEFORE add_child, because _ready is where the signal is connected.
	var StubGD := GDScript.new()
	StubGD.source_code = """
extends Node
signal asset_ready(kind: String, name: String, path: String)
var ensure_returns := ""
var published := []
func ensure(_kind: String, _name: String) -> String:
	return ensure_returns
func publish_file(kind: String, name: String, path: String, ext: String) -> String:
	published.append([kind, name, path, ext])
	return "oid-fake"
"""
	StubGD.reload()
	var stub: Node = StubGD.new()
	host.sync = stub
	root.add_child(stub)
	# In the tree so _process runs -- the tick path is part of what is under test.
	root.add_child(host)

	var failures: Array[String] = []
	host.script_failed.connect(func(n, r): failures.append("%s: %s" % [n, r]))

	# 1. compile + run, entry point fires
	var src1 := """
extends RefCounted
var ran := false
var ticks := 0
func _bot_init() -> void:
	ran = true
func value() -> int:
	return 41
"""
	var inst = host.run(src1, "probe")
	_p(inst != null, "run() returned an instance")
	if inst != null:
		_p(inst.ran == true, "_bot_init was called")
		_p(inst.value() == 41, "method callable, returned %s" % str(inst.value()))
	_p(host.is_loaded("probe"), "is_loaded after run")

	# 2. THE UPDATE PATH. Re-running the same NAME with NEW source must run the
	# NEW code. This is why compile() uses source_code instead of load(): load()
	# caches by path, so the second version of an edited script would silently
	# run as the first -- and "edit the file and it propagates" is the entire
	# point of the feature.
	var src2 := src1.replace("return 41", "return 42")
	var inst2 = host.run(src2, "probe")
	_p(inst2 != null and inst2.value() == 42,
			"re-run same name with new source -> %s (expect 42, NOT a cached 41)"
			% (str(inst2.value()) if inst2 != null else "null"))

	# 3. a broken script fails gracefully rather than taking the process down
	var before := failures.size()
	var bad = host.run("extends RefCounted\nthis is not gdscript\n", "broken")
	_p(bad == null, "invalid source returns null")
	_p(not host.is_loaded("broken"), "invalid source is not held")
	_p(failures.size() > before, "script_failed emitted (%d)" % failures.size())

	# 4. the world is reachable from a script -- this is what "alter the gamestate"
	# rests on. Asserting VISIBILITY, not a durable write: a write needs a room.
	var src3 := """
extends RefCounted
var saw_gamestate := false
func _bot_init() -> void:
	saw_gamestate = Engine.get_main_loop().root.get_node_or_null("/root/GameState") != null
"""
	var inst3 = host.run(src3, "world")
	_p(inst3 != null, "world-access script ran")

	# 5. unload calls _bot_exit and drops it
	var src4 := """
extends RefCounted
static var exited := false
func _bot_exit() -> void:
	exited = true
"""
	host.run(src4, "exiting")
	host.unload("exiting")
	_p(not host.is_loaded("exiting"), "unload drops the script")

	# 6. tick runs for a script that implements it
	var src5 := """
extends RefCounted
var ticks := 0
func _bot_tick(_d: float) -> void:
	ticks += 1
"""
	var t = host.run(src5, "ticker")
	await process_frame
	await process_frame
	_p(t != null and t.ticks > 0, "_bot_tick ran (%d tick(s))" % (t.ticks if t != null else -1))

	# ---- arrival path -------------------------------------------------------
	# Write a real script to disk; run_file reads bytes, so a fake path will not do.
	var spath := "user://gate_arrived.gd"
	var wf := FileAccess.open(spath, FileAccess.WRITE)
	wf.store_string("extends RefCounted\nstatic var went := false\nfunc _bot_init() -> void:\n\twent = true\n")
	wf.close()

	# 7. ARRIVAL DOES NOT RUN. This is the security property, not a detail.
	stub.asset_ready.emit("script", "arrived", spath)
	await process_frame
	_p(host.available_path("arrived") == spath, "arrival recorded as available")
	_p(not host.is_loaded("arrived"), "arrival did NOT auto-run it")

	# 8. a non-script asset is ignored entirely
	stub.asset_ready.emit("tile", "some_tile", spath)
	await process_frame
	_p(host.available_path("some_tile") == "", "non-script kind ignored")

	# 9. sync_and_run on something not local -> pending, not a failure
	stub.ensure_returns = ""
	var immediate: bool = host.sync_and_run("later")
	_p(immediate == false, "sync_and_run returns false while pending")
	_p(not host.is_loaded("later"), "pending script is not loaded yet")
	# ...and it runs when the bytes turn up
	stub.asset_ready.emit("script", "later", spath)
	await process_frame
	_p(host.is_loaded("later"), "pending script ran on arrival")

	# 10. sync_and_run when the bytes are already local runs immediately
	stub.ensure_returns = spath
	_p(host.sync_and_run("now") == true, "sync_and_run runs immediately when local")

	# 11. publish goes through AssetSync with the mutable script kind
	host.publish(spath, "pub")
	_p(stub.published.size() == 1 and stub.published[0][0] == "script"
			and stub.published[0][3] == "gd",
			"publish -> AssetSync kind=script ext=gd (%s)" % str(stub.published))

	# 12. reachable from admin-cli, or the autoload is unreachable dead code
	_p(host.is_in_group("admin_controllable"), "registered admin_controllable")
	_p(String(host.get_meta("admin_name", "")) == "scripts", "admin_name=scripts")
	var exposed: Array = host.get_meta("admin_exposed_methods", [])
	_p(exposed.has("sync_and_run") and exposed.has("run_file"),
			"methods exposed: %s" % str(exposed))
	# The scope must actually be SETTABLE now. A colon key silently is not --
	# that is the whole defect this replaced.
	var scopes: Dictionary = host.get_meta("admin_call_scopes", {})
	_p(String(scopes.get("sync_and_run", "")) == "local",
			"sync_and_run scope=local via dictionary meta (settable)")
	host.set_meta("admin_call_scope:probe", "local")
	_p(not host.has_meta("admin_call_scope:probe"),
			"colon meta still rejected by the engine (why the dict shape exists)")

	print("GATE script_host: %s" % ("PASS" if ok else "FAIL"))
	quit(0 if ok else 1)