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<InvalidOperationException>(()=>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<object>{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);
}
}
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<InvalidOperationException>(()=>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<object>{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);
}
}