331 lines
9.9 KiB
GDScript
331 lines
9.9 KiB
GDScript
@tool
|
|
class_name McpTestSuite
|
|
extends RefCounted
|
|
|
|
## Base class for MCP test suites. Provides assertion methods and
|
|
## lifecycle hooks. Subclass this, add test_* methods, and drop the
|
|
## script in res://tests/.
|
|
|
|
## Override to return a short name for this suite (e.g. "scene", "node").
|
|
func suite_name() -> String:
|
|
return "unnamed"
|
|
|
|
|
|
## Called once before the suite runs. Override to create handlers.
|
|
func suite_setup(_ctx: Dictionary) -> void:
|
|
pass
|
|
|
|
|
|
## Called before each test method.
|
|
func setup() -> void:
|
|
pass
|
|
|
|
|
|
## Called after each test method.
|
|
func teardown() -> void:
|
|
pass
|
|
|
|
|
|
## Called once after the suite finishes.
|
|
func suite_teardown() -> void:
|
|
pass
|
|
|
|
|
|
# ----- tracked allocations (freed by the runner after each test) -----
|
|
|
|
var _tracked_objects: Array[Object] = []
|
|
|
|
|
|
## Register a manually-managed Object (plain Object or out-of-tree Node) so
|
|
## the runner frees it after the current test. RefCounted instances are
|
|
## accepted but ignored because they manage their own lifetime.
|
|
func track(obj: Object) -> Object:
|
|
if obj != null and not obj is RefCounted:
|
|
_tracked_objects.append(obj)
|
|
return obj
|
|
|
|
|
|
## Free everything registered via track(). Called by the runner after each
|
|
## test's teardown() and again after suite_teardown().
|
|
func _free_tracked() -> void:
|
|
for obj in _tracked_objects:
|
|
if not is_instance_valid(obj) or (obj is Node and obj.is_queued_for_deletion()):
|
|
continue
|
|
if obj is Node:
|
|
var parent := (obj as Node).get_parent()
|
|
if parent != null:
|
|
parent.remove_child(obj)
|
|
obj.free()
|
|
_tracked_objects.clear()
|
|
|
|
|
|
# ----- assertion state (managed by McpTestRunner) -----
|
|
|
|
var _failed: bool = false
|
|
var _message: String = ""
|
|
var _assertion_count: int = 0
|
|
var _skipped: bool = false
|
|
var _skip_reason: String = ""
|
|
var _expected_script_error_substrings: Array[String] = []
|
|
|
|
# ----- suite-level state (managed by McpTestRunner) -----
|
|
|
|
var _suite_failed: bool = false
|
|
var _suite_failed_message: String = ""
|
|
var _suite_skipped: bool = false
|
|
var _suite_skipped_reason: String = ""
|
|
|
|
|
|
func _reset() -> void:
|
|
_failed = false
|
|
_message = ""
|
|
_assertion_count = 0
|
|
_skipped = false
|
|
_skip_reason = ""
|
|
_expected_script_error_substrings.clear()
|
|
|
|
|
|
func _reset_suite_state() -> void:
|
|
_suite_failed = false
|
|
_suite_failed_message = ""
|
|
_suite_skipped = false
|
|
_suite_skipped_reason = ""
|
|
|
|
|
|
## Mark the current test as skipped. Use when a precondition isn't met
|
|
## (e.g. no scene open, no Node3D in scene) and the test can't run.
|
|
## Skipped tests count separately from passed/failed.
|
|
func skip(reason: String = "") -> void:
|
|
_skipped = true
|
|
_skip_reason = reason
|
|
|
|
|
|
## Bail out of suite_setup() with a failure. Subsequent tests in this suite
|
|
## are not run; the runner reports a single suite-level failure with the
|
|
## given reason instead of N zero-assertion noise lines per test.
|
|
##
|
|
## Example:
|
|
## func suite_setup(ctx):
|
|
## var arena = preload("res://game/arena.gd").new()
|
|
## if arena == null:
|
|
## fail_setup("arena.gd failed to instantiate in @tool scope")
|
|
## return
|
|
func fail_setup(reason: String) -> void:
|
|
_suite_failed = true
|
|
_suite_failed_message = reason
|
|
|
|
|
|
## Bail out of suite_setup() because a precondition isn't met (no scene open,
|
|
## no game running, etc.). Subsequent tests are not run and the runner emits
|
|
## a single suite-level skip rather than per-test skip noise.
|
|
func skip_suite(reason: String) -> void:
|
|
_suite_skipped = true
|
|
_suite_skipped_reason = reason
|
|
|
|
|
|
## Mark the current test as skipped when the running Godot is older than
|
|
## `min_version` (a "major.minor" string like "4.4"). Use for tests that
|
|
## exercise an engine API or behavior that only exists on newer Godot.
|
|
## Returns true when the test was skipped, so callers can `return` from
|
|
## the test body.
|
|
##
|
|
## Example:
|
|
## func test_uses_44_only_api() -> void:
|
|
## if skip_on_godot_lt("4.4", "Engine.capture_script_backtraces is 4.4+"):
|
|
## return
|
|
## ...
|
|
func skip_on_godot_lt(min_version: String, reason: String = "") -> bool:
|
|
var v := Engine.get_version_info()
|
|
var current_major := int(v.get("major", 0))
|
|
var current_minor := int(v.get("minor", 0))
|
|
var parts := min_version.split(".")
|
|
var want_major := int(parts[0]) if parts.size() > 0 else 0
|
|
var want_minor := int(parts[1]) if parts.size() > 1 else 0
|
|
if (
|
|
current_major < want_major
|
|
or (current_major == want_major and current_minor < want_minor)
|
|
):
|
|
var msg := reason if not reason.is_empty() else "requires Godot %s+" % min_version
|
|
skip(msg + " (running %d.%d)" % [current_major, current_minor])
|
|
return true
|
|
return false
|
|
|
|
|
|
## Allow one captured SCRIPT ERROR whose text contains `substring`.
|
|
## Use only for negative-path tests that intentionally compile or execute
|
|
## invalid GDScript and assert on the resulting diagnostics.
|
|
func expect_script_error_containing(substring: String) -> void:
|
|
_expected_script_error_substrings.append(substring)
|
|
|
|
|
|
func _unexpected_script_errors(captured: PackedStringArray) -> PackedStringArray:
|
|
var unexpected := PackedStringArray()
|
|
var remaining := _expected_script_error_substrings.duplicate()
|
|
for error in captured:
|
|
var matched_index := -1
|
|
for i in range(remaining.size()):
|
|
if error.find(remaining[i]) != -1:
|
|
matched_index = i
|
|
break
|
|
if matched_index == -1:
|
|
unexpected.append(error)
|
|
else:
|
|
remaining.remove_at(matched_index)
|
|
return unexpected
|
|
|
|
|
|
## Trigger an undo against whichever history (scene or global) holds the most
|
|
## recent action. `EditorUndoRedoManager` in Godot 4.x doesn't expose `.undo()`
|
|
## directly — you resolve the history's underlying UndoRedo and call it there.
|
|
## Actions registered via `add_do_method(self, …)` with a non-scene target land
|
|
## in GLOBAL_HISTORY, while actions on scene nodes land in the scene's history,
|
|
## so we try both (matches the pattern in batch_handler.gd).
|
|
func editor_undo(undo_redo: EditorUndoRedoManager) -> bool:
|
|
for ur in _collect_histories(undo_redo):
|
|
if ur.undo():
|
|
return true
|
|
return false
|
|
|
|
|
|
## Mirror of `editor_undo` for redo.
|
|
func editor_redo(undo_redo: EditorUndoRedoManager) -> bool:
|
|
for ur in _collect_histories(undo_redo):
|
|
if ur.redo():
|
|
return true
|
|
return false
|
|
|
|
|
|
func _collect_histories(undo_redo: EditorUndoRedoManager) -> Array:
|
|
var out: Array = []
|
|
if undo_redo == null:
|
|
return out
|
|
var scene_root := EditorInterface.get_edited_scene_root()
|
|
if scene_root != null:
|
|
var scene_id := undo_redo.get_object_history_id(scene_root)
|
|
var scene_ur := undo_redo.get_history_undo_redo(scene_id)
|
|
if scene_ur != null:
|
|
out.append(scene_ur)
|
|
var global_ur := undo_redo.get_history_undo_redo(EditorUndoRedoManager.GLOBAL_HISTORY)
|
|
if global_ur != null and not global_ur in out:
|
|
out.append(global_ur)
|
|
return out
|
|
|
|
|
|
# ----- assertions -----
|
|
|
|
func assert_true(condition: bool, msg: String = "") -> void:
|
|
_assertion_count += 1
|
|
if _failed:
|
|
return
|
|
if not condition:
|
|
_failed = true
|
|
_message = msg if msg else "Expected true"
|
|
|
|
|
|
func assert_false(condition: bool, msg: String = "") -> void:
|
|
_assertion_count += 1
|
|
if _failed:
|
|
return
|
|
if condition:
|
|
_failed = true
|
|
_message = msg if msg else "Expected false"
|
|
|
|
|
|
func assert_eq(actual: Variant, expected: Variant, msg: String = "") -> void:
|
|
_assertion_count += 1
|
|
if _failed:
|
|
return
|
|
if actual != expected:
|
|
_failed = true
|
|
_message = msg if msg else "Expected %s, got %s" % [str(expected), str(actual)]
|
|
|
|
|
|
func assert_ne(actual: Variant, not_expected: Variant, msg: String = "") -> void:
|
|
_assertion_count += 1
|
|
if _failed:
|
|
return
|
|
if actual == not_expected:
|
|
_failed = true
|
|
_message = msg if msg else "Expected value != %s" % str(not_expected)
|
|
|
|
|
|
func assert_gt(actual: Variant, threshold: Variant, msg: String = "") -> void:
|
|
_assertion_count += 1
|
|
if _failed:
|
|
return
|
|
if not (actual > threshold):
|
|
_failed = true
|
|
_message = msg if msg else "Expected %s > %s" % [str(actual), str(threshold)]
|
|
|
|
|
|
func assert_has_key(dict: Variant, key: String, msg: String = "") -> void:
|
|
_assertion_count += 1
|
|
if _failed:
|
|
return
|
|
if not dict is Dictionary:
|
|
_failed = true
|
|
_message = msg if msg else "Expected Dictionary, got %s" % type_string(typeof(dict))
|
|
return
|
|
if not dict.has(key):
|
|
_failed = true
|
|
_message = msg if msg else "Missing key: %s (keys: %s)" % [key, str(dict.keys())]
|
|
|
|
|
|
func assert_contains(haystack: Variant, needle: Variant, msg: String = "") -> void:
|
|
_assertion_count += 1
|
|
if _failed:
|
|
return
|
|
if haystack is String:
|
|
if haystack.find(str(needle)) == -1:
|
|
_failed = true
|
|
_message = msg if msg else "'%s' not found in '%s'" % [str(needle), haystack]
|
|
elif haystack is Array:
|
|
if not haystack.has(needle):
|
|
_failed = true
|
|
_message = msg if msg else "%s not found in array" % str(needle)
|
|
else:
|
|
_failed = true
|
|
_message = msg if msg else "assert_contains requires String or Array"
|
|
|
|
|
|
func assert_is_error(result: Dictionary, expected_code: String = "", msg: String = "") -> void:
|
|
_assertion_count += 1
|
|
if _failed:
|
|
return
|
|
if not result.has("error"):
|
|
_failed = true
|
|
_message = msg if msg else "Expected error response, got: %s" % str(result.keys())
|
|
return
|
|
if expected_code and result.error.get("code", "") != expected_code:
|
|
_failed = true
|
|
_message = msg if msg else "Expected error code %s, got %s" % [expected_code, result.error.get("code", "")]
|
|
|
|
|
|
# ----- scene helpers (shared across suites that create/remove Controls) -----
|
|
|
|
## Add a Control under the scene root. Creates a Panel if ctl is null.
|
|
## Returns the scene path, or "" when no scene is open — in which case a
|
|
## caller-supplied ctl is freed to prevent leaks.
|
|
func _add_control(ctl_name: String, ctl: Control = null) -> String:
|
|
var scene_root := EditorInterface.get_edited_scene_root()
|
|
if scene_root == null:
|
|
if ctl != null:
|
|
ctl.queue_free()
|
|
return ""
|
|
if ctl == null:
|
|
ctl = Panel.new()
|
|
ctl.name = ctl_name
|
|
scene_root.add_child(ctl)
|
|
ctl.owner = scene_root
|
|
return "/" + scene_root.name + "/" + ctl_name
|
|
|
|
|
|
func _remove_control(path: String) -> void:
|
|
var scene_root := EditorInterface.get_edited_scene_root()
|
|
if scene_root == null:
|
|
return
|
|
var node := McpScenePath.resolve(path, scene_root)
|
|
if node != null:
|
|
node.get_parent().remove_child(node)
|
|
node.queue_free()
|