openac-mosswartmassacre/tests/OpenAC.MosswartMassacre.Tests/PluginHarness.cs
Erik 5dbb294a96 Skeleton: plugin root, backend socket, /mm router, settings, test harness
The socket connects with the shared-secret header, registers first, reassembles
incoming frames of any size (the original dropped anything over 4 KiB) and
reconnects two seconds after a failure. Incoming share_* frames go to the
vital-sharing handlers; any other frame is a chat-box command for this
character, run one per tick through the chat bar as if typed. Frames are
written with Newtonsoft.Json defaults, the original's serializer, so the
backend sees the same bytes. The secret lives in the character's settings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 12:03:00 +02:00

101 lines
3.5 KiB
C#

using Newtonsoft.Json.Linq;
using OpenAC.MosswartMassacre.Infrastructure;
using OpenAC.MosswartMassacre.Tests.Fakes;
namespace OpenAC.MosswartMassacre.Tests;
/// <summary>A clock a test sets by hand.</summary>
internal sealed class FixedClock : IClock
{
public DateTime UtcNow { get; set; } = new(2026, 9, 25, 8, 30, 15, 123, DateTimeKind.Utc);
public DateTime Now { get; set; } = new(2026, 9, 25, 10, 30, 15, 123, DateTimeKind.Local);
public void Advance(TimeSpan by)
{
UtcNow += by;
Now += by;
}
}
/// <summary>
/// The plugin built on a fake host and a fixed clock, logged in as a named
/// character with streaming on, and every frame it sends recorded.
/// </summary>
internal sealed class PluginHarness : IDisposable
{
public PluginHarness(string characterName = "Mossy", bool webSocketEnabled = true, Action<FakeHost>? arrange = null)
{
Host.Automation.IsAvailable = true;
Host.Automation.Character.Name = characterName;
Host.Automation.Character.IsInWorld = false;
Host.Automation.Character.ObjectId = 0x50000001;
arrange?.Invoke(Host);
Plugin.Initialize(Host);
Plugin.Build(Host, Clock);
Context.FrameTap = frame => Frames.Add(frame);
// The stored settings keep streaming off so a login never opens a real
// socket; Login() turns it on in memory afterwards, which is all the
// frame gate reads.
Context.Settings.Load(characterName);
Context.Settings.Current.WebSocketEnabled = false;
Context.Settings.Current.BackendUrl = "ws://127.0.0.1:1/unused/";
Context.Settings.Save();
_webSocketEnabled = webSocketEnabled;
}
private readonly bool _webSocketEnabled;
public FakeHost Host { get; } = new();
public FixedClock Clock { get; } = new();
public MosswartMassacrePlugin Plugin { get; } = new();
public MmContext Context => Plugin.Context!;
public List<string> Frames { get; } = [];
public IReadOnlyList<string> ChatLines => Host.ChatLines;
/// <summary>Logs in without starting the socket.</summary>
public void Login()
{
Host.Automation.Character.IsInWorld = true;
Plugin.OnLogin();
Context.Settings.Current.WebSocketEnabled = _webSocketEnabled;
}
/// <summary>Runs what the plugin runs right after the socket connects and registers.</summary>
public void SimulateConnected() => Session.OnConnected(Clock.UtcNow);
public OpenAC.MosswartMassacre.Backend.BackendSession Session =>
Plugin.Features.OfType<OpenAC.MosswartMassacre.Backend.BackendSession>().Single();
/// <summary>A frame as if the backend had sent it; routed on the next tick.</summary>
public void Receive(string frame) => Session.Route(frame);
public T Feature<T>() where T : IMmFeature => Plugin.Features.OfType<T>().Single();
public void Tick(double seconds) => Host.Events.RaiseTick(seconds);
/// <summary>Advances both the scheduler and the clock by <paramref name="seconds"/> in small steps.</summary>
public void Run(double seconds, double step = 0.1)
{
for (double t = 0; t < seconds - 1e-9; t += step)
{
Clock.Advance(TimeSpan.FromSeconds(step));
Tick(step);
}
}
public IEnumerable<JObject> FramesOfType(string type) =>
Frames.Select(JObject.Parse).Where(frame => (string?)frame["type"] == type);
public void Dispose()
{
Plugin.OnLogoff();
foreach (IMmFeature feature in Plugin.Features)
feature.Dispose();
}
}