diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-20 20:58:23 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-20 20:58:23 +0800 |
| commit | 5cb4970e2410a43e528b4b3e44dda15fac0a219a (patch) | |
| tree | 84c2c7618f18c92367c13d8e3667068961c72b9f /src/LivingVillage.Kernel.Tests/M6aTests.fs | |
| parent | 6f904d6f8528bd13941894af3db16d0bc3b0aeb3 (diff) | |
| download | living-village-5cb4970e2410a43e528b4b3e44dda15fac0a219a.tar.gz | |
feat(m6a): add file save acceptance smoke
[Change Nature]
This commit adds the bounded M6a file-save acceptance path.
[New Capability]
- Add file-level round-trip and deterministic continuation coverage.
- Add Headless --m6a-smoke with digest checks and explicit PASS/FAIL output.
[Implementation]
- Reject non-finite numeric values and negative inventory quantities.
- Exercise dialogue, chat/rumor, trade, events, annals, time, and RNG state.
[Impact]
- Affects Kernel save parsing, Kernel tests, and Headless smoke tooling.
- Preserves the existing save format and M5 smoke behavior.
Diffstat (limited to 'src/LivingVillage.Kernel.Tests/M6aTests.fs')
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/M6aTests.fs | 113 |
1 files changed, 92 insertions, 21 deletions
diff --git a/src/LivingVillage.Kernel.Tests/M6aTests.fs b/src/LivingVillage.Kernel.Tests/M6aTests.fs index 8a318be..91f0a10 100644 --- a/src/LivingVillage.Kernel.Tests/M6aTests.fs +++ b/src/LivingVillage.Kernel.Tests/M6aTests.fs @@ -1,5 +1,7 @@ namespace LivingVillage.Kernel.Tests +open System +open System.IO open Microsoft.VisualStudio.TestTools.UnitTesting open LivingVillage.Kernel open LivingVillage.Kernel.Sim @@ -44,6 +46,19 @@ module private M6aHarness = let advance (inputs: Input list) (world: World) : World = inputs |> List.fold (fun current input -> Sim.step { Input = input } current) world + let advanceTrace (inputs: Input list) (world: World) : World list = + inputs + |> List.scan (fun current input -> Sim.step { Input = input } current) world + |> List.tail + + let withTempSave (action: string -> unit) = + let id = Guid.NewGuid().ToString("N") + let path = Path.Combine(Path.GetTempPath(), $"living-village-m6a-{id}.save") + try + action path + finally + if File.Exists path then File.Delete path + [<TestClass>] type M6aTests () = @@ -54,27 +69,83 @@ type M6aTests () = |> M6aHarness.withDialogue |> M6aHarness.withChat |> M6aHarness.withTrade - let encoded = WorldSave.save world - - match WorldSave.load encoded with - | Error failure -> Assert.Fail($"save should load: {failure}") - | Ok restored -> - Assert.AreEqual<string>(encoded, WorldSave.save restored) - let inputs = - [ { MoveX = 0.5f; MoveY = -0.25f } - { MoveX = 0.0f; MoveY = 0.0f } - { MoveX = -1.0f; MoveY = 0.75f } - { MoveX = 0.25f; MoveY = 0.0f } ] - let continued = M6aHarness.advance inputs world - let restoredContinued = M6aHarness.advance inputs restored - Assert.AreEqual<string>(WorldSave.save continued, WorldSave.save restoredContinued) - Assert.AreEqual<int64>(world.Tick, restored.Tick) - Assert.AreEqual<uint64>(world.Rng.State, restored.Rng.State) - Assert.IsFalse(restored.Events.IsEmpty) - Assert.IsFalse(restored.Rumors.IsEmpty) - Assert.IsFalse(restored.Annals.IsEmpty) - Assert.AreEqual<Avatar>(world.Avatar, restored.Avatar) - CollectionAssert.AreEqual(world.Npcs, restored.Npcs) + |> fun current -> + let tick = 22L * (Sim.ticksPerDay / 24L) + { current with Tick = tick; Time = float tick * Sim.dtSeconds } + + M6aHarness.withTempSave (fun path -> + WorldSave.saveToFile path world + + match WorldSave.loadFromFile path with + | Error failure -> Assert.Fail($"save should load: {failure}") + | Ok restored -> + Assert.AreEqual<string>(WorldSave.save world, WorldSave.save restored) + Assert.AreEqual<float>(world.Time, restored.Time) + Assert.IsTrue(Sim.isNightTick restored.Tick) + Assert.AreEqual<Needs>(world.Avatar.Mind.Needs, restored.Avatar.Mind.Needs) + Assert.AreEqual<float32>(world.Avatar.Mind.Needs.Money, restored.Avatar.Mind.Needs.Money) + CollectionAssert.AreEqual(world.Npcs, restored.Npcs) + Assert.AreEqual<Map<ItemKind, int>>(world.Npcs.[0].Inventory, restored.Npcs.[0].Inventory) + Assert.AreEqual<InteractionEvent list>(world.Events, restored.Events) + Assert.AreEqual<RumorEvent list>(world.Rumors, restored.Rumors) + Assert.AreEqual<AnnalEntry list>(world.Annals, restored.Annals) + Assert.AreEqual<uint64>(world.Rng.State, restored.Rng.State) + + let inputs = + [ { MoveX = 0.5f; MoveY = -0.25f } + { MoveX = 0.0f; MoveY = 0.0f } + { MoveX = -1.0f; MoveY = 0.75f } + { MoveX = 0.25f; MoveY = 0.0f } ] + let continued = M6aHarness.advanceTrace inputs world + let restoredContinued = M6aHarness.advanceTrace inputs restored + Assert.AreEqual<string list>(continued |> List.map WorldSave.save, restoredContinued |> List.map WorldSave.save) + Assert.AreEqual<uint64 list>(continued |> List.map (fun next -> next.Rng.State), restoredContinued |> List.map (fun next -> next.Rng.State)) + Assert.AreEqual<InteractionEvent list list>(continued |> List.map (fun next -> next.Events), restoredContinued |> List.map (fun next -> next.Events)) + Assert.AreEqual<string>(WorldSave.save (List.last continued), WorldSave.save (List.last restoredContinued))) + + [<TestMethod>] + member _.RejectedFilesDoNotReplaceCurrentWorld () = + let currentWorld = Sim.initialWorldN 99UL 2 + let before = WorldSave.save currentWorld + let mutable current = currentWorld + + let reject path = + match WorldSave.loadFromFile path with + | Ok loaded -> + current <- loaded + Assert.Fail("invalid save must not load") + | Error _ -> () + Assert.AreEqual<string>(before, WorldSave.save current) + + M6aHarness.withTempSave (fun path -> + reject (path + ".missing") + + File.WriteAllText(path, "LV_WORLD_SAVE_V0") + reject path + + File.WriteAllText(path, WorldSave.save currentWorld + "|trailing-corruption") + reject path) + + [<TestMethod>] + member _.CorruptNumericAndInventoryValuesAreRejected () = + let world = Sim.initialWorldN 7UL 2 + + M6aHarness.withTempSave (fun path -> + let nonFinite = { world with Time = Double.NaN } + WorldSave.saveToFile path nonFinite + match WorldSave.loadFromFile path with + | Ok _ -> Assert.Fail("non-finite time must be rejected") + | Error _ -> () + + let invalidInventory = + { world with + Npcs = + [| { world.Npcs.[0] with Inventory = Map.ofList [ Food, -1 ] } + world.Npcs.[1] |] } + WorldSave.saveToFile path invalidInventory + match WorldSave.loadFromFile path with + | Ok _ -> Assert.Fail("negative inventory must be rejected") + | Error _ -> ()) [<TestMethod>] member _.SimulationClockSupportsPauseAndRequestedSpeeds () = |
