diff --git a/AGENTS.md b/AGENTS.md index 45573a35..2f7e98bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ - `docs/class-registration.md` - ServiceCollection/ServiceProvider API: registration overloads, source generator requirements, lifecycle, aliases, multi-registration, parent/child provider callback merging and scoped lifecycles - `docs/events.md` - Pixely.Events EventBus, event handlers, publishing, and DI auto-subscription +- `docs/observations.md` - Pixely.Observations: the log, its writer and its readers, trimming behind the slowest reader, capacity and stall detection, registration - `docs/static-factory-methods.md` - Static Create() method pattern - `docs/componentize.md` - Pixely.Componentize setup and usage - `docs/components.md` - GameWorld, GameObject, GameComponent lifecycle, Services, UpdateSystem diff --git a/Pixely.slnx b/Pixely.slnx index 7b214f78..b6cdb8b1 100644 --- a/Pixely.slnx +++ b/Pixely.slnx @@ -12,6 +12,7 @@ + @@ -27,6 +28,7 @@ + diff --git a/docs/observations.md b/docs/observations.md new file mode 100644 index 00000000..7aa31b04 --- /dev/null +++ b/docs/observations.md @@ -0,0 +1,130 @@ +# Observations + +`Pixely.Observations` is an append-only log of entries that readers drain at their own pace. Rules append; +nothing is called back. A reader holds its own position, reads when it suits its own point in the frame, and +the log drops an entry once every reader has passed it. + +Use it for a transition that two reads of game state one frame apart cannot derive: a unit that walked a path +and arrived, one that appeared and was gone again, an action that resolved and was reversed inside the same +frame. State answers what is true now, and a rule can resolve many transitions between two reads, so any +"most recent transition" field is overwritten before a reader looks at it. + +Three types, each with one job: + +- `ObservationLog` is the storage. It is constructed and then handed to the other two; it has no other + public members. +- `ObservationWriter` appends. It cannot read. +- `ObservationReader` is one reader's position in the log. It cannot append. + +So a rule holding a writer has no way to drain the log, and a reader has no way to record an observation no +rule produced. + +The log belongs to one frame loop and is not thread safe. Appending, reading and constructing a reader all +happen on the same thread. + +## The entry type + +One log carries one entry type. To carry several kinds of entry in one order, make `TEntry` a tagged type; the +log never looks inside it. A value type is the one to reach for, because appending it costs no allocation. + +```csharp +public readonly record struct UnitMovedEntry(UnitId Unit, TilePoint From, TilePoint To); +public readonly record struct UnitDiedEntry(UnitId Unit); + +public enum ObservationKind { UnitMoved, UnitDied } + +public readonly record struct Observation(ObservationKind Kind, ParticipantId Perceiver, UnitMovedEntry Moved, UnitDiedEntry Died); +``` + +Entries are past-tense records of ids and value types, never a live reference into game state. With a value +entry nothing allocates per entry: the log stores `TEntry` inline in an array, `Append` takes it by `in`, and +`TryRead` copies it out. + +`TEntry` may be a class where that suits the game better, and the log releases each slot as it trims so a +drained entry is not held alive. It costs an allocation per append, so it does not belong on a path that +appends every frame. + +## Writing + +Inject `ObservationWriter` where rules record what happened: + +```csharp +internal sealed class MoveMechanic +{ + private readonly ObservationWriter _observations; + + internal MoveMechanic(ObservationWriter observations) => _observations = observations; + + internal void Move(UnitId unit, TilePoint destination) + { + // mutate state, then record what happened + _observations.Append(new Observation(ObservationKind.UnitMoved, perceiver, moved, default)); + } +} +``` + +## Reading + +Inject the log, construct a reader named after the consumer, and drain it in the consumer's own update. Dispose +the reader with the consumer: + +```csharp +internal sealed class UnitSpritePresenter : IUpdatable, IDisposable +{ + private readonly ObservationReader _observations; + + internal UnitSpritePresenter(ObservationLog log) + { + _observations = new ObservationReader(log, nameof(UnitSpritePresenter)); + } + + public void Update() + { + while (_observations.TryRead(out Observation observation)) + { + // skip what another participant perceived, then switch on the tag + } + } + + public void Dispose() => _observations.Dispose(); +} +``` + +Each consumer constructs its own reader rather than being handed one, because every reader of a given log is +the same closed type and the container resolves by type. Constructing it also lets the consumer name it. + +A reader starts positioned after the last appended entry, so a consumer created part-way through a run sees +only what is appended from then on. Readers drain independently: entries appended this frame may be drained by +one reader now and by another several frames later. + +Addressing an entry to a subset of readers is the game's business, not the log's. Carry the perceiver in +`TEntry` and have the reader skip what it did not perceive. A consumer bound to one participant should wrap its +reader once rather than repeat the check in every place that drains. + +## Bounds and stalls + +The buffer starts small and doubles towards the maximum capacity given to the constructor. It never shrinks, +so it settles at the high-water mark of its bursts and is reclaimed when the log's scope dies. + +Maximum capacity is a stall detector, not a working size. Pick a number far past any legitimate burst, knowing +a slot costs the size of `TEntry`. A reader that stops draining holds the trim point where it stopped; once the +log fills, appending throws and names the reader that stopped and how far behind it is. Dropping the oldest +entry instead would hide exactly that failure. + +```text +Observation log reached its maximum capacity of 4096 entries. Reader 'UnitSpritePresenter' stopped draining +4096 entries ago. +``` + +## Registration + +Construct the log, then register it alongside a writer over it: + +```csharp +ObservationLog log = new ObservationLog(4096); +services.AddSingleton(log); +services.AddSingleton(new ObservationWriter(log)); +``` + +Register it in the scope it belongs to. A log registered in a stage's child provider dies with that stage, and +nothing carries into the next one. diff --git a/packaging/Pixely/Pixely.Package.csproj b/packaging/Pixely/Pixely.Package.csproj index 8891c044..254399bb 100644 --- a/packaging/Pixely/Pixely.Package.csproj +++ b/packaging/Pixely/Pixely.Package.csproj @@ -48,6 +48,7 @@ + diff --git a/src/Pixely.Observations/ObservationLog.cs b/src/Pixely.Observations/ObservationLog.cs new file mode 100644 index 00000000..bcf100f1 --- /dev/null +++ b/src/Pixely.Observations/ObservationLog.cs @@ -0,0 +1,169 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace Pixely.Observations; + +/// +/// An append-only log of entries that readers drain at their own pace. Appending never calls a reader. An entry +/// is dropped once every reader has passed it, so the log is bounded by the slowest reader rather than by how +/// long the run lasts. +/// +/// +/// The log is storage and nothing else: it is reached through an or an +/// , so neither role can do the other's job. It belongs to one frame +/// loop and is not thread safe: appending, reading and constructing a reader must all happen on the same thread. +/// +/// +/// The single entry type of this log; the log never looks inside it. Carry several kinds of entry in one log +/// by making it a tagged type. A value type keeps appending free of allocation, which is why the entries a +/// game appends every frame should be one. +/// +public sealed class ObservationLog +{ + private const int InitialCapacity = 16; + + private readonly int _maximumCapacity; + private readonly List> _readers = new(); + private TEntry[] _entries; + private int _head; + private int _count; + private long _firstSequence; + private long _nextSequence; + + /// + /// How many entries the log may retain before appending throws. The buffer starts small and grows towards + /// this bound, so it is a stall detector rather than a working size: pick a number far past any legitimate + /// burst, knowing a slot costs the size of . + /// + public ObservationLog(int maximumCapacity) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumCapacity, 1); + _maximumCapacity = maximumCapacity; + _entries = new TEntry[Math.Min(InitialCapacity, maximumCapacity)]; + } + + internal void Append(in TEntry entry) + { + if (_count == _maximumCapacity) + { + throw new InvalidOperationException(DescribeOverflow()); + } + + EnsureCapacity(_count + 1); + _entries[PhysicalIndex(_count)] = entry; + _count++; + _nextSequence++; + Trim(); + } + + internal bool TryRead(ObservationReader reader, [MaybeNullWhen(false)] out TEntry entry) + { + int offset = checked((int)(reader.NextSequence - _firstSequence)); + if (offset >= _count) + { + entry = default; + return false; + } + + entry = _entries[PhysicalIndex(offset)]; + reader.NextSequence++; + Trim(); + return true; + } + + // A reader starts after the last appended entry, so it sees only what is appended from now on. + internal void AddReader(ObservationReader reader) + { + reader.NextSequence = _nextSequence; + _readers.Add(reader); + } + + internal void RemoveReader(ObservationReader reader) + { + _readers.Remove(reader); + Trim(); + } + + private void Trim() + { + if (_count == 0) + { + return; + } + + int removeCount = checked((int)(SlowestSequence() - _firstSequence)); + if (removeCount == 0) + { + return; + } + + // Only worth clearing when a slot can keep an object alive; the check folds away for the rest. A cleared + // slot is past every reader, so nothing reads it back before an append overwrites it. + if (RuntimeHelpers.IsReferenceOrContainsReferences()) + { + for (int i = 0; i < removeCount; i++) + { + _entries[PhysicalIndex(i)] = default!; + } + } + + _head = PhysicalIndex(removeCount); + _count -= removeCount; + _firstSequence += removeCount; + } + + private long SlowestSequence() + { + long slowest = _nextSequence; + foreach (ObservationReader reader in _readers) + { + if (reader.NextSequence < slowest) + { + slowest = reader.NextSequence; + } + } + + return slowest; + } + + private void EnsureCapacity(int requiredCapacity) + { + if (requiredCapacity <= _entries.Length) + { + return; + } + + // Doubling only while it stays under the bound, so the last step lands on the bound instead of overflowing. + int newCapacity = _entries.Length <= _maximumCapacity / 2 ? _entries.Length * 2 : _maximumCapacity; + TEntry[] newEntries = new TEntry[newCapacity]; + int untilWrap = Math.Min(_count, _entries.Length - _head); + Array.Copy(_entries, _head, newEntries, 0, untilWrap); + Array.Copy(_entries, 0, newEntries, untilWrap, _count - untilWrap); + _entries = newEntries; + _head = 0; + } + + // Wrapping by subtraction rather than by modulo, so a buffer whose head plus offset would pass int range + // still indexes correctly, and the hot path costs a compare instead of a division. + private int PhysicalIndex(int offset) + { + int untilWrap = _entries.Length - _head; + return offset < untilWrap ? _head + offset : offset - untilWrap; + } + + private string DescribeOverflow() + { + long slowest = SlowestSequence(); + List stalled = new(); + foreach (ObservationReader reader in _readers) + { + if (reader.NextSequence == slowest) + { + stalled.Add(reader.Name); + } + } + + return $"Observation log reached its maximum capacity of {_maximumCapacity} entries. " + + $"Reader '{string.Join("', '", stalled)}' stopped draining {_nextSequence - slowest} entries ago."; + } +} diff --git a/src/Pixely.Observations/ObservationReader.cs b/src/Pixely.Observations/ObservationReader.cs new file mode 100644 index 00000000..43b401f1 --- /dev/null +++ b/src/Pixely.Observations/ObservationReader.cs @@ -0,0 +1,45 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Pixely.Observations; + +/// +/// One reader's own position in an . Several readers drain the same log at +/// their own pace, each through its own instance. Dispose it when the reader goes away, otherwise it holds the +/// log's trim point where it stopped and the log fills. +/// +public sealed class ObservationReader : IDisposable +{ + private readonly ObservationLog _log; + private bool _disposed; + + /// + /// Identifies this reader when it stops draining and fills the log, so give it the reader's own name. + /// + public ObservationReader(ObservationLog log, string name = "unnamed") + { + _log = log; + Name = name; + log.AddReader(this); + } + + public string Name { get; } + + internal long NextSequence { get; set; } + + public bool TryRead([MaybeNullWhen(false)] out TEntry entry) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _log.TryRead(this, out entry); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _log.RemoveReader(this); + } +} diff --git a/src/Pixely.Observations/ObservationWriter.cs b/src/Pixely.Observations/ObservationWriter.cs new file mode 100644 index 00000000..f638d1f3 --- /dev/null +++ b/src/Pixely.Observations/ObservationWriter.cs @@ -0,0 +1,20 @@ +namespace Pixely.Observations; + +/// +/// Appends to an . Inject it where rules record what happened, so nothing +/// that writes can also read. +/// +public sealed class ObservationWriter +{ + private readonly ObservationLog _log; + + public ObservationWriter(ObservationLog log) + { + _log = log; + } + + public void Append(in TEntry entry) + { + _log.Append(entry); + } +} diff --git a/src/Pixely.Observations/Pixely.Observations.csproj b/src/Pixely.Observations/Pixely.Observations.csproj new file mode 100644 index 00000000..a3cb2a51 --- /dev/null +++ b/src/Pixely.Observations/Pixely.Observations.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + 14 + enable + enable + + + diff --git a/tests/Pixely.Observations.Tests/ObservationLogTests.cs b/tests/Pixely.Observations.Tests/ObservationLogTests.cs new file mode 100644 index 00000000..4ac4a003 --- /dev/null +++ b/tests/Pixely.Observations.Tests/ObservationLogTests.cs @@ -0,0 +1,374 @@ +using System.Runtime.CompilerServices; + +namespace Pixely.Observations.Tests; + +public readonly record struct TestEntry(int Value); + +public sealed record TestReferenceEntry(int Value); + +[TestFixture] +public sealed class ObservationLogTests +{ + [Test] + public void Reader_ReadsAppendedEntriesInOrder() + { + ObservationLog log = new ObservationLog(64); + ObservationWriter writer = new ObservationWriter(log); + ObservationReader reader = new ObservationReader(log); + + writer.Append(new TestEntry(1)); + writer.Append(new TestEntry(2)); + writer.Append(new TestEntry(3)); + + Assert.That(Drain(reader), Is.EqualTo(new[] { 1, 2, 3 })); + Assert.That(reader.TryRead(out _), Is.False); + } + + [Test] + public void Reader_OnlySeesEntriesAppendedAfterItsCreation() + { + ObservationLog log = new ObservationLog(64); + ObservationWriter writer = new ObservationWriter(log); + writer.Append(new TestEntry(1)); + + ObservationReader reader = new ObservationReader(log); + writer.Append(new TestEntry(2)); + + Assert.That(Drain(reader), Is.EqualTo(new[] { 2 })); + } + + [Test] + public void Readers_DrainIndependentlyAtTheirOwnPace() + { + ObservationLog log = new ObservationLog(64); + ObservationWriter writer = new ObservationWriter(log); + ObservationReader fast = new ObservationReader(log, "fast"); + ObservationReader slow = new ObservationReader(log, "slow"); + + writer.Append(new TestEntry(1)); + writer.Append(new TestEntry(2)); + + Assert.That(Drain(fast), Is.EqualTo(new[] { 1, 2 })); + + writer.Append(new TestEntry(3)); + + // The slow reader still sees everything from where it started. + Assert.That(Drain(slow), Is.EqualTo(new[] { 1, 2, 3 })); + Assert.That(Drain(fast), Is.EqualTo(new[] { 3 })); + } + + [Test] + public void Buffer_GrowsBeyondInitialCapacityPreservingOrder() + { + ObservationLog log = new ObservationLog(1024); + ObservationWriter writer = new ObservationWriter(log); + ObservationReader reader = new ObservationReader(log); + + // Far beyond the initial capacity of 16, without draining, forcing growth. + int[] expected = Enumerable.Range(0, 100).ToArray(); + foreach (int value in expected) + { + writer.Append(new TestEntry(value)); + } + + Assert.That(Drain(reader), Is.EqualTo(expected)); + } + + [Test] + public void Buffer_GrowsWhileWrappedPreservingOrder() + { + ObservationLog log = new ObservationLog(1024); + ObservationWriter writer = new ObservationWriter(log); + ObservationReader reader = new ObservationReader(log); + + // Drain ten entries first so the head sits mid-buffer and the retained entries wrap around the end, + // which is what makes growth copy them in two segments. + for (int i = 0; i < 10; i++) + { + writer.Append(new TestEntry(i)); + reader.TryRead(out _); + } + + int[] expected = Enumerable.Range(100, 20).ToArray(); + foreach (int value in expected) + { + writer.Append(new TestEntry(value)); + } + + Assert.That(Drain(reader), Is.EqualTo(expected)); + } + + [Test] + public void Buffer_GrowsNoFurtherThanTheMaximumCapacity() + { + ObservationLog log = new ObservationLog(40); + ObservationWriter writer = new ObservationWriter(log); + ObservationReader reader = new ObservationReader(log); + + // Doubling from 16 would overshoot 40; the buffer has to stop at it and still keep order. + int[] expected = Enumerable.Range(0, 40).ToArray(); + foreach (int value in expected) + { + writer.Append(new TestEntry(value)); + } + + Assert.That(Drain(reader), Is.EqualTo(expected)); + } + + [Test] + public void MaximumCapacity_BelowTheInitialCapacityIsHonoured() + { + ObservationLog log = new ObservationLog(4); + ObservationWriter writer = new ObservationWriter(log); + ObservationReader reader = new ObservationReader(log, "stalled"); + + for (int i = 0; i < 4; i++) + { + writer.Append(new TestEntry(i)); + } + + Assert.That(() => writer.Append(new TestEntry(4)), Throws.InvalidOperationException); + Assert.That(Drain(reader), Is.EqualTo(new[] { 0, 1, 2, 3 })); + } + + [Test] + public void MaximumCapacity_MustBeAtLeastOne() + { + Assert.That(() => new ObservationLog(0), Throws.TypeOf()); + } + + [Test] + public void Buffer_WrapsAroundWhenInterleavingAppendAndRead() + { + ObservationLog log = new ObservationLog(64); + ObservationWriter writer = new ObservationWriter(log); + ObservationReader reader = new ObservationReader(log); + + // Interleaving advances the head past the modulo boundary repeatedly. + List read = new(); + for (int i = 0; i < 100; i++) + { + writer.Append(new TestEntry(i)); + Assert.That(reader.TryRead(out TestEntry entry), Is.True); + read.Add(entry.Value); + } + + Assert.That(read, Is.EqualTo(Enumerable.Range(0, 100))); + } + + [Test] + public void Trimming_ReleasesEntriesOnceEveryReaderHasPassedThem() + { + ObservationLog log = new ObservationLog(32); + ObservationWriter writer = new ObservationWriter(log); + ObservationReader reader = new ObservationReader(log); + + // With a single reader that keeps up, the log never fills no matter how many entries flow through it. + for (int i = 0; i < 32 * 100; i++) + { + writer.Append(new TestEntry(i)); + reader.TryRead(out _); + } + + Assert.Pass(); + } + + [Test] + public void Trimming_KeepsNothingWhenNoReaderExists() + { + ObservationLog log = new ObservationLog(4); + ObservationWriter writer = new ObservationWriter(log); + + // Nothing reads, so nothing is retained and the maximum capacity is never reached. + for (int i = 0; i < 100; i++) + { + writer.Append(new TestEntry(i)); + } + + Assert.That(Drain(new ObservationReader(log)), Is.Empty); + } + + [Test] + public void Append_ThrowsNamingTheReaderThatStoppedDraining() + { + ObservationLog log = new ObservationLog(8); + ObservationWriter writer = new ObservationWriter(log); + ObservationReader keepingUp = new ObservationReader(log, "keeping-up"); + _ = new ObservationReader(log, "presenter"); + + for (int i = 0; i < 8; i++) + { + writer.Append(new TestEntry(i)); + keepingUp.TryRead(out _); + } + + Assert.That(() => writer.Append(new TestEntry(8)), + Throws.InvalidOperationException.With.Message.Contains("presenter") + .And.Message.Contains("8 entries ago") + .And.Message.Not.Contains("keeping-up")); + } + + [Test] + public void Append_NamesEveryReaderTiedAtTheBack() + { + ObservationLog log = new ObservationLog(4); + ObservationWriter writer = new ObservationWriter(log); + _ = new ObservationReader(log, "presenter"); + _ = new ObservationReader(log, "audio"); + + for (int i = 0; i < 4; i++) + { + writer.Append(new TestEntry(i)); + } + + Assert.That(() => writer.Append(new TestEntry(4)), + Throws.InvalidOperationException.With.Message.Contains("presenter") + .And.Message.Contains("audio")); + } + + [Test] + public void Append_SucceedsAgainOnceTheStalledReaderDrains() + { + ObservationLog log = new ObservationLog(4); + ObservationWriter writer = new ObservationWriter(log); + ObservationReader stalled = new ObservationReader(log, "stalled"); + + for (int i = 0; i < 4; i++) + { + writer.Append(new TestEntry(i)); + } + + Assert.That(() => writer.Append(new TestEntry(4)), Throws.InvalidOperationException); + Assert.That(Drain(stalled), Is.EqualTo(new[] { 0, 1, 2, 3 })); + + writer.Append(new TestEntry(5)); + + Assert.That(Drain(stalled), Is.EqualTo(new[] { 5 })); + } + + [Test] + public void DisposingStalledReader_FreesTheLogForTrimming() + { + ObservationLog log = new ObservationLog(8); + ObservationWriter writer = new ObservationWriter(log); + ObservationReader stalled = new ObservationReader(log, "stalled"); + + for (int i = 0; i < 8; i++) + { + writer.Append(new TestEntry(i)); + } + + stalled.Dispose(); + + Assert.That(() => writer.Append(new TestEntry(8)), Throws.Nothing); + } + + [Test] + public void DisposingOneReader_KeepsTheEntriesAnotherStillNeeds() + { + ObservationLog log = new ObservationLog(64); + ObservationWriter writer = new ObservationWriter(log); + ObservationReader leaving = new ObservationReader(log, "leaving"); + ObservationReader staying = new ObservationReader(log, "staying"); + + writer.Append(new TestEntry(1)); + writer.Append(new TestEntry(2)); + leaving.Dispose(); + + Assert.That(Drain(staying), Is.EqualTo(new[] { 1, 2 })); + } + + [Test] + public void DisposingAReaderTwice_IsHarmless() + { + ObservationLog log = new ObservationLog(64); + ObservationReader reader = new ObservationReader(log); + reader.Dispose(); + + Assert.That(() => reader.Dispose(), Throws.Nothing); + } + + [Test] + public void DisposedReader_ThrowsOnRead() + { + ObservationLog log = new ObservationLog(64); + ObservationReader reader = new ObservationReader(log); + reader.Dispose(); + + Assert.That(() => reader.TryRead(out _), Throws.TypeOf()); + } + + [Test] + public void Reader_OnEmptyLogReturnsFalseAndTheDefaultEntry() + { + ObservationLog log = new ObservationLog(64); + ObservationReader reader = new ObservationReader(log); + + Assert.That(reader.TryRead(out TestEntry entry), Is.False); + Assert.That(entry, Is.EqualTo(default(TestEntry))); + } + + [Test] + public void ReferenceEntry_ReadsBackTheAppendedInstancesInOrder() + { + ObservationLog log = new ObservationLog(64); + ObservationWriter writer = new ObservationWriter(log); + ObservationReader reader = new ObservationReader(log); + TestReferenceEntry first = new TestReferenceEntry(1); + TestReferenceEntry second = new TestReferenceEntry(2); + + writer.Append(first); + writer.Append(second); + + Assert.That(reader.TryRead(out TestReferenceEntry? read), Is.True); + Assert.That(read, Is.SameAs(first)); + Assert.That(reader.TryRead(out read), Is.True); + Assert.That(read, Is.SameAs(second)); + } + + [Test] + public void ReferenceEntry_OnEmptyLogReturnsFalseAndNull() + { + ObservationLog log = new ObservationLog(64); + ObservationReader reader = new ObservationReader(log); + + Assert.That(reader.TryRead(out TestReferenceEntry? entry), Is.False); + Assert.That(entry, Is.Null); + } + + [Test] + public void Trimming_ReleasesAReferenceEntryOnceEveryReaderHasPassedIt() + { + ObservationLog log = new ObservationLog(64); + ObservationReader reader = new ObservationReader(log); + + WeakReference reference = AppendAndDrainOne(log, reader); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.That(reference.IsAlive, Is.False); + } + + // The only strong reference lives in this frame, so returning drops it and leaves the log's slot as the + // one thing that could still keep the entry alive. + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference AppendAndDrainOne(ObservationLog log, ObservationReader reader) + { + TestReferenceEntry entry = new TestReferenceEntry(1); + new ObservationWriter(log).Append(entry); + reader.TryRead(out TestReferenceEntry? _); + return new WeakReference(entry); + } + + private static int[] Drain(ObservationReader reader) + { + List values = new(); + while (reader.TryRead(out TestEntry entry)) + { + values.Add(entry.Value); + } + + return values.ToArray(); + } +} diff --git a/tests/Pixely.Observations.Tests/Pixely.Observations.Tests.csproj b/tests/Pixely.Observations.Tests/Pixely.Observations.Tests.csproj new file mode 100644 index 00000000..90e7a459 --- /dev/null +++ b/tests/Pixely.Observations.Tests/Pixely.Observations.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + 14 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/tests/Pixely.Package.Tests/PackageIntegrationTests.cs b/tests/Pixely.Package.Tests/PackageIntegrationTests.cs index 9a4b164a..c784a16d 100644 --- a/tests/Pixely.Package.Tests/PackageIntegrationTests.cs +++ b/tests/Pixely.Package.Tests/PackageIntegrationTests.cs @@ -26,6 +26,7 @@ public class PackageIntegrationTests "Pixely.DependencyInjection", "Pixely.Events", "Pixely.Logging", + "Pixely.Observations", "Pixely.Pencuil", "Pixely.ShaderCommon", "Pixely.Utils",