Plugin-registerable self tests

The goal of this change is to let plugins register their own self-tests. 

We do this through the `ISelfTestRegistry` interface. For a plugin it
would look like this:

```csharp
[PluginService]
public ISelfTestRegistry SelfTestRegistry

// Somewhere that gets called by your plugin
SelfTestRegistry.RegisterTestSteps([
  new MySelfTestStep(),
  new MyOtherSelfTestStep()
])
```

Where `MySelfTest` and `MyOtherSelfTest` are instances of
the existing `ISelfTestStep` interface.

The biggest changes are to `SelfTestWindow` and the introduction of
`SelfTestWithResults`. I wanted to make sure test state wasn't lost when
changing the dropdown state and I was finding it a bit annoying to work
with the Dictionary now that we can't just rely on the index of the
item.

To fix this I moved all the "test run" state into `SelfTestWithResults`,
most of the changes to `SelfTestWindow` are derived from that, other
then the addition of the combo box.

The documentation for this service is a bit sparse, but I wanted to put
it up for review first before I invest a bunch of time making nice
documentation. 

I'm keen to hear if we think this is useful or if any changes are
needed.
This commit is contained in:
GrittyFrog 2025-10-11 16:50:29 +11:00
parent 4ac4505d72
commit ae777000e2
41 changed files with 674 additions and 133 deletions

View file

@ -16,6 +16,7 @@ using Dalamud.Game.Text.Sanitizer;
using Dalamud.Interface;
using Dalamud.Interface.Internal;
using Dalamud.Interface.Internal.Windows.PluginInstaller;
using Dalamud.Interface.Internal.Windows.SelfTest;
using Dalamud.Interface.Internal.Windows.Settings;
using Dalamud.IoC.Internal;
using Dalamud.Plugin.Internal;
@ -25,6 +26,7 @@ using Dalamud.Plugin.Internal.Types.Manifest;
using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Ipc.Exceptions;
using Dalamud.Plugin.Ipc.Internal;
using Dalamud.Plugin.Services;
using Serilog;

View file

@ -15,6 +15,7 @@ using Dalamud.Plugin.Internal.Types.Manifest;
using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Ipc.Exceptions;
using Dalamud.Plugin.Ipc.Internal;
using Dalamud.Plugin.Services;
namespace Dalamud.Plugin;

View file

@ -0,0 +1,54 @@
using System.Collections.Generic;
namespace Dalamud.Plugin.SelfTest;
/// <summary>
/// Interface for registering and unregistering self-test steps from plugins.
/// </summary>
/// <example>
/// Registering custom self-test steps for your plugin:
/// <code>
/// [PluginService]
/// public ISelfTestRegistry SelfTestRegistry { get; init; }
///
/// // In your plugin initialization
/// this.SelfTestRegistry.RegisterTestSteps([
/// new MyCustomSelfTestStep(),
/// new AnotherSelfTestStep()
/// ]);
/// </code>
///
/// Creating a custom self-test step:
/// <code>
/// public class MyCustomSelfTestStep : ISelfTestStep
/// {
/// public string Name => "My Custom Test";
///
/// public SelfTestStepResult RunStep()
/// {
/// // Your test logic here
/// if (/* test condition passes */)
/// return SelfTestStepResult.Pass;
///
/// if (/* test condition fails */)
/// return SelfTestStepResult.Fail;
///
/// // Still waiting for test to complete
/// return SelfTestStepResult.Waiting;
/// }
///
/// public void CleanUp()
/// {
/// // Clean up any resources used by the test
/// }
/// }
/// </code>
/// </example>
public interface ISelfTestRegistry
{
/// <summary>
/// Registers the self-test steps for this plugin.
/// </summary>
/// <param name="steps">The test steps to register.</param>
public void RegisterTestSteps(IEnumerable<ISelfTestStep> steps);
}

View file

@ -0,0 +1,23 @@
namespace Dalamud.Plugin.SelfTest;
/// <summary>
/// Interface for test implementations.
/// </summary>
public interface ISelfTestStep
{
/// <summary>
/// Gets the name of the test.
/// </summary>
public string Name { get; }
/// <summary>
/// Run the test step, once per frame it is active.
/// </summary>
/// <returns>The result of this frame, test is discarded once a result other than <see cref="SelfTestStepResult.Waiting"/> is returned.</returns>
public SelfTestStepResult RunStep();
/// <summary>
/// Clean up this test.
/// </summary>
public void CleanUp();
}

View file

@ -0,0 +1,28 @@
namespace Dalamud.Plugin.SelfTest.Internal;
/// <summary>
/// Represents a self-test group with its loaded/unloaded state.
/// </summary>
internal class SelfTestGroup
{
/// <summary>
/// Initializes a new instance of the <see cref="SelfTestGroup"/> class.
/// </summary>
/// <param name="name">The name of the test group.</param>
/// <param name="loaded">Whether the group is currently loaded.</param>
public SelfTestGroup(string name, bool loaded = true)
{
this.Name = name;
this.Loaded = loaded;
}
/// <summary>
/// Gets the name of the test group.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets or sets a value indicating whether this test group is currently loaded.
/// </summary>
public bool Loaded { get; set; }
}

View file

@ -0,0 +1,124 @@
using System.Collections.Generic;
using System.Linq;
using Dalamud.Logging.Internal;
using Dalamud.Plugin.Internal.Types;
namespace Dalamud.Plugin.SelfTest.Internal;
/// <summary>
/// Registry for self-tests that can be run in the SelfTest window.
/// </summary>
[ServiceManager.EarlyLoadedService]
internal class SelfTestRegistry : IServiceType
{
/// <summary>
/// The name of the Dalamud test group.
/// </summary>
public const string DalamudTestGroup = "Dalamud";
private static readonly ModuleLog Log = new("SelfTestRegistry");
private List<SelfTestWithResults> dalamudSelfTests = new();
private List<SelfTestWithResults> pluginSelfTests = new();
private Dictionary<string, SelfTestGroup> allGroups = new();
/// <summary>
/// Initializes a new instance of the <see cref="SelfTestRegistry"/> class.
/// </summary>
[ServiceManager.ServiceConstructor]
public SelfTestRegistry()
{
}
/// <summary>
/// Gets all available self test groups.
/// </summary>
public IEnumerable<SelfTestGroup> SelfTestGroups
{
get
{
// Always return Dalamud group first, then plugin groups
if (this.allGroups.TryGetValue(DalamudTestGroup, out var dalamudGroup))
{
yield return dalamudGroup;
}
foreach (var group in this.allGroups.Values)
{
if (group.Name != DalamudTestGroup)
{
yield return group;
}
}
}
}
/// <summary>
/// Gets all self tests from all groups.
/// </summary>
public IEnumerable<SelfTestWithResults> SelfTests => this.dalamudSelfTests.Concat(this.pluginSelfTests);
/// <summary>
/// Registers Dalamud self test steps.
/// </summary>
/// <param name="steps">The steps to register.</param>
public void RegisterDalamudSelfTestSteps(IEnumerable<ISelfTestStep> steps)
{
// Ensure Dalamud group exists and is loaded
if (!this.allGroups.ContainsKey(DalamudTestGroup))
{
this.allGroups[DalamudTestGroup] = new SelfTestGroup(DalamudTestGroup, loaded: true);
}
else
{
this.allGroups[DalamudTestGroup].Loaded = true;
}
this.dalamudSelfTests.AddRange(steps.Select(step => SelfTestWithResults.FromDalamudStep(step)));
}
/// <summary>
/// Registers plugin self test steps.
/// </summary>
/// <param name="plugin">The plugin registering the tests.</param>
/// <param name="steps">The steps to register.</param>
public void RegisterPluginSelfTestSteps(LocalPlugin plugin, IEnumerable<ISelfTestStep> steps)
{
// Ensure plugin group exists and is loaded
if (!this.allGroups.ContainsKey(plugin.InternalName))
{
this.allGroups[plugin.InternalName] = new SelfTestGroup(plugin.InternalName, loaded: true);
}
else
{
this.allGroups[plugin.InternalName].Loaded = true;
}
this.pluginSelfTests.AddRange(steps.Select(step => SelfTestWithResults.FromPluginStep(plugin, step)));
}
/// <summary>
/// Unregisters all self test steps for a plugin.
/// </summary>
/// <param name="plugin">The plugin to unregister tests for.</param>
public void UnregisterPluginSelfTestSteps(LocalPlugin plugin)
{
// Clean up existing tests for this plugin
this.pluginSelfTests.ForEach(test =>
{
if (test.Plugin == plugin)
{
test.Unload();
}
});
this.pluginSelfTests.RemoveAll(test => test.Plugin == plugin);
// Mark group as unloaded if it exists
if (this.allGroups.ContainsKey(plugin.InternalName))
{
this.allGroups[plugin.InternalName].Loaded = false;
}
}
}

View file

@ -0,0 +1,51 @@
using System.Collections.Generic;
using Dalamud.IoC;
using Dalamud.IoC.Internal;
using Dalamud.Plugin.Internal.Types;
namespace Dalamud.Plugin.SelfTest.Internal;
/// <summary>
/// Plugin-scoped version of SelfTestRegistry.
/// </summary>
[PluginInterface]
[ServiceManager.ScopedService]
[ResolveVia<ISelfTestRegistry>]
internal class SelfTestRegistryPluginScoped : ISelfTestRegistry, IInternalDisposableService
{
[ServiceManager.ServiceDependency]
private readonly SelfTestRegistry selfTestRegistry = Service<SelfTestRegistry>.Get();
private readonly LocalPlugin plugin;
/// <summary>
/// Initializes a new instance of the <see cref="SelfTestRegistryPluginScoped"/> class.
/// </summary>
/// <param name="plugin">The plugin this service belongs to.</param>
[ServiceManager.ServiceConstructor]
public SelfTestRegistryPluginScoped(LocalPlugin plugin)
{
this.plugin = plugin;
}
/// <summary>
/// Gets the plugin name.
/// </summary>
public string PluginName { get; private set; }
/// <summary>
/// Registers test steps for this plugin.
/// </summary>
/// <param name="steps">The test steps to register.</param>
public void RegisterTestSteps(IEnumerable<ISelfTestStep> steps)
{
this.selfTestRegistry.RegisterPluginSelfTestSteps(this.plugin, steps);
}
/// <inheritdoc/>
void IInternalDisposableService.DisposeService()
{
this.selfTestRegistry.UnregisterPluginSelfTestSteps(this.plugin);
}
}

View file

@ -0,0 +1,173 @@
using Dalamud.Logging.Internal;
using Dalamud.Plugin.Internal.Types;
namespace Dalamud.Plugin.SelfTest.Internal;
/// <summary>
/// A self test step with result tracking.
/// </summary>
internal class SelfTestWithResults
{
private static readonly ModuleLog Log = new("SelfTest");
/// <summary>
/// Initializes a new instance of the <see cref="SelfTestWithResults"/> class.
/// </summary>
/// <param name="plugin">The plugin providing this test.</param>
/// <param name="group">The test group name.</param>
/// <param name="step">The test step.</param>
public SelfTestWithResults(LocalPlugin plugin, string group, ISelfTestStep step)
{
this.Plugin = plugin;
this.Group = group;
this.Step = step;
}
/// <summary>
/// Gets the test group name.
/// </summary>
public string Group { get; private set; }
/// <summary>
/// Gets the plugin that defined these tests. <c>null</c> for Dalamud tests.
/// </summary>
public LocalPlugin? Plugin { get; private set; }
/// <summary>
/// Gets the test name.
/// </summary>
public string Name { get => this.Step.Name; }
/// <summary>
/// Gets a value indicating whether the test has run and finished.
/// </summary>
public bool Finished => this.Result == SelfTestStepResult.Fail || this.Result == SelfTestStepResult.Pass;
/// <summary>
/// Gets a value indicating whether the plugin that provided this test has been unloaded.
/// </summary>
public bool Unloaded => this.Step == null;
/// <summary>
/// Gets the most recent result of running this test.
/// </summary>
public SelfTestStepResult Result { get; private set; } = SelfTestStepResult.NotRan;
/// <summary>
/// Gets the last time this test was started.
/// </summary>
public DateTimeOffset? StartTime { get; private set; } = null;
/// <summary>
/// Gets how long it took (or is taking) for this test to execute.
/// </summary>
public TimeSpan? Duration { get; private set; } = null;
/// <summary>
/// Gets or sets the Step that our results are for.
///
/// If <c>null</c> it means the Plugin that provided this test has been unloaded and we can't use this test anymore.
/// </summary>
private ISelfTestStep? Step { get; set; }
/// <summary>
/// Creates a SelfTestWithResults from a Dalamud step.
/// </summary>
/// <param name="step">The step to wrap.</param>
/// <returns>A new SelfTestWithResults instance.</returns>
public static SelfTestWithResults FromDalamudStep(ISelfTestStep step)
{
return new SelfTestWithResults(plugin: null, group: "Dalamud", step: step);
}
/// <summary>
/// Creates a SelfTestWithResults from a plugin step.
/// </summary>
/// <param name="plugin">The plugin providing the step.</param>
/// <param name="step">The step to wrap.</param>
/// <returns>A new SelfTestWithResults instance.</returns>
public static SelfTestWithResults FromPluginStep(LocalPlugin plugin, ISelfTestStep step)
{
return new SelfTestWithResults(plugin: plugin, group: plugin.InternalName, step: step);
}
/// <summary>
/// Reset the test.
/// </summary>
public void Reset()
{
this.Result = SelfTestStepResult.NotRan;
this.StartTime = null;
this.Duration = null;
}
/// <summary>
/// Finish the currently running test and clean up any state. This preserves test run results.
/// </summary>
public void Finish()
{
if (this.Step == null)
{
return;
}
if (this.Result == SelfTestStepResult.NotRan)
{
return;
}
this.Step.CleanUp();
}
/// <summary>
/// Steps the state of this Self Test. This should be called every frame that we care about the results of this test.
/// </summary>
public void DrawAndStep()
{
// If we've been unloaded then there's nothing to do.
if (this.Step == null)
{
return;
}
// If we have already finished then there's nothing to do
if (this.Finished)
{
return;
}
// Otherwise, we assume that calling this functions means we are running the test.
if (this.Result == SelfTestStepResult.NotRan)
{
this.StartTime = DateTimeOffset.Now;
this.Result = SelfTestStepResult.Waiting;
}
try
{
this.Result = this.Step.RunStep();
}
catch (Exception ex)
{
Log.Error(ex, $"Step failed: {this.Name} ({this.Group})");
this.Result = SelfTestStepResult.Fail;
}
this.Duration = DateTimeOffset.Now - this.StartTime;
// If we ran and finished we need to clean up
if (this.Finished)
{
this.Finish();
}
}
/// <summary>
/// Unloads the test and cleans up.
/// </summary>
public void Unload()
{
this.Finish();
this.Step = null;
}
}

View file

@ -0,0 +1,27 @@
namespace Dalamud.Plugin.SelfTest;
/// <summary>
/// Enum declaring result states of tests.
/// </summary>
public enum SelfTestStepResult
{
/// <summary>
/// Test was not ran.
/// </summary>
NotRan,
/// <summary>
/// Test is waiting for completion.
/// </summary>
Waiting,
/// <summary>
/// Test has failed.
/// </summary>
Fail,
/// <summary>
/// Test has passed.
/// </summary>
Pass,
}