diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 073b7c0..842b6a8 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -73,13 +73,16 @@ jobs: restore-keys: Library-${{ runner.os }}-${{ matrix.unityVersion }}-EditMode- - name: Run EditMode tests + env: + LIBGL_ALWAYS_SOFTWARE: '1' run: | - unity --non-interactive test . \ + xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" \ + unity --non-interactive test . \ --editor-version "${{ matrix.unityVersion }}" \ --mode EditMode \ --output Logs/TestResults/editmode.xml \ --timeout 900 \ - -- -nographics -logFile Logs/editmode.log + -- -force-glcore -logFile Logs/editmode.log - name: Return Unity license if: always() && env.UNITY_LICENSE_ACTIVATION_ATTEMPTED == 'true' diff --git a/AGENTS.md b/AGENTS.md index 85f0971..b8446b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,11 +31,12 @@ Use Unity CLI through `.github/actions/setup-unity`. ## How to run tests locally ### EditMode -Run tests through Unity CLI (which manages batch mode): +Run tests through Unity CLI (which manages batch mode). Inspector interaction and +rendering tests require graphics; run Linux tests under Xvfb as in CI: ``` unity --non-interactive test . --editor-version 2021.3.45f2 \ --mode EditMode --output Logs/TestResults/editmode.xml --timeout 900 \ - -- -nographics -logFile Logs/editmode.log + -- -logFile Logs/editmode.log ``` Repeat with `--editor-version 2023.2.22f1` for the generic/variance feature gate. diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/AddTypeMenuAttributeTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/AddTypeMenuAttributeTests.cs new file mode 100644 index 0000000..0e8ebfd --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/AddTypeMenuAttributeTests.cs @@ -0,0 +1,18 @@ +using NUnit.Framework; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class AddTypeMenuAttributeTests + { + [Test] + public void MenuPath_ExposesSegmentsAndLeafName () + { + var attribute = new AddTypeMenuAttribute("Commands/Movement/Run", -4); + + Assert.That(attribute.GetSplittedMenuName(), Is.EqualTo(new[] { "Commands", "Movement", "Run" })); + Assert.That(attribute.GetTypeNameWithoutPath(), Is.EqualTo("Run")); + Assert.That(attribute.MenuName, Is.EqualTo("Commands/Movement/Run")); + Assert.That(attribute.Order, Is.EqualTo(-4)); + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/AddTypeMenuAttributeTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/AddTypeMenuAttributeTests.cs.meta new file mode 100644 index 0000000..97fe96e --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/AddTypeMenuAttributeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ad4f1e44b4c494149895a6ae7449d375 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/GenericValueTypeVarianceReproductionTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/GenericValueTypeVarianceReproductionTests.cs new file mode 100644 index 0000000..77378f2 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/GenericValueTypeVarianceReproductionTests.cs @@ -0,0 +1,38 @@ +#if UNITY_2023_2_OR_NEWER +using System; +using MackySoft.SerializeReferenceExtensions.Editor; +using NUnit.Framework; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class GenericValueTypeVarianceReproductionTests + { + // Known product defect: boxing does not make a variant interface conversion legal. + // Run explicitly until the product's compatibility policy is corrected. + [Test, Explicit("Known product defect: value-type arguments must remain invariant.")] + public void Covariance_DoesNotBoxValueTypeArguments () + { + var candidates = TypeSearchService.TypeCandiateService.GetDisplayableTypes(typeof(ICovariant)); + + Assert.That(candidates, !Does.Contain(typeof(IntProducer))); + } + + [Test, Explicit("Known product defect: value-type arguments must remain invariant.")] + public void Contravariance_DoesNotBoxValueTypeArguments () + { + var candidates = TypeSearchService.TypeCandiateService.GetDisplayableTypes(typeof(IContravariant)); + + Assert.That(candidates, !Does.Contain(typeof(ObjectConsumer))); + } + + [Serializable] + public sealed class IntProducer : ICovariant + { + public int Create () => 0; + } + + [Serializable] + public sealed class ObjectConsumer : IContravariant { } + } +} +#endif diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/GenericValueTypeVarianceReproductionTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/GenericValueTypeVarianceReproductionTests.cs.meta new file mode 100644 index 0000000..631f6d1 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/GenericValueTypeVarianceReproductionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4ee11fd55fe3a46c9bc11e6b5d2c3cbb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/InspectorDrawing.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/InspectorDrawing.cs new file mode 100644 index 0000000..5f9c567 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/InspectorDrawing.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections; +using System.Linq; +using System.Reflection; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + internal static class InspectorDrawing + { + internal readonly struct DrawnContent + { + public string Text { get; } + public Rect Rect { get; } + + public DrawnContent (string text, Rect rect) + { + Text = text; + Rect = rect; + } + } + + public static DrawnContent[] Capture (SelectorInspectorWindow window) + { + // Unity has no public API for reading rendered IMGUI text. Adapt its GUI debugger + // here; package drawers and their registrations execute through PropertyField. + const BindingFlags flags = BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + Assembly editorAssembly = typeof(EditorWindow).Assembly; + Type debugger = editorAssembly.GetType("UnityEditor.GUIViewDebuggerHelper", true); + FieldInfo parentField = typeof(EditorWindow).GetField("m_Parent", flags); + Assert.That(parentField, Is.Not.Null, "Unity GUI observation adapter: EditorWindow parent unavailable."); + object view = parentField.GetValue(window); + Assert.That(view, Is.Not.Null, "A graphics-capable Editor view is required for Inspector tests."); + + debugger.GetMethod("DebugWindow", flags).Invoke(null, new[] { view }); + try + { + window.DrawNow(); + Type instructionType = editorAssembly.GetType("UnityEditor.IMGUIDrawInstruction", true); + Type listType = typeof(System.Collections.Generic.List<>).MakeGenericType(instructionType); + var instructions = (IList)Activator.CreateInstance(listType); + MethodInfo getInstructions = debugger.GetMethod("GetDrawInstructions", flags); + // Newer Editors add an optional stack-trace flag; it is irrelevant to text observation. + object[] arguments = getInstructions.GetParameters().Length == 1 + ? new object[] { instructions } + : new object[] { instructions, false }; + getInstructions.Invoke(null, arguments); + FieldInfo contentField = instructionType.GetField("usedGUIContent", flags); + FieldInfo rectField = instructionType.GetField("rect", flags); + return instructions.Cast() + .Select(item => new DrawnContent(((GUIContent)contentField.GetValue(item)).text, (Rect)rectField.GetValue(item))) + .Where(item => !string.IsNullOrEmpty(item.Text)) + .ToArray(); + } + finally + { + debugger.GetMethod("StopDebugging", flags).Invoke(null, null); + } + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/InspectorDrawing.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/InspectorDrawing.cs.meta new file mode 100644 index 0000000..557e360 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/InspectorDrawing.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 74d0bb2fd9dc84dbf920b65508dec1dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/ManagedReferenceInstanceMenuTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/ManagedReferenceInstanceMenuTests.cs new file mode 100644 index 0000000..dc5c688 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/ManagedReferenceInstanceMenuTests.cs @@ -0,0 +1,121 @@ +using System; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using Asset = MackySoft.SerializeReferenceExtensions.Tests.SelectorTestAsset; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class ManagedReferenceInstanceMenuTests + { + private const string NewInstance = "New Instance"; + private const string ResetInstance = "Reset and New Instance"; + private Asset target; + private string assetPath; + + [SetUp] + public void SetUp () + { + target = ScriptableObject.CreateInstance(); + Undo.ClearAll(); + } + + [TearDown] + public void TearDown () + { + Undo.ClearAll(); + if (!string.IsNullOrEmpty(assetPath)) + { + Assert.That(AssetDatabase.DeleteAsset(assetPath), Is.True); + assetPath = null; + } + else if (target != null) + { + UnityEngine.Object.DestroyImmediate(target); + } + } + + [TestCase(NewInstance)] + [TestCase(ResetInstance)] + public void NullReference_DisablesInstanceCommands (string command) + { + using (var serialized = new SerializedObject(target)) + { + GenericMenu menu = SerializedPropertyContextMenu.Open(serialized.FindProperty("reference")); + Assert.That(SerializedPropertyContextMenu.IsEnabled(menu, command), Is.False); + } + } + + [TestCase("reference")] + [TestCase("parent.reference")] + [TestCase("array.Array.data[1]")] + [TestCase("list.Array.data[1]")] + public void NewInstance_ReplacesOnlySelectedReferenceWithAnIndependentInstance (string path) + { + var original = new Asset.FirstNode(37, "original"); + var sibling = new Asset.FirstNode(52, "sibling"); + target.array[0] = sibling; + target.list[0] = sibling; + using (var serialized = new SerializedObject(target)) + { + serialized.FindProperty(path).managedReferenceValue = original; + serialized.ApplyModifiedPropertiesWithoutUndo(); + SerializedPropertyContextMenu.Execute(SerializedPropertyContextMenu.Open(serialized.FindProperty(path)), NewInstance); + serialized.Update(); + object replacement = serialized.FindProperty(path).managedReferenceValue; + + Assert.That(replacement, Is.TypeOf()); + Assert.That(replacement, Is.Not.SameAs(original)); + } + Assert.That(original.amount, Is.EqualTo(37)); + Assert.That(original.Title, Is.EqualTo("original")); + Assert.That(target.array[0], Is.SameAs(sibling)); + Assert.That(target.list[0], Is.SameAs(sibling)); + Assert.That(target.unrelated, Is.EqualTo(81)); + } + + [Test] + public void ResetInstance_RestoresInitialValuesAndSupportsUndoRedoAndAssetReload () + { + target.reference = new Asset.FirstNode(67, "edited"); + assetPath = "Assets/__SelectorReset_" + Guid.NewGuid().ToString("N") + ".asset"; + AssetDatabase.CreateAsset(target, assetPath); + AssetDatabase.SaveAssets(); + Undo.ClearAll(); + Undo.IncrementCurrentGroup(); + object original = target.reference; + + using (var serialized = new SerializedObject(target)) + { + SerializedPropertyContextMenu.Execute(SerializedPropertyContextMenu.Open(serialized.FindProperty("reference")), ResetInstance); + } + Undo.FlushUndoRecordObjects(); + AssertResetState(); + Assert.That(target.reference, Is.Not.SameAs(original)); + + Undo.PerformUndo(); + Assert.That(target.reference, Is.TypeOf()); + Assert.That(((Asset.FirstNode)target.reference).amount, Is.EqualTo(67)); + Assert.That(((Asset.FirstNode)target.reference).Title, Is.EqualTo("edited")); + Undo.PerformRedo(); + AssertResetState(); + + AssetDatabase.SaveAssets(); + Resources.UnloadAsset(target); + target = AssetDatabase.LoadAssetAtPath(assetPath); + Assert.That(target, Is.Not.Null); + AssertResetState(); + } + + private void AssertResetState () + { + Assert.That(target.reference, Is.TypeOf()); + var node = (Asset.FirstNode)target.reference; + Assert.That(node.amount, Is.EqualTo(7)); + Assert.That(node.Title, Is.EqualTo("first")); + Assert.That(node.payload.value, Is.EqualTo(8)); + Assert.That(node.numbers, Is.EqualTo(new[] { 9 })); + Assert.That(target.unrelated, Is.EqualTo(81)); + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/ManagedReferenceInstanceMenuTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/ManagedReferenceInstanceMenuTests.cs.meta new file mode 100644 index 0000000..ea062a7 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/ManagedReferenceInstanceMenuTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7d701fc599352421187825a78724e88b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/ManagedReferenceSelectionTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/ManagedReferenceSelectionTests.cs new file mode 100644 index 0000000..4a8eced --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/ManagedReferenceSelectionTests.cs @@ -0,0 +1,131 @@ +using System; +using MackySoft.SerializeReferenceExtensions.Editor; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using Asset = MackySoft.SerializeReferenceExtensions.Tests.SelectorTestAsset; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class ManagedReferenceSelectionTests + { + private Asset target; + private string assetPath; + + [SetUp] + public void SetUp () + { + target = ScriptableObject.CreateInstance(); + } + + [TearDown] + public void TearDown () + { + Undo.ClearAll(); + if (!string.IsNullOrEmpty(assetPath)) + { + Assert.That(AssetDatabase.DeleteAsset(assetPath), Is.True); + assetPath = null; + } + else + { + UnityEngine.Object.DestroyImmediate(target); + } + } + + [Test] + public void SelectingAConcreteTypeForNull_CreatesAnInstance () + { + ChangeType("reference", typeof(Asset.FirstNode)); + + Assert.That(target.reference, Is.TypeOf()); + Assert.That(((Asset.FirstNode)target.reference).amount, Is.EqualTo(7)); + Assert.That(((Asset.FirstNode)target.reference).Title, Is.EqualTo("first")); + } + + [Test] + public void ChangingType_RestoresMatchingSerializedFields () + { + var original = new Asset.FirstNode(29, "user edited"); + target.reference = original; + + ChangeType("reference", typeof(Asset.SecondNode)); + + Assert.That(target.reference, Is.TypeOf()); + var changed = (Asset.SecondNode)target.reference; + Assert.That(changed.amount, Is.EqualTo(29)); + Assert.That(changed.Title, Is.EqualTo("user edited")); + Assert.That(changed.payload.value, Is.EqualTo(30)); + Assert.That(changed.numbers, Is.EqualTo(new[] { 31 })); + Assert.That(original.amount, Is.EqualTo(29)); + Assert.That(target.unrelated, Is.EqualTo(81)); + } + + [Test] + public void TypeChange_CanBeUndoneRedoneAndSavedForReload () + { + target.reference = new Asset.FirstNode(43, "saved selection"); + assetPath = "Assets/__SelectorSelection_" + Guid.NewGuid().ToString("N") + ".asset"; + AssetDatabase.CreateAsset(target, assetPath); + AssetDatabase.SaveAssets(); + Undo.ClearAll(); + Undo.IncrementCurrentGroup(); + + ChangeType("reference", typeof(Asset.SecondNode)); + Undo.FlushUndoRecordObjects(); + Assert.That(target.reference, Is.TypeOf()); + Undo.PerformUndo(); + Assert.That(target.reference, Is.TypeOf()); + Assert.That(((Asset.FirstNode)target.reference).amount, Is.EqualTo(43)); + Undo.PerformRedo(); + Assert.That(target.reference, Is.TypeOf()); + + AssetDatabase.SaveAssets(); + Resources.UnloadAsset(target); + target = AssetDatabase.LoadAssetAtPath(assetPath); + Assert.That(target, Is.Not.Null); + Assert.That(target.reference, Is.TypeOf()); + Assert.That(((Asset.SecondNode)target.reference).amount, Is.EqualTo(43)); + Assert.That(((Asset.SecondNode)target.reference).Title, Is.EqualTo("saved selection")); + Assert.That(target.unrelated, Is.EqualTo(81)); + } + + [TestCase("reference")] + [TestCase("parent.reference")] + [TestCase("array.Array.data[1]")] + [TestCase("list.Array.data[1]")] + public void SelectingAndClearingAProperty_ChangesOnlyThatReference (string path) + { + var untouched = new Asset.FirstNode(41, "untouched"); + target.array[0] = untouched; + target.list[0] = untouched; + + ChangeType(path, typeof(Asset.SecondNode)); + using (var serialized = new SerializedObject(target)) + { + Assert.That(serialized.FindProperty(path).managedReferenceValue, Is.TypeOf()); + } + ChangeType(path, null); + + using (var serialized = new SerializedObject(target)) + { + Assert.That(serialized.FindProperty(path).managedReferenceValue, Is.Null); + } + Assert.That(target.array.Length, Is.EqualTo(2)); + Assert.That(target.list.Count, Is.EqualTo(2)); + Assert.That(target.array[0], Is.SameAs(untouched)); + Assert.That(target.list[0], Is.SameAs(untouched)); + Assert.That(untouched.amount, Is.EqualTo(41)); + Assert.That(target.unrelated, Is.EqualTo(81)); + } + + private void ChangeType (string path, Type type) + { + using (var serialized = new SerializedObject(target)) + { + serialized.FindProperty(path).SetManagedReference(type); + serialized.ApplyModifiedProperties(); + } + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/ManagedReferenceSelectionTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/ManagedReferenceSelectionTests.cs.meta new file mode 100644 index 0000000..ff39794 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/ManagedReferenceSelectionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6e3820fbbc83a456b86a8eb73f3b6d67 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorCustomPropertyDrawer.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorCustomPropertyDrawer.cs new file mode 100644 index 0000000..cdec6d4 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorCustomPropertyDrawer.cs @@ -0,0 +1,28 @@ +using UnityEditor; +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + [CustomPropertyDrawer(typeof(SelectorTestAsset.DrawnNode), true)] + public sealed class SelectorCustomPropertyDrawer : PropertyDrawer + { + public const string Marker = "Custom subtype editor"; + public const string EditButton = "Set subtype value"; + + public override float GetPropertyHeight (SerializedProperty property, GUIContent label) + { + return EditorGUIUtility.singleLineHeight * 4f; + } + + public override void OnGUI (Rect position, SerializedProperty property, GUIContent label) + { + var header = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight); + EditorGUI.LabelField(header, Marker); + var button = new Rect(position.x, position.yMax - EditorGUIUtility.singleLineHeight, position.width, EditorGUIUtility.singleLineHeight); + if (GUI.Button(button, EditButton)) + { + property.FindPropertyRelative("amount").intValue = 59; + } + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorCustomPropertyDrawer.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorCustomPropertyDrawer.cs.meta new file mode 100644 index 0000000..2a7dabf --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorCustomPropertyDrawer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 98d26b2eb63204dd4bdce0e3131efb39 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorInspectorWindow.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorInspectorWindow.cs new file mode 100644 index 0000000..9a87e6c --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorInspectorWindow.cs @@ -0,0 +1,60 @@ +using UnityEditor; +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class SelectorInspectorWindow : EditorWindow + { + public const string FieldLabel = "Fixture field"; + public const string FollowingLabel = "Following field"; + private SerializedObject serializedTarget; + private string propertyPath; + private bool expanded; + + public void Initialize (UnityEngine.Object target, string path, bool isExpanded) + { + serializedTarget = new SerializedObject(target); + propertyPath = path; + expanded = isExpanded; + position = new Rect(100f, 100f, 600f, 300f); + } + + public void SetProperty (string path, bool isExpanded) + { + propertyPath = path; + expanded = isExpanded; + } + + public void DrawNow () + { + SendEvent(new Event { type = EventType.Layout }); + SendEvent(new Event { type = EventType.Repaint }); + } + + private void OnGUI () + { + if (serializedTarget == null || serializedTarget.targetObject == null) + { + return; + } + + serializedTarget.Update(); + SerializedProperty property = serializedTarget.FindProperty(propertyPath); + property.isExpanded = expanded; + var label = new GUIContent(FieldLabel); + var rect = new Rect(16f, 16f, position.width - 32f, EditorGUI.GetPropertyHeight(property, label, true)); + EditorGUI.PropertyField(rect, property, label, true); + EditorGUI.LabelField(new Rect(rect.x, rect.yMax + 8f, rect.width, EditorGUIUtility.singleLineHeight), FollowingLabel); + serializedTarget.ApplyModifiedProperties(); + } + + private void OnDisable () + { + if (serializedTarget != null) + { + serializedTarget.Dispose(); + serializedTarget = null; + } + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorInspectorWindow.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorInspectorWindow.cs.meta new file mode 100644 index 0000000..a1995df --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorInspectorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 10b5027535cdb414b9a5ab8b7ca99253 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorTestAsset.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorTestAsset.cs new file mode 100644 index 0000000..e24648c --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorTestAsset.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class SelectorTestAsset : ScriptableObject + { + [SerializeReference, SubclassSelector] public INode reference; + [SerializeReference, SubclassSelector(UseToStringAsLabel = true)] public INode labelled; + [SerializeReference, SubclassSelector] public INode[] array = new INode[2]; + [SerializeReference, SubclassSelector] public List list = new List { null, null }; + [SerializeReference, SubclassSelector(UseToStringAsLabel = true)] public INode[] labelledArray = new INode[2]; + [SerializeReference, SubclassSelector(UseToStringAsLabel = true)] public List labelledList = new List { null, null }; + public Parent parent = new Parent(); + public int unrelated = 81; + + public interface INode { } + + [Serializable] + public sealed class Parent + { + [SerializeReference, SubclassSelector] public INode reference; + } + + [Serializable] + public sealed class Payload + { + public int value; + } + + [Serializable, AddTypeMenu("First Node")] + public sealed class FirstNode : INode + { + public int amount = 7; + [SerializeField] private string m_Title = "first"; + public Payload payload = new Payload { value = 8 }; + public int[] numbers = { 9 }; + + public string Title => m_Title; + + public FirstNode () { } + + public FirstNode (int value, string title) + { + amount = value; + m_Title = title; + payload.value = value + 1; + numbers[0] = value + 2; + } + + public override string ToString () => m_Title; + } + + [Serializable, AddTypeMenu("Commands/Renamed Node")] + public sealed class SecondNode : INode + { + public int amount = 11; + [SerializeField] private string m_Title = "second"; + public Payload payload = new Payload { value = 12 }; + public int[] numbers = { 13 }; + + public string Title => m_Title; + + public override string ToString () => m_Title; + } + + [Serializable] + public class DrawnNode : INode + { + public int amount; + } + + [Serializable] + public sealed class DerivedDrawnNode : DrawnNode { } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorTestAsset.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorTestAsset.cs.meta new file mode 100644 index 0000000..bb66714 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SelectorTestAsset.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fd79db74730df4ab48a41c586a137380 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorInspectorTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorInspectorTests.cs new file mode 100644 index 0000000..bd024bf --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorInspectorTests.cs @@ -0,0 +1,189 @@ +using System.Collections; +using System.Linq; +using MackySoft.SerializeReferenceExtensions.Editor; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; +using Asset = MackySoft.SerializeReferenceExtensions.Tests.SelectorTestAsset; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class SubclassSelectorInspectorTests + { + private Asset target; + private SelectorInspectorWindow window; + private EditorWindow popup; + + [UnitySetUp] + public IEnumerator SetUp () + { + target = ScriptableObject.CreateInstance(); + window = ScriptableObject.CreateInstance(); + window.Initialize(target, "reference", false); + window.ShowUtility(); + yield return null; + } + + [TearDown] + public void TearDown () + { + if (popup != null) + { + popup.Close(); + } + if (window != null) + { + window.Close(); + } + if (target != null) + { + UnityEngine.Object.DestroyImmediate(target); + } + } + + [UnityTest] + public IEnumerator DropdownSelection_ChangesTypePreservesValuesAndCanSelectNull () + { + target.reference = new Asset.SecondNode { amount = 46 }; + OpenPopup("Renamed Node"); + TypePopupInteraction.SelectVisibleItem(popup, "First Node"); + yield return null; + + Assert.That(target.reference, Is.TypeOf()); + Assert.That(((Asset.FirstNode)target.reference).amount, Is.EqualTo(46)); + Assert.That(((Asset.FirstNode)target.reference).Title, Is.EqualTo("second")); + Assert.That(Text(), Does.Contain("First Node")); + + OpenPopup("First Node"); + TypePopupInteraction.SelectVisibleItem(popup, ""); + yield return null; + + Assert.That(target.reference, Is.Null); + Assert.That(Text(), Does.Contain("")); + Assert.That(target.unrelated, Is.EqualTo(81)); + } + + [Test] + public void TypeLabel_UsesMenuLeafAndShowsNullAfterClearing () + { + target.reference = new Asset.SecondNode(); + Assert.That(Text(), Does.Contain("Renamed Node")); + Assert.That(Text(), Does.Contain(SelectorInspectorWindow.FieldLabel)); + + using (var serialized = new SerializedObject(target)) + { + serialized.FindProperty("reference").SetManagedReference(null); + serialized.ApplyModifiedProperties(); + } + Assert.That(Text(), Does.Contain("")); + Assert.That(Text(), Does.Not.Contain("Renamed Node")); + } + + [Test] + public void UseToStringAsLabel_DrawsCurrentInstanceTextAndFallsBackForNull () + { + window.SetProperty("labelled", false); + target.labelled = new Asset.FirstNode(20, "Player command"); + + Assert.That(Text(), Does.Contain("Player command")); + Assert.That(Text(), Does.Not.Contain(SelectorInspectorWindow.FieldLabel)); + + using (var serialized = new SerializedObject(target)) + { + serialized.FindProperty("labelled.m_Title").stringValue = "Edited command"; + serialized.ApplyModifiedProperties(); + } + Assert.That(Text(), Does.Contain("Edited command")); + Assert.That(Text(), Does.Not.Contain("Player command")); + + target.labelled = new Asset.FirstNode(30, "Updated command"); + Assert.That(Text(), Does.Contain("Updated command")); + Assert.That(Text(), Does.Not.Contain("Player command")); + + target.labelled = null; + Assert.That(Text(), Does.Contain(SelectorInspectorWindow.FieldLabel)); + Assert.That(Text(), Does.Contain("")); + } + + [TestCase("labelledArray")] + [TestCase("labelledList")] + public void CollectionElements_DrawTheirOwnInstanceLabels (string collection) + { + target.labelledArray[0] = new Asset.FirstNode(1, "First element"); + target.labelledArray[1] = new Asset.FirstNode(2, "Second element"); + target.labelledList[0] = new Asset.FirstNode(1, "First element"); + target.labelledList[1] = new Asset.FirstNode(2, "Second element"); + window.SetProperty(collection + ".Array.data[0]", false); + Assert.That(Text(), Does.Contain("First element")); + Assert.That(Text(), Does.Not.Contain("Second element")); + + window.SetProperty(collection + ".Array.data[1]", false); + Assert.That(Text(), Does.Contain("Second element")); + Assert.That(Text(), Does.Not.Contain("First element")); + } + + [TestCase(false)] + [TestCase(true)] + public void CustomSubtypeDrawer_RendersFitsAndEditsSelectedInstance (bool derived) + { + target.reference = derived ? new Asset.DerivedDrawnNode() : new Asset.DrawnNode(); + window.SetProperty("reference", true); + InspectorDrawing.DrawnContent[] contents = InspectorDrawing.Capture(window); + Assert.That(contents.Select(item => item.Text), Does.Contain(SelectorCustomPropertyDrawer.Marker)); + Assert.That(contents.Select(item => item.Text), Does.Contain(derived ? "Derived Drawn Node" : "Drawn Node")); + Rect button = contents.First(item => item.Text == SelectorCustomPropertyDrawer.EditButton).Rect; + Rect following = contents.First(item => item.Text == SelectorInspectorWindow.FollowingLabel).Rect; + Assert.That(button.yMax, Is.LessThanOrEqualTo(following.yMin), "The custom drawer must fit before the following field."); + + window.SendEvent(new Event { type = EventType.MouseDown, mousePosition = button.center, button = 0 }); + window.SendEvent(new Event { type = EventType.MouseUp, mousePosition = button.center, button = 0 }); + Assert.That(((Asset.DrawnNode)target.reference).amount, Is.EqualTo(59)); + + window.SetProperty("reference", false); + Assert.That(Text(), Does.Not.Contain(SelectorCustomPropertyDrawer.Marker)); + Assert.That(Text(), Does.Not.Contain(SelectorCustomPropertyDrawer.EditButton)); + } + + [Test] + public void ExpandedReference_DrawsSerializedChildrenBeforeFollowingField () + { + target.reference = new Asset.FirstNode(34, "child fields"); + window.SetProperty("reference", true); + InspectorDrawing.DrawnContent[] contents = InspectorDrawing.Capture(window); + if (!contents.Any(item => item.Text == "Value")) + { + Rect payload = contents.First(item => item.Text == "Payload").Rect; + window.SendEvent(new Event { type = EventType.MouseDown, mousePosition = payload.center, button = 0 }); + window.SendEvent(new Event { type = EventType.MouseUp, mousePosition = payload.center, button = 0 }); + contents = InspectorDrawing.Capture(window); + } + string[] labels = contents.Select(item => item.Text).ToArray(); + Assert.That(labels, Does.Contain("Amount")); + Assert.That(labels, Does.Contain("Title")); + Assert.That(labels, Does.Contain("Payload")); + Assert.That(labels, Does.Contain("Value")); + Assert.That(labels, Does.Contain("Numbers")); + Rect following = contents.First(item => item.Text == SelectorInspectorWindow.FollowingLabel).Rect; + foreach (string name in new[] { "Amount", "Title", "Payload", "Value", "Numbers" }) + { + Assert.That(contents.First(item => item.Text == name).Rect.yMax, Is.LessThanOrEqualTo(following.yMin), name); + } + } + + private string[] Text () + { + return InspectorDrawing.Capture(window).Select(item => item.Text).ToArray(); + } + + private void OpenPopup (string currentTypeLabel) + { + window.Focus(); + Rect button = InspectorDrawing.Capture(window).First(item => item.Text == currentTypeLabel).Rect; + window.SendEvent(new Event { type = EventType.MouseDown, mousePosition = button.center, button = 0 }); + popup = Resources.FindObjectsOfTypeAll() + .SingleOrDefault(candidate => candidate.GetType().Name == "AdvancedDropdownWindow"); + Assert.That(popup, Is.Not.Null, "Clicking the registered selector must open its dropdown."); + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorInspectorTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorInspectorTests.cs.meta new file mode 100644 index 0000000..3a78f6b --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorInspectorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 72a41dd4b8c2a47bfb7d324ba91a4b3a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/TypeCandidateContractTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/TypeCandidateContractTests.cs new file mode 100644 index 0000000..5099c7f --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/TypeCandidateContractTests.cs @@ -0,0 +1,75 @@ +using System; +using MackySoft.SerializeReferenceExtensions.Editor; +using NUnit.Framework; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class TypeCandidateContractTests + { + [Test] + public void AbstractBase_OffersConcreteDescendantsAndExcludesUnrelatedTypes () + { + var candidates = TypeSearchService.TypeCandiateService.GetDisplayableTypes(typeof(AbstractCommand)); + + Assert.That(candidates, Does.Contain(typeof(ConcreteCommand))); + Assert.That(candidates, Does.Contain(typeof(IndirectCommand))); + Assert.That(candidates, !Does.Contain(typeof(AbstractCommand))); + Assert.That(candidates, !Does.Contain(typeof(HiddenCommand))); + Assert.That(candidates, !Does.Contain(typeof(PublicSerializableClass))); + } + +#if UNITY_2023_2_OR_NEWER + [Test] + public void ConstructedAbstractGenericBase_OffersOnlyMatchingNongenericDescendants () + { + var candidates = TypeSearchService.TypeCandiateService.GetDisplayableTypes(typeof(GenericCommand)); + + Assert.That(candidates, Does.Contain(typeof(IntCommand))); + Assert.That(candidates, Does.Contain(typeof(IndirectIntCommand))); + Assert.That(candidates, !Does.Contain(typeof(StringCommand))); + Assert.That(candidates, !Does.Contain(typeof(GenericCommand))); + Assert.That(candidates, !Does.Contain(typeof(OpenCommand<>))); + Assert.That(candidates, !Does.Contain(typeof(OpenCommand))); + } + + [Test] + public void InheritedGenericInterface_IsDiscoveredThroughItsNongenericParent () + { + var candidates = TypeSearchService.TypeCandiateService.GetDisplayableTypes(typeof(IInvariant)); + + Assert.That(candidates, Does.Contain(typeof(InheritedInterfaceCommand))); + Assert.That(candidates, !Does.Contain(typeof(StringInvariantCommand))); + } + + [Test] + public void CombinedVariance_RequiresBothInputAndOutputToBeCompatible () + { + var candidates = TypeSearchService.TypeCandiateService.GetDisplayableTypes(typeof(IConversion)); + + Assert.That(candidates, Does.Contain(typeof(CompatibleConversion))); + Assert.That(candidates, !Does.Contain(typeof(NarrowInputConversion))); + Assert.That(candidates, !Does.Contain(typeof(WideOutputConversion))); + } + + [Serializable] public abstract class GenericCommand { } + [Serializable] public sealed class IntCommand : GenericCommand { } + [Serializable] public abstract class IntBridge : GenericCommand { } + [Serializable] public sealed class IndirectIntCommand : IntBridge { } + [Serializable] public sealed class StringCommand : GenericCommand { } + [Serializable] public sealed class OpenCommand : GenericCommand { } + public interface IIntCommand : IInvariant { } + [Serializable] public sealed class InheritedInterfaceCommand : IIntCommand { } + [Serializable] public sealed class StringInvariantCommand : IInvariant { } + public interface IConversion { } + [Serializable] public sealed class CompatibleConversion : IConversion { } + [Serializable] public sealed class NarrowInputConversion : IConversion { } + [Serializable] public sealed class WideOutputConversion : IConversion { } +#endif + + [Serializable] public abstract class AbstractCommand { } + [Serializable] public sealed class ConcreteCommand : AbstractCommand { } + [Serializable] public abstract class IntermediateCommand : AbstractCommand { } + [Serializable] public sealed class IndirectCommand : IntermediateCommand { } + [Serializable, HideInTypeMenu] public sealed class HiddenCommand : AbstractCommand { } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/TypeCandidateContractTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/TypeCandidateContractTests.cs.meta new file mode 100644 index 0000000..5aaab1c --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/TypeCandidateContractTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1f0c8b641d2c9495abf864774af69fb4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/TypeCandidateService_NonGenericTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/TypeCandidateService_NonGenericTests.cs index ea28b68..7ea72c8 100644 --- a/Assets/Tests/SerializeReferenceExtensions/Editor/TypeCandidateService_NonGenericTests.cs +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/TypeCandidateService_NonGenericTests.cs @@ -30,6 +30,8 @@ public static IEnumerable Cases () yield return new TestCaseData(typeof(HiddenSerializableClass), false).SetName("Candidates_ITestBase_HideInTypeMenu_NG"); yield return new TestCaseData(typeof(UnityObjectDerived), false).SetName("Candidates_ITestBase_UnityObjectDerived_NG"); yield return new TestCaseData(typeof(GenericCandidate), false).SetName("Candidates_ITestBase_GenericClosed_NG"); + yield return new TestCaseData(typeof(GenericCandidate<>), false).SetName("Candidates_ITestBase_GenericOpen_NG"); + yield return new TestCaseData(typeof(ConcreteDerivedType), false).SetName("Candidates_ITestBase_Unrelated_NG"); } [TestCaseSource(nameof(Cases))] diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/TypePopupInteraction.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/TypePopupInteraction.cs new file mode 100644 index 0000000..8f91df7 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/TypePopupInteraction.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using System.Reflection; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.IMGUI.Controls; +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + internal static class TypePopupInteraction + { + public static void SelectVisibleItem (EditorWindow popup, string label) + { + // Unity keeps dropdown rows and the keyboard selection internal. Read them only + // to navigate the real popup with keys; package selection callbacks run normally. + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + var tree = (AdvancedDropdownItem)popup.GetType().GetField("m_CurrentlyRenderedTree", flags).GetValue(popup); + object state = popup.GetType().GetField("m_State", flags).GetValue(popup); + MethodInfo selectedIndex = state.GetType().GetMethod("GetSelectedIndex", flags); +#if UNITY_6000_5_OR_NEWER + AdvancedDropdownItem[] rows = tree.childList.ToArray(); +#else + AdvancedDropdownItem[] rows = tree.children.ToArray(); +#endif + int targetIndex = Array.FindIndex(rows, row => row.name == label); + Assert.That(targetIndex, Is.GreaterThanOrEqualTo(0), "Visible popup item missing: " + label); + for (int step = 0; step <= rows.Length; step++) + { + if ((int)selectedIndex.Invoke(state, new object[] { tree }) == targetIndex) + { + popup.SendEvent(new Event { type = EventType.KeyDown, keyCode = KeyCode.Return }); + return; + } + popup.SendEvent(new Event { type = EventType.KeyDown, keyCode = KeyCode.DownArrow }); + } + Assert.Fail("Keyboard navigation did not reach popup item: " + label); + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/TypePopupInteraction.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/TypePopupInteraction.cs.meta new file mode 100644 index 0000000..0455591 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/TypePopupInteraction.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2f22f40edb09c49b48bee62215b1c8d2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: