using XFEExtension.NetCore.XUnit.Attributes; using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Navigation; namespace XFEEDToolBox.Test; [TestFixture] public class NativeFrameProjectorTests { private static readonly DateTimeOffset Now=DateTimeOffset.Parse("2026-10-21T08:00:00Z"); private static PlayerState Player(double fuel=5,bool scoop=true)=>new(){InGame=true,CurrentShipId=7,StatusAt=Now, Navigation=new(){CurrentSystemAddress=1},Status=new(16|(scoop?2048:0),0,fuel,1,0,null,null,null,null,[]), Ships=[new(){ShipId=7,Type="anaconda",ConfigurationAt=Now,FuelMain=32,UnladenMass=500, Modules=[new(){Slot="FrameShiftDrive",Item="int_hyperdrive_size6_class5",Enabled=true},new(){Slot="Slot01_Size6",Item="int_fuelscoop_size6_class5",Enabled=true}]}]}; // Synthetic transport fixture, deliberately independent of game allocations and accounts. private static System.Text.Json.JsonElement Raw(double radius=1500,bool fresh=true,string fsdEntity="42",bool cooling=true,double heat=30,double nativePeak=88)=>Check.Event(new{ sequence=1,observedAtUnixMilliseconds=Now.ToUnixTimeMilliseconds(),entityId="42",localPlayerShipVerified=true,heatNormalizationVerified=true,heatDisplayScale=1d, position=new[]{radius,0d,0d},forward=new[]{0d,1d,0d},up=new[]{0d,0d,1d},heatPercent=heat,internalHeatPercent=heat,speedMetresPerSecond=30000d,playerOverride=false, fsd=new{fresh,entityId=fsdEntity,targetValid=true,targetAddress="2",targetDirection=new[]{-1d,0d,0d},targetDistance=1e16,state=2,duration=60d,clock=0d,speedFraction=.01, driveParametersValid=true,driveChargeSeconds=15d,driveHeatBase=37d,driveHeatRamp=0d,heatCapacity=334d,coolingConfigurationValid=true,coolingPassive=0d,coolingMaximum=67.15,coolingHeatLower=0d,coolingHeatUpper=1d}, geometry=new{fresh=true,worldAddress="1",bodyAddress="1",bodyKind=1,starType=4,exclusionRadius=1000d}, thermal=new{fresh=true,coolingActive=cooling,coolingScale=1d,idleInputUpper=20d,allowsCharging=true,fullChargeAndHoldPeak=nativePeak,evidence="Synthetic input/cooling fixture"}}); [Test] public void ConvertsNativeHeatUnitsAndSeparatesFuelBudgetFromActualFuel() { var observation=new NativeFrameProjector("build").Project(Raw(),Player(),null,20,Now); Check.Equal(5d,observation.Frame.FuelMain!.Value);Check.That(observation.Frame.FuelNeededToNextRefuel>20); Check.That(Math.Abs(observation.Thermal!.ChargingGainUpperPerSecond-3700d/334)<1e-10); Check.Equal(100d,observation.Thermal.CoolingCurve!.HeatUpper);Check.Equal(2d,observation.Thermal.CoolingCurve.Exponent); } [Test]public void AShipWithoutAScoopNeverPromotesItsMinimumFuelBudgetToAnUnreachableScoopFillTarget() { var node=System.Text.Json.Nodes.JsonNode.Parse(Raw().GetRawText())!;node["adaptiveFlightControl"]=true;node["referenceWorldAddress"]="1"; node["geometry"]!["referenceFrameVerified"]=true;node["geometry"]!["position"]=node["position"]!.DeepClone(); node["geometry"]!["forward"]=node["forward"]!.DeepClone();node["geometry"]!["up"]=node["up"]!.DeepClone(); node["geometry"]!["nearbyBodies"]=System.Text.Json.Nodes.JsonNode.Parse("[{\"bodyAddress\":\"1\",\"centre\":[0,0,0],\"exclusionRadius\":1000}]"); var player=Player(5,false);player=player with{Ships=player.Ships.Select(ship=>ship with{Modules=ship.Modules.Where(module=>!module.Item.Contains("fuelscoop")).ToArray()}).ToArray()}; var frame=new NativeFrameProjector("build").Project(Check.Json(node.ToJsonString()),player,null,.5,Now).Frame; Check.That(!frame.ScoopPowered&&!frame.RefuellingToFull); Check.Equal(frame.MinimumFuelToNextRefuel!.Value,frame.FuelNeededToNextRefuel!.Value); Check.That(frame.FuelNeededToNextRefuel<31.9); } [Test] public void HudNormalizationScalesBothHeatingAndCoolingWithoutChangingTheObservedTemperature() { var node=System.Text.Json.Nodes.JsonNode.Parse(Raw(heat:49).GetRawText())!;node["heatDisplayScale"]=1.5; var result=new NativeFrameProjector("build").Project(Check.Json(node.ToJsonString()),Player(),null,.5,Now); Check.Equal(49d,result.Frame.HeatPercent!.Value); Check.That(Math.Abs(result.Thermal!.ChargingGainUpperPerSecond-3700d/(334*1.5))<1e-10); Check.That(Math.Abs(result.Thermal.CoolingCurve!.HeatUpper-100/1.5)<1e-10); } [Test] public void RejectsStaleOrDifferentShipFsdBeforeProducingControlFrame() { Check.Throws(()=>new NativeFrameProjector("build").Project(Raw(fresh:false),Player(),null,.5,Now)); Check.Throws(()=>new NativeFrameProjector("build").Project(Raw(fsdEntity:"NPC"),Player(),null,.5,Now)); var cold=new NativeFrameProjector("build").Project(Raw(cooling:false),Player(),null,.5,Now); Check.That(cold.Thermal is null); } [Test] public void FirstIdleObservationWaitsForThePhysicsSnapshotToAgreeBeforeCharging() { foreach(var snapshot in new[]{0,1,2,3,4}) { var node=System.Text.Json.Nodes.JsonNode.Parse(Raw().GetRawText())!;node["rawFsdState"]=snapshot; var frame=new NativeFrameProjector("build").Project(Check.Json(node.ToJsonString()),Player(),null,.5,Now).Frame; Check.Equal(snapshot==2,frame.CanBeginCharge); Check.Equal(snapshot==2?FsdPhase.Idle:FsdPhase.Cooldown,frame.Fsd); } } [Test] public void ReacquiresActualScoopBandAfterOutwardTrackingDriftUntilBudgetFilled() { var projector=new NativeFrameProjector("build");var first=projector.Project(Raw(),Player(),null,20,Now); var second=projector.Project(Raw(1520),Player(scoop:false) with{StatusAt=Now.AddSeconds(2)},null,20,Now.AddSeconds(2)); Check.That(second.Frame.PreferredOrbitRadius31.9); Check.That(Math.Abs(first.PreferredOrbitRadius!.Value-1080)<1e-8,"The full-rate circle is selected before any measured inward search."); Check.Equal(1100d,first.ScoopFullRateRadius!.Value);Check.Equal(.878,first.ScoopRate!.Value); var at=Now.AddSeconds(1); var partial=projector.Project(Input(2,at),Player(12) with{StatusAt=at},null,.5,at).Frame; Check.That(partial.RefuellingToFull&&partial.FuelNeededToNextRefuel>31.9,"Being able to afford one jump must not stop a refill already in progress."); at=Now.AddSeconds(2); var full=projector.Project(Input(3,at),Player(32) with{StatusAt=at},null,.5,at).Frame; Check.That(!full.RefuellingToFull&&full.FuelNeededToNextRefuel<32); } [Test] public void NewerFuelScoopJournalTotalWinsOverAnOlderStatusFile() { var player=Player(5) with{Navigation=new(){CurrentSystemAddress=1,MainFuel=12,FuelAt=Now.AddMilliseconds(200)}}; var frame=new NativeFrameProjector("build").Project(Raw(),player,null,.5,Now).Frame; Check.Equal(12d,frame.FuelMain!.Value); } [Test] public void AdaptiveDriverRejectsLegacySingleStarFallbackWhenPlanetGeometryFailed() { var node=System.Text.Json.Nodes.JsonNode.Parse(Raw().GetRawText())!;node["adaptiveFlightControl"]=true; node["geometry"]!["referenceFailureStage"]=7; Check.Throws(()=>new NativeFrameProjector("build").Project(Check.Json(node.ToJsonString()),Player(),null,.5,Now)); } [Test] public void SupercruiseTransitionEventsDoNotPretendToBeHyperspaceArrival() { var projector=new NavigationProjector(); projector.Apply(Check.Json("""{"event":"StartJump","timestamp":"2026-10-21T08:00:00Z","JumpType":"Supercruise"}""")); Check.Equal(Now,projector.State.SupercruiseTransitionAt!.Value);Check.That(!projector.State.JumpInProgress); projector.Apply(Check.Json("""{"event":"SupercruiseEntry","timestamp":"2026-10-21T08:00:05Z","SystemAddress":1}""")); Check.Equal(Now.AddSeconds(5),projector.State.SupercruiseEnteredAt!.Value);Check.That(projector.State.LastJumpAt is null); } [Test] public void SeparatesBinaryStarBodyIndexFromJournalSystemAddress() { Check.Equal(11666607908257L,NativeFrameProjector.SystemAddressFromBody(36040463626872225UL)); Check.Equal(388451289451L,NativeFrameProjector.SystemAddressFromBody(388451289451UL)); Check.Equal(11666607908257L,NativeFrameProjector.SystemAddressFromBody((511UL<<55)|11666607908257UL)); } private static System.Text.Json.JsonElement StationFrame(bool matching=true,bool primary=true) { var node=System.Text.Json.Nodes.JsonNode.Parse(Raw(1e6).GetRawText())!; node["referenceWorldAddress"]="2630102182384369665"; node["position"]=System.Text.Json.Nodes.JsonNode.Parse("[12,34,56]"); node["fsd"]!["targetDirection"]=System.Text.Json.Nodes.JsonNode.Parse("[-0.1,0.99498743710662,0]"); node["geometry"]=System.Text.Json.Nodes.JsonNode.Parse(System.Text.Json.JsonSerializer.Serialize(new{ fresh=true,referenceFrameVerified=true,referenceBodyKind=6,worldAddress=matching?"2630102182384369665":"1",bodyAddress="1",bodyKind=1,starType=4,exclusionRadius=1000d, position=new[]{1e6,0d,0d},forward=new[]{-.1,.99498743710662,0d},up=new[]{0d,0d,1d}, nearbyBodies=primary?new object[]{new{bodyAddress="1",bodyKind=1,starType=4,exclusionRadius=1000d,centre=new[]{0d,0d,0d}}, new{bodyAddress="396316767208603649",bodyKind=2,starType=0,exclusionRadius=100d,centre=new[]{1e6,10000d,0d}}}: new object[]{new{bodyAddress="396316767208603649",bodyKind=2,starType=0,exclusionRadius=100d,centre=new[]{1e6,1000d,0d}}}})); return Check.Json(node.ToJsonString()); } [Test] public void StationReferenceUsesLiveInertialPoseAndIncludesPlanetObstacles() { var state=Player(32,false) with{Status=Player(32,false).Status! with{Flags=8}}; var observation=new NativeFrameProjector("build").Project(StationFrame(),state,null,.5,Now); var frame=observation.Frame; Check.Equal(new Vector3d(1e6,0,0),frame.Position);Check.Equal(1,frame.Obstacles.Count); Check.Equal(1L,frame.SystemAddress);Check.That(!frame.InSupercruise&&!frame.OutwardDepartureRequired); Check.That(Vector3d.Dot(frame.Position,frame.TargetPosition-frame.Position)<0); var decision=new FlightNavigator().Decide(frame,new(2,"Next",new(1,0,0),"K"),observation.Thermal,Now); Check.That(decision.BeginCharging,decision.Reason);Check.Equal(1d,decision.Throttle); } [Test] public void ChangedStationReferenceOrMissingPrimaryCannotProduceAControlFrame() { Check.Throws(()=>new NativeFrameProjector("build").Project(StationFrame(matching:false),Player(),null,.5,Now)); Check.Throws(()=>new NativeFrameProjector("build").Project(StationFrame(primary:false),Player(),null,.5,Now)); } [Test] public void LegacyPlanetGeometryReportsMissingPluginSupportInsteadOfPoseLoss() { var node=System.Text.Json.Nodes.JsonNode.Parse(Raw().GetRawText())!; node["geometry"]!["bodyKind"]=2;node["geometry"]!["fresh"]=false; try{new NativeFrameProjector("build").Project(Check.Json(node.ToJsonString()),Player(),null,.5,Now);} catch(InvalidOperationException error){Check.That(error.Message.Contains("旧版导航插件")&&error.Message.Contains("参考系"));return;} throw new InvalidOperationException("Expected an explicit unsupported-reference error."); } }