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(6150); // 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); } }