using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class GameRouteMirrorTests
{
private static readonly DateTimeOffset Now=DateTimeOffset.Parse("2026-10-23T00:00:00Z");
private static NavWaypoint Point(long id)=>new(id,"System "+id,new(id,0,0),"K");
private static PlayerState Player=>new(){InGame=true,CommanderId="commander",LoadGameAt=Now,Navigation=new(){CurrentSystemAddress=1,Route=[Point(1),Point(2),Point(3)],RouteAt=Now}};
[Test] public void AGamePlottedRouteIsAdoptedOnceWithoutPlottingOrFlightCommands()
{
var mirror=new GameRouteMirror();var update=mirror.Observe(Player);
Check.That(update.Changed&&!update.Cleared&&!update.Arrived);Check.Equal(3L,update.Destination!.SystemAddress);
Check.That(!mirror.Observe(Player).Changed);
Check.That(!mirror.Observe(Player with{InGame=false,Navigation=new()}).Changed);
Check.That(!mirror.Observe(Player with{Navigation=Player.Navigation with{Route=[Point(1),Point(4)]}},true).Changed);
}
[Test] public void ManualClearAndFinalArrivalRemainDistinctAndANewGameRouteReplacesTheOldOne()
{
var mirror=new GameRouteMirror();mirror.Observe(Player);
var cleared=mirror.Observe(Player with{Navigation=Player.Navigation with{Route=[],RouteAt=Now.AddSeconds(1)}});
Check.That(cleared.Cleared&&!cleared.Arrived);
mirror.Observe(Player);
var arrived=mirror.Observe(Player with{Navigation=Player.Navigation with{Route=[],CurrentSystemAddress=3,RouteAt=Now.AddSeconds(2)}});
Check.That(arrived.Arrived&&!arrived.Cleared);
mirror.Observe(Player);
var replaced=mirror.Observe(Player with{Navigation=Player.Navigation with{Route=[Point(1),Point(4)],RouteAt=Now.AddSeconds(3)}});
Check.That(replaced.Replaced);Check.Equal(4L,replaced.Destination!.SystemAddress);
}
[Test] public void ARecentMatchingFinalJumpClearRetainsTheRouteUntilActualArrival()
{
var mirror=new GameRouteMirror(()=>Now.AddSeconds(12));mirror.Observe(Player);
var jumping=Player with{Navigation=Player.Navigation with{CurrentSystemAddress=2,Route=[],RouteAt=Now.AddSeconds(11),JumpInProgress=true,
JumpTargetSystemAddress=3,JumpTargetSystem="System 3",JumpStartedAt=Now.AddSeconds(10)}};
Check.That(!mirror.Observe(jumping).Cleared);
var arrived=mirror.Observe(jumping with{Navigation=jumping.Navigation with{JumpInProgress=false,LastJumpAt=Now.AddSeconds(12),CurrentSystemAddress=3}});
Check.That(arrived.Arrived&&!arrived.Cleared);
mirror.Observe(Player);
Check.That(mirror.Observe(jumping with{Navigation=jumping.Navigation with{JumpTargetSystemAddress=4,JumpTargetSystem="System 4"}}).Cleared);
mirror.Observe(Player);
Check.That(mirror.Observe(jumping with{Navigation=jumping.Navigation with{JumpStartedAt=Now.AddMinutes(-2)}}).Cleared);
}
[Test] public async Task CancellationRequiresBothTheOriginalGameAcknowledgementAndTheJournalClear()
{
var player=Player;var driver=new FakeDriver();
driver.Read=number=>
{
if(number==2)player=player with{Navigation=player.Navigation with{Route=[],RouteAt=Now.AddSeconds(1)}};
return new(71,true,false,false,0,0,false,true,0,0);
};
await new GameRouteCancellation(()=>player,TimeSpan.FromSeconds(2)).CancelAsync(3,driver);
Check.Equal(1,driver.Mutations);Check.That(driver.Reads>=3);
}
[Test] public async Task ChangingTheDestinationDuringCancellationPreservesTheNewRoute()
{
var player=Player;var driver=new FakeDriver();
driver.Read=_=>{player=player with{Navigation=player.Navigation with{Route=[Point(1),Point(4)]}};return new(71,false,true,false,0,0,false,false,0,0);};
await Rejects<InvalidOperationException>(()=>new GameRouteCancellation(()=>player).CancelAsync(3,driver));
Check.Equal(1,driver.Mutations);Check.Equal(4L,player.Navigation.Route[^1].SystemAddress);
}
[Test] public async Task UnsupportedOrAlreadyChangedRoutesCannotIssueNativeClear()
{
var driver=new FakeDriver{SupportsGameRouteCancellation=false};
await Rejects<InvalidOperationException>(()=>new GameRouteCancellation(()=>Player).CancelAsync(3,driver));
Check.Equal(0,driver.Mutations);driver.SupportsGameRouteCancellation=true;
await Rejects<InvalidOperationException>(()=>new GameRouteCancellation(()=>Player).CancelAsync(4,driver));
Check.Equal(0,driver.Mutations);
}
[Test] public async Task AFalseNativeClearNeverClearsTheGameRouteOnScreen()
{
var driver=new FakeDriver{Read=_=>new(71,true,false,false,0,0,false,true,0,0)};
await Rejects<OperationCanceledException>(()=>new GameRouteCancellation(()=>Player,TimeSpan.FromMilliseconds(150)).CancelAsync(3,driver));
Check.Equal(1,driver.Mutations);Check.Equal(3,Player.Navigation.Route.Count);
}
private static async Task Rejects<T>(Func<Task> action)where T:Exception
{try{await action();}catch(T){return;}throw new InvalidOperationException("Expected "+typeof(T).Name);}
private sealed class FakeDriver:IGameRouteCancellationDriver
{
public bool SupportsGameRouteCancellation{get;set;}=true;
public int Mutations,Reads;
public Func<int,GameRouteStatus> Read{get;set;}=_=>new(71,false,true,false,3,2,true,true,3,123);
public Task<long> RequestGameRouteCancellationAsync(long destination,CancellationToken token){Mutations++;return Task.FromResult(71L);}
public Task<GameRouteStatus> ReadGameRouteStatusAsync(long generation,long destination,CancellationToken token)=>Task.FromResult(Read(++Reads));
}
}
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class GameRouteMirrorTests
{
private static readonly DateTimeOffset Now=DateTimeOffset.Parse("2026-10-23T00:00:00Z");
private static NavWaypoint Point(long id)=>new(id,"System "+id,new(id,0,0),"K");
private static PlayerState Player=>new(){InGame=true,CommanderId="commander",LoadGameAt=Now,Navigation=new(){CurrentSystemAddress=1,Route=[Point(1),Point(2),Point(3)],RouteAt=Now}};
[Test] public void AGamePlottedRouteIsAdoptedOnceWithoutPlottingOrFlightCommands()
{
var mirror=new GameRouteMirror();var update=mirror.Observe(Player);
Check.That(update.Changed&&!update.Cleared&&!update.Arrived);Check.Equal(3L,update.Destination!.SystemAddress);
Check.That(!mirror.Observe(Player).Changed);
Check.That(!mirror.Observe(Player with{InGame=false,Navigation=new()}).Changed);
Check.That(!mirror.Observe(Player with{Navigation=Player.Navigation with{Route=[Point(1),Point(4)]}},true).Changed);
}
[Test] public void ManualClearAndFinalArrivalRemainDistinctAndANewGameRouteReplacesTheOldOne()
{
var mirror=new GameRouteMirror();mirror.Observe(Player);
var cleared=mirror.Observe(Player with{Navigation=Player.Navigation with{Route=[],RouteAt=Now.AddSeconds(1)}});
Check.That(cleared.Cleared&&!cleared.Arrived);
mirror.Observe(Player);
var arrived=mirror.Observe(Player with{Navigation=Player.Navigation with{Route=[],CurrentSystemAddress=3,RouteAt=Now.AddSeconds(2)}});
Check.That(arrived.Arrived&&!arrived.Cleared);
mirror.Observe(Player);
var replaced=mirror.Observe(Player with{Navigation=Player.Navigation with{Route=[Point(1),Point(4)],RouteAt=Now.AddSeconds(3)}});
Check.That(replaced.Replaced);Check.Equal(4L,replaced.Destination!.SystemAddress);
}
[Test] public void ARecentMatchingFinalJumpClearRetainsTheRouteUntilActualArrival()
{
var mirror=new GameRouteMirror(()=>Now.AddSeconds(12));mirror.Observe(Player);
var jumping=Player with{Navigation=Player.Navigation with{CurrentSystemAddress=2,Route=[],RouteAt=Now.AddSeconds(11),JumpInProgress=true,
JumpTargetSystemAddress=3,JumpTargetSystem="System 3",JumpStartedAt=Now.AddSeconds(10)}};
Check.That(!mirror.Observe(jumping).Cleared);
var arrived=mirror.Observe(jumping with{Navigation=jumping.Navigation with{JumpInProgress=false,LastJumpAt=Now.AddSeconds(12),CurrentSystemAddress=3}});
Check.That(arrived.Arrived&&!arrived.Cleared);
mirror.Observe(Player);
Check.That(mirror.Observe(jumping with{Navigation=jumping.Navigation with{JumpTargetSystemAddress=4,JumpTargetSystem="System 4"}}).Cleared);
mirror.Observe(Player);
Check.That(mirror.Observe(jumping with{Navigation=jumping.Navigation with{JumpStartedAt=Now.AddMinutes(-2)}}).Cleared);
}
[Test] public async Task CancellationRequiresBothTheOriginalGameAcknowledgementAndTheJournalClear()
{
var player=Player;var driver=new FakeDriver();
driver.Read=number=>
{
if(number==2)player=player with{Navigation=player.Navigation with{Route=[],RouteAt=Now.AddSeconds(1)}};
return new(71,true,false,false,0,0,false,true,0,0);
};
await new GameRouteCancellation(()=>player,TimeSpan.FromSeconds(2)).CancelAsync(3,driver);
Check.Equal(1,driver.Mutations);Check.That(driver.Reads>=3);
}
[Test] public async Task ChangingTheDestinationDuringCancellationPreservesTheNewRoute()
{
var player=Player;var driver=new FakeDriver();
driver.Read=_=>{player=player with{Navigation=player.Navigation with{Route=[Point(1),Point(4)]}};return new(71,false,true,false,0,0,false,false,0,0);};
await Rejects<InvalidOperationException>(()=>new GameRouteCancellation(()=>player).CancelAsync(3,driver));
Check.Equal(1,driver.Mutations);Check.Equal(4L,player.Navigation.Route[^1].SystemAddress);
}
[Test] public async Task UnsupportedOrAlreadyChangedRoutesCannotIssueNativeClear()
{
var driver=new FakeDriver{SupportsGameRouteCancellation=false};
await Rejects<InvalidOperationException>(()=>new GameRouteCancellation(()=>Player).CancelAsync(3,driver));
Check.Equal(0,driver.Mutations);driver.SupportsGameRouteCancellation=true;
await Rejects<InvalidOperationException>(()=>new GameRouteCancellation(()=>Player).CancelAsync(4,driver));
Check.Equal(0,driver.Mutations);
}
[Test] public async Task AFalseNativeClearNeverClearsTheGameRouteOnScreen()
{
var driver=new FakeDriver{Read=_=>new(71,true,false,false,0,0,false,true,0,0)};
await Rejects<OperationCanceledException>(()=>new GameRouteCancellation(()=>Player,TimeSpan.FromMilliseconds(150)).CancelAsync(3,driver));
Check.Equal(1,driver.Mutations);Check.Equal(3,Player.Navigation.Route.Count);
}
private static async Task Rejects<T>(Func<Task> action)where T:Exception
{try{await action();}catch(T){return;}throw new InvalidOperationException("Expected "+typeof(T).Name);}
private sealed class FakeDriver:IGameRouteCancellationDriver
{
public bool SupportsGameRouteCancellation{get;set;}=true;
public int Mutations,Reads;
public Func<int,GameRouteStatus> Read{get;set;}=_=>new(71,false,true,false,3,2,true,true,3,123);
public Task<long> RequestGameRouteCancellationAsync(long destination,CancellationToken token){Mutations++;return Task.FromResult(71L);}
public Task<GameRouteStatus> ReadGameRouteStatusAsync(long generation,long destination,CancellationToken token)=>Task.FromResult(Read(++Reads));
}
}