Files
sbox-public/engine/Tests/Sandbox.Test.Unit/Json/BlobSerialization.cs
2026-07-23 19:29:30 +01:00

511 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json.Nodes;
namespace JsonTests;
/// <summary>
/// Test blob for binary serialization testing
/// </summary>
public class TestBlob : BlobData
{
public int IntValue { get; set; }
public string StringValue { get; set; }
public List<Vector3> Positions { get; set; } = new();
public override int Version => 1;
public override void Serialize( ref Writer writer )
{
writer.Stream.Write( IntValue );
writer.Stream.Write( StringValue ?? "" );
writer.Stream.Write( Positions.Count );
foreach ( var pos in Positions )
{
writer.Stream.Write( pos );
}
}
public override void Deserialize( ref Reader reader )
{
IntValue = reader.Stream.Read<int>();
StringValue = reader.Stream.Read<string>();
int count = reader.Stream.Read<int>();
Positions = new List<Vector3>( count );
for ( int i = 0; i < count; i++ )
{
Positions.Add( reader.Stream.Read<Vector3>() );
}
}
}
[TestClass]
[DoNotParallelize]
public class BlobSerializationTest
{
[TestMethod]
public void BlobSerializeToNode()
{
var blob = new TestBlob
{
IntValue = 42,
StringValue = "Hello Blob",
Positions = new List<Vector3>
{
new Vector3( 1, 2, 3 ),
new Vector3( 4, 5, 6 )
}
};
var testFilePath = Path.GetTempFileName();
using var blobs = BlobDataSerializer.Capture();
var node = Json.ToNode( blob );
Assert.IsNotNull( node );
Assert.IsInstanceOfType( node, typeof( JsonObject ) );
var obj = node as JsonObject;
Assert.IsTrue( obj.ContainsKey( "$blob" ) );
Assert.IsTrue( Guid.TryParse( obj["$blob"].ToString(), out _ ) );
blobs.SaveTo( testFilePath );
Assert.IsTrue( File.Exists( testFilePath + "_d" ) );
File.Delete( testFilePath );
File.Delete( testFilePath + "_d" );
}
[TestMethod]
public void BlobRoundTrip()
{
// Arrange
var originalBlob = new TestBlob
{
IntValue = 123,
StringValue = "Test Data",
Positions = new List<Vector3>
{
new Vector3( 10, 20, 30 ),
new Vector3( 40, 50, 60 )
}
};
var testFilePath = Path.GetTempFileName();
JsonNode node;
using ( var blobs = BlobDataSerializer.Capture() )
{
node = Json.ToNode( originalBlob );
blobs.SaveTo( testFilePath );
}
TestBlob deserializedBlob;
using ( var blobs = BlobDataSerializer.LoadFrom( testFilePath ) )
{
deserializedBlob = Json.FromNode( node, typeof( TestBlob ) ) as TestBlob;
}
Assert.IsNotNull( deserializedBlob );
Assert.AreEqual( originalBlob.IntValue, deserializedBlob.IntValue );
Assert.AreEqual( originalBlob.StringValue, deserializedBlob.StringValue );
Assert.AreEqual( originalBlob.Positions.Count, deserializedBlob.Positions.Count );
for ( int i = 0; i < originalBlob.Positions.Count; i++ )
{
Assert.AreEqual( originalBlob.Positions[i], deserializedBlob.Positions[i] );
}
File.Delete( testFilePath );
File.Delete( testFilePath + "_d" );
}
[TestMethod]
public void MultipleBlobs()
{
var blob1 = new TestBlob { IntValue = 100, StringValue = "Blob 1" };
var blob2 = new TestBlob { IntValue = 200, StringValue = "Blob 2" };
var testFilePath = Path.GetTempFileName();
JsonNode node1, node2;
using ( var blobs = BlobDataSerializer.Capture() )
{
node1 = Json.ToNode( blob1 );
node2 = Json.ToNode( blob2 );
blobs.SaveTo( testFilePath );
}
TestBlob result1, result2;
using ( var blobs = BlobDataSerializer.LoadFrom( testFilePath ) )
{
result1 = Json.FromNode( node1, typeof( TestBlob ) ) as TestBlob;
result2 = Json.FromNode( node2, typeof( TestBlob ) ) as TestBlob;
}
Assert.IsNotNull( result1 );
Assert.IsNotNull( result2 );
Assert.AreEqual( 100, result1.IntValue );
Assert.AreEqual( 200, result2.IntValue );
File.Delete( testFilePath );
File.Delete( testFilePath + "_d" );
}
[TestMethod]
public void BlobUpgrade()
{
var testFilePath = Path.GetTempFileName();
var blobPath = testFilePath + "_d";
var blobGuid = Guid.NewGuid();
var blobStream = ByteStream.Create( 256 );
blobStream.Write( 1 ); // data version
blobStream.Write( 42 ); // IntValue
blobStream.Write( "Old Format" ); // StringValue
var blobData = blobStream.ToArray();
blobStream.Dispose();
var fileStream = ByteStream.Create( 512 );
fileStream.Write( 1 ); // File version
fileStream.Write( 1 ); // Entry count
// Calculate offset: Header (8 bytes) + TOC entry (32 bytes)
long dataOffset = 8 + 32;
// Write TOC entry
fileStream.Write( blobGuid.ToByteArray() );
fileStream.Write( 1 ); // Data version = 1 (old version)
fileStream.Write( dataOffset );
fileStream.Write( blobData.Length );
// Write the blob data
fileStream.Write( blobData );
File.WriteAllBytes( blobPath, fileStream.ToArray() );
fileStream.Dispose();
// should trigger upgrade
UpgradeableTestBlob upgradedBlob;
using ( var blobs = BlobDataSerializer.LoadFrom( testFilePath ) )
{
var node = new JsonObject { ["$blob"] = blobGuid.ToString() };
upgradedBlob = Json.FromNode( node, typeof( UpgradeableTestBlob ) ) as UpgradeableTestBlob;
}
Assert.IsNotNull( upgradedBlob );
Assert.AreEqual( 42, upgradedBlob.IntValue );
Assert.AreEqual( "Old Format", upgradedBlob.StringValue );
Assert.IsNotNull( upgradedBlob.Positions );
Assert.AreEqual( 0, upgradedBlob.Positions.Count );
Assert.IsTrue( upgradedBlob.WasUpgraded );
File.Delete( testFilePath );
File.Delete( blobPath );
}
[TestMethod]
public void ObjectWithBlobProperty()
{
var container = new ContainerWithBlob
{
Name = "Test Container",
RegularValue = 999,
BlobData = new TestBlob
{
IntValue = 456,
StringValue = "Data in blob",
Positions = new List<Vector3>
{
new Vector3( 1, 1, 1 ),
new Vector3( 2, 2, 2 ),
new Vector3( 3, 3, 3 )
}
}
};
var testFilePath = Path.GetTempFileName();
JsonNode jsonNode;
using ( var blobs = BlobDataSerializer.Capture() )
{
jsonNode = Json.ToNode( container );
var json = jsonNode.ToJsonString();
Console.WriteLine( "Generated JSON:" );
Console.WriteLine( json );
Assert.IsNotNull( json );
Assert.IsTrue( json.Contains( "$blob" ), "JSON should contain $blob reference" );
Assert.IsFalse( json.Contains( "\"IntValue\"" ), "Blob contents should NOT be in JSON" );
blobs.SaveTo( testFilePath );
Assert.IsTrue( File.Exists( testFilePath + "_d" ) );
}
ContainerWithBlob deserialized;
using ( var blobs = BlobDataSerializer.LoadFrom( testFilePath ) )
{
deserialized = Json.FromNode( jsonNode, typeof( ContainerWithBlob ) ) as ContainerWithBlob;
}
Assert.IsNotNull( deserialized );
Assert.AreEqual( "Test Container", deserialized.Name );
Assert.AreEqual( 999, deserialized.RegularValue );
Assert.IsNotNull( deserialized.BlobData );
Assert.AreEqual( 456, deserialized.BlobData.IntValue );
Assert.AreEqual( "Data in blob", deserialized.BlobData.StringValue );
Assert.AreEqual( 3, deserialized.BlobData.Positions.Count );
File.Delete( testFilePath );
File.Delete( testFilePath + "_d" );
}
[TestMethod]
public void InlineBlobRoundTrip()
{
var blob = new TestBlob
{
IntValue = 123,
StringValue = "Inline Data",
Positions = new List<Vector3>
{
new Vector3( 10, 20, 30 ),
new Vector3( 40, 50, 60 )
}
};
JsonNode node;
using ( var blobs = BlobDataSerializer.Capture() )
{
node = Json.ToNode( blob );
blobs.SaveTo( node );
}
Assert.IsNotNull( node["__blobdata"] );
TestBlob result;
using ( var blobs = BlobDataSerializer.LoadFrom( node ) )
{
result = Json.FromNode( node, typeof( TestBlob ) ) as TestBlob;
}
Assert.IsNotNull( result );
Assert.AreEqual( blob.IntValue, result.IntValue );
Assert.AreEqual( blob.StringValue, result.StringValue );
Assert.IsTrue( blob.Positions.SequenceEqual( result.Positions ) );
}
[TestMethod]
public void InlineBlobProperty()
{
var container = new ContainerWithBlob
{
Name = "Inline Container",
RegularValue = 777,
BlobData = new TestBlob { IntValue = 456, StringValue = "Data in blob" }
};
JsonNode node;
using ( var blobs = BlobDataSerializer.Capture() )
{
node = Json.ToNode( container );
blobs.SaveTo( node );
}
ContainerWithBlob result;
using ( var blobs = BlobDataSerializer.LoadFrom( node ) )
{
result = Json.FromNode( node, typeof( ContainerWithBlob ) ) as ContainerWithBlob;
}
Assert.IsNotNull( result );
Assert.AreEqual( "Inline Container", result.Name );
Assert.AreEqual( 777, result.RegularValue );
Assert.IsNotNull( result.BlobData );
Assert.AreEqual( 456, result.BlobData.IntValue );
Assert.AreEqual( "Data in blob", result.BlobData.StringValue );
}
[TestMethod]
public void SaveToNodeWithNoBlobs()
{
var node = new JsonObject { ["Name"] = "Nothing here" };
using ( var blobs = BlobDataSerializer.Capture() )
{
Assert.IsFalse( blobs.SaveTo( node ) );
}
Assert.IsFalse( node.ContainsKey( "__blobdata" ) );
}
[TestMethod]
public void LoadFromNodeWithNoBlobs()
{
var node = new JsonObject { ["$blob"] = Guid.NewGuid().ToString() };
TestBlob result;
using ( var blobs = BlobDataSerializer.LoadFrom( node ) )
{
result = Json.FromNode( node, typeof( TestBlob ) ) as TestBlob;
}
Assert.IsNull( result );
}
[TestMethod]
public void ContextLoadFromMergesSeparatelyCapturedBlobs()
{
var blob1 = new TestBlob { IntValue = 1, StringValue = "First" };
var blob2 = new TestBlob { IntValue = 2, StringValue = "Second" };
// Capture each blob in its own context, like per-object undo snapshots do
JsonNode node1, node2;
using ( var blobs = BlobDataSerializer.Capture() )
{
node1 = Json.ToNode( blob1 );
blobs.SaveTo( node1 );
}
using ( var blobs = BlobDataSerializer.Capture() )
{
node2 = Json.ToNode( blob2 );
blobs.SaveTo( node2 );
}
// Merge both into one shared context - both must be readable
using ( var blobs = BlobDataSerializer.LoadFromMemory( default ) )
{
blobs.LoadFrom( node1 );
blobs.LoadFrom( node2 );
var result1 = Json.FromNode( node1, typeof( TestBlob ) ) as TestBlob;
var result2 = Json.FromNode( node2, typeof( TestBlob ) ) as TestBlob;
Assert.AreEqual( "First", result1?.StringValue );
Assert.AreEqual( "Second", result2?.StringValue );
}
}
[TestMethod]
public void ContextLoadFromToleratesMissingData()
{
using var blobs = BlobDataSerializer.LoadFromMemory( default );
blobs.LoadFrom( null );
blobs.LoadFrom( new JsonObject { ["Name"] = "no blob data here" } );
}
[TestMethod]
public void UnchangedContentSerializesIdentically()
{
// Two separate instances with identical content, captured in separate contexts -
// like re-saving an unchanged scene. Ids are derived from content, so the json
// and the blob payload must come out byte-identical, or every save dirties the file.
static TestBlob MakeBlob() => new()
{
IntValue = 42,
StringValue = "Stable",
Positions = new List<Vector3> { new Vector3( 1, 2, 3 ) }
};
JsonNode node1, node2;
byte[] data1, data2;
using ( var blobs = BlobDataSerializer.Capture() )
{
node1 = Json.ToNode( MakeBlob() );
data1 = blobs.ToByteArray();
}
using ( var blobs = BlobDataSerializer.Capture() )
{
node2 = Json.ToNode( MakeBlob() );
data2 = blobs.ToByteArray();
}
Assert.AreEqual( node1.ToJsonString(), node2.ToJsonString(), "Identical blob content must produce an identical $blob reference" );
CollectionAssert.AreEqual( data1, data2, "Identical blob content must produce identical blob file bytes" );
}
[TestMethod]
public void ChangedContentGetsNewBlobId()
{
JsonNode node1, node2;
using ( var blobs = BlobDataSerializer.Capture() )
{
node1 = Json.ToNode( new TestBlob { IntValue = 1 } );
node2 = Json.ToNode( new TestBlob { IntValue = 2 } );
// both blobs must stay readable in the capture context
Assert.AreEqual( 1, (Json.FromNode( node1, typeof( TestBlob ) ) as TestBlob)?.IntValue );
Assert.AreEqual( 2, (Json.FromNode( node2, typeof( TestBlob ) ) as TestBlob)?.IntValue );
}
Assert.AreNotEqual( node1.ToJsonString(), node2.ToJsonString(), "Different blob content must produce different $blob references" );
}
}
/// <summary>
/// Test blob that supports upgrading from version 1 to version 2
/// </summary>
public class UpgradeableTestBlob : BlobData
{
public int IntValue { get; set; }
public string StringValue { get; set; }
public List<Vector3> Positions { get; set; } = new();
public bool WasUpgraded { get; set; }
public override int Version => 2;
public override void Serialize( ref Writer writer )
{
writer.Stream.Write( IntValue );
writer.Stream.Write( StringValue ?? "" );
writer.Stream.Write( Positions.Count );
foreach ( var pos in Positions )
{
writer.Stream.Write( pos );
}
}
public override void Deserialize( ref Reader reader )
{
IntValue = reader.Stream.Read<int>();
StringValue = reader.Stream.Read<string>();
int count = reader.Stream.Read<int>();
Positions = new List<Vector3>( count );
for ( int i = 0; i < count; i++ )
{
Positions.Add( reader.Stream.Read<Vector3>() );
}
}
public override void Upgrade( ref Reader reader, int fromVersion )
{
if ( fromVersion == 1 )
{
IntValue = reader.Stream.Read<int>();
StringValue = reader.Stream.Read<string>();
Positions = new List<Vector3>();
WasUpgraded = true;
}
else
{
Deserialize( ref reader );
}
}
}
public class ContainerWithBlob
{
public string Name { get; set; }
public int RegularValue { get; set; }
public TestBlob BlobData { get; set; }
}