using System; using System.Collections.Generic; using Codely.Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using UnityTcp.Editor.Helpers; namespace UnityTcp.Editor.Tools { /// /// Custom validation helpers that can be invoked via the execute_custom_tool MCP tool. /// Each method validates a specific scenario and returns a standard response object: /// /// { /// success: bool, /// message: string, /// data: { ... optional extra info ... } /// } /// /// Tool registration is done via [ExecuteCustomTool.CustomTool] attribute. /// public static class CodelyUnityValidationTools { /// /// Validates current play mode against an expected value. /// tool_name: "codely.validate_play_mode" /// /// Parameters: /// { /// "expected": "stopped" | "playing" | "paused" /// } /// [ExecuteCustomTool.CustomTool("codely.validate_play_mode", "Validate current editor play mode")] public static object ValidatePlayMode(JObject parameters) { var expected = parameters?["expected"]?.ToString() ?? "stopped"; var actual = GetPlayModeString(); if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) { var msg = $"PlayMode mismatch. Expected={expected}, Actual={actual}"; Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary { ["expected"] = expected, ["actual"] = actual } }; } var okMsg = $"PlayMode OK: {actual}"; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary { ["expected"] = expected, ["actual"] = actual } }; } /// /// Validates that the latest console messages contain at least one entry /// matching the given filter text and type set. /// /// tool_name: "codely.validate_console_contains" /// /// Parameters: /// { /// "filterText": "ConsoleSpamTest", /// "types": ["error","warning","log","exception"], /// "minCount": 1 /// } /// [ExecuteCustomTool.CustomTool("codely.validate_console_contains", "Validate console contains messages matching filter")] public static object ValidateConsoleContains(JObject parameters) { var filterText = parameters?["filterText"]?.ToString() ?? string.Empty; var minCount = parameters?["minCount"]?.ToObject() ?? 1; var typesToken = parameters?["types"] as JArray; var types = new List(); if (typesToken != null) { foreach (var t in typesToken) { if (t.Type == JTokenType.String) { types.Add(t.ToString()); } } } if (types.Count == 0) { types.AddRange(new[] { "error", "warning", "log" }); } var logEntries = ReadConsoleMessages(types, filterText); var matchedCount = logEntries.Count; if (matchedCount < minCount) { var msg = $"Console validation failed. Expected at least {minCount} messages containing '{filterText}', but found {matchedCount}."; Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary { ["filterText"] = filterText, ["types"] = types, ["matchedCount"] = matchedCount, ["messages"] = logEntries } }; } var okMsg = $"Console validation OK. Found {matchedCount} messages containing '{filterText}'."; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary { ["filterText"] = filterText, ["types"] = types, ["matchedCount"] = matchedCount, ["messages"] = logEntries } }; } /// /// Validates that a specific Tag and Layer both exist in the project. /// /// tool_name: "codely.validate_tag_and_layer_exist" /// /// Parameters: /// { /// "tagName": "CodelyTestTag", /// "layerName": "CodelyLayer" /// } /// [ExecuteCustomTool.CustomTool("codely.validate_tag_and_layer_exist", "Validate that a specific Tag and Layer exist")] public static object ValidateTagAndLayerExist(JObject parameters) { var tagName = parameters?["tagName"]?.ToString(); var layerName = parameters?["layerName"]?.ToString(); var missing = new List(); if (!string.IsNullOrEmpty(tagName) && Array.IndexOf(UnityEditorInternal.InternalEditorUtility.tags, tagName) < 0) { missing.Add($"Tag '{tagName}'"); } if (!string.IsNullOrEmpty(layerName) && Array.IndexOf(UnityEditorInternal.InternalEditorUtility.layers, layerName) < 0) { missing.Add($"Layer '{layerName}'"); } if (missing.Count > 0) { var msg = "Missing project identifiers: " + string.Join(", ", missing); Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary { ["tagName"] = tagName, ["layerName"] = layerName } }; } var okMsg = $"Tag/Layer validation OK. Tag='{tagName}', Layer='{layerName}'."; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary { ["tagName"] = tagName, ["layerName"] = layerName } }; } /// /// Generic response validator - checks if a tool response contains expected fields/values. /// /// tool_name: "codely.validate_response" /// /// Parameters: /// { /// "hasField": "fieldName", // Check if response has this field /// "fieldEquals": { "field": "value" }, // Check if field equals value /// "isSuccess": true/false, // Check success field /// "messageContains": "text" // Check if message contains text /// } /// [ExecuteCustomTool.CustomTool("codely.validate_response", "Generic response validator")] public static object ValidateResponse(JObject parameters) { // This tool is meant to be called after another tool call // The caller should pass the previous response data for validation var hasField = parameters?["hasField"]?.ToString(); var fieldEquals = parameters?["fieldEquals"] as JObject; var isSuccess = parameters?["isSuccess"]?.ToObject(); var messageContains = parameters?["messageContains"]?.ToString(); var responseData = parameters?["responseData"] as JObject; var errors = new List(); if (responseData == null) { return new Dictionary { ["success"] = false, ["message"] = "No responseData provided for validation", ["data"] = new Dictionary() }; } // Check hasField (supports nested paths like "state.project.srp" or "project.srp") // Also supports automatic recursive search if direct path fails if (!string.IsNullOrEmpty(hasField)) { JToken fieldValue = null; string foundPath = null; if (hasField.Contains(".")) { // Handle explicit nested field paths (e.g., "state.project.srp" or "project.srp") var pathParts = hasField.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries); JToken current = responseData; foreach (var part in pathParts) { if (current == null || current.Type != JTokenType.Object) { current = null; break; } current = current[part]; } fieldValue = current; if (fieldValue != null && fieldValue.Type != JTokenType.Null) { foundPath = hasField; } } else { // First try simple top-level field check fieldValue = responseData[hasField]; if (fieldValue != null && fieldValue.Type != JTokenType.Null) { foundPath = hasField; } } // If not found and it's a simple field name (no dots), try recursive search if ((fieldValue == null || fieldValue.Type == JTokenType.Null) && !hasField.Contains(".")) { var recursiveResult = FindFieldRecursive(responseData, hasField); if (recursiveResult.found) { fieldValue = recursiveResult.value; foundPath = recursiveResult.path; } } if (fieldValue == null || fieldValue.Type == JTokenType.Null) { errors.Add($"Missing field: {hasField}" + (foundPath == null ? "" : $" (searched recursively, not found)")); } else if (foundPath != null && foundPath != hasField) { // Log that we found it via recursive search (for debugging) Debug.Log($"[CodelyValidation] Field '{hasField}' found at nested path: {foundPath}"); } } // Check fieldEquals if (fieldEquals != null) { foreach (var prop in fieldEquals.Properties()) { var actualValue = responseData[prop.Name]; if (actualValue == null) { errors.Add($"Field '{prop.Name}' not found"); } else if (!JToken.DeepEquals(actualValue, prop.Value)) { errors.Add($"Field '{prop.Name}' expected={prop.Value}, actual={actualValue}"); } } } // Check isSuccess if (isSuccess.HasValue) { var actualSuccess = responseData["success"]?.ToObject() ?? false; if (actualSuccess != isSuccess.Value) { errors.Add($"success expected={isSuccess.Value}, actual={actualSuccess}"); } } // Check messageContains if (!string.IsNullOrEmpty(messageContains)) { var message = responseData["message"]?.ToString() ?? ""; if (!message.Contains(messageContains, StringComparison.OrdinalIgnoreCase)) { errors.Add($"message does not contain '{messageContains}'"); } } if (errors.Count > 0) { var msg = "Response validation failed: " + string.Join("; ", errors); Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary { ["errors"] = errors } }; } var okMsg = "Response validation OK"; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary() }; } /// /// Validates that the active editor tool matches expected. /// /// tool_name: "codely.validate_active_tool" /// /// Parameters: /// { /// "expected": "Move" | "Rotate" | "Scale" | "View" | "Rect" | "Transform" /// } /// [ExecuteCustomTool.CustomTool("codely.validate_active_tool", "Validate current active editor tool")] public static object ValidateActiveTool(JObject parameters) { var expected = parameters?["expected"]?.ToString(); if (string.IsNullOrEmpty(expected)) { return new Dictionary { ["success"] = false, ["message"] = "Parameter 'expected' is required", ["data"] = new Dictionary() }; } var currentTool = UnityEditor.Tools.current; var actual = currentTool.ToString(); // Map Unity's Tool enum to friendly names var toolNameMap = new Dictionary { { UnityEditor.Tool.View, "View" }, { UnityEditor.Tool.Move, "Move" }, { UnityEditor.Tool.Rotate, "Rotate" }, { UnityEditor.Tool.Scale, "Scale" }, { UnityEditor.Tool.Rect, "Rect" }, { UnityEditor.Tool.Transform, "Transform" } }; if (toolNameMap.TryGetValue(currentTool, out var friendlyName)) { actual = friendlyName; } if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) { var msg = $"Active tool mismatch. Expected={expected}, Actual={actual}"; Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary { ["expected"] = expected, ["actual"] = actual } }; } var okMsg = $"Active tool OK: {actual}"; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary { ["expected"] = expected, ["actual"] = actual } }; } /// /// Validates editor is not compiling. /// /// tool_name: "codely.validate_not_compiling" /// [ExecuteCustomTool.CustomTool("codely.validate_not_compiling", "Validate editor is not compiling")] public static object ValidateNotCompiling(JObject parameters) { var isCompiling = EditorApplication.isCompiling; if (isCompiling) { var msg = "Editor is still compiling"; Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary { ["isCompiling"] = true } }; } var okMsg = "Editor is not compiling"; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary { ["isCompiling"] = false } }; } /// /// Validates console message count within expected range. /// /// tool_name: "codely.validate_console_count" /// /// Parameters: /// { /// "minCount": 0, /// "maxCount": 100, /// "types": ["error", "warning", "log"] /// } /// [ExecuteCustomTool.CustomTool("codely.validate_console_count", "Validate console message count")] public static object ValidateConsoleCount(JObject parameters) { var minCount = parameters?["minCount"]?.ToObject() ?? 0; var maxCount = parameters?["maxCount"]?.ToObject() ?? int.MaxValue; var typesToken = parameters?["types"] as JArray; var types = new List(); if (typesToken != null) { foreach (var t in typesToken) { if (t.Type == JTokenType.String) { types.Add(t.ToString()); } } } if (types.Count == 0) { types.AddRange(new[] { "error", "warning", "log" }); } var messages = ReadConsoleMessages(types, ""); var count = messages.Count; if (count < minCount || count > maxCount) { var msg = $"Console count out of range. Expected [{minCount}, {maxCount}], Actual={count}"; Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary { ["count"] = count, ["minCount"] = minCount, ["maxCount"] = maxCount } }; } var okMsg = $"Console count OK: {count} (range [{minCount}, {maxCount}])"; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary { ["count"] = count, ["minCount"] = minCount, ["maxCount"] = maxCount } }; } // --------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------- /// /// Recursively searches for a field in a JToken tree. /// Returns the found value and its path, or null if not found. /// private static (bool found, JToken value, string path) FindFieldRecursive(JToken token, string fieldName, string currentPath = "") { if (token == null || token.Type == JTokenType.Null) return (false, null, null); // If it's an object, check if it has the field if (token.Type == JTokenType.Object) { var obj = (JObject)token; if (obj[fieldName] != null && obj[fieldName].Type != JTokenType.Null) { var path = string.IsNullOrEmpty(currentPath) ? fieldName : $"{currentPath}.{fieldName}"; return (true, obj[fieldName], path); } // Recursively search in all properties foreach (var prop in obj.Properties()) { var newPath = string.IsNullOrEmpty(currentPath) ? prop.Name : $"{currentPath}.{prop.Name}"; var result = FindFieldRecursive(prop.Value, fieldName, newPath); if (result.found) return result; } } // If it's an array, search in each element else if (token.Type == JTokenType.Array) { var arr = (JArray)token; for (int i = 0; i < arr.Count; i++) { var newPath = $"{currentPath}[{i}]"; var result = FindFieldRecursive(arr[i], fieldName, newPath); if (result.found) return result; } } return (false, null, null); } private static string GetPlayModeString() { if (!Application.isPlaying) { return "stopped"; } return EditorApplication.isPaused ? "paused" : "playing"; } /// /// Reads current console messages via Unity's internal LogEntries API. /// We keep this intentionally simple: only type + message text for validation. /// private static List> ReadConsoleMessages(List allowedTypes, string filterText) { var results = new List>(); var logEntriesType = Type.GetType("UnityEditor.LogEntries, UnityEditor.dll"); var logEntryType = Type.GetType("UnityEditor.LogEntry, UnityEditor.dll"); if (logEntriesType == null || logEntryType == null) { Debug.LogWarning("[CodelyValidation] Unable to access UnityEditor.LogEntries/LogEntry types."); return results; } var getCountMethod = logEntriesType.GetMethod("GetCount", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public); var getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public); var startGettingEntries = logEntriesType.GetMethod("StartGettingEntries", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public); var endGettingEntries = logEntriesType.GetMethod("EndGettingEntries", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public); if (getCountMethod == null || getEntryMethod == null || startGettingEntries == null || endGettingEntries == null) { Debug.LogWarning("[CodelyValidation] Unable to reflect LogEntries methods."); return results; } var entry = Activator.CreateInstance(logEntryType); var conditionField = logEntryType.GetField("condition"); var modeField = logEntryType.GetField("mode"); startGettingEntries.Invoke(null, null); try { var count = (int)getCountMethod.Invoke(null, null); for (int i = 0; i < count; i++) { object[] args = { i, entry }; getEntryMethod.Invoke(null, args); var condition = conditionField?.GetValue(entry)?.ToString() ?? string.Empty; var modeValue = modeField != null ? (int)modeField.GetValue(entry) : 0; var typeName = LogTypeFromMode(modeValue); if (allowedTypes.Count > 0 && !allowedTypes.Contains(typeName)) continue; if (!string.IsNullOrEmpty(filterText) && !condition.Contains(filterText, StringComparison.OrdinalIgnoreCase)) continue; results.Add(new Dictionary { ["type"] = typeName, ["message"] = condition }); } } finally { endGettingEntries.Invoke(null, null); } return results; } private static string LogTypeFromMode(int mode) { // Unity uses bit flags; we map common ones to our string types. // See: https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/ConsoleWindow.cs const int ErrorMask = 1; const int AssertMask = 2; const int LogMask = 4; const int FatalMask = 16; if ((mode & ErrorMask) != 0 || (mode & FatalMask) != 0) return "error"; if ((mode & AssertMask) != 0) return "assert"; if ((mode & LogMask) != 0) return "log"; // Fallback return "log"; } // ===================================================================== // Shader / Material Validation Tools // ===================================================================== /// /// Validates that a shader file exists at the expected location and optionally /// that its contents contain (or do not contain) specific substrings. /// /// tool_name: "codely.validate_shader_file" /// /// Parameters: /// { /// "name": "CodelyTestShader1", // Shader name without .shader (required) /// "path": "Shaders/Custom", // Optional: same semantics as unity_shader.path (relative to Assets/) /// "shouldExist": true, // Optional: default true /// "mustContain": ["Shader", "_Color"], // Optional: substrings that must appear in file /// "mustNotContain": ["TODO"] // Optional: substrings that must NOT appear /// } /// [ExecuteCustomTool.CustomTool("codely.validate_shader_file", "Validate shader file existence and contents")] public static object ValidateShaderFile(JObject parameters) { var name = parameters?["name"]?.ToString(); if (string.IsNullOrEmpty(name)) { return new Dictionary { ["success"] = false, ["message"] = "Parameter 'name' is required", ["data"] = new Dictionary() }; } // Determine directory relative to Assets/, following the same rules as ManageShader var pathParam = parameters?["path"]?.ToString(); string relativeDir = pathParam ?? "Shaders"; if (!string.IsNullOrEmpty(relativeDir)) { relativeDir = relativeDir.Replace('\\', '/').Trim('/'); if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); } } if (string.IsNullOrEmpty(relativeDir)) { relativeDir = "Shaders"; } var shaderFileName = $"{name}.shader"; var fullPathDir = System.IO.Path.Combine(Application.dataPath, relativeDir); var fullPath = System.IO.Path.Combine(fullPathDir, shaderFileName); var relativePath = System.IO.Path.Combine("Assets", relativeDir, shaderFileName) .Replace('\\', '/'); var shouldExist = parameters?["shouldExist"]?.ToObject() ?? true; var mustContainArray = parameters?["mustContain"] as JArray; var mustNotContainArray = parameters?["mustNotContain"] as JArray; var errors = new List(); bool fileExists = System.IO.File.Exists(fullPath); if (shouldExist && !fileExists) { errors.Add($"Shader file expected but not found at '{relativePath}'."); } else if (!shouldExist && fileExists) { errors.Add($"Shader file should not exist at '{relativePath}', but it was found."); } string contents = null; if (fileExists && (mustContainArray != null || mustNotContainArray != null)) { try { contents = System.IO.File.ReadAllText(fullPath); } catch (Exception e) { errors.Add($"Failed to read shader file '{relativePath}': {e.Message}"); } } if (!string.IsNullOrEmpty(contents) && mustContainArray != null) { foreach (var item in mustContainArray) { if (item.Type != JTokenType.String) continue; var expected = item.ToString(); if (!contents.Contains(expected, StringComparison.Ordinal)) { errors.Add($"Shader file '{relativePath}' does not contain required text: \"{expected}\"."); } } } if (!string.IsNullOrEmpty(contents) && mustNotContainArray != null) { foreach (var item in mustNotContainArray) { if (item.Type != JTokenType.String) continue; var forbidden = item.ToString(); if (contents.Contains(forbidden, StringComparison.Ordinal)) { errors.Add($"Shader file '{relativePath}' contains forbidden text: \"{forbidden}\"."); } } } if (errors.Count > 0) { var msg = "Shader file validation failed: " + string.Join("; ", errors); Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary { ["name"] = name, ["path"] = relativePath, ["exists"] = fileExists, ["errors"] = errors } }; } var okMsg = $"Shader file validation OK for '{relativePath}'."; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary { ["name"] = name, ["path"] = relativePath, ["exists"] = fileExists } }; } /// /// Validates that a material is using the expected shader for the current SRP, /// given a shader_for_srp mapping (same structure as unity_shader.ensure_material_shader_for_srp). /// /// tool_name: "codely.validate_material_shader_for_srp" /// /// Parameters: /// { /// "material_path": "Assets/Materials/CodelyShaderTestMat.mat", /// "shader_for_srp": { "builtin": "Standard", "urp": "Universal Render Pipeline/Lit", "hdrp": "HDRP/Lit" } /// } /// [ExecuteCustomTool.CustomTool("codely.validate_material_shader_for_srp", "Validate material shader against SRP mapping")] public static object ValidateMaterialShaderForSrp(JObject parameters) { var materialPath = parameters?["material_path"]?.ToString() ?? parameters?["material"]?.ToString(); if (string.IsNullOrEmpty(materialPath)) { return new Dictionary { ["success"] = false, ["message"] = "Parameter 'material_path' (or legacy 'material') is required", ["data"] = new Dictionary() }; } var shaderMapping = parameters?["shader_for_srp"] as JObject; if (shaderMapping == null) { return new Dictionary { ["success"] = false, ["message"] = "Parameter 'shader_for_srp' is required", ["data"] = new Dictionary() }; } if (!shaderMapping.ContainsKey("builtin")) { return new Dictionary { ["success"] = false, ["message"] = "shader_for_srp.builtin is required as fallback shader", ["data"] = new Dictionary() }; } try { var material = AssetDatabase.LoadAssetAtPath(materialPath); if (material == null) { return new Dictionary { ["success"] = false, ["message"] = $"Material not found at: {materialPath}", ["data"] = new Dictionary() }; } // Detect current SRP (same logic as ManageShader) var currentSrp = "builtin"; var currentRP = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline; if (currentRP != null) { var rpName = currentRP.GetType().Name.ToLowerInvariant(); var rpFullName = currentRP.GetType().FullName.ToLowerInvariant(); if (rpName.Contains("urp") || rpName.Contains("universal") || rpFullName.Contains("universal")) { currentSrp = "urp"; } else if (rpName.Contains("hdrp") || rpName.Contains("highdefinition") || rpFullName.Contains("highdefinition")) { currentSrp = "hdrp"; } } // Resolve expected shader name based on SRP string expectedShaderName = null; if (currentSrp == "urp" && shaderMapping.ContainsKey("urp")) { expectedShaderName = shaderMapping["urp"]?.ToString(); } else if (currentSrp == "hdrp" && shaderMapping.ContainsKey("hdrp")) { expectedShaderName = shaderMapping["hdrp"]?.ToString(); } else if (shaderMapping.ContainsKey("builtin")) { expectedShaderName = shaderMapping["builtin"]?.ToString(); } if (string.IsNullOrEmpty(expectedShaderName)) { var msgNoMap = $"No shader mapping provided for current SRP: {currentSrp}"; Debug.LogError($"[CodelyValidation] {msgNoMap}"); return new Dictionary { ["success"] = false, ["message"] = msgNoMap, ["data"] = new Dictionary() }; } var actualShaderName = material.shader != null ? material.shader.name : "None"; if (!string.Equals(actualShaderName, expectedShaderName, StringComparison.Ordinal)) { var msgMismatch = $"Material '{materialPath}' shader mismatch for SRP '{currentSrp}'. Expected='{expectedShaderName}', Actual='{actualShaderName}'."; Debug.LogError($"[CodelyValidation] {msgMismatch}"); return new Dictionary { ["success"] = false, ["message"] = msgMismatch, ["data"] = new Dictionary { ["material"] = materialPath, ["currentSrp"] = currentSrp, ["expectedShader"] = expectedShaderName, ["actualShader"] = actualShaderName } }; } var okMsg = $"Material '{materialPath}' shader is correct for SRP '{currentSrp}': '{actualShaderName}'."; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary { ["material"] = materialPath, ["currentSrp"] = currentSrp, ["shader"] = actualShaderName } }; } catch (Exception e) { var msg = $"Failed to validate material shader for SRP: {e.Message}"; Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary() }; } } /// /// Simple render pipeline validation helper. /// /// tool_name: "codely.validate_render_pipeline" /// /// Parameters: /// { /// "expected": "builtin" | "urp" | "hdrp" // Optional, if provided will be checked /// } /// [ExecuteCustomTool.CustomTool("codely.validate_render_pipeline", "Validate current render pipeline SRP value")] public static object ValidateRenderPipeline(JObject parameters) { var expected = parameters?["expected"]?.ToString(); try { var srp = "builtin"; var currentRP = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline; if (currentRP != null) { var rpName = currentRP.GetType().Name.ToLowerInvariant(); var rpFullName = currentRP.GetType().FullName.ToLowerInvariant(); if (rpName.Contains("urp") || rpName.Contains("universal") || rpFullName.Contains("universal")) { srp = "urp"; } else if (rpName.Contains("hdrp") || rpName.Contains("highdefinition") || rpFullName.Contains("highdefinition")) { srp = "hdrp"; } } var errors = new List(); if (!string.IsNullOrEmpty(expected) && !string.Equals(expected, srp, StringComparison.OrdinalIgnoreCase)) { errors.Add($"SRP mismatch. Expected='{expected}', Actual='{srp}'."); } if (errors.Count > 0) { var msgErr = "Render pipeline validation failed: " + string.Join("; ", errors); Debug.LogError($"[CodelyValidation] {msgErr}"); return new Dictionary { ["success"] = false, ["message"] = msgErr, ["data"] = new Dictionary { ["srp"] = srp, ["errors"] = errors } }; } var okMsg = $"Render pipeline validation OK. srp='{srp}'."; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary { ["srp"] = srp } }; } catch (Exception e) { var msg = $"Failed to validate render pipeline: {e.Message}"; Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary() }; } } // ===================================================================== // Scene Validation Tools // ===================================================================== /// /// Validates the active scene matches expected name and/or path. /// /// tool_name: "codely.validate_active_scene" /// /// Parameters: /// { /// "expectedName": "SceneName", // Optional: expected scene name (without .unity) /// "expectedPath": "Assets/Scenes/SceneName.unity" // Optional: expected full asset path /// } /// [ExecuteCustomTool.CustomTool("codely.validate_active_scene", "Validate active scene name and path")] public static object ValidateActiveScene(JObject parameters) { var expectedName = parameters?["expectedName"]?.ToString(); var expectedPath = parameters?["expectedPath"]?.ToString(); if (string.IsNullOrEmpty(expectedName) && string.IsNullOrEmpty(expectedPath)) { return new Dictionary { ["success"] = false, ["message"] = "At least one of 'expectedName' or 'expectedPath' is required", ["data"] = new Dictionary() }; } var activeScene = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene(); if (!activeScene.IsValid()) { return new Dictionary { ["success"] = false, ["message"] = "No valid active scene", ["data"] = new Dictionary() }; } var errors = new List(); var actualName = activeScene.name; var actualPath = activeScene.path; if (!string.IsNullOrEmpty(expectedName) && !string.Equals(expectedName, actualName, StringComparison.OrdinalIgnoreCase)) { errors.Add($"Scene name mismatch: expected='{expectedName}', actual='{actualName}'"); } if (!string.IsNullOrEmpty(expectedPath) && !string.Equals(expectedPath, actualPath, StringComparison.OrdinalIgnoreCase)) { errors.Add($"Scene path mismatch: expected='{expectedPath}', actual='{actualPath}'"); } if (errors.Count > 0) { var msg = string.Join("; ", errors); Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary { ["expectedName"] = expectedName, ["expectedPath"] = expectedPath, ["actualName"] = actualName, ["actualPath"] = actualPath } }; } var okMsg = $"Active scene validation OK: name='{actualName}', path='{actualPath}'"; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary { ["actualName"] = actualName, ["actualPath"] = actualPath } }; } /// /// Validates the active scene's dirty state. /// /// tool_name: "codely.validate_scene_dirty" /// /// Parameters: /// { /// "expectedDirty": true/false /// } /// [ExecuteCustomTool.CustomTool("codely.validate_scene_dirty", "Validate scene dirty state")] public static object ValidateSceneDirty(JObject parameters) { var expectedDirty = parameters?["expectedDirty"]?.ToObject() ?? parameters?["isDirty"]?.ToObject(); if (!expectedDirty.HasValue) { return new Dictionary { ["success"] = false, ["message"] = "Parameter 'expectedDirty' (or 'isDirty') is required", ["data"] = new Dictionary() }; } var activeScene = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene(); if (!activeScene.IsValid()) { return new Dictionary { ["success"] = false, ["message"] = "No valid active scene", ["data"] = new Dictionary() }; } var actualDirty = activeScene.isDirty; if (expectedDirty.Value != actualDirty) { var msg = $"Scene dirty state mismatch: expected={expectedDirty.Value}, actual={actualDirty}"; Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary { ["expectedDirty"] = expectedDirty.Value, ["actualDirty"] = actualDirty, ["sceneName"] = activeScene.name } }; } var okMsg = $"Scene dirty state OK: isDirty={actualDirty} (scene='{activeScene.name}')"; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary { ["isDirty"] = actualDirty, ["sceneName"] = activeScene.name } }; } /// /// Validates the hierarchy root count of the active scene. /// /// tool_name: "codely.validate_hierarchy_root_count" /// /// Parameters: /// { /// "minCount": 0, /// "maxCount": 100 /// } /// [ExecuteCustomTool.CustomTool("codely.validate_hierarchy_root_count", "Validate scene hierarchy root count")] public static object ValidateHierarchyRootCount(JObject parameters) { var minCount = parameters?["minCount"]?.ToObject() ?? 0; var maxCount = parameters?["maxCount"]?.ToObject() ?? int.MaxValue; var activeScene = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene(); if (!activeScene.IsValid() || !activeScene.isLoaded) { return new Dictionary { ["success"] = false, ["message"] = "No valid and loaded active scene", ["data"] = new Dictionary() }; } var actualCount = activeScene.rootCount; if (actualCount < minCount || actualCount > maxCount) { var msg = $"Hierarchy root count out of range: expected [{minCount}, {maxCount}], actual={actualCount}"; Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary { ["minCount"] = minCount, ["maxCount"] = maxCount, ["actualCount"] = actualCount } }; } var okMsg = $"Hierarchy root count OK: {actualCount} (range [{minCount}, {maxCount}])"; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary { ["actualCount"] = actualCount } }; } // ===================================================================== // GameObject Validation Tools // ===================================================================== /// /// Validates that a GameObject exists in the scene. /// /// tool_name: "codely.validate_gameobject_exists" /// /// Parameters: /// { /// "name": "GameObjectName", /// "shouldExist": true/false // default: true /// } /// [ExecuteCustomTool.CustomTool("codely.validate_gameobject_exists", "Validate GameObject existence")] public static object ValidateGameObjectExists(JObject parameters) { var name = parameters?["name"]?.ToString(); var shouldExist = parameters?["shouldExist"]?.ToObject() ?? true; if (string.IsNullOrEmpty(name)) { return new Dictionary { ["success"] = false, ["message"] = "Parameter 'name' is required", ["data"] = new Dictionary() }; } var go = GameObject.Find(name); var exists = go != null; if (exists != shouldExist) { var msg = shouldExist ? $"GameObject '{name}' not found but expected to exist" : $"GameObject '{name}' found but expected NOT to exist"; Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary { ["name"] = name, ["shouldExist"] = shouldExist, ["actuallyExists"] = exists } }; } var okMsg = shouldExist ? $"GameObject '{name}' exists as expected" : $"GameObject '{name}' does not exist as expected"; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary { ["name"] = name, ["exists"] = exists } }; } /// /// Cleans up leftover test GameObjects in the active scene by name pattern. /// /// tool_name: "codely.cleanup_test_objects" /// /// Parameters: /// { /// "namePrefixes"?: ["Test"], // 按前缀匹配名称,可选 /// "exactNames"?: ["ParentObject", ...], // 精确名称列表,可选 /// "contains"?: ["Prefab"], // 按子串匹配名称,可选 /// "includeInactive"?: true, // 是否包含 inactive 对象,默认 true /// "logOnly"?: false, // 仅输出将被删除的对象,不实际删除 /// "maxDeleted"?: 500 // 安全上限,超过则报错避免误删 /// } /// /// 如果未提供任何匹配条件,默认使用 namePrefixes = ["Test"] 和 contains = ["Prefab"], /// 以覆盖本测试规范中常见的测试对象命名(Test* / *Prefab*)。 /// [ExecuteCustomTool.CustomTool("codely.cleanup_test_objects", "Cleanup leftover test GameObjects by name pattern")] public static object CleanupTestObjects(JObject parameters) { // 解析参数 var namePrefixes = ToStringList(parameters?["namePrefixes"] as JArray); var exactNames = ToStringList(parameters?["exactNames"] as JArray); var contains = ToStringList(parameters?["contains"] as JArray); var includeInactive = parameters?["includeInactive"]?.ToObject() ?? true; var logOnly = parameters?["logOnly"]?.ToObject() ?? false; var maxDeleted = parameters?["maxDeleted"]?.ToObject() ?? 500; // 如果没有任何过滤条件,使用一套保守的默认规则 if (namePrefixes.Count == 0 && exactNames.Count == 0 && contains.Count == 0) { namePrefixes.Add("Test"); contains.Add("Prefab"); } var activeScene = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene(); if (!activeScene.IsValid() || !activeScene.isLoaded) { return new Dictionary { ["success"] = false, ["message"] = "No valid and loaded active scene for cleanup", ["data"] = new Dictionary() }; } var toDelete = new HashSet(); var visited = new HashSet(); foreach (var root in activeScene.GetRootGameObjects()) { CollectMatchingGameObjectsRecursive( root, namePrefixes, exactNames, contains, includeInactive, toDelete, visited ); } var totalCandidates = toDelete.Count; if (totalCandidates == 0) { var msgNone = "No matching test GameObjects found to cleanup."; Debug.Log($"[CodelyCleanup] {msgNone}"); return new Dictionary { ["success"] = true, ["message"] = msgNone, ["data"] = new Dictionary { ["deletedCount"] = 0, ["candidates"] = new List() } }; } if (totalCandidates > maxDeleted) { var msgTooMany = $"Cleanup would delete {totalCandidates} GameObjects which exceeds safety limit maxDeleted={maxDeleted}. Aborting."; Debug.LogError($"[CodelyCleanup] {msgTooMany}"); return new Dictionary { ["success"] = false, ["message"] = msgTooMany, ["data"] = new Dictionary { ["candidateCount"] = totalCandidates, ["maxDeleted"] = maxDeleted } }; } var deletedNames = new List(); if (logOnly) { foreach (var go in toDelete) { if (go != null) { deletedNames.Add(go.name); } } var msgLogOnly = $"[Dry Run] Found {deletedNames.Count} GameObjects matching cleanup filters. No objects were deleted."; Debug.Log($"[CodelyCleanup] {msgLogOnly}"); return new Dictionary { ["success"] = true, ["message"] = msgLogOnly, ["data"] = new Dictionary { ["deletedCount"] = 0, ["candidates"] = deletedNames } }; } foreach (var go in toDelete) { if (go == null) continue; deletedNames.Add(go.name); Undo.DestroyObjectImmediate(go); } var summary = $"Deleted {deletedNames.Count} GameObjects by cleanup_test_objects."; Debug.Log($"[CodelyCleanup] {summary}"); return new Dictionary { ["success"] = true, ["message"] = summary, ["data"] = new Dictionary { ["deletedCount"] = deletedNames.Count, ["deletedNames"] = deletedNames } }; } private static List ToStringList(JArray array) { var result = new List(); if (array == null) return result; foreach (var item in array) { if (item.Type == JTokenType.String) { var value = item.ToString(); if (!string.IsNullOrEmpty(value)) { result.Add(value); } } } return result; } private static void CollectMatchingGameObjectsRecursive( GameObject go, List namePrefixes, List exactNames, List contains, bool includeInactive, HashSet matches, HashSet visited ) { if (go == null) return; if (visited.Contains(go)) return; visited.Add(go); if (includeInactive || go.activeInHierarchy) { if (MatchesNameFilters(go.name, namePrefixes, exactNames, contains)) { matches.Add(go); } } var transform = go.transform; var childCount = transform.childCount; for (var i = 0; i < childCount; i++) { var child = transform.GetChild(i); if (child != null) { CollectMatchingGameObjectsRecursive( child.gameObject, namePrefixes, exactNames, contains, includeInactive, matches, visited ); } } } private static bool MatchesNameFilters( string name, List namePrefixes, List exactNames, List contains ) { if (string.IsNullOrEmpty(name)) return false; foreach (var exact in exactNames) { if (!string.IsNullOrEmpty(exact) && string.Equals(name, exact, StringComparison.Ordinal)) { return true; } } foreach (var prefix in namePrefixes) { if (!string.IsNullOrEmpty(prefix) && name.StartsWith(prefix, StringComparison.Ordinal)) { return true; } } foreach (var part in contains) { if (!string.IsNullOrEmpty(part) && name.IndexOf(part, StringComparison.Ordinal) >= 0) { return true; } } return false; } // ===================================================================== // Window Validation Tools // ===================================================================== /// /// Validates that an Editor window is open. /// /// tool_name: "codely.validate_window_open" /// /// Parameters: /// { /// "windowType": "Console" | "Inspector" | "Hierarchy" | "Project" | "Scene" | "Game", /// "shouldBeOpen": true/false // default: true /// } /// [ExecuteCustomTool.CustomTool("codely.validate_window_open", "Validate Editor window is open")] public static object ValidateWindowOpen(JObject parameters) { var windowType = parameters?["windowType"]?.ToString() ?? parameters?["windowTitle"]?.ToString(); var shouldBeOpen = parameters?["shouldBeOpen"]?.ToObject() ?? true; if (string.IsNullOrEmpty(windowType)) { return new Dictionary { ["success"] = false, ["message"] = "Parameter 'windowType' is required", ["data"] = new Dictionary() }; } // Map common names to actual EditorWindow types var typeMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "Console", "UnityEditor.ConsoleWindow" }, { "Inspector", "UnityEditor.InspectorWindow" }, { "Hierarchy", "UnityEditor.SceneHierarchyWindow" }, { "Project", "UnityEditor.ProjectBrowser" }, { "Scene", "UnityEditor.SceneView" }, { "Game", "UnityEditor.GameView" }, { "Animator", "UnityEditor.Graphs.AnimatorControllerTool" }, { "Animation", "UnityEditor.AnimationWindow" }, { "Profiler", "UnityEditor.ProfilerWindow" } }; bool isOpen = false; string actualTypeName = windowType; if (typeMap.TryGetValue(windowType, out var fullTypeName)) { actualTypeName = fullTypeName; } // Check if any window of this type is open var allWindows = Resources.FindObjectsOfTypeAll(); foreach (var window in allWindows) { var winTypeName = window.GetType().FullName; if (winTypeName.Equals(actualTypeName, StringComparison.OrdinalIgnoreCase) || winTypeName.EndsWith("." + windowType, StringComparison.OrdinalIgnoreCase) || window.titleContent.text.Equals(windowType, StringComparison.OrdinalIgnoreCase)) { isOpen = true; break; } } if (isOpen != shouldBeOpen) { var msg = shouldBeOpen ? $"Window '{windowType}' is not open but expected to be open" : $"Window '{windowType}' is open but expected to be closed"; Debug.LogError($"[CodelyValidation] {msg}"); return new Dictionary { ["success"] = false, ["message"] = msg, ["data"] = new Dictionary { ["windowType"] = windowType, ["shouldBeOpen"] = shouldBeOpen, ["actuallyOpen"] = isOpen } }; } var okMsg = shouldBeOpen ? $"Window '{windowType}' is open as expected" : $"Window '{windowType}' is closed as expected"; Debug.Log($"[CodelyValidation] {okMsg}"); return new Dictionary { ["success"] = true, ["message"] = okMsg, ["data"] = new Dictionary { ["windowType"] = windowType, ["isOpen"] = isOpen } }; } } }