test: Campaign LA finish LA2 probe and idle gates
Prove idle play remains passive and live until cancellation, then converges through one truthful status teardown. Keep probe mode string-only so numeric enum aliases cannot expand the pinned v1 contract, and record the Windows/WSL gates.
This commit is contained in:
parent
c601942467
commit
000ea979d5
5 changed files with 243 additions and 38 deletions
77
tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs
Normal file
77
tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
using System.Reflection;
|
||||
using AcDream.Headless.Policies;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.Headless.Tests;
|
||||
|
||||
public sealed class HeadlessBotPolicyTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: <c>idle</c> is a deliberately passive,
|
||||
/// non-terminal policy. It must neither inspect Runtime state nor reach
|
||||
/// any command surface, and no event can make it complete on its own.
|
||||
/// The process scheduler therefore keeps the play session alive until
|
||||
/// external cancellation/stop drives the host's ordinary teardown path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IdlePolicyIsPassiveAndNeverCompletesAutonomously()
|
||||
{
|
||||
var policy = new IdleHeadlessBotPolicy();
|
||||
IGameRuntimeView view = CreateNoTouchProxy<IGameRuntimeView>(
|
||||
out InvocationCountingProxy viewCalls);
|
||||
IGameRuntimeCommands commands =
|
||||
CreateNoTouchProxy<IGameRuntimeCommands>(
|
||||
out InvocationCountingProxy commandCalls);
|
||||
|
||||
for (int index = 0; index < 3; index++)
|
||||
policy.Tick(view, commands);
|
||||
|
||||
RuntimeLifecycleDelta lifecycle = default;
|
||||
RuntimeCommandDelta command = default;
|
||||
RuntimeEntityDelta entity = default;
|
||||
RuntimeInventoryDelta inventory = default;
|
||||
RuntimeChatDelta chat = default;
|
||||
RuntimeMovementDelta movement = default;
|
||||
RuntimePortalDelta portal = default;
|
||||
RuntimeCombatDelta combat = default;
|
||||
policy.OnLifecycle(in lifecycle);
|
||||
policy.OnCommand(in command);
|
||||
policy.OnEntity(in entity);
|
||||
policy.OnInventory(in inventory);
|
||||
policy.OnChat(in chat);
|
||||
policy.OnMovement(in movement);
|
||||
policy.OnPortal(in portal);
|
||||
policy.OnCombat(in combat);
|
||||
|
||||
Assert.False(policy.IsComplete);
|
||||
Assert.Equal(0, viewCalls.InvocationCount);
|
||||
Assert.Equal(0, commandCalls.InvocationCount);
|
||||
|
||||
policy.Dispose();
|
||||
policy.Dispose();
|
||||
Assert.False(policy.IsComplete);
|
||||
}
|
||||
|
||||
private static T CreateNoTouchProxy<T>(
|
||||
out InvocationCountingProxy proxy)
|
||||
where T : class
|
||||
{
|
||||
T value = DispatchProxy.Create<T, InvocationCountingProxy>();
|
||||
proxy = (InvocationCountingProxy)(object)value;
|
||||
return value;
|
||||
}
|
||||
|
||||
public class InvocationCountingProxy : DispatchProxy
|
||||
{
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
protected override object? Invoke(
|
||||
MethodInfo? targetMethod,
|
||||
object?[]? args)
|
||||
{
|
||||
InvocationCount++;
|
||||
throw new InvalidOperationException(
|
||||
$"Idle policy unexpectedly invoked {targetMethod?.Name}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -237,6 +237,38 @@ public sealed class HeadlessConfigurationLoaderTests
|
|||
Assert.Null(session.Policy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pinned v1 contract has one named mode value: <c>"probe"</c>.
|
||||
/// In particular, the enum's underlying numeric zero must not become an
|
||||
/// accidental second spelling through an enum converter configured to
|
||||
/// allow integers.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("\"play\"")]
|
||||
[InlineData("0")]
|
||||
public void SessionModeRejectsEveryValueOtherThanTheNamedProbeMode(
|
||||
string modeJson)
|
||||
{
|
||||
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||
$$"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "unsupported-mode",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"mode": {{modeJson}},
|
||||
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<JsonException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
|
|
@ -315,41 +316,122 @@ public sealed class HeadlessSessionHostTests
|
|||
Assert.False(playSession.IsFaulted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: the configured <c>idle</c> policy follows the
|
||||
/// normal play shape (selector + policy, mode absent), enters world, and
|
||||
/// remains non-terminal through real scheduler turns until cancellation.
|
||||
/// Cancellation stops the process loop; the owning host's ordinary
|
||||
/// disposal transaction then performs graceful session teardown. Status
|
||||
/// events must describe those boundaries truthfully and remain exactly
|
||||
/// once even when disposal is repeated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
|
||||
public async Task IdlePolicyEntersWorldRunsUntilCancellationAndConvergesExactlyOnce()
|
||||
{
|
||||
var configuration = new HeadlessConfiguration
|
||||
string statusPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-headless-idle-status-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
Version = 1,
|
||||
Sessions =
|
||||
[
|
||||
Descriptor(
|
||||
HeadlessCredentialProviderKind.StandardInput,
|
||||
"stdin-bot"),
|
||||
],
|
||||
};
|
||||
HeadlessPathSet paths = HeadlessPathSet.Resolve(
|
||||
new HeadlessPathOverrides());
|
||||
using var diagnostics = new StringWriter();
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var host = new HeadlessProcessHost(
|
||||
configuration,
|
||||
paths,
|
||||
new System.IO.StringReader(
|
||||
"process-password" + Environment.NewLine),
|
||||
diagnostics,
|
||||
operations);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
var configuration = new HeadlessConfiguration
|
||||
{
|
||||
Version = 1,
|
||||
Sessions =
|
||||
[
|
||||
Descriptor(
|
||||
HeadlessCredentialProviderKind.StandardInput,
|
||||
"stdin-bot",
|
||||
statusFile: statusPath),
|
||||
],
|
||||
};
|
||||
HeadlessPathSet paths = HeadlessPathSet.Resolve(
|
||||
new HeadlessPathOverrides());
|
||||
using var diagnostics = new StringWriter();
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var host = new HeadlessProcessHost(
|
||||
configuration,
|
||||
paths,
|
||||
new System.IO.StringReader(
|
||||
"process-password" + Environment.NewLine),
|
||||
diagnostics,
|
||||
operations);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
|
||||
HeadlessExitCode result =
|
||||
await host.RunAsync(cancellation.Token);
|
||||
Task<HeadlessExitCode> run = host.RunAsync(cancellation.Token);
|
||||
var timeout = Stopwatch.StartNew();
|
||||
while (operations.TickCallCount < 3
|
||||
&& !run.IsCompleted
|
||||
&& timeout.Elapsed < TimeSpan.FromSeconds(10))
|
||||
{
|
||||
await Task.Delay(5);
|
||||
}
|
||||
|
||||
Assert.Equal(HeadlessExitCode.Success, result);
|
||||
Assert.True(host.Session.Runtime.Session.IsInWorld);
|
||||
Assert.DoesNotContain(
|
||||
"process-password",
|
||||
diagnostics.ToString());
|
||||
Assert.True(
|
||||
operations.TickCallCount >= 3,
|
||||
$"Expected at least 3 idle scheduler turns, observed {operations.TickCallCount}.");
|
||||
Assert.False(run.IsCompleted);
|
||||
Assert.Equal(1, operations.EnterWorldCallCount);
|
||||
Assert.Equal("Headless", host.Session.ActiveCharacterName);
|
||||
Assert.True(host.Session.Runtime.Session.IsInWorld);
|
||||
Assert.False(host.Session.IsPolicyComplete);
|
||||
Assert.Equal(
|
||||
["started", "connected", "characterList", "enteredWorld"],
|
||||
ReadStatusEventNames(statusPath));
|
||||
|
||||
cancellation.Cancel();
|
||||
HeadlessExitCode result = await run.WaitAsync(
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal(HeadlessExitCode.Success, result);
|
||||
// RunAsync owns scheduling, not the host lifetime. The session
|
||||
// remains honestly connected until its owner disposes it.
|
||||
Assert.True(host.Session.Runtime.Session.IsInWorld);
|
||||
Assert.Equal(
|
||||
["started", "connected", "characterList", "enteredWorld"],
|
||||
ReadStatusEventNames(statusPath));
|
||||
|
||||
host.Dispose();
|
||||
host.Dispose();
|
||||
|
||||
Assert.True(host.Session.Runtime.CaptureOwnership().IsConverged);
|
||||
Assert.Equal(1, operations.DisposedSessionCount);
|
||||
string[] lines = File.ReadAllLines(statusPath);
|
||||
string[] eventNames = ReadStatusEventNames(statusPath);
|
||||
Assert.Equal(
|
||||
[
|
||||
"started", "connected", "characterList", "enteredWorld",
|
||||
"disconnected", "exited",
|
||||
],
|
||||
eventNames);
|
||||
|
||||
using JsonDocument disconnected = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "disconnected")]);
|
||||
Assert.Equal(
|
||||
"stopped",
|
||||
disconnected.RootElement.GetProperty("reason").GetString());
|
||||
|
||||
using JsonDocument exited = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "exited")]);
|
||||
JsonElement exit = exited.RootElement;
|
||||
Assert.Equal(0, exit.GetProperty("code").GetInt32());
|
||||
string? exitReason = exit.GetProperty("reason").GetString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(exitReason));
|
||||
Assert.NotEqual("fault", exitReason);
|
||||
Assert.NotEqual("probe", exitReason);
|
||||
Assert.DoesNotContain(
|
||||
"process-password",
|
||||
File.ReadAllText(statusPath),
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"process-password",
|
||||
diagnostics.ToString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(statusPath))
|
||||
File.Delete(statusPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -2276,6 +2358,15 @@ public sealed class HeadlessSessionHostTests
|
|||
StatusFile = statusFile,
|
||||
};
|
||||
|
||||
private static string[] ReadStatusEventNames(string path) =>
|
||||
File.ReadAllLines(path)
|
||||
.Select(static line =>
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(line);
|
||||
return document.RootElement.GetProperty("e").GetString()!;
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
private static void HydrateGroundedPlayer(GameRuntime runtime)
|
||||
{
|
||||
const uint player = 0x50000002u;
|
||||
|
|
@ -2917,11 +3008,17 @@ public sealed class HeadlessSessionHostTests
|
|||
|
||||
private sealed class FixtureSessionOperations : ILiveSessionOperations
|
||||
{
|
||||
private int _enterWorldCallCount;
|
||||
private int _tickCallCount;
|
||||
|
||||
public List<WorldSession> Sessions { get; } = [];
|
||||
public int CreatedSessionCount { get; private set; }
|
||||
public int DisposedSessionCount { get; private set; }
|
||||
public string? LastUser { get; private set; }
|
||||
public string? LastPassword { get; private set; }
|
||||
public int EnterWorldCallCount =>
|
||||
Volatile.Read(ref _enterWorldCallCount);
|
||||
public int TickCallCount => Volatile.Read(ref _tickCallCount);
|
||||
|
||||
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
||||
new(IPAddress.Loopback, port);
|
||||
|
|
@ -2963,19 +3060,16 @@ public sealed class HeadlessSessionHostTests
|
|||
true,
|
||||
true);
|
||||
|
||||
/// <summary>Campaign LA slice LA2: lets a probe test assert the
|
||||
/// live-session controller never reached EnterWorld.</summary>
|
||||
public int EnterWorldCallCount { get; private set; }
|
||||
|
||||
public void EnterWorld(
|
||||
WorldSession session,
|
||||
int activeCharacterIndex)
|
||||
{
|
||||
EnterWorldCallCount++;
|
||||
Interlocked.Increment(ref _enterWorldCallCount);
|
||||
}
|
||||
|
||||
public void Tick(WorldSession session)
|
||||
{
|
||||
Interlocked.Increment(ref _tickCallCount);
|
||||
}
|
||||
|
||||
public void DisposeSession(WorldSession session)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue