Replace dasher-pack with unified animation-pack using original Blender bone names
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Minimal duck-typed stand-in for Godot's built-in `ScriptBacktrace`
|
||||
## class (the type of `script_backtraces[i]` entries inside `_log_error`).
|
||||
## Mirrors the getter surface `_log_error`'s `script_backtraces` argument
|
||||
## exposes (`get_frame_count` + per-frame file/line/function), so test
|
||||
## suites for `editor_logger` and `game_logger` can exercise the
|
||||
## backtrace-remapping path without a live script execution — Godot
|
||||
## doesn't expose a constructor for the real ScriptBacktrace.
|
||||
##
|
||||
## Defaults to a single frame for existing tests, but can carry multiple
|
||||
## frames so detail payload tests can verify full stack preservation.
|
||||
|
||||
var _frames: Array[Dictionary] = []
|
||||
|
||||
|
||||
func _init(file: String, line: int, function: String, frames: Array[Dictionary] = []) -> void:
|
||||
if frames.is_empty():
|
||||
_frames = [{"path": file, "line": line, "function": function}]
|
||||
else:
|
||||
_frames = frames
|
||||
|
||||
|
||||
func get_frame_count() -> int:
|
||||
return _frames.size()
|
||||
|
||||
|
||||
func get_frame_file(idx: int) -> String:
|
||||
return str(_frames[idx].get("path", ""))
|
||||
|
||||
|
||||
func get_frame_line(idx: int) -> int:
|
||||
return int(_frames[idx].get("line", 0))
|
||||
|
||||
|
||||
func get_frame_function(idx: int) -> String:
|
||||
return str(_frames[idx].get("function", ""))
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2xpmw5kvtjr7
|
||||
@@ -0,0 +1,244 @@
|
||||
@tool
|
||||
class_name McpTestRunner
|
||||
extends RefCounted
|
||||
|
||||
## Lightweight test runner for MCP plugin tests. Discovers test_* methods
|
||||
## on McpTestSuite instances, runs them, and collects structured results.
|
||||
|
||||
var _results: Array[Dictionary] = []
|
||||
var _last_run_ms: int = 0
|
||||
|
||||
|
||||
func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filter: String = "") -> void:
|
||||
var name := suite.suite_name()
|
||||
var methods := _get_test_methods(suite)
|
||||
var exclusions := _parse_exclusions(exclude_test_filter)
|
||||
|
||||
for method_name in methods:
|
||||
if not test_filter.is_empty() and method_name.find(test_filter) == -1:
|
||||
continue
|
||||
if _matches_any_exclusion(method_name, exclusions):
|
||||
_results.append({
|
||||
"suite": name,
|
||||
"test": method_name,
|
||||
"passed": true,
|
||||
"skipped": true,
|
||||
"message": "Excluded by exclude_test_name filter",
|
||||
"assertion_count": 0,
|
||||
})
|
||||
continue
|
||||
|
||||
suite._reset()
|
||||
suite.setup()
|
||||
suite.call(method_name)
|
||||
suite.teardown()
|
||||
|
||||
## Issue #19 defence: free any `_McpTest*` nodes the test created, even
|
||||
## nested ones. If the scene gets auto-saved mid-test while one of these
|
||||
## exists, the reference bakes into main.tscn and breaks the next open
|
||||
## with a "missing dependency" error. Runs after every test, not just at
|
||||
## suite boundaries, so a test that fails mid-flow can't leave a trap
|
||||
## for the next test or for scene autosave.
|
||||
var scene_root_for_cleanup := EditorInterface.get_edited_scene_root()
|
||||
if scene_root_for_cleanup != null and scene_root_for_cleanup.is_inside_tree():
|
||||
_free_mcp_test_nodes_recursive(scene_root_for_cleanup)
|
||||
|
||||
if suite._skipped:
|
||||
_results.append({
|
||||
"suite": name,
|
||||
"test": method_name,
|
||||
"passed": true,
|
||||
"skipped": true,
|
||||
"message": suite._skip_reason,
|
||||
"assertion_count": 0,
|
||||
})
|
||||
continue
|
||||
|
||||
var passed := not suite._failed
|
||||
var msg := suite._message
|
||||
|
||||
## Warn about zero-assertion tests (likely silently skipped logic).
|
||||
if passed and suite._assertion_count == 0:
|
||||
passed = false
|
||||
msg = "Test completed with 0 assertions (likely skipped its logic)"
|
||||
|
||||
_results.append({
|
||||
"suite": name,
|
||||
"test": method_name,
|
||||
"passed": passed,
|
||||
"message": msg,
|
||||
"assertion_count": suite._assertion_count,
|
||||
})
|
||||
|
||||
|
||||
func run_suites(suites: Array, suite_filter: String = "", test_filter: String = "", ctx: Dictionary = {}, verbose: bool = false, exclude_test_filter: String = "") -> Dictionary:
|
||||
_results.clear()
|
||||
var start := Time.get_ticks_msec()
|
||||
|
||||
## Silence the plugin's ring-buffer console echo while tests run. Negative-
|
||||
## path suites deliberately fill the ring with 500 lines and log malformed-
|
||||
## result errors; echoing all of that buries an all-green run in scary
|
||||
## console output. The ring contents tests assert on are untouched, and
|
||||
## the flag is restored after the run so live logging resumes.
|
||||
var _prev_console_echo := McpLogBuffer.console_echo
|
||||
McpLogBuffer.console_echo = false
|
||||
|
||||
for suite: McpTestSuite in suites:
|
||||
if not suite_filter.is_empty() and suite.suite_name() != suite_filter:
|
||||
continue
|
||||
|
||||
## Snapshot scene children before the suite so we can clean up leaks.
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
var before_children: Array[Node] = []
|
||||
if scene_root != null:
|
||||
before_children = _get_children_snapshot(scene_root)
|
||||
|
||||
suite._reset_suite_state()
|
||||
suite.suite_setup(ctx.duplicate(true))
|
||||
|
||||
## fail_setup() / skip_suite() gives suites a clean way to bail out of
|
||||
## suite_setup without leaving N tests to fail with "0 assertions". We
|
||||
## emit ONE suite-level result and skip individual tests entirely.
|
||||
if suite._suite_failed:
|
||||
_results.append({
|
||||
"suite": suite.suite_name(),
|
||||
"test": "<suite_setup>",
|
||||
"passed": false,
|
||||
"message": "suite_setup() failed: %s (subsequent tests not run)" % suite._suite_failed_message,
|
||||
"assertion_count": 0,
|
||||
})
|
||||
elif suite._suite_skipped:
|
||||
_results.append({
|
||||
"suite": suite.suite_name(),
|
||||
"test": "<suite_setup>",
|
||||
"passed": true,
|
||||
"skipped": true,
|
||||
"message": "suite_setup() skipped: %s" % suite._suite_skipped_reason,
|
||||
"assertion_count": 0,
|
||||
})
|
||||
else:
|
||||
run_suite(suite, test_filter, exclude_test_filter)
|
||||
suite.suite_teardown()
|
||||
|
||||
## Remove any nodes the suite left behind (failed undo, missing cleanup).
|
||||
if scene_root != null and scene_root.is_inside_tree():
|
||||
_cleanup_leaked_nodes(scene_root, before_children)
|
||||
|
||||
_last_run_ms = Time.get_ticks_msec() - start
|
||||
McpLogBuffer.console_echo = _prev_console_echo
|
||||
return get_results(verbose)
|
||||
|
||||
|
||||
func get_results(verbose: bool = false) -> Dictionary:
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
var skipped := 0
|
||||
var failures: Array[Dictionary] = []
|
||||
var suites_seen := {}
|
||||
for r in _results:
|
||||
suites_seen[r.suite] = true
|
||||
if r.get("skipped", false):
|
||||
skipped += 1
|
||||
elif r.passed:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
failures.append(r)
|
||||
|
||||
var result := {
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"skipped": skipped,
|
||||
"total": _results.size(),
|
||||
"duration_ms": _last_run_ms,
|
||||
"suites_run": suites_seen.keys(),
|
||||
"suite_count": suites_seen.size(),
|
||||
}
|
||||
|
||||
if not failures.is_empty():
|
||||
result["failures"] = failures
|
||||
|
||||
if verbose:
|
||||
result["results"] = _results
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
_results.clear()
|
||||
_last_run_ms = 0
|
||||
|
||||
|
||||
func _get_test_methods(obj: Object) -> Array[String]:
|
||||
var methods: Array[String] = []
|
||||
for m in obj.get_method_list():
|
||||
var name: String = m.get("name", "")
|
||||
if name.begins_with("test_"):
|
||||
methods.append(name)
|
||||
methods.sort()
|
||||
return methods
|
||||
|
||||
|
||||
func _get_children_snapshot(node: Node) -> Array[Node]:
|
||||
var children: Array[Node] = []
|
||||
for child in node.get_children():
|
||||
children.append(child)
|
||||
return children
|
||||
|
||||
|
||||
## Remove any nodes in scene_root that weren't present before the suite ran,
|
||||
## plus any _McpTest* named nodes anywhere in the tree (catches nested leaks).
|
||||
## NOTE: this bypasses EditorUndoRedoManager by design — the test runner
|
||||
## owns these leaks and needs to clear them unconditionally. Don't Ctrl-Z in
|
||||
## the editor immediately after a test run that triggered cleanup; the undo
|
||||
## stack may reference freed nodes.
|
||||
func _cleanup_leaked_nodes(scene_root: Node, before: Array[Node]) -> void:
|
||||
var before_set := {}
|
||||
for n in before:
|
||||
before_set[n] = true
|
||||
for child in scene_root.get_children():
|
||||
if not before_set.has(child):
|
||||
scene_root.remove_child(child)
|
||||
child.queue_free()
|
||||
|
||||
|
||||
## Recursively free every node whose name starts with `_McpTest`, anywhere in
|
||||
## the scene. Intentionally bypasses undo — these are test leaks, not user
|
||||
## work. Walk breadth-first so we can collect victims before mutating the tree.
|
||||
func _free_mcp_test_nodes_recursive(root: Node) -> void:
|
||||
var victims: Array[Node] = []
|
||||
var queue: Array[Node] = [root]
|
||||
while not queue.is_empty():
|
||||
var node: Node = queue.pop_back()
|
||||
for child in node.get_children():
|
||||
if str(child.name).begins_with("_McpTest"):
|
||||
victims.append(child)
|
||||
else:
|
||||
queue.append(child)
|
||||
for v in victims:
|
||||
if v.get_parent() != null:
|
||||
v.get_parent().remove_child(v)
|
||||
v.queue_free()
|
||||
|
||||
|
||||
## Split the `exclude_test_name` filter into individual substring matchers.
|
||||
## Comma-separated so the CI smoke harness can list multiple flaky tests
|
||||
## without shipping a richer schema (single names still work — same string,
|
||||
## no comma, same one-element list). Whitespace around each name is stripped
|
||||
## so `"a, b"` and `"a,b"` behave identically.
|
||||
static func _parse_exclusions(filter: String) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
if filter.is_empty():
|
||||
return out
|
||||
for part in filter.split(","):
|
||||
var trimmed := part.strip_edges()
|
||||
if not trimmed.is_empty():
|
||||
out.append(trimmed)
|
||||
return out
|
||||
|
||||
|
||||
static func _matches_any_exclusion(method_name: String, exclusions: Array[String]) -> bool:
|
||||
for ex in exclusions:
|
||||
if method_name.find(ex) != -1:
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://367b77qh5grt
|
||||
@@ -0,0 +1,277 @@
|
||||
@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
|
||||
|
||||
|
||||
# ----- 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 = ""
|
||||
|
||||
# ----- 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 = ""
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
## 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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlrq2s7jhp71s
|
||||
Reference in New Issue
Block a user