fix(launcher): Campaign LA close LA2 review findings
This commit is contained in:
parent
000ea979d5
commit
1c5e66c05b
8 changed files with 346 additions and 58 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue