using System.Text.Json; using XFEEDToolBox.Core.Execution; using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Navigation; using XFEExtension.NetCore.XUnit.Attributes; namespace XFEEDToolBox.Test; [TestFixture]public sealed class AutomaticTradeSafetyRecoveryTests { private static readonly DateTimeOffset Now=DateTimeOffset.UtcNow; private static PlayerState Ship=>new(){CommanderId="safety-test",CurrentShipId=8,CargoCapacity=1200,CargoCount=0, InGame=true,LoadGameAt=Now.AddHours(-1),Docked=true,MarketId=1,CargoReliable=true,CreditsReliable=true,StatusReadAt=Now, Ships=[new(){ShipId=8,HullHealth=.8}]}; [Test]public void SafeDockAndFreshContinuousHealthMustRemainStableForTenSeconds() { var gate=new AutomaticTradeSafetyGate(Ship); Check.That(!gate.Observe(Ship,Now,true));Check.That(!gate.Observe(Ship with{StatusReadAt=Now.AddSeconds(9)},Now.AddSeconds(9),true)); Check.That(gate.Observe(Ship with{StatusReadAt=Now.AddSeconds(10)},Now.AddSeconds(10),true)); } [Test]public void FlightThreatDamageHealthGapAndStaleStatusNeverAuthorizeResume() { foreach(var unsafeState in new[]{Ship with{Docked=false},Ship with{Navigation=new(){ExternalThreat=true}},Ship with{CargoReliable=false}, Ship with{CreditsReliable=false},Ship with{StatusReadAt=Now.AddSeconds(-3)},Ship with{MarketId=null}, Ship with{Ships=[new(){ShipId=8,HullHealth=0}]},Ship with{InGame=false}}) { var gate=new AutomaticTradeSafetyGate(Ship);Check.That(!gate.Observe(Ship,Now,true)); Check.That(!gate.Observe(unsafeState,Now.AddSeconds(10),true)); Check.That(!gate.Observe(Ship with{StatusReadAt=Now.AddSeconds(11)},Now.AddSeconds(11),true)); } var health=new AutomaticTradeSafetyGate(Ship);Check.That(!health.Observe(Ship,Now,true)); Check.That(!health.Observe(Ship with{StatusReadAt=Now.AddSeconds(10)},Now.AddSeconds(10),false)); } [Test]public void IdentitySessionOrCapacityChangesRemainTerminalAndCannotBeRearmed() { foreach(var foreign in new[]{Ship with{CommanderId="other"},Ship with{CurrentShipId=9},Ship with{CargoCapacity=2000},Ship with{LoadGameAt=Now}}) Check.Throws(()=>new AutomaticTradeSafetyGate(Ship).Observe(foreign,Now,true)); Check.That(!FlightThreatPauseException.Present(new OperationCanceledException("manual cancel"))); Check.That(!FlightThreatPauseException.Present(new InvalidOperationException("人工接管"))); Check.That(FlightThreatPauseException.Present(new InvalidOperationException("navigation wrapper",new FlightThreatPauseException()))); } [Test]public void MovingToAnotherDockResetsTheStabilityWindow() { var gate=new AutomaticTradeSafetyGate(Ship);gate.Observe(Ship,Now,true); Check.That(!gate.Observe(Ship with{MarketId=2,StatusReadAt=Now.AddSeconds(10)},Now.AddSeconds(10),true)); Check.That(gate.Observe(Ship with{MarketId=2,StatusReadAt=Now.AddSeconds(20)},Now.AddSeconds(20),true)); } [Test]public void StableHistoricalHullInFlightStillNeedsFreshContinuousAuthorization() { var flying=Ship with{Docked=false,MarketId=null};var gate=new AutomaticTradeSafetyGate(flying){RequireDocked=false}; Check.That(!gate.Observe(flying,Now,false));var at=Now.AddSeconds(10); Check.That(!gate.Observe(flying with{StatusReadAt=at},at,true));at=Now.AddSeconds(20); Check.That(gate.Observe(flying with{StatusReadAt=at},at,true)); Check.That(!gate.Observe(flying with{StatusReadAt=at},at,false)); } [Test]public void RepeatAttackBackoffPersistsAndDuplicateJournalRowsDoNotMultiplyIt() { var first=new TradeSourceThreat(42,"Station",Now.AddHours(-2),Now.AddHours(-1)); var second=TradeSourceThreat.Merge(first,new(42,"Station",Now,Now.AddHours(1))); Check.Equal(2,second.Incidents);Check.Equal(Now.AddHours(24),second.AvoidUntil); Check.Equal(2,TradeSourceThreat.Merge(second,second with{AttackedAt=Now.AddSeconds(1)}).Incidents); var third=TradeSourceThreat.Merge(second,new(42,"Station",Now.AddHours(25),Now.AddHours(26))); Check.Equal(3,third.Incidents);Check.Equal(Now.AddHours(97),third.AvoidUntil); } [Test]public void DepartureAttackIsAttributedToTheStationActuallyLeft() { using var folder=new TemporaryDirectory();var file=Path.Combine(folder.Path,"Journal.departure.log"); File.WriteAllLines(file,new object[]{new{timestamp=Now.AddMinutes(-3),@event="LoadGame",FID="safety-test",ShipID=8}, new{timestamp=Now.AddMinutes(-2),@event="Undocked",MarketID=42,StationName="Actual origin"}, new{timestamp=Now.AddMinutes(-1),@event="UnderAttack",Target="You"}}.Select(value=>JsonSerializer.Serialize(value))); var rows=TradeSourceThreatJournal.Read(folder.Path,Ship,Now);Check.Equal(1,rows.Count);Check.Equal(42L,rows[0].MarketId); } private static void Journal(string path,string commander="safety-test",long ship=8,string target="You",bool afterJump=false) { var rows=new List{new{timestamp=Now.AddHours(-2),@event="LoadGame",FID=commander,ShipID=ship}, new{timestamp=Now.AddMinutes(-10),@event="DockingGranted",MarketID=42,StationName="Attacked supply"}}; if(afterJump)rows.Add(new{timestamp=Now.AddMinutes(-9),@event="FSDJump",StarSystem="Other"}); rows.Add(new{timestamp=Now.AddMinutes(-8),@event="UnderAttack",Target=target}); File.WriteAllLines(path,rows.Select(r=>JsonSerializer.Serialize(r))); } [Test]public void OriginalApproachAttackIsScopedToOurCommanderShipAndStation() { using var dir=new TemporaryDirectory();Journal(Path.Combine(dir.Path,"Journal.test.log")); var rows=TradeSourceThreatJournal.Read(dir.Path,Ship,Now);Check.Equal(1,rows.Count);Check.Equal(42L,rows[0].MarketId); Check.Equal(Now.AddMinutes(52),rows[0].AvoidUntil); Check.Equal(0,TradeSourceThreatJournal.Read(dir.Path,Ship with{CommanderId="other"},Now).Count); Check.Equal(0,TradeSourceThreatJournal.Read(dir.Path,Ship with{CurrentShipId=9},Now).Count); Check.Equal(0,TradeSourceThreatJournal.Read(dir.Path,Ship,Now.AddHours(2)).Count); } [Test]public void DifferentTargetAndAnOldStationPermissionDoNotQuarantineAnUnrelatedMarket() { using var dir=new TemporaryDirectory();var file=Path.Combine(dir.Path,"Journal.test.log");Journal(file,target:"Fighter"); Check.Equal(0,TradeSourceThreatJournal.Read(dir.Path,Ship,Now).Count);Journal(file,afterJump:true); Check.Equal(0,TradeSourceThreatJournal.Read(dir.Path,Ship,Now).Count); } }