fix(plugins): close LA5 host lifecycle review

This commit is contained in:
Erik 2026-08-14 19:05:13 +02:00
parent 95f4be94db
commit fbe9c8a288
25 changed files with 1043 additions and 120 deletions

View file

@ -56,6 +56,18 @@ public sealed class SessionConfigurationSharedFixtureTests
StringComparison.Ordinal);
}
[Fact]
public void AppReaderPreservesLauncherExplicitEmptyPluginAllowList()
{
using TemporaryFile file = TemporaryFile.Create(
LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
(_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path);
Assert.NotNull(session.Plugins);
Assert.Empty(session.Plugins);
}
[Fact]
public void AppReaderAcceptsTheProductionShapedSharedFixture()
{

View file

@ -1,4 +1,5 @@
using AcDream.App.Plugins;
using AcDream.App.UI;
namespace AcDream.App.Tests.Plugins;
@ -18,4 +19,26 @@ public class BufferedUiRegistryTests
Assert.Empty(reg.Drain()); // consumed
}
[Fact]
public void ScopedRegistrationTokenRemovesAnAlreadyMountedElement()
{
var registry = new BufferedUiRegistry();
IDisposable registration = registry.RegisterMarkupPanel(
"plugin.xml",
new object());
BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain());
var root = new UiRoot();
var element = new UiPanel();
root.AddChild(element);
registry.CompleteMount(pending, root, element);
Assert.Contains(element, root.Children);
Assert.Equal(1, registry.RegistrationCount);
registration.Dispose();
Assert.DoesNotContain(element, root.Children);
Assert.Equal(0, registry.RegistrationCount);
}
}

View file

@ -1,17 +1,20 @@
using System.Text.Json;
using System.Runtime.CompilerServices;
using AcDream.App.Configuration;
using AcDream.App.Plugins;
using AcDream.Core.Plugins;
using AcDream.Core.Selection;
using AcDream.Platform;
using AcDream.Plugin.Abstractions;
using AcDream.Runtime.Session;
using AcDream.Tests.Fixtures.CampaignLa;
namespace AcDream.App.Tests.Plugins;
public sealed class GraphicalPluginSessionTests
{
private const string FixtureId = "acdream.test.host-fixture";
private const string ThrowingId = "acdream.test.throwing-fixture";
[Fact]
public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes()
@ -27,12 +30,13 @@ public sealed class GraphicalPluginSessionTests
var ui = new BufferedUiRegistry();
var host = new AppPluginHost(logger, state, events, selection, ui);
using GraphicalPluginSession plugins = GraphicalPluginSession.Start(
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
paths,
[FixtureId.ToUpperInvariant(), "acdream.test.missing"],
"gui-session",
host,
new SessionStatusWriter(statusPath));
plugins.Start();
Assert.Equal(1, plugins.LoadedCount);
Assert.True(host.HasUi);
@ -42,19 +46,20 @@ public sealed class GraphicalPluginSessionTests
message => message.Contains("fixture-enabled:hasUi=True", StringComparison.Ordinal));
JsonElement[] statuses = ReadStatuses(statusPath);
Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses));
Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString());
Assert.Equal(["started", "pluginLoaded", "pluginFailed"], EventNames(statuses));
Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString());
Assert.Equal(
"acdream.test.missing",
statuses[1].GetProperty("plugin").GetString());
statuses[2].GetProperty("plugin").GetString());
Assert.Contains(
"not found",
statuses[1].GetProperty("error").GetString(),
statuses[2].GetProperty("error").GetString(),
StringComparison.OrdinalIgnoreCase);
WeakReference context = Assert.Single(
plugins.CaptureLoadContextWeakReferences());
plugins.Dispose();
Assert.Equal(0, ui.RegistrationCount);
Collect(context);
Assert.False(context.IsAlive);
}
@ -66,6 +71,12 @@ public sealed class GraphicalPluginSessionTests
ApplicationPathSet paths = Paths(temporary.Path);
InstallFixture(paths.PluginsDirectory, FixtureId);
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
string configPath = Path.Combine(temporary.Path, "session.json");
File.WriteAllText(
configPath,
LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
(_, SessionDescriptor descriptor) =
SessionConfigurationLoader.Load(configPath);
var ui = new BufferedUiRegistry();
var host = new AppPluginHost(
new CapturingLogger(),
@ -74,16 +85,70 @@ public sealed class GraphicalPluginSessionTests
new SelectionState(),
ui);
using GraphicalPluginSession plugins = GraphicalPluginSession.Start(
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
paths,
[],
descriptor.Plugins,
"gui-session",
host,
new SessionStatusWriter(statusPath));
plugins.Start();
Assert.Equal(0, plugins.LoadedCount);
Assert.NotNull(descriptor.Plugins);
Assert.Empty(descriptor.Plugins);
Assert.Empty(ui.Drain());
Assert.Equal(["started"], EventNames(ReadStatuses(statusPath)));
}
[Fact]
public void ThrowAfterRegistrationRollsBackUiAndEventsAndCollectsContext()
{
using var temporary = new TemporaryDirectory();
ApplicationPathSet paths = Paths(temporary.Path);
string pluginDirectory = InstallFixture(
paths.PluginsDirectory,
ThrowingId,
"throwing-fixture");
File.WriteAllText(
Path.Combine(pluginDirectory, "throw-after-register"),
string.Empty);
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
var events = new WorldEvents();
var ui = new BufferedUiRegistry();
var host = new AppPluginHost(
new CapturingLogger(),
new WorldGameState(),
events,
new SelectionState(),
ui);
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
paths,
[ThrowingId],
"gui-session",
host,
new SessionStatusWriter(statusPath));
plugins.Start();
Assert.Equal(0, plugins.LoadedCount);
Assert.Empty(ui.Drain());
Assert.False(File.Exists(statusPath));
Assert.Equal(0, ui.RegistrationCount);
events.FireEntitySpawned(new WorldEntitySnapshot(
1u,
2u,
default,
System.Numerics.Quaternion.Identity));
Assert.False(File.Exists(
Path.Combine(pluginDirectory, "unexpected-callback")));
Assert.Equal(
["started", "pluginFailed"],
EventNames(ReadStatuses(statusPath)));
WeakReference context = Assert.Single(
plugins.CaptureLoadContextWeakReferences());
plugins.Dispose();
Collect(context);
Assert.False(context.IsAlive);
}
private static ApplicationPathSet Paths(string root) => new(
@ -114,11 +179,14 @@ public sealed class GraphicalPluginSessionTests
private static string[] EventNames(IEnumerable<JsonElement> events) =>
events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
private static void InstallFixture(string root, string id)
private static string InstallFixture(
string root,
string id,
string directoryName = "host-fixture")
{
string source = FixtureAssemblyPath();
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
string pluginDirectory = Path.Combine(root, "host-fixture");
string pluginDirectory = Path.Combine(root, directoryName);
Directory.CreateDirectory(pluginDirectory);
string fileName = Path.GetFileName(source);
File.Copy(source, Path.Combine(pluginDirectory, fileName));
@ -132,6 +200,7 @@ public sealed class GraphicalPluginSessionTests
entryDll = fileName,
apiVersion = 1,
}));
return pluginDirectory;
}
private static string FixtureAssemblyPath()
@ -195,8 +264,22 @@ public sealed class GraphicalPluginSessionTests
public void Dispose()
{
if (Directory.Exists(Path))
Directory.Delete(Path, recursive: true);
for (int attempt = 0; Directory.Exists(Path); attempt++)
{
try
{
Directory.Delete(Path, recursive: true);
return;
}
catch (Exception error)
when (error is IOException or UnauthorizedAccessException
&& attempt < 9)
{
GC.Collect();
GC.WaitForPendingFinalizers();
Thread.Sleep(10);
}
}
}
}
}

View file

@ -447,6 +447,11 @@ public sealed class GameWindowSlice8BoundaryTests
public void Shutdown_PreservesDependencyStagesAndNativeWindowLast()
{
string source = GameWindowSource();
string program = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Program.cs"));
string lifetime = GameWindowLifetimeSource();
string manifest = Slice(
lifetime,
@ -464,6 +469,7 @@ public sealed class GameWindowSlice8BoundaryTests
[
"new ResourceShutdownStage(\"host and session barriers\"",
"new ResourceShutdownStage(\"physical ingress cleanup\"",
"new ResourceShutdownStage(\"plugin host\"",
"new ResourceShutdownStage(\"frame borrowers\"",
"new ResourceShutdownStage(\"session dependents\"",
"new ResourceShutdownStage(\"live entities\"",
@ -499,6 +505,8 @@ public sealed class GameWindowSlice8BoundaryTests
"Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))",
"Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))",
"Soft(\"native window callbacks\", () => DisposeWindowCallbacks(ingress.WindowCallbacks))",
"new ResourceShutdownStage(\"plugin host\"",
"Hard(\"plugins\", () => ingress.Plugins?.Dispose())",
"new ResourceShutdownStage(\"session dependents\"",
"Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())");
Assert.Contains(
@ -509,7 +517,27 @@ public sealed class GameWindowSlice8BoundaryTests
"UiHost? RetainedUiHost,",
lifetime,
StringComparison.Ordinal);
Assert.Contains(
"IDisposable? Plugins,",
lifetime,
StringComparison.Ordinal);
Assert.Contains("_uiHost,", source, StringComparison.Ordinal);
Assert.Contains("_pluginSession,", source, StringComparison.Ordinal);
AssertAppearsInOrder(
program,
"GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(",
"window.StartPluginHosting(pluginSession);",
"window.Run();");
AssertAppearsInOrder(
source,
"_pluginSession = pluginSession;",
"pluginSession.Start();",
"public void Run()");
AssertAppearsInOrder(
manifest,
"Hard(\"plugins\", () => ingress.Plugins?.Dispose())",
"Hard(\"retail UI\", () => DisposeRetailUi(live.RetailUi))",
"Hard(\"game runtime\", () => DisposeGameRuntime(live.Runtime))");
AssertAppearsInOrder(
nativeRelease,
"TryComplete();",

View file

@ -1,4 +1,5 @@
using System.Text.Json;
using System.Net;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Headless.Configuration;
@ -7,6 +8,9 @@ using AcDream.Headless.Diagnostics;
using AcDream.Headless.Hosting;
using AcDream.Headless.Plugins;
using AcDream.Plugin.Abstractions;
using AcDream.Runtime;
using AcDream.Runtime.Session;
using AcDream.Tests.Fixtures.CampaignLa;
namespace AcDream.Headless.Tests;
@ -14,6 +18,7 @@ public sealed class HeadlessPluginSessionTests
{
private const string FixtureId = "acdream.test.host-fixture";
private const string BrokenId = "acdream.test.broken";
private const string ThrowingId = "acdream.test.throwing-fixture";
[Fact]
public void ConfiguredSetBorrowsRuntimeUsesNoOpUiIsolatesFailureAndUnloads()
@ -29,8 +34,10 @@ public sealed class HeadlessPluginSessionTests
Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath),
credential,
diagnostics,
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]);
HeadlessPluginSession plugins = session.Plugins;
_ = session.Start();
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
Assert.Equal(1, plugins.LoadedCount);
@ -47,12 +54,17 @@ public sealed class HeadlessPluginSessionTests
Assert.Equal(2, plugins.Host.State.Entities.Count);
JsonElement[] statuses = ReadStatuses(statusPath);
Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses));
Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString());
Assert.Equal(BrokenId, statuses[1].GetProperty("plugin").GetString());
Assert.Equal(
[
"started", "pluginLoaded", "pluginFailed", "connected",
"characterList", "enteredWorld",
],
EventNames(statuses));
Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString());
Assert.Equal(BrokenId, statuses[2].GetProperty("plugin").GetString());
Assert.Contains(
"entry dll not found",
statuses[1].GetProperty("error").GetString()!,
statuses[2].GetProperty("error").GetString()!,
StringComparison.OrdinalIgnoreCase);
Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString());
@ -79,13 +91,126 @@ public sealed class HeadlessPluginSessionTests
Descriptor([], statusPath),
credential,
new HeadlessDiagnosticWriter(output),
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]);
_ = session.Start();
Assert.Equal(0, session.Plugins.LoadedCount);
Assert.False(File.Exists(statusPath));
Assert.Equal(
["started", "connected", "characterList", "enteredWorld"],
EventNames(ReadStatuses(statusPath)));
Assert.DoesNotContain("fixture-", output.ToString());
}
[Fact]
public void ThrowAfterRegistrationRollsBackEventsAndCollectsContext()
{
using var temporary = new TemporaryDirectory();
string pluginDirectory = InstallFixture(
temporary.Path,
ThrowingId,
"throwing-fixture");
File.WriteAllText(
Path.Combine(pluginDirectory, "throw-after-register"),
string.Empty);
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
var credential = new HeadlessCredentialSecret("fixture", "password");
using var session = new HeadlessSessionHost(
Descriptor([ThrowingId], statusPath),
credential,
new HeadlessDiagnosticWriter(new StringWriter()),
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]);
_ = session.Start();
Assert.Equal(0, session.Plugins.LoadedCount);
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
Assert.False(File.Exists(
Path.Combine(pluginDirectory, "unexpected-callback")));
Assert.Equal(
[
"started", "pluginFailed", "connected", "characterList",
"enteredWorld",
],
EventNames(ReadStatuses(statusPath)));
WeakReference context = Assert.Single(
session.Plugins.CaptureLoadContextWeakReferences());
session.Dispose();
Collect(context);
Assert.False(context.IsAlive);
}
[Fact]
public void LauncherProbeRoundTripKeepsPluginsDisabledInTheRealHost()
{
using var temporary = new TemporaryDirectory();
InstallFixture(temporary.Path, FixtureId);
string configPath = Path.Combine(temporary.Path, "probe.json");
File.WriteAllText(
configPath,
LauncherCoreSessionConfigFixture.ComposeProbe());
HeadlessSessionDescriptor descriptor = Assert.Single(
HeadlessConfigurationLoader.Load(configPath).Sessions)! with
{
StatusFile = Path.Combine(temporary.Path, "probe-status.jsonl"),
};
var credential = new HeadlessCredentialSecret("fixture", "password");
using var session = new HeadlessSessionHost(
descriptor,
credential,
new HeadlessDiagnosticWriter(new StringWriter()),
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]);
RuntimeSessionStartResult result = session.Start();
Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, result.Status);
Assert.NotNull(descriptor.Plugins);
Assert.Empty(descriptor.Plugins);
Assert.Equal(0, session.Plugins.LoadedCount);
Assert.Equal(
["started", "connected", "characterList"],
EventNames(ReadStatuses(descriptor.StatusFile!)));
}
[Fact]
public async Task LateSubscriberReplayQueuesConcurrentRegistrationExactlyOnceInOrder()
{
using var temporary = new TemporaryDirectory();
var credential = new HeadlessCredentialSecret("fixture", "password");
using var session = new HeadlessSessionHost(
Descriptor([], Path.Combine(temporary.Path, "status.jsonl")),
credential,
new HeadlessDiagnosticWriter(new StringWriter()),
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]);
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
HeadlessPluginHost host = session.Plugins.Host;
using var replayCaptured = new ManualResetEventSlim();
using var releaseReplay = new ManualResetEventSlim();
host.ReplayCapturedForTest = () =>
{
replayCaptured.Set();
Assert.True(releaseReplay.Wait(TimeSpan.FromSeconds(10)));
};
var observed = new List<uint>();
Action<WorldEntitySnapshot> handler = snapshot =>
{
lock (observed)
observed.Add(snapshot.Id);
};
Task subscribe = Task.Run(() => host.Events.EntitySpawned += handler);
Assert.True(replayCaptured.Wait(TimeSpan.FromSeconds(10)));
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f));
releaseReplay.Set();
await subscribe.WaitAsync(TimeSpan.FromSeconds(10));
host.Events.EntitySpawned -= handler;
Assert.Equal([1_000_000u, 1_000_001u], observed);
}
private static HeadlessSessionDescriptor Descriptor(
List<string> plugins,
string statusPath) => new()
@ -144,15 +269,19 @@ public sealed class HeadlessPluginSessionTests
private static string[] EventNames(IEnumerable<JsonElement> events) =>
events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
private static void InstallFixture(string root, string id)
private static string InstallFixture(
string root,
string id,
string directoryName = "host-fixture")
{
string source = FixtureAssemblyPath();
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
string pluginDirectory = Path.Combine(root, "host-fixture");
string pluginDirectory = Path.Combine(root, directoryName);
Directory.CreateDirectory(pluginDirectory);
string fileName = Path.GetFileName(source);
File.Copy(source, Path.Combine(pluginDirectory, fileName));
WriteManifest(pluginDirectory, id, fileName);
return pluginDirectory;
}
private static void InstallBrokenPlugin(string root, string id)
@ -214,6 +343,39 @@ public sealed class HeadlessPluginSessionTests
}
}
private sealed class FixtureSessionOperations : ILiveSessionOperations
{
private static readonly CharacterList.Parsed Characters = new(
0u,
[new CharacterList.Character(0x50000001u, "Fixture", 0u)],
[],
1,
"account",
true,
true);
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint);
public void Connect(WorldSession session, string user, string password)
{
}
public CharacterList.Parsed? GetCharacters(WorldSession session) => Characters;
public void EnterWorld(WorldSession session, int activeCharacterIndex)
{
}
public void Tick(WorldSession session)
{
}
public void DisposeSession(WorldSession session) => session.Dispose();
}
private sealed class TemporaryDirectory : IDisposable
{
internal TemporaryDirectory()
@ -228,8 +390,22 @@ public sealed class HeadlessPluginSessionTests
public void Dispose()
{
if (Directory.Exists(Path))
Directory.Delete(Path, recursive: true);
for (int attempt = 0; Directory.Exists(Path); attempt++)
{
try
{
Directory.Delete(Path, recursive: true);
return;
}
catch (Exception error)
when (error is IOException or UnauthorizedAccessException
&& attempt < 9)
{
GC.Collect();
GC.WaitForPendingFinalizers();
Thread.Sleep(10);
}
}
}
}
}

View file

@ -60,6 +60,33 @@ public sealed class SessionConfigurationSharedFixtureTests
StringComparison.Ordinal);
}
[Fact]
public void HeadlessReaderPreservesLauncherExplicitEmptyPluginAllowList()
{
using TemporaryFile file = TemporaryFile.Create(
LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
HeadlessSessionDescriptor session = Assert.Single(
HeadlessConfigurationLoader.Load(file.Path).Sessions)!;
Assert.NotNull(session.Plugins);
Assert.Empty(session.Plugins);
}
[Fact]
public void HeadlessReaderPreservesProbeLoadNoneAllowList()
{
using TemporaryFile file = TemporaryFile.Create(
LauncherCoreSessionConfigFixture.ComposeProbe());
HeadlessSessionDescriptor session = Assert.Single(
HeadlessConfigurationLoader.Load(file.Path).Sessions)!;
Assert.Equal(HeadlessSessionMode.Probe, session.Mode);
Assert.NotNull(session.Plugins);
Assert.Empty(session.Plugins);
}
[Fact]
public void HeadlessReaderAcceptsTheProductionShapedSharedFixture()
{

View file

@ -175,7 +175,7 @@ public sealed class SessionConfigComposerTests
}
[Fact]
public void PluginsAndLoginCommandsAreOmittedWhenEmptyRatherThanEmptyArrays()
public void EmptyPluginsRemainAnExplicitLoadNoneAllowListWhileLoginCommandsAreOmitted()
{
CharacterProfile character = Character(LaunchMode.Gui);
character.Plugins = [];
@ -190,7 +190,8 @@ public sealed class SessionConfigComposerTests
sessionId: "session-empty-lists");
JsonObject session = SingleSession(composed);
Assert.False(session.ContainsKey("plugins"));
Assert.True(session.ContainsKey("plugins"));
Assert.Empty(session["plugins"]!.AsArray());
Assert.False(session.ContainsKey("loginCommands"));
}
@ -243,7 +244,7 @@ public sealed class SessionConfigComposerTests
}
[Fact]
public void ProbeModeSetsModeAndOmitsCharacterPolicyPluginsAndLoginCommands()
public void ProbeModeSetsModeAndCarriesExplicitLoadNonePluginAllowList()
{
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
Server(),
@ -256,7 +257,7 @@ public sealed class SessionConfigComposerTests
AssertKeys(
session,
"id", "mode", "endpoint", "account", "credential", "statusFile");
"id", "mode", "endpoint", "account", "credential", "plugins", "statusFile");
Assert.Equal("session-probe", (string?)session["id"]);
Assert.Equal("probe", (string?)session["mode"]);
@ -266,7 +267,7 @@ public sealed class SessionConfigComposerTests
Assert.Equal("standardInput", (string?)session["credential"]!["provider"]);
Assert.False(session.ContainsKey("character"));
Assert.False(session.ContainsKey("policy"));
Assert.False(session.ContainsKey("plugins"));
Assert.Empty(session["plugins"]!.AsArray());
Assert.False(session.ContainsKey("loginCommands"));
Assert.False(session.ContainsKey("loginCommandDelayMs"));
Assert.Equal(

View file

@ -11,11 +11,17 @@ namespace AcDream.Plugin.Tests.Fixtures.HostPlugin;
public sealed class HostPlugin : IAcDreamPlugin
{
private IPluginHost? _host;
private string? _assemblyDirectory;
private bool _throwAfterRegistration;
private int _entitiesSeen;
public void Initialize(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_assemblyDirectory = Path.GetDirectoryName(
typeof(HostPlugin).Assembly.Location);
_throwAfterRegistration = File.Exists(
Path.Combine(_assemblyDirectory!, "throw-after-register"));
host.Log.Info($"fixture-initialized:hasUi={host.HasUi}");
}
@ -27,6 +33,11 @@ public sealed class HostPlugin : IAcDreamPlugin
Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"),
this);
host.Events.EntitySpawned += OnEntitySpawned;
if (_throwAfterRegistration)
{
throw new InvalidOperationException(
"fixture enable failed after registering UI and events");
}
host.Log.Info(
$"fixture-enabled:hasUi={host.HasUi}:entities={host.State.Entities.Count}");
}
@ -36,11 +47,24 @@ public sealed class HostPlugin : IAcDreamPlugin
IPluginHost? host = _host;
if (host is null)
return;
if (_throwAfterRegistration)
{
throw new InvalidOperationException(
"fixture disable intentionally refuses cleanup");
}
host.Events.EntitySpawned -= OnEntitySpawned;
host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}");
_host = null;
}
private void OnEntitySpawned(WorldEntitySnapshot snapshot) =>
private void OnEntitySpawned(WorldEntitySnapshot snapshot)
{
_entitiesSeen++;
if (_throwAfterRegistration && _assemblyDirectory is not null)
{
File.AppendAllText(
Path.Combine(_assemblyDirectory, "unexpected-callback"),
$"{snapshot.Id}{Environment.NewLine}");
}
}
}

View file

@ -55,4 +55,66 @@ internal static class LauncherCoreSessionConfigFixture
return SessionConfigComposer.Serialize(composed.Document);
}
internal static string ComposeEmptyPlugins()
{
(ServerProfile server, AccountProfile account,
LauncherInstallRecord install, ApplicationPathSet paths) = Inputs();
var character = new CharacterProfile
{
Name = "Composer Character",
Id = "0x50000001",
LaunchMode = LaunchMode.Headless,
Plugins = [],
LoginCommands = [],
};
ComposedSessionConfig composed = SessionConfigComposer.Compose(
server,
account,
character,
install,
paths,
"composer-empty-plugins");
return SessionConfigComposer.Serialize(composed.Document);
}
internal static string ComposeProbe()
{
(ServerProfile server, AccountProfile account,
LauncherInstallRecord install, ApplicationPathSet paths) = Inputs();
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
server,
account,
install,
paths,
"composer-probe");
return SessionConfigComposer.Serialize(composed.Document);
}
private static (
ServerProfile Server,
AccountProfile Account,
LauncherInstallRecord Install,
ApplicationPathSet Paths) Inputs() =>
(
new ServerProfile
{
Name = "Composer Server",
Host = "composer.example",
Port = 9010,
},
new AccountProfile
{
Account = "composer-account",
Password = Password,
},
new LauncherInstallRecord(
"composer-dats",
"composer-dats/acdream.pak"),
new ApplicationPathSet(
Path.Combine(Path.GetTempPath(), "composer-config"),
Path.Combine(Path.GetTempPath(), "composer-data"),
Path.Combine(Path.GetTempPath(), "composer-cache"),
LegacyConfigDirectory: null));
}