using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class ScoopThermalTests
{
private static readonly DateTimeOffset At=DateTimeOffset.Parse("2026-10-23T00:00:00Z");
private static FlightFrame Frame(double heat,double fuel=4,double radius=150)=>new()
{
Sequence=1,ObservedAt=At,SystemAddress=1,CurrentStar=new(1,"G",Vector3d.Zero,100,115,200),Position=new(radius,0,0),
HeatPercent=heat,FuelMain=fuel,FuelRequiredForJump=2,FuelNeededToNextRefuel=15.99,MinimumFuelToNextRefuel=5,
ScoopActive=true,ScoopPowered=true,RefuellingToFull=true,PreferredOrbitRadius=145,SpeedMetresPerSecond=10
};
[Test] public void RisingHeatNearTheLimitOnlyRequestsASmallOrbitAdjustment()
{
var controller=new ScoopThermalController();controller.Observe(Frame(91));
var adjustment=controller.Observe(Frame(92) with{ObservedAt=At.AddSeconds(.5),Sequence=2});
Check.That(adjustment.MovingOutward&&adjustment.Interrupted);Check.That(adjustment.Radius>150&&adjustment.Radius<151.1);
Check.That(!adjustment.CanSkipFullTank);
}
[Test] public void ARealFlatOrFallingTemperatureStopsOutwardTravelImmediately()
{
foreach(var heat in new[]{94d,93.9})
{
var controller=new ScoopThermalController();controller.Observe(Frame(94));
var held=controller.Observe(Frame(heat,radius:150.4) with{ObservedAt=At.AddSeconds(.1),Sequence=2});
Check.That(!held.MovingOutward);Check.Equal(150.4,held.Radius);
}
}
[Test] public void InterruptedRefuellingCanDepartOnTheVerifiedRouteBudgetWithoutFillingTheTank()
{
var controller=new ScoopThermalController();var hot=controller.Observe(Frame(94,6));
Check.That(hot.CanSkipFullTank);var cool=controller.Observe(Frame(84,6) with{ObservedAt=At.AddSeconds(.2),Sequence=2});
Check.That(cool.CanSkipFullTank&&!cool.MovingOutward);Check.That(6<Frame(84).FuelNeededToNextRefuel);
var insufficient=new ScoopThermalController().Observe(Frame(94,4.99));Check.That(!insufficient.CanSkipFullTank);
}
[Test] public void ANewStarDoesNotInheritThePreviousThermalInterruptionOrOrbitRadius()
{
var controller=new ScoopThermalController();controller.Observe(Frame(94,6));
var next=controller.Observe(Frame(70,6) with{Sequence=2,ObservedAt=At.AddSeconds(2),SystemAddress=2,CurrentStar=new(2,"K",Vector3d.Zero,100,115,200)});
Check.That(!next.Interrupted&&!next.CanSkipFullTank&&!next.MovingOutward);Check.Equal(145d,next.Radius);
}
[Test] public void RisingTemperatureDuringDepartureAlsoRequiresAnOutwardCorrection()
{
var controller=new ScoopThermalController();
var frame=Frame(88,16) with{ScoopActive=false,RefuellingToFull=false,FuelNeededToNextRefuel=5};
controller.Observe(frame);
var hot=controller.Observe(frame with{Sequence=2,ObservedAt=At.AddSeconds(.25),HeatPercent=91});
Check.That(hot.MovingOutward&&hot.Radius>150);
// Even if the ship still drifts inward during the turn, a flat sample
// must not reset the orbit back to the hotter, smaller radius.
var flat=controller.Observe(frame with{Sequence=3,ObservedAt=At.AddSeconds(.5),HeatPercent=91,Position=new(149,0,0)});
Check.That(!flat.MovingOutward&&flat.Radius>=150);
}
[Test] public void AnInwardSlowTurningShipStartsCoolingBeforeItsTurnWouldOverheat()
{
var controller=new ScoopThermalController();
var frame=Frame(84,16) with{ScoopActive=false,RefuellingToFull=false,FuelNeededToNextRefuel=5,Forward=new(-.5,0,Math.Sqrt(.75)),AlignmentRateLowerDegreesPerSecond=3};
controller.Observe(frame);
var next=controller.Observe(frame with{ObservedAt=At.AddSeconds(1),Sequence=2,HeatPercent=85});
Check.That(next.MovingOutward&&next.Radius>150);
}
[Test] public void StableTemperatureIgnoresAnOldLargeOrbitDemandThenAllowsRefuellingAfterCooling()
{
var controller=new ScoopThermalController();controller.Observe(Frame(94));
var stable=controller.Observe(Frame(94,radius:150.4) with{ObservedAt=At.AddSeconds(.1),Sequence=2,PreferredOrbitRadius=180});
Check.Equal(150.4,stable.Radius);Check.That(!stable.MovingOutward);
var cool=controller.Observe(Frame(84,radius:150.4) with{ObservedAt=At.AddSeconds(.2),Sequence=3,PreferredOrbitRadius=145});
Check.Equal(145d,cool.Radius);Check.That(!cool.CanSkipFullTank&&!cool.MovingOutward);
}
[Test] public void HotVisibleInwardDestinationCannotOverrideCoolingEvenWithEnoughFuel()
{
foreach(var fuel in new[]{4d,16d})
{
var frame=Frame(91,fuel) with{GameSha256="build",LoadoutFingerprint="fit",GeometryVerified=true,AdaptiveFlightControl=true,InSupercruise=true,
Forward=new(-.5,0,Math.Sqrt(.75)),Up=new(0,1,0),TargetPosition=new(0,0,1000),TargetSystemAddress=2,Fsd=FsdPhase.Idle,
CanBeginCharge=true,NativeAllowsCharging=true,NativeFullChargePeak=94,JumpCommitInterlockVerified=true,AlignmentRateLowerDegreesPerSecond=10,
AngularSpeedDegreesPerSecond=0,ScoopActive=fuel<5,RefuellingToFull=fuel<5};
Check.That(!FlightGeometry.Obscures(frame.CurrentStar!,frame.Position,frame.TargetPosition));
var navigator=new FlightNavigator();var next=new NavWaypoint(2,"Visible inward next",new(1,0,0),"K");
var hot=navigator.Decide(frame,next,null,At);
Check.That(hot.Direction is {} direction&&Vector3d.Dot(direction,new(1,0,0))>.2);
Check.Equal(0d,hot.Throttle);Check.That(!hot.BeginCharging&&!hot.PermitJump);
var at=At.AddMilliseconds(100);
var aligned=frame with{Sequence=2,ObservedAt=at,HeatPercent=91.1,Forward=hot.Direction!.Value,Position=new(149.8,0,0)};
var continuing=navigator.Decide(aligned,next,null,at);
Check.That(continuing.Throttle>0&&continuing.Throttle<=.4);
Check.That(Vector3d.Dot(continuing.Direction!.Value,new(1,0,0))>.2);
}
}
[Test] public void TheNavigatorWaitsNearbyThenChargesAfterCoolingWithEnoughFuel()
{
var frame=Frame(94,6) with{GameSha256="build",LoadoutFingerprint="fit",GeometryVerified=true,AdaptiveFlightControl=true,InSupercruise=true,
Forward=new(1,0,0),Up=new(0,1,0),TargetPosition=new(10000,0,0),TargetSystemAddress=2,Fsd=FsdPhase.Idle,CanBeginCharge=true,
NativeAllowsCharging=false,NativeFullChargePeak=104,JumpCommitInterlockVerified=true,AlignmentRateLowerDegreesPerSecond=10,AngularSpeedDegreesPerSecond=0};
var model=new ShipThermalCalibration{GameSha256="build",LoadoutFingerprint="fit",StarBodyId=1,Verified=true,ExpiresAt=At.AddHours(1),
MinimumHeat=0,MaximumHeat=150,ChargingGainUpperPerSecond=.1,ChargedHoldGainUpperPerSecond=.1,UncertaintyPercent=1,ChargeDurationUpperSeconds=10,
DistanceBands=[new(105,1000,0,1)],Evidence="Independent offline thermal fixture"};
var navigator=new FlightNavigator();var next=new NavWaypoint(2,"Next",new(10,0,0),"K");
var hot=navigator.Decide(frame,next,model,At);
Check.That(!hot.BeginCharging&&hot.Throttle<=.4&&hot.Direction is {} direction&&Vector3d.Dot(direction,new(1,0,0))<.9);
var cool=frame with{Sequence=2,ObservedAt=At.AddSeconds(.1),HeatPercent=84,NativeAllowsCharging=true,NativeFullChargePeak=90};
var ready=navigator.Decide(cool,next,model,cool.ObservedAt);Check.That(ready.BeginCharging&&!ready.CancelCharging);
}
}
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class ScoopThermalTests
{
private static readonly DateTimeOffset At=DateTimeOffset.Parse("2026-10-23T00:00:00Z");
private static FlightFrame Frame(double heat,double fuel=4,double radius=150)=>new()
{
Sequence=1,ObservedAt=At,SystemAddress=1,CurrentStar=new(1,"G",Vector3d.Zero,100,115,200),Position=new(radius,0,0),
HeatPercent=heat,FuelMain=fuel,FuelRequiredForJump=2,FuelNeededToNextRefuel=15.99,MinimumFuelToNextRefuel=5,
ScoopActive=true,ScoopPowered=true,RefuellingToFull=true,PreferredOrbitRadius=145,SpeedMetresPerSecond=10
};
[Test] public void RisingHeatNearTheLimitOnlyRequestsASmallOrbitAdjustment()
{
var controller=new ScoopThermalController();controller.Observe(Frame(91));
var adjustment=controller.Observe(Frame(92) with{ObservedAt=At.AddSeconds(.5),Sequence=2});
Check.That(adjustment.MovingOutward&&adjustment.Interrupted);Check.That(adjustment.Radius>150&&adjustment.Radius<151.1);
Check.That(!adjustment.CanSkipFullTank);
}
[Test] public void ARealFlatOrFallingTemperatureStopsOutwardTravelImmediately()
{
foreach(var heat in new[]{94d,93.9})
{
var controller=new ScoopThermalController();controller.Observe(Frame(94));
var held=controller.Observe(Frame(heat,radius:150.4) with{ObservedAt=At.AddSeconds(.1),Sequence=2});
Check.That(!held.MovingOutward);Check.Equal(150.4,held.Radius);
}
}
[Test] public void InterruptedRefuellingCanDepartOnTheVerifiedRouteBudgetWithoutFillingTheTank()
{
var controller=new ScoopThermalController();var hot=controller.Observe(Frame(94,6));
Check.That(hot.CanSkipFullTank);var cool=controller.Observe(Frame(84,6) with{ObservedAt=At.AddSeconds(.2),Sequence=2});
Check.That(cool.CanSkipFullTank&&!cool.MovingOutward);Check.That(6<Frame(84).FuelNeededToNextRefuel);
var insufficient=new ScoopThermalController().Observe(Frame(94,4.99));Check.That(!insufficient.CanSkipFullTank);
}
[Test] public void ANewStarDoesNotInheritThePreviousThermalInterruptionOrOrbitRadius()
{
var controller=new ScoopThermalController();controller.Observe(Frame(94,6));
var next=controller.Observe(Frame(70,6) with{Sequence=2,ObservedAt=At.AddSeconds(2),SystemAddress=2,CurrentStar=new(2,"K",Vector3d.Zero,100,115,200)});
Check.That(!next.Interrupted&&!next.CanSkipFullTank&&!next.MovingOutward);Check.Equal(145d,next.Radius);
}
[Test] public void RisingTemperatureDuringDepartureAlsoRequiresAnOutwardCorrection()
{
var controller=new ScoopThermalController();
var frame=Frame(88,16) with{ScoopActive=false,RefuellingToFull=false,FuelNeededToNextRefuel=5};
controller.Observe(frame);
var hot=controller.Observe(frame with{Sequence=2,ObservedAt=At.AddSeconds(.25),HeatPercent=91});
Check.That(hot.MovingOutward&&hot.Radius>150);
// Even if the ship still drifts inward during the turn, a flat sample
// must not reset the orbit back to the hotter, smaller radius.
var flat=controller.Observe(frame with{Sequence=3,ObservedAt=At.AddSeconds(.5),HeatPercent=91,Position=new(149,0,0)});
Check.That(!flat.MovingOutward&&flat.Radius>=150);
}
[Test] public void AnInwardSlowTurningShipStartsCoolingBeforeItsTurnWouldOverheat()
{
var controller=new ScoopThermalController();
var frame=Frame(84,16) with{ScoopActive=false,RefuellingToFull=false,FuelNeededToNextRefuel=5,Forward=new(-.5,0,Math.Sqrt(.75)),AlignmentRateLowerDegreesPerSecond=3};
controller.Observe(frame);
var next=controller.Observe(frame with{ObservedAt=At.AddSeconds(1),Sequence=2,HeatPercent=85});
Check.That(next.MovingOutward&&next.Radius>150);
}
[Test] public void StableTemperatureIgnoresAnOldLargeOrbitDemandThenAllowsRefuellingAfterCooling()
{
var controller=new ScoopThermalController();controller.Observe(Frame(94));
var stable=controller.Observe(Frame(94,radius:150.4) with{ObservedAt=At.AddSeconds(.1),Sequence=2,PreferredOrbitRadius=180});
Check.Equal(150.4,stable.Radius);Check.That(!stable.MovingOutward);
var cool=controller.Observe(Frame(84,radius:150.4) with{ObservedAt=At.AddSeconds(.2),Sequence=3,PreferredOrbitRadius=145});
Check.Equal(145d,cool.Radius);Check.That(!cool.CanSkipFullTank&&!cool.MovingOutward);
}
[Test] public void HotVisibleInwardDestinationCannotOverrideCoolingEvenWithEnoughFuel()
{
foreach(var fuel in new[]{4d,16d})
{
var frame=Frame(91,fuel) with{GameSha256="build",LoadoutFingerprint="fit",GeometryVerified=true,AdaptiveFlightControl=true,InSupercruise=true,
Forward=new(-.5,0,Math.Sqrt(.75)),Up=new(0,1,0),TargetPosition=new(0,0,1000),TargetSystemAddress=2,Fsd=FsdPhase.Idle,
CanBeginCharge=true,NativeAllowsCharging=true,NativeFullChargePeak=94,JumpCommitInterlockVerified=true,AlignmentRateLowerDegreesPerSecond=10,
AngularSpeedDegreesPerSecond=0,ScoopActive=fuel<5,RefuellingToFull=fuel<5};
Check.That(!FlightGeometry.Obscures(frame.CurrentStar!,frame.Position,frame.TargetPosition));
var navigator=new FlightNavigator();var next=new NavWaypoint(2,"Visible inward next",new(1,0,0),"K");
var hot=navigator.Decide(frame,next,null,At);
Check.That(hot.Direction is {} direction&&Vector3d.Dot(direction,new(1,0,0))>.2);
Check.Equal(0d,hot.Throttle);Check.That(!hot.BeginCharging&&!hot.PermitJump);
var at=At.AddMilliseconds(100);
var aligned=frame with{Sequence=2,ObservedAt=at,HeatPercent=91.1,Forward=hot.Direction!.Value,Position=new(149.8,0,0)};
var continuing=navigator.Decide(aligned,next,null,at);
Check.That(continuing.Throttle>0&&continuing.Throttle<=.4);
Check.That(Vector3d.Dot(continuing.Direction!.Value,new(1,0,0))>.2);
}
}
[Test] public void TheNavigatorWaitsNearbyThenChargesAfterCoolingWithEnoughFuel()
{
var frame=Frame(94,6) with{GameSha256="build",LoadoutFingerprint="fit",GeometryVerified=true,AdaptiveFlightControl=true,InSupercruise=true,
Forward=new(1,0,0),Up=new(0,1,0),TargetPosition=new(10000,0,0),TargetSystemAddress=2,Fsd=FsdPhase.Idle,CanBeginCharge=true,
NativeAllowsCharging=false,NativeFullChargePeak=104,JumpCommitInterlockVerified=true,AlignmentRateLowerDegreesPerSecond=10,AngularSpeedDegreesPerSecond=0};
var model=new ShipThermalCalibration{GameSha256="build",LoadoutFingerprint="fit",StarBodyId=1,Verified=true,ExpiresAt=At.AddHours(1),
MinimumHeat=0,MaximumHeat=150,ChargingGainUpperPerSecond=.1,ChargedHoldGainUpperPerSecond=.1,UncertaintyPercent=1,ChargeDurationUpperSeconds=10,
DistanceBands=[new(105,1000,0,1)],Evidence="Independent offline thermal fixture"};
var navigator=new FlightNavigator();var next=new NavWaypoint(2,"Next",new(10,0,0),"K");
var hot=navigator.Decide(frame,next,model,At);
Check.That(!hot.BeginCharging&&hot.Throttle<=.4&&hot.Direction is {} direction&&Vector3d.Dot(direction,new(1,0,0))<.9);
var cool=frame with{Sequence=2,ObservedAt=At.AddSeconds(.1),HeatPercent=84,NativeAllowsCharging=true,NativeFullChargePeak=90};
var ready=navigator.Decide(cool,next,model,cool.ObservedAt);Check.That(ready.BeginCharging&&!ready.CancelCharging);
}
}