diff --git a/Runtime/ISaveable.cs b/Runtime/ISaveable.cs
index 35d4e8c..31773fc 100644
--- a/Runtime/ISaveable.cs
+++ b/Runtime/ISaveable.cs
@@ -12,12 +12,18 @@ public interface ISaveable
/// If you choose to use a Guid, it is recommended that it is backed by a
/// serialized byte array that does not change.
///
+ ///
+ /// This is invoked off Unity's main thread, cannot use thread unsafe APIs.
+ ///
public string Key { get; }
///
/// This is the file name where this object's data will be saved.
/// It is recommended to use a static class to store file paths as strings to avoid typos.
///
+ ///
+ /// This is invoked on Unity's main thread, safe to use thread unsafe APIs.
+ ///
public string Filename { get; }
///
@@ -25,6 +31,9 @@ public interface ISaveable
/// Typically this is a struct defined by the ISaveable implementing class.
/// The contents of the struct could be created at the time of saving, or cached in a variable.
///
+ ///
+ /// This is invoked off Unity's main thread, cannot use thread unsafe APIs.
+ ///
object CaptureState();
///
@@ -32,6 +41,9 @@ public interface ISaveable
/// This will be called any time the game is loaded, so you may want to consider
/// also using this method to initialize any fields that are not saved (i.e. "resetting the object").
///
+ ///
+ /// This is invoked on Unity's main thread, safe to use thread unsafe APIs.
+ ///
void RestoreState(object state);
}
}
diff --git a/Runtime/SaveManager.cs b/Runtime/SaveManager.cs
index 9823028..7b71402 100644
--- a/Runtime/SaveManager.cs
+++ b/Runtime/SaveManager.cs
@@ -52,9 +52,20 @@ public FileOperation(FileOperationType operationType, string[] filenames)
}
static FileHandler m_fileHandler;
+ ///
+ /// Saveables which have registered themselves inside the manager.
+ ///
static Dictionary m_saveables = new();
+ ///
+ /// Temporary working memory used during a operation. Stores
+ /// data which was loaded from a save file and will be restored to ISaveables after the load operation completes.
+ ///
static List m_loadedSaveables = new();
static Queue m_fileOperationQueue = new();
+ ///
+ /// A set of all files associated with currently registered ISaveables.
+ /// Currently unused.
+ ///
static HashSet m_files = new();
static bool m_isInitialized;
diff --git a/Tests.meta b/Tests.meta
new file mode 100644
index 0000000..88deba7
--- /dev/null
+++ b/Tests.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 3220b088ee9d4e60826b78a6544fe1d4
+timeCreated: 1725243685
\ No newline at end of file
diff --git a/Tests/Runtime.meta b/Tests/Runtime.meta
new file mode 100644
index 0000000..09c2645
--- /dev/null
+++ b/Tests/Runtime.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 3e8afb4fbe54475f964f2abea3160a68
+timeCreated: 1725243792
\ No newline at end of file
diff --git a/Tests/Runtime/BUCK.SaveAsync.Tests.asmdef b/Tests/Runtime/BUCK.SaveAsync.Tests.asmdef
new file mode 100644
index 0000000..ab052b9
--- /dev/null
+++ b/Tests/Runtime/BUCK.SaveAsync.Tests.asmdef
@@ -0,0 +1,22 @@
+{
+ "name": "BUCK.SaveAsync.Tests",
+ "rootNamespace": "Buck.SaveAsync.Tests",
+ "references": [
+ "GUID:27619889b8ba8c24980f49ee34dbb44a",
+ "GUID:0acc523941302664db1f4e527237feb3",
+ "GUID:ad4bea86cb6093347bcf3482d1635cc9"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [
+ "nunit.framework.dll"
+ ],
+ "autoReferenced": false,
+ "defineConstraints": [
+ "UNITY_INCLUDE_TESTS"
+ ],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
\ No newline at end of file
diff --git a/Tests/Runtime/BUCK.SaveAsync.Tests.asmdef.meta b/Tests/Runtime/BUCK.SaveAsync.Tests.asmdef.meta
new file mode 100644
index 0000000..c01085b
--- /dev/null
+++ b/Tests/Runtime/BUCK.SaveAsync.Tests.asmdef.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 06d4c76cc3914fce9036eb37a7ec33ae
+timeCreated: 1725243828
\ No newline at end of file
diff --git a/Tests/Runtime/TestConstants.cs b/Tests/Runtime/TestConstants.cs
new file mode 100644
index 0000000..1f4d762
--- /dev/null
+++ b/Tests/Runtime/TestConstants.cs
@@ -0,0 +1,14 @@
+namespace Buck.SaveAsync.Tests
+{
+ public static class TestConstants
+ {
+ ///
+ /// Asserted against when the namespace is included in json serialization output
+ ///
+ public static string Namespace => "Buck.SaveAsync.Tests";
+ ///
+ /// Asserted against when the assembly is included in json serialization output
+ ///
+ public static string Assembly => "BUCK.SaveAsync.Tests";
+ }
+}
\ No newline at end of file
diff --git a/Tests/Runtime/TestConstants.cs.meta b/Tests/Runtime/TestConstants.cs.meta
new file mode 100644
index 0000000..590358a
--- /dev/null
+++ b/Tests/Runtime/TestConstants.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 13c2e224a35d46acad45b370dcd4b1c0
+timeCreated: 1725250898
\ No newline at end of file
diff --git a/Tests/Runtime/TestRoundTripSaveLoad.cs b/Tests/Runtime/TestRoundTripSaveLoad.cs
new file mode 100644
index 0000000..8e40a9f
--- /dev/null
+++ b/Tests/Runtime/TestRoundTripSaveLoad.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+namespace Buck.SaveAsync.Tests
+{
+ ///
+ /// These tests verify round-trip save and load by saving a state, changing the state,
+ /// and then loading the previously saved state.
+ ///
+ public class TestRoundTripSaveLoad : TestCaseBase
+ {
+ [UnityTest]
+ public IEnumerator Test_RoundTrip_String()
+ {
+ async Awaitable Impl()
+ {
+ var expected = "Hello, World!";
+ var actual = await GetRoundTrip(expected, "Goodbye, World!");
+
+ Assert.AreEqual(expected, actual);
+ }
+
+ return Impl();
+ }
+
+ [UnityTest]
+ public IEnumerator Test_RoundTrip_String_WithDelay()
+ {
+ async Awaitable Impl()
+ {
+ var expected = "Hello, World!";
+ var actual = await GetRoundTrip(expected, "Goodbye, World!", TimeSpan.FromSeconds(0.3f));
+ Assert.AreEqual(expected, actual);
+ }
+
+ return Impl();
+ }
+
+ class SaveObjectWithNestedVector3
+ {
+ public Vector3 NestedVector3 { get; set; }
+ }
+
+ [UnityTest]
+ public IEnumerator Test_RoundTrip_Vector3Nested()
+ {
+ async Awaitable Impl()
+ {
+ var expected = new Vector3(1, 2.3f, 10000.2f);
+ var actual = await GetRoundTrip(new SaveObjectWithNestedVector3
+ {
+ NestedVector3 = expected
+ },
+ new SaveObjectWithNestedVector3
+ {
+ NestedVector3 = Vector3.zero
+ });
+ Assert.AreEqual(0, (expected - actual.NestedVector3).magnitude, 0.0001f);
+ }
+
+ return Impl();
+ }
+
+ [UnityTest]
+ public IEnumerator Test_RoundTrip_Vector3Raw()
+ {
+ async Awaitable Impl()
+ {
+ var expected = new Vector3(1, 2.3f, 10000.2f);
+ var actual = await GetRoundTrip(expected, Vector3.zero);
+ Assert.AreEqual(0, (expected - actual).magnitude, 0.0001f);
+ }
+
+ return Impl();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Runtime/TestRoundTripSaveLoad.cs.meta b/Tests/Runtime/TestRoundTripSaveLoad.cs.meta
new file mode 100644
index 0000000..1972a33
--- /dev/null
+++ b/Tests/Runtime/TestRoundTripSaveLoad.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: a41fed8bd4e443518725d337830d0661
+timeCreated: 1725243954
\ No newline at end of file
diff --git a/Tests/Runtime/TestSaveFileFormat.cs b/Tests/Runtime/TestSaveFileFormat.cs
new file mode 100644
index 0000000..6b33a22
--- /dev/null
+++ b/Tests/Runtime/TestSaveFileFormat.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+namespace Buck.SaveAsync.Tests
+{
+ internal class TestSaveObject
+ {
+ public int IntValue { get; set; }
+ public string StringValue { get; set; }
+ }
+
+ ///
+ /// These tests verify the json format of save files generated by the save system. Useful to detect when a change
+ /// is introduced which may break existing saves.
+ ///
+ public class TestSaveFileFormat : TestCaseBase
+ {
+ [UnityTest]
+ public IEnumerator Test_SaveFormat_NestedDictionary()
+ {
+ async Awaitable Impl()
+ {
+ // Arrange
+ var nestedObject = new Dictionary
+ {
+ { "key1", "value1" },
+ { "key2", 2 },
+ {
+ "key3", new Dictionary
+ {
+ { "key4", "value4" },
+ { "key5", 5 }
+ }
+ }
+ };
+
+ // Act
+ var key = Guid.NewGuid().ToString();
+ var serializedFile = await GetSerializedFileForObject(key, nestedObject);
+
+ // Assert
+ var expected = $@"
+[
+ {{
+ ""Key"": ""{key}"",
+ ""Data"": {{
+ ""$type"": ""System.Collections.Generic.Dictionary`2[[System.String, mscorlib],[System.Object, mscorlib]], mscorlib"",
+ ""key1"": ""value1"",
+ ""key2"": 2,
+ ""key3"": {{
+ ""$type"": ""System.Collections.Generic.Dictionary`2[[System.String, mscorlib],[System.Object, mscorlib]], mscorlib"",
+ ""key4"": ""value4"",
+ ""key5"": 5
+ }}
+ }}
+ }}
+]
+";
+ MultilineDiffUtils.AssertMultilineStringEqual(expected,serializedFile);
+ }
+
+ return Impl();
+ }
+
+ [UnityTest]
+ public IEnumerator Test_SaveFormat_BasicObject()
+ {
+ async Awaitable Impl()
+ {
+ // Arrange
+ var nestedObject = new TestSaveObject
+ {
+ IntValue = 1337,
+ StringValue = "Goodbye, World!"
+ };
+
+ // Act
+ var key = Guid.NewGuid().ToString();
+ var serializedFile = await GetSerializedFileForObject(key, nestedObject);
+
+ // Assert
+ var expected = $@"
+[
+ {{
+ ""Key"": ""{key}"",
+ ""Data"": {{
+ ""$type"": ""{TestConstants.Namespace}.TestSaveObject, {TestConstants.Assembly}"",
+ ""IntValue"": 1337,
+ ""StringValue"": ""Goodbye, World!""
+ }}
+ }}
+]
+";
+ MultilineDiffUtils.AssertMultilineStringEqual(expected,serializedFile);
+ }
+
+ return Impl();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Runtime/TestSaveFileFormat.cs.meta b/Tests/Runtime/TestSaveFileFormat.cs.meta
new file mode 100644
index 0000000..0c18fc0
--- /dev/null
+++ b/Tests/Runtime/TestSaveFileFormat.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 6596da966b6e4250a735feb06943a76d
+timeCreated: 1725247266
\ No newline at end of file
diff --git a/Tests/Runtime/TestTools.meta b/Tests/Runtime/TestTools.meta
new file mode 100644
index 0000000..f1d1119
--- /dev/null
+++ b/Tests/Runtime/TestTools.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 3377ad339e18464ba08c76a0ef386df1
+timeCreated: 1725249891
\ No newline at end of file
diff --git a/Tests/Runtime/TestTools/InMemoryFileHandler.cs b/Tests/Runtime/TestTools/InMemoryFileHandler.cs
new file mode 100644
index 0000000..e49264f
--- /dev/null
+++ b/Tests/Runtime/TestTools/InMemoryFileHandler.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Buck.SaveAsync.Tests
+{
+ ///
+ /// A FileHandler used for testing which does not write anything to disk, but instead
+ /// stores data in memory. Can be configured to simulate slow operations.
+ ///
+ public class InMemoryFileHandler : FileHandler
+ {
+ public TimeSpan AllOperationDelay { get; set; } = TimeSpan.Zero;
+
+ readonly Dictionary m_files = new();
+
+ protected override string GetPath(string pathOrFilename) => pathOrFilename;
+
+ public override async Task Exists(string pathOrFilename, CancellationToken cancellationToken)
+ {
+ await Task.Delay(AllOperationDelay, cancellationToken);
+ return m_files.ContainsKey(pathOrFilename);
+ }
+
+ public override async Task WriteFile(string pathOrFilename, string content, CancellationToken cancellationToken)
+ {
+ await Task.Delay(AllOperationDelay, cancellationToken);
+ m_files[pathOrFilename] = content;
+ }
+
+ public override async Task ReadFile(string pathOrFilename, CancellationToken cancellationToken)
+ {
+ await Task.Delay(AllOperationDelay, cancellationToken);
+ return m_files[pathOrFilename] ?? "";
+ }
+
+ public override async Task Erase(string pathOrFilename, CancellationToken cancellationToken)
+ {
+ await Task.Delay(AllOperationDelay, cancellationToken);
+ m_files[pathOrFilename] = "";
+ }
+
+ public override void Delete(string pathOrFilename)
+ {
+ // Delete is sync, no delay
+ m_files.Remove(pathOrFilename);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Runtime/TestTools/InMemoryFileHandler.cs.meta b/Tests/Runtime/TestTools/InMemoryFileHandler.cs.meta
new file mode 100644
index 0000000..6103d4d
--- /dev/null
+++ b/Tests/Runtime/TestTools/InMemoryFileHandler.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 11bcd2d87ab941bc87862dff5eb716d5
+timeCreated: 1725244017
\ No newline at end of file
diff --git a/Tests/Runtime/TestTools/MultilineDiffUtils.cs b/Tests/Runtime/TestTools/MultilineDiffUtils.cs
new file mode 100644
index 0000000..519e394
--- /dev/null
+++ b/Tests/Runtime/TestTools/MultilineDiffUtils.cs
@@ -0,0 +1,48 @@
+using System.Collections.Generic;
+using System.Text;
+using NUnit.Framework;
+
+namespace Buck.SaveAsync.Tests
+{
+ ///
+ /// Helpers which assert equality of multiline strings in a readable way
+ ///
+ public class MultilineDiffUtils
+ {
+ public static void AssertMultilineStringEqual(string expected, string actual)
+ {
+ expected = expected.Trim();
+ actual = actual.Trim();
+ if (expected == actual) return;
+ Assert.Fail(StringEqualErrorMessage(expected, actual));
+ }
+
+ public static string StringEqualErrorMessage(string expected, string actual)
+ {
+ var errorMessage = new StringBuilder();
+ errorMessage.AppendLine($"#### Expected ####\n{expected}\n#### Actual ####\n{actual}");
+ var expectedStrLines = expected.Split('\n');
+ var actualStrLines = actual.Split('\n');
+ if (expectedStrLines.Length == actualStrLines.Length)
+ {
+ foreach (var (index, expectedLine, actualLine) in DiffLines(expectedStrLines, actualStrLines))
+ {
+ var addlMessage = $"##{index}##\n- {expectedLine}\n+ {actualLine}";
+ errorMessage.AppendLine(addlMessage);
+ }
+ }
+ return errorMessage.ToString();
+ }
+
+ static IEnumerable<(int index, string expected, string actual)> DiffLines(string[] expected, string[] actual)
+ {
+ for (var i = 0; i < expected.Length; i++)
+ {
+ var expectedLine = expected[i].Trim();
+ var actualLine = actual[i].Trim();
+ if(expectedLine == actualLine) continue;
+ yield return (i, expectedLine, actualLine);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Runtime/TestTools/MultilineDiffUtils.cs.meta b/Tests/Runtime/TestTools/MultilineDiffUtils.cs.meta
new file mode 100644
index 0000000..7e4d09f
--- /dev/null
+++ b/Tests/Runtime/TestTools/MultilineDiffUtils.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: a7adc162acf04c88b8df54e713265688
+timeCreated: 1725247693
\ No newline at end of file
diff --git a/Tests/Runtime/TestTools/SaveManagerExtensions.cs b/Tests/Runtime/TestTools/SaveManagerExtensions.cs
new file mode 100644
index 0000000..c2c6d2f
--- /dev/null
+++ b/Tests/Runtime/TestTools/SaveManagerExtensions.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace Buck.SaveAsync.Tests
+{
+ ///
+ /// These are extensions to configure the SaveManager in potentially nonstandard ways, required in order to test it.
+ /// Could be placed inside SaveManager if it is appropriate to expose a public API for these functions, rather than a testing-only API.
+ ///
+ public static class SaveManagerExtensions
+ {
+ ///
+ /// Set the static file handler used by the SaveManager singleton.
+ ///
+ ///
+ public static void SetCustomFileHandler(FileHandler newFileHander)
+ {
+ if (!newFileHander)
+ {
+ throw new ArgumentNullException(nameof(newFileHander));
+ }
+
+ var fileHandlerField = typeof(SaveManager)
+ .GetField("m_fileHandler", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+ if (fileHandlerField == null)
+ {
+ throw new InvalidOperationException("Could not find static private field 'm_fileHandler' on SaveManager.");
+ }
+ fileHandlerField.SetValue(null, newFileHander);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Runtime/TestTools/SaveManagerExtensions.cs.meta b/Tests/Runtime/TestTools/SaveManagerExtensions.cs.meta
new file mode 100644
index 0000000..6fea56d
--- /dev/null
+++ b/Tests/Runtime/TestTools/SaveManagerExtensions.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: a4b127ef92c3444e8406b129190e86e8
+timeCreated: 1725245422
\ No newline at end of file
diff --git a/Tests/Runtime/TestTools/TestCaseBase.cs b/Tests/Runtime/TestTools/TestCaseBase.cs
new file mode 100644
index 0000000..bbbbbe1
--- /dev/null
+++ b/Tests/Runtime/TestTools/TestCaseBase.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using NUnit.Framework;
+using UnityEngine;
+
+namespace Buck.SaveAsync.Tests
+{
+ ///
+ /// A set of base methods used by most test cases. Overridable to provide different save manager setup methods,
+ /// useful when we want to configure JsonConvert's default settings for example.
+ ///
+ public class TestCaseBase
+ {
+ protected virtual void SetupSaveManager(FileHandler withFileHandler)
+ {
+ SaveManagerExtensions.SetCustomFileHandler(withFileHandler);
+ // ensure that the default settings are not overriden, for test consistency.
+ JsonConvert.DefaultSettings = null;
+ }
+
+ protected FileHandler CreateFileHandler(TimeSpan? withEmulatedDelay = null)
+ {
+ var fileHandler = ScriptableObject.CreateInstance();
+ fileHandler.AllOperationDelay = withEmulatedDelay ?? TimeSpan.Zero;
+ return fileHandler;
+ }
+
+ protected TestSaveableEntity CreateSaveableEntity(string key, string filename = "test.dat")
+ {
+ var saveableEntity = new GameObject();
+ var saveable = saveableEntity.AddComponent();
+ saveable.Key = key;
+ saveable.Filename = filename;
+ saveable.RegisterSelf();
+ return saveable;
+ }
+
+ ///
+ /// Creates a saveable object, saves it to an emulated file handler, and returns
+ /// the serialized string from the emulated file handler.
+ ///
+ /// The key of the saveable object
+ /// The data to place inside the saveable object
+ /// The string value which is saved to file when the temporary saveable object is saved
+ protected async Task GetSerializedFileForObject(string key, object savedObject)
+ {
+ var fileName = Guid.NewGuid() + ".dat";
+
+ var fileHandler = CreateFileHandler();
+ SetupSaveManager(fileHandler);
+
+ var saveable = CreateSaveableEntity(key, fileName);
+ saveable.CurrentState = savedObject;
+ await SaveManager.Save(fileName);
+
+ return await fileHandler.ReadFile(fileName, CancellationToken.None);
+ }
+
+ ///
+ /// Tests round-trip serialization of a given value.
+ /// is round-tripped through the save system backed by a temporary file handler.
+ ///
+ /// The value to be round-tripped
+ /// A value used to clear the saveable's internal state, to ensure the round-trip isn't a result of a cached value.
+ /// Optional artificial delay applied to the file handler
+ ///
+ /// The value in the saveable after it has been Loaded
+ protected async Task GetRoundTrip(T initial, T resetTo, TimeSpan? fileHandlerDelay = null)
+ {
+ // Arrange
+ var seed = Guid.NewGuid().ToString();
+
+ var fileHandler = CreateFileHandler(fileHandlerDelay);
+ SetupSaveManager(fileHandler);
+ var saveable = CreateSaveableEntity("saveable_" + seed, "test.dat");
+
+ saveable.CurrentState = initial;
+ await SaveManager.Save("test.dat");
+
+ // Act
+ saveable.CurrentState = resetTo;
+ await SaveManager.Load("test.dat");
+
+ // Assert
+ Assert.IsAssignableFrom(typeof(T), saveable.CurrentState);
+ return (T)saveable.CurrentState;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Runtime/TestTools/TestCaseBase.cs.meta b/Tests/Runtime/TestTools/TestCaseBase.cs.meta
new file mode 100644
index 0000000..684386f
--- /dev/null
+++ b/Tests/Runtime/TestTools/TestCaseBase.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: b938ab02942849599696dc5ffaf9e899
+timeCreated: 1725249929
\ No newline at end of file
diff --git a/Tests/Runtime/TestTools/TestSaveableEntity.cs b/Tests/Runtime/TestTools/TestSaveableEntity.cs
new file mode 100644
index 0000000..ef49eea
--- /dev/null
+++ b/Tests/Runtime/TestTools/TestSaveableEntity.cs
@@ -0,0 +1,22 @@
+using UnityEngine;
+
+namespace Buck.SaveAsync.Tests
+{
+ ///
+ /// A test MonoBehavior that implements saveable. Its internals are exposed since it is piloted
+ /// from test cases.
+ ///
+ public class TestSaveableEntity : MonoBehaviour, ISaveable
+ {
+ public string Key { get; set; } = nameof(TestSaveableEntity);
+ public string Filename { get; set; }
+ public object CurrentState { get; set; }
+ public object CaptureState() => CurrentState;
+ public void RestoreState(object state) => this.CurrentState = state;
+
+ public void RegisterSelf()
+ {
+ SaveManager.RegisterSaveable(this);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Runtime/TestTools/TestSaveableEntity.cs.meta b/Tests/Runtime/TestTools/TestSaveableEntity.cs.meta
new file mode 100644
index 0000000..4ed4ed0
--- /dev/null
+++ b/Tests/Runtime/TestTools/TestSaveableEntity.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 41bd6787a3a447d394ae94b49cc7d3b7
+timeCreated: 1725244973
\ No newline at end of file
diff --git a/Tests/Runtime/UnityConverters.meta b/Tests/Runtime/UnityConverters.meta
new file mode 100644
index 0000000..07c5c83
--- /dev/null
+++ b/Tests/Runtime/UnityConverters.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: ed79852fba21f6b4a964e75637ceb74a
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Runtime/UnityConverters/BUCK.SaveAsync.UnityConverters.Tests.asmdef b/Tests/Runtime/UnityConverters/BUCK.SaveAsync.UnityConverters.Tests.asmdef
new file mode 100644
index 0000000..ecf110a
--- /dev/null
+++ b/Tests/Runtime/UnityConverters/BUCK.SaveAsync.UnityConverters.Tests.asmdef
@@ -0,0 +1,31 @@
+{
+ "name": "BUCK.SaveAsync.Tests.UnityConverters",
+ "rootNamespace": "Buck.SaveAsync.Tests",
+ "references": [
+ "GUID:27619889b8ba8c24980f49ee34dbb44a",
+ "GUID:0acc523941302664db1f4e527237feb3",
+ "GUID:ad4bea86cb6093347bcf3482d1635cc9",
+ "GUID:06d4c76cc3914fce9036eb37a7ec33ae",
+ "GUID:c55d28459d9c4444ebb4788be912e1f1"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [
+ "nunit.framework.dll"
+ ],
+ "autoReferenced": false,
+ "defineConstraints": [
+ "UNITY_INCLUDE_TESTS",
+ "HAVE_JSON_FOR_UNITY_CONVERTERS"
+ ],
+ "versionDefines": [
+ {
+ "name": "jillejr.newtonsoft.json-for-unity.converters",
+ "expression": "",
+ "define": "HAVE_JSON_FOR_UNITY_CONVERTERS"
+ }
+ ],
+ "noEngineReferences": false
+}
\ No newline at end of file
diff --git a/Tests/Runtime/UnityConverters/BUCK.SaveAsync.UnityConverters.Tests.asmdef.meta b/Tests/Runtime/UnityConverters/BUCK.SaveAsync.UnityConverters.Tests.asmdef.meta
new file mode 100644
index 0000000..1a92595
--- /dev/null
+++ b/Tests/Runtime/UnityConverters/BUCK.SaveAsync.UnityConverters.Tests.asmdef.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 82e0ee47d15b6954ab150e78908f6c69
+timeCreated: 1725243828
diff --git a/Tests/Runtime/UnityConverters/TestRoundTripSaveLoadWithUnityConverters.cs b/Tests/Runtime/UnityConverters/TestRoundTripSaveLoadWithUnityConverters.cs
new file mode 100644
index 0000000..b74cb27
--- /dev/null
+++ b/Tests/Runtime/UnityConverters/TestRoundTripSaveLoadWithUnityConverters.cs
@@ -0,0 +1,55 @@
+using System.Collections;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+namespace Buck.SaveAsync.Tests
+{
+ ///
+ /// These tests verify round-trip save and load by saving a state, changing the state,
+ /// and then loading the previously saved state.
+ ///
+ ///
+ /// specifically meant for testing integration with Json-for-Unity converters,
+ /// see https://github.com/applejag/Newtonsoft.Json-for-Unity.Converters
+ ///
+ public class TestRoundTripSaveLoadWithUnityConverters : UnityConverterTestCaseBase
+ {
+ class SaveObjectWithNestedVector3
+ {
+ public Vector3 NestedVector3 { get; set; }
+ }
+
+ [UnityTest]
+ public IEnumerator TestUnityConverter_RoundTrip_Vector3Nested() {
+ async Awaitable Impl()
+ {
+ var expected = new Vector3(1, 2.3f, 10000.2f);
+ var actual = await GetRoundTrip(new SaveObjectWithNestedVector3
+ {
+ NestedVector3 = expected
+ },
+ new SaveObjectWithNestedVector3
+ {
+ NestedVector3 = Vector3.zero
+ });
+ Assert.AreEqual(0, (expected - actual.NestedVector3).magnitude, 0.0001f);
+ }
+
+ return Impl();
+ }
+
+ [UnityTest]
+ public IEnumerator TestUnityConverter_RoundTrip_Vector3Raw()
+ {
+ async Awaitable Impl()
+ {
+ var expected = new Vector3(1, 2.3f, 10000.2f);
+ var actual = await GetRoundTrip(expected, Vector3.zero);
+ Assert.AreEqual(0, (expected - actual).magnitude, 0.0001f);
+ }
+
+ return Impl();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Runtime/UnityConverters/TestRoundTripSaveLoadWithUnityConverters.cs.meta b/Tests/Runtime/UnityConverters/TestRoundTripSaveLoadWithUnityConverters.cs.meta
new file mode 100644
index 0000000..a9ce314
--- /dev/null
+++ b/Tests/Runtime/UnityConverters/TestRoundTripSaveLoadWithUnityConverters.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 73732682696c4e2ab8c5cbcb1a8455ae
+timeCreated: 1725288339
\ No newline at end of file
diff --git a/Tests/Runtime/UnityConverters/TestSaveFileFormatWithUnityConverters.cs b/Tests/Runtime/UnityConverters/TestSaveFileFormatWithUnityConverters.cs
new file mode 100644
index 0000000..1b78a95
--- /dev/null
+++ b/Tests/Runtime/UnityConverters/TestSaveFileFormatWithUnityConverters.cs
@@ -0,0 +1,133 @@
+using System;
+using System.Collections;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+namespace Buck.SaveAsync.Tests
+{
+ internal class TestUnitySaveObject
+ {
+ public Vector3 Vector3Value { get; set; }
+ public Quaternion QuaternionValue { get; set; }
+ public Color ColorValue { get; set; }
+ public AnimationCurve AnimationCurveValue { get; set; }
+ }
+
+ ///
+ /// These tests verify the json format of save files generated by the save system. Useful to detect when a change
+ /// is introduced which may break existing saves.
+ ///
+ ///
+ /// specifically meant for testing integration with Json-for-Unity converters,
+ /// see https://github.com/applejag/Newtonsoft.Json-for-Unity.Converters
+ ///
+ public class TestSaveFileFormatWithUnityConverters : UnityConverterTestCaseBase
+ {
+ [UnityTest]
+ public IEnumerator TestUnityConverter_SaveFormat_UnityPrimitiveObjects() {
+ async Awaitable Impl() {
+ // Arrange
+ var nestedObject = new TestUnitySaveObject
+ {
+ Vector3Value = new Vector3(1, 2, 3.5f),
+ QuaternionValue = new Quaternion(0.1f, 0.2f, 0.3f, 0.4f),
+ ColorValue = new Color(0.1f, 0.2f, 0.3f, 0.4f),
+ AnimationCurveValue = AnimationCurve.EaseInOut(0, 0, 1, 1)
+ };
+ // Act
+ var key = Guid.NewGuid().ToString();
+ var serializedFile = await GetSerializedFileForObject(key, nestedObject);
+
+ // Assert
+ var expected = $@"
+[
+ {{
+ ""Key"": ""{key}"",
+ ""Data"": {{
+ ""$type"": ""{TestConstants.Namespace}.TestUnitySaveObject, {TestConstants.Assembly}.UnityConverters"",
+ ""Vector3Value"": {{
+ ""x"": 1.0,
+ ""y"": 2.0,
+ ""z"": 3.5
+ }},
+ ""QuaternionValue"": {{
+ ""x"": 0.1,
+ ""y"": 0.2,
+ ""z"": 0.3,
+ ""w"": 0.4
+ }},
+ ""ColorValue"": {{
+ ""r"": 0.1,
+ ""g"": 0.2,
+ ""b"": 0.3,
+ ""a"": 0.4
+ }},
+ ""AnimationCurveValue"": {{
+ ""keys"": [
+ {{
+ ""time"": 0.0,
+ ""value"": 0.0,
+ ""inTangent"": 0.0,
+ ""outTangent"": 0.0,
+ ""inWeight"": 0.0,
+ ""outWeight"": 0.0,
+ ""weightedMode"": ""None"",
+ ""tangentMode"": 0
+ }},
+ {{
+ ""time"": 1.0,
+ ""value"": 1.0,
+ ""inTangent"": 0.0,
+ ""outTangent"": 0.0,
+ ""inWeight"": 0.0,
+ ""outWeight"": 0.0,
+ ""weightedMode"": ""None"",
+ ""tangentMode"": 0
+ }}
+ ],
+ ""length"": 2,
+ ""preWrapMode"": ""ClampForever"",
+ ""postWrapMode"": ""ClampForever""
+ }}
+ }}
+ }}
+]
+";
+ MultilineDiffUtils.AssertMultilineStringEqual(expected, serializedFile);
+ }
+
+ return Impl();
+ }
+
+ [UnityTest]
+ public IEnumerator TestUnityConverter_SaveFormat_Vector3Raw()
+ {
+ async Awaitable Impl()
+ {
+ // Arrange
+ var nestedObject = new Vector3(1, 2, 3.5f);
+
+ // Act
+ var key = Guid.NewGuid().ToString();
+ var serializedFile = await GetSerializedFileForObject(key, nestedObject);
+
+ // Assert
+ var expected = $@"
+[
+ {{
+ ""Key"": ""{key}"",
+ ""Data"": {{
+ ""x"": 1.0,
+ ""y"": 2.0,
+ ""z"": 3.5
+ }}
+ }}
+]
+";
+ MultilineDiffUtils.AssertMultilineStringEqual(expected,serializedFile);
+ }
+
+ return Impl();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Runtime/UnityConverters/TestSaveFileFormatWithUnityConverters.cs.meta b/Tests/Runtime/UnityConverters/TestSaveFileFormatWithUnityConverters.cs.meta
new file mode 100644
index 0000000..5c04108
--- /dev/null
+++ b/Tests/Runtime/UnityConverters/TestSaveFileFormatWithUnityConverters.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 79383e7b47f44a7fb8cca613aec3dd6c
+timeCreated: 1725250709
\ No newline at end of file
diff --git a/Tests/Runtime/UnityConverters/UnityConverterTestCaseBase.cs b/Tests/Runtime/UnityConverters/UnityConverterTestCaseBase.cs
new file mode 100644
index 0000000..654b603
--- /dev/null
+++ b/Tests/Runtime/UnityConverters/UnityConverterTestCaseBase.cs
@@ -0,0 +1,16 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.UnityConverters;
+
+namespace Buck.SaveAsync.Tests
+{
+ public class UnityConverterTestCaseBase : TestCaseBase
+ {
+ protected override void SetupSaveManager(FileHandler withFileHandler)
+ {
+ base.SetupSaveManager(withFileHandler);
+ // ensure we are using the default Json-for-Unity converters
+ var jsonSettings = UnityConverterInitializer.defaultUnityConvertersSettings;
+ JsonConvert.DefaultSettings = () => jsonSettings;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Runtime/UnityConverters/UnityConverterTestCaseBase.cs.meta b/Tests/Runtime/UnityConverters/UnityConverterTestCaseBase.cs.meta
new file mode 100644
index 0000000..e86c6e2
--- /dev/null
+++ b/Tests/Runtime/UnityConverters/UnityConverterTestCaseBase.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 762b7f18faf04283b3893f320ac36b81
+timeCreated: 1725291217
\ No newline at end of file