using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.Tests.Fakes;
///
/// The one fake host every test uses. Each surface is settable so a test names
/// only the state it cares about.
///
public sealed class FakeHost : IPluginHost
{
public bool HasUi { get; set; }
public IPluginLogger Log { get; } = new RecordingLogger();
public IGameState State { get; } = new FakeGameState();
public FakeEvents Events { get; } = new();
public FakeSelection Selection { get; } = new();
public FakeUiRegistry Ui { get; } = new();
public FakeCommandRegistry Commands { get; } = new();
public MemoryStorage Storage { get; } = new();
public FakeAutomation Automation { get; } = new();
public FakeLootClassifierRegistry LootClassifiers { get; } = new();
public FakeClipboard Clipboard { get; } = new();
public FakeHotkeyRegistry Hotkeys { get; } = new();
public FakeHostWindow Window { get; } = new();
public FakeStatusBoard StatusBoard { get; } = new();
IPluginStatusBoard IPluginHost.StatusBoard => StatusBoard;
IEvents IPluginHost.Events => Events;
ISelectionService IPluginHost.Selection => Selection;
IUiRegistry IPluginHost.Ui => Ui;
IPluginCommandRegistry IPluginHost.Commands => Commands;
IPluginStorage IPluginHost.Storage => Storage;
IAutomationSurface IPluginHost.Automation => Automation;
IPluginLootClassifierRegistry IPluginHost.LootClassifiers => LootClassifiers;
IPluginClipboard IPluginHost.Clipboard => Clipboard;
IHotkeyRegistry IPluginHost.Hotkeys => Hotkeys;
IHostWindow IPluginHost.Window => Window;
/// The chat lines the plugin posted, in order.
public IReadOnlyList ChatLines => Automation.Chat.Posted;
}
///
/// A settable : records every call, defaults to
/// like a graphical host with a
/// responsive window, and a test can flip /
/// / to simulate a
/// refusal (a headless host, or an unconfirmed platform outcome such as
/// Wayland's always-Unavailable Minimize).
///
public sealed class FakeHostWindow : IHostWindow
{
public int MinimizeCalls { get; private set; }
public int RestoreCalls { get; private set; }
public int RequestCloseCalls { get; private set; }
public bool IsMinimized { get; set; }
public HostWindowResult MinimizeResult { get; set; } = new(HostWindowStatus.Done);
public HostWindowResult RestoreResult { get; set; } = new(HostWindowStatus.Done);
public HostWindowResult RequestCloseResult { get; set; } = new(HostWindowStatus.Done);
public HostWindowResult Minimize()
{
MinimizeCalls++;
if (MinimizeResult.Succeeded)
IsMinimized = true;
return MinimizeResult;
}
public HostWindowResult Restore()
{
RestoreCalls++;
if (RestoreResult.Succeeded)
IsMinimized = false;
return RestoreResult;
}
public HostWindowResult RequestClose()
{
RequestCloseCalls++;
return RequestCloseResult;
}
}
/// A settable : records every write, can fail on demand.
public sealed class FakeClipboard : IPluginClipboard
{
public List Written { get; } = [];
public bool Available { get; set; } = true;
public bool TrySetText(string text)
{
if (!Available)
return false;
Written.Add(text);
return true;
}
}
///
/// A settable : one test-controlled
/// classifier answers //
/// , or the registry reports no classifier
/// registered at all (the "VTank isn't loaded" degradation path) when
/// is left empty.
///
public sealed class FakeLootClassifierRegistry : IPluginLootClassifierRegistry
{
public List Available { get; } = [];
/// What returns for any classifier id.
public PluginLootClassification? ClassificationResult { get; set; }
/// What returns for any classifier id.
public bool NeedsIdentificationResult { get; set; }
///
/// When set, overrides with a
/// per-context verdict -- for a test where only SOME items in a
/// container still need identification.
///
public Func? NeedsIdentificationHandler { get; set; }
/// What returns for any classifier id.
public PluginLootClassification? ProfileClassificationResult { get; set; }
///
/// When set, overrides with a
/// per-context verdict — for a test that needs different items to
/// classify differently. Returning null from the function means "no
/// match" (mirrors the live registry's false return).
///
public Func? ClassifyHandler { get; set; }
///
/// When set, overrides with a
/// per-profile, per-context verdict. Returning null means "no profile,
/// or no match" (both collapse to the registry's false).
///
public Func? ProfileClassifyHandler { get; set; }
/// Every profile name ever probed/classified via .
public List ProfileNamesSeen { get; } = [];
public List ClassifyCalls { get; } = [];
IReadOnlyList IPluginLootClassifierRegistry.Available => Available;
public bool TryClassify(
string classifierId,
in PluginLootClassificationContext context,
out PluginLootClassification classification)
{
ClassifyCalls.Add(classifierId);
if (ClassifyHandler is { } handler)
{
PluginLootClassification? result = handler(context);
if (result is { } found)
{
classification = found;
return true;
}
classification = default;
return false;
}
if (ClassificationResult is { } fixedResult)
{
classification = fixedResult;
return true;
}
classification = default;
return false;
}
public bool TryNeedsIdentification(
string classifierId,
in PluginLootClassificationContext context)
=> NeedsIdentificationHandler is { } handler ? handler(context) : NeedsIdentificationResult;
public bool TryClassifyWithProfile(
string classifierId,
string profileName,
in PluginLootClassificationContext context,
out PluginLootClassification classification)
{
ProfileNamesSeen.Add(profileName);
if (ProfileClassifyHandler is { } handler)
{
PluginLootClassification? result = handler(profileName, context);
if (result is { } found)
{
classification = found;
return true;
}
classification = default;
return false;
}
if (ProfileClassificationResult is { } fixedResult)
{
classification = fixedResult;
return true;
}
classification = default;
return false;
}
}
/// Records every registration and lets a test fire the handler or flip .
public sealed class FakeHotkeyRegistry : IHotkeyRegistry
{
public List<(string Id, string DisplayName, PluginKeyChord DefaultChord, Action Handler)> Registrations { get; } = [];
/// The most recently returned handle for each id, so a test can assert on its Disposed/IsBound state.
public Dictionary Handles { get; } = new(StringComparer.Ordinal);
public IPluginHotkeyRegistration Register(
string id,
string displayName,
PluginKeyChord defaultChord,
Action handler)
{
Registrations.Add((id, displayName, defaultChord, handler));
var registration = new FakeHotkeyRegistration(defaultChord);
Handles[id] = registration;
return registration;
}
/// Invokes the handler registered under , as if the chord had just fired.
public void Fire(string id)
{
foreach ((string registeredId, _, _, Action handler) in Registrations)
{
if (string.Equals(registeredId, id, StringComparison.Ordinal))
{
handler();
return;
}
}
}
}
public sealed class FakeHotkeyRegistration(PluginKeyChord chord) : IPluginHotkeyRegistration
{
public bool IsBound { get; set; } = true;
public PluginKeyChord EffectiveChord { get; private set; } = chord;
public bool Disposed { get; private set; }
public void Rebind(PluginKeyChord newChord) => EffectiveChord = newChord;
public void Dispose() => Disposed = true;
}
public sealed class RecordingLogger : IPluginLogger
{
public List Messages { get; } = [];
public void Info(string message) => Messages.Add("info: " + message);
public void Warn(string message) => Messages.Add("warn: " + message);
public void Error(string message, Exception? exception = null)
=> Messages.Add("error: " + message);
}
public sealed class FakeGameState : IGameState
{
public IReadOnlyList Entities { get; set; } = [];
}
/// Drives from a test.
public sealed class FakeEvents : IEvents
{
public event Action? EntitySpawned;
public event Action? Tick;
public event Action? LoginComplete;
public event Action? Logoff;
public event Action? LocalPlayerDied;
public event Action? ObjectChanged;
public event Action? ContainerOpened;
public event Action? ContainerClosed;
public event Action? ConfirmationRequested;
public int TickSubscriberCount => Tick?.GetInvocationList().Length ?? 0;
public int LoginCompleteSubscriberCount =>
LoginComplete?.GetInvocationList().Length ?? 0;
public int LogoffSubscriberCount => Logoff?.GetInvocationList().Length ?? 0;
public void RaiseTick(double elapsedSeconds) => Tick?.Invoke(elapsedSeconds);
public void RaiseEntitySpawned(WorldEntitySnapshot snapshot)
=> EntitySpawned?.Invoke(snapshot);
public void RaiseLoginComplete() => LoginComplete?.Invoke();
public void RaiseLogoff() => Logoff?.Invoke();
public void RaiseLocalPlayerDied(string deathMessage)
=> LocalPlayerDied?.Invoke(deathMessage);
public void RaiseObjectChanged(PluginObjectChange change) => ObjectChanged?.Invoke(change);
public void RaiseObjectChanged(uint objectId, PluginObjectChangeKind kind)
=> ObjectChanged?.Invoke(new PluginObjectChange(objectId, kind));
public void RaiseContainerOpened(uint containerObjectId) => ContainerOpened?.Invoke(containerObjectId);
public void RaiseContainerClosed(uint containerObjectId) => ContainerClosed?.Invoke(containerObjectId);
public void RaiseConfirmationRequested(PluginConfirmation confirmation)
=> ConfirmationRequested?.Invoke(confirmation);
event Action IEvents.ConfirmationRequested
{
add => ConfirmationRequested += value;
remove => ConfirmationRequested -= value;
}
event Action IEvents.ObjectChanged
{
add => ObjectChanged += value;
remove => ObjectChanged -= value;
}
event Action IEvents.ContainerOpened
{
add => ContainerOpened += value;
remove => ContainerOpened -= value;
}
event Action IEvents.ContainerClosed
{
add => ContainerClosed += value;
remove => ContainerClosed -= value;
}
event Action IEvents.EntitySpawned
{
add => EntitySpawned += value;
remove => EntitySpawned -= value;
}
event Action IEvents.Tick
{
add => Tick += value;
remove => Tick -= value;
}
event Action IEvents.LoginComplete
{
add => LoginComplete += value;
remove => LoginComplete -= value;
}
event Action IEvents.Logoff
{
add => Logoff += value;
remove => Logoff -= value;
}
event Action IEvents.LocalPlayerDied
{
add => LocalPlayerDied += value;
remove => LocalPlayerDied -= value;
}
}
public sealed class FakeSelection : ISelectionService
{
public uint? SelectedObjectId { get; private set; }
public uint? PreviousObjectId { get; private set; }
public event Action? Changed;
public bool Select(uint objectId)
{
PreviousObjectId = SelectedObjectId;
SelectedObjectId = objectId;
Changed?.Invoke(new SelectionChangedEvent(PreviousObjectId, objectId));
return true;
}
public bool Clear()
{
PreviousObjectId = SelectedObjectId;
SelectedObjectId = null;
Changed?.Invoke(new SelectionChangedEvent(PreviousObjectId, null));
return true;
}
}
public sealed record RegisteredPanel(
PluginPanelDescriptor Descriptor,
string MarkupPath,
object Binding);
public sealed class FakeUiRegistry : IUiRegistry
{
public List Panels { get; } = [];
/// Every call, in order -- a test sets to simulate the host refusing it.
public List ShowClientWindowCalls { get; } = [];
public bool ShowClientWindowResult { get; set; } = true;
public bool ShowClientWindow(PluginClientWindow window)
{
ShowClientWindowCalls.Add(window);
return ShowClientWindowResult;
}
public List HideClientWindowCalls { get; } = [];
public bool HideClientWindow(PluginClientWindow window)
{
HideClientWindowCalls.Add(window);
return true;
}
public List ToggleClientWindowCalls { get; } = [];
public bool ToggleClientWindowResult { get; set; } = true;
public bool ToggleClientWindow(PluginClientWindow window)
{
ToggleClientWindowCalls.Add(window);
return ToggleClientWindowResult;
}
public HashSet VisibleClientWindows { get; } = [];
public bool IsClientWindowVisible(PluginClientWindow window)
=> VisibleClientWindows.Contains(window);
public void AddMarkupPanel(string markupPath, object binding)
=> Panels.Add(new RegisteredPanel(
new PluginPanelDescriptor(
Path.GetFileNameWithoutExtension(markupPath),
"Mag-Tools"),
markupPath,
binding));
public void AddPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
=> Panels.Add(new RegisteredPanel(descriptor, markupPath, binding));
public IDisposable RegisterPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
{
var panel = new RegisteredPanel(descriptor, markupPath, binding);
Panels.Add(panel);
return new PanelRegistration(this, panel);
}
private sealed class PanelRegistration(FakeUiRegistry owner, RegisteredPanel panel)
: IDisposable
{
public bool Disposed { get; private set; }
public void Dispose()
{
Disposed = true;
owner.Panels.Remove(panel);
}
}
}
public sealed class FakeCommandRegistry : IPluginCommandRegistry
{
public Dictionary> Handlers { get; } =
new(StringComparer.OrdinalIgnoreCase);
public IDisposable Register(string verb, Action handler)
{
Handlers[verb] = handler;
return new Registration(this, verb);
}
private sealed class Registration(FakeCommandRegistry owner, string verb)
: IDisposable
{
public void Dispose() => owner.Handlers.Remove(verb);
}
}
/// An in-memory .
public sealed class MemoryStorage : IPluginStorage
{
private readonly Dictionary _files =
new(StringComparer.OrdinalIgnoreCase);
public bool IsAvailable { get; set; } = true;
public string? RootPath { get; set; } = "memory://storage";
public int WriteCount { get; private set; }
/// Makes the next (and every subsequent) write throw.
public bool ThrowOnWrite { get; set; }
///
/// The message throws with while
/// is set. Change it between calls to make
/// consecutive throws distinct EXCEPTION SHAPES for a test — the default
/// keeps every throw identical, for a test pinning same-shape dedup.
///
public string ThrowMessage { get; set; } = "Storage write failed (test fault).";
public string? ReadText(string key)
=> _files.TryGetValue(key, out string? value) ? value : null;
public IReadOnlyList List(string prefix)
=> [.. _files.Keys.Where(key =>
key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))];
public void WriteText(string key, string content)
{
if (ThrowOnWrite)
throw new InvalidOperationException(ThrowMessage);
_files[key] = content;
WriteCount++;
}
public bool Delete(string key) => _files.Remove(key);
/// Seeds a file as if it had been copied in by hand.
public void Seed(string key, string content) => _files[key] = content;
}