Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions MCPForUnity/Editor/Helpers/ParamCoercion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,65 @@ public static T CoerceEnum<T>(JToken token, T defaultValue) where T : struct, En
return defaultValue;
}

/// <summary>
/// Checks if a JToken represents a numeric value (integer or float).
/// Useful for validating JSON values before parsing.
/// </summary>
/// <param name="token">The JSON token to check</param>
/// <returns>True if the token is an integer or float, false otherwise</returns>
public static bool IsNumericToken(JToken token)
{
return token != null && (token.Type == JTokenType.Integer || token.Type == JTokenType.Float);
}

/// <summary>
/// Validates that an optional field in a JObject is numeric if present.
/// Used for dry-run validation of complex type formats.
/// </summary>
/// <param name="obj">The JSON object containing the field</param>
/// <param name="fieldName">The name of the field to validate</param>
/// <param name="error">Output error message if validation fails</param>
/// <returns>True if the field is absent, null, or numeric; false if present but non-numeric</returns>
public static bool ValidateNumericField(JObject obj, string fieldName, out string error)
{
error = null;
var token = obj[fieldName];
if (token == null || token.Type == JTokenType.Null)
{
return true; // Field not present, valid (will use default)
}
if (!IsNumericToken(token))
{
error = $"must be a number, got {token.Type}";
return false;
}
return true;
}

/// <summary>
/// Validates that an optional field in a JObject is an integer if present.
/// Used for dry-run validation of complex type formats.
/// </summary>
/// <param name="obj">The JSON object containing the field</param>
/// <param name="fieldName">The name of the field to validate</param>
/// <param name="error">Output error message if validation fails</param>
/// <returns>True if the field is absent, null, or integer; false if present but non-integer</returns>
public static bool ValidateIntegerField(JObject obj, string fieldName, out string error)
{
error = null;
var token = obj[fieldName];
if (token == null || token.Type == JTokenType.Null)
{
return true; // Field not present, valid
}
if (token.Type != JTokenType.Integer)
{
error = $"must be an integer, got {token.Type}";
return false;
}
return true;
}

/// <summary>
/// Normalizes a property name by removing separators and converting to camelCase.
/// Handles common naming variations from LLMs and humans.
Expand Down
209 changes: 204 additions & 5 deletions MCPForUnity/Editor/Helpers/VectorParsing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -391,11 +391,24 @@ public static Gradient ParseGradientOrDefault(JToken token)

/// <summary>
/// Parses a JToken into an AnimationCurve.
/// Supports formats:
/// - Constant: 1.0 (number)
/// - Simple: {start: 0.0, end: 1.0}
/// - Full: {keys: [{time: 0.0, value: 1.0, inTangent: 0.0, outTangent: 0.0}, ...]}
/// Added for ManageVFX refactoring.
///
/// <para><b>Supported formats:</b></para>
/// <list type="bullet">
/// <item>Constant: <c>1.0</c> (number) - Creates constant curve at that value</item>
/// <item>Simple: <c>{start: 0.0, end: 1.0}</c> or <c>{startValue: 0.0, endValue: 1.0}</c></item>
/// <item>Full: <c>{keys: [{time: 0, value: 1, inTangent: 0, outTangent: 0}, ...]}</c></item>
/// </list>
///
/// <para><b>Keyframe field defaults (for Full format):</b></para>
/// <list type="bullet">
/// <item><c>time</c> (float): <b>Default: 0</b></item>
/// <item><c>value</c> (float): <b>Default: 1</b> (note: differs from ManageScriptableObject which uses 0)</item>
/// <item><c>inTangent</c> (float): <b>Default: 0</b></item>
/// <item><c>outTangent</c> (float): <b>Default: 0</b></item>
/// </list>
///
/// <para><b>Note:</b> This method is used by ManageVFX. For ScriptableObject patching,
/// see <see cref="MCPForUnity.Editor.Tools.ManageScriptableObject"/> which has slightly different defaults.</para>
/// </summary>
/// <param name="token">The JSON token to parse</param>
/// <returns>The parsed AnimationCurve or null if parsing fails</returns>
Expand Down Expand Up @@ -459,6 +472,192 @@ public static AnimationCurve ParseAnimationCurveOrDefault(JToken token, float de
{
return ParseAnimationCurve(token) ?? AnimationCurve.Constant(0f, 1f, defaultValue);
}

/// <summary>
/// Validates AnimationCurve JSON format without parsing it.
/// Used by dry-run validation to provide early feedback on format errors.
///
/// <para><b>Validated formats:</b></para>
/// <list type="bullet">
/// <item>Wrapped: <c>{ "keys": [ { "time": 0, "value": 1.0 }, ... ] }</c></item>
/// <item>Direct array: <c>[ { "time": 0, "value": 1.0 }, ... ]</c></item>
/// <item>Null/empty: Valid (will set empty curve)</item>
/// </list>
/// </summary>
/// <param name="valueToken">The JSON value to validate</param>
/// <param name="message">Output message describing validation result or error</param>
/// <returns>True if format is valid, false otherwise</returns>
public static bool ValidateAnimationCurveFormat(JToken valueToken, out string message)
{
message = null;

if (valueToken == null || valueToken.Type == JTokenType.Null)
{
message = "Value format valid (will set empty curve).";
return true;
}

JArray keysArray = null;

if (valueToken is JObject curveObj)
{
keysArray = curveObj["keys"] as JArray;
if (keysArray == null)
{
message = "AnimationCurve object requires 'keys' array. Expected: { \"keys\": [ { \"time\": 0, \"value\": 0 }, ... ] }";
return false;
}
}
else if (valueToken is JArray directArray)
{
keysArray = directArray;
}
else
{
message = "AnimationCurve requires object with 'keys' or array of keyframes. " +
"Expected: { \"keys\": [ { \"time\": 0, \"value\": 0, \"inSlope\": 0, \"outSlope\": 0 }, ... ] }";
return false;
}

// Validate each keyframe
for (int i = 0; i < keysArray.Count; i++)
{
var keyToken = keysArray[i];
if (keyToken is not JObject keyObj)
{
message = $"Keyframe at index {i} must be an object with 'time' and 'value'.";
return false;
}

// Validate numeric fields if present
string[] numericFields = { "time", "value", "inSlope", "outSlope", "inTangent", "outTangent", "inWeight", "outWeight" };
foreach (var field in numericFields)
{
if (!ParamCoercion.ValidateNumericField(keyObj, field, out var fieldError))
{
message = $"Keyframe[{i}].{field}: {fieldError}";
return false;
}
}

if (!ParamCoercion.ValidateIntegerField(keyObj, "weightedMode", out var weightedModeError))
{
message = $"Keyframe[{i}].weightedMode: {weightedModeError}";
return false;
}
}

message = $"Value format valid (AnimationCurve with {keysArray.Count} keyframes). " +
"Note: Missing keyframe fields default to 0 (time, value, inSlope, outSlope, inWeight, outWeight).";
return true;
}

/// <summary>
/// Validates Quaternion JSON format without parsing it.
/// Used by dry-run validation to provide early feedback on format errors.
///
/// <para><b>Validated formats:</b></para>
/// <list type="bullet">
/// <item>Euler array: <c>[x, y, z]</c> - 3 numeric elements</item>
/// <item>Raw quaternion: <c>[x, y, z, w]</c> - 4 numeric elements</item>
/// <item>Object: <c>{ "x": 0, "y": 0, "z": 0, "w": 1 }</c></item>
/// <item>Explicit euler: <c>{ "euler": [x, y, z] }</c></item>
/// <item>Null/empty: Valid (will set identity)</item>
/// </list>
/// </summary>
/// <param name="valueToken">The JSON value to validate</param>
/// <param name="message">Output message describing validation result or error</param>
/// <returns>True if format is valid, false otherwise</returns>
public static bool ValidateQuaternionFormat(JToken valueToken, out string message)
{
message = null;

if (valueToken == null || valueToken.Type == JTokenType.Null)
{
message = "Value format valid (will set identity quaternion).";
return true;
}

if (valueToken is JArray arr)
{
if (arr.Count == 3)
{
// Validate Euler angles [x, y, z]
for (int i = 0; i < 3; i++)
{
if (!ParamCoercion.IsNumericToken(arr[i]))
{
message = $"Euler angle at index {i} must be a number.";
return false;
}
}
message = "Value format valid (Quaternion from Euler angles [x, y, z]).";
return true;
}
else if (arr.Count == 4)
{
// Validate raw quaternion [x, y, z, w]
for (int i = 0; i < 4; i++)
{
if (!ParamCoercion.IsNumericToken(arr[i]))
{
message = $"Quaternion component at index {i} must be a number.";
return false;
}
}
message = "Value format valid (Quaternion from [x, y, z, w]).";
return true;
}
else
{
message = "Quaternion array must have 3 elements (Euler angles) or 4 elements (x, y, z, w).";
return false;
}
}
else if (valueToken is JObject obj)
{
// Check for explicit euler property
if (obj["euler"] is JArray eulerArr)
{
if (eulerArr.Count != 3)
{
message = "Quaternion euler array must have exactly 3 elements [x, y, z].";
return false;
}
for (int i = 0; i < 3; i++)
{
if (!ParamCoercion.IsNumericToken(eulerArr[i]))
{
message = $"Euler angle at index {i} must be a number.";
return false;
}
}
message = "Value format valid (Quaternion from { euler: [x, y, z] }).";
return true;
}

// Object format { x, y, z, w }
if (obj["x"] != null && obj["y"] != null && obj["z"] != null && obj["w"] != null)
{
if (!ParamCoercion.IsNumericToken(obj["x"]) || !ParamCoercion.IsNumericToken(obj["y"]) ||
!ParamCoercion.IsNumericToken(obj["z"]) || !ParamCoercion.IsNumericToken(obj["w"]))
{
message = "Quaternion { x, y, z, w } fields must all be numbers.";
return false;
}
message = "Value format valid (Quaternion from { x, y, z, w }).";
return true;
}

message = "Quaternion object must have { x, y, z, w } or { euler: [x, y, z] }.";
return false;
}
else
{
message = "Quaternion requires array [x,y,z] (Euler), [x,y,z,w] (raw), or object { x, y, z, w }.";
return false;
}
}

/// <summary>
/// Parses a JToken into a Rect.
Expand Down
9 changes: 4 additions & 5 deletions MCPForUnity/Editor/Services/TestRunnerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,9 @@ public async Task<TestRunResult> RunTestsAsync(TestMode mode, TestFilterOptions
};
var settings = new ExecutionSettings(filter);

if (mode == TestMode.PlayMode)
{
SaveDirtyScenesIfNeeded();
}
// Save dirty scenes for all test modes to prevent modal dialogs blocking MCP
// (Issue #525: EditMode tests were blocked by save dialog)
SaveDirtyScenesIfNeeded();

_testRunnerApi.Execute(settings);

Expand Down Expand Up @@ -331,7 +330,7 @@ private static void SaveDirtyScenesIfNeeded()
{
if (string.IsNullOrEmpty(scene.path))
{
McpLog.Warn($"[TestRunnerService] Skipping unsaved scene '{scene.name}': save it manually before running PlayMode tests.");
McpLog.Warn($"[TestRunnerService] Skipping unsaved scene '{scene.name}': save it manually before running tests.");
continue;
}
try
Expand Down
Loading