using System; using Sandbox.Internal; namespace SceneTests.GameObjects; /// /// Pins how deserialization behaves with bad input: unknown components are preserved /// rather than dropped, type resolution falls back to the short class name, missing /// prefabs warn instead of crashing, and one broken component doesn't take down the /// rest of the object. /// [TestClass] [DoNotParallelize] public class SerializeRobustnessTest : SceneTest { /// /// A component whose type can't be resolved becomes a missing-component /// placeholder, and the original json is preserved through a serialize round /// trip - user data must not be silently dropped on save. /// [TestMethod] public void UnknownComponentSurvivesRoundTrip() { var scene = new Scene(); using var sceneScope = scene.Push(); var json = Json.ParseToJsonObject( $$""" { "__guid": "{{Guid.NewGuid()}}", "Name": "Mystery", "Components": [ { "__type": "Totally.Unknown.ComponentType", "__guid": "{{Guid.NewGuid()}}", "SomeSetting": 42 } ] } """ ); var go = new GameObject(); go.Deserialize( json ); Assert.AreEqual( "Mystery", go.Name ); Assert.AreEqual( 1, go.Components.Count, "the unknown component should exist as a placeholder" ); var serialized = go.Serialize().ToJsonString(); StringAssert.Contains( serialized, "Totally.Unknown.ComponentType" ); StringAssert.Contains( serialized, "SomeSetting" ); } /// /// When the full type name doesn't resolve, deserialization falls back to the /// short class name - so a component that moved namespaces still loads. /// [TestMethod] public void ComponentTypeFallsBackToShortName() { var oldTypeLibrary = Game.TypeLibrary; var typeLibrary = new Sandbox.Internal.TypeLibrary(); typeLibrary.AddAssembly( typeof( ModelRenderer ).Assembly, false ); typeLibrary.AddAssembly( typeof( SerializeRobustnessTest ).Assembly, false ); Game.TypeLibrary = typeLibrary; try { var scene = new Scene(); using var sceneScope = scene.Push(); var json = Json.ParseToJsonObject( $$""" { "__guid": "{{Guid.NewGuid()}}", "Name": "Renamed", "Components": [ { "__type": "Some.Old.Namespace.WellKnownComponent", "__guid": "{{Guid.NewGuid()}}", "Value": 7 } ] } """ ); var go = new GameObject(); go.Deserialize( json ); var component = go.Components.Get( true ); Assert.IsNotNull( component, "short-name fallback should have resolved the component" ); Assert.AreEqual( 7, component.Value ); } finally { Game.TypeLibrary = oldTypeLibrary; } } /// /// A component whose constructor throws during deserialization is logged and /// skipped - the rest of the object still loads. /// [TestMethod] public void ThrowingComponentDoesNotBreakSiblings() { var oldTypeLibrary = Game.TypeLibrary; var typeLibrary = new Sandbox.Internal.TypeLibrary(); typeLibrary.AddAssembly( typeof( ModelRenderer ).Assembly, false ); typeLibrary.AddAssembly( typeof( SerializeRobustnessTest ).Assembly, false ); Game.TypeLibrary = typeLibrary; try { ExplodingComponent.Explode = true; var scene = new Scene(); using var sceneScope = scene.Push(); var json = Json.ParseToJsonObject( $$""" { "__guid": "{{Guid.NewGuid()}}", "Name": "Survivor", "Components": [ { "__type": "{{typeof( ExplodingComponent ).FullName}}", "__guid": "{{Guid.NewGuid()}}" }, { "__type": "{{typeof( WellKnownComponent ).FullName}}", "__guid": "{{Guid.NewGuid()}}", "Value": 5 } ] } """ ); var go = new GameObject(); go.Deserialize( json ); var survivor = go.Components.Get( true ); Assert.IsNotNull( survivor, "the healthy component should have loaded" ); Assert.AreEqual( 5, survivor.Value ); } finally { ExplodingComponent.Explode = false; Game.TypeLibrary = oldTypeLibrary; } } public class WellKnownComponent : Component { [Property] public int Value { get; set; } } public class ExplodingComponent : Component { public static bool Explode; public ExplodingComponent() { if ( Explode ) throw new InvalidOperationException( "intentional test constructor failure" ); } } }