Things were working, suddenly regressed
This commit is contained in:
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
@@ -16,8 +17,12 @@ namespace SVSim.UnitTests.Controllers;
|
||||
/// </summary>
|
||||
public class DeckControllerTests
|
||||
{
|
||||
// ToApi() converts internal Format -> wire deck_format int (e.g. Format.Rotation -> 1).
|
||||
// Tests MUST send wire values; the controller routes them back via FormatExtensions.FromApi.
|
||||
// Inline `"deck_format":1` literals below correspond to Format.Rotation (the format the
|
||||
// SeedDeckAsync fixtures use).
|
||||
private static string DeckFormatRequestJson(Format f) =>
|
||||
$$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_format":{{(int)f}}}""";
|
||||
$$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_format":{{f.ToApi()}}}""";
|
||||
|
||||
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
|
||||
|
||||
@@ -145,7 +150,7 @@ public class DeckControllerTests
|
||||
{"viewer_id":"0","steam_id":0,"steam_session_ticket":"",
|
||||
"deck_no":1,"class_id":{{classId}},"leader_skin_id":{{leaderSkinId}},
|
||||
"is_random_leader_skin":false,"sleeve_id":{{sleeveId}},"deck_name":"Fresh Deck",
|
||||
"is_delete":0,"deck_format":0}
|
||||
"is_delete":0,"deck_format":1}
|
||||
""";
|
||||
var response = await client.PostAsync("/deck/update", JsonBody(updateJson));
|
||||
|
||||
@@ -173,7 +178,7 @@ public class DeckControllerTests
|
||||
{"viewer_id":"0","steam_id":0,"steam_session_ticket":"",
|
||||
"deck_no":1,"class_id":{{classId}},"leader_skin_id":{{leaderSkinId}},
|
||||
"is_random_leader_skin":false,"sleeve_id":{{sleeveId}},"deck_name":"Renamed",
|
||||
"is_delete":0,"deck_format":0}
|
||||
"is_delete":0,"deck_format":1}
|
||||
""";
|
||||
await client.PostAsync("/deck/update", JsonBody(updateJson));
|
||||
|
||||
@@ -197,7 +202,7 @@ public class DeckControllerTests
|
||||
{"viewer_id":"0","steam_id":0,"steam_session_ticket":"",
|
||||
"deck_no":1,"class_id":{{classId}},"leader_skin_id":{{leaderSkinId}},
|
||||
"is_random_leader_skin":false,"sleeve_id":{{sleeveId}},"deck_name":null,
|
||||
"is_delete":1,"deck_format":0}
|
||||
"is_delete":1,"deck_format":1}
|
||||
""";
|
||||
var response = await client.PostAsync("/deck/update", JsonBody(deleteJson));
|
||||
|
||||
@@ -222,7 +227,7 @@ public class DeckControllerTests
|
||||
{"viewer_id":"0","steam_id":0,"steam_session_ticket":"",
|
||||
"deck_no":2,"class_id":{{classId}},"leader_skin_id":{{leaderSkinId}},
|
||||
"is_random_leader_skin":false,"sleeve_id":{{sleeveId}},"deck_name":"Second",
|
||||
"is_delete":0,"deck_format":0}
|
||||
"is_delete":0,"deck_format":1}
|
||||
""";
|
||||
var response = await client.PostAsync("/deck/update", JsonBody(updateJson));
|
||||
|
||||
@@ -247,7 +252,7 @@ public class DeckControllerTests
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, name: "Old Name");
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_name":"New Name","deck_format":0}""";
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_name":"New Name","deck_format":1}""";
|
||||
var response = await client.PostAsync("/deck/update_name", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
@@ -299,7 +304,7 @@ public class DeckControllerTests
|
||||
}
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var json = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"leader_skin_id":{{skinId}},"deck_format":0}""";
|
||||
var json = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"leader_skin_id":{{skinId}},"deck_format":1}""";
|
||||
var response = await client.PostAsync("/deck/update_leader_skin", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
@@ -325,7 +330,7 @@ public class DeckControllerTests
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var json =
|
||||
$$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_format":0,"leader_skin_id_list":[{{string.Join(',', pool)}}]}""";
|
||||
$$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_format":1,"leader_skin_id_list":[{{string.Join(',', pool)}}]}""";
|
||||
var response = await client.PostAsync("/deck/update_random_leader_skin", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
@@ -347,7 +352,7 @@ public class DeckControllerTests
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var json =
|
||||
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_format":0,"leader_skin_id_list":[]}""";
|
||||
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_format":1,"leader_skin_id_list":[]}""";
|
||||
var response = await client.PostAsync("/deck/update_random_leader_skin", JsonBody(json));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
@@ -365,7 +370,7 @@ public class DeckControllerTests
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var json =
|
||||
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_order":[2,1],"deck_format":0}""";
|
||||
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_order":[2,1],"deck_format":1}""";
|
||||
var response = await client.PostAsync("/deck/update_order", JsonBody(json));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
@@ -382,7 +387,7 @@ public class DeckControllerTests
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var json =
|
||||
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no_list":[1,3],"deck_format":0}""";
|
||||
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no_list":[1,3],"deck_format":1}""";
|
||||
var response = await client.PostAsync("/deck/delete_deck_list", JsonBody(json));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
|
||||
@@ -18,9 +18,14 @@ public class LoadControllerTests
|
||||
|
||||
/// <summary>
|
||||
/// Wire keys (from <c>[Key("...")]</c> / mirrored <c>[JsonPropertyName]</c>) for fields the
|
||||
/// client reads unconditionally in <c>LoadDetail.ConvertJsonData</c>. These are the names
|
||||
/// the decompiled client actually looks up — NOT <c>SnakeCaseLower(C# property name)</c>.
|
||||
/// Missing any of these crashes the client with <c>KeyNotFoundException</c> on /load/index.
|
||||
/// client reads UNCONDITIONALLY in <c>LoadDetail.ConvertJsonData</c> (no <c>Keys.Contains</c>
|
||||
/// or <c>TryGetValue</c> guard). Missing any of these crashes the client.
|
||||
///
|
||||
/// Fields that ARE guarded by the client get a separate, dedicated assertion (or no assertion)
|
||||
/// — they're allowed to be omitted. Examples: <c>arena_info</c>, <c>daily_login_bonus</c>,
|
||||
/// <c>battle_pass_level_info</c>, <c>pre_release_info</c>, <c>my_rotation_info</c>,
|
||||
/// <c>avatar_info</c>, <c>item_expire_date</c> are all optional per
|
||||
/// <c>docs/api-spec/endpoints/post-login/load-index.md</c>.
|
||||
/// </summary>
|
||||
private static readonly string[] RequiredIndexKeys =
|
||||
{
|
||||
@@ -28,7 +33,7 @@ public class LoadControllerTests
|
||||
"user_deck_rotation", "user_deck_unlimited", "user_deck_my_rotation",
|
||||
"user_card_list", "user_class_list", "user_sleeve_list", "user_emblem_list",
|
||||
"user_degree_list", "user_leader_skin_list", "user_mypage_list",
|
||||
"user_rank", "user_rank_match_list", "daily_login_bonus", "challenge_config",
|
||||
"user_rank", "user_rank_match_list", "challenge_config",
|
||||
"red_ether_overwrite_list", "maintenance_card_list", "rank_info",
|
||||
"class_exp", "loading_exclusion_card_list", "default_setting",
|
||||
"unlimited_restricted_base_card_id_list", "rotation_card_set_id_list",
|
||||
@@ -116,6 +121,29 @@ public class LoadControllerTests
|
||||
Assert.That(root.GetProperty("user_rank").GetArrayLength(), Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_user_rank_deck_formats_are_wire_codes_not_internal_enum()
|
||||
{
|
||||
// Regression for the /load/index KeyNotFoundException crash (2026-05-23):
|
||||
// server was emitting (int)Format directly, so deck_format 0 (Format.Rotation
|
||||
// internal) reached the client, ParseApiFormat mapped wire-0 to Format.Max, and
|
||||
// LoadDetail._userRank[2] threw. Wire codes per Data.FormatConvertApi:
|
||||
// Rotation→1, Unlimited→2, Crossover→4, MyRotation→5, Avatar→39.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
var root = await PostIndexAndReadBody(factory, viewerId);
|
||||
|
||||
var deckFormats = root.GetProperty("user_rank").EnumerateArray()
|
||||
.Select(e => e.GetProperty("deck_format").GetInt32())
|
||||
.ToList();
|
||||
Assert.That(deckFormats, Is.EquivalentTo(new[] { 1, 2, 5, 39, 4 }),
|
||||
"user_rank entries must carry wire deck_format codes, not internal Format ints.");
|
||||
|
||||
// The top-level deck_format default is also a wire code (Rotation = wire 1).
|
||||
Assert.That(root.GetProperty("deck_format").GetInt32(), Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_rotation_card_set_id_list_has_at_least_two_entries()
|
||||
{
|
||||
@@ -146,6 +174,31 @@ public class LoadControllerTests
|
||||
"If you re-add it, populate at least one entry with a valid format_info.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_user_card_list_excludes_zero_count_entries()
|
||||
{
|
||||
// Documents the divergence from prod (see load-index.md §user_card_list policy).
|
||||
// Our server emits only owned cards (Count > 0) plus basics; prod returns a
|
||||
// larger curated set that includes some 0-count "ever-touched" rows we don't
|
||||
// model. The client falls back to 0 for absent ids (DataMgr.cs:1182), so this
|
||||
// is semantically safe — but if anything ever starts emitting Count=0 rows again
|
||||
// (e.g. someone re-introduces a left-join against the full card catalog), this
|
||||
// test pins the policy.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
var root = await PostIndexAndReadBody(factory, viewerId);
|
||||
|
||||
var userCards = root.GetProperty("user_card_list");
|
||||
Assert.That(userCards.ValueKind, Is.EqualTo(JsonValueKind.Array));
|
||||
var zeroCount = userCards.EnumerateArray()
|
||||
.Where(c => c.GetProperty("number").GetInt32() == 0)
|
||||
.ToList();
|
||||
Assert.That(zeroCount, Is.Empty,
|
||||
"user_card_list must not contain Count=0 entries; we ship only the owned-only " +
|
||||
"subset (plus basics with count=3). See load-index.md §user_card_list policy.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_when_viewer_has_no_decks_returns_empty_format_lists()
|
||||
{
|
||||
@@ -167,4 +220,79 @@ public class LoadControllerTests
|
||||
$"{key}.user_deck_list must be an empty array for a deckless viewer, not null.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_surfaces_seeded_globals_after_bootstrap()
|
||||
{
|
||||
// Verifies the end-to-end seed → repo → controller wiring for the prod-captured globals.
|
||||
// Counts and spot-checked values come from the 2026-05-23 capture; if a recapture lands
|
||||
// with different cardinalities, update the assertions alongside.
|
||||
using var factory = new SVSimTestFactory();
|
||||
await factory.SeedGlobalsAsync();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
var root = await PostIndexAndReadBody(factory, viewerId);
|
||||
|
||||
// SpotCards: dict[card_id_str] → cost, 239 entries
|
||||
var spotCards = root.GetProperty("spot_cards");
|
||||
Assert.That(spotCards.ValueKind, Is.EqualTo(JsonValueKind.Object));
|
||||
Assert.That(spotCards.EnumerateObject().Count(), Is.EqualTo(239), "spot_cards entry count");
|
||||
|
||||
// ReprintedCards: flat number[], 54 entries
|
||||
var reprinted = root.GetProperty("reprinted_base_card_ids");
|
||||
Assert.That(reprinted.GetArrayLength(), Is.EqualTo(54), "reprinted_base_card_ids length");
|
||||
|
||||
// UnlimitedBanList: dict[card_id_str] → restriction value, 3 entries; 107813030 = hard ban
|
||||
var bans = root.GetProperty("unlimited_restricted_base_card_id_list");
|
||||
Assert.That(bans.EnumerateObject().Count(), Is.EqualTo(3));
|
||||
Assert.That(bans.GetProperty("107813030").GetInt32(), Is.EqualTo(1));
|
||||
|
||||
// LoadingExclusion: 176 ids
|
||||
Assert.That(root.GetProperty("loading_exclusion_card_list").GetArrayLength(), Is.EqualTo(176));
|
||||
|
||||
// GameConfiguration-sourced scalars
|
||||
Assert.That(root.GetProperty("is_battle_pass_period").GetBoolean(), Is.True,
|
||||
"is_battle_pass_period is bool on the wire (matches prod 2026-05-23)");
|
||||
Assert.That(root.GetProperty("card_set_id_for_resource_dl_view").GetInt32(), Is.EqualTo(1));
|
||||
|
||||
// challenge_config sourced from GameConfiguration cols
|
||||
var challenge = root.GetProperty("challenge_config");
|
||||
Assert.That(challenge.GetProperty("use_challenge_two_pick_premium_card").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(challenge.GetProperty("challenge_two_pick_sleeve_id").GetInt32(), Is.EqualTo(3000011));
|
||||
|
||||
// arena_info: single element with format_info populated
|
||||
Assert.That(root.TryGetProperty("arena_info", out var arenaInfo), Is.True,
|
||||
"arena_info present once an ArenaSeasonConfig row is seeded");
|
||||
Assert.That(arenaInfo.GetArrayLength(), Is.EqualTo(1));
|
||||
var fi = arenaInfo[0].GetProperty("format_info");
|
||||
Assert.That(fi.GetProperty("card_pool_name").GetString(), Does.Contain("Take Two"));
|
||||
|
||||
// my_rotation_info: setting dict has 27 entries
|
||||
var mri = root.GetProperty("my_rotation_info");
|
||||
Assert.That(mri.GetProperty("setting").EnumerateObject().Count(), Is.EqualTo(27));
|
||||
Assert.That(mri.GetProperty("abilities").EnumerateObject().Count(), Is.EqualTo(6));
|
||||
|
||||
// avatar_info: abilities dict has 24 entries; schedules is empty list
|
||||
var ai = root.GetProperty("avatar_info");
|
||||
Assert.That(ai.GetProperty("abilities").EnumerateObject().Count(), Is.EqualTo(24));
|
||||
Assert.That(ai.GetProperty("schedules").ValueKind, Is.EqualTo(JsonValueKind.Array));
|
||||
Assert.That(ai.GetProperty("schedules").GetArrayLength(), Is.EqualTo(0));
|
||||
|
||||
// pre_release_info: present (singleton seeded, even with stale dates per audit)
|
||||
Assert.That(root.TryGetProperty("pre_release_info", out var pri), Is.True);
|
||||
Assert.That(pri.GetProperty("id").GetString(), Is.EqualTo("1"));
|
||||
|
||||
// rotation_card_set_id_list: now comes from the real CardSets table — six entries after
|
||||
// GlobalsImporter flags IsInRotation on the rotation_card_set_id_list seeded ids. But
|
||||
// CardImport isn't run in tests, so the table is empty and we fall back to StubRotationSets
|
||||
// (3 entries). That's still ≥ 2 so the client won't crash.
|
||||
Assert.That(root.GetProperty("rotation_card_set_id_list").GetArrayLength(),
|
||||
Is.GreaterThanOrEqualTo(2));
|
||||
|
||||
// Optional/absent fields stay absent when nothing meaningful to surface
|
||||
Assert.That(root.TryGetProperty("daily_login_bonus", out _), Is.False,
|
||||
"daily_login_bonus optional per spec; emit null when no active campaign");
|
||||
Assert.That(root.TryGetProperty("battle_pass_level_info", out _), Is.False,
|
||||
"battle_pass_level_info optional per spec; emit null until viewer pass state is wired");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
@@ -16,8 +17,9 @@ public class PracticeControllerTests
|
||||
private const string BaseRequestJson =
|
||||
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
|
||||
|
||||
// ToApi() converts internal Format -> wire deck_format int (Format.All -> 0, etc.).
|
||||
private static string DeckFormatRequestJson(Format f) =>
|
||||
$$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_format":{{(int)f}}}""";
|
||||
$$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_format":{{f.ToApi()}}}""";
|
||||
|
||||
[Test]
|
||||
public async Task Info_returns_non_empty_opponent_array()
|
||||
@@ -103,8 +105,11 @@ public class PracticeControllerTests
|
||||
|
||||
// recoveryData is an opaque JSON blob serialized to string by the client; the server
|
||||
// is supposed to accept it without validation. Anything goes.
|
||||
// deck_format:1 = Format.Rotation on the wire. The controller ignores the field today
|
||||
// (practice is per-format upstream), but sending a coherent wire code keeps the test
|
||||
// intent clean if Finish ever starts validating it.
|
||||
var finishJson =
|
||||
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"is_win":1,"evolve_count":2,"total_turn":5,"enemy_class_id":3,"difficulty":1,"deck_format":0,"class_id":1,"recovery_data":"{\"opaque\":\"blob\"}"}""";
|
||||
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"is_win":1,"evolve_count":2,"total_turn":5,"enemy_class_id":3,"difficulty":1,"deck_format":1,"class_id":1,"recovery_data":"{\"opaque\":\"blob\"}"}""";
|
||||
|
||||
var response = await client.PostAsync("/practice/finish",
|
||||
new StringContent(finishJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
Reference in New Issue
Block a user