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!;
}
}
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!;
}
}