diff --git a/MCPForUnity/Editor/Tools/ManageProfiler.cs b/MCPForUnity/Editor/Tools/ManageProfiler.cs new file mode 100644 index 000000000..3b299df2a --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageProfiler.cs @@ -0,0 +1,167 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor.Profiling; +using UnityEditorInternal; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Reads Unity Profiler frame data and controls profiler state. + /// Uses ProfilerDriver to access the same data visible in the Profiler window. + /// + [McpForUnityTool("manage_profiler", AutoRegister = false)] + public static class ManageProfiler + { + /// + /// Main handler for profiler actions. + /// + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + var p = new ToolParams(@params); + + var actionResult = p.GetRequired("action"); + if (!actionResult.IsSuccess) + { + return new ErrorResponse(actionResult.ErrorMessage); + } + string action = actionResult.Value.ToLowerInvariant(); + + switch (action) + { + case "read_frames": + return ReadFrames(p); + case "enable": + return EnableProfiler(); + case "disable": + return DisableProfiler(); + case "status": + return GetStatus(); + case "clear": + return ClearFrames(); + default: + return new ErrorResponse($"Unknown action: '{action}'. Valid actions: read_frames, enable, disable, status, clear."); + } + } + + private static object ReadFrames(ToolParams p) + { + if (ProfilerDriver.enabled == false) + { + return new ErrorResponse("Profiler is not enabled. Use action='enable' first or open the Profiler window."); + } + + int lastFrame = ProfilerDriver.lastFrameIndex; + int firstFrame = ProfilerDriver.firstFrameIndex; + if (lastFrame < 0 || firstFrame < 0) + { + return new ErrorResponse("No profiler frames available."); + } + + int frameCount = p.GetInt("frameCount", 1) ?? 1; + int threadIndex = p.GetInt("thread", 0) ?? 0; + string filter = p.Get("filter") ?? ""; + float minMs = p.GetFloat("minMs", 0.01f) ?? 0.01f; + + frameCount = System.Math.Min(frameCount, lastFrame - firstFrame + 1); + + var frames = new List(); + for (int f = 0; f < frameCount; f++) + { + int frameIndex = lastFrame - f; + using var frameData = ProfilerDriver.GetRawFrameDataView(frameIndex, threadIndex); + if (frameData == null || frameData.valid == false) + { + continue; + } + + var collected = new List<(string name, float ms, int childCount)>(); + for (int i = 0; i < frameData.sampleCount; i++) + { + float timeMs = frameData.GetSampleTimeMs(i); + if (timeMs < minMs) + { + continue; + } + + string name = frameData.GetSampleName(i); + if (string.IsNullOrEmpty(filter) == false && + name.IndexOf(filter, System.StringComparison.OrdinalIgnoreCase) < 0) + { + continue; + } + + collected.Add((name, timeMs, frameData.GetSampleChildrenCount(i))); + } + + // Sort by time descending + collected.Sort((a, b) => b.ms.CompareTo(a.ms)); + + var samples = new List(collected.Count); + foreach (var s in collected) + { + samples.Add(new { s.name, s.ms, formatted = FormatTime(s.ms), s.childCount }); + } + + frames.Add(new + { + frameIndex, + threadIndex, + sampleCount = frameData.sampleCount, + samples + }); + } + + return new SuccessResponse($"{frames.Count} frames read", new { frames }); + } + + private static object EnableProfiler() + { + UnityEngine.Profiling.Profiler.enabled = true; + ProfilerDriver.enabled = true; + return new SuccessResponse("Profiler enabled."); + } + + private static object DisableProfiler() + { + ProfilerDriver.enabled = false; + UnityEngine.Profiling.Profiler.enabled = false; + return new SuccessResponse("Profiler disabled."); + } + + private static object GetStatus() + { + int firstFrame = ProfilerDriver.firstFrameIndex; + int lastFrame = ProfilerDriver.lastFrameIndex; + int frameCount = lastFrame >= firstFrame ? lastFrame - firstFrame + 1 : 0; + + return new SuccessResponse("Profiler status", new + { + enabled = ProfilerDriver.enabled, + firstFrame, + lastFrame, + frameCount, + isPlaying = Application.isPlaying + }); + } + + private static object ClearFrames() + { + ProfilerDriver.ClearAllFrames(); + return new SuccessResponse("All profiler frames cleared."); + } + + private static string FormatTime(float ms) + { + if (ms >= 1f) return $"{ms:F2}ms"; + if (ms >= 0.001f) return $"{ms * 1000f:F2}us"; + return $"{ms * 1_000_000f:F0}ns"; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ReserializeAssets.cs b/MCPForUnity/Editor/Tools/ReserializeAssets.cs new file mode 100644 index 000000000..b4e75063d --- /dev/null +++ b/MCPForUnity/Editor/Tools/ReserializeAssets.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Forces Unity to reserialize assets, updating them to the current serialization format. + /// Useful after Unity version upgrades, script changes that affect serialized data, + /// or when assets need their .meta files regenerated. + /// + [McpForUnityTool("reserialize_assets", AutoRegister = false)] + public static class ReserializeAssets + { + /// + /// Main handler for asset reserialization. + /// + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + var p = new ToolParams(@params); + + // Support both single path and array of paths + string singlePath = p.Get("path"); + JToken pathsToken = @params["paths"]; + + var paths = new List(); + + if (pathsToken != null && pathsToken.Type == JTokenType.Array) + { + foreach (var item in pathsToken) + { + string val = item.ToString(); + if (!string.IsNullOrEmpty(val)) + { + paths.Add(val); + } + } + } + else if (!string.IsNullOrEmpty(singlePath)) + { + paths.Add(singlePath); + } + + if (paths.Count == 0) + { + return new ErrorResponse("'path' (string) or 'paths' (array of strings) parameter required."); + } + + AssetDatabase.ForceReserializeAssets(paths); + + return new SuccessResponse( + $"Reserialized {paths.Count} asset(s).", + new { paths = paths.ToArray() } + ); + } + } +} diff --git a/Server/src/services/tools/manage_profiler.py b/Server/src/services/tools/manage_profiler.py new file mode 100644 index 000000000..4f46899ee --- /dev/null +++ b/Server/src/services/tools/manage_profiler.py @@ -0,0 +1,81 @@ +from typing import Annotated, Any, Literal + +from fastmcp import Context +from mcp.types import ToolAnnotations + +from services.registry import mcp_for_unity_tool +from services.tools import get_unity_instance_from_context +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry + + +@mcp_for_unity_tool( + description="Reads Unity Profiler frame data and controls profiler state. " + "Uses ProfilerDriver to access the same data visible in the Profiler window. " + "Read-only actions: read_frames, status. " + "Modifying actions: enable, disable, clear.", + annotations=ToolAnnotations( + title="Manage Profiler", + ), +) +async def manage_profiler( + ctx: Context, + action: Annotated[ + Literal["read_frames", "enable", "disable", "status", "clear"], + "Action to perform. " + "'read_frames' reads profiler frame data (same as Profiler window). " + "'enable' starts profiler recording. " + "'disable' stops profiler recording. " + "'status' returns current profiler state. " + "'clear' clears all recorded frames." + ], + frame_count: Annotated[ + int | str, + "Number of recent frames to read (default: 1). Only used with 'read_frames'." + ] | None = None, + thread: Annotated[ + int | str, + "Thread index to read (default: 0). " + "0=Main Thread (game logic, ECS, physics, animation), " + "1=Render Thread (GPU commands, shader), " + "2+=Job Worker Threads (Burst/Jobs parallel work). " + "Only used with 'read_frames'." + ] | None = None, + filter: Annotated[ + str, + "Filter samples by name (case-insensitive substring match). Only used with 'read_frames'." + ] | None = None, + min_ms: Annotated[ + float | str, + "Minimum milliseconds to include a sample (default: 0.01). Only used with 'read_frames'." + ] | None = None, +) -> dict[str, Any]: + unity_instance = get_unity_instance_from_context(ctx) + + try: + params: dict[str, Any] = {"action": action} + + if action == "read_frames": + if frame_count is not None: + params["frameCount"] = int(frame_count) + if thread is not None: + params["thread"] = int(thread) + if filter is not None: + params["filter"] = filter + if min_ms is not None: + params["minMs"] = float(min_ms) + + response = await send_with_unity_instance( + async_send_command_with_retry, unity_instance, "manage_profiler", params + ) + + if isinstance(response, dict) and response.get("success"): + return { + "success": True, + "message": response.get("message", "Profiler operation successful."), + "data": response.get("data"), + } + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + except Exception as e: + return {"success": False, "message": f"Python error managing profiler: {str(e)}"} diff --git a/Server/src/services/tools/reserialize_assets.py b/Server/src/services/tools/reserialize_assets.py new file mode 100644 index 000000000..83309612b --- /dev/null +++ b/Server/src/services/tools/reserialize_assets.py @@ -0,0 +1,57 @@ +from typing import Annotated, Any + +from fastmcp import Context +from mcp.types import ToolAnnotations + +from services.registry import mcp_for_unity_tool +from services.tools import get_unity_instance_from_context +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry + + +@mcp_for_unity_tool( + description="Forces Unity to reserialize assets, updating them to the current serialization format. " + "Useful after Unity version upgrades, script changes that affect serialized data, " + "or when assets need their .meta files regenerated. " + "Provide either 'path' for a single asset or 'paths' for multiple assets.", + annotations=ToolAnnotations( + title="Reserialize Assets", + ), +) +async def reserialize_assets( + ctx: Context, + path: Annotated[ + str, + "Single asset path to reserialize (e.g., 'Assets/Prefabs/Player.prefab')." + ] | None = None, + paths: Annotated[ + list[str], + "Array of asset paths to reserialize." + ] | None = None, +) -> dict[str, Any]: + unity_instance = get_unity_instance_from_context(ctx) + + try: + params: dict[str, Any] = {} + + if paths is not None: + params["paths"] = paths + elif path is not None: + params["path"] = path + else: + return {"success": False, "message": "'path' or 'paths' parameter required."} + + response = await send_with_unity_instance( + async_send_command_with_retry, unity_instance, "reserialize_assets", params + ) + + if isinstance(response, dict) and response.get("success"): + return { + "success": True, + "message": response.get("message", "Assets reserialized."), + "data": response.get("data"), + } + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + except Exception as e: + return {"success": False, "message": f"Python error reserializing assets: {str(e)}"} diff --git a/Server/tests/integration/test_manage_profiler.py b/Server/tests/integration/test_manage_profiler.py new file mode 100644 index 000000000..afd3603c6 --- /dev/null +++ b/Server/tests/integration/test_manage_profiler.py @@ -0,0 +1,238 @@ +""" +Tests for the manage_profiler tool. + +Validates parameter mapping, action routing, and response handling. +""" +import pytest + +from .test_helpers import DummyContext +import services.tools.manage_profiler as profiler_mod + + +@pytest.mark.asyncio +async def test_manage_profiler_read_frames_default_params(monkeypatch): + """Test read_frames with default parameters sends correct command.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["cmd"] = cmd + captured["params"] = params + return { + "success": True, + "message": "1 frames read", + "data": {"frames": []}, + } + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="read_frames", + ) + + assert resp.get("success") is True + assert captured["cmd"] == "manage_profiler" + assert captured["params"]["action"] == "read_frames" + # No optional params should be sent when not provided + assert "frameCount" not in captured["params"] + assert "thread" not in captured["params"] + assert "filter" not in captured["params"] + assert "minMs" not in captured["params"] + + +@pytest.mark.asyncio +async def test_manage_profiler_read_frames_with_all_params(monkeypatch): + """Test read_frames passes all optional parameters correctly.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return { + "success": True, + "message": "5 frames read", + "data": {"frames": []}, + } + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="read_frames", + frame_count=5, + thread=1, + filter="Render", + min_ms=0.5, + ) + + assert resp.get("success") is True + p = captured["params"] + assert p["action"] == "read_frames" + assert p["frameCount"] == 5 + assert p["thread"] == 1 + assert p["filter"] == "Render" + assert p["minMs"] == 0.5 + + +@pytest.mark.asyncio +async def test_manage_profiler_read_frames_string_coercion(monkeypatch): + """Test that string values for numeric params are coerced to proper types.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "ok", "data": None} + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="read_frames", + frame_count="3", + thread="2", + min_ms="0.01", + ) + + assert resp.get("success") is True + p = captured["params"] + assert p["frameCount"] == 3 + assert isinstance(p["frameCount"], int) + assert p["thread"] == 2 + assert isinstance(p["thread"], int) + assert p["minMs"] == 0.01 + assert isinstance(p["minMs"], float) + + +@pytest.mark.asyncio +async def test_manage_profiler_enable(monkeypatch): + """Test enable action sends correct command.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["cmd"] = cmd + captured["params"] = params + return {"success": True, "message": "Profiler enabled."} + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="enable", + ) + + assert resp.get("success") is True + assert captured["cmd"] == "manage_profiler" + assert captured["params"]["action"] == "enable" + # No read_frames params should be present + assert "frameCount" not in captured["params"] + + +@pytest.mark.asyncio +async def test_manage_profiler_disable(monkeypatch): + """Test disable action sends correct command.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "Profiler disabled."} + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="disable", + ) + + assert resp.get("success") is True + assert captured["params"]["action"] == "disable" + + +@pytest.mark.asyncio +async def test_manage_profiler_status(monkeypatch): + """Test status action returns profiler state data.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return { + "success": True, + "message": "Profiler status", + "data": { + "enabled": True, + "firstFrame": 0, + "lastFrame": 100, + "frameCount": 101, + }, + } + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="status", + ) + + assert resp.get("success") is True + assert resp["data"]["enabled"] is True + assert resp["data"]["frameCount"] == 101 + + +@pytest.mark.asyncio +async def test_manage_profiler_clear(monkeypatch): + """Test clear action sends correct command.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "All profiler frames cleared."} + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="clear", + ) + + assert resp.get("success") is True + assert captured["params"]["action"] == "clear" + + +@pytest.mark.asyncio +async def test_manage_profiler_unity_error_passthrough(monkeypatch): + """Test that Unity-side errors are passed through correctly.""" + + async def fake_send(cmd, params, **kwargs): + return {"success": False, "error": "Profiler is not enabled."} + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="read_frames", + ) + + assert resp.get("success") is False + + +@pytest.mark.asyncio +async def test_manage_profiler_non_read_frames_ignores_extra_params(monkeypatch): + """Test that non-read_frames actions don't send read_frames-specific params.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "ok"} + + monkeypatch.setattr(profiler_mod, "async_send_command_with_retry", fake_send) + + resp = await profiler_mod.manage_profiler( + ctx=DummyContext(), + action="enable", + frame_count=5, + thread=1, + filter="test", + min_ms=0.1, + ) + + assert resp.get("success") is True + p = captured["params"] + assert p == {"action": "enable"} diff --git a/Server/tests/integration/test_reserialize_assets.py b/Server/tests/integration/test_reserialize_assets.py new file mode 100644 index 000000000..e628824a1 --- /dev/null +++ b/Server/tests/integration/test_reserialize_assets.py @@ -0,0 +1,135 @@ +""" +Tests for the reserialize_assets tool. + +Validates parameter handling for single path, multiple paths, and error cases. +""" +import pytest + +from .test_helpers import DummyContext +import services.tools.reserialize_assets as reserialize_mod + + +@pytest.mark.asyncio +async def test_reserialize_assets_single_path(monkeypatch): + """Test reserialization with a single path parameter.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["cmd"] = cmd + captured["params"] = params + return { + "success": True, + "message": "Reserialized 1 asset(s).", + "data": {"paths": ["Assets/Prefabs/Player.prefab"]}, + } + + monkeypatch.setattr(reserialize_mod, "async_send_command_with_retry", fake_send) + + resp = await reserialize_mod.reserialize_assets( + ctx=DummyContext(), + path="Assets/Prefabs/Player.prefab", + ) + + assert resp.get("success") is True + assert captured["cmd"] == "reserialize_assets" + assert captured["params"]["path"] == "Assets/Prefabs/Player.prefab" + assert "paths" not in captured["params"] + + +@pytest.mark.asyncio +async def test_reserialize_assets_multiple_paths(monkeypatch): + """Test reserialization with an array of paths.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return { + "success": True, + "message": "Reserialized 3 asset(s).", + "data": { + "paths": [ + "Assets/Prefabs/A.prefab", + "Assets/Prefabs/B.prefab", + "Assets/Materials/C.mat", + ] + }, + } + + monkeypatch.setattr(reserialize_mod, "async_send_command_with_retry", fake_send) + + resp = await reserialize_mod.reserialize_assets( + ctx=DummyContext(), + paths=[ + "Assets/Prefabs/A.prefab", + "Assets/Prefabs/B.prefab", + "Assets/Materials/C.mat", + ], + ) + + assert resp.get("success") is True + assert captured["params"]["paths"] == [ + "Assets/Prefabs/A.prefab", + "Assets/Prefabs/B.prefab", + "Assets/Materials/C.mat", + ] + assert "path" not in captured["params"] + + +@pytest.mark.asyncio +async def test_reserialize_assets_paths_preferred_over_path(monkeypatch): + """Test that paths array takes priority when both path and paths are provided.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "ok", "data": None} + + monkeypatch.setattr(reserialize_mod, "async_send_command_with_retry", fake_send) + + resp = await reserialize_mod.reserialize_assets( + ctx=DummyContext(), + path="Assets/single.prefab", + paths=["Assets/multi1.prefab", "Assets/multi2.prefab"], + ) + + assert resp.get("success") is True + assert "paths" in captured["params"] + assert "path" not in captured["params"] + + +@pytest.mark.asyncio +async def test_reserialize_assets_no_params_returns_error(monkeypatch): + """Test that providing neither path nor paths returns an error without calling Unity.""" + send_called = False + + async def fake_send(cmd, params, **kwargs): + nonlocal send_called + send_called = True + return {"success": True} + + monkeypatch.setattr(reserialize_mod, "async_send_command_with_retry", fake_send) + + resp = await reserialize_mod.reserialize_assets( + ctx=DummyContext(), + ) + + assert resp.get("success") is False + assert "path" in resp.get("message", "").lower() + assert send_called is False, "Should not call Unity when no paths provided" + + +@pytest.mark.asyncio +async def test_reserialize_assets_unity_error_passthrough(monkeypatch): + """Test that Unity-side errors are passed through correctly.""" + + async def fake_send(cmd, params, **kwargs): + return {"success": False, "error": "Asset not found"} + + monkeypatch.setattr(reserialize_mod, "async_send_command_with_retry", fake_send) + + resp = await reserialize_mod.reserialize_assets( + ctx=DummyContext(), + path="Assets/NonExistent.prefab", + ) + + assert resp.get("success") is False diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProfilerTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProfilerTests.cs new file mode 100644 index 000000000..03c4b5eaf --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProfilerTests.cs @@ -0,0 +1,104 @@ +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnityTests.Editor.Tools +{ + /// + /// EditMode tests for the ManageProfiler tool. + /// Tests parameter validation, action routing, and error handling. + /// + [TestFixture] + public class ManageProfilerTests + { + private static JObject ToJO(object o) => JObject.FromObject(o); + + [Test] + public void HandleCommand_NullParams_ReturnsError() + { + var result = ManageProfiler.HandleCommand(null); + var jo = ToJO(result); + Assert.IsFalse((bool)jo["success"]); + Assert.IsNotNull(jo["error"]); + Assert.That((string)jo["error"], Does.Contain("cannot be null")); + } + + [Test] + public void HandleCommand_MissingAction_ReturnsError() + { + var result = ManageProfiler.HandleCommand(new JObject()); + var jo = ToJO(result); + Assert.IsFalse((bool)jo["success"]); + Assert.IsNotNull(jo["error"]); + } + + [Test] + public void HandleCommand_UnknownAction_ReturnsError() + { + var result = ManageProfiler.HandleCommand(new JObject { ["action"] = "invalid_action" }); + var jo = ToJO(result); + Assert.IsFalse((bool)jo["success"]); + Assert.That((string)jo["error"], Does.Contain("Unknown action")); + } + + [Test] + public void HandleCommand_ActionCaseInsensitive() + { + // Both "STATUS" and "status" should be recognized + var upper = ManageProfiler.HandleCommand(new JObject { ["action"] = "STATUS" }); + var lower = ManageProfiler.HandleCommand(new JObject { ["action"] = "status" }); + var upperJo = ToJO(upper); + var lowerJo = ToJO(lower); + Assert.AreEqual((bool)upperJo["success"], (bool)lowerJo["success"]); + } + + [Test] + public void HandleCommand_Status_ReturnsProfilerState() + { + var result = ManageProfiler.HandleCommand(new JObject { ["action"] = "status" }); + var jo = ToJO(result); + Assert.IsTrue((bool)jo["success"]); + Assert.IsNotNull(jo["data"]); + + var data = jo["data"]; + Assert.IsNotNull(data["enabled"], "Should report enabled state"); + Assert.IsNotNull(data["firstFrame"], "Should report first frame index"); + Assert.IsNotNull(data["lastFrame"], "Should report last frame index"); + Assert.IsNotNull(data["frameCount"], "Should report frame count"); + } + + [Test] + public void HandleCommand_EnableDisable_Succeeds() + { + // Enable + var enableResult = ManageProfiler.HandleCommand(new JObject { ["action"] = "enable" }); + var enableJo = ToJO(enableResult); + Assert.IsTrue((bool)enableJo["success"]); + + // Disable + var disableResult = ManageProfiler.HandleCommand(new JObject { ["action"] = "disable" }); + var disableJo = ToJO(disableResult); + Assert.IsTrue((bool)disableJo["success"]); + } + + [Test] + public void HandleCommand_Clear_Succeeds() + { + var result = ManageProfiler.HandleCommand(new JObject { ["action"] = "clear" }); + var jo = ToJO(result); + Assert.IsTrue((bool)jo["success"]); + } + + [Test] + public void HandleCommand_ReadFrames_WhenProfilerDisabled_ReturnsError() + { + // Ensure profiler is disabled first + ManageProfiler.HandleCommand(new JObject { ["action"] = "disable" }); + + var result = ManageProfiler.HandleCommand(new JObject { ["action"] = "read_frames" }); + var jo = ToJO(result); + Assert.IsFalse((bool)jo["success"]); + Assert.That((string)jo["error"], Does.Contain("not enabled")); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReserializeAssetsTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReserializeAssetsTests.cs new file mode 100644 index 000000000..9e78850b3 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReserializeAssetsTests.cs @@ -0,0 +1,46 @@ +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnityTests.Editor.Tools +{ + /// + /// EditMode tests for the ReserializeAssets tool. + /// Tests parameter validation and error handling. + /// + [TestFixture] + public class ReserializeAssetsTests + { + private static JObject ToJO(object o) => JObject.FromObject(o); + + [Test] + public void HandleCommand_NullParams_ReturnsError() + { + var result = ReserializeAssets.HandleCommand(null); + var jo = ToJO(result); + Assert.IsFalse((bool)jo["success"]); + Assert.IsNotNull(jo["error"]); + Assert.That((string)jo["error"], Does.Contain("cannot be null")); + } + + [Test] + public void HandleCommand_NoPathOrPaths_ReturnsError() + { + var result = ReserializeAssets.HandleCommand(new JObject()); + var jo = ToJO(result); + Assert.IsFalse((bool)jo["success"]); + Assert.That((string)jo["error"], Does.Contain("path")); + } + + [Test] + public void HandleCommand_EmptyPathsArray_ReturnsError() + { + var result = ReserializeAssets.HandleCommand(new JObject + { + ["paths"] = new JArray() + }); + var jo = ToJO(result); + Assert.IsFalse((bool)jo["success"]); + } + } +}