test(pack-controller): derive expected active-pack count from seed at runtime
The full-catalog regression test hardcoded "35 active packs as of 2026-05-23" but the controller filters by DateTime.UtcNow against each pack's commence/complete dates. When two packs (99047, 80047) crossed their complete_date of 2026-06-01 01:59:59 UTC, the test started failing with Expected: 35 / But was: 33 — which had been masked all along by NUnit's trx serializer OOMing on a different test. The hardcoded count conflated three things that happened to be equal on the day the test was written: packs in the seed file, packs active right now, and 35. The test's real intent (per its class docstring) is "every pack the importer ingests round-trips through /pack/info"; pinning the clock with TimeProvider would solve today's drift but re-break the moment someone regenerates the seed or retires a pack. Expected count now derives from the seed file at test time, filtered by the same predicate the controller uses (PackRepository .GetActivePacks: IsEnabled && commence <= now <= complete) via the shared ImporterBase.ParseWireDateTime parser so any date-string quirk parses identically on both sides. Spot-check on pack 99047 swapped for "any pack with non-default pack_category" — same schema-fidelity coverage (non-zero category survives JSON round trip) without pinning to an id that rotates. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -9,16 +9,16 @@ using SVSim.UnitTests.Infrastructure;
|
|||||||
namespace SVSim.UnitTests.Controllers;
|
namespace SVSim.UnitTests.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Drives the importer + controller against the full production pack seed (35 packs). Guards
|
/// Drives the importer + controller against the full production pack seed. Guards against
|
||||||
/// against regressions in either layer caused by future seed refreshes.
|
/// regressions in either layer caused by future seed refreshes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PackControllerFullCatalogTests
|
public class PackControllerFullCatalogTests
|
||||||
{
|
{
|
||||||
[Test]
|
[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
|
// 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
|
// 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/).
|
// (copied from the Bootstrap project's source-tree Data/seeds/).
|
||||||
var prodSeed = LocateProdSeed("packs.json");
|
var prodSeed = LocateProdSeed("packs.json");
|
||||||
@@ -40,6 +40,15 @@ public class PackControllerFullCatalogTests
|
|||||||
|
|
||||||
long viewerId = await factory.SeedViewerAsync();
|
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);
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
var response = await client.PostAsync(
|
var response = await client.PostAsync(
|
||||||
"/pack/info",
|
"/pack/info",
|
||||||
@@ -51,23 +60,28 @@ public class PackControllerFullCatalogTests
|
|||||||
|
|
||||||
using var doc = JsonDocument.Parse(body);
|
using var doc = JsonDocument.Parse(body);
|
||||||
var list = doc.RootElement.GetProperty("pack_config_list");
|
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)
|
Assert.That(list.GetArrayLength(), Is.EqualTo(expectedActive),
|
||||||
bool sawSpecial = false;
|
$"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++)
|
for (int i = 0; i < list.GetArrayLength(); i++)
|
||||||
{
|
{
|
||||||
var el = list[i];
|
if (list[i].GetProperty("pack_category").GetInt32() != 0)
|
||||||
if (el.GetProperty("parent_gacha_id").GetInt32() == 99047)
|
|
||||||
{
|
{
|
||||||
Assert.That(el.GetProperty("pack_category").GetInt32(), Is.EqualTo(1),
|
sawNonDefaultCategory = true;
|
||||||
"99047 is a LegendCardPack (category 1) in the prod capture.");
|
|
||||||
sawSpecial = true;
|
|
||||||
break;
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -75,6 +89,28 @@ public class PackControllerFullCatalogTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirrors <c>PackRepository.GetActivePacks</c>'s filter (IsEnabled &&
|
||||||
|
/// CommenceDate <= now <= CompleteDate) against the raw seed file. is_enabled
|
||||||
|
/// defaults to true to match <c>PackSeed.IsEnabled</c>. Uses the same wire-date parser
|
||||||
|
/// as the importer so any date-string quirk is parsed identically on both sides.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The test output dir's <c>Data/seeds/packs.json</c> is the fixture overlay (3 packs). The
|
/// The test output dir's <c>Data/seeds/packs.json</c> is the fixture overlay (3 packs). The
|
||||||
/// upstream production seed lives in the Bootstrap project's source tree. Walk up from the
|
/// upstream production seed lives in the Bootstrap project's source tree. Walk up from the
|
||||||
|
|||||||
Reference in New Issue
Block a user