using System.Text.Json; using XFEEDToolBox.Core.Execution; using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Planning; using XFEExtension.NetCore.XUnit.Attributes; namespace XFEEDToolBox.Test; [TestFixture] public sealed class CommunityGoalSupplyPlanningTests { private static readonly DateTimeOffset Now=DateTimeOffset.UtcNow; private static PlannerRequest Request=>Check.Request with{CommunityGoalSupplyOnly=true,MaxLegs=1,Loop=false,AsOf=Now}; private static CommunityGoalInfo Goal(Station station,ulong id=42,string goods="water") =>new(id,"Confirmed delivery initiative","tradelist","trade",station.MarketId,station.SystemName,station.Name,Now.AddDays(1),true,false,null){AcceptedCommodities=[goods]}; private static MarketBook Book=>new([Check.Station(1),Check.Station(2,10),Check.Station(3,10)], [Check.Quote(1,"water",10,0),Check.Quote(1,"gold",10,0),Check.Quote(2,"water",0,40),Check.Quote(2,"gold",0,400),Check.Quote(3,"gold",0,1000)]); [Test]public void OrdinaryWinnersCannotHideCgSupplyBeforeTheTopCandidatesAreChosen() { var planner=new TradeRoutePlanner();var book=Book;var goal=Goal(book.Stations[1]); var ordinary=planner.CalculateCandidates(book,Request with{CommunityGoalSupplyOnly=false},1); Check.Equal(3L,ordinary.Single().Legs.Single().To.MarketId); var cg=planner.CalculateCandidatesWithDiagnostics(book,Request,3,communityGoals:[goal]); Check.Equal("cg-found",cg.Diagnostics.Code);Check.That(cg.Candidates.Count>0); foreach(var plan in cg.Candidates) {Check.That(CommunityGoalSupplyScope.MatchesRoute([goal],plan,Now));Check.That(plan.Legs.SelectMany(leg=>leg.Orders).All(order=>order.Commodity=="water"));} } [Test]public void MissingExpiredCompletedUnknownGoodsAndWrongDestinationNeverFallBackToOrdinaryProfit() { var book=Book;var valid=Goal(book.Stations[1]);var planner=new TradeRoutePlanner(); var catalogues=new IReadOnlyList[] { [],[valid with{Expires=Now}],[valid with{Active=false}],[valid with{AcceptedCommodities=[]}], [valid with{StationName="Wrong station"}],[valid with{SystemName="Wrong system"}],[valid with{MarketId=99}], [valid with{Title="CG delivery",GoalType="combat"}],[valid with{Activity="bounty"}],[valid,valid with{StationName="Conflicting destination"}] }; foreach(var catalogue in catalogues) { var result=planner.CalculateCandidatesWithDiagnostics(book,Request,communityGoals:catalogue); Check.Equal(0,result.Candidates.Count);Check.Equal("cg-unavailable",result.Diagnostics.Code);Check.That(result.Diagnostics.Message.Contains("未回退普通")); } Check.Equal(0,planner.Calculate(book,Request).Legs.Count); Check.Equal(0,planner.CalculateCandidates(book,Request).Count); Check.Equal("cg-unavailable",planner.CalculateCandidatesWithDiagnostics(new([],[]),Request).Diagnostics.Code); } [Test]public void MixedUnacceptedCargoAndWrongLegIdentityCannotBorrowACgLabel() { var from=Check.Station(1);var to=Check.Station(2);var goal=Goal(to); var leg=new TradeLeg{From=from,To=to,Orders=[new("water","Water",1,10,40),new("gold","Gold",9,10,1000)]}; var plan=new RoutePlan{Legs=[leg]}; Check.That(CommunityGoalCatalogue.MatchRoute([goal],plan,Now).Count==1); Check.That(!CommunityGoalSupplyScope.MatchesRoute([goal],plan,Now)); leg=leg with{Orders=[new("water","Water",10,10,40)]}; Check.That(CommunityGoalSupplyScope.MatchesRoute([goal],plan with{Legs=[leg, new(){From=to,To=from,Orders=[]}]},Now)); Check.That(!CommunityGoalSupplyScope.MatchesRoute([goal],plan with{Legs=[leg with{To=to with{Name="Wrong station"}}]},Now)); } [Test]public void EachInitiativeUsesItsOwnMarketAndAcceptedGoodsWhileHourlyProfitRemainsSorted() { var stations=new[]{Check.Station(1),Check.Station(2,10),Check.Station(3,10)};var goals=new[]{Goal(stations[1]),Goal(stations[2],43,"coffee")}; var book=new MarketBook(stations,[Check.Quote(1,"water",10,0),Check.Quote(1,"coffee",10,0),Check.Quote(2,"water",0,40),Check.Quote(2,"coffee",0,4000),Check.Quote(3,"water",0,5000),Check.Quote(3,"coffee",0,30)]); var plans=new TradeRoutePlanner().CalculateCandidates(book,Request,8,communityGoals:goals); Check.That(plans.Count>=2);Check.That(plans.All(plan=>CommunityGoalSupplyScope.MatchesRoute(goals,plan,Now))); Check.That(plans.All(plan=>plan.Legs.All(leg=>leg.Orders.All(order=>leg.To.MarketId==2?order.Commodity=="water":leg.To.MarketId==3&&order.Commodity=="coffee")))); Check.That(plans.Zip(plans.Skip(1)).All(pair=>pair.First.CreditsPerHour>=pair.Second.CreditsPerHour)); } [Test]public void CapacitySupplyFundingAndBothLandingPadsRemainRealConstraints() { var stations=new[]{Check.Station(1),Check.Station(2),Check.Station(3,10),Check.Station(4,10) with{MaxLandingPad=LandingPad.Medium}}; var goals=new[]{Goal(stations[2]),Goal(stations[3],43)}; var book=new MarketBook(stations,[Check.Quote(1,"water",10,0,900),Check.Quote(2,"water",10,0,1000000),Check.Quote(3,"water",0,110,demand:1000000),Check.Quote(4,"water",0,100000,demand:1000000)]); var request=Request with{CargoCapacity=1200,OccupiedCargo=0,Credits=100000,ReserveCredits=1000,InvestmentCredits=12000,CurrentMarketId=null,ActualShipType="PantherMkII"}; var plans=new TradeRoutePlanner().CalculateCandidates(book,request,8,communityGoals:goals); Check.That(plans.Count>0);Check.That(plans.All(plan=>plan.Legs.All(leg=>leg.To.MarketId!=4))); Check.That(plans.SelectMany(plan=>plan.Legs).All(leg=>leg.Orders.Sum(order=>order.Quantity)<=1200&&leg.Orders.Sum(order=>order.Cost)<=12000)); Check.That(plans.SelectMany(plan=>plan.Legs).Where(leg=>leg.From.MarketId==1).All(leg=>leg.Orders.Sum(order=>order.Quantity)<=900)); Check.Equal(2L,plans[0].Legs[0].From.MarketId);Check.Equal(1200,plans[0].Legs[0].Orders.Sum(order=>order.Quantity)); } [Test]public void AllTwoHundredFiveSourcesParticipateWithoutAStationLimit() { var stations=Enumerable.Range(1,205).Select(id=>Check.Station(id,id*.001)).Append(Check.Station(999,1)).ToArray(); var quotes=Enumerable.Range(1,205).Select(id=>Check.Quote(id,"water",id==205?1:10,0)).Append(Check.Quote(999,"water",0,50)).ToArray(); var goal=Goal(stations[^1]);var stages=new List(); var result=new TradeRoutePlanner().CalculateCandidatesWithDiagnostics(new(stations,quotes),Request with{CurrentMarketId=null},3,progress:new InlineProgress(stages.Add),communityGoals:[goal]); Check.Equal(205L,result.Candidates[0].Legs[0].From.MarketId); var complete=stages.Last(stage=>stage.Stage=="计算全部货源起点");Check.Equal(205L,complete.Processed);Check.Equal(205L,complete.Total!.Value); Check.Equal(206,result.Diagnostics.CompatibleStations); } [Test]public void DefaultIsOrdinaryAndTheExplicitCgChoiceSurvivesSettingsSerialization() { Check.That(!new PlannerRequest().CommunityGoalSupplyOnly); var saved=new ToolboxSettings{Planner=Request}; var restored=JsonSerializer.Deserialize(JsonSerializer.Serialize(saved,ToolboxSettings.JsonOptions),ToolboxSettings.JsonOptions)!; Check.That(restored.Planner.CommunityGoalSupplyOnly); } [Test]public void EmptyOriginAndDestinationUseTheActualPositionAndSearchAllSupplyStations() { var book=Book;var goal=Goal(book.Stations[1]);var planner=new TradeRoutePlanner(); foreach(var reference in new[]{0L,goal.MarketId,99999L}) { var request=Request with{StartMarketId=reference,CurrentMarketId=goal.MarketId,CurrentPosition=book.Stations[1].Position, SourceSystemFilter=" ",SourceStationFilter="",DestinationSystemFilter="",DestinationStationFilter=" "}; var result=planner.CalculateCandidatesWithDiagnostics(book,request,communityGoals:[goal]); Check.That(result.Candidates.Count>0);Check.Equal(1L,result.Candidates[0].Legs[0].From.MarketId);Check.That(result.Candidates[0].DeadheadMinutes>0); } } [Test]public void OnlyExplicitOriginAndDestinationFieldsConstrainTheCgSearch() { var book=Book;var goal=Goal(book.Stations[1]);var planner=new TradeRoutePlanner(); var allowed=Request with{StartMarketId=0,SourceMarketIdFilter=1,SourceSystemFilter=" system 1 ",SourceStationFilter=" station 1 ", DestinationMarketIdFilter=2,DestinationSystemFilter=" SYSTEM 2 ",DestinationStationFilter="Station 2"}; Check.That(planner.CalculateCandidates(book,allowed,communityGoals:[goal]).Count>0); foreach(var blocked in new[]{allowed with{SourceMarketIdFilter=3},allowed with{SourceSystemFilter="System 9"},allowed with{SourceStationFilter="Missing"}, allowed with{DestinationMarketIdFilter=3},allowed with{DestinationSystemFilter="System 9"},allowed with{DestinationStationFilter="Missing"}}) Check.Equal(0,planner.CalculateCandidates(book,blocked,communityGoals:[goal]).Count); var erased=allowed with{SourceMarketIdFilter=null,SourceSystemFilter="",SourceStationFilter=" ",DestinationMarketIdFilter=null,DestinationSystemFilter=" ",DestinationStationFilter=""}; Check.That(planner.CalculateCandidates(book,erased,communityGoals:[goal]).Count>0); } [Test]public async Task MarketReadDoesNotRequireACachedStartingStationWhenTheActualPositionIsKnown() { using var temporary=new TemporaryDirectory();var repository=new XFEEDToolBox.Core.Data.MarketRepository(temporary.File("market.db"));repository.InitializeCheap(); foreach(var station in Book.Stations)await repository.SaveSnapshotAsync(new(station,Book.Quotes.Where(quote=>quote.MarketId==station.MarketId).ToArray(),Now,DataSource.Journal)); var read=repository.LoadPlanningRegion(Request with{StartMarketId=0,CurrentMarketId=null});Check.Equal(3,read.Stations.Count); Check.That(new TradeRoutePlanner().CalculateCandidates(read,Request with{StartMarketId=0,CurrentMarketId=null},communityGoals:[Goal(Book.Stations[1])]).Count>0); } [Test]public void OptionalGoodsFilterUsesTheActivityIntersectionAndEmptySelectionUsesAllAcceptedGoods() { var book=Book;var planner=new TradeRoutePlanner();var goal=Goal(book.Stations[1]) with{AcceptedCommodities=["water","gold"]}; var unrestricted=planner.CalculateCandidates(book,Request,8,communityGoals:[goal]); Check.That(unrestricted.Count>0&&unrestricted.SelectMany(plan=>plan.Legs).SelectMany(leg=>leg.Orders).Any(order=>order.Commodity=="gold")); var waterOnly=planner.CalculateCandidates(book,Request with{CommunityGoalCommodityFilter=[" Water ","coffee"]},8,communityGoals:[goal]); Check.That(waterOnly.Count>0);Check.That(waterOnly.SelectMany(plan=>plan.Legs).SelectMany(leg=>leg.Orders).All(order=>order.Commodity=="water")); Check.That(waterOnly.All(plan=>CommunityGoalSupplyScope.MatchesRoute([goal],plan,Now))); var blocked=planner.CalculateCandidatesWithDiagnostics(book,Request with{CommunityGoalCommodityFilter=["coffee"]},communityGoals:[goal]); Check.Equal(0,blocked.Candidates.Count);Check.That(blocked.Diagnostics.Message.Contains("交集")); Check.Equal(2,goal.AcceptedCommodities.Count);Check.That(goal.AcceptedCommodities.Contains("gold")); // The optional CG field cannot change ordinary-mode route comparison. Check.That(planner.CalculateCandidates(book,Request with{CommunityGoalSupplyOnly=false,CommunityGoalCommodityFilter=["coffee"]},1)[0].Legs.SelectMany(leg=>leg.Orders).Any(order=>order.Commodity=="gold")); } [Test]public void FinalRouteValidationCannotBorrowAnAcceptedGoodOutsideTheUserSubset() { var from=Check.Station(1);var to=Check.Station(2);var goal=Goal(to) with{AcceptedCommodities=["water","gold"]}; var plan=new RoutePlan{Request=Request with{CommunityGoalCommodityFilter=["water"]},Legs=[new(){From=from,To=to,Orders=[new("gold","Gold",1,10,40)]}]}; Check.That(!CommunityGoalSupplyScope.MatchesRoute([goal],plan,Now)); Check.That(CommunityGoalSupplyScope.MatchesRoute([goal],plan with{Request=plan.Request with{CommunityGoalCommodityFilter=[]}},Now)); } [Test]public void OptionalGoodsSearchNormalizesSeparatorsDuplicatesAndSettingsWithoutChangingEmptyDefaults() { Check.Equal(0,TradeSearchConstraints.ParseCommodityFilter(" \t,;\r\n").Length); var goods=TradeSearchConstraints.ParseCommodityFilter(" Water ;water,Gold\n gold "); Check.That(goods.SequenceEqual(new[]{"water","gold"})); var serialized=JsonSerializer.Serialize(Request with{CommunityGoalCommodityFilter=goods},ToolboxSettings.JsonOptions); var restored=JsonSerializer.Deserialize(serialized,ToolboxSettings.JsonOptions)!; Check.That(restored.CommunityGoalCommodityFilter.SequenceEqual(goods));Check.Equal(0,new PlannerRequest().CommunityGoalCommodityFilter.Length); } private sealed class InlineProgress(Action report):IProgress {public void Report(TradePlanningProgress value)=>report(value);} }