refactor(bootstrap): migrate /pack/info to seed file

This commit is contained in:
gamer147
2026-05-26 15:02:49 -04:00
parent 83298a2d47
commit a71bf6c62b
12 changed files with 3287 additions and 192 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +0,0 @@
{
"data_headers": { "sid": "fixture", "short_udid": 1, "viewer_id": 1, "servertime": 1779591187, "result_code": 1 },
"data": {
"pack_config_list": [
{
"parent_gacha_id": 10001, "base_pack_id": 10001, "override_draw_effect_pack_id": 10001,
"override_ui_effect_pack_id": 10001, "gacha_type": 1, "sleeve_id": 3000011, "special_sleeve_id": 0,
"commence_date": "2015-04-01 00:00:00", "complete_date": "2030-12-31 23:59:59",
"cardpack_banner_list": [], "gacha_detail": "A pack contains 8 cards, including at least one silver, gold, or legendary card.",
"child_gacha_info": [
{ "gacha_id": 100002, "type_detail": 2, "cost": 100, "count": 8, "override_increase_gacha_point": "1" },
{ "gacha_id": 200001, "type_detail": 3, "cost": 50, "count": 8, "override_increase_gacha_point": "1", "is_daily_single": true },
{ "gacha_id": 400002, "type_detail": 7, "cost": 100, "count": 8, "override_increase_gacha_point": "1" }
],
"open_count": 0, "open_count_limit": 0, "is_hide": 0, "pack_category": 0,
"gacha_point": { "pack_id": "10001", "gacha_point": 0, "increase_gacha_point": "1", "exchangeable_gacha_point": 400, "is_exchangeable_gacha_point": false },
"is_pre_release": false, "exists_purchase_reward": false, "is_new": false, "sales_period_info": [], "poster_type": 0
},
{
"parent_gacha_id": 92001, "base_pack_id": 90001, "override_draw_effect_pack_id": 90001,
"override_ui_effect_pack_id": 90001, "gacha_type": 1, "sleeve_id": 5090001, "special_sleeve_id": 0,
"commence_date": "2017-06-14 10:00:00", "complete_date": "2030-12-31 23:59:59",
"cardpack_banner_list": [], "gacha_detail": "A pack contains 8 cards, including at least one leader card!",
"child_gacha_info": [
{ "gacha_id": 920002, "type_detail": 5, "cost": 1, "count": 8, "item_id": "92001", "item_number": 0 }
],
"open_count": 0, "open_count_limit": 0, "is_hide": 1, "pack_category": 1, "gacha_point": null,
"is_pre_release": false, "exists_purchase_reward": false, "is_new": false, "sales_period_info": [], "poster_type": 0
},
{
"parent_gacha_id": 16015, "base_pack_id": 10015, "override_draw_effect_pack_id": 10015,
"override_ui_effect_pack_id": 10015, "gacha_type": 1, "sleeve_id": 5010015, "special_sleeve_id": 0,
"commence_date": "2017-07-01 03:00:00", "complete_date": "2030-12-31 23:59:59",
"cardpack_banner_list": [
{ "banner_name": "card_pack_711331010_dialog", "dialog_title": "Dia_BuyCard_005_Title" }
],
"gacha_detail": "A pack contains 8 cards, including at least one silver, gold, or legendary card.",
"child_gacha_info": [
{ "gacha_id": 160152, "type_detail": 2, "cost": 100, "count": 8, "override_increase_gacha_point": "1" },
{ "gacha_id": 460152, "type_detail": 7, "cost": 100, "count": 8, "override_increase_gacha_point": "1" }
],
"open_count": 0, "open_count_limit": 0, "is_hide": 0, "pack_category": 0,
"gacha_point": { "pack_id": "10015", "gacha_point": 0, "increase_gacha_point": "1", "exchangeable_gacha_point": 400, "is_exchangeable_gacha_point": false },
"is_pre_release": false, "exists_purchase_reward": false, "is_new": false, "sales_period_info": [], "poster_type": 0
}
]
}
}

View File

@@ -0,0 +1,154 @@
[
{
"parent_gacha_id": 10001,
"base_pack_id": 10001,
"gacha_type": 1,
"pack_category": 0,
"poster_type": 0,
"commence_date": "2015-04-01 00:00:00",
"complete_date": "2030-12-31 23:59:59",
"sleeve_id": 3000011,
"special_sleeve_id": 0,
"override_draw_effect_pack_id": 10001,
"override_ui_effect_pack_id": 10001,
"gacha_detail": "A pack contains 8 cards, including at least one silver, gold, or legendary card.",
"is_hide": false,
"is_new": false,
"is_pre_release": false,
"open_count_limit": 0,
"sales_period_time": null,
"gacha_point": {
"exchangeable_point": 400,
"increase_gacha_point": 1
},
"child_gachas": [
{
"gacha_id": 100002,
"type_detail": 2,
"cost": 100,
"card_count": 8,
"item_id": null,
"is_daily_single": false,
"override_increase_gacha_point": 1,
"purchase_limit_count": 0,
"free_gacha_campaign_id": null,
"campaign_name": null
},
{
"gacha_id": 200001,
"type_detail": 3,
"cost": 50,
"card_count": 8,
"item_id": null,
"is_daily_single": true,
"override_increase_gacha_point": 1,
"purchase_limit_count": 0,
"free_gacha_campaign_id": null,
"campaign_name": null
},
{
"gacha_id": 400002,
"type_detail": 7,
"cost": 100,
"card_count": 8,
"item_id": null,
"is_daily_single": false,
"override_increase_gacha_point": 1,
"purchase_limit_count": 0,
"free_gacha_campaign_id": null,
"campaign_name": null
}
],
"banners": []
},
{
"parent_gacha_id": 92001,
"base_pack_id": 90001,
"gacha_type": 1,
"pack_category": 1,
"poster_type": 0,
"commence_date": "2017-06-14 10:00:00",
"complete_date": "2030-12-31 23:59:59",
"sleeve_id": 5090001,
"special_sleeve_id": 0,
"override_draw_effect_pack_id": 90001,
"override_ui_effect_pack_id": 90001,
"gacha_detail": "A pack contains 8 cards, including at least one leader card!",
"is_hide": true,
"is_new": false,
"is_pre_release": false,
"open_count_limit": 0,
"sales_period_time": null,
"gacha_point": null,
"child_gachas": [
{
"gacha_id": 920002,
"type_detail": 5,
"cost": 1,
"card_count": 8,
"item_id": 92001,
"is_daily_single": false,
"override_increase_gacha_point": 0,
"purchase_limit_count": 0,
"free_gacha_campaign_id": null,
"campaign_name": null
}
],
"banners": []
},
{
"parent_gacha_id": 16015,
"base_pack_id": 10015,
"gacha_type": 1,
"pack_category": 0,
"poster_type": 0,
"commence_date": "2017-07-01 03:00:00",
"complete_date": "2030-12-31 23:59:59",
"sleeve_id": 5010015,
"special_sleeve_id": 0,
"override_draw_effect_pack_id": 10015,
"override_ui_effect_pack_id": 10015,
"gacha_detail": "A pack contains 8 cards, including at least one silver, gold, or legendary card.",
"is_hide": false,
"is_new": false,
"is_pre_release": false,
"open_count_limit": 0,
"sales_period_time": null,
"gacha_point": {
"exchangeable_point": 400,
"increase_gacha_point": 1
},
"child_gachas": [
{
"gacha_id": 160152,
"type_detail": 2,
"cost": 100,
"card_count": 8,
"item_id": null,
"is_daily_single": false,
"override_increase_gacha_point": 1,
"purchase_limit_count": 0,
"free_gacha_campaign_id": null,
"campaign_name": null
},
{
"gacha_id": 460152,
"type_detail": 7,
"cost": 100,
"card_count": 8,
"item_id": null,
"is_daily_single": false,
"override_increase_gacha_point": 1,
"purchase_limit_count": 0,
"free_gacha_campaign_id": null,
"campaign_name": null
}
],
"banners": [
{
"banner_name": "card_pack_711331010_dialog",
"dialog_title": "Dia_BuyCard_005_Title"
}
]
}
]

View File

@@ -1,7 +1,6 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using static SVSim.Bootstrap.Importers.ImporterBase;
@@ -10,8 +9,8 @@ namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Imports prod-captured globals from <c>{capturesDir}/{endpoint}-*.json</c> snapshots into the
/// DB via idempotent upserts. Source endpoints: <c>load-index</c>, <c>pack-info</c>. Per-endpoint
/// seed-file importers (DefaultDeckImporter, MyPageGlobalsImporter, etc.) cover the rest.
/// DB via idempotent upserts. Source endpoints: <c>load-index</c>. Per-endpoint seed-file
/// importers (DefaultDeckImporter, PackImporter, MyPageGlobalsImporter, etc.) cover the rest.
///
/// Topological order: GameConfiguration extensions → standalone tables → card-referencing tables →
/// rotation CardSet flag update. Card-referencing importers warn on orphans (missing card rows)
@@ -28,7 +27,6 @@ public class GlobalsImporter
Console.WriteLine($"[GlobalsImporter] Loading captures from {capturesDir}...");
JsonElement? loadIndex = LoadCapture(capturesDir, "load-index");
JsonElement? packInfo = LoadCapture(capturesDir, "pack-info");
int total = 0;
@@ -50,11 +48,6 @@ public class GlobalsImporter
total += await UpdateRotationCardSetFlags(context, loadIndex.Value);
}
if (packInfo.HasValue)
{
total += await ImportPacks(context, packInfo.Value);
}
await context.SaveChangesAsync();
Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed.");
return total;
@@ -506,122 +499,6 @@ public class GlobalsImporter
return updated;
}
// ---------- Pack catalog ----------
/// <summary>
/// Imports /pack/info's pack_config_list into PackConfigEntry rows. The capture's <c>data</c>
/// element wraps an object with a <c>pack_config_list</c> array; iterate that. Owned children
/// (child_gacha_info, cardpack_banner_list) are replaced wholesale on re-runs — diffing
/// owned collections by composite keys is more code than it's worth for catalog updates.
/// </summary>
private async Task<int> ImportPacks(SVSimDbContext context, JsonElement packData)
{
if (!packData.TryGetProperty("pack_config_list", out var list) || list.ValueKind != JsonValueKind.Array)
{
Console.Error.WriteLine("[GlobalsImporter] pack-info capture missing 'pack_config_list'");
return 0;
}
var existing = await context.Packs
.Include(p => p.ChildGachas)
.Include(p => p.Banners)
.ToDictionaryAsync(p => p.Id);
int created = 0, updated = 0;
foreach (var el in list.EnumerateArray())
{
int parentId = GetInt(el, "parent_gacha_id");
if (parentId == 0) continue;
var pack = existing.TryGetValue(parentId, out var ex) ? ex : new PackConfigEntry { Id = parentId };
pack.BasePackId = GetInt(el, "base_pack_id");
pack.GachaType = GetInt(el, "gacha_type");
pack.PackCategory = (PackCategory)GetInt(el, "pack_category");
pack.PosterType = GetInt(el, "poster_type");
pack.CommenceDate = ParseWireDateTime(GetString(el, "commence_date"));
pack.CompleteDate = ParseWireDateTime(GetString(el, "complete_date"));
pack.SleeveId = GetInt(el, "sleeve_id");
pack.SpecialSleeveId = GetInt(el, "special_sleeve_id");
pack.OverrideDrawEffectPackId = GetInt(el, "override_draw_effect_pack_id");
pack.OverrideUiEffectPackId = GetInt(el, "override_ui_effect_pack_id");
pack.GachaDetail = GetString(el, "gacha_detail");
pack.IsHide = GetBool(el, "is_hide");
pack.IsNew = GetBool(el, "is_new");
pack.IsPreRelease = GetBool(el, "is_pre_release");
pack.OpenCountLimit = GetInt(el, "open_count_limit");
// sales_period_info is `{}` when set (object with sales_period_time) and `[]` when unset
if (el.TryGetProperty("sales_period_info", out var spi) && spi.ValueKind == JsonValueKind.Object)
{
var raw = GetString(spi, "sales_period_time");
pack.SalesPeriodTime = string.IsNullOrEmpty(raw) ? null : ParseWireDateTime(raw);
}
else
{
pack.SalesPeriodTime = null;
}
// gacha_point is null when the pack doesn't participate
if (el.TryGetProperty("gacha_point", out var gp) && gp.ValueKind == JsonValueKind.Object)
{
pack.GachaPointConfig = new PackGachaPointConfig
{
ExchangeablePoint = GetInt(gp, "exchangeable_gacha_point"),
IncreaseGachaPoint = GetInt(gp, "increase_gacha_point"),
};
}
else
{
pack.GachaPointConfig = null;
}
// Replace owned collections wholesale.
pack.ChildGachas.Clear();
if (el.TryGetProperty("child_gacha_info", out var cg) && cg.ValueKind == JsonValueKind.Array)
{
foreach (var c in cg.EnumerateArray())
{
pack.ChildGachas.Add(new PackChildGachaEntry
{
GachaId = GetInt(c, "gacha_id"),
TypeDetail = GetInt(c, "type_detail"),
Cost = GetInt(c, "cost"),
CardCount = GetInt(c, "count", 8),
ItemId = c.TryGetProperty("item_id", out var ii) && ii.ValueKind != JsonValueKind.Null
? GetLong(c, "item_id") : (long?)null,
IsDailySingle = GetBool(c, "is_daily_single"),
OverrideIncreaseGachaPoint = GetInt(c, "override_increase_gacha_point"),
PurchaseLimitCount = GetInt(c, "purchase_limit_count"),
FreeGachaCampaignId = c.TryGetProperty("free_gacha_campaign_id", out var fc) && fc.ValueKind != JsonValueKind.Null
? GetInt(c, "free_gacha_campaign_id") : (int?)null,
CampaignName = c.TryGetProperty("campaign_name", out var cn) && cn.ValueKind == JsonValueKind.String
? cn.GetString() : null,
});
}
}
pack.Banners.Clear();
if (el.TryGetProperty("cardpack_banner_list", out var bl) && bl.ValueKind == JsonValueKind.Array)
{
foreach (var b in bl.EnumerateArray())
{
pack.Banners.Add(new PackBannerEntry
{
BannerName = GetString(b, "banner_name"),
DialogTitle = GetString(b, "dialog_title"),
});
}
}
if (ex is null) { context.Packs.Add(pack); created++; }
else updated++;
}
Console.WriteLine($"[GlobalsImporter] Packs: +{created}/~{updated}");
return created + updated;
}
// ---------- Helpers ----------
private static void WarnOrphans(string label, int count)

View File

@@ -0,0 +1,107 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using static SVSim.Bootstrap.Importers.ImporterBase;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of /pack/info catalog from <c>seeds/packs.json</c>. Owned collections
/// (ChildGachas, Banners) are replaced wholesale per pack (clear-then-rehydrate) -- diffing owned
/// collections by composite keys is more code than it's worth for catalog updates, and this
/// matches the wholesale-replace semantics of the previous in-line ImportPacks implementation.
/// Rows missing from the seed are LEFT INTACT.
/// </summary>
public class PackImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
var seed = SeedLoader.LoadList<PackSeed>(Path.Combine(seedDir, "packs.json"));
if (seed.Count == 0)
{
Console.WriteLine("[PackImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.Packs
.Include(p => p.ChildGachas)
.Include(p => p.Banners)
.ToDictionaryAsync(p => p.Id);
int created = 0, updated = 0;
foreach (var s in seed)
{
if (s.ParentGachaId == 0) continue;
var pack = existing.TryGetValue(s.ParentGachaId, out var ex)
? ex : new PackConfigEntry { Id = s.ParentGachaId };
pack.BasePackId = s.BasePackId;
pack.GachaType = s.GachaType;
pack.PackCategory = (PackCategory)s.PackCategory;
pack.PosterType = s.PosterType;
pack.CommenceDate = ParseWireDateTime(s.CommenceDate);
pack.CompleteDate = ParseWireDateTime(s.CompleteDate);
pack.SleeveId = s.SleeveId;
pack.SpecialSleeveId = s.SpecialSleeveId;
pack.OverrideDrawEffectPackId = s.OverrideDrawEffectPackId;
pack.OverrideUiEffectPackId = s.OverrideUiEffectPackId;
pack.GachaDetail = s.GachaDetail;
pack.IsHide = s.IsHide;
pack.IsNew = s.IsNew;
pack.IsPreRelease = s.IsPreRelease;
pack.OpenCountLimit = s.OpenCountLimit;
pack.SalesPeriodTime = string.IsNullOrEmpty(s.SalesPeriodTime)
? null
: ParseWireDateTime(s.SalesPeriodTime);
pack.GachaPointConfig = s.GachaPoint is null ? null : new PackGachaPointConfig
{
ExchangeablePoint = s.GachaPoint.ExchangeablePoint,
IncreaseGachaPoint = s.GachaPoint.IncreaseGachaPoint,
};
// Owned collections -- clear and rehydrate (matches the previous wholesale-replace semantics).
pack.ChildGachas.Clear();
foreach (var c in s.ChildGachas)
{
pack.ChildGachas.Add(new PackChildGachaEntry
{
GachaId = c.GachaId,
TypeDetail = c.TypeDetail,
Cost = c.Cost,
CardCount = c.CardCount,
ItemId = c.ItemId,
IsDailySingle = c.IsDailySingle,
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint,
PurchaseLimitCount = c.PurchaseLimitCount,
FreeGachaCampaignId = c.FreeGachaCampaignId,
CampaignName = c.CampaignName,
});
}
pack.Banners.Clear();
foreach (var b in s.Banners)
{
pack.Banners.Add(new PackBannerEntry
{
BannerName = b.BannerName,
DialogTitle = b.DialogTitle,
});
}
if (ex is null)
{
context.Packs.Add(pack);
existing[s.ParentGachaId] = pack;
created++;
}
else updated++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[PackImporter] +{created}/~{updated}");
return created + updated;
}
}

View File

@@ -0,0 +1,53 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class PackSeed
{
[JsonPropertyName("parent_gacha_id")] public int ParentGachaId { get; set; }
[JsonPropertyName("base_pack_id")] public int BasePackId { get; set; }
[JsonPropertyName("gacha_type")] public int GachaType { get; set; }
[JsonPropertyName("pack_category")] public int PackCategory { get; set; }
[JsonPropertyName("poster_type")] public int PosterType { get; set; }
[JsonPropertyName("commence_date")] public string CommenceDate { get; set; } = "";
[JsonPropertyName("complete_date")] public string CompleteDate { get; set; } = "";
[JsonPropertyName("sleeve_id")] public int SleeveId { get; set; }
[JsonPropertyName("special_sleeve_id")] public int SpecialSleeveId { get; set; }
[JsonPropertyName("override_draw_effect_pack_id")] public int OverrideDrawEffectPackId { get; set; }
[JsonPropertyName("override_ui_effect_pack_id")] public int OverrideUiEffectPackId { get; set; }
[JsonPropertyName("gacha_detail")] public string GachaDetail { get; set; } = "";
[JsonPropertyName("is_hide")] public bool IsHide { get; set; }
[JsonPropertyName("is_new")] public bool IsNew { get; set; }
[JsonPropertyName("is_pre_release")] public bool IsPreRelease { get; set; }
[JsonPropertyName("open_count_limit")] public int OpenCountLimit { get; set; }
[JsonPropertyName("sales_period_time")] public string? SalesPeriodTime { get; set; }
[JsonPropertyName("gacha_point")] public PackGachaPointSeed? GachaPoint { get; set; }
[JsonPropertyName("child_gachas")] public List<PackChildGachaSeed> ChildGachas { get; set; } = new();
[JsonPropertyName("banners")] public List<PackBannerSeed> Banners { get; set; } = new();
}
public sealed class PackGachaPointSeed
{
[JsonPropertyName("exchangeable_point")] public int ExchangeablePoint { get; set; }
[JsonPropertyName("increase_gacha_point")] public int IncreaseGachaPoint { get; set; }
}
public sealed class PackChildGachaSeed
{
[JsonPropertyName("gacha_id")] public int GachaId { get; set; }
[JsonPropertyName("type_detail")] public int TypeDetail { get; set; }
[JsonPropertyName("cost")] public int Cost { get; set; }
[JsonPropertyName("card_count")] public int CardCount { get; set; } = 8;
[JsonPropertyName("item_id")] public long? ItemId { get; set; }
[JsonPropertyName("is_daily_single")] public bool IsDailySingle { get; set; }
[JsonPropertyName("override_increase_gacha_point")] public int OverrideIncreaseGachaPoint { get; set; }
[JsonPropertyName("purchase_limit_count")] public int PurchaseLimitCount { get; set; }
[JsonPropertyName("free_gacha_campaign_id")] public int? FreeGachaCampaignId { get; set; }
[JsonPropertyName("campaign_name")] public string? CampaignName { get; set; }
}
public sealed class PackBannerSeed
{
[JsonPropertyName("banner_name")] public string BannerName { get; set; } = "";
[JsonPropertyName("dialog_title")] public string DialogTitle { get; set; } = "";
}

View File

@@ -91,6 +91,7 @@ public static class Program
await mypage.ImportSpecialDeckFormatsAsync(context, opts.SeedDir);
await new DefaultDeckImporter().ImportAsync(context, opts.SeedDir);
await new PackImporter().ImportAsync(context, opts.SeedDir);
// BuildDeck pipeline: series CSV → catalog JSON → package CSV. Catalog must run after
// series CSV (FK on products → series) and before package CSV (so the catalog-side

View File

@@ -1,37 +1,43 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Bootstrap.Importers;
using SVSim.Database;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
/// <summary>
/// Drives the importer + controller against the real prod capture (35 packs). Guards against
/// regressions in either layer caused by future capture refreshes.
/// Drives the importer + controller against the full production pack seed (35 packs). Guards
/// against regressions in either layer caused by future seed refreshes.
/// </summary>
public class PackControllerProdCaptureTests
{
[Test]
public async Task Info_returns_full_35_pack_catalog_from_prod_capture()
{
// The default captures dir has both pack-info-fixture.json (3 packs) and
// pack-info-2026-05-23.json (35 packs). LoadCapture sorts by name descending and
// "pack-info-fixture.json" > "pack-info-2026-05-23.json" lexicographically, so the
// fixture would win. Copy captures to a temp dir, drop the fixture, then seed from there.
var sourceDir = Path.Combine(AppContext.BaseDirectory, "Data", "prod-captures");
var tempDir = Path.Combine(Path.GetTempPath(), "svsim-pack-prod-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
// 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,
// 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");
var tempSeedDir = Path.Combine(Path.GetTempPath(), "svsim-pack-prod-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempSeedDir);
try
{
foreach (var src in Directory.EnumerateFiles(sourceDir))
{
if (Path.GetFileName(src).Equals("pack-info-fixture.json", StringComparison.OrdinalIgnoreCase))
continue;
File.Copy(src, Path.Combine(tempDir, Path.GetFileName(src)));
}
File.Copy(prodSeed, Path.Combine(tempSeedDir, "packs.json"));
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync(tempDir); // imports the 35-pack pack-info-2026-05-23.json
// Run the default seed pipeline first so GlobalsImporter populates surrounding tables,
// then re-run PackImporter against the prod seed to overwrite the fixture-loaded packs.
await factory.SeedGlobalsAsync();
using (var scope = factory.Services.CreateScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new PackImporter().ImportAsync(ctx, tempSeedDir);
}
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
@@ -46,7 +52,7 @@ public class PackControllerProdCaptureTests
using var doc = JsonDocument.Parse(body);
var list = doc.RootElement.GetProperty("pack_config_list");
Assert.That(list.GetArrayLength(), Is.EqualTo(35),
"Full prod capture should yield 35 active packs as of 2026-05-23.");
"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;
@@ -65,7 +71,25 @@ public class PackControllerProdCaptureTests
}
finally
{
try { Directory.Delete(tempDir, recursive: true); } catch { /* best-effort cleanup */ }
try { Directory.Delete(tempSeedDir, recursive: true); } catch { /* best-effort cleanup */ }
}
}
/// <summary>
/// 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
/// test binary dir to the repo root and locate it there.
/// </summary>
private static string LocateProdSeed(string fileName)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
var candidate = Path.Combine(dir.FullName, "SVSim.Bootstrap", "Data", "seeds", fileName);
if (File.Exists(candidate)) return candidate;
dir = dir.Parent;
}
throw new FileNotFoundException(
$"Could not locate SVSim.Bootstrap/Data/seeds/{fileName} above {AppContext.BaseDirectory}.");
}
}

View File

@@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Bootstrap.Importers;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Importers;
public class PackImporterTests
{
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
[Test]
public async Task Imports_packs_from_seed_file()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new PackImporter().ImportAsync(db, SeedDir);
var packs = await db.Packs.OrderBy(p => p.Id).ToListAsync();
Assert.That(packs.Count, Is.GreaterThan(0), "seed file must contain packs");
Assert.That(packs.All(p => p.Id > 0), Is.True);
}
[Test]
public async Task Is_idempotent_on_rerun()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new PackImporter().ImportAsync(db, SeedDir);
int before = await db.Packs.CountAsync();
await new PackImporter().ImportAsync(db, SeedDir);
int after = await db.Packs.CountAsync();
Assert.That(after, Is.EqualTo(before));
}
[Test]
public async Task Leaves_existing_rows_untouched_when_missing_from_seed()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const int legacyId = 99999;
db.Packs.Add(new PackConfigEntry
{
Id = legacyId,
BasePackId = legacyId,
GachaType = 1,
PackCategory = PackCategory.None,
GachaDetail = "legacy",
});
await db.SaveChangesAsync();
await new PackImporter().ImportAsync(db, SeedDir);
var legacy = await db.Packs.FindAsync(legacyId);
Assert.That(legacy, Is.Not.Null, "seed-missing row must be left intact");
Assert.That(legacy!.GachaDetail, Is.EqualTo("legacy"));
}
[Test]
public async Task Skips_rows_with_zero_parent_gacha_id()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
string tmp = Path.Combine(Path.GetTempPath(), $"seed-{Guid.NewGuid()}");
Directory.CreateDirectory(tmp);
try
{
File.WriteAllText(Path.Combine(tmp, "packs.json"),
"[{\"parent_gacha_id\":0,\"base_pack_id\":1,\"gacha_type\":1,\"pack_category\":0,\"child_gachas\":[],\"banners\":[]}]");
await new PackImporter().ImportAsync(db, tmp);
int count = await db.Packs.CountAsync();
Assert.That(count, Is.EqualTo(0), "rows with parent_gacha_id=0 must not be inserted");
}
finally { Directory.Delete(tmp, true); }
}
[Test]
public async Task Owned_collections_are_replaced_wholesale_on_rerun()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new PackImporter().ImportAsync(db, SeedDir);
var pack1 = await db.Packs.AsNoTracking().FirstAsync(p => p.Id == 10001);
int childCountBefore = pack1.ChildGachas.Count;
int bannerCountBefore = pack1.Banners.Count;
// Re-run: owned collections must NOT stack. Same fixture content -> same counts.
await new PackImporter().ImportAsync(db, SeedDir);
var pack2 = await db.Packs.AsNoTracking().FirstAsync(p => p.Id == 10001);
Assert.That(pack2.ChildGachas.Count, Is.EqualTo(childCountBefore),
"child_gachas must be replaced wholesale on rerun, not stacked");
Assert.That(pack2.Banners.Count, Is.EqualTo(bannerCountBefore),
"banners must be replaced wholesale on rerun, not stacked");
}
}

View File

@@ -207,6 +207,7 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
await mypage.ImportSpecialDeckFormatsAsync(ctx, seedDir);
await new DefaultDeckImporter().ImportAsync(ctx, seedDir);
await new PackImporter().ImportAsync(ctx, seedDir);
}
/// <summary>Convenience: bake the X-Test-Viewer-Id header into a fresh client.</summary>

View File

@@ -47,6 +47,13 @@
<Content Include="..\SVSim.Bootstrap\Data\seeds\**\*.json" Link="Data\seeds\%(RecursiveDir)%(Filename)%(Extension)">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- Test-only fixture seeds overlay the production seeds. Placed AFTER the production glob
so MSBuild's "last Link wins" rule routes the fixture file into the same test output
path as the production seed of the same name. Use for per-table fixtures (e.g. packs)
where the production seed is too large to drive focused integration tests. -->
<Content Include="..\SVSim.Bootstrap\Data\test-fixtures\seeds\*.json" Link="Data\seeds\%(Filename)%(Extension)">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- Test-only fixtures live outside prod-captures so the production bootstrap glob doesn't
pick them up (a fixture-named file would win the importer's reverse-alphabetical sort
against a dated capture). Linked into the same test output dir so SeedGlobalsAsync sees