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<AutomaticSupplyChoice> 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<long,IReadOnlyList<CommodityQuote>> observed=new Dictionary<long,IReadOnlyList<CommodityQuote>>{[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<long,IReadOnlyList<CommodityQuote>>{[1]=[Quote(1,1234) with{CollectedAt=Now.AddMinutes(2)}]};
Check.Equal(1234L,AutomaticSupplyPlanner.DeductConfirmedTrades(observed,[purchase])[1][0].Supply);
}
[Test]public void ActualSnapshotReplacesOlderCacheButNewerObservedReplenishmentCanParticipate()
{
IReadOnlyDictionary<long,IReadOnlyList<CommodityQuote>> observed=new Dictionary<long,IReadOnlyList<CommodityQuote>>{[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<long,IReadOnlyList<CommodityQuote>> observed=new Dictionary<long,IReadOnlyList<CommodityQuote>>{[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<TradePlanningProgress> action):IProgress<TradePlanningProgress>{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<TradePlanningProgress>();
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));
}
}
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<AutomaticSupplyChoice> 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<long,IReadOnlyList<CommodityQuote>> observed=new Dictionary<long,IReadOnlyList<CommodityQuote>>{[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<long,IReadOnlyList<CommodityQuote>>{[1]=[Quote(1,1234) with{CollectedAt=Now.AddMinutes(2)}]};
Check.Equal(1234L,AutomaticSupplyPlanner.DeductConfirmedTrades(observed,[purchase])[1][0].Supply);
}
[Test]public void ActualSnapshotReplacesOlderCacheButNewerObservedReplenishmentCanParticipate()
{
IReadOnlyDictionary<long,IReadOnlyList<CommodityQuote>> observed=new Dictionary<long,IReadOnlyList<CommodityQuote>>{[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<long,IReadOnlyList<CommodityQuote>> observed=new Dictionary<long,IReadOnlyList<CommodityQuote>>{[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<TradePlanningProgress> action):IProgress<TradePlanningProgress>{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<TradePlanningProgress>();
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));
}
}