XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Threading;
using XFEToolBox.Tools.SpaceEngineers;

namespace SpaceEngineersPluginManager.Validation;

internal static partial class Program
{
    private static ManagerSettings CreateSettings(string name = "runtime")
    {
        string root = Path.Combine(Fixtures, name);
        string bin = Path.Combine(root, "游戏 路径", "Bin64");
        string loader = Path.Combine(root, "XFE 加载器", "XFE.SpaceEngineers.exe");
        Directory.CreateDirectory(bin); Directory.CreateDirectory(Path.GetDirectoryName(loader)!);
        File.WriteAllBytes(Path.Combine(bin, "SpaceEngineers.exe"), []); File.WriteAllBytes(loader, []);
        string bridge = Path.Combine(Path.GetDirectoryName(loader)!, "XFE.SpaceEngineers.Bridge.dll"); File.WriteAllBytes(bridge, []);
        string profile = Path.Combine(root, "配置 目录", "profile.json"); Directory.CreateDirectory(Path.GetDirectoryName(profile)!);
        File.WriteAllText(profile, "{\"formatVersion\":1,\"plugins\":[]}");
        return new ManagerSettings { Bin64Path = bin, LauncherPath = loader, BridgePath = bridge, ProfilePath = profile };
    }

    private static async Task TestRuntimeAsync()
    {
        var settings = CreateSettings();
        string[] arguments = ["-nosplash", "中文 参数", "", "quote\"inside", @"C:\path with space\", "a\tb"];
        settings.Arguments = arguments.ToList();
        var info = GameRuntime.BuildStartInfo(settings, false);
        Check(info.FileName == settings.LauncherPath && !info.UseShellExecute && info.WorkingDirectory == Path.GetDirectoryName(settings.LauncherPath), "XFE launch uses explicit executable and working directory without shell");
        Check(info.ArgumentList.TakeLast(arguments.Length).SequenceEqual(arguments) && info.Arguments == "", "launch preserves exact Unicode, quotes, empty strings and trailing slash as argument list");
        Check(info.ArgumentList.Contains(settings.Bin64Path) && info.ArgumentList.Contains(settings.ProfilePath) && info.ArgumentList.Contains(settings.BridgePath) && info.ArgumentList[8] == "--", "XFE receives game, bridge and JSON profile separately from game arguments");
        var vanilla = GameRuntime.BuildStartInfo(settings, true);
        Check(vanilla.FileName == Path.Combine(settings.Bin64Path, "SpaceEngineers.exe") && vanilla.ArgumentList.SequenceEqual(arguments), "vanilla launch bypasses loader arguments and uses original game executable");
        foreach (string blocked in new[] { "-plugin", "--PROFILE", "--game-bin64", "--bridge", "invalid\0arg" })
        {
            settings.Arguments = [blocked];
            Throws<ArgumentException>(() => GameRuntime.BuildStartInfo(settings, false), "managed option/NUL rejected: " + blocked.Replace("\0", "[NUL]"));
        }
        settings.Arguments = [];
        string originalLoader = settings.LauncherPath;
        settings.LauncherPath += ".missing";
        Throws<FileNotFoundException>(() => GameRuntime.BuildStartInfo(settings, false), "missing launcher rejected before launch");
        settings.LauncherPath = originalLoader;
        var process = new RunningGame(17, Path.Combine(settings.Bin64Path, "SpaceEngineers.exe"), DateTime.UtcNow);
        var fixtureHost = new FixtureProcessHost(process);
        var runtime = new GameRuntime(fixtureHost);
        Throws<InvalidOperationException>(() => runtime.Launch(settings, false), "running game prevents duplicate launch");
        Check(fixtureHost.Starts.Count == 0 && fixtureHost.RequestedPaths!.All(Path.IsPathFullyQualified), "process search receives exact full installation paths and launch remains untouched");
        fixtureHost.AcceptClose = false;
        await ThrowsAsync<IOException>(() => runtime.CloseAsync(settings), "game without closable window fails without force termination");
        Check(fixtureHost.Alive && fixtureHost.Starts.Count == 0, "failed graceful close does not launch a replacement");
        fixtureHost.AcceptClose = true;
        using (var canceled = new CancellationTokenSource(TimeSpan.FromMilliseconds(80)))
            await ThrowsAsync<OperationCanceledException>(() => runtime.CloseAsync(settings, token: canceled.Token), "close wait can be canceled while original process remains alive");
        Check(fixtureHost.Alive && fixtureHost.Starts.Count == 0, "canceling close does not force-kill or launch game");
        fixtureHost.ExitOnClose = true;
        await runtime.CloseAsync(settings);
        runtime.Launch(settings, false);
        Check(fixtureHost.Starts.Count == 1 && !fixtureHost.Alive, "replacement launch becomes available only after graceful close completes");
        string oldLauncher = Path.Combine(Fixtures, "previous", "XFE.SpaceEngineers.exe"); settings.KnownLauncherPaths.Add(oldLauncher);
        Check(GameRuntime.Executables(settings).Contains(settings.LauncherPath) && GameRuntime.Executables(settings).Contains(oldLauncher), "process matching includes current and previous XFE launcher versions");
        await TestWindowsProcessHostAsync();
    }

    private sealed class FixtureProcessHost(RunningGame game) : IGameProcessHost
    {
        public bool Alive { get; set; } = true;
        public bool AcceptClose { get; set; } = true;
        public bool ExitOnClose { get; set; }
        public Exception? StartFailure { get; set; }
        public int CloseRequests { get; private set; }
        public IReadOnlyList<string>? RequestedPaths { get; private set; }
        public List<ProcessStartInfo> Starts { get; } = [];
        public IReadOnlyList<RunningGame> Find(IReadOnlyList<string> executablePaths) { RequestedPaths = executablePaths; return Alive ? [game] : []; }
        public bool IsAlive(RunningGame candidate) => Alive && candidate == game;
        public bool RequestClose(RunningGame candidate) { if (candidate != game) throw new InvalidOperationException("unexpected fixture PID"); CloseRequests++; if (AcceptClose && ExitOnClose) Alive = false; return AcceptClose; }
        public void Start(ProcessStartInfo startInfo) { Starts.Add(startInfo); if (StartFailure != null) throw StartFailure; }
    }

    private static int RunProcessFixture(string marker)
    {
        var app = new Application();
        var window = new Window { Width = 180, Height = 100, Left = -30000, Top = -30000, ShowInTaskbar = true, ShowActivated = false, Title = "Owned validation fixture" };
        window.Loaded += (_, _) =>
        {
            var ready = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
            ready.Tick += (_, _) => { ready.Stop(); File.WriteAllText(marker + ".ready", Environment.ProcessId.ToString()); };
            ready.Start();
        };
        window.Closed += (_, _) => File.WriteAllText(marker + ".closed", "graceful");
        var expiry = new DispatcherTimer { Interval = TimeSpan.FromSeconds(20) };
        expiry.Tick += (_, _) => app.Shutdown(9); expiry.Start();
        return app.Run(window);
    }

    private static async Task TestWindowsProcessHostAsync()
    {
        string marker = Path.Combine(Fixtures, "owned-window");
        var info = new ProcessStartInfo(Environment.ProcessPath!) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
        info.ArgumentList.Add("--process-fixture"); info.ArgumentList.Add(marker);
        using var fixture = Process.Start(info)!;
        try
        {
            var wait = Stopwatch.StartNew();
            while (!File.Exists(marker + ".ready"))
            {
                if (fixture.HasExited || wait.Elapsed > TimeSpan.FromSeconds(12)) throw new InvalidOperationException("Owned process fixture did not become ready: " + await fixture.StandardError.ReadToEndAsync());
                await Task.Delay(30);
            }
            var host = new WindowsGameProcessHost();
            var found = host.Find([Environment.ProcessPath!]);
            var identity = found.Single(game => game.Id == fixture.Id);
            Check(host.IsAlive(identity), "Windows process discovery verifies real owned fixture path and start time");
            Check(!host.IsAlive(identity with { StartTimeUtc = identity.StartTimeUtc.AddSeconds(-1) }) && !host.IsAlive(identity with { ExecutablePath = Path.Combine(Fixtures, "different.exe") }), "PID reuse or executable mismatch cannot match a managed process");
            Check(host.Find([Path.Combine(Fixtures, Path.GetFileName(Environment.ProcessPath)!)]).Count == 0, "same executable name in a different directory never matches process search");
            Check(host.RequestClose(identity), "real owned window accepts normal close message");
            await fixture.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(8));
            Check(fixture.ExitCode == 0 && File.ReadAllText(marker + ".closed") == "graceful" && !host.IsAlive(identity), "normal close runs owned fixture shutdown handler and identity expires");
        }
        finally
        {
            // Only this test's directly spawned fixture can be terminated during failed-test cleanup.
            if (!fixture.HasExited) { fixture.Kill(true); await fixture.WaitForExitAsync(); }
        }
    }
}