test(unit-tests): silence captured stdout in Testing env

The unit-test suite was spending most of its wall clock writing logs.
NUnit captures stdout per test and embeds it in the trx; with HttpLogging
emitting full request/response per controller call, EF Core SQL at
Information level, and ReferenceDataImporter banners running ~500x
(once per factory construction), the trx grew to 3.2 GB and the NUnit
result-XML serializer OOMed in StringBuilder.ToString() — which the
runner reported as one mysteriously failed test, masking a real
date-dependent failure underneath.

Three sources silenced under environment "Testing":
- appsettings.Testing.json drops Default + Microsoft.AspNetCore +
  HttpLoggingMiddleware + EntityFrameworkCore to Warning.
- Program.cs skips app.UseHttpLogging() entirely (avoids the
  middleware overhead, not just the log emission).
- ReferenceDataImporter takes optional TextWriters; the test factory
  passes TextWriter.Null. Per-importer helpers become instance methods
  so they can use the injected writer.

Result on a fresh run with ParallelScope.Fixtures already in place:
- Test duration: 1m46s -> 59s
- Wall clock: 2m23s -> 1m00s
- trx size: 3.2 GB -> 1.7 MB

The previously-masked date-dependent failure (PackControllerFullCatalog
.Info_returns_full_35_pack_catalog_from_production_seed asserting 35
active packs as of 2026-05-23 against a live clock) is now visible and
can be addressed separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-01 00:13:42 -04:00
parent d093d872ae
commit 7914bab84e
4 changed files with 58 additions and 25 deletions

View File

@@ -15,14 +15,30 @@ namespace SVSim.Bootstrap.Importers;
/// </summary> /// </summary>
public class ReferenceDataImporter public class ReferenceDataImporter
{ {
private readonly TextWriter _out;
private readonly TextWriter _err;
public ReferenceDataImporter() : this(Console.Out, Console.Error) { }
/// <summary>
/// Pass <see cref="TextWriter.Null"/> for both to silence progress banners (tests
/// instantiate this importer ~500 times per run; the captured stdout otherwise OOMs
/// the NUnit trx serializer).
/// </summary>
public ReferenceDataImporter(TextWriter output, TextWriter error)
{
_out = output;
_err = error;
}
public async Task ImportAllAsync(SVSimDbContext context, string dataDir) public async Task ImportAllAsync(SVSimDbContext context, string dataDir)
{ {
if (!Directory.Exists(dataDir)) if (!Directory.Exists(dataDir))
{ {
Console.Error.WriteLine($"[ReferenceDataImporter] Data dir missing: {dataDir}"); _err.WriteLine($"[ReferenceDataImporter] Data dir missing: {dataDir}");
return; return;
} }
Console.WriteLine($"[ReferenceDataImporter] Reading CSVs from {dataDir}..."); _out.WriteLine($"[ReferenceDataImporter] Reading CSVs from {dataDir}...");
await ImportClasses(context, dataDir); await ImportClasses(context, dataDir);
await ImportLeaderSkins(context, dataDir); await ImportLeaderSkins(context, dataDir);
@@ -34,10 +50,10 @@ public class ReferenceDataImporter
await ImportRankInfo(context, dataDir); await ImportRankInfo(context, dataDir);
await ImportClassExp(context, dataDir); await ImportClassExp(context, dataDir);
Console.WriteLine("[ReferenceDataImporter] Done."); _out.WriteLine("[ReferenceDataImporter] Done.");
} }
private static async Task ImportClasses(SVSimDbContext ctx, string dir) private async Task ImportClasses(SVSimDbContext ctx, string dir)
{ {
var rows = ReadCsv<ClassEntry, ClassEntryMap>(dir, "classes.csv"); var rows = ReadCsv<ClassEntry, ClassEntryMap>(dir, "classes.csv");
var existing = await ctx.Classes.ToDictionaryAsync(c => c.Id); var existing = await ctx.Classes.ToDictionaryAsync(c => c.Id);
@@ -51,10 +67,10 @@ public class ReferenceDataImporter
else { ctx.Classes.Add(r); created++; } else { ctx.Classes.Add(r); created++; }
} }
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] Classes: +{created} / ~{updated}"); _out.WriteLine($"[ReferenceDataImporter] Classes: +{created} / ~{updated}");
} }
private static async Task ImportLeaderSkins(SVSimDbContext ctx, string dir) private async Task ImportLeaderSkins(SVSimDbContext ctx, string dir)
{ {
var rows = ReadCsv<LeaderSkinEntry, LeaderSkinEntryMap>(dir, "leaderskins.csv"); var rows = ReadCsv<LeaderSkinEntry, LeaderSkinEntryMap>(dir, "leaderskins.csv");
// CSV writes class_chara_id=0 for neutral/unassigned; the FK column is nullable. // CSV writes class_chara_id=0 for neutral/unassigned; the FK column is nullable.
@@ -74,10 +90,10 @@ public class ReferenceDataImporter
else { ctx.LeaderSkins.Add(r); created++; } else { ctx.LeaderSkins.Add(r); created++; }
} }
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] LeaderSkins: +{created} / ~{updated}"); _out.WriteLine($"[ReferenceDataImporter] LeaderSkins: +{created} / ~{updated}");
} }
private static async Task ImportSleeves(SVSimDbContext ctx, string dir) private async Task ImportSleeves(SVSimDbContext ctx, string dir)
{ {
var rows = ReadCsv<SleeveEntry, SleeveEntryMap>(dir, "sleeves.csv"); var rows = ReadCsv<SleeveEntry, SleeveEntryMap>(dir, "sleeves.csv");
var existing = (await ctx.Sleeves.ToListAsync()).ToHashSet(); var existing = (await ctx.Sleeves.ToListAsync()).ToHashSet();
@@ -88,10 +104,10 @@ public class ReferenceDataImporter
ctx.Sleeves.Add(r); created++; ctx.Sleeves.Add(r); created++;
} }
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] Sleeves: +{created}"); _out.WriteLine($"[ReferenceDataImporter] Sleeves: +{created}");
} }
private static async Task ImportEmblems(SVSimDbContext ctx, string dir) private async Task ImportEmblems(SVSimDbContext ctx, string dir)
{ {
var rows = ReadCsv<EmblemEntry, EmblemEntryMap>(dir, "emblems.csv"); var rows = ReadCsv<EmblemEntry, EmblemEntryMap>(dir, "emblems.csv");
var existing = (await ctx.Emblems.Select(e => e.Id).ToListAsync()).ToHashSet(); var existing = (await ctx.Emblems.Select(e => e.Id).ToListAsync()).ToHashSet();
@@ -102,10 +118,10 @@ public class ReferenceDataImporter
ctx.Emblems.Add(r); created++; ctx.Emblems.Add(r); created++;
} }
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] Emblems: +{created}"); _out.WriteLine($"[ReferenceDataImporter] Emblems: +{created}");
} }
private static async Task ImportDegrees(SVSimDbContext ctx, string dir) private async Task ImportDegrees(SVSimDbContext ctx, string dir)
{ {
var rows = ReadCsv<DegreeEntry, DegreeEntryMap>(dir, "degrees.csv"); var rows = ReadCsv<DegreeEntry, DegreeEntryMap>(dir, "degrees.csv");
var existing = (await ctx.Degrees.Select(e => e.Id).ToListAsync()).ToHashSet(); var existing = (await ctx.Degrees.Select(e => e.Id).ToListAsync()).ToHashSet();
@@ -116,10 +132,10 @@ public class ReferenceDataImporter
ctx.Degrees.Add(r); created++; ctx.Degrees.Add(r); created++;
} }
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] Degrees: +{created}"); _out.WriteLine($"[ReferenceDataImporter] Degrees: +{created}");
} }
private static async Task ImportBattlefields(SVSimDbContext ctx, string dir) private async Task ImportBattlefields(SVSimDbContext ctx, string dir)
{ {
var rows = ReadCsv<BattlefieldEntry, BattlefieldEntryMap>(dir, "battlefields.csv"); var rows = ReadCsv<BattlefieldEntry, BattlefieldEntryMap>(dir, "battlefields.csv");
var existing = await ctx.Battlefields.ToDictionaryAsync(b => b.Id); var existing = await ctx.Battlefields.ToDictionaryAsync(b => b.Id);
@@ -133,10 +149,10 @@ public class ReferenceDataImporter
else { ctx.Battlefields.Add(r); created++; } else { ctx.Battlefields.Add(r); created++; }
} }
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] Battlefields: +{created} / ~{updated}"); _out.WriteLine($"[ReferenceDataImporter] Battlefields: +{created} / ~{updated}");
} }
private static async Task ImportMyPageBackgrounds(SVSimDbContext ctx, string dir) private async Task ImportMyPageBackgrounds(SVSimDbContext ctx, string dir)
{ {
var rows = ReadCsv<MyPageBackgroundEntry, MyPageBackgroundEntryMap>(dir, "mypagebackgrounds.csv"); var rows = ReadCsv<MyPageBackgroundEntry, MyPageBackgroundEntryMap>(dir, "mypagebackgrounds.csv");
var existing = (await ctx.MyPageBackgrounds.Select(e => e.Id).ToListAsync()).ToHashSet(); var existing = (await ctx.MyPageBackgrounds.Select(e => e.Id).ToListAsync()).ToHashSet();
@@ -147,10 +163,10 @@ public class ReferenceDataImporter
ctx.MyPageBackgrounds.Add(r); created++; ctx.MyPageBackgrounds.Add(r); created++;
} }
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] MyPageBackgrounds: +{created}"); _out.WriteLine($"[ReferenceDataImporter] MyPageBackgrounds: +{created}");
} }
private static async Task ImportRankInfo(SVSimDbContext ctx, string dir) private async Task ImportRankInfo(SVSimDbContext ctx, string dir)
{ {
var rows = ReadCsv<RankInfoEntry, RankInfoEntryMap>(dir, "ranks.csv"); var rows = ReadCsv<RankInfoEntry, RankInfoEntryMap>(dir, "ranks.csv");
var existing = await ctx.RankInfo.ToDictionaryAsync(r => r.Id); var existing = await ctx.RankInfo.ToDictionaryAsync(r => r.Id);
@@ -164,7 +180,7 @@ public class ReferenceDataImporter
else { ctx.RankInfo.Add(r); created++; } else { ctx.RankInfo.Add(r); created++; }
} }
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] RankInfo: +{created} / ~{updated}"); _out.WriteLine($"[ReferenceDataImporter] RankInfo: +{created} / ~{updated}");
} }
private static bool ApplyRankUpdates(RankInfoEntry e, RankInfoEntry r) private static bool ApplyRankUpdates(RankInfoEntry e, RankInfoEntry r)
@@ -189,7 +205,7 @@ public class ReferenceDataImporter
return changed; return changed;
} }
private static async Task ImportClassExp(SVSimDbContext ctx, string dir) private async Task ImportClassExp(SVSimDbContext ctx, string dir)
{ {
var rows = ReadCsv<ClassExpEntry, ClassExpEntryMap>(dir, "classexp.csv"); var rows = ReadCsv<ClassExpEntry, ClassExpEntryMap>(dir, "classexp.csv");
var existing = await ctx.ClassExpCurve.ToDictionaryAsync(c => c.Id); var existing = await ctx.ClassExpCurve.ToDictionaryAsync(c => c.Id);
@@ -203,15 +219,15 @@ public class ReferenceDataImporter
else { ctx.ClassExpCurve.Add(r); created++; } else { ctx.ClassExpCurve.Add(r); created++; }
} }
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] ClassExp: +{created} / ~{updated}"); _out.WriteLine($"[ReferenceDataImporter] ClassExp: +{created} / ~{updated}");
} }
private static List<T> ReadCsv<T, TMap>(string dir, string fileName) where TMap : ClassMap<T>, new() private List<T> ReadCsv<T, TMap>(string dir, string fileName) where TMap : ClassMap<T>, new()
{ {
string path = Path.Combine(dir, fileName); string path = Path.Combine(dir, fileName);
if (!File.Exists(path)) if (!File.Exists(path))
{ {
Console.Error.WriteLine($"[ReferenceDataImporter] Missing CSV: {path}"); _err.WriteLine($"[ReferenceDataImporter] Missing CSV: {path}");
return new List<T>(); return new List<T>();
} }
using var reader = new StreamReader(path); using var reader = new StreamReader(path);

View File

@@ -151,7 +151,13 @@ public class Program
} }
} }
app.UseHttpLogging(); // HttpLogging captures full request/response per call. In Testing it pipes ~3 GB of
// stdout into NUnit's per-test result capture across the suite, which OOMs the trx
// serializer. Production keeps it on.
if (!app.Environment.IsEnvironment("Testing"))
{
app.UseHttpLogging();
}
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())

View File

@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
}

View File

@@ -78,7 +78,8 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
// production uses so tests exercise the same code path. CardCosmeticRewards skipped — // 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. // FK to Cards would reject every row against the minimal 3-card test seed below.
var dataDir = Path.Combine(AppContext.BaseDirectory, "Data"); var dataDir = Path.Combine(AppContext.BaseDirectory, "Data");
new ReferenceDataImporter().ImportAllAsync(db, dataDir).GetAwaiter().GetResult(); new ReferenceDataImporter(TextWriter.Null, TextWriter.Null)
.ImportAllAsync(db, dataDir).GetAwaiter().GetResult();
// Seed a minimal card set so card-pool tests can resolve a non-empty pool without // 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 // requiring the full CardImporter tool or a cards.json file. The set is marked