Seeding reorg

This commit is contained in:
gamer147
2026-05-24 21:13:15 -04:00
parent 34bcc579a5
commit c14408ba06
73 changed files with 4611 additions and 369716 deletions

View File

@@ -0,0 +1,145 @@
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Services;
/// <summary>
/// Covers <see cref="GameConfigService"/>'s tier chain (DB → IConfiguration → ShippedDefaults →
/// <c>new T()</c>) and the atomic-per-section policy. Uses a real test SVSimDbContext from
/// <see cref="SVSimTestFactory"/> for the DB tier and an in-memory IConfiguration for the
/// appsettings tier. Test-only section types live in this file (assembly not scanned by the
/// seeder) so the fallback tiers can be exercised without fighting EnsureSeedDataAsync.
/// </summary>
public class GameConfigServiceTests
{
// Real section type (in Models.Config, seeded by EnsureSeedDataAsync) — used to test DB and
// override-DB scenarios.
private const string PackRatesKey = "PackRates";
// Test-only section types: not in SVSim.Database assembly → seeder ignores them → DB row is
// never written by the seed step. Exercises appsettings / ShippedDefaults / new T() tiers
// without having to delete seeded rows.
[ConfigSection("UnseededWithFactory")]
public class UnseededWithFactory
{
public string Value { get; set; } = "";
public static UnseededWithFactory ShippedDefaults() => new() { Value = "from-shipped-defaults" };
}
[ConfigSection("UnseededNoFactory")]
public class UnseededNoFactory
{
public int N { get; set; }
// Intentionally no ShippedDefaults() — exercises the final `new T()` tier.
}
public class UnattributedSection
{
public string Foo { get; set; } = "";
}
private static IConfiguration EmptyConfig() =>
new ConfigurationBuilder().Build();
private static IConfiguration ConfigFrom(params (string key, string value)[] entries) =>
new ConfigurationBuilder()
.AddInMemoryCollection(entries.Select(e => new KeyValuePair<string, string?>(e.key, e.value)))
.Build();
[Test]
public void Get_returns_DB_row_when_section_exists()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var svc = new GameConfigService(db, EmptyConfig());
// The fresh-install seeder wrote PackRates → tier 1 must hit it.
var rates = svc.Get<PackRateConfig>();
Assert.That(rates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9),
"tier-1 (DB) should return the seeded PackRates row");
Assert.That(rates.PerSlot.Any(s => s.Slot == "8"), Is.True);
}
[Test]
public void Get_atomic_DB_wins_even_when_appsettings_also_supplies_the_section()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Mutate DB row so we can detect which tier won.
var row = db.GameConfigs.First(s => s.SectionName == PackRatesKey);
var rates = JsonSerializer.Deserialize<PackRateConfig>(row.ValueJson)!;
rates.AnimatedRate = 0.5;
row.ValueJson = JsonSerializer.Serialize(rates);
db.SaveChanges();
// appsettings also supplies a different value — DB must still win (atomic per section).
var appsettings = ConfigFrom(($"GameConfig:{PackRatesKey}:AnimatedRate", "0.99"));
var svc = new GameConfigService(db, appsettings);
var result = svc.Get<PackRateConfig>();
Assert.That(result.AnimatedRate, Is.EqualTo(0.5).Within(1e-9),
"atomic-per-section: DB row wins entirely; appsettings tier never consulted");
}
[Test]
public void Get_falls_through_to_appsettings_when_no_DB_row()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var appsettings = ConfigFrom(("GameConfig:UnseededWithFactory:Value", "from-appsettings"));
var svc = new GameConfigService(db, appsettings);
var result = svc.Get<UnseededWithFactory>();
Assert.That(result.Value, Is.EqualTo("from-appsettings"),
"tier 2 should win when DB has no row and appsettings has the section");
}
[Test]
public void Get_falls_through_to_ShippedDefaults_when_no_DB_row_and_no_appsettings_section()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var svc = new GameConfigService(db, EmptyConfig());
var result = svc.Get<UnseededWithFactory>();
Assert.That(result.Value, Is.EqualTo("from-shipped-defaults"),
"tier 3 (ShippedDefaults) should win when neither DB nor appsettings supplies the section");
}
[Test]
public void Get_falls_through_to_parameterless_ctor_when_section_has_no_ShippedDefaults()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var svc = new GameConfigService(db, EmptyConfig());
var result = svc.Get<UnseededNoFactory>();
Assert.That(result.N, Is.EqualTo(0),
"tier 4 (new T()) should win when no other tier and no ShippedDefaults method exists");
}
[Test]
public void Get_throws_when_section_type_is_not_marked_with_ConfigSection()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var svc = new GameConfigService(db, EmptyConfig());
var ex = Assert.Throws<InvalidOperationException>(() => svc.Get<UnattributedSection>());
Assert.That(ex!.Message, Does.Contain("[ConfigSection"),
"unmarked type must produce a clear diagnostic, not a silent fallback");
}
}

View File

@@ -1,6 +1,7 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.UnitTests.Services;
@@ -25,6 +26,24 @@ public class PackOpenServiceTests
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) => null;
}
/// <summary>
/// Test stub that returns a single pre-built section. Only handles <see cref="PackRateConfig"/>
/// (the type <see cref="PackOpenService"/> reads in its ctor); other section types throw so a
/// future test that needs them must extend this stub explicitly.
/// </summary>
private sealed class StubConfig : IGameConfigService
{
private readonly PackRateConfig _rates;
public StubConfig(PackRateConfig rates) { _rates = rates; }
public T Get<T>() where T : class, new()
{
if (typeof(T) == typeof(PackRateConfig)) return (T)(object)_rates;
throw new NotImplementedException($"StubConfig: unhandled section type {typeof(T)}.");
}
}
private static PackOpenService MakeService(PackRateConfig rates) => new(new StubConfig(rates));
private static List<ShadowverseCardEntry> MakeFourCards() => new()
{
new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze },
@@ -41,8 +60,7 @@ public class PackOpenServiceTests
[Test]
public void Draw_returns_eight_cards_for_one_pack()
{
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
var svc = new PackOpenService(rootDefault);
var svc = MakeService(PackRateConfig.ShippedDefaults());
var pool = new StubPool(MakeFourCards());
var rng = new ScriptedRandom(0.5); // anything in Bronze/Silver range for non-slot-8
@@ -54,8 +72,9 @@ public class PackOpenServiceTests
[Test]
public void Draw_slot_8_never_returns_bronze_for_standard_pack()
{
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
var svc = new PackOpenService(rootDefault);
// PackRateConfig.ShippedDefaults() includes the SV Classic slot-8 "Silver-or-better
// guarantee" entry (PerSlot Bronze=0). Same shape the runtime seeder writes to GameConfigs.
var svc = MakeService(PackRateConfig.ShippedDefaults());
var pool = new StubPool(MakeFourCards());
for (int trial = 0; trial < 1000; trial++)
@@ -69,8 +88,7 @@ public class PackOpenServiceTests
[Test]
public void Draw_legendary_special_forces_slot_8_to_legendary()
{
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
var svc = new PackOpenService(rootDefault);
var svc = MakeService(PackRateConfig.ShippedDefaults());
var pool = new StubPool(MakeFourCards());
var pack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };
@@ -85,8 +103,7 @@ public class PackOpenServiceTests
[Test]
public void Draw_distribution_over_10k_slots_1_to_7_matches_declared_rates_within_2_percent()
{
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
var svc = new PackOpenService(rootDefault);
var svc = MakeService(PackRateConfig.ShippedDefaults());
var pool = new StubPool(MakeFourCards());
var counts = new Dictionary<Rarity, int>
{
@@ -117,8 +134,7 @@ public class PackOpenServiceTests
[Test]
public void Draw_excludes_listed_card_ids()
{
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
var svc = new PackOpenService(rootDefault);
var svc = MakeService(PackRateConfig.ShippedDefaults());
// Pool with two bronze cards; exclude one — every Bronze slot must pick the other.
var pool = new StubPool(new List<ShadowverseCardEntry>
{
@@ -140,16 +156,16 @@ public class PackOpenServiceTests
public void Draw_per_slot_override_is_applied_for_that_slot_and_default_for_others()
{
// Config: slot 3 is forced to Legendary; everything else uses Default.
// PerSlot is a List<SlotRarityWeights> with a Slot string key (EF Core 8 deviation from
// the Dictionary<int, T> in the spec — see Task 5 notes).
var root = new GameConfigRoot();
root.PackRates.PerSlot.Add(new SlotRarityWeights
// PerSlot is a List<SlotRarityWeights> with a Slot string key (no Dictionary<int,T> of
// complex types under jsonb-friendly serialisation — see Task 5 notes).
var rates = PackRateConfig.ShippedDefaults();
rates.PerSlot.Add(new SlotRarityWeights
{
Slot = "3",
Bronze = 0, Silver = 0, Gold = 0, Legendary = 1.0,
});
var svc = new PackOpenService(root);
var svc = MakeService(rates);
var pool = new StubPool(MakeFourCards());
for (int trial = 0; trial < 50; trial++)
@@ -186,8 +202,7 @@ public class PackOpenServiceTests
new List<ShadowverseCardEntry> { bronze },
new Dictionary<long, ShadowverseCardEntry> { [bronze.Id] = bronzeFoil });
var root = new GameConfigRoot(); // default AnimatedRate = 0.08
var svc = new PackOpenService(root);
var svc = MakeService(PackRateConfig.ShippedDefaults()); // default AnimatedRate = 0.08
const int packs = 1_000; // 8000 slots
int foilCount = 0;
@@ -211,9 +226,9 @@ public class PackOpenServiceTests
new Dictionary<long, ShadowverseCardEntry>()); // no foils
// Force the animated roll to always hit by setting AnimatedRate = 1.0.
var root = new GameConfigRoot();
root.PackRates.AnimatedRate = 1.0;
var svc = new PackOpenService(root);
var rates = PackRateConfig.ShippedDefaults();
rates.AnimatedRate = 1.0;
var svc = MakeService(rates);
var r = svc.Draw(StandardPack(), pools, 1, Array.Empty<long>(), new SystemRandom(seed: 1));
foreach (var c in r.Cards)
@@ -232,9 +247,9 @@ public class PackOpenServiceTests
new List<ShadowverseCardEntry> { leg },
new Dictionary<long, ShadowverseCardEntry> { [leg.Id] = legFoil });
var root = new GameConfigRoot();
root.PackRates.AnimatedRate = 1.0; // every slot upgrades
var svc = new PackOpenService(root);
var rates = PackRateConfig.ShippedDefaults();
rates.AnimatedRate = 1.0; // every slot upgrades
var svc = MakeService(rates);
var specialPack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };
var r = svc.Draw(specialPack, pools, 1, Array.Empty<long>(), new SystemRandom(seed: 3));
@@ -243,4 +258,22 @@ public class PackOpenServiceTests
Assert.That(r.Cards[7].CardId, Is.EqualTo(legFoil.Id),
"legendary-special slot 8 must be the foil-legendary when animated rate is forced to 1.0");
}
/// <summary>
/// Regression guard for the 2026-05-24 slot-8-ignores-PerSlot-override bug. Invariant: a
/// freshly-constructed <see cref="PackRateConfig"/> has an EMPTY PerSlot list. The original
/// trigger (EF Core 8's <c>OwnsMany</c>+<c>ToJson</c> jsonb materialisation appending rows
/// onto whatever the parent's ctor produced — leaving two slot-8 entries where the seeded one
/// silently won <see cref="PackOpenService.ResolveWeights"/>'s <c>FirstOrDefault</c>) is gone
/// now (config goes through <c>IGameConfigService</c> + STJ, which replaces correctly). The
/// invariant stays because any future config layer that hydrates into a pre-initialised
/// collection (custom deserialiser, ORM, manual Add loop) would resurrect the same failure
/// mode. Defaults for collections live in <see cref="PackRateConfig.ShippedDefaults"/>.
/// </summary>
[Test]
public void PackRateConfig_PerSlot_defaults_to_empty_to_avoid_jsonb_append_bug()
{
Assert.That(new PackRateConfig().PerSlot, Is.Empty,
"PackRateConfig.PerSlot must default to empty — see test docstring for why.");
}
}