diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/AdvancedTypePopup.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/AdvancedTypePopup.cs index 0c53750..c766a1d 100644 --- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/AdvancedTypePopup.cs +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/AdvancedTypePopup.cs @@ -61,6 +61,39 @@ public TypeMenu (IEnumerable types) } + private sealed class SelectionSession : AdvancedDropdown + { + private readonly AdvancedTypePopup owner; + private Action callback; + + internal SelectionSession (AdvancedTypePopup owner, AdvancedDropdownState state, Action callback) : base(state) + { + this.owner = owner; + this.callback = callback; + minimumSize = owner.minimumSize; + } + + protected override AdvancedDropdownItem BuildRoot () + { + return owner.BuildRoot(); + } + + protected override void ItemSelected (AdvancedDropdownItem item) + { + try + { + if (item is AdvancedTypePopupItem typeItem) + { + callback?.Invoke(typeItem); + } + } + finally + { + callback = null; + } + } + } + private static bool HasSingleNamespace (TypeMenuItem[] items) { // Flatten an unbranched namespace unless an explicit menu requests a category. @@ -171,11 +204,13 @@ private static AdvancedDropdownItem GetItem (AdvancedDropdownItem parent, string private Type[] types; private TypeMenu menu; + private readonly AdvancedDropdownState state; public event Action OnItemSelected; public AdvancedTypePopup (IEnumerable types, int maxLineCount, AdvancedDropdownState state) : base(state) { + this.state = state; SetTypes(types); minimumSize = new Vector2(minimumSize.x, EditorGUIUtility.singleLineHeight * maxLineCount + HeaderHeight); } @@ -186,6 +221,12 @@ public void SetTypes (IEnumerable types) menu = null; } + internal void ShowForSelection (Rect position, Action callback) + { + // Only the native window owns this session. The cached metadata owner retains no target callback. + new SelectionSession(this, state, callback).Show(position); + } + protected override AdvancedDropdownItem BuildRoot () { var root = new AdvancedDropdownItem("Select Type"); diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs new file mode 100644 index 0000000..e228136 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs @@ -0,0 +1,415 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using UnityEditor; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + internal sealed class DeferredManagedReferenceTarget + { + private sealed class HostObservation + { + internal UnityEngine.Object Host; + internal long ReferenceId; + internal string TypeName; + internal string FieldTypeName; + internal RuntimePath Path; + + internal bool MatchesProperty (SerializedProperty property) + { + return property != null && property.propertyType == SerializedPropertyType.ManagedReference && + property.managedReferenceId == ReferenceId && property.managedReferenceFullTypename == TypeName && + property.managedReferenceFieldTypename == FieldTypeName; + } + } + + private sealed class PathStep + { + internal FieldInfo Field; + internal int Index; + + internal object Read (object container) + { + if (container == null) + { + return null; + } + return Field != null ? Field.GetValue(container) : ((IList)container)[Index]; + } + } + + private sealed class ContainerObservation + { + private readonly object value; + private readonly object[] elements; + + internal ContainerObservation (object value, bool captureSequence) + { + this.value = SnapshotValue(value); + if (captureSequence && value is IList list) + { + elements = new object[list.Count]; + for (int i = 0; i < elements.Length; i++) + { + elements[i] = SnapshotValue(list[i]); + } + } + } + + internal bool Matches (object current) + { + if (!SameValue(value, current)) + { + return false; + } + if (elements != null) + { + var list = (IList)current; + if (list.Count != elements.Length) + { + return false; + } + for (int i = 0; i < elements.Length; i++) + { + if (!SameValue(elements[i], list[i])) + { + return false; + } + } + } + return true; + } + } + + private sealed class RuntimePath + { + private readonly PathStep[] steps; + private readonly ContainerObservation[] containers; + private readonly object value; + + private RuntimePath (PathStep[] steps, ContainerObservation[] containers, object value) + { + this.steps = steps; + this.containers = containers; + this.value = SnapshotValue(value); + } + + internal static RuntimePath Capture (UnityEngine.Object host, string propertyPath) + { + string[] parts = propertyPath.Split('.'); + var steps = new List(); + var containers = new List(); + object current = host; + Type currentType = host.GetType(); + for (int i = 0; i < parts.Length; i++) + { + PathStep step; + if (parts[i] == "Array" && i + 1 < parts.Length && parts[i + 1].StartsWith("data[", StringComparison.Ordinal)) + { + string element = parts[++i]; + if (!element.EndsWith("]", StringComparison.Ordinal) || + !int.TryParse(element.Substring(5, element.Length - 6), out int index) || index < 0) + { + return null; + } + Type elementType = GetElementType(currentType); + if (elementType == null || (current != null && (current is not IList list || index >= list.Count))) + { + return null; + } + step = new PathStep { Index = index }; + currentType = elementType; + } + else + { + FieldInfo field = FindField(currentType, parts[i]); + if (field == null) + { + return null; + } + step = new PathStep { Field = field }; + currentType = field.FieldType; + } + + // A field owner may implement IList for unrelated purposes; only index steps observe its sequence. + containers.Add(new ContainerObservation(current, step.Field == null)); + steps.Add(step); + current = step.Read(current); + if (current != null) + { + currentType = current.GetType(); + } + } + return new RuntimePath(steps.ToArray(), containers.ToArray(), current); + } + + internal bool Matches (UnityEngine.Object host) + { + object current = host; + for (int i = 0; i < steps.Length; i++) + { + if (!containers[i].Matches(current)) + { + return false; + } + current = steps[i].Read(current); + } + return SameValue(value, current); + } + + private static FieldInfo FindField (Type type, string name) + { + for (; type != null; type = type.BaseType) + { + FieldInfo field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); + if (field != null) + { + return field; + } + } + return null; + } + + private static Type GetElementType (Type type) + { + if (type.IsArray) + { + return type.GetElementType(); + } + for (; type != null; type = type.BaseType) + { + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)) + { + return type.GetGenericArguments()[0]; + } + } + return null; + } + } + + private static readonly Func NativePointer = BindNativePointer(); + + private SerializedObject origin; + private string propertyPath; + private HostObservation[] hosts; + private bool consumed; + + private DeferredManagedReferenceTarget (SerializedObject origin, string propertyPath, HostObservation[] hosts) + { + this.origin = origin; + this.propertyPath = propertyPath; + this.hosts = hosts; + } + + /// Applies the caller's pending edits and captures a one-shot managed-reference menu target. + /// The caller retains ownership of its property and SerializedObject. The target retains no temporary native wrappers. + internal static DeferredManagedReferenceTarget Capture (SerializedProperty property) + { + SerializedObject origin = property.serializedObject; + string path = property.propertyPath; + if (!IsAlive(origin)) + { + return null; + } + UnityEngine.Object[] targets = origin.targetObjects; + if (targets.Length == 0) + { + return null; + } + foreach (UnityEngine.Object target in targets) + { + if (target == null) + { + return null; + } + } + + // Commit the caller's buffered edits before observing live instances. The incoming iterator is no longer used. + origin.ApplyModifiedProperties(); + var observations = new HostObservation[targets.Length]; + for (int i = 0; i < targets.Length; i++) + { + using (var serialized = new SerializedObject(targets[i])) + using (SerializedProperty fresh = serialized.FindProperty(path)) + { + if (fresh == null || fresh.propertyType != SerializedPropertyType.ManagedReference) + { + return null; + } + RuntimePath runtimePath = RuntimePath.Capture(targets[i], path); + if (runtimePath == null) + { + return null; + } + observations[i] = new HostObservation + { + Host = targets[i], + ReferenceId = fresh.managedReferenceId, + TypeName = fresh.managedReferenceFullTypename, + FieldTypeName = fresh.managedReferenceFieldTypename, + Path = runtimePath + }; + } + } + return new DeferredManagedReferenceTarget(origin, path, observations); + } + + /// Reads current corresponding properties to determine menu availability. + /// Receives properties borrowed only for this callback; this target releases their wrappers. + internal bool TryInspect (Action inspect) + { + if (consumed || !HasCorrespondence()) + { + return false; + } + return WithProperties(properties => + { + inspect(properties); + return true; + }); + } + + /// Runs one command only after the caller and every host still identify the captured target. + /// Receives scoped properties and returns true after executing the command, or false when it is unavailable. + /// Consumes and releases the captured observations on success, cancellation, or exception. + internal bool TryExecute (Func execute) + { + if (consumed) + { + return false; + } + consumed = true; + try + { + // Check runtime identity first: a stale caller buffer must not restore a removed or shifted target. + if (!HasCorrespondence()) + { + return false; + } + origin.ApplyModifiedProperties(); + bool executed = WithProperties(execute); + if (executed) + { + origin.Update(); + } + return executed; + } + finally + { + origin = null; + propertyPath = null; + hosts = null; + } + } + + private bool HasCorrespondence () + { + if (!IsAlive(origin)) + { + return false; + } + foreach (HostObservation host in hosts) + { + if (host.Host == null || !host.Path.Matches(host.Host)) + { + return false; + } + } + return true; + } + + private bool WithProperties (Func execute) + { + var objects = new SerializedObject[hosts.Length]; + var properties = new SerializedProperty[hosts.Length]; + try + { + for (int i = 0; i < hosts.Length; i++) + { + if (hosts[i].Host == null) + { + return false; + } + objects[i] = new SerializedObject(hosts[i].Host); + properties[i] = objects[i].FindProperty(propertyPath); + } + // Finish opening every native view before the final all-host correspondence check. + if (!HasCorrespondence()) + { + return false; + } + for (int i = 0; i < hosts.Length; i++) + { + if (!hosts[i].MatchesProperty(properties[i])) + { + return false; + } + } + return execute(properties); + } + finally + { + for (int i = properties.Length - 1; i >= 0; i--) + { + properties[i]?.Dispose(); + objects[i]?.Dispose(); + } + } + } + + private static object SnapshotValue (object value) + { + // An interface/object can hold a mutable boxed struct; retain its value rather than that mutable box. + return value != null && value.GetType().IsValueType ? RuntimeHelpers.GetObjectValue(value) : value; + } + + private static bool SameValue (object expected, object current) + { + if (ReferenceEquals(expected, current)) + { + return true; + } + if (expected == null || current == null || expected.GetType() != current.GetType()) + { + return false; + } + Type type = expected.GetType(); + if (!type.IsValueType) + { + return false; + } + if (type.IsPrimitive || type.IsEnum) + { + return expected.Equals(current); + } + // Follow value-type fields only. Reference fields retain identity without visiting their graphs or user Equals. + foreach (FieldInfo field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + if (!SameValue(field.GetValue(expected), field.GetValue(current))) + { + return false; + } + } + return true; + } + + private static bool IsAlive (SerializedObject serialized) + { + return serialized != null && NativePointer(serialized) != IntPtr.Zero; + } + + private static Func BindNativePointer () + { + FieldInfo field = typeof(SerializedObject).GetField("m_NativeObjectPtr", BindingFlags.Instance | BindingFlags.NonPublic); + if (field == null) + { + throw new MissingFieldException(typeof(SerializedObject).FullName, "m_NativeObjectPtr"); + } + var serialized = Expression.Parameter(typeof(SerializedObject), "serialized"); + return Expression.Lambda>(Expression.Field(serialized, field), serialized).Compile(); + } + } +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs.meta new file mode 100644 index 0000000..05eb800 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b70c5f0bc655f4effa5e0e7c57325df7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceContextualPropertyMenu.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceContextualPropertyMenu.cs index 3d444ea..ca8b811 100644 --- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceContextualPropertyMenu.cs +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceContextualPropertyMenu.cs @@ -32,111 +32,148 @@ private static void Initialize () private static void OnContextualPropertyMenu (GenericMenu menu, SerializedProperty property) { - if (property.propertyType == SerializedPropertyType.ManagedReference) + if (property.propertyType != SerializedPropertyType.ManagedReference) { - // NOTE: When the callback function is called, the SerializedProperty is rewritten to the property that was being moused over at the time, - // so a new SerializedProperty instance must be created. - SerializedProperty clonedProperty = property.Copy(); - // Resolve clipboard targets by path because a property iterator can become invalid while the menu is open. - object clipboardTarget = (property.serializedObject, property.propertyPath); - - var copyContent = new GUIContent($"Copy \"{property.propertyPath}\" property"); - if (CanCopy(clonedProperty)) - { - menu.AddItem(copyContent, false, Copy, clipboardTarget); - } - else - { - menu.AddDisabledItem(copyContent); - } - - ClipboardSnapshot snapshot = ReadClipboard(); - if (CanPasteValuesOnly(clonedProperty, snapshot)) - { - menu.AddItem(new GUIContent(PasteValuesOnlyContent.text, $"Copy matching serialized fields from \"{snapshot.sourcePropertyPath}\"."), false, PasteValuesOnly, clipboardTarget); - } - else - { - menu.AddDisabledItem(PasteValuesOnlyContent); - } - - if (CanPasteTypeAndValues(clonedProperty, snapshot, out _)) + return; + } + string path = property.propertyPath; + DeferredManagedReferenceTarget target = DeferredManagedReferenceTarget.Capture(property); + ClipboardSnapshot snapshot = ReadClipboard(); + bool canCopy = false; + bool canPasteValues = false; + bool canPasteType = false; + bool hasInstances = false; + target?.TryInspect(properties => + { + if (properties.Length == 1) { - menu.AddItem(new GUIContent(PasteTypeAndValuesContent.text, $"Replace this reference with a copy of \"{snapshot.sourcePropertyPath}\"."), false, PasteTypeAndValues, clipboardTarget); + canCopy = CanCopy(properties[0]); + canPasteValues = CanPasteValuesOnly(properties[0], snapshot); + canPasteType = CanPasteTypeAndValues(properties[0], snapshot, out _); } - else + hasInstances = true; + foreach (SerializedProperty current in properties) { - menu.AddDisabledItem(PasteTypeAndValuesContent); + if (!current.editable || current.managedReferenceValue == null) + { + hasInstances = false; + break; + } } + }); - menu.AddSeparator(""); + var copyContent = new GUIContent($"Copy \"{path}\" property"); + if (canCopy) + { + menu.AddItem(copyContent, false, Copy, target); + } + else + { + menu.AddDisabledItem(copyContent); + } + if (canPasteValues) + { + menu.AddItem(new GUIContent(PasteValuesOnlyContent.text, $"Copy matching serialized fields from \"{snapshot.sourcePropertyPath}\"."), false, PasteValuesOnly, target); + } + else + { + menu.AddDisabledItem(PasteValuesOnlyContent); + } + if (canPasteType) + { + menu.AddItem(new GUIContent(PasteTypeAndValuesContent.text, $"Replace this reference with a copy of \"{snapshot.sourcePropertyPath}\"."), false, PasteTypeAndValues, target); + } + else + { + menu.AddDisabledItem(PasteTypeAndValuesContent); + } - bool hasInstance = clonedProperty.managedReferenceValue != null; - if (hasInstance) - { - menu.AddItem(NewInstanceContent, false, NewInstance, clonedProperty); - menu.AddItem(ResetAndNewInstanceContent, false, ResetAndNewInstance, clonedProperty); - } - else - { - menu.AddDisabledItem(NewInstanceContent); - menu.AddDisabledItem(ResetAndNewInstanceContent); - } + menu.AddSeparator(""); + if (hasInstances) + { + menu.AddItem(NewInstanceContent, false, NewInstance, target); + menu.AddItem(ResetAndNewInstanceContent, false, ResetAndNewInstance, target); + } + else + { + menu.AddDisabledItem(NewInstanceContent); + menu.AddDisabledItem(ResetAndNewInstanceContent); } } private static void Copy (object customData) { - if (!TryPrepareSingleTargetProperty(customData, out SerializedProperty property) || !CanCopy(property)) + ((DeferredManagedReferenceTarget)customData).TryExecute(properties => { - return; - } + if (properties.Length != 1 || !CanCopy(properties[0])) + { + return false; + } - object value = property.managedReferenceValue; - var snapshot = new ClipboardSnapshot - { - sourcePropertyPath = property.propertyPath, - typeName = value?.GetType().AssemblyQualifiedName, - json = (value != null) ? JsonUtility.ToJson(value) : string.Empty - }; - SessionState.SetString(ClipboardKey, JsonUtility.ToJson(snapshot)); + SerializedProperty property = properties[0]; + object value = property.managedReferenceValue; + var snapshot = new ClipboardSnapshot + { + sourcePropertyPath = property.propertyPath, + typeName = value?.GetType().AssemblyQualifiedName, + json = (value != null) ? JsonUtility.ToJson(value) : string.Empty + }; + SessionState.SetString(ClipboardKey, JsonUtility.ToJson(snapshot)); + return true; + }); } private static void PasteValuesOnly (object customData) { - ClipboardSnapshot snapshot = ReadClipboard(); - if (!TryPrepareSingleTargetProperty(customData, out SerializedProperty property) || !CanPasteValuesOnly(property, snapshot)) + ((DeferredManagedReferenceTarget)customData).TryExecute(properties => { - return; - } - - UnityEngine.Object targetObject = property.serializedObject.targetObject; - Undo.RecordObject(targetObject, PasteValuesOnlyContent.text); - JsonUtility.FromJsonOverwrite(snapshot.json, property.managedReferenceValue); + ClipboardSnapshot snapshot = ReadClipboard(); + if (properties.Length != 1 || !CanPasteValuesOnly(properties[0], snapshot)) + { + return false; + } - // The live instance was changed without a SerializedProperty setter, so record persistence and refresh the Inspector cache. - PrefabUtility.RecordPrefabInstancePropertyModifications(targetObject); - if (targetObject is ScriptableObject) - { - EditorUtility.SetDirty(targetObject); - } - property.serializedObject.Update(); + SerializedProperty property = properties[0]; + UnityEngine.Object targetObject = property.serializedObject.targetObject; + Undo.IncrementCurrentGroup(); + int group = Undo.GetCurrentGroup(); + Undo.SetCurrentGroupName(PasteValuesOnlyContent.text); + try + { + Undo.RecordObject(targetObject, PasteValuesOnlyContent.text); + JsonUtility.FromJsonOverwrite(snapshot.json, property.managedReferenceValue); + + // A live overwrite needs explicit persistence because it bypasses SerializedProperty assignment. + PrefabUtility.RecordPrefabInstancePropertyModifications(targetObject); + if (targetObject is ScriptableObject) + { + EditorUtility.SetDirty(targetObject); + } + property.serializedObject.Update(); + } + finally + { + Undo.CollapseUndoOperations(group); + } + return true; + }); } private static void PasteTypeAndValues (object customData) { - ClipboardSnapshot snapshot = ReadClipboard(); - if (!TryPrepareSingleTargetProperty(customData, out SerializedProperty property) || !CanPasteTypeAndValues(property, snapshot, out Type copiedType)) + ((DeferredManagedReferenceTarget)customData).TryExecute(properties => { - return; - } + ClipboardSnapshot snapshot = ReadClipboard(); + if (properties.Length != 1 || !CanPasteTypeAndValues(properties[0], snapshot, out Type copiedType)) + { + return false; + } - // A copied null has no type name; an unresolvable non-null type never reaches this assignment. - object value = (copiedType != null) ? JsonUtility.FromJson(snapshot.json, copiedType) : null; - Undo.SetCurrentGroupName(PasteTypeAndValuesContent.text); - property.managedReferenceValue = value; - property.serializedObject.ApplyModifiedProperties(); - property.serializedObject.Update(); + // A copied null has no type name; an unresolvable non-null type never reaches this assignment. + object value = (copiedType != null) ? JsonUtility.FromJson(snapshot.json, copiedType) : null; + ManagedReferenceUtility.ApplyManagedReferences(properties, new[] { value }, PasteTypeAndValuesContent.text, false); + return true; + }); } private static ClipboardSnapshot ReadClipboard () @@ -182,43 +219,38 @@ private static bool CanPasteTypeAndValues (SerializedProperty property, Clipboar return fieldType != null && fieldType.IsAssignableFrom(copiedType); } - private static bool TryPrepareSingleTargetProperty (object customData, out SerializedProperty property) - { - var (serializedObject, propertyPath) = ((SerializedObject, string))customData; - property = null; - if (serializedObject.targetObject == null || serializedObject.isEditingMultipleObjects) - { - return false; - } - - // Apply pending Inspector edits before reading the live instance or refreshing the serialized cache. - serializedObject.ApplyModifiedProperties(); - serializedObject.Update(); - property = serializedObject.FindProperty(propertyPath); - return property != null && property.propertyType == SerializedPropertyType.ManagedReference; - } - private static void NewInstance (object customData) { - SerializedProperty property = (SerializedProperty)customData; - string json = JsonUtility.ToJson(property.managedReferenceValue); - - Undo.RecordObject(property.serializedObject.targetObject, "New Instance"); - property.managedReferenceValue = JsonUtility.FromJson(json, property.managedReferenceValue.GetType()); - property.serializedObject.ApplyModifiedProperties(); - - Debug.Log($"Create new instance of \"{property.propertyPath}\"."); + ReplaceInstances((DeferredManagedReferenceTarget)customData, false); } private static void ResetAndNewInstance (object customData) { - SerializedProperty property = (SerializedProperty)customData; - - Undo.RecordObject(property.serializedObject.targetObject, "Reset and New Instance"); - property.managedReferenceValue = Activator.CreateInstance(property.managedReferenceValue.GetType()); - property.serializedObject.ApplyModifiedProperties(); + ReplaceInstances((DeferredManagedReferenceTarget)customData, true); + } - Debug.Log($"Reset property and created new instance of \"{property.propertyPath}\"."); + private static void ReplaceInstances (DeferredManagedReferenceTarget target, bool reset) + { + target.TryExecute(properties => + { + foreach (SerializedProperty property in properties) + { + if (!property.editable || property.managedReferenceValue == null) + { + return false; + } + } + var values = new object[properties.Length]; + for (int i = 0; i < properties.Length; i++) + { + object current = properties[i].managedReferenceValue; + values[i] = reset ? Activator.CreateInstance(current.GetType()) : + ManagedReferenceUtility.CreateManagedReference(current, current.GetType()); + } + ManagedReferenceUtility.ApplyManagedReferences(properties, values, + reset ? ResetAndNewInstanceContent.text : NewInstanceContent.text, false); + return true; + }); } } } diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceUtility.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceUtility.cs index dda4bd1..3628175 100644 --- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceUtility.cs +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceUtility.cs @@ -11,26 +11,56 @@ public static class ManagedReferenceUtility public static object SetManagedReference (this SerializedProperty property, Type type) { - object result = null; - #if UNITY_2021_3_OR_NEWER - // NOTE: managedReferenceValue getter is available only in Unity 2021.3 or later. - if ((type != null) && (property.managedReferenceValue != null)) - { - // Restore an previous values from json. - string json = JsonUtility.ToJson(property.managedReferenceValue); - result = JsonUtility.FromJson(json, type); - } + object result = CreateManagedReference(property.managedReferenceValue, type); +#else + object result = (type != null) ? Activator.CreateInstance(type) : null; #endif + property.managedReferenceValue = result; + return result; + } - if (result == null) + internal static object CreateManagedReference (object previousValue, Type type) + { + if (type == null) { - result = (type != null) ? Activator.CreateInstance(type) : null; + return null; } + if (previousValue != null) + { + // Keep the existing field-name matching semantics for both type changes and same-type replacement. + object restored = JsonUtility.FromJson(JsonUtility.ToJson(previousValue), type); + if (restored != null) + { + return restored; + } + } + return Activator.CreateInstance(type); + } - property.managedReferenceValue = result; - return result; - + /// Applies previously validated and constructed replacements as one Undo operation. + /// Properties and their SerializedObjects are borrowed from the enclosing command scope. + internal static void ApplyManagedReferences (SerializedProperty[] properties, object[] values, string undoName, bool expandAssignedValues) + { + Undo.IncrementCurrentGroup(); + int group = Undo.GetCurrentGroup(); + Undo.SetCurrentGroupName(undoName); + try + { + for (int i = 0; i < properties.Length; i++) + { + properties[i].managedReferenceValue = values[i]; + if (expandAssignedValues) + { + properties[i].isExpanded = values[i] != null; + } + properties[i].serializedObject.ApplyModifiedProperties(); + } + } + finally + { + Undo.CollapseUndoOperations(group); + } } public static Type GetType (string typeName) @@ -46,4 +76,4 @@ public static Type GetType (string typeName) } } -} \ No newline at end of file +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/PropertyDrawerCache.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/PropertyDrawerCache.cs index 868413f..c918263 100644 --- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/PropertyDrawerCache.cs +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/PropertyDrawerCache.cs @@ -8,17 +8,21 @@ namespace MackySoft.SerializeReferenceExtensions.Editor public static class PropertyDrawerCache { - private static readonly Dictionary Caches = new Dictionary(); + private static readonly Dictionary DrawerTypes = new Dictionary(); + /// Creates an unbound custom drawer registered for the specified type. + /// The value type whose drawer registration is resolved. + /// A fresh drawer when a registration exists; otherwise, null. The caller owns this instance and must release it if it implements . + /// True when a custom drawer registration exists. + /// A type-only lookup supplies neither declaring-field context nor Unity handler ownership. public static bool TryGetPropertyDrawer (Type type, out PropertyDrawer drawer) { - if (!Caches.TryGetValue(type, out drawer)) + if (!DrawerTypes.TryGetValue(type, out Type drawerType)) { - Type drawerType = GetCustomPropertyDrawerType(type); - drawer = (drawerType != null) ? (PropertyDrawer)Activator.CreateInstance(drawerType) : null; - - Caches.Add(type, drawer); + drawerType = GetCustomPropertyDrawerType(type); + DrawerTypes.Add(type, drawerType); } + drawer = (drawerType != null) ? (PropertyDrawer)Activator.CreateInstance(drawerType) : null; return (drawer != null); } @@ -77,4 +81,4 @@ private static Type GetCustomPropertyDrawerType (Type type) } } -} \ No newline at end of file +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SerializedPropertyExtensions.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SerializedPropertyExtensions.cs index 75316bc..154829a 100644 --- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SerializedPropertyExtensions.cs +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SerializedPropertyExtensions.cs @@ -5,25 +5,41 @@ namespace MackySoft.SerializeReferenceExtensions.Editor { public static class SerializedPropertyExtensions { + /// Enumerates visible child properties without moving the parent iterator. + /// The parent whose iterator position is preserved. + /// The maximum descendant depth. Nonpositive values return no children. + /// Independent property copies that the caller must dispose. + /// Array parents return their elements without entering element descendants, regardless of positive depth. public static IEnumerable GetChildProperties (this SerializedProperty parent, int depth = 1) { - parent = parent.Copy(); - - int depthOfParent = parent.depth; - var enumerator = parent.GetEnumerator(); + if (depth <= 0) + { + yield break; + } - while (enumerator.MoveNext()) + using (SerializedProperty cursor = parent.Copy()) { - if (enumerator.Current is not SerializedProperty childProperty) + if (cursor.isArray) { - continue; + for (int i = 0; i < cursor.arraySize; i++) + { + yield return cursor.GetArrayElementAtIndex(i); + } + yield break; } - if (childProperty.depth > (depthOfParent + depth)) + + int depthOfParent = cursor.depth; + long maximumDepth = (long)depthOfParent + depth; + using (SerializedProperty end = cursor.GetEndProperty()) { - continue; + // Skip descendants at the requested depth so cycles and closed branches remain bounded. + while (cursor.NextVisible(cursor.depth < maximumDepth) && + !SerializedProperty.EqualContents(cursor, end) && cursor.depth > depthOfParent) + { + yield return cursor.Copy(); + } } - yield return childProperty.Copy(); } } } -} \ No newline at end of file +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs index c91b6be..571d147 100644 --- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs @@ -36,9 +36,6 @@ private sealed class TypeChangeState private readonly Dictionary typePopups = new Dictionary(); private readonly Dictionary typeNameCaches = new Dictionary(); - private SerializedProperty targetProperty; - private TypeChangeState targetTypeChangeState; - public override void OnGUI (Rect position, SerializedProperty property, GUIContent label) { int controlID = GUIUtility.GetControlID(property.propertyPath.GetHashCode(), FocusType.Passive); @@ -57,114 +54,120 @@ public override void OnGUI (Rect position, SerializedProperty property, GUIConte #endif EditorGUI.BeginProperty(position, label, property); - - // A type change can recreate the drawer. Keep its pending notification with the owning GUI control. - if (typeChangeState.Pending) + try { - typeChangeState.Pending = false; - GUI.changed = true; - } - - if (isManagedReference) - { - // Render label first to avoid label overlap for lists - Rect foldoutLabelRect = new Rect(position); - foldoutLabelRect.height = EditorGUIUtility.singleLineHeight; - - // NOTE: IndentedRect should be disabled as it causes extra indentation. - //foldoutLabelRect = EditorGUI.IndentedRect(foldoutLabelRect); - Rect popupPosition = EditorGUI.PrefixLabel(foldoutLabelRect, label); - - // Draw the subclass selector popup. - if (EditorGUI.DropdownButton(popupPosition, GetTypeName(property), FocusType.Keyboard)) + // A type change can recreate the drawer. Keep its pending notification with the owning GUI control. + if (typeChangeState.Pending) { - TypePopupCache popup = GetTypePopup(property); - targetProperty = property; - targetTypeChangeState = typeChangeState; - popup.TypePopup.Show(popupPosition); + typeChangeState.Pending = false; + GUI.changed = true; } - // Draw the foldout. - if (!string.IsNullOrEmpty(property.managedReferenceFullTypename)) + if (isManagedReference) { - Rect foldoutRect = new Rect(position); - foldoutRect.height = EditorGUIUtility.singleLineHeight; + // Render label first to avoid label overlap for lists + Rect foldoutLabelRect = new Rect(position); + foldoutLabelRect.height = EditorGUIUtility.singleLineHeight; + + // NOTE: IndentedRect should be disabled as it causes extra indentation. + //foldoutLabelRect = EditorGUI.IndentedRect(foldoutLabelRect); + Rect popupPosition = EditorGUI.PrefixLabel(foldoutLabelRect, label); + + // Draw the subclass selector popup. + if (EditorGUI.DropdownButton(popupPosition, GetTypeName(property), FocusType.Keyboard)) + { + TypePopupCache popup = GetTypePopup(property); + DeferredManagedReferenceTarget target = DeferredManagedReferenceTarget.Capture(property); + if (target != null) + { + popup.TypePopup.ShowForSelection(popupPosition, item => SelectType(target, item.Type, typeChangeState)); + } + return; + } + + // Draw the foldout. + if (!string.IsNullOrEmpty(property.managedReferenceFullTypename)) + { + Rect foldoutRect = new Rect(position); + foldoutRect.height = EditorGUIUtility.singleLineHeight; #if UNITY_2022_2_OR_NEWER && !UNITY_6000_0_OR_NEWER && !UNITY_2022_3 - // NOTE: Position x must be adjusted. - // FIXME: Is there a more essential solution...? - // The most promising is UI Toolkit, but it is currently unable to reproduce all of SubclassSelector features. (Complete provision of contextual menu, e.g.) - // 2021.3: No adjustment - // 2022.1: No adjustment - // 2022.2: Adjustment required - // 2022.3: Adjustment required - // 2023.1: Adjustment required - // 2023.2: Adjustment required - // 6000.0: No adjustment - foldoutRect.x -= 12; + // NOTE: Position x must be adjusted. + // FIXME: Is there a more essential solution...? + // The most promising is UI Toolkit, but it is currently unable to reproduce all of SubclassSelector features. (Complete provision of contextual menu, e.g.) + // 2021.3: No adjustment + // 2022.1: No adjustment + // 2022.2: Adjustment required + // 2022.3: Adjustment required + // 2023.1: Adjustment required + // 2023.2: Adjustment required + // 6000.0: No adjustment + foldoutRect.x -= 12; #endif - property.isExpanded = EditorGUI.Foldout(foldoutRect, property.isExpanded, GUIContent.none, true); - } + property.isExpanded = EditorGUI.Foldout(foldoutRect, property.isExpanded, GUIContent.none, true); + } - // Draw property if expanded. - if (property.isExpanded) - { - using (new EditorGUI.IndentLevelScope()) + // Draw property if expanded. + if (property.isExpanded) { - // Check if a custom property drawer exists for this type. - PropertyDrawer customDrawer = GetCustomPropertyDrawer(property); - if (customDrawer != null) + using (new EditorGUI.IndentLevelScope()) { - // Draw the property with custom property drawer. - Rect indentedRect = position; - float foldoutDifference = EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; - indentedRect.height = customDrawer.GetPropertyHeight(property, label); - indentedRect.y += foldoutDifference; - customDrawer.OnGUI(indentedRect, property, label); - } - else - { - // Draw the properties of the child elements. - // NOTE: In the following code, since the foldout layout isn't working properly, I'll iterate through the properties of the child elements myself. - // EditorGUI.PropertyField(position, property, GUIContent.none, true); - - Rect childPosition = position; - childPosition.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; - foreach (SerializedProperty childProperty in property.GetChildProperties()) + if (UnityPropertyDrawerUtility.HasNextPropertyDrawer(property)) { - float height = EditorGUI.GetPropertyHeight(childProperty, new GUIContent(childProperty.displayName, childProperty.tooltip), true); - childPosition.height = height; - EditorGUI.PropertyField(childPosition, childProperty, true); - - childPosition.y += height + EditorGUIUtility.standardVerticalSpacing; + // Let Unity invoke the next drawer with its declaring-field context and native ownership. + Rect indentedRect = position; + float foldoutDifference = EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; + indentedRect.height = EditorGUI.GetPropertyHeight(property, label, true); + indentedRect.y += foldoutDifference; + EditorGUI.PropertyField(indentedRect, property, label, true); + } + else + { + // Draw the properties of the child elements. + // NOTE: In the following code, since the foldout layout isn't working properly, I'll iterate through the properties of the child elements myself. + // EditorGUI.PropertyField(position, property, GUIContent.none, true); + + Rect childPosition = position; + childPosition.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; + foreach (SerializedProperty childProperty in property.GetChildProperties()) + { + using (childProperty) + { + float height = EditorGUI.GetPropertyHeight(childProperty, new GUIContent(childProperty.displayName, childProperty.tooltip), true); + childPosition.height = height; + using (var changeCheck = new EditorGUI.ChangeCheckScope()) + { + EditorGUI.PropertyField(childPosition, childProperty, true); + // A child can replace its structure and invalidate the active iterator. + if (changeCheck.changed) + { + break; + } + } + + childPosition.y += height + EditorGUIUtility.standardVerticalSpacing; + } + } } } - } #if UNITY_6000_0_OR_NEWER - // TextArea can defer its prefix label during Layout. Resolve it inside - // this drawer's IMGUI context before UI Toolkit starts another one. - GUIUtility.GetControlID(FocusType.Passive); + // TextArea can defer its prefix label during Layout. Resolve it inside + // this drawer's IMGUI context before UI Toolkit starts another one. + GUIUtility.GetControlID(FocusType.Passive); #endif + } + } + else + { + EditorGUI.LabelField(position, label, IsNotManagedReferenceLabel); } } - else - { - EditorGUI.LabelField(position, label, IsNotManagedReferenceLabel); - } - - EditorGUI.EndProperty(); - } - - private PropertyDrawer GetCustomPropertyDrawer (SerializedProperty property) - { - Type propertyType = ManagedReferenceUtility.GetType(property.managedReferenceFullTypename); - if (propertyType != null && PropertyDrawerCache.TryGetPropertyDrawer(propertyType, out PropertyDrawer drawer)) + finally { - return drawer; + EditorGUI.EndProperty(); } - return null; } private TypePopupCache GetTypePopup (SerializedProperty property) @@ -183,29 +186,36 @@ private TypePopupCache GetTypePopup (SerializedProperty property) MaxTypePopupLineCount, state ); - popup.OnItemSelected += item => - { - Type type = item.Type; + result = new TypePopupCache(popup, state); + typePopups.Add(managedReferenceFieldTypename, result); + } + return result; + } - // Apply changes to individual serialized objects. - foreach (var targetObject in targetProperty.serializedObject.targetObjects) + private static void SelectType (DeferredManagedReferenceTarget target, Type type, TypeChangeState changeState) + { + if (target.TryExecute(properties => + { + for (int i = 0; i < properties.Length; i++) + { + Type fieldType = ManagedReferenceUtility.GetType(properties[i].managedReferenceFieldTypename.Replace('/', '+')); + if (!properties[i].editable || (type != null && (fieldType == null || !fieldType.IsAssignableFrom(type)))) { - SerializedObject individualObject = new SerializedObject(targetObject); - SerializedProperty individualProperty = individualObject.FindProperty(targetProperty.propertyPath); - object obj = individualProperty.SetManagedReference(type); - individualProperty.isExpanded = (obj != null); - - individualObject.ApplyModifiedProperties(); - individualObject.Update(); + return false; } + } - targetTypeChangeState.Pending = true; - }; - - result = new TypePopupCache(popup, state); - typePopups.Add(managedReferenceFieldTypename, result); + var values = new object[properties.Length]; + for (int i = 0; i < properties.Length; i++) + { + values[i] = ManagedReferenceUtility.CreateManagedReference(properties[i].managedReferenceValue, type); + } + ManagedReferenceUtility.ApplyManagedReferences(properties, values, "Change Managed Reference Type", true); + return true; + })) + { + changeState.Pending = true; } - return result; } private GUIContent GetTypeName (SerializedProperty property) @@ -259,10 +269,9 @@ public override float GetPropertyHeight (SerializedProperty property, GUIContent float height = EditorGUIUtility.singleLineHeight; height += EditorGUIUtility.standardVerticalSpacing; - PropertyDrawer customDrawer = GetCustomPropertyDrawer(property); - if (customDrawer != null) + if (UnityPropertyDrawerUtility.HasNextPropertyDrawer(property)) { - height += customDrawer.GetPropertyHeight(property, label); + height += EditorGUI.GetPropertyHeight(property, label, true); return height; } @@ -278,16 +287,19 @@ private static float GetChildrenHeight (SerializedProperty property) foreach (SerializedProperty child in property.GetChildProperties()) { - if (!first) + using (child) { - height += EditorGUIUtility.standardVerticalSpacing; - } - first = false; + if (!first) + { + height += EditorGUIUtility.standardVerticalSpacing; + } + first = false; - TempChildLabel.text = child.displayName; - TempChildLabel.tooltip = child.tooltip; + TempChildLabel.text = child.displayName; + TempChildLabel.tooltip = child.tooltip; - height += EditorGUI.GetPropertyHeight(child, TempChildLabel, true); + height += EditorGUI.GetPropertyHeight(child, TempChildLabel, true); + } } return height; diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs new file mode 100644 index 0000000..ae5bc22 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq.Expressions; +using System.Reflection; +using UnityEditor; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + internal static class UnityPropertyDrawerUtility + { + private static readonly Func HasNextDrawer = BindHasNextDrawer(); + + internal static bool HasNextPropertyDrawer (SerializedProperty property) + { + return HasNextDrawer(property); + } + + private static Func BindHasNextDrawer () + { + Assembly editorAssembly = typeof(EditorGUI).Assembly; + Type utilityType = editorAssembly.GetType("UnityEditor.ScriptAttributeUtility", true); + Type handlerType = editorAssembly.GetType("UnityEditor.PropertyHandler", true); + MethodInfo getHandler = utilityType.GetMethod("GetHandler", BindingFlags.Static | BindingFlags.NonPublic, + null, new[] { typeof(SerializedProperty) }, null); + PropertyInfo hasDrawer = handlerType.GetProperty("hasPropertyDrawer", BindingFlags.Instance | BindingFlags.Public); + if (getHandler == null) + { + throw new MissingMethodException(utilityType.FullName, "GetHandler"); + } + if (hasDrawer == null) + { + throw new MissingMemberException(handlerType.FullName, "hasPropertyDrawer"); + } + + // Unity's current nesting level determines the next drawer and keeps its context and lifetime native-owned. + var property = Expression.Parameter(typeof(SerializedProperty), "property"); + return Expression.Lambda>( + Expression.Property(Expression.Call(getHandler, property), hasDrawer), property).Compile(); + } + } +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs.meta new file mode 100644 index 0000000..e3f8f66 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ab2a86b3864b34abf988416770b698bb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs new file mode 100644 index 0000000..e9c1cbe --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using UnityEditor; +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + [CustomPropertyDrawer(typeof(DrawerContextTestAsset.StatefulNode))] + public sealed class ContextualNodeDrawer : PropertyDrawer, IDisposable + { + public static readonly List<(int TargetId, string PropertyPath)> DisposedContexts = new List<(int, string)>(); + private int observedTargetId; + private string observedPath; + private int edits; + + public bool IsDisposed { get; private set; } + + public override float GetPropertyHeight (SerializedProperty property, GUIContent label) + { + DrawerContextAttribute context = fieldInfo?.GetCustomAttribute(); + int lines = context?.Lines ?? 1; + return EditorGUIUtility.singleLineHeight * lines + EditorGUIUtility.standardVerticalSpacing * (lines - 1); + } + + public override void OnGUI (Rect position, SerializedProperty property, GUIContent label) + { + if (observedPath == null) + { + observedTargetId = property.serializedObject.targetObject.GetInstanceID(); + observedPath = property.propertyPath; + } + + DrawerContextAttribute context = fieldInfo?.GetCustomAttribute(); + var line = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight); + if (context == null) + { + EditorGUI.LabelField(line, "Missing declaring-field context"); + return; + } + + EditorGUI.LabelField(line, context.Caption); + line.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; + EditorGUI.LabelField(line, context.Caption + " edits: " + edits); + line.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; + EditorGUI.LabelField(line, "Tooltip: " + label.tooltip); + var button = new Rect(position.x, position.yMax - EditorGUIUtility.singleLineHeight, position.width, EditorGUIUtility.singleLineHeight); + if (GUI.Button(button, "Edit " + context.Caption)) + { + using (SerializedProperty amount = property.FindPropertyRelative("amount")) + { + amount.intValue += context.Increment; + } + edits++; + GUI.changed = true; + } + } + + public void Dispose () + { + IsDisposed = true; + if (observedPath != null) + { + DisposedContexts.Add((observedTargetId, observedPath)); + } + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs.meta new file mode 100644 index 0000000..7ed5e6c --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 194f4f53e75ee4e87a924f1e30f653ff +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredMenuLifecycleTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredMenuLifecycleTests.cs new file mode 100644 index 0000000..99e8e0d --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredMenuLifecycleTests.cs @@ -0,0 +1,480 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using Asset = MackySoft.SerializeReferenceExtensions.Tests.DeferredReferenceTestAsset; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class DeferredMenuLifecycleTests + { + private const string NewInstance = "New Instance"; + private const string ResetInstance = "Reset and New Instance"; + private const string PasteType = "Paste Type and Values"; + private const string PasteValues = "Paste Values Only"; + private Asset target; + private Asset other; + private Asset source; + private string assetPath; + + [SetUp] + public void SetUp () + { + Undo.ClearAll(); + assetPath = null; + target = ScriptableObject.CreateInstance(); + other = ScriptableObject.CreateInstance(); + source = ScriptableObject.CreateInstance(); + source.reference = new Asset.Alpha(31); + using (var serialized = new SerializedObject(source)) + { + SerializedPropertyContextMenu.Execute(Open(serialized, "reference"), CopyLabel("reference")); + } + } + + [TearDown] + public void TearDown () + { + Undo.ClearAll(); + if (!string.IsNullOrEmpty(assetPath)) + { + Assert.That(AssetDatabase.DeleteAsset(assetPath), Is.True); + } + else if (target != null) + { + UnityEngine.Object.DestroyImmediate(target); + } + if (other != null) + { + UnityEngine.Object.DestroyImmediate(other); + } + if (source != null) + { + UnityEngine.Object.DestroyImmediate(source); + } + } + + [Test] + public void OpeningAndCancellingMenu_PreservesCallerEditsWithoutReplacingReference () + { + Asset.INode original = target.reference; + using (var serialized = new SerializedObject(target)) + { + StageEdits(serialized); + Open(serialized, "reference"); + Assert.That(target.reference, Is.SameAs(original)); + serialized.ApplyModifiedProperties(); + AssertAlpha(target.reference, 93, 94); + Assert.That(target.unrelated, Is.EqualTo(88)); + } + } + + [TestCase(NewInstance, false)] + [TestCase(NewInstance, true)] + [TestCase(ResetInstance, false)] + [TestCase(ResetInstance, true)] + public void InstanceCommand_PreservesEditsStagedBeforeOrAfterOpening (string command, bool afterOpening) + { + Asset.INode original = target.reference; + using (var serialized = new SerializedObject(target)) + { + if (!afterOpening) + { + StageEdits(serialized); + } + GenericMenu menu = Open(serialized, "reference"); + if (afterOpening) + { + StageEdits(serialized); + } + SerializedPropertyContextMenu.Execute(menu, command); + Assert.That(target.reference, Is.Not.SameAs(original)); + AssertAlpha(target.reference, command == NewInstance ? 93 : 7, command == NewInstance ? 94 : 8); + Assert.That(target.unrelated, Is.EqualTo(88)); + using (SerializedProperty property = serialized.FindProperty("unrelated")) + { + Assert.That(property.intValue, Is.EqualTo(88), "The caller's wrapper remains usable and refreshed."); + } + } + } + + [TestCase("elements.Array.data[1]", 23)] + [TestCase("parent.reference", 17)] + [TestCase("inlineItems.Array.data[0].reference", 19)] + [TestCase("slots.Array.data[0].reference", 41)] + [TestCase("inherited.reference", 19)] + public void UnchangedNestedTarget_NewInstanceReplacesOnlyItsValue (string path, int amount) + { + using (var serialized = new SerializedObject(target)) + { + Asset.INode original = ValueAt(serialized, path); + SerializedPropertyContextMenu.Execute(Open(serialized, path), NewInstance); + Asset.INode replacement = ValueAt(serialized, path); + Assert.That(replacement, Is.Not.SameAs(original)); + AssertAlpha(replacement, amount, amount + 1); + } + Assert.That(target.unrelated, Is.EqualTo(81)); + } + + [TestCase(false)] + [TestCase(true)] + public void UnchangedNullCollectionOrInlinePrefix_AcceptsPaste (bool nullInlinePrefix) + { + string path = "elements.Array.data[0]"; + if (nullInlinePrefix) + { + target.parent = null; + path = "parent.reference"; + } + using (var serialized = new SerializedObject(target)) + { + SerializedPropertyContextMenu.Execute(Open(serialized, path), PasteType); + AssertAlpha(ValueAt(serialized, path), 31, 32); + } + Assert.That(target.elements[2], Is.Null); + Assert.That(target.unrelated, Is.EqualTo(81)); + } + + [TestCase(NewInstance)] + [TestCase(ResetInstance)] + [TestCase(PasteValues)] + [TestCase(PasteType)] + public void ReorderedReferences_DeferredEditCancelsWithoutEditingEitherElement (string command) + { + Asset.INode first = target.elements[1]; + Asset.INode second = target.elements[3]; + using (var serialized = new SerializedObject(target)) + { + GenericMenu menu = Open(serialized, "elements.Array.data[1]"); + target.elements[1] = second; + target.elements[3] = first; + SerializedPropertyContextMenu.Execute(menu, command); + } + Assert.That(target.elements[1], Is.SameAs(second)); + Assert.That(target.elements[3], Is.SameAs(first)); + AssertAlpha(first, 23, 24); + Assert.That(((Asset.Beta)second).amount, Is.EqualTo(47)); + Assert.That(target.unrelated, Is.EqualTo(81)); + } + + [TestCase("removed element")] + [TestCase("replacement collection")] + [TestCase("inline ancestor")] + [TestCase("managed ancestor")] + [TestCase("equal target")] + [TestCase("changed type")] + [TestCase("inline reorder")] + [TestCase("value reorder")] + public void LostCorrespondence_NewInstanceSafelyCancels (string change) + { + string path = "reference"; + Action mutate; + Action assertRetained; + switch (change) + { + case "removed element": + path = "elements.Array.data[3]"; + Asset.INode survivor = target.elements[1]; + mutate = () => target.elements.RemoveAt(3); + assertRetained = () => + { + Assert.That(target.elements, Has.Count.EqualTo(3)); + Assert.That(target.elements[1], Is.SameAs(survivor)); + }; + break; + case "replacement collection": + path = "elements.Array.data[1]"; + var collection = new List(target.elements); + Asset.INode element = collection[1]; + mutate = () => target.elements = collection; + assertRetained = () => + { + Assert.That(target.elements, Is.SameAs(collection)); + Assert.That(target.elements[1], Is.SameAs(element)); + }; + break; + case "inline ancestor": + path = "parent.reference"; + var inline = new Asset.Parent { reference = target.parent.reference }; + Asset.INode inlineValue = inline.reference; + mutate = () => target.parent = inline; + assertRetained = () => + { + Assert.That(target.parent, Is.SameAs(inline)); + Assert.That(target.parent.reference, Is.SameAs(inlineValue)); + }; + break; + case "managed ancestor": + path = "managedParent.reference"; + var managed = new Asset.Parent { reference = target.managedParent.reference }; + Asset.INode managedValue = managed.reference; + mutate = () => target.managedParent = managed; + assertRetained = () => + { + Assert.That(target.managedParent, Is.SameAs(managed)); + Assert.That(target.managedParent.reference, Is.SameAs(managedValue)); + }; + break; + case "equal target": + var equal = new Asset.Alpha(17); + mutate = () => target.reference = equal; + assertRetained = () => Assert.That(target.reference, Is.SameAs(equal)); + break; + case "changed type": + var changedType = new Asset.Beta(47); + mutate = () => target.reference = changedType; + assertRetained = () => Assert.That(target.reference, Is.SameAs(changedType)); + break; + case "inline reorder": + path = "inlineItems.Array.data[0].reference"; + Asset.Parent first = target.inlineItems[0]; + Asset.Parent second = target.inlineItems[1]; + Asset.INode secondValue = second.reference; + mutate = () => { target.inlineItems[0] = second; target.inlineItems[1] = first; }; + assertRetained = () => + { + Assert.That(target.inlineItems[0], Is.SameAs(second)); + Assert.That(second.reference, Is.SameAs(secondValue)); + AssertAlpha(second.reference, 29, 30); + Assert.That(target.inlineItems[1], Is.SameAs(first)); + }; + break; + case "value reorder": + path = "slots.Array.data[0].reference"; + var shared = new Asset.Alpha(41); + target.slots = new[] { new Asset.Slot { marker = 1, reference = shared }, new Asset.Slot { marker = 2, reference = shared } }; + mutate = () => Array.Reverse(target.slots); + assertRetained = () => + { + Assert.That(target.slots[0].marker, Is.EqualTo(2)); + Assert.That(target.slots[1].marker, Is.EqualTo(1)); + Assert.That(target.slots[0].reference, Is.SameAs(shared)); + Assert.That(target.slots[1].reference, Is.SameAs(shared)); + }; + break; + default: + throw new ArgumentOutOfRangeException(nameof(change), change, "Unknown correspondence change."); + } + using (var serialized = new SerializedObject(target)) + { + GenericMenu menu = Open(serialized, path); + mutate(); + SerializedPropertyContextMenu.Execute(menu, NewInstance); + } + assertRetained(); + Assert.That(target.unrelated, Is.EqualTo(81)); + } + + [Test] + public void NullElementReorderCancellation_LeavesUnrelatedCallerBufferUnappliedAndIntact () + { + Asset.INode first = target.elements[1]; + Asset.INode second = target.elements[3]; + using (var serialized = new SerializedObject(target)) + { + GenericMenu menu = Open(serialized, "elements.Array.data[0]"); + using (SerializedProperty pending = serialized.FindProperty("unrelated")) + { + pending.intValue = 105; + } + target.elements[1] = second; + target.elements[3] = first; + SerializedPropertyContextMenu.Execute(menu, PasteType); + + Assert.That(target.elements[0], Is.Null); + Assert.That(target.elements[1], Is.SameAs(second)); + Assert.That(target.elements[3], Is.SameAs(first)); + Assert.That(target.unrelated, Is.EqualTo(81)); + Assert.That(serialized.hasModifiedProperties, Is.True); + using (SerializedProperty pending = serialized.FindProperty("unrelated")) + { + Assert.That(pending.intValue, Is.EqualTo(105)); + } + } + } + + [Test] + public void StaleCopy_DoesNotReplaceThePreviousClipboardSnapshot () + { + using (var serialized = new SerializedObject(target)) + { + GenericMenu menu = Open(serialized, "elements.Array.data[1]"); + target.elements[1] = new Asset.Beta(66); + SerializedPropertyContextMenu.Execute(menu, CopyLabel("elements.Array.data[1]")); + } + using (var serialized = new SerializedObject(other)) + { + SerializedPropertyContextMenu.Execute(Open(serialized, "reference"), PasteType); + } + AssertAlpha(other.reference, 31, 32); + Assert.That(((Asset.Beta)target.elements[1]).amount, Is.EqualTo(66)); + } + + [TestCase(false)] + [TestCase(true)] + public void DisposedOriginOrDestroyedHost_DeferredCommandCancels (bool destroyHost) + { + Asset.INode original = target.reference; + var serialized = new SerializedObject(target); + try + { + GenericMenu menu = Open(serialized, "reference"); + if (destroyHost) + { + UnityEngine.Object.DestroyImmediate(target); + } + else + { + serialized.Dispose(); + serialized = null; + } + SerializedPropertyContextMenu.Execute(menu, NewInstance); + if (!destroyHost) + { + Assert.That(target.reference, Is.SameAs(original)); + } + AssertAlpha(original, 17, 18); + Assert.That(other.unrelated, Is.EqualTo(81)); + } + finally + { + serialized?.Dispose(); + } + } + + [TestCase(NewInstance)] + [TestCase(ResetInstance)] + public void HeterogeneousHosts_KeepTheirOwnTypeValuesAndIndependentInstances (string command) + { + target.reference = new Asset.Alpha(23); + other.reference = new Asset.Beta(47); + Asset.INode original = target.reference; + Asset.INode otherOriginal = other.reference; + using (var serialized = new SerializedObject(new UnityEngine.Object[] { target, other })) + { + using (SerializedProperty pending = serialized.FindProperty("unrelated")) + { + pending.intValue = 88; + } + SerializedPropertyContextMenu.Execute(Open(serialized, "reference"), command); + } + Assert.That(target.reference, Is.Not.SameAs(original)); + Assert.That(other.reference, Is.Not.SameAs(otherOriginal)); + Assert.That(target.reference, Is.Not.SameAs(other.reference)); + AssertAlpha(target.reference, command == NewInstance ? 23 : 7, command == NewInstance ? 24 : 8); + Assert.That(other.reference, Is.TypeOf()); + Assert.That(((Asset.Beta)other.reference).amount, Is.EqualTo(command == NewInstance ? 47 : 11)); + Assert.That(((Asset.Beta)other.reference).payload.value, Is.EqualTo(command == NewInstance ? 48 : 12)); + Assert.That(target.unrelated, Is.EqualTo(88)); + Assert.That(other.unrelated, Is.EqualTo(88)); + } + + [TestCase(false)] + [TestCase(true)] + public void OneInvalidHost_CancelsTheWholeCommand (bool destroyHost) + { + Asset.INode original = target.reference; + var replacement = new Asset.Beta(47); + using (var serialized = new SerializedObject(new UnityEngine.Object[] { target, other })) + { + GenericMenu menu = Open(serialized, "reference"); + if (destroyHost) + { + UnityEngine.Object.DestroyImmediate(other); + } + else + { + other.reference = replacement; + } + SerializedPropertyContextMenu.Execute(menu, NewInstance); + } + Assert.That(target.reference, Is.SameAs(original)); + AssertAlpha(target.reference, 17, 18); + if (!destroyHost) + { + Assert.That(other.reference, Is.SameAs(replacement)); + } + } + + [Test] + public void NewInstance_PersistsBufferedScalarAndChildValuesForAssetReload () + { + assetPath = "Assets/__DeferredInstance_" + Guid.NewGuid().ToString("N") + ".asset"; + AssetDatabase.CreateAsset(target, assetPath); + AssetDatabase.SaveAssets(); + Asset.INode original = target.reference; + using (var serialized = new SerializedObject(target)) + { + StageEdits(serialized); + SerializedPropertyContextMenu.Execute(Open(serialized, "reference"), NewInstance); + } + Assert.That(target.reference, Is.Not.SameAs(original)); + AssetDatabase.SaveAssets(); + Resources.UnloadAsset(target); + target = AssetDatabase.LoadAssetAtPath(assetPath); + Assert.That(target, Is.Not.Null); + AssertAlpha(target.reference, 93, 94); + Assert.That(target.unrelated, Is.EqualTo(88)); + } + + [TestCase("reference", 17)] + [TestCase("parent.reference", 23)] + public void OrdinaryHostOrInlineOwnerWithUnrelatedListApi_CanEditSerializedReference (string path, int amount) + { + var owner = ScriptableObject.CreateInstance(); + try + { + using (var serialized = new SerializedObject(owner)) + { + Asset.INode original = ValueAt(serialized, path); + SerializedPropertyContextMenu.Execute(Open(serialized, path), NewInstance); + Asset.INode replacement = ValueAt(serialized, path); + Assert.That(replacement, Is.Not.SameAs(original)); + AssertAlpha(replacement, amount, amount + 1); + } + } + finally + { + UnityEngine.Object.DestroyImmediate(owner); + } + } + + private static void StageEdits (SerializedObject serialized) + { + using (SerializedProperty amount = serialized.FindProperty("reference.amount")) + using (SerializedProperty child = serialized.FindProperty("reference.payload.value")) + using (SerializedProperty unrelated = serialized.FindProperty("unrelated")) + { + amount.intValue = 93; + child.intValue = 94; + unrelated.intValue = 88; + } + } + + private static GenericMenu Open (SerializedObject serialized, string path) + { + using (SerializedProperty property = serialized.FindProperty(path)) + { + return SerializedPropertyContextMenu.Open(property); + } + } + + private static Asset.INode ValueAt (SerializedObject serialized, string path) + { + using (SerializedProperty property = serialized.FindProperty(path)) + { + return (Asset.INode)property.managedReferenceValue; + } + } + + private static string CopyLabel (string path) => "Copy \"" + path + "\" property"; + + private static void AssertAlpha (Asset.INode value, int amount, int child) + { + Assert.That(value, Is.TypeOf()); + Assert.That(((Asset.Alpha)value).amount, Is.EqualTo(amount)); + Assert.That(((Asset.Alpha)value).payload.value, Is.EqualTo(child)); + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredMenuLifecycleTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredMenuLifecycleTests.cs.meta new file mode 100644 index 0000000..26276dd --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredMenuLifecycleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 87d97140cdbf341d4b74202e0ac6002a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredPrefabLifecycleTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredPrefabLifecycleTests.cs new file mode 100644 index 0000000..4f2d24c --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredPrefabLifecycleTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class DeferredPrefabLifecycleTests + { + private const string PasteType = "Paste Type and Values"; + private const string PasteValues = "Paste Values Only"; + private string folder; + private Scene scene; + private Scene loadedScene; + private GameObject prefab; + private GameObject instance; + private Type componentType; + + [SetUp] + public void SetUp () + { + Undo.ClearAll(); + folder = "Assets/__DeferredPrefab_" + Guid.NewGuid().ToString("N"); + AssetDatabase.CreateFolder("Assets", folder.Substring("Assets/".Length)); + scene = SceneManager.GetActiveScene(); + // Reuse the project's public runtime example; asmdef tests cannot reference Assembly-CSharp directly. + componentType = Type.GetType("Example, Assembly-CSharp", true); + var template = new GameObject("Deferred prefab consumer " + Guid.NewGuid().ToString("N")); + try + { + Component templateComponent = template.AddComponent(componentType); + ConfigureFood(templateComponent, "Apple", "Prefab apple", 13f); + prefab = PrefabUtility.SaveAsPrefabAsset(template, folder + "/Source.prefab"); + Assert.That(prefab, Is.Not.Null); + } + finally + { + UnityEngine.Object.DestroyImmediate(template); + } + instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab, scene); + + var clipboardSource = new GameObject("Clipboard source"); + try + { + Component sourceComponent = clipboardSource.AddComponent(componentType); + ConfigureFood(sourceComponent, "Grape", "Copied value", 77f); + using (var serialized = new SerializedObject(sourceComponent)) + using (SerializedProperty property = serialized.FindProperty("foodOne")) + { + SerializedPropertyContextMenu.Execute(SerializedPropertyContextMenu.Open(property), "Copy \"foodOne\" property"); + } + } + finally + { + UnityEngine.Object.DestroyImmediate(clipboardSource); + } + AssetDatabase.SaveAssets(); + Undo.ClearAll(); + } + + [TearDown] + public void TearDown () + { + Undo.ClearAll(); + if (loadedScene.IsValid() && loadedScene.isLoaded) + { + Assert.That(EditorSceneManager.CloseScene(loadedScene, true), Is.True); + } + if (instance != null) + { + UnityEngine.Object.DestroyImmediate(instance); + } + if (!string.IsNullOrEmpty(folder)) + { + Assert.That(AssetDatabase.DeleteAsset(folder), Is.True); + } + } + + [TestCase(PasteType)] + [TestCase(PasteValues)] + public void Paste_RecordsPrefabOverrideAndSupportsUndoRedoAndSavedInstance (string command) + { + Component component = instance.GetComponent(componentType); + object original = ReadReference(component); + Assert.That(PrefabUtility.HasPrefabInstanceAnyOverrides(instance, false), Is.False); + using (var serialized = new SerializedObject(component)) + using (SerializedProperty property = serialized.FindProperty("foodOne")) + { + SerializedPropertyContextMenu.Execute(SerializedPropertyContextMenu.Open(property), command); + } + Undo.FlushUndoRecordObjects(); + AssertFood(component, command == PasteType ? "Grape" : "Apple", "Copied value", 77f); + Assert.That(ReadReference(component), command == PasteType ? Is.Not.SameAs(original) : Is.SameAs(original)); + Assert.That(PrefabUtility.HasPrefabInstanceAnyOverrides(instance, false), Is.True); + AssertSourcePrefab(); + + Undo.PerformUndo(); + AssertFood(component, "Apple", "Prefab apple", 13f); + Assert.That(PrefabUtility.HasPrefabInstanceAnyOverrides(instance, false), Is.False); + Undo.PerformRedo(); + AssertFood(component, command == PasteType ? "Grape" : "Apple", "Copied value", 77f); + Assert.That(PrefabUtility.HasPrefabInstanceAnyOverrides(instance, false), Is.True); + AssertSourcePrefab(); + + string scenePath = folder + "/Instance.unity"; + // Save a copy and read it in isolation, preserving all currently open scenes. + Assert.That(EditorSceneManager.SaveScene(scene, scenePath, true), Is.True); + loadedScene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Additive); + GameObject loadedInstance = loadedScene.GetRootGameObjects().Single(root => root.name == instance.name); + component = loadedInstance.GetComponent(componentType); + Assert.That(PrefabUtility.IsPartOfPrefabInstance(component), Is.True); + AssertFood(component, command == PasteType ? "Grape" : "Apple", "Copied value", 77f); + Assert.That(PrefabUtility.HasPrefabInstanceAnyOverrides(loadedInstance, false), Is.True); + AssertSourcePrefab(); + } + + private void AssertSourcePrefab () + { + AssertFood(prefab.GetComponent(componentType), "Apple", "Prefab apple", 13f); + } + + private static object ReadReference (Component component) + { + return component.GetType().GetField("foodOne").GetValue(component); + } + + private static void ConfigureFood (Component component, string typeName, string name, float calories) + { + object food = Activator.CreateInstance(Type.GetType(typeName + ", Assembly-CSharp", true)); + component.GetType().GetField("foodOne").SetValue(component, food); + using (var serialized = new SerializedObject(component)) + using (SerializedProperty property = serialized.FindProperty("foodOne")) + using (SerializedProperty foodName = property.FindPropertyRelative("name")) + using (SerializedProperty kcal = property.FindPropertyRelative("kcal")) + { + foodName.stringValue = name; + kcal.floatValue = calories; + serialized.ApplyModifiedPropertiesWithoutUndo(); + } + } + + private static void AssertFood (Component component, string typeName, string name, float calories) + { + Assert.That(ReadReference(component).GetType().Name, Is.EqualTo(typeName)); + using (var serialized = new SerializedObject(component)) + using (SerializedProperty property = serialized.FindProperty("foodOne")) + using (SerializedProperty foodName = property.FindPropertyRelative("name")) + using (SerializedProperty kcal = property.FindPropertyRelative("kcal")) + { + Assert.That(foodName.stringValue, Is.EqualTo(name)); + Assert.That(kcal.floatValue, Is.EqualTo(calories)); + } + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredPrefabLifecycleTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredPrefabLifecycleTests.cs.meta new file mode 100644 index 0000000..4bfde49 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredPrefabLifecycleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1c54f5845958546dcb9c817ea4bf1e60 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAsset.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAsset.cs new file mode 100644 index 0000000..7d61458 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAsset.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class DeferredReferenceTestAsset : DeferredReferenceTestAssetBase + { + [SerializeReference, SubclassSelector] public INode reference = new Alpha(17); + [SerializeReference, SubclassSelector] public List elements = new List { null, new Alpha(23), null, new Beta(47) }; + public Parent parent = new Parent { reference = new Alpha(17) }; + [SerializeReference] public Parent managedParent = new Parent { reference = new Alpha(37) }; + public Parent[] inlineItems = { new Parent { reference = new Alpha(19) }, new Parent { reference = new Alpha(29) } }; + public Slot[] slots = { new Slot { marker = 1, reference = new Alpha(41) }, new Slot { marker = 2, reference = new Alpha(51) } }; + public int unrelated = 81; + + public interface INode { } + + [Serializable] + public sealed class Payload + { + public int value; + } + + [Serializable, AddTypeMenu("Deferred Alpha")] + public sealed class Alpha : INode + { + public int amount = 7; + public Payload payload = new Payload { value = 8 }; + + public Alpha () { } + + public Alpha (int value) + { + amount = value; + payload.value = value + 1; + } + } + + [Serializable, AddTypeMenu("Deferred Beta")] + public sealed class Beta : INode + { + public int amount = 11; + public Payload payload = new Payload { value = 12 }; + + public Beta () { } + + public Beta (int value) + { + amount = value; + payload.value = value + 1; + } + } + + [Serializable] + public sealed class Parent + { + [SerializeReference, SubclassSelector] public INode reference; + } + + [Serializable] + public struct Slot + { + public int marker; + [SerializeReference, SubclassSelector] public INode reference; + + public override bool Equals (object obj) => true; + public override int GetHashCode () => 0; + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAsset.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAsset.cs.meta new file mode 100644 index 0000000..591ea70 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAsset.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 11a0523d7d9d14f12a0eb534909ed48a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAssetBase.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAssetBase.cs new file mode 100644 index 0000000..cd02233 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAssetBase.cs @@ -0,0 +1,14 @@ +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public class DeferredReferenceTestAssetBase : ScriptableObject + { + [SerializeField] private DeferredReferenceTestAsset.Parent inherited = new DeferredReferenceTestAsset.Parent + { + reference = new DeferredReferenceTestAsset.Alpha(19) + }; + + public DeferredReferenceTestAsset.INode InheritedReference => inherited.reference; + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAssetBase.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAssetBase.cs.meta new file mode 100644 index 0000000..a260026 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAssetBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d9c38b7e893c04eecb77280d44692084 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionLifecycleTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionLifecycleTests.cs new file mode 100644 index 0000000..4d020e2 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionLifecycleTests.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections; +using System.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; +using Asset = MackySoft.SerializeReferenceExtensions.Tests.DeferredReferenceTestAsset; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class DeferredSelectionLifecycleTests + { + private Asset target; + private Asset other; + private SerializedObject caller; + private DeferredSelectionWindow window; + private EditorWindow popup; + private string assetPath; + + [UnitySetUp] + public IEnumerator SetUp () + { + Undo.ClearAll(); + assetPath = null; + target = ScriptableObject.CreateInstance(); + other = ScriptableObject.CreateInstance(); + caller = new SerializedObject(target); + ShowWindow("reference"); + yield return null; + } + + [TearDown] + public void TearDown () + { + if (popup != null) + { + popup.Close(); + } + if (window != null) + { + window.Close(); + } + caller?.Dispose(); + if (!string.IsNullOrEmpty(assetPath)) + { + Assert.That(AssetDatabase.DeleteAsset(assetPath), Is.True); + } + else if (target != null) + { + UnityEngine.Object.DestroyImmediate(target); + } + if (other != null) + { + UnityEngine.Object.DestroyImmediate(other); + } + Undo.ClearAll(); + } + + [TestCase(false)] + [TestCase(true)] + public void Selection_PreservesEditsStagedBeforeOrAfterOpening (bool afterOpening) + { + Asset.INode original = target.reference; + if (!afterOpening) + { + StageEdits(); + } + OpenPopup(); + Assert.That(window.Changed, Is.False); + if (afterOpening) + { + StageEdits(); + } + TypePopupInteraction.SelectVisibleItem(popup, "Deferred Beta"); + window.DrawNow(); + + AssertBeta(target.reference, 93, 94); + Assert.That(target.reference, Is.Not.SameAs(original)); + Assert.That(target.unrelated, Is.EqualTo(88)); + Assert.That(window.Changed, Is.True); + using (SerializedProperty preserved = caller.FindProperty("reference.amount")) + { + Assert.That(preserved.intValue, Is.EqualTo(93)); + } + } + + [Test] + public void CancellingPopup_PreservesBufferedEditsWithoutReplacingOrNotifying () + { + Asset.INode original = target.reference; + StageEdits(); + OpenPopup(); + popup.SendEvent(new Event { type = EventType.KeyDown, keyCode = KeyCode.Escape }); + caller.ApplyModifiedProperties(); + window.DrawNow(); + + Assert.That(target.reference, Is.SameAs(original)); + Assert.That(((Asset.Alpha)target.reference).amount, Is.EqualTo(93)); + Assert.That(((Asset.Alpha)target.reference).payload.value, Is.EqualTo(94)); + Assert.That(target.unrelated, Is.EqualTo(88)); + Assert.That(window.Changed, Is.False); + } + + [TestCase("elements.Array.data[0]", 11, 12)] + [TestCase("inlineItems.Array.data[0].reference", 19, 20)] + [TestCase("slots.Array.data[0].reference", 41, 42)] + public void UnchangedNullInlineAndValueTypeTargets_AcceptSelection (string path, int amount, int child) + { + ShowWindow(path); + OpenPopup(); + TypePopupInteraction.SelectVisibleItem(popup, "Deferred Beta"); + window.DrawNow(); + using (SerializedProperty property = caller.FindProperty(path)) + { + AssertBeta((Asset.INode)property.managedReferenceValue, amount, child); + } + Assert.That(window.Changed, Is.True); + Assert.That(target.elements[2], Is.Null); + Assert.That(target.unrelated, Is.EqualTo(81)); + } + + [Test] + public void ReorderedElement_SelectionCancelsWithoutEditingOrNotifying () + { + ShowWindow("elements.Array.data[1]"); + Asset.INode first = target.elements[1]; + Asset.INode second = target.elements[3]; + OpenPopup(); + target.elements[1] = second; + target.elements[3] = first; + TypePopupInteraction.SelectVisibleItem(popup, "Deferred Beta"); + caller.Update(); + window.DrawNow(); + + Assert.That(target.elements[1], Is.SameAs(second)); + Assert.That(target.elements[3], Is.SameAs(first)); + Assert.That(((Asset.Alpha)first).amount, Is.EqualTo(23)); + Assert.That(((Asset.Beta)second).amount, Is.EqualTo(47)); + Assert.That(window.Changed, Is.False); + } + + [TestCase(false)] + [TestCase(true)] + public void DisposedOriginOrDestroyedHost_SelectionSafelyCancels (bool destroyHost) + { + Asset.INode original = target.reference; + OpenPopup(); + if (destroyHost) + { + UnityEngine.Object.DestroyImmediate(target); + } + else + { + window.Close(); + caller.Dispose(); + caller = null; + } + TypePopupInteraction.SelectVisibleItem(popup, "Deferred Beta"); + if (!destroyHost) + { + Assert.That(target.reference, Is.SameAs(original)); + } + Assert.That(((Asset.Alpha)original).amount, Is.EqualTo(17)); + Assert.That(other.reference, Is.TypeOf()); + } + + [Test] + public void MultipleHosts_SelectionPreservesEachHostsCurrentValues () + { + target.reference = new Asset.Alpha(23); + other.reference = new Asset.Beta(47); + caller.Dispose(); + caller = new SerializedObject(new UnityEngine.Object[] { target, other }); + ShowWindow("reference"); + using (SerializedProperty unrelated = caller.FindProperty("unrelated")) + { + unrelated.intValue = 88; + } + OpenPopup(); + TypePopupInteraction.SelectVisibleItem(popup, "Deferred Beta"); + window.DrawNow(); + + AssertBeta(target.reference, 23, 24); + AssertBeta(other.reference, 47, 48); + Assert.That(target.reference, Is.Not.SameAs(other.reference)); + Assert.That(target.unrelated, Is.EqualTo(88)); + Assert.That(other.unrelated, Is.EqualTo(88)); + Assert.That(window.Changed, Is.True); + } + + [Test] + public void OneChangedHost_SelectionCancelsForEveryHost () + { + Asset.INode original = target.reference; + caller.Dispose(); + caller = new SerializedObject(new UnityEngine.Object[] { target, other }); + ShowWindow("reference"); + OpenPopup(); + var replacement = new Asset.Beta(47); + other.reference = replacement; + TypePopupInteraction.SelectVisibleItem(popup, "Deferred Beta"); + caller.Update(); + window.DrawNow(); + + Assert.That(target.reference, Is.SameAs(original)); + Assert.That(other.reference, Is.SameAs(replacement)); + Assert.That(((Asset.Alpha)original).amount, Is.EqualTo(17)); + Assert.That(window.Changed, Is.False); + } + + [Test] + public void PopupTypeChange_SupportsUndoRedoAndAssetReloadWithBufferedValues () + { + assetPath = "Assets/__DeferredSelection_" + Guid.NewGuid().ToString("N") + ".asset"; + AssetDatabase.CreateAsset(target, assetPath); + AssetDatabase.SaveAssets(); + Undo.ClearAll(); + StageEdits(); + OpenPopup(); + TypePopupInteraction.SelectVisibleItem(popup, "Deferred Beta"); + Undo.FlushUndoRecordObjects(); + AssertBeta(target.reference, 93, 94); + + Undo.PerformUndo(); + Assert.That(target.reference, Is.TypeOf()); + Assert.That(((Asset.Alpha)target.reference).amount, Is.EqualTo(93)); + Assert.That(((Asset.Alpha)target.reference).payload.value, Is.EqualTo(94)); + Assert.That(target.unrelated, Is.EqualTo(88)); + Undo.PerformRedo(); + AssertBeta(target.reference, 93, 94); + + window.Close(); + caller.Dispose(); + caller = null; + AssetDatabase.SaveAssets(); + Resources.UnloadAsset(target); + target = AssetDatabase.LoadAssetAtPath(assetPath); + Assert.That(target, Is.Not.Null); + AssertBeta(target.reference, 93, 94); + Assert.That(target.unrelated, Is.EqualTo(88)); + } + + private void ShowWindow (string path) + { + if (window != null) + { + window.Close(); + } + window = ScriptableObject.CreateInstance(); + window.Initialize(caller, path); + window.ShowUtility(); + } + + private void OpenPopup () + { + window.Focus(); + window.DrawNow(); + window.ClearChanges(); + Rect rect = window.FieldRect; + window.SendEvent(new Event + { + type = EventType.MouseDown, + mousePosition = new Vector2(rect.xMax - 30f, rect.y + EditorGUIUtility.singleLineHeight * 0.5f), + button = 0 + }); + popup = Resources.FindObjectsOfTypeAll() + .SingleOrDefault(candidate => candidate.GetType().Name == "AdvancedDropdownWindow"); + Assert.That(popup, Is.Not.Null, "Clicking the registered selector opens a real type popup."); + window.SendEvent(new Event + { + type = EventType.MouseUp, + mousePosition = new Vector2(rect.xMax - 30f, rect.y + EditorGUIUtility.singleLineHeight * 0.5f), + button = 0 + }); + } + + private void StageEdits () + { + using (SerializedProperty amount = caller.FindProperty("reference.amount")) + using (SerializedProperty child = caller.FindProperty("reference.payload.value")) + using (SerializedProperty unrelated = caller.FindProperty("unrelated")) + { + amount.intValue = 93; + child.intValue = 94; + unrelated.intValue = 88; + } + } + + private static void AssertBeta (Asset.INode value, int amount, int child) + { + Assert.That(value, Is.TypeOf()); + Assert.That(((Asset.Beta)value).amount, Is.EqualTo(amount)); + Assert.That(((Asset.Beta)value).payload.value, Is.EqualTo(child)); + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionLifecycleTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionLifecycleTests.cs.meta new file mode 100644 index 0000000..57f0e53 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionLifecycleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 38f1d0721330a440789096d2d63db037 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionWindow.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionWindow.cs new file mode 100644 index 0000000..e9f4bee --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionWindow.cs @@ -0,0 +1,62 @@ +using UnityEditor; +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class DeferredSelectionWindow : EditorWindow + { + private SerializedObject serializedTarget; + private string propertyPath; + + public Rect FieldRect { get; private set; } + public bool Changed { get; private set; } + + public void Initialize (SerializedObject caller, string path) + { + serializedTarget = caller; + propertyPath = path; + position = new Rect(100f, 100f, 600f, 160f); + } + + public void DrawNow () + { + SendEvent(new Event { type = EventType.Layout }); + SendEvent(new Event { type = EventType.Repaint }); + } + + public void ClearChanges () + { + Changed = false; + } + + private void OnGUI () + { + if (serializedTarget == null || serializedTarget.targetObject == null) + { + return; + } + + // The caller owns Update/Apply so the selector receives its genuine pending buffer. + using (SerializedProperty property = serializedTarget.FindProperty(propertyPath)) + { + if (property == null) + { + return; + } + property.isExpanded = false; + var label = new GUIContent("Deferred selection"); + FieldRect = new Rect(16f, 16f, position.width - 32f, EditorGUI.GetPropertyHeight(property, label, true)); + using (var change = new EditorGUI.ChangeCheckScope()) + { + EditorGUI.PropertyField(FieldRect, property, label, true); + Changed |= change.changed; + } + } + } + + private void OnDisable () + { + serializedTarget = null; + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionWindow.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionWindow.cs.meta new file mode 100644 index 0000000..38fe9f3 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: afbbd2394b74842a096e997834153687 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextAttribute.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextAttribute.cs new file mode 100644 index 0000000..27c7161 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextAttribute.cs @@ -0,0 +1,19 @@ +using System; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + [AttributeUsage(AttributeTargets.Field)] + public sealed class DrawerContextAttribute : Attribute + { + public string Caption { get; } + public int Increment { get; } + public int Lines { get; } + + public DrawerContextAttribute (string caption, int increment, int lines) + { + Caption = caption; + Increment = increment; + Lines = lines; + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextAttribute.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextAttribute.cs.meta new file mode 100644 index 0000000..9a9813c --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cea9ab057c9e04390b28d5fdddc8d0c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestAsset.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestAsset.cs new file mode 100644 index 0000000..d424c32 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestAsset.cs @@ -0,0 +1,32 @@ +using System; +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class DrawerContextTestAsset : ScriptableObject + { + [SerializeReference, SubclassSelector, DrawerContext("Primary selector", 7, 4)] + public StatefulNode primary = new StatefulNode { amount = 101 }; + + [SerializeReference, SubclassSelector, DrawerContext("Secondary selector", 13, 5)] + public StatefulNode secondary = new StatefulNode { amount = 201 }; + + [SerializeReference, DrawerContext("Native reference", 17, 4)] + public StatefulNode native = new StatefulNode { amount = 301 }; + + [SerializeReference, SubclassSelector] + public ForwardingNode forwarding = new ForwardingNode(); + + [Serializable] + public sealed class StatefulNode + { + public int amount; + } + + [Serializable] + public sealed class ForwardingNode + { + public int number = 29; + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestAsset.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestAsset.cs.meta new file mode 100644 index 0000000..f1c41ab --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestAsset.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 93a7f6c5ef35f48dfa629b10298d4188 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestEditor.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestEditor.cs new file mode 100644 index 0000000..b107f20 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestEditor.cs @@ -0,0 +1,28 @@ +using UnityEditor; +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + [CustomEditor(typeof(DrawerContextTestAsset))] + public sealed class DrawerContextTestEditor : UnityEditor.Editor + { + public const string FollowingLabel = "Following context field"; + public const string Tooltip = "Consumer context tooltip"; + public string PropertyPath { get; set; } = "primary"; + + public override void OnInspectorGUI () + { + serializedObject.Update(); + using (SerializedProperty property = serializedObject.FindProperty(PropertyPath)) + { + property.isExpanded = true; + var label = new GUIContent(property.displayName, Tooltip); + float height = EditorGUI.GetPropertyHeight(property, label, true); + Rect position = GUILayoutUtility.GetRect(0f, height, GUILayout.ExpandWidth(true)); + EditorGUI.PropertyField(position, property, label, true); + } + EditorGUILayout.LabelField(FollowingLabel); + serializedObject.ApplyModifiedProperties(); + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestEditor.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestEditor.cs.meta new file mode 100644 index 0000000..03a4040 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 31b75f1f748e74ea88c4fada3b435b5f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/ForwardingNodeDrawer.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/ForwardingNodeDrawer.cs new file mode 100644 index 0000000..20bcb3e --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/ForwardingNodeDrawer.cs @@ -0,0 +1,19 @@ +using UnityEditor; +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + [CustomPropertyDrawer(typeof(DrawerContextTestAsset.ForwardingNode))] + public sealed class ForwardingNodeDrawer : PropertyDrawer + { + public override float GetPropertyHeight (SerializedProperty property, GUIContent label) + { + return EditorGUI.GetPropertyHeight(property, label, true); + } + + public override void OnGUI (Rect position, SerializedProperty property, GUIContent label) + { + EditorGUI.PropertyField(position, property, label, true); + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/ForwardingNodeDrawer.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/ForwardingNodeDrawer.cs.meta new file mode 100644 index 0000000..4a971ea --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/ForwardingNodeDrawer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 71a7d704512ff447c972ec53e71b821c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs new file mode 100644 index 0000000..58a37d4 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs @@ -0,0 +1,179 @@ +using System.Collections.Generic; +using System.Linq; +using MackySoft.SerializeReferenceExtensions.Editor; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using Asset = MackySoft.SerializeReferenceExtensions.Tests.TraversalTestAsset; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class SerializedPropertyChildTraversalTests + { + private Asset target; + + [SetUp] + public void SetUp () + { + target = ScriptableObject.CreateInstance(); + } + + [TearDown] + public void TearDown () + { + if (target != null) + { + Object.DestroyImmediate(target); + } + } + + [TestCase(false)] + [TestCase(true)] + public void CyclicReferences_ReturnTheRequestedFiniteDepth (bool mutual) + { + var first = new Asset.Cycle(); + var second = mutual ? new Asset.Cycle() : first; + first.next = second; + second.next = first; + target.reference = first; + + using (var serialized = new SerializedObject(target)) + using (SerializedProperty parent = serialized.FindProperty("reference")) + using (SerializedProperty next = parent.FindPropertyRelative("next")) + { + parent.isExpanded = true; + next.isExpanded = false; + Assert.That(Paths(parent, 2), Is.EqualTo(new[] + { + "reference.visibleValue", "reference.next", "reference.next.visibleValue", + "reference.next.next", "reference.next.tailValue", "reference.tailValue" + })); + } + } + + [Test] + public void DirectChildren_ClosedCollectionDoesNotEnterCyclicElements () + { + var entries = new Asset.Cycle[128]; + for (int i = 0; i < entries.Length; i++) + { + var entry = new Asset.Cycle(); + entry.next = entry; + entries[i] = entry; + } + target.reference = new Asset.ClosedBranches { entries = entries }; + + using (var serialized = new SerializedObject(target)) + using (SerializedProperty parent = serialized.FindProperty("reference")) + using (SerializedProperty collection = parent.FindPropertyRelative("entries")) + { + parent.isExpanded = true; + collection.isExpanded = false; + Assert.That(Paths(parent), Is.EqualTo(new[] + { + "reference.visibleValue", "reference.entries", "reference.tailValue" + })); + } + } + + [Test] + public void RequestedDepth_IncludesVisibleChildrenAndStopsBeforeGrandchildren () + { + target.reference = new Asset.NestedRoot(); + using (var serialized = new SerializedObject(target)) + using (SerializedProperty parent = serialized.FindProperty("reference")) + { + Assert.That(Paths(parent, 2), Is.EqualTo(new[] + { + "reference.visibleValue", "reference.branch", "reference.branch.value", + "reference.branch.leaf", "reference.tailValue" + })); + } + } + + [TestCase(0)] + [TestCase(-1)] + public void NonpositiveDepth_ReturnsNoChildren (int depth) + { + var cycle = new Asset.Cycle(); + cycle.next = cycle; + target.reference = cycle; + using (var serialized = new SerializedObject(target)) + using (SerializedProperty parent = serialized.FindProperty("reference")) + { + Assert.That(Paths(parent, depth), Is.Empty); + Assert.That(parent.propertyPath, Is.EqualTo("reference")); + } + } + + [TestCase("array")] + [TestCase("list")] + public void ArrayParents_ReturnElementsWithoutTheirDescendants (string path) + { + using (var serialized = new SerializedObject(target)) + using (SerializedProperty parent = serialized.FindProperty(path)) + { + Assert.That(Paths(parent, 3), Is.EqualTo(new[] + { + path + ".Array.data[0]", path + ".Array.data[1]" + })); + Assert.That(parent.propertyPath, Is.EqualTo(path)); + } + } + + [Test] + public void ReturnedCopies_RemainIndependentAndPreserveParentPosition () + { + target.reference = new Asset.NestedRoot(); + using (var serialized = new SerializedObject(target)) + using (SerializedProperty parent = serialized.FindProperty("reference")) + { + SerializedProperty[] children = parent.GetChildProperties().ToArray(); + try + { + Assert.That(children.Select(child => child.propertyPath), Is.EqualTo(new[] + { + "reference.visibleValue", "reference.branch", "reference.tailValue" + })); + Assert.That(children[0].NextVisible(false), Is.True); + Assert.That(children[1].propertyPath, Is.EqualTo("reference.branch")); + Assert.That(children[2].propertyPath, Is.EqualTo("reference.tailValue")); + children[0].NextVisible(false); + Assert.That(children[1].propertyPath, Is.EqualTo("reference.branch")); + Assert.That(parent.propertyPath, Is.EqualTo("reference")); + Assert.That(parent.managedReferenceValue, Is.SameAs(target.reference)); + } + finally + { + foreach (SerializedProperty child in children) + { + child.Dispose(); + } + } + + using (IEnumerator iterator = parent.GetChildProperties().GetEnumerator()) + { + Assert.That(iterator.MoveNext(), Is.True); + using (SerializedProperty first = iterator.Current) + { + Assert.That(first.propertyPath, Is.EqualTo("reference.visibleValue")); + } + } + Assert.That(parent.propertyPath, Is.EqualTo("reference")); + } + } + + private static string[] Paths (SerializedProperty parent, int depth = 1) + { + var paths = new List(); + foreach (SerializedProperty child in parent.GetChildProperties(depth)) + { + using (child) + { + paths.Add(child.propertyPath); + } + } + return paths.ToArray(); + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs.meta new file mode 100644 index 0000000..ba71fc4 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 243819863a67e42bc9e99c43b17f7dee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs new file mode 100644 index 0000000..ba15cf3 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections; +using System.Linq; +using MackySoft.SerializeReferenceExtensions.Editor; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.TestTools; +using Asset = MackySoft.SerializeReferenceExtensions.Tests.DrawerContextTestAsset; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class SubclassSelectorDrawerContextTests + { + private Asset[] targets; + private DrawerContextTestEditor[] editors; + private SelectorInspectorWindow[] windows; + + [UnitySetUp] + public IEnumerator SetUp () + { + ContextualNodeDrawer.DisposedContexts.Clear(); + targets = new Asset[2]; + editors = new DrawerContextTestEditor[2]; + windows = new SelectorInspectorWindow[2]; + for (int i = 0; i < targets.Length; i++) + { + targets[i] = ScriptableObject.CreateInstance(); + targets[i].name = "Drawer context host " + i; + targets[i].primary.amount += i * 200; + targets[i].secondary.amount += i * 200; + editors[i] = (DrawerContextTestEditor)UnityEditor.Editor.CreateEditor(targets[i]); + windows[i] = ScriptableObject.CreateInstance(); + windows[i].position = new Rect(100f + i * 20f, 100f + i * 20f, 600f, 500f); + // InspectorElement supplies Unity's normal per-Editor handler context and ownership. + windows[i].rootVisualElement.Add(new InspectorElement(editors[i])); + windows[i].ShowUtility(); + } + yield return null; + yield return null; + } + + [TearDown] + public void TearDown () + { + for (int i = 0; i < targets.Length; i++) + { + ReleaseEditor(i); + if (windows[i] != null) + { + windows[i].Close(); + } + if (targets[i] != null) + { + UnityEngine.Object.DestroyImmediate(targets[i]); + } + } + } + + [Test] + public void RegisteredCustomDrawer_UsesFieldMetadataAndIndependentHostFieldState () + { + AssertContext(0, "primary", "Primary selector", 0); + Click(0, "Edit Primary selector"); + Assert.That(targets[0].primary.amount, Is.EqualTo(108)); + Assert.That(targets[0].secondary.amount, Is.EqualTo(201)); + + AssertContext(0, "secondary", "Secondary selector", 0); + Click(0, "Edit Secondary selector"); + Assert.That(targets[0].secondary.amount, Is.EqualTo(214)); + Assert.That(targets[0].primary.amount, Is.EqualTo(108)); + + AssertContext(1, "primary", "Primary selector", 0); + Click(1, "Edit Primary selector"); + Assert.That(targets[1].primary.amount, Is.EqualTo(308)); + Assert.That(targets[1].secondary.amount, Is.EqualTo(401)); + AssertContext(0, "primary", "Primary selector", 1); + AssertContext(0, "secondary", "Secondary selector", 1); + AssertContext(1, "primary", "Primary selector", 1); + } + + [Test] + public void NativeEditorDisposal_ReleasesSelectorAndOrdinaryCustomDrawerResources () + { + AssertContext(0, "native", "Native reference", 0); + AssertContext(0, "primary", "Primary selector", 0); + int hostId = targets[0].GetInstanceID(); + Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.TargetId == hostId && context.PropertyPath == "native"), Is.False); + Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.TargetId == hostId && context.PropertyPath == "primary"), Is.False); + + ReleaseEditor(0); + + Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.TargetId == hostId && context.PropertyPath == "native"), Is.True); + Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.TargetId == hostId && context.PropertyPath == "primary"), Is.True); + } + + [Test] + public void CustomDrawer_ForwardingTheSamePropertyReachesDefaultRendering () + { + editors[0].PropertyPath = "forwarding"; + InspectorDrawing.DrawnContent[] contents = InspectorDrawing.Capture(windows[0]); + Assert.That(contents.Select(item => item.Text), Does.Contain("Number")); + Assert.That(contents.Select(item => item.Text), Does.Contain("29")); + AssertFits(contents, "Number"); + } + + [Test] + public void TypeOnlyLookup_ReturnsIndependentUnboundCallerOwnedDrawers () + { + PropertyDrawer first = null; + PropertyDrawer second = null; + try + { + Assert.That(PropertyDrawerCache.TryGetPropertyDrawer(typeof(Asset.StatefulNode), out first), Is.True); + Assert.That(PropertyDrawerCache.TryGetPropertyDrawer(typeof(Asset.StatefulNode), out second), Is.True); + Assert.That(first, Is.TypeOf()); + Assert.That(second, Is.TypeOf()); + Assert.That(second, Is.Not.SameAs(first)); + Assert.That(first.fieldInfo, Is.Null); + Assert.That(second.fieldInfo, Is.Null); + + ((IDisposable)first).Dispose(); + Assert.That(((ContextualNodeDrawer)second).IsDisposed, Is.False); + } + finally + { + (first as IDisposable)?.Dispose(); + (second as IDisposable)?.Dispose(); + } + } + + private void AssertContext (int owner, string path, string caption, int edits) + { + editors[owner].PropertyPath = path; + InspectorDrawing.DrawnContent[] contents = InspectorDrawing.Capture(windows[owner]); + string[] labels = contents.Select(item => item.Text).ToArray(); + Assert.That(labels, Does.Contain(caption)); + Assert.That(labels, Does.Contain(caption + " edits: " + edits)); + Assert.That(labels, Does.Contain("Tooltip: " + DrawerContextTestEditor.Tooltip)); + AssertFits(contents, caption, caption + " edits: " + edits, "Edit " + caption); + } + + private static void AssertFits (InspectorDrawing.DrawnContent[] contents, params string[] labels) + { + Rect following = contents.Single(item => item.Text == DrawerContextTestEditor.FollowingLabel).Rect; + foreach (string label in labels) + { + Assert.That(contents.Single(item => item.Text == label).Rect.yMax, Is.LessThanOrEqualTo(following.yMin)); + } + } + + private void Click (int owner, string text) + { + Rect button = InspectorDrawing.Capture(windows[owner]).Single(item => item.Text == text).Rect; + windows[owner].Focus(); + windows[owner].SendEvent(new Event { type = EventType.MouseDown, mousePosition = button.center, button = 0 }); + windows[owner].SendEvent(new Event { type = EventType.MouseUp, mousePosition = button.center, button = 0 }); + } + + private void ReleaseEditor (int owner) + { + if (windows[owner] != null) + { + windows[owner].rootVisualElement.Clear(); + } + if (editors[owner] != null) + { + UnityEngine.Object.DestroyImmediate(editors[owner]); + editors[owner] = null; + } + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs.meta new file mode 100644 index 0000000..ccbaa40 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 485d5c2ee65ba4536bdb66023b74841a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorTraversalTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorTraversalTests.cs new file mode 100644 index 0000000..ae64551 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorTraversalTests.cs @@ -0,0 +1,119 @@ +using System.Collections; +using System.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; +using Asset = MackySoft.SerializeReferenceExtensions.Tests.TraversalTestAsset; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class SubclassSelectorTraversalTests + { + private Asset target; + private SelectorInspectorWindow window; + + [UnitySetUp] + public IEnumerator SetUp () + { + target = ScriptableObject.CreateInstance(); + window = ScriptableObject.CreateInstance(); + window.Initialize(target, "reference", true); + window.ShowUtility(); + yield return null; + } + + [TearDown] + public void TearDown () + { + if (window != null) + { + window.Close(); + } + if (target != null) + { + Object.DestroyImmediate(target); + } + } + + [TestCase(false)] + [TestCase(true)] + public void CyclicReferences_InspectorDrawsAndEditsVisibleChildren (bool mutual) + { + var first = new Asset.Cycle(); + var second = mutual ? new Asset.Cycle { visibleValue = 43 } : first; + first.next = second; + second.next = first; + target.reference = first; + SetExpanded("reference.next", false); + + InspectorDrawing.DrawnContent[] contents = InspectorDrawing.Capture(window); + AssertChildrenFit(contents, "Visible Value", "Next", "Tail Value"); + ReplaceNumber(contents, "17", "42"); + + Assert.That(((Asset.Cycle)target.reference).visibleValue, Is.EqualTo(42)); + Assert.That(((Asset.Cycle)target.reference).tailValue, Is.EqualTo(71)); + if (mutual) + { + Assert.That(((Asset.Cycle)target.reference).next.visibleValue, Is.EqualTo(43)); + } + AssertChildrenFit(InspectorDrawing.Capture(window), "Visible Value", "Next", "Tail Value"); + } + + [Test] + public void ChildControlResizesArray_UpdatesValuesAndFollowingLayout () + { + target.reference = new Asset.CollectionRoot(); + InspectorDrawing.DrawnContent[] contents = InspectorDrawing.Capture(window); + Rect resize = contents.Single(item => item.Text == TraversalCollectionDrawer.ResizeButton).Rect; + window.Focus(); + Click(resize.center); + + var value = (Asset.CollectionRoot)target.reference; + Assert.That(value.collection.values, Has.Length.EqualTo(3)); + Assert.That(value.collection.values[0], Is.EqualTo(31)); + Assert.That(value.visibleValue, Is.EqualTo(17)); + Assert.That(value.tailValue, Is.EqualTo(71)); + AssertChildrenFit(InspectorDrawing.Capture(window), "Visible Value", "Collection", TraversalCollectionDrawer.ResizeButton, "Tail Value"); + } + + private void SetExpanded (string path, bool expanded) + { + using (var serialized = new SerializedObject(target)) + using (SerializedProperty property = serialized.FindProperty(path)) + { + property.isExpanded = expanded; + } + } + + private static void AssertChildrenFit (InspectorDrawing.DrawnContent[] contents, params string[] labels) + { + Rect following = contents.Single(item => item.Text == SelectorInspectorWindow.FollowingLabel).Rect; + foreach (string label in labels) + { + Rect child = contents.Single(item => item.Text == label).Rect; + Assert.That(child.yMax, Is.LessThanOrEqualTo(following.yMin), label + " must fit before the following field."); + } + } + + private void Click (Vector2 position) + { + window.SendEvent(new Event { type = EventType.MouseDown, mousePosition = position, button = 0 }); + window.SendEvent(new Event { type = EventType.MouseUp, mousePosition = position, button = 0 }); + } + + private void ReplaceNumber (InspectorDrawing.DrawnContent[] contents, string current, string replacement) + { + Rect input = contents.First(item => item.Text == current).Rect; + window.Focus(); + Click(input.center); + EventModifiers modifier = Application.platform == RuntimePlatform.OSXEditor ? EventModifiers.Command : EventModifiers.Control; + window.SendEvent(new Event { type = EventType.KeyDown, keyCode = KeyCode.A, modifiers = modifier }); + foreach (char character in replacement) + { + window.SendEvent(new Event { type = EventType.KeyDown, character = character }); + } + window.SendEvent(new Event { type = EventType.KeyDown, keyCode = KeyCode.Return, character = '\n' }); + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorTraversalTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorTraversalTests.cs.meta new file mode 100644 index 0000000..1120a24 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorTraversalTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f52cd87a8986043fcaa4aa98562aefab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalCollectionDrawer.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalCollectionDrawer.cs new file mode 100644 index 0000000..e8fa688 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalCollectionDrawer.cs @@ -0,0 +1,32 @@ +using UnityEditor; +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + [CustomPropertyDrawer(typeof(TraversalTestAsset.ResizableCollection))] + public sealed class TraversalCollectionDrawer : PropertyDrawer + { + public const string ResizeButton = "Add child items"; + + public override float GetPropertyHeight (SerializedProperty property, GUIContent label) + { + return EditorGUIUtility.singleLineHeight * 2f + EditorGUIUtility.standardVerticalSpacing; + } + + 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, label); + var button = new Rect(position.x, header.yMax + EditorGUIUtility.standardVerticalSpacing, position.width, EditorGUIUtility.singleLineHeight); + if (GUI.Button(button, ResizeButton)) + { + using (SerializedProperty values = property.FindPropertyRelative("values")) + { + values.arraySize += 2; + property.serializedObject.ApplyModifiedProperties(); + } + GUI.changed = true; + } + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalCollectionDrawer.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalCollectionDrawer.cs.meta new file mode 100644 index 0000000..d7bcf80 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalCollectionDrawer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 66475e37db4894f319060868110a7f87 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalTestAsset.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalTestAsset.cs new file mode 100644 index 0000000..762ef17 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalTestAsset.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class TraversalTestAsset : ScriptableObject + { + [SerializeReference, SubclassSelector] public object reference; + public Branch[] array = { new Branch(), new Branch() }; + public List list = new List { new Branch(), new Branch() }; + + [Serializable] + public sealed class Cycle + { + public int visibleValue = 17; + [SerializeReference, SubclassSelector] public Cycle next; + public int tailValue = 71; + } + + [Serializable] + public sealed class NestedRoot + { + public int visibleValue = 17; + public Branch branch = new Branch(); + [HideInInspector] public int hiddenValue; + public int tailValue = 71; + } + + [Serializable] + public sealed class Branch + { + public int value = 23; + public Leaf leaf = new Leaf(); + } + + [Serializable] + public sealed class Leaf + { + public int value = 29; + } + + [Serializable] + public sealed class CollectionRoot + { + public int visibleValue = 17; + public ResizableCollection collection = new ResizableCollection(); + public int tailValue = 71; + } + + [Serializable] + public sealed class ResizableCollection + { + public int[] values = { 31 }; + } + + [Serializable] + public sealed class ClosedBranches + { + public int visibleValue = 17; + [SerializeReference, SubclassSelector] public Cycle[] entries; + public int tailValue = 71; + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalTestAsset.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalTestAsset.cs.meta new file mode 100644 index 0000000..90c078b --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalTestAsset.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 86463595d0a1744cba7e5053c2e17ed8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/UnrelatedListReferenceTestAsset.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/UnrelatedListReferenceTestAsset.cs new file mode 100644 index 0000000..12cc07a --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/UnrelatedListReferenceTestAsset.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections; +using UnityEngine; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class UnrelatedListReferenceTestAsset : ScriptableObject, IList + { + [SerializeReference] public DeferredReferenceTestAsset.INode reference = new DeferredReferenceTestAsset.Alpha(17); + public Parent parent = new Parent(); + + int IList.Add (object value) => ((IList)parent).Add(value); + void IList.Clear () => ((IList)parent).Clear(); + bool IList.Contains (object value) => ((IList)parent).Contains(value); + int IList.IndexOf (object value) => ((IList)parent).IndexOf(value); + void IList.Insert (int index, object value) => ((IList)parent).Insert(index, value); + void IList.Remove (object value) => ((IList)parent).Remove(value); + void IList.RemoveAt (int index) => ((IList)parent).RemoveAt(index); + bool IList.IsFixedSize => ((IList)parent).IsFixedSize; + bool IList.IsReadOnly => ((IList)parent).IsReadOnly; + object IList.this[int index] { get => ((IList)parent)[index]; set => ((IList)parent)[index] = value; } + int ICollection.Count => ((ICollection)parent).Count; + bool ICollection.IsSynchronized => ((ICollection)parent).IsSynchronized; + object ICollection.SyncRoot => ((ICollection)parent).SyncRoot; + void ICollection.CopyTo (Array array, int index) => ((ICollection)parent).CopyTo(array, index); + IEnumerator IEnumerable.GetEnumerator () => ((IEnumerable)parent).GetEnumerator(); + + [Serializable] + public sealed class Parent : IList + { + [SerializeReference] public DeferredReferenceTestAsset.INode reference = new DeferredReferenceTestAsset.Alpha(23); + + int IList.Add (object value) => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + void IList.Clear () => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + bool IList.Contains (object value) => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + int IList.IndexOf (object value) => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + void IList.Insert (int index, object value) => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + void IList.Remove (object value) => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + void IList.RemoveAt (int index) => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + bool IList.IsFixedSize => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + bool IList.IsReadOnly => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + object IList.this[int index] + { + get => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + set => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + } + int ICollection.Count => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + bool ICollection.IsSynchronized => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + object ICollection.SyncRoot => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + void ICollection.CopyTo (Array array, int index) => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + IEnumerator IEnumerable.GetEnumerator () => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits."); + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/UnrelatedListReferenceTestAsset.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/UnrelatedListReferenceTestAsset.cs.meta new file mode 100644 index 0000000..a568e40 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/UnrelatedListReferenceTestAsset.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c4ed90e4f7bf14b6794d643c013a2c8a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: