diff --git a/SVSim.UnitTests/Controllers/PackControllerFullCatalogTests.cs b/SVSim.UnitTests/Controllers/PackControllerFullCatalogTests.cs index bcf2a24..30c1065 100644 --- a/SVSim.UnitTests/Controllers/PackControllerFullCatalogTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerFullCatalogTests.cs @@ -9,16 +9,16 @@ using SVSim.UnitTests.Infrastructure; namespace SVSim.UnitTests.Controllers; /// -/// Drives the importer + controller against the full production pack seed (35 packs). Guards -/// against regressions in either layer caused by future seed refreshes. +/// Drives the importer + controller against the full production pack seed. Guards against +/// regressions in either layer caused by future seed refreshes. /// public class PackControllerFullCatalogTests { [Test] - public async Task Info_returns_full_35_pack_catalog_from_production_seed() + public async Task Info_round_trips_every_active_pack_from_production_seed() { // The production seed (packs.json) is overlaid by a 3-pack test fixture in the default test - // output dir (see SVSim.UnitTests.csproj). For this test we need the FULL 35-pack catalog, + // output dir (see SVSim.UnitTests.csproj). For this test we need the FULL prod catalog, // so we point PackImporter at a temp seed dir holding only the upstream production seed // (copied from the Bootstrap project's source-tree Data/seeds/). var prodSeed = LocateProdSeed("packs.json"); @@ -40,6 +40,15 @@ public class PackControllerFullCatalogTests long viewerId = await factory.SeedViewerAsync(); + // Snapshot the clock BEFORE the request so the expected count derives from the + // same moment the controller will read. PackController calls DateTime.UtcNow + // directly (not via TimeProvider), so we can't share a single instant — the + // sub-millisecond window between this and the controller's read is the only + // race exposure and any pack whose complete_date falls inside it is on the + // boundary either way. + var now = DateTime.UtcNow; + int expectedActive = CountActiveInSeed(prodSeed, now); + using var client = factory.CreateAuthenticatedClient(viewerId); var response = await client.PostAsync( "/pack/info", @@ -51,23 +60,28 @@ public class PackControllerFullCatalogTests using var doc = JsonDocument.Parse(body); var list = doc.RootElement.GetProperty("pack_config_list"); - Assert.That(list.GetArrayLength(), Is.EqualTo(35), - "Full prod seed should yield 35 active packs as of 2026-05-23."); - // Spot-check pack 99047 (LegendCardPack throwback, pack_category=1) - bool sawSpecial = false; + Assert.That(list.GetArrayLength(), Is.EqualTo(expectedActive), + $"Importer+controller must surface every active pack in the prod seed " + + $"(expected {expectedActive} active as of {now:O})."); + + // Schema-fidelity spot check: at least one pack with a non-default pack_category + // proves the field survives the JSON round trip. Doesn't pin to a specific pack id + // so it stays valid as seasonal packs roll over. + bool sawNonDefaultCategory = false; for (int i = 0; i < list.GetArrayLength(); i++) { - var el = list[i]; - if (el.GetProperty("parent_gacha_id").GetInt32() == 99047) + if (list[i].GetProperty("pack_category").GetInt32() != 0) { - Assert.That(el.GetProperty("pack_category").GetInt32(), Is.EqualTo(1), - "99047 is a LegendCardPack (category 1) in the prod capture."); - sawSpecial = true; + sawNonDefaultCategory = true; break; } } - Assert.That(sawSpecial, Is.True, "pack 99047 must be in the prod capture output."); + Assert.That(sawNonDefaultCategory, Is.True, + "At least one active pack should carry a non-default pack_category — " + + "the prod seed always has e.g. category 1 LegendCardPacks alongside the " + + "category 0 standard packs. If this fails, either the round-trip dropped " + + "the field or the seed no longer contains any non-default category."); } finally { @@ -75,6 +89,28 @@ public class PackControllerFullCatalogTests } } + /// + /// Mirrors PackRepository.GetActivePacks's filter (IsEnabled && + /// CommenceDate <= now <= CompleteDate) against the raw seed file. is_enabled + /// defaults to true to match PackSeed.IsEnabled. Uses the same wire-date parser + /// as the importer so any date-string quirk is parsed identically on both sides. + /// + private static int CountActiveInSeed(string seedPath, DateTime when) + { + using var doc = JsonDocument.Parse(File.ReadAllText(seedPath)); + int count = 0; + foreach (var pack in doc.RootElement.EnumerateArray()) + { + bool enabled = !pack.TryGetProperty("is_enabled", out var en) || en.GetBoolean(); + if (!enabled) continue; + + var commence = ImporterBase.ParseWireDateTime(pack.GetProperty("commence_date").GetString()); + var complete = ImporterBase.ParseWireDateTime(pack.GetProperty("complete_date").GetString()); + if (commence <= when && complete >= when) count++; + } + return count; + } + /// /// The test output dir's Data/seeds/packs.json is the fixture overlay (3 packs). The /// upstream production seed lives in the Bootstrap project's source tree. Walk up from the