diff --git a/src/MicroPlumberd.Tests.App/Infrastructure/InMemoryAssertionDb.cs b/src/MicroPlumberd.Tests.App/Infrastructure/InMemoryAssertionDb.cs
index eb307fe..5a87081 100644
--- a/src/MicroPlumberd.Tests.App/Infrastructure/InMemoryAssertionDb.cs
+++ b/src/MicroPlumberd.Tests.App/Infrastructure/InMemoryAssertionDb.cs
@@ -37,13 +37,11 @@ public void Add(Metadata m, object evt)
public class LiteDbFactory
{
- private static int _id = 0;
-
- public static LiteDatabase Get()
- {
- string fileName = $"./test_{Interlocked.Increment(ref _id)}.db";
- if(File.Exists(fileName))
- File.Delete(fileName);
- return new LiteDatabase(fileName);
- }
+ ///
+ /// A fresh, empty, IN-MEMORY LiteDB per call. It was a file, ./test_{n}.db, "fresh" by deleting that file — but LiteDB
+ /// also writes a journal, ./test_{n}-log.db, which survived and was REPLAYED on the next open: a later process's
+ /// HowToSubscribeRealModel counted earlier runs' reservations ("found 2", "found 6", 10 — growing run on run, and
+ /// passing only where the journal had been checkpointed away). Memory leaves nothing behind for the next run.
+ ///
+ public static LiteDatabase Get() => new LiteDatabase(new MemoryStream());
}
\ No newline at end of file
diff --git a/src/MicroPlumberd.Tests/HowTo/PlumberMethodsTests.cs b/src/MicroPlumberd.Tests/HowTo/PlumberMethodsTests.cs
index 938ea0a..93cf3fb 100644
--- a/src/MicroPlumberd.Tests/HowTo/PlumberMethodsTests.cs
+++ b/src/MicroPlumberd.Tests/HowTo/PlumberMethodsTests.cs
@@ -82,12 +82,16 @@ public async Task HowToSubscribeRealModel()
await plumber.SubscribeEventHandler(dbModel);
var suffixOfStreamWhereOurEventWillBeAppend = Guid.NewGuid();
- var ourLovelyEvent = new TicketReserved() { MovieName = "Golden Eye", RoomName = "101"};
+ // THIS row's own reservation, by its own name. The "found 2/6" reds were a LiteDB journal replayed from earlier runs
+ // (fixed in LiteDbFactory); the class-fixture store contributes nothing TODAY (the absolute-count mutant survives) —
+ // but the model subscribes from the beginning, so the next row in this class that reserves under a shared name would
+ // bring an absolute count's order-dependence back. Count ours.
+ var movie = $"Golden Eye {Guid.NewGuid():N}";
+ var ourLovelyEvent = new TicketReserved() { MovieName = movie, RoomName = "101"};
await plumber.AppendEvent(ourLovelyEvent, suffixOfStreamWhereOurEventWillBeAppend);
- await Task.Delay(1000);
- dbModel.Reservations.Query().Count().Should().Be(1);
+ (await OwnReservationsFolded(dbModel.Reservations, movie)).Should().Be(1, "our event is folded exactly once");
}
[Fact]
@@ -102,12 +106,12 @@ public async Task HowToSubscribeRealModelWithHost()
.StartAsync();
var suffixOfStreamWhereOurEventWillBeAppend = Guid.NewGuid();
- var ourLovelyEvent = new TicketReserved() { MovieName = "Golden Eye", RoomName = "101" };
+ var movie = $"Golden Eye {Guid.NewGuid():N}"; // this row's own reservation (see HowToSubscribeRealModel)
+ var ourLovelyEvent = new TicketReserved() { MovieName = movie, RoomName = "101" };
await sp.GetRequiredService().AppendEvent(ourLovelyEvent, suffixOfStreamWhereOurEventWillBeAppend);
- await Task.Delay(1000);
- sp.GetRequiredService().Reservations().Query().Count().Should().Be(1);
+ (await OwnReservationsFolded(sp.GetRequiredService().Reservations(), movie)).Should().Be(1, "our event is folded exactly once");
}
@@ -265,6 +269,19 @@ await plumber.Rehydrate(model, $"ReservationStream-{Guid.NewGuid()}",
}
+ ///
+ /// Waits — bounded, for the condition the assertion needs: THIS row's reservation folded — instead of a fixed delay, then
+ /// gives a short settle so a duplicate delivery of the same event would still be counted. Returns how many are folded.
+ ///
+ private static async Task OwnReservationsFolded(LiteDB.ILiteCollection reservations, string movie)
+ {
+ var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
+ while (reservations.Count(x => x.MovieName == movie) == 0 && DateTime.UtcNow < deadline)
+ await Task.Delay(50);
+ await Task.Delay(250);
+ return reservations.Count(x => x.MovieName == movie);
+ }
+
public void Dispose()
{
_host?.Dispose();
diff --git a/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs b/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs
new file mode 100644
index 0000000..1ae1d91
--- /dev/null
+++ b/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs
@@ -0,0 +1,48 @@
+using FluentAssertions;
+using LiteDB;
+using MicroPlumberd.Tests.App.CinemaDomain;
+using MicroPlumberd.Tests.App.Infrastructure;
+
+namespace MicroPlumberd.Tests.Unit;
+
+///
+/// HowToSubscribeRealModel counted reservations of EARLIER test runs: LiteDbFactory "freshened" ./test_{n}.db by deleting
+/// it, but LiteDB's journal ./test_{n}-log.db survived and was replayed on the next open ("found 2", "found 6", 10 — growing
+/// run on run). Two rows, because a file factory shows differently on the two kinds of bin (rev-reach):
+/// - CLEAN bin (CI, always): the file factory CREATES test_1.db — the no-file row is red; StartsEmpty is green (nothing to replay).
+/// - DIRTY bin (a dev's second run): it re-opens existing names — no new path, but the files are MODIFIED (the no-file row,
+/// by length + write time, is red). StartsEmpty is red only where a NOT-yet-checkpointed journal was left behind (as the
+/// HowTo rows left one: "found 11"); in this class's own runs it stayed green. The no-file row is the guard on both bins.
+/// A path-set-only form of the no-file row was blind on the dirty bin (names are reused); this one sees creation AND change.
+///
+public class LiteDbFactoryTests
+{
+ [Fact]
+ public void AnAssertionDb_CreatesOrTouchesNoFile_ForALaterRunToReplay()
+ {
+ var dir = Directory.GetCurrentDirectory();
+ var before = Snapshot(dir);
+
+ using (var db = LiteDbFactory.Get())
+ db.Reservations().Insert(new Reservation { MovieName = "written", RoomName = "1" });
+
+ var after = Snapshot(dir);
+ var created = after.Keys.Except(before.Keys).ToList();
+ var modified = after.Where(f => before.TryGetValue(f.Key, out var was) && was != f.Value).Select(f => f.Key).ToList();
+ created.Concat(modified).Should().BeEmpty(
+ "an assertion db must leave nothing on disk — a file, or its journal, is replayed into the next run's model");
+ }
+
+ private static Dictionary Snapshot(string dir) =>
+ Directory.GetFiles(dir, "*.db").ToDictionary(f => f, f => { var i = new FileInfo(f); return (i.Length, i.LastWriteTimeUtc); });
+
+ [Fact]
+ public void EachAssertionDb_StartsEmpty()
+ {
+ using (var first = LiteDbFactory.Get())
+ first.Reservations().Insert(new Reservation { MovieName = "written", RoomName = "1" });
+
+ using var next = LiteDbFactory.Get();
+ next.Reservations().Count().Should().Be(0);
+ }
+}