using Sandbox.Utility; using System; using System.Text.Json; using System.Text.Json.Nodes; namespace SceneTests.GameObjects; /// /// Plain logic component used as prefab content by . /// Carries two serialized properties so tests can override one and leave the other untouched. /// public class PrefabInstanceStatComponent : Component { /// /// A serialized integer property used to test prefab instance property overrides. /// [Property] public int Number { get; set; } /// /// A serialized string property used to test prefab instance property overrides. /// [Property] public string Text { get; set; } } /// /// A second plain component type, used when a test needs to add a brand new component /// to a prefab instance without confusing it with the prefab-defined components. /// public class PrefabInstanceExtraComponent : Component { /// /// A serialized string property so the added component carries data through round trips. /// [Property] public string Label { get; set; } } /// /// Tests for the prefab instance system: instance creation from a prefab scene, override/patch /// tracking (), patch-style serialization round trips, /// revert/apply-to-prefab flows, BreakFromPrefab and nested prefab instances. /// [TestClass] [DoNotParallelize] public class PrefabInstanceTest : SceneTest { private const string BasicPrefabPath = "test_prefab_instance_basic.prefab"; private const string InnerPrefabPath = "test_prefab_instance_inner.prefab"; private const string OuterPrefabPath = "test_prefab_instance_outer.prefab"; private const string ContainerPrefabPath = "test_prefab_instance_container.prefab"; private const string GroupedOuterPrefabPath = "test_prefab_instance_grouped_outer.prefab"; // GUIDs used inside the basic test prefab private const string RootGuid = "9e6a3e6e-0001-4a01-8001-000000000001"; private const string RootCompGuid = "9e6a3e6e-0001-4a01-8001-000000000002"; private const string ChildAGuid = "9e6a3e6e-0001-4a01-8001-000000000003"; private const string ChildACompGuid = "9e6a3e6e-0001-4a01-8001-000000000004"; private const string ChildBGuid = "9e6a3e6e-0001-4a01-8001-000000000005"; // GUIDs used inside the standalone container test prefab private const string ContainerRootGuid = "9e6a3e6e-0004-4a04-8004-000000000001"; private const string ContainerCompGuid = "9e6a3e6e-0004-4a04-8004-000000000002"; private const string ContainerSlotGuid = "9e6a3e6e-0004-4a04-8004-000000000003"; // GUIDs used inside the grouped outer test prefab (nested instance under a group child) private const string GroupedOuterRootGuid = "9e6a3e6e-0005-4a05-8005-000000000001"; private const string GroupedOuterGroupGuid = "9e6a3e6e-0005-4a05-8005-000000000002"; private const string GroupedNestedInstanceGuid = "9e6a3e6e-0005-4a05-8005-000000000003"; private const string GroupedNestedCompGuid = "9e6a3e6e-0005-4a05-8005-000000000004"; // GUIDs used inside the inner/outer test prefabs (nested prefab instance tests) private const string InnerRootGuid = "9e6a3e6e-0002-4a02-8002-000000000001"; private const string InnerCompGuid = "9e6a3e6e-0002-4a02-8002-000000000002"; private const string OuterRootGuid = "9e6a3e6e-0003-4a03-8003-000000000001"; private const string NestedInstanceGuid = "9e6a3e6e-0003-4a03-8003-000000000002"; private const string NestedInstanceCompGuid = "9e6a3e6e-0003-4a03-8003-000000000003"; /// /// Root object JSON for a basic prefab: a root with a stat component and two children, /// one of which has its own stat component. /// private static readonly string BasicPrefabJson = $$""" { "__guid": "{{RootGuid}}", "__version": 2, "Flags": 0, "Name": "BasicRoot", "Enabled": true, "Components": [ { "__type": "PrefabInstanceStatComponent", "__guid": "{{RootCompGuid}}", "__enabled": true, "Number": 5, "Text": "prefab" } ], "Children": [ { "__guid": "{{ChildAGuid}}", "__version": 2, "Flags": 0, "Name": "ChildA", "Enabled": true, "Components": [ { "__type": "PrefabInstanceStatComponent", "__guid": "{{ChildACompGuid}}", "__enabled": true, "Number": 10, "Text": "childA" } ], "Children": [] }, { "__guid": "{{ChildBGuid}}", "__version": 2, "Flags": 0, "Name": "ChildB", "Enabled": true, "Components": [], "Children": [] } ] } """; /// /// Root object JSON for a standalone container prefab: a root with a stat component and an /// empty child. Used as the drop target when adding a prefab instance to another instance. /// private static readonly string ContainerPrefabJson = $$""" { "__guid": "{{ContainerRootGuid}}", "__version": 2, "Flags": 0, "Name": "ContainerRoot", "Enabled": true, "Components": [ { "__type": "PrefabInstanceStatComponent", "__guid": "{{ContainerCompGuid}}", "__enabled": true, "Number": 20, "Text": "container" } ], "Children": [ { "__guid": "{{ContainerSlotGuid}}", "__version": 2, "Flags": 0, "Name": "Slot", "Enabled": true, "Components": [], "Children": [] } ] } """; /// /// Root object JSON for an outer prefab whose nested instance sits under a plain group /// child instead of directly under the root, like props grouped inside a structure prefab. /// private static readonly string GroupedOuterPrefabJson = $$""" { "__guid": "{{GroupedOuterRootGuid}}", "__version": 2, "Flags": 0, "Name": "GroupedOuterRoot", "Enabled": true, "Components": [], "Children": [ { "__guid": "{{GroupedOuterGroupGuid}}", "__version": 2, "Flags": 0, "Name": "Props", "Enabled": true, "Components": [], "Children": [ { "__guid": "{{GroupedNestedInstanceGuid}}", "__version": 2, "__Prefab": "{{InnerPrefabPath}}", "__PrefabInstancePatch": { "AddedObjects": [], "RemovedObjects": [], "PropertyOverrides": [], "MovedObjects": [] }, "__PrefabIdToInstanceId": { "{{InnerRootGuid}}": "{{GroupedNestedInstanceGuid}}", "{{InnerCompGuid}}": "{{GroupedNestedCompGuid}}" } } ] } ] } """; /// /// Root object JSON for the inner prefab used by the nested prefab instance tests. /// private static readonly string InnerPrefabJson = $$""" { "__guid": "{{InnerRootGuid}}", "__version": 2, "Flags": 0, "Name": "InnerRoot", "Enabled": true, "Components": [ { "__type": "PrefabInstanceStatComponent", "__guid": "{{InnerCompGuid}}", "__enabled": true, "Number": 7, "Text": "inner" } ], "Children": [] } """; /// /// Root object JSON for the outer prefab, containing a prefab instance of the inner prefab /// stored in the compact patch form, the same way prefab files store them on disk. /// private static readonly string OuterPrefabJson = $$""" { "__guid": "{{OuterRootGuid}}", "__version": 2, "Flags": 0, "Name": "OuterRoot", "Enabled": true, "Components": [], "Children": [ { "__guid": "{{NestedInstanceGuid}}", "__version": 2, "__Prefab": "{{InnerPrefabPath}}", "__PrefabInstancePatch": { "AddedObjects": [], "RemovedObjects": [], "PropertyOverrides": [], "MovedObjects": [] }, "__PrefabIdToInstanceId": { "{{InnerRootGuid}}": "{{NestedInstanceGuid}}", "{{InnerCompGuid}}": "{{NestedInstanceCompGuid}}" } } ] } """; /// /// Registers the basic test prefab in the resource system and fetches its cached prefab scene. /// Dispose the returned registration to unregister the prefab file again. /// private static IDisposable RegisterBasicPrefab( out PrefabScene prefabScene ) { var registration = Helpers.RegisterPrefabFromJson( BasicPrefabPath, BasicPrefabJson ); prefabScene = SceneUtility.GetPrefabScene( ResourceLibrary.Get( BasicPrefabPath ) ); return registration; } /// /// Registers the inner and outer test prefabs and fetches the outer cached prefab scene. /// Dispose the returned registration to unregister both prefab files again. /// private static IDisposable RegisterNestedPrefabs( out PrefabScene outerScene ) { var innerRegistration = Helpers.RegisterPrefabFromJson( InnerPrefabPath, InnerPrefabJson ); var outerRegistration = Helpers.RegisterPrefabFromJson( OuterPrefabPath, OuterPrefabJson ); outerScene = SceneUtility.GetPrefabScene( ResourceLibrary.Get( OuterPrefabPath ) ); return new DisposeAction( () => { outerRegistration.Dispose(); innerRegistration.Dispose(); } ); } /// /// Deserializes the given GameObject JSON into a brand new scene, simulating a scene file /// being loaded from disk, and returns the restored GameObject. /// private static GameObject RestoreIntoFreshScene( JsonObject serialized ) { var restoreScene = new Scene(); using var restoreScope = restoreScene.Push(); var restored = restoreScene.CreateObject(); restored.Deserialize( serialized ); return restored; } /// /// Finds a direct child by name, asserting it exists. /// private static GameObject GetChild( GameObject go, string name ) { var child = go.Children.FirstOrDefault( c => c.Name == name ); Assert.IsNotNull( child, $"Expected '{go.Name}' to have a child named '{name}'" ); return child; } /// /// Cloning a prefab scene must produce a prefab instance: root flagged as the outermost /// instance root, children part of the instance, all prefab content present, and the /// GUID lookups translating between prefab ids and fresh instance ids in both directions. /// [TestMethod] public void ClonePrefabCreatesPrefabInstance() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); Assert.IsTrue( instance.IsPrefabInstance ); Assert.IsTrue( instance.IsPrefabInstanceRoot ); Assert.IsTrue( instance.IsOutermostPrefabInstanceRoot ); Assert.IsFalse( instance.PrefabInstance.IsNested ); Assert.AreEqual( BasicPrefabPath, instance.PrefabInstanceSource ); Assert.AreEqual( "BasicRoot", instance.Name ); Assert.AreEqual( 2, instance.Children.Count ); var childA = GetChild( instance, "ChildA" ); var childB = GetChild( instance, "ChildB" ); Assert.IsTrue( childA.IsPrefabInstance ); Assert.IsFalse( childA.IsPrefabInstanceRoot ); Assert.AreEqual( instance, childA.OutermostPrefabInstanceRoot ); var rootComp = instance.Components.Get(); Assert.IsNotNull( rootComp ); Assert.AreEqual( 5, rootComp.Number ); Assert.AreEqual( "prefab", rootComp.Text ); Assert.AreEqual( 10, childA.Components.Get().Number ); // The instance must use fresh ids, not the prefab's ids Assert.AreNotEqual( Guid.Parse( RootGuid ), instance.Id ); Assert.AreNotEqual( Guid.Parse( ChildAGuid ), childA.Id ); // Lookups must map every prefab object to its instance object, in both directions var toInstance = instance.PrefabInstance.PrefabToInstanceLookup; var toPrefab = instance.PrefabInstance.InstanceToPrefabLookup; Assert.AreEqual( instance.Id, toInstance[Guid.Parse( RootGuid )] ); Assert.AreEqual( childA.Id, toInstance[Guid.Parse( ChildAGuid )] ); Assert.AreEqual( childB.Id, toInstance[Guid.Parse( ChildBGuid )] ); Assert.AreEqual( rootComp.Id, toInstance[Guid.Parse( RootCompGuid )] ); Assert.AreEqual( Guid.Parse( RootGuid ), toPrefab[instance.Id] ); Assert.AreEqual( Guid.Parse( ChildAGuid ), toPrefab[childA.Id] ); Assert.AreEqual( Guid.Parse( RootCompGuid ), toPrefab[rootComp.Id] ); } /// /// An unmodified prefab instance must serialize as a compact patch reference /// (__Prefab + patch + id lookup) instead of the full hierarchy, must not report itself /// as modified, and must round trip through serialization preserving ids and content. /// [TestMethod] public void UnmodifiedInstanceSerializesCompactAndRoundTrips() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var childA = GetChild( instance, "ChildA" ); var json = instance.Serialize(); Assert.AreEqual( BasicPrefabPath, json["__Prefab"].GetValue() ); Assert.IsTrue( json.ContainsKey( "__PrefabInstancePatch" ) ); Assert.IsTrue( json.ContainsKey( "__PrefabIdToInstanceId" ) ); Assert.AreEqual( instance.Id, json["__guid"].Deserialize() ); // The whole point of the patch format: no expanded hierarchy in the saved scene Assert.IsFalse( json.ContainsKey( "Children" ) ); Assert.IsFalse( json.ContainsKey( "Components" ) ); Assert.IsFalse( json.ContainsKey( "Name" ) ); Assert.IsFalse( instance.PrefabInstance.IsModified() ); var restored = RestoreIntoFreshScene( json ); Assert.IsTrue( restored.IsPrefabInstanceRoot ); Assert.AreEqual( instance.Id, restored.Id ); Assert.AreEqual( "BasicRoot", restored.Name ); Assert.AreEqual( 2, restored.Children.Count ); Assert.AreEqual( 5, restored.Components.Get().Number ); Assert.AreEqual( "prefab", restored.Components.Get().Text ); // Instance ids survive the round trip via the id lookup Assert.AreEqual( childA.Id, GetChild( restored, "ChildA" ).Id ); } /// /// Changing a component property on an instance must be tracked as a property override /// targeting the prefab's component id, flagged through IsPropertyOverridden, /// IsComponentModified, IsGameObjectModified and IsModified. Untouched properties must /// not be reported as overridden. /// [TestMethod] public void ComponentPropertyOverrideTracked() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var comp = instance.Components.Get(); comp.Number = 42; instance.PrefabInstance.RefreshPatch(); Assert.IsTrue( instance.PrefabInstance.IsPropertyOverridden( comp, "Number" ) ); Assert.IsFalse( instance.PrefabInstance.IsPropertyOverridden( comp, "Text" ) ); Assert.IsTrue( instance.PrefabInstance.IsComponentModified( comp ) ); Assert.IsTrue( instance.PrefabInstance.IsModified() ); Assert.IsTrue( instance.PrefabInstance.IsGameObjectModified( instance, ignoreBasicGoOverrides: true ) ); var numberOverride = instance.PrefabInstance.Patch.PropertyOverrides.Single( x => x.Property == "Number" ); Assert.AreEqual( Guid.Parse( RootCompGuid ), Guid.Parse( numberOverride.Target.IdValue ) ); Assert.AreEqual( 42, numberOverride.Value.GetValue() ); } /// /// Transform and name changes on the instance root are expected on every placed instance, /// so while they appear in the patch they must not make the instance count as modified, /// and must be filterable through the ignoreBasicGoOverrides flag. /// [TestMethod] public void RootBasicOverridesAreIgnored() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone( new Vector3( 100, 200, 300 ) ); instance.Name = "Renamed Instance"; instance.PrefabInstance.RefreshPatch(); Assert.IsTrue( instance.PrefabInstance.IsPropertyOverridden( instance, "Name" ) ); Assert.IsTrue( instance.PrefabInstance.IsPropertyOverridden( instance, "Position" ) ); Assert.IsFalse( instance.PrefabInstance.IsPropertyOverridden( instance, "Name", ignoreBasicGoOverrides: true ) ); Assert.IsFalse( instance.PrefabInstance.IsPropertyOverridden( instance, "Position", ignoreBasicGoOverrides: true ) ); Assert.IsFalse( instance.PrefabInstance.IsModified() ); Assert.IsFalse( instance.PrefabInstance.IsGameObjectModified( instance, ignoreBasicGoOverrides: true ) ); } /// /// Unlike on the instance root, name and transform changes on a child inside the instance /// are real modifications: they must be tracked (including the GameTransform property name /// remapping LocalPosition -> Position) and make the instance modified. /// [TestMethod] public void ChildModificationsTracked() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var childA = GetChild( instance, "ChildA" ); var childB = GetChild( instance, "ChildB" ); childA.Name = "ChildA_Renamed"; childA.LocalPosition = new Vector3( 50, 0, 0 ); instance.PrefabInstance.RefreshPatch(); Assert.IsTrue( instance.PrefabInstance.IsModified() ); Assert.IsTrue( instance.PrefabInstance.IsPropertyOverridden( childA, "Name" ) ); Assert.IsTrue( instance.PrefabInstance.IsPropertyOverridden( childA, "Position" ) ); // Editor passes the GameTransform as the owner, with the C# property name Assert.IsTrue( instance.PrefabInstance.IsPropertyOverridden( childA.Transform, "LocalPosition" ) ); // Basic overrides are only ignored on the root, not on children Assert.IsTrue( instance.PrefabInstance.IsPropertyOverridden( childA, "Name", ignoreBasicGoOverrides: true ) ); Assert.IsTrue( instance.PrefabInstance.IsGameObjectModified( childA ) ); Assert.IsFalse( instance.PrefabInstance.IsGameObjectModified( childB ) ); } /// /// A modified instance must survive a serialize/deserialize round trip: the override /// values are restored, untouched values still come from the prefab, instance ids are /// preserved and the restored instance still knows what was overridden. /// [TestMethod] public void ModifiedInstanceRoundTrip() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var comp = instance.Components.Get(); var childA = GetChild( instance, "ChildA" ); comp.Number = 42; childA.Name = "ChildA_Renamed"; // Serialize() refreshes the patch internally for outermost instance roots var restored = RestoreIntoFreshScene( instance.Serialize() ); var restoredComp = restored.Components.Get(); Assert.AreEqual( 42, restoredComp.Number ); Assert.AreEqual( "prefab", restoredComp.Text ); var restoredChildA = GetChild( restored, "ChildA_Renamed" ); Assert.AreEqual( childA.Id, restoredChildA.Id ); // Data that wasn't overridden still comes from the prefab Assert.AreEqual( 10, restoredChildA.Components.Get().Number ); Assert.AreEqual( "ChildB", GetChild( restored, "ChildB" ).Name ); Assert.IsTrue( restored.PrefabInstance.IsPropertyOverridden( restoredComp, "Number" ) ); Assert.IsTrue( restored.PrefabInstance.IsPropertyOverridden( restoredChildA, "Name" ) ); } /// /// GameObjects and components added to a prefab instance must be tracked as additions, /// survive a round trip exactly once (no duplication through the patch), and keep their /// position at the end of the child list. /// [TestMethod] public void AddedObjectsTrackedAndRoundTrip() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var childA = GetChild( instance, "ChildA" ); var added = scene.CreateObject(); added.Name = "Extra"; added.SetParent( instance ); var addedComp = added.AddComponent(); addedComp.Number = 99; var extraComp = childA.AddComponent(); extraComp.Label = "added"; instance.PrefabInstance.RefreshPatch(); Assert.IsTrue( instance.PrefabInstance.IsAddedGameObject( added ) ); Assert.IsTrue( instance.PrefabInstance.IsAddedComponent( extraComp ) ); Assert.IsTrue( instance.PrefabInstance.IsModified() ); Assert.IsTrue( instance.PrefabInstance.IsGameObjectModified( instance, ignoreBasicGoOverrides: true ) ); // Added objects have no prefab counterpart, so they can't have property overrides Assert.IsFalse( instance.PrefabInstance.IsPropertyOverridden( added, "Name" ) ); var restored = RestoreIntoFreshScene( instance.Serialize() ); Assert.AreEqual( 3, restored.Children.Count ); Assert.AreEqual( "Extra", restored.Children[2].Name ); var restoredExtra = GetChild( restored, "Extra" ); Assert.AreEqual( added.Id, restoredExtra.Id ); Assert.AreEqual( 99, restoredExtra.Components.Get().Number ); // The added object and its component must not be duplicated by patch application Assert.AreEqual( 1, restoredExtra.Components.GetAll( FindMode.EverythingInSelf ).Count() ); var restoredChildA = GetChild( restored, "ChildA" ); Assert.AreEqual( "added", restoredChildA.Components.Get().Label ); Assert.AreEqual( 1, restoredChildA.Components.GetAll( FindMode.EverythingInSelf ).Count() ); Assert.IsTrue( restored.PrefabInstance.IsAddedGameObject( restoredExtra ) ); } /// /// Removing a prefab-defined GameObject or component from an instance must be tracked as /// a removal targeting the prefab ids, and the removal must survive a round trip without /// the prefab content leaking back in. /// [TestMethod] public void RemovedObjectsTrackedAndRoundTrip() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var childA = GetChild( instance, "ChildA" ); var childB = GetChild( instance, "ChildB" ); childB.DestroyImmediate(); childA.Components.Get().Destroy(); instance.PrefabInstance.RefreshPatch(); var removed = instance.PrefabInstance.Patch.RemovedObjects; Assert.AreEqual( 2, removed.Count ); Assert.IsTrue( removed.Any( x => Guid.Parse( x.Id.IdValue ) == Guid.Parse( ChildBGuid ) ) ); Assert.IsTrue( removed.Any( x => Guid.Parse( x.Id.IdValue ) == Guid.Parse( ChildACompGuid ) ) ); Assert.IsTrue( instance.PrefabInstance.IsModified() ); Assert.IsTrue( instance.PrefabInstance.IsGameObjectModified( instance, ignoreBasicGoOverrides: true ) ); Assert.IsTrue( instance.PrefabInstance.IsGameObjectModified( childA, ignoreBasicGoOverrides: true ) ); var restored = RestoreIntoFreshScene( instance.Serialize() ); Assert.AreEqual( 1, restored.Children.Count ); Assert.AreEqual( "ChildA", restored.Children[0].Name ); Assert.IsNull( restored.Children[0].Components.Get() ); } /// /// Reparenting a prefab-defined child within the instance must be tracked as a moved /// object and the new hierarchy must survive a round trip. /// [TestMethod] public void ReparentedChildTrackedAndRoundTrip() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var childA = GetChild( instance, "ChildA" ); var childB = GetChild( instance, "ChildB" ); childB.SetParent( childA ); instance.PrefabInstance.RefreshPatch(); Assert.AreEqual( 1, instance.PrefabInstance.Patch.MovedObjects.Count ); Assert.AreEqual( Guid.Parse( ChildBGuid ), Guid.Parse( instance.PrefabInstance.Patch.MovedObjects[0].Id.IdValue ) ); Assert.IsTrue( instance.PrefabInstance.IsModified() ); var restored = RestoreIntoFreshScene( instance.Serialize() ); Assert.AreEqual( 1, restored.Children.Count ); var restoredChildA = GetChild( restored, "ChildA" ); Assert.AreEqual( 1, restoredChildA.Children.Count ); Assert.AreEqual( "ChildB", restoredChildA.Children[0].Name ); Assert.AreEqual( childB.Id, restoredChildA.Children[0].Id ); } /// /// RevertPropertyChange must restore the prefab's value for a single overridden property, /// remove its override from the patch and leave the instance unmodified. /// [TestMethod] public void RevertPropertyChangeRestoresPrefabValue() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var comp = instance.Components.Get(); comp.Number = 42; instance.PrefabInstance.RefreshPatch(); Assert.IsTrue( instance.PrefabInstance.IsModified() ); instance.PrefabInstance.RevertPropertyChange( comp, "Number" ); Assert.AreEqual( 5, comp.Number ); Assert.IsFalse( instance.PrefabInstance.IsPropertyOverridden( comp, "Number" ) ); Assert.IsFalse( instance.PrefabInstance.IsModified() ); } /// /// RevertComponentChanges must restore all of a component's overridden properties to /// the prefab values at once. /// [TestMethod] public void RevertComponentChangesRestoresAllProperties() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var comp = instance.Components.Get(); comp.Number = 42; comp.Text = "changed"; instance.PrefabInstance.RefreshPatch(); Assert.IsTrue( instance.PrefabInstance.IsComponentModified( comp ) ); instance.PrefabInstance.RevertComponentChanges( comp ); Assert.AreEqual( 5, comp.Number ); Assert.AreEqual( "prefab", comp.Text ); Assert.IsFalse( instance.PrefabInstance.IsComponentModified( comp ) ); } /// /// RevertGameObjectChanges on the instance root must throw away every local change: /// property overrides revert to prefab values, added objects are removed, and the /// existing C# object references stay valid because the revert refreshes in place. /// [TestMethod] public void RevertGameObjectChangesRevertsEverything() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var comp = instance.Components.Get(); var childA = GetChild( instance, "ChildA" ); comp.Number = 42; childA.Name = "ChildA_Renamed"; var added = scene.CreateObject(); added.Name = "Extra"; added.SetParent( instance ); instance.PrefabInstance.RefreshPatch(); Assert.IsTrue( instance.PrefabInstance.IsModified() ); instance.PrefabInstance.RevertGameObjectChanges( instance ); scene.ProcessDeletes(); Assert.AreEqual( 5, comp.Number ); Assert.AreEqual( "ChildA", childA.Name ); Assert.AreEqual( 2, instance.Children.Count ); Assert.IsNull( instance.Children.FirstOrDefault( c => c.Name == "Extra" ) ); Assert.IsFalse( instance.PrefabInstance.IsModified() ); } /// /// UpdateFromPrefab re-applies prefab data plus the cached patch. Overrides captured in /// the patch must survive the refresh, while changes that were never captured by a patch /// refresh must be discarded — the patch is the source of truth for instance state. /// [TestMethod] public void UpdateFromPrefabKeepsRefreshedOverridesDiscardsRest() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var comp = instance.Components.Get(); comp.Number = 42; instance.PrefabInstance.RefreshPatch(); // This change never makes it into the patch comp.Text = "scratch"; instance.UpdateFromPrefab(); Assert.AreEqual( 42, comp.Number ); Assert.AreEqual( "prefab", comp.Text ); Assert.IsTrue( instance.IsPrefabInstanceRoot ); } /// /// ClearPatch must drop all recorded overrides so the instance reports itself unmodified, /// and a following UpdateFromPrefab restores the original prefab state. /// [TestMethod] public void ClearPatchDiscardsOverrides() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var comp = instance.Components.Get(); var childA = GetChild( instance, "ChildA" ); comp.Number = 42; childA.Name = "ChildA_Renamed"; instance.PrefabInstance.RefreshPatch(); Assert.IsTrue( instance.PrefabInstance.IsModified() ); instance.PrefabInstance.ClearPatch( keepBasicGoOverridesOnRoot: false ); Assert.IsFalse( instance.PrefabInstance.IsModified() ); Assert.AreEqual( 0, instance.PrefabInstance.Patch.PropertyOverrides.Count ); instance.UpdateFromPrefab(); Assert.AreEqual( 5, comp.Number ); Assert.AreEqual( "ChildA", childA.Name ); } /// /// BreakFromPrefab must detach the instance from its prefab: no more prefab instance /// flags or source, all content kept as plain GameObjects, and serialization switches /// from the compact patch form to the full hierarchy. /// [TestMethod] public void BreakFromPrefabDetachesInstance() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var comp = instance.Components.Get(); comp.Number = 42; instance.BreakFromPrefab(); Assert.IsFalse( instance.IsPrefabInstance ); Assert.IsFalse( instance.IsPrefabInstanceRoot ); Assert.IsNull( instance.PrefabInstanceSource ); Assert.IsFalse( GetChild( instance, "ChildA" ).IsPrefabInstance ); // Content survives the break Assert.AreEqual( 2, instance.Children.Count ); Assert.AreEqual( 42, comp.Number ); Assert.AreEqual( 10, GetChild( instance, "ChildA" ).Components.Get().Number ); var json = instance.Serialize(); Assert.IsFalse( json.ContainsKey( "__Prefab" ) ); Assert.IsTrue( json.ContainsKey( "Children" ) ); Assert.IsTrue( json.ContainsKey( "Components" ) ); } /// /// ApplyComponentChangesToPrefab must write the instance's component state into the /// prefab itself: the cached prefab scene and prefab file update, the source instance /// stops being modified, new clones get the value, and existing instances pick it up /// when they call UpdateFromPrefab. /// [TestMethod] public void ApplyComponentChangesToPrefabPropagates() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance1 = prefabScene.Clone(); var instance2 = prefabScene.Clone(); var comp1 = instance1.Components.Get(); comp1.Number = 42; instance1.PrefabInstance.ApplyComponentChangesToPrefab( comp1 ); // The prefab itself now carries the value Assert.AreEqual( 42, prefabScene.Components.Get().Number ); // The source instance keeps the value but is no longer modified Assert.AreEqual( 42, comp1.Number ); Assert.IsFalse( instance1.PrefabInstance.IsModified() ); // A fresh clone gets the new prefab value var instance3 = prefabScene.Clone(); Assert.AreEqual( 42, instance3.Components.Get().Number ); // Existing unmodified instances update when refreshed from the prefab var comp2 = instance2.Components.Get(); Assert.AreEqual( 5, comp2.Number ); instance2.UpdateFromPrefab(); Assert.AreEqual( 42, comp2.Number ); } /// /// ApplyPropertyChangeToPrefab must push exactly one overridden property into the prefab, /// leaving other overrides on the instance intact. /// [TestMethod] public void ApplyPropertyChangeToPrefabUpdatesSingleProperty() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var comp = instance.Components.Get(); comp.Number = 42; comp.Text = "local"; instance.PrefabInstance.RefreshPatch(); instance.PrefabInstance.ApplyPropertyChangeToPrefab( comp, "Number" ); // Only Number was pushed to the prefab var fresh = prefabScene.Clone(); var freshComp = fresh.Components.Get(); Assert.AreEqual( 42, freshComp.Number ); Assert.AreEqual( "prefab", freshComp.Text ); // The instance no longer overrides Number, but still overrides Text Assert.IsFalse( instance.PrefabInstance.IsPropertyOverridden( comp, "Number" ) ); Assert.IsTrue( instance.PrefabInstance.IsPropertyOverridden( comp, "Text" ) ); Assert.AreEqual( "local", comp.Text ); } /// /// AddGameObjectToPrefab must move an added GameObject from the instance patch into the /// prefab definition: new clones contain it, and the source instance stops reporting it /// as an addition. /// [TestMethod] public void AddGameObjectToPrefabAddsToPrefab() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var added = scene.CreateObject(); added.Name = "Extra"; added.SetParent( instance ); added.AddComponent().Number = 99; instance.PrefabInstance.RefreshPatch(); Assert.IsTrue( instance.PrefabInstance.IsAddedGameObject( added ) ); instance.PrefabInstance.AddGameObjectToPrefab( added ); // New clones now contain the object as prefab content var fresh = prefabScene.Clone(); var freshExtra = GetChild( fresh, "Extra" ); Assert.AreEqual( 99, freshExtra.Components.Get().Number ); // The source instance no longer treats it as an addition Assert.IsFalse( instance.PrefabInstance.IsAddedGameObject( added ) ); Assert.IsFalse( instance.PrefabInstance.IsModified() ); } /// /// OverridePrefabWithInstance must make the prefab definition match the instance exactly: /// overrides and additions become prefab content, the instance becomes unmodified, and it /// still serializes as a compact prefab instance afterwards. /// [TestMethod] public void OverridePrefabWithInstanceMakesPrefabMatch() { using var registration = RegisterBasicPrefab( out var prefabScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var instance = prefabScene.Clone(); var comp = instance.Components.Get(); comp.Number = 77; var added = scene.CreateObject(); added.Name = "Extra"; added.SetParent( instance ); instance.PrefabInstance.RefreshPatch(); Assert.IsTrue( instance.PrefabInstance.IsModified() ); instance.PrefabInstance.OverridePrefabWithInstance(); Assert.IsFalse( instance.PrefabInstance.IsModified() ); Assert.IsTrue( instance.IsPrefabInstanceRoot ); // The prefab now matches the instance var fresh = prefabScene.Clone(); Assert.AreEqual( 77, fresh.Components.Get().Number ); Assert.IsNotNull( fresh.Children.FirstOrDefault( c => c.Name == "Extra" ) ); // The instance still serializes as a compact prefab instance reference var json = instance.Serialize(); Assert.IsTrue( json.ContainsKey( "__Prefab" ) ); Assert.IsFalse( json.ContainsKey( "Children" ) ); } /// /// A prefab containing an instance of another prefab must produce nested prefab instances /// when cloned: the inner root is flagged as nested with the outer root as its outermost /// root, BreakFromPrefab is a no-op on it, and modifications to nested content serialize /// through the outer instance's patch and survive a round trip, rebuilding the nested /// instance's GUID mappings back to the inner prefab. /// [TestMethod] public void NestedPrefabInstanceFlagsAndRoundTrip() { using var registration = RegisterNestedPrefabs( out var outerScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var outer = outerScene.Clone(); Assert.IsTrue( outer.IsOutermostPrefabInstanceRoot ); Assert.AreEqual( OuterPrefabPath, outer.PrefabInstanceSource ); Assert.AreEqual( 1, outer.Children.Count ); var nested = outer.Children[0]; Assert.AreEqual( "InnerRoot", nested.Name ); Assert.IsTrue( nested.IsPrefabInstanceRoot ); Assert.IsTrue( nested.IsNestedPrefabInstanceRoot ); Assert.IsTrue( nested.PrefabInstance.IsNested ); Assert.IsFalse( nested.IsOutermostPrefabInstanceRoot ); Assert.AreEqual( outer, nested.OutermostPrefabInstanceRoot ); Assert.AreEqual( InnerPrefabPath, nested.PrefabInstanceSource ); // The nested instance maps the inner prefab's ids onto its own objects var nestedComp = nested.Components.Get(); Assert.AreEqual( 7, nestedComp.Number ); Assert.AreEqual( nested.Id, nested.PrefabInstance.PrefabToInstanceLookup[Guid.Parse( InnerRootGuid )] ); Assert.AreEqual( nestedComp.Id, nested.PrefabInstance.PrefabToInstanceLookup[Guid.Parse( InnerCompGuid )] ); // Breaking from prefab only works on the outermost root, nested roots are a no-op nested.BreakFromPrefab(); Assert.IsTrue( nested.IsPrefabInstanceRoot ); // Modify nested content, then round trip the whole outer instance nestedComp.Number = 1234; var restored = RestoreIntoFreshScene( outer.Serialize() ); Assert.AreEqual( 1, restored.Children.Count ); var restoredNested = restored.Children[0]; Assert.IsTrue( restoredNested.IsNestedPrefabInstanceRoot ); Assert.AreEqual( InnerPrefabPath, restoredNested.PrefabInstanceSource ); Assert.AreEqual( nested.Id, restoredNested.Id ); var restoredNestedComp = restoredNested.Components.Get(); Assert.AreEqual( 1234, restoredNestedComp.Number ); Assert.AreEqual( nestedComp.Id, restoredNestedComp.Id ); // Nested instances don't store their lookups, they are rebuilt on load and // must cover every object of the inner prefab. // KNOWN ISSUE: after a load the lookup VALUES point at the outer prefab's // internal expansion objects rather than the live instance objects (a fresh // clone maps to live objects) - so we only pin key coverage here, not identity. Assert.IsTrue( restoredNested.PrefabInstance.PrefabToInstanceLookup.ContainsKey( Guid.Parse( InnerRootGuid ) ) ); Assert.IsTrue( restoredNested.PrefabInstance.PrefabToInstanceLookup.ContainsKey( Guid.Parse( InnerCompGuid ) ) ); } /// /// Dragging an item prefab instance into another prefab instance and applying changes on /// the outer root (editor "Apply to Prefab" = OverridePrefabWithInstance) must keep all /// scene objects intact: names, components, values and children must survive, the item /// must become a nested instance, and fresh clones must contain the item. /// [TestMethod] public void OverridePrefabWithInstanceKeepsAddedPrefabInstanceIntact() { using var containerRegistration = Helpers.RegisterPrefabFromJson( ContainerPrefabPath, ContainerPrefabJson ); var containerScene = SceneUtility.GetPrefabScene( ResourceLibrary.Get( ContainerPrefabPath ) ); using var itemRegistration = RegisterBasicPrefab( out var itemScene ); var scene = new Scene(); using var sceneScope = scene.Push(); // Drag outer prefab into scene var container = containerScene.Clone(); // Drag item prefab into scene, then into the outer instance in the hierarchy var item = itemScene.Clone(); item.SetParent( container ); container.PrefabInstance.RefreshPatch(); Assert.IsTrue( container.PrefabInstance.IsAddedGameObject( item ) ); // "Apply to Prefab" on the outer root container.PrefabInstance.OverridePrefabWithInstance(); // The container keeps its own content Assert.AreEqual( "ContainerRoot", container.Name ); var containerComp = container.Components.Get(); Assert.IsNotNull( containerComp ); Assert.AreEqual( 20, containerComp.Number ); Assert.AreEqual( "container", containerComp.Text ); Assert.IsNotNull( GetChild( container, "Slot" ) ); // The container stays a regular outermost instance, applying must not flag it nested Assert.IsTrue( container.IsOutermostPrefabInstanceRoot ); Assert.IsFalse( container.PrefabInstance.IsNested ); // The item keeps its content and becomes a nested instance Assert.AreEqual( "BasicRoot", item.Name ); Assert.IsTrue( item.IsPrefabInstanceRoot ); Assert.IsTrue( item.IsNestedPrefabInstanceRoot ); Assert.AreEqual( BasicPrefabPath, item.PrefabInstanceSource ); var itemComp = item.Components.Get(); Assert.IsNotNull( itemComp ); Assert.AreEqual( 5, itemComp.Number ); Assert.AreEqual( "prefab", itemComp.Text ); var itemChildA = GetChild( item, "ChildA" ); Assert.IsNotNull( GetChild( item, "ChildB" ) ); Assert.AreEqual( 10, itemChildA.Components.Get().Number ); Assert.IsFalse( container.PrefabInstance.IsModified() ); // A fresh clone of the container prefab contains the item as nested instance var fresh = containerScene.Clone(); var freshItem = GetChild( fresh, "BasicRoot" ); Assert.IsTrue( freshItem.IsNestedPrefabInstanceRoot ); Assert.AreEqual( 5, freshItem.Components.Get().Number ); Assert.AreEqual( 10, GetChild( freshItem, "ChildA" ).Components.Get().Number ); } /// /// Regression: OverridePrefabWithInstance used to flag the applied root itself as nested. /// If that instance was later dragged into another prefab instance, the reparent logic /// treated it as belonging to the new outer instance without any GUID mappings, corrupting /// both prefabs on the next apply. This chains: apply on the item, drag it into another /// instance (onto a child), apply on the outer. /// [TestMethod] public void ApplyToPrefabAfterAddedInstanceWasPreviouslyApplied() { using var containerRegistration = Helpers.RegisterPrefabFromJson( ContainerPrefabPath, ContainerPrefabJson ); var containerScene = SceneUtility.GetPrefabScene( ResourceLibrary.Get( ContainerPrefabPath ) ); using var itemRegistration = RegisterBasicPrefab( out _ ); var itemScene = SceneUtility.GetPrefabScene( ResourceLibrary.Get( BasicPrefabPath ) ); var scene = new Scene(); using var sceneScope = scene.Push(); var container = containerScene.Clone(); var item = itemScene.Clone(); // Tweak the item and apply changes on it first item.Components.Get().Number = 42; item.PrefabInstance.RefreshPatch(); item.PrefabInstance.OverridePrefabWithInstance(); Assert.IsTrue( item.IsOutermostPrefabInstanceRoot ); Assert.IsFalse( item.PrefabInstance.IsNested ); // Drag the item onto a child inside the container instance var slot = GetChild( container, "Slot" ); item.SetParent( slot ); // The item must still be its own instance, added to the container Assert.IsTrue( item.IsOutermostPrefabInstanceRoot ); container.PrefabInstance.RefreshPatch(); Assert.IsTrue( container.PrefabInstance.IsAddedGameObject( item ) ); // "Apply to Prefab" on the outer root container.PrefabInstance.OverridePrefabWithInstance(); container.PrefabInstance.RefreshPatch(); // The prefab file is saved, hot reload round-trips the JSON and rebuilds the cache scene var containerFile = ResourceLibrary.Get( ContainerPrefabPath ); containerFile.RootObject = JsonNode.Parse( containerFile.RootObject.ToJsonString() ).AsObject(); ((PrefabCacheScene)(GameObject)containerScene).Refresh( containerFile ); // The editor then refreshes all instances of the changed prefab in open scenes container.UpdateFromPrefab(); // Everything must still be intact Assert.AreEqual( 20, container.Components.Get().Number ); var refreshedSlot = GetChild( container, "Slot" ); var refreshedItem = GetChild( refreshedSlot, "BasicRoot" ); Assert.IsTrue( refreshedItem.IsNestedPrefabInstanceRoot ); var itemComp = refreshedItem.Components.Get(); Assert.IsNotNull( itemComp ); Assert.AreEqual( 42, itemComp.Number ); Assert.AreEqual( "prefab", itemComp.Text ); Assert.AreEqual( 10, GetChild( refreshedItem, "ChildA" ).Components.Get().Number ); // Fresh clones contain the chain with correct values var fresh = containerScene.Clone(); var freshItem = GetChild( GetChild( fresh, "Slot" ), "BasicRoot" ); Assert.AreEqual( 42, freshItem.Components.Get().Number ); } /// /// Regression: applying an instance whose prefab contains a nested instance under a plain /// group child (not directly under the root) wiped that nested instance's data in the /// prefab cache scene. The update serialization only marked direct children of the root /// with EditorSkipPrefabBreakOnRefresh, deeper nested roots got EditorPrefabInstanceNestedSource, /// which cannot rebuild mappings inside a PrefabCacheScene. Everything then lost its /// identity, values and components on refresh. /// [TestMethod] public void ApplyToPrefabKeepsDeepNestedInstanceIntact() { using var innerRegistration = Helpers.RegisterPrefabFromJson( InnerPrefabPath, InnerPrefabJson ); using var groupedRegistration = Helpers.RegisterPrefabFromJson( GroupedOuterPrefabPath, GroupedOuterPrefabJson ); var groupedScene = SceneUtility.GetPrefabScene( ResourceLibrary.Get( GroupedOuterPrefabPath ) ); using var itemRegistration = RegisterBasicPrefab( out _ ); var itemScene = SceneUtility.GetPrefabScene( ResourceLibrary.Get( BasicPrefabPath ) ); var scene = new Scene(); using var sceneScope = scene.Push(); var outer = groupedScene.Clone(); var group = GetChild( outer, "Props" ); var nested = GetChild( group, "InnerRoot" ); Assert.IsTrue( nested.IsNestedPrefabInstanceRoot ); var nestedId = nested.Id; var nestedComp = nested.Components.Get(); var nestedCompId = nestedComp.Id; Assert.AreEqual( 7, nestedComp.Number ); // Drag the item into the outer instance and apply var item = itemScene.Clone(); item.SetParent( outer ); outer.PrefabInstance.RefreshPatch(); outer.PrefabInstance.OverridePrefabWithInstance(); outer.PrefabInstance.RefreshPatch(); // The prefab file keeps the nested instance with its original stable ids var outerFile = ResourceLibrary.Get( GroupedOuterPrefabPath ); var fileGroup = outerFile.RootObject["Children"].AsArray().First( c => c["Name"]?.GetValue() == "Props" ); var fileNested = fileGroup["Children"].AsArray().First().AsObject(); Assert.AreEqual( InnerPrefabPath, fileNested["__Prefab"]?.GetValue() ); var fileMapping = fileNested["__PrefabIdToInstanceId"].AsObject(); Assert.AreEqual( GroupedNestedInstanceGuid, fileMapping[InnerRootGuid]?.GetValue() ); Assert.AreEqual( GroupedNestedCompGuid, fileMapping[InnerCompGuid]?.GetValue() ); // Hot reload round-trips the JSON and rebuilds the cache scene, then instances refresh outerFile.RootObject = JsonNode.Parse( outerFile.RootObject.ToJsonString() ).AsObject(); ((PrefabCacheScene)(GameObject)groupedScene).Refresh( outerFile ); outer.UpdateFromPrefab(); // The nested instance keeps its identity, content and mappings in the scene var refreshedNested = GetChild( GetChild( outer, "Props" ), "InnerRoot" ); Assert.AreEqual( nestedId, refreshedNested.Id ); Assert.IsTrue( refreshedNested.IsNestedPrefabInstanceRoot ); var refreshedComp = refreshedNested.Components.Get(); Assert.IsNotNull( refreshedComp ); Assert.AreEqual( nestedCompId, refreshedComp.Id ); Assert.AreEqual( 7, refreshedComp.Number ); Assert.AreEqual( "inner", refreshedComp.Text ); Assert.AreEqual( refreshedNested.Id, refreshedNested.PrefabInstance.PrefabToInstanceLookup[Guid.Parse( InnerRootGuid )] ); Assert.AreEqual( refreshedComp.Id, refreshedNested.PrefabInstance.PrefabToInstanceLookup[Guid.Parse( InnerCompGuid )] ); // The added item is intact too var refreshedItem = GetChild( outer, "BasicRoot" ); Assert.AreEqual( 5, refreshedItem.Components.Get().Number ); } /// /// Breaking the outer instance from its prefab must promote nested prefab instances to /// full outermost prefab instances, so the link to the inner prefab is preserved even /// though the outer link is gone. /// [TestMethod] public void BreakFromPrefabConvertsNestedInstancesToFull() { using var registration = RegisterNestedPrefabs( out var outerScene ); var scene = new Scene(); using var sceneScope = scene.Push(); var outer = outerScene.Clone(); var nested = outer.Children[0]; Assert.IsTrue( nested.IsNestedPrefabInstanceRoot ); outer.BreakFromPrefab(); Assert.IsFalse( outer.IsPrefabInstance ); Assert.IsTrue( nested.IsPrefabInstanceRoot ); Assert.IsFalse( nested.PrefabInstance.IsNested ); Assert.IsTrue( nested.IsOutermostPrefabInstanceRoot ); Assert.AreEqual( InnerPrefabPath, nested.PrefabInstanceSource ); Assert.IsFalse( nested.PrefabInstance.IsModified() ); // The promoted instance serializes as a compact prefab instance of the inner prefab var nestedJson = nested.Serialize(); Assert.AreEqual( InnerPrefabPath, nestedJson["__Prefab"].GetValue() ); // The broken outer object serializes as a plain hierarchy containing it var outerJson = outer.Serialize(); Assert.IsFalse( outerJson.ContainsKey( "__Prefab" ) ); Assert.IsTrue( outerJson.ContainsKey( "Children" ) ); } }