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

XFEWarframe

【WinUI】基于Warframe游戏本体的公开服务器以及安装器

公开
关注 0 Fork 0 Star 0
UTF-8
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Automation.Peers;
using Microsoft.UI.Xaml.Automation.Provider;
using Microsoft.UI.Xaml.Controls;
using Newtonsoft.Json.Linq;
using Windows.Foundation;

namespace XFEWarframe.Desktop.Editing;

internal static class EquipmentEditorVerification
{
    internal static async Task VerifyAsync(Page page, EquipmentEditorView view, long experience, bool verifyLiveInput)
    {
        if (verifyLiveInput)
        {
            for (var i = 0; i < 250 && !InVisibleTree(view, page.XamlRoot?.Content); i++) { page.UpdateLayout(); await Task.Delay(20); }
            if (!InVisibleTree(view, page.XamlRoot?.Content)) throw new InvalidOperationException("The native equipment page is not attached to the visible window.");
        }
        var model = view.Model!;
        if (model.HasMessage || !model.Tabs.Any(x => x.Key == "basic")) throw new InvalidOperationException("Detail page failed to load: " + model.Message);
        if (DialogVerification.Children<FormDialogButton>(page).Any()) throw new InvalidOperationException("Equipment editing still uses a modal launcher.");
        foreach (var width in verifyLiveInput ? new[] { page.ActualWidth } : new[] { 1400d, 1024d, 800d })
        {
            // A visible page is laid out by its window. Manually arranging it races native responsive
            // templates; the hidden-layout pass covers the synthetic widths independently.
            if (!verifyLiveInput) { page.Measure(new Size(width, 900)); page.Arrange(new Rect(0, 0, width, 900)); }
            page.UpdateLayout();
            foreach (var item in view.Selector.Items) { view.Selector.SelectedItem = item; page.UpdateLayout(); if (verifyLiveInput) await Task.Delay(200); }
        }
        void SelectBasic()
        {
            view.Selector.SelectedItem = view.Selector.Items.Single(x => ((EquipmentTab)x.Tag).Key == "basic");
            page.UpdateLayout();
        }
        SelectBasic();
        var number = await ExperienceControlAsync(page, view);
        number.Value = experience;
        foreach (var item in view.Selector.Items) { view.Selector.SelectedItem = item; page.UpdateLayout(); if (verifyLiveInput) await Task.Delay(200); }
        SelectBasic();
        if (model.Actions.Single(x => x.Key == "experience").Fields.Single().Value.Value<long>() != experience)
            throw new InvalidOperationException("Switching SelectorBar sections lost the unsaved experience.");
        await InvokeSaveAsync(view, "experience");
        if (model.HasMessage || model.HasChanges || model.Actions.Single(x => x.Key == "experience").Fields.Single().Value.Value<long>() != experience)
            throw new InvalidOperationException("Native detail save did not round-trip: " + model.Message);

        // Saving another operation must not discard a draft in a different tab.
        if (model.Actions.FirstOrDefault(x => x.Key == "forma") is { } forma)
        {
            model.Actions.Single(x => x.Key == "experience").Fields.Single().Value = new JValue(experience + 1);
            forma.Fields.Single().Value = new JValue(2);
            await model.SaveCommand.ExecuteAsync(forma);
            if (model.HasMessage || model.Actions.Single(x => x.Key == "experience").Fields.Single().Value.Value<long>() != experience + 1)
                throw new InvalidOperationException("Saving one operation discarded another tab's draft.");
        }
        foreach (var action in model.Actions.Where(x => x.Changed).ToArray()) model.ResetCommand.Execute(action);
        SelectBasic();
        number = await ExperienceControlAsync(page, view);
        number.Value = experience + 900;
        var reset = DialogVerification.Children<Button>(view).Single(x => AutomationProperties.GetAutomationId(x) == "equipment-reset-experience");
        ((IInvokeProvider)new ButtonAutomationPeer(reset).GetPattern(PatternInterface.Invoke)).Invoke();
        await Task.Delay(20);
        if (model.HasChanges || model.Actions.Single(x => x.Key == "experience").Fields.Single().Value.Value<long>() != experience)
            throw new InvalidOperationException("Reset failed to discard the local draft.");
        await model.RefreshCommand.ExecuteAsync(null);
        if (model.HasMessage || model.Actions.Single(x => x.Key == "experience").Fields.Single().Value.Value<long>() != experience)
            throw new InvalidOperationException("Reset wrote an unsaved draft to the server.");
        await VerifyAbilitySearchAsync(page, view, verifyLiveInput);
        await VerifyMultipleAbilitiesAsync(page, view, verifyLiveInput);
        await VerifyPermissionReadRaceAsync();
    }
    private static async Task VerifyMultipleAbilitiesAsync(Page page, EquipmentEditorView view, bool live)
    {
        var model = view.Model!;
        var section = view.Selector.Items.Single(x => ((EquipmentTab)x.Tag).Key == "experimental-abilities");
        view.Selector.SelectedItem = section;
        if (live) await Task.Delay(400);
        var action = model.Actions.Single(x => x.Key == "ability.multiple.0");
        CommunityToolkit.WinUI.Controls.SettingsExpander[] operations = [];
        for (var i = 0; i < 200; i++)
        {
            page.UpdateLayout();
            operations = DialogVerification.Children<CommunityToolkit.WinUI.Controls.SettingsExpander>(view)
                .Where(x => AutomationProperties.GetAutomationId(x).StartsWith("equipment-operation-" + action.Key + ".slot", StringComparison.Ordinal)).ToArray();
            foreach (var operation in operations) operation.IsExpanded = true;
            if (operations.Length == 4 && operations.Sum(x => DialogVerification.Children<AutoSuggestBox>(x).Count()) == 12) break;
            await Task.Delay(10);
        }
        if (operations.Length != 4 || operations.Sum(x => DialogVerification.Children<AutoSuggestBox>(x).Count()) != 12)
            throw new InvalidOperationException("The multiple-ability editor must display three searchable choices in each of four expandable slots.");
        static JObject Values(EquipmentAction a) => new(a.Fields.Select(f => new JProperty(f.Key, f.Value.DeepClone())));
        var untouched = Values(model.Actions.Single(x => x.Key == "ability.multiple.1"));
        var selected = new List<JToken>();
        for (var index = 0; index < 12; index++)
        {
            var field = action.Fields[index];
            var option = field.Options.Where(x => x.Value.ToString().Length > 0).ElementAt(index);
            var search = DialogVerification.Children<AutoSuggestBox>(view).Single(x => AutomationProperties.GetAutomationId(x) == field.Key);
            if (live)
            {
                search.StartBringIntoView();
                if (!search.Focus(FocusState.Programmatic)) throw new InvalidOperationException("Experimental ability slot could not receive focus.");
                await Task.Delay(300);
                await TypeAbilityQueryAsync(search, option.Value.ToString());
                var submit = DialogVerification.Children<Button>(search).Single(x => x.Name == "QueryButton");
                ((IInvokeProvider)new ButtonAutomationPeer(submit).GetPattern(PatternInterface.Invoke)).Invoke();
                await Task.Delay(20);
            }
            else field.Value = option.Value.DeepClone();
            if (!JToken.DeepEquals(field.Value, option.Value)) throw new InvalidOperationException("Experimental ability suggestion did not select its value.");
            selected.Add(option.Value.DeepClone());
        }
        await InvokeSaveAsync(view, action.Key);
        await model.RefreshCommand.ExecuteAsync(null);
        if (model.HasMessage) throw new InvalidOperationException("Multiple-ability save failed: " + model.Message);
        action = model.Actions.Single(x => x.Key == action.Key);
        for (var i = 0; i < 12; i++)
            if (!JToken.DeepEquals(action.Fields[i].Value, selected[i])) throw new InvalidOperationException("Multiple-ability slots did not survive a server reload.");
        if (!JToken.DeepEquals(untouched, Values(model.Actions.Single(x => x.Key == "ability.multiple.1"))))
            throw new InvalidOperationException("Saving configuration A modified configuration B.");
        foreach (var field in action.Fields) field.Value = new JValue("");
        await model.SaveCommand.ExecuteAsync(action);
        if (model.HasMessage || model.Actions.Single(x => x.Key == action.Key).Fields.Any(f => f.Value.ToString().Length != 0))
            throw new InvalidOperationException("Restoring the original four skills failed.");
    }
    private static async Task VerifyPermissionReadRaceAsync()
    {
        var permissions = "p1"; var inventoryVersion = "v1"; var failRead = false;
        var target = new EquipmentTarget("fixture", "Fixture", "Suits", "suit");
        var model = new EquipmentEditorModel(_ =>
        {
            if (failRead) throw new IOException("isolated transient read failure");
            return Task.FromResult(new JObject
            {
                ["version"] = inventoryVersion, ["permissionsRevision"] = permissions, ["selectedKey"] = "suit",
                ["actions"] = new JArray(new JObject
                {
                    ["key"] = "experience", ["label"] = "Experience", ["fields"] = new JArray(new JObject
                    { ["key"] = "xp", ["type"] = "number", ["value"] = 10, ["min"] = 0, ["max"] = 900000 })
                })
            });
        }, (_, _) => throw new InvalidOperationException("This concurrency check must not save."),
            _ => Task.FromResult(new EquipmentRevision("v1", permissions, true)), (_, _) => Task.FromResult(false), _ => { });
        model.Attach();
        try
        {
            await model.LoadAsync(target);
            model.Actions.Single().Fields.Single().Value = new JValue(77);
            permissions = "p2"; inventoryVersion = "v2"; failRead = true;
            await model.CheckChangesAsync();
            if (!model.HasMessage || model.Actions.Single().Fields.Single().Value.Value<long>() != 77)
                throw new InvalidOperationException("A failed permission refresh discarded a draft.");
            failRead = false;
            await model.CheckChangesAsync();
            if (!model.HasConflict || !model.HasChanges || model.SaveCommand.CanExecute(model.Actions.Single()) || model.Actions.Single().Fields.Single().Value.Value<long>() != 77)
                throw new InvalidOperationException("Permission refresh did not retry or detect inventory changed between revision and read.");
            permissions = "p3";
            await model.CheckChangesAsync();
            if (!model.HasConflict) throw new InvalidOperationException("A further permission update cleared the unresolved inventory conflict.");
        }
        finally { model.Detach(); }
    }
    private static async Task VerifyAbilitySearchAsync(Page page, EquipmentEditorView view, bool verifyLiveInput)
    {
        var model = view.Model!;
        var section = view.Selector.Items.FirstOrDefault(x => ((EquipmentTab)x.Tag).Key == "abilities");
        if (section is null) throw new InvalidOperationException("Authorized abilities are missing.");
        view.Selector.SelectedItem = section;
        if (verifyLiveInput) await Task.Delay(400); // Allow the native section and expander transition to complete before input.
        var action = model.Actions.First(x => x.Key.StartsWith("ability.", StringComparison.Ordinal));
        AutoSuggestBox? search = null;
        for (var i = 0; i < 200 && search is null; i++)
        {
            page.UpdateLayout();
            action = model.Actions.First(x => x.Key == action.Key);
            // Each configuration has its own ability field. Do not select a previous content presenter
            // or a collapsed configuration while WinUI is switching the SelectorBar content.
            var operation = DialogVerification.Children<CommunityToolkit.WinUI.Controls.SettingsExpander>(view).FirstOrDefault(x => ReferenceEquals(x.Tag, action));
            if (operation is not null)
            {
                operation.IsExpanded = true;
                if (verifyLiveInput && operation.IsLoaded) operation.StartBringIntoView();
                search = DialogVerification.Children<AutoSuggestBox>(operation).FirstOrDefault(x => (!verifyLiveInput || InVisibleTree(x, view)) && AutomationProperties.GetAutomationId(x) == "ability");
            }
            if (search is null) await Task.Delay(10);
        }
        if (search is null || search.QueryIcon is null || search.PlaceholderText.Length == 0)
        {
            var operations = DialogVerification.Children<CommunityToolkit.WinUI.Controls.SettingsExpander>(view)
                .Select(x => $"{(x.Tag as EquipmentAction)?.Key}:current={ReferenceEquals(x.Tag, action)},loaded={x.IsLoaded},expanded={x.IsExpanded}");
            string Ancestors(DependencyObject element)
            {
                var chain = new List<string>();
                for (DependencyObject? current = element; current is not null; current = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(current))
                    if (current is FrameworkElement parent) chain.Add($"{parent.GetType().Name}/{parent.Name}/{parent.IsLoaded}/{parent.Visibility}/{parent.ActualWidth:0}x{parent.ActualHeight:0}");
                return string.Join(">", chain);
            }
            var inputs = DialogVerification.Children<AutoSuggestBox>(view).Select(Ancestors);
            throw new InvalidOperationException($"Ability AutoSuggestBox missing: page={page.IsLoaded}, view={view.IsLoaded}, host={view.XamlRoot?.IsHostVisible}, tab={model.SelectedTabKey}; operations={string.Join(";", operations)}; inputs={string.Join(";", inputs)}");
        }
        // IsLoaded can remain false after WinUI template moves (microsoft-ui-xaml#1900).
        // Require the current visual ancestry, actual focus, and native text/query events instead.
        if (!verifyLiveInput) return;
        search.StartBringIntoView();
        if (!search.Focus(FocusState.Programmatic)) throw new InvalidOperationException("The visible ability input could not receive native focus.");
        // Focus and BringIntoView schedule native template work. Let that transition finish before
        // editing the TextBox part; writing in the same dispatcher turn can precede its hookup.
        await Task.Delay(400);
        var field = action.Fields.Single(x => x.Key == "ability");
        var ability = field.Options.First(x => x.Value.ToString().Length > 0);
        try { await TypeAbilityQueryAsync(search, "no-such-ability-" + Guid.NewGuid().ToString("N")); }
        catch (Exception error)
        {
            var chain = new List<string>();
            for (DependencyObject? current = search; current is not null; current = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(current))
                chain.Add(current.GetType().Name + (current is FrameworkElement f ? $"({f.Name},{f.IsLoaded})" : ""));
            throw new InvalidOperationException($"Ability input failed: currentPage={ReferenceEquals(page.Frame?.Content, page)}, currentAction={model.Actions.Contains(action)}, currentInput={DialogVerification.Children<AutoSuggestBox>(view).Contains(search)}, tab={model.SelectedTabKey}, parents={string.Join('>', chain)}", error);
        }
        if (field.Value.Type != JTokenType.Null || ((System.Collections.IEnumerable)search.ItemsSource).Cast<EditorOption>().Any())
            throw new InvalidOperationException($"Unmatched ability text retained a previous selection. Text={search.Text}; value={field.Value}; suggestions={(search.ItemsSource as System.Collections.IEnumerable)?.Cast<EditorOption>().Count()}");
        await InvokeSaveAsync(view, action.Key);
        if (!model.HasMessage) throw new InvalidOperationException("An unmatched ability was saved.");
        await TypeAbilityQueryAsync(search, ability.Label);
        if (!((System.Collections.IEnumerable)search.ItemsSource).Cast<EditorOption>().Any(x => JToken.DeepEquals(x.Value, ability.Value)))
            throw new InvalidOperationException("Ability name search did not offer the expected suggestion.");
        // Use the exact path for an unambiguous native query, even when names are duplicated.
        await TypeAbilityQueryAsync(search, ability.Value.ToString());
        var query = DialogVerification.Children<Button>(search).Single(x => x.Name == "QueryButton");
        ((IInvokeProvider)new ButtonAutomationPeer(query).GetPattern(PatternInterface.Invoke)).Invoke();
        await Task.Delay(20);
        if (!JToken.DeepEquals(field.Value, ability.Value) || search.Text != ability.Label)
            throw new InvalidOperationException("Submitting an ability suggestion did not select its stored value.");
        await InvokeSaveAsync(view, action.Key);
        if (model.HasMessage || !JToken.DeepEquals(model.Actions.Single(x => x.Key == action.Key).Fields.Single(x => x.Key == "ability").Value, ability.Value))
            throw new InvalidOperationException("The selected ability did not round-trip through the real server: " + model.Message);
    }
    private static Task TypeAbilityQueryAsync(AutoSuggestBox search, string query) => SuggestionVerification.TypeAsync(search, query);
    private static bool InVisibleTree(FrameworkElement element, DependencyObject? ancestor)
    {
        if (element.XamlRoot?.IsHostVisible != true || element.ActualWidth <= 0 || element.ActualHeight <= 0) return false;
        for (DependencyObject? current = element; current is not null; current = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(current))
        {
            if (current is FrameworkElement { Visibility: Visibility.Collapsed }) return false;
            if (ReferenceEquals(current, ancestor)) return true;
        }
        return false;
    }
    private static async Task<NumberBox> ExperienceControlAsync(Page page, EquipmentEditorView view)
    {
        for (var i = 0; i < 200; i++)
        {
            page.UpdateLayout();
            if (DialogVerification.Children<NumberBox>(view).FirstOrDefault(x => AutomationProperties.GetAutomationId(x) == "xp") is { } number) return number;
            await Task.Delay(10);
        }
        var selected = view.Selector.SelectedItem;
        var controls = DialogVerification.Children<FrameworkElement>(view)
            .Select(x => x.GetType().Name + ":" + AutomationProperties.GetAutomationId(x)).Distinct();
        throw new InvalidOperationException("The selected SelectorBar section did not render its experience control. Selected=" +
            (selected?.Tag as EquipmentTab)?.Key + "; sections=" +
            string.Join(",", view.Selector.Items.Select(x => ((EquipmentTab)x.Tag).Key)) + "; controls=" + string.Join(",", controls));
    }
    internal static async Task VerifySynchronizationAsync(EquipmentEditorModel model, Func<Task> mutateRemote)
    {
        var action = model.Actions.Single(x => x.Key == "experience");
        var persisted = action.Fields.Single().Value.Value<long>();
        action.Fields.Single().Value = new JValue(persisted + 123);
        await mutateRemote(); await model.CheckChangesAsync();
        if (!model.HasConflict || model.SaveCommand.CanExecute(action) || action.Fields.Single().Value.Value<long>() != persisted + 123)
            throw new InvalidOperationException("The detail editor failed to protect a draft after an external inventory change.");
        model.ResetCommand.Execute(action); await model.RefreshCommand.ExecuteAsync(null);
        if (model.HasConflict || model.HasMessage || model.HasChanges || model.Actions.Single(x => x.Key == "experience").Fields.Single().Value.Value<long>() != persisted)
            throw new InvalidOperationException("The detail editor did not recover after discarding the conflicting input.");
    }
    private static async Task InvokeSaveAsync(EquipmentEditorView view, string key)
    {
        view.UpdateLayout();
        var model = view.Model!; var previous = model.SaveCommand.ExecutionTask;
        var button = DialogVerification.Children<Button>(view).Single(x => AutomationProperties.GetAutomationId(x) == "equipment-save-" + key);
        if (!button.IsEnabled) throw new InvalidOperationException("Authorized Save is disabled.");
        ((IInvokeProvider)new ButtonAutomationPeer(button).GetPattern(PatternInterface.Invoke)).Invoke();
        for (var i = 0; i < 200 && ReferenceEquals(previous, model.SaveCommand.ExecutionTask); i++) await Task.Delay(10);
        if (ReferenceEquals(previous, model.SaveCommand.ExecutionTask)) throw new InvalidOperationException("The native Save button did not execute.");
        await model.SaveCommand.ExecutionTask!;
    }
}