using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Navigation; using XFEEDToolBox.Core.Data; using System.Text.Json; using XFEExtension.NetCore.XUnit.Attributes; namespace XFEEDToolBox.Test; [TestFixture] public sealed class NavigationFuelResupplyTests { private static PlayerState StaticDocked(DateTimeOffset now)=>new(){InGame=true,Docked=true,MarketId=3,CommanderId="fuel-test",CurrentShipId=8, LoadGameAt=now.AddMinutes(-20),CargoReliable=true,CargoCapacity=1200,Status=new(1,0,24.524504,1,0,null,null,null,null,[]), StatusAt=now.AddMinutes(-15),StatusWrittenAt=now.AddMinutes(-15),StatusReadAt=now}; [Test]public void ReconfirmedStaticFuelIsAllowedOnlyForTheSameDockedSessionAndCannotCompleteAPaidRefuel() { var now=DateTimeOffset.UtcNow;var state=StaticDocked(now); Check.Equal(24.524504,NavigationFuelResupply.CurrentFuel(state,now)); foreach(var rejected in new[]{state with{Docked=false},state with{StatusReadAt=now.AddSeconds(-3)},state with{LoadGameAt=now.AddMinutes(-1)}, state with{StatusWrittenAt=now.AddSeconds(2)},state with{Status=state.Status! with{Flags=16}},state with{Navigation=new(){MainFuel=40,FuelAt=now.AddMinutes(-14)}}}) Check.Throws(()=>NavigationFuelResupply.CurrentFuel(rejected,now)); var reread=state with{StatusReadAt=now.AddSeconds(1)}; Check.That(!NavigationFuelResupply.IsPostReceiptSample(state,reread,now.AddMilliseconds(-500),now,now.AddSeconds(1),true)); var newSample=reread with{StatusAt=now.AddSeconds(2),StatusWrittenAt=now.AddMilliseconds(200),StatusReadAt=now.AddMilliseconds(300),Status=state.Status! with{FuelMain=128}}; Check.That(NavigationFuelResupply.IsPostReceiptSample(state,newSample,now.AddMilliseconds(-500),now,now.AddMilliseconds(300),true)); } [Test]public void ARealSameSecondFuelWriteAfterTheReceiptIsConfirmedButRereadingOrTouchingOldContentIsNot() { var receipt=DateTimeOffset.Parse("2026-10-26T18:52:07.4761144Z");var submitted=receipt.AddMilliseconds(-300);var now=receipt.AddSeconds(1); var before=StaticDocked(now) with{StatusAt=DateTimeOffset.Parse("2026-10-26T18:52:13Z"),StatusWrittenAt=submitted.AddMilliseconds(-100),StatusReadAt=submitted}; var updated=before with{Status=before.Status! with{FuelMain=128},StatusWrittenAt=DateTimeOffset.Parse("2026-10-26T18:52:07.8714283Z"),StatusReadAt=now}; Check.Equal(before.StatusAt,updated.StatusAt);Check.That(NavigationFuelResupply.IsPostReceiptSample(before,updated,submitted,receipt,now,true)); Check.That(!NavigationFuelResupply.IsPostReceiptSample(before,before with{StatusReadAt=now},submitted,receipt,now,true)); Check.That(!NavigationFuelResupply.IsPostReceiptSample(before,before with{StatusWrittenAt=updated.StatusWrittenAt,StatusReadAt=now},submitted,receipt,now,true)); Check.That(!NavigationFuelResupply.IsPostReceiptSample(before,updated with{CurrentShipId=9},submitted,receipt,now,true)); Check.That(!NavigationFuelResupply.IsPostReceiptSample(before,updated with{StatusReadAt=receipt.AddMilliseconds(-1)},submitted,receipt,now,true)); Check.That(!NavigationFuelResupply.IsPostReceiptSample(before,updated with{StatusWrittenAt=submitted.AddMilliseconds(-1)},submitted,receipt,now,true)); // The original game can publish Status before the host receives its // confirmed reply. It still belongs to the same submitted mutation. Check.That(NavigationFuelResupply.IsPostReceiptSample(before,updated with{StatusWrittenAt=receipt.AddMilliseconds(-50)},submitted,receipt,now,true)); } [Test]public void AReservoirOnlyPaidRefuelConfirmsTheServiceAndReplansOnlyWhenTotalFuelMassIncreases() { var now=DateTimeOffset.UtcNow;var submitted=now.AddSeconds(-1);var receipt=now.AddMilliseconds(-500); var before=StaticDocked(now) with{StatusAt=now,StatusWrittenAt=submitted.AddSeconds(-1),StatusReadAt=submitted.AddMilliseconds(-10), Status=StaticDocked(now).Status! with{FuelMain=128,FuelReserve=1}}; var reservoirOnly=before with{Status=before.Status! with{FuelReserve=1.0625},StatusWrittenAt=now.AddMilliseconds(-200),StatusReadAt=now}; Check.Equal(128d,NavigationFuelResupply.CurrentFuel(reservoirOnly,now)); Check.That(NavigationFuelResupply.IsPostReceiptSample(before,reservoirOnly,submitted,receipt,now,true)); Check.That(NavigationFuelResupply.FuelMassIncreased(before,reservoirOnly)); // The main tank can feed the reservoir before the next status sample. // Its new reservoir value proves the paid result, but unchanged total // fuel mass does not require another original route calculation. var noMassIncrease=reservoirOnly with{Status=reservoirOnly.Status! with{FuelMain=127.9375}}; Check.That(NavigationFuelResupply.IsPostReceiptSample(before,noMassIncrease,submitted,receipt,now,true)); Check.That(!NavigationFuelResupply.FuelMassIncreased(before,noMassIncrease)); Check.That(!NavigationFuelResupply.IsPostReceiptSample(before,reservoirOnly,submitted,receipt,now,false)); Check.That(!NavigationFuelResupply.IsPostReceiptSample(before,before with{StatusWrittenAt=reservoirOnly.StatusWrittenAt,StatusReadAt=now},submitted,receipt,now,true)); Check.That(!NavigationFuelResupply.IsPostReceiptSample(before,reservoirOnly with{Status=reservoirOnly.Status! with{FuelReserve=null}},submitted,receipt,now,true)); } [Test]public async Task DockedFuelReadWaitsForAReconfirmedSampleBeforeReturningTheBudget() { var state=StaticDocked(DateTimeOffset.UtcNow) with{StatusReadAt=null};var initial=state;var checks=0;var paid=false; var snapshot=await NavigationFuelResupply.ReadDockedBudgetAsync(()=>state,cancellation=> { cancellation.ThrowIfCancellationRequested();NavigationFuelResupply.RequireSameCargo(initial,state);checks++;Check.That(!paid); if(checks==2)state=state with{StatusReadAt=DateTimeOffset.UtcNow};return Task.CompletedTask; },()=>.5,false,default,TimeSpan.FromSeconds(1),TimeSpan.FromMilliseconds(1)); paid=true;Check.Equal(2,checks);Check.Equal(24.524504,snapshot.Available);Check.Equal(.75,snapshot.Required); } [Test]public async Task FuelWaitIsBoundedAndNeverRetriesIdentitySafetyOrUserCancellation() { var stale=StaticDocked(DateTimeOffset.UtcNow) with{StatusReadAt=null};var checks=0;var bounded=false; try{await NavigationFuelResupply.ReadDockedBudgetAsync(()=>stale,_=>{checks++;return Task.CompletedTask;},()=>.5,false,default,TimeSpan.FromMilliseconds(30),TimeSpan.FromMilliseconds(1));} catch(InvalidDataException error){bounded=error.InnerException is FlightTelemetryUnavailableException;} Check.That(bounded&&checks>0); foreach(var cause in new[]{"identity","cargo","safety","cancel"}) { using var stop=new CancellationTokenSource();checks=0;Exception? actual=null; try { await NavigationFuelResupply.ReadDockedBudgetAsync(()=>stale,cancellation=> { checks++;if(checks==1)return Task.CompletedTask; if(cause=="cancel"){stop.Cancel();cancellation.ThrowIfCancellationRequested();} if(cause=="safety")throw new FlightHealthPauseException(); if(cause=="cargo")NavigationFuelResupply.RequireSameCargo(stale,stale with{CargoCount=1}); throw new InvalidDataException("identity changed"); },()=>.5,false,stop.Token,TimeSpan.FromSeconds(1),TimeSpan.FromMilliseconds(1)); } catch(Exception error){actual=error;} Check.Equal(2,checks);Check.That(cause switch{"cancel"=>actual is OperationCanceledException,"safety"=>actual is FlightHealthPauseException,_=>actual is InvalidDataException}); } } [Test]public async Task ARealUnchangedDockedStatusFileRetainsItsFuelAndTimestampAcrossMonitorReconfirmation() { using var directory=new TemporaryDirectory();var eventAt=DateTimeOffset.UtcNow.AddMinutes(-15); await File.WriteAllTextAsync(directory.File("Journal.01.log"),string.Join('\n',new object[]{ new{@event="LoadGame",timestamp=eventAt.AddMinutes(-1),FID="fuel-test",Commander="Pilot",ShipID=8,GameMode="Solo"}, new{@event="Location",timestamp=eventAt.AddSeconds(-1),Docked=true,MarketID=3,StationName="Origin",StarSystem="Here",SystemAddress=1}, new{@event="Cargo",timestamp=eventAt.AddSeconds(-1),Vessel="Ship",Inventory=Array.Empty()}} .Select(value=>JsonSerializer.Serialize(value)))+"\n"); var status=directory.File("Status.json");var original=JsonSerializer.Serialize(new{timestamp=eventAt,@event="Status",Flags=1,Fuel=new{FuelMain=24.524504,FuelReservoir=1},Cargo=0}); await File.WriteAllTextAsync(status,original);File.SetLastWriteTimeUtc(status,eventAt.UtcDateTime); await using var monitor=new JournalMonitor(directory.Path);await monitor.StartAsync();var first=monitor.State.StatusReadAt; Check.Equal(24.524504,NavigationFuelResupply.CurrentFuel(monitor.State,DateTimeOffset.UtcNow));await Task.Delay(1100); Check.That(monitor.State.StatusReadAt>first);Check.Equal(eventAt,monitor.State.StatusAt!.Value); Check.Equal(original,await File.ReadAllTextAsync(status));Check.Equal(24.524504,NavigationFuelResupply.CurrentFuel(monitor.State,DateTimeOffset.UtcNow)); var now=DateTimeOffset.UtcNow; Check.That(!NavigationFuelResupply.IsPostReceiptSample(monitor.State,monitor.State,now.AddSeconds(-1),now.AddMilliseconds(-500),now,true)); } [Test]public void ActualUnscoopableTwoLegBudgetIncludesTheReserveInsteadOfDemandingAFull128TonneTank() { var now=DateTimeOffset.UtcNow; var ship=new ShipRecord{ShipId=8,UnladenMass=1494.5,FuelMain=128,ConfigurationAt=now, Modules=[new(){Slot="FrameShiftDrive",Item="int_hyperdrive_overcharge_size7_class5",Enabled=true, Engineering=new(){Modifiers=[new("FSDOptimalMass",3949.799805,null,false)]}}]}; var player=new PlayerState{CurrentShipId=8,Ships=[ship],CargoReliable=true,Status=new(0,0,30,1.104355,0,null,null,null,null,[]),StatusAt=now, Navigation=new(){CurrentSystemAddress=1,Route=[new(1,"HR 8061",new(33.46875,-37.9375,39.9375),"K"), new(2,"LB 3303",new(22.9375,-22.9375,6.5625),"T"),new(3,"LHS 1731",new(15.71875,-15.46875,-20.40625),"M")]}}; Check.That(!NavigationPreviewBuilder.HasScoop(ship)); Check.That(Math.Abs(NavigationFuelResupply.DepartureBudget(player,.5,true,now)-26.95)<.00001); Check.Equal(.75,NavigationFuelResupply.DepartureBudget(player,.5,false,now)); } [Test]public void OnlyTheMinimumBudgetCanRequestStationFuelAndAScoopRemainsUsable() { var frame=new FlightFrame{SystemAddress=1,FuelMain=30,FuelRequiredForJump=13.1,FuelNeededToNextRefuel=127.99,MinimumFuelToNextRefuel=26.95}; Check.That(NavigationFuelResupply.Required(frame) is null); var shortage=NavigationFuelResupply.Required(frame with{FuelMain=24.524504}); Check.That(shortage is not null);Check.Equal(26.95,shortage!.Required);Check.Equal(1L,shortage.SystemAddress); Check.That(NavigationFuelResupply.Required(frame with{FuelMain=1,ScoopPowered=true}) is null); Check.That(NavigationFuelResupply.Required(frame with{FuelMain=1,Fsd=FsdPhase.Jumping}) is null); Check.That(NavigationFuelResupply.Required(frame with{FuelMain=double.NaN}) is null); } [Test]public void FuelReturnRequiresThisJourneysOriginalDockAndTheSameSystem() { var initial=new PlayerState{InGame=true,Docked=true,MarketId=3,StationName="Known origin",StationServicesKnown=true,StationServices=["refuel"],SystemName="Here",Navigation=new(){CurrentSystemAddress=1}}; var origin=NavigationFuelResupply.CaptureOrigin(initial);var flying=initial with{Docked=false};var shortage=new NavigationFuelResupplyRequiredException(1,1,2); Check.That(origin is not null&&NavigationFuelResupply.CanReturn(initial,flying,origin,shortage,false)); Check.That(!NavigationFuelResupply.CanReturn(initial,flying,origin,shortage,true)); Check.That(!NavigationFuelResupply.CanReturn(initial,flying,null,shortage,false)); Check.That(!NavigationFuelResupply.CanReturn(initial,flying with{Navigation=new(){CurrentSystemAddress=2}},origin,shortage,false)); Check.That(NavigationFuelResupply.CaptureOrigin(initial with{StationServices=[]}) is null); Check.That(NavigationFuelResupply.CaptureOrigin(initial with{StationName=""}) is null); } [Test]public void FuelPreparationRequiresFreshFiniteFuelAndTheActualCargoIdentity() { var now=DateTimeOffset.UtcNow;var player=new PlayerState{CargoReliable=true,CargoCapacity=1200,CargoCount=1,Inventory=[new("water","Water",1)],Navigation=new(){MainFuel=30,FuelAt=now}}; Check.Equal(30d,NavigationFuelResupply.CurrentFuel(player,now)); Check.Throws(()=>NavigationFuelResupply.CurrentFuel(player with{Navigation=player.Navigation with{FuelAt=now.AddSeconds(-11)}},now)); Check.Throws(()=>NavigationFuelResupply.CurrentFuel(player with{Navigation=player.Navigation with{MainFuel=double.NaN}},now)); NavigationFuelResupply.RequireSameCargo(player,player); Check.Throws(()=>NavigationFuelResupply.RequireSameCargo(player,player with{Inventory=[new("gold","Gold",1)]})); Check.Throws(()=>NavigationFuelResupply.RequireSameCargo(player,player with{CargoReliable=false})); } }