mirror of
https://github.com/Facepunch/sbox-public.git
synced 2026-08-01 08:18:20 -04:00
* Add tests for applying added prefab instances to prefabs * Fix nested prefab instances getting wiped when applying changes Prefab update serialization only kept instance data for nested roots sitting directly under the instance root, deeper ones (under group objects) got their mappings wiped in the prefab cache scene where they can't be rebuilt. Also only convert children to nested after applying, flagging the applied root itself made it get silently adopted when dragged into another instance later.
1305 lines
49 KiB
C#
1305 lines
49 KiB
C#
using Sandbox.Utility;
|
|
using System;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace SceneTests.GameObjects;
|
|
|
|
/// <summary>
|
|
/// Plain logic component used as prefab content by <see cref="PrefabInstanceTest"/>.
|
|
/// Carries two serialized properties so tests can override one and leave the other untouched.
|
|
/// </summary>
|
|
public class PrefabInstanceStatComponent : Component
|
|
{
|
|
/// <summary>
|
|
/// A serialized integer property used to test prefab instance property overrides.
|
|
/// </summary>
|
|
[Property]
|
|
public int Number { get; set; }
|
|
|
|
/// <summary>
|
|
/// A serialized string property used to test prefab instance property overrides.
|
|
/// </summary>
|
|
[Property]
|
|
public string Text { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class PrefabInstanceExtraComponent : Component
|
|
{
|
|
/// <summary>
|
|
/// A serialized string property so the added component carries data through round trips.
|
|
/// </summary>
|
|
[Property]
|
|
public string Label { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests for the prefab instance system: instance creation from a prefab scene, override/patch
|
|
/// tracking (<see cref="PrefabInstanceData"/>), patch-style serialization round trips,
|
|
/// revert/apply-to-prefab flows, BreakFromPrefab and nested prefab instances.
|
|
/// </summary>
|
|
[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";
|
|
|
|
/// <summary>
|
|
/// Root object JSON for a basic prefab: a root with a stat component and two children,
|
|
/// one of which has its own stat component.
|
|
/// </summary>
|
|
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": []
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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": []
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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}}"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
/// <summary>
|
|
/// Root object JSON for the inner prefab used by the nested prefab instance tests.
|
|
/// </summary>
|
|
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": []
|
|
}
|
|
""";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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}}"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static IDisposable RegisterBasicPrefab( out PrefabScene prefabScene )
|
|
{
|
|
var registration = Helpers.RegisterPrefabFromJson( BasicPrefabPath, BasicPrefabJson );
|
|
prefabScene = SceneUtility.GetPrefabScene( ResourceLibrary.Get<PrefabFile>( BasicPrefabPath ) );
|
|
return registration;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers the inner and outer test prefabs and fetches the outer cached prefab scene.
|
|
/// Dispose the returned registration to unregister both prefab files again.
|
|
/// </summary>
|
|
private static IDisposable RegisterNestedPrefabs( out PrefabScene outerScene )
|
|
{
|
|
var innerRegistration = Helpers.RegisterPrefabFromJson( InnerPrefabPath, InnerPrefabJson );
|
|
var outerRegistration = Helpers.RegisterPrefabFromJson( OuterPrefabPath, OuterPrefabJson );
|
|
outerScene = SceneUtility.GetPrefabScene( ResourceLibrary.Get<PrefabFile>( OuterPrefabPath ) );
|
|
|
|
return new DisposeAction( () =>
|
|
{
|
|
outerRegistration.Dispose();
|
|
innerRegistration.Dispose();
|
|
} );
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deserializes the given GameObject JSON into a brand new scene, simulating a scene file
|
|
/// being loaded from disk, and returns the restored GameObject.
|
|
/// </summary>
|
|
private static GameObject RestoreIntoFreshScene( JsonObject serialized )
|
|
{
|
|
var restoreScene = new Scene();
|
|
using var restoreScope = restoreScene.Push();
|
|
|
|
var restored = restoreScene.CreateObject();
|
|
restored.Deserialize( serialized );
|
|
return restored;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds a direct child by name, asserting it exists.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>();
|
|
Assert.IsNotNull( rootComp );
|
|
Assert.AreEqual( 5, rootComp.Number );
|
|
Assert.AreEqual( "prefab", rootComp.Text );
|
|
Assert.AreEqual( 10, childA.Components.Get<PrefabInstanceStatComponent>().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] );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<string>() );
|
|
Assert.IsTrue( json.ContainsKey( "__PrefabInstancePatch" ) );
|
|
Assert.IsTrue( json.ContainsKey( "__PrefabIdToInstanceId" ) );
|
|
Assert.AreEqual( instance.Id, json["__guid"].Deserialize<Guid>() );
|
|
|
|
// 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<PrefabInstanceStatComponent>().Number );
|
|
Assert.AreEqual( "prefab", restored.Components.Get<PrefabInstanceStatComponent>().Text );
|
|
|
|
// Instance ids survive the round trip via the id lookup
|
|
Assert.AreEqual( childA.Id, GetChild( restored, "ChildA" ).Id );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>();
|
|
|
|
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<int>() );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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 ) );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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 ) );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>();
|
|
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<PrefabInstanceStatComponent>();
|
|
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<PrefabInstanceStatComponent>().Number );
|
|
Assert.AreEqual( "ChildB", GetChild( restored, "ChildB" ).Name );
|
|
|
|
Assert.IsTrue( restored.PrefabInstance.IsPropertyOverridden( restoredComp, "Number" ) );
|
|
Assert.IsTrue( restored.PrefabInstance.IsPropertyOverridden( restoredChildA, "Name" ) );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>();
|
|
addedComp.Number = 99;
|
|
|
|
var extraComp = childA.AddComponent<PrefabInstanceExtraComponent>();
|
|
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<PrefabInstanceStatComponent>().Number );
|
|
|
|
// The added object and its component must not be duplicated by patch application
|
|
Assert.AreEqual( 1, restoredExtra.Components.GetAll<PrefabInstanceStatComponent>( FindMode.EverythingInSelf ).Count() );
|
|
|
|
var restoredChildA = GetChild( restored, "ChildA" );
|
|
Assert.AreEqual( "added", restoredChildA.Components.Get<PrefabInstanceExtraComponent>().Label );
|
|
Assert.AreEqual( 1, restoredChildA.Components.GetAll<PrefabInstanceExtraComponent>( FindMode.EverythingInSelf ).Count() );
|
|
|
|
Assert.IsTrue( restored.PrefabInstance.IsAddedGameObject( restoredExtra ) );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>().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<PrefabInstanceStatComponent>() );
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reparenting a prefab-defined child within the instance must be tracked as a moved
|
|
/// object and the new hierarchy must survive a round trip.
|
|
/// </summary>
|
|
[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 );
|
|
}
|
|
|
|
/// <summary>
|
|
/// RevertPropertyChange must restore the prefab's value for a single overridden property,
|
|
/// remove its override from the patch and leave the instance unmodified.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>();
|
|
|
|
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() );
|
|
}
|
|
|
|
/// <summary>
|
|
/// RevertComponentChanges must restore all of a component's overridden properties to
|
|
/// the prefab values at once.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>();
|
|
|
|
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 ) );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>();
|
|
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() );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>();
|
|
|
|
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 );
|
|
}
|
|
|
|
/// <summary>
|
|
/// ClearPatch must drop all recorded overrides so the instance reports itself unmodified,
|
|
/// and a following UpdateFromPrefab restores the original prefab state.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>();
|
|
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 );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>();
|
|
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<PrefabInstanceStatComponent>().Number );
|
|
|
|
var json = instance.Serialize();
|
|
Assert.IsFalse( json.ContainsKey( "__Prefab" ) );
|
|
Assert.IsTrue( json.ContainsKey( "Children" ) );
|
|
Assert.IsTrue( json.ContainsKey( "Components" ) );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>();
|
|
comp1.Number = 42;
|
|
|
|
instance1.PrefabInstance.ApplyComponentChangesToPrefab( comp1 );
|
|
|
|
// The prefab itself now carries the value
|
|
Assert.AreEqual( 42, prefabScene.Components.Get<PrefabInstanceStatComponent>().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<PrefabInstanceStatComponent>().Number );
|
|
|
|
// Existing unmodified instances update when refreshed from the prefab
|
|
var comp2 = instance2.Components.Get<PrefabInstanceStatComponent>();
|
|
Assert.AreEqual( 5, comp2.Number );
|
|
instance2.UpdateFromPrefab();
|
|
Assert.AreEqual( 42, comp2.Number );
|
|
}
|
|
|
|
/// <summary>
|
|
/// ApplyPropertyChangeToPrefab must push exactly one overridden property into the prefab,
|
|
/// leaving other overrides on the instance intact.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>();
|
|
|
|
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<PrefabInstanceStatComponent>();
|
|
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 );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>().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<PrefabInstanceStatComponent>().Number );
|
|
|
|
// The source instance no longer treats it as an addition
|
|
Assert.IsFalse( instance.PrefabInstance.IsAddedGameObject( added ) );
|
|
Assert.IsFalse( instance.PrefabInstance.IsModified() );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>();
|
|
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<PrefabInstanceStatComponent>().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" ) );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<PrefabInstanceStatComponent>();
|
|
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<PrefabInstanceStatComponent>();
|
|
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 ) ) );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[TestMethod]
|
|
public void OverridePrefabWithInstanceKeepsAddedPrefabInstanceIntact()
|
|
{
|
|
using var containerRegistration = Helpers.RegisterPrefabFromJson( ContainerPrefabPath, ContainerPrefabJson );
|
|
var containerScene = SceneUtility.GetPrefabScene( ResourceLibrary.Get<PrefabFile>( 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<PrefabInstanceStatComponent>();
|
|
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<PrefabInstanceStatComponent>();
|
|
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<PrefabInstanceStatComponent>().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<PrefabInstanceStatComponent>().Number );
|
|
Assert.AreEqual( 10, GetChild( freshItem, "ChildA" ).Components.Get<PrefabInstanceStatComponent>().Number );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[TestMethod]
|
|
public void ApplyToPrefabAfterAddedInstanceWasPreviouslyApplied()
|
|
{
|
|
using var containerRegistration = Helpers.RegisterPrefabFromJson( ContainerPrefabPath, ContainerPrefabJson );
|
|
var containerScene = SceneUtility.GetPrefabScene( ResourceLibrary.Get<PrefabFile>( ContainerPrefabPath ) );
|
|
using var itemRegistration = RegisterBasicPrefab( out _ );
|
|
var itemScene = SceneUtility.GetPrefabScene( ResourceLibrary.Get<PrefabFile>( 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<PrefabInstanceStatComponent>().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<PrefabFile>( 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<PrefabInstanceStatComponent>().Number );
|
|
|
|
var refreshedSlot = GetChild( container, "Slot" );
|
|
var refreshedItem = GetChild( refreshedSlot, "BasicRoot" );
|
|
Assert.IsTrue( refreshedItem.IsNestedPrefabInstanceRoot );
|
|
|
|
var itemComp = refreshedItem.Components.Get<PrefabInstanceStatComponent>();
|
|
Assert.IsNotNull( itemComp );
|
|
Assert.AreEqual( 42, itemComp.Number );
|
|
Assert.AreEqual( "prefab", itemComp.Text );
|
|
Assert.AreEqual( 10, GetChild( refreshedItem, "ChildA" ).Components.Get<PrefabInstanceStatComponent>().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<PrefabInstanceStatComponent>().Number );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[TestMethod]
|
|
public void ApplyToPrefabKeepsDeepNestedInstanceIntact()
|
|
{
|
|
using var innerRegistration = Helpers.RegisterPrefabFromJson( InnerPrefabPath, InnerPrefabJson );
|
|
using var groupedRegistration = Helpers.RegisterPrefabFromJson( GroupedOuterPrefabPath, GroupedOuterPrefabJson );
|
|
var groupedScene = SceneUtility.GetPrefabScene( ResourceLibrary.Get<PrefabFile>( GroupedOuterPrefabPath ) );
|
|
using var itemRegistration = RegisterBasicPrefab( out _ );
|
|
var itemScene = SceneUtility.GetPrefabScene( ResourceLibrary.Get<PrefabFile>( 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<PrefabInstanceStatComponent>();
|
|
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<PrefabFile>( GroupedOuterPrefabPath );
|
|
var fileGroup = outerFile.RootObject["Children"].AsArray().First( c => c["Name"]?.GetValue<string>() == "Props" );
|
|
var fileNested = fileGroup["Children"].AsArray().First().AsObject();
|
|
Assert.AreEqual( InnerPrefabPath, fileNested["__Prefab"]?.GetValue<string>() );
|
|
var fileMapping = fileNested["__PrefabIdToInstanceId"].AsObject();
|
|
Assert.AreEqual( GroupedNestedInstanceGuid, fileMapping[InnerRootGuid]?.GetValue<string>() );
|
|
Assert.AreEqual( GroupedNestedCompGuid, fileMapping[InnerCompGuid]?.GetValue<string>() );
|
|
|
|
// 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<PrefabInstanceStatComponent>();
|
|
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<PrefabInstanceStatComponent>().Number );
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<string>() );
|
|
|
|
// 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" ) );
|
|
}
|
|
}
|