diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs new file mode 100644 index 0000000..0465ac6 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs @@ -0,0 +1,142 @@ +using System; +using NUnit.Framework; +using UnityEngine; +using MCPForUnity.Editor.Tools; +using static MCPForUnity.Editor.Tools.ManageGameObject; + +namespace MCPForUnityTests.Editor.Tools +{ + public class ComponentResolverTests + { + [Test] + public void TryResolve_ReturnsTrue_ForBuiltInComponentShortName() + { + bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error); + + Assert.IsTrue(result, "Should resolve Transform component"); + Assert.AreEqual(typeof(Transform), type, "Should return correct Transform type"); + Assert.IsEmpty(error, "Should have no error message"); + } + + [Test] + public void TryResolve_ReturnsTrue_ForBuiltInComponentFullyQualifiedName() + { + bool result = ComponentResolver.TryResolve("UnityEngine.Rigidbody", out Type type, out string error); + + Assert.IsTrue(result, "Should resolve UnityEngine.Rigidbody component"); + Assert.AreEqual(typeof(Rigidbody), type, "Should return correct Rigidbody type"); + Assert.IsEmpty(error, "Should have no error message"); + } + + [Test] + public void TryResolve_ReturnsTrue_ForCustomComponentShortName() + { + bool result = ComponentResolver.TryResolve("TicTacToe3D", out Type type, out string error); + + Assert.IsTrue(result, "Should resolve TicTacToe3D component"); + Assert.IsNotNull(type, "Should return valid type"); + Assert.AreEqual("TicTacToe3D", type.Name, "Should have correct type name"); + Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Should be a Component type"); + Assert.IsEmpty(error, "Should have no error message"); + } + + [Test] + public void TryResolve_ReturnsTrue_ForCustomComponentFullyQualifiedName() + { + bool result = ComponentResolver.TryResolve("TestNamespace.CustomComponent", out Type type, out string error); + + Assert.IsTrue(result, "Should resolve TestNamespace.CustomComponent"); + Assert.IsNotNull(type, "Should return valid type"); + Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name"); + Assert.AreEqual("TestNamespace.CustomComponent", type.FullName, "Should have correct full name"); + Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Should be a Component type"); + Assert.IsEmpty(error, "Should have no error message"); + } + + [Test] + public void TryResolve_ReturnsFalse_ForNonExistentComponent() + { + bool result = ComponentResolver.TryResolve("NonExistentComponent", out Type type, out string error); + + Assert.IsFalse(result, "Should not resolve non-existent component"); + Assert.IsNull(type, "Should return null type"); + Assert.IsNotEmpty(error, "Should have error message"); + Assert.That(error, Does.Contain("not found"), "Error should mention component not found"); + } + + [Test] + public void TryResolve_ReturnsFalse_ForEmptyString() + { + bool result = ComponentResolver.TryResolve("", out Type type, out string error); + + Assert.IsFalse(result, "Should not resolve empty string"); + Assert.IsNull(type, "Should return null type"); + Assert.IsNotEmpty(error, "Should have error message"); + } + + [Test] + public void TryResolve_ReturnsFalse_ForNullString() + { + bool result = ComponentResolver.TryResolve(null, out Type type, out string error); + + Assert.IsFalse(result, "Should not resolve null string"); + Assert.IsNull(type, "Should return null type"); + Assert.IsNotEmpty(error, "Should have error message"); + Assert.That(error, Does.Contain("null or empty"), "Error should mention null or empty"); + } + + [Test] + public void TryResolve_CachesResolvedTypes() + { + // First call + bool result1 = ComponentResolver.TryResolve("Transform", out Type type1, out string error1); + + // Second call should use cache + bool result2 = ComponentResolver.TryResolve("Transform", out Type type2, out string error2); + + Assert.IsTrue(result1, "First call should succeed"); + Assert.IsTrue(result2, "Second call should succeed"); + Assert.AreSame(type1, type2, "Should return same type instance (cached)"); + Assert.IsEmpty(error1, "First call should have no error"); + Assert.IsEmpty(error2, "Second call should have no error"); + } + + [Test] + public void TryResolve_PrefersPlayerAssemblies() + { + // Test that custom user scripts (in Player assemblies) are found + bool result = ComponentResolver.TryResolve("TicTacToe3D", out Type type, out string error); + + Assert.IsTrue(result, "Should resolve user script from Player assembly"); + Assert.IsNotNull(type, "Should return valid type"); + + // Verify it's not from an Editor assembly by checking the assembly name + string assemblyName = type.Assembly.GetName().Name; + Assert.That(assemblyName, Does.Not.Contain("Editor"), + "User script should come from Player assembly, not Editor assembly"); + } + + [Test] + public void TryResolve_HandlesDuplicateNames_WithAmbiguityError() + { + // This test would need duplicate component names to be meaningful + // For now, test with a built-in component that should not have duplicates + bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error); + + Assert.IsTrue(result, "Transform should resolve uniquely"); + Assert.AreEqual(typeof(Transform), type, "Should return correct type"); + Assert.IsEmpty(error, "Should have no ambiguity error"); + } + + [Test] + public void ResolvedType_IsValidComponent() + { + bool result = ComponentResolver.TryResolve("Rigidbody", out Type type, out string error); + + Assert.IsTrue(result, "Should resolve Rigidbody"); + Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Resolved type should be assignable from Component"); + Assert.IsTrue(typeof(MonoBehaviour).IsAssignableFrom(type) || + typeof(Component).IsAssignableFrom(type), "Should be a valid Unity component"); + } + } +} \ No newline at end of file diff --git a/UnityMcpBridge/Editor/AssemblyInfo.cs b/UnityMcpBridge/Editor/AssemblyInfo.cs new file mode 100644 index 0000000..30a86a3 --- /dev/null +++ b/UnityMcpBridge/Editor/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")] \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs index 3e0def8..6f535f9 100644 --- a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs +++ b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs @@ -1508,12 +1508,37 @@ namespace MCPForUnity.Editor.Tools { if (!SetProperty(targetComponent, propName, propValue)) { - // Log warning if property could not be set - Debug.LogWarning( - $"[ManageGameObject] Could not set property '{propName}' on component '{compName}' ('{targetComponent.GetType().Name}'). Property might not exist, be read-only, or type mismatch." - ); - // Optionally return an error here instead of just logging - // return Response.Error($"Could not set property '{propName}' on component '{compName}'."); + // Get available properties and AI suggestions for better error messages + var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType()); + var suggestions = ComponentResolver.GetAIPropertySuggestions(propName, availableProperties); + + var errorMessage = $"[ManageGameObject] Could not set property '{propName}' on component '{compName}' ('{targetComponent.GetType().Name}')."; + + if (suggestions.Any()) + { + errorMessage += $" Did you mean: {string.Join(", ", suggestions)}?"; + } + + errorMessage += $" Available properties: [{string.Join(", ", availableProperties)}]"; + + Debug.LogWarning(errorMessage); + + // Return enhanced error with suggestions for better UX + if (suggestions.Any()) + { + return Response.Error( + $"Property '{propName}' not found on {compName}. " + + $"Did you mean: {string.Join(", ", suggestions)}? " + + $"Available properties: [{string.Join(", ", availableProperties)}]" + ); + } + else + { + return Response.Error( + $"Property '{propName}' not found on {compName}. " + + $"Available properties: [{string.Join(", ", availableProperties)}]" + ); + } } } catch (Exception e) @@ -2132,6 +2157,14 @@ namespace MCPForUnity.Editor.Tools public static bool TryResolve(string nameOrFullName, out Type type, out string error) { error = string.Empty; + type = null!; + + // Handle null/empty input + if (string.IsNullOrWhiteSpace(nameOrFullName)) + { + error = "Component name cannot be null or empty"; + return false; + } // 1) Exact FQN via Type.GetType if (CacheByFqn.TryGetValue(nameOrFullName, out type)) return true; @@ -2228,6 +2261,144 @@ namespace MCPForUnity.Editor.Tools "\nProvide a fully qualified type name to disambiguate."; } + /// + /// Gets all accessible property and field names from a component type. + /// + public static List GetAllComponentProperties(Type componentType) + { + if (componentType == null) return new List(); + + var properties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.CanWrite) + .Select(p => p.Name); + + var fields = componentType.GetFields(BindingFlags.Public | BindingFlags.Instance) + .Where(f => !f.IsInitOnly && !f.IsLiteral) + .Select(f => f.Name); + + // Also include SerializeField private fields (common in Unity) + var serializeFields = componentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance) + .Where(f => f.GetCustomAttribute() != null) + .Select(f => f.Name); + + return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList(); + } + + /// + /// Uses AI to suggest the most likely property matches for a user's input. + /// + public static List GetAIPropertySuggestions(string userInput, List availableProperties) + { + if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any()) + return new List(); + + // Simple caching to avoid repeated AI calls for the same input + var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}"; + if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached)) + return cached; + + try + { + var prompt = $"A Unity developer is trying to set a component property but used an incorrect name.\n\n" + + $"User requested: \"{userInput}\"\n" + + $"Available properties: [{string.Join(", ", availableProperties)}]\n\n" + + $"Find 1-3 most likely matches considering:\n" + + $"- Unity Inspector display names vs actual field names (e.g., \"Max Reach Distance\" → \"maxReachDistance\")\n" + + $"- camelCase vs PascalCase vs spaces\n" + + $"- Similar meaning/semantics\n" + + $"- Common Unity naming patterns\n\n" + + $"Return ONLY the matching property names, comma-separated, no quotes or explanation.\n" + + $"If confidence is low (<70%), return empty string.\n\n" + + $"Examples:\n" + + $"- \"Max Reach Distance\" → \"maxReachDistance\"\n" + + $"- \"Health Points\" → \"healthPoints, hp\"\n" + + $"- \"Move Speed\" → \"moveSpeed, movementSpeed\""; + + // For now, we'll use a simple rule-based approach that mimics AI behavior + // This can be replaced with actual AI calls later + var suggestions = GetRuleBasedSuggestions(userInput, availableProperties); + + PropertySuggestionCache[cacheKey] = suggestions; + return suggestions; + } + catch (Exception ex) + { + Debug.LogWarning($"[AI Property Matching] Error getting suggestions for '{userInput}': {ex.Message}"); + return new List(); + } + } + + private static readonly Dictionary> PropertySuggestionCache = new(); + + /// + /// Rule-based suggestions that mimic AI behavior for property matching. + /// This provides immediate value while we could add real AI integration later. + /// + private static List GetRuleBasedSuggestions(string userInput, List availableProperties) + { + var suggestions = new List(); + var cleanedInput = userInput.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); + + foreach (var property in availableProperties) + { + var cleanedProperty = property.ToLowerInvariant(); + + // Exact match after cleaning + if (cleanedProperty == cleanedInput) + { + suggestions.Add(property); + continue; + } + + // Check if property contains all words from input + var inputWords = userInput.ToLowerInvariant().Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries); + if (inputWords.All(word => cleanedProperty.Contains(word.ToLowerInvariant()))) + { + suggestions.Add(property); + continue; + } + + // Levenshtein distance for close matches + if (LevenshteinDistance(cleanedInput, cleanedProperty) <= Math.Max(2, cleanedInput.Length / 4)) + { + suggestions.Add(property); + } + } + + // Prioritize exact matches, then by similarity + return suggestions.OrderBy(s => LevenshteinDistance(cleanedInput, s.ToLowerInvariant().Replace(" ", ""))) + .Take(3) + .ToList(); + } + + /// + /// Calculates Levenshtein distance between two strings for similarity matching. + /// + private static int LevenshteinDistance(string s1, string s2) + { + if (string.IsNullOrEmpty(s1)) return s2?.Length ?? 0; + if (string.IsNullOrEmpty(s2)) return s1.Length; + + var matrix = new int[s1.Length + 1, s2.Length + 1]; + + for (int i = 0; i <= s1.Length; i++) matrix[i, 0] = i; + for (int j = 0; j <= s2.Length; j++) matrix[0, j] = j; + + for (int i = 1; i <= s1.Length; i++) + { + for (int j = 1; j <= s2.Length; j++) + { + int cost = (s2[j - 1] == s1[i - 1]) ? 0 : 1; + matrix[i, j] = Math.Min(Math.Min( + matrix[i - 1, j] + 1, // deletion + matrix[i, j - 1] + 1), // insertion + matrix[i - 1, j - 1] + cost); // substitution + } + } + + return matrix[s1.Length, s2.Length]; + } + /// /// Parses a JArray like [x, y, z] into a Vector3. ///