@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()