using System.Reflection; using System.Windows; using System.Windows.Controls; using System.Windows.Threading; namespace Zcbot.WindowsNode.Tests; public sealed class PresentationCommandTests { [Fact] public void MainWindow_BindingsKeepOnlyTheSelectedPageVisible() { Exception? failure = null; var thread = new Thread(() => { try { var application = new System.Windows.Application { ShutdownMode = ShutdownMode.OnExplicitShutdown, }; application.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri( "pack://application:,,,/Zcbot.WindowsNode;component/Presentation/Themes/LightTheme.xaml", UriKind.Absolute), }); using var viewModel = CreateViewModel(); var window = new MainWindow(viewModel); window.Measure(new Size(1040, 720)); window.Arrange(new Rect(0, 0, 1040, 720)); window.Dispatcher.Invoke(() => { }, DispatcherPriority.DataBind); Assert.Equal(Visibility.Visible, FindPage(window, "OverviewPage").Visibility); Assert.Equal(Visibility.Collapsed, FindPage(window, "SoftwarePage").Visibility); Assert.Equal(Visibility.Collapsed, FindPage(window, "JobsPage").Visibility); Assert.Equal(Visibility.Collapsed, FindPage(window, "SettingsPage").Visibility); viewModel.ShowJobsCommand.Execute(null); window.Dispatcher.Invoke(() => { }, DispatcherPriority.DataBind); Assert.Equal(Visibility.Collapsed, FindPage(window, "OverviewPage").Visibility); Assert.Equal(Visibility.Collapsed, FindPage(window, "SoftwarePage").Visibility); Assert.Equal(Visibility.Visible, FindPage(window, "JobsPage").Visibility); Assert.Equal(Visibility.Collapsed, FindPage(window, "SettingsPage").Visibility); Assert.Equal( "本机任务", FindElement(window, "PageTitleText").Text); viewModel.ShowSettingsCommand.Execute(null); window.Dispatcher.Invoke(() => { }, DispatcherPriority.DataBind); Assert.Equal(Visibility.Collapsed, FindPage(window, "OverviewPage").Visibility); Assert.Equal(Visibility.Collapsed, FindPage(window, "SoftwarePage").Visibility); Assert.Equal(Visibility.Collapsed, FindPage(window, "JobsPage").Visibility); Assert.Equal(Visibility.Visible, FindPage(window, "SettingsPage").Visibility); Assert.Equal( "运行设置", FindElement(window, "PageTitleText").Text); Assert.Equal( @"C:\node-data", FindElement(window, "DataRootText").Text); } catch (Exception exception) { failure = exception; } }); thread.SetApartmentState(ApartmentState.STA); thread.Start(); Assert.True(thread.Join(TimeSpan.FromSeconds(5)), "WPF binding smoke test timed out."); if (failure is not null) throw failure; } [Theory] [InlineData(typeof(MainWindowViewModel), "PageTitle")] [InlineData(typeof(MainWindowViewModel), "StatusText")] [InlineData(typeof(MainWindowViewModel), "IsOverviewPage")] [InlineData(typeof(MainWindowViewModel), "IsSoftwarePage")] [InlineData(typeof(MainWindowViewModel), "IsJobsPage")] [InlineData(typeof(MainWindowViewModel), "IsSettingsPage")] [InlineData(typeof(MainWindowViewModel), "ShowOverviewCommand")] [InlineData(typeof(MainWindowViewModel), "ShowSoftwareCommand")] [InlineData(typeof(MainWindowViewModel), "ShowJobsCommand")] [InlineData(typeof(MainWindowViewModel), "ShowSettingsCommand")] [InlineData(typeof(MainWindowViewModel), "SoftwarePage")] [InlineData(typeof(MainWindowViewModel), "JobPage")] [InlineData(typeof(MainWindowViewModel), "AcceptancePage")] [InlineData(typeof(MainWindowViewModel), "DataRootPage")] [InlineData(typeof(MainWindowViewModel), "CopyDiagnosticsCommand")] [InlineData(typeof(SoftwarePageViewModel), "Cards")] [InlineData(typeof(SoftwarePageViewModel), "Message")] [InlineData(typeof(SoftwareCardViewModel), "DisplayName")] [InlineData(typeof(SoftwareCardViewModel), "LocationText")] [InlineData(typeof(SoftwareCardViewModel), "InstallRuntimeCommand")] [InlineData(typeof(JobPageViewModel), "Jobs")] [InlineData(typeof(JobPageViewModel), "SelectedJob")] [InlineData(typeof(JobPageViewModel), "Summary")] [InlineData(typeof(JobPageViewModel), "RefreshCommand")] [InlineData(typeof(LocalJobItemViewModel), "StageText")] [InlineData(typeof(LocalJobItemViewModel), "DetailText")] [InlineData(typeof(AnsysAcceptanceViewModel), "IsAvailable")] [InlineData(typeof(AnsysAcceptanceViewModel), "Message")] [InlineData(typeof(AnsysAcceptanceViewModel), "RunCommand")] [InlineData(typeof(AnsysAcceptanceViewModel), "EnableGateCommand")] [InlineData(typeof(DataRootPageViewModel), "CurrentRoot")] [InlineData(typeof(DataRootPageViewModel), "Status")] [InlineData(typeof(DataRootPageViewModel), "ChangeCommand")] [InlineData(typeof(DataRootPageViewModel), "OpenCommand")] public void WpfBindingSources_ArePublicProperties(Type type, string propertyName) { var property = type.GetProperty(propertyName); Assert.NotNull(property); Assert.True(property!.GetMethod?.IsPublic); } [Fact] public void MainWindowViewModel_MapsStatusAndDisablesExitDuringShutdown() { var viewModel = CreateViewModel(); var config = new NodeConfig( new Uri("https://zcbot.example/"), Guid.NewGuid(), Guid.NewGuid(), "lab-node", "secret", 30, ["origin.plot@v2"]); viewModel.ApplyStatus(NodeStatus.Create(NodeState.Online, "已连接"), config); Assert.Equal("节点在线", viewModel.StatusText); Assert.Equal("success", viewModel.StatusTone); Assert.StartsWith("已连接", viewModel.StatusDetail); Assert.True(viewModel.IsRegistered); Assert.Contains("lab-node", viewModel.IdentityText); Assert.True(viewModel.HideCommand.CanExecute(null)); Assert.True(viewModel.ExitCommand.CanExecute(null)); viewModel.BeginExit(); Assert.True(viewModel.IsExiting); Assert.Equal("正在退出", viewModel.StatusText); Assert.Equal("neutral", viewModel.StatusTone); Assert.False(viewModel.HideCommand.CanExecute(null)); Assert.False(viewModel.ExitCommand.CanExecute(null)); } [Fact] public void MainWindowViewModel_ForwardsShellCommands() { var viewModel = CreateViewModel(); var copied = false; var hidden = false; viewModel.ApplyStatus(NodeStatus.Create(NodeState.Online, "online"), CreateConfig()); viewModel.CopyDiagnosticsRequested += text => copied = text == "diagnostic"; viewModel.HideRequested += () => hidden = true; viewModel.CopyDiagnosticsCommand.Execute(null); viewModel.HideCommand.Execute(null); Assert.True(copied); Assert.True(hidden); } [Fact] public void MainWindowViewModel_NavigatesBetweenMigratedPages() { var viewModel = CreateViewModel(); viewModel.ShowSettingsCommand.Execute(null); Assert.True(viewModel.IsSettingsPage); Assert.Equal("运行设置", viewModel.PageTitle); viewModel.ShowJobsCommand.Execute(null); Assert.True(viewModel.IsJobsPage); Assert.Equal("本机任务", viewModel.PageTitle); viewModel.ShowOverviewCommand.Execute(null); Assert.True(viewModel.IsOverviewPage); Assert.Equal("节点概览", viewModel.PageTitle); } [Fact] public async Task MainWindowViewModel_ValidatesAndForwardsRegistration() { var viewModel = CreateViewModel(); var received = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); viewModel.RegisterRequested += options => { received.SetResult(options); return Task.CompletedTask; }; viewModel.ServerUrl = "https://zcbot.example/base"; viewModel.NodeName = "lab-node"; viewModel.EnrollmentCode = "ZCN-123456789012"; Assert.True(viewModel.RegisterCommand.CanExecute(null)); viewModel.RegisterCommand.Execute(null); var options = await received.Task.WaitAsync(TimeSpan.FromSeconds(2)); await WaitUntilAsync(() => !viewModel.IsBusy); Assert.Equal("https://zcbot.example/base/", options.ServerUrl.AbsoluteUri); Assert.Equal("lab-node", options.NodeName); Assert.Equal("ZCN-123456789012", options.EnrollmentCode); Assert.Equal("注册成功,正在连接节点", viewModel.OperationMessage); } [Fact] public void MainWindowViewModel_AppliesAndRollsBackStartupSetting() { var startup = new FakeStartupRegistration(); var viewModel = CreateViewModel(startup); viewModel.IsStartupEnabled = true; Assert.True(startup.IsEnabled); Assert.True(viewModel.IsStartupEnabled); Assert.True(viewModel.HasOperationMessage); startup.Failure = new UnauthorizedAccessException("denied"); viewModel.IsStartupEnabled = false; Assert.True(viewModel.IsStartupEnabled); Assert.Contains("denied", viewModel.OperationMessage); } [Fact] public async Task AsyncCommand_PreventsReentryAndRestoresAvailability() { var release = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var finished = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var callCount = 0; var command = new AsyncCommand(async () => { callCount++; await release.Task; finished.SetResult(); }); command.Execute(null); command.Execute(null); Assert.Equal(1, callCount); Assert.True(command.IsRunning); Assert.False(command.CanExecute(null)); release.SetResult(); await finished.Task.WaitAsync(TimeSpan.FromSeconds(2)); await WaitUntilAsync(() => !command.IsRunning); Assert.True(command.CanExecute(null)); } [Fact] public async Task AsyncCommand_ReportsExecutionFailure() { var failure = new InvalidOperationException("failed"); var reported = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var command = new AsyncCommand(() => Task.FromException(failure)); command.Failed += reported.SetResult; command.Execute(null); Assert.Same(failure, await reported.Task.WaitAsync(TimeSpan.FromSeconds(2))); await WaitUntilAsync(() => !command.IsRunning); Assert.True(command.CanExecute(null)); } private static async Task WaitUntilAsync(Func condition) { var deadline = DateTime.UtcNow.AddSeconds(2); while (!condition() && DateTime.UtcNow < deadline) { await Task.Delay(10); } Assert.True(condition()); } private static FrameworkElement FindPage(MainWindow window, string name) => FindElement(window, name); private static T FindElement(MainWindow window, string name) where T : FrameworkElement => Assert.IsAssignableFrom(window.FindName(name)); private static MainWindowViewModel CreateViewModel( FakeStartupRegistration? startup = null) => new( startup ?? new FakeStartupRegistration(), new FakeDiagnosticService(), new SoftwarePageViewModel(new EmptySoftwareManagementService()), new JobPageViewModel(new EmptyJobMonitorService()), new AnsysAcceptanceViewModel(new EmptyAnsysAcceptanceService()), new DataRootPageViewModel(new EmptyDataRootManagementService())); private static NodeConfig CreateConfig() => new( new Uri("https://zcbot.example/"), Guid.NewGuid(), Guid.NewGuid(), "lab-node", "secret", 30, ["origin.plot@v2"]); private sealed class FakeStartupRegistration : IStartupRegistration { internal Exception? Failure { get; set; } public bool IsEnabled { get; private set; } public void SetEnabled(bool enabled) { if (Failure is not null) throw Failure; IsEnabled = enabled; } } private sealed class FakeDiagnosticService : IDiagnosticService { public string Build(NodeConfig config) => "diagnostic"; } private sealed class EmptySoftwareManagementService : ISoftwareManagementService { public Task> RefreshAsync( CancellationToken cancellationToken) => Task.FromResult>([]); public void SaveLocation(string softwareId, string path) { } public void ClearLocation(string softwareId) { } public Task InstallRuntimeAsync( string softwareId, IProgress progress, CancellationToken cancellationToken) => throw new NotSupportedException(); } private sealed class EmptyJobMonitorService : IJobMonitorService { public IReadOnlyList ReadSnapshots(int limit = 50) => []; } private sealed class EmptyAnsysAcceptanceService : IAnsysAcceptanceService { public bool IsAvailable => false; public bool IsGateEnabled => false; public Task RunAsync( string workRoot, IProgress progress, CancellationToken cancellationToken) => throw new NotSupportedException(); public void EnableGate(string reportPath) => throw new NotSupportedException(); } private sealed class EmptyDataRootManagementService : IDataRootManagementService { public NodeDataRootSelection ReadSelection() => new(@"C:\node-data", IsEnvironmentManaged: false, "default"); public bool HasPendingJobs => false; public string Normalize(string path) => path; } }