fix(launcher): Campaign LA close LA2 review findings

This commit is contained in:
Erik 2026-08-14 17:20:02 +02:00
parent 000ea979d5
commit 1c5e66c05b
8 changed files with 346 additions and 58 deletions

View file

@ -269,6 +269,105 @@ public sealed class HeadlessConfigurationLoaderTests
() => HeadlessConfigurationLoader.Load(file.Path));
}
/// <summary>
/// Campaign LA LA2 review fix: the pinned contract is presence-aware.
/// Normal play omits mode and supplies character/policy; probe supplies
/// mode and omits character/policy. JSON null is not another spelling of
/// omission for any of those conditional fields.
/// </summary>
[Theory]
[InlineData(false, "mode")]
[InlineData(false, "character")]
[InlineData(false, "policy")]
[InlineData(true, "character")]
[InlineData(true, "policy")]
public void ConditionalSessionFieldsRejectExplicitJsonNull(
bool probe,
string nullField)
{
string mode = probe
? "\"mode\":\"probe\","
: nullField == "mode"
? "\"mode\":null,"
: string.Empty;
string character = nullField == "character"
? "\"character\":null,"
: probe
? string.Empty
: "\"character\":{\"index\":0},";
string policy = nullField == "policy"
? "\"policy\":null,"
: probe
? string.Empty
: "\"policy\":{\"id\":\"idle\"},";
using TemporaryConfiguration file = TemporaryConfiguration.Create(
$$"""
{
"version": 1,
"sessions": [
{
"id": "explicit-null",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
{{mode}}
{{character}}
{{policy}}
"credential": { "provider": "environment", "reference": "PASSWORD" }
}
]
}
""");
HeadlessConfigurationException exception = Assert.Throws<
HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
Assert.Contains(
$"'{nullField}'",
exception.Message,
StringComparison.Ordinal);
Assert.Contains(
"cannot be null",
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public void PresenceAwareValidationKeepsUnmappedMemberRejection()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
ConfigurationWith(Session(
"bot",
"PASSWORD",
"\"notAContractField\":true")));
Assert.Throws<JsonException>(
() => HeadlessConfigurationLoader.Load(file.Path));
}
[Fact]
public void PresenceAwareValidationKeepsTypedValueRejection()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "wrong-type",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"mode": { "value": "probe" },
"credential": { "provider": "environment", "reference": "PASSWORD" }
}
]
}
""");
Assert.Throws<JsonException>(
() => HeadlessConfigurationLoader.Load(file.Path));
}
[Fact]
public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField()
{

View file

@ -262,6 +262,85 @@ public sealed class HeadlessSessionHostTests
Assert.False(cancellation.IsCancellationRequested);
}
/// <summary>
/// Campaign LA LA2 review fix: configured probe intent is not proof of a
/// completed probe. If the connected session produces no CharacterList,
/// Runtime returns NoCharacters, the process returns ConnectionError, and
/// the sole terminal status event reports that same non-success instead of
/// the former false code-0/reason-probe pair.
/// </summary>
[Fact]
public async Task ProbeWithoutRosterReportsTheProcessConnectionErrorExactlyOnce()
{
string statusPath = Path.Combine(
Path.GetTempPath(),
$"acdream-headless-probe-no-roster-{Guid.NewGuid():N}.jsonl");
try
{
var configuration = new HeadlessConfiguration
{
Version = 1,
Sessions =
[
ProbeDescriptor(
provider: HeadlessCredentialProviderKind.StandardInput,
credentialReference: "probe-password",
statusFile: statusPath),
],
};
var operations = new FixtureSessionOperations
{
Characters = null,
};
using var diagnostics = new StringWriter();
using var host = new HeadlessProcessHost(
configuration,
HeadlessPathSet.Resolve(new HeadlessPathOverrides()),
new System.IO.StringReader(
"probe-password" + Environment.NewLine),
diagnostics,
operations);
HeadlessExitCode result = await host.RunAsync(
CancellationToken.None);
Assert.Equal(HeadlessExitCode.ConnectionError, result);
Assert.Equal(0, operations.EnterWorldCallCount);
Assert.Equal(1, operations.DisposedSessionCount);
host.Dispose();
host.Dispose();
string[] lines = File.ReadAllLines(statusPath);
JsonElement[] events = lines
.Select(static line =>
JsonDocument.Parse(line).RootElement.Clone())
.ToArray();
Assert.Equal(
["started", "connected", "disconnected", "exited"],
events.Select(static item =>
item.GetProperty("e").GetString()));
Assert.DoesNotContain(
events,
static item =>
item.GetProperty("e").GetString() == "characterList");
JsonElement exited = Assert.Single(
events,
static item => item.GetProperty("e").GetString() == "exited");
Assert.Equal(
(int)result,
exited.GetProperty("code").GetInt32());
Assert.Equal(
"connection-error",
exited.GetProperty("reason").GetString());
}
finally
{
if (File.Exists(statusPath))
File.Delete(statusPath);
}
}
/// <summary>
/// Campaign LA slice LA2: a probe session completing must not tear down
/// a sibling play session sharing the same process — the process exit
@ -3019,6 +3098,23 @@ public sealed class HeadlessSessionHostTests
public int EnterWorldCallCount =>
Volatile.Read(ref _enterWorldCallCount);
public int TickCallCount => Volatile.Read(ref _tickCallCount);
public CharacterList.Parsed? Characters { get; init; } = new(
0u,
[
new CharacterList.Character(
0x50000001u,
"Other",
0u),
new CharacterList.Character(
0x50000002u,
"Headless",
0u),
],
[],
11,
"account",
true,
true);
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
@ -3040,25 +3136,8 @@ public sealed class HeadlessSessionHostTests
LastPassword = password;
}
public CharacterList.Parsed GetCharacters(
WorldSession session) =>
new(
0u,
[
new CharacterList.Character(
0x50000001u,
"Other",
0u),
new CharacterList.Character(
0x50000002u,
"Headless",
0u),
],
[],
11,
"account",
true,
true);
public CharacterList.Parsed? GetCharacters(
WorldSession session) => Characters;
public void EnterWorld(
WorldSession session,