using System;
using System.IO;
using System.Text;
using System.Text.Json;
namespace SceneTests.GameObjects;
///
/// Pins how GameObject references serialize to and from JSON: the reference
/// object form, the legacy guid string form, unknown references resolving to
/// null, destroyed objects writing null, and reference fixup through a
/// serialize/clone round trip.
///
[TestClass]
[DoNotParallelize]
public class GameObjectReferenceTest : SceneTest
{
///
/// Reads a GameObject from a JSON snippet through the production
/// entry point.
///
static GameObject ReadGameObjectJson( string json )
{
var bytes = Encoding.UTF8.GetBytes( json );
var reader = new Utf8JsonReader( bytes );
reader.Read();
return (GameObject)GameObject.JsonRead( ref reader, typeof( GameObject ) );
}
///
/// Writes a value through the production
/// entry point and returns the produced JSON text.
///
static string WriteGameObjectJson( object value )
{
using var stream = new MemoryStream();
using ( var writer = new Utf8JsonWriter( stream ) )
{
GameObject.JsonWrite( value, writer );
}
return Encoding.UTF8.GetString( stream.ToArray() );
}
///
/// The reference object form ({"_type":"gameobject","go":guid}) resolves to
/// the live instance in the active scene.
///
[TestMethod]
public void ReferenceObjectFormResolves()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var resolved = ReadGameObjectJson( $$"""{ "_type": "gameobject", "go": "{{go.Id}}" }""" );
Assert.AreSame( go, resolved );
}
///
/// A reference to a guid that doesn't exist in the scene resolves to null
/// (with a warning) instead of throwing - this happens routinely when
/// deserializing networked data referencing objects we don't know about.
///
[TestMethod]
public void UnknownReferenceResolvesNull()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var resolved = ReadGameObjectJson( $$"""{ "_type": "gameobject", "go": "{{Guid.NewGuid()}}" }""" );
Assert.IsNull( resolved );
}
///
/// The legacy form - a plain guid string - still resolves to the instance.
///
[TestMethod]
public void LegacyGuidStringResolves()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var resolved = ReadGameObjectJson( $"\"{go.Id}\"" );
Assert.AreSame( go, resolved );
}
///
/// A legacy guid string for an unknown object resolves to null with a
/// warning.
///
[TestMethod]
public void LegacyUnknownGuidStringResolvesNull()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var resolved = ReadGameObjectJson( $"\"{Guid.NewGuid()}\"" );
Assert.IsNull( resolved );
}
///
/// A reference object with an unknown "_type" is rejected loudly - silent
/// nulls here would hide data corruption.
///
[TestMethod]
public void UnknownReferenceTypeThrows()
{
var scene = new Scene();
using var sceneScope = scene.Push();
Assert.ThrowsException( () =>
{
var bytes = Encoding.UTF8.GetBytes( """{ "_type": "banana" }""" );
var reader = new Utf8JsonReader( bytes );
reader.Read();
GameObject.JsonRead( ref reader, typeof( GameObject ) );
} );
}
///
/// Writing a valid GameObject produces the reference object form containing
/// its guid.
///
[TestMethod]
public void WriteValidGameObjectProducesReference()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var json = WriteGameObjectJson( go );
StringAssert.Contains( json, "gameobject" );
StringAssert.Contains( json, go.Id.ToString() );
}
///
/// A destroyed GameObject serializes as JSON null, so stale references
/// don't leak invalid guids into saved data.
///
[TestMethod]
public void WriteDestroyedGameObjectIsNull()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
go.DestroyImmediate();
Assert.AreEqual( "null", WriteGameObjectJson( go ) );
}
///
/// JsonWrite only accepts GameObjects - anything else is a programming
/// error and throws.
///
[TestMethod]
public void WriteNonGameObjectThrows()
{
var scene = new Scene();
using var sceneScope = scene.Push();
Assert.ThrowsException( () => WriteGameObjectJson( 5 ) );
}
///
/// A component property referencing a sibling GameObject survives a
/// serialize / make-unique / deserialize round trip: the copy's property
/// points at the copied sibling, not the original.
///
[TestMethod]
public void ComponentPropertyReferenceRemapsOnRoundTrip()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var root = scene.CreateObject();
var childA = new GameObject( root, true, "A" );
var childB = new GameObject( root, true, "B" );
var holder = childB.Components.Create();
holder.Target = childA;
var node = root.Serialize();
SceneUtility.MakeIdGuidsUnique( node );
var copy = new GameObject();
copy.Deserialize( node );
var copyA = copy.Children.First( x => x.Name == "A" );
var copyB = copy.Children.First( x => x.Name == "B" );
var copyHolder = copyB.Components.Get( true );
Assert.IsNotNull( copyHolder );
Assert.AreSame( copyA, copyHolder.Target );
Assert.AreNotSame( childA, copyHolder.Target );
}
///
/// Deserializing a component whose GameObject reference can't be found in
/// the scene leaves the property null instead of failing the whole object.
///
[TestMethod]
public void ComponentPropertyUnknownReferenceBecomesNull()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var json = Json.ParseToJsonObject( $$"""
{
"__guid": "{{Guid.NewGuid()}}",
"__version": 2,
"Name": "Holder",
"Enabled": true,
"Components": [
{
"__type": "{{typeof( RefHolderComponent ).FullName}}",
"__guid": "{{Guid.NewGuid()}}",
"Target": { "_type": "gameobject", "go": "{{Guid.NewGuid()}}" }
}
]
}
""" );
var go = new GameObject();
go.Deserialize( json );
var holder = go.Components.Get( true );
Assert.IsNotNull( holder, "the component itself must still deserialize" );
Assert.IsNull( holder.Target );
}
///
/// Serializing a component whose referenced GameObject has been destroyed
/// writes a null value for the property.
///
[TestMethod]
public void DestroyedTargetSerializesAsNull()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var holder = go.Components.Create();
var target = scene.CreateObject();
holder.Target = target;
target.DestroyImmediate();
var node = go.Serialize();
var compJson = node["Components"].AsArray()[0].AsObject();
Assert.IsTrue( compJson.ContainsKey( "Target" ) );
Assert.IsNull( compJson["Target"], "the destroyed reference must serialize as JSON null" );
}
}
///
/// Component with a serialized GameObject reference property, used to test
/// reference resolution. Top level so JSON type lookup by full name works.
///
public class RefHolderComponent : Component
{
///
/// The referenced GameObject under test.
///
[Property] public GameObject Target { get; set; }
}