From 87a0eb25ec8fd2aaca47116def6c557316b0ebe4 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 27 Jun 2026 19:15:14 -0400 Subject: [PATCH] NEO-147: add contract generator unit and integration tests --- ...ractGeneratorOperationsIntegrationTests.cs | 53 ++++ .../ContractGeneratorOperationsTests.cs | 251 ++++++++++++++++++ ...actGeneratorPersistenceIntegrationTests.cs | 100 +++++++ 3 files changed, 404 insertions(+) create mode 100644 server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs new file mode 100644 index 0000000..7d20519 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs @@ -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(); + var instanceStore = scope.ServiceProvider.GetRequiredService(); + var standingStore = scope.ServiceProvider.GetRequiredService(); + var timeProvider = scope.ServiceProvider.GetRequiredService(); + 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); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs new file mode 100644 index 0000000..3dba1cc --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs @@ -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(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(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); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs new file mode 100644 index 0000000..3d5a0c2 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs @@ -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(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); + } + + ContractInstanceState readBack; + await using var secondFactory = new PostgresWebApplicationFactory(); + using var secondScope = secondFactory.Services.CreateScope(); + var readStore = secondScope.ServiceProvider.GetRequiredService(); + 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>().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(); + } +}