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:
Erik 2026-08-14 16:49:51 +02:00
parent c601942467
commit 000ea979d5
5 changed files with 243 additions and 38 deletions

View file

@ -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)