zcbot/windows-node/Zcbot.WindowsNode.Tests/PresentationCommandTests.cs

380 lines
14 KiB
C#

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<TextBlock>(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<TextBlock>(window, "PageTitleText").Text);
Assert.Equal(
@"C:\node-data",
FindElement<TextBox>(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<EnrollOptions>(
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<Exception>(
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<bool> 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<FrameworkElement>(window, name);
private static T FindElement<T>(MainWindow window, string name)
where T : FrameworkElement =>
Assert.IsAssignableFrom<T>(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<IReadOnlyList<SoftwareManagementSnapshot>> RefreshAsync(
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<SoftwareManagementSnapshot>>([]);
public void SaveLocation(string softwareId, string path) { }
public void ClearLocation(string softwareId) { }
public Task<RuntimeInstallResult> InstallRuntimeAsync(
string softwareId,
IProgress<string> progress,
CancellationToken cancellationToken) =>
throw new NotSupportedException();
}
private sealed class EmptyJobMonitorService : IJobMonitorService
{
public IReadOnlyList<JobDisplaySnapshot> ReadSnapshots(int limit = 50) => [];
}
private sealed class EmptyAnsysAcceptanceService : IAnsysAcceptanceService
{
public bool IsAvailable => false;
public bool IsGateEnabled => false;
public Task<AdapterAcceptanceResult> RunAsync(
string workRoot,
IProgress<string> 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;
}
}