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(() => planner.CalculateCandidates(Check.TwoStations, Request with { OccupiedCargo = 10 })); Check.Throws(() => planner.CalculateCandidates(Check.TwoStations, Request with { EstimatedJumpRangeFactor = 0 })); Check.Throws(() => planner.CalculateCandidates(Check.TwoStations, Request, 0)); } [Test] public void CandidateSearchObservesCancellation() { using var cancelled = new CancellationTokenSource(); cancelled.Cancel(); Check.Throws(() => 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(() => 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); } }