feat(progression): json stores on a real filesystem (slice 8)

new progression.adapters project, no engine reference. Stores take a
directory path; Godot resolves user:// with GlobalizePath and passes it
in, which is what makes them testable at all - originally I'd written
this slice off as untestable.

version lives in a file envelope, not the snapshot, so the domain only
knows the current shape. A newer-build save reports Corrupt with a
reason instead of masquerading as "no save" and getting overwritten.

atomic write via temp + rename. One file per aggregate: saving a run
leaves the profile file byte-identical, asserted.

guard rail fixed - it was matching prose in doc comments, now anchored
to using-directives plus a project.assets.json check. 63 tests.
This commit is contained in:
2026-07-28 19:35:43 +02:00
parent f991bb99fc
commit 7a69292066
13 changed files with 385 additions and 4 deletions

View File

@@ -0,0 +1,65 @@
using Mr.BrickAdventures.Progression.Adapters;
namespace Mr.BrickAdventures.Progression.Tests;
public sealed class JsonProfileStoreTests : IDisposable
{
private readonly string _directory =
Path.Combine(Path.GetTempPath(), "ppc-progression-" + Guid.NewGuid().ToString("N"));
private static readonly LevelId Level = LevelId.Of("village_1");
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
[Fact]
public void A_profile_survives_a_round_trip_through_a_real_file()
{
var store = new JsonProfileStore(_directory);
var profile = Profile.Empty();
profile.RecordTime(Level, LevelTime.Of(31.25));
profile.UnlockAchievement(AchievementId.Of("rescue_ten_kids"));
profile.Record(StatName.Deaths, 7);
store.Save(profile);
var result = store.Load();
Assert.Equal(LoadOutcome.Loaded, result.Outcome);
Assert.Equal(LevelTime.Of(31.25), result.Value!.BestTimeFor(Level));
Assert.True(result.Value.HasAchievement(AchievementId.Of("rescue_ten_kids")));
Assert.Equal(7, result.Value.ReadStat(StatName.Deaths));
}
[Fact]
public void On_a_first_run_there_is_no_profile_file_and_that_is_absent()
{
Assert.Equal(LoadOutcome.Absent, new JsonProfileStore(_directory).Load().Outcome);
}
[Fact]
public void Saving_a_playthrough_does_not_touch_the_profile_file()
{
// The point of one file per aggregate. A run being saved on every level completion must not
// rewrite achievements and best times, and a corrupt slot must not cost them.
var profiles = new JsonProfileStore(_directory);
var playthroughs = new JsonPlaythroughStore(_directory);
var profile = Profile.Empty();
profile.RecordTime(Level, LevelTime.Of(31.25));
profiles.Save(profile);
var profilePath = Directory.GetFiles(_directory, "profile*.json").Single();
var before = File.GetLastWriteTimeUtc(profilePath);
var contentBefore = File.ReadAllText(profilePath);
playthroughs.Save(Playthrough.StartNew(
PlaythroughId.Of(Guid.NewGuid()),
Campaign.Of(new[] { Level }),
Lives.Of(3)));
Assert.Equal(before, File.GetLastWriteTimeUtc(profilePath));
Assert.Equal(contentBefore, File.ReadAllText(profilePath));
Assert.Equal(2, Directory.GetFiles(_directory, "*.json").Length);
}
}