Files
SVSimServer/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
gamer147 9e8ebd1b2b fix(battle-node): preserve long type on numeric array elements in FromJson
Root cause for the lingering mulligan failure: the inline conditional
expression in MsgEnvelope.ToObject

    JsonValueKind.Number => el.TryGetInt64(out var l) ? l : el.GetDouble(),

unified its branches to the common implicit-convertible type. long→double
is implicit, so both branches collapsed to double and the integer value
silently widened. Inside an array (idxList:[2]), each element came back
as boxed double; OfType<long> in ExtractIdxList then filtered every
entry out, so swapIndices arrived empty and BuildSwapResponse echoed
the unchanged hand — exactly the diff-against-Deal mismatch the client
flagged as "Card swap failed: AbandonCards[2]/DrawCards[]".

Extract a ParseNumber helper that returns object explicitly so each
branch boxes its own runtime type. Also harden ExtractIdxList to accept
any boxed numeric type (long/int/double/decimal/string) so a future
JSON-parser drift can't silently regress this path again.

Two regression tests:
- FromJson_NumericArray_PreservesLongTypeOnEachElement: confirms the
  fix at the JSON-parse layer with a hardcoded "{\"idxList\":[2,3]}".
- Swap_WithIdxListContainingTwo_ProducesHandWithFreshIdxAtPosition1:
  exercises the dispatch end-to-end with a Body holding a real boxed
  long; asserts position 1 of the response hand is the fresh deck idx 4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:40:50 -04:00

133 lines
5.8 KiB
C#

// SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions;
namespace SVSim.UnitTests.BattleNode.Sessions;
[TestFixture]
public class BattleSessionDispatchTests
{
private static BattleSession NewSession()
{
// ws is unused by ComputeResponses; pass null! and rely on the test never invoking the pump.
return new BattleSession(ws: null!, battleId: "bid-1", viewerId: 1, log: NullLogger<BattleSession>.Instance);
}
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle,
PubSeq: null, PlaySeq: null, Body: new Dictionary<string, object?>());
[Test]
public void InitNetwork_PushesAckOnly_TransitionsToAwaitingInitBattle()
{
var s = NewSession();
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
Assert.That(responses.Select(r => r.Envelope.Uri),
Is.EqualTo(new[] { NetworkBattleUri.InitNetwork }));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
}
[Test]
public void InitBattle_PushesMatched_TransitionsToAwaitingLoaded()
{
var s = NewSession();
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle));
Assert.That(responses.Single().Envelope.Uri, Is.EqualTo(NetworkBattleUri.Matched));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
}
[Test]
public void Loaded_PushesBattleStartThenDeal_TransitionsToAwaitingSwap()
{
var s = NewSession();
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle));
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded));
Assert.That(responses.Select(r => r.Envelope.Uri),
Is.EqualTo(new[] { NetworkBattleUri.BattleStart, NetworkBattleUri.Deal }));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap));
}
[Test]
public void Swap_WithIdxListContainingTwo_ProducesHandWithFreshIdxAtPosition1()
{
var s = NewSession();
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle));
s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded));
var swapEnv = NewEnvelope(NetworkBattleUri.Swap);
// Simulate the client's Swap{idxList:[2]}: the dict shape produced by MsgEnvelope.FromJson
// (a List<object?> of boxed long values).
swapEnv.Body["idxList"] = new List<object?> { 2L };
var responses = s.ComputeResponses(swapEnv);
var swapBody = responses[0].Envelope.Body;
var self = (List<object?>)swapBody["self"]!;
Assert.That(((Dictionary<string, object?>)self[0]!)["idx"], Is.EqualTo(1));
Assert.That(((Dictionary<string, object?>)self[1]!)["idx"], Is.EqualTo(4)); // swapped — fresh deck idx
Assert.That(((Dictionary<string, object?>)self[2]!)["idx"], Is.EqualTo(3));
}
[Test]
public void Swap_PushesSwapResponseThenReady_TransitionsToAfterReady()
{
var s = NewSession();
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle));
s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded));
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap));
Assert.That(responses.Select(r => r.Envelope.Uri),
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
}
[Test]
public void TurnEnd_AfterReady_PushesOpponentTurnStart_TransitionsToOpponentTurn()
{
var s = NewSession();
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle));
s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded));
s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap));
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.TurnEnd));
Assert.That(responses.Single().Envelope.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.OpponentTurn));
}
[Test]
public void Retire_PushesBattleFinishNoContest_TransitionsToTerminal()
{
var s = NewSession();
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Retire));
var (env, noStock) = responses.Single();
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(noStock, Is.True);
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
}
[Test]
public void Kill_PushesBattleFinishNoContest_TransitionsToTerminal()
{
var s = NewSession();
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Kill));
var (env, noStock) = responses.Single();
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(noStock, Is.True);
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
}
[Test]
public void Swap_ArrivingBeforeLoaded_ProducesNoResponseAndDoesNotAdvancePhase()
{
var s = NewSession();
// Skip Loaded — fire Swap straight out of AwaitingInitNetwork.
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap));
Assert.That(responses, Is.Empty);
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork));
}
}