diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index eb74c39a4..d9219a87f 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -57,7 +57,7 @@ jobs: # blame-hang kills a stuck test host after 10 minutes and reports the in-flight # tests plus a process dump instead of hanging until the job timeout. run: | - dotnet test --logger trx --results-directory ./TestResults --blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full + dotnet test --filter "Category!=Manual" --logger trx --results-directory ./TestResults --blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full if [ -z "$(find ./TestResults -name '*.trx' 2>/dev/null)" ]; then echo "::error::No test result files were produced - no test projects ran. Failing to avoid masking failures." exit 1 @@ -167,7 +167,7 @@ jobs: # blame-hang kills a stuck test host after 10 minutes and reports the in-flight # tests plus a process dump instead of hanging until the job timeout. run: | - dotnet test --logger trx --results-directory ./TestResults --blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full + dotnet test --filter "Category!=Manual" --logger trx --results-directory ./TestResults --blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full if [ -z "$(find ./TestResults -name '*.trx' 2>/dev/null)" ]; then echo "::error::No test result files were produced - no test projects ran. Failing to avoid masking failures." exit 1 diff --git a/Projects/Server.Tests/Helpers/EntityCleanup.cs b/Projects/Server.Tests/Helpers/EntityCleanup.cs new file mode 100644 index 000000000..61917b61e --- /dev/null +++ b/Projects/Server.Tests/Helpers/EntityCleanup.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; + +namespace Server.Tests; + +/// +/// Deletes tracked entities on dispose, so a test that fails mid-way does not leave them in the shared World. +/// Null and already-deleted entities are skipped. +/// +internal sealed class EntityCleanup : IDisposable +{ + private readonly List _entities = []; + + public T Track(T entity) where T : IEntity + { + _entities.Add(entity); + return entity; + } + + public void Dispose() + { + // Reverse order deletes children before the containers or owners that hold them. + for (var i = _entities.Count - 1; i >= 0; i--) + { + if (_entities[i] is { Deleted: false } entity) + { + entity.Delete(); + } + } + + _entities.Clear(); + } +} diff --git a/Projects/Server.Tests/Helpers/MockAccount.cs b/Projects/Server.Tests/Helpers/MockAccount.cs index 8d8973d86..2725d3438 100644 --- a/Projects/Server.Tests/Helpers/MockAccount.cs +++ b/Projects/Server.Tests/Helpers/MockAccount.cs @@ -20,15 +20,27 @@ public class MockAccount : IAccount public string Username { get; } public string Email { get; set; } public AccessLevel AccessLevel { get; set; } - public int Length { get; } + public int Length { get; private set; } public int Limit { get; set; } = 6; // Default to 6 character slots - public int Count { get; } + public int Count { get; private set; } private readonly Dictionary _mobiles = new(); public Mobile this[int index] { get => _mobiles.GetValueOrDefault(index); - set => _mobiles[index] = value; + set + { + var old = _mobiles.GetValueOrDefault(index); + _mobiles[index] = value; + + Length = Math.Max(Length, index + 1); + Count += (value != null ? 1 : 0) - (old != null ? 1 : 0); + + if (value != null) + { + value.Account = this; + } + } } public DateTime Created { get; set; } diff --git a/Projects/Server.Tests/Helpers/NetTestHelpers.cs b/Projects/Server.Tests/Helpers/NetTestHelpers.cs new file mode 100644 index 000000000..3f3cef3fc --- /dev/null +++ b/Projects/Server.Tests/Helpers/NetTestHelpers.cs @@ -0,0 +1,152 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Net.Sockets; +using System.Network; +using System.Runtime.CompilerServices; +using System.Threading; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +/// +/// Shared plumbing for tests that drive a real over loopback. +/// +public static class NetTestHelpers +{ + public const byte PingPacketId = 0x73; + + /// The payload byte of the last ping delivered to the handler from . + public static byte LastPing { get; private set; } + + /// + /// Restores the handler that was in a packet slot before a test replaced it. The handler table is + /// process-global, so a handler left behind would silently answer for that id in every later test. + /// + public readonly struct HandlerRegistration : IDisposable + { + private readonly int _packetId; + private readonly PacketHandler _previous; + + internal HandlerRegistration(int packetId, PacketHandler previous) + { + _packetId = packetId; + _previous = previous; + } + + public void Dispose() => IncomingPackets.Handlers[_packetId] = _previous; + } + + public static unsafe HandlerRegistration RegisterHandler( + int packetId, int length, delegate* onReceive + ) + { + var previous = IncomingPackets.GetHandler(packetId); + IncomingPackets.Register(packetId, length, false, onReceive); + return new HandlerRegistration(packetId, previous); + } + + /// Registers a two-byte ping whose payload byte is recorded in . + public static unsafe HandlerRegistration RegisterPing() + { + LastPing = 0; + return RegisterHandler(PingPacketId, 2, &RecordPing); + } + + private static void RecordPing(NetState state, SpanReader reader) => LastPing = reader.ReadByte(); + + public static byte[] Pattern(int length, int seed) + { + var data = new byte[length]; + new System.Random(seed).NextBytes(data); + return data; + } + + /// + /// Pre-auth buffers are off only where the platform floor leaves no room below the base size + /// (the Windows Server 2012/2016 mapping path, or a 64 KiB-page kernel). Anywhere else a zero + /// initial size is a configuration regression and fails rather than skips. + /// + public static void RequirePreAuthBuffers() + { + var floor = IORingBuffer.MinimumSize; + Skip.If( + floor >= NetState.RecvBufferSize || floor >= NetState.SendBufferSize, + $"Pre-auth buffers are off on this platform: the {floor}-byte mapping floor leaves no room below the base buffers (recv {NetState.RecvBufferSize}, send {NetState.SendBufferSize})" + ); + + Assert.True( + NetState.InitialRecvBufferSize > 0 && NetState.InitialSendBufferSize > 0, + $"Pre-auth buffers are off although the {floor}-byte platform floor allows them (initial recv {NetState.InitialRecvBufferSize}, send {NetState.InitialSendBufferSize})" + ); + } + + /// Runs the network slice until holds, failing with the condition that timed out. + public static void SliceUntil( + Func done, int timeoutMs = 5000, [CallerArgumentExpression(nameof(done))] string condition = null + ) + { + var deadline = Stopwatch.StartNew(); + while (!done()) + { + if (deadline.ElapsedMilliseconds >= timeoutMs) + { + Assert.Fail($"Timed out after {timeoutMs} ms waiting for {condition}"); + } + + NetState.Slice(); + Thread.Sleep(5); + } + } + + /// Reads exactly bytes from the peer, slicing the server while it waits. + public static byte[] ReadAll(Socket client, int length, int timeoutMs = 10000) + { + var received = new byte[length]; + var total = 0; + var deadline = Stopwatch.StartNew(); + while (total < length) + { + if (deadline.ElapsedMilliseconds >= timeoutMs) + { + Assert.Fail($"Timed out after {timeoutMs} ms with {total} of {length} bytes received by the peer"); + } + + NetState.Slice(); + + // Poll takes microseconds, not milliseconds + if (client.Poll(50_000, SelectMode.SelectRead)) + { + var read = client.Receive(received, total, length - total, SocketFlags.None); + Assert.True(read != 0, $"Peer saw EOF after {total} of {length} bytes"); + total += read; + } + } + + return received; + } + + /// Waits for the server's FIN to reach the peer, failing if any data arrives first. + public static void WaitForEof(Socket client, int timeoutMs = 10000) + { + var scratch = new byte[64]; + var deadline = Stopwatch.StartNew(); + while (true) + { + if (deadline.ElapsedMilliseconds >= timeoutMs) + { + Assert.Fail($"Timed out after {timeoutMs} ms waiting for the peer to see EOF"); + } + + NetState.Slice(); + + if (client.Poll(50_000, SelectMode.SelectRead)) + { + var read = client.Receive(scratch); + Assert.True(read == 0, $"Peer received {read} unexpected bytes while waiting for EOF"); + return; + } + } + } +} diff --git a/Projects/Server.Tests/Helpers/PacketAssert.cs b/Projects/Server.Tests/Helpers/PacketAssert.cs new file mode 100644 index 000000000..ca8efb5ee --- /dev/null +++ b/Projects/Server.Tests/Helpers/PacketAssert.cs @@ -0,0 +1,31 @@ +using System; +using Server.Network; + +namespace Server.Tests.Network; + +public static class PacketAssert +{ + /// + /// Asserts that writes exactly the bytes of the reference + /// packet to a fresh test NetState negotiated with . + /// + public static void Sends(Packet expected, Action send, ProtocolChanges changes = ProtocolChanges.None) => + Sends(_ => expected, send, changes); + + /// + /// As , for a reference packet that depends on + /// the negotiated NetState. It is built after is applied and before the send. + /// + public static void Sends( + Func expected, Action send, ProtocolChanges changes = ProtocolChanges.None + ) + { + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.ProtocolChanges = changes; + + var expectedBytes = expected(ns).Compile(); + send(ns); + + AssertThat.Equal(ns.SendBuffer.GetReadSpan(), expectedBytes); + } +} diff --git a/Projects/Server.Tests/Helpers/PersistencePipelineScope.cs b/Projects/Server.Tests/Helpers/PersistencePipelineScope.cs new file mode 100644 index 000000000..dbdb05524 --- /dev/null +++ b/Projects/Server.Tests/Helpers/PersistencePipelineScope.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace Server.Tests; + +/// +/// A private save pipeline: a chunk source with its own serialization workers, optionally installed as +/// World's workers, optionally a scratch directory and an extra assembly visible to the type loader. +/// Dispose unregisters every tracked persistence, exits the workers and restores every static it swapped. +/// +internal sealed class PersistencePipelineScope : IDisposable +{ + private readonly List _persistences = []; + private readonly SerializationThreadWorker[] _previousWorkers; + private readonly Assembly[] _previousAssemblies; + private readonly DateTime _previousSaveStart; + private readonly bool _installedWorkers; + private readonly bool _swappedAssemblies; + + /// Workers to create, each with an allocated heap. + /// Install the workers as World's, which WriteSnapshot reads segments from. + /// An assembly the loader must resolve types from by hash. + /// Creates a unique scratch directory under the temp path when set. + public PersistencePipelineScope( + int workerCount, + bool installWorkers = false, + Assembly visibleAssembly = null, + string directoryPrefix = null + ) + { + _previousSaveStart = World.SaveStartTime; + + Source = new SerializationChunkSource(); + Workers = new SerializationThreadWorker[workerCount]; + for (var i = 0; i < Workers.Length; i++) + { + Workers[i] = new SerializationThreadWorker(i, Source); + Workers[i].AllocateHeap(); + } + + if (installWorkers) + { + _installedWorkers = true; + _previousWorkers = World._threadWorkers; + World._threadWorkers = Workers; + } + + if (visibleAssembly != null) + { + _swappedAssemblies = true; + _previousAssemblies = AssemblyHandler.Assemblies; + AssemblyHandler.Assemblies = [.. _previousAssemblies ?? [], visibleAssembly]; + } + + if (directoryPrefix != null) + { + Directory = Path.Combine(Path.GetTempPath(), $"{directoryPrefix}-{Guid.NewGuid():N}"); + System.IO.Directory.CreateDirectory(Directory); + } + } + + public SerializationChunkSource Source { get; } + + public SerializationThreadWorker[] Workers { get; } + + /// The scratch directory, or null when none was requested. + public string Directory { get; } + + /// Tracks a persistence so it leaves the static registry on dispose. + public T Track(T persistence) where T : Persistence + { + _persistences.Add(persistence); + return persistence; + } + + public void WakeWorkers() + { + for (var i = 0; i < Workers.Length; i++) + { + Workers[i].Wake(); + } + } + + public void SleepWorkers() + { + for (var i = 0; i < Workers.Length; i++) + { + Workers[i].Sleep(); + } + } + + /// Mirrors World.PauseSerializationThreads: publish the partial chunk, then wait for the drain. + public void FlushAndSleepWorkers() + { + Source.Flush(); + SleepWorkers(); + } + + public void Dispose() + { + World.SaveStartTime = _previousSaveStart; + + for (var i = 0; i < _persistences.Count; i++) + { + _persistences[i]?.Unregister(); + } + + for (var i = 0; i < Workers.Length; i++) + { + Workers[i].Exit(); + } + + if (_installedWorkers) + { + World._threadWorkers = _previousWorkers; + } + + if (_swappedAssemblies) + { + AssemblyHandler.Assemblies = _previousAssemblies; + } + + if (Directory != null) + { + System.IO.Directory.Delete(Directory, true); + } + } +} diff --git a/Projects/Server.Tests/Helpers/SimulatedClockScope.cs b/Projects/Server.Tests/Helpers/SimulatedClockScope.cs new file mode 100644 index 000000000..cb4b5acfd --- /dev/null +++ b/Projects/Server.Tests/Helpers/SimulatedClockScope.cs @@ -0,0 +1,42 @@ +using System; +using Server.Items; + +namespace Server.Tests; + +/// +/// Lets a test move the game clock () and restores it on dispose, so a failed +/// assert cannot leave every later test running in the future. +/// +internal sealed class SimulatedClockScope : IDisposable +{ + private static readonly TimeSpan _decayTickStep = TimeSpan.FromMilliseconds(256); + + public SimulatedClockScope() => Start = Core._now; + + public DateTime Start { get; } + + public DateTime Now + { + get => Core._now; + set => Core._now = value; + } + + /// + /// Steps the clock from through , driving the decay + /// scheduler each step without the shared timer wheel, and stops early once + /// is deleted. + /// + public void AdvanceDecay(TimeSpan duration, Item item) + { + var start = Core._now; + var deadline = start + duration; + + for (var now = start; now <= deadline && !item.Deleted; now += _decayTickStep) + { + Core._now = now; + DecayScheduler.ProcessTick(now); + } + } + + public void Dispose() => Core._now = Start; +} diff --git a/Projects/Server.Tests/Helpers/TestClientHarness.cs b/Projects/Server.Tests/Helpers/TestClientHarness.cs new file mode 100644 index 000000000..aa9dde5d0 --- /dev/null +++ b/Projects/Server.Tests/Helpers/TestClientHarness.cs @@ -0,0 +1,82 @@ +using System; +using System.Buffers; +using Server.Network; +using Server.Tests.Network; + +namespace Server.Tests; + +/// +/// A logged-in test client (NetState + Mobile) and byte-pattern probes over what it was sent. +/// The probes search the raw send buffer, so they only see packets queued since the NetState was created. +/// +internal static class TestClientHarness +{ + public static (NetState, Mobile) CreateClient(Point3D location) + { + var ns = PacketTestUtilities.CreateTestNetState(); + ns.Account = new MockAccount(); + + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + ns.Mobile = mobile; + mobile.NetState = ns; + mobile.MoveToWorld(location, Map.Felucca); + return (ns, mobile); + } + + public static void DisposeClient(NetState ns, Mobile mobile) + { + ns.Mobile = null; + ns.Dispose(); + mobile.Delete(); + } + + public static bool ReceivedRemove(NetState ns, Serial serial) => CountRemoves(ns, serial) > 0; + + public static int CountRemoves(NetState ns, Serial serial) + { + Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength]; + OutgoingEntityPackets.CreateRemoveEntity(expected, serial); + + var span = ns.SendBuffer.GetReadSpan(); + var count = 0; + + while (true) + { + var index = span.IndexOf(expected); + + if (index < 0) + { + break; + } + + count++; + span = span[(index + expected.Length)..]; + } + + return count; + } + + public static bool ReceivedWorldItem(NetState ns, Item item) + { + Span expected = stackalloc byte[OutgoingEntityPackets.MaxWorldEntityPacketLength]; + var length = OutgoingItemPackets.CreateWorldItem(expected, item); + return ns.SendBuffer.GetReadSpan().IndexOf(expected[..length]) >= 0; + } + + public static bool ReceivedContentUpdate(NetState ns, Serial serial) => ReceivedPacketForSerial(ns, 0x25, serial); + + public static bool ReceivedEquipUpdate(NetState ns, Serial serial) => ReceivedPacketForSerial(ns, 0x2E, serial); + + public static bool ReceivedOplInfo(NetState ns, Serial serial) => ReceivedPacketForSerial(ns, 0xDC, serial); + + // Each of these packets is fixed-length with the serial immediately after the packet ID. + private static bool ReceivedPacketForSerial(NetState ns, byte packetId, Serial serial) + { + Span expected = stackalloc byte[5]; + var writer = new SpanWriter(expected); + writer.Write(packetId); + writer.Write(serial); + return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; + } +} diff --git a/Projects/Server.Tests/Helpers/TestMulti.cs b/Projects/Server.Tests/Helpers/TestMulti.cs new file mode 100644 index 000000000..b7363c3f7 --- /dev/null +++ b/Projects/Server.Tests/Helpers/TestMulti.cs @@ -0,0 +1,17 @@ +using Server.Items; + +namespace Server.Tests; + +/// +/// A multi with caller-supplied components. The test host never loads MultiData, so a multi built +/// from client data would have no components; without a list this one is empty the same way. +/// +internal sealed class TestMulti : BaseMulti +{ + private readonly MultiComponentList _components; + + public TestMulti(MultiComponentList components = null) : base(0x1) => + _components = components ?? MultiComponentList.Empty; + + public override MultiComponentList Components => _components; +} diff --git a/Projects/Server.Tests/Tests/Client/ClientVersionTests.cs b/Projects/Server.Tests/Tests/Client/ClientVersionTests.cs index 471a5584c..39a73ad7b 100644 --- a/Projects/Server.Tests/Tests/Client/ClientVersionTests.cs +++ b/Projects/Server.Tests/Tests/Client/ClientVersionTests.cs @@ -2,13 +2,13 @@ using Xunit; namespace Server.Tests.Network; -[Collection("Sequential Server Tests")] public class ClientVersionTests { [Theory] [InlineData("7.0.33.1", "67.0.33", false, 0)] [InlineData("7.0.45.65", "67.0.33", false, 1)] [InlineData("6.0.0.0", "67.0.33", false, -1)] + [InlineData("7.0.33.1", "7.0.33.1", true, 0)] public void TestCCAndECStringCtor(string ccVersion, string ecVersion, bool equal, int comparison) { var cc = new ClientVersion(ccVersion); @@ -22,6 +22,7 @@ public class ClientVersionTests [InlineData(7, 0, 33, 1, 67, 0, 33, 0, false, 0)] [InlineData(7, 0, 45, 65, 67, 0, 33, 0, false, 1)] [InlineData(6, 0, 0, 0, 67, 0, 33, 0, false, -1)] + [InlineData(7, 0, 33, 1, 7, 0, 33, 1, true, 0)] public void TestCCAndECCtor( int ccMaj, int ccMin, int ccRev, int ccPatch, int ecMaj, int ecMin, int ecRev, int ecPatch, bool equal, int comparison diff --git a/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs b/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs index 50d015555..cdf084fb7 100644 --- a/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs +++ b/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs @@ -70,10 +70,11 @@ public class ConsoleInputPumpTests cmd => cmd == "ping" ? onPing : null ); - Task.Run(pump.Run); + var run = Task.Run(pump.Run); Assert.True(invokedWith.Task.Wait(Timeout), "command not dispatched"); Assert.Equal("hello", invokedWith.Task.Result); + Assert.True(run.Wait(Timeout), "Run() did not return on EOF"); } [Fact] @@ -81,7 +82,7 @@ public class ConsoleInputPumpTests { var reader = new BlockingTextReader(); var pump = new ConsoleInputPump(reader, _ => null); - Task.Run(pump.Run); + var run = Task.Run(pump.Run); // Register the prompt, then wait until it is actually pending before feeding a // line — otherwise the line could be consumed as a command before registration. @@ -93,6 +94,7 @@ public class ConsoleInputPumpTests Assert.Equal("the answer", prompt.Result); reader.Complete(); + Assert.True(run.Wait(Timeout), "Run() did not return on EOF"); } [Fact] @@ -100,7 +102,7 @@ public class ConsoleInputPumpTests { var reader = new BlockingTextReader(); var pump = new ConsoleInputPump(reader, _ => null); - Task.Run(pump.Run); + var run = Task.Run(pump.Run); var prompt = Task.Run(pump.ReadLine); WaitUntil(() => pump.HasPendingPrompt, "prompt did not register"); @@ -108,6 +110,7 @@ public class ConsoleInputPumpTests Assert.True(prompt.Wait(Timeout), "prompt hung on EOF"); Assert.Null(prompt.Result); + Assert.True(run.Wait(Timeout), "Run() did not return on EOF"); Assert.False(pump.Running); } @@ -116,7 +119,7 @@ public class ConsoleInputPumpTests { var reader = new BlockingTextReader(); var pump = new ConsoleInputPump(reader, _ => throw new InvalidOperationException("boom")); - Task.Run(pump.Run); + var run = Task.Run(pump.Run); // Wait until the reader loop is blocked on its first read, then feed a bad command. WaitUntil(() => reader.ReadCallCount >= 1, "reader did not start"); @@ -133,6 +136,7 @@ public class ConsoleInputPumpTests Assert.True(prompt.Wait(Timeout), "prompt hung after throwing lookup"); Assert.Null(prompt.Result); + Assert.True(run.Wait(Timeout), "Run() did not return on EOF"); Assert.False(pump.Running); } } diff --git a/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs b/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs index 7fae1f3bf..cdad2512b 100644 --- a/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs @@ -68,7 +68,7 @@ public class Point3DTests Assert.Equal(new Point3D(101, 23, 55), Point3D.Parse(" (101,23 ,55) ", null)); // No parenthesis - Assert.False(Point3D.TryParse("101, 23 55)", null, out p)); + Assert.False(Point3D.TryParse("101, 23, 55)", null, out p)); Assert.Equal(default, p); Assert.Throws(() => Point3D.Parse("101, 23, 55)", null)); diff --git a/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs b/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs index d225b625f..9f1ffdb20 100644 --- a/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs @@ -104,7 +104,7 @@ public class Rectangle3DTests Assert.False(Rectangle3D.TryParse("(101,,10)+(55, 89 1)", null, out p)); Assert.Equal(default, p); - Assert.Throws(() => Rectangle3D.Parse("(,23,)+(,89)", null)); + Assert.Throws(() => Rectangle3D.Parse("(101,,10)+(55, 89 1)", null)); Assert.False(Rectangle3D.TryParse("(,,)+(55,89, 1)", null, out p)); Assert.Equal(default, p); diff --git a/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs b/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs index 561ece1f9..a913eb9a9 100644 --- a/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs @@ -90,16 +90,16 @@ public sealed class WorldLocationTests Assert.Equal(new WorldLocation(101, 23, 55, null), WorldLocation.Parse(" (101,23 ,55) [(-null-)]", null)); // No Brackets - Assert.False(WorldLocation.TryParse("(101, 23 55) Felucca]", null, out p)); + Assert.False(WorldLocation.TryParse("(101, 23, 55) Felucca]", null, out p)); Assert.Equal(default, p); Assert.Throws(() => WorldLocation.Parse("(101, 23, 55) Felucca]", null)); - Assert.False(WorldLocation.TryParse("(101, 23 55) [Felucca", null, out p)); + Assert.False(WorldLocation.TryParse("(101, 23, 55) [Felucca", null, out p)); Assert.Equal(default, p); Assert.Throws(() => WorldLocation.Parse("(101, 23, 55) [Felucca", null)); // No parenthesis - Assert.False(WorldLocation.TryParse("101, 23 55) [Felucca]", null, out p)); + Assert.False(WorldLocation.TryParse("101, 23, 55) [Felucca]", null, out p)); Assert.Equal(default, p); Assert.Throws(() => WorldLocation.Parse("101, 23, 55) [Felucca]", null)); diff --git a/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs b/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs index c529f8a12..33740ec79 100644 --- a/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs +++ b/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs @@ -6,32 +6,43 @@ namespace Server.Tests; [Collection("Sequential Server Tests")] public class ContainerBulkRemovalTests { + // Large enough that a quadratic child removal (a front-to-back IndexOf per child) costs + // roughly 2 * 10^8 comparisons and shows up in the test's run time. + private const int ChildCount = 20_000; + [Fact] public void DeletingLargeContainer_DeletesAllChildren_AndTotalsStayConsistent() { var outer = new Container(0xE75); - var pack = new Container(0xE75); - outer.DropItem(pack); - var children = new Item[100_000]; - for (var i = 0; i < children.Length; i++) + try { - children[i] = new Item(0x1234); - pack.DropItem(children[i]); + var pack = new Container(0xE75); + outer.DropItem(pack); + + var children = new Item[ChildCount]; + for (var i = 0; i < children.Length; i++) + { + children[i] = new Item(0x1234); + pack.DropItem(children[i]); + } + + Assert.Equal(children.Length + 1, outer.TotalItems); + + pack.Delete(); + + for (var i = 0; i < children.Length; i++) + { + Assert.True(children[i].Deleted); + } + + Assert.Empty(outer.Items); + Assert.Equal(0, outer.TotalItems); } - - Assert.Equal(children.Length + 1, outer.TotalItems); - - pack.Delete(); - - for (var i = 0; i < children.Length; i++) + finally { - Assert.True(children[i].Deleted); + outer.Delete(); } - - Assert.Empty(outer.Items); - Assert.Equal(0, outer.TotalItems); - outer.Delete(); } // The tail fast path must not change which entry is removed when the item is elsewhere. @@ -39,20 +50,27 @@ public class ContainerBulkRemovalTests public void RemovingFromFrontMiddleAndEnd_RemovesExactlyThatItem() { var pack = new Container(0xE75); - var items = new Item[5]; - for (var i = 0; i < items.Length; i++) + + try { - items[i] = new Item(0x1234); - pack.DropItem(items[i]); + var items = new Item[5]; + for (var i = 0; i < items.Length; i++) + { + items[i] = new Item(0x1234); + pack.DropItem(items[i]); + } + + items[0].Delete(); + items[2].Delete(); + items[4].Delete(); + + Assert.Equal(2, pack.Items.Count); + Assert.Same(items[1], pack.Items[0]); + Assert.Same(items[3], pack.Items[1]); + } + finally + { + pack.Delete(); } - - items[0].Delete(); - items[2].Delete(); - items[4].Delete(); - - Assert.Equal(2, pack.Items.Count); - Assert.Same(items[1], pack.Items[0]); - Assert.Same(items[3], pack.Items[1]); - pack.Delete(); } } diff --git a/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs b/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs index 695838185..587753250 100644 --- a/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs +++ b/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs @@ -1,8 +1,7 @@ -using System; using Server.Items; using Server.Network; -using Server.Tests.Network; using Xunit; +using static Server.Tests.TestClientHarness; namespace Server.Tests; @@ -44,19 +43,6 @@ public class ContainerChildRemovalTests private static readonly Point3D _ownerLoc = new(1500, 1500, 0); - private static (NetState, Mobile) CreateClient(Point3D location) - { - var ns = PacketTestUtilities.CreateTestNetState(); - ns.Account = new MockAccount(); - - var mobile = new Mobile(World.NewMobile); - mobile.DefaultMobileInit(); - ns.Mobile = mobile; - mobile.NetState = ns; - mobile.MoveToWorld(location, Map.Felucca); - return (ns, mobile); - } - private static Mobile CreateOwnerWith(Container pack) { var owner = new Mobile(World.NewMobile); @@ -68,44 +54,6 @@ public class ContainerChildRemovalTests return owner; } - private static bool ReceivedRemove(NetState ns, Serial serial) - { - Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength]; - OutgoingEntityPackets.CreateRemoveEntity(expected, serial); - return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; - } - - private static int CountRemoves(NetState ns, Serial serial) - { - Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength]; - OutgoingEntityPackets.CreateRemoveEntity(expected, serial); - - var span = ns.SendBuffer.GetReadSpan(); - var count = 0; - - while (true) - { - var index = span.IndexOf(expected); - - if (index < 0) - { - break; - } - - count++; - span = span[(index + expected.Length)..]; - } - - return count; - } - - private static void DisposeClient(NetState ns, Mobile mobile) - { - ns.Mobile = null; - ns.Dispose(); - mobile.Delete(); - } - private static void Cleanup(NetState ns, Mobile client, Mobile owner) { DisposeClient(ns, client); diff --git a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs index 0f97eaa28..85ffd3c84 100644 --- a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs +++ b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs @@ -5,11 +5,15 @@ using Xunit; namespace Server.Tests; [Collection("Sequential Server Tests")] -public class DecayRegistrationTests +public class DecayRegistrationTests : IDisposable { + private readonly EntityCleanup _cleanup = new(); + // The wheel is static and these tests move the clock, so start each from a known schedule. public DecayRegistrationTests() => DecayScheduler.ResetForTests(); + public void Dispose() => _cleanup.Dispose(); + // Stands in for a region that refuses decay, e.g. HouseRegion over a locked down item. private class RefusesDecayItem : Item { @@ -20,61 +24,34 @@ public class DecayRegistrationTests public override bool OnDecay() => false; } - // The item is already dequeued, so a refusal that does not reschedule strands it forever. - // Drives the scheduler and the game clock without depending on the shared timer wheel. - private static void AdvanceDecay(DateTime start, TimeSpan duration, Item item) - { - var deadline = start + duration; - - for (var now = start; now <= deadline && !item.Deleted; now += TimeSpan.FromMilliseconds(256)) - { - Core._now = now; - DecayScheduler.ProcessTick(now); - } - } - [Fact] public void ItemOnGround_ActuallyDecaysAfterDecayTime() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); - Assert.True(DecayScheduler.IsRegistered(item)); + Assert.True(DecayScheduler.IsRegistered(item)); - AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item); + clock.AdvanceDecay(item.DecayTime + TimeSpan.FromMinutes(2), item); - Assert.True(item.Deleted, "Item on the ground must decay once DecayTime elapses."); - } - finally - { - Core._now = start; - } + Assert.True(item.Deleted, "Item on the ground must decay once DecayTime elapses."); } [Fact] public void ItemOnGround_DoesNotDecayBeforeDecayTime() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(102, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(102, 100, 0), Map.Felucca); - AdvanceDecay(start, item.DecayTime - TimeSpan.FromMinutes(2), item); + clock.AdvanceDecay(item.DecayTime - TimeSpan.FromMinutes(2), item); - Assert.False(item.Deleted, "Item must not decay before DecayTime elapses."); + Assert.False(item.Deleted, "Item must not decay before DecayTime elapses."); - item.Delete(); - } - finally - { - Core._now = start; - } + item.Delete(); } // The real client path: Mobile.Lift internalizes, then Mobile.Drop calls MoveToWorld. @@ -84,7 +61,7 @@ public class DecayRegistrationTests [Fact] public void DecaySlot_AgreesWithStructures_AcrossLifecycle() { - var item = new Item(0x1234); + var item = _cleanup.Track(new Item(0x1234)); item.MoveToWorld(new Point3D(103, 100, 0), Map.Felucca); Assert.NotEqual(DecayScheduler.SlotNone, item.DecaySlot); @@ -108,10 +85,10 @@ public class DecayRegistrationTests [Fact] public void ContainerDestroy_ContentsDroppedToGroundAreTracked() { - var pack = new Container(0xE75); + var pack = _cleanup.Track(new Container(0xE75)); pack.MoveToWorld(new Point3D(105, 100, 0), Map.Felucca); - var item = new Item(0x1234); + var item = _cleanup.Track(new Item(0x1234)); pack.AddItem(item); Assert.False(DecayScheduler.IsRegistered(item)); @@ -128,163 +105,127 @@ public class DecayRegistrationTests [Fact] public void StaleImmovableItemMadeMovable_GetsAFreshDecayWindow() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(107, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(107, 100, 0), Map.Felucca); - item.Movable = false; - Assert.False(DecayScheduler.IsRegistered(item), "A frozen item must not be tracked for decay."); + item.Movable = false; + Assert.False(DecayScheduler.IsRegistered(item), "A frozen item must not be tracked for decay."); - Core._now = start + TimeSpan.FromDays(30); - var flipped = Core._now; + clock.Now = clock.Start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = true; - Assert.True(DecayScheduler.IsRegistered(item), "An unfrozen item must be tracked for decay."); + Assert.True(DecayScheduler.IsRegistered(item), "An unfrozen item must be tracked for decay."); - AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); - Assert.False(item.Deleted, "An unfrozen item must get a full decay window, not vanish immediately."); + clock.AdvanceDecay(item.DecayTime - TimeSpan.FromMinutes(2), item); + Assert.False(item.Deleted, "An unfrozen item must get a full decay window, not vanish immediately."); - AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); - Assert.True(item.Deleted, "An unfrozen item must still decay once the fresh window elapses."); - } - finally - { - Core._now = start; - } + clock.AdvanceDecay(TimeSpan.FromMinutes(4), item); + Assert.True(item.Deleted, "An unfrozen item must still decay once the fresh window elapses."); } // Same transition through the Visible setter: unhiding a long-hidden item. [Fact] public void StaleHiddenItemMadeVisible_GetsAFreshDecayWindow() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(109, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(109, 100, 0), Map.Felucca); - item.Visible = false; - Assert.False(DecayScheduler.IsRegistered(item), "A hidden item must not be tracked for decay."); + item.Visible = false; + Assert.False(DecayScheduler.IsRegistered(item), "A hidden item must not be tracked for decay."); - Core._now = start + TimeSpan.FromDays(30); - var flipped = Core._now; + clock.Now = clock.Start + TimeSpan.FromDays(30); - item.Visible = true; + item.Visible = true; - Assert.True(DecayScheduler.IsRegistered(item), "An unhidden item must be tracked for decay."); + Assert.True(DecayScheduler.IsRegistered(item), "An unhidden item must be tracked for decay."); - AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); - Assert.False(item.Deleted, "An unhidden item must get a full decay window, not vanish immediately."); + clock.AdvanceDecay(item.DecayTime - TimeSpan.FromMinutes(2), item); + Assert.False(item.Deleted, "An unhidden item must get a full decay window, not vanish immediately."); - AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); - Assert.True(item.Deleted, "An unhidden item must still decay once the fresh window elapses."); - } - finally - { - Core._now = start; - } + clock.AdvanceDecay(TimeSpan.FromMinutes(4), item); + Assert.True(item.Deleted, "An unhidden item must still decay once the fresh window elapses."); } - // A refusal restarts the countdown without rewriting LastMoved. + // A refusal restarts the countdown without rewriting LastMoved. The item is already dequeued + // when OnDecay refuses, so a refusal that does not reschedule strands it forever. [Fact] public void RefusedDecay_DoesNotRewriteLastMoved() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new RefusesDecayItem(); - item.MoveToWorld(new Point3D(110, 100, 0), Map.Felucca); - var lastMoved = item.LastMoved; + var item = _cleanup.Track(new RefusesDecayItem()); + item.MoveToWorld(new Point3D(110, 100, 0), Map.Felucca); + var lastMoved = item.LastMoved; - AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item); + clock.AdvanceDecay(item.DecayTime + TimeSpan.FromMinutes(2), item); - Assert.False(item.Deleted, "A refused decay must not delete the item."); - Assert.True(DecayScheduler.IsRegistered(item), "A refused decay must leave the item tracked."); - Assert.Equal(lastMoved, item.LastMoved); + Assert.False(item.Deleted, "A refused decay must not delete the item."); + Assert.True(DecayScheduler.IsRegistered(item), "A refused decay must leave the item tracked."); + Assert.Equal(lastMoved, item.LastMoved); - item.Delete(); - } - finally - { - Core._now = start; - } + item.Delete(); } // The fresh window must survive a save/load cycle, or a restart mid-window deletes the item. [Fact] public void FreshDecayWindow_SurvivesSerialization() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(111, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(111, 100, 0), Map.Felucca); - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = false; + clock.Now = clock.Start + TimeSpan.FromDays(30); + item.Movable = true; - var expected = item.ScheduledDecayTime; + var expected = item.ScheduledDecayTime; - var writer = new BufferWriter(new byte[512], true); - item.Serialize(writer); + var writer = new BufferWriter(new byte[512], true); + item.Serialize(writer); - var copy = new Item(item.Serial); - copy.Deserialize(new BufferReader(writer.Buffer)); + var copy = new Item(item.Serial); + copy.Deserialize(new BufferReader(writer.Buffer)); - // The stamp is stored as a delta, so it ages only by the real time between - // write and read - milliseconds here, the downtime in production. - Assert.True( - (copy.ScheduledDecayTime - expected).Duration() <= TimeSpan.FromSeconds(5), - "The restarted decay window must survive a save/load cycle." - ); + // The stamp is stored as a delta, so it ages only by the real time between + // write and read - milliseconds here, the downtime in production. + Assert.True( + (copy.ScheduledDecayTime - expected).Duration() <= TimeSpan.FromSeconds(5), + "The restarted decay window must survive a save/load cycle." + ); - item.Delete(); - copy.Delete(); - } - finally - { - Core._now = start; - } + item.Delete(); + copy.Delete(); } // A real move supersedes the reset stamp; it must be dropped so the CompactInfo can collapse. [Fact] public void MovingAnItem_ClearsASupersededDecayResetStamp() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(112, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(112, 100, 0), Map.Felucca); - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = false; + clock.Now = clock.Start + TimeSpan.FromDays(30); + item.Movable = true; - Assert.NotEqual(default, item.DecayResetTime); + Assert.NotEqual(default, item.DecayResetTime); - Core._now += TimeSpan.FromMinutes(1); - item.MoveToWorld(new Point3D(113, 100, 0), Map.Felucca); + clock.Now += TimeSpan.FromMinutes(1); + item.MoveToWorld(new Point3D(113, 100, 0), Map.Felucca); - Assert.Equal(default, item.DecayResetTime); - Assert.Equal(item.LastMoved + item.DecayTime, item.ScheduledDecayTime); - Assert.True(DecayScheduler.IsRegistered(item)); + Assert.Equal(default, item.DecayResetTime); + Assert.Equal(item.LastMoved + item.DecayTime, item.ScheduledDecayTime); + Assert.True(DecayScheduler.IsRegistered(item)); - item.Delete(); - } - finally - { - Core._now = start; - } + item.Delete(); } // Losing decay eligibility makes the stamp meaningless; it must be dropped so the @@ -292,29 +233,22 @@ public class DecayRegistrationTests [Fact] public void ItemBecomingIneligible_DropsTheDecayResetStamp() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(115, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(115, 100, 0), Map.Felucca); - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = false; + clock.Now = clock.Start + TimeSpan.FromDays(30); + item.Movable = true; - Assert.NotEqual(default, item.DecayResetTime); + Assert.NotEqual(default, item.DecayResetTime); - item.Movable = false; + item.Movable = false; - Assert.Equal(default, item.DecayResetTime); + Assert.Equal(default, item.DecayResetTime); - item.Delete(); - } - finally - { - Core._now = start; - } + item.Delete(); } // Moving a stamped item into a container programmatically (no drop, no SetLastMoved) @@ -322,32 +256,25 @@ public class DecayRegistrationTests [Fact] public void StampedItemAddedToContainer_DropsTheDecayResetStamp() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var pack = new Container(0xE75); - pack.MoveToWorld(new Point3D(116, 100, 0), Map.Felucca); + var pack = _cleanup.Track(new Container(0xE75)); + pack.MoveToWorld(new Point3D(116, 100, 0), Map.Felucca); - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(117, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(117, 100, 0), Map.Felucca); - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = false; + clock.Now = clock.Start + TimeSpan.FromDays(30); + item.Movable = true; - Assert.NotEqual(default, item.DecayResetTime); + Assert.NotEqual(default, item.DecayResetTime); - pack.AddItem(item); + pack.AddItem(item); - Assert.Equal(default, item.DecayResetTime); + Assert.Equal(default, item.DecayResetTime); - pack.Delete(); - } - finally - { - Core._now = start; - } + pack.Delete(); } // A raw Map assignment (e.g. a GM changing Map through props) is a move: it must @@ -355,7 +282,7 @@ public class DecayRegistrationTests [Fact] public void ItemMovedToRealMapViaMapSetter_IsRegisteredForDecay() { - var item = new Item(0x1234); + var item = _cleanup.Track(new Item(0x1234)); Assert.False(DecayScheduler.IsRegistered(item)); item.Map = Map.Felucca; @@ -371,10 +298,10 @@ public class DecayRegistrationTests [Fact] public void PartialLiftOfGroundStack_LeavesRemainderRegisteredForDecay() { - var stack = new Item(0x1234) { Stackable = true, Amount = 10 }; + var stack = _cleanup.Track(new Item(0x1234) { Stackable = true, Amount = 10 }); stack.MoveToWorld(new Point3D(114, 100, 0), Map.Felucca); - var remainder = Mobile.LiftItemDupe(stack, 3); + var remainder = _cleanup.Track(Mobile.LiftItemDupe(stack, 3)); Assert.NotNull(remainder); Assert.Equal(7, remainder.Amount); @@ -388,6 +315,4 @@ public class DecayRegistrationTests stack.Delete(); remainder.Delete(); } - - // Dropping into a container must untrack; taking it back out to the ground must re-track. } diff --git a/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs b/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs index 97cffbba9..03b372e14 100644 --- a/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs +++ b/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs @@ -1,9 +1,7 @@ -using System; -using System.Buffers; using Server.Items; using Server.Network; -using Server.Tests.Network; using Xunit; +using static Server.Tests.TestClientHarness; namespace Server.Tests; @@ -12,51 +10,6 @@ public class LiftRejectResyncTests { private static readonly Point3D _baseLoc = new(2200, 2200, 0); - private static (NetState, Mobile) CreateClient(Point3D location) - { - var ns = PacketTestUtilities.CreateTestNetState(); - ns.Account = new MockAccount(); - - var mobile = new Mobile(World.NewMobile); - mobile.DefaultMobileInit(); - ns.Mobile = mobile; - mobile.NetState = ns; - mobile.MoveToWorld(location, Map.Felucca); - return (ns, mobile); - } - - private static void DisposeClient(NetState ns, Mobile mobile) - { - ns.Mobile = null; - ns.Dispose(); - mobile.Delete(); - } - - private static bool ReceivedContentUpdate(NetState ns, Serial serial) - { - Span expected = stackalloc byte[5]; - var writer = new SpanWriter(expected); - writer.Write((byte)0x25); // ContainerContentUpdate packet ID - writer.Write(serial); - return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; - } - - private static bool ReceivedWorldItem(NetState ns, Item item) - { - Span expected = stackalloc byte[OutgoingEntityPackets.MaxWorldEntityPacketLength]; - var length = OutgoingItemPackets.CreateWorldItem(expected, item); - return ns.SendBuffer.GetReadSpan().IndexOf(expected[..length]) >= 0; - } - - private static bool ReceivedEquipUpdate(NetState ns, Serial serial) - { - Span expected = stackalloc byte[5]; - var writer = new SpanWriter(expected); - writer.Write((byte)0x2E); // EquipUpdate packet ID - writer.Write(serial); - return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; - } - [Fact] public void PrivateNestedItem_RejectedLift_DoesNotResyncBystander() { diff --git a/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs b/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs index b8b4fd419..e7d790b7b 100644 --- a/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs +++ b/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs @@ -5,8 +5,10 @@ using Xunit; namespace Server.Tests; [Collection("Sequential Server Tests")] -public class LockedDownContainerDecayTests +public class LockedDownContainerDecayTests : IDisposable { + private readonly EntityCleanup _cleanup = new(); + public LockedDownContainerDecayTests() { DecayScheduler.ResetForTests(); @@ -19,6 +21,8 @@ public class LockedDownContainerDecayTests } } + public void Dispose() => _cleanup.Dispose(); + private class TestChest : Container { public TestChest() : base(0xE43) @@ -35,11 +39,11 @@ public class LockedDownContainerDecayTests public override bool ContentsDecay => false; } - private static Item NewLoot() => new Item(0x1F03) { Movable = true, Visible = true }; + private Item NewLoot() => _cleanup.Track(new Item(0x1F03) { Movable = true, Visible = true }); - private static TestChest PlaceLockedDownChest(int x) + private TestChest PlaceLockedDownChest(int x) { - var chest = new TestChest { Movable = false }; + var chest = _cleanup.Track(new TestChest { Movable = false }); chest.MoveToWorld(new Point3D(x, 100, 0), Map.Felucca); chest.IsLockedDown = true; return chest; @@ -62,7 +66,7 @@ public class LockedDownContainerDecayTests [Fact] public void ItemInOrdinaryContainer_IsNotTracked() { - var chest = new TestChest(); + var chest = _cleanup.Track(new TestChest()); chest.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca); var loot = NewLoot(); @@ -77,7 +81,7 @@ public class LockedDownContainerDecayTests [Fact] public void LockingAndReleasingContainer_RegistersAndUnregistersContents() { - var chest = new TestChest { Movable = false }; + var chest = _cleanup.Track(new TestChest { Movable = false }); chest.MoveToWorld(new Point3D(122, 100, 0), Map.Felucca); var loot = NewLoot(); chest.AddItem(loot); @@ -102,7 +106,7 @@ public class LockedDownContainerDecayTests [Fact] public void ContainerThatKeepsContents_DoesNotTrackThem() { - var board = new KeepsContentsChest { Movable = false }; + var board = _cleanup.Track(new KeepsContentsChest { Movable = false }); board.MoveToWorld(new Point3D(123, 100, 0), Map.Felucca); board.IsLockedDown = true; var piece = NewLoot(); @@ -118,7 +122,7 @@ public class LockedDownContainerDecayTests public void NestedContainerContents_AreNotTracked() { var chest = PlaceLockedDownChest(124); - var bag = new TestChest(); + var bag = _cleanup.Track(new TestChest()); chest.AddItem(bag); var loot = NewLoot(); bag.AddItem(loot); @@ -145,29 +149,17 @@ public class LockedDownContainerDecayTests [Fact] public void TrackedContents_DecayOnSchedule() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var chest = PlaceLockedDownChest(126); - var loot = NewLoot(); - chest.AddItem(loot); + var chest = PlaceLockedDownChest(126); + var loot = NewLoot(); + chest.AddItem(loot); - var deadline = start + loot.DecayTime + TimeSpan.FromMinutes(2); - for (var now = start; now <= deadline && !loot.Deleted; now += TimeSpan.FromMilliseconds(256)) - { - Core._now = now; - DecayScheduler.ProcessTick(now); - } + clock.AdvanceDecay(loot.DecayTime + TimeSpan.FromMinutes(2), loot); - Assert.True(loot.Deleted, "Contents of a locked-down container must decay."); - Assert.False(chest.Deleted, "The locked-down container itself must not decay."); + Assert.True(loot.Deleted, "Contents of a locked-down container must decay."); + Assert.False(chest.Deleted, "The locked-down container itself must not decay."); - chest.Delete(); - } - finally - { - Core._now = start; - } + chest.Delete(); } } diff --git a/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs b/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs index 4481b08b2..97b67613f 100644 --- a/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs +++ b/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs @@ -1,15 +1,24 @@ using System; -using System.Buffers; using Server.Items; using Server.Network; -using Server.Tests.Network; using Xunit; +using static Server.Tests.TestClientHarness; namespace Server.Tests; [Collection("Sequential Server Tests")] -public class PrivateChildPropertiesTests +public class PrivateChildPropertiesTests : IDisposable { + private readonly bool _oplWasEnabled; + + public PrivateChildPropertiesTests() + { + _oplWasEnabled = ObjectPropertyList.Enabled; + ObjectPropertyList.Enabled = true; + } + + public void Dispose() => ObjectPropertyList.Enabled = _oplWasEnabled; + private class PublicContainer : Container { public PublicContainer() : base(0xE75) @@ -21,35 +30,6 @@ public class PrivateChildPropertiesTests private static readonly Point3D _ownerLoc = new(1600, 1600, 0); - private static (NetState, Mobile) CreateClient(Point3D location) - { - var ns = PacketTestUtilities.CreateTestNetState(); - ns.Account = new MockAccount(); - - var mobile = new Mobile(World.NewMobile); - mobile.DefaultMobileInit(); - ns.Mobile = mobile; - mobile.NetState = ns; - mobile.MoveToWorld(location, Map.Felucca); - return (ns, mobile); - } - - private static void DisposeClient(NetState ns, Mobile mobile) - { - ns.Mobile = null; - ns.Dispose(); - mobile.Delete(); - } - - private static bool ReceivedOplInfo(NetState ns, Serial serial) - { - Span expected = stackalloc byte[5]; - var writer = new SpanWriter(expected); - writer.Write((byte)0xDC); // OPL info packet ID - writer.Write(serial); - return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; - } - // AddItem/MoveToWorld already queue their own Update (+Properties) delta as a side effect of the // Map setter. Draining it first isolates the properties-only delta the test actually exercises; // otherwise the leftover Update flag routes through the Update branch and the test would pass or @@ -68,9 +48,6 @@ public class PrivateChildPropertiesTests [Fact] public void PrivateChildInOwnerBackpack_PropertiesOnlyUpdate_ReachesOwnerNotBystander() { - var wasEnabled = ObjectPropertyList.Enabled; - ObjectPropertyList.Enabled = true; - var (ownerNs, owner) = CreateClient(_ownerLoc); var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); @@ -93,16 +70,12 @@ public class PrivateChildPropertiesTests child.Delete(); DisposeClient(ownerNs, owner); DisposeClient(bystanderNs, bystander); - ObjectPropertyList.Enabled = wasEnabled; } } [Fact] public void PrivateGroundContainer_PropertiesOnlyUpdate_ReachesOpenerNotNonOpener() { - var wasEnabled = ObjectPropertyList.Enabled; - ObjectPropertyList.Enabled = true; - var (openerNs, opener) = CreateClient(_ownerLoc); var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 2, _ownerLoc.Y, 0)); @@ -126,16 +99,12 @@ public class PrivateChildPropertiesTests container.Delete(); DisposeClient(openerNs, opener); DisposeClient(bystanderNs, bystander); - ObjectPropertyList.Enabled = wasEnabled; } } [Fact] public void PublicContainerChild_PropertiesOnlyUpdate_StillReachesBystander() { - var wasEnabled = ObjectPropertyList.Enabled; - ObjectPropertyList.Enabled = true; - var (bystanderNs, bystander) = CreateClient(_ownerLoc); var pack = new PublicContainer(); @@ -155,16 +124,12 @@ public class PrivateChildPropertiesTests { pack.Delete(); DisposeClient(bystanderNs, bystander); - ObjectPropertyList.Enabled = wasEnabled; } } [Fact] public void GroundItem_PropertiesOnlyUpdate_StillReachesBystander() { - var wasEnabled = ObjectPropertyList.Enabled; - ObjectPropertyList.Enabled = true; - var (bystanderNs, bystander) = CreateClient(_ownerLoc); var item = new Item(0x1234); @@ -181,7 +146,6 @@ public class PrivateChildPropertiesTests { item.Delete(); DisposeClient(bystanderNs, bystander); - ObjectPropertyList.Enabled = wasEnabled; } } } diff --git a/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs b/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs index a240a0576..c4c48b41b 100644 --- a/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs +++ b/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs @@ -1,18 +1,28 @@ +using System; using Xunit; namespace Server.Tests; [Collection("Sequential Server Tests")] -public class LocalizationEntryTests +public class LocalizationEntryTests : IDisposable { + private const int FormatNumber = 500002; + private const int ParameterNumber = 500003; + + public void Dispose() + { + Localization.Remove("enu", FormatNumber); + Localization.Remove("enu", ParameterNumber); + } + [Fact] public void TestClilocAsParameter() { - Localization.Add("enu", 500002, "This tests ~1_NUMBER~ as parameters."); - Localization.Add("enu", 500003, "clilocs"); + Localization.Add("enu", FormatNumber, "This tests ~1_NUMBER~ as parameters."); + Localization.Add("enu", ParameterNumber, "clilocs"); - string numericFormatter = Localization.Format(500002, "enu", $"{500003:#}"); - string stringParam = Localization.Format(500002, "enu", $"{"#500003"}"); + string numericFormatter = Localization.Format(FormatNumber, "enu", $"{ParameterNumber:#}"); + string stringParam = Localization.Format(FormatNumber, "enu", $"{"#500003"}"); Assert.Equal("This tests clilocs as parameters.", numericFormatter); Assert.Equal("This tests clilocs as parameters.", stringParam); diff --git a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs index 684c77743..5531e0530 100644 --- a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs @@ -33,6 +33,8 @@ public class CanFitItemTests Assert.False(map.CanFitItem(100, map.Height + 1, 0, 1)); } + // Plain walkable land in the real Felucca map. Without map files every cell is the default land + // tile at Z 0 with no flags, which these tests treat the same way: a surface with nothing above it. private const int TestLandX = 1500; private const int TestLandY = 1600; @@ -170,23 +172,19 @@ public class CanFitItemTests Item movableItem = null; try { - // Create a movable surface item + // Raised well clear of the land so the movable item's top is the only candidate surface there + var itemZ = map.GetAverageZ(x, y) + 20; + movableItem = new Item(ServerFixture.SurfaceTileId) { Movable = true }; - movableItem.MoveToWorld(new Point3D(x, y, 10), map); + movableItem.MoveToWorld(new Point3D(x, y, itemZ), map); - var itemTop = 10 + movableItem.ItemData.CalcHeight; - // Try placing at item top - should fail unless there's land there too - var avgZ = map.GetAverageZ(x, y); + var itemTop = itemZ + movableItem.ItemData.CalcHeight; + var result = map.CanFitItem(x, y, itemTop, 1); - // If avgZ != itemTop, there's no other surface, so it should fail - if (avgZ != itemTop) - { - var result = map.CanFitItem(x, y, itemTop, 1); - Assert.False(result, "Movable item should not count as valid surface"); - } + Assert.False(result, "Movable item should not count as valid surface"); } finally { @@ -248,16 +246,4 @@ public class CanFitItemTests multi.MoveToWorld(location, map); return multi; } - - private class TestMulti : BaseMulti - { - private readonly MultiComponentList _components; - - public TestMulti(MultiComponentList components) : base(0x1) - { - _components = components ?? MultiComponentList.Empty; - } - - public override MultiComponentList Components => _components; - } } diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs index c90cce9ea..23bc9d173 100644 --- a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs @@ -29,7 +29,8 @@ public class CanSpawnMobileTests Assert.False(map.CanSpawnMobile(100, map.Height + 1, -128, 127, false, false, out _)); } - // Use coordinates near Britain which should be walkable land in both test and real data + // Plain walkable land near Britain in the real Felucca map. Without map files every cell is the + // default land tile at Z 0 with no flags, which CanSpawnMobile treats the same way: walkable, not water. private const int TestLandX = 1500; private const int TestLandY = 1600; @@ -37,10 +38,9 @@ public class CanSpawnMobileTests public void CanSpawnMobile_EmptyLocationWithLandReturnsTrue() { var map = Map.Felucca; - map.GetAverageZ(TestLandX, TestLandY, out _, out var landZ, out _); // Test with full Z range to find land - var result = map.CanSpawnMobile(TestLandX, TestLandY, -128, 127, false, false, out var spawnZ); + var result = map.CanSpawnMobile(TestLandX, TestLandY, -128, 127, false, false, out _); // Should find a valid spawn point (land surface) Assert.True(result, $"Expected to find valid spawn point on land at ({TestLandX}, {TestLandY})"); @@ -53,7 +53,7 @@ public class CanSpawnMobileTests map.GetAverageZ(TestLandX, TestLandY, out _, out var avgZ, out _); // Use a Z range that excludes the land surface (50+ units above land) - var result = map.CanSpawnMobile(TestLandX, TestLandY, avgZ + 50, avgZ + 100, false, false, out var spawnZ); + var result = map.CanSpawnMobile(TestLandX, TestLandY, avgZ + 50, avgZ + 100, false, false, out _); Assert.False(result, $"Expected no spawn point above land at Z={avgZ}"); } @@ -63,7 +63,7 @@ public class CanSpawnMobileTests { var map = Map.Felucca; // cantWalk=true means the mob can only swim, so land shouldn't be valid - var result = map.CanSpawnMobile(TestLandX, TestLandY, -128, 127, false, true, out var spawnZ); + var result = map.CanSpawnMobile(TestLandX, TestLandY, -128, 127, false, true, out _); Assert.False(result, "cantWalk=true should not find land as valid surface"); } @@ -83,7 +83,7 @@ public class CanSpawnMobileTests blockingMobile.MoveToWorld(new Point3D(TestLandX, TestLandY, landZ), map); // The mobile should block spawning at its location and Z - var result = map.CanSpawnMobile(TestLandX, TestLandY, landZ - 16, landZ + 16, false, false, out var spawnZ); + var result = map.CanSpawnMobile(TestLandX, TestLandY, landZ - 16, landZ + 16, false, false, out _); // Land surface is blocked by mobile at the same Z Assert.False(result, "Mobile should block spawn at same location"); @@ -115,7 +115,7 @@ public class CanSpawnMobileTests gm.MoveToWorld(new Point3D(x, y, landZ), map); // Hidden GM should not block spawning - var result = map.CanSpawnMobile(x, y, landZ - 16, landZ + 16, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, landZ - 16, landZ + 16, false, false, out _); Assert.True(result, "Hidden GM should not block spawn"); } @@ -142,7 +142,7 @@ public class CanSpawnMobileTests blockingMobile.MoveToWorld(new Point3D(x, y, landZ + 50), map); // Land should still be valid (mobile above doesn't block ground level) - var result = map.CanSpawnMobile(x, y, landZ - 16, landZ + 16, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, landZ - 16, landZ + 16, false, false, out _); Assert.True(result, "Mobile at different Z should not block"); } diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs index ea12926f1..38d757463 100644 --- a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs @@ -11,13 +11,6 @@ namespace Server.Tests.Maps; [Collection("Sequential Server Tests")] public class CanSpawnMobileTileDataTests { - private readonly ServerFixture _fixture; - - public CanSpawnMobileTileDataTests(ServerFixture fixture) - { - _fixture = fixture; - } - private void SkipIfNoTileData() { Skip.If(!ServerFixture.TileDataLoaded, "TileData not loaded - client files required"); @@ -96,7 +89,7 @@ public class CanSpawnMobileTileDataTests multi = CreateFloorWithCeilingMulti(map, new Point3D(x, y, 0), floorZ: 20, ceilingZ: 28); // The low ceiling should block spawning (need 16 units clearance for mobiles) - var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out _); Assert.False(result); } @@ -121,7 +114,7 @@ public class CanSpawnMobileTileDataTests // Create a multi with floor at Z=20 and ceiling at Z=50 (30 units clearance) multi = CreateFloorWithCeilingMulti(map, new Point3D(x, y, 0), floorZ: 20, ceilingZ: 50); - var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out _); Assert.True(result); } @@ -147,7 +140,7 @@ public class CanSpawnMobileTileDataTests multi = CreateFloorWithCeilingMulti(map, new Point3D(x, y, 0), floorZ: 20, ceilingZ: 100); // The blocker at Z=100 shouldn't affect spawning in range 15-35 - var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out _); Assert.True(result); } @@ -173,7 +166,7 @@ public class CanSpawnMobileTileDataTests multi = CreateWaterMulti(map, new Point3D(x, y, 0), waterZ: 0); // canSwim=true should find the water surface - var result = map.CanSpawnMobile(x, y, -10, 10, true, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, -10, 10, true, false, out _); Assert.True(result); } @@ -202,7 +195,7 @@ public class CanSpawnMobileTileDataTests }; surfaceItem.MoveToWorld(new Point3D(x, y, 10), map); - var result = map.CanSpawnMobile(x, y, 5, 30, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, 5, 30, false, false, out _); // Should find either land at Z=0 or the item surface, whichever is lowest Assert.True(result); @@ -274,16 +267,4 @@ public class CanSpawnMobileTileDataTests new MultiTileEntry(ServerFixture.WetTileId, 0, 0, (short)waterZ, TileFlag.Wet) ]); } - - private class TestMulti : BaseMulti - { - private readonly MultiComponentList _components; - - public TestMulti(MultiComponentList components) : base(0x1) - { - _components = components ?? MultiComponentList.Empty; - } - - public override MultiComponentList Components => _components; - } } diff --git a/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs index c5ed6c517..718fadefc 100644 --- a/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs @@ -313,7 +313,7 @@ public class MultiEnumeratorTests var map = Map.Felucca; // Components reach two tiles either side, so at x = 1024 the multi spans sectors 63 and 64 - var spanning = new SpanningMulti(); + var spanning = new TestMulti(SpanningComponents); try { spanning.MoveToWorld(new Point3D(1024, 1000, 0), map); @@ -351,29 +351,11 @@ public class MultiEnumeratorTests } } - // Test implementation of BaseMulti - private class TestMulti : BaseMulti - { - public TestMulti() : base(0x1) - { - } - } - - private class SpanningMulti : BaseMulti - { - // Entries after the first need non-zero flags or MultiComponentList drops them - private static readonly MultiComponentList _components = new( - [ - new MultiTileEntry(0x1, -2, 0, 0, TileFlag.Background), - new MultiTileEntry(0x1, 2, 0, 0, TileFlag.Background) - ] - ); - - public SpanningMulti() : base(0x1) - { - } - - public override MultiComponentList Components => _components; - } + // Entries after the first need non-zero flags or MultiComponentList drops them + private static readonly MultiComponentList SpanningComponents = new( + [ + new MultiTileEntry(0x1, -2, 0, 0, TileFlag.Background), + new MultiTileEntry(0x1, 2, 0, 0, TileFlag.Background) + ] + ); } - diff --git a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs index 07b8e37d3..9c6e01a36 100644 --- a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs @@ -13,21 +13,17 @@ public class StaticTileEnumeratorTests var map = Map.Felucca; var location = new Point2D(200, 200); + // Real map files may have statics here; without them there are none. Either way the multi adds nothing. + var mapStatics = CollectTiles(map, location, includeStatics: true, includeMultis: false); + TestMulti multi = null; try { - // Create a multi at the location multi = CreateMultiWithComponents(map, new Point3D(200, 200, 0)); - // Get tiles with statics only (no multis) - var tiles = new List(); - foreach (var tile in new Map.StaticTileEnumerable(map, location, includeStatics: true, includeMultis: false)) - { - tiles.Add(tile); - } + var tiles = CollectTiles(map, location, includeStatics: true, includeMultis: false); - // Should not include multi tiles - Assert.Empty(tiles); + Assert.Equal(mapStatics, tiles); } finally { @@ -69,21 +65,20 @@ public class StaticTileEnumeratorTests var map = Map.Felucca; var location = new Point2D(400, 400); + var mapStatics = CollectTiles(map, location, includeStatics: true, includeMultis: false); + TestMulti multi = null; try { - // Create a multi at the location multi = CreateMultiWithComponents(map, new Point3D(400, 400, 0)); - // Get all tiles (statics and multis) - var tiles = new List(); - foreach (var tile in new Map.StaticTileEnumerable(map, location, includeStatics: true, includeMultis: true)) - { - tiles.Add(tile); - } + var multiTiles = CollectTiles(map, location, includeStatics: false, includeMultis: true); + var tiles = CollectTiles(map, location, includeStatics: true, includeMultis: true); - // Should include multi tiles (statics would be empty without map files) - Assert.NotEmpty(tiles); + Assert.NotEmpty(multiTiles); + Assert.Equal(mapStatics.Count + multiTiles.Count, tiles.Count); + Assert.All(mapStatics, tile => Assert.Contains(tile, tiles)); + Assert.All(multiTiles, tile => Assert.Contains(tile, tiles)); } finally { @@ -257,10 +252,10 @@ public class StaticTileEnumeratorTests { // Outer's bounds cover the probe cell but it has no tile there; placing it first puts it // ahead of inner in the sector's multi list - outer = new TestMulti(World.NewItem, GappedComponents); + outer = new TestMulti(GappedComponents); outer.MoveToWorld(new Point3D(1100, 1100, 0), map); - inner = new TestMulti(World.NewItem, SingleTileComponents); + inner = new TestMulti(SingleTileComponents); inner.MoveToWorld(new Point3D(location.X, location.Y, 20), map); var tiles = new List(); @@ -294,28 +289,28 @@ public class StaticTileEnumeratorTests ] ); + private static readonly MultiComponentList DefaultComponents = new( + [ + new MultiTileEntry(0x1, 0, 0, 0, 0x0), + new MultiTileEntry(0x2, 1, 0, 0, 0x0) + ] + ); + + private static List CollectTiles(Map map, Point2D location, bool includeStatics, bool includeMultis) + { + var tiles = new List(); + foreach (var tile in new Map.StaticTileEnumerable(map, location, includeStatics, includeMultis)) + { + tiles.Add(tile); + } + + return tiles; + } + private static TestMulti CreateMultiWithComponents(Map map, Point3D location) { - var multi = new TestMulti(World.NewItem); + var multi = new TestMulti(DefaultComponents); multi.MoveToWorld(location, map); return multi; } - - private class TestMulti : BaseMulti - { - private readonly MultiComponentList _components; - - public TestMulti(Serial serial, MultiComponentList components = null) : base(serial) => - _components = components ?? DefaultComponents; - - public override MultiComponentList Components => _components; - - private static readonly MultiComponentList DefaultComponents = new( - [ - new MultiTileEntry(0x1, 0, 0, 0, 0x0), - new MultiTileEntry(0x2, 1, 0, 0, 0x0) - ] - ); - } } - diff --git a/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs index 5eb1895d8..931947a55 100644 --- a/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs +++ b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs @@ -7,8 +7,18 @@ using Xunit; namespace Server.Tests.Network.Bans; -public class BanChannelTests +[Collection("Sequential Server Tests")] +public class BanChannelTests : IDisposable { + private readonly IBanReporter[] _previousReporters = [..BanChannel.Reporters]; + private readonly Func _previousIsExempt = BanChannel.IsExempt; + + public void Dispose() + { + BanChannel.ConfigureForTesting(_previousReporters); + BanChannel.IsExempt = _previousIsExempt; + } + private sealed class FakeReporter : IBanReporter { public readonly List<(IPAddress ip, TimeSpan ttl, string reason)> Reports = []; @@ -69,18 +79,11 @@ public class BanChannelTests var exempt = IPAddress.Parse("203.0.113.7"); BanChannel.IsExempt = (ip, _) => ip.Equals(exempt); - try - { - BanChannel.Report(exempt, TimeSpan.FromHours(1), "rate-limit"); - Assert.Empty(reporter.Reports); // escalation suppressed; the local gate already acted + BanChannel.Report(exempt, TimeSpan.FromHours(1), "rate-limit"); + Assert.Empty(reporter.Reports); // escalation suppressed; the local gate already acted - BanChannel.Report(IPAddress.Parse("203.0.113.8"), TimeSpan.FromHours(1), "rate-limit"); - Assert.Single(reporter.Reports); // everyone else is still contributed - } - finally - { - BanChannel.IsExempt = null; - } + BanChannel.Report(IPAddress.Parse("203.0.113.8"), TimeSpan.FromHours(1), "rate-limit"); + Assert.Single(reporter.Reports); // everyone else is still contributed } [Fact] diff --git a/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs b/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs index 8644993ab..4e9c82ff5 100644 --- a/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs +++ b/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs @@ -4,89 +4,20 @@ using Xunit; namespace Server.Tests.Network; /// -/// Manual tests for MovementThrottle latency scenarios. -/// These tests simulate specific timing scenarios and should NOT run in CI/CD. -/// -/// To run these tests manually: -/// dotnet test --filter "Category=Manual" -/// -/// To exclude from CI/CD, add to your test command: -/// dotnet test --filter "Category!=Manual" +/// MovementThrottle burst forgiveness against a full, wrapped movement history. /// [Collection("Sequential Server Tests")] -[Trait("Category", "Manual")] public class MovementThrottleLatencyTests { - /// - /// Sets the simulated tick count for testing. - /// - private static void SetTickCount(long ticks) - { - Core._tickCount = ticks; - } - - /// - /// Simulates recording movements at specific intervals. - /// - private static void SimulateMovements(NetState ns, params (int delayMs, int costMs)[] movements) - { - ns._movementHistory ??= new NetState.MovementRecord[20]; - ns._movementHistoryIndex = 0; - ns._movementHistoryFull = false; - ns._lastMovementRecordTime = 0; - - var currentTime = 0L; - - foreach (var (delayMs, costMs) in movements) - { - currentTime += delayMs; - SetTickCount(currentTime); - - if (ns._lastMovementRecordTime > 0) - { - var interval = (int)(currentTime - ns._lastMovementRecordTime); - - // Skip chain-breaking gaps (> 2000ms) like the real code does - if (interval > 2000) - { - ns._lastGapDuration = interval; - ns._lastMovementRecordTime = currentTime; - continue; - } - - ns._movementHistory[ns._movementHistoryIndex] = new NetState.MovementRecord - { - Interval = (short)interval, - TargetSpeed = (ushort)costMs, - QueueDepth = 0, - Flags = 0 - }; - - ns._movementHistoryIndex++; - if (ns._movementHistoryIndex >= 20) - { - ns._movementHistoryIndex = 0; - ns._movementHistoryFull = true; - } - } - - ns._lastMovementRecordTime = currentTime; - } - } - - /// - /// Scenario: Verifies that burst forgiveness triggers correctly and clears gap duration. - /// [Fact] - public void MultipleLagSpikes_NoAccumulatedSuspicion() + public void AnalyzeMovement_BurstAfterGap_WrappedFullHistory_ForgivesAndClearsGap() { var ns = PacketTestUtilities.CreateTestNetState(); - SetTickCount(0); - // Set up history buffer with mostly normal movements and a burst at the end + // The production recorder is private and only reachable through a full movement request, so + // the history is laid out directly: 17 normal steps, then a 3-step burst ending at the wrap ns._movementHistory = new NetState.MovementRecord[20]; - // Fill with 17 normal movements (100ms intervals) for (var i = 0; i < 17; i++) { ns._movementHistory[i] = new NetState.MovementRecord @@ -98,7 +29,6 @@ public class MovementThrottleLatencyTests }; } - // Add 3 burst movements at the end (10ms intervals = burst) for (var i = 17; i < 20; i++) { ns._movementHistory[i] = new NetState.MovementRecord @@ -110,26 +40,19 @@ public class MovementThrottleLatencyTests }; } - ns._movementHistoryIndex = 0; // Next write position (wrapped) - ns._movementHistoryFull = true; // Buffer is full + ns._movementHistoryIndex = 0; + ns._movementHistoryFull = true; - // Set gap duration (simulating a lag spike before the burst) + // A lag spike longer than the chain gap preceded the burst ns._lastGapDuration = 2500; - // Analyze - burst forgiveness should trigger - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var samples, out var confidence); + var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); - // Key assertions: - // 1. Gap duration should be cleared (burst forgiveness triggered) Assert.Equal(0, ns._lastGapDuration); - - // 2. Confidence should be reduced by 0.3x multiplier Assert.True( confidence < 0.5f, $"Burst after gap should have low confidence. Confidence={confidence}, Rate={rate}" ); - - // 3. Should NOT be Definite despite the burst (forgiveness applied) Assert.True( verdict != MovementThrottle.DetectionVerdict.Definite, $"Should not be Definite after burst forgiveness. Verdict={verdict}" diff --git a/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs b/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs index d1f273db3..fafb15d8a 100644 --- a/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs +++ b/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs @@ -1,6 +1,4 @@ using System; -using System.Diagnostics; -using System.Net.Sockets; using Server.Network; using Xunit; @@ -96,35 +94,10 @@ public class NetStateDisconnectTests Assert.True(ns._socket.DisconnectPending); - var received = new byte[payload.Length]; - var total = 0; - var deadline = Stopwatch.StartNew(); - - while (total < payload.Length && deadline.ElapsedMilliseconds < 5000) - { - NetState.Slice(); - if (client.Poll(1000, SelectMode.SelectRead)) - { - var read = client.Receive(received, total, payload.Length - total, SocketFlags.None); - Assert.NotEqual(0, read); - total += read; - } - } - - Assert.Equal(payload, received); + Assert.Equal(payload, NetTestHelpers.ReadAll(client, payload.Length, 5000)); // FIN follows once the send completes - var eof = -1; - while (eof != 0 && deadline.ElapsedMilliseconds < 5000) - { - NetState.Slice(); - if (client.Poll(1000, SelectMode.SelectRead)) - { - eof = client.Receive(received); - } - } - - Assert.Equal(0, eof); + NetTestHelpers.WaitForEof(client, 5000); } finally { @@ -156,35 +129,8 @@ public class NetStateDisconnectTests Assert.True(ns._socket.DisconnectPending); - var received = new byte[payload.Length]; - var total = 0; - var deadline = Stopwatch.StartNew(); - - while (total < payload.Length && deadline.ElapsedMilliseconds < 10000) - { - NetState.Slice(); - if (client.Poll(1000, SelectMode.SelectRead)) - { - var read = client.Receive(received, total, payload.Length - total, SocketFlags.None); - Assert.NotEqual(0, read); - total += read; - } - } - - Assert.Equal(payload.Length, total); - Assert.Equal(payload, received); - - var eof = -1; - while (eof != 0 && deadline.ElapsedMilliseconds < 10000) - { - NetState.Slice(); - if (client.Poll(1000, SelectMode.SelectRead)) - { - eof = client.Receive(received); - } - } - - Assert.Equal(0, eof); + Assert.Equal(payload, NetTestHelpers.ReadAll(client, payload.Length)); + NetTestHelpers.WaitForEof(client); } finally { diff --git a/Projects/Server.Tests/Tests/Network/NetStateRecvGuardTests.cs b/Projects/Server.Tests/Tests/Network/NetStateRecvGuardTests.cs index 548406ec4..8d596d31b 100644 --- a/Projects/Server.Tests/Tests/Network/NetStateRecvGuardTests.cs +++ b/Projects/Server.Tests/Tests/Network/NetStateRecvGuardTests.cs @@ -1,8 +1,4 @@ -using System; using System.Buffers; -using System.Diagnostics; -using System.Network; -using System.Threading; using Server.Network; using Xunit; @@ -12,71 +8,33 @@ namespace Server.Tests.Network; public class NetStateRecvGuardTests { private const byte TestPacketId = 0xB1; + private const byte AttachAccountPacketId = 0x71; - private static unsafe void RegisterVariableLength() - { - if (IncomingPackets.GetHandler(TestPacketId) == null) - { - IncomingPackets.Register(TestPacketId, 0, false, &NoOp); - } - } + private static unsafe NetTestHelpers.HandlerRegistration RegisterVariableLength() => + NetTestHelpers.RegisterHandler(TestPacketId, 0, &NoOp); private static void NoOp(NetState state, SpanReader reader) { } - // Not 0x73: NetStateSendBufferTests registers its own no-op there first-come-wins, since the - // handler table is process-global, and this test needs its own handler's side effect to fire. - private const byte TestPingPacketId = 0x72; - - private static byte _lastPing; - - private static unsafe void RegisterNoOpPing() - { - if (IncomingPackets.GetHandler(TestPingPacketId) == null) - { - IncomingPackets.Register(TestPingPacketId, 2, false, &RecordPing); - } - } - - private static void RecordPing(NetState state, SpanReader reader) => _lastPing = reader.ReadByte(); - - private const byte AttachAccountPacketId = 0x71; - - private static unsafe void RegisterAttachAccount() - { - if (IncomingPackets.GetHandler(AttachAccountPacketId) == null) - { - IncomingPackets.Register(AttachAccountPacketId, 3, false, &AttachAccount); - } - } + private static unsafe NetTestHelpers.HandlerRegistration RegisterAttachAccount() => + NetTestHelpers.RegisterHandler(AttachAccountPacketId, 3, &AttachAccount); // Stands in for the 0x91 handler: the account attaches mid-parse, so the promotion is pending // with a receive armed when the next packet in the same completion reaches the guard private static void AttachAccount(NetState state, SpanReader reader) => state.Account = new MockAccount(); - private static void SliceUntil(Func done) - { - var deadline = Stopwatch.StartNew(); - while (!done() && deadline.ElapsedMilliseconds < 5000) - { - NetState.Slice(); - Thread.Sleep(5); - } - - Assert.True(done()); - } - [SkippableFact] public void HandleReceive_PacketLongerThanTheRecvBuffer_IsAnError() { - RegisterVariableLength(); + // A 16-bit length cannot exceed the 64 KiB base buffer, so the guard is only reachable + // through a pre-auth buffer + NetTestHelpers.RequirePreAuthBuffers(); + using var handler = RegisterVariableLength(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { - // A 16-bit length cannot exceed a 64 KiB buffer, so the guard is unreachable there and - // this only ever hit the `< 3` check on a buffer that size. - Skip.If(ns._socket.RecvBuffer.PhysicalSize > ushort.MaxValue); + Assert.True(ns._socket.RecvBuffer.PhysicalSize <= ushort.MaxValue); ns._protocolState = NetState.ProtocolState.GameServer_LoggedIn; @@ -84,7 +42,7 @@ public class NetStateRecvGuardTests // park the connection with nothing to arm a recv into var declared = ns._socket.RecvBuffer.PhysicalSize; // one more than the buffer can ever hold client.Send(new byte[] { TestPacketId, (byte)(declared >> 8), (byte)declared }); - SliceUntil(() => ns._protocolState == NetState.ProtocolState.Error); + NetTestHelpers.SliceUntil(() => ns._protocolState == NetState.ProtocolState.Error); Assert.Contains("bad state", ns._disconnectReason); } @@ -98,7 +56,7 @@ public class NetStateRecvGuardTests [Fact] public void HandleReceive_PacketThatFits_WaitsForTheRest() { - RegisterVariableLength(); + using var handler = RegisterVariableLength(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -106,7 +64,7 @@ public class NetStateRecvGuardTests var declared = ns._socket.RecvBuffer.PhysicalSize / 2; client.Send(new byte[] { TestPacketId, (byte)(declared >> 8), (byte)declared }); - SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 3); + NetTestHelpers.SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 3); Assert.Equal(NetState.ProtocolState.GameServer_LoggedIn, ns._protocolState); Assert.True(ns.Running); @@ -122,8 +80,8 @@ public class NetStateRecvGuardTests [SkippableFact] public void HandleReceive_OversizePacket_WithAnAccount_RetriesPromotionInsteadOfErroring() { - Skip.If(NetState.InitialRecvBufferSize == 0); - RegisterVariableLength(); + NetTestHelpers.RequirePreAuthBuffers(); + using var handler = RegisterVariableLength(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -135,7 +93,7 @@ public class NetStateRecvGuardTests var declared = NetState.InitialRecvBufferSize; // as much as the initial buffer can ever hold client.Send(new byte[] { TestPacketId, (byte)(declared >> 8), (byte)declared }); - SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 3); + NetTestHelpers.SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 3); Assert.Equal(NetState.ProtocolState.GameServer_LoggedIn, ns._protocolState); Assert.True(ns.Running); @@ -143,7 +101,7 @@ public class NetStateRecvGuardTests // The deferred promotion applies at this next completion; the header is then delivered // whole to the 0xB1 no-op handler client.Send(new byte[declared - 3]); - SliceUntil( + NetTestHelpers.SliceUntil( () => ns._socket.RecvBuffer.ReadableBytes == 0 && ns._socket.RecvBuffer.PhysicalSize == NetState.RecvBufferSize ); @@ -161,8 +119,8 @@ public class NetStateRecvGuardTests [SkippableFact] public void HandleReceive_AfterABurstFillsTheInitialBuffer_KeepsReceiving() { - Skip.If(NetState.InitialRecvBufferSize == 0); - RegisterNoOpPing(); + NetTestHelpers.RequirePreAuthBuffers(); + using var ping = NetTestHelpers.RegisterPing(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -174,17 +132,17 @@ public class NetStateRecvGuardTests var pings = new byte[capacity + 1]; for (var i = 0; i < pings.Length; i += 2) { - pings[i] = TestPingPacketId; + pings[i] = NetTestHelpers.PingPacketId; pings[i + 1] = (byte)(i / 2); } client.Send(pings); - SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 0 && ns._receivedData); + NetTestHelpers.SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 0 && ns._receivedData); Assert.True(ns.Running); // Without a re-arm this ping never arrives - client.Send(new byte[] { TestPingPacketId, 0xEE }); - SliceUntil(() => _lastPing == 0xEE); + client.Send(new byte[] { NetTestHelpers.PingPacketId, 0xEE }); + NetTestHelpers.SliceUntil(() => NetTestHelpers.LastPing == 0xEE); Assert.True(ns.Running); } finally @@ -197,9 +155,9 @@ public class NetStateRecvGuardTests [SkippableFact] public void HandleReceive_AccountAttachedMidParse_ThenOversizeHeader_WaitsForThePendingPromotion() { - Skip.If(NetState.InitialRecvBufferSize == 0); - RegisterAttachAccount(); - RegisterVariableLength(); + NetTestHelpers.RequirePreAuthBuffers(); + using var attach = RegisterAttachAccount(); + using var handler = RegisterVariableLength(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -212,7 +170,7 @@ public class NetStateRecvGuardTests // header then hits the guard after the promotion already applied and simply waits on the // 64 KiB buffer, so the test cannot flake, it just exercises the weaker path on that run. client.Send(new byte[] { AttachAccountPacketId, 0x00, 0x00, TestPacketId, (byte)(declared >> 8), (byte)declared }); - SliceUntil(() => ns.Account != null); + NetTestHelpers.SliceUntil(() => ns.Account != null); Assert.True(ns.Running); Assert.Equal(NetState.ProtocolState.GameServer_LoggedIn, ns._protocolState); @@ -220,7 +178,7 @@ public class NetStateRecvGuardTests // The next completion applies the promotion and the whole packet is delivered client.Send(new byte[declared - 3]); - SliceUntil( + NetTestHelpers.SliceUntil( () => ns._socket.RecvBuffer.ReadableBytes == 0 && ns._socket.RecvBuffer.PhysicalSize == NetState.RecvBufferSize ); diff --git a/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs b/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs index 6f5a22d04..c92260070 100644 --- a/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs +++ b/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs @@ -1,10 +1,8 @@ using System; using System.Buffers; using System.Collections.Generic; -using System.Diagnostics; using System.Net.Sockets; using System.Network; -using System.Threading; using Server.Network; using Xunit; @@ -22,42 +20,11 @@ public class NetStateSendBufferTests return ns; } - private static byte[] Pattern(int length, int seed) - { - var data = new byte[length]; - new System.Random(seed).NextBytes(data); - return data; - } - - private static unsafe void RegisterNoOpPing() - { - if (IncomingPackets.GetHandler(0x73) == null) - { - IncomingPackets.Register(0x73, 2, false, &NoOp); - } - } - - private static void NoOp(NetState state, SpanReader reader) - { - } - - private static void SliceUntil(Func done) - { - var deadline = Stopwatch.StartNew(); - while (!done() && deadline.ElapsedMilliseconds < 5000) - { - NetState.Slice(); - Thread.Sleep(5); - } - - Assert.True(done()); - } - [SkippableFact] public void Account_InTheGameServerState_PromotesBothBuffers() { - Skip.If(NetState.InitialSendBufferSize == 0); - RegisterNoOpPing(); + NetTestHelpers.RequirePreAuthBuffers(); + using var ping = NetTestHelpers.RegisterPing(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -73,8 +40,8 @@ public class NetStateSendBufferTests // Recv promotes at the next completion ns._protocolState = NetState.ProtocolState.GameServer_LoggedIn; - client.Send(new byte[] { 0x73, 0x01 }); - SliceUntil(() => ns._socket.RecvBuffer.PhysicalSize == NetState.RecvBufferSize); + client.Send(new byte[] { NetTestHelpers.PingPacketId, 0x01 }); + NetTestHelpers.SliceUntil(() => ns._socket.RecvBuffer.PhysicalSize == NetState.RecvBufferSize); Assert.True(ns.Running); } finally @@ -87,7 +54,7 @@ public class NetStateSendBufferTests [SkippableFact] public void Account_OnTheLoginServer_KeepsTheInitialBuffers() { - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -108,11 +75,11 @@ public class NetStateSendBufferTests public void Send_BeyondTheInitialBuffer_BeforeCredentials_IsExhausted() { // Nothing promotes before credentials verify: the 4 KiB ring is the whole pre-auth budget - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { - var data = Pattern(NetState.InitialSendBufferSize + 512, 7); + var data = NetTestHelpers.Pattern(NetState.InitialSendBufferSize + 512, 7); ns.Send(data); Assert.Contains("exhausted", ns._disconnectReason, StringComparison.OrdinalIgnoreCase); @@ -123,7 +90,7 @@ public class NetStateSendBufferTests // Once it completes the NetState is disposed and _socket goes null, so nothing below // this point may touch it. client.Close(); - SliceUntil(() => !ns.Running); + NetTestHelpers.SliceUntil(() => !ns.Running); } finally { @@ -137,19 +104,19 @@ public class NetStateSendBufferTests { // The late-verdict path from Account_AssignedAfterTheStateFlipped_StillPromotes: an account // attached while the setter's own gate does not fire must still let the send path promote - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { ns.Account = new MockAccount(); // _protocolState is still AwaitingSeed: the setter does not promote - var data = Pattern(NetState.InitialSendBufferSize + 512, 7); + var data = NetTestHelpers.Pattern(NetState.InitialSendBufferSize + 512, 7); ns.Send(data); Assert.True(ns.Running); Assert.Equal(string.Empty, ns._disconnectReason); Assert.Equal(NetState.SendBufferSize, ns._socket.SendBuffer.PhysicalSize); Assert.False(ns._sendBufferGrown); // promotion is not growth: nothing to shrink later - Assert.Equal(data, ReadAll(client, data.Length)); + Assert.Equal(data, NetTestHelpers.ReadAll(client, data.Length)); } finally { @@ -161,12 +128,12 @@ public class NetStateSendBufferTests [SkippableFact] public void Send_BacklogBeforeCredentials_IsExhaustedInsteadOfPromoted() { - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { // No Slice() between sends: nothing drains, so a second write finds a non-empty initial buffer - var half = Pattern(NetState.InitialSendBufferSize / 2, 11); + var half = NetTestHelpers.Pattern(NetState.InitialSendBufferSize / 2, 11); ns.Send(half); ns.Send(half); ns.Send(half); // cannot fit, buffer not empty, no account: refused @@ -179,7 +146,7 @@ public class NetStateSendBufferTests // Once it completes the NetState is disposed and _socket goes null, so nothing below // this point may touch it. client.Close(); - SliceUntil(() => !ns.Running); + NetTestHelpers.SliceUntil(() => !ns.Running); } finally { @@ -191,7 +158,7 @@ public class NetStateSendBufferTests [SkippableFact] public void Account_AssignedAfterTheStateFlipped_StillPromotes() { - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -211,7 +178,7 @@ public class NetStateSendBufferTests { var randomLength = compressedLength * 5 / 8; var data = new byte[compressedLength * 8]; - Pattern(randomLength, seed).CopyTo(data, 0); + NetTestHelpers.Pattern(randomLength, seed).CopyTo(data, 0); var scratch = new byte[compressedLength * 2 + 16]; for (var length = randomLength; length < data.Length; length++) @@ -228,47 +195,12 @@ public class NetStateSendBufferTests throw new InvalidOperationException($"No chunk compresses to exactly {compressedLength} bytes"); } - private static byte[] ReadAll(Socket client, int length) - { - var received = new byte[length]; - var total = 0; - var deadline = Stopwatch.StartNew(); - while (total < length && deadline.ElapsedMilliseconds < 10000) - { - NetState.Slice(); - - // Poll takes microseconds, not milliseconds - if (client.Poll(50_000, SelectMode.SelectRead)) - { - var read = client.Receive(received, total, length - total, SocketFlags.None); - Assert.NotEqual(0, read); - total += read; - } - } - - Assert.Equal(length, total); - return received; - } - // Peer receipt does not free the buffer; the completion must land first - private static void WaitForDrain(NetState ns) - { - var deadline = Stopwatch.StartNew(); - while (deadline.ElapsedMilliseconds < 10000) - { - NetState.Slice(); - - if (ns._socket.SendBuffer.ReadableBytes == 0 && ns._socket.SendBuffer.InFlightBytes == 0) - { - break; - } - - Thread.Sleep(5); - } - - Assert.Equal(0, ns._socket.SendBuffer.ReadableBytes); - Assert.Equal(0, ns._socket.SendBuffer.InFlightBytes); - } + private static void WaitForDrain(NetState ns) => + NetTestHelpers.SliceUntil( + () => ns._socket.SendBuffer.ReadableBytes == 0 && ns._socket.SendBuffer.InFlightBytes == 0, + 10000 + ); [Fact] public void Send_GrowsInsteadOfDisconnecting_AndStreamStaysIntact() @@ -283,7 +215,7 @@ public class NetStateSendBufferTests var expected = new byte[chunk * 6]; for (var i = 0; i < 6; i++) { - var data = Pattern(chunk, i); + var data = NetTestHelpers.Pattern(chunk, i); data.CopyTo(expected, i * chunk); ns.Send(data); } @@ -293,7 +225,7 @@ public class NetStateSendBufferTests Assert.True(ns._socket.SendBuffer.PhysicalSize > baseSize); Assert.True(ns._sendBufferGrown); - Assert.Equal(expected, ReadAll(client, expected.Length)); + Assert.Equal(expected, NetTestHelpers.ReadAll(client, expected.Length)); } finally { @@ -318,7 +250,7 @@ public class NetStateSendBufferTests var expectedLength = 0; for (var i = 0; i < 6; i++) { - var data = Pattern(chunkLength, i + 100); + var data = NetTestHelpers.Pattern(chunkLength, i + 100); var compressedLength = NetworkCompression.Compress(data, scratch); Assert.True(compressedLength > 0); scratch.AsSpan(0, compressedLength).CopyTo(expected.AsSpan(expectedLength)); @@ -332,7 +264,7 @@ public class NetStateSendBufferTests Assert.True(ns._sendBufferGrown); Array.Resize(ref expected, expectedLength); - Assert.Equal(expected, ReadAll(client, expected.Length)); + Assert.Equal(expected, NetTestHelpers.ReadAll(client, expected.Length)); } finally { @@ -365,7 +297,7 @@ public class NetStateSendBufferTests while (ns._socket.SendBuffer.WritableBytes > 4096) { var writable = ns._socket.SendBuffer.WritableBytes; - SendAndRecord(Pattern(Math.Min(baseSize / 8, (writable - 2048) * 8 / 11), seed++)); + SendAndRecord(NetTestHelpers.Pattern(Math.Min(baseSize / 8, (writable - 2048) * 8 / 11), seed++)); } // Full; no span to compress into @@ -381,7 +313,7 @@ public class NetStateSendBufferTests Assert.Equal(baseSize * 2, ns._socket.SendBuffer.PhysicalSize); Assert.True(ns._sendBufferGrown); - Assert.Equal(expected.ToArray(), ReadAll(client, expected.Count)); + Assert.Equal(expected.ToArray(), NetTestHelpers.ReadAll(client, expected.Count)); } finally { @@ -401,8 +333,8 @@ public class NetStateSendBufferTests // A small peer buffer keeps the first send in flight through the growth client.ReceiveBufferSize = 4096; - var first = Pattern(baseSize / 2, 300); - var second = Pattern(baseSize / 2 + 1, 301); + var first = NetTestHelpers.Pattern(baseSize / 2, 300); + var second = NetTestHelpers.Pattern(baseSize / 2 + 1, 301); ns.Send(first); NetState.Slice(); // posts the first send @@ -419,7 +351,7 @@ public class NetStateSendBufferTests first.CopyTo(expected, 0); second.CopyTo(expected, first.Length); - Assert.Equal(expected, ReadAll(client, expected.Length)); + Assert.Equal(expected, NetTestHelpers.ReadAll(client, expected.Length)); } finally { @@ -439,11 +371,11 @@ public class NetStateSendBufferTests var chunk = baseSize / 4; for (var i = 0; i < 6; i++) { - ns.Send(Pattern(chunk, i)); + ns.Send(NetTestHelpers.Pattern(chunk, i)); } Assert.True(ns._sendBufferGrown); - ReadAll(client, chunk * 6); + NetTestHelpers.ReadAll(client, chunk * 6); WaitForDrain(ns); var grewAt = ns._sendBufferGrewAt; @@ -471,11 +403,11 @@ public class NetStateSendBufferTests var chunk = baseSize / 4; for (var i = 0; i < 6; i++) { - ns.Send(Pattern(chunk, i + 400)); + ns.Send(NetTestHelpers.Pattern(chunk, i + 400)); } Assert.True(ns._sendBufferGrown); - ReadAll(client, chunk * 6); + NetTestHelpers.ReadAll(client, chunk * 6); WaitForDrain(ns); // The sweep shrinks; nothing here calls TryShrinkSendBuffer @@ -504,7 +436,7 @@ public class NetStateSendBufferTests var writes = NetState.MaxSendBufferSize / chunk + 1; for (var i = 0; i < writes; i++) { - ns.Send(Pattern(chunk, i)); + ns.Send(NetTestHelpers.Pattern(chunk, i)); } Assert.Contains("Send buffer exhausted", ns._disconnectReason); @@ -532,7 +464,7 @@ public class NetStateSendBufferTests for (var i = 0; i < 6; i++) { - ns.Send(Pattern(baseSize / 4, i)); + ns.Send(NetTestHelpers.Pattern(baseSize / 4, i)); } Assert.Equal(baseSize, ns._socket.SendBuffer.PhysicalSize); @@ -558,7 +490,7 @@ public class NetStateSendBufferTests NetState.Slice(); // handoff // CannotSendPackets short-circuits before any growth - ns.Send(Pattern(baseSize * 2, 500)); + ns.Send(NetTestHelpers.Pattern(baseSize * 2, 500)); Assert.Equal(baseSize, ns._socket.SendBuffer.PhysicalSize); Assert.False(ns._sendBufferGrown); diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/AccountPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/AccountPacketTests.cs index 6d8bd9fd9..c6a8b43a4 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/AccountPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/AccountPacketTests.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; using System.Net; -using Server.Accounting; using Server.Network; using Xunit; @@ -10,60 +8,6 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class AccountPacketTests { - private class MockedAccount : IAccount - { - public int TotalGold { get; } - public int TotalPlat { get; } - public bool DepositGold(int amount) => throw new NotImplementedException(); - - public bool DepositPlat(int amount) => throw new NotImplementedException(); - - public bool WithdrawGold(int amount) => throw new NotImplementedException(); - - public bool WithdrawPlat(int amount) => throw new NotImplementedException(); - - public long GetTotalGold() => throw new NotImplementedException(); - - public int CompareTo(IAccount other) => throw new NotImplementedException(); - - public string Username { get; } - public string Email { get; set; } - public AccessLevel AccessLevel { get; set; } - public int Length { get; } - public int Limit { get; } - public int Count { get; } - - private Dictionary _mobiles = new(); - - public Mobile this[int index] - { - get => _mobiles[index]; - set - { - _mobiles[index] = value; - if (value != null) - { - value.Account = this; - } - } - } - - public DateTime Created { get; set; } - public Serial Serial { get; } - public void Deserialize(IGenericReader reader) => throw new NotImplementedException(); - - public void Serialize(IGenericWriter writer) => throw new NotImplementedException(); - - public bool Deleted { get; } - - public void Delete() => throw new NotImplementedException(); - public bool TrySetUsername(string username) => throw new NotImplementedException(); - - public void SetPassword(string password) => throw new NotImplementedException(); - - public bool CheckPassword(string password) => throw new NotImplementedException(); - } - [Fact] public void TestChangeCharacter() { @@ -75,58 +19,38 @@ public class AccountPacketTests secondMobile.DefaultMobileInit(); secondMobile.RawName = null; - var account = new MockedAccount + var account = new MockAccount { [0] = firstMobile, [1] = null, [2] = secondMobile }; - // var account = new MockAccount(new[] { firstMobile, null, secondMobile }); - var expected = new ChangeCharacter(account).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendChangeCharacter(account); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ChangeCharacter(account), ns => ns.SendChangeCharacter(account)); } [Fact] public void TestClientVersionReq() { - var expected = new ClientVersionReq().Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - - ns.SendClientVersionRequest(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ClientVersionReq(), ns => ns.SendClientVersionRequest()); } [Fact] public void TestDeleteResult() { - var expected = new DeleteResult(DeleteResultType.BadRequest).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCharacterDeleteResult(DeleteResultType.BadRequest); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DeleteResult(DeleteResultType.BadRequest), + ns => ns.SendCharacterDeleteResult(DeleteResultType.BadRequest) + ); } [Fact] public void TestPopupMessage() { - var expected = new PopupMessage(PMMessage.LoginSyncError).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPopupMessage(PMMessage.LoginSyncError); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new PopupMessage(PMMessage.LoginSyncError), + ns => ns.SendPopupMessage(PMMessage.LoginSyncError) + ); } [Theory, InlineData(ProtocolChanges.Version70610), InlineData(ProtocolChanges.Version6000)] @@ -136,7 +60,7 @@ public class AccountPacketTests firstMobile.DefaultMobileInit(); firstMobile.Name = "Test Mobile"; - var account = new MockedAccount + var account = new MockAccount { [0] = firstMobile, [1] = null, @@ -168,25 +92,13 @@ public class AccountPacketTests m.Direction = Direction.Down; m.LogoutMap = Map.Felucca; - var expected = new LoginConfirm(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLoginConfirmation(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LoginConfirm(m), ns => ns.SendLoginConfirmation(m)); } [Fact] public void TestLoginComplete() { - var expected = new LoginComplete().Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLoginComplete(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LoginComplete(), ns => ns.SendLoginComplete()); } [Fact] @@ -196,7 +108,7 @@ public class AccountPacketTests firstMobile.DefaultMobileInit(); firstMobile.RawName = "Test Mobile"; - var account = new MockedAccount + var account = new MockAccount { [0] = null, [1] = firstMobile, @@ -205,13 +117,7 @@ public class AccountPacketTests [4] = null }; - var expected = new CharacterListUpdate(account).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCharacterListUpdate(account); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CharacterListUpdate(account), ns => ns.SendCharacterListUpdate(account)); } [Fact] @@ -221,7 +127,7 @@ public class AccountPacketTests firstMobile.DefaultMobileInit(); firstMobile.Name = "Test Mobile"; - var account = new MockedAccount + var account = new MockAccount { [0] = null, [1] = firstMobile, @@ -235,17 +141,16 @@ public class AccountPacketTests new CityInfo("Test City", "Test Building", 50, 100, 10, -10) }; - var expected = new CharacterList(account, info).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.CityInfo = info; - ns.Account = account; - ns.ProtocolChanges = ProtocolChanges.Version70130; - - ns.SendCharacterList(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new CharacterList(account, info), + ns => + { + ns.CityInfo = info; + ns.Account = account; + ns.SendCharacterList(); + }, + ProtocolChanges.Version70130 + ); } [Fact] @@ -255,7 +160,7 @@ public class AccountPacketTests firstMobile.DefaultMobileInit(); firstMobile.Name = "Test Mobile"; - var account = new MockedAccount + var account = new MockAccount { [0] = null, [1] = firstMobile, @@ -269,29 +174,23 @@ public class AccountPacketTests new CityInfo("Test City", "Test Building", 50, 100, 10, -10) }; - var expected = new CharacterListOld(account, info).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.CityInfo = info; - ns.Account = account; - - ns.SendCharacterList(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new CharacterListOld(account, info), + ns => + { + ns.CityInfo = info; + ns.Account = account; + ns.SendCharacterList(); + } + ); } [Fact] public void TestAccountLoginRej() { var reason = ALRReason.BadComm; - var expected = new AccountLoginRej(reason).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendAccountLoginRejected(reason); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new AccountLoginRej(reason), ns => ns.SendAccountLoginRejected(reason)); } [Fact] @@ -302,15 +201,14 @@ public class AccountPacketTests new ServerInfo("Test Server", 0, TimeZoneInfo.Local, IPEndPoint.Parse("127.0.0.1")) }; - var expected = new AccountLoginAck(info).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ServerInfo = info; - - ns.SendAccountLoginAck(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new AccountLoginAck(info), + ns => + { + ns.ServerInfo = info; + ns.SendAccountLoginAck(); + } + ); } [Fact] @@ -319,13 +217,6 @@ public class AccountPacketTests var si = new ServerInfo("Test Server", 0, TimeZoneInfo.Local, IPEndPoint.Parse("127.0.0.1")); const int authId = 0x123456; - var expected = new PlayServerAck(si, authId).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - - ns.SendPlayServerAck(si, authId); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PlayServerAck(si, authId), ns => ns.SendPlayServerAck(si, authId)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/CombatPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/CombatPacketTests.cs index a885cae9e..de2401a0c 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/CombatPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/CombatPacketTests.cs @@ -12,25 +12,13 @@ public class CombatPacketTests var attacker = (Serial)0x1024; var defender = (Serial)0x2048; - var expected = new Swing(attacker, defender).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSwing(attacker, defender); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new Swing(attacker, defender), ns => ns.SendSwing(attacker, defender)); } [Theory, InlineData(true), InlineData(false)] public void TestSetWarMode(bool warmode) { - var expected = new SetWarMode(warmode).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSetWarMode(warmode); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new SetWarMode(warmode), ns => ns.SendSetWarMode(warmode)); } [Fact] @@ -38,12 +26,6 @@ public class CombatPacketTests { var serial = (Serial)0x1024; - var expected = new ChangeCombatant(serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendChangeCombatant(serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ChangeCombatant(serial), ns => ns.SendChangeCombatant(serial)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs index 00dfc4054..98229c6fe 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs @@ -14,13 +14,7 @@ public class ContainerPacketTests var serial = (Serial)0x1024; ushort gumpId = 100; - var expected = new ContainerDisplay(serial, gumpId).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayContainer(serial, gumpId); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ContainerDisplay(serial, gumpId), ns => ns.SendDisplayContainer(serial, gumpId)); } [Fact] @@ -29,14 +23,11 @@ public class ContainerPacketTests var serial = (Serial)0x1024; ushort gumpId = 100; - var expected = new ContainerDisplayHS(serial, gumpId).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ns.ProtocolChanges | ProtocolChanges.ContainerGridLines | ProtocolChanges.HighSeas; - ns.SendDisplayContainer(serial, gumpId); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new ContainerDisplayHS(serial, gumpId), + ns => ns.SendDisplayContainer(serial, gumpId), + ProtocolChanges.ContainerGridLines | ProtocolChanges.HighSeas + ); } [Fact] @@ -44,13 +35,7 @@ public class ContainerPacketTests { var serial = (Serial)0x1024; - var expected = new DisplaySpellbook(serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplaySpellbook(serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DisplaySpellbook(serial), ns => ns.SendDisplaySpellbook(serial)); } [Fact] @@ -58,14 +43,11 @@ public class ContainerPacketTests { var serial = (Serial)0x1024; - var expected = new DisplaySpellbookHS(serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ns.ProtocolChanges | ProtocolChanges.ContainerGridLines | ProtocolChanges.HighSeas; - ns.SendDisplaySpellbook(serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplaySpellbookHS(serial), + ns => ns.SendDisplaySpellbook(serial), + ProtocolChanges.ContainerGridLines | ProtocolChanges.HighSeas + ); } [Fact] @@ -76,17 +58,20 @@ public class ContainerPacketTests ushort offset = 10; ulong content = 0x123456789ABCDEF0; var opl = ObjectPropertyList.Enabled; - - var expected = new NewSpellbookContent(serial, graphic, offset, content).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ns.ProtocolChanges | ProtocolChanges.ContainerGridLines | ProtocolChanges.NewSpellbook; ObjectPropertyList.Enabled = true; - ns.SendSpellbookContent(serial, graphic, offset, content); - ObjectPropertyList.Enabled = opl; - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + try + { + PacketAssert.Sends( + new NewSpellbookContent(serial, graphic, offset, content), + ns => ns.SendSpellbookContent(serial, graphic, offset, content), + ProtocolChanges.ContainerGridLines | ProtocolChanges.NewSpellbook + ); + } + finally + { + ObjectPropertyList.Enabled = opl; + } } [Fact] @@ -97,13 +82,10 @@ public class ContainerPacketTests ushort graphic = 100; ulong content = 0x123456789ABCDEF0; - var expected = new SpellbookContent(serial, offset, content).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSpellbookContent(serial, graphic, offset, content); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new SpellbookContent(serial, offset, content), + ns => ns.SendSpellbookContent(serial, graphic, offset, content) + ); } [Fact] @@ -114,14 +96,11 @@ public class ContainerPacketTests ushort graphic = 100; ulong content = 0x123456789ABCDEF0; - var expected = new SpellbookContent6017(serial, offset, content).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - ns.SendSpellbookContent(serial, graphic, offset, content); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new SpellbookContent6017(serial, offset, content), + ns => ns.SendSpellbookContent(serial, graphic, offset, content), + ProtocolChanges.ContainerGridLines + ); } [Fact] @@ -130,13 +109,7 @@ public class ContainerPacketTests var serial = (Serial)0x1024; var item = new Item(serial); - var expected = new ContainerContentUpdate(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendContainerContentUpdate(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ContainerContentUpdate(item), ns => ns.SendContainerContentUpdate(item)); } [Fact] @@ -145,66 +118,17 @@ public class ContainerPacketTests var serial = (Serial)0x1024; var item = new Item(serial); - var expected = new ContainerContentUpdate6017(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - ns.SendContainerContentUpdate(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new ContainerContentUpdate6017(item), + ns => ns.SendContainerContentUpdate(item), + ProtocolChanges.ContainerGridLines + ); } - [Fact] - public void TestContainerContent() + // An administrator beholder on the container's map, so every item is visible and sent. + private static (Container Container, Mobile Beholder) CreateViewedContainer(int itemCount) { var cont = new Container(World.NewItem); - cont.AddItem(new Item(World.NewItem)); - cont.Map = Map.Felucca; - - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.AccessLevel = AccessLevel.Administrator; - m.Map = Map.Felucca; - - var expected = new ContainerContent(m, cont).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendContainerContent(m, cont); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); - } - - [Fact] - public void TestContainerContent6017() - { - var cont = new Container(World.NewItem); - cont.AddItem(new Item(World.NewItem)); - cont.Map = Map.Felucca; - - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.AccessLevel = AccessLevel.Administrator; - m.Map = Map.Felucca; - - var expected = new ContainerContent6017(m, cont).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - ns.SendContainerContent(m, cont); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); - } - - [Fact] - public void TestContainerContent_CapsAtProtocolLimit() - { - var cont = new Container(World.NewItem); - - // Past the 3448-entry cap (19-byte entries) so truncation engages. - const int itemCount = 5000; for (var i = 0; i < itemCount; i++) { cont.AddItem(new Item(World.NewItem)); @@ -217,9 +141,62 @@ public class ContainerPacketTests m.AccessLevel = AccessLevel.Administrator; m.Map = Map.Felucca; + return (cont, m); + } + + [Fact] + public void TestContainerContent() + { + var (cont, m) = CreateViewedContainer(1); + + try + { + PacketAssert.Sends(new ContainerContent(m, cont), ns => ns.SendContainerContent(m, cont)); + } + finally + { + cont.Delete(); + m.Delete(); + } + } + + [Fact] + public void TestContainerContent6017() + { + var (cont, m) = CreateViewedContainer(1); + + try + { + PacketAssert.Sends( + new ContainerContent6017(m, cont), + ns => ns.SendContainerContent(m, cont), + ProtocolChanges.ContainerGridLines + ); + } + finally + { + cont.Delete(); + m.Delete(); + } + } + + // 0x3C's length and count fields are 16-bit: 19-byte entries cap at 3448, 20-byte grid-line entries at 3276. + [Theory] + [InlineData(false, 3448, 65517)] + [InlineData(true, 3276, 65525)] + public void TestContainerContent_CapsAtProtocolLimit(bool gridLines, int maxEntries, int maxLength) + { + // One item past the cap is enough to make truncation engage. + var (cont, m) = CreateViewedContainer(maxEntries + 1); + try { using var ns = PacketTestUtilities.CreateTestNetState(); + if (gridLines) + { + ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; + } + // A ~64KB packet exceeds the 4KB pre-auth send buffer; an account lets Send() promote it // on demand instead of exhausting the buffer and disconnecting. ns.Account = new MockAccount(); @@ -233,57 +210,9 @@ public class ContainerPacketTests var length = BinaryPrimitives.ReadUInt16BigEndian(span[1..3]); var count = BinaryPrimitives.ReadUInt16BigEndian(span[3..5]); - const int entrySize = 19; // no grid lines negotiated on this NetState - var expectedMaxEntries = (ushort.MaxValue - 5) / entrySize; - - Assert.Equal(65517, length); + Assert.Equal(maxLength, length); Assert.Equal(length, span.Length); - Assert.Equal(expectedMaxEntries, count); - } - finally - { - cont.Delete(); - m.Delete(); - } - } - - [Fact] - public void TestContainerContent_CapsAtProtocolLimit_WithGridLines() - { - var cont = new Container(World.NewItem); - - // Past the 3276-entry cap (20-byte entries with grid lines) so truncation engages. - const int itemCount = 5000; - for (var i = 0; i < itemCount; i++) - { - cont.AddItem(new Item(World.NewItem)); - } - - cont.Map = Map.Felucca; - - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.AccessLevel = AccessLevel.Administrator; - m.Map = Map.Felucca; - - try - { - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - ns.Account = new MockAccount(); - - var ex = Record.Exception(() => ns.SendContainerContent(m, cont)); - Assert.Null(ex); - - var span = ns.SendBuffer.GetReadSpan(); - Assert.Equal((byte)0x3C, span[0]); - - var length = BinaryPrimitives.ReadUInt16BigEndian(span[1..3]); - var count = BinaryPrimitives.ReadUInt16BigEndian(span[3..5]); - - Assert.Equal(65525, length); - Assert.Equal(length, span.Length); - Assert.Equal(3276, count); + Assert.Equal(maxEntries, count); } finally { @@ -295,32 +224,13 @@ public class ContainerPacketTests [Fact] public void TestContainerContent_RentedBuffer_MatchesReferencePacket() { - var cont = new Container(World.NewItem); - // Below the entry cap but past the 1024-byte stackalloc threshold, so SendContainerContent // rents its buffer instead of using the stack. - const int itemCount = 100; - for (var i = 0; i < itemCount; i++) - { - cont.AddItem(new Item(World.NewItem)); - } - - cont.Map = Map.Felucca; - - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.AccessLevel = AccessLevel.Administrator; - m.Map = Map.Felucca; + var (cont, m) = CreateViewedContainer(100); try { - var expected = new ContainerContent(m, cont).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendContainerContent(m, cont); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ContainerContent(m, cont), ns => ns.SendContainerContent(m, cont)); } finally { diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/DamagePacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/DamagePacketTests.cs index 5f31a49d4..bda7ad009 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/DamagePacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/DamagePacketTests.cs @@ -11,13 +11,7 @@ public class DamagePacketTests { var serial = (Serial)0x1024; - var expected = new DamagePacketOld(serial, inputAmount).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDamage(serial, inputAmount); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DamagePacketOld(serial, inputAmount), ns => ns.SendDamage(serial, inputAmount)); } [Theory, InlineData(10), InlineData(-5), InlineData(1024), InlineData(100000)] @@ -25,14 +19,10 @@ public class DamagePacketTests { var serial = (Serial)0x1024; - var expected = new DamagePacket(serial, inputAmount).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ProtocolChanges.DamagePacket; - - ns.SendDamage(serial, inputAmount); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DamagePacket(serial, inputAmount), + ns => ns.SendDamage(serial, inputAmount), + ProtocolChanges.DamagePacket + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPacketTests.cs index 7ade294f6..e9395948d 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPacketTests.cs @@ -7,18 +7,13 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class EffectPackets { - [Theory, InlineData(10, 1000, 10, 5)] - public void TestSoundEffect(ushort soundID, int x, int y, int z) + [Fact] + public void TestSoundEffect() { - var p = new Point3D(x, y, z); + const ushort soundID = 10; + var p = new Point3D(1000, 10, 5); - var expected = new PlaySound(soundID, p).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSoundEffect(soundID, p); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PlaySound(soundID, p), ns => ns.SendSoundEffect(soundID, p)); } [Fact] @@ -94,13 +89,7 @@ public class EffectPackets [Theory, InlineData(ScreenEffectType.DarkFlash), InlineData(ScreenEffectType.FadeInOut)] public void TestScreenEffect(ScreenEffectType screenType) { - var expected = new ScreenEffect(screenType).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendScreenEffect(screenType); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ScreenEffect(screenType), ns => ns.SendScreenEffect(screenType)); } [Fact] diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPackets.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPackets.cs index e18256383..2899569f8 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPackets.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPackets.cs @@ -76,157 +76,6 @@ public class HuedEffect : Packet } } -public sealed class TargetParticleEffect : ParticleEffect -{ - public TargetParticleEffect( - IEntity e, int itemID, int speed, int duration, int hue, int renderMode, int effect, - int layer, int unknown - ) : base( - EffectType.FixedFrom, - e.Serial, - Serial.Zero, - itemID, - e.Location, - e.Location, - speed, - duration, - true, - false, - hue, - renderMode, - effect, - 1, - 0, - e.Serial, - layer, - unknown - ) - { - } -} - -public sealed class TargetEffect : HuedEffect -{ - public TargetEffect(IEntity e, int itemID, int speed, int duration, int hue, int renderMode) : base( - EffectType.FixedFrom, - e.Serial, - Serial.Zero, - itemID, - e.Location, - e.Location, - speed, - duration, - true, - false, - hue, - renderMode - ) - { - } -} - -public sealed class LocationParticleEffect : ParticleEffect -{ - public LocationParticleEffect( - IEntity e, int itemID, int speed, int duration, int hue, int renderMode, int effect, - int unknown - ) : base( - EffectType.FixedXYZ, - e.Serial, - Serial.Zero, - itemID, - e.Location, - e.Location, - speed, - duration, - true, - false, - hue, - renderMode, - effect, - 1, - 0, - e.Serial, - 255, - unknown - ) - { - } -} - -public sealed class LocationEffect : HuedEffect -{ - public LocationEffect(IPoint3D p, int itemID, int speed, int duration, int hue, int renderMode) : base( - EffectType.FixedXYZ, - Serial.Zero, - Serial.Zero, - itemID, - p, - p, - speed, - duration, - true, - false, - hue, - renderMode - ) - { - } -} - -public sealed class MovingParticleEffect : ParticleEffect -{ - public MovingParticleEffect( - IEntity from, IEntity to, int itemID, int speed, int duration, bool fixedDirection, - bool explodes, int hue, int renderMode, int effect, int explodeEffect, int explodeSound, EffectLayer layer, - int unknown - ) : base( - EffectType.Moving, - from.Serial, - to.Serial, - itemID, - from.Location, - to.Location, - speed, - duration, - fixedDirection, - explodes, - hue, - renderMode, - effect, - explodeEffect, - explodeSound, - Serial.Zero, - (int)layer, - unknown - ) - { - } -} - -public sealed class MovingEffect : HuedEffect -{ - public MovingEffect( - IEntity from, IEntity to, int itemID, int speed, int duration, bool fixedDirection, - bool explodes, int hue, int renderMode - ) : base( - EffectType.Moving, - from.Serial, - to.Serial, - itemID, - from.Location, - to.Location, - speed, - duration, - fixedDirection, - explodes, - hue, - renderMode - ) - { - } -} - public class ScreenEffect : Packet { public ScreenEffect(ScreenEffectType type) diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EquipmentPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EquipmentPacketTests.cs index 8dc535e03..21933bc91 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EquipmentPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EquipmentPacketTests.cs @@ -31,19 +31,16 @@ public class EquipmentPacketTests } ); - var expected = new DisplayEquipmentInfo(item, info).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayEquipmentInfo( - item.Serial, - info.Number, - info.Crafter?.RawName, - info.Unidentified, - new List(info.Attributes) + PacketAssert.Sends( + new DisplayEquipmentInfo(item, info), + ns => ns.SendDisplayEquipmentInfo( + item.Serial, + info.Number, + info.Crafter?.RawName, + info.Unidentified, + new List(info.Attributes) + ) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -54,12 +51,6 @@ public class EquipmentPacketTests var item = new Item(World.NewItem) { Parent = m }; - var expected = new EquipUpdate(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendEquipUpdate(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new EquipUpdate(item), ns => ns.SendEquipUpdate(item)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/GumpPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/GumpPacketTests.cs index edbe6b668..febac5288 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/GumpPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/GumpPacketTests.cs @@ -6,17 +6,13 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class GumpPacketTests { - [Theory] - [InlineData(100, 10)] - public void TestCloseGump(int typeId, int buttonId) + [Fact] + public void TestCloseGump() { - var expected = new CloseGump(typeId, buttonId).Compile(); + const int typeId = 100; + const int buttonId = 10; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCloseGump(typeId, buttonId); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CloseGump(typeId, buttonId), ns => ns.SendCloseGump(typeId, buttonId)); } [Fact] @@ -27,13 +23,10 @@ public class GumpPacketTests const string unknownString = "This is an unknown string"; const string caption = "This is a caption"; - var expected = new DisplaySignGump(gumpSerial, gumpId, unknownString, caption).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplaySignGump(gumpSerial, gumpId, unknownString, caption); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplaySignGump(gumpSerial, gumpId, unknownString, caption), + ns => ns.SendDisplaySignGump(gumpSerial, gumpId, unknownString, caption) + ); } [Fact] @@ -41,13 +34,7 @@ public class GumpPacketTests { var gump = new NameChangeDeedGump(); - using var ns = PacketTestUtilities.CreateTestNetState(); - - var expected = gump.Compile(ns).Compile(); - ns.SendGump(gump); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(ns => gump.Compile(ns), ns => ns.SendGump(gump)); } [Fact] @@ -60,13 +47,7 @@ public class GumpPacketTests var gump = new AdminGump(m, AdminGumpPage.Clients); - using var ns = PacketTestUtilities.CreateTestNetState(); - - var expected = gump.Compile(ns).Compile(); - ns.SendGump(gump); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(ns => gump.Compile(ns), ns => ns.SendGump(gump)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs index 27bad8aaf..f9d5cb948 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs @@ -1,116 +1,75 @@ +using System; using Server.Network; using Xunit; namespace Server.Tests.Network; [Collection("Sequential Server Tests")] -public class ItemPacketTests +public class ItemPacketTests : IDisposable { + private const int TestItemId = 1; + + // TileData is global and holds real client data when client files are loaded, so each test restores it. + private readonly ItemData _originalItemData; + + public ItemPacketTests() + { + _originalItemData = TileData.ItemTable[TestItemId]; + TileData.ItemTable[TestItemId] = new ItemData("Test Item Data", TileFlag.Generic, 1, 1, 1, 1, 1, 1); + } + + public void Dispose() => TileData.ItemTable[TestItemId] = _originalItemData; + [Fact] public void TestWorldItemPacket() { var serial = (Serial)0x1024; - var itemId = 1; - - // Move to fixture - TileData.ItemTable[itemId] = new ItemData( - "Test Item Data", - TileFlag.Generic, - 1, - 1, - 1, - 1, - 1, - 1 - ); var item = new Item(serial) { - ItemID = itemId, + ItemID = TestItemId, Hue = 0x1024, Amount = 10, Location = new Point3D(1000, 100, -10), Direction = Direction.Left }; - var expected = new WorldItem(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendWorldItem(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new WorldItem(item), ns => ns.SendWorldItem(item)); } [Fact] public void TestWorldItemSAPacket() { var serial = (Serial)0x1024; - ushort itemId = 1; - - // Move to fixture - TileData.ItemTable[itemId] = new ItemData( - "Test Item Data", - TileFlag.Generic, - 1, - 1, - 1, - 1, - 1, - 1 - ); var item = new Item(serial) { - ItemID = itemId, + ItemID = TestItemId, Hue = 0x1024, Amount = 10, Location = new Point3D(1000, 100, -10) }; - var expected = new WorldItemSA(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ProtocolChanges.StygianAbyss; - ns.SendWorldItem(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new WorldItemSA(item), ns => ns.SendWorldItem(item), ProtocolChanges.StygianAbyss); } [Fact] public void TestWorldItemHSPacket() { var serial = (Serial)0x1024; - var itemId = 1; - - // Move to fixture - TileData.ItemTable[itemId] = new ItemData( - "Test Item Data", - TileFlag.Generic, - 1, - 1, - 1, - 1, - 1, - 1 - ); var item = new Item(serial) { - ItemID = itemId, + ItemID = TestItemId, Hue = 0x1024, Amount = 10, Location = new Point3D(1000, 100, -10) }; - var expected = new WorldItemHS(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ProtocolChanges.StygianAbyss | ProtocolChanges.HighSeas; - ns.SendWorldItem(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new WorldItemHS(item), + ns => ns.SendWorldItem(item), + ProtocolChanges.StygianAbyss | ProtocolChanges.HighSeas + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPackets.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPackets.cs index 084ba4587..90bd89dfb 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPackets.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPackets.cs @@ -187,21 +187,3 @@ public sealed class WorldItemHS : Packet Stream.Write((short)0x00); // ?? } } - -public sealed class OPLInfo : Packet -{ - /*public OPLInfo( ObjectPropertyList list ) : base( 0xBF ) - { - EnsureCapacity( 13 ); - - m_Stream.Write( (short) 0x10 ); - m_Stream.Write( (int) list.Entity.Serial ); - m_Stream.Write( (int) list.Hash ); - }*/ - - public OPLInfo(Serial serial, int hash) : base(0xDC, 9) - { - Stream.Write(serial); - Stream.Write(hash); - } -} \ No newline at end of file diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/LightPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/LightPacketTests.cs index c66a61ca7..22ed4c423 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/LightPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/LightPacketTests.cs @@ -10,13 +10,8 @@ public class LightPacketTests public void TestGlobalLightLevel() { const byte lightLevel = 5; - var expected = new GlobalLightLevel(lightLevel).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendGlobalLightLevel(lightLevel); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new GlobalLightLevel(lightLevel), ns => ns.SendGlobalLightLevel(lightLevel)); } [Fact] @@ -24,12 +19,10 @@ public class LightPacketTests { var serial = (Serial)0x1024; byte lightLevel = 5; - var expected = new PersonalLightLevel(serial, lightLevel).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPersonalLightLevel(serial, lightLevel); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new PersonalLightLevel(serial, lightLevel), + ns => ns.SendPersonalLightLevel(serial, lightLevel) + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MapPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MapPacketTests.cs index a438730e0..d563db252 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MapPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MapPacketTests.cs @@ -9,26 +9,13 @@ public class MapPatchesTests [Fact] public void TestMapPatches() { - using var ns = PacketTestUtilities.CreateTestNetState(); - - var expected = new MapPatches().Compile(); - - ns.SendMapPatches(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MapPatches(), ns => ns.SendMapPatches()); } [Fact] public void TestInvalidMapEnable() { - var expected = new InvalidMapEnable().Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendInvalidMap(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new InvalidMapEnable(), ns => ns.SendInvalidMap()); } [Theory] @@ -37,12 +24,7 @@ public class MapPatchesTests public void TestMapChange(string mapName) { var map = Map.Parse(mapName); - var expected = new MapChange(map).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMapChange(map); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MapChange(map), ns => ns.SendMapChange(map)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MenuPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MenuPacketTests.cs index 3da55c89e..c87355ac8 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MenuPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MenuPacketTests.cs @@ -42,13 +42,7 @@ public class MenuPacketTests ] ); - var expected = new DisplayItemListMenu(menu).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayItemListMenu(menu); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DisplayItemListMenu(menu), ns => ns.SendDisplayItemListMenu(menu)); } [Fact] @@ -63,12 +57,7 @@ public class MenuPacketTests ] ); - var expected = new DisplayQuestionMenu(menu).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayQuestionMenu(menu); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DisplayQuestionMenu(menu), ns => ns.SendDisplayQuestionMenu(menu)); } [Theory] @@ -84,18 +73,12 @@ public class MenuPacketTests var item = new ContextMenuItem(World.NewItem, newPacket); var menu = ContextMenuSystem.CreateContextMenu(m, item); - var packet = newHaven && newPacket ? (Packet)new DisplayContextMenu(menu) : new DisplayContextMenuOld(menu); - var expected = packet.Compile(); + var expected = newHaven && newPacket ? (Packet)new DisplayContextMenu(menu) : new DisplayContextMenuOld(menu); - using var ns = PacketTestUtilities.CreateTestNetState(); - if (newHaven) - { - ns.ProtocolChanges |= ProtocolChanges.NewHaven; - } - - ns.SendDisplayContextMenu(menu); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + expected, + ns => ns.SendDisplayContextMenu(menu), + newHaven ? ProtocolChanges.NewHaven : ProtocolChanges.None + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MessagePacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MessagePacketTests.cs index b5eb423f1..c1911db57 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MessagePacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MessagePacketTests.cs @@ -19,31 +19,10 @@ public class MessageTests var name = "Stuff"; var args = "Arguments"; - var expected = new MessageLocalized( - serial, - graphic, - messageType, - hue, - font, - number, - name, - args - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessageLocalized( - serial, - graphic, - messageType, - hue, - font, - number, - name, - args + PacketAssert.Sends( + new MessageLocalized(serial, graphic, messageType, hue, font, number, name, args), + ns => ns.SendMessageLocalized(serial, graphic, messageType, hue, font, number, name, args) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -58,15 +37,10 @@ public class MessageTests var name = "Stuff"; var argValue = 42; - var expected = new MessageLocalized( - serial, graphic, messageType, hue, font, number, name, $"value: {argValue}" - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessageLocalized(serial, graphic, messageType, hue, font, number, name, $"value: {argValue}"); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new MessageLocalized(serial, graphic, messageType, hue, font, number, name, $"value: {argValue}"), + ns => ns.SendMessageLocalized(serial, graphic, messageType, hue, font, number, name, $"value: {argValue}") + ); } [Fact] @@ -83,35 +57,12 @@ public class MessageTests var affixType = AffixType.System; var affix = "Affix"; - var expected = new MessageLocalizedAffix( - serial, - graphic, - messageType, - hue, - font, - number, - name, - affixType, - affix, - args - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessageLocalizedAffix( - serial, - graphic, - messageType, - hue, - font, - number, - name, - affixType, - affix, - args + PacketAssert.Sends( + new MessageLocalizedAffix(serial, graphic, messageType, hue, font, number, name, affixType, affix, args), + ns => ns.SendMessageLocalizedAffix( + serial, graphic, messageType, hue, font, number, name, affixType, affix, args + ) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -128,18 +79,14 @@ public class MessageTests var affix = "Affix"; var argValue = 42; - var expected = new MessageLocalizedAffix( - serial, graphic, messageType, hue, font, number, name, affixType, affix, $"value: {argValue}" - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessageLocalizedAffix( - serial, graphic, messageType, hue, font, number, name, affixType, affix, - $"value: {argValue}" + PacketAssert.Sends( + new MessageLocalizedAffix( + serial, graphic, messageType, hue, font, number, name, affixType, affix, $"value: {argValue}" + ), + ns => ns.SendMessageLocalizedAffix( + serial, graphic, messageType, hue, font, number, name, affixType, affix, $"value: {argValue}" + ) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -154,17 +101,10 @@ public class MessageTests var name = "Stuff"; var who = "Alice"; - var expected = new UnicodeMessage( - serial, graphic, messageType, hue, font, lang, name, $"hello, {who}" - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessage( - serial, graphic, messageType, hue, font, false, lang, name, $"hello, {who}" + PacketAssert.Sends( + new UnicodeMessage(serial, graphic, messageType, hue, font, lang, name, $"hello, {who}"), + ns => ns.SendMessage(serial, graphic, messageType, hue, font, false, lang, name, $"hello, {who}") ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -178,31 +118,10 @@ public class MessageTests var name = "Stuff"; var text = "Some Text"; - var expected = new AsciiMessage( - serial, - graphic, - messageType, - hue, - font, - name, - text - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessage( - serial, - graphic, - messageType, - hue, - font, - true, - null, - name, - text + PacketAssert.Sends( + new AsciiMessage(serial, graphic, messageType, hue, font, name, text), + ns => ns.SendMessage(serial, graphic, messageType, hue, font, true, null, name, text) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -217,32 +136,10 @@ public class MessageTests var name = "Stuff"; var text = "Some Text"; - var expected = new UnicodeMessage( - serial, - graphic, - messageType, - hue, - font, - lang, - name, - text - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessage( - serial, - graphic, - messageType, - hue, - font, - false, - lang, - name, - text + PacketAssert.Sends( + new UnicodeMessage(serial, graphic, messageType, hue, font, lang, name, text), + ns => ns.SendMessage(serial, graphic, messageType, hue, font, false, lang, name, text) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -251,13 +148,7 @@ public class MessageTests var serial = (Serial)0x1024; var serial2 = (Serial)0x2; - var expected = new FollowMessage(serial, serial2).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendFollowMessage(serial, serial2); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new FollowMessage(serial, serial2), ns => ns.SendFollowMessage(serial, serial2)); } [Fact] @@ -266,33 +157,18 @@ public class MessageTests var s = (Serial)0x100; var text = "This is some testing text"; - var expected = new ObjectHelpResponse(s, text).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendHelpResponse(s, text); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ObjectHelpResponse(s, text), ns => ns.SendHelpResponse(s, text)); } - internal class TestPrompt : Prompt + [Fact] + public void TestUnicodePrompt() { + var prompt = new TestPrompt(); + + PacketAssert.Sends(new UnicodePrompt(prompt), ns => ns.SendPrompt(prompt)); } - [Collection("Sequential Server Tests")] - public class UnicodePromptTests + private class TestPrompt : Prompt { - [Fact] - public void TestUnicodePrompt() - { - var prompt = new TestPrompt(); - var expected = new UnicodePrompt(prompt).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPrompt(prompt); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); - } } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePacketTests.cs index d7d3beca2..3d839899b 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePacketTests.cs @@ -6,19 +6,38 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class MobilePacketTests { + private static Mobile CreateMobileWithStats(uint serial = 0x1, string name = null) + { + var m = new Mobile((Serial)serial) { Name = name }; + m.DefaultMobileInit(); + m.Str = 50; + m.Hits = 100; + m.Int = 75; + m.Mana = 100; + m.Dex = 25; + m.Stam = 100; + + return m; + } + + private sealed class TestPoison : Poison + { + public TestPoison(int level) : base(level) => Level = level; + + public override string Name => $"Test Poison {Level}"; + public override int Level { get; } + public override PoisonFamily Family => PoisonFamily.Standard; + + public override Timer ConstructTimer(Mobile m) => null; + } + [Fact] public void TestDeathAnimation() { var killed = (Serial)0x1; var corpse = (Serial)0x1000; - var expected = new DeathAnimation(killed, corpse).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDeathAnimation(killed, corpse); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DeathAnimation(killed, corpse), ns => ns.SendDeathAnimation(killed, corpse)); } [Fact] @@ -27,13 +46,7 @@ public class MobilePacketTests var petSerial = (Serial)0x1; const bool bonded = true; - var expected = new BondedStatus(petSerial, bonded).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendBondedStatus(petSerial, bonded); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new BondedStatus(petSerial, bonded), ns => ns.SendBondedStatus(petSerial, bonded)); } [Theory] @@ -46,14 +59,11 @@ public class MobilePacketTests var noto = 10; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = protocolChanges; - var expected = new MobileMoving(m, noto, ns.StygianAbyss).Compile(); - - ns.SendMobileMoving(m, noto); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new MobileMoving(m, noto, ns.StygianAbyss), + ns => ns.SendMobileMoving(m, noto), + protocolChanges + ); } [Theory] @@ -65,13 +75,7 @@ public class MobilePacketTests var m = new Mobile((Serial)0x1) { Name = name }; m.DefaultMobileInit(); - var expected = new MobileName(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileName(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileName(m), ns => ns.SendMobileName(m)); } [Theory] @@ -81,29 +85,10 @@ public class MobilePacketTests { var mobile = (Serial)0x1; - var expected = new MobileAnimation( - mobile, - action, - frameCount, - repeatCount, - !reverse, - repeat, - delay - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileAnimation( - mobile, - action, - frameCount, - repeatCount, - !reverse, - repeat, - delay + PacketAssert.Sends( + new MobileAnimation(mobile, action, frameCount, repeatCount, !reverse, repeat, delay), + ns => ns.SendMobileAnimation(mobile, action, frameCount, repeatCount, !reverse, repeat, delay) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Theory] @@ -113,43 +98,24 @@ public class MobilePacketTests { var mobile = (Serial)0x1; - var expected = new NewMobileAnimation( - mobile, - action, - frameCount, - delay - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendNewMobileAnimation( - mobile, - action, - frameCount, - delay + PacketAssert.Sends( + new NewMobileAnimation(mobile, action, frameCount, delay), + ns => ns.SendNewMobileAnimation(mobile, action, frameCount, delay) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } + // Level -1 means not poisoned. Constructed directly: the poison registry is filled by UOContent. [Theory] - [InlineData("None")] - [InlineData("Lesser")] - [InlineData("Lethal")] - public void TestHealthbarPoison(string pName) + [InlineData(-1)] + [InlineData(0)] + [InlineData(4)] + public void TestHealthbarPoison(int level) { - var p = Poison.GetPoison(pName); var m = new Mobile((Serial)0x1); m.DefaultMobileInit(); - m.Poison = p; + m.Poison = level < 0 ? null : new TestPoison(level); - var expected = new HealthbarPoison(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileHealthbar(m, Healthbar.Poison); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new HealthbarPoison(m), ns => ns.SendMobileHealthbar(m, Healthbar.Poison)); } [Theory] @@ -164,13 +130,7 @@ public class MobilePacketTests m.Blessed = isBlessed; m.YellowHealthbar = isYellowHealth; - var expected = new HealthbarYellow(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileHealthbar(m, Healthbar.Yellow); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new HealthbarYellow(m), ns => ns.SendMobileHealthbar(m, Healthbar.Yellow)); } [Theory] @@ -178,22 +138,9 @@ public class MobilePacketTests [InlineData(false)] public void TestMobileStatusCompact(bool canBeRenamed) { - var m = new Mobile((Serial)0x1) { Name = "Random Mobile 1" }; - m.DefaultMobileInit(); - m.Str = 50; - m.Hits = 100; - m.Int = 75; - m.Mana = 100; - m.Dex = 25; - m.Stam = 100; + var m = CreateMobileWithStats(name: "Random Mobile 1"); - var expected = new MobileStatusCompact(canBeRenamed, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileStatusCompact(m, canBeRenamed); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileStatusCompact(canBeRenamed, m), ns => ns.SendMobileStatusCompact(m, canBeRenamed)); } [Theory] @@ -201,32 +148,14 @@ public class MobilePacketTests [InlineData(ProtocolChanges.Version502b)] public void TestMobileStatus(ProtocolChanges changes) { - var beholder = new Mobile((Serial)0x1) { Name = "Random Mobile 1" }; - beholder.DefaultMobileInit(); - beholder.Str = 50; - beholder.Hits = 100; - beholder.Int = 75; - beholder.Mana = 100; - beholder.Dex = 25; - beholder.Stam = 100; + var beholder = CreateMobileWithStats(0x1, "Random Mobile 1"); + var beheld = CreateMobileWithStats(0x2, "Random Mobile 2"); - var beheld = new Mobile((Serial)0x2) { Name = "Random Mobile 2" }; - beheld.DefaultMobileInit(); - beheld.Str = 50; - beheld.Hits = 100; - beheld.Int = 75; - beheld.Mana = 100; - beheld.Dex = 25; - beheld.Stam = 100; - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = changes; - - var expected = new MobileStatus(beholder, beheld, ns).Compile(); - ns.SendMobileStatus(beholder, beheld); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new MobileStatus(beholder, beheld, ns), + ns => ns.SendMobileStatus(beholder, beheld), + changes + ); } [Theory] @@ -240,33 +169,32 @@ public class MobilePacketTests int mobileStatusVersion ) { - var expansionInfo = ExpansionInfo.CoreExpansion; var oldExpansion = Core.Expansion; + var expansionInfo = ExpansionInfo.GetInfo(expansion); var oldVersion = expansionInfo.MobileStatusVersion; - Core.Expansion = expansion; - ExpansionInfo.CoreExpansion.MobileStatusVersion = mobileStatusVersion; - var m = new Mobile((Serial)0x1) { Name = "Random Mobile 1" }; - m.DefaultMobileInit(); - m.Str = 50; - m.Hits = 100; - m.Int = 75; - m.Mana = 100; - m.Dex = 25; - m.Stam = 100; + try + { + Core.Expansion = expansion; + expansionInfo.MobileStatusVersion = mobileStatusVersion; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = changes; - ns.Version = new ClientVersion(version); - ns.Flags = clientFlags; + var m = CreateMobileWithStats(name: "Random Mobile 1"); - var expected = new MobileStatusExtended(m, ns).Compile(); - ns.SendMobileStatus(m, m); + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.ProtocolChanges = changes; + ns.Version = new ClientVersion(version); + ns.Flags = clientFlags; - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); - Core.Expansion = oldExpansion; - expansionInfo.MobileStatusVersion = oldVersion; + var expected = new MobileStatusExtended(m, ns).Compile(); + ns.SendMobileStatus(m, m); + + AssertThat.Equal(ns.SendBuffer.GetReadSpan(), expected); + } + finally + { + Core.Expansion = oldExpansion; + expansionInfo.MobileStatusVersion = oldVersion; + } } [Theory] @@ -278,14 +206,7 @@ public class MobilePacketTests m.DefaultMobileInit(); m.SolidHueOverride = solidHueOverride; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = changes; - - var expected = new MobileUpdate(m, ns.StygianAbyss).Compile(); - ns.SendMobileUpdate(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(ns => new MobileUpdate(m, ns.StygianAbyss), ns => ns.SendMobileUpdate(m), changes); } [Theory] @@ -333,170 +254,82 @@ public class MobilePacketTests beheld.FacialHairItemID = facialHairItemId; beheld.FacialHairHue = facialHairHue; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = changes; - - var expected = new MobileIncoming(ns, beholder, beheld).Compile(); - ns.SendMobileIncoming(beholder, beheld); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new MobileIncoming(ns, beholder, beheld), + ns => ns.SendMobileIncoming(beholder, beheld), + changes + ); } [Fact] public void TestMobileHits() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Str = 100; - m.Hits = 100; + var m = CreateMobileWithStats(); - var expected = new MobileHits(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileHits(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileHits(m), ns => ns.SendMobileHits(m)); } [Fact] public void TestMobileHitsN() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Str = 100; - m.Hits = 100; + var m = CreateMobileWithStats(); - var expected = new MobileHitsN(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileHits(m, true); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileHitsN(m), ns => ns.SendMobileHits(m, true)); } [Fact] public void TestMobileMana() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Int = 75; - m.Mana = 100; + var m = CreateMobileWithStats(); - var expected = new MobileMana(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileMana(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileMana(m), ns => ns.SendMobileMana(m)); } [Fact] public void TestMobileManaN() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Int = 75; - m.Mana = 100; + var m = CreateMobileWithStats(); - var expected = new MobileManaN(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileMana(m, true); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileManaN(m), ns => ns.SendMobileMana(m, true)); } [Fact] public void TestMobileStam() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Dex = 75; - m.Stam = 100; + var m = CreateMobileWithStats(); - var expected = new MobileStam(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileStam(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileStam(m), ns => ns.SendMobileStam(m)); } [Fact] public void TestMobileStamN() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Dex = 75; - m.Stam = 100; + var m = CreateMobileWithStats(); - var expected = new MobileStamN(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileStam(m, true); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileStamN(m), ns => ns.SendMobileStam(m, true)); } [Fact] public void TestMobileAttributes() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Str = 50; - m.Hits = 100; - m.Int = 75; - m.Mana = 100; - m.Dex = 25; - m.Stam = 100; + var m = CreateMobileWithStats(); - var expected = new MobileAttributes(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileAttributes(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileAttributes(m), ns => ns.SendMobileAttributes(m)); } [Fact] public void TestMobileAttributesN() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Str = 50; - m.Hits = 100; - m.Int = 75; - m.Mana = 100; - m.Dex = 25; - m.Stam = 100; + var m = CreateMobileWithStats(); - var expected = new MobileAttributesN(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileAttributes(m, true); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileAttributesN(m), ns => ns.SendMobileAttributes(m, true)); } [Fact] public void TestRemoveEntity() { var e = (Serial)0x1000; - var expected = new RemoveEntity(e).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendRemoveEntity(e); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new RemoveEntity(e), ns => ns.SendRemoveEntity(e)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MovementPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MovementPacketTests.cs index 330eaf0b5..5f8a27206 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MovementPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MovementPacketTests.cs @@ -12,26 +12,18 @@ public class MovementPacketTests [InlineData(2)] public void TestSpeedControl(byte speedControl) { - var expected = new SpeedControl(speedControl).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSpeedControl((SpeedControlSetting)speedControl); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new SpeedControl(speedControl), + ns => ns.SendSpeedControl((SpeedControlSetting)speedControl) + ); } [Fact] public void TestMovePlayer() { const Direction d = Direction.Left; - var expected = new MovePlayer(d).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMovePlayer(d); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MovePlayer(d), ns => ns.SendMovePlayer(d)); } [Fact] @@ -42,13 +34,7 @@ public class MovementPacketTests const byte seq = 100; - var expected = new MovementRej(seq, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMovementRej(seq, m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MovementRej(seq, m), ns => ns.SendMovementRej(seq, m)); } [Fact] @@ -59,12 +45,6 @@ public class MovementPacketTests const byte seq = 100; - var expected = MovementAck.Instantiate(seq, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMovementAck(seq, m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(MovementAck.Instantiate(seq, m), ns => ns.SendMovementAck(seq, m)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/PlayerPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/PlayerPacketTests.cs index 6d6e5daa3..8bfe17a9e 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/PlayerPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/PlayerPacketTests.cs @@ -8,23 +8,16 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class PlayerPacketTests { - [Theory] - [InlineData(StatLockType.Down, StatLockType.Up, StatLockType.Locked)] - public void TestStatLockInfo(StatLockType str, StatLockType intel, StatLockType dex) + [Fact] + public void TestStatLockInfo() { var m = new Mobile((Serial)0x1); m.DefaultMobileInit(); - m.StrLock = str; - m.IntLock = intel; - m.DexLock = dex; + m.StrLock = StatLockType.Down; + m.IntLock = StatLockType.Up; + m.DexLock = StatLockType.Locked; - var expected = new StatLockInfo(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendStatLockInfo(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new StatLockInfo(m), ns => ns.SendStatLockInfo(m)); } [Theory] @@ -33,25 +26,13 @@ public class PlayerPacketTests [InlineData(200)] public void TestChangeUpdateRange(int range) { - var expected = new ChangeUpdateRange(range).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendChangeUpdateRange((byte)range); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ChangeUpdateRange(range), ns => ns.SendChangeUpdateRange((byte)range)); } [Fact] public void TestDeathStatus() { - var expected = new DeathStatus(false).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDeathStatus(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DeathStatus(false), ns => ns.SendDeathStatus()); } [Theory] @@ -59,13 +40,10 @@ public class PlayerPacketTests [InlineData(0x1000u, null, null, null)] public void TestDisplayProfile(uint serial, string header, string body, string footer) { - var expected = new DisplayProfile((Serial)serial, header, body, footer).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayProfile((Serial)serial, header, body, footer); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplayProfile((Serial)serial, header, body, footer), + ns => ns.SendDisplayProfile((Serial)serial, header, body, footer) + ); } [Theory] @@ -73,55 +51,31 @@ public class PlayerPacketTests [InlineData(LRReason.TryToSteal)] public void TestLiftRej(LRReason reason) { - var expected = new LiftRej(reason).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLiftReject(reason); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LiftRej(reason), ns => ns.SendLiftReject(reason)); } [Fact] public void TestLogoutAck() { - var expected = new LogoutAck().Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLogoutAck(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LogoutAck(), ns => ns.SendLogoutAck()); } [Theory] [InlineData(1, 2, 3)] [InlineData(4, 5, 6)] - [InlineData(0x1234, 0x5678, 0x9ABC)] - public void TestWeather(int type, int density, int temp) + [InlineData(0xFF, 0x46, 0xEC)] + public void TestWeather(byte type, byte density, byte temp) { - var expected = new Weather(type, density, temp).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendWeather((byte)type, (byte)density, (byte)temp); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new Weather(type, density, temp), ns => ns.SendWeather(type, density, temp)); } - [Theory] - [InlineData(100, 1000, 1, 0)] - public void TestServerChange(int x, int y, int z, int mapID) + [Fact] + public void TestServerChange() { - var p = new Point3D(x, y, z); - var map = Map.Maps[mapID]; - var expected = new ServerChange(p, map).Compile(); + var p = new Point3D(100, 1000, 1); + var map = Map.Maps[0]; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendServerChange(p, map); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ServerChange(p, map), ns => ns.SendServerChange(p, map)); } [Theory] @@ -130,13 +84,7 @@ public class PlayerPacketTests [InlineData(255)] public void TestSequence(byte num) { - var expected = new Sequence(num).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSequence(num); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new Sequence(num), ns => ns.SendSequence(num)); } [Theory] @@ -146,37 +94,22 @@ public class PlayerPacketTests [InlineData("https://www.modernuo.com")] public void TestLaunchBrowser(string uri) { - var expected = new LaunchBrowser(uri).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLaunchBrowser(uri); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LaunchBrowser(uri), ns => ns.SendLaunchBrowser(uri)); } - [Theory] - [InlineData(0x1000u, 1000, 100, 10, 0x2000u, 1125, 125, 5, 0x384, 1024, 25)] - public void TestDragEffect( - uint srcSerial, int srcX, int srcY, int srcZ, - uint trgSerial, int trgX, int trgY, int trgZ, - int itemId, int hue, int amount - ) + [Fact] + public void TestDragEffect() { - var src = new Entity((Serial)srcSerial, new Point3D(srcX, srcY, srcZ), null); - var targ = new Entity((Serial)trgSerial, new Point3D(trgX, trgY, trgZ), null); + var src = new Entity((Serial)0x1000, new Point3D(1000, 100, 10), null); + var targ = new Entity((Serial)0x2000, new Point3D(1125, 125, 5), null); + const int itemId = 0x384; + const int hue = 1024; + const int amount = 25; - var expected = new DragEffect(src, targ, itemId, hue, amount).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDragEffect( - src.Serial, src.Location, - targ.Serial, targ.Location, - itemId, hue, amount + PacketAssert.Sends( + new DragEffect(src, targ, itemId, hue, amount), + ns => ns.SendDragEffect(src.Serial, src.Location, targ.Serial, targ.Location, itemId, hue, amount) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Theory] @@ -184,13 +117,7 @@ public class PlayerPacketTests [InlineData(2, true)] public void TestSeasonChange(int season, bool playSound) { - var expected = new SeasonChange(season, playSound).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSeasonChange((byte)season, playSound); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new SeasonChange(season, playSound), ns => ns.SendSeasonChange((byte)season, playSound)); } [Theory] @@ -199,13 +126,10 @@ public class PlayerPacketTests [InlineData(0x1024u, "Test Title", true, false)] public void TestDisplayPaperdoll(uint m, string title, bool warmode, bool canLift) { - var expected = new DisplayPaperdoll((Serial)m, title, warmode, canLift).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayPaperdoll((Serial)m, title, warmode, canLift); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplayPaperdoll((Serial)m, title, warmode, canLift), + ns => ns.SendDisplayPaperdoll((Serial)m, title, warmode, canLift) + ); } [Theory] @@ -214,13 +138,7 @@ public class PlayerPacketTests [InlineData(MusicName.ValoriaShips)] public void TestPlayMusic(MusicName music) { - var expected = new PlayMusic(music).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPlayMusic(music); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PlayMusic(music), ns => ns.SendPlayMusic(music)); } [Theory] @@ -228,41 +146,23 @@ public class PlayerPacketTests [InlineData(100, 10, "Some more text")] public void TestScrollMessage(int type, int tip, string text) { - var expected = new ScrollMessage(type, tip, text).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendScrollMessage(type, tip ,text); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ScrollMessage(type, tip, text), ns => ns.SendScrollMessage(type, tip, text)); } - [Theory] - [InlineData(14, 10, 05)] - public void TestCurrentTime(int hour, int minute, int second) + [Fact] + public void TestCurrentTime() { - var date = new DateTime(2020, 1, 1, hour, minute, second); - var expected = new CurrentTime(date).Compile(); + var date = new DateTime(2020, 1, 1, 14, 10, 5); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCurrentTime(date); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CurrentTime(date), ns => ns.SendCurrentTime(date)); } - [Theory] - [InlineData(1000, 10, 1)] - public void TestPathfindMessage(int x, int y, int z) + [Fact] + public void TestPathfindMessage() { - var p = new Point3D(x, y, z); - var expected = new PathfindMessage(p).Compile(); + var p = new Point3D(1000, 10, 1); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPathfindMessage(p); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PathfindMessage(p), ns => ns.SendPathfindMessage(p)); } [Theory] @@ -271,27 +171,17 @@ public class PlayerPacketTests [InlineData(100)] public void TestPingAck(byte ping) { - var expected = new PingAck(ping).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPingAck(ping); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PingAck(ping), ns => ns.SendPingAck(ping)); } - [Theory] - [InlineData(0xFF01)] - public void TestDisplayHuePicker(int itemID) + [Fact] + public void TestDisplayHuePicker() { - var huePicker = new HuePicker(itemID); + var huePicker = new HuePicker(0xFF01); - var expected = new DisplayHuePicker(huePicker).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayHuePicker(huePicker.Serial, huePicker.ItemID); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplayHuePicker(huePicker), + ns => ns.SendDisplayHuePicker(huePicker.Serial, huePicker.ItemID) + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/SecureTradePacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/SecureTradePacketTests.cs index 6c10b63e9..ca2934b0c 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/SecureTradePacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/SecureTradePacketTests.cs @@ -18,13 +18,10 @@ public class SecureTradePacketTests var firstCont = new Container(World.NewItem); var secondCont = new Container(World.NewItem); - var expected = new DisplaySecureTrade(m, firstCont, secondCont, name).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplaySecureTrade(m, firstCont, secondCont, name); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplaySecureTrade(m, firstCont, secondCont, name), + ns => ns.SendDisplaySecureTrade(m, firstCont, secondCont, name) + ); } [Fact] @@ -32,13 +29,7 @@ public class SecureTradePacketTests { var cont = new Container(World.NewItem); - var expected = new CloseSecureTrade(cont).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCloseSecureTrade(cont); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CloseSecureTrade(cont), ns => ns.SendCloseSecureTrade(cont)); } [Theory, InlineData(true, false), InlineData(false, true)] @@ -50,13 +41,11 @@ public class SecureTradePacketTests var secondCont = new Container(World.NewItem); var cont = first ? firstCont : secondCont; - var expected = new UpdateSecureTrade(cont, first, second).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendUpdateSecureTrade(cont, first, second); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new UpdateSecureTrade(cont, first, second), + ns => ns.SendUpdateSecureTrade(cont, first, second) + ); } [Theory] @@ -65,13 +54,11 @@ public class SecureTradePacketTests public void TestUpdateGoldSecureTrade(int gold, int plat, TradeFlag flag) { var cont = new Container(World.NewItem); - var expected = new UpdateSecureTrade(cont, flag, gold, plat).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendUpdateSecureTrade(cont, flag, gold, plat); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new UpdateSecureTrade(cont, flag, gold, plat), + ns => ns.SendUpdateSecureTrade(cont, flag, gold, plat) + ); } [Fact] @@ -83,13 +70,7 @@ public class SecureTradePacketTests var cont = new Container(World.NewItem); var itemInCont = new Item(World.NewItem) { Parent = cont }; - var expected = new SecureTradeEquip(itemInCont, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSecureTradeEquip(itemInCont, m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new SecureTradeEquip(itemInCont, m), ns => ns.SendSecureTradeEquip(itemInCont, m)); } [Fact] @@ -101,14 +82,10 @@ public class SecureTradePacketTests var cont = new Container(World.NewItem); var itemInCont = new Item(World.NewItem) { Parent = cont }; - var expected = new SecureTradeEquip6017(itemInCont, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - - ns.SendSecureTradeEquip(itemInCont, m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new SecureTradeEquip6017(itemInCont, m), + ns => ns.SendSecureTradeEquip(itemInCont, m), + ProtocolChanges.ContainerGridLines + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/TargetPacketsTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/TargetPacketsTests.cs index c2d4d29fc..4cc8112b9 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/TargetPacketsTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/TargetPacketsTests.cs @@ -38,14 +38,7 @@ public class TargetPacketsTests var p = new Point3D(1000, 100, 10); MultiTarget t = new TestMultiTarget(multiID, p); - var expected = new MultiTargetReqHS(t).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.HighSeas; - ns.SendMultiTargetReq(t); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MultiTargetReqHS(t), ns => ns.SendMultiTargetReq(t), ProtocolChanges.HighSeas); } [Fact] @@ -55,36 +48,20 @@ public class TargetPacketsTests var p = new Point3D(1000, 100, 10); MultiTarget t = new TestMultiTarget(multiID, p); - var expected = new MultiTargetReq(t).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMultiTargetReq(t); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MultiTargetReq(t), ns => ns.SendMultiTargetReq(t)); } [Fact] public void TestCancelTarget() { - var expected = new CancelTarget().Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCancelTarget(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CancelTarget(), ns => ns.SendCancelTarget()); } [Fact] public void TestTargetReq() { var t = new TestTarget(10, true, TargetFlags.Beneficial); - var expected = new TargetReq(t).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendTargetReq(t); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new TargetReq(t), ns => ns.SendTargetReq(t)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorBuyPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorBuyPacketTests.cs index 6262cc243..10f498a20 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorBuyPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorBuyPacketTests.cs @@ -22,15 +22,11 @@ public class VendorBuyPacketTests new("Third Item", cont.Serial, World.NewItem, 30, 10, 0x0F, 0) }; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = protocolChanges; - - var expected = new VendorBuyContent(buyStates, ns.ContainerGridLines).Compile(); - - ns.SendVendorBuyContent(buyStates); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new VendorBuyContent(buyStates, ns.ContainerGridLines), + ns => ns.SendVendorBuyContent(buyStates), + protocolChanges + ); } [Theory] @@ -41,15 +37,11 @@ public class VendorBuyPacketTests var vendor = new Mobile((Serial)0x1); vendor.DefaultMobileInit(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = protocolChanges; - - var expected = new DisplayBuyList(vendor.Serial, ns.HighSeas).Compile(); - - ns.SendDisplayBuyList(vendor.Serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new DisplayBuyList(vendor.Serial, ns.HighSeas), + ns => ns.SendDisplayBuyList(vendor.Serial), + protocolChanges + ); } [Fact] @@ -67,13 +59,7 @@ public class VendorBuyPacketTests new("Third Item", cont.Serial, World.NewItem, 30, 10, 0x0F, 0) }; - var expected = new VendorBuyList(vendor, buyStates).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendVendorBuyList(vendor, buyStates); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new VendorBuyList(vendor, buyStates), ns => ns.SendVendorBuyList(vendor, buyStates)); } [Fact] @@ -82,12 +68,6 @@ public class VendorBuyPacketTests var vendor = new Mobile((Serial)0x1); vendor.DefaultMobileInit(); - var expected = new EndVendorBuy(vendor.Serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendEndVendorBuy(vendor.Serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new EndVendorBuy(vendor.Serial), ns => ns.SendEndVendorBuy(vendor.Serial)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPacketTests.cs index 154223349..0b911e6bb 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPacketTests.cs @@ -25,13 +25,10 @@ public class VendorSellPacketTests new(item3, 1, "Item 3") }; - var expected = new VendorSellList(vendor, sellStates.ToList()).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendVendorSellList(vendor.Serial, sellStates); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new VendorSellList(vendor, sellStates.ToList()), + ns => ns.SendVendorSellList(vendor.Serial, sellStates) + ); } [Fact] @@ -40,12 +37,6 @@ public class VendorSellPacketTests var vendor = new Mobile((Serial)0x1024u); vendor.DefaultMobileInit(); - var expected = new EndVendorBuy(vendor.Serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendEndVendorSell(vendor.Serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new EndVendorSell(vendor), ns => ns.SendEndVendorSell(vendor.Serial)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs index 3a105d1a4..2d70896f4 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs @@ -15,13 +15,10 @@ public class VirtualHairPacketTests m.HairHue = 0x1000; m.HairItemID = 0x2000; - var expected = new HairEquipUpdate(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendHairEquipUpdatePacket(m, (uint)m.HairSerial, m.HairItemID, m.HairHue, Layer.Hair); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new HairEquipUpdate(m), + ns => ns.SendHairEquipUpdatePacket(m, (uint)m.HairSerial, m.HairItemID, m.HairHue, Layer.Hair) + ); } [Fact] @@ -30,13 +27,7 @@ public class VirtualHairPacketTests var m = new Mobile((Serial)0x1024u); m.DefaultMobileInit(); - var expected = new RemoveHair(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendRemoveHairPacket((uint)m.HairSerial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new RemoveHair(m), ns => ns.SendRemoveHairPacket((uint)m.HairSerial)); } [Fact] diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs index 135a6e698..ac7ebbf59 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs @@ -16,22 +16,6 @@ public sealed class HairEquipUpdate : Packet } } -public sealed class FacialHairEquipUpdate : Packet -{ - public FacialHairEquipUpdate(Mobile parent) - : base(0x2E, 15) - { - var hue = parent.SolidHueOverride >= 0 ? parent.SolidHueOverride : parent.FacialHairHue; - - Stream.Write(parent.FacialHairSerial); - Stream.Write((short)parent.FacialHairItemID); - Stream.Write((byte)0); - Stream.Write((byte)Layer.FacialHair); - Stream.Write(parent.Serial); - Stream.Write((short)hue); - } -} - public sealed class RemoveHair : Packet { public RemoveHair(Mobile parent) @@ -40,12 +24,3 @@ public sealed class RemoveHair : Packet Stream.Write(parent.HairSerial); } } - -public sealed class RemoveFacialHair : Packet -{ - public RemoveFacialHair(Mobile parent) - : base(0x1D, 5) - { - Stream.Write(parent.FacialHairSerial); - } -} diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs index 19a8704b7..6dbef52b1 100644 --- a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs +++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs @@ -102,36 +102,31 @@ public class ObjectPropertyListHashTests // 6- and 8-byte blocks take xxHash3's short-input paths. They still avalanche across all 26 // kept bits, so a counter ticking down never repeats the revision it just had. [Fact] - public void ShortNumericArguments_ConsecutiveValuesDiffer() + public void ShortPropertyBlocks_ConsecutiveValuesDifferAndStayWellDistributed() { - var previous = BuildHash((1060584, "0")); - - for (var charges = 1; charges < 20000; charges++) - { - var current = BuildHash((1060584, charges.ToString())); - Assert.NotEqual(previous, current); - previous = current; - } - } - - [Fact] - public void SmallPropertyBlocks_StayWellDistributed() - { - const int count = 20000; + const int count = 5000; var withArgument = new HashSet(); var withoutArgument = new HashSet(); + var previous = 0; for (var i = 0; i < count; i++) { - withArgument.Add(BuildHash((1060584, i.ToString()))); + var current = BuildHash((1060584, i.ToString())); + if (i > 0) + { + Assert.NotEqual(previous, current); + } + + previous = current; + withArgument.Add(current); withoutArgument.Add(BuildHash((1060000 + i, null))); } - // Birthday expects ~3 collisions over a 26-bit space; allow an order of magnitude so the - // bound holds for any seed. A hash that stopped mixing collapses far past it. - Assert.True(withArgument.Count >= count - 30, $"8-byte blocks: {withArgument.Count}/{count}"); - Assert.True(withoutArgument.Count >= count - 30, $"6-byte blocks: {withoutArgument.Count}/{count}"); + // Birthday expects ~0.2 collisions over a 26-bit space; allow a few so the bound holds for + // any seed. A hash that stopped mixing collapses far past it. + Assert.True(withArgument.Count >= count - 5, $"8-byte blocks: {withArgument.Count}/{count}"); + Assert.True(withoutArgument.Count >= count - 5, $"6-byte blocks: {withoutArgument.Count}/{count}"); } [Fact] diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs index a99b9850c..74edf09c3 100644 --- a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.IO; using Xunit; namespace Server.Tests; @@ -107,83 +105,48 @@ public class AnchoredTimePersistenceTests [Fact] public void SaveStartAnchor_RebasesAnchoredTimestampsAtLoad() { - var previousAssemblies = AssemblyHandler.Assemblies; - AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(AnchoredEntity).Assembly]; + // The scope also restores World.SaveStartTime, which this test backdates. + using var pipeline = new PersistencePipelineScope( + 2, + installWorkers: true, + visibleAssembly: typeof(AnchoredEntity).Assembly, + directoryPrefix: "muo-anchored" + ); + var source = pipeline.Source; + var dir = pipeline.Directory; - var source = new SerializationChunkSource(); - var workers = new SerializationThreadWorker[2]; - for (var i = 0; i < workers.Length; i++) - { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); - } + var persistence = pipeline.Track(new AnchoredPersistence(2100)); - var previousWorkers = World._threadWorkers; - World._threadWorkers = workers; + var lastRested = Core.Now - TimeSpan.FromMinutes(10); + var serial = (Serial)1u; + persistence.EntitiesBySerial[serial] = new AnchoredEntity(serial) { LastRested = lastRested }; + persistence.RegisterType(typeof(AnchoredEntity)); - var previousSaveStart = World.SaveStartTime; + // Pretend the save started two hours ago, as if the server had been down since. + var downtime = TimeSpan.FromHours(2); + World.SaveStartTime = Core.Now - downtime; - var persistence = new AnchoredPersistence(2100); - AnchoredPersistence loaded = null; + pipeline.WakeWorkers(); - var dir = Path.Combine(Path.GetTempPath(), $"muo-anchored-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); + source.SetOwner(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); - try - { - var lastRested = Core.Now - TimeSpan.FromMinutes(10); - var serial = (Serial)1u; - persistence.EntitiesBySerial[serial] = new AnchoredEntity(serial) { LastRested = lastRested }; - persistence.RegisterType(typeof(AnchoredEntity)); + pipeline.FlushAndSleepWorkers(); - // Pretend the save started two hours ago, as if the server had been down since. - var downtime = TimeSpan.FromHours(2); - World.SaveStartTime = Core.Now - downtime; + persistence.WriteSnapshot(dir); + persistence.PostWorldSave(); - foreach (var worker in workers) - { - worker.Wake(); - } + var loaded = pipeline.Track(new AnchoredPersistence(2101)); + loaded.DeserializeIndexes(dir, null); + loaded.Deserialize(dir, null); - source.SetOwner(persistence); - Assert.True(persistence.TrySnapshotEntries(out var slotCount)); - source.PushSlotRanges(persistence, slotCount); + var entity = loaded.EntitiesBySerial[serial]; + var expected = lastRested + downtime; - source.Flush(); - foreach (var worker in workers) - { - worker.Sleep(); - } - - persistence.WriteSnapshot(dir); - persistence.PostWorldSave(); - - loaded = new AnchoredPersistence(2101); - loaded.DeserializeIndexes(dir, null); - loaded.Deserialize(dir, null); - - var entity = loaded.EntitiesBySerial[serial]; - var expected = lastRested + downtime; - - Assert.True( - (entity.LastRested - expected).Duration() <= TimeSpan.FromSeconds(30), - $"Anchored timestamp must re-base by the downtime; expected ~{expected}, got {entity.LastRested}." - ); - } - finally - { - World.SaveStartTime = previousSaveStart; - persistence.Unregister(); - loaded?.Unregister(); - - foreach (var worker in workers) - { - worker.Exit(); - } - - World._threadWorkers = previousWorkers; - AssemblyHandler.Assemblies = previousAssemblies; - Directory.Delete(dir, true); - } + Assert.True( + (entity.LastRested - expected).Duration() <= TimeSpan.FromSeconds(30), + $"Anchored timestamp must re-base by the downtime; expected ~{expected}, got {entity.LastRested}." + ); } } diff --git a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs index 48ba16e55..c8c4522aa 100644 --- a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.IO; using Xunit; namespace Server.Tests; @@ -72,103 +70,68 @@ public class GenericEntityPersistenceRoundTripTests [Fact] public void SnapshotRoundTripsThroughWorkersAndSegmentLogs() { - // The loader resolves types by hash through AssemblyHandler; make this test - // assembly visible for the duration of the test. - var previousAssemblies = AssemblyHandler.Assemblies; - AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(RoundTripEntity).Assembly]; + // The loader resolves types by hash through AssemblyHandler, so this test assembly must be visible. + using var pipeline = new PersistencePipelineScope( + 3, + installWorkers: true, + visibleAssembly: typeof(RoundTripEntity).Assembly, + directoryPrefix: "muo-roundtrip" + ); + var source = pipeline.Source; + var dir = pipeline.Directory; - var source = new SerializationChunkSource(); - var workers = new SerializationThreadWorker[3]; - for (var i = 0; i < workers.Length; i++) + var persistence = pipeline.Track(new RoundTripPersistence(2000)); + + const int entityCount = 25_000; + var rng = new System.Random(0x5EED); + + for (var i = 1; i <= entityCount; i++) { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); + var serial = (Serial)(uint)i; + persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) + { + Value = rng.Next(), + Name = i % 5 == 0 ? null : $"entity-{i}" + }; } - var previousWorkers = World._threadWorkers; - World._threadWorkers = workers; + persistence.RegisterType(typeof(RoundTripEntity)); - var persistence = new RoundTripPersistence(2000); - RoundTripPersistence loaded = null; + // Freeze: what Persistence.SerializeAll + GenericEntityPersistence.Serialize do, + // against this test's chunk source instead of the world's. + pipeline.WakeWorkers(); - var dir = Path.Combine(Path.GetTempPath(), $"muo-roundtrip-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); + source.SetOwner(persistence); + source.PushSingle(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); - try + pipeline.FlushAndSleepWorkers(); + + // Background write phase: snapshot from the segment logs. v4 embeds the type + // table in the idx, so no SerializedTypes.db is produced or needed. + persistence.WriteSnapshot(dir); + + persistence.PostWorldSave(); // releases the entries snapshot + + // Load into a fresh persistence, like a server boot would. + var loaded = pipeline.Track(new RoundTripPersistence(2001)); + loaded.DeserializeIndexes(dir, null); + loaded.Deserialize(dir, null); + + Assert.True(loaded.SelfPayloadDeserialized); + Assert.Equal(persistence.EntitiesBySerial.Count, loaded.EntitiesBySerial.Count); + + foreach (var (serial, original) in persistence.EntitiesBySerial) { - const int entityCount = 25_000; - var rng = new System.Random(0x5EED); - - for (var i = 1; i <= entityCount; i++) - { - var serial = (Serial)(uint)i; - persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) - { - Value = rng.Next(), - Name = i % 5 == 0 ? null : $"entity-{i}" - }; - } - - persistence.RegisterType(typeof(RoundTripEntity)); - - // Freeze: what Persistence.SerializeAll + GenericEntityPersistence.Serialize do, - // against this test's chunk source instead of the world's. - foreach (var worker in workers) - { - worker.Wake(); - } - - source.SetOwner(persistence); - source.PushSingle(persistence); - Assert.True(persistence.TrySnapshotEntries(out var slotCount)); - source.PushSlotRanges(persistence, slotCount); - - // Mirrors World.PauseSerializationThreads - source.Flush(); - foreach (var worker in workers) - { - worker.Sleep(); - } - - // Background write phase: snapshot from the segment logs. v4 embeds the type - // table in the idx, so no SerializedTypes.db is produced or needed. - persistence.WriteSnapshot(dir); - - persistence.PostWorldSave(); // releases the entries snapshot - - // Load into a fresh persistence, like a server boot would. - loaded = new RoundTripPersistence(2001); - loaded.DeserializeIndexes(dir, null); - loaded.Deserialize(dir, null); - - Assert.True(loaded.SelfPayloadDeserialized); - Assert.Equal(persistence.EntitiesBySerial.Count, loaded.EntitiesBySerial.Count); - - foreach (var (serial, original) in persistence.EntitiesBySerial) - { - var entity = loaded.EntitiesBySerial[serial]; - Assert.Equal(original.Value, entity.Value); - Assert.Equal(original.Name, entity.Name); - Assert.Equal(original.Created.Ticks, entity.Created.Ticks); - } - - // Loading must hydrate the type table so the next save can write indexes. - Assert.True(loaded.TryGetTypeIndex(typeof(RoundTripEntity), out _)); + var entity = loaded.EntitiesBySerial[serial]; + Assert.Equal(original.Value, entity.Value); + Assert.Equal(original.Name, entity.Name); + Assert.Equal(original.Created.Ticks, entity.Created.Ticks); } - finally - { - persistence.Unregister(); - loaded?.Unregister(); - foreach (var worker in workers) - { - worker.Exit(); - } - - World._threadWorkers = previousWorkers; - AssemblyHandler.Assemblies = previousAssemblies; - Directory.Delete(dir, true); - } + // Loading must hydrate the type table so the next save can write indexes. + Assert.True(loaded.TryGetTypeIndex(typeof(RoundTripEntity), out _)); } /// @@ -179,70 +142,44 @@ public class GenericEntityPersistenceRoundTripTests [Fact] public void SerializerException_IsRecordedOnTheWorker_AndTheDrainCompletes() { - var source = new SerializationChunkSource(); - var workers = new SerializationThreadWorker[2]; - for (var i = 0; i < workers.Length; i++) + using var pipeline = new PersistencePipelineScope(2); + var source = pipeline.Source; + var workers = pipeline.Workers; + + var persistence = pipeline.Track(new RoundTripPersistence(2002)); + + for (var i = 1; i <= 100; i++) { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); + var serial = (Serial)(uint)i; + persistence.EntitiesBySerial[serial] = i == 50 + ? new ThrowingRoundTripEntity(serial) + : new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" }; } - var persistence = new RoundTripPersistence(2002); + pipeline.WakeWorkers(); - try + source.SetOwner(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); + + pipeline.FlushAndSleepWorkers(); + + Exception error = null; + foreach (var worker in workers) { - for (var i = 1; i <= 100; i++) - { - var serial = (Serial)(uint)i; - persistence.EntitiesBySerial[serial] = i == 50 - ? new ThrowingRoundTripEntity(serial) - : new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" }; - } - - foreach (var worker in workers) - { - worker.Wake(); - } - - source.SetOwner(persistence); - Assert.True(persistence.TrySnapshotEntries(out var slotCount)); - source.PushSlotRanges(persistence, slotCount); - source.Flush(); - - foreach (var worker in workers) - { - worker.Sleep(); - } - - Exception error = null; - foreach (var worker in workers) - { - error ??= worker.Error; - } - - Assert.IsType(error); - persistence.PostWorldSave(); - - // The next drain starts clean. - foreach (var worker in workers) - { - worker.Wake(); - } - - foreach (var worker in workers) - { - worker.Sleep(); - Assert.Null(worker.Error); - } + error ??= worker.Error; } - finally - { - persistence.Unregister(); - foreach (var worker in workers) - { - worker.Exit(); - } + Assert.IsType(error); + persistence.PostWorldSave(); + + // The next drain starts clean. + pipeline.WakeWorkers(); + + foreach (var worker in workers) + { + worker.Sleep(); + Assert.Null(worker.Error); } } @@ -253,60 +190,28 @@ public class GenericEntityPersistenceRoundTripTests [Fact] public void WriteSnapshot_FailsTheSave_InsteadOfDroppingASegment() { - var source = new SerializationChunkSource(); - var workers = new SerializationThreadWorker[2]; - for (var i = 0; i < workers.Length; i++) + using var pipeline = new PersistencePipelineScope(2, installWorkers: true, directoryPrefix: "muo-segmentfail"); + var source = pipeline.Source; + + var persistence = pipeline.Track(new RoundTripPersistence(2003)); + + for (var i = 1; i <= 100; i++) { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); + var serial = (Serial)(uint)i; + persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" }; } - var previousWorkers = World._threadWorkers; - World._threadWorkers = workers; + // Deliberately not registered: the writer cannot index the type. - var persistence = new RoundTripPersistence(2003); - var dir = Path.Combine(Path.GetTempPath(), $"muo-segmentfail-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); + pipeline.WakeWorkers(); - try - { - for (var i = 1; i <= 100; i++) - { - var serial = (Serial)(uint)i; - persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" }; - } + source.SetOwner(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); - // Deliberately not registered: the writer cannot index the type. + pipeline.FlushAndSleepWorkers(); - foreach (var worker in workers) - { - worker.Wake(); - } - - source.SetOwner(persistence); - Assert.True(persistence.TrySnapshotEntries(out var slotCount)); - source.PushSlotRanges(persistence, slotCount); - source.Flush(); - - foreach (var worker in workers) - { - worker.Sleep(); - } - - Assert.Throws(() => persistence.WriteSnapshot(dir)); - persistence.PostWorldSave(); - } - finally - { - persistence.Unregister(); - - foreach (var worker in workers) - { - worker.Exit(); - } - - World._threadWorkers = previousWorkers; - Directory.Delete(dir, true); - } + Assert.Throws(() => persistence.WriteSnapshot(pipeline.Directory)); + persistence.PostWorldSave(); } } diff --git a/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs b/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs index 69387f4e6..24fea88a7 100644 --- a/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs @@ -199,114 +199,91 @@ public class SerializationChunkSourceTests [Fact] public void WorkersDrainAllEntitiesAndLogSegments() { - var source = new SerializationChunkSource(); - var owner = new TestPersistence { PayloadSize = 512 * 1024 }; - var workers = new SerializationThreadWorker[2]; - for (var i = 0; i < workers.Length; i++) + using var pipeline = new PersistencePipelineScope(2); + var source = pipeline.Source; + var workers = pipeline.Workers; + var owner = pipeline.Track(new TestPersistence { PayloadSize = 512 * 1024 }); + + var entities = new List(); + for (var i = 0; i < 10_000; i++) { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); + entities.Add(new TestEntity { PayloadSize = 16 + i % 64 }); } - try + pipeline.WakeWorkers(); + + // A large payload published as a dedicated single chunk (a persistence + // self-payload), interleaved with the bare entity stream. + source.SetOwner(owner); + for (var i = 0; i < entities.Count; i++) { - var entities = new List(); - for (var i = 0; i < 10_000; i++) + if (i == 5000) { - entities.Add(new TestEntity { PayloadSize = 16 + i % 64 }); + source.PushSingle(owner); } - foreach (var worker in workers) - { - worker.Wake(); - } + source.Push(entities[i]); + } - // A large payload published as a dedicated single chunk (a persistence - // self-payload), interleaved with the bare entity stream. - source.SetOwner(owner); - for (var i = 0; i < entities.Count; i++) + pipeline.FlushAndSleepWorkers(); + + long totalEntities = 0; + long totalBytes = 0; + foreach (var worker in workers) + { + totalEntities += worker.EntitiesSerialized; + totalBytes += worker.BytesSerialized; + } + + Assert.Equal(entities.Count + 1, totalEntities); + + // The self-payload recorded its placement on the persistence itself. + var (selfThread, selfPosition, selfLength) = owner.Placement; + Assert.Equal(owner.PayloadSize, selfLength); + Assert.InRange(selfThread, (byte)0, (byte)(workers.Length - 1)); + var selfHeap = workers[selfThread].GetHeap(selfPosition, selfLength); + Assert.Equal(0, selfHeap[0]); + Assert.Equal((selfLength - 1) & 0xFF, selfHeap[^1]); + + // Every entity appears exactly once in the worker segment logs, with a + // consistent span on that worker's heap: positions are implicit (contiguous + // writes), identity comes from the buffer-entities log. + var seen = new HashSet(); + long expectedBytes = owner.PayloadSize; + foreach (var e in entities) + { + expectedBytes += e.PayloadSize; + } + + foreach (var worker in workers) + { + var lengths = worker.Lengths; + var bufferEntities = worker.BufferEntities; + + foreach (var segment in worker.Segments) { - if (i == 5000) + Assert.Same(owner, segment.Owner); + Assert.Equal(-1, segment.SlotOffset); + + var heapPos = (int)segment.HeapStart; + for (var i = 0; i < segment.RecordCount; i++) { - source.PushSingle(owner); - } + var entity = (TestEntity)bufferEntities[segment.EntitiesStart + i]; + var length = lengths[segment.LengthsStart + i]; - source.Push(entities[i]); - } + Assert.True(seen.Add(entity)); + Assert.Equal(entity.PayloadSize, length); - // Mirrors World.PauseSerializationThreads - source.Flush(); - foreach (var worker in workers) - { - worker.Sleep(); - } + var heap = workers[Array.IndexOf(workers, worker)].GetHeap(heapPos, length); + Assert.Equal(0, heap[0]); + Assert.Equal((length - 1) & 0xFF, heap[^1]); - long totalEntities = 0; - long totalBytes = 0; - foreach (var worker in workers) - { - totalEntities += worker.EntitiesSerialized; - totalBytes += worker.BytesSerialized; - } - - Assert.Equal(entities.Count + 1, totalEntities); - - // The self-payload recorded its placement on the persistence itself. - var (selfThread, selfPosition, selfLength) = owner.Placement; - Assert.Equal(owner.PayloadSize, selfLength); - Assert.InRange(selfThread, (byte)0, (byte)(workers.Length - 1)); - var selfHeap = workers[selfThread].GetHeap(selfPosition, selfLength); - Assert.Equal(0, selfHeap[0]); - Assert.Equal((selfLength - 1) & 0xFF, selfHeap[^1]); - - // Every entity appears exactly once in the worker segment logs, with a - // consistent span on that worker's heap: positions are implicit (contiguous - // writes), identity comes from the buffer-entities log. - var seen = new HashSet(); - long expectedBytes = owner.PayloadSize; - foreach (var e in entities) - { - expectedBytes += e.PayloadSize; - } - - foreach (var worker in workers) - { - var lengths = worker.Lengths; - var bufferEntities = worker.BufferEntities; - - foreach (var segment in worker.Segments) - { - Assert.Same(owner, segment.Owner); - Assert.Equal(-1, segment.SlotOffset); - - var heapPos = (int)segment.HeapStart; - for (var i = 0; i < segment.RecordCount; i++) - { - var entity = (TestEntity)bufferEntities[segment.EntitiesStart + i]; - var length = lengths[segment.LengthsStart + i]; - - Assert.True(seen.Add(entity)); - Assert.Equal(entity.PayloadSize, length); - - var heap = workers[Array.IndexOf(workers, worker)].GetHeap(heapPos, length); - Assert.Equal(0, heap[0]); - Assert.Equal((length - 1) & 0xFF, heap[^1]); - - heapPos += length; - } + heapPos += length; } } + } - Assert.Equal(entities.Count, seen.Count); - Assert.Equal(expectedBytes, totalBytes); - } - finally - { - owner.Unregister(); - foreach (var worker in workers) - { - worker.Exit(); - } - } + Assert.Equal(entities.Count, seen.Count); + Assert.Equal(expectedBytes, totalBytes); } } diff --git a/Projects/Server.Tests/Tests/World/VirtualSerialTests.cs b/Projects/Server.Tests/Tests/World/VirtualSerialTests.cs index 6c841f9f8..376fd661e 100644 --- a/Projects/Server.Tests/Tests/World/VirtualSerialTests.cs +++ b/Projects/Server.Tests/Tests/World/VirtualSerialTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using Xunit; namespace Server.Tests; @@ -5,20 +6,38 @@ namespace Server.Tests; [Collection("Sequential Server Tests")] public class VirtualSerialTests { + // Walking up from ResetVirtualSerial takes ~17.9 million draws; start just short of the wrap. + private const uint DrawsBeforeMax = 1000; + + private static readonly FieldInfo _nextVirtualSerial = + typeof(World).GetField("_nextVirtualSerial", BindingFlags.NonPublic | BindingFlags.Static); + [Fact] public void TestNewVirtualGetsAndRollover() { - // Acquire virtual serials until we hit the max - var lastSerial = World.NewVirtual; - do - { - var virtualSerial = World.NewVirtual; - Assert.Equal(lastSerial + 1, virtualSerial); - lastSerial = (Serial) (uint) virtualSerial; - } while (lastSerial != World.MaxVirtualSerial); + Assert.NotNull(_nextVirtualSerial); - // Next one should be MaxItemSerial (the first VirtualSerial) due to rollover - var nextSerial = World.NewVirtual; - Assert.Equal(nextSerial, (Serial)World.ResetVirtualSerial); + var saved = (uint)_nextVirtualSerial.GetValue(null); + _nextVirtualSerial.SetValue(null, World.MaxVirtualSerial - DrawsBeforeMax); + + try + { + // Acquire virtual serials until we hit the max + var lastSerial = World.NewVirtual; + do + { + var virtualSerial = World.NewVirtual; + Assert.Equal(lastSerial + 1, virtualSerial); + lastSerial = (Serial)(uint)virtualSerial; + } while (lastSerial != World.MaxVirtualSerial); + + // Next one should be MaxItemSerial (the first VirtualSerial) due to rollover + var nextSerial = World.NewVirtual; + Assert.Equal(nextSerial, (Serial)World.ResetVirtualSerial); + } + finally + { + _nextVirtualSerial.SetValue(null, saved); + } } } diff --git a/Projects/UOContent.Tests/Helpers/AiTestStubs.cs b/Projects/UOContent.Tests/Helpers/AiTestStubs.cs new file mode 100644 index 000000000..9d954c062 --- /dev/null +++ b/Projects/UOContent.Tests/Helpers/AiTestStubs.cs @@ -0,0 +1,42 @@ +using Server.Mobiles; + +namespace Server.Tests; + +/// +/// A plain : walker, target or bystander. Deriving straight from Mobile rather +/// than BaseCreature is the point: MovementImpl sees no creature capabilities (no swim, no fly, no +/// door-opening), which is exactly the walker the step cache bakes for. +/// +public sealed class WalkerStub : Mobile +{ + public WalkerStub() => Body = 0xC9; +} + +/// +/// A BaseCreature built through the Serial constructor, which skips the NPCSpeeds lookup and +/// leaves AIObject unset: call and attach an AI by hand. +/// +public sealed class BareCreatureStub : BaseCreature +{ + public BareCreatureStub(Serial serial) : base(serial) => Body = 0xC9; +} + +public static class TestTiles +{ + /// + /// The first item ID whose tile data is an impassable surface, so a test can wall a cell off + /// without pinning a specific graphic. 0 when tile data is not loaded. + /// + public static ushort FirstImpassableItemId() + { + for (ushort id = 1; id < TileData.MaxItemValue; id++) + { + if (TileData.ItemTable[id].ImpassableSurface) + { + return id; + } + } + + return 0; + } +} diff --git a/Projects/UOContent.Tests/Helpers/CommandTestHarness.cs b/Projects/UOContent.Tests/Helpers/CommandTestHarness.cs new file mode 100644 index 000000000..61ee1f6b3 --- /dev/null +++ b/Projects/UOContent.Tests/Helpers/CommandTestHarness.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using Server.Commands; +using Server.Items; + +namespace Server.Tests; + +/// +/// Shared setup for the generic-command compiler tests: teleporters to run conditions, sorts and +/// distincts against, deleted on dispose, and property bindings resolved for reading. +/// +internal sealed class CommandTestHarness : IDisposable +{ + private readonly List _items = []; + + public T Track(T item) where T : Item + { + _items.Add(item); + return item; + } + + public SkillTeleporter Teleporter(TextDefinition message) => Track(new SkillTeleporter { Message = message }); + + public static Property Bind(Type type, string binding) + { + var prop = new Property(binding); + prop.BindTo(type, PropertyAccess.Read); + return prop; + } + + public void Dispose() + { + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + + _items.Clear(); + } +} diff --git a/Projects/UOContent.Tests/Helpers/MobileTestHarness.cs b/Projects/UOContent.Tests/Helpers/MobileTestHarness.cs new file mode 100644 index 000000000..30b86f6d6 --- /dev/null +++ b/Projects/UOContent.Tests/Helpers/MobileTestHarness.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using Server.Mobiles; + +namespace Server.Tests; + +/// +/// Owns the mobiles and items a test creates and deletes them on dispose, so a failed assertion +/// cannot leave entities registered in the shared world. Hold one per test class instance and +/// dispose it from the class's . +/// +internal sealed class MobileTestHarness : IDisposable +{ + private readonly List _mobiles = []; + private readonly List _items = []; + + public T Track(T mobile) where T : Mobile + { + _mobiles.Add(mobile); + return mobile; + } + + public T TrackItem(T item) where T : Item + { + _items.Add(item); + return item; + } + + /// + /// A player registered in the world table, so serialized references to it resolve on load. + /// + public PlayerMobile NewPlayer() + { + var pm = new PlayerMobile(World.NewMobile); + pm.DefaultMobileInit(); + World.AddEntity(pm); + return Track(pm); + } + + public TestCreature NewCreature() => Track(new TestCreature()); + + public void Dispose() + { + for (var i = 0; i < _mobiles.Count; i++) + { + _mobiles[i].Delete(); + } + + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + } +} + +/// +/// A plain melee creature. Its speeds are pinned so tests do not depend on the npc-speeds table. +/// +internal class TestCreature : BaseCreature +{ + public TestCreature() : base(AIType.AI_Melee) => Body = 0xC9; + + public TestCreature(Serial serial) : base(serial) => Body = 0xC9; + + public DateTime SummonEndValue => SummonEnd; + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + + public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) + { + activeMoveSpeed = 0.6; + passiveMoveSpeed = 1.2; + } +} + +/// +/// A vendor with no outfit and pinned speeds. Subclasses stock it by overriding +/// , which runs from the base constructor. +/// +internal class TestVendor : BaseVendor +{ + // Initialized before the base constructor calls InitSBInfo. + private readonly List _sbInfos = []; + + public TestVendor() : base("the stub") + { + } + + public TestVendor(Serial serial) : base(serial) + { + } + + protected override List SBInfos => _sbInfos; + + public override void InitSBInfo() => AddSBInfos(_sbInfos); + + protected virtual void AddSBInfos(List sbInfos) + { + } + + public override void InitOutfit() + { + } + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } +} diff --git a/Projects/UOContent.Tests/Helpers/StepCacheStateScope.cs b/Projects/UOContent.Tests/Helpers/StepCacheStateScope.cs new file mode 100644 index 000000000..39e22fb63 --- /dev/null +++ b/Projects/UOContent.Tests/Helpers/StepCacheStateScope.cs @@ -0,0 +1,45 @@ +using System; +using Server.Engines.Pathing.Cache; +using Server.Systems.FeatureFlags; + +namespace Server.Tests; + +/// +/// Snapshots the pathfinding cache's tuning knobs and feature flag, and restores them on dispose +/// along with empty caches. StepCache and MultiMaskCache are process-wide singletons and +/// leaves the knobs alone, so a test that tunes one would otherwise +/// leak it into every test that runs after it. +/// +internal sealed class StepCacheStateScope : IDisposable +{ + private readonly int _missPromotionThreshold; + private readonly uint _missPromotionWindowMs; + private readonly int _maxResidentChunks; + private readonly bool _preloadOnLazyOpen; + private readonly bool _cacheEnabled; + + public StepCacheStateScope() + { + var cache = StepCache.Instance; + _missPromotionThreshold = cache.MissPromotionThreshold; + _missPromotionWindowMs = cache.MissPromotionWindowMs; + _maxResidentChunks = cache.MaxResidentChunks; + _preloadOnLazyOpen = cache.PreloadOnLazyOpen; + _cacheEnabled = ContentFeatureFlags.BitmapPathfindingCache; + } + + public void Dispose() + { + var cache = StepCache.Instance; + + // Clear first: it closes any lazy reader a test opened under the knobs it set. + cache.Clear(); + MultiMaskCache.Instance.Clear(); + + cache.MissPromotionThreshold = _missPromotionThreshold; + cache.MissPromotionWindowMs = _missPromotionWindowMs; + cache.MaxResidentChunks = _maxResidentChunks; + cache.PreloadOnLazyOpen = _preloadOnLazyOpen; + ContentFeatureFlags.BitmapPathfindingCache = _cacheEnabled; + } +} diff --git a/Projects/UOContent.Tests/Helpers/TempDirectory.cs b/Projects/UOContent.Tests/Helpers/TempDirectory.cs new file mode 100644 index 000000000..a88f54d19 --- /dev/null +++ b/Projects/UOContent.Tests/Helpers/TempDirectory.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; + +namespace Server.Tests; + +/// +/// A uniquely named directory under the system temp path, deleted with its contents on dispose so a +/// failing assertion cannot leave files behind. +/// +internal sealed class TempDirectory : IDisposable +{ + public TempDirectory(string prefix = "modernuo-test") + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"{prefix}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public string Combine(string relativePath) => System.IO.Path.Combine(Path, relativePath); + + /// Writes to and returns its full path. + public string WriteFile(string relativePath, string content) + { + var path = Combine(relativePath); + File.WriteAllText(path, content); + return path; + } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, true); + } + } + catch (IOException) + { + // A handle still open on Windows; the OS temp cleaner reclaims it. + } + } +} diff --git a/Projects/UOContent.Tests/Helpers/TimerWheelScope.cs b/Projects/UOContent.Tests/Helpers/TimerWheelScope.cs new file mode 100644 index 000000000..daabd3188 --- /dev/null +++ b/Projects/UOContent.Tests/Helpers/TimerWheelScope.cs @@ -0,0 +1,140 @@ +using System; +using System.Reflection; + +namespace Server.Tests; + +/// +/// Gives a test its own simulated clock and an empty timer wheel, and puts the process-wide ones +/// back on dispose. Core's tick, Core.Now and the wheel are statics shared by every test in the +/// host, so a test that resets them without restoring hands its clock to whatever runs next. +/// +/// +/// Timers started inside the scope are stopped on dispose; timers that were scheduled before it +/// resume on the restored wheel. +/// +internal sealed class TimerWheelScope : IDisposable +{ + private const BindingFlags PrivateStatic = BindingFlags.NonPublic | BindingFlags.Static; + + private static readonly FieldInfo _ringsField = GetTimerField("_rings"); + private static readonly FieldInfo _ringIndexesField = GetTimerField("_ringIndexes"); + private static readonly FieldInfo _lastTickTurnedField = GetTimerField("_lastTickTurned"); + + private readonly long _savedTick; + private readonly DateTime _savedNow; + private readonly Timer[][] _savedRings; + private readonly int[] _savedRingIndexes; + private readonly long _savedLastTickTurned; + private bool _disposed; + + public TimerWheelScope(long startTick = 0) + { + _savedTick = Core._tickCount; + _savedNow = Core._now; + _savedRings = (Timer[][])Rings.Clone(); + _savedRingIndexes = (int[])RingIndexes.Clone(); + _savedLastTickTurned = (long)_lastTickTurnedField.GetValue(null)!; + + Core._tickCount = startTick; + Timer.Init(startTick); + } + + private static Timer[][] Rings => (Timer[][])_ringsField.GetValue(null)!; + + private static int[] RingIndexes => (int[])_ringIndexesField.GetValue(null)!; + + /// The wheel's tick rate: one turn per call keeps it in lockstep with Core.TickCount. + public static int Step => Timer.TickRate; + + /// Advances the clock by , turning the wheel every step. + public static void RunFor(long ms) + { + var deadline = Core._tickCount + ms; + + while (Core._tickCount - deadline < 0) + { + Core._tickCount += Step; + Timer.Slice(Core._tickCount); + } + } + + /// + /// Advances the clock step by step until holds or + /// has passed. Returns the condition's final value. + /// + public static bool RunUntil(Func condition, long maxMs) + { + var deadline = Core._tickCount + maxMs; + + while (Core._tickCount - deadline < 0) + { + if (condition()) + { + return true; + } + + Core._tickCount += Step; + Timer.Slice(Core._tickCount); + } + + return condition(); + } + + /// Jumps the tick and Core.Now forward together, then fires everything that came due. + public static void Advance(TimeSpan time) + { + Core._now += time; + Core._tickCount += (long)time.TotalMilliseconds; + Timer.Slice(Core._tickCount); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + StopAll(Rings); + + var rings = Rings; + for (var i = 0; i < rings.Length; i++) + { + rings[i] = _savedRings[i]; + } + + Array.Copy(_savedRingIndexes, RingIndexes, _savedRingIndexes.Length); + _lastTickTurnedField.SetValue(null, _savedLastTickTurned); + + Core._tickCount = _savedTick; + Core._now = _savedNow; + } + + // Stop() unlinks the slot's head and promotes the next timer, so draining heads empties a slot. + private static void StopAll(Timer[][] rings) + { + for (var i = 0; i < rings.Length; i++) + { + var ring = rings[i]; + for (var slot = 0; slot < ring.Length; slot++) + { + Timer head; + while ((head = ring[slot]) != null) + { + head.Stop(); + + if (ring[slot] == head) + { + // A head that was already stopped never unlinks itself. + ring[slot] = null; + } + } + } + } + } + + private static FieldInfo GetTimerField(string name) => + typeof(Timer).GetField(name, PrivateStatic) + ?? throw new InvalidOperationException($"Timer.{name} not found via reflection; the timer wheel was refactored."); +} diff --git a/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs b/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs index 29a1b818f..c0901ca48 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs @@ -1,6 +1,7 @@ using System; using Server.Accounting; using Server.Accounting.Security; +using Server.Tests.Accounting.Security; using Xunit; namespace Server.Tests.Accounting; @@ -64,7 +65,9 @@ public class AccountPasswordTests : IDisposable "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; Assert.True(account.CheckPassword(Password)); - Assert.StartsWith("$argon2id$v=19$m=16384,t=1,p=1$", account.Password); + // The cost parameters are shard-tunable; the variant and version are not. + Assert.StartsWith("$argon2id$v=19$", account.Password); + Assert.StartsWith(Argon2TestParameters.CurrentPrefix, account.Password); // Already current: verifying again must not rewrite the hash. var afterFirst = account.Password; diff --git a/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs b/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs index df499509e..3412feab9 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Threading; using Server.Accounting; using Server.Accounting.Security; +using Server.Tests.Accounting.Security; using Xunit; namespace Server.Tests.Accounting; @@ -13,12 +15,27 @@ public class PasswordWorkerTests : IDisposable private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm; + // The constructor registers each account in the process-wide Accounts table. + private readonly List _created = []; + public PasswordWorkerTests() => AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; - public void Dispose() => AccountSecurity.CurrentAlgorithm = _originalAlgorithm; + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } - private static Account CreateAccount(string username) => - Accounts.GetAccount(username) as Account ?? new Account(username, Password); + AccountSecurity.CurrentAlgorithm = _originalAlgorithm; + } + + private Account CreateAccount(string username) + { + var account = new Account(username, Password); + _created.Add(account); + return account; + } /// /// Enqueues work, then pumps the loop context until or the deadline. @@ -136,7 +153,8 @@ public class PasswordWorkerTests : IDisposable var outcome = PasswordWorker.ComputeInline(JobFor(account, Password)); Assert.True(outcome.Verified); - Assert.StartsWith("$argon2id$v=19$m=16384,t=1,p=1$", outcome.Hash); + Assert.StartsWith("$argon2id$v=19$", outcome.Hash); + Assert.StartsWith(Argon2TestParameters.CurrentPrefix, outcome.Hash); } [Fact] diff --git a/Projects/UOContent.Tests/Tests/Accounting/Security/Argon2TestParameters.cs b/Projects/UOContent.Tests/Tests/Accounting/Security/Argon2TestParameters.cs new file mode 100644 index 000000000..8e62573ac --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Accounting/Security/Argon2TestParameters.cs @@ -0,0 +1,55 @@ +using System; +using Server.Accounting.Security; + +namespace Server.Tests.Accounting.Security; + +/// +/// The PHC parameter prefix the configured writes, read from +/// a real hash so a shard that tunes the Argon2 cost does not break assertions about the format. +/// +internal static class Argon2TestParameters +{ + /// For example $argon2id$v=19$m=16384,t=1,p=1$, ending at the salt. + public static readonly string CurrentPrefix = ReadPrefix(); + + public static string Type => CurrentPrefix.Split('$')[1]; + + public static string Version => CurrentPrefix.Split('$')[2]; + + public static int Memory => ReadCost('m'); + + public static int Time => ReadCost('t'); + + public static int Parallelism => ReadCost('p'); + + public static string BuildPrefix(string type, int memory, int time, int parallelism) => + $"${type}${Version}$m={memory},t={time},p={parallelism}$"; + + private static string ReadPrefix() + { + var hash = Argon2PasswordProtection.Instance.EncryptPassword("prefix-probe"); + + // $type$version$params$salt$digest: the prefix runs through the fourth '$'. + var end = 0; + for (var i = 0; i < 4; i++) + { + end = hash.IndexOf('$', end) + 1; + } + + return hash[..end]; + } + + private static int ReadCost(char key) + { + var costs = CurrentPrefix.Split('$')[3].Split(','); + for (var i = 0; i < costs.Length; i++) + { + if (costs[i][0] == key) + { + return int.Parse(costs[i].AsSpan(2)); + } + } + + return -1; + } +} diff --git a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs index dc16a7f5b..382a4bc0d 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs @@ -113,34 +113,60 @@ public class PasswordProtectionTest Assert.False(Argon2PasswordProtection.Instance.ValidatePassword(LegacyArgon2iHash, "wrong")); } - [Theory] - // type, memory, time, parallelism -> expected NeedsRehash - [InlineData("argon2id", 16384, 1, 1, false)] // current defaults - [InlineData("argon2i", 8192, 3, 1, true)] // the old shipping default - [InlineData("argon2id", 8192, 1, 1, true)] // right type, stale memory - [InlineData("argon2id", 16384, 3, 1, true)] // right type, stale iterations - [InlineData("argon2id", 16384, 1, 2, true)] // right type, stale parallelism - [InlineData("argon2i", 16384, 1, 1, true)] // right cost, stale type - public void Argon2_NeedsRehash_ComparesTypeAndCost( - string type, int memory, int time, int parallelism, bool expected - ) - { - var hash = $"${type}$v=19$m={memory},t={time},p={parallelism}$" + - "LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; + private const string SaltAndDigest = "LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; - Assert.Equal(expected, Argon2PasswordProtection.Instance.NeedsRehash(hash)); + [Fact] + public void Argon2_NeedsRehash_IsFalseForCurrentParameters() + { + var hash = Argon2TestParameters.CurrentPrefix + SaltAndDigest; + + Assert.False(Argon2PasswordProtection.Instance.NeedsRehash(hash)); + } + + // Each case differs from the configured parameters in exactly one field, so the test holds + // whatever cost a shard configures. + [Theory] + [InlineData("type")] + [InlineData("memory")] + [InlineData("time")] + [InlineData("parallelism")] + public void Argon2_NeedsRehash_ComparesTypeAndCost(string staleField) + { + var type = Argon2TestParameters.Type; + var memory = Argon2TestParameters.Memory; + var time = Argon2TestParameters.Time; + var parallelism = Argon2TestParameters.Parallelism; + var otherType = type == "argon2id" ? "argon2i" : "argon2id"; + + (type, memory, time, parallelism) = staleField switch + { + "type" => (otherType, memory, time, parallelism), + "memory" => (type, memory * 2, time, parallelism), + "time" => (type, memory, time + 1, parallelism), + _ => (type, memory, time, parallelism + 1) + }; + + var hash = Argon2TestParameters.BuildPrefix(type, memory, time, parallelism) + SaltAndDigest; + + Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(hash)); + } + + [Fact] + public void Argon2_NeedsRehash_IsTrueForLegacyShippingDefault() + { + Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(LegacyArgon2iHash)); } // Digest and salt lengths are decoded base64 sizes rather than parameter-list entries, so they // need their own literals. Current type and cost throughout; only a length differs. [Theory] // 16-byte digest: 22 base64 chars instead of the 43 a 32-byte digest encodes to. - [InlineData("$argon2id$v=19$m=16384,t=1,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4g")] + [InlineData("LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4g")] // 8-byte salt: 11 base64 chars instead of the 22 a 16-byte salt encodes to. - [InlineData("$argon2id$v=19$m=16384,t=1,p=1$LD1XJz7P3wQ$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw")] - public void Argon2_NeedsRehash_ComparesSaltAndDigestLengths(string hash) + [InlineData("LD1XJz7P3wQ$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw")] + public void Argon2_NeedsRehash_ComparesSaltAndDigestLengths(string saltAndDigest) { - Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(hash)); + Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(Argon2TestParameters.CurrentPrefix + saltAndDigest)); } [Theory] diff --git a/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs index 80e664647..dbeba969e 100644 --- a/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs @@ -4,6 +4,7 @@ using Server; using Server.Commands; using Server.Commands.Generic; using Server.Items; +using Server.Tests; using Xunit; namespace UOContent.Tests.Commands; @@ -14,31 +15,13 @@ namespace UOContent.Tests.Commands; [Collection("Sequential UOContent Tests")] public class ChainedBindingSortTests : IDisposable { - private readonly List _items = []; + private readonly CommandTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _items.Count; i++) - { - _items[i].Delete(); - } + public void Dispose() => _harness.Dispose(); - _items.Clear(); - } + private SkillTeleporter Teleporter(TextDefinition message) => _harness.Teleporter(message); - private SkillTeleporter Teleporter(TextDefinition message) - { - var tp = new SkillTeleporter { Message = message }; - _items.Add(tp); - return tp; - } - - private static Property Bind(string binding) - { - var prop = new Property(binding); - prop.BindTo(typeof(SkillTeleporter), PropertyAccess.Read); - return prop; - } + private static Property Bind(string binding) => CommandTestHarness.Bind(typeof(SkillTeleporter), binding); private static IComparer Sorter(string binding) => SortCompiler.Compile( @@ -107,6 +90,4 @@ public class ChainedBindingSortTests : IDisposable Assert.True(comparer.Compare(blank, valued) < 0); Assert.True(comparer.Compare(valued, blank) > 0); } - - // An unchained binding never had the problem and must keep working untouched. } diff --git a/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs b/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs index f430973a9..31b1de29d 100644 --- a/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs @@ -4,42 +4,25 @@ using Server; using Server.Commands; using Server.Commands.Generic; using Server.Items; +using Server.Tests; using Xunit; namespace UOContent.Tests.Commands; -// Two shapes the IL emitter got wrong or could not express. A chained binding ending in a value -// type (Message.Number) reused a temp while its value was still live, so every pair compared -// equal and `sort by` was a no-op. A struct with no CompareTo fell through to a raw Ceq, which is -// not valid IL for a non-primitive struct. Both are pinned here against the expression-tree port. +// Two shapes the compiled comparers must handle. A chained binding ending in a value type +// (Message.Number) must order and separate by that value, or every pair compares equal and +// `sort by` does nothing. A struct with no CompareTo compares by value equality for == and !=, +// and the relational operators are rejected when the condition is compiled. [Collection("Sequential UOContent Tests")] public class ConditionalCompilerEdgeTests : IDisposable { - private readonly List _items = []; + private readonly CommandTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _items.Count; i++) - { - _items[i].Delete(); - } + public void Dispose() => _harness.Dispose(); - _items.Clear(); - } + private SkillTeleporter Teleporter(TextDefinition message) => _harness.Teleporter(message); - private SkillTeleporter Teleporter(TextDefinition message) - { - var tp = new SkillTeleporter { Message = message }; - _items.Add(tp); - return tp; - } - - private static Property Bind(Type type, string binding) - { - var prop = new Property(binding); - prop.BindTo(type, PropertyAccess.Read); - return prop; - } + private static Property Bind(Type type, string binding) => CommandTestHarness.Bind(type, binding); [Fact] public void SortingOnAChainedValueTypeOrdersValues() diff --git a/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs b/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs index e1068f6e2..c135480da 100644 --- a/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs @@ -4,6 +4,7 @@ using Server; using Server.Commands; using Server.Commands.Generic; using Server.Items; +using Server.Tests; using Xunit; namespace UOContent.Tests.Commands; @@ -14,23 +15,14 @@ namespace UOContent.Tests.Commands; [Collection("Sequential UOContent Tests")] public class DistinctComparerTests : IDisposable { - private readonly List _items = []; + private readonly CommandTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _items.Count; i++) - { - _items[i].Delete(); - } - - _items.Clear(); - } + public void Dispose() => _harness.Dispose(); private SkillTeleporter Teleporter(Action setup = null) { - var tp = new SkillTeleporter(); + var tp = _harness.Track(new SkillTeleporter()); setup?.Invoke(tp); - _items.Add(tp); return tp; } @@ -40,8 +32,7 @@ public class DistinctComparerTests : IDisposable for (var i = 0; i < bindings.Length; i++) { - props[i] = new Property(bindings[i]); - props[i].BindTo(typeof(SkillTeleporter), PropertyAccess.Read); + props[i] = CommandTestHarness.Bind(typeof(SkillTeleporter), bindings[i]); } return (IEqualityComparer)DistinctCompiler.Compile(typeof(SkillTeleporter), props); diff --git a/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs b/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs index ef10033eb..e8cef86c6 100644 --- a/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs @@ -6,10 +6,8 @@ using Xunit; namespace UOContent.Tests.Commands; -// The conditional compiler emits into a fresh dynamic assembly for every command invocation -- -// there is no per-type cache, so `Run` (which can never be unloaded) grows the process by one -// assembly per `[global where`. And PropertyValue could only load a handful of primitive constant -// types, so a comparison against anything narrower or unsigned than int threw instead of running. +// A comparison constant is converted to the property's own type, however narrow or unsigned, so +// conditions on byte, sbyte, short, ushort, uint and ulong properties run and compare correctly. public class EmitterRobustnessTests { public class Subject diff --git a/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs b/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs index 3d43eca42..3088e2d26 100644 --- a/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs @@ -3,37 +3,27 @@ using System.Collections.Generic; using Server; using Server.Commands.Generic; using Server.Items; +using Server.Tests; using Xunit; namespace UOContent.Tests.Commands; -// `where` compiles conditions to IL. Two shapes were broken there: a property type with value -// equality but no IComparable fell through to a raw Ceq (reference equality, so never true), and -// a chained binding dereferenced every link with no null guard (so the first object with a null -// intermediate took the whole sweep down with an NRE). +// `where` compiles conditions into a delegate. A property type with value equality but no +// IComparable compares by value, not reference identity, and a chained binding with a null +// intermediate makes the condition false rather than throwing and ending the whole sweep. [Collection("Sequential UOContent Tests")] public class ObjectConditionalTests : IDisposable { - private readonly List _items = []; + private readonly CommandTestHarness _harness = new(); private readonly Mobile _from = new() { AccessLevel = AccessLevel.Developer }; public void Dispose() { - for (var i = 0; i < _items.Count; i++) - { - _items[i].Delete(); - } - - _items.Clear(); + _harness.Dispose(); _from.Delete(); } - private SkillTeleporter Teleporter(TextDefinition message) - { - var tp = new SkillTeleporter { Message = message }; - _items.Add(tp); - return tp; - } + private SkillTeleporter Teleporter(TextDefinition message) => _harness.Teleporter(message); private bool Check(object target, params string[] condition) { diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs index df795a440..cbd818c40 100644 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs @@ -58,8 +58,7 @@ public class AdvancedSearchConditionsTests [InlineData("Hue")] // no operator public void UnusableLeafIsNoMatchAndDoesNotThrow(string test) { - var ex = Record.Exception(() => Assert.False(Check(test))); - Assert.Null(ex); + Assert.False(Check(test)); } // The old evaluator negated a failed comparison, so `~Hue=abc` matched every entity. @@ -195,8 +194,7 @@ public class AdvancedSearchConditionsTests [Fact] public void ReferenceTypeOrderingIsNoMatchAndDoesNotThrow() { - var ex = Record.Exception(() => Assert.False(Check("Reference>whatever"))); - Assert.Null(ex); + Assert.False(Check("Reference>whatever")); } [Fact] diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs index 36f97eaf0..a7149eeae 100644 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs @@ -4,6 +4,7 @@ using Xunit; namespace UOContent.Tests; +// Registers the poison table, which is process-wide, so this stays in the sequential collection. [Collection("Sequential UOContent Tests")] public class AdvancedSearchTypesTests { @@ -15,7 +16,11 @@ public class AdvancedSearchTypesTests [Fact] public void Poison_ReferenceTypeParsedViaTypes() { - PoisonKinds.Configure(); // idempotent; registers Lesser..Lethal now that Core.Expansion is set + // The fixture does not run PoisonKinds.Configure, and a second registration throws. + if (Poison.Poisons.Count == 0) + { + PoisonKinds.Configure(); + } // Poison is a reference type implementing ISpanParsable; its value routes through the shared // Server.Types converter. Poison.Parse returns the registered singleton, so "= Lethal" is a @@ -26,8 +31,6 @@ public class AdvancedSearchTypesTests Assert.True(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=Lethal")(subject)); Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=Lesser")(subject)); - var ex = Record.Exception(() => - Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=notapoison")(subject))); - Assert.Null(ex); + Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=notapoison")(subject)); } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs b/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs index addfea7d0..4927d6813 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs @@ -2,13 +2,19 @@ using System; using System.Collections.Generic; using Server; using Server.Engines.Events; +using Server.Tests; using Xunit; namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] -public class EventSchedulerTests +public class EventSchedulerTests : IDisposable { + // Init() pins the process-wide loop clock and timer wheel; every other test in the host reads them. + private readonly TimerWheelScope _clock = new(); + + public void Dispose() => _clock.Dispose(); + // Test implementations for controlled testing private class TestRecurrencePattern : IRecurrencePattern { @@ -58,7 +64,6 @@ public class EventSchedulerTests private static void Init() { Core._now = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc); - Timer.Init(0); EventScheduler.Configure(); } @@ -148,39 +153,36 @@ public class EventSchedulerTests [Fact] public void Scheduler_HandlesRecurringEvents() { - for (var i = 0; i < 100_000; i++) + Init(); + + try { - Init(); + var callCount = 0; - try - { - var callCount = 0; + // Create a recurrence pattern that fires every 10 seconds, up to 3 times + var recurrence = new TestRecurrencePattern(TimeSpan.FromSeconds(10), 3); - // Create a recurrence pattern that fires every 10 seconds, up to 3 times - var recurrence = new TestRecurrencePattern(TimeSpan.FromSeconds(10), 3); + var evt = new TestScheduledEvent( + TimeOnly.FromDateTime(Core._now), + () => callCount++, + recurrence + ); - var evt = new TestScheduledEvent( - TimeOnly.FromDateTime(Core._now), - () => callCount++, - recurrence - ); + evt.Schedule(Core._now); - evt.Schedule(Core._now); + // Advance time to after all occurrences should have happened + Core._now = Core._now.AddSeconds(50); + Timer.Slice(8); - // Advance time to after all occurrences should have happened - Core._now = Core._now.AddSeconds(50); - Timer.Slice(8); + // One call per occurrence the pattern yields + Assert.Equal(3, callCount); + Assert.Equal(3, evt.CallCount); - // Should have fired 4 times (3 recurrences) - Assert.Equal(3, callCount); - Assert.Equal(3, evt.CallCount); - - evt.Cancel(); - } - finally - { - Finish(); - } + evt.Cancel(); + } + finally + { + Finish(); } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs b/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs index bd3ef178e..70d06a86d 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs @@ -225,10 +225,13 @@ public class RecurrencePatternTests } [Theory] - [InlineData("America/New_York", 2024, 3, 10, 2, 30)] // Spring forward - 2:30 AM doesn't exist - [InlineData("America/New_York", 2024, 11, 3, 1, 30)] // Fall back - 1:30 AM happens twice + // 2:30 AM does not exist on the spring-forward day, so the occurrence moves to the next month. + [InlineData("America/New_York", 2024, 3, 10, 2, 30, 2024, 4, 10, 6, 30)] + // 1:30 AM happens twice on the fall-back day; LocalToUtc resolves it to the first (EDT) pass. + [InlineData("America/New_York", 2024, 11, 3, 1, 30, 2024, 11, 3, 5, 30)] public void MonthlyRecurrencePattern_DSTTransitions_HandlesCorrectly( - string tzId, int year, int month, int day, int hour, int minute) + string tzId, int year, int month, int day, int hour, int minute, + int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute) { // Arrange var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId); @@ -238,19 +241,14 @@ public class RecurrencePatternTests var beforeUtc = localDateBefore.LocalToUtc(tz); var timeOnly = new TimeOnly(hour, minute); - // Act - shouldn't throw exceptions + // Act var result = pattern.GetNextOccurrence(beforeUtc, timeOnly, tz); - // Assert - just make sure we got a valid result - Assert.NotEqual(DateTime.MaxValue, result); - - // For invalid times (spring forward), should skip to next valid time - var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz); - if (month == 3) // Spring forward - { - // Should skip the invalid 2:30 AM time - Assert.True(resultLocal.Hour >= 3 || resultLocal > new DateTime(year, month, day)); - } + // Assert + var expectedUtc = new DateTime( + expectedYear, expectedMonth, expectedDay, expectedHour, expectedMinute, 0, DateTimeKind.Utc + ); + Assert.Equal(expectedUtc, result); } [Fact] diff --git a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs index 2a22b74bf..ae8fe35d9 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs @@ -36,10 +36,8 @@ public class FishingTests [InlineData(4.5, true)] public void Give_SerpentGoesBackAfterFiveMinutesUnlessHitInTheLastTwo(double? hitAtMinutes, bool stays) { - Core._tickCount = 0; - Timer.Init(0); + using var clock = new TimerWheelScope(); - var previousNow = Core._now; var previousHitsRate = Mobile.DefaultHitsRate; var fisher = new PlayerMobile(); BaseCreature serpent = null; @@ -62,15 +60,15 @@ public class FishingTests if (hitAtMinutes is { } minutes) { elapsed = TimeSpan.FromMinutes(minutes); - Advance(elapsed); + TimerWheelScope.Advance(elapsed); serpent.Damage(20, fisher); Assert.Same(fisher, serpent.FindMostRecentDamager(false)); } - Advance(TimeSpan.FromMinutes(4.9) - elapsed); + TimerWheelScope.Advance(TimeSpan.FromMinutes(4.9) - elapsed); Assert.False(serpent.Deleted); - Advance(TimeSpan.FromMinutes(0.2)); + TimerWheelScope.Advance(TimeSpan.FromMinutes(0.2)); Assert.Equal(!stays, serpent.Deleted); Assert.Equal(!stays, bottle.Deleted); } @@ -79,7 +77,6 @@ public class FishingTests serpent?.Delete(); fisher.Delete(); Mobile.DefaultHitsRate = previousHitsRate; - Core._now = previousNow; } } @@ -125,13 +122,6 @@ public class FishingTests } } - private static void Advance(TimeSpan time) - { - Core._now += time; - Core._tickCount += (long)time.TotalMilliseconds; - Timer.Slice(Core.TickCount); - } - // Map.Internal skips FindLocation's search; the wreck is then where the fisher stands. private static SOS AddSOS(Mobile fisher) { diff --git a/Projects/UOContent.Tests/Tests/Engines/Party/PartyPacketTests.cs b/Projects/UOContent.Tests/Tests/Engines/Party/PartyPacketTests.cs index 99921aea6..6f4e6d85e 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Party/PartyPacketTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Party/PartyPacketTests.cs @@ -9,8 +9,9 @@ namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] public class PartyPacketTests { + // Without a party, the remove-member packet tells the removed member their list is now empty. [Fact] - public void TestPartyEmptyList() + public void TestPartyRemoveMember_WithoutParty_SendsEmptyList() { var m = (Serial)0x1024u; @@ -26,43 +27,61 @@ public class PartyPacketTests [Fact] public void TestPartyRemoveMember() { - var leader = new Mobile((Serial)0x1024u); - leader.DefaultMobileInit(); + // Fresh serials: Delete removes by serial, and a fixed one could belong to a live entity. + var leader = new Mobile(World.NewMobile); + var member = new Mobile(World.NewMobile); - var member = new Mobile((Serial)0x2048u); - member.DefaultMobileInit(); + try + { + leader.DefaultMobileInit(); + member.DefaultMobileInit(); - var p = new Party(leader); - p.Add(member); + var p = new Party(leader); + p.Add(member); - var expected = new PartyRemoveMember(member.Serial, p).Compile(); + var expected = new PartyRemoveMember(member.Serial, p).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPartyRemoveMember(member.Serial, p); + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.SendPartyRemoveMember(member.Serial, p); - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + var result = ns.SendBuffer.GetReadSpan(); + AssertThat.Equal(result, expected); + } + finally + { + member.Delete(); + leader.Delete(); + } } [Fact] public void TestPartyMemberList() { - var leader = new Mobile((Serial)0x1024u); - leader.DefaultMobileInit(); + // Fresh serials: Delete removes by serial, and a fixed one could belong to a live entity. + var leader = new Mobile(World.NewMobile); + var member = new Mobile(World.NewMobile); - var member = new Mobile((Serial)0x2048u); - member.DefaultMobileInit(); + try + { + leader.DefaultMobileInit(); + member.DefaultMobileInit(); - var p = new Party(leader); - p.Add(member); + var p = new Party(leader); + p.Add(member); - var expected = new PartyMemberList(p).Compile(); + var expected = new PartyMemberList(p).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPartyMemberList(p); + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.SendPartyMemberList(p); - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + var result = ns.SendBuffer.GetReadSpan(); + AssertThat.Equal(result, expected); + } + finally + { + member.Delete(); + leader.Delete(); + } } [Theory] diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs index 667995278..ed8b8d006 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs @@ -1,3 +1,4 @@ +using System; using Server.Engines.Pathing.Cache; using Server.Mobiles; using Server.PathAlgorithms; @@ -14,15 +15,18 @@ namespace Server.Tests.Pathfinding; /// to "no path found" on reachable goals. /// [Collection("Sequential Pathfinding Tests")] -public class BitmapAStarAlgorithmTests +public class BitmapAStarAlgorithmTests : IDisposable { private readonly ITestOutputHelper _output; + private readonly StepCacheStateScope _cacheState = new(); public BitmapAStarAlgorithmTests(ITestOutputHelper output) { _output = output; } + public void Dispose() => _cacheState.Dispose(); + [Theory] // Pinned cell (1500, 1600, z=10): mask=0xC1 → N, W, NW walkable. // Use start.Z for goal.Z so destNode lands in the same Z plane the algorithm reaches @@ -33,22 +37,27 @@ public class BitmapAStarAlgorithmTests public void DefaultWalker_FindsPath_ViaCacheFastPath(int sx, int sy, int gx, int gy) { StepCache.Instance.Clear(); + StepCache.Instance.MissPromotionThreshold = 1; var map = Map.Maps[1]; Assert.NotNull(map); - var stub = new DefaultWalkerStub(); + var stub = new WalkerStub(); map.GetAverageZ(sx, sy, out _, out var startZ, out _); var start = new Point3D(sx, sy, (sbyte)startZ); var goal = new Point3D(gx, gy, (sbyte)startZ); stub.MoveToWorld(start, map); + var statsBefore = StepCache.Instance.GetStats(); var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); + var statsAfter = StepCache.Instance.GetStats(); stub.Delete(); Assert.NotNull(result); Assert.NotEmpty(result); + Assert.True(statsAfter.BuildsTotal > statsBefore.BuildsTotal, + "a default walker should expand through the cache, not the slow path"); _output.WriteLine($"default-walker ({sx},{sy})->({gx},{gy}): {result.Length} steps"); } @@ -62,7 +71,7 @@ public class BitmapAStarAlgorithmTests var map = Map.Maps[1]; Assert.NotNull(map); - var stub = new SwimmingStub(World.NewMobile); + var stub = new BareCreatureStub(World.NewMobile); stub.DefaultMobileInit(); stub.CanSwim = true; // overlay route — cache + (walkMask | wetMask&canSwim) map.GetAverageZ(sx, sy, out _, out var startZ, out _); @@ -104,13 +113,13 @@ public class BitmapAStarAlgorithmTests var start = new Point3D(sx, sy, (sbyte)startZ); var goal = new Point3D(gx, gy, (sbyte)startZ); - var walker = new DefaultWalkerStub(); + var walker = new WalkerStub(); walker.MoveToWorld(start, map); // Living blocker on the only direct-line cell. CanMoveOver returns false for an // alive non-staff mobile, so the dynamic-obstacle pass must reject this cell. map.GetAverageZ(blockX, blockY, out _, out var blockZ, out _); - var blocker = new DefaultWalkerStub(); + var blocker = new WalkerStub(); blocker.MoveToWorld(new Point3D(blockX, blockY, (sbyte)blockZ), map); var result = BitmapAStarAlgorithm.Instance.Find(walker, map, start, goal); @@ -146,24 +155,14 @@ public class BitmapAStarAlgorithmTests var blockX = 1499; var blockY = 1599; - // Find any ItemID whose TileData has ImpassableSurface so the dynamic pass - // rejects the cell. Pinning to a specific ID would couple the test to UO art data. - ushort blockerItemId = 0; - for (ushort id = 1; id < TileData.MaxItemValue; id++) - { - if (TileData.ItemTable[id].ImpassableSurface) - { - blockerItemId = id; - break; - } - } + var blockerItemId = TestTiles.FirstImpassableItemId(); Assert.NotEqual(0, blockerItemId); map.GetAverageZ(sx, sy, out _, out var startZ, out _); var start = new Point3D(sx, sy, (sbyte)startZ); var goal = new Point3D(gx, gy, (sbyte)startZ); - var walker = new DefaultWalkerStub(); + var walker = new WalkerStub(); walker.MoveToWorld(start, map); map.GetAverageZ(blockX, blockY, out _, out var blockZ, out _); @@ -197,28 +196,20 @@ public class BitmapAStarAlgorithmTests var map = Map.Maps[1]; Assert.NotNull(map); - var stub = new DefaultWalkerStub(); + var stub = new WalkerStub(); map.GetAverageZ(1500, 1600, out _, out var startZ, out _); var start = new Point3D(1500, 1600, (sbyte)startZ); var goal = new Point3D(1498, 1598, (sbyte)startZ); stub.MoveToWorld(start, map); var statsBefore = StepCache.Instance.GetStats(); - var prevFlag = ContentFeatureFlags.BitmapPathfindingCache; - try - { - ContentFeatureFlags.BitmapPathfindingCache = false; - var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); - Assert.NotNull(result); - Assert.NotEmpty(result); - } - finally - { - ContentFeatureFlags.BitmapPathfindingCache = prevFlag; - } + ContentFeatureFlags.BitmapPathfindingCache = false; + var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); var statsAfter = StepCache.Instance.GetStats(); stub.Delete(); + Assert.NotNull(result); + Assert.NotEmpty(result); Assert.Equal(statsBefore.BuildsTotal, statsAfter.BuildsTotal); } @@ -346,7 +337,7 @@ public class BitmapAStarAlgorithmTests StepCache.Instance.MissPromotionThreshold = 2; var map = Map.Maps[1]; - var stub = new DefaultWalkerStub(); + var stub = new WalkerStub(); map.GetAverageZ(1500, 1600, out _, out var startZ, out _); var start = new Point3D(1500, 1600, (sbyte)startZ); var goal = new Point3D(1498, 1598, (sbyte)startZ); @@ -376,7 +367,7 @@ public class BitmapAStarAlgorithmTests StepCache.Instance.MissPromotionWindowMs = 30_000; var map = Map.Maps[1]; - var stub = new DefaultWalkerStub(); + var stub = new WalkerStub(); map.GetAverageZ(1500, 1600, out _, out var startZ, out _); var start = new Point3D(1500, 1600, (sbyte)startZ); var goal = new Point3D(1498, 1598, (sbyte)startZ); @@ -401,18 +392,6 @@ public class BitmapAStarAlgorithmTests ); } - /// - /// Plain Mobile — RequiresSlowPath returns false, the bitmap algorithm uses the cache - /// fast path on every expansion. - /// - private sealed class DefaultWalkerStub : Mobile - { - public DefaultWalkerStub() - { - Body = 0xC9; - } - } - /// /// Mobile with Player=true and default AccessLevel (Player). Triggers the strict /// AND-rule for diagonal corner-cut while still using the cache. @@ -426,20 +405,6 @@ public class BitmapAStarAlgorithmTests } } - /// - /// BaseCreature with CanSwim=true — uses the cache via capability overlay (walk OR - /// (wet AND canSwim)). Use the Serial constructor (deserialization path) to bypass - /// NPCSpeeds init, which requires the npc-speeds.json table loaded — not available - /// in tests. - /// - private sealed class SwimmingStub : BaseCreature - { - public SwimmingStub(Serial serial) : base(serial) - { - Body = 0xC9; - } - } - /// /// BaseCreature with CanFly=true — RequiresSlowPath returns true (Z-jumping is beyond /// the cache's static-only scope), so GetSuccessors short-circuits to the slow path. diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs index 8ecbd0591..5ecb0cdc1 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs @@ -1,3 +1,4 @@ +using System; using Server.Engines.Pathing.Cache; using Server.Items; using Server.Mobiles; @@ -9,25 +10,22 @@ using CalcMoves = Server.Movement.Movement; namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] -public class HousePathRoutingTests +public class HousePathRoutingTests : IDisposable { private readonly ITestOutputHelper _output; + private readonly StepCacheStateScope _cacheState = new(); public HousePathRoutingTests(ITestOutputHelper output) => _output = output; - private const int MapId = 1; + public void Dispose() => _cacheState.Dispose(); - private sealed class WalkerStub : Mobile - { - public WalkerStub() => Body = 0xC9; - } + private const int MapId = 1; [SkippableFact] public void PathAround_NeverTraversesAWallCell() { TileDataRequirement.SkipIfMissing(); StepCache.Instance.Clear(); - var prevThreshold = StepCache.Instance.MissPromotionThreshold; StepCache.Instance.MissPromotionThreshold = 1; var map = Map.Maps[MapId]; Assert.NotNull(map); @@ -69,7 +67,6 @@ public class HousePathRoutingTests } finally { - StepCache.Instance.MissPromotionThreshold = prevThreshold; walker.Delete(); multi.Delete(); } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs index fc9e58898..50a27ee44 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs @@ -1,3 +1,4 @@ +using System; using Server.Engines.Pathing.Cache; using Server.PathAlgorithms; using Server.Systems.FeatureFlags; @@ -6,20 +7,25 @@ using Xunit; namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] -public class MultiCacheUsedTests +public class MultiCacheUsedTests : IDisposable { private const int MapId = 1; // Trammel private const int GuildHouseId = 0x74; - [Fact] + private readonly StepCacheStateScope _cacheState = new(); + + public void Dispose() => _cacheState.Dispose(); + + [SkippableFact] public void PathNearMulti_IncrementsMultiLocalHits() { + // Without the client's multi data the house has no footprint for the path to skirt. + TileDataRequirement.SkipIfMissing(); var map = Map.Maps[MapId]; map.GetAverageZ(1480, 1620, out _, out var z, out _); var houseLoc = new Point3D(1480, 1620, (sbyte)z); var multi = new TestMulti(GuildHouseId); - var cacheWas = ContentFeatureFlags.BitmapPathfindingCache; ContentFeatureFlags.BitmapPathfindingCache = true; var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(1480, 1630, (sbyte)z)); @@ -44,7 +50,6 @@ public class MultiCacheUsedTests { mover.Delete(); multi.Delete(); - ContentFeatureFlags.BitmapPathfindingCache = cacheWas; } } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs index 38a6824ea..2b2adf93c 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs @@ -1,3 +1,4 @@ +using System; using Server.Engines.Pathing.Cache; using Server.Items; using Xunit; @@ -5,13 +6,17 @@ using Xunit; namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] -public class MultiMaskCacheTests +public class MultiMaskCacheTests : IDisposable { private const int MapId = 1; private const int GuildHouseId = 0x74; private const int PlaceX = 1480; private const int PlaceY = 1620; + private readonly StepCacheStateScope _cacheState = new(); + + public void Dispose() => _cacheState.Dispose(); + [SkippableFact] public void TryResolveCoveringMulti_FindsPlacedMulti_AndLocalIndices() { @@ -99,7 +104,6 @@ public class MultiMaskCacheTests map.GetAverageZ(CleanX, CleanY, out _, out var z, out _); var houseLoc = new Point3D(CleanX, CleanY, (sbyte)z); var multi = new TestMulti(GuildHouseId); - var cacheWas = Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache; Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = true; var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(CleanX, CleanY + 10, (sbyte)z)); try @@ -124,17 +128,17 @@ public class MultiMaskCacheTests { mover.Delete(); multi.Delete(); - Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = cacheWas; } } - [Fact] + [SkippableFact] public void ClutteredHouse_DoesNotServeCache_ButStillPaths() { + // The clutter is Britain's statics; on a blank map the house has no footprint to dirty. + TileDataRequirement.SkipIfMissing(); var map = Map.Maps[MapId]; map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); var multi = new TestMulti(GuildHouseId); - var cacheWas = Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache; Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = true; var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(PlaceX, PlaceY + 10, (sbyte)z)); try @@ -159,7 +163,6 @@ public class MultiMaskCacheTests { mover.Delete(); multi.Delete(); - Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = cacheWas; } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs index 9a920282f..9ec598916 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs @@ -1,3 +1,4 @@ +using System; using Server.Engines.Pathing.Cache; using Server.Items; using Server.Mobiles; @@ -9,41 +10,33 @@ using Xunit.Abstractions; namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] -public class MultiPathInvariantTests +public class MultiPathInvariantTests : IDisposable { private readonly ITestOutputHelper _output; + private readonly StepCacheStateScope _cacheState = new(); public MultiPathInvariantTests(ITestOutputHelper output) => _output = output; - private const int MapId = 1; + public void Dispose() => _cacheState.Dispose(); - private sealed class WalkerStub : Mobile - { - public WalkerStub() => Body = 0xC9; - } + private const int MapId = 1; private static Direction[] FindWithFlag(Mobile m, Map map, Point3D start, Point3D goal, bool cacheOn) { - var prev = ContentFeatureFlags.BitmapPathfindingCache; - try - { - ContentFeatureFlags.BitmapPathfindingCache = cacheOn; - StepCache.Instance.Clear(); - StepCache.Instance.MissPromotionThreshold = 1; - return BitmapAStarAlgorithm.Instance.Find(m, map, start, goal); - } - finally - { - ContentFeatureFlags.BitmapPathfindingCache = prev; - } + ContentFeatureFlags.BitmapPathfindingCache = cacheOn; + StepCache.Instance.Clear(); + StepCache.Instance.MissPromotionThreshold = 1; + return BitmapAStarAlgorithm.Instance.Find(m, map, start, goal); } - [Theory] + [SkippableTheory] // start, goal: straddle a house placed between them (house at ~ midpoint). [InlineData(1500, 1600, 1500, 1612)] // N-S across the footprint [InlineData(1494, 1606, 1512, 1606)] // E-W across the footprint public void Find_CacheOn_EqualsCacheOff_WithHousePresent(int sx, int sy, int gx, int gy) { + // Without the client's multi data the house has no footprint and nothing stands in the way. + TileDataRequirement.SkipIfMissing(); var map = Map.Maps[MapId]; Assert.NotNull(map); @@ -100,11 +93,11 @@ public class MultiPathInvariantTests { var on = FindWithFlag(walker, map, start, goal, cacheOn: true); var off = FindWithFlag(walker, map, start, goal, cacheOn: false); - Assert.Equal(off == null, on == null); - if (on != null) - { - Assert.Equal(off, on); - } + + // Two nulls would agree about nothing: the control route must exist. + Assert.NotNull(off); + Assert.NotNull(on); + Assert.Equal(off, on); } finally { @@ -112,9 +105,10 @@ public class MultiPathInvariantTests } } - [Fact] + [SkippableFact] public void Find_CacheOn_EqualsCacheOff_RoutesAroundHouse() { + TileDataRequirement.SkipIfMissing(); var map = Map.Maps[MapId]; Assert.NotNull(map); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs index 1103cdb83..8f20d04f4 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs @@ -1,3 +1,4 @@ +using System; using Server.Engines.Pathing.Cache; using Server.Items; using Xunit; @@ -6,12 +7,15 @@ using Xunit.Abstractions; namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] -public class MultiSplitRoutingTests +public class MultiSplitRoutingTests : IDisposable { private readonly ITestOutputHelper _output; + private readonly StepCacheStateScope _cacheState = new(); public MultiSplitRoutingTests(ITestOutputHelper output) => _output = output; + public void Dispose() => _cacheState.Dispose(); + // Open plain on Trammel used by existing pathfinding tests; room for a 7x7 house. private const int MapId = 1; private const int PlaceX = 1500; @@ -60,7 +64,6 @@ public class MultiSplitRoutingTests public void CleanMap_AfterMultiRemoved_ServesStaticHitAgain() { StepCache.Instance.Clear(); - var prevThreshold = StepCache.Instance.MissPromotionThreshold; StepCache.Instance.MissPromotionThreshold = 1; // build on first touch var map = Map.Maps[MapId]; Assert.NotNull(map); @@ -72,20 +75,13 @@ public class MultiSplitRoutingTests var sector = map.GetRealSector(PlaceX >> 4, PlaceY >> 4); Assert.True(sector.HasMultis, "placing a multi must set Sector.HasMultis"); - try - { - multi.Delete(); - Assert.False(sector.HasMultis, "removing the only multi must clear HasMultis"); + multi.Delete(); + Assert.False(sector.HasMultis, "removing the only multi must clear HasMultis"); - // A cell that is NOT a static-fallthrough kind (e.g. off-map / multi) should now be - // eligible for a real static answer. Use the placement center, which is open plain. - var mask = StepCache.Instance.TryGetMask(map, PlaceX, PlaceY, (sbyte)z); - Assert.True(mask.IsHit, $"expected a real static answer after removal, got {mask.HitKind}"); - _output.WriteLine($"post-removal hitKind at center: {mask.HitKind}"); - } - finally - { - StepCache.Instance.MissPromotionThreshold = prevThreshold; - } + // A cell that is NOT a static-fallthrough kind (e.g. off-map / multi) should now be + // eligible for a real static answer. Use the placement center, which is open plain. + var mask = StepCache.Instance.TryGetMask(map, PlaceX, PlaceY, (sbyte)z); + Assert.True(mask.IsHit, $"expected a real static answer after removal, got {mask.HitKind}"); + _output.WriteLine($"post-removal hitKind at center: {mask.HitKind}"); } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs index 092b28711..0f06a44aa 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs @@ -20,12 +20,6 @@ public sealed class TestMulti : BaseMulti } } -/// Default walker body, shared across pathfinding test fixtures. -public sealed class WalkerStub : Mobile -{ - public WalkerStub() => Body = 0xC9; -} - /// /// Stand-in for a customizable foundation: Components is a swappable MCL, exactly the /// runtime-mutation shape HouseFoundation uses (it replaces its MCL wholesale on redesign diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs index d8d46e7f8..1f993d477 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs @@ -18,11 +18,6 @@ public class MultiWalkabilityTests private const int PlaceX = 1500; private const int PlaceY = 1600; - private sealed class WalkerStub : Mobile - { - public WalkerStub() => Body = 0xC9; - } - [SkippableFact] public void WallCell_CannotBeEnteredFromAnyAdjacentCell() { diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs index 0930bad7a..efb434738 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs @@ -19,7 +19,7 @@ public class PathfindRecorderTests try { - var stub = new RecorderStub(World.NewMobile); + var stub = new BareCreatureStub(World.NewMobile); stub.DefaultMobileInit(); stub.MoveToWorld(new Point3D(1500, 1600, 0), Map.Maps[1]); @@ -48,7 +48,7 @@ public class PathfindRecorderTests { Assert.True(PathfindRecorder.Enabled); - var stub = new RecorderStub(World.NewMobile); + var stub = new BareCreatureStub(World.NewMobile); stub.DefaultMobileInit(); stub.MoveToWorld(new Point3D(1500, 1600, 0), Map.Maps[1]); @@ -89,7 +89,7 @@ public class PathfindRecorderTests try { - var stub = new RecorderStub(World.NewMobile); + var stub = new BareCreatureStub(World.NewMobile); stub.DefaultMobileInit(); stub.MoveToWorld(new Point3D(1500, 1600, 0), Map.Maps[1]); stub.CanSwim = true; @@ -100,13 +100,17 @@ public class PathfindRecorderTests ); PathfindRecorder.SetEnabled(false); + + // BaseCreature's own defaults for these two; the recording must carry whatever they are. + var canOpenDoors = JsonBool(stub.CanOpenDoors); + var canMoveOverObstacles = JsonBool(stub.CanMoveOverObstacles); stub.Delete(); var content = File.ReadAllText(path); Assert.Contains("\"CanSwim\":true", content); Assert.Contains("\"CanFly\":false", content); - Assert.Contains("\"CanOpenDoors\":", content); // value depends on BaseCreature defaults - Assert.Contains("\"CanMoveOverObstacles\":", content); // — same. + Assert.Contains($"\"CanOpenDoors\":{canOpenDoors}", content); + Assert.Contains($"\"CanMoveOverObstacles\":{canMoveOverObstacles}", content); } finally { @@ -142,8 +146,5 @@ public class PathfindRecorderTests } } - private sealed class RecorderStub : Server.Mobiles.BaseCreature - { - public RecorderStub(Serial serial) : base(serial) => Body = 0xC9; - } + private static string JsonBool(bool value) => value ? "true" : "false"; } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs index 9027332c7..de0fdaaae 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs @@ -4,8 +4,8 @@ using Server.Engines.Pathing.Cache; namespace Server.Tests.Pathfinding; /// -/// Shared fixtures for the step-cache tests: the walker the parity tests measure against, the -/// cell-index arithmetic, and builders for the chunk state several tests inject by hand. +/// Shared fixtures for the step-cache tests: the test map, the cell-index arithmetic, and builders +/// for the chunk state several tests inject by hand. /// internal static class PathingTestSupport { @@ -53,17 +53,4 @@ internal static class PathingTestSupport return data; } - - /// - /// The default static walker. Deriving straight from rather than - /// BaseCreature is the point: MovementImpl then sees no creature capabilities (no swim, no fly, - /// no door-opening), which is exactly the walker the cache bakes for. - /// - public sealed class StaticWalker : Mobile - { - public StaticWalker() - { - Body = 0xC9; - } - } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs index d29b79ef9..4094c093f 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs @@ -6,8 +6,12 @@ using Xunit; namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] -public class StepCacheFileTests +public class StepCacheFileTests : IDisposable { + private readonly StepCacheStateScope _cacheState = new(); + + public void Dispose() => _cacheState.Dispose(); + /// /// Round-trip a populated cache through a .swb file under lazy loading: build chunks, /// save, clear (closes lazy readers + drops residents), open as lazy backing store, @@ -363,23 +367,16 @@ public class StepCacheFileTests cache.Clear(); cache.PreloadOnLazyOpen = true; - try - { - Assert.True(cache.TryOpenLazyReader(path, map.MapID)); + Assert.True(cache.TryOpenLazyReader(path, map.MapID)); - // Every chunk should be resident — no further queries needed. - Assert.Equal(coords.Length, cache.GetStats().ResidentChunks); - Assert.Equal(0L, cache.GetStats().BuildsTotal); // came from file, not baker + // Every chunk should be resident — no further queries needed. + Assert.Equal(coords.Length, cache.GetStats().ResidentChunks); + Assert.Equal(0L, cache.GetStats().BuildsTotal); // came from file, not baker - // Subsequent query is a clean Hit, not a Miss_NotBuilt. - var lookup = cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: 10); - Assert.Equal(CacheHitKind.Hit, lookup.HitKind); - Assert.Equal(coords.Length, cache.GetStats().ResidentChunks); - } - finally - { - cache.PreloadOnLazyOpen = false; - } + // Subsequent query is a clean Hit, not a Miss_NotBuilt. + var lookup = cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: 10); + Assert.Equal(CacheHitKind.Hit, lookup.HitKind); + Assert.Equal(coords.Length, cache.GetStats().ResidentChunks); } finally { diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs index dee698e59..6d2bc6f7a 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs @@ -15,9 +15,12 @@ public class StepCacheFingerprintTests /// different one, marking the bake stale and re-baking on every boot. Hashing the file makes /// the fingerprint a pure function of the client's tile data, immune to those mutations. /// - [Fact] + [SkippableFact] public void Fingerprint_IgnoresRuntimeTileDataMutation() { + // Without tiledata.mul there is no file to hash, so the fingerprint is constant either way. + TileDataRequirement.SkipIfMissing(); + const int mapId = 1; // Trammel — loaded by the test bootstrap. var before = StepCacheFile.ComputeFingerprint(mapId); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs index 9c410efc7..fd90aedf9 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs @@ -17,11 +17,9 @@ namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] public class StepCacheLifecycleTests : IDisposable { - // The window is a knob on the singleton and Clear() leaves it alone, so a test that narrows - // it would otherwise leak the narrow window into every later test in the collection. - private readonly uint _savedWindowMs = StepCache.Instance.MissPromotionWindowMs; + private readonly StepCacheStateScope _cacheState = new(); - public void Dispose() => StepCache.Instance.MissPromotionWindowMs = _savedWindowMs; + public void Dispose() => _cacheState.Dispose(); /// Resets to a known state and returns the singleton. private static StepCache FreshCache(int promotionThreshold) @@ -396,26 +394,19 @@ public class StepCacheLifecycleTests : IDisposable var cache = FreshCache(promotionThreshold: 1); cache.MaxResidentChunks = 4; - try + var map = TestMap; + + // Five chunks into a cache that holds four. Which one goes doesn't matter here, only that + // the count comes down and the key list stays in step, so equal touch ticks are fine. + for (var i = 0; i < 5; i++) { - var map = TestMap; - - // Five chunks into a cache that holds four. - for (var i = 0; i < 5; i++) - { - cache.TryGetMask(map, PlainX + i * 16, PlainY, sourceZ: 10); - Thread.Sleep(2); // separate their LastTouchedTicks so LRU has something to order by - } - - cache.EnforceLruCap(); - - Assert.Equal(4, cache.GetStats().ResidentChunks); - Assert.True(cache.GetStats().EvictionsByLruCap >= 1L); - Assert.True(cache.ResidentIndexInSync(), "eviction desynced the key list from the resident set"); - } - finally - { - cache.MaxResidentChunks = 8192; + cache.TryGetMask(map, PlainX + i * 16, PlainY, sourceZ: 10); } + + cache.EnforceLruCap(); + + Assert.Equal(4, cache.GetStats().ResidentChunks); + Assert.True(cache.GetStats().EvictionsByLruCap >= 1L); + Assert.True(cache.ResidentIndexInSync(), "eviction desynced the key list from the resident set"); } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs index b38c15277..769945af1 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs @@ -20,12 +20,15 @@ namespace Server.Tests.Pathfinding; /// falls through on everything agrees with the slow path perfectly and is worthless. /// [Collection("Sequential Pathfinding Tests")] -public class StepCacheParityTests +public class StepCacheParityTests : IDisposable { private readonly ITestOutputHelper _output; + private readonly StepCacheStateScope _cacheState = new(); public StepCacheParityTests(ITestOutputHelper output) => _output = output; + public void Dispose() => _cacheState.Dispose(); + // ---- layer 1: the bake agrees with MovementImpl ---- /// @@ -43,7 +46,7 @@ public class StepCacheParityTests var map = TestMap; Assert.NotNull(map); - var walker = new StaticWalker(); + var walker = new WalkerStub(); walker.MoveToWorld(new Point3D(xStart, yStart, 0), map); var disagreements = 0; @@ -154,12 +157,16 @@ public class StepCacheParityTests /// the land average instead would trip the source-Z guard on raised terrain (a causeway, a /// walkway) and report a fallthrough that is correct behaviour rather than a fault. /// - [Theory] + [SkippableTheory] [InlineData("britain_inn_dense", 1480, 1610, 32)] [InlineData("trammel_open_plain", 1500, 1600, 32)] [InlineData("britain_causeway", 1475, 1641, 32)] public void CacheMatchesProbe(string label, int xStart, int yStart, int size) { + // A storage fault (crossed cell index, swapped Z array) only shows against varied terrain; + // on a blank map every cell bakes the same answer. + TileDataRequirement.SkipIfMissing(); + var cache = StepCache.Instance; cache.Clear(); cache.MissPromotionThreshold = 1; // build on first touch: every cell should get a real answer @@ -253,7 +260,7 @@ public class StepCacheParityTests /// Cardinals only: the cache stores raw masks and applies the corner-cut at query time, so a /// raw diagonal bit legitimately differs from MovementImpl's diagonal answer. /// - [Theory] + [SkippableTheory] // Seeds chosen for the terrain classes the standable-surface bake has to get right. Each one // floods across a wide local area, so a handful covers thousands of states without a map walk. [InlineData("brit_sewer_walkway", 6034, 1476, 5, 2500)] // static walkway over impassable land @@ -262,6 +269,8 @@ public class StepCacheParityTests [InlineData("trammel_open_plain", 1500, 1600, 10, 2500)] // flat ground: catches clearance false-positives public void CacheServesReachableWalkStates(string label, int sx, int sy, int sz, int maxStates) { + TileDataRequirement.SkipIfMissing(); + var cache = StepCache.Instance; cache.Clear(); cache.MissPromotionThreshold = 1; // build on first touch: every reached state should be answered @@ -269,7 +278,7 @@ public class StepCacheParityTests var map = TestMap; Assert.NotNull(map); - var walker = new StaticWalker(); + var walker = new WalkerStub(); walker.MoveToWorld(new Point3D(sx, sy, sz), map); var startIsWalkable = false; diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/AllSpawnFilesLoadTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/AllSpawnFilesLoadTests.cs index 9eb9e1909..946e83e36 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/AllSpawnFilesLoadTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/AllSpawnFilesLoadTests.cs @@ -20,10 +20,9 @@ public class AllSpawnFilesLoadTests public void EverySpawnFile_DeserializesAndBuildsSpawners() { var root = Path.Combine(Core.BaseDirectory, "Data", "Spawns"); - if (!Directory.Exists(root)) - { - return; // distribution data not present in this checkout - } + // Data/ is copied into the test output by the build; a missing folder means the build was run + // without SolutionDir, and passing would hide that nothing was checked. + Assert.True(Directory.Exists(root), $"Spawn data not found at {root}"); var failures = new List(); var fileCount = 0; @@ -64,6 +63,7 @@ public class AllSpawnFilesLoadTests } } + Assert.NotEqual(0, fileCount); Assert.True( failures.Count == 0, $"Loaded {fileCount} files, built {spawnerCount} spawners. Failures:\n{string.Join("\n", failures)}" diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs index 6b4bba2dd..e30cc289d 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs @@ -3,22 +3,24 @@ using System.Collections.Generic; using System.IO; using Server; using Server.Engines.Spawners; +using Server.Tests; using Xunit; namespace UOContent.Tests.Engines.Spawners.Json; [Collection("Sequential UOContent Tests")] -public class ImportCleanupTests +public class ImportCleanupTests : IDisposable { private const string SpawnerGuid = "11111111-1111-1111-1111-111111111111"; + private readonly TempDirectory _dir = new("muo-spawner-import"); + + public void Dispose() => _dir.Dispose(); + [Fact] public void Import_ValidFile_PlacesSpawner() { - var dir = Path.Combine(Path.GetTempPath(), "muo-spawner-import-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dir); - var path = Path.Combine(dir, "test.json"); - File.WriteAllText(path, """ + var path = _dir.WriteFile("test.json", """ [ { "$type": "Spawner", @@ -53,22 +55,15 @@ public class ImportCleanupTests finally { placedSpawner?.Delete(); - if (Directory.Exists(dir)) - { - Directory.Delete(dir, true); - } } } + // A DTO with no map entry deserializes with map == null. The importer rejects it before building + // a spawner, so nothing is registered and nothing lands on any live map. [Fact] - public void Import_NoMap_DeletesOrphanedSpawner() + public void Import_NoMap_SkipsTheSpawner() { - // A spawner with no map entry deserialized by STJ will have map == null (or Map.Internal). - // Verify it does NOT end up on any live map. - var dir = Path.Combine(Path.GetTempPath(), "muo-spawner-import-nomap-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dir); - var path = Path.Combine(dir, "test-nomap.json"); - File.WriteAllText(path, """ + var path = _dir.WriteFile("test-nomap.json", """ [ { "$type": "Spawner", @@ -81,45 +76,28 @@ public class ImportCleanupTests ] """); - try - { - var all = new Dictionary(); - ImportSpawnersCommand.ImportFile(new FileInfo(path), all); + var guid = new Guid("22222222-2222-2222-2222-222222222222"); + var all = new Dictionary(); + ImportSpawnersCommand.ImportFile(new FileInfo(path), all); - // The spawner must NOT be in allSpawners (rejected due to missing map). - Assert.DoesNotContain(new Guid("22222222-2222-2222-2222-222222222222"), all.Keys); - } - finally + Assert.DoesNotContain(guid, all.Keys); + + var maps = Map.AllMaps; + for (var i = 0; i < maps.Count; i++) { - if (Directory.Exists(dir)) + foreach (var spawner in maps[i].GetItemsAt(new Point3D(400, 400, 0))) { - Directory.Delete(dir, true); + Assert.NotEqual(guid, spawner.Guid); } } } - private static int CountSpawnersNear(Point3D p) - { - var n = 0; - foreach (var _ in Map.Felucca.GetItemsAt(p)) - { - n++; - } - - return n; - } - [Fact] public void Import_DuplicateLocation_ReplacesExistingSpawner() { // Verifies that an existing spawner at the same location and type is removed and replaced. - var dir = Path.Combine(Path.GetTempPath(), "muo-spawner-import-dup-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dir); - var path = Path.Combine(dir, "test-dup.json"); - var newGuid = new Guid("33333333-3333-3333-3333-333333333333"); - // Use a non-interpolated raw string literal; the guid is a known constant so hardcode it. - File.WriteAllText(path, """ + var path = _dir.WriteFile("test-dup.json", """ [ { "$type": "Spawner", @@ -133,13 +111,13 @@ public class ImportCleanupTests ] """); - // Place a pre-existing spawner at the same location. var existing = new Spawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)); - existing.MoveToWorld(new Point3D(310, 310, 0), Map.Felucca); - BaseSpawner placedSpawner = null; + try { + existing.MoveToWorld(new Point3D(310, 310, 0), Map.Felucca); + var all = new Dictionary { [existing.Guid] = existing }; ImportSpawnersCommand.ImportFile(new FileInfo(path), all); @@ -160,15 +138,8 @@ public class ImportCleanupTests } finally { - if (!existing.Deleted) - { - existing.Delete(); - } + existing.Delete(); placedSpawner?.Delete(); - if (Directory.Exists(dir)) - { - Directory.Delete(dir, true); - } } } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs index 8a25c4b0f..930f059ce 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs @@ -15,15 +15,21 @@ public class SpawnerDtoEntryTests public void Export_WritesEntriesAtSameJsonPosition_WithDisabledOnlyWhenSet() { var spawner = new Spawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, default, "Rabbit", "Bird"); - spawner.Entries[1].Disabled = true; - var json = SpawnerJsonSerializer.SerializeCompact(new List { spawner.ToDto() }); + try + { + spawner.Entries[1].Disabled = true; - Assert.Contains("\"entries\": [\n { \"name\": \"Rabbit\"", json); - Assert.Contains("{ \"name\": \"Bird\"", json); - Assert.Equal(1, CountOccurrences(json, "\"disabled\": true")); + var json = SpawnerJsonSerializer.SerializeCompact(new List { spawner.ToDto() }); - spawner.Delete(); + Assert.Contains("\"entries\": [\n { \"name\": \"Rabbit\"", json); + Assert.Contains("{ \"name\": \"Bird\"", json); + Assert.Equal(1, CountOccurrences(json, "\"disabled\": true")); + } + finally + { + spawner.Delete(); + } } [Fact] @@ -35,11 +41,16 @@ public class SpawnerDtoEntryTests var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); var spawner = dtos![0].ToSpawner(); - Assert.Single(spawner.Entries); - Assert.True(spawner.Entries[0].Disabled); - Assert.Same(dtos[0].EntryView[0], spawner.Entries[0]); - - spawner.Delete(); + try + { + Assert.Single(spawner.Entries); + Assert.True(spawner.Entries[0].Disabled); + Assert.Same(dtos[0].EntryView[0], spawner.Entries[0]); + } + finally + { + spawner.Delete(); + } } private static int CountOccurrences(string source, string value) diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs index 1acc29960..ad67e96b9 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs @@ -260,14 +260,8 @@ public class SectorSpawnCacheManagerTests } } -[Collection("SectorSpawnCache")] public class SpiralScanTests { - public SpiralScanTests() - { - SectorSpawnCacheManager.ClearAll(); - } - [Theory] [InlineData(1, 0, -1, -1)] // Ring 1, position 0: top-left [InlineData(1, 1, 0, -1)] // Ring 1, position 1: top diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs index 96023870d..6d9e8e711 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text.Json; @@ -11,26 +12,43 @@ using Xunit; namespace UOContent.Tests.Engines.Spawners; [Collection("Sequential UOContent Tests")] -public class SpawnerEntryOwnershipTests +public class SpawnerEntryOwnershipTests : IDisposable { + // Deleted in Dispose so a failed assertion cannot leave spawners registered in the world. + private readonly List _created = []; + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private T Track(T item) where T : Item + { + _created.Add(item); + return item; + } + + private Spawner NewSpawner(params string[] names) => + Track(new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, names)); + [Fact] public void Disabled_DefaultsFalse_AndRoundTripsBinary() { - var spawner = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird"); + var spawner = NewSpawner("Rabbit", "Bird"); Assert.False(spawner.Entries[0].Disabled); Assert.True(spawner.Entries[0].Enabled); spawner.Entries[1].Disabled = true; var bytes = SpawnerBlob.Write(spawner); - var loaded = SpawnerBlob.Read(bytes, (Serial)0x40001234); + var loaded = Track(SpawnerBlob.Read(bytes, (Serial)0x40001234)); Assert.Equal(2, loaded.Entries.Count); Assert.False(loaded.Entries[0].Disabled); Assert.True(loaded.Entries[1].Disabled); - - spawner.Delete(); - loaded.Delete(); } [Fact] @@ -48,7 +66,7 @@ public class SpawnerEntryOwnershipTests [Fact] public void AddRemoveClear_OperateOnOwnerList() { - var spawner = new Spawner(); + var spawner = Track(new Spawner()); Assert.Empty(spawner.Entries); var a = spawner.AddEntry("Rabbit", 100, 2, false); @@ -63,17 +81,15 @@ public class SpawnerEntryOwnershipTests spawner.RemoveAllEntries(); Assert.Empty(spawner.Entries); - - spawner.Delete(); } [Fact] public void Dupe_ClonesEntriesIntoIndependentList() { - var source = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird"); + var source = NewSpawner("Rabbit", "Bird"); source.Entries[1].Disabled = true; - var copy = new Spawner(); + var copy = Track(new Spawner()); source.Dupe(copy); Assert.Equal(2, copy.Entries.Count); @@ -84,31 +100,25 @@ public class SpawnerEntryOwnershipTests source.AddEntry("Orc", 100, 1, false); Assert.Equal(2, copy.Entries.Count); - - source.Delete(); - copy.Delete(); } [Fact] public void CopyEntriesTo_ReplacesTargetEntries() { - var source = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit"); - var target = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Orc", "Troll"); + var source = NewSpawner("Rabbit"); + var target = NewSpawner("Orc", "Troll"); source.CopyEntriesTo(target); Assert.Single(target.Entries); Assert.Equal("Rabbit", target.Entries[0].SpawnedName); Assert.NotSame(source.Entries[0], target.Entries[0]); - - source.Delete(); - target.Delete(); } [Fact] public void CopyEntriesTo_Self_IsNoOp() { - var spawner = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird"); + var spawner = NewSpawner("Rabbit", "Bird"); var first = spawner.Entries[0]; var second = spawner.Entries[1]; @@ -117,18 +127,16 @@ public class SpawnerEntryOwnershipTests Assert.Equal(2, spawner.Entries.Count); Assert.Same(first, spawner.Entries[0]); Assert.Same(second, spawner.Entries[1]); - - spawner.Delete(); } [Fact] public void RemoveEntry_ForeignEntry_IsIgnored() { - var a = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit"); - var b = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Bird"); + var a = NewSpawner("Rabbit"); + var b = NewSpawner("Bird"); var foreign = b.Entries[0]; - var spawned = new Item(0x1f13); + var spawned = Track(new Item(0x1f13)); foreign.AddToSpawned(spawned); a.RemoveEntry(foreign); @@ -140,10 +148,6 @@ public class SpawnerEntryOwnershipTests Assert.False(spawned.Deleted); Assert.Single(foreign.Spawned); - - spawned.Delete(); - a.Delete(); - b.Delete(); } // Manual: remove the Skip, run, and read the numbers from the thrown assertion. diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs index 01a58a697..ca3c7399f 100644 --- a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs @@ -17,30 +17,42 @@ public class CorpseChildPublicTests private static VikingSword EquipSword(Mobile owner) { var sword = new VikingSword { Layer = Layer.OneHanded }; - Assert.True(owner.EquipItem(sword)); + var equipped = owner.EquipItem(sword); + if (!equipped) + { + sword.Delete(); + } + + Assert.True(equipped); return sword; } + private static Mobile NewOwner(int body) + { + var owner = new Mobile(World.NewMobile); + owner.DefaultMobileInit(); + owner.Body = body; + return owner; + } + [Fact] public void IsChildPublic_HumanCorpseWithWornGear_ReturnsTrue() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - var wornItem = EquipSword(owner); - - var corpse = new Corpse(owner, owner.Items); + var owner = NewOwner(0x190); + Corpse corpse = null; try { + var wornItem = EquipSword(owner); + corpse = new Corpse(owner, owner.Items); + Assert.Equal(0x2006, corpse.ItemID); Assert.True(((Body)corpse.Amount).IsHuman); Assert.True(corpse.IsChildPublic(wornItem)); } finally { - corpse.Delete(); + corpse?.Delete(); owner.Delete(); } } @@ -48,26 +60,24 @@ public class CorpseChildPublicTests [Fact] public void IsChildPublic_HumanCorpseWithLootItem_ReturnsFalse() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - var wornItem = EquipSword(owner); - - var corpse = new Corpse(owner, owner.Items); - - var lootItem = new Gold(100); - corpse.DropItem(lootItem); + var owner = NewOwner(0x190); + Corpse corpse = null; try { + EquipSword(owner); + corpse = new Corpse(owner, owner.Items); + + var lootItem = new Gold(100); + corpse.DropItem(lootItem); + Assert.Equal(0x2006, corpse.ItemID); Assert.True(((Body)corpse.Amount).IsHuman); Assert.False(corpse.IsChildPublic(lootItem)); } finally { - corpse.Delete(); + corpse?.Delete(); owner.Delete(); } } @@ -75,16 +85,14 @@ public class CorpseChildPublicTests [Fact] public void IsChildPublic_BoneCorpse_ReturnsFalse() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - var wornItem = EquipSword(owner); - - var corpse = new Corpse(owner, owner.Items); + var owner = NewOwner(0x190); + Corpse corpse = null; try { + var wornItem = EquipSword(owner); + corpse = new Corpse(owner, owner.Items); + corpse.ItemID = 0xECA; Assert.True(((Body)corpse.Amount).IsHuman); @@ -93,7 +101,7 @@ public class CorpseChildPublicTests } finally { - corpse.Delete(); + corpse?.Delete(); owner.Delete(); } } @@ -101,23 +109,21 @@ public class CorpseChildPublicTests [Fact] public void IsChildPublic_NonHumanCorpse_ReturnsFalse() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0xC9; - - var wornItem = EquipSword(owner); - - var corpse = new Corpse(owner, owner.Items); + var owner = NewOwner(0xC9); + Corpse corpse = null; try { + var wornItem = EquipSword(owner); + corpse = new Corpse(owner, owner.Items); + Assert.Equal(0x2006, corpse.ItemID); Assert.False(((Body)corpse.Amount).IsHuman); Assert.False(corpse.IsChildPublic(wornItem)); } finally { - corpse.Delete(); + corpse?.Delete(); owner.Delete(); } } @@ -129,36 +135,48 @@ public class CorpseChildPublicTests return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; } + private static Mobile NewBystander(NetState ns, Point3D location) + { + ns.Account = new MockAccount(); + var bystander = new Mobile(World.NewMobile); + bystander.DefaultMobileInit(); + ns.Mobile = bystander; + bystander.NetState = ns; + bystander.MoveToWorld(location, Map.Felucca); + return bystander; + } + + private static void DeleteBystander(NetState ns, Mobile bystander) + { + ns.Mobile = null; + ns.Dispose(); + bystander?.Delete(); + } + [Fact] public void HumanCorpse_WornGearRemovalBroadcasts_LootRemovalDoesNot() { var corpseLoc = new Point3D(1500, 1500, 0); var bystanderNs = PacketTestUtilities.CreateTestNetState(); - bystanderNs.Account = new MockAccount(); - var bystander = new Mobile(World.NewMobile); - bystander.DefaultMobileInit(); - bystanderNs.Mobile = bystander; - bystander.NetState = bystanderNs; - bystander.MoveToWorld(new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); - - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - var sword = new VikingSword(); - var corpse = new Corpse(owner, [sword]); - corpse.AddItem(sword); - - var lootItem = new Gold(100); - corpse.DropItem(lootItem); - - corpse.MoveToWorld(corpseLoc, Map.Felucca); - + Mobile bystander = null; + var owner = NewOwner(0x190); + Corpse corpse = null; Container pouch = null; try { + bystander = NewBystander(bystanderNs, new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z)); + + var sword = new VikingSword(); + corpse = new Corpse(owner, [sword]); + corpse.AddItem(sword); + + var lootItem = new Gold(100); + corpse.DropItem(lootItem); + + corpse.MoveToWorld(corpseLoc, Map.Felucca); + // A container not in the world is a valid destination; only the removal from the corpse matters here. pouch = new Container(0xE75); pouch.AddItem(sword); @@ -171,11 +189,9 @@ public class CorpseChildPublicTests finally { pouch?.Delete(); - corpse.Delete(); + corpse?.Delete(); owner.Delete(); - bystanderNs.Mobile = null; - bystanderNs.Dispose(); - bystander.Delete(); + DeleteBystander(bystanderNs, bystander); } } @@ -185,25 +201,20 @@ public class CorpseChildPublicTests var corpseLoc = new Point3D(1500, 1500, 0); var bystanderNs = PacketTestUtilities.CreateTestNetState(); - bystanderNs.Account = new MockAccount(); - var bystander = new Mobile(World.NewMobile); - bystander.DefaultMobileInit(); - bystanderNs.Mobile = bystander; - bystander.NetState = bystanderNs; - bystander.MoveToWorld(new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); - - var owner = new Mobile(World.NewMobile); - owner.DefaultMobileInit(); - owner.Body = 0x190; - owner.MoveToWorld(new Point3D(corpseLoc.X - 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); - + Mobile bystander = null; + var owner = NewOwner(0x190); var sword = new VikingSword { Layer = Layer.OneHanded }; - var corpse = new Corpse(owner, [sword]); - corpse.AddItem(sword); - corpse.MoveToWorld(corpseLoc, Map.Felucca); + Corpse corpse = null; try { + bystander = NewBystander(bystanderNs, new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z)); + owner.MoveToWorld(new Point3D(corpseLoc.X - 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); + + corpse = new Corpse(owner, [sword]); + corpse.AddItem(sword); + corpse.MoveToWorld(corpseLoc, Map.Felucca); + // Lifting takes the piece off the worn list before the remove is sent; the remove must // still reach everyone who was shown it worn. owner.Lift(sword, sword.Amount, out var rejected, out _); @@ -216,11 +227,9 @@ public class CorpseChildPublicTests { owner.Holding = null; sword.Delete(); - corpse.Delete(); + corpse?.Delete(); owner.Delete(); - bystanderNs.Mobile = null; - bystanderNs.Dispose(); - bystander.Delete(); + DeleteBystander(bystanderNs, bystander); } } } diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs index 26556155e..9cfec050a 100644 --- a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using Server; using Server.Items; using Xunit; @@ -11,19 +10,22 @@ public class CorpseLootTests [Fact] public void LiftedWornGear_PutBack_StaysLoot() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - // Weapons take their layer from tiledata, which CI doesn't load. - var sword = new VikingSword { Layer = Layer.OneHanded }; - Assert.True(owner.EquipItem(sword)); - - var corpse = new Corpse(owner, new List { sword }); - corpse.DropItem(sword); + var owner = new Mobile(World.NewMobile); + VikingSword sword = null; + Corpse corpse = null; try { + owner.DefaultMobileInit(); + owner.Body = 0x190; + + // Weapons take their layer from tiledata, which CI doesn't load. + sword = new VikingSword { Layer = Layer.OneHanded }; + Assert.True(owner.EquipItem(sword)); + + corpse = new Corpse(owner, [sword]); + corpse.DropItem(sword); + Assert.Contains(sword, corpse.EquipItems); corpse.OnItemLifted(owner, sword); @@ -35,7 +37,8 @@ public class CorpseLootTests } finally { - corpse.Delete(); + corpse?.Delete(); + sword?.Delete(); owner.Delete(); } } diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs index b5cec8aa6..1669147f2 100644 --- a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs @@ -10,19 +10,34 @@ namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] public class CorpsePacketTests { + // Weapons take their layer from tiledata, which CI doesn't load; without an explicit layer the + // equip is refused and the packets would only cover an empty item list. + private static VikingSword EquipSword(Mobile m) + { + var sword = new VikingSword { Layer = Layer.OneHanded }; + var equipped = m.EquipItem(sword); + if (!equipped) + { + sword.Delete(); + } + + Assert.True(equipped); + return sword; + } + [Fact] public void TestCorpseEquipPacket() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - - var weapon = new VikingSword(); - m.EquipItem(weapon); - - var c = new Corpse(m, m.Items); + var m = new Mobile(World.NewMobile); + VikingSword weapon = null; + Corpse c = null; try { + m.DefaultMobileInit(); + weapon = EquipSword(m); + c = new Corpse(m, m.Items); + var expected = new CorpseEquip(m, c).Compile(); using var ns = PacketTestUtilities.CreateTestNetState(); @@ -33,8 +48,9 @@ public class CorpsePacketTests } finally { - c.Delete(); - weapon.Delete(); + c?.Delete(); + weapon?.Delete(); + m.Delete(); } } @@ -43,16 +59,16 @@ public class CorpsePacketTests [InlineData(ProtocolChanges.ContainerGridLines)] public void TestCorpseContainerPacket(ProtocolChanges changes) { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - - var weapon = new VikingSword(); - m.EquipItem(weapon); - - var c = new Corpse(m, m.Items); + var m = new Mobile(World.NewMobile); + VikingSword weapon = null; + Corpse c = null; try { + m.DefaultMobileInit(); + weapon = EquipSword(m); + c = new Corpse(m, m.Items); + using var ns = PacketTestUtilities.CreateTestNetState(); ns.ProtocolChanges = changes; @@ -65,8 +81,9 @@ public class CorpsePacketTests } finally { - c.Delete(); - weapon.Delete(); + c?.Delete(); + weapon?.Delete(); + m.Delete(); } } } diff --git a/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs b/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs index cc0ccad42..b9f13a2f1 100644 --- a/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs @@ -87,14 +87,14 @@ public class VendorBuybackPackTests var pack = new VendorBuybackPack(); var items = new Item[10]; - for (var i = 0; i < items.Length; i++) - { - items[i] = new Item(0x1234); - pack.AddBuyback(items[i], 250); - } - try { + for (var i = 0; i < items.Length; i++) + { + items[i] = new Item(0x1234); + pack.AddBuyback(items[i], 250); + } + pack.Purge(); Assert.Empty(pack.Items); diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs index da896ba8b..745922d08 100644 --- a/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs @@ -10,13 +10,19 @@ namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] public class ThrowingTests { - // Exposes protected ModifyHitChance so the math can be verified directly. + // Exposes protected ModifyHitChance so the math can be verified directly. The requirement and + // throw band are pinned here so retuning a shipped weapon cannot move these expectations. private class TestThrown : BaseThrown { - public TestThrown() : base(0x8FF) { } + // Weapons take their layer from tiledata, which CI doesn't load; without it AddItem is refused. + public TestThrown() : base(0x8FF) => Layer = Layer.OneHanded; + + public override int AosStrengthReq => 60; public override int MinThrowRange => 4; + public override int MaxThrowRange => 7; + public double TestModifyHitChance(Mobile attacker, Mobile defender, double chance) => ModifyHitChance(attacker, defender, chance); @@ -220,18 +226,19 @@ public class ThrowingTests } } - // DefMaxRange STR scaling (uses SoulGlaive: StrReq 60, Min 8, Max 11) + // DefMaxRange STR scaling (TestThrown: StrReq 60, Min 4, Max 7, so one tile per 26 Str) [Fact] public void DefMaxRange_AtStrReq_EqualsMinThrowRange() { - var weapon = new SoulGlaive(); + var weapon = new TestThrown(); var attacker = CreateMobile(Map.Felucca, new Point3D(5800, 500, 0)); try { attacker.RawStr = 60; // == AosStrengthReq attacker.AddItem(weapon); - Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 8 + Assert.Same(attacker, weapon.Parent); + Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 4 } finally { @@ -243,13 +250,14 @@ public class ThrowingTests [Fact] public void DefMaxRange_At140Str_EqualsMaxThrowRange() { - var weapon = new SoulGlaive(); + var weapon = new TestThrown(); var attacker = CreateMobile(Map.Felucca, new Point3D(5810, 500, 0)); try { attacker.RawStr = 140; attacker.AddItem(weapon); - Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 11 + Assert.Same(attacker, weapon.Parent); + Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 7 } finally { @@ -261,13 +269,14 @@ public class ThrowingTests [Fact] public void DefMaxRange_AboveMaxStr_IsCappedAtMaxThrowRange() { - var weapon = new SoulGlaive(); + var weapon = new TestThrown(); var attacker = CreateMobile(Map.Felucca, new Point3D(5820, 500, 0)); try { - attacker.RawStr = 200; // uncapped formula would give 13 + attacker.RawStr = 200; // uncapped formula would give 9 attacker.AddItem(weapon); - Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 11, not 13 + Assert.Same(attacker, weapon.Parent); + Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 7, not 9 } finally { @@ -279,13 +288,14 @@ public class ThrowingTests [Fact] public void DefMaxRange_BelowStrReq_FlooredAtMinThrowRange() { - var weapon = new SoulGlaive(); + var weapon = new TestThrown(); var attacker = CreateMobile(Map.Felucca, new Point3D(5830, 500, 0)); try { attacker.RawStr = 10; // below StrReq 60 attacker.AddItem(weapon); - Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 8, not 6 + Assert.Same(attacker, weapon.Parent); + Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 4, not 3 } finally { @@ -305,6 +315,7 @@ public class ThrowingTests { attacker.RawStr = 140; // MaxRange == MaxThrowRange == 7 attacker.AddItem(weapon); + Assert.Same(attacker, weapon.Parent); var defender = CreateMobile(map, new Point3D(5907, 500, 0)); // distance 7 == MaxRange try { @@ -333,6 +344,7 @@ public class ThrowingTests { attacker.RawStr = 140; // MaxRange 7 attacker.AddItem(weapon); + Assert.Same(attacker, weapon.Parent); var defender = CreateMobile(map, new Point3D(5925, 500, 0)); // distance 5 < 7 try { diff --git a/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs b/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs index a46c56003..68be84e7b 100644 --- a/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs +++ b/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs @@ -17,22 +17,23 @@ public class CorpseNotorietyTests public void PlayerCorpseNotorietyIgnoresTransformedBody(int bodyMod, int expected, int kills = 0) { var victim = new PlayerMobile(World.NewMobile); - victim.DefaultMobileInit(); - victim.Kills = kills; - victim.BodyMod = bodyMod; - var looter = new PlayerMobile(World.NewMobile); - looter.DefaultMobileInit(); - - var corpse = new Corpse(victim, []); + Corpse corpse = null; try { + victim.DefaultMobileInit(); + victim.Kills = kills; + victim.BodyMod = bodyMod; + looter.DefaultMobileInit(); + + corpse = new Corpse(victim, []); + Assert.Equal(expected, NotorietyHandlers.CorpseNotoriety(looter, corpse)); } finally { - corpse.Delete(); + corpse?.Delete(); victim.Delete(); looter.Delete(); } diff --git a/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs b/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs index de6825c9f..06d940512 100644 --- a/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs +++ b/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Reflection; using Server.Configurations; using Server.Misc; using Xunit; @@ -7,13 +8,22 @@ using Xunit; namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] -public class EmailTests +public class EmailTests : IDisposable { + private static readonly PropertyInfo _emailEnabled = + typeof(EmailConfiguration).GetProperty(nameof(EmailConfiguration.EmailEnabled)); + + private readonly bool _savedEmailEnabled = EmailConfiguration.EmailEnabled; + + // Pinned off rather than read from the host's email.json, so a developer config with email + // enabled cannot turn these into real sends. + public EmailTests() => _emailEnabled.SetValue(null, false); + + public void Dispose() => _emailEnabled.SetValue(null, _savedEmailEnabled); + [Fact] public void SendCrashEmailDoesNothingWhileEmailIsDisabled() { - Assert.False(EmailConfiguration.EmailEnabled, "The test host never enables email"); - // Missing input must be ignored while email is disabled. var report = Path.Combine(Path.GetTempPath(), $"crash-email-test-{Guid.NewGuid():N}", "Crash.log"); @@ -23,8 +33,7 @@ public class EmailTests [Fact] public void SendQueueEmailDoesNothingWhileEmailIsDisabled() { - Assert.False(EmailConfiguration.EmailEnabled, "The test host never enables email"); - + // A null entry would throw on the enabled path, so returning cleanly shows the guard held. Email.SendQueueEmail(null, "Other"); } diff --git a/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs b/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs index f596282f6..86787e0f2 100644 --- a/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs +++ b/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs @@ -22,40 +22,55 @@ public class GuildSerializePurityTests public void SerializeDoesNotRecalculateFealtyOrMutateState() { var leader = CreatePlayer(); - var guild = new Guild(leader, "Purity Test Guild", "PTG"); - var staleFealty = Core.Now - TimeSpan.FromDays(3); - guild.LastFealty = staleFealty; + Guild guild = null; - var writer = new BufferWriter(true); - guild.Serialize(writer); + try + { + guild = new Guild(leader, "Purity Test Guild", "PTG"); + var staleFealty = Core.Now - TimeSpan.FromDays(3); + guild.LastFealty = staleFealty; - Assert.Equal(staleFealty, guild.LastFealty); - Assert.Same(leader, guild.Leader); + var writer = new BufferWriter(true); + guild.Serialize(writer); - var first = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray(); - writer.Seek(0, SeekOrigin.Begin); - guild.Serialize(writer); - var second = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray(); + Assert.Equal(staleFealty, guild.LastFealty); + Assert.Same(leader, guild.Leader); - Assert.Equal(first, second); + var first = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray(); + writer.Seek(0, SeekOrigin.Begin); + guild.Serialize(writer); + var second = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray(); - writer.Close(); - guild.Disband(); - leader.Delete(); + Assert.Equal(first, second); + + writer.Close(); + } + finally + { + guild?.Disband(); + leader.Delete(); + } } [Fact] public void RunMaintenanceRecalculatesStaleFealty() { var leader = CreatePlayer(); - var guild = new Guild(leader, "Maintenance Test Guild", "MTG"); - guild.LastFealty = Core.Now - TimeSpan.FromDays(3); + Guild guild = null; - guild.RunMaintenance(); + try + { + guild = new Guild(leader, "Maintenance Test Guild", "MTG"); + guild.LastFealty = Core.Now - TimeSpan.FromDays(3); - Assert.True(Core.Now - guild.LastFealty < TimeSpan.FromMinutes(1)); + guild.RunMaintenance(); - guild.Disband(); - leader.Delete(); + Assert.True(Core.Now - guild.LastFealty < TimeSpan.FromMinutes(1)); + } + finally + { + guild?.Disband(); + leader.Delete(); + } } } diff --git a/Projects/UOContent.Tests/Tests/Misc/NameVerificationTests.cs b/Projects/UOContent.Tests/Tests/Misc/NameVerificationTests.cs index 60b450b0d..233086f04 100644 --- a/Projects/UOContent.Tests/Tests/Misc/NameVerificationTests.cs +++ b/Projects/UOContent.Tests/Tests/Misc/NameVerificationTests.cs @@ -38,7 +38,7 @@ public class NameVerificationTests [InlineData("Blacksmith 123")] [InlineData("Baker-Smith")] [InlineData("*Innkeeper*")] - public void ValidateVendorName_ValidNames_ReturnsFalse(string name) + public void ValidateVendorName_DisallowedCharacters_ReturnsFalse(string name) { Assert.False(NameVerification.ValidateVendorName(name)); } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs index 3ee516dcf..e06571b63 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs @@ -60,9 +60,14 @@ public class AcquisitionTests : IDisposable bc.NextReacquireTime = Core.TickCount; Assert.False(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); - Assert.InRange(bc.NextReacquireTime - Core.TickCount, 5000, 10000); + Assert.Equal(ReacquireDelayMs(bc), bc.NextReacquireTime - Core.TickCount); } + private static long ReacquireDelayMs(BaseCreature bc) => (long)bc.ReacquireDelay.TotalMilliseconds; + + // A legal, fully armed deadline: as far out as a scan ever sets it. Movement can only pull it in. + private static void ArmFullDelay(BaseCreature bc) => bc.NextReacquireTime = Core.TickCount + ReacquireDelayMs(bc); + [Fact] public void WedgedGate_SelfHeals() { @@ -78,7 +83,7 @@ public class AcquisitionTests : IDisposable _created.Add(target); // Illegal deadline (beyond ReacquireDelay): must read as open, not block forever. - bc.NextReacquireTime = Core.TickCount + 60000; + bc.NextReacquireTime = Core.TickCount + ReacquireDelayMs(bc) * 6; Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); Assert.Equal(target, bc.FocusMob); @@ -96,7 +101,8 @@ public class AcquisitionTests : IDisposable map.GetAverageZ(1500, 1600, out _, out var z, out _); var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); - bc.NextReacquireTime = Core.TickCount + 8000; + ArmFullDelay(bc); + var armed = bc.NextReacquireTime; Mobile mover; if (wildMover) @@ -117,12 +123,12 @@ public class AcquisitionTests : IDisposable if (notices) { - // Clamped to the approach delay (2s), never opened outright. + // Clamped to the approach delay, never opened outright. Assert.InRange(remaining, 1, (long)bc.AcquireOnApproachDelay.TotalMilliseconds); } else { - Assert.True(remaining > 5000); + Assert.Equal(armed, bc.NextReacquireTime); } } @@ -150,7 +156,7 @@ public class AcquisitionTests : IDisposable bc.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); bc.AIObject.AITimer?.Stop(); _created.Add(bc); - bc.NextReacquireTime = Core.TickCount + 8000; + ArmFullDelay(bc); var mover = new TargetStub { Player = true }; mover.DefaultMobileInit(); @@ -173,7 +179,7 @@ public class AcquisitionTests : IDisposable map.GetAverageZ(1500, 1600, out _, out var z, out _); var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); - bc.NextReacquireTime = Core.TickCount + 8000; + ArmFullDelay(bc); var mover = new TargetStub { Player = true }; mover.DefaultMobileInit(); @@ -206,6 +212,6 @@ public class AcquisitionTests : IDisposable Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); Assert.Equal(target, bc.FocusMob); - Assert.True(bc.NextReacquireTime - Core.TickCount > 5000); + Assert.Equal(ReacquireDelayMs(bc), bc.NextReacquireTime - Core.TickCount); } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs index fe8e397a1..55965375b 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs @@ -1,43 +1,47 @@ +using System; using System.Collections.Generic; using Server; using Server.Engines.Pathing.Cache; using Server.Mobiles; using Server.Tests; -using Server.Tests.Mobiles.AI; using Xunit; namespace UOContent.Tests.Mobiles.AI; // Driven manually against live map statics; the pathfinder's buffers are not reentrant. [Collection("Sequential Pathfinding Tests")] -public class ApproachOutcomeTests +public class ApproachOutcomeTests : IDisposable { - private sealed class Stub : BaseCreature + private readonly List _created = []; + + public void Dispose() { - public Stub(Serial serial) : base(serial) => Body = 0xC9; + for (var i = _created.Count - 1; i >= 0; i--) + { + _created[i].Delete(); + } + + _created.Clear(); } - private sealed class Target : Mobile - { - public Target() => Body = 0xC9; - } - - private static (Stub bc, BaseAI ai) NewFollower(Map map, int x, int y) + private (BareCreatureStub bc, BaseAI ai) NewFollower(Map map, int x, int y) { map.GetAverageZ(x, y, out _, out var z, out _); - var bc = new Stub(World.NewMobile); + var bc = new BareCreatureStub(World.NewMobile); bc.DefaultMobileInit(); bc.MoveToWorld(new Point3D(x, y, (sbyte)z), map); + _created.Add(bc); BaseAI ai = new AnimalAI(bc); ai.AITimer?.Stop(); return (bc, ai); } - private static Target NewTarget(Map map, int x, int y) + private WalkerStub NewTarget(Map map, int x, int y) { map.GetAverageZ(x, y, out _, out var z, out _); - var t = new Target(); + var t = new WalkerStub(); t.MoveToWorld(new Point3D(x, y, (sbyte)z), map); + _created.Add(t); return t; } @@ -46,32 +50,24 @@ public class ApproachOutcomeTests { TileDataRequirement.SkipIfMissing(); var map = Map.Maps[1]; - var (bc, ai) = NewFollower(map, 1500, 1600); + var (_, ai) = NewFollower(map, 1500, 1600); var target = NewTarget(map, 1497, 1600); StepCache.Instance.Clear(); - try - { - ai.NextMove = 0; - ai.MoveTo(target, 1); - Assert.Equal(ApproachOutcome.DirectProgress, ai.LastApproach); + ai.NextMove = 0; + ai.MoveTo(target, 1); + Assert.Equal(ApproachOutcome.DirectProgress, ai.LastApproach); - ai.MoveTo(target, 1); // budget consumed: no step - Assert.Equal(ApproachOutcome.Waiting, ai.LastApproach); + ai.MoveTo(target, 1); // budget consumed: no step + Assert.Equal(ApproachOutcome.Waiting, ai.LastApproach); - ai.NextMove = 0; - ai.MoveTo(target, 1); // lands adjacent: still a progress step - Assert.Equal(ApproachOutcome.DirectProgress, ai.LastApproach); + ai.NextMove = 0; + ai.MoveTo(target, 1); // lands adjacent: still a progress step + Assert.Equal(ApproachOutcome.DirectProgress, ai.LastApproach); - ai.NextMove = 0; - ai.MoveTo(target, 1); - Assert.Equal(ApproachOutcome.Arrived, ai.LastApproach); - } - finally - { - bc.Delete(); - target.Delete(); - } + ai.NextMove = 0; + ai.MoveTo(target, 1); + Assert.Equal(ApproachOutcome.Arrived, ai.LastApproach); } [SkippableFact] @@ -79,10 +75,9 @@ public class ApproachOutcomeTests { TileDataRequirement.SkipIfMissing(); var map = Map.Maps[1]; - var (bc, ai) = NewFollower(map, 1500, 1601); + var (_, ai) = NewFollower(map, 1500, 1601); var target = NewTarget(map, 1500, 1596); - var ring = new List(); - var id = ApproachTargetTests.FirstImpassableItemId(); + var id = TestTiles.FirstImpassableItemId(); Assert.NotEqual(0, id); // Impassable ring around the target: unreachable. @@ -96,35 +91,22 @@ public class ApproachOutcomeTests } map.GetAverageZ(x, y, out _, out var rz, out _); - ring.Add(new Item(World.NewItem) { ItemID = id, Map = map, Location = new Point3D(x, y, (sbyte)rz) }); + _created.Add(new Item(World.NewItem) { ItemID = id, Map = map, Location = new Point3D(x, y, (sbyte)rz) }); } } StepCache.Instance.Clear(); - try + var sawRouting = false; + + for (var i = 0; i < 120; i++) { - var sawRouting = false; - - for (var i = 0; i < 120; i++) - { - ai.NextMove = 0; - ai.MoveTo(target, 1); - sawRouting |= ai.LastApproach is ApproachOutcome.Routing or ApproachOutcome.Blocked; - } - - Assert.True(sawRouting, "a walled target must route or block before giving up"); - Assert.Equal(ApproachOutcome.GaveUp, ai.LastApproach); + ai.NextMove = 0; + ai.MoveTo(target, 1); + sawRouting |= ai.LastApproach is ApproachOutcome.Routing or ApproachOutcome.Blocked; } - finally - { - bc.Delete(); - target.Delete(); - for (var i = 0; i < ring.Count; i++) - { - ring[i].Delete(); - } - } + Assert.True(sawRouting, "a walled target must route or block before giving up"); + Assert.Equal(ApproachOutcome.GaveUp, ai.LastApproach); } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs index f3869ffef..e330268f0 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Server.Engines.Pathing.Cache; using Server.Mobiles; @@ -9,29 +10,45 @@ namespace Server.Tests.Mobiles.AI; // pathfinder uses non-reentrant static buffers, so we share the pathfinding sequential // collection to avoid parallel interference. [Collection("Sequential Pathfinding Tests")] -public class ApproachTargetTests +public class ApproachTargetTests : IDisposable { - private sealed class FollowerStub : BaseCreature + private readonly List _created = []; + + public void Dispose() { - // Serial ctor + DefaultMobileInit bypasses NPCSpeeds JSON (absent in tests). - public FollowerStub(Serial serial) : base(serial) => Body = 0xC9; + for (var i = _created.Count - 1; i >= 0; i--) + { + _created[i].Delete(); + } + + _created.Clear(); } - private sealed class TargetStub : Mobile + private (BareCreatureStub bc, BaseAI ai) NewFollower(Map map, Point3D loc) { - public TargetStub() => Body = 0xC9; - } - - private static (FollowerStub bc, BaseAI ai) NewFollower(Map map, Point3D loc) - { - var bc = new FollowerStub(World.NewMobile); + var bc = new BareCreatureStub(World.NewMobile); bc.DefaultMobileInit(); bc.MoveToWorld(loc, map); + _created.Add(bc); BaseAI ai = new AnimalAI(bc); ai.AITimer?.Stop(); // we drive movement manually; no background timer return (bc, ai); } + private WalkerStub NewTarget(Map map, Point3D loc) + { + var target = new WalkerStub(); + target.MoveToWorld(loc, map); + _created.Add(target); + return target; + } + + private void PlaceBlocker(Map map, ushort itemId, int x, int y) + { + map.GetAverageZ(x, y, out _, out var z, out _); + _created.Add(new Item(World.NewItem) { ItemID = itemId, Map = map, Location = new Point3D(x, y, (sbyte)z) }); + } + // Drives the pet follow primitive once per tick; returns true once it reaches within // arriveDist tiles. Uses InRange (Chebyshev) to match the game's own range semantics — // a diagonal neighbor at Euclidean ~2.24 is "within 2" to the engine. @@ -49,18 +66,6 @@ public class ApproachTargetTests return false; } - internal static ushort FirstImpassableItemId() - { - for (ushort id = 1; id < TileData.MaxItemValue; id++) - { - if (TileData.ItemTable[id].ImpassableSurface) - { - return id; - } - } - return 0; - } - [Fact] public void OpenTerrain_ReachesTarget_WithoutPathfinding() { @@ -69,8 +74,7 @@ public class ApproachTargetTests map.GetAverageZ(1500, 1600, out _, out var z, out _); var (bc, ai) = NewFollower(map, new Point3D(1500, 1600, (sbyte)z)); - var target = new TargetStub(); - target.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map); // 5 tiles west, open + var target = NewTarget(map, new Point3D(1495, 1600, (sbyte)z)); // 5 tiles west, open StepCache.Instance.Clear(); var buildsBefore = StepCache.Instance.GetStats().BuildsTotal; @@ -78,44 +82,40 @@ public class ApproachTargetTests var arrived = DriveFollow(ai, bc, target, 2, 40); var buildsAfter = StepCache.Instance.GetStats().BuildsTotal; - bc.Delete(); - target.Delete(); Assert.True(arrived, "creature should reach an open-terrain target by stepping"); Assert.Equal(buildsBefore, buildsAfter); // greedy fast path: no chunk builds } - [Fact] + // The desk is a Britain inn static; on a blank map the detour is a straight walk. + [SkippableFact] public void BritainInnDesk_PetReachesMaster() { + TileDataRequirement.SkipIfMissing(); var map = Map.Maps[1]; Assert.NotNull(map); // Exact in-game repro: pet south of the L-desk, master north of it. var (bc, ai) = NewFollower(map, new Point3D(1493, 1614, 20)); - var target = new TargetStub(); - target.MoveToWorld(new Point3D(1494, 1605, 21), map); + var target = NewTarget(map, new Point3D(1494, 1605, 21)); StepCache.Instance.Clear(); // 200 ticks is generous for the ~17-step detour at one step per tick. var arrived = DriveFollow(ai, bc, target, 2, 200); - bc.Delete(); - target.Delete(); - Assert.True(arrived, "pet must navigate around the L-desk to reach the master"); } - [Fact] + [SkippableFact] public void MoveTo_ReachesTargetAroundDesk() { + TileDataRequirement.SkipIfMissing(); var map = Map.Maps[1]; Assert.NotNull(map); var (bc, ai) = NewFollower(map, new Point3D(1493, 1614, 20)); - var target = new TargetStub(); - target.MoveToWorld(new Point3D(1494, 1605, 21), map); + var target = NewTarget(map, new Point3D(1494, 1605, 21)); StepCache.Instance.Clear(); @@ -131,9 +131,6 @@ public class ApproachTargetTests } } - bc.Delete(); - target.Delete(); - Assert.True(arrived, "MoveTo chaser must reach the target around the desk"); } @@ -145,8 +142,7 @@ public class ApproachTargetTests map.GetAverageZ(1500, 1600, out _, out var z, out _); var (bc, ai) = NewFollower(map, new Point3D(1500, 1600, (sbyte)z)); - var target = new TargetStub(); - target.MoveToWorld(new Point3D(1498, 1600, (sbyte)z), map); + var target = NewTarget(map, new Point3D(1498, 1600, (sbyte)z)); StepCache.Instance.Clear(); @@ -170,9 +166,6 @@ public class ApproachTargetTests } } - bc.Delete(); - target.Delete(); - Assert.True(caught, "chaser must catch a target that walks away then stops"); } @@ -185,13 +178,11 @@ public class ApproachTargetTests map.GetAverageZ(1500, 1601, out _, out var z, out _); var (bc, ai) = NewFollower(map, new Point3D(1500, 1601, (sbyte)z)); - var target = new TargetStub(); - target.MoveToWorld(new Point3D(1500, 1596, (sbyte)z), map); + var target = NewTarget(map, new Point3D(1500, 1596, (sbyte)z)); // Full impassable ring around the target cell -> no path to within range 1. - var id = FirstImpassableItemId(); + var id = TestTiles.FirstImpassableItemId(); Assert.NotEqual(0, id); - var ring = new List(); for (var x = 1499; x <= 1501; x++) { for (var y = 1595; y <= 1597; y++) @@ -200,11 +191,8 @@ public class ApproachTargetTests { continue; // leave the target's own cell } - map.GetAverageZ(x, y, out _, out var rz, out _); - ring.Add(new Item(World.NewItem) - { - ItemID = id, Map = map, Location = new Point3D(x, y, (sbyte)rz) - }); + + PlaceBlocker(map, id, x, y); } } @@ -231,16 +219,7 @@ public class ApproachTargetTests } } - var reached = bc.InRange(target, 1); - - bc.Delete(); - target.Delete(); - foreach (var it in ring) - { - it.Delete(); - } - - Assert.False(reached, "walled-off target must not be reached"); + Assert.False(bc.InRange(target, 1), "walled-off target must not be reached"); Assert.True(stayedIdle, "after giving up, the creature must idle, not shuffle"); } @@ -253,33 +232,20 @@ public class ApproachTargetTests map.GetAverageZ(1500, 1600, out _, out var z, out _); var (bc, ai) = NewFollower(map, new Point3D(1500, 1600, (sbyte)z)); - var target = new TargetStub(); - target.MoveToWorld(new Point3D(1500, 1595, (sbyte)z), map); // 5 tiles north + var target = NewTarget(map, new Point3D(1500, 1595, (sbyte)z)); // 5 tiles north // Impassable wall on Y=1598 from X=1497..1503: blocks the straight line; ends open. - var id = FirstImpassableItemId(); + var id = TestTiles.FirstImpassableItemId(); Assert.NotEqual(0, id); - var wall = new List(); for (var x = 1497; x <= 1503; x++) { - map.GetAverageZ(x, 1598, out _, out var wz, out _); - wall.Add(new Item(World.NewItem) - { - ItemID = id, Map = map, Location = new Point3D(x, 1598, (sbyte)wz) - }); + PlaceBlocker(map, id, x, 1598); } StepCache.Instance.Clear(); var arrived = DriveFollow(ai, bc, target, 2, 200); - bc.Delete(); - target.Delete(); - foreach (var it in wall) - { - it.Delete(); - } - Assert.True(arrived, "creature must route around the wall to reach the target"); } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs index 383e2e103..84898b7f1 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs @@ -1,6 +1,7 @@ using System; using Server; using Server.Mobiles; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles.AI; @@ -37,9 +38,8 @@ public class DetectHiddenTimerTests public void DetectHidden_SchedulesBoundedCooldown_AndWaitsBeforeRetry( int intelligence, long minDelay, long maxDelay) { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); - Core._tickCount = 0; - Timer.Init(0); + Skip.If(!TestServerInitializer.TileDataLoaded, "Requires UO client map data."); + using var clock = new TimerWheelScope(); var creature = new DetectingCreature(); DetectingAI ai = null; @@ -63,12 +63,8 @@ public class DetectHiddenTimerTests Assert.InRange(ai._nextDetectHidden - Core.TickCount, minDelay, maxDelay); Assert.True(ai.AITimer.Running); - var deadline = ai._nextDetectHidden; - while (Core.TickCount + 8 < deadline) - { - Core._tickCount += 8; - Timer.Slice(Core.TickCount); - } + // Stop one wheel step short of the cooldown: no retry may have fired yet. + TimerWheelScope.RunFor(ai._nextDetectHidden - TimerWheelScope.Step - Core.TickCount); Assert.Equal(1, ai.Detections); AdvanceUntil(() => ai.Detections > 1, 1024); @@ -82,14 +78,9 @@ public class DetectHiddenTimerTests } } - private static void AdvanceUntil(Func condition, int milliseconds) - { - for (var elapsed = 0; !condition() && elapsed < milliseconds; elapsed += 8) - { - Core._tickCount += 8; - Timer.Slice(Core.TickCount); - } - - Assert.True(condition(), "The AI timer did not reach the expected detection."); - } + private static void AdvanceUntil(Func condition, int milliseconds) => + Assert.True( + TimerWheelScope.RunUntil(condition, milliseconds), + "The AI timer did not reach the expected detection." + ); } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs index 692ff8152..27b4ee0a0 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs @@ -4,8 +4,8 @@ using Server; using Server.Items; using Server.Mobiles; using Server.Tests; -using Server.Tests.Mobiles.AI; using Xunit; +using static Server.Tests.TimerWheelScope; namespace UOContent.Tests.Mobiles.AI; @@ -50,12 +50,12 @@ public class FamiliarAITests : IDisposable private static readonly Map _map = Map.Maps[1]; private readonly List _created = new(); + private readonly TimerWheelScope _clock; public FamiliarAITests() { TileDataRequirement.SkipIfMissing(); - Core._tickCount = 0; - Timer.Init(0); + _clock = new TimerWheelScope(); } public void Dispose() @@ -64,6 +64,8 @@ public class FamiliarAITests : IDisposable { _created[i].Delete(); } + + _clock.Dispose(); } private static Point3D At(int x, int y) @@ -109,36 +111,6 @@ public class FamiliarAITests : IDisposable return e; } - // 8ms lockstep keeps the wheel and Core.TickCount in sync. - private static void RunFor(long ms) - { - var deadline = Core._tickCount + ms; - - while (Core._tickCount - deadline < 0) - { - Core._tickCount += 8; - Timer.Slice(Core._tickCount); - } - } - - private static bool RunUntil(Func condition, long maxMs) - { - var deadline = Core._tickCount + maxMs; - - while (Core._tickCount - deadline < 0) - { - if (condition()) - { - return true; - } - - Core._tickCount += 8; - Timer.Slice(Core._tickCount); - } - - return condition(); - } - [SkippableFact] public void Follows_WithoutBacktracking() { @@ -323,7 +295,7 @@ public class FamiliarAITests : IDisposable var f = Familiar(0, p, 1500, 1602); // 5x5 ring, open 3x3 interior: a landing tile exists, no route reaches it. - var id = ApproachTargetTests.FirstImpassableItemId(); + var id = TestTiles.FirstImpassableItemId(); Assert.NotEqual(0, id); for (var x = 1498; x <= 1502; x++) diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs index 785278507..f76a0e4db 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Server; using Server.Mobiles; @@ -7,8 +8,20 @@ namespace UOContent.Tests.Mobiles.AI; // Guard-following may pathfind, so this shares the pathfinding collection. [Collection("Sequential Pathfinding Tests")] -public class GuardFollowTests +public class GuardFollowTests : IDisposable { + private readonly List _created = []; + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + + _created.Clear(); + } + [Fact] public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent() { @@ -16,13 +29,14 @@ public class GuardFollowTests Assert.NotNull(map); map.GetAverageZ(1500, 1600, out _, out var z, out _); - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); - - var pet = new PetTestStub(); - pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain - pet.SetControlMaster(master); + // Pet 6 tiles east of the master, open terrain. + var (master, pet) = PetTestSetup.SpawnControlledPet( + new Point3D(1494, 1600, (sbyte)z), + new Point3D(1500, 1600, (sbyte)z), + map + ); + _created.Add(master); + _created.Add(pet); var ai = pet.AIObject; ai.AITimer?.Stop(); // drive manually @@ -33,21 +47,13 @@ public class GuardFollowTests ai.NextMove = 0; ai.Obey(); - var moved = pet.Location != start; - var hasIntent = ai.TryGetMoveWake(out _); - var currentSpeed = pet.CurrentSpeed; - var currentMoveSpeed = pet.CurrentMoveSpeed; - - pet.Delete(); - master.Delete(); - - Assert.True(moved, "a guarding pet beyond guard range must step toward its master"); + Assert.True(pet.Location != start, "a guarding pet beyond guard range must step toward its master"); // Without a move intent, guard-following only steps on the think grid. - Assert.True(hasIntent, "guard-following must register a move intent"); + Assert.True(ai.TryGetMoveWake(out _), "guard-following must register a move intent"); // The return is paced by FollowMoveSpeed; the think clock stays on the active value // and the per-step speed flip must not undo either. - Assert.Equal(0.2, currentSpeed); - Assert.Equal(0.1, currentMoveSpeed); + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(pet.FollowMoveSpeed, pet.CurrentMoveSpeed); } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs index ec11a3ba5..ee3bfe7cd 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs @@ -11,10 +11,24 @@ public class GuardFriendlyFireTests : IDisposable { private readonly List _created = []; + // AI_Mage stands in for a shipped caster: MageAI's combat loop has its own reacquire path. + private sealed class MagePet : BaseCreature + { + public MagePet() : base(AIType.AI_Mage, FightMode.Closest, 10, 1) => Body = 0xC8; + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.2; + passiveSpeed = 0.4; + } + } + private (PlayerMobile owner, PetTestStub pet) Spawn() { Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); var z = Map.Felucca.GetAverageZ(1500, 1600); + // The constructed PlayerMobile, not PetTestSetup's deserialization-shaped one: the harmful + // checks read PermaFlags, which only the full constructor allocates. var owner = new PlayerMobile(); owner.MoveToWorld(new Point3D(1500, 1600, z), Map.Felucca); _created.Add(owner); @@ -192,28 +206,28 @@ public class GuardFriendlyFireTests : IDisposable } [SkippableFact] - public void ActualWhiteWyrmDoesNotTurnOnOwnerWhenEnemyDisappears() + public void GuardingMageDoesNotTurnOnOwnerWhenEnemyDisappears() { var (owner, _) = Spawn(); - var wyrm = new WhiteWyrm(); - _created.Add(wyrm); - wyrm.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map); - wyrm.SetControlMaster(owner); - wyrm.IssueOrder(OrderType.Guard, owner); - wyrm.AIObject.AITimer.Stop(); + var mage = new MagePet(); + _created.Add(mage); + mage.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map); + mage.SetControlMaster(owner); + mage.IssueOrder(OrderType.Guard, owner); + mage.AIObject.AITimer.Stop(); var enemy = new PetTestStub(); _created.Add(enemy); enemy.MoveToWorld(new Point3D(1497, 1600, owner.Z), owner.Map); enemy.AIObject.AITimer.Stop(); - wyrm.Combatant = enemy; + mage.Combatant = enemy; enemy.Hidden = true; - wyrm.NextReacquireTime = Core.TickCount - 1; - Assert.IsType(wyrm.AIObject); - wyrm.AIObject.DoActionCombat(); - Assert.NotSame(owner, wyrm.Combatant); - Assert.Null(wyrm.FocusMob); - wyrm.AIObject.DoOrderGuard(); - Assert.Null(wyrm.Combatant); + mage.NextReacquireTime = Core.TickCount - 1; + Assert.IsType(mage.AIObject); + mage.AIObject.DoActionCombat(); + Assert.NotSame(owner, mage.Combatant); + Assert.Null(mage.FocusMob); + mage.AIObject.DoOrderGuard(); + Assert.Null(mage.Combatant); } public void Dispose() diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs index 7d572786a..356994020 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using Server; using Server.Mobiles; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles.AI; @@ -14,11 +15,6 @@ public class GuardOrderTests : IDisposable { private readonly List _created = new(); - private sealed class AggressorStub : Mobile - { - public AggressorStub() => Body = 0xC9; - } - public void Dispose() { foreach (var m in _created) @@ -35,14 +31,12 @@ public class GuardOrderTests : IDisposable Assert.NotNull(map); map.GetAverageZ(1500, 1600, out _, out z, out _); - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + var (master, pet) = PetTestSetup.SpawnControlledPet( + new Point3D(1500, 1600, (sbyte)z), + new Point3D(1499, 1600, (sbyte)z), + map + ); _created.Add(master); - - var pet = new PetTestStub(); - pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map); - pet.SetControlMaster(master); _created.Add(pet); pet.AIObject.AITimer?.Stop(); // drive manually @@ -52,9 +46,9 @@ public class GuardOrderTests : IDisposable return (master, pet); } - private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking) + private WalkerStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking) { - var aggr = new AggressorStub(); + var aggr = new WalkerStub(); aggr.MoveToWorld(loc, pet.Map); _created.Add(aggr); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs index 357a39979..6c88649fa 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs @@ -10,11 +10,6 @@ namespace UOContent.Tests.Mobiles.AI; [Collection("Sequential Pathfinding Tests")] public class HerdingTests { - private sealed class Stub : BaseCreature - { - public Stub(Serial serial) : base(serial) => Body = 0xC9; - } - [SkippableFact] public void HerdedCreature_ReachesTheTile_AndClearsTargetLocation() { @@ -22,7 +17,7 @@ public class HerdingTests var map = Map.Maps[1]; map.GetAverageZ(1500, 1600, out _, out var z, out _); - var bc = new Stub(World.NewMobile); + var bc = new BareCreatureStub(World.NewMobile); bc.DefaultMobileInit(); bc.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); BaseAI ai = new AnimalAI(bc); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs index 2789cd735..288572eb7 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs @@ -210,10 +210,10 @@ public class PetOrderTests : IDisposable Assert.Equal(post, pet.Home); // anchor still the original post, not the corpse } - // NOTE: the test fixture does not load tile data, so Mobile.Move is blocked and Location - // never changes here. DoMoveImpl still sets Mobile.Direction before the (blocked) move, - // so an *attempted* wander is observable via Direction. The subjective wander cadence is - // covered by manual QA; these tests verify the gate/frozen wiring deterministically. + // A step may be blocked by whatever terrain (1000, 1000) holds, but DoMoveImpl sets + // Mobile.Direction before attempting the move, so an *attempted* wander is observable via + // Direction either way. The subjective wander cadence is covered by manual QA; these tests + // verify the gate/frozen wiring deterministically. [Fact] public void IdlePet_DoesNotAttemptToMove_WhileResting() { @@ -224,12 +224,18 @@ public class PetOrderTests : IDisposable pet.ForceIdle = true; // CheckIdle() reports resting -> idle wander must be skipped pet.Direction = Direction.North; + var start = pet.Location; + var ai = pet.AIObject; for (var i = 0; i < 40; i++) { - pet.AIObject.DoOrderNone(); + // Reopen the move clock every pass, so CheckIdle is the only gate left standing. + ai.NextMove = Core.TickCount; + Assert.True(ai.CanMoveNow(out _)); + ai.DoOrderNone(); } Assert.Equal(Direction.North, pet.Direction); // gated by CheckIdle -> never attempts a step + Assert.Equal(start, pet.Location); } [Fact] diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs index 1064fb317..793fd3d6f 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs @@ -2,7 +2,9 @@ using System; using System.Collections.Generic; using Server; using Server.Mobiles; +using Server.Tests; using Xunit; +using static Server.Tests.TimerWheelScope; namespace UOContent.Tests.Mobiles.AI; @@ -11,6 +13,7 @@ namespace UOContent.Tests.Mobiles.AI; public class PetPacingTests : IDisposable { private readonly List _created = new(); + private TimerWheelScope _clock; private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc) { @@ -28,6 +31,7 @@ public class PetPacingTests : IDisposable } _created.Clear(); + _clock?.Dispose(); } // Movement orders run active, resting orders run passive; the move clock follows. @@ -64,8 +68,8 @@ public class PetPacingTests : IDisposable pet.ControlTarget = master; pet.ControlOrder = OrderType.Follow; // fixture era is EJ - Assert.Equal(0.2, pet.CurrentSpeed); // active think, not the follow pace - Assert.Equal(0.1, pet.CurrentMoveSpeed); // capped at the follow pace + Assert.Equal(0.2, pet.CurrentSpeed); // active think, not the follow pace + Assert.Equal(pet.FollowMoveSpeed, pet.CurrentMoveSpeed); // capped at the follow pace } // A creature configured faster than the follow pace keeps its own. @@ -73,12 +77,13 @@ public class PetPacingTests : IDisposable public void FollowMaster_KeepsAFasterConfiguredPace() { var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.SetMoveSpeed(0.05, 0.9); + var faster = pet.FollowMoveSpeed / 2; + pet.SetMoveSpeed(faster, 0.9); pet.ControlTarget = master; pet.ControlOrder = OrderType.Follow; - Assert.Equal(0.05, pet.CurrentMoveSpeed); + Assert.Equal(faster, pet.CurrentMoveSpeed); } // The move-clock override survives the order: it is capped while following, not overwritten. @@ -145,7 +150,7 @@ public class PetPacingTests : IDisposable pet.ControlOrder = OrderType.Guard; Assert.Equal(0.2, pet.CurrentSpeed); - Assert.Equal(0.1, pet.CurrentMoveSpeed); + Assert.Equal(pet.FollowMoveSpeed, pet.CurrentMoveSpeed); } // Obeying the follow order must not write the pace into either clock. @@ -161,7 +166,7 @@ public class PetPacingTests : IDisposable pet.AIObject.Obey(); Assert.Equal(0.2, pet.CurrentSpeed); - Assert.Equal(0.1, pet.CurrentMoveSpeed); + Assert.Equal(pet.FollowMoveSpeed, pet.CurrentMoveSpeed); } // At the master's side a guarding pet stays active: no stale-warmode passive, no sprint. @@ -208,41 +213,10 @@ public class PetPacingTests : IDisposable return (master, pet); } - // Advances time in 8ms lockstep so the wheel and Core.TickCount stay in sync. - private static void RunFor(long ms) - { - var deadline = Core._tickCount + ms; - - while (Core._tickCount < deadline) - { - Core._tickCount += 8; - Timer.Slice(Core._tickCount); - } - } - - private static bool RunUntil(Func condition, long maxMs) - { - var deadline = Core._tickCount + maxMs; - - while (Core._tickCount < deadline) - { - if (condition()) - { - return true; - } - - Core._tickCount += 8; - Timer.Slice(Core._tickCount); - } - - return condition(); - } - // Runs past the spawn stagger; returns right after a think with the next 0.4s away. private ThinkProbe SettledProbe(out PlayerMobile master) { - Core._tickCount = 0; - Timer.Init(0); + _clock = new TimerWheelScope(); var (m, pet) = SpawnProbe(); master = m; diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs index 41b52a189..dec588d49 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs @@ -35,16 +35,10 @@ public class PetRetaliationTests : IDisposable private (PlayerMobile master, T pet) Spawn() where T : BaseCreature, new() { - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + var (master, pet) = PetTestSetup.SpawnControlledPet(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); _created.Add(master); - - var pet = new T(); - pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); - pet.SetControlMaster(master); - pet.AIObject.AITimer?.Stop(); _created.Add(pet); + pet.AIObject.AITimer?.Stop(); return (master, pet); } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs index d15228590..7779384b6 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs @@ -49,17 +49,21 @@ public class PetTestStub : BaseCreature public static class PetTestSetup { - // Places a player master and a controlled pet on Felucca and returns both. + // Places a player master and a controlled pet (on Felucca unless a map is given) and returns both. public static (PlayerMobile master, PetTestStub pet) SpawnControlledPet( - Point3D masterLoc, Point3D petLoc) + Point3D masterLoc, Point3D petLoc, Map map = null) => + SpawnControlledPet(masterLoc, petLoc, map); + + public static (PlayerMobile master, T pet) SpawnControlledPet( + Point3D masterLoc, Point3D petLoc, Map map = null) where T : BaseCreature, new() { - var map = Map.Felucca; + map ??= Map.Felucca; var master = new PlayerMobile(World.NewMobile); master.DefaultMobileInit(); master.MoveToWorld(masterLoc, map); - var pet = new PetTestStub(); + var pet = new T(); pet.MoveToWorld(petLoc, map); pet.SetControlMaster(master); // sets Controlled, Home=Zero, ControlOrder=Come diff --git a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs index ba106218c..5d92fca85 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Server; using Server.Items; using Server.Mobiles; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles; @@ -15,60 +16,14 @@ namespace UOContent.Tests.Mobiles; [Collection("Sequential UOContent Tests")] public class BaseCreatureSerializationTests : IDisposable { - private readonly List _created = new(); - private readonly List _createdItems = new(); + private readonly MobileTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } + public void Dispose() => _harness.Dispose(); - for (var i = 0; i < _createdItems.Count; i++) - { - _createdItems[i].Delete(); - } - } - - private class CreatureStub : BaseCreature - { - public CreatureStub() : base(AIType.AI_Melee) => Body = 0xC9; - - public CreatureStub(Serial serial) : base(serial) => Body = 0xC9; - - public DateTime SummonEndValue => SummonEnd; - - // Stands in for the npc-speeds table (unconfigured in the test fixture). - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } - - public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) - { - activeMoveSpeed = 0.6; - passiveMoveSpeed = 1.2; - } - } - - private CreatureStub NewCreature() - { - var bc = new CreatureStub(); - _created.Add(bc); - return bc; - } + private TestCreature NewCreature() => _harness.NewCreature(); // ReadEntity resolves references through the world table, so the master must be registered. - private PlayerMobile NewMaster() - { - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - World.AddEntity(master); - _created.Add(master); - return master; - } + private PlayerMobile NewMaster() => _harness.NewPlayer(); private static byte[] Snapshot(Mobile m) { @@ -80,10 +35,10 @@ public class BaseCreatureSerializationTests : IDisposable return buffer; } - private CreatureStub Load(byte[] buffer) + private TestCreature Load(byte[] buffer) { - var copy = new CreatureStub(World.NewMobile); - _created.Add(copy); + var copy = new TestCreature(World.NewMobile); + _harness.Track(copy); var reader = new BufferReader(buffer); copy.Deserialize(reader); @@ -219,7 +174,7 @@ public class BaseCreatureSerializationTests : IDisposable var bc = NewCreature(); var friend = NewMaster(); var wayPoint = new WayPoint(); - _createdItems.Add(wayPoint); + _harness.TrackItem(wayPoint); bc.AddPetFriend(friend); bc.CurrentWayPoint = wayPoint; @@ -245,49 +200,20 @@ public class BaseCreatureSerializationTests : IDisposable Assert.InRange(copy.DeleteTimeLeft, TimeSpan.FromDays(3.0) - TimeSpan.FromSeconds(5), TimeSpan.FromDays(3.0)); } - private sealed class VendorStub : BaseVendor - { - private static readonly List _sbInfos = []; - - public VendorStub() : base("the stub") - { - } - - public VendorStub(Serial serial) : base(serial) - { - } - - protected override List SBInfos => _sbInfos; - - public override void InitSBInfo() - { - } - - public override void InitOutfit() - { - } - - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } - } - // BaseVendor is generated on top of the generated BaseCreature; the chain must write // and read both sections in order with exact consumption. [Fact] public void GeneratedVendorChain_RoundTrips() { - var vendor = new VendorStub(); - _created.Add(vendor); + var vendor = new TestVendor(); + _harness.Track(vendor); vendor.Home = new Point3D(1500, 1600, 0); vendor.RangeHome = 2; var buffer = Snapshot(vendor); - var copy = new VendorStub(World.NewMobile); - _created.Add(copy); + var copy = new TestVendor(World.NewMobile); + _harness.Track(copy); var reader = new BufferReader(buffer); copy.Deserialize(reader); @@ -378,7 +304,7 @@ public class BaseCreatureSerializationTests : IDisposable writer.Write(0.0); // passive move speed (v22) } - private CreatureStub LoadLegacyV22( + private TestCreature LoadLegacyV22( bool controlled = false, Mobile controlMaster = null, bool summoned = false, @@ -390,7 +316,7 @@ public class BaseCreatureSerializationTests : IDisposable // Mobile donor produces a byte-authentic one. var donor = new MobileStub(); donor.DefaultMobileInit(); - _created.Add(donor); + _harness.Track(donor); var writer = new BufferWriter(true); donor.Serialize(writer); @@ -399,8 +325,8 @@ public class BaseCreatureSerializationTests : IDisposable var buffer = new byte[writer.Position]; writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); - var copy = new CreatureStub(World.NewMobile); - _created.Add(copy); + var copy = new TestCreature(World.NewMobile); + _harness.Track(copy); var reader = new BufferReader(buffer); copy.Deserialize(reader); @@ -499,7 +425,7 @@ public class BaseCreatureSerializationTests : IDisposable var donor = new MobileStub(); donor.DefaultMobileInit(); - _created.Add(donor); + _harness.Track(donor); var writer = new BufferWriter(true); donor.Serialize(writer); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs index f01e5c7bd..be314b575 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs @@ -1,8 +1,8 @@ using System; -using System.Collections.Generic; using Server; using Server.Mobiles; using Server.Spells.Necromancy; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles; @@ -11,40 +11,13 @@ namespace UOContent.Tests.Mobiles; [Collection("Sequential UOContent Tests")] public class DeletedMasterTests : IDisposable { - private readonly List _created = []; + private readonly MobileTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } - } + public void Dispose() => _harness.Dispose(); - private sealed class Stub : BaseCreature - { - public Stub() : base(AIType.AI_Melee) => Body = 0xC9; + private TestCreature NewCreature() => _harness.NewCreature(); - public Stub(Serial serial) : base(serial) - { - } - } - - private Stub NewCreature() - { - var bc = new Stub(); - _created.Add(bc); - return bc; - } - - private PlayerMobile NewPlayer() - { - var pm = new PlayerMobile(World.NewMobile); - pm.DefaultMobileInit(); - World.AddEntity(pm); - _created.Add(pm); - return pm; - } + private PlayerMobile NewPlayer() => _harness.NewPlayer(); [Fact] public void DeletingAPlayer_ReleasesAnUnbondedPet() diff --git a/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs index 20578ecfc..bb027a951 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs @@ -1,7 +1,7 @@ using System; -using System.Collections.Generic; using Server; using Server.Mobiles; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles; @@ -10,40 +10,13 @@ namespace UOContent.Tests.Mobiles; [Collection("Sequential UOContent Tests")] public class MasterViewsTests : IDisposable { - private readonly List _created = []; + private readonly MobileTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } - } + public void Dispose() => _harness.Dispose(); - private sealed class Stub : BaseCreature - { - public Stub() : base(AIType.AI_Melee) => Body = 0xC9; + private TestCreature NewCreature() => _harness.NewCreature(); - public Stub(Serial serial) : base(serial) - { - } - } - - private Stub NewCreature() - { - var bc = new Stub(); - _created.Add(bc); - return bc; - } - - private PlayerMobile NewPlayer() - { - var pm = new PlayerMobile(World.NewMobile); - pm.DefaultMobileInit(); - World.AddEntity(pm); - _created.Add(pm); - return pm; - } + private PlayerMobile NewPlayer() => _harness.NewPlayer(); public enum Shape { diff --git a/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs index f61ca6229..fac93cb54 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs @@ -9,15 +9,9 @@ namespace Server.Tests.Mobiles; [Collection("Sequential UOContent Tests")] public class VendorBuybackTests : IDisposable { - private readonly List _created = []; + private readonly MobileTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } - } + public void Dispose() => _harness.Dispose(); private sealed class BandageSB : SBInfo { @@ -30,34 +24,9 @@ public class VendorBuybackTests : IDisposable public override List BuyInfo { get; } = []; } - private sealed class VendorStub : BaseVendor + private sealed class BandageVendor : TestVendor { - private readonly List _sbInfos = []; - - public VendorStub() : base("the stub") - { - } - - public VendorStub(Serial serial) : base(serial) - { - } - - protected override List SBInfos => _sbInfos; - - public override void InitSBInfo() - { - _sbInfos.Add(new BandageSB()); - } - - public override void InitOutfit() - { - } - - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } + protected override void AddSBInfos(List sbInfos) => sbInfos.Add(new BandageSB()); } // Same sell price as BandageSB, but a buy list at the 250-entry limit so buyback capacity is 0. @@ -81,55 +50,36 @@ public class VendorBuybackTests : IDisposable public override List BuyInfo => _buyInfo; } - private sealed class FullStockVendorStub : BaseVendor + private sealed class FullStockVendor : TestVendor { - private readonly List _sbInfos = []; - - public FullStockVendorStub() : base("the stub") - { - } - - protected override List SBInfos => _sbInfos; - - public override void InitSBInfo() - { - _sbInfos.Add(new FullStockSB()); - } - - public override void InitOutfit() - { - } - - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } + protected override void AddSBInfos(List sbInfos) => sbInfos.Add(new FullStockSB()); } - private VendorStub NewVendor() - { - var vendor = new VendorStub(); - _created.Add(vendor); - return vendor; - } + private BandageVendor NewVendor() => _harness.Track(new BandageVendor()); - private FullStockVendorStub NewFullStockVendor() - { - var vendor = new FullStockVendorStub(); - _created.Add(vendor); - return vendor; - } + private FullStockVendor NewFullStockVendor() => _harness.Track(new FullStockVendor()); private Mobile NewSeller() { - var seller = new Mobile(World.NewMobile); + var seller = _harness.Track(new Mobile(World.NewMobile)); seller.DefaultMobileInit(); seller.AddItem(new Backpack()); - _created.Add(seller); return seller; } + // A fresh seller offers a stack of 10 bandages and sells of them. + private Bandage SellBandages(BaseVendor vendor, int amount, out Mobile seller) + { + seller = NewSeller(); + var bandages = new Bandage(10); + seller.Backpack.DropItem(bandages); + + Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, amount)])); + return bandages; + } + + private Bandage SellBandages(BaseVendor vendor, int amount) => SellBandages(vendor, amount, out _); + [Theory] [InlineData(0.25, 1.0)] // restocked 15m ago: next boundary is the regular one [InlineData(5.0, 6.0)] // exactly on a boundary: strictly after now @@ -160,11 +110,7 @@ public class VendorBuybackTests : IDisposable public void PartialStackSale_SplitGoesToBuyback_SellerKeepsRemainder() { var vendor = NewVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - - Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, 4)])); + var bandages = SellBandages(vendor, 4, out var seller); Assert.Equal(6, bandages.Amount); Assert.Same(seller.Backpack, bandages.Parent); @@ -181,10 +127,7 @@ public class VendorBuybackTests : IDisposable public void Restock_PurgesBuyback() { var vendor = NewVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + var bandages = SellBandages(vendor, 10); Assert.Single(vendor.BuyPack.Items); vendor.Restock(); @@ -197,20 +140,14 @@ public class VendorBuybackTests : IDisposable public void Restock_ClearsPurgeSchedule_AndASubsequentSaleRearmsIt() { var vendor = NewVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + SellBandages(vendor, 10); Assert.True(vendor.BuybackPurgeScheduled); vendor.Restock(); Assert.False(vendor.BuybackPurgeScheduled); - var seller2 = NewSeller(); - var moreBandages = new Bandage(10); - seller2.Backpack.DropItem(moreBandages); - vendor.OnSellItems(seller2, [new SellItemResponse(moreBandages, 10)]); + SellBandages(vendor, 10); Assert.True(vendor.BuybackPurgeScheduled); } @@ -219,10 +156,7 @@ public class VendorBuybackTests : IDisposable public void Delete_CancelsBuybackPurgeTimer() { var vendor = NewVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + SellBandages(vendor, 10); Assert.True(vendor.BuybackPurgeScheduled); vendor.Delete(); @@ -239,7 +173,8 @@ public class VendorBuybackTests : IDisposable var legacy = new Backpack { Layer = Layer.ShopBuy, Movable = false, Visible = false }; vendor.AddItem(legacy); - var children = new Item[20_000]; + // Several children, so a delete loop that skips entries as the list shrinks would show. + var children = new Item[3]; for (var i = 0; i < children.Length; i++) { children[i] = new Item(0x1234); @@ -261,11 +196,7 @@ public class VendorBuybackTests : IDisposable public void SaleWithNoBuybackCapacity_ConsumesItem_AndStillPays() { var vendor = NewFullStockVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - - Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)])); + var bandages = SellBandages(vendor, 10, out var seller); Assert.True(bandages.Deleted); Assert.Empty(vendor.BuyPack.Items); diff --git a/Projects/UOContent.Tests/Tests/Multis/Houses/HousePackets.cs b/Projects/UOContent.Tests/Tests/Multis/Houses/HousePackets.cs index b981d9f86..d603a0c12 100644 --- a/Projects/UOContent.Tests/Tests/Multis/Houses/HousePackets.cs +++ b/Projects/UOContent.Tests/Tests/Multis/Houses/HousePackets.cs @@ -216,7 +216,8 @@ public sealed class DesignStateDetailed : Packet if (deflatedLength == 0) { - Console.WriteLine("Compression error"); + // An oracle that silently emits a truncated plane would make the comparison meaningless. + throw new InvalidOperationException("Compression error"); } Write((byte)(0x20 | i)); @@ -248,7 +249,8 @@ public sealed class DesignStateDetailed : Packet if (deflatedLength == 0) { - Console.WriteLine("Compression error"); + // An oracle that silently emits a truncated plane would make the comparison meaningless. + throw new InvalidOperationException("Compression error"); } Write((byte)(9 + i)); diff --git a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs index b3a6d291f..2faa8eba5 100644 --- a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs @@ -13,7 +13,9 @@ * along with this program. If not, see . * *************************************************************************/ +using System; using System.Net; +using System.Reflection; using System.Text; using Server.Network; using Server.Network.Bans; @@ -21,10 +23,18 @@ using Xunit; namespace Server.Tests.Network.Exemptions; -// Addresses come from TEST-NET-1 (192.0.2.0/24) to stay clear of the other ban test classes if xUnit runs -// them concurrently. -public class BanExemptionsTests +// ManualAllowlist is process-wide and BlocklistFilter reads it, so this runs in the sequential collection +// and puts the original snapshot back. Addresses come from TEST-NET-1 (192.0.2.0/24). +[Collection("Sequential UOContent Tests")] +public class BanExemptionsTests : IDisposable { + private static readonly FieldInfo _snapshotField = + typeof(ManualAllowlist).GetField("_snapshot", BindingFlags.Static | BindingFlags.NonPublic); + + private readonly object _savedSnapshot = _snapshotField.GetValue(null); + + public void Dispose() => ManualAllowlist.LoadForTesting((BlocklistSnapshot)_savedSnapshot); + private static readonly IPAddress _listed = IPAddress.Parse("192.0.2.10"); private static readonly IPAddress _unlisted = IPAddress.Parse("192.0.2.11"); diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs index 1e1c0fedf..991e49451 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs @@ -21,14 +21,13 @@ using Xunit; namespace Server.Tests.Network.Bans.Blocklist; -public class BlocklistFileTests +public class BlocklistFileTests : IDisposable { - private static string WriteTemp(string content) - { - var p = Path.Combine(Path.GetTempPath(), "bl-" + Guid.NewGuid().ToString("N") + ".txt"); - File.WriteAllText(p, content); - return p; - } + private readonly TempDirectory _temp = new("bl"); + + public void Dispose() => _temp.Dispose(); + + private string WriteTemp(string content) => _temp.WriteFile("blocklist.txt", content); [Fact] public void Reads_header_generated_and_count() @@ -38,13 +37,12 @@ public class BlocklistFileTests Assert.True(h.Present); Assert.Equal("2026-07-21T09:24:23Z", h.Generated); Assert.Equal(2, h.Count); - File.Delete(p); } [Fact] public void Missing_file_reports_absent_and_loads_empty() { - var p = Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid().ToString("N")); + var p = _temp.Combine("does-not-exist.txt"); Assert.False(BlocklistFile.TryReadHeader(p, out var h)); Assert.False(h.Present); var snap = BlocklistFile.Load(p, out _, out _); @@ -70,6 +68,5 @@ public class BlocklistFileTests Assert.Equal(0, skipped); Assert.True(snap.IsBanned(IPAddress.Parse("2.181.183.77"))); Assert.True(snap.IsBanned(IPAddress.Parse("223.169.4.9"))); - File.Delete(p); } } diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs index 4f3ee5ad1..6e630ba49 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs @@ -20,8 +20,10 @@ using Xunit; namespace Server.Tests.Network.Bans.Blocklist; -// No [Collection] and no static reset hook: the filter is an instance, so each test owns its own -// snapshot and promote-guard. That is the point of it no longer being a static class. +// The filter is an instance, so each test owns its own snapshot and promote-guard. Evaluate still +// consults the process-wide ManualAllowlist and LoginAllowlist, which other tests rewrite, so this +// runs in the sequential collection. +[Collection("Sequential UOContent Tests")] public class BlocklistFilterTests { private static BlocklistFilter WithList(string list, bool reportHits = true, long suppressionMs = 5000) diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs index 6ad850f34..0a6d956d6 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs @@ -218,14 +218,18 @@ public class CrowdSecReporterTests /// /// The drain task must track the loop's lifetime, not just its first await — a ValueTask-returning - /// drain loop passed to Task.Run yields a Task<ValueTask> that completes immediately, which makes - /// Stop()'s drain-exited handshake a no-op. With an empty queue the loop parks on WaitToReadAsync, - /// so a correctly unwrapped task cannot win this race; a slow pool only under-detects. + /// drain loop passed to Task.Run yields a Task<ValueTask> that completes at the first await + /// that suspends, which makes Stop()'s drain-exited handshake a no-op. The flush delay always + /// suspends before the first post, so once the client sees a post a wrapped task is already done. /// [Fact] public async Task Start_DrainTaskSpansLoopLifetime_NotJustTheFirstAwait() { - var reporter = new CrowdSecReporter(new NullAlertClient(), Settings()); + var settings = Settings(); + settings.FlushInterval = TimeSpan.FromMilliseconds(1); + + var client = new SignalingAlertClient(); + var reporter = new CrowdSecReporter(client, settings); using var cts = new CancellationTokenSource(); reporter.Start(cts.Token); @@ -233,14 +237,32 @@ public class CrowdSecReporterTests var drain = reporter.DrainTaskForTesting; Assert.NotNull(drain); - var first = await Task.WhenAny(drain, Task.Delay(TimeSpan.FromMilliseconds(500))); - Assert.False(ReferenceEquals(first, drain), "drain task completed while the loop was still running"); + reporter.Report(IPAddress.Parse("9.9.9.9"), TimeSpan.FromHours(1), "rate-limit"); + await client.Posted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.False(drain.IsCompleted, "drain task completed while the loop was still running"); reporter.Stop(); Assert.True(drain.IsCompleted, "Stop() returned before the drain loop exited"); } + private sealed class SignalingAlertClient : ICrowdSecAlertClient + { + public TaskCompletionSource Posted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) + { + Posted.TrySetResult(); + return ValueTask.CompletedTask; + } + + public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) => + ValueTask.CompletedTask; + + public void Dispose() { } + } + private sealed class NullAlertClient : ICrowdSecAlertClient { public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) => diff --git a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs index 01691e223..c5617c0d3 100644 --- a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs @@ -21,8 +21,11 @@ using Xunit; namespace Server.Tests.Network.Firewall; [Collection("Sequential UOContent Tests")] -public class FirewallPersistenceTests +public class FirewallPersistenceTests : IDisposable { + // The test host never configures the firewall, so empty is the state every other test expects. + public void Dispose() => Server.Network.Firewall.ResetForTesting(); + [Fact] public void ToSettings_RoundTrips_PermanentAndTtl() { diff --git a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs index 26e289f0c..7dba7508c 100644 --- a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs @@ -21,8 +21,11 @@ using Xunit; namespace Server.Tests.Network.Firewall; [Collection("Sequential UOContent Tests")] -public class FirewallTests +public class FirewallTests : IDisposable { + // The test host never configures the firewall, so empty is the state every other test expects. + public void Dispose() => Server.Network.Firewall.ResetForTesting(); + private static IPAddress Ip(string s) => IPAddress.Parse(s); [Fact] diff --git a/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs b/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs index 7f3780e58..b48933ec4 100644 --- a/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs @@ -13,17 +13,74 @@ * along with this program. If not, see . * *************************************************************************/ +using System; +using System.Collections; +using System.Collections.Generic; using System.Net; +using System.Reflection; using Server.Network; using Server.Network.Bans; using Xunit; namespace Server.Tests.Network.LoginAllowlists; -// The list is static, so every test resets it first. Addresses come from TEST-NET-3 (203.0.113.0/24) so they -// cannot collide with the blocklist tests if xUnit runs the two classes at the same time. -public class LoginAllowlistTests +// The list is static and the login path and BlocklistFilter both read it, so every test resets it first, +// the class runs in the sequential collection, and Dispose puts the original state back. Addresses come +// from TEST-NET-3 (203.0.113.0/24). +[Collection("Sequential UOContent Tests")] +public class LoginAllowlistTests : IDisposable { + private readonly List<(FieldInfo Field, object Value, DictionaryEntry[] Entries)> _saved = []; + + public LoginAllowlistTests() + { + var fields = typeof(LoginAllowlist).GetFields(BindingFlags.Static | BindingFlags.NonPublic); + for (var i = 0; i < fields.Length; i++) + { + var field = fields[i]; + if (field.IsLiteral) + { + continue; + } + + var value = field.GetValue(null); + + // Readonly tables cannot be reassigned, so their contents are copied and refilled instead. + DictionaryEntry[] entries = null; + if (field.IsInitOnly && value is IDictionary table) + { + entries = new DictionaryEntry[table.Count]; + table.CopyTo(entries, 0); + } + else if (field.IsInitOnly) + { + continue; + } + + _saved.Add((field, value, entries)); + } + } + + public void Dispose() + { + for (var i = 0; i < _saved.Count; i++) + { + var (field, value, entries) = _saved[i]; + if (entries == null) + { + field.SetValue(null, value); + continue; + } + + var table = (IDictionary)value; + table.Clear(); + for (var j = 0; j < entries.Length; j++) + { + table.Add(entries[j].Key, entries[j].Value); + } + } + } + private const long Ttl = 90 * 24 * 60 * 60; // 90 days, the shipped default private const long Window = 3600; private const long Now = 1_800_000_000; diff --git a/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs b/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs index 2fb0f73cb..062fba315 100644 --- a/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net; using Server.Accounting; using Server.Accounting.Security; @@ -22,14 +23,43 @@ public class AuthIdTests : IDisposable IncomingAccountPackets.ClearAuthIdWindow(); } + // The constructor registers each account in the process-wide Accounts table. + private readonly List _created = []; + public void Dispose() { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + IncomingAccountPackets.ClearAuthIdWindow(); AccountSecurity.CurrentAlgorithm = _originalAlgorithm; } - private static IAccount CreateAccount(string username) => - Accounts.GetAccount(username) ?? new Account(username, "hunter2"); + // Auth ids never read the password, so these accounts skip the Argon2 cost. + private IAccount CreateAccount(string username) + { + AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.SHA2; + try + { + return TrackNewAccount(username); + } + finally + { + AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; + } + } + + // The game-login tests run the real password check, so they keep the configured algorithm. + private IAccount CreateArgon2Account(string username) => TrackNewAccount(username); + + private Account TrackNewAccount(string username) + { + var account = new Account(username, "hunter2"); + _created.Add(account); + return account; + } private static int Register(IAccount account, IPAddress address) => IncomingAccountPackets.RegisterAuthId(account, address, new ClientVersion(7, 0, 0, 0)); @@ -336,7 +366,7 @@ public class AuthIdTests : IDisposable [Fact] public void PreAuthenticatedGameLogin_SkipsThePasswordCheck() { - var account = CreateAccount("authid-preauth-user"); + var account = CreateArgon2Account("authid-preauth-user"); using var ns = PacketTestUtilities.CreateTestNetState(); // A wrong password is accepted only because the auth id already vouched for the account. @@ -349,7 +379,7 @@ public class AuthIdTests : IDisposable [Fact] public void GameLoginWithoutPreAuthentication_StillChecksThePassword() { - var account = CreateAccount("authid-nopreauth-user"); + var account = CreateArgon2Account("authid-nopreauth-user"); using var ns = PacketTestUtilities.CreateTestNetState(); var wrong = new GameServer.GameLoginEventArgs(ns, account.Username, "wrong-password", false); diff --git a/Projects/UOContent.Tests/Tests/WorldSaves/ArchiveJournalTests.cs b/Projects/UOContent.Tests/Tests/WorldSaves/ArchiveJournalTests.cs index 5cf7dbeea..14db8d19d 100644 --- a/Projects/UOContent.Tests/Tests/WorldSaves/ArchiveJournalTests.cs +++ b/Projects/UOContent.Tests/Tests/WorldSaves/ArchiveJournalTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Reflection; using Server.Saves; using Xunit; @@ -9,16 +10,32 @@ namespace Server.Tests; [Collection("Sequential UOContent Tests")] public class ArchiveJournalTests : IDisposable { + private static readonly FieldInfo _dataField = + typeof(ArchiveJournal).GetField("_data", BindingFlags.Static | BindingFlags.NonPublic); + + private static readonly FieldInfo _journalPathField = + typeof(ArchiveJournal).GetField("_journalPath", BindingFlags.Static | BindingFlags.NonPublic); + + private readonly object _savedData; + private readonly object _savedJournalPath; private readonly string _testDir; public ArchiveJournalTests() { + // Configure() repoints the process-wide journal at the temp dir; restore it so no later + // caller writes into a deleted directory. + _savedData = _dataField.GetValue(null); + _savedJournalPath = _journalPathField.GetValue(null); + _testDir = Path.Combine(Path.GetTempPath(), $"modernuo-journal-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(_testDir); } public void Dispose() { + _dataField.SetValue(null, _savedData); + _journalPathField.SetValue(null, _savedJournalPath); + if (Directory.Exists(_testDir)) { Directory.Delete(_testDir, true);