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<SessionOperation>())
{
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 & <B>"};
var toast=XElement.Parse(ArrivalToastContent.Build(notice));Check.Equal("action=session&page=home",toast.Attribute("launch")!.Value);
Check.Equal("A & <B>",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<SessionReentryEvent>();
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<SessionReentryEvent>();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<string> Calls=[];
public Task<GameSessionTarget> FindTargetAsync(CancellationToken token)=>Task.FromResult(Old);
public Task<GameSessionTarget> 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<bool> 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<GameSessionTarget?> FindReplacementAsync(GameSessionTarget previous,CancellationToken token)=>Task.FromResult<GameSessionTarget?>(New);
public Task RelogAsync(GameSessionTarget target,CancellationToken token)=>Task.CompletedTask;
}
}
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<SessionOperation>())
{
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 & <B>"};
var toast=XElement.Parse(ArrivalToastContent.Build(notice));Check.Equal("action=session&page=home",toast.Attribute("launch")!.Value);
Check.Equal("A & <B>",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<SessionReentryEvent>();
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<SessionReentryEvent>();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<string> Calls=[];
public Task<GameSessionTarget> FindTargetAsync(CancellationToken token)=>Task.FromResult(Old);
public Task<GameSessionTarget> 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<bool> 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<GameSessionTarget?> FindReplacementAsync(GameSessionTarget previous,CancellationToken token)=>Task.FromResult<GameSessionTarget?>(New);
public Task RelogAsync(GameSessionTarget target,CancellationToken token)=>Task.CompletedTask;
}
}