using System.Xml.Linq; using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Notifications; using XFEEDToolBox.Core.Session; using XFEExtension.NetCore.XUnit.Attributes; namespace XFEEDToolBox.Test; [TestFixture] public class SessionReadyNotificationTests { private static readonly DateTimeOffset At=DateTimeOffset.Parse("2026-10-23T06:00:00Z"),ReadyAt=At.AddSeconds(20); private static readonly GameSessionTarget Old=new(1,At.AddMinutes(-5).UtcTicks,"C:/game/EliteDangerous64.exe","launcher",false); private static readonly GameSessionTarget New=Old with{ProcessId=2,StartedUtcTicks=At.AddSeconds(5).UtcTicks}; private static PlayerState Before()=>new(){CommanderId="F1",InGame=true,LoadGameAt=At.AddMinutes(-1),SystemName="Sol",Navigation=new(){CurrentSystemAddress=42}}; private static PlayerState Ready()=>Before() with{LoadGameAt=ReadyAt,LocationAt=ReadyAt,StatusAt=ReadyAt,StatusReadAt=ReadyAt, Status=new(1L<<24,0,1,1,0,null,null,null,null,[])}; private static SessionReentryEvent Request(SessionOperation operation=SessionOperation.Restart,bool protection=false) =>new(Guid.NewGuid(),operation,Old,protection,SessionReentryStage.Requested,At); private static SessionReadyNotificationObserver Waiting(SessionReentryEvent? request=null) { var observer=new SessionReadyNotificationObserver();var entry=request??Request();observer.OnReentry(entry,Before()); observer.OnReentry(entry with{Stage=SessionReentryStage.ProcessReady,Target=New},Before());return observer; } [Test] public void ANewProcessCannotNotifyUntilTheFreshPlayableSessionAndLocationAreReady() { var observer=new SessionReadyNotificationObserver();var request=Request(); Check.That(observer.Observe(Ready(),New,ReadyAt) is null); observer.OnReentry(request,Before());Check.That(observer.Observe(Ready(),New,ReadyAt) is null); observer.OnReentry(request with{Stage=SessionReentryStage.ProcessReady,Target=New},Before()); Check.That(observer.Observe(Before(),New,ReadyAt) is null); var notice=observer.Observe(Ready(),New,ReadyAt)!;Check.Equal("已重新进入游戏",notice.Title); Check.Equal("一键大退已完成 · Sol",notice.Detail);Check.That(observer.Observe(Ready(),New,ReadyAt) is null); } [Test] public void RelogRestartStartAndProtectionUseTheSameConfirmedReadinessGate() { foreach(var operation in Enum.GetValues()) { var notice=Waiting(Request(operation)).Observe(Ready(),New,ReadyAt)!; Check.Equal(operation==SessionOperation.Start?"游戏已准备就绪":"已重新进入游戏",notice.Title); } Check.Equal("飞船保护大退已完成 · Sol",Waiting(Request(protection:true)).Observe(Ready(),New,ReadyAt)!.Detail); } [Test] public void LoadingOldStatusOldLocationMissingWorldAndUnfreshReadsDoNotCountAsEntry() { foreach(var player in new[]{Ready() with{InGame=false},Ready() with{LoadGameAt=Before().LoadGameAt}, Ready() with{LocationAt=At},Ready() with{StatusAt=At},Ready() with{StatusReadAt=At},Ready() with{Status=null}, Ready() with{Status=Ready().Status! with{Flags=0}},Ready() with{Navigation=new(){CurrentSystemAddress=0}}, Ready() with{Navigation=new(){CurrentSystemAddress=42,JumpInProgress=true}}}) Check.That(Waiting().Observe(player,New,ReadyAt) is null); } [Test] public void AnotherProcessOrCommanderCannotCompleteTheRequestedEntry() { foreach(var target in new[]{Old,New with{StartedUtcTicks=0},New with{Executable="other"}}) Check.That(Waiting().Observe(Ready(),target,ReadyAt) is null); var observer=Waiting();Check.That(observer.Observe(Ready() with{CommanderId="F2"},New,ReadyAt) is null); Check.That(observer.Observe(Ready(),New,ReadyAt) is null); } [Test] public void AbortedOperationsAndDelayedSignalsFromPreviousOperationsCannotNotify() { var first=Request();var second=Request(SessionOperation.Relog);var observer=Waiting(first); observer.OnReentry(first with{Stage=SessionReentryStage.Aborted},Before()); observer.OnReentry(first with{Stage=SessionReentryStage.ProcessReady,Target=New},Before()); Check.That(observer.Observe(Ready(),New,ReadyAt) is null); observer.OnReentry(second,Before());observer.OnReentry(first with{Stage=SessionReentryStage.Aborted},Before()); observer.OnReentry(second with{Stage=SessionReentryStage.ProcessReady,Target=New},Before()); Check.That(observer.Observe(Ready(),New,ReadyAt)!.Detail.StartsWith("一键重进")); } [Test] public void SessionToastPreservesTextAndOnlyOpensTheHomePage() { var notice=Waiting().Observe(Ready(),New,ReadyAt)! with{Detail="A & "}; var toast=XElement.Parse(ArrivalToastContent.Build(notice));Check.Equal("action=session&page=home",toast.Attribute("launch")!.Value); Check.Equal("A & ",toast.Descendants("text").Last().Value);Check.That(!toast.Descendants("image").Any()); } [Test] public async Task SessionEventsIdentifyRealRelaunchesAndObserverFailuresCannotBlockThem() { var driver=new Driver();var controller=new GameSessionController(driver,()=>false);var events=new List(); controller.Reentry+=_=>throw new InvalidOperationException("notification unavailable");controller.Reentry+=events.Add; await controller.RunAsync(SessionOperation.Restart); Check.Equal("kill,launch",string.Join(',',driver.Calls));Check.Equal(2,events.Count); Check.Equal(SessionReentryStage.Requested,events[0].Stage);Check.Equal(SessionReentryStage.ProcessReady,events[1].Stage); Check.Equal(events[0].Id,events[1].Id);Check.Equal(New.ProcessId,events[1].Target.ProcessId); events.Clear();await controller.RunAsync(SessionOperation.Start);Check.Equal(0,events.Count); } [Test] public async Task FailedLaunchPublishesAbortRatherThanProcessReady() { var controller=new GameSessionController(new Driver{FailLaunch=true},()=>false);var events=new List();controller.Reentry+=events.Add; try{await controller.RunAsync(SessionOperation.Restart);}catch(InvalidOperationException){} Check.Equal(2,events.Count);Check.Equal(SessionReentryStage.Aborted,events[1].Stage); } private sealed class Driver:IGameSessionDriver { public bool CanRelog=>true;public bool FailLaunch;public readonly List Calls=[]; public Task FindTargetAsync(CancellationToken token)=>Task.FromResult(Old); public Task FindLaunchTargetAsync(CancellationToken token)=>Task.FromResult(Old); public Task ValidateLaunchAsync(GameSessionTarget target,CancellationToken token)=>Task.CompletedTask; public Task ValidateRestartAsync(GameSessionTarget target,CancellationToken token)=>Task.CompletedTask; public Task TerminateAsync(GameSessionTarget target,CancellationToken token){Calls.Add("kill");return Task.CompletedTask;} public Task HasExitedAsync(GameSessionTarget target,CancellationToken token)=>Task.FromResult(true); public Task LaunchAsync(GameSessionTarget target,CancellationToken token) {Calls.Add("launch");return FailLaunch?Task.FromException(new InvalidOperationException("launcher failed")):Task.CompletedTask;} public Task FindReplacementAsync(GameSessionTarget previous,CancellationToken token)=>Task.FromResult(New); public Task RelogAsync(GameSessionTarget target,CancellationToken token)=>Task.CompletedTask; } }