Seeding reorg
This commit is contained in:
@@ -277,6 +277,17 @@ public class LoadControllerTests
|
||||
Assert.That(mri.GetProperty("setting").EnumerateObject().Count(), Is.EqualTo(27));
|
||||
Assert.That(mri.GetProperty("abilities").EnumerateObject().Count(), Is.EqualTo(6));
|
||||
|
||||
// my_rotation_info.schedules drives the client's "Custom Rotation" button visibility
|
||||
// (Wizard/MyRotationAllInfo.cs:45 — IsMyRotationEnable). GlobalsImporter sources the
|
||||
// window from the prod capture; default-initialised DateTime.MinValue values would hide
|
||||
// the button. Assert the captured 2024→2030 free_battle window round-trips through the
|
||||
// MyRotationScheduleConfig section.
|
||||
var fb = mri.GetProperty("schedules").GetProperty("free_battle");
|
||||
Assert.That(DateTime.Parse(fb.GetProperty("begin_time").GetString()!),
|
||||
Is.EqualTo(new DateTime(2024, 5, 1, 20, 0, 0, DateTimeKind.Utc)));
|
||||
Assert.That(DateTime.Parse(fb.GetProperty("end_time").GetString()!),
|
||||
Is.EqualTo(new DateTime(2030, 6, 26, 19, 59, 59, DateTimeKind.Utc)));
|
||||
|
||||
// avatar_info: abilities dict has 24 entries; schedules is empty list
|
||||
var ai = root.GetProperty("avatar_info");
|
||||
Assert.That(ai.GetProperty("abilities").EnumerateObject().Count(), Is.EqualTo(24));
|
||||
|
||||
@@ -58,6 +58,12 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
db.Database.EnsureCreated();
|
||||
db.EnsureSeedDataAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Reference data is no longer HasData-seeded; load the CSVs via the same importer
|
||||
// production uses so tests exercise the same code path. CardCosmeticRewards skipped —
|
||||
// FK to Cards would reject every row against the minimal 3-card test seed below.
|
||||
var dataDir = Path.Combine(AppContext.BaseDirectory, "Data");
|
||||
new ReferenceDataImporter().ImportAllAsync(db, dataDir).GetAwaiter().GetResult();
|
||||
|
||||
// Seed a minimal card set so card-pool tests can resolve a non-empty pool without
|
||||
// requiring the full CardImporter tool or a cards.json file. The set is marked
|
||||
// IsInRotation so both standard-pack (by setId) and special-pack (rotation scan)
|
||||
|
||||
@@ -30,34 +30,6 @@ internal class SqliteFriendlyModelCustomizer : ModelCustomizer
|
||||
shortUdidProperty.ValueGenerated = ValueGenerated.Never;
|
||||
|
||||
AssignClientSideKeyGenerators(modelBuilder.Model);
|
||||
StripCardCosmeticRewardSeed(modelBuilder.Model);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CardCosmeticReward rows have an FK to Cards.Id, and the production model HasData-seeds
|
||||
/// 1068 rows from card_cosmetic_rewards.csv (chunk A.2). In production those rows have
|
||||
/// matching cards inserted by the CardImporter before runtime. The unit-test factory uses
|
||||
/// SQLite + EnsureCreated + a minimal 3-card seed — most of the cosmetic-reward rows have
|
||||
/// no matching Cards row, and EnsureCreated's FK-deferred batch insert throws SqliteException
|
||||
/// "FOREIGN KEY constraint failed" at host construction time. Strip the seed in tests; the
|
||||
/// test fixture inserts CardCosmeticReward rows ad-hoc when a specific scenario needs them.
|
||||
///
|
||||
/// HasData seed is stored in the internal EntityType._data field (no public API). Clear it
|
||||
/// via reflection. The clear runs after base.Customize so the HasData call inside Seed()
|
||||
/// has populated the list before we wipe it.
|
||||
/// </summary>
|
||||
private static void StripCardCosmeticRewardSeed(IMutableModel model)
|
||||
{
|
||||
var entityType = model.FindEntityType(typeof(CardCosmeticReward));
|
||||
if (entityType is null) return;
|
||||
|
||||
// EntityType._data is a List<IDictionary<string, object>>? — null when empty.
|
||||
var dataField = entityType.GetType().GetField(
|
||||
"_data",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
if (dataField is null) return;
|
||||
var list = dataField.GetValue(entityType) as System.Collections.IList;
|
||||
list?.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,75 +1,152 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip tests for the GameConfigs key/value table — one row per section, raw jsonb payload
|
||||
/// deserialised by IGameConfigService rather than EF Core. Replaces the prior single-row
|
||||
/// GameConfigurations / GameConfigRoot jsonb-tree shape (2026-05-24 refactor).
|
||||
/// </summary>
|
||||
public class GameConfigurationJsonbTests
|
||||
{
|
||||
[Test]
|
||||
public async Task DefaultSeed_populates_canonical_GameConfigRoot_defaults()
|
||||
public async Task EnsureSeedData_writes_one_row_per_ConfigSection_with_ShippedDefaults_payload()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
var cfg = await db.GameConfigurations.FirstOrDefaultAsync(c => c.Id == "default");
|
||||
var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
|
||||
var byName = rows.ToDictionary(r => r.SectionName);
|
||||
|
||||
Assert.That(cfg, Is.Not.Null, "default GameConfiguration row must exist (seeded via EnsureSeedDataAsync)");
|
||||
Assert.That(cfg!.Config, Is.Not.Null, "Config must round-trip to non-null GameConfigRoot");
|
||||
Assert.That(cfg.Config.DefaultGrants.Crystals, Is.EqualTo(50000UL), "pre-refactor default");
|
||||
Assert.That(cfg.Config.DefaultGrants.Rupees, Is.EqualTo(50000UL), "pre-refactor default");
|
||||
Assert.That(cfg.Config.DefaultGrants.Ether, Is.EqualTo(50000UL), "pre-refactor default");
|
||||
Assert.That(cfg.Config.Player.MaxFriends, Is.EqualTo(20), "pre-refactor default");
|
||||
Assert.That(cfg.Config.DefaultLoadout.SleeveId, Is.EqualTo(3000011), "pre-refactor default sleeve");
|
||||
Assert.That(cfg.Config.PackRates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9), "SV Classic default");
|
||||
Assert.That(cfg.Config.PackRates.Default.Bronze, Is.EqualTo(0.6744).Within(1e-9));
|
||||
// PerSlot is now a List<SlotRarityWeights> keyed by Slot string (see Task 5 deviation note).
|
||||
var slot8 = cfg.Config.PackRates.PerSlot.FirstOrDefault(s => s.Slot == "8");
|
||||
Assert.That(slot8, Is.Not.Null, "slot-8 default entry must be present");
|
||||
// One row per [ConfigSection]-marked POCO (7 sections today: Player, DefaultGrants,
|
||||
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule).
|
||||
Assert.That(byName.Keys, Is.EquivalentTo(new[]
|
||||
{
|
||||
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
|
||||
"MyRotationSchedule",
|
||||
}));
|
||||
|
||||
var mrSchedule = JsonSerializer.Deserialize<MyRotationScheduleConfig>(byName["MyRotationSchedule"].ValueJson)!;
|
||||
Assert.That(mrSchedule.FreeBattle.Begin, Is.EqualTo(new DateTime(2024, 5, 1, 20, 0, 0, DateTimeKind.Utc)),
|
||||
"ShippedDefaults reproduces the 2026-05-23 prod capture so a fresh install ships with Custom Rotation enabled");
|
||||
Assert.That(mrSchedule.FreeBattle.End, Is.EqualTo(new DateTime(2030, 6, 26, 19, 59, 59, DateTimeKind.Utc)));
|
||||
|
||||
var packRates = JsonSerializer.Deserialize<PackRateConfig>(byName["PackRates"].ValueJson)!;
|
||||
Assert.That(packRates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9), "SV Classic AnimatedRate");
|
||||
Assert.That(packRates.Default.Bronze, Is.EqualTo(0.6744).Within(1e-9));
|
||||
var slot8 = packRates.PerSlot.FirstOrDefault(s => s.Slot == "8");
|
||||
Assert.That(slot8, Is.Not.Null, "ShippedDefaults() includes the slot-8 Silver-or-better entry");
|
||||
Assert.That(slot8!.Silver, Is.EqualTo(0.7692).Within(1e-9));
|
||||
|
||||
var grants = JsonSerializer.Deserialize<DefaultGrantsConfig>(byName["DefaultGrants"].ValueJson)!;
|
||||
Assert.That(grants.Crystals, Is.EqualTo(50000UL));
|
||||
Assert.That(grants.Rupees, Is.EqualTo(50000UL));
|
||||
Assert.That(grants.Ether, Is.EqualTo(50000UL));
|
||||
|
||||
var player = JsonSerializer.Deserialize<PlayerConfig>(byName["Player"].ValueJson)!;
|
||||
Assert.That(player.MaxFriends, Is.EqualTo(20));
|
||||
|
||||
var loadout = JsonSerializer.Deserialize<DefaultLoadoutConfig>(byName["DefaultLoadout"].ValueJson)!;
|
||||
Assert.That(loadout.SleeveId, Is.EqualTo(3000011));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Mutation_then_save_then_reload_round_trips_through_jsonb()
|
||||
public async Task Section_row_round_trips_through_jsonb_via_raw_json()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var cfg = await db.GameConfigurations.FirstAsync(c => c.Id == "default");
|
||||
cfg.Config.Rotation.TsRotationId = "99999";
|
||||
cfg.Config.PackRates.AnimatedRate = 0.42;
|
||||
db.Entry(cfg).Property(c => c.Config).IsModified = true;
|
||||
var rotation = await db.GameConfigs.FirstAsync(s => s.SectionName == "Rotation");
|
||||
// Hydrate, mutate, re-serialise — same pattern GlobalsImporter and any admin-write
|
||||
// path will use.
|
||||
var value = JsonSerializer.Deserialize<RotationConfig>(rotation.ValueJson)!;
|
||||
value.TsRotationId = "99999";
|
||||
value.IsBattlePassPeriod = true;
|
||||
rotation.ValueJson = JsonSerializer.Serialize(value);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var cfg = await db.GameConfigurations.FirstAsync(c => c.Id == "default");
|
||||
Assert.That(cfg.Config.Rotation.TsRotationId, Is.EqualTo("99999"));
|
||||
Assert.That(cfg.Config.PackRates.AnimatedRate, Is.EqualTo(0.42).Within(1e-9));
|
||||
var rotation = await db.GameConfigs.FirstAsync(s => s.SectionName == "Rotation");
|
||||
var value = JsonSerializer.Deserialize<RotationConfig>(rotation.ValueJson)!;
|
||||
Assert.That(value.TsRotationId, Is.EqualTo("99999"));
|
||||
Assert.That(value.IsBattlePassPeriod, Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator-edited PerSlot override (e.g. 100% Legendary for testing) must survive a DB
|
||||
/// round-trip and produce exactly ONE entry per slot — not stack on top of any default seed.
|
||||
/// The 2026-05-24 bug shape: pre-refactor PackRateConfig.PerSlot shipped with a Classic
|
||||
/// slot-8 seed in its initialiser; EF Core 8's OwnsMany jsonb path appended the operator's
|
||||
/// override on top instead of replacing it, and the seed won the FirstOrDefault in
|
||||
/// ResolveWeights. Post-refactor this can't happen (PerSlot defaults to empty,
|
||||
/// IGameConfigService uses pure STJ which replaces) but the round-trip assertion stays.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Operator_PerSlot_override_round_trips_as_sole_entry_for_that_slot()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var row = await db.GameConfigs.FirstAsync(s => s.SectionName == "PackRates");
|
||||
var rates = JsonSerializer.Deserialize<PackRateConfig>(row.ValueJson)!;
|
||||
// Operator wipes the seeded slot-8 entry and replaces it with a 100%-Legendary override.
|
||||
rates.PerSlot.Clear();
|
||||
rates.PerSlot.Add(new SlotRarityWeights
|
||||
{
|
||||
Slot = "8", Bronze = 0, Silver = 0, Gold = 0, Legendary = 1,
|
||||
});
|
||||
row.ValueJson = JsonSerializer.Serialize(rates);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var row = await db.GameConfigs.FirstAsync(s => s.SectionName == "PackRates");
|
||||
var rates = JsonSerializer.Deserialize<PackRateConfig>(row.ValueJson)!;
|
||||
|
||||
var slot8Entries = rates.PerSlot.Where(s => s.Slot == "8").ToList();
|
||||
Assert.That(slot8Entries, Has.Count.EqualTo(1),
|
||||
"exactly one PerSlot[8] entry must round-trip — duplicates mean the loader appended " +
|
||||
"instead of replacing (the 2026-05-24 bug pattern).");
|
||||
Assert.That(slot8Entries[0].Legendary, Is.EqualTo(1.0).Within(1e-9),
|
||||
"the surviving PerSlot[8] entry must be the operator's override, not a stale seed.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GlobalsImporter_updates_Rotation_without_clobbering_other_subconfigs()
|
||||
public async Task GlobalsImporter_updates_Rotation_without_clobbering_other_sections()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
await factory.SeedGlobalsAsync(); // imports load-index which has ts_rotation_id="10015"
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var cfg = await db.GameConfigurations.FirstAsync(c => c.Id == "default");
|
||||
|
||||
Assert.That(cfg.Config.Rotation.TsRotationId, Is.EqualTo("10015"),
|
||||
var rotation = JsonSerializer.Deserialize<RotationConfig>(
|
||||
(await db.GameConfigs.FirstAsync(s => s.SectionName == "Rotation")).ValueJson)!;
|
||||
Assert.That(rotation.TsRotationId, Is.EqualTo("10015"),
|
||||
"GlobalsImporter should set Rotation.TsRotationId from the prod capture.");
|
||||
// PackRates is NOT in the load-index capture; must keep the seeded default unchanged.
|
||||
Assert.That(cfg.Config.PackRates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9),
|
||||
|
||||
// PackRates is NOT in the load-index capture; its row must keep ShippedDefaults values.
|
||||
var packRates = JsonSerializer.Deserialize<PackRateConfig>(
|
||||
(await db.GameConfigs.FirstAsync(s => s.SectionName == "PackRates")).ValueJson)!;
|
||||
Assert.That(packRates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9),
|
||||
"GlobalsImporter must not clobber PackRates while updating Rotation.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,18 +206,9 @@ public class GlobalsRepositoryTests
|
||||
Assert.That(skins.Count, Is.EqualTo(8));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetGameConfiguration_default_has_seeded_ts_rotation_id()
|
||||
{
|
||||
var (factory, repo) = await SetupAsync();
|
||||
using var _ = factory;
|
||||
var cfg = await repo.GetGameConfiguration("default");
|
||||
Assert.That(cfg, Is.Not.Null);
|
||||
Assert.That(cfg.Config.Rotation.TsRotationId, Is.EqualTo("10015"),
|
||||
"GlobalsImporter should overwrite the migration's empty-string default with the capture value.");
|
||||
Assert.That(cfg.Config.Rotation.IsBattlePassPeriod, Is.True,
|
||||
"Prod sends bool true for is_battle_pass_period; capture should overwrite the migration default of false.");
|
||||
}
|
||||
// Note: GetGameConfiguration was removed from IGlobalsRepository in the 2026-05-24 config
|
||||
// refactor — Rotation/Challenge/etc. now load via IGameConfigService. See
|
||||
// GameConfigurationJsonbTests for the equivalent round-trip coverage.
|
||||
|
||||
[Test]
|
||||
public async Task GetMaintenanceCards_empty_when_capture_has_none()
|
||||
|
||||
@@ -29,9 +29,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- BaseDataSeeder reads CSVs from the runtime "Data" folder; mirror them into the test
|
||||
output so HasData seeding fires when EnsureCreated builds the SQLite schema. -->
|
||||
<Content Include="..\SVSim.EmulatedEntrypoint\Data\*.csv" Link="Data\%(Filename)%(Extension)">
|
||||
<!-- ReferenceDataImporter / CardCosmeticRewardImporter read CSVs from the runtime "Data"
|
||||
folder; mirror Bootstrap's copies into the test output so SVSimTestFactory can call
|
||||
the importers after EnsureCreated to populate reference tables. -->
|
||||
<Content Include="..\SVSim.Bootstrap\Data\*.csv" Link="Data\%(Filename)%(Extension)">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- Tests that call SVSimTestFactory.SeedGlobalsAsync() need the prod captures available in
|
||||
|
||||
145
SVSim.UnitTests/Services/GameConfigServiceTests.cs
Normal file
145
SVSim.UnitTests/Services/GameConfigServiceTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user