Pack opening
This commit is contained in:
133
SVSim.UnitTests/Controllers/PackControllerInfoTests.cs
Normal file
133
SVSim.UnitTests/Controllers/PackControllerInfoTests.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
public class PackControllerInfoTests
|
||||
{
|
||||
private const string EmptyEnvelope = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
|
||||
|
||||
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
|
||||
|
||||
private static async Task SeedActivePack(SVSimTestFactory f, int parentId, int baseId, PackCategory cat)
|
||||
{
|
||||
using var scope = f.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = parentId, BasePackId = baseId, PackCategory = cat,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1, GachaDetail = "test",
|
||||
ChildGachas = { new PackChildGachaEntry { GachaId = parentId * 10 + 7, TypeDetail = 7, Cost = 100, CardCount = 8 } },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Info_returns_active_packs_only()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedActivePack(factory, 10001, 10001, PackCategory.None);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var list = doc.RootElement.GetProperty("pack_config_list");
|
||||
Assert.That(list.GetArrayLength(), Is.EqualTo(1));
|
||||
Assert.That(list[0].GetProperty("parent_gacha_id").GetInt32(), Is.EqualTo(10001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Info_overlays_viewer_open_count()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedActivePack(factory, 10001, 10001, PackCategory.None);
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.PackOpenCounts).FirstAsync(x => x.Id == viewerId);
|
||||
v.PackOpenCounts.Add(new ViewerPackOpenCount { PackId = 10001, OpenCount = 7 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var p = doc.RootElement.GetProperty("pack_config_list")[0];
|
||||
Assert.That(p.GetProperty("open_count").GetInt32(), Is.EqualTo(7));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Info_emits_child_gacha_info_with_correct_wire_keys()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedActivePack(factory, 10001, 10001, PackCategory.None);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var children = doc.RootElement.GetProperty("pack_config_list")[0].GetProperty("child_gacha_info");
|
||||
Assert.That(children.GetArrayLength(), Is.EqualTo(1));
|
||||
Assert.That(children[0].GetProperty("type_detail").GetInt32(), Is.EqualTo(7));
|
||||
Assert.That(children[0].GetProperty("cost").GetInt32(), Is.EqualTo(100));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Info_emits_gacha_point_key_as_null_when_pack_has_no_gacha_point_config()
|
||||
{
|
||||
// PackInfoTask.cs:126 does `if (jsonData2["gacha_point"] != null)`. LitJson's JsonData
|
||||
// indexer throws KeyNotFoundException on missing keys — the null check protects against
|
||||
// null *value*, not missing *key*. With Program.cs's global WhenWritingNull, a null
|
||||
// PackGachaPointDto would be omitted entirely and crash the client. Override per
|
||||
// [[project_wire_null_policy]].
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
// Seed a pack WITHOUT GachaPointConfig — matches packs 80047, 92001, 99047 in prod
|
||||
// (legendary specials whose `gacha_point` is null).
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = 92001, BasePackId = 90001, PackCategory = PackCategory.LegendCardPack,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1, GachaDetail = "legendary special", SleeveId = 5090001,
|
||||
GachaPointConfig = null,
|
||||
ChildGachas = { new PackChildGachaEntry { GachaId = 920002, TypeDetail = 5, Cost = 1, CardCount = 8, ItemId = 92001 } },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var pack = doc.RootElement.GetProperty("pack_config_list")[0];
|
||||
|
||||
// The key MUST be present, even though its value is null.
|
||||
Assert.That(pack.TryGetProperty("gacha_point", out var gachaPoint), Is.True,
|
||||
"gacha_point key must always be present in /pack/info — client at PackInfoTask.cs:126 does a direct key access guarded only by a null check, not Keys.Contains.");
|
||||
Assert.That(gachaPoint.ValueKind, Is.EqualTo(JsonValueKind.Null),
|
||||
"gacha_point should serialize as explicit null when no GachaPointConfig is set.");
|
||||
}
|
||||
}
|
||||
357
SVSim.UnitTests/Controllers/PackControllerOpenTests.cs
Normal file
357
SVSim.UnitTests/Controllers/PackControllerOpenTests.cs
Normal file
@@ -0,0 +1,357 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
public class PackControllerOpenTests
|
||||
{
|
||||
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an active pack (parent 10001, base = first seeded card set) with one rupee child
|
||||
/// gacha (gacha_id=400002, cost=100), then gives the viewer enough rupees to buy once.
|
||||
/// </summary>
|
||||
private static async Task<int> SeedOpenablePack(SVSimTestFactory f, long viewerId, ulong rupees = 200)
|
||||
{
|
||||
int baseId;
|
||||
using (var scope = f.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync();
|
||||
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1, GachaDetail = "test", SleeveId = 3000011,
|
||||
ChildGachas = {
|
||||
new PackChildGachaEntry { GachaId = 400002, TypeDetail = 7, Cost = 100, CardCount = 8 },
|
||||
},
|
||||
});
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
v.Currency.Rupees = rupees;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return baseId;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Open_with_rupees_returns_8_cards_and_deducts_currency()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedOpenablePack(factory, viewerId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
|
||||
var response = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var pack = doc.RootElement.GetProperty("pack_list");
|
||||
Assert.That(pack.GetArrayLength(), Is.EqualTo(8));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
Assert.That(v.Currency.Rupees, Is.EqualTo(100UL), "200 starting - 100 cost");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Open_persists_drawn_cards_to_viewer_collection()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedOpenablePack(factory, viewerId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
|
||||
await client.PostAsync("/pack/open", JsonBody(json));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.Cards).ThenInclude(c => c.Card).FirstAsync(x => x.Id == viewerId);
|
||||
var totalGranted = v.Cards.Sum(c => c.Count);
|
||||
Assert.That(totalGranted, Is.EqualTo(8), "8 cards drawn, all persisted (Count sums to 8 even when duplicates collapse).");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Open_increments_viewer_open_count()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedOpenablePack(factory, viewerId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
|
||||
await client.PostAsync("/pack/open", JsonBody(json));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.PackOpenCounts).FirstAsync(x => x.Id == viewerId);
|
||||
Assert.That(v.PackOpenCounts.Single(p => p.PackId == 10001).OpenCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Open_rejects_when_class_id_present()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedOpenablePack(factory, viewerId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"class_id":3}""";
|
||||
var response = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Open_rejects_when_target_card_id_present()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedOpenablePack(factory, viewerId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"target_card_id":12345}""";
|
||||
var response = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Open_rejects_skin_pack_category()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = 70001, BasePackId = 70001, PackCategory = PackCategory.LeaderSkinPack,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1, GachaDetail = "skin pack",
|
||||
ChildGachas = { new PackChildGachaEntry { GachaId = 700017, TypeDetail = 7, Cost = 100, CardCount = 8 } },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":70001,"gacha_id":700017,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
|
||||
var response = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Open_rejects_ticket_type_detail()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1, GachaDetail = "ticket-only pack", SleeveId = 5090001,
|
||||
ChildGachas = { new PackChildGachaEntry { GachaId = 920002, TypeDetail = 5, Cost = 1, CardCount = 8, ItemId = 92001 } },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":92001,"gacha_id":920002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
|
||||
var response = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Open_rejects_insufficient_rupees()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedOpenablePack(factory, viewerId, rupees: 50); // need 100
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
|
||||
var response = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
Assert.That(v.Currency.Rupees, Is.EqualTo(50UL), "no deduction on insufficient-funds reject");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Open_with_crystals_deducts_crystals()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync();
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1, GachaDetail = "test",
|
||||
ChildGachas = { new PackChildGachaEntry { GachaId = 100002, TypeDetail = 2, Cost = 100, CardCount = 8 } },
|
||||
});
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
v.Currency.Crystals = 250;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
// gacha_type:1 (parent pack's gacha_type) not :2 (child's type_detail) — see project_wire_pack_gacha_type memory.
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":100002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
|
||||
var response = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
|
||||
using (var scope2 = factory.Services.CreateScope())
|
||||
{
|
||||
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v2 = await db2.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
Assert.That(v2.Currency.Crystals, Is.EqualTo(150UL));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Open_daily_marks_last_daily_free_at_and_rejects_second_attempt()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync();
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1, GachaDetail = "daily test",
|
||||
ChildGachas = { new PackChildGachaEntry { GachaId = 200001, TypeDetail = 3, Cost = 0, CardCount = 1, IsDailySingle = true } },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
// gacha_type:1 (parent pack's gacha_type) not :3 (child's type_detail=DAILY) — prod-correct.
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":200001,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
|
||||
|
||||
var first = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK), await first.Content.ReadAsStringAsync());
|
||||
|
||||
var second = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
|
||||
"Second daily-free open the same UTC day should be rejected.");
|
||||
}
|
||||
|
||||
// ---------------- Regression tests for wire-shape quirks ----------------
|
||||
|
||||
[Test]
|
||||
public async Task Open_succeeds_when_request_gacha_type_differs_from_child_type_detail()
|
||||
{
|
||||
// Prod client sends gacha_type=1 (parent pack's gacha_type) for every buy on a
|
||||
// gacha_type=1 pack, regardless of which child gacha is being bought (RUPY_MULTI=7,
|
||||
// DAILY=3, CRYSTAL_MULTI=2, TICKET_MULTI=5, ...). Server must NOT reject on
|
||||
// `child.TypeDetail != request.GachaType` — gacha_id alone identifies the child.
|
||||
// See project_wire_pack_gacha_type memory.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedOpenablePack(factory, viewerId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
// gacha_id 400002 is RUPY_MULTI (type_detail=7); request sends gacha_type=1 — should succeed.
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
|
||||
var response = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Open_reward_list_includes_post_state_rupee_balance()
|
||||
{
|
||||
// Client's PlayerStaticData.UpdateHaveUserGoodsNum does `UserRupyCount = reward_num`
|
||||
// (direct assignment). Without an entry, the on-screen rupee count stays stale until
|
||||
// restart / /mypage/refresh. Verify the entry shape: reward_type=9 (Rupy), reward_id=0,
|
||||
// reward_num=new post-state balance (starting 200 - cost 100 = 100).
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedOpenablePack(factory, viewerId, rupees: 200);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
|
||||
var response = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var rewardList = doc.RootElement.GetProperty("reward_list");
|
||||
Assert.That(rewardList.GetArrayLength(), Is.GreaterThan(0),
|
||||
"reward_list must be populated — empty list leaves client cache stale.");
|
||||
|
||||
var rupyEntry = Enumerable.Range(0, rewardList.GetArrayLength())
|
||||
.Select(i => rewardList[i])
|
||||
.FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 9);
|
||||
|
||||
Assert.That(rupyEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined),
|
||||
"missing Rupy (type=9) entry — client will keep showing the old rupee balance.");
|
||||
Assert.That(rupyEntry.GetProperty("reward_id").GetInt64(), Is.EqualTo(0L));
|
||||
Assert.That(rupyEntry.GetProperty("reward_num").GetInt32(), Is.EqualTo(100),
|
||||
"reward_num is the new TOTAL balance (200 starting - 100 cost), not a delta.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Open_reward_list_includes_post_state_card_counts()
|
||||
{
|
||||
// For each unique drawn card the client expects `{reward_type:5, reward_id:<card_id>,
|
||||
// reward_num:<new total owned count>}`. Without these the in-session collection cache
|
||||
// is stale (cards appear in the open animation but the collection view doesn't update).
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedOpenablePack(factory, viewerId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
|
||||
var response = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var packList = doc.RootElement.GetProperty("pack_list");
|
||||
var rewardList = doc.RootElement.GetProperty("reward_list");
|
||||
|
||||
// Every distinct card_id in pack_list must have a matching type=5 reward_list entry.
|
||||
var drawnCardIds = Enumerable.Range(0, packList.GetArrayLength())
|
||||
.Select(i => packList[i].GetProperty("card_id").GetInt64())
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
|
||||
var cardEntryIds = Enumerable.Range(0, rewardList.GetArrayLength())
|
||||
.Select(i => rewardList[i])
|
||||
.Where(e => e.GetProperty("reward_type").GetInt32() == 5)
|
||||
.Select(e => e.GetProperty("reward_id").GetInt64())
|
||||
.ToHashSet();
|
||||
|
||||
Assert.That(cardEntryIds, Is.SupersetOf(drawnCardIds),
|
||||
"Every unique drawn card_id must appear in reward_list with reward_type=5.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
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.
|
||||
/// </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);
|
||||
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)));
|
||||
}
|
||||
|
||||
using var factory = new SVSimTestFactory();
|
||||
await factory.SeedGlobalsAsync(tempDir); // imports the 35-pack pack-info-2026-05-23.json
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync(
|
||||
"/pack/info",
|
||||
new StringContent("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""",
|
||||
Encoding.UTF8, "application/json"));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
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.");
|
||||
|
||||
// Spot-check pack 99047 (LegendCardPack throwback, pack_category=1)
|
||||
bool sawSpecial = false;
|
||||
for (int i = 0; i < list.GetArrayLength(); i++)
|
||||
{
|
||||
var el = list[i];
|
||||
if (el.GetProperty("parent_gacha_id").GetInt32() == 99047)
|
||||
{
|
||||
Assert.That(el.GetProperty("pack_category").GetInt32(), Is.EqualTo(1),
|
||||
"99047 is a LegendCardPack (category 1) in the prod capture.");
|
||||
sawSpecial = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.That(sawSpecial, Is.True, "pack 99047 must be in the prod capture output.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(tempDir, recursive: true); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
61
SVSim.UnitTests/Importers/GlobalsImporterPackTests.cs
Normal file
61
SVSim.UnitTests/Importers/GlobalsImporterPackTests.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Importers;
|
||||
|
||||
public class GlobalsImporterPackTests
|
||||
{
|
||||
[Test]
|
||||
public async Task ImportAll_loads_pack_catalog_from_fixture()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
await factory.SeedGlobalsAsync(); // uses prod-captures fixture dir copied into test output
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var packs = await db.Packs.OrderBy(p => p.Id).ToListAsync();
|
||||
|
||||
Assert.That(packs.Count, Is.GreaterThanOrEqualTo(3), "fixture has at least 3 packs");
|
||||
var p10001 = packs.Single(p => p.Id == 10001);
|
||||
Assert.That(p10001.PackCategory, Is.EqualTo(PackCategory.None));
|
||||
Assert.That(p10001.BasePackId, Is.EqualTo(10001));
|
||||
Assert.That(p10001.SleeveId, Is.EqualTo(3000011));
|
||||
Assert.That(p10001.GachaPointConfig, Is.Not.Null);
|
||||
Assert.That(p10001.GachaPointConfig!.ExchangeablePoint, Is.EqualTo(400));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportAll_persists_child_gachas_with_correct_types_and_costs()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
await factory.SeedGlobalsAsync();
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var pack = await db.Packs.AsNoTracking()
|
||||
.FirstAsync(p => p.Id == 10001);
|
||||
var children = pack.ChildGachas.OrderBy(c => c.GachaId).ToList();
|
||||
|
||||
Assert.That(children.Count, Is.EqualTo(3));
|
||||
Assert.That(children.Select(c => c.TypeDetail), Is.EqualTo(new[] { 2, 3, 7 }));
|
||||
Assert.That(children.Select(c => c.Cost), Is.EqualTo(new[] { 100, 50, 100 }));
|
||||
Assert.That(children.Single(c => c.TypeDetail == 3).IsDailySingle, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportAll_is_idempotent_on_rerun()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
await factory.SeedGlobalsAsync();
|
||||
await factory.SeedGlobalsAsync(); // second run must not duplicate or stack child gachas
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var pack = await db.Packs.AsNoTracking().FirstAsync(p => p.Id == 10001);
|
||||
Assert.That(pack.ChildGachas.Count, Is.EqualTo(3),
|
||||
"child_gacha_info is owned — rerun must replace, not stack.");
|
||||
}
|
||||
}
|
||||
@@ -57,9 +57,37 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// 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)
|
||||
// tests see real data.
|
||||
SeedMinimalCardSet(db);
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
private static void SeedMinimalCardSet(SVSimDbContext db)
|
||||
{
|
||||
if (db.CardSets.Any())
|
||||
return; // Already seeded (e.g. if CreateHost is called more than once)
|
||||
|
||||
var set = new ShadowverseCardSetEntry
|
||||
{
|
||||
Id = 10001,
|
||||
Name = "TestSet",
|
||||
IsInRotation = true,
|
||||
IsBasic = false,
|
||||
Cards =
|
||||
[
|
||||
new ShadowverseCardEntry { Id = 10001001L, Name = "TestCard1", Rarity = Rarity.Bronze },
|
||||
new ShadowverseCardEntry { Id = 10001002L, Name = "TestCard2", Rarity = Rarity.Gold },
|
||||
new ShadowverseCardEntry { Id = 10001003L, Name = "TestCard3", Rarity = Rarity.Legendary },
|
||||
]
|
||||
};
|
||||
db.CardSets.Add(set);
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private void ReplaceDbContext(IServiceCollection services)
|
||||
{
|
||||
// Production registered DbContextOptions<SVSimDbContext> with the Npgsql provider; tear
|
||||
|
||||
119
SVSim.UnitTests/Repositories/PackRepositoryTests.cs
Normal file
119
SVSim.UnitTests/Repositories/PackRepositoryTests.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Pack;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Repositories;
|
||||
|
||||
public class PackRepositoryTests
|
||||
{
|
||||
private static async Task SeedPack(SVSimTestFactory factory, int parentId, int baseId, PackCategory cat,
|
||||
DateTime commence, DateTime complete)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = parentId, BasePackId = baseId, PackCategory = cat,
|
||||
CommenceDate = commence, CompleteDate = complete,
|
||||
GachaType = 1, GachaDetail = "test",
|
||||
ChildGachas = { new PackChildGachaEntry { GachaId = parentId * 10, TypeDetail = 7, Cost = 100, CardCount = 8 } },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetActivePacks_filters_by_date_window()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var now = new DateTime(2026, 5, 24, 12, 0, 0, DateTimeKind.Utc);
|
||||
await SeedPack(factory, 10001, 10001, PackCategory.None, now.AddDays(-30), now.AddDays(30)); // active
|
||||
await SeedPack(factory, 10002, 10002, PackCategory.None, now.AddDays(+1), now.AddDays(30)); // not started
|
||||
await SeedPack(factory, 10003, 10003, PackCategory.None, now.AddDays(-30), now.AddDays(-1)); // expired
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IPackRepository>();
|
||||
var packs = await repo.GetActivePacks(now);
|
||||
|
||||
Assert.That(packs.Select(p => p.Id), Is.EquivalentTo(new[] { 10001 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPack_includes_child_gachas_and_banners()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var now = DateTime.UtcNow;
|
||||
await SeedPack(factory, 10001, 10001, PackCategory.None, now.AddDays(-1), now.AddDays(1));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IPackRepository>();
|
||||
var pack = await repo.GetPack(10001);
|
||||
|
||||
Assert.That(pack, Is.Not.Null);
|
||||
Assert.That(pack!.ChildGachas.Count, Is.EqualTo(1));
|
||||
Assert.That(pack.ChildGachas[0].GachaId, Is.EqualTo(100010));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task IncrementOpenCount_creates_then_updates()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IPackRepository>();
|
||||
await repo.IncrementOpenCount(viewerId, 10001, 3);
|
||||
await repo.IncrementOpenCount(viewerId, 10001, 2);
|
||||
}
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.PackOpenCounts).FirstAsync(x => x.Id == viewerId);
|
||||
var row = v.PackOpenCounts.Single(p => p.PackId == 10001);
|
||||
Assert.That(row.OpenCount, Is.EqualTo(5));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GrantCardsToViewer_inserts_new_and_increments_existing()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
// Cards are not seeded by BaseDataSeeder (they come from CardImport). Insert one directly.
|
||||
const long seedCardId = 100000001L;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Cards.Add(new ShadowverseCardEntry { Id = seedCardId, Name = "Test Card", Rarity = Rarity.Bronze });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
long sampleCardId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
sampleCardId = await db.Cards.OrderBy(c => c.Id).Select(c => c.Id).FirstAsync();
|
||||
}
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IPackRepository>();
|
||||
await repo.GrantCardsToViewer(viewerId, new[] { sampleCardId, sampleCardId });
|
||||
}
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.Cards).ThenInclude(c => c.Card)
|
||||
.FirstAsync(x => x.Id == viewerId);
|
||||
var owned = v.Cards.Single(c => c.Card.Id == sampleCardId);
|
||||
Assert.That(owned.Count, Is.EqualTo(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,14 @@
|
||||
<Content Include="..\SVSim.Bootstrap\Data\prod-captures\*.json" Link="Data\prod-captures\%(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
|
||||
them; per-feature tests rely on the fixture, the prod-capture smoke test routes around it
|
||||
via a temp dir. -->
|
||||
<Content Include="..\SVSim.Bootstrap\Data\test-fixtures\*.json" Link="Data\prod-captures\%(Filename)%(Extension)">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
67
SVSim.UnitTests/Services/DbCardPoolProviderTests.cs
Normal file
67
SVSim.UnitTests/Services/DbCardPoolProviderTests.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class DbCardPoolProviderTests
|
||||
{
|
||||
[Test]
|
||||
public async Task GetPool_for_standard_pack_returns_cards_of_matching_set()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long anyCardId;
|
||||
int setId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var setWithCards = await db.CardSets.Include(s => s.Cards)
|
||||
.FirstAsync(s => s.Cards.Count > 0);
|
||||
setId = setWithCards.Id;
|
||||
anyCardId = setWithCards.Cards.First().Id;
|
||||
}
|
||||
|
||||
using var scope2 = factory.Services.CreateScope();
|
||||
var provider = scope2.ServiceProvider.GetRequiredService<ICardPoolProvider>();
|
||||
var pool = provider.GetPool(new PackConfigEntry
|
||||
{
|
||||
Id = setId, BasePackId = setId, PackCategory = PackCategory.None
|
||||
});
|
||||
|
||||
Assert.That(pool.Any(c => c.Id == anyCardId), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetPool_for_legendary_special_returns_cards_from_rotation_sets()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var provider = scope.ServiceProvider.GetRequiredService<ICardPoolProvider>();
|
||||
|
||||
var pool = provider.GetPool(new PackConfigEntry
|
||||
{
|
||||
Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack
|
||||
});
|
||||
|
||||
Assert.That(pool.Count, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetPool_for_skin_pack_returns_empty()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var provider = scope.ServiceProvider.GetRequiredService<ICardPoolProvider>();
|
||||
|
||||
var pool = provider.GetPool(new PackConfigEntry
|
||||
{
|
||||
Id = 70001, BasePackId = 70001, PackCategory = PackCategory.LeaderSkinPack
|
||||
});
|
||||
|
||||
Assert.That(pool, Is.Empty);
|
||||
}
|
||||
}
|
||||
131
SVSim.UnitTests/Services/PackOpenServiceTests.cs
Normal file
131
SVSim.UnitTests/Services/PackOpenServiceTests.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class PackOpenServiceTests
|
||||
{
|
||||
/// <summary>Deterministic RNG that returns the supplied doubles in order, cycling.</summary>
|
||||
private sealed class ScriptedRandom : IRandom
|
||||
{
|
||||
private readonly double[] _seq; private int _i;
|
||||
public ScriptedRandom(params double[] seq) { _seq = seq; }
|
||||
public double NextDouble() { var v = _seq[_i++ % _seq.Length]; return v; }
|
||||
public int Next(int maxExclusive) => (int)(NextDouble() * maxExclusive);
|
||||
}
|
||||
|
||||
/// <summary>Simple in-memory pool keyed by rarity for slot-distribution tests.</summary>
|
||||
private sealed class StubPool : ICardPoolProvider
|
||||
{
|
||||
private readonly IReadOnlyList<ShadowverseCardEntry> _cards;
|
||||
public StubPool(IReadOnlyList<ShadowverseCardEntry> cards) { _cards = cards; }
|
||||
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry _) => _cards;
|
||||
}
|
||||
|
||||
private static List<ShadowverseCardEntry> MakeFourCards() => new()
|
||||
{
|
||||
new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze },
|
||||
new ShadowverseCardEntry { Id = 2, Rarity = Rarity.Silver },
|
||||
new ShadowverseCardEntry { Id = 3, Rarity = Rarity.Gold },
|
||||
new ShadowverseCardEntry { Id = 4, Rarity = Rarity.Legendary },
|
||||
};
|
||||
|
||||
private static PackConfigEntry StandardPack() => new()
|
||||
{
|
||||
Id = 10001, BasePackId = 10001, PackCategory = PackCategory.None,
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void Draw_returns_eight_cards_for_one_pack()
|
||||
{
|
||||
var svc = new PackOpenService();
|
||||
var pool = new StubPool(MakeFourCards());
|
||||
var rng = new ScriptedRandom(0.5); // anything in Bronze/Silver range for non-slot-8
|
||||
|
||||
var result = svc.Draw(StandardPack(), pool, packNumber: 1, excludeCardIds: Array.Empty<long>(), rng: rng);
|
||||
|
||||
Assert.That(result.Cards.Count, Is.EqualTo(8));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_slot_8_never_returns_bronze_for_standard_pack()
|
||||
{
|
||||
var svc = new PackOpenService();
|
||||
var pool = new StubPool(MakeFourCards());
|
||||
|
||||
for (int trial = 0; trial < 1000; trial++)
|
||||
{
|
||||
var result = svc.Draw(StandardPack(), pool, 1, Array.Empty<long>(), new SystemRandom(trial));
|
||||
Assert.That(result.Cards[7].Rarity, Is.Not.EqualTo(Rarity.Bronze),
|
||||
$"slot 8 must never be Bronze (trial {trial})");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_legendary_special_forces_slot_8_to_legendary()
|
||||
{
|
||||
var svc = new PackOpenService();
|
||||
var pool = new StubPool(MakeFourCards());
|
||||
var pack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };
|
||||
|
||||
for (int trial = 0; trial < 100; trial++)
|
||||
{
|
||||
var result = svc.Draw(pack, pool, 1, Array.Empty<long>(), new SystemRandom(trial));
|
||||
Assert.That(result.Cards[7].Rarity, Is.EqualTo(Rarity.Legendary),
|
||||
$"legendary-special pack slot 8 must be Legendary (trial {trial})");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_distribution_over_10k_slots_1_to_7_matches_declared_rates_within_2_percent()
|
||||
{
|
||||
var svc = new PackOpenService();
|
||||
var pool = new StubPool(MakeFourCards());
|
||||
var counts = new Dictionary<Rarity, int>
|
||||
{
|
||||
{ Rarity.Bronze, 0 }, { Rarity.Silver, 0 }, { Rarity.Gold, 0 }, { Rarity.Legendary, 0 }
|
||||
};
|
||||
|
||||
var rng = new SystemRandom(seed: 42);
|
||||
const int packs = 10_000;
|
||||
for (int i = 0; i < packs; i++)
|
||||
{
|
||||
var r = svc.Draw(StandardPack(), pool, 1, Array.Empty<long>(), rng);
|
||||
// Only look at slots 0..6 (the unrestricted rarity slots)
|
||||
for (int s = 0; s < 7; s++) counts[r.Cards[s].Rarity]++;
|
||||
}
|
||||
|
||||
int total = packs * 7;
|
||||
double bronze = counts[Rarity.Bronze] / (double)total;
|
||||
double silver = counts[Rarity.Silver] / (double)total;
|
||||
double gold = counts[Rarity.Gold] / (double)total;
|
||||
double leg = counts[Rarity.Legendary] / (double)total;
|
||||
|
||||
Assert.That(bronze, Is.EqualTo(0.6744).Within(0.02), $"bronze rate {bronze:P}");
|
||||
Assert.That(silver, Is.EqualTo(0.2500).Within(0.02), $"silver rate {silver:P}");
|
||||
Assert.That(gold, Is.EqualTo(0.0600).Within(0.01), $"gold rate {gold:P}");
|
||||
Assert.That(leg, Is.EqualTo(0.0150).Within(0.01), $"legendary rate {leg:P}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_excludes_listed_card_ids()
|
||||
{
|
||||
var svc = new PackOpenService();
|
||||
// Pool with two bronze cards; exclude one — every Bronze slot must pick the other.
|
||||
var pool = new StubPool(new List<ShadowverseCardEntry>
|
||||
{
|
||||
new() { Id = 1, Rarity = Rarity.Bronze },
|
||||
new() { Id = 99, Rarity = Rarity.Bronze },
|
||||
new() { Id = 2, Rarity = Rarity.Silver },
|
||||
});
|
||||
|
||||
var rng = new SystemRandom(seed: 7);
|
||||
var result = svc.Draw(StandardPack(), pool, 1, excludeCardIds: new long[] { 1 }, rng: rng);
|
||||
|
||||
foreach (var c in result.Cards.Where(x => x.Rarity == Rarity.Bronze))
|
||||
{
|
||||
Assert.That(c.CardId, Is.EqualTo(99), "excluded card 1 must never appear in Bronze slot");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user