NEO-147: add contract generator unit and integration tests
parent
7f62f43f92
commit
87a0eb25ec
|
|
@ -0,0 +1,53 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
using NeonSprawl.Server.Tests;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
||||||
|
|
||||||
|
public sealed class ContractGeneratorOperationsIntegrationTests
|
||||||
|
{
|
||||||
|
private const string PlayerId = "dev-local-1";
|
||||||
|
private const string TemplateId = "prototype_contract_clear_combat_pocket";
|
||||||
|
private const string EncounterId = "prototype_combat_pocket";
|
||||||
|
private const string SeedBucket = "2026-06-22";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryIssue_ShouldCreateActiveInstanceWithEncounterBinding_WhenResolvedFromDi()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
_ = await client.GetAsync("/health");
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var templateRegistry = scope.ServiceProvider.GetRequiredService<IContractTemplateRegistry>();
|
||||||
|
var instanceStore = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
|
||||||
|
var standingStore = scope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
|
||||||
|
var timeProvider = scope.ServiceProvider.GetRequiredService<TimeProvider>();
|
||||||
|
var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId(
|
||||||
|
PlayerId,
|
||||||
|
TemplateId,
|
||||||
|
SeedBucket);
|
||||||
|
// Act
|
||||||
|
var result = ContractGeneratorOperations.TryIssue(
|
||||||
|
PlayerId,
|
||||||
|
TemplateId,
|
||||||
|
SeedBucket,
|
||||||
|
zoneDifficultyBand: null,
|
||||||
|
templateRegistry,
|
||||||
|
instanceStore,
|
||||||
|
standingStore,
|
||||||
|
timeProvider);
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Null(result.ReasonCode);
|
||||||
|
Assert.NotNull(result.Snapshot);
|
||||||
|
Assert.Equal(expectedInstanceId, result.Snapshot!.ContractInstanceId);
|
||||||
|
Assert.Equal(TemplateId, result.Snapshot.TemplateId);
|
||||||
|
Assert.Equal(EncounterId, result.EncounterTemplateId);
|
||||||
|
Assert.Equal(ContractInstanceStatus.Active, result.Snapshot.Status);
|
||||||
|
Assert.True(instanceStore.TryGetActiveForPlayer(PlayerId, out var active));
|
||||||
|
Assert.Equal(expectedInstanceId, active.ContractInstanceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Game.Quests;
|
||||||
|
using NeonSprawl.Server.Tests;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
||||||
|
|
||||||
|
public sealed class ContractGeneratorOperationsTests
|
||||||
|
{
|
||||||
|
private const string PlayerId = "dev-local-1";
|
||||||
|
private const string UnknownPlayerId = "unknown-player-xyz";
|
||||||
|
private const string TemplateId = "prototype_contract_clear_combat_pocket";
|
||||||
|
private const string EncounterId = "prototype_combat_pocket";
|
||||||
|
private const string SeedBucket = "2026-06-22";
|
||||||
|
private const string AlternateSeedBucket = "2026-06-23";
|
||||||
|
private static readonly DateTimeOffset IssuedAt = new(2026, 6, 22, 10, 0, 0, TimeSpan.Zero);
|
||||||
|
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 22, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryIssue_ShouldCreateActiveInstance_WhenTemplateEligible()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deps = CreateDependencies();
|
||||||
|
var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId(
|
||||||
|
PlayerId,
|
||||||
|
TemplateId,
|
||||||
|
SeedBucket);
|
||||||
|
// Act
|
||||||
|
var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Null(result.ReasonCode);
|
||||||
|
Assert.NotNull(result.Snapshot);
|
||||||
|
Assert.Equal(expectedInstanceId, result.Snapshot!.ContractInstanceId);
|
||||||
|
Assert.Equal(TemplateId, result.Snapshot.TemplateId);
|
||||||
|
Assert.Equal(PlayerId, result.Snapshot.PlayerId);
|
||||||
|
Assert.Equal(ContractInstanceStatus.Active, result.Snapshot.Status);
|
||||||
|
Assert.Equal(SeedBucket, result.Snapshot.SeedBucket);
|
||||||
|
Assert.Equal(IssuedAt, result.Snapshot.IssuedAt);
|
||||||
|
Assert.Equal(EncounterId, result.EncounterTemplateId);
|
||||||
|
Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active));
|
||||||
|
Assert.Equal(expectedInstanceId, active.ContractInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryIssue_ShouldAutoSelectTemplate_WhenTemplateIdOmitted()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deps = CreateDependencies();
|
||||||
|
// Act
|
||||||
|
var result = TryIssue(deps, templateId: null, seedBucket: SeedBucket);
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal(TemplateId, result.Snapshot!.TemplateId);
|
||||||
|
Assert.Equal(EncounterId, result.EncounterTemplateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MakeDeterministicInstanceId_ShouldMatchForSameInputs_AndDifferForDifferentSeedBucket()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var first = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, SeedBucket);
|
||||||
|
var same = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, SeedBucket);
|
||||||
|
var different = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, AlternateSeedBucket);
|
||||||
|
// Act
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(first, same);
|
||||||
|
Assert.NotEqual(first, different);
|
||||||
|
Assert.StartsWith("ci_", first, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryIssue_ShouldDenyActiveContractExists_WhenPlayerAlreadyHasActiveInstance()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deps = CreateDependencies();
|
||||||
|
var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
|
||||||
|
Assert.True(first.Success);
|
||||||
|
// Act
|
||||||
|
var second = TryIssue(deps, templateId: TemplateId, seedBucket: AlternateSeedBucket);
|
||||||
|
// Assert
|
||||||
|
Assert.False(second.Success);
|
||||||
|
Assert.Equal(ContractGeneratorReasonCodes.ActiveContractExists, second.ReasonCode);
|
||||||
|
Assert.NotNull(second.Snapshot);
|
||||||
|
Assert.Equal(first.Snapshot!.ContractInstanceId, second.Snapshot!.ContractInstanceId);
|
||||||
|
Assert.Equal(ContractInstanceStatus.Active, second.Snapshot.Status);
|
||||||
|
Assert.Null(second.EncounterTemplateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryIssue_ShouldAllowNewIssue_WhenPriorInstanceCompleted()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deps = CreateDependencies();
|
||||||
|
var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
|
||||||
|
Assert.True(first.Success);
|
||||||
|
Assert.True(deps.InstanceStore.TryMarkComplete(
|
||||||
|
PlayerId,
|
||||||
|
first.Snapshot!.ContractInstanceId,
|
||||||
|
CompletedAt,
|
||||||
|
out _));
|
||||||
|
// Act
|
||||||
|
var second = TryIssue(deps, templateId: TemplateId, seedBucket: AlternateSeedBucket);
|
||||||
|
// Assert
|
||||||
|
Assert.True(second.Success);
|
||||||
|
Assert.NotEqual(first.Snapshot.ContractInstanceId, second.Snapshot!.ContractInstanceId);
|
||||||
|
Assert.Equal(ContractInstanceStatus.Active, second.Snapshot.Status);
|
||||||
|
Assert.Equal(EncounterId, second.EncounterTemplateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryIssue_ShouldDenyUnknownTemplate_WhenExplicitTemplateMissing()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deps = CreateDependencies();
|
||||||
|
// Act
|
||||||
|
var result = TryIssue(deps, templateId: "not_a_real_template", seedBucket: SeedBucket);
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(ContractGeneratorReasonCodes.UnknownTemplate, result.ReasonCode);
|
||||||
|
Assert.Null(result.Snapshot);
|
||||||
|
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryIssue_ShouldDenyNoEligibleTemplate_WhenBandMismatch()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deps = CreateDependencies();
|
||||||
|
// Act
|
||||||
|
var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket, zoneDifficultyBand: 99);
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode);
|
||||||
|
Assert.Null(result.Snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryIssue_ShouldDenyNoEligibleTemplate_WhenAutoSelectBandHasNoTemplates()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deps = CreateDependencies();
|
||||||
|
// Act
|
||||||
|
var result = TryIssue(deps, templateId: null, seedBucket: SeedBucket, zoneDifficultyBand: 99);
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryIssue_ShouldDenyPlayerNotWritable_WhenPlayerUnknown()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deps = CreateDependencies();
|
||||||
|
// Act
|
||||||
|
var result = TryIssue(deps, playerId: UnknownPlayerId, templateId: TemplateId, seedBucket: SeedBucket);
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(ContractGeneratorReasonCodes.PlayerNotWritable, result.ReasonCode);
|
||||||
|
Assert.Null(result.Snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryIssue_ShouldDenyInvalidIds_WhenPlayerIdEmpty()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deps = CreateDependencies();
|
||||||
|
// Act
|
||||||
|
var result = TryIssue(deps, playerId: " ", templateId: TemplateId, seedBucket: SeedBucket);
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryIssue_ShouldDenyInvalidIds_WhenSeedBucketEmpty()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deps = CreateDependencies();
|
||||||
|
// Act
|
||||||
|
var result = TryIssue(deps, templateId: TemplateId, seedBucket: " ");
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ContractIssueOperationResult TryIssue(
|
||||||
|
GeneratorTestDependencies deps,
|
||||||
|
string? playerId = PlayerId,
|
||||||
|
string? templateId = TemplateId,
|
||||||
|
string seedBucket = SeedBucket,
|
||||||
|
int? zoneDifficultyBand = null) =>
|
||||||
|
ContractGeneratorOperations.TryIssue(
|
||||||
|
playerId!,
|
||||||
|
templateId,
|
||||||
|
seedBucket,
|
||||||
|
zoneDifficultyBand,
|
||||||
|
deps.TemplateRegistry,
|
||||||
|
deps.InstanceStore,
|
||||||
|
deps.StandingStore,
|
||||||
|
deps.TimeProvider);
|
||||||
|
|
||||||
|
private static GeneratorTestDependencies CreateDependencies()
|
||||||
|
{
|
||||||
|
var template = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId];
|
||||||
|
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
[template.Id] = template,
|
||||||
|
};
|
||||||
|
var catalog = new ContractTemplateCatalog("/tmp/catalog", rows, catalogJsonFileCount: 1);
|
||||||
|
var templateRegistry = new ContractTemplateRegistry(catalog);
|
||||||
|
var positionOptions = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
|
||||||
|
var instanceStore = new InMemoryContractInstanceStore(positionOptions);
|
||||||
|
var standingStore = CreateStandingStore();
|
||||||
|
return new GeneratorTestDependencies(
|
||||||
|
templateRegistry,
|
||||||
|
instanceStore,
|
||||||
|
standingStore,
|
||||||
|
new FakeTimeProvider(IssuedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InMemoryFactionStandingStore CreateStandingStore()
|
||||||
|
{
|
||||||
|
var gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
|
||||||
|
var rustFactionId = "prototype_faction_rust_collective";
|
||||||
|
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
[gridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[gridFactionId],
|
||||||
|
[rustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[rustFactionId],
|
||||||
|
};
|
||||||
|
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
|
||||||
|
var registry = new FactionDefinitionRegistry(catalog);
|
||||||
|
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
|
||||||
|
return new InMemoryFactionStandingStore(options, registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
|
||||||
|
{
|
||||||
|
public override DateTimeOffset GetUtcNow() => utcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct GeneratorTestDependencies(
|
||||||
|
IContractTemplateRegistry TemplateRegistry,
|
||||||
|
IContractInstanceStore InstanceStore,
|
||||||
|
IFactionStandingStore StandingStore,
|
||||||
|
TimeProvider TimeProvider);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Tests.Game.PositionState;
|
||||||
|
using Npgsql;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
||||||
|
|
||||||
|
[Collection("Postgres integration")]
|
||||||
|
public sealed class ContractGeneratorPersistenceIntegrationTests(PostgresIntegrationHarness harness)
|
||||||
|
{
|
||||||
|
private PostgresWebApplicationFactory Factory => harness.Factory;
|
||||||
|
|
||||||
|
private const string PlayerId = "dev-local-1";
|
||||||
|
private const string TemplateId = "prototype_contract_clear_combat_pocket";
|
||||||
|
private const string EncounterId = "prototype_combat_pocket";
|
||||||
|
private const string SeedBucket = "2026-06-22";
|
||||||
|
|
||||||
|
[RequirePostgresFact]
|
||||||
|
public async Task TryIssue_ShouldPersistActiveInstanceAcrossNewFactory()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await ResetContractInstanceTableAsync();
|
||||||
|
var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId(
|
||||||
|
PlayerId,
|
||||||
|
TemplateId,
|
||||||
|
SeedBucket);
|
||||||
|
|
||||||
|
// Act — issue through first host
|
||||||
|
ContractIssueOperationResult issued;
|
||||||
|
using (var firstScope = Factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var services = firstScope.ServiceProvider;
|
||||||
|
issued = ContractGeneratorOperations.TryIssue(
|
||||||
|
PlayerId,
|
||||||
|
TemplateId,
|
||||||
|
SeedBucket,
|
||||||
|
zoneDifficultyBand: null,
|
||||||
|
services.GetRequiredService<IContractTemplateRegistry>(),
|
||||||
|
services.GetRequiredService<IContractInstanceStore>(),
|
||||||
|
services.GetRequiredService<IFactionStandingStore>(),
|
||||||
|
services.GetRequiredService<TimeProvider>());
|
||||||
|
}
|
||||||
|
|
||||||
|
ContractInstanceState readBack;
|
||||||
|
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||||
|
using var secondScope = secondFactory.Services.CreateScope();
|
||||||
|
var readStore = secondScope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
|
||||||
|
readBack = readStore.TryGetActiveForPlayer(PlayerId, out var snapshot)
|
||||||
|
? snapshot
|
||||||
|
: null!;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(issued.Success);
|
||||||
|
Assert.Equal(expectedInstanceId, issued.Snapshot!.ContractInstanceId);
|
||||||
|
Assert.Equal(EncounterId, issued.EncounterTemplateId);
|
||||||
|
Assert.NotNull(readBack);
|
||||||
|
Assert.Equal(expectedInstanceId, readBack.ContractInstanceId);
|
||||||
|
Assert.Equal(TemplateId, readBack.TemplateId);
|
||||||
|
Assert.Equal(ContractInstanceStatus.Active, readBack.Status);
|
||||||
|
Assert.Equal(SeedBucket, readBack.SeedBucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResetContractInstanceTableAsync()
|
||||||
|
{
|
||||||
|
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||||
|
if (string.IsNullOrWhiteSpace(cs))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Factory.Services;
|
||||||
|
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
|
||||||
|
|
||||||
|
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
|
||||||
|
var instanceDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V011__contract_instance.sql");
|
||||||
|
if (!File.Exists(positionDdlPath) || !File.Exists(instanceDdlPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException("Test DDL for contract generator persistence not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
|
||||||
|
var instanceDdl = await File.ReadAllTextAsync(instanceDdlPath);
|
||||||
|
await using var conn = new NpgsqlConnection(cs);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await using var applyPosition = new NpgsqlCommand(positionDdl, conn);
|
||||||
|
await applyPosition.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
await using var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn);
|
||||||
|
await truncate.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||||
|
|
||||||
|
await using var applyInstance = new NpgsqlCommand(instanceDdl, conn);
|
||||||
|
await applyInstance.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue