using System.Collections.Concurrent; using XFEEDToolBox.Core.Execution; using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Planning; using XFEExtension.NetCore.XUnit.Attributes; namespace XFEEDToolBox.Test; [TestFixture]public sealed class AutomaticSupplyPlannerTests { private static readonly DateTimeOffset Now=DateTimeOffset.UtcNow; private static Station Station(long id,double distance=10)=>new(){MarketId=id,Name="Port "+id,SystemName="System "+id, Position=new(distance,0,0),DistanceToArrivalLs=256,MaxLandingPad=LandingPad.Large,Type="Orbis",Security=SecurityLevel.High}; private static readonly Station Destination=Station(999,0); private static CommodityQuote Quote(long id,long supply=1000000,long buy=100,long sell=1000,long demand=1000000)=>new(){MarketId=id, Commodity="uranium",DisplayName="铀",BuyPrice=buy,SellPrice=sell,Supply=supply,Demand=demand,CollectedAt=Now}; private static PlannerRequest Request=>new(){StartMarketId=999,Credits=100000000,ReserveCredits=1000000,CargoCapacity=1200, ActualShipType="Type9",RequiredPad=LandingPad.Large,CurrentPosition=new(0,0,0),CurrentSystemName=Destination.SystemName, CurrentMarketId=999,SearchRadiusLy=500,MaxLegDistanceLy=500,LadenJumpRangeLy=30,AsOf=Now}; private static IReadOnlyList Calculate(Station[] sources,CommodityQuote[] quotes,PlannerRequest? request=null) =>AutomaticSupplyPlanner.Calculate(new([Destination,..sources],[Quote(999),..quotes]),request??Request,Destination,["uranium"]); [Test]public void Actual875TonSupplyLosesToFull1200LoadEvenWithAHigherPerTonSpread() { var choices=Calculate([Station(1),Station(2)],[Quote(1,875,buy:50),Quote(2,1185593,buy:100)]); Check.Equal(2L,choices[0].Source.MarketId);Check.Equal(1200,choices[0].Order.Quantity);Check.Equal(875,choices[1].Order.Quantity); Check.That(choices[0].CreditsPerHour>choices[1].CreditsPerHour);Check.Equal(1185593L,choices[0].Supply); Check.Equal(choices[0].EstimatedNetProfit*60/(decimal)choices[0].EstimatedMinutes,choices[0].CreditsPerHour); Check.That(choices[0].Basis.Contains("空载返程")); } [Test]public void ExplicitSearchLimitsPersistDuringDynamicSupplySelectionAndEmptyLimitsDoNotFilter() { var sources=new[]{Station(1),Station(2)};var quotes=new[]{Quote(1),Quote(2)}; Check.Equal(2,Calculate(sources,quotes,Request with{SourceSystemFilter=" ",DestinationStationFilter=""}).Count); var limited=Request with{SourceMarketIdFilter=2,SourceSystemFilter=" System 2 ",DestinationMarketIdFilter=999}; Check.Equal(2L,Calculate(sources,quotes,limited).Single().Source.MarketId); Check.Equal(0,Calculate(sources,quotes,limited with{DestinationStationFilter="Different port"}).Count); } [Test]public void OptionalGoodsFilterAlsoLimitsRepeatedSupplyWithoutAddingUnacceptedGoods() { var sources=new[]{Station(1)};var quotes=new[]{Quote(1)}; Check.Equal(1,Calculate(sources,quotes,Request with{CommunityGoalCommodityFilter=[]}).Count); Check.Equal(1,Calculate(sources,quotes,Request with{CommunityGoalCommodityFilter=[" Uranium ","coffee"]}).Count); Check.Equal(0,Calculate(sources,quotes,Request with{CommunityGoalCommodityFilter=["coffee"]}).Count); Check.Equal(1,Calculate(sources,quotes,Request with{CommunityGoalCommodityFilter=[" "]}).Count); } [Test]public void LargerActualHoldsAreNotClampedAndFundsDemandAndOccupiedSpaceStillBoundOrders() { Check.Equal(4096,Calculate([Station(1)],[Quote(1)],Request with{CargoCapacity=4096})[0].Order.Quantity); Check.Equal(3000,Calculate([Station(1)],[Quote(1)],Request with{CargoCapacity=4096,OccupiedCargo=1096})[0].Order.Quantity); Check.Equal(12,Calculate([Station(1)],[Quote(1)],Request with{Credits=2200,ReserveCredits=1000})[0].Order.Quantity); Check.Equal(7,Calculate([Station(1)],[Quote(999,demand:7) with{CollectedAt=Now.AddSeconds(1)},Quote(1)])[0].Order.Quantity); Check.Equal(9,Calculate([Station(1)],[Quote(1)],Request with{InvestmentCredits=900})[0].Order.Quantity); } [Test]public void MoreStockIsNotAutomaticallyBetterThanAShorterCompleteCycle() { var choices=Calculate([Station(1,10),Station(2,400)],[Quote(1,1200),Quote(2,5000000)]); Check.Equal(1L,choices[0].Source.MarketId);Check.That(choices[1].EstimatedMinutes>choices[0].EstimatedMinutes); Check.That(choices.All(c=>c.EstimatedMinutes==c.Delivery.EstimatedMinutes+c.EmptyReturnMinutes+c.RepositionMinutes)); var transfer=Calculate([Station(1)],[Quote(1)],Request with{CurrentMarketId=3,CurrentPosition=new(100,0,0)})[0]; Check.That(transfer.RepositionMinutes>0);Check.That(transfer.EstimatedMinutes>choices[0].EstimatedMinutes); } [Test]public void LatestZeroOrIllegalQuoteNeverResurrectsOlderLargeStock() { var old=Quote(1,5000000) with{CollectedAt=Now.AddMinutes(-1)}; Check.Equal(0,Calculate([Station(1)],[old,Quote(1,0)]).Count); Check.Equal(0,Calculate([Station(1)],[old,Quote(1) with{Illegal=true}]).Count); Check.Equal(0,Calculate([Station(1)],[Quote(1) with{CollectedAt=Now.AddHours(-7)}]).Count); } [Test]public void OwnConfirmedPurchaseConsumesOldStockOnceAndNewLiveQuotesSupersedeThatDeduction() { var purchase=new ConfirmedTradeReceipt("purchase",1,"uranium",875,100,1000000,912500,0,875,"commander",8,Now.AddHours(-1),Now.AddSeconds(1),true,true); var sale=purchase with{OperationKey="sale",MarketId=999,QuantityDelta=-875,ConfirmedAt=Now.AddMinutes(5)}; IReadOnlyDictionary> observed=new Dictionary>{[1]=[Quote(1,875)],[999]=[Quote(999)]}; var remaining=AutomaticSupplyPlanner.DeductConfirmedTrades(observed,[purchase,purchase,sale]); Check.Equal(0L,remaining[1][0].Supply);Check.Equal(999125L,remaining[999][0].Demand);Check.Equal(875L,observed[1][0].Supply); Check.Equal(0,Calculate([Station(1)],[..remaining[1]]).Count); observed=new Dictionary>{[1]=[Quote(1,1234) with{CollectedAt=Now.AddMinutes(2)}]}; Check.Equal(1234L,AutomaticSupplyPlanner.DeductConfirmedTrades(observed,[purchase])[1][0].Supply); } [Test]public void ActualSnapshotReplacesOlderCacheButNewerObservedReplenishmentCanParticipate() { IReadOnlyDictionary> observed=new Dictionary>{[1]=[Quote(1,0)]}; var cached=new MarketBook([Destination,Station(1)],[Quote(999),Quote(1,5000000) with{CollectedAt=Now.AddMinutes(-1)}]); var depleted=AutomaticSupplyPlanner.MergeObservations(cached,observed); Check.Equal(0,AutomaticSupplyPlanner.Calculate(depleted,Request,Destination,["uranium"]).Count); var refreshed=AutomaticSupplyPlanner.MergeObservations(cached with{Quotes=[Quote(999),Quote(1,2000) with{CollectedAt=Now.AddMinutes(1)}]},observed); Check.Equal(1200,AutomaticSupplyPlanner.Calculate(refreshed,Request,Destination,["uranium"])[0].Order.Quantity); } [Test]public void PublicQuoteBetweenTheDockedObservationAndOurConfirmedPurchaseCannotResurrectConsumedStock() { var buy=new ConfirmedTradeReceipt("buy",1,"uranium",875,100,1000000,912500,0,875,"commander",8,Now.AddHours(-1),Now.AddSeconds(30),true,true); IReadOnlyDictionary> observed=new Dictionary>{[1]=[Quote(1,0)]}; var cache=new MarketBook([Destination,Station(1)],[Quote(999),Quote(1,875) with{CollectedAt=Now.AddSeconds(10)}]); var adjusted=AutomaticSupplyPlanner.DeductConfirmedTrades(cache,[buy]); Check.Equal(0,AutomaticSupplyPlanner.Calculate(AutomaticSupplyPlanner.MergeObservations(adjusted,observed),Request,Destination,["uranium"]).Count); var replenished=cache with{Quotes=[Quote(999),Quote(1,5000) with{CollectedAt=Now.AddMinutes(1)}]}; var newer=AutomaticSupplyPlanner.DeductConfirmedTrades(replenished,[buy]); Check.Equal(1200,AutomaticSupplyPlanner.Calculate(AutomaticSupplyPlanner.MergeObservations(newer,observed),Request,Destination,["uranium"])[0].Order.Quantity); } [Test]public void ActualLargeShipPadAndUserSourceTypesApplyToEverySupplier() { var small=Station(1) with{MaxLandingPad=LandingPad.Medium};var carrier=Station(2) with{Type="FleetCarrier",IsFleetCarrier=true}; var port=Station(3);var quotes=new[]{Quote(1),Quote(2),Quote(3)}; var choices=Calculate([small,carrier,port],quotes,Request with{AllowedSourceKinds=StationSourceKinds.FleetCarrier}); Check.Equal(1,choices.Count);Check.Equal(2L,choices[0].Source.MarketId); Check.Equal(0,Calculate([small],[Quote(1)],Request with{AllowUnknownMetadata=true}).Count); Check.Equal(0,Calculate([port with{MaxLandingPad=LandingPad.Unknown}],[Quote(3)],Request with{AllowUnknownMetadata=true}).Count); } private sealed class InlineProgress(Action action):IProgress{public void Report(TradePlanningProgress value)=>action(value);} [Test]public void All501SourcesAreComparedBeforeTakingDisplayResultsAndProgressCountsRealWork() { var sources=Enumerable.Range(1,501).Select(id=>Station(id)).ToArray();var quotes=sources.Select(s=>Quote(s.MarketId,s.MarketId==501?1200:1)).ToArray(); var events=new ConcurrentQueue(); var result=AutomaticSupplyPlanner.Calculate(new([Destination,..sources],[Quote(999),..quotes]),Request,Destination,["uranium"], progress:new InlineProgress(events.Enqueue)); Check.Equal(501L,result[0].Source.MarketId);Check.Equal(8,result.Count); Check.That(events.Any(e=>e.Processed==501&&e.Total==501)); Check.That(events.All(e=>e.Total is null||e.Processed<=e.Total)); } }