using NUnit.Framework; using ProjectM.Simulation; using Unity.Entities; using UnityEngine; namespace ProjectM.Tests { /// /// Pure tests for the save FOUNDATION: the JSON schema round-trips (JsonUtility), version handling is safe, /// and the born-correct ledger apply () the server spawn system uses to /// overwrite a director's StorageEntry buffer from a staged PendingSave. /// public class SavePersistenceTests { [Test] public void SaveData_Json_RoundTrip_PreservesFields() { var data = new SaveData { GoalCharge = 42, GoalTarget = 10, Ledger = new[] { new LedgerRow { ItemId = 1, Count = 5 }, new LedgerRow { ItemId = 2, Count = 9 }, }, SavedAtMs = 1234567890123L, }; var json = JsonUtility.ToJson(data); var back = JsonUtility.FromJson(json); Assert.AreEqual(SaveData.CurrentVersion, back.Version); Assert.AreEqual(42, back.GoalCharge); Assert.AreEqual(10, back.GoalTarget); Assert.AreEqual(2, back.Ledger.Length); Assert.AreEqual(1, back.Ledger[0].ItemId); Assert.AreEqual(5, back.Ledger[0].Count); Assert.AreEqual(2, back.Ledger[1].ItemId); Assert.AreEqual(9, back.Ledger[1].Count); Assert.AreEqual(1234567890123L, back.SavedAtMs); } [Test] public void SaveData_EmptyJson_DoesNotThrow_And_EmptyLedgerRoundTrips() { Assert.DoesNotThrow(() => JsonUtility.FromJson("{}")); var empty = new SaveData { GoalCharge = 0, GoalTarget = 10 }; var back = JsonUtility.FromJson(JsonUtility.ToJson(empty)); Assert.IsNotNull(back.Ledger); Assert.AreEqual(0, back.Ledger.Length); } [Test] public void SaveData_OldVersion_IsDetectable() { // A stale-version blob round-trips with its Version intact, so SaveService.Load rejects it (-> New Game). var old = new SaveData { Version = 0, GoalCharge = 7 }; var back = JsonUtility.FromJson(JsonUtility.ToJson(old)); Assert.AreEqual(0, back.Version); Assert.AreNotEqual(SaveData.CurrentVersion, back.Version); } [Test] public void WriteLedger_Overwrites_Destination_From_Staged_Rows() { using var world = new World("SaveApplyTest"); var em = world.EntityManager; var e = em.CreateEntity(); em.AddBuffer(e); em.AddBuffer(e); var src = em.GetBuffer(e); src.Add(new PendingSaveLedgerRow { ItemId = 3, Count = 7 }); src.Add(new PendingSaveLedgerRow { ItemId = 5, Count = 12 }); var dest = em.GetBuffer(e); dest.Add(new StorageEntry { ItemId = 99, Count = 1 }); // pre-existing junk that must be cleared SaveApply.WriteLedger(em.GetBuffer(e), em.GetBuffer(e)); var result = em.GetBuffer(e); Assert.AreEqual(2, result.Length); Assert.AreEqual(3, result[0].ItemId); Assert.AreEqual(7, result[0].Count); Assert.AreEqual(5, result[1].ItemId); Assert.AreEqual(12, result[1].Count); } [Test] public void WriteLedger_EmptySource_ClearsDestination() { using var world = new World("SaveApplyEmptyTest"); var em = world.EntityManager; var e = em.CreateEntity(); em.AddBuffer(e); em.AddBuffer(e); var dest = em.GetBuffer(e); dest.Add(new StorageEntry { ItemId = 1, Count = 1 }); SaveApply.WriteLedger(em.GetBuffer(e), em.GetBuffer(e)); Assert.AreEqual(0, em.GetBuffer(e).Length); } [Test] public void StructureSave_HP_RoundTrips_And_Writes_V3() { var data = new SaveData { Structures = new[] { new StructureSave { Type = 1, CellX = 1, CellZ = 2, HP = 37f } } }; var back = JsonUtility.FromJson(JsonUtility.ToJson(data)); Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version (v5 since END-2; v4 added Core, v3 HP)."); Assert.AreEqual(1, back.Structures.Length); Assert.AreEqual(37f, back.Structures[0].HP, 1e-4f, "the wounded HP round-trips through JSON."); } [Test] public void V2_Save_IsWithinLoadableRange_And_ZeroHp_Restores_Full() { // A pre-EB-1 v2 save sits inside the additive load floor [Min,Current], so SaveService.Load accepts it; // an unset HP (0) is mapped by BaseRestoreSystem to the baked Max (structures come back at full HP). var v2 = new SaveData { Version = 2, GoalCharge = 3, GoalTarget = 10, Structures = new[] { new StructureSave { Type = 1, CellX = 2, CellZ = 4 } } }; var back = JsonUtility.FromJson(JsonUtility.ToJson(v2)); Assert.AreEqual(2, back.Version); Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v2 is at/above the load floor."); Assert.LessOrEqual(back.Version, SaveData.CurrentVersion); Assert.AreEqual(0f, back.Structures[0].HP, 1e-4f, "unset HP (0) -> restore maps to baked Max."); } [Test] public void ToPending_Maps_All_Fields_Including_The_Wounded_HP() { // The WorldLauncher save->stage copy: omitting any field here silently restores at full HP (review-caught). var s = new StructureSave { Type = 1, CellX = 3, CellZ = -2, Direction = 2, RemainingTicks = 50, ConveyorResId = 1, ConveyorCount = 4, HP = 37f }; var p = SaveApply.ToPending(s); Assert.AreEqual(1, p.Type); Assert.AreEqual(3, p.CellX); Assert.AreEqual(-2, p.CellZ); Assert.AreEqual(2, p.Direction); Assert.AreEqual(50u, p.RemainingTicks); Assert.AreEqual(1, p.ConveyorResId); Assert.AreEqual(4, p.ConveyorCount); Assert.AreEqual(37f, p.HP, 1e-4f, "the wounded HP survives the save->staging copy."); } [Test] public void CoreCurrent_RoundTrips_And_Writes_Current_Version() { var data = new SaveData { GoalCharge = 1, GoalTarget = 10, CoreCurrent = 63 }; var back = JsonUtility.FromJson(JsonUtility.ToJson(data)); Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version (v5 since END-2)."); Assert.AreEqual(63, back.CoreCurrent, "the wounded Core integrity round-trips through JSON."); } [Test] public void Pre_END1_Save_Missing_CoreCurrent_Defaults_To_Zero() { // A pre-END-1 save JSON lacks the CoreCurrent field -> JsonUtility defaults it to 0, which the // born-correct spawn maps to the baked Max (the Core comes back full). Additive: no field, no break. var back = JsonUtility.FromJson("{\"Version\":3,\"GoalCharge\":2,\"GoalTarget\":10}"); Assert.AreEqual(0, back.CoreCurrent, "missing CoreCurrent -> 0 -> restored full at baked Max."); Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v3 stays within the additive load floor."); Assert.LessOrEqual(back.Version, SaveData.CurrentVersion); } [Test] public void RunOutcome_RoundTrips_And_Writes_Current_Version() { var data = new SaveData { GoalCharge = 1, GoalTarget = 4, RunOutcome = RunOutcomeId.Victory }; var back = JsonUtility.FromJson(JsonUtility.ToJson(data)); Assert.AreEqual(SaveData.CurrentVersion, back.Version, "END-2: new saves write v5."); Assert.AreEqual(5, SaveData.CurrentVersion, "SaveData is at v5 (END-2 added RunOutcome)."); Assert.AreEqual((int)RunOutcomeId.Victory, back.RunOutcome, "the latched terminal outcome round-trips through JSON."); } [Test] public void Pre_END2_Save_Missing_RunOutcome_Defaults_To_InProgress() { // A pre-END-2 (v4) save JSON lacks RunOutcome -> JsonUtility defaults it to 0 (InProgress) -> the run loads // as in-progress, NOT a finished run. Additive: no field, no break; v4 stays within the load floor. var back = JsonUtility.FromJson("{\"Version\":4,\"GoalCharge\":2,\"GoalTarget\":10,\"CoreCurrent\":50}"); Assert.AreEqual((int)RunOutcomeId.InProgress, back.RunOutcome, "missing RunOutcome -> 0 (InProgress)."); Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v4 stays within the additive load floor."); Assert.LessOrEqual(back.Version, SaveData.CurrentVersion); } } }