using System; using System.Collections; using Newtonsoft.Json.Linq; using NUnit.Framework; using UnityEditor; using UnityEngine; using UnityEngine.TestTools; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Tools; using MCPForUnityTests.Editor.Tools.Fixtures; namespace MCPForUnityTests.Editor.Tools { public class ManageScriptableObjectTests { private const string TempRoot = "Assets/Temp/ManageScriptableObjectTests"; private const double UnityReadyTimeoutSeconds = 180.0; private string _runRoot; private string _nestedFolder; private string _createdAssetPath; private string _createdGuid; private string _matAPath; private string _matBPath; [UnitySetUp] public IEnumerator SetUp() { yield return WaitForUnityReady(UnityReadyTimeoutSeconds); EnsureFolder("Assets/Temp"); // Avoid deleting/recreating the entire TempRoot each test (can trigger heavy reimport churn). // Instead, isolate each test in its own unique subfolder under TempRoot. EnsureFolder(TempRoot); _runRoot = $"{TempRoot}/Run_{Guid.NewGuid():N}"; EnsureFolder(_runRoot); _nestedFolder = _runRoot + "/Nested/Deeper"; _createdAssetPath = null; _createdGuid = null; // Create two Materials we can reference by guid/path. _matAPath = $"{TempRoot}/MatA_{Guid.NewGuid():N}.mat"; _matBPath = $"{TempRoot}/MatB_{Guid.NewGuid():N}.mat"; var shader = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("HDRP/Lit") ?? Shader.Find("Standard") ?? Shader.Find("Unlit/Color"); Assert.IsNotNull(shader, "A fallback shader must be available for creating Material assets in tests."); AssetDatabase.CreateAsset(new Material(shader), _matAPath); AssetDatabase.CreateAsset(new Material(shader), _matBPath); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); yield return WaitForUnityReady(UnityReadyTimeoutSeconds); } [TearDown] public void TearDown() { // Best-effort cleanup if (!string.IsNullOrEmpty(_createdAssetPath) && AssetDatabase.LoadAssetAtPath(_createdAssetPath) != null) { AssetDatabase.DeleteAsset(_createdAssetPath); } if (!string.IsNullOrEmpty(_matAPath) && AssetDatabase.LoadAssetAtPath(_matAPath) != null) { AssetDatabase.DeleteAsset(_matAPath); } if (!string.IsNullOrEmpty(_matBPath) && AssetDatabase.LoadAssetAtPath(_matBPath) != null) { AssetDatabase.DeleteAsset(_matBPath); } if (!string.IsNullOrEmpty(_runRoot) && AssetDatabase.IsValidFolder(_runRoot)) { AssetDatabase.DeleteAsset(_runRoot); } // Clean up parent Temp folder if empty if (AssetDatabase.IsValidFolder("Assets/Temp")) { var remainingDirs = System.IO.Directory.GetDirectories("Assets/Temp"); var remainingFiles = System.IO.Directory.GetFiles("Assets/Temp"); if (remainingDirs.Length == 0 && remainingFiles.Length == 0) { AssetDatabase.DeleteAsset("Assets/Temp"); } } AssetDatabase.Refresh(); } [Test] public void Create_CreatesNestedFolders_PlacesAssetCorrectly() { var create = new JObject { ["action"] = "create", ["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName, ["folderPath"] = _nestedFolder, ["assetName"] = "My_Test_Def_Placement", ["overwrite"] = true, }; var raw = ManageScriptableObject.HandleCommand(create); var result = raw as JObject ?? JObject.FromObject(raw); Assert.IsTrue(result.Value("success"), result.ToString()); var data = result["data"] as JObject; Assert.IsNotNull(data, "Expected data payload"); _createdGuid = data!["guid"]?.ToString(); _createdAssetPath = data["path"]?.ToString(); Assert.IsTrue(AssetDatabase.IsValidFolder(_nestedFolder), "Nested folder should be created."); Assert.IsTrue(_createdAssetPath!.StartsWith(_nestedFolder, StringComparison.Ordinal), $"Asset should be created under {_nestedFolder}: {_createdAssetPath}"); Assert.IsTrue(_createdAssetPath.EndsWith(".asset", StringComparison.OrdinalIgnoreCase), "Asset should have .asset extension."); Assert.IsFalse(string.IsNullOrWhiteSpace(_createdGuid), "Expected guid in response."); var asset = AssetDatabase.LoadAssetAtPath(_createdAssetPath); Assert.IsNotNull(asset, "Created asset should load as TestDefinition."); } [Test] public void Create_AppliesPatches_ToCreatedAsset() { var create = new JObject { ["action"] = "create", ["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName, // Patching correctness does not depend on nested folder creation; keep this lightweight. ["folderPath"] = _runRoot, ["assetName"] = "My_Test_Def_Patches", ["overwrite"] = true, ["patches"] = new JArray { new JObject { ["propertyPath"] = "displayName", ["op"] = "set", ["value"] = "Hello" }, new JObject { ["propertyPath"] = "baseNumber", ["op"] = "set", ["value"] = 42 }, new JObject { ["propertyPath"] = "nested.note", ["op"] = "set", ["value"] = "note!" } } }; var raw = ManageScriptableObject.HandleCommand(create); var result = raw as JObject ?? JObject.FromObject(raw); Assert.IsTrue(result.Value("success"), result.ToString()); var data = result["data"] as JObject; Assert.IsNotNull(data, "Expected data payload"); _createdGuid = data!["guid"]?.ToString(); _createdAssetPath = data["path"]?.ToString(); Assert.IsTrue(_createdAssetPath!.StartsWith(_runRoot, StringComparison.Ordinal), $"Asset should be created under {_runRoot}: {_createdAssetPath}"); Assert.IsFalse(string.IsNullOrWhiteSpace(_createdGuid), "Expected guid in response."); var asset = AssetDatabase.LoadAssetAtPath(_createdAssetPath); Assert.IsNotNull(asset, "Created asset should load as TestDefinition."); Assert.AreEqual("Hello", asset!.DisplayName, "Private [SerializeField] string should be set via SerializedProperty."); Assert.AreEqual(42, asset.BaseNumber, "Inherited serialized field should be set via SerializedProperty."); Assert.AreEqual("note!", asset.NestedNote, "Nested struct field should be set via SerializedProperty path."); } [Test] public void Modify_ArrayResize_ThenAssignObjectRefs_ByGuidAndByPath() { // Create base asset first with no patches. var create = new JObject { ["action"] = "create", ["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName, ["folderPath"] = _runRoot, ["assetName"] = "Modify_Target", ["overwrite"] = true }; var createRes = ToJObject(ManageScriptableObject.HandleCommand(create)); Assert.IsTrue(createRes.Value("success"), createRes.ToString()); _createdGuid = createRes["data"]?["guid"]?.ToString(); _createdAssetPath = createRes["data"]?["path"]?.ToString(); var matAGuid = AssetDatabase.AssetPathToGUID(_matAPath); var modify = new JObject { ["action"] = "modify", ["target"] = new JObject { ["guid"] = _createdGuid }, ["patches"] = new JArray { // Resize list to 2 new JObject { ["propertyPath"] = "materials.Array.size", ["op"] = "array_resize", ["value"] = 2 }, // Assign element 0 by guid new JObject { ["propertyPath"] = "materials.Array.data[0]", ["op"] = "set", ["ref"] = new JObject { ["guid"] = matAGuid } }, // Assign element 1 by path new JObject { ["propertyPath"] = "materials.Array.data[1]", ["op"] = "set", ["ref"] = new JObject { ["path"] = _matBPath } } } }; var modRes = ToJObject(ManageScriptableObject.HandleCommand(modify)); Assert.IsTrue(modRes.Value("success"), modRes.ToString()); // Assert patch results are ok so failures are visible even if the tool returns success. var results = modRes["data"]?["results"] as JArray; Assert.IsNotNull(results, "Expected per-patch results in response."); foreach (var r in results!) { Assert.IsTrue(r.Value("ok"), $"Patch failed: {r}"); } var asset = AssetDatabase.LoadAssetAtPath(_createdAssetPath); Assert.IsNotNull(asset); Assert.AreEqual(2, asset!.Materials.Count, "List should be resized to 2."); var matA = AssetDatabase.LoadAssetAtPath(_matAPath); var matB = AssetDatabase.LoadAssetAtPath(_matBPath); Assert.AreEqual(matA, asset.Materials[0], "Element 0 should be set by GUID ref."); Assert.AreEqual(matB, asset.Materials[1], "Element 1 should be set by path ref."); } [Test] public void Errors_InvalidAction_TypeNotFound_TargetNotFound() { // invalid action var badAction = ToJObject(ManageScriptableObject.HandleCommand(new JObject { ["action"] = "nope" })); Assert.IsFalse(badAction.Value("success")); Assert.AreEqual("invalid_params", badAction.Value("error")); // type not found var badType = ToJObject(ManageScriptableObject.HandleCommand(new JObject { ["action"] = "create", ["typeName"] = "Nope.MissingType", ["folderPath"] = TempRoot, ["assetName"] = "X", })); Assert.IsFalse(badType.Value("success")); Assert.AreEqual("type_not_found", badType.Value("error")); // target not found var badTarget = ToJObject(ManageScriptableObject.HandleCommand(new JObject { ["action"] = "modify", ["target"] = new JObject { ["guid"] = "00000000000000000000000000000000" }, ["patches"] = new JArray(), })); Assert.IsFalse(badTarget.Value("success")); Assert.AreEqual("target_not_found", badTarget.Value("error")); } [Test] public void Create_RejectsNonAssetsRootFolders() { var badPackages = ToJObject(ManageScriptableObject.HandleCommand(new JObject { ["action"] = "create", ["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName, ["folderPath"] = "Packages/NotAllowed", ["assetName"] = "BadFolder", ["overwrite"] = true, })); Assert.IsFalse(badPackages.Value("success")); Assert.AreEqual("invalid_folder_path", badPackages.Value("error")); var badAbsolute = ToJObject(ManageScriptableObject.HandleCommand(new JObject { ["action"] = "create", ["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName, ["folderPath"] = "/tmp/not_allowed", ["assetName"] = "BadFolder2", ["overwrite"] = true, })); Assert.IsFalse(badAbsolute.Value("success")); Assert.AreEqual("invalid_folder_path", badAbsolute.Value("error")); var badFileUri = ToJObject(ManageScriptableObject.HandleCommand(new JObject { ["action"] = "create", ["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName, ["folderPath"] = "file:///tmp/not_allowed", ["assetName"] = "BadFolder3", ["overwrite"] = true, })); Assert.IsFalse(badFileUri.Value("success")); Assert.AreEqual("invalid_folder_path", badFileUri.Value("error")); } [Test] public void Create_NormalizesRelativeAndBackslashPaths_AndAvoidsDoubleSlashesInResult() { var create = new JObject { ["action"] = "create", ["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName, ["folderPath"] = @"Temp\ManageScriptableObjectTests\SlashProbe\\Deep", ["assetName"] = "SlashProbe", ["overwrite"] = true, }; var res = ToJObject(ManageScriptableObject.HandleCommand(create)); Assert.IsTrue(res.Value("success"), res.ToString()); var path = res["data"]?["path"]?.ToString(); Assert.IsNotNull(path, "Expected path in response."); Assert.IsTrue(path!.StartsWith("Assets/Temp/ManageScriptableObjectTests/SlashProbe/Deep", StringComparison.Ordinal), $"Expected sanitized Assets-rooted path, got: {path}"); Assert.IsFalse(path.Contains("//", StringComparison.Ordinal), $"Path should not contain double slashes: {path}"); } private static void EnsureFolder(string folderPath) { if (AssetDatabase.IsValidFolder(folderPath)) return; // Only used for Assets/... paths in tests. var sanitized = AssetPathUtility.SanitizeAssetPath(folderPath); if (string.Equals(sanitized, "Assets", StringComparison.OrdinalIgnoreCase)) return; var parts = sanitized.Split('/'); string current = "Assets"; for (int i = 1; i < parts.Length; i++) { var next = current + "/" + parts[i]; if (!AssetDatabase.IsValidFolder(next)) { AssetDatabase.CreateFolder(current, parts[i]); } current = next; } } private static JObject ToJObject(object result) { return result as JObject ?? JObject.FromObject(result); } private static IEnumerator WaitForUnityReady(double timeoutSeconds = 30.0) { // Some EditMode tests trigger script compilation/domain reload. Tools like ManageScriptableObject // intentionally return "compiling_or_reloading" during these windows. Wait until Unity is stable // to make tests deterministic. double start = EditorApplication.timeSinceStartup; while (EditorApplication.isCompiling || EditorApplication.isUpdating) { if (EditorApplication.timeSinceStartup - start > timeoutSeconds) { Assert.Fail($"Timed out waiting for Unity to finish compiling/updating (>{timeoutSeconds:0.0}s)."); } yield return null; // yield to the editor loop so importing/compiling can actually progress } } } }