Files
MagnetismHeroes/Assets/MagnetismHeroes/Tests/EditMode/LocalProgressStoreTests.cs
T

180 lines
11 KiB
C#

using System;
using System.IO;
using System.Linq;
using MagnetismHeroes.Core;
using MagnetismHeroes.UnityRuntime.Persistence;
using NUnit.Framework;
namespace MagnetismHeroes.Tests
{
public sealed class LocalProgressStoreTests
{
private string _directory, _path;
private StageDefinition _stage;
[SetUp] public void SetUp()
{
_directory = Path.Combine(Path.GetTempPath(), "MagnetismHeroes-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_directory);
_path = Path.Combine(_directory, "progress.json");
_stage = new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 });
}
[TearDown] public void TearDown() => Directory.Delete(_directory, true);
private LocalProgressStore Store() => new LocalProgressStore(_path, new[] { _stage });
private ProgressSave Puzzle()
{ var board = new BoardRules(_stage); return new ProgressSave { puzzleStates = new[] { board.ToPuzzleState(GameSession.DefaultCamera()) } }; }
[Test] public void CommittedPositionAndCameraRoundTripWithoutPartialLoad()
{
var progress = Puzzle(); progress.puzzleStates[0].balls[0].position = new Position3(1, -2, 3);
progress.puzzleStates[0].camera.distance = 10;
Assert.That(Store().TrySave(progress, out var error), Is.True, error);
Assert.That(Store().TryLoad(out var loaded, out error), Is.True, error);
Assert.That(loaded.puzzleStates[0].balls[0].position, Is.EqualTo(new Position3(1, -2, 3)));
Assert.That(loaded.puzzleStates[0].camera.distance, Is.EqualTo(10));
}
[TestCase("position")]
[TestCase("x")]
[TestCase("camera")]
[TestCase("countedBallIds")]
[TestCase("latestFreePlay")]
public void MissingRequiredFieldsAreRejectedInsteadOfDefaulted(string field)
{
Assert.That(Store().TrySave(Puzzle(), out _), Is.True);
var json = File.ReadAllText(_path).Replace("\"" + field + "\":", "\"removed_" + field + "\":");
File.WriteAllText(_path, json);
Assert.That(Store().TryLoad(out var loaded, out var error), Is.False);
Assert.That(loaded, Is.Null); Assert.That(error, Does.Contain(field));
}
[Test] public void DuplicateJsonKeysAndTrailingContentAreRejected()
{
File.WriteAllText(_path, "{\"schemaVersion\":1,\"schemaVersion\":1}");
Assert.That(Store().TryLoad(out _, out _), Is.False);
File.Delete(_path); Assert.That(Store().TrySave(Puzzle(), out _), Is.True);
File.WriteAllText(_path, File.ReadAllText(_path) + " {}");
Assert.That(Store().TryLoad(out _, out _), Is.False);
}
[Test] public void ValidBackupRecoversWholeBoardAndDamagedOriginalSurvivesNextSave()
{
var store = Store(); var progress = Puzzle(); Assert.That(store.TrySave(progress, out _), Is.True);
progress.puzzleStates[0].camera.distance = 12; Assert.That(store.TrySave(progress, out _), Is.True);
File.WriteAllText(_path, "broken-primary");
Assert.That(store.TryLoad(out var loaded, out var error), Is.True, error);
Assert.That(loaded.puzzleStates[0].camera.distance, Is.EqualTo(8));
Assert.That(error, Does.Contain("Recovered"));
Assert.That(store.TrySave(loaded, out error), Is.True, error);
var damaged = Directory.GetFiles(_directory, "*.damaged-*").Single();
Assert.That(File.ReadAllText(damaged), Is.EqualTo("broken-primary"));
Assert.That(File.ReadAllText(_path + ".bak"), Does.Not.Contain("broken-primary"));
Assert.That(File.Exists(_path + ".tmp"), Is.False);
}
[Test] public void WithoutValidBackupLoaderDoesNotOverwriteDamagedEvidence()
{
File.WriteAllText(_path, "broken-primary"); File.WriteAllText(_path + ".bak", "broken-backup");
var store = Store(); Assert.That(store.TryLoad(out _, out _), Is.False);
Assert.That(File.ReadAllText(_path), Is.EqualTo("broken-primary"));
Assert.That(store.TrySave(new ProgressSave(), out var error), Is.True, error);
Assert.That(Directory.GetFiles(_directory, "*.damaged-*").Select(File.ReadAllText), Does.Contain("broken-primary"));
Assert.That(File.ReadAllText(_path + ".bak"), Is.EqualTo("broken-backup"));
}
[Test] public void BothDamagedOriginalsSurviveTwoSubsequentSuccessfulWrites()
{
File.WriteAllText(_path, "distinct-broken-primary"); File.WriteAllText(_path + ".bak", "distinct-broken-backup");
var store = Store(); Assert.That(store.TryLoad(out _, out _), Is.False);
Assert.That(store.TrySave(new ProgressSave(), out var error), Is.True, error);
Assert.That(store.TrySave(Puzzle(), out error), Is.True, error);
var forensic = Directory.GetFiles(_directory, "*.damaged-*").Select(File.ReadAllText).ToArray();
Assert.That(forensic, Does.Contain("distinct-broken-primary"));
Assert.That(forensic, Does.Contain("distinct-broken-backup"));
Assert.That(store.TryLoad(out var restored, out error), Is.True, error);
Assert.That(restored.puzzleStates.Single().stageId, Is.EqualTo(1));
}
[Test] public void MissingPrimaryRestoresEntireValidBackup()
{
var definitions = new[] { _stage,
new StageDefinition(2, StageKind.Counting, new[] { 1, 1 }, new[] { 2 }),
new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }) };
var session = new GameSession(definitions, new LocalProgressStore(_path, definitions));
session.StartStage(1); session.MoveGroup(session.Board.Groups[0].id, new Position3(1, 2, 3));
session.Count(session.Board.Balls[0].id); session.MarkTutorialSeen("merge");
session.StartStage(2); session.MoveAndMerge(session.Board.Groups[0].id, new Position3(2, 1, -3), session.Board.Groups[1].id);
session.Count(session.Board.Balls[0].id);
session.CommitCamera(new CameraState { target = new Position3(1, 2, 3), rotation = new Rotation4(0, .6f, 0, .8f), distance = 11 });
var store = new LocalProgressStore(_path, definitions);
Assert.That(store.TryLoad(out var expected, out var error), Is.True, error);
var six = new BoardRules(definitions[2]); six.TryMoveGroup(six.Groups[0].id, new Position3(-1, 2, 4));
expected.puzzleStates = expected.puzzleStates.Concat(new[] { six.ToPuzzleState(GameSession.DefaultCamera()) }).ToArray();
Assert.That(store.TrySave(expected, out error), Is.True, error);
var expectedJson = File.ReadAllText(_path);
Assert.That(store.TrySave(expected, out error), Is.True, error); File.Delete(_path);
Assert.That(store.TryLoad(out var loaded, out error), Is.True, error);
Assert.That(error, Does.Contain("Recovered valid backup"));
Assert.That(loaded.puzzleStates.Length, Is.EqualTo(2));
Assert.That(loaded.puzzleStates.Single(state => state.stageId == 2).countedBallIds.Length, Is.EqualTo(1));
Assert.That(store.TrySave(loaded, out error), Is.True, error);
Assert.That(File.ReadAllText(_path), Is.EqualTo(expectedJson), "Every contract field, including free play and tutorial progress, must survive.");
}
[Test] public void MissingPrimaryWithInvalidBackupPreservesEvidenceThroughTwoSaves()
{
File.WriteAllText(_path + ".bak", "distinct-backup-only-damage");
var store = Store(); Assert.That(store.TryLoad(out var loaded, out var error), Is.False);
Assert.That(loaded, Is.Null); Assert.That(error, Does.Contain("Invalid primary and backup"));
Assert.That(File.Exists(_path), Is.False);
Assert.That(File.ReadAllText(_path + ".bak"), Is.EqualTo("distinct-backup-only-damage"));
Assert.That(store.TrySave(new ProgressSave(), out error), Is.True, error);
Assert.That(store.TrySave(Puzzle(), out error), Is.True, error);
Assert.That(Directory.GetFiles(_directory, "*.bak.damaged-*").Select(File.ReadAllText), Does.Contain("distinct-backup-only-damage"));
Assert.That(store.TryLoad(out loaded, out error), Is.True, error);
Assert.That(loaded.puzzleStates.Single().stageId, Is.EqualTo(1));
}
[Test] public void MissingPrimaryAndBackupStartsFreshWithoutRecoveryError()
{
Assert.That(Store().TryLoad(out var loaded, out var error), Is.True, error);
Assert.That(error, Is.Null); Assert.That(loaded.puzzleStates, Is.Empty);
Assert.That(loaded.completedStageIds, Is.Empty); Assert.That(loaded.seenTutorialIds, Is.Empty);
Assert.That(loaded.latestFreePlay, Is.Null); Assert.That(Directory.GetFiles(_directory), Is.Empty);
}
[Test] public void InvalidGroupMembershipDoesNotReplaceAValidSave()
{
var store = Store(); var progress = Puzzle(); Assert.That(store.TrySave(progress, out _), Is.True);
var before = File.ReadAllBytes(_path); progress.puzzleStates[0].groups[0].ballIds = new[] { "missing" };
Assert.That(store.TrySave(progress, out _), Is.False); Assert.That(File.ReadAllBytes(_path), Is.EqualTo(before));
}
[TestCase("schemaVersion", "null")]
[TestCase("schemaVersion", "\"1\"")]
[TestCase("schemaVersion", "1.5")]
[TestCase("distance", "null")]
[TestCase("distance", "\"8\"")]
[TestCase("x", "null")]
[TestCase("x", "\"0\"")]
public void WrongScalarTypesAreRejected(string field, string value)
{
Assert.That(Store().TrySave(Puzzle(), out _), Is.True);
var json = File.ReadAllText(_path);
json = System.Text.RegularExpressions.Regex.Replace(json, "\"" + field + "\":\\s*-?[0-9]+(?:\\.[0-9]+)?", "\"" + field + "\":" + value);
File.WriteAllText(_path, json); Assert.That(Store().TryLoad(out var loaded, out _), Is.False); Assert.That(loaded, Is.Null);
}
[Test] public void SavedJsonContainsOnlyContractFields()
{
Assert.That(Store().TrySave(Puzzle(), out _), Is.True);
Assert.That(File.ReadAllText(_path), Does.Not.Contain("IsValid"));
}
[Test] public void LockedTemporaryFileReportsFailureAndKeepsTheLastValidSave()
{
var store = Store(); Assert.That(store.TrySave(Puzzle(), out _), Is.True);
Assert.That(store.TrySave(Puzzle(), out _), Is.True);
var before = File.ReadAllBytes(_path);
var backupBefore = File.ReadAllBytes(_path + ".bak");
using (var locked = new FileStream(_path + ".tmp", FileMode.Create, FileAccess.ReadWrite, FileShare.None))
{
string error = null; bool saved = true;
Assert.DoesNotThrow(() => saved = store.TrySave(Puzzle(), out error));
Assert.That(saved, Is.False); Assert.That(error, Is.Not.Empty);
Assert.That(File.ReadAllBytes(_path), Is.EqualTo(before));
Assert.That(File.ReadAllBytes(_path + ".bak"), Is.EqualTo(backupBefore));
}
Assert.That(store.TryLoad(out var restored, out _), Is.True);
Assert.That(restored.puzzleStates.Length, Is.EqualTo(1));
Assert.That(store.TrySave(Puzzle(), out var recoveredError), Is.True, recoveredError);
}
}
}