using System;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace SceneTests.GameObjects;
///
/// Pins GameObject serialization behaviour not covered by the existing
/// serialize tests: the NotSaved flag, serialize options, which flags persist
/// through a round trip, flag-dependent transform serialization, transform
/// overrides on deserialize, and id/enabled/tags round trips.
///
[TestClass]
[DoNotParallelize]
public class SerializeCoverageTest : SceneTest
{
///
/// An object flagged NotSaved serializes to null - it is omitted from disk
/// saves entirely.
///
[TestMethod]
public void NotSavedRootSerializesToNull()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
go.Flags |= GameObjectFlags.NotSaved;
Assert.IsNull( go.Serialize() );
}
///
/// A NotSaved child is silently omitted from its parent's serialized
/// Children array.
///
[TestMethod]
public void NotSavedChildOmittedFromChildren()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var parent = scene.CreateObject();
var saved = new GameObject( parent, true, "Saved" );
var skipped = new GameObject( parent, true, "Skipped" );
skipped.Flags |= GameObjectFlags.NotSaved;
var node = parent.Serialize();
var children = node["Children"].AsArray();
Assert.AreEqual( 1, children.Count );
Assert.AreEqual( "Saved", children[0]["Name"].GetValue() );
}
///
/// The IgnoreChildren / IgnoreComponents serialize options drop the
/// corresponding keys from the output entirely.
///
[TestMethod]
public void IgnoreChildrenAndComponentsOptions()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
go.Components.Create();
_ = new GameObject( go, true, "Child" );
var full = go.Serialize();
Assert.IsTrue( full.ContainsKey( "Children" ) );
Assert.IsTrue( full.ContainsKey( "Components" ) );
var slim = go.Serialize( new GameObject.SerializeOptions { IgnoreChildren = true, IgnoreComponents = true } );
Assert.IsFalse( slim.ContainsKey( "Children" ) );
Assert.IsFalse( slim.ContainsKey( "Components" ) );
}
///
/// The serialized object carries the core identity keys: guid, format
/// version 2, name, enabled state and the local transform.
///
[TestMethod]
public void SerializeWritesCoreKeys()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = new GameObject( true, "Core" );
go.LocalPosition = new Vector3( 1, 2, 3 );
var node = go.Serialize();
Assert.AreEqual( go.Id, node["__guid"].Deserialize() );
Assert.AreEqual( 2, node["__version"].GetValue() );
Assert.AreEqual( "Core", node["Name"].GetValue() );
Assert.IsTrue( node["Enabled"].GetValue() );
Assert.AreEqual( new Vector3( 1, 2, 3 ), node["Position"].GetValue() );
}
///
/// Only the object's own tags are serialized - inherited tags belong to the
/// ancestor - and they come back on deserialize.
///
[TestMethod]
public void OnlyOwnTagsSerializedAndRestored()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var parent = scene.CreateObject();
parent.Tags.Add( "red" );
var child = new GameObject( parent );
child.Tags.Add( "small" );
var node = child.Serialize();
Assert.AreEqual( "small", node["Tags"].GetValue() );
SceneUtility.MakeIdGuidsUnique( node );
var restored = new GameObject();
restored.Deserialize( node );
Assert.IsTrue( restored.Tags.Has( "small" ) );
Assert.IsFalse( restored.Tags.Has( "red" ), "inherited tags must not be baked into the child" );
}
///
/// Deserializing minimal JSON applies the documented defaults: the name is
/// kept, the object ends up disabled (no Enabled key means false) and the
/// transform resets to identity.
///
[TestMethod]
public void MinimalJsonLeavesDefaults()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = new GameObject( true, "KeepMe" );
go.LocalPosition = new Vector3( 5, 5, 5 );
var json = Json.ParseToJsonObject( $$"""{ "__guid": "{{Guid.NewGuid()}}", "__version": 2 }""" );
go.Deserialize( json );
Assert.AreEqual( "KeepMe", go.Name, "a missing Name key keeps the current name" );
Assert.IsFalse( go.Enabled, "a missing Enabled key means disabled" );
Assert.AreEqual( Vector3.Zero, go.LocalPosition );
Assert.AreEqual( Vector3.One, go.LocalTransform.Scale );
}
///
/// Only the persistent flags survive a serialize/deserialize round trip;
/// runtime flags like DontDestroyOnLoad are dropped.
///
[TestMethod]
public void FlagsRoundTripKeepsOnlyPersistentFlags()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
go.Flags |= GameObjectFlags.Hidden | GameObjectFlags.Absolute | GameObjectFlags.DontDestroyOnLoad;
var node = go.Serialize();
SceneUtility.MakeIdGuidsUnique( node );
var restored = new GameObject();
restored.Deserialize( node );
Assert.IsTrue( restored.Flags.Contains( GameObjectFlags.Hidden ) );
Assert.IsTrue( restored.Flags.Contains( GameObjectFlags.Absolute ) );
Assert.IsFalse( restored.Flags.Contains( GameObjectFlags.DontDestroyOnLoad ), "runtime-only flags must not survive the round trip" );
Assert.IsFalse( restored.Flags.Contains( GameObjectFlags.Deserializing ), "the deserializing flag must be cleared when done" );
}
// Loading and friends shouldn't end up on disk, they break prefab diffs if they do.
[TestMethod]
public void TransientFlagsAreNotSerializedForDisk()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
go.Flags |= GameObjectFlags.Hidden | GameObjectFlags.Loading;
var flags = (GameObjectFlags)(long)go.Serialize()[GameObject.JsonKeys.Flags];
Assert.IsTrue( flags.Contains( GameObjectFlags.Hidden ) );
Assert.IsFalse( flags.Contains( GameObjectFlags.Loading ) );
}
// Network is the exception, it sends everything.
[TestMethod]
public void NetworkSerializeKeepsFullFlags()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
go.Flags |= GameObjectFlags.Hidden | GameObjectFlags.Loading;
var flags = (GameObjectFlags)(long)go.Serialize( new GameObject.SerializeOptions { SceneForNetwork = true } )[GameObject.JsonKeys.Flags];
Assert.IsTrue( flags.Contains( GameObjectFlags.Hidden ) );
Assert.IsTrue( flags.Contains( GameObjectFlags.Loading ) );
}
// With every flag set, a disk serialize must write precisely PersistedFlags and nothing else.
[TestMethod]
public void OnlyPersistedFlagsAreWrittenToDisk()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
// NotSaved omits the whole object from disk, so leave it off.
go.Flags = (GameObjectFlags)(~0) & ~GameObjectFlags.NotSaved;
var flags = (GameObjectFlags)(long)go.Serialize()[GameObject.JsonKeys.Flags];
Assert.AreEqual( GameObject.PersistedFlags, flags, "disk serialize must contain exactly the persisted flags" );
}
///
/// EditorOnly objects are destroyed on the spot when deserialized into a
/// non-editor scene - they never appear in game.
///
[TestMethod]
public void EditorOnlyObjectDestroyedOnDeserialize()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
go.Flags |= GameObjectFlags.EditorOnly;
var node = go.Serialize();
SceneUtility.MakeIdGuidsUnique( node );
var restored = new GameObject();
restored.Deserialize( node );
Assert.IsFalse( restored.IsValid );
Assert.IsFalse( scene.Children.Contains( restored ) );
}
///
/// Attachment-flagged objects don't persist their transform - they
/// serialize an identity transform because an animation drives them.
///
[TestMethod]
public void AttachmentSerializesIdentityTransform()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
go.Flags |= GameObjectFlags.Attachment;
go.LocalPosition = new Vector3( 5, 6, 7 );
var node = go.Serialize();
SceneUtility.MakeIdGuidsUnique( node );
var restored = new GameObject();
restored.Deserialize( node );
Assert.AreEqual( Vector3.Zero, restored.LocalPosition );
}
///
/// PhysicsBone objects serialize their parent-relative transform computed
/// from world space, and deserialize back through the parent to the same
/// world position.
///
[TestMethod]
public void PhysicsBoneTransformRoundTrips()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var parent = scene.CreateObject();
parent.WorldPosition = new Vector3( 100, 0, 0 );
var bone = new GameObject( parent, true, "Bone" );
bone.Flags |= GameObjectFlags.PhysicsBone;
bone.WorldPosition = new Vector3( 150, 0, 0 );
var node = parent.Serialize();
SceneUtility.MakeIdGuidsUnique( node );
var restored = new GameObject();
restored.Deserialize( node );
var restoredBone = restored.Children[0];
Assert.IsTrue( restored.WorldPosition.AlmostEqual( new Vector3( 100, 0, 0 ) ), $"{restored.WorldPosition}" );
Assert.IsTrue( restoredBone.LocalPosition.AlmostEqual( new Vector3( 50, 0, 0 ) ), $"{restoredBone.LocalPosition}" );
Assert.IsTrue( restoredBone.WorldPosition.AlmostEqual( new Vector3( 150, 0, 0 ) ), $"{restoredBone.WorldPosition}" );
}
///
/// DeserializeOptions.TransformOverride repositions the root object only -
/// children keep their serialized local transforms relative to it.
///
[TestMethod]
public void TransformOverrideAppliesToRootOnly()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var parent = scene.CreateObject();
parent.LocalPosition = new Vector3( 10, 0, 0 );
var child = new GameObject( parent );
child.LocalPosition = new Vector3( 5, 0, 0 );
var node = parent.Serialize();
SceneUtility.MakeIdGuidsUnique( node );
var restored = new GameObject();
restored.Deserialize( node, new GameObject.DeserializeOptions { TransformOverride = new Transform( new Vector3( 50, 0, 0 ) ) } );
Assert.IsTrue( restored.WorldPosition.AlmostEqual( new Vector3( 50, 0, 0 ) ), $"{restored.WorldPosition}" );
var restoredChild = restored.Children[0];
Assert.IsTrue( restoredChild.LocalPosition.AlmostEqual( new Vector3( 5, 0, 0 ) ), $"{restoredChild.LocalPosition}" );
Assert.IsTrue( restoredChild.WorldPosition.AlmostEqual( new Vector3( 55, 0, 0 ) ), $"{restoredChild.WorldPosition}" );
}
///
/// When no guid collision is in play - deserializing into a fresh scene -
/// the serialized id is preserved exactly.
///
[TestMethod]
public void IdPreservedWhenDeserializingInFreshScene()
{
JsonObject node;
Guid originalId;
{
var sceneA = new Scene();
using var scopeA = sceneA.Push();
var go = sceneA.CreateObject();
go.Name = "Original";
originalId = go.Id;
node = go.Serialize();
}
var sceneB = new Scene();
using var scopeB = sceneB.Push();
var restored = new GameObject();
restored.Deserialize( node );
Assert.AreEqual( originalId, restored.Id );
Assert.AreEqual( restored, sceneB.Directory.FindByGuid( originalId ) );
}
///
/// The enabled state round trips in both directions.
///
[TestMethod]
public void EnabledStateRoundTrips()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var enabled = scene.CreateObject();
var enabledNode = enabled.Serialize();
SceneUtility.MakeIdGuidsUnique( enabledNode );
var restoredEnabled = new GameObject();
restoredEnabled.Deserialize( enabledNode );
Assert.IsTrue( restoredEnabled.Enabled );
var disabled = scene.CreateObject( false );
var disabledNode = disabled.Serialize();
SceneUtility.MakeIdGuidsUnique( disabledNode );
var restoredDisabled = new GameObject();
restoredDisabled.Deserialize( disabledNode );
Assert.IsFalse( restoredDisabled.Enabled );
}
}
///
/// Bare component used to populate the Components array in the serialize
/// options tests.
///
public class SerializeCoverageProbe : Component
{
}