using XFEExtension.NetCore.XUnit.Attributes;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Planning;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class PlannerCandidateTests
{
private static MarketBook Alternatives()
=> new([Check.Station(1) with { DistanceToArrivalLs = 0 }, Check.Station(2, 10) with { DistanceToArrivalLs = 0 }, Check.Station(3, 50) with { DistanceToArrivalLs = 10000 }],
[Check.Quote(1, "gold", 100, 90), Check.Quote(2, "gold", 0, 180), Check.Quote(3, "gold", 0, 200)]);
private static PlannerRequest Request => Check.Request with { Loop = false, MaxLegs = 1, Objective = RouteObjective.TotalProfit };
private static string PathOf(RoutePlan plan) => string.Join("/", plan.Legs.Select(leg => leg.From.MarketId)) + "/" + plan.Legs[^1].To.MarketId;
[Test] public void ShorterDeliveryOutranksLargerAbsoluteProfitRegardlessOfLegacyObjective()
{
var planner = new TradeRoutePlanner();
var plans = planner.CalculateCandidates(Alternatives(), Request);
Check.Equal(2, plans.Count);
Check.Equal(2L, plans[0].Legs.Single().To.MarketId);
Check.That(plans[0].NetProfit < plans[1].NetProfit, "Fixture must have lower absolute profit on the faster route.");
Check.That(plans[0].CreditsPerHour > plans[1].CreditsPerHour);
Check.Equal(RouteObjective.CreditsPerHour, plans[0].Request.Objective);
Check.Equal(3L, planner.Calculate(Alternatives(), Request).Legs.Single().To.MarketId);
}
[Test] public void RiskIsAFilterAndVisibleTierNotAnInventedExpectedLoss()
{
var book = new MarketBook([Check.Station(1), Check.Station(2, 10) with { Security = SecurityLevel.Low }, Check.Station(3, 10)],
[Check.Quote(1, "gold", 100, 90), Check.Quote(2, "gold", 0, 200), Check.Quote(3, "gold", 0, 195)]);
var plans = new TradeRoutePlanner().CalculateCandidates(book, Request);
Check.Equal(2L, plans[0].Legs.Single().To.MarketId);
Check.That(plans[0].RiskTier > plans[1].RiskTier);
Check.That(!plans[0].Legs.Single().Metrics.Contains('%') && !plans[0].RiskDisplay.Contains('%'));
Check.That(plans[0].RiskMethod.Contains("不是概率"));
}
[Test] public void OccupiedCargoReducesEveryLegWithoutInventingSaleRevenue()
{
var plans = new TradeRoutePlanner().CalculateCandidates(Check.TwoStations, Request with { Loop = true, MaxLegs = 4, CargoCapacity = 10, OccupiedCargo = 7 });
var plan = plans[0];
Check.Equal(3, plan.Request.UsableCargoCapacity);
Check.That(plan.Legs.All(leg => leg.Quantity == 3));
Check.Equal(plan.Request.Credits + plan.NetProfit, plan.Legs[^1].CashAfter);
Check.Equal(480m, plan.NetProfit);
}
[Test] public void TravelToOriginConsumesTimeFuelBudgetAndWorkingCapital()
{
var request = Request with { Credits = 1500, ReserveCredits = 500, CostPerJump = 100, CurrentPosition = new(-40, 0, 0), CurrentSystemName = "Remote", CurrentMarketId = 9 };
var book = Check.TwoStations;
var plan = new TradeRoutePlanner().CalculateCandidates(book, request).Single(plan=>plan.Request.StartMarketId==request.StartMarketId);
Check.Equal(40d, plan.DeadheadDistanceLy);
Check.Equal(2, plan.DeadheadJumps);
Check.Equal(200m, plan.DeadheadOperatingCost);
Check.That(plan.DeadheadMinutes > 0);
Check.Equal(7, plan.Legs.Single().Quantity);
Check.Equal(plan.Legs.Sum(leg => leg.NetProfit) - 200, plan.NetProfit);
Check.Equal(plan.Legs.Sum(leg => leg.EstimatedMinutes) + plan.DeadheadMinutes, plan.Minutes);
Check.Equal(request.Credits + plan.NetProfit, plan.Legs[^1].CashAfter);
var alreadyAtOrigin = new TradeRoutePlanner().CalculateCandidates(book, request with { CurrentMarketId = request.StartMarketId }).Single(plan=>plan.Request.StartMarketId==request.StartMarketId);
Check.Equal(0d, alreadyAtOrigin.DeadheadMinutes);
Check.Equal(9, alreadyAtOrigin.Legs.Single().Quantity);
Check.That(alreadyAtOrigin.CreditsPerHour > plan.CreditsPerHour);
}
[Test] public void JournalMaximumJumpRangeUsesExplicitConservativeLadenEstimate()
{
var book = Check.TwoStations with { Stations = [Check.Station(1), Check.Station(2, 22)] };
var planner = new TradeRoutePlanner();
var verified = planner.CalculateCandidates(book, Request with { LadenJumpRangeLy = 25 }).Single(plan=>plan.Request.StartMarketId==Request.StartMarketId);
var estimate = planner.CalculateCandidates(book, Request with { LadenJumpRangeLy = 25, JumpRangeIsEstimate = true }).Single(plan=>plan.Request.StartMarketId==Request.StartMarketId);
Check.Equal(1, verified.Legs.Single().EstimatedJumps);
Check.Equal(2, estimate.Legs.Single().EstimatedJumps);
Check.Equal(20d, estimate.Request.EffectiveLadenJumpRangeLy);
Check.That(estimate.Minutes > verified.Minutes && estimate.CreditsPerHour < verified.CreditsPerHour);
Check.That(estimate.Warnings.Any(warning => warning.Contains("不是经核实的满载跳距")));
}
[Test] public void RepeatedLoopCountsDoNotCrowdAlternativeCandidates()
{
var plans = new TradeRoutePlanner().CalculateCandidates(Check.TwoStations, Request with { Loop = true, MaxLegs = 8 });
Check.That(plans.Count > 0);
Check.That(plans.All(plan => plan.Legs.Count == 2 && plan.IsLoop));
Check.Equal(plans.Count, plans.Select(plan => string.Join("/", plan.Legs.SelectMany(leg => leg.Orders).Select(order => order.Commodity + ":" + order.Quantity))).Distinct().Count());
}
[Test] public void CandidateOrderingIsIndependentOfInputEnumerationOrder()
{
var book = Alternatives();
var planner = new TradeRoutePlanner();
var first = planner.CalculateCandidates(book, Request).Select(PathOf).ToArray();
var reversed = planner.CalculateCandidates(book with { Stations = book.Stations.Reverse().ToArray(), Quotes = book.Quotes.Reverse().ToArray() }, Request).Select(PathOf).ToArray();
Check.That(first.SequenceEqual(reversed));
}
[Test] public void TruncationAndUnverifiedAssumptionsRemainVisible()
{
var plan = new TradeRoutePlanner().CalculateCandidates(Check.TwoStations with { Truncated = true }, Request).First();
Check.That(plan.IsEstimate && plan.SearchBounded && plan.SearchPruned);
Check.That(plan.Warnings.Any(warning => warning.Contains("不保证")));
Check.That(plan.Warnings.Any(warning => warning.Contains("截断")));
Check.That(plan.CargoOptimal);
}
[Test] public void EmptyAndInvalidInputsDoNotProduceInventedCandidates()
{
var planner = new TradeRoutePlanner();
var stale = Check.TwoStations with { Quotes = Check.TwoStations.Quotes.Select(quote => quote with { CollectedAt = Request.AsOf.AddDays(-2) }).ToArray() };
Check.Equal(0, planner.CalculateCandidates(stale, Request).Count);
Check.Throws<ArgumentException>(() => planner.CalculateCandidates(Check.TwoStations, Request with { OccupiedCargo = 10 }));
Check.Throws<ArgumentException>(() => planner.CalculateCandidates(Check.TwoStations, Request with { EstimatedJumpRangeFactor = 0 }));
Check.Throws<ArgumentOutOfRangeException>(() => planner.CalculateCandidates(Check.TwoStations, Request, 0));
}
[Test] public void CandidateSearchObservesCancellation()
{
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
Check.Throws<OperationCanceledException>(() => new TradeRoutePlanner().CalculateCandidates(Check.TwoStations, Request, token: cancelled.Token));
}
[Test] public void ExplicitInvestmentCapsEveryPurchaseWhileAllFundsCanReinvestProfit()
{
var request = Request with { Credits = 2000, ReserveCredits = 1500, CargoCapacity = 10, Loop = true, MaxLegs = 2, InvestmentCredits = 500 };
var planner = new TradeRoutePlanner();
var fixedAmount = planner.CalculateCandidates(Check.TwoStations, request)[0];
Check.That(fixedAmount.Legs.All(leg => leg.Orders.Sum(order => order.Cost) <= 500));
Check.That(fixedAmount.Legs.All(leg => leg.Quantity == 5));
var reinvest = planner.CalculateCandidates(Check.TwoStations, request with { InvestmentCredits = null })[0];
Check.Equal(5, reinvest.Legs[0].Quantity);
Check.Equal(9, reinvest.Legs[1].Quantity);
Check.That(reinvest.NetProfit > fixedAmount.NetProfit);
Check.Throws<ArgumentException>(() => planner.CalculateCandidates(Check.TwoStations, request with { InvestmentCredits = 0 }));
}
[Test] public void ThreeProfitableGoodsProduceRealDistinctCargoAlternativesOnOneStationPair()
{
var book = new MarketBook([Check.Station(1), Check.Station(2, 10)],
[Check.Quote(1, "gold", 100, 90), Check.Quote(2, "gold", 0, 200),
Check.Quote(1, "silver", 100, 90), Check.Quote(2, "silver", 0, 180),
Check.Quote(1, "uranium", 100, 90), Check.Quote(2, "uranium", 0, 160)]);
var request = Request with { Loop = true, MaxLegs = 2, OccupiedCargo = 6, InvestmentCredits = 250 };
var planner = new TradeRoutePlanner();
var originalBest = planner.CalculateCandidates(book, request, maxCandidates: 1).Single();
var plans = planner.CalculateCandidates(book, request);
Check.Equal(3, plans.Count);
Check.Equal(1, plans.Select(PathOf).Distinct().Count());
Check.Equal(3, plans.Select(plan => plan.Legs.SelectMany(leg => leg.Orders).Single().Commodity).Distinct().Count());
Check.Equal(originalBest.CreditsPerHour, plans[0].CreditsPerHour);
Check.Equal(originalBest.NetProfit, plans[0].NetProfit);
Check.That(!plans[0].IsCargoAlternative && plans.Skip(1).All(plan => plan.IsCargoAlternative));
Check.That(plans.Select(plan => plan.CreditsPerHour).SequenceEqual(plans.OrderByDescending(plan => plan.CreditsPerHour).Select(plan => plan.CreditsPerHour)));
foreach (var plan in plans)
{
var order = plan.Legs.SelectMany(leg => leg.Orders).Single();
Check.Equal(2, order.Quantity); // Real cash cap, not cosmetic one-tonne variants.
Check.That(order.Cost <= 250 && plan.Legs.All(leg => leg.Quantity <= request.UsableCargoCapacity));
Check.That(plan.CargoOptimal && plan.IsEstimate && plan.SearchBounded);
Check.Equal(request.Credits + plan.NetProfit, plan.Legs[^1].CashAfter);
}
}
[Test] public void AOneCommodityBookDoesNotFabricateExtraCargoSchemes()
{
var book = new MarketBook([Check.Station(1), Check.Station(2, 10)], [Check.Quote(1, "gold", 100, 90), Check.Quote(2, "gold", 0, 200)]);
var plans = new TradeRoutePlanner().CalculateCandidates(book, Request with { Loop = true, MaxLegs = 2 });
Check.Equal(1, plans.Count);
Check.That(!plans[0].IsCargoAlternative);
}
}
using XFEExtension.NetCore.XUnit.Attributes;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Planning;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class PlannerCandidateTests
{
private static MarketBook Alternatives()
=> new([Check.Station(1) with { DistanceToArrivalLs = 0 }, Check.Station(2, 10) with { DistanceToArrivalLs = 0 }, Check.Station(3, 50) with { DistanceToArrivalLs = 10000 }],
[Check.Quote(1, "gold", 100, 90), Check.Quote(2, "gold", 0, 180), Check.Quote(3, "gold", 0, 200)]);
private static PlannerRequest Request => Check.Request with { Loop = false, MaxLegs = 1, Objective = RouteObjective.TotalProfit };
private static string PathOf(RoutePlan plan) => string.Join("/", plan.Legs.Select(leg => leg.From.MarketId)) + "/" + plan.Legs[^1].To.MarketId;
[Test] public void ShorterDeliveryOutranksLargerAbsoluteProfitRegardlessOfLegacyObjective()
{
var planner = new TradeRoutePlanner();
var plans = planner.CalculateCandidates(Alternatives(), Request);
Check.Equal(2, plans.Count);
Check.Equal(2L, plans[0].Legs.Single().To.MarketId);
Check.That(plans[0].NetProfit < plans[1].NetProfit, "Fixture must have lower absolute profit on the faster route.");
Check.That(plans[0].CreditsPerHour > plans[1].CreditsPerHour);
Check.Equal(RouteObjective.CreditsPerHour, plans[0].Request.Objective);
Check.Equal(3L, planner.Calculate(Alternatives(), Request).Legs.Single().To.MarketId);
}
[Test] public void RiskIsAFilterAndVisibleTierNotAnInventedExpectedLoss()
{
var book = new MarketBook([Check.Station(1), Check.Station(2, 10) with { Security = SecurityLevel.Low }, Check.Station(3, 10)],
[Check.Quote(1, "gold", 100, 90), Check.Quote(2, "gold", 0, 200), Check.Quote(3, "gold", 0, 195)]);
var plans = new TradeRoutePlanner().CalculateCandidates(book, Request);
Check.Equal(2L, plans[0].Legs.Single().To.MarketId);
Check.That(plans[0].RiskTier > plans[1].RiskTier);
Check.That(!plans[0].Legs.Single().Metrics.Contains('%') && !plans[0].RiskDisplay.Contains('%'));
Check.That(plans[0].RiskMethod.Contains("不是概率"));
}
[Test] public void OccupiedCargoReducesEveryLegWithoutInventingSaleRevenue()
{
var plans = new TradeRoutePlanner().CalculateCandidates(Check.TwoStations, Request with { Loop = true, MaxLegs = 4, CargoCapacity = 10, OccupiedCargo = 7 });
var plan = plans[0];
Check.Equal(3, plan.Request.UsableCargoCapacity);
Check.That(plan.Legs.All(leg => leg.Quantity == 3));
Check.Equal(plan.Request.Credits + plan.NetProfit, plan.Legs[^1].CashAfter);
Check.Equal(480m, plan.NetProfit);
}
[Test] public void TravelToOriginConsumesTimeFuelBudgetAndWorkingCapital()
{
var request = Request with { Credits = 1500, ReserveCredits = 500, CostPerJump = 100, CurrentPosition = new(-40, 0, 0), CurrentSystemName = "Remote", CurrentMarketId = 9 };
var book = Check.TwoStations;
var plan = new TradeRoutePlanner().CalculateCandidates(book, request).Single(plan=>plan.Request.StartMarketId==request.StartMarketId);
Check.Equal(40d, plan.DeadheadDistanceLy);
Check.Equal(2, plan.DeadheadJumps);
Check.Equal(200m, plan.DeadheadOperatingCost);
Check.That(plan.DeadheadMinutes > 0);
Check.Equal(7, plan.Legs.Single().Quantity);
Check.Equal(plan.Legs.Sum(leg => leg.NetProfit) - 200, plan.NetProfit);
Check.Equal(plan.Legs.Sum(leg => leg.EstimatedMinutes) + plan.DeadheadMinutes, plan.Minutes);
Check.Equal(request.Credits + plan.NetProfit, plan.Legs[^1].CashAfter);
var alreadyAtOrigin = new TradeRoutePlanner().CalculateCandidates(book, request with { CurrentMarketId = request.StartMarketId }).Single(plan=>plan.Request.StartMarketId==request.StartMarketId);
Check.Equal(0d, alreadyAtOrigin.DeadheadMinutes);
Check.Equal(9, alreadyAtOrigin.Legs.Single().Quantity);
Check.That(alreadyAtOrigin.CreditsPerHour > plan.CreditsPerHour);
}
[Test] public void JournalMaximumJumpRangeUsesExplicitConservativeLadenEstimate()
{
var book = Check.TwoStations with { Stations = [Check.Station(1), Check.Station(2, 22)] };
var planner = new TradeRoutePlanner();
var verified = planner.CalculateCandidates(book, Request with { LadenJumpRangeLy = 25 }).Single(plan=>plan.Request.StartMarketId==Request.StartMarketId);
var estimate = planner.CalculateCandidates(book, Request with { LadenJumpRangeLy = 25, JumpRangeIsEstimate = true }).Single(plan=>plan.Request.StartMarketId==Request.StartMarketId);
Check.Equal(1, verified.Legs.Single().EstimatedJumps);
Check.Equal(2, estimate.Legs.Single().EstimatedJumps);
Check.Equal(20d, estimate.Request.EffectiveLadenJumpRangeLy);
Check.That(estimate.Minutes > verified.Minutes && estimate.CreditsPerHour < verified.CreditsPerHour);
Check.That(estimate.Warnings.Any(warning => warning.Contains("不是经核实的满载跳距")));
}
[Test] public void RepeatedLoopCountsDoNotCrowdAlternativeCandidates()
{
var plans = new TradeRoutePlanner().CalculateCandidates(Check.TwoStations, Request with { Loop = true, MaxLegs = 8 });
Check.That(plans.Count > 0);
Check.That(plans.All(plan => plan.Legs.Count == 2 && plan.IsLoop));
Check.Equal(plans.Count, plans.Select(plan => string.Join("/", plan.Legs.SelectMany(leg => leg.Orders).Select(order => order.Commodity + ":" + order.Quantity))).Distinct().Count());
}
[Test] public void CandidateOrderingIsIndependentOfInputEnumerationOrder()
{
var book = Alternatives();
var planner = new TradeRoutePlanner();
var first = planner.CalculateCandidates(book, Request).Select(PathOf).ToArray();
var reversed = planner.CalculateCandidates(book with { Stations = book.Stations.Reverse().ToArray(), Quotes = book.Quotes.Reverse().ToArray() }, Request).Select(PathOf).ToArray();
Check.That(first.SequenceEqual(reversed));
}
[Test] public void TruncationAndUnverifiedAssumptionsRemainVisible()
{
var plan = new TradeRoutePlanner().CalculateCandidates(Check.TwoStations with { Truncated = true }, Request).First();
Check.That(plan.IsEstimate && plan.SearchBounded && plan.SearchPruned);
Check.That(plan.Warnings.Any(warning => warning.Contains("不保证")));
Check.That(plan.Warnings.Any(warning => warning.Contains("截断")));
Check.That(plan.CargoOptimal);
}
[Test] public void EmptyAndInvalidInputsDoNotProduceInventedCandidates()
{
var planner = new TradeRoutePlanner();
var stale = Check.TwoStations with { Quotes = Check.TwoStations.Quotes.Select(quote => quote with { CollectedAt = Request.AsOf.AddDays(-2) }).ToArray() };
Check.Equal(0, planner.CalculateCandidates(stale, Request).Count);
Check.Throws<ArgumentException>(() => planner.CalculateCandidates(Check.TwoStations, Request with { OccupiedCargo = 10 }));
Check.Throws<ArgumentException>(() => planner.CalculateCandidates(Check.TwoStations, Request with { EstimatedJumpRangeFactor = 0 }));
Check.Throws<ArgumentOutOfRangeException>(() => planner.CalculateCandidates(Check.TwoStations, Request, 0));
}
[Test] public void CandidateSearchObservesCancellation()
{
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
Check.Throws<OperationCanceledException>(() => new TradeRoutePlanner().CalculateCandidates(Check.TwoStations, Request, token: cancelled.Token));
}
[Test] public void ExplicitInvestmentCapsEveryPurchaseWhileAllFundsCanReinvestProfit()
{
var request = Request with { Credits = 2000, ReserveCredits = 1500, CargoCapacity = 10, Loop = true, MaxLegs = 2, InvestmentCredits = 500 };
var planner = new TradeRoutePlanner();
var fixedAmount = planner.CalculateCandidates(Check.TwoStations, request)[0];
Check.That(fixedAmount.Legs.All(leg => leg.Orders.Sum(order => order.Cost) <= 500));
Check.That(fixedAmount.Legs.All(leg => leg.Quantity == 5));
var reinvest = planner.CalculateCandidates(Check.TwoStations, request with { InvestmentCredits = null })[0];
Check.Equal(5, reinvest.Legs[0].Quantity);
Check.Equal(9, reinvest.Legs[1].Quantity);
Check.That(reinvest.NetProfit > fixedAmount.NetProfit);
Check.Throws<ArgumentException>(() => planner.CalculateCandidates(Check.TwoStations, request with { InvestmentCredits = 0 }));
}
[Test] public void ThreeProfitableGoodsProduceRealDistinctCargoAlternativesOnOneStationPair()
{
var book = new MarketBook([Check.Station(1), Check.Station(2, 10)],
[Check.Quote(1, "gold", 100, 90), Check.Quote(2, "gold", 0, 200),
Check.Quote(1, "silver", 100, 90), Check.Quote(2, "silver", 0, 180),
Check.Quote(1, "uranium", 100, 90), Check.Quote(2, "uranium", 0, 160)]);
var request = Request with { Loop = true, MaxLegs = 2, OccupiedCargo = 6, InvestmentCredits = 250 };
var planner = new TradeRoutePlanner();
var originalBest = planner.CalculateCandidates(book, request, maxCandidates: 1).Single();
var plans = planner.CalculateCandidates(book, request);
Check.Equal(3, plans.Count);
Check.Equal(1, plans.Select(PathOf).Distinct().Count());
Check.Equal(3, plans.Select(plan => plan.Legs.SelectMany(leg => leg.Orders).Single().Commodity).Distinct().Count());
Check.Equal(originalBest.CreditsPerHour, plans[0].CreditsPerHour);
Check.Equal(originalBest.NetProfit, plans[0].NetProfit);
Check.That(!plans[0].IsCargoAlternative && plans.Skip(1).All(plan => plan.IsCargoAlternative));
Check.That(plans.Select(plan => plan.CreditsPerHour).SequenceEqual(plans.OrderByDescending(plan => plan.CreditsPerHour).Select(plan => plan.CreditsPerHour)));
foreach (var plan in plans)
{
var order = plan.Legs.SelectMany(leg => leg.Orders).Single();
Check.Equal(2, order.Quantity); // Real cash cap, not cosmetic one-tonne variants.
Check.That(order.Cost <= 250 && plan.Legs.All(leg => leg.Quantity <= request.UsableCargoCapacity));
Check.That(plan.CargoOptimal && plan.IsEstimate && plan.SearchBounded);
Check.Equal(request.Credits + plan.NetProfit, plan.Legs[^1].CashAfter);
}
}
[Test] public void AOneCommodityBookDoesNotFabricateExtraCargoSchemes()
{
var book = new MarketBook([Check.Station(1), Check.Station(2, 10)], [Check.Quote(1, "gold", 100, 90), Check.Quote(2, "gold", 0, 200)]);
var plans = new TradeRoutePlanner().CalculateCandidates(book, Request with { Loop = true, MaxLegs = 2 });
Check.Equal(1, plans.Count);
Check.That(!plans[0].IsCargoAlternative);
}
}