SoloDefaultsToScripted was only consulted by ArenaTwoPickBattleController; RankBattleController did its own inline pair-up + state-code mapping and ignored the flag entirely. Result: turning on the flag globally only short-circuited TK2 polls, while rank-battle polls still parked for the PvpFirstThenAiFallback threshold (15s) before resolving — surfaced today when the user set the flag and saw rank-battle still queue, then bot- battle via the client-side AI (not the server-side Scripted lifecycle we need to test WS traffic against). New IMatchingResolver owns the cross-cutting decisions: - honor scriptedOptIn (per-request) OR options.SoloDefaultsToScripted (process-wide) — bypass pair-up, register Scripted, return 3004 - otherwise call IMatchingPairUpService.TryPairAsync and translate the PairUpResult to the 3002/3004/3007/3011 vocabulary Family controllers shed the duplicated logic: - ArenaTwoPickBattleController: ~50 LOC → ~25; preserves ?scripted=1 query opt-in (parsed permissively for "1"/"true") and the ArenaTwoPickException catch - RankBattleController: ~30 LOC → ~12; preserves the 3001 mapping for InvalidOperationException (no deck for format) and card_master_id emission DoMatchingContractTests is the durable enforcement: parametrized over TK2 + rotation + unlimited rank, asserts SoloDefaultsToScripted=true makes every family's first poll skip 3002 and return SUCCEEDED with a battle_id + node_server_url. Adding a fourth family that forgets to route through IMatchingResolver fails this test — that's the point. MatchingResolverTests covers the six resolver paths in isolation with mocks; per-test Harness locals (not fixture-level fields) because the assembly is [Parallelizable(ParallelScope.All)] and shared mocks race. 957 tests passing (was 948; +9: 6 resolver + 3 contract parametrizations). No regressions in the existing TK2 / rank-battle controller suites. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
119 lines
5.2 KiB
C#
119 lines
5.2 KiB
C#
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NUnit.Framework;
|
|
using SVSim.BattleNode.Bridge;
|
|
using SVSim.Database;
|
|
using SVSim.Database.Enums;
|
|
using SVSim.Database.Models;
|
|
using SVSim.UnitTests.Infrastructure;
|
|
|
|
namespace SVSim.UnitTests.Controllers;
|
|
|
|
/// <summary>
|
|
/// Cross-family contract for <c>/do_matching</c>. The single load-bearing assertion: when
|
|
/// <see cref="BattleNodeOptions.SoloDefaultsToScripted"/> is <c>true</c>, every family's
|
|
/// first poll must bypass pair-up and return a SUCCEEDED matching_state with a battle_id +
|
|
/// node_server_url — not the 3002 RETRY of the normal pair-up path.
|
|
/// <para>
|
|
/// Adding a new family is the failure trigger for this test: the new controller MUST route
|
|
/// through <see cref="SVSim.EmulatedEntrypoint.Matching.IMatchingResolver"/>, or this test
|
|
/// fails. That's the point — the test enforces "stay in line" across families.
|
|
/// </para>
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class DoMatchingContractTests
|
|
{
|
|
private static readonly object DoMatchingBody = new
|
|
{
|
|
deck_no = 1L,
|
|
need_init = 1,
|
|
log = 1,
|
|
excluded_field_id_list = Array.Empty<long>(),
|
|
use_stage_select = 1,
|
|
is_default_skin = 0,
|
|
viewer_id = "0",
|
|
steam_id = 0,
|
|
steam_session_ticket = "",
|
|
};
|
|
|
|
[TestCase("/arena_two_pick_battle/do_matching", FamilyKind.TwoPick)]
|
|
[TestCase("/rotation_rank_battle/do_matching", FamilyKind.RankRotation)]
|
|
[TestCase("/unlimited_rank_battle/do_matching", FamilyKind.RankUnlimited)]
|
|
public async Task SoloDefaultsToScripted_short_circuits_every_family_to_immediate_SUCCEEDED(string url, FamilyKind family)
|
|
{
|
|
await using var factory = new SVSimTestFactory();
|
|
factory.Services.GetRequiredService<BattleNodeOptions>().SoloDefaultsToScripted = true;
|
|
|
|
var viewerId = await factory.SeedViewerAsync();
|
|
await SetupFamilyAsync(factory, viewerId, family);
|
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
|
|
|
var resp = await client.PostAsJsonAsync(url, DoMatchingBody);
|
|
|
|
Assert.That(resp.IsSuccessStatusCode, Is.True, $"Expected 2xx from {url}, got {resp.StatusCode}.");
|
|
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
|
var root = doc.RootElement;
|
|
|
|
var state = root.GetProperty("matching_state").GetInt32();
|
|
Assert.That(state, Is.Not.EqualTo(3002),
|
|
$"{url}: SoloDefaultsToScripted=true must bypass pair-up; saw matching_state=3002 RETRY which means the family didn't honor the flag (probably forgot to route through IMatchingResolver).");
|
|
Assert.That(state, Is.AnyOf(3004, 3007, 3011),
|
|
$"{url}: matching_state must be SUCCEEDED (3004), SUCCEEDED_OWNER (3007), or AI_SUCCEEDED (3011) — got {state}.");
|
|
|
|
Assert.That(root.GetProperty("battle_id").GetString(), Is.Not.Null.And.Not.Empty,
|
|
$"{url}: SUCCEEDED responses must carry battle_id.");
|
|
Assert.That(root.GetProperty("node_server_url").GetString(), Does.Contain("/socket.io/"),
|
|
$"{url}: node_server_url must point at the WS endpoint.");
|
|
}
|
|
|
|
// Each family has different prerequisites — TK2 needs an active draft run, rank needs
|
|
// a deck for the requested format. The factory's seeders are sufficient for both.
|
|
public enum FamilyKind { TwoPick, RankRotation, RankUnlimited }
|
|
|
|
private static async Task SetupFamilyAsync(SVSimTestFactory factory, long viewerId, FamilyKind family)
|
|
{
|
|
switch (family)
|
|
{
|
|
case FamilyKind.TwoPick:
|
|
await SeedCompleteTwoPickRunAsync(factory, viewerId);
|
|
break;
|
|
case FamilyKind.RankRotation:
|
|
await factory.SeedGlobalsAsync();
|
|
await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1);
|
|
break;
|
|
case FamilyKind.RankUnlimited:
|
|
await factory.SeedGlobalsAsync();
|
|
await factory.SeedDeckAsync(viewerId, Format.Unlimited, number: 1);
|
|
break;
|
|
default:
|
|
throw new ArgumentOutOfRangeException(nameof(family));
|
|
}
|
|
}
|
|
|
|
// Mirrors ArenaTwoPickBattleControllerTests.SeedCompleteTwoPickRunAsync. Duplicated
|
|
// rather than promoted because the original is a private static there and only this
|
|
// test class needs to share it cross-family today; promote if a third caller surfaces.
|
|
private static async Task SeedCompleteTwoPickRunAsync(SVSimTestFactory factory, long viewerId)
|
|
{
|
|
using var scope = factory.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var deck = Enumerable.Range(1, 30).Select(i => 100_011_000L + i).ToList();
|
|
db.ViewerArenaTwoPickRuns.Add(new ViewerArenaTwoPickRun
|
|
{
|
|
ViewerId = viewerId,
|
|
EntryId = 1,
|
|
ClassId = 1,
|
|
LeaderSkinId = 1,
|
|
SelectedCardIdsJson = JsonSerializer.Serialize(deck),
|
|
IsSelectCompleted = true,
|
|
MaxBattleCount = 5,
|
|
CandidateClassIdsJson = "[1,2,3]",
|
|
PendingPickSetsJson = "[]",
|
|
ResultListJson = "[]",
|
|
NextCandidateId = 1,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
}
|