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<CommunityGoalInfo>[]
{
[],[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<TradePlanningProgress>();
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<ToolboxSettings>(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<PlannerRequest>(serialized,ToolboxSettings.JsonOptions)!;
Check.That(restored.CommunityGoalCommodityFilter.SequenceEqual(goods));Check.Equal(0,new PlannerRequest().CommunityGoalCommodityFilter.Length);
}
private sealed class InlineProgress(Action<TradePlanningProgress> report):IProgress<TradePlanningProgress>
{public void Report(TradePlanningProgress value)=>report(value);}
}
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<CommunityGoalInfo>[]
{
[],[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<TradePlanningProgress>();
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<ToolboxSettings>(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<PlannerRequest>(serialized,ToolboxSettings.JsonOptions)!;
Check.That(restored.CommunityGoalCommodityFilter.SequenceEqual(goods));Check.Equal(0,new PlannerRequest().CommunityGoalCommodityFilter.Length);
}
private sealed class InlineProgress(Action<TradePlanningProgress> report):IProgress<TradePlanningProgress>
{public void Report(TradePlanningProgress value)=>report(value);}
}