Files
Fishing2/Packages/cn.tuanjie.codely.bridge/Editor/Tools/CodelyUnityValidationTools.cs
2026-03-09 17:50:20 +08:00

1687 lines
66 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using Codely.Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// 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.
/// </summary>
public static class CodelyUnityValidationTools
{
/// <summary>
/// Validates current play mode against an expected value.
/// tool_name: "codely.validate_play_mode"
///
/// Parameters:
/// {
/// "expected": "stopped" | "playing" | "paused"
/// }
/// </summary>
[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<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object>
{
["expected"] = expected,
["actual"] = actual
}
};
}
var okMsg = $"PlayMode OK: {actual}";
Debug.Log($"[CodelyValidation] {okMsg}");
return new Dictionary<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object>
{
["expected"] = expected,
["actual"] = actual
}
};
}
/// <summary>
/// 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
/// }
/// </summary>
[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<int?>() ?? 1;
var typesToken = parameters?["types"] as JArray;
var types = new List<string>();
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<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object>
{
["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<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object>
{
["filterText"] = filterText,
["types"] = types,
["matchedCount"] = matchedCount,
["messages"] = logEntries
}
};
}
/// <summary>
/// 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"
/// }
/// </summary>
[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<string>();
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<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object>
{
["tagName"] = tagName,
["layerName"] = layerName
}
};
}
var okMsg = $"Tag/Layer validation OK. Tag='{tagName}', Layer='{layerName}'.";
Debug.Log($"[CodelyValidation] {okMsg}");
return new Dictionary<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object>
{
["tagName"] = tagName,
["layerName"] = layerName
}
};
}
/// <summary>
/// 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
/// }
/// </summary>
[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<bool?>();
var messageContains = parameters?["messageContains"]?.ToString();
var responseData = parameters?["responseData"] as JObject;
var errors = new List<string>();
if (responseData == null)
{
return new Dictionary<string, object>
{
["success"] = false,
["message"] = "No responseData provided for validation",
["data"] = new Dictionary<string, object>()
};
}
// 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<bool?>() ?? 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<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object> { ["errors"] = errors }
};
}
var okMsg = "Response validation OK";
Debug.Log($"[CodelyValidation] {okMsg}");
return new Dictionary<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object>()
};
}
/// <summary>
/// Validates that the active editor tool matches expected.
///
/// tool_name: "codely.validate_active_tool"
///
/// Parameters:
/// {
/// "expected": "Move" | "Rotate" | "Scale" | "View" | "Rect" | "Transform"
/// }
/// </summary>
[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<string, object>
{
["success"] = false,
["message"] = "Parameter 'expected' is required",
["data"] = new Dictionary<string, object>()
};
}
var currentTool = UnityEditor.Tools.current;
var actual = currentTool.ToString();
// Map Unity's Tool enum to friendly names
var toolNameMap = new Dictionary<UnityEditor.Tool, string>
{
{ 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<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object>
{
["expected"] = expected,
["actual"] = actual
}
};
}
var okMsg = $"Active tool OK: {actual}";
Debug.Log($"[CodelyValidation] {okMsg}");
return new Dictionary<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object>
{
["expected"] = expected,
["actual"] = actual
}
};
}
/// <summary>
/// Validates editor is not compiling.
///
/// tool_name: "codely.validate_not_compiling"
/// </summary>
[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<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object> { ["isCompiling"] = true }
};
}
var okMsg = "Editor is not compiling";
Debug.Log($"[CodelyValidation] {okMsg}");
return new Dictionary<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object> { ["isCompiling"] = false }
};
}
/// <summary>
/// Validates console message count within expected range.
///
/// tool_name: "codely.validate_console_count"
///
/// Parameters:
/// {
/// "minCount": 0,
/// "maxCount": 100,
/// "types": ["error", "warning", "log"]
/// }
/// </summary>
[ExecuteCustomTool.CustomTool("codely.validate_console_count", "Validate console message count")]
public static object ValidateConsoleCount(JObject parameters)
{
var minCount = parameters?["minCount"]?.ToObject<int?>() ?? 0;
var maxCount = parameters?["maxCount"]?.ToObject<int?>() ?? int.MaxValue;
var typesToken = parameters?["types"] as JArray;
var types = new List<string>();
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<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object>
{
["count"] = count,
["minCount"] = minCount,
["maxCount"] = maxCount
}
};
}
var okMsg = $"Console count OK: {count} (range [{minCount}, {maxCount}])";
Debug.Log($"[CodelyValidation] {okMsg}");
return new Dictionary<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object>
{
["count"] = count,
["minCount"] = minCount,
["maxCount"] = maxCount
}
};
}
// ---------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------
/// <summary>
/// Recursively searches for a field in a JToken tree.
/// Returns the found value and its path, or null if not found.
/// </summary>
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";
}
/// <summary>
/// Reads current console messages via Unity's internal LogEntries API.
/// We keep this intentionally simple: only type + message text for validation.
/// </summary>
private static List<Dictionary<string, object>> ReadConsoleMessages(List<string> allowedTypes, string filterText)
{
var results = new List<Dictionary<string, object>>();
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<string, object>
{
["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
// =====================================================================
/// <summary>
/// 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
/// }
/// </summary>
[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<string, object>
{
["success"] = false,
["message"] = "Parameter 'name' is required",
["data"] = new Dictionary<string, object>()
};
}
// 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<bool?>() ?? true;
var mustContainArray = parameters?["mustContain"] as JArray;
var mustNotContainArray = parameters?["mustNotContain"] as JArray;
var errors = new List<string>();
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<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object>
{
["name"] = name,
["path"] = relativePath,
["exists"] = fileExists,
["errors"] = errors
}
};
}
var okMsg = $"Shader file validation OK for '{relativePath}'.";
Debug.Log($"[CodelyValidation] {okMsg}");
return new Dictionary<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object>
{
["name"] = name,
["path"] = relativePath,
["exists"] = fileExists
}
};
}
/// <summary>
/// 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" }
/// }
/// </summary>
[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<string, object>
{
["success"] = false,
["message"] = "Parameter 'material_path' (or legacy 'material') is required",
["data"] = new Dictionary<string, object>()
};
}
var shaderMapping = parameters?["shader_for_srp"] as JObject;
if (shaderMapping == null)
{
return new Dictionary<string, object>
{
["success"] = false,
["message"] = "Parameter 'shader_for_srp' is required",
["data"] = new Dictionary<string, object>()
};
}
if (!shaderMapping.ContainsKey("builtin"))
{
return new Dictionary<string, object>
{
["success"] = false,
["message"] = "shader_for_srp.builtin is required as fallback shader",
["data"] = new Dictionary<string, object>()
};
}
try
{
var material = AssetDatabase.LoadAssetAtPath<Material>(materialPath);
if (material == null)
{
return new Dictionary<string, object>
{
["success"] = false,
["message"] = $"Material not found at: {materialPath}",
["data"] = new Dictionary<string, object>()
};
}
// 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<string, object>
{
["success"] = false,
["message"] = msgNoMap,
["data"] = new Dictionary<string, object>()
};
}
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<string, object>
{
["success"] = false,
["message"] = msgMismatch,
["data"] = new Dictionary<string, object>
{
["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<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object>
{
["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<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object>()
};
}
}
/// <summary>
/// Simple render pipeline validation helper.
///
/// tool_name: "codely.validate_render_pipeline"
///
/// Parameters:
/// {
/// "expected": "builtin" | "urp" | "hdrp" // Optional, if provided will be checked
/// }
/// </summary>
[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<string>();
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<string, object>
{
["success"] = false,
["message"] = msgErr,
["data"] = new Dictionary<string, object>
{
["srp"] = srp,
["errors"] = errors
}
};
}
var okMsg = $"Render pipeline validation OK. srp='{srp}'.";
Debug.Log($"[CodelyValidation] {okMsg}");
return new Dictionary<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object>
{
["srp"] = srp
}
};
}
catch (Exception e)
{
var msg = $"Failed to validate render pipeline: {e.Message}";
Debug.LogError($"[CodelyValidation] {msg}");
return new Dictionary<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object>()
};
}
}
// =====================================================================
// Scene Validation Tools
// =====================================================================
/// <summary>
/// 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
/// }
/// </summary>
[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<string, object>
{
["success"] = false,
["message"] = "At least one of 'expectedName' or 'expectedPath' is required",
["data"] = new Dictionary<string, object>()
};
}
var activeScene = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene();
if (!activeScene.IsValid())
{
return new Dictionary<string, object>
{
["success"] = false,
["message"] = "No valid active scene",
["data"] = new Dictionary<string, object>()
};
}
var errors = new List<string>();
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<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object>
{
["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<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object>
{
["actualName"] = actualName,
["actualPath"] = actualPath
}
};
}
/// <summary>
/// Validates the active scene's dirty state.
///
/// tool_name: "codely.validate_scene_dirty"
///
/// Parameters:
/// {
/// "expectedDirty": true/false
/// }
/// </summary>
[ExecuteCustomTool.CustomTool("codely.validate_scene_dirty", "Validate scene dirty state")]
public static object ValidateSceneDirty(JObject parameters)
{
var expectedDirty = parameters?["expectedDirty"]?.ToObject<bool?>() ?? parameters?["isDirty"]?.ToObject<bool?>();
if (!expectedDirty.HasValue)
{
return new Dictionary<string, object>
{
["success"] = false,
["message"] = "Parameter 'expectedDirty' (or 'isDirty') is required",
["data"] = new Dictionary<string, object>()
};
}
var activeScene = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene();
if (!activeScene.IsValid())
{
return new Dictionary<string, object>
{
["success"] = false,
["message"] = "No valid active scene",
["data"] = new Dictionary<string, object>()
};
}
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<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object>
{
["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<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object>
{
["isDirty"] = actualDirty,
["sceneName"] = activeScene.name
}
};
}
/// <summary>
/// Validates the hierarchy root count of the active scene.
///
/// tool_name: "codely.validate_hierarchy_root_count"
///
/// Parameters:
/// {
/// "minCount": 0,
/// "maxCount": 100
/// }
/// </summary>
[ExecuteCustomTool.CustomTool("codely.validate_hierarchy_root_count", "Validate scene hierarchy root count")]
public static object ValidateHierarchyRootCount(JObject parameters)
{
var minCount = parameters?["minCount"]?.ToObject<int?>() ?? 0;
var maxCount = parameters?["maxCount"]?.ToObject<int?>() ?? int.MaxValue;
var activeScene = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene();
if (!activeScene.IsValid() || !activeScene.isLoaded)
{
return new Dictionary<string, object>
{
["success"] = false,
["message"] = "No valid and loaded active scene",
["data"] = new Dictionary<string, object>()
};
}
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<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object>
{
["minCount"] = minCount,
["maxCount"] = maxCount,
["actualCount"] = actualCount
}
};
}
var okMsg = $"Hierarchy root count OK: {actualCount} (range [{minCount}, {maxCount}])";
Debug.Log($"[CodelyValidation] {okMsg}");
return new Dictionary<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object>
{
["actualCount"] = actualCount
}
};
}
// =====================================================================
// GameObject Validation Tools
// =====================================================================
/// <summary>
/// Validates that a GameObject exists in the scene.
///
/// tool_name: "codely.validate_gameobject_exists"
///
/// Parameters:
/// {
/// "name": "GameObjectName",
/// "shouldExist": true/false // default: true
/// }
/// </summary>
[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<bool?>() ?? true;
if (string.IsNullOrEmpty(name))
{
return new Dictionary<string, object>
{
["success"] = false,
["message"] = "Parameter 'name' is required",
["data"] = new Dictionary<string, object>()
};
}
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<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object>
{
["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<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object>
{
["name"] = name,
["exists"] = exists
}
};
}
/// <summary>
/// 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*)。
/// </summary>
[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<bool?>() ?? true;
var logOnly = parameters?["logOnly"]?.ToObject<bool?>() ?? false;
var maxDeleted = parameters?["maxDeleted"]?.ToObject<int?>() ?? 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<string, object>
{
["success"] = false,
["message"] = "No valid and loaded active scene for cleanup",
["data"] = new Dictionary<string, object>()
};
}
var toDelete = new HashSet<GameObject>();
var visited = new HashSet<GameObject>();
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<string, object>
{
["success"] = true,
["message"] = msgNone,
["data"] = new Dictionary<string, object>
{
["deletedCount"] = 0,
["candidates"] = new List<string>()
}
};
}
if (totalCandidates > maxDeleted)
{
var msgTooMany =
$"Cleanup would delete {totalCandidates} GameObjects which exceeds safety limit maxDeleted={maxDeleted}. Aborting.";
Debug.LogError($"[CodelyCleanup] {msgTooMany}");
return new Dictionary<string, object>
{
["success"] = false,
["message"] = msgTooMany,
["data"] = new Dictionary<string, object>
{
["candidateCount"] = totalCandidates,
["maxDeleted"] = maxDeleted
}
};
}
var deletedNames = new List<string>();
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<string, object>
{
["success"] = true,
["message"] = msgLogOnly,
["data"] = new Dictionary<string, object>
{
["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<string, object>
{
["success"] = true,
["message"] = summary,
["data"] = new Dictionary<string, object>
{
["deletedCount"] = deletedNames.Count,
["deletedNames"] = deletedNames
}
};
}
private static List<string> ToStringList(JArray array)
{
var result = new List<string>();
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<string> namePrefixes,
List<string> exactNames,
List<string> contains,
bool includeInactive,
HashSet<GameObject> matches,
HashSet<GameObject> 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<string> namePrefixes,
List<string> exactNames,
List<string> 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
// =====================================================================
/// <summary>
/// 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
/// }
/// </summary>
[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<bool?>() ?? true;
if (string.IsNullOrEmpty(windowType))
{
return new Dictionary<string, object>
{
["success"] = false,
["message"] = "Parameter 'windowType' is required",
["data"] = new Dictionary<string, object>()
};
}
// Map common names to actual EditorWindow types
var typeMap = new Dictionary<string, string>(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<EditorWindow>();
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<string, object>
{
["success"] = false,
["message"] = msg,
["data"] = new Dictionary<string, object>
{
["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<string, object>
{
["success"] = true,
["message"] = okMsg,
["data"] = new Dictionary<string, object>
{
["windowType"] = windowType,
["isOpen"] = isOpen
}
};
}
}
}