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

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Text.Json;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Automation.Peers;
using Microsoft.UI.Xaml.Media;
using XFEEDToolBox.Views;
using XFEEDToolBox.Core.Galaxy;
using XFEEDToolBox.Core.Models;

namespace XFEEDToolBox;

public partial class App
{
    private sealed record TradeUiCheck(string Item,string Status,string Evidence);
    private static async Task<object> RunTradePlannerProbeAsync(string directory)
    {
        var checks=new List<TradeUiCheck>();var captures=new List<string>();
        object? defaultLayout=null,plannedLayout=null,editLayout=null,editBottomLayout=null,executionLayout=null;
        object? initialInvestment=null;
        string? calculationSkipped=null,failure=null;
        object? regionFixture=null;
        object? planningVisualFixture=null;
        var isolatedDirectory=Path.GetFullPath(Path.Combine(directory,"isolated-data"));
        var isolatedFixture=ViewModel.OfflinePreview&&Path.GetFullPath(Runtime.DataDirectory).Equals(isolatedDirectory,StringComparison.OrdinalIgnoreCase);
        void Check(string name,bool? passed,string evidence)=>checks.Add(new(name,passed is null?"unconfirmed":passed.Value?"passed":"failed",evidence));
        if(ViewModel.DemoMode)throw new InvalidOperationException("Trade UI acceptance must use actual local player and market data.");
        AppShellPage.Current!.Navigate(typeof(PlannerPage));await Task.Delay(250);
        var page=(PlannerPage)AppShellPage.Current.CurrentPage!;
        var scroller=(ScrollViewer)page.Content;
        FrameworkElement Find(string id)=>page.FindName(id) as FrameworkElement
            ??Descendants(page).OfType<FrameworkElement>().FirstOrDefault(element=>AutomationProperties.GetAutomationId(element)==id)
            ??throw new InvalidOperationException("The trade page does not contain the named element: "+id);
        var advancedSearch=(Expander)Find("TradeAdvancedSearchExpander");
        var advancedPeer=new ExpanderAutomationPeer(advancedSearch);
        bool Visible(FrameworkElement element)
        {
            DependencyObject? current=element;
            while(current is not null)
            {
                if(current is UIElement ui&&ui.Visibility!=Visibility.Visible)return false;
                if(ReferenceEquals(current,page))break;
                current=VisualTreeHelper.GetParent(current);
            }
            return element.ActualWidth>0&&element.ActualHeight>0;
        }
        object Bounds(FrameworkElement element)
        {
            var visible=Visible(element);
            var origin=visible?element.TransformToVisual(page).TransformPoint(new(0,0)):(Windows.Foundation.Point?)null;
            return new{element.Name,x=origin?.X,y=origin?.Y,width=element.ActualWidth,height=element.ActualHeight,
                visibility=element.Visibility.ToString(),effectiveVisible=visible,right=origin?.X+element.ActualWidth,bottom=origin?.Y+element.ActualHeight};
        }
        object Layout()=>new
        {
            pageWidth=page.ActualWidth,pageHeight=page.ActualHeight,viewportWidth=scroller.ViewportWidth,
            scrollableWidth=scroller.ScrollableWidth,verticalOffset=scroller.VerticalOffset,scrollableHeight=scroller.ScrollableHeight,
            investment=Bounds(Find("TradeInvestmentAmount")),planButton=Bounds(Find("CalculateTradeRoutesButton")),
            advancedExpander=Bounds(advancedSearch),advancedExpanded=advancedSearch.IsExpanded,advanced=Bounds(Find("TradeAdvancedOptions")),
            center=Bounds(Find("TradeRegionCenterInput")),radius=Bounds(Find("TradeRegionRadiusInput")),followCurrent=Bounds(Find("FollowCurrentTradeRegionButton")),
            execution=Bounds((FrameworkElement)page.FindName("TradeRunCard")),planningMode=Bounds(Find("TradePlanningModeInput")),
            ViewModel.TradePlanningModeIndex,ViewModel.TradeEditLabel,
            visibleNumberInputs=Descendants(page).OfType<NumberBox>().Count(Visible),
            texts=Descendants(page).OfType<TextBlock>().Where(Visible).Select(text=>text.Text).Where(text=>!string.IsNullOrWhiteSpace(text)).Distinct().ToArray()
        };
        async Task Capture(string name)
        {
            page.UpdateLayout();await Task.Delay(100);
            var path=Path.Combine(directory,name+".png");await CapturePageAsync(page,path);captures.Add(path);
        }
        try
        {
            Check("advanced search initially collapsed",!advancedSearch.IsExpanded&&!ViewModel.TradeAdvancedEditing,"A newly opened planning page starts with the actual advanced-search Expander collapsed.");
            advancedPeer.Collapse();scroller.ChangeView(null,0,null,true);page.UpdateLayout();await Task.Delay(120);
            await Capture("trade-planner-default");defaultLayout=Layout();
            var investment=Find("TradeInvestmentAmount");var calculate=Find("CalculateTradeRoutesButton");
            var advanced=Find("TradeAdvancedOptions");
            Check("default hides advanced controls",!advancedSearch.IsExpanded&&!ViewModel.TradeAdvancedEditing&&!Visible(advanced),"The real Expander is collapsed and its content is hidden, not merely off-screen.");
            var planningMode=(ComboBox)Find("TradePlanningModeInput");
            Check("planning type is visible without advanced search",Visible(planningMode)&&planningMode.Items.Cast<string>().SequenceEqual(new[]{"常规跑商","CG 供货"}),"The main bar exposes both planning types while advanced fields remain collapsed.");
            Check("advanced search replaces top-right button",advancedSearch.Header as string=="高级搜索选项"&&page.FindName("TradePlannerEditButton") is null,"The advanced-search header belongs to the inline Expander; the old top-right action is absent.");
            Check("advanced expansion respects execution ownership",advancedSearch.IsEnabled==ViewModel.TradeSelectionEnabled,"Expansion and its entire content share the existing idle-only selection gate.");
            if(isolatedFixture)
            {
                var savedMode=ViewModel.TradePlanningModeIndex;
                try
                {
                    ViewModel.TradePlanningModeIndex=1;page.UpdateLayout();await Task.Delay(100);
                    await Capture("trade-planner-cg-mode");
                    Check("main CG selection shares advanced mode",ViewModel.AutomaticModeIndex==1&&planningMode.SelectedIndex==1
                        &&((ComboBox)Find("AutomaticTradeMode")).SelectedIndex==1,"Both real XAML selectors bind the same mode; offline preview sends no native requests or game commands.");
                }
                finally{ViewModel.TradePlanningModeIndex=savedMode;page.UpdateLayout();}
            }
            Check("default shows investment and region radius",Descendants(page).OfType<NumberBox>().Count(Visible)==2&&Visible(Find("TradeRegionRadiusInput"))&&Visible(Find("TradeRegionCenterInput"))&&Visible(Find("FollowCurrentTradeRegionButton")),"Investment, exact centre selection and radius remain visible; other constraints stay in Edit.");
            Check("saved shared radius is used",ViewModel.Radius==Runtime.Settings.MarketCacheRadiusLy&&double.IsFinite(ViewModel.Radius)&&ViewModel.Radius>0,$"Saved shared scope: {Runtime.Settings.MarketCacheCenterName}; radius {Runtime.Settings.MarketCacheRadiusLy} ly. New profiles default to 500 ly; explicit saved values are preserved.");
            Check("investment and planning controls visible",Visible(investment)&&Visible(calculate)&&Visible(advancedSearch),"Actual XAML layout includes investment, planning and the collapsed advanced-search header.");
            var advancedOrigin=advancedSearch.TransformToVisual(page).TransformPoint(new(0,0));
            var regionInput=Find("TradeRegionRadiusInput");var regionOrigin=regionInput.TransformToVisual(page).TransformPoint(new(0,0));
            Check("advanced search follows ordinary search",advancedOrigin.Y>=regionOrigin.Y+regionInput.ActualHeight,"The Expander is below the ordinary investment, mode and search-region controls.");
            var investmentOrigin=investment.TransformToVisual(page).TransformPoint(new(0,0));var calculateOrigin=calculate.TransformToVisual(page).TransformPoint(new(0,0));
            Check("fullwidth investment bar fits",investmentOrigin.X>=0&&calculateOrigin.X+calculate.ActualWidth<=page.ActualWidth+1&&scroller.ScrollableWidth<1,"Investment and plan button fit the page without horizontal scrolling.");
            var player=ViewModel.CurrentPlayer;
            initialInvestment=new{ViewModel.TradeInvestment,ViewModel.TradeInvestmentMode,ViewModel.TradeFundsSummary,ViewModel.Reserve,player.Credits,player.Rebuy};
            Check("investment defaults to all usable funds",ViewModel.TradeInvestmentMode=="全部可用资金"&&Math.Abs(ViewModel.TradeInvestment-Math.Max(0,(double)(player.Credits??0)-ViewModel.Reserve))<.01,"The initial investment follows the actual wallet after the operational and insurance reserve.");
            if(isolatedFixture)
            {
                var original=Runtime.Settings;var oldRadius=ViewModel.Radius;
                try
                {
                    var center=new GalacticSystem("Isolated region fixture",new(120,3,-5),Source:"isolated-fixture");
                    ViewModel.RegisterTradeRegionSystemForProbe(center,isolatedDirectory);
                    await ViewModel.CommitTradeRegionCenterAsync(center.Name);
                    var saved=ToolboxSettings.Load(Runtime.SettingsPath);
                    Check("isolated selected center is persisted",saved.MarketCacheCenterName==center.Name&&saved.MarketCacheCenterPosition==center.Position,"Explicit synthetic centre fixture only; no player, wallet, cargo or game state was substituted.");
                    ViewModel.Radius=725;await ViewModel.SaveTradeRegionRadiusAsync();saved=ToolboxSettings.Load(Runtime.SettingsPath);
                    Check("isolated radius commit is persisted",saved.MarketCacheRadiusLy==725&&saved.Planner.SearchRadiusLy==725,"The same committed radius controls cache admission and planning.");
                    var bytes=await File.ReadAllBytesAsync(Runtime.SettingsPath);
                    ViewModel.Radius=double.NaN;
                    await ViewModel.SaveTradeRegionRadiusAsync();
                    var afterInvalid=await File.ReadAllBytesAsync(Runtime.SettingsPath);
                    var emptyRadiusDefaulted=ViewModel.Radius==725&&bytes.SequenceEqual(afterInvalid);
                    Check("empty radius retains saved default",emptyRadiusDefaulted,"Clearing the optional numeric field restores the last saved radius and leaves settings unchanged.");
                    ViewModel.Radius=725;await ViewModel.FollowCurrentTradeRegionCommand.ExecuteAsync(null);saved=ToolboxSettings.Load(Runtime.SettingsPath);
                    Check("restore-follow clears fixed center",saved.MarketCacheCenterName.Length==0&&saved.MarketCacheCenterPosition is null,"Follow-current stores no invented fixed coordinate.");
                    regionFixture=new{explicitIsolatedFixture=true,center=center.Name,center.Position,savedRadius=725,emptyRadiusDefaulted,followRestored=true,gameActionsInvoked=false};
                }
                finally
                {
                    ViewModel.Radius=oldRadius;
                    if(original.MarketCacheCenterPosition is {} originalPosition&&original.MarketCacheCenterName.Length>0)
                        ViewModel.RegisterTradeRegionSystemForProbe(new(original.MarketCacheCenterName,originalPosition,Source:"isolated-fixture"),isolatedDirectory);
                    await ViewModel.CommitTradeRegionCenterAsync(original.MarketCacheCenterName);
                    await Runtime.SaveSettingsAsync(original);
                }
            }
            else Check("isolated center/radius persistence fixture",null,"Not exercised against the production profile; requires --offline-preview and report-directory/isolated-data.");
            var inputsReady=player.CreditsReliable&&player.CargoReliable&&player.Credits>player.Rebuy&&player.CargoCapacity>player.CargoCount&&player.MaxJumpRange is >0&&ViewModel.Stations.Count>0;
            if(inputsReady&&ViewModel.CalculateCommand.CanExecute(null))
            {
                await ViewModel.CalculateCommand.ExecuteAsync(null);page.UpdateLayout();await Task.Delay(180);
                plannedLayout=Layout();await Capture("trade-planner-ranked-routes");
                var plans=ViewModel.TradeCandidates.Select(candidate=>candidate.Plan).ToArray();
                Check("routes ranked by estimated Cr per hour",plans.Length>0?plans.Zip(plans.Skip(1)).All(pair=>pair.First.CreditsPerHour>=pair.Second.CreditsPerHour):null,$"Actual local-data candidate count: {plans.Length}. {ViewModel.TradePlanningStatus}");
                Check("candidate selection sets actual plan",plans.Length>0?ReferenceEquals(ViewModel.Plan,ViewModel.SelectedTradeCandidate?.Plan):null,"Selected route must bind the same RoutePlan used for execution.");
                Check("existing cargo disables automatic candidate execution",!ViewModel.TradeHasExistingCargo||!ViewModel.ExecuteTradePlanCommand.CanExecute(null),ViewModel.TradeExecutionPolicy);
                Check("risk tiers shown without fabricated probability",plans.Length>0?ViewModel.TradeCandidates.All(candidate=>Descendants(page).OfType<TextBlock>().Any(text=>Visible(text)&&text.Text==candidate.Risk)):null,"Risk badges use the computed heuristic tier or explicitly state insufficient information.");
                Check("estimates include total route and approach",plans.Length>0?plans.All(plan=>plan.IsEstimate&&plan.Minutes>=plan.DeadheadMinutes&&plan.CreditsPerHour==(plan.Minutes>0?plan.NetProfit*60/(decimal)plan.Minutes:0)):null,"Hourly profit and duration are calculated from whole-route totals, including approach to origin.");
                Check("planning submits no execution",!ViewModel.RunActive,"Only CalculateCommand was invoked; start, plugin, session and trade commands were never invoked by this probe.");
                var list=(ListView)Find("TradeRouteCandidates");
                for(var index=0;index<ViewModel.TradeCandidates.Count;index++)
                {
                    list.ScrollIntoView(ViewModel.TradeCandidates[index]);page.UpdateLayout();await Task.Delay(100);
                    if(list.ContainerFromIndex(index) is not FrameworkElement card)throw new InvalidOperationException("A route candidate was not realized for capture.");
                    card.StartBringIntoView(new BringIntoViewOptions{AnimationDesired=false});await Task.Delay(100);
                    var cardPath=Path.Combine(directory,$"trade-planner-route-{index+1:00}.png");
                    await CaptureRefinementElementAsync(card,cardPath);captures.Add(cardPath);
                }
                var runCard=(FrameworkElement)page.FindName("TradeRunCard");runCard.StartBringIntoView(new BringIntoViewOptions{AnimationDesired=false});await Task.Delay(100);
                var runPath=Path.Combine(directory,"trade-planner-execution.png");await CaptureRefinementElementAsync(runCard,runPath);captures.Add(runPath);
                await Capture("trade-planner-execution-visible");executionLayout=Layout();
                var runOrigin=runCard.TransformToVisual(page).TransformPoint(new(0,0));
                Check("execution panel fully in viewport",runOrigin.Y>=-1&&runOrigin.Y+runCard.ActualHeight<=page.ActualHeight+1,"The scrolled page capture includes the complete execution summary and its actual message.");
            }
            else
            {
                calculationSkipped="Current local balance, free cargo, jump range, station cache or idle planning command is unavailable; no demonstration data was substituted.";
                Check("route calculation with actual data",null,calculationSkipped);
            }
            Check("execution metrics labels",new[]{"已完成轮数","本次已确认净利润","运行时长","当前阶段"}.All(label=>Descendants(page).OfType<TextBlock>().Any(text=>text.Text==label)),"All four execution metrics have explicit labels.");
            advancedPeer.Expand();scroller.ChangeView(null,0,null,true);page.UpdateLayout();await Task.Delay(160);
            editLayout=Layout();await Capture("trade-planner-edit");
            Check("advanced reveals optional search constraints",advancedSearch.IsExpanded&&ViewModel.TradeAdvancedEditing&&Visible(Find("AutomaticTradeSource"))&&Visible(Find("AutomaticTradeDestination"))&&Visible(Find("AutomaticTradeMode")),"The actual Expander automation peer opens the optional fields and writes the shared editing state through its two-way binding.");
            if(isolatedFixture)
            {
                Check("empty search fields remain optional",ViewModel.TradeSourceSearchStation is null&&ViewModel.TradeDestinationSearchStation is null
                    &&string.IsNullOrWhiteSpace(ViewModel.TradeSourceSearchText)&&string.IsNullOrWhiteSpace(ViewModel.TradeDestinationSearchText)
                    &&string.IsNullOrWhiteSpace(ViewModel.TradeSourceSystemText)&&string.IsNullOrWhiteSpace(ViewModel.TradeDestinationSystemText),"Fresh isolated defaults leave all four source/destination constraints empty; no execution-station choice is forced into search.");
                var savedMode=ViewModel.TradePlanningModeIndex;
                try
                {
                    ViewModel.AutomaticModeIndex=1;scroller.ChangeView(null,0,null,true);page.UpdateLayout();await Task.Delay(100);
                    await Capture("trade-planner-cg-advanced");
                    Check("advanced CG selection updates main mode",ViewModel.TradePlanningModeIndex==1&&planningMode.SelectedIndex==1,"Changing the advanced mode updates the main choice; neither search origin nor destination must be selected.");
                    Find("AutomaticTradeSource").StartBringIntoView(new BringIntoViewOptions{AnimationDesired=false});await Task.Delay(120);
                    await Capture("trade-planner-cg-constraints");
                    Check("CG commodity filter is optional search input",Visible(Find("TradeCommoditySearchInput"))&&string.IsNullOrWhiteSpace(ViewModel.TradeCommoditySearchText),"The isolated default leaves the search commodity blank; the actual goal catalogue remains the acceptance whitelist.");
                    Find("TradeCommoditySearchInput").StartBringIntoView(new BringIntoViewOptions{AnimationDesired=false});await Task.Delay(120);
                    await Capture("trade-planner-cg-commodity");
                }
                finally{ViewModel.TradePlanningModeIndex=savedMode;page.UpdateLayout();}
            }
            var timingOptions=Descendants(advanced).OfType<Expander>().Single();timingOptions.IsExpanded=true;
            page.UpdateLayout();await Task.Delay(120);
            timingOptions.StartBringIntoView(new BringIntoViewOptions{AnimationDesired=false});await Task.Delay(160);
            await Capture("trade-planner-edit-bottom");editBottomLayout=Layout();
            var timingOrigin=timingOptions.TransformToVisual(page).TransformPoint(new(0,0));
            Check("expanded advanced bottom fully in viewport",timingOrigin.Y>=-1&&timingOrigin.Y+timingOptions.ActualHeight<=page.ActualHeight+1&&scroller.ScrollableWidth<1,"Expanded time, cost and station filters are visible in a scrolled actual page capture without horizontal scrolling.");
            timingOptions.IsExpanded=false;
            Check("planning does not change session or cargo",player.CommanderId==ViewModel.CurrentPlayer.CommanderId&&player.CurrentShipId==ViewModel.CurrentPlayer.CurrentShipId&&player.CargoCount==ViewModel.CurrentPlayer.CargoCount&&player.Credits==ViewModel.CurrentPlayer.Credits,"Original local commander, ship, cargo count and wallet remain unchanged during this read-only planning probe.");
            if(isolatedFixture)planningVisualFixture=await RunTradePlanningFixtureProbeAsync(page,directory,Capture,Check);
        }
        catch(Exception error){failure=error.ToString();}
        finally
        {
            advancedSearch.IsExpanded=false;scroller.ChangeView(null,0,null,true);page.UpdateLayout();
            var marketStatistics=Runtime.Markets.Statistics();
            await File.WriteAllTextAsync(Path.Combine(directory,"trade-planner-evidence.json"),JsonSerializer.Serialize(new
            {
                at=DateTimeOffset.UtcNow,checks,captures,defaultLayout,plannedLayout,editLayout,editBottomLayout,executionLayout,calculationSkipped,failure,regionFixture,planningVisualFixture,
                planningDiagnostic=ViewModel.TradePlanningDiagnosticForProbe,communityGoalRead=ViewModel.TradeCommunityGoalReadForProbe,planningProgress=ViewModel.TradeProgressForProbe,
                sourceKinds=ViewModel.TradeSourceKinds.Select(option=>new{option.Label,option.Kind,option.IsSelected}),ViewModel.TradeShipPadSummary,ViewModel.TradeEffectivePadSummary,
                actualDataDirectory=Runtime.DataDirectory,marketDatabasePath=Runtime.Markets.DatabasePath,
                marketStatistics=new{marketStatistics.Stations,marketStatistics.Quotes,marketStatistics.Latest},initialInvestment,
                candidateCount=ViewModel.TradeCandidates.Count,advancedRestoredCollapsed=!ViewModel.TradeAdvancedEditing,
                executionPolicy=ViewModel.TradeExecutionPolicy,executeEnabled=ViewModel.ExecuteTradePlanCommand.CanExecute(null),ViewModel.TradeHasExistingCargo,
                currentPlayer=new{ViewModel.CurrentPlayer.CommanderId,ViewModel.CurrentPlayer.Ship,ViewModel.CurrentPlayer.CargoCount,ViewModel.CurrentPlayer.CargoCapacity,ViewModel.CurrentPlayer.Credits},
                routes=ViewModel.TradeCandidates.Select(candidate=>new{candidate.Rank,candidate.IsCommunityGoal,candidate.CommunityGoalLabel,candidate.CommunityGoalDescription,candidate.Plan.CreditsPerHour,candidate.Plan.NetProfit,candidate.Plan.Minutes,candidate.Plan.RiskDisplay,candidate.RouteSummary,candidate.Cargo,candidate.CargoDetails,candidate.Plan.Request.InvestmentCredits,candidate.Plan.IsCargoAlternative,candidate.Plan.Request.OccupiedCargo,
                    distanceLy=candidate.Plan.DeadheadDistanceLy+candidate.Plan.Legs.Sum(leg=>leg.DistanceLy),jumps=candidate.Plan.DeadheadJumps+candidate.Plan.Legs.Sum(leg=>leg.EstimatedJumps),
                    legs=candidate.Plan.Legs.Select(leg=>new{leg.Number,from=leg.From.Name,to=leg.To.Name,orders=leg.Orders.Select(order=>new{order.Commodity,order.Name,order.Quantity})})}),
                structurePassed=failure is null&&checks.All(check=>check.Status!="failed"),visualReview="Pixel and local-detail inspection is still required; capture success does not establish visual acceptance.",
                gameFlightCommandsInvoked=false,gameRestartInvoked=false,tradeOrdersSubmitted=0,osInputsSent=0,syntheticPlayerData=false,
                syntheticPlayerOrMarketData=planningVisualFixture is not null,syntheticMarketVisualFixture=planningVisualFixture is not null,syntheticRegionFixture=isolatedFixture
            },new JsonSerializerOptions{WriteIndented=true}));
        }
        return new{checks,captures,calculationSkipped,failure,candidateCount=ViewModel.TradeCandidates.Count,advancedRestoredCollapsed=!ViewModel.TradeAdvancedEditing,
            structurePassed=failure is null&&checks.All(check=>check.Status!="failed"),gameActionsInvoked=false};
    }
}