using System.Text.Json;
using System.Text.Json.Nodes;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Session;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class TradePauseRecoveryTests
{
[Test]public void NativeRenewUsesTheUnsignedNumericEntityWireContractWithoutPrecisionLoss()
{
foreach(var entity in new[]{11825915469828UL,9007199254740993UL,ulong.MaxValue})
{
var request=JsonSerializer.SerializeToElement(new NativeTradePauseRenewRequest(new string('a',32),new string('b',32),entity),ToolboxSettings.JsonOptions);
// This is the exact decoder used by native JsonWire.U64. A JSON
// string would throw here even when it contains decimal digits.
Check.Equal(JsonValueKind.Number,request.GetProperty("entityId").ValueKind);
Check.Equal(entity,request.GetProperty("entityId").GetUInt64());
Check.Equal("trade.pause.renew",request.GetProperty("op").GetString());
Check.Equal(750,request.GetProperty("ttlMilliseconds").GetInt32());
}
var oldWire=JsonSerializer.SerializeToElement(new{entityId="11825915469828"});
Check.Throws<InvalidOperationException>(()=>oldWire.GetProperty("entityId").GetUInt64());
}
private static readonly DateTimeOffset Now=DateTimeOffset.Parse("2026-10-25T12:00:00Z");
private static PlayerState Player=>new(){InGame=true,CommanderId="owner",CurrentShipId=5,LoadGameAt=Now.AddHours(-1)};
private static JsonElement Menu(Action<JsonObject>? change=null)
{
var value=JsonSerializer.SerializeToNode(new{state="Pause",nextState="Pause",menuReady=true,kind=1,categoryName="Pause",menuSequence=12UL,
observedAtUnixMilliseconds=Now.ToUnixTimeMilliseconds(),flightTelemetryFresh=false,
actions=new[]{new{name="InGame",enabled=true},new{name="ExitOptions",enabled=true}},
tradePauseIdentity=new{verified=true,entityId="42",hullFraction=1d,observedAtUnixMilliseconds=Now.ToUnixTimeMilliseconds(),physicsTelemetryRequired=false},
tradePauseRecovery=new{requestId="",applied=false,failed=false}})!.AsObject();
change?.Invoke(value);return JsonSerializer.SerializeToElement(value);
}
private sealed class Channel:ITradePauseRecoveryChannel
{
public JsonElement Value=Menu();public List<string> Calls=[];public Action? AfterRead,AfterRenew;public string? ResumeId;
public Task PrepareAsync(CancellationToken token){Calls.Add("prepare");return Task.CompletedTask;}
public Task<JsonElement> ReadAsync(CancellationToken token){Calls.Add("read");AfterRead?.Invoke();return Task.FromResult(Value);}
public Task RenewAsync(string owner,string run,ulong entity,CancellationToken token){Calls.Add("renew");Check.Equal(42UL,entity);AfterRenew?.Invoke();return Task.CompletedTask;}
public Task ResumeAsync(string owner,string run,string id,ulong sequence,CancellationToken token){Calls.Add("resume");ResumeId=id;Check.Equal(12UL,sequence);return Task.CompletedTask;}
public Task EndAsync(string owner,string run){Calls.Add("end");return Task.CompletedTask;}
}
[Test]public async Task OnlyARunningScopeCanSubmitThePreciseOriginalPauseResume()
{
var running=false;var channel=new Channel();var session=new TradePauseRecoverySession(channel,()=>Player,()=>running,()=>Now);
await session.StepAsync(default);Check.Equal(0,channel.Calls.Count);
running=true;await session.StepAsync(default);Check.Equal(1,channel.Calls.Count(x=>x=="resume"));
running=false;await session.StepAsync(default);Check.Equal("end",channel.Calls.Last());
await session.EndAsync();running=true;await session.StepAsync(default);Check.Equal(1,channel.Calls.Count(x=>x=="resume"));
}
[Test]public async Task StaleFlightTelemetryDoesNotBlockFreshOriginalMenuThreadIdentity()
{
var channel=new Channel();var session=new TradePauseRecoverySession(channel,()=>Player,()=>true,()=>Now);
await session.StepAsync(default);Check.That(channel.Calls.Contains("resume"));
var stale=new Channel{Value=Menu(row=>row["tradePauseIdentity"]!["observedAtUnixMilliseconds"]=Now.AddSeconds(-1).ToUnixTimeMilliseconds())};
await new TradePauseRecoverySession(stale,()=>Player,()=>true,()=>Now).StepAsync(default);Check.That(!stale.Calls.Contains("renew")&&!stale.Calls.Contains("resume"));
}
[Test]public async Task ErrorsRespawnExitAndOrdinaryPanelsCannotBeDismissed()
{
foreach(var state in new[]{"InGame","MainMenu","ExitOptions","Rebirth","NetworkError","Pause Options","Supercruise"})
{
var channel=new Channel{Value=Menu(row=>{row["state"]=state;row["nextState"]=state;})};
await new TradePauseRecoverySession(channel,()=>Player,()=>true,()=>Now).StepAsync(default);Check.That(!channel.Calls.Contains("resume"));
}
}
[Test]public async Task DisabledDuplicateStaleAndWrongCategoryActionsCannotResume()
{
foreach(var mode in new[]{0,1,2,3,4})
{
var channel=new Channel{Value=Menu(row=>
{
if(mode==0)row["actions"]![0]!["enabled"]=false;
else if(mode==1)row["actions"]![1]!["name"]="InGame";
else if(mode==2)row["observedAtUnixMilliseconds"]=Now.AddSeconds(-1).ToUnixTimeMilliseconds();
else if(mode==3)row["kind"]=2;
else row["nextState"]="ExitOptions";
})};
await new TradePauseRecoverySession(channel,()=>Player,()=>true,()=>Now).StepAsync(default);Check.That(!channel.Calls.Contains("resume"));
}
}
[Test]public async Task CancellationBetweenObservationAndSubmissionRevokesWithoutAResume()
{
foreach(var afterRenew in new[]{false,true})
{
var running=true;var channel=new Channel();
if(afterRenew)channel.AfterRenew=()=>running=false;else channel.AfterRead=()=>running=false;
await new TradePauseRecoverySession(channel,()=>Player,()=>running,()=>Now).StepAsync(default);Check.That(!channel.Calls.Contains("resume"));
if(afterRenew)Check.That(channel.Calls.Contains("end"));
}
}
[Test]public async Task OneRequestWaitsForTheRealInGameObservationRatherThanRepeating()
{
var channel=new Channel();var session=new TradePauseRecoverySession(channel,()=>Player,()=>true,()=>Now);
await session.StepAsync(default);await session.StepAsync(default);Check.Equal(1,channel.Calls.Count(x=>x=="resume"));
channel.Value=Menu(row=>{row["state"]="InGame";row["nextState"]="InGame";row["tradePauseRecovery"]!["requestId"]=channel.ResumeId;row["tradePauseRecovery"]!["applied"]=true;});
await session.StepAsync(default);Check.Equal(1,session.ConfirmedResumes);Check.Equal(1,channel.Calls.Count(x=>x=="resume"));
}
[Test]public async Task ShipOrSessionChangeAndCurrentUnhealthyIdentityFailClosed()
{
var current=Player;var channel=new Channel{Value=Menu(row=>{row["state"]="InGame";row["nextState"]="InGame";})};
var session=new TradePauseRecoverySession(channel,()=>current,()=>true,()=>Now);
await session.StepAsync(default);Check.That(channel.Calls.Contains("renew"));
current=current with{CurrentShipId=6};channel.Value=Menu();
await session.StepAsync(default);Check.That(channel.Calls.Contains("end")&&!channel.Calls.Contains("resume"));
foreach(var mode in new[]{0,1,2})
{
var invalid=new Channel{Value=Menu(row=>
{
if(mode==0)row["tradePauseIdentity"]!["verified"]=false;
else if(mode==1)row["tradePauseIdentity"]!["hullFraction"]=0;
else row["tradePauseIdentity"]!["entityId"]="0";
})};
await new TradePauseRecoverySession(invalid,()=>Player,()=>true,()=>Now).StepAsync(default);
Check.That(!invalid.Calls.Contains("renew")&&!invalid.Calls.Contains("resume"));
}
}
}
using System.Text.Json;
using System.Text.Json.Nodes;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Session;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class TradePauseRecoveryTests
{
[Test]public void NativeRenewUsesTheUnsignedNumericEntityWireContractWithoutPrecisionLoss()
{
foreach(var entity in new[]{11825915469828UL,9007199254740993UL,ulong.MaxValue})
{
var request=JsonSerializer.SerializeToElement(new NativeTradePauseRenewRequest(new string('a',32),new string('b',32),entity),ToolboxSettings.JsonOptions);
// This is the exact decoder used by native JsonWire.U64. A JSON
// string would throw here even when it contains decimal digits.
Check.Equal(JsonValueKind.Number,request.GetProperty("entityId").ValueKind);
Check.Equal(entity,request.GetProperty("entityId").GetUInt64());
Check.Equal("trade.pause.renew",request.GetProperty("op").GetString());
Check.Equal(750,request.GetProperty("ttlMilliseconds").GetInt32());
}
var oldWire=JsonSerializer.SerializeToElement(new{entityId="11825915469828"});
Check.Throws<InvalidOperationException>(()=>oldWire.GetProperty("entityId").GetUInt64());
}
private static readonly DateTimeOffset Now=DateTimeOffset.Parse("2026-10-25T12:00:00Z");
private static PlayerState Player=>new(){InGame=true,CommanderId="owner",CurrentShipId=5,LoadGameAt=Now.AddHours(-1)};
private static JsonElement Menu(Action<JsonObject>? change=null)
{
var value=JsonSerializer.SerializeToNode(new{state="Pause",nextState="Pause",menuReady=true,kind=1,categoryName="Pause",menuSequence=12UL,
observedAtUnixMilliseconds=Now.ToUnixTimeMilliseconds(),flightTelemetryFresh=false,
actions=new[]{new{name="InGame",enabled=true},new{name="ExitOptions",enabled=true}},
tradePauseIdentity=new{verified=true,entityId="42",hullFraction=1d,observedAtUnixMilliseconds=Now.ToUnixTimeMilliseconds(),physicsTelemetryRequired=false},
tradePauseRecovery=new{requestId="",applied=false,failed=false}})!.AsObject();
change?.Invoke(value);return JsonSerializer.SerializeToElement(value);
}
private sealed class Channel:ITradePauseRecoveryChannel
{
public JsonElement Value=Menu();public List<string> Calls=[];public Action? AfterRead,AfterRenew;public string? ResumeId;
public Task PrepareAsync(CancellationToken token){Calls.Add("prepare");return Task.CompletedTask;}
public Task<JsonElement> ReadAsync(CancellationToken token){Calls.Add("read");AfterRead?.Invoke();return Task.FromResult(Value);}
public Task RenewAsync(string owner,string run,ulong entity,CancellationToken token){Calls.Add("renew");Check.Equal(42UL,entity);AfterRenew?.Invoke();return Task.CompletedTask;}
public Task ResumeAsync(string owner,string run,string id,ulong sequence,CancellationToken token){Calls.Add("resume");ResumeId=id;Check.Equal(12UL,sequence);return Task.CompletedTask;}
public Task EndAsync(string owner,string run){Calls.Add("end");return Task.CompletedTask;}
}
[Test]public async Task OnlyARunningScopeCanSubmitThePreciseOriginalPauseResume()
{
var running=false;var channel=new Channel();var session=new TradePauseRecoverySession(channel,()=>Player,()=>running,()=>Now);
await session.StepAsync(default);Check.Equal(0,channel.Calls.Count);
running=true;await session.StepAsync(default);Check.Equal(1,channel.Calls.Count(x=>x=="resume"));
running=false;await session.StepAsync(default);Check.Equal("end",channel.Calls.Last());
await session.EndAsync();running=true;await session.StepAsync(default);Check.Equal(1,channel.Calls.Count(x=>x=="resume"));
}
[Test]public async Task StaleFlightTelemetryDoesNotBlockFreshOriginalMenuThreadIdentity()
{
var channel=new Channel();var session=new TradePauseRecoverySession(channel,()=>Player,()=>true,()=>Now);
await session.StepAsync(default);Check.That(channel.Calls.Contains("resume"));
var stale=new Channel{Value=Menu(row=>row["tradePauseIdentity"]!["observedAtUnixMilliseconds"]=Now.AddSeconds(-1).ToUnixTimeMilliseconds())};
await new TradePauseRecoverySession(stale,()=>Player,()=>true,()=>Now).StepAsync(default);Check.That(!stale.Calls.Contains("renew")&&!stale.Calls.Contains("resume"));
}
[Test]public async Task ErrorsRespawnExitAndOrdinaryPanelsCannotBeDismissed()
{
foreach(var state in new[]{"InGame","MainMenu","ExitOptions","Rebirth","NetworkError","Pause Options","Supercruise"})
{
var channel=new Channel{Value=Menu(row=>{row["state"]=state;row["nextState"]=state;})};
await new TradePauseRecoverySession(channel,()=>Player,()=>true,()=>Now).StepAsync(default);Check.That(!channel.Calls.Contains("resume"));
}
}
[Test]public async Task DisabledDuplicateStaleAndWrongCategoryActionsCannotResume()
{
foreach(var mode in new[]{0,1,2,3,4})
{
var channel=new Channel{Value=Menu(row=>
{
if(mode==0)row["actions"]![0]!["enabled"]=false;
else if(mode==1)row["actions"]![1]!["name"]="InGame";
else if(mode==2)row["observedAtUnixMilliseconds"]=Now.AddSeconds(-1).ToUnixTimeMilliseconds();
else if(mode==3)row["kind"]=2;
else row["nextState"]="ExitOptions";
})};
await new TradePauseRecoverySession(channel,()=>Player,()=>true,()=>Now).StepAsync(default);Check.That(!channel.Calls.Contains("resume"));
}
}
[Test]public async Task CancellationBetweenObservationAndSubmissionRevokesWithoutAResume()
{
foreach(var afterRenew in new[]{false,true})
{
var running=true;var channel=new Channel();
if(afterRenew)channel.AfterRenew=()=>running=false;else channel.AfterRead=()=>running=false;
await new TradePauseRecoverySession(channel,()=>Player,()=>running,()=>Now).StepAsync(default);Check.That(!channel.Calls.Contains("resume"));
if(afterRenew)Check.That(channel.Calls.Contains("end"));
}
}
[Test]public async Task OneRequestWaitsForTheRealInGameObservationRatherThanRepeating()
{
var channel=new Channel();var session=new TradePauseRecoverySession(channel,()=>Player,()=>true,()=>Now);
await session.StepAsync(default);await session.StepAsync(default);Check.Equal(1,channel.Calls.Count(x=>x=="resume"));
channel.Value=Menu(row=>{row["state"]="InGame";row["nextState"]="InGame";row["tradePauseRecovery"]!["requestId"]=channel.ResumeId;row["tradePauseRecovery"]!["applied"]=true;});
await session.StepAsync(default);Check.Equal(1,session.ConfirmedResumes);Check.Equal(1,channel.Calls.Count(x=>x=="resume"));
}
[Test]public async Task ShipOrSessionChangeAndCurrentUnhealthyIdentityFailClosed()
{
var current=Player;var channel=new Channel{Value=Menu(row=>{row["state"]="InGame";row["nextState"]="InGame";})};
var session=new TradePauseRecoverySession(channel,()=>current,()=>true,()=>Now);
await session.StepAsync(default);Check.That(channel.Calls.Contains("renew"));
current=current with{CurrentShipId=6};channel.Value=Menu();
await session.StepAsync(default);Check.That(channel.Calls.Contains("end")&&!channel.Calls.Contains("resume"));
foreach(var mode in new[]{0,1,2})
{
var invalid=new Channel{Value=Menu(row=>
{
if(mode==0)row["tradePauseIdentity"]!["verified"]=false;
else if(mode==1)row["tradePauseIdentity"]!["hullFraction"]=0;
else row["tradePauseIdentity"]!["entityId"]="0";
})};
await new TradePauseRecoverySession(invalid,()=>Player,()=>true,()=>Now).StepAsync(default);
Check.That(!invalid.Calls.Contains("renew")&&!invalid.Calls.Contains("resume"));
}
}
}