diff --git a/Common/Adapters/ContentControlRegionAdapter.cs b/Common/Adapters/ContentControlRegionAdapter.cs new file mode 100644 index 0000000..462bf57 --- /dev/null +++ b/Common/Adapters/ContentControlRegionAdapter.cs @@ -0,0 +1,26 @@ +using SimpleNavigation.Interface.Adapters; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Common.Adapters; + +internal sealed class ContentControlRegionAdapter : IContentRegionHostAdapter +{ + public RegionHostKind Kind => RegionHostKind.Content; + + public bool CanHandle(FrameworkElement region) + => region is ContentControl && region is not Frame && region is not TabControl; + + public void Present(FrameworkElement host, FrameworkElement content) + { + if (host is not ContentControl contentControl || host is Frame) + { + throw new ArgumentException( + $"Host type '{host.GetType().FullName}' must be a non-Frame ContentControl.", + nameof(host)); + } + + host.Dispatcher.VerifyAccess(); + contentControl.Content = content; + } +} diff --git a/Common/Adapters/FrameRegionAdapter.cs b/Common/Adapters/FrameRegionAdapter.cs new file mode 100644 index 0000000..a51f71c --- /dev/null +++ b/Common/Adapters/FrameRegionAdapter.cs @@ -0,0 +1,30 @@ +using SimpleNavigation.Interface.Adapters; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Common.Adapters; + +internal sealed class FrameRegionAdapter : IPageRegionHostAdapter +{ + public RegionHostKind Kind => RegionHostKind.Page; + + public bool CanHandle(FrameworkElement region) => region is Frame; + + public bool Navigate(Frame frame, Page page) + { + frame.Dispatcher.VerifyAccess(); + return frame.Navigate(page); + } + + public bool CanGoBack(Frame frame) + { + frame.Dispatcher.VerifyAccess(); + return frame.CanGoBack; + } + + public void GoBack(Frame frame) + { + frame.Dispatcher.VerifyAccess(); + frame.GoBack(); + } +} diff --git a/Common/Adapters/RegionHostAdapter.cs b/Common/Adapters/RegionHostAdapter.cs new file mode 100644 index 0000000..8c1a6ea --- /dev/null +++ b/Common/Adapters/RegionHostAdapter.cs @@ -0,0 +1,52 @@ +using SimpleNavigation.Interface.Adapters; +using System.Windows; + +namespace SimpleNavigation.Common.Adapters; + +/// +/// 区域宿主可接受的内容类型 +/// +internal enum RegionHostKind +{ + Page, + Content, +} + +/// +/// 获取适配导航元素的 Adapter +/// +internal static class RegionHostAdapterResolver +{ + private static readonly IRegionHostAdapter[] HostAdapters = + { + + new FrameRegionAdapter(), + new TabControlRegionAdapter(), + new ContentControlRegionAdapter(), + + }; + + /// + /// 根据 附加对象的类型,筛选相匹配的 Adapter + /// + /// 附加对象 + /// 相匹配的 Adapter + /// + /// + public static IRegionHostAdapter GetRequired(FrameworkElement attachedRegion) + { + if (attachedRegion == null) + throw new ArgumentNullException(nameof(attachedRegion)); + + foreach (var adapter in HostAdapters) + { + if (adapter.CanHandle(attachedRegion)) + return adapter; + } + + var regionType = attachedRegion.GetType(); + throw new ArgumentException( + $"Region host type '{regionType.FullName}' is not supported.", + nameof(attachedRegion)); + } +} diff --git a/Common/Adapters/TabControlRegionAdapter.cs b/Common/Adapters/TabControlRegionAdapter.cs new file mode 100644 index 0000000..243859c --- /dev/null +++ b/Common/Adapters/TabControlRegionAdapter.cs @@ -0,0 +1,58 @@ +锘縰sing SimpleNavigation.Interface.Adapters; +using System.Collections; +using System.Reflection; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Common.Adapters +{ + internal class TabControlRegionAdapter : IContentRegionHostAdapter + { + public RegionHostKind Kind => RegionHostKind.Content; + + public bool CanHandle(FrameworkElement region) => region is TabControl; + + public void Present(FrameworkElement host, FrameworkElement content) + { + if (host is not TabControl tabControl || content is not UserControl view) + { + throw new ArgumentException( + $"Host type '{host.GetType().FullName}' must be a TabControl.", + nameof(host)); + } + + host.Dispatcher.VerifyAccess(); + + if (tabControl.ItemsSource == null) + { + tabControl.Items.Add(content); + } + else + { + if (tabControl.ItemsSource is IList list) + { + //list.Add(new TabItem() { Header = "new tab", Content = view}); + if (list.Count <= 0) + throw new ArgumentException("The tab control must be keep at least 1 item"); + + var targetType = list[0]?.GetType(); + object instance = Activator.CreateInstance(targetType); + if (instance != null) + { + FieldInfo contentFi = targetType.GetField("ContentProperty", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy); + DependencyProperty contentDp = (DependencyProperty)contentFi.GetValue(null); + + FieldInfo headerFi = targetType.GetField("HeaderProperty", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy); + DependencyProperty headerDp = (DependencyProperty)headerFi.GetValue(null); + + var header = content.GetType().Name.Replace("View", ""); + + ((DependencyObject)instance).SetValue(headerDp, header); + ((DependencyObject)instance).SetValue(contentDp, content); + list.Add(instance); + } + } + } + } + } +} diff --git a/Common/DialogManager.cs b/Common/DialogManager.cs deleted file mode 100644 index 697556c..0000000 --- a/Common/DialogManager.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Windows; -using Microsoft.Extensions.DependencyInjection; -using SimpleNavigation.Interface; - -namespace SimpleNavigation.Common -{ - public class DialogManager : IDialogManager - { - private readonly IServiceProvider provider; - private Dictionary> dialogWindows = new(); - - public DialogManager(IServiceProvider provider) - { - this.provider = provider; - } - - private void OnWindowClosed(object? sender, EventArgs e) - { - if (sender == null) return; - - var type = sender.GetType(); - if (dialogWindows.ContainsKey(type)) - dialogWindows.Remove(type); - } - - public T? GetDialogWindow() where T : Window - { - if (dialogWindows.ContainsKey(typeof(T))) - return dialogWindows[typeof(T)].TryGetTarget(out var window) ? window as T : null; - - var weakWindow = new WeakReference(provider.GetRequiredService()); - dialogWindows[typeof(T)] = weakWindow; - - var getResult = weakWindow.TryGetTarget(out var w); - if (!getResult || w == null) return null; - - w.Closed += OnWindowClosed; - - return w as T; - } - } -} diff --git a/Common/Managers/DialogManager.cs b/Common/Managers/DialogManager.cs new file mode 100644 index 0000000..a437e44 --- /dev/null +++ b/Common/Managers/DialogManager.cs @@ -0,0 +1,203 @@ +using System.Runtime.ExceptionServices; +using System.Windows; +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Interface.Managers; + +namespace SimpleNavigation.Common.Managers +{ + public class DialogManager : IDialogManager + { + private readonly IServiceProvider provider; + private readonly object syncRoot = new(); + private readonly Dictionary> dialogWindows = new(); + private readonly Dictionary resolutionStates = new(); + + public DialogManager(IServiceProvider provider) + { + this.provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + private void OnWindowClosed(object? sender, EventArgs e) + { + if (sender is not Window closedWindow) return; + + closedWindow.Closed -= OnWindowClosed; + + lock (syncRoot) + { + var windowType = closedWindow.GetType(); + if (dialogWindows.TryGetValue(windowType, out var weakWindow) + && weakWindow.TryGetTarget(out var cachedWindow) + && ReferenceEquals(cachedWindow, closedWindow)) + { + dialogWindows.Remove(windowType); + } + } + } + + public Window GetOrCreateWindow(Type windowType) + { + ValidateWindowType(windowType); + + while (true) + { + ResolutionState resolutionState; + var ownsResolution = false; + + lock (syncRoot) + { + var existingWindow = GetExistingWindowLocked(windowType); + if (existingWindow != null) + { + return existingWindow; + } + + if (resolutionStates.TryGetValue(windowType, out var activeResolutionState)) + { + resolutionState = activeResolutionState; + if (resolutionState.OwnerThreadId == Environment.CurrentManagedThreadId) + { + throw new InvalidOperationException( + $"Window type '{windowType.FullName}' is already being resolved by this dialog manager."); + } + } + else + { + resolutionState = new ResolutionState(Environment.CurrentManagedThreadId); + resolutionStates.Add(windowType, resolutionState); + ownsResolution = true; + } + } + + if (ownsResolution) + { + return ResolveAndPublishWindow(windowType, resolutionState); + } + + resolutionState.Completion.Task.GetAwaiter().GetResult(); + if (resolutionState.ResolutionException != null) + { + resolutionState.ResolutionException.Throw(); + } + } + } + + private Window ResolveAndPublishWindow(Type windowType, ResolutionState resolutionState) + { + try + { + var service = provider.GetRequiredService(windowType); + if (service is not Window newWindow) + { + throw new InvalidOperationException( + $"The service registered for window type '{windowType.FullName}' resolved to " + + $"'{service.GetType().FullName}', which is not a '{typeof(Window).FullName}'."); + } + + if (newWindow.GetType() != windowType) + { + throw new InvalidOperationException( + $"The service registered for window type '{windowType.FullName}' resolved to " + + $"the different window type '{newWindow.GetType().FullName}'."); + } + + newWindow.Closed += OnWindowClosed; + + lock (syncRoot) + { + dialogWindows[windowType] = new WeakReference(newWindow); + RemoveResolutionStateLocked(windowType, resolutionState); + } + + resolutionState.Completion.TrySetResult(true); + return newWindow; + } + catch (Exception exception) + { + resolutionState.ResolutionException = ExceptionDispatchInfo.Capture(exception); + + lock (syncRoot) + { + RemoveResolutionStateLocked(windowType, resolutionState); + } + + resolutionState.Completion.TrySetResult(true); + throw; + } + } + + private void RemoveResolutionStateLocked(Type windowType, ResolutionState resolutionState) + { + if (resolutionStates.TryGetValue(windowType, out var currentState) + && ReferenceEquals(currentState, resolutionState)) + { + resolutionStates.Remove(windowType); + } + } + + public Window? GetExistingWindow(Type windowType) + { + ValidateWindowType(windowType); + return GetExistingWindowCore(windowType); + } + + public T? GetDialogWindow() where T : Window + { + return (T)GetOrCreateWindow(typeof(T)); + } + + private Window? GetExistingWindowCore(Type windowType) + { + lock (syncRoot) + { + return GetExistingWindowLocked(windowType); + } + } + + private Window? GetExistingWindowLocked(Type windowType) + { + if (!dialogWindows.TryGetValue(windowType, out var weakWindow)) + { + return null; + } + + if (weakWindow.TryGetTarget(out var window)) + { + return window; + } + + dialogWindows.Remove(windowType); + return null; + } + + private static void ValidateWindowType(Type windowType) + { + if (windowType == null) + { + throw new ArgumentNullException(nameof(windowType)); + } + + if (!typeof(Window).IsAssignableFrom(windowType)) + { + throw new ArgumentException( + $"Type '{windowType.FullName}' must derive from '{typeof(Window).FullName}'.", + nameof(windowType)); + } + } + + private sealed class ResolutionState + { + public ResolutionState(int ownerThreadId) + { + OwnerThreadId = ownerThreadId; + Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public int OwnerThreadId { get; } + + public TaskCompletionSource Completion { get; } + + public ExceptionDispatchInfo? ResolutionException { get; set; } + } + } +} diff --git a/Common/Managers/RegionManager.cs b/Common/Managers/RegionManager.cs new file mode 100644 index 0000000..24f97e0 --- /dev/null +++ b/Common/Managers/RegionManager.cs @@ -0,0 +1,274 @@ +using System.Windows; +using SimpleNavigation.Common.Adapters; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Services; + +namespace SimpleNavigation.Common.Managers +{ + public sealed class RegionManager : IRegionManager, IDisposable + { + private readonly Dictionary regions = new(StringComparer.Ordinal); + private readonly Action declarationSubscriber; + private readonly List pendingDeclarationChanges = new(); + private readonly object syncRoot = new(); + private bool isImportingDeclarations = true; + private bool isDisposed; + + public RegionManager() + { + declarationSubscriber = OnDeclarationChanged; + Region.Subscribe(declarationSubscriber); + + try + { + var snapshot = Region.GetActiveSnapshot(); + + lock (syncRoot) + { + foreach (var change in snapshot) + ApplyDeclarationChangeUnderLock(change); + + foreach (var change in pendingDeclarationChanges) + ApplyDeclarationChangeUnderLock(change); + + pendingDeclarationChanges.Clear(); + isImportingDeclarations = false; + } + } + catch + { + lock (syncRoot) + { + isDisposed = true; + isImportingDeclarations = false; + pendingDeclarationChanges.Clear(); + regions.Clear(); + } + + Region.Unsubscribe(declarationSubscriber); + throw; + } + } + + public void RegisterRegion(string regionName, FrameworkElement region) + { + ValidateRegionName(regionName); + if (region == null) throw new ArgumentNullException(nameof(region)); + + RegionHostAdapterResolver.GetRequired(region); // 无匹配的Adapter则抛出异常 + + lock (syncRoot) + { + ThrowIfDisposedUnderLock(); + + if (TryGetLiveEntryUnderLock(regionName, out var existingEntry, out var existingRegion)) + { + if (ReferenceEquals(existingRegion, region)) + { + existingEntry.HasProgrammaticOwnership = true; + return; + } + + throw new InvalidOperationException($"Region '{regionName}' is already registered."); + } + + regions[regionName] = new RegionEntry(region) + { + HasProgrammaticOwnership = true, + }; + } + } + + public bool UnregisterRegion(string regionName, FrameworkElement region) + { + ValidateRegionName(regionName); + + if (region == null) + { + throw new ArgumentNullException(nameof(region)); + } + + lock (syncRoot) + { + ThrowIfDisposedUnderLock(); + + if (!TryGetLiveEntryUnderLock(regionName, out var existingEntry, out var existingRegion)) + { + return false; + } + + if (!ReferenceEquals(existingRegion, region) || + !existingEntry.HasProgrammaticOwnership) + { + return false; + } + + existingEntry.HasProgrammaticOwnership = false; + if (existingEntry.AttachedActivationTokens.Count == 0) + { + regions.Remove(regionName); + } + + return true; + } + } + + public FrameworkElement? GetRegion(string regionName) + { + ValidateRegionName(regionName); + + lock (syncRoot) + { + ThrowIfDisposedUnderLock(); + + if (TryGetLiveEntryUnderLock(regionName, out _, out var region)) + { + return region; + } + + return null; + } + } + + public TRegion? GetRegion(string regionName) where TRegion : FrameworkElement + => GetRegion(regionName) as TRegion; + + public void Dispose() + { + lock (syncRoot) + { + if (isDisposed) + { + return; + } + + isDisposed = true; + isImportingDeclarations = false; + pendingDeclarationChanges.Clear(); + regions.Clear(); + } + + Region.Unsubscribe(declarationSubscriber); + } + + /// + /// 当宿主容器属性发生变化事件回调 + /// + /// 变化事件携带信息 + private void OnDeclarationChanged(RegionDeclarationChange change) + { + lock (syncRoot) + { + if (isDisposed) + return; + + if (isImportingDeclarations) + { + pendingDeclarationChanges.Add(change); + return; + } + + ApplyDeclarationChangeUnderLock(change); + } + } + + private void ApplyDeclarationChangeUnderLock(RegionDeclarationChange change) + { + if (change.Kind == RegionDeclarationChangeKind.Add) + { + AddAttachedOwnershipUnderLock(change); + return; + } + + RemoveAttachedOwnershipUnderLock(change); + } + + private void AddAttachedOwnershipUnderLock(RegionDeclarationChange change) + { + var host = change.Host; + + if (TryGetLiveEntryUnderLock(change.Name, out var existingEntry, out var existingHost)) + { + if (!ReferenceEquals(existingHost, host)) + { + throw new InvalidOperationException($"Region '{change.Name}' is already registered."); + } + + existingEntry.AttachedActivationTokens.Add(change.ActivationToken); + return; + } + + var entry = new RegionEntry(host); + entry.AttachedActivationTokens.Add(change.ActivationToken); + regions[change.Name] = entry; + } + + private void RemoveAttachedOwnershipUnderLock(RegionDeclarationChange change) + { + if (!TryGetLiveEntryUnderLock(change.Name, out var existingEntry, out var existingHost) || + !ReferenceEquals(existingHost, change.Host)) + { + return; + } + + existingEntry.AttachedActivationTokens.Remove(change.ActivationToken); + if (!existingEntry.HasProgrammaticOwnership && + existingEntry.AttachedActivationTokens.Count == 0) + { + regions.Remove(change.Name); + } + } + + /// + /// 获取活跃的 + /// + /// + /// + /// + /// + private bool TryGetLiveEntryUnderLock(string regionName, out RegionEntry entry, out FrameworkElement region) + { + if (!regions.TryGetValue(regionName, out entry!)) + { + region = null!; + return false; + } + + if (entry.Host.TryGetTarget(out region!)) + return true; + + regions.Remove(regionName); + entry = null!; + region = null!; + return false; + } + + private void ThrowIfDisposedUnderLock() + { + if (isDisposed) + throw new ObjectDisposedException(nameof(RegionManager)); + } + + private static void ValidateRegionName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) + throw new ArgumentException("Region name cannot be null, empty, or whitespace.", nameof(regionName)); + } + } + + internal sealed class RegionEntry + { + public RegionEntry(FrameworkElement host) + { + Host = new WeakReference(host); + } + + public WeakReference Host { get; } + + public bool HasProgrammaticOwnership { get; set; } + + public HashSet AttachedActivationTokens { get; } = new(); + } +} + + diff --git a/Common/NavigationAwareNotifier.cs b/Common/NavigationAwareNotifier.cs new file mode 100644 index 0000000..4172348 --- /dev/null +++ b/Common/NavigationAwareNotifier.cs @@ -0,0 +1,38 @@ +using SimpleNavigation.Interface.Awares; +using System.Windows; + +namespace SimpleNavigation.Common +{ + /// + /// 对于Page或Content类型的导航回调 + /// + internal static class NavigationAwareNotifier + { + public static void Notify(FrameworkElement target, DialogParameters? parameters) + { + if (target is IViewInitializeAware view && !view.IsViewInitialized) + { + view.Initialize(); + view.IsViewInitialized = true; + } + + var dataContext = target.DataContext; + + if (dataContext is IViewInitializeAware contextViewAware + && !ReferenceEquals(contextViewAware, target) + && !contextViewAware.IsViewInitialized) + { + contextViewAware.Initialize(); + contextViewAware.IsViewInitialized = true; + } + + if (target is INavigationAware targetAware) + targetAware.OnNavigated(parameters); + + if (dataContext is INavigationAware contextAware && !ReferenceEquals(dataContext, target)) + contextAware.OnNavigated(parameters); + } + } +} + + diff --git a/Common/NavigationRouteRegistry.cs b/Common/NavigationRouteRegistry.cs new file mode 100644 index 0000000..6f76b8d --- /dev/null +++ b/Common/NavigationRouteRegistry.cs @@ -0,0 +1,85 @@ +namespace SimpleNavigation.Common +{ + /// + /// 被导航对象的类型 + /// + internal enum NavigationRouteKind + { + Page, + Content, + Dialog, + } + + /// + /// 导航对象 + /// + internal sealed class NavigationRouteRegistration + { + public NavigationRouteKind Kind { get; } + + public string Key { get; } + + public Type TargetType { get; } + + public NavigationRouteRegistration(NavigationRouteKind kind, string key, Type targetType) + { + Kind = kind; + Key = key; + TargetType = targetType; + } + } + + internal sealed class NavigationRouteRegistry + { + private readonly IReadOnlyDictionary pages; + private readonly IReadOnlyDictionary contents; + private readonly IReadOnlyDictionary dialogs; + + public NavigationRouteRegistry(IEnumerable registrations) + { + pages = BuildRoute(registrations, NavigationRouteKind.Page); + contents = BuildRoute(registrations, NavigationRouteKind.Content); + dialogs = BuildRoute(registrations, NavigationRouteKind.Dialog); + } + + public Type GetRequiredPageType(string key) => GetRequiredTarget(pages, key, "page"); + + public Type GetRequiredContentType(string key) => GetRequiredTarget(contents, key, "content"); + + public Type GetRequiredDialogType(string key) => GetRequiredTarget(dialogs, key, "dialog"); + + /// + /// Build 导航路由 + /// + /// 被导航对象 + /// 被导航对象的类型 + /// + private IReadOnlyDictionary BuildRoute( + IEnumerable registrations, + NavigationRouteKind kind) + { + var routes = new Dictionary(StringComparer.Ordinal); + + foreach (var registration in registrations.Where(item => item.Kind == kind)) + routes.Add(registration.Key, registration.TargetType); + + return routes; + } + + private Type GetRequiredTarget(IReadOnlyDictionary routes, string key, string category) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException( + "Route key cannot be null or whitespace.", + nameof(key)); + } + + if (routes.TryGetValue(key, out var targetType)) + return targetType; + + throw new KeyNotFoundException( + $"No {category} route is registered for key '{key}'."); + } + } +} diff --git a/Extensions/NavigationExtensions.cs b/Extensions/NavigationExtensions.cs index 756a2fd..55e3f0f 100644 --- a/Extensions/NavigationExtensions.cs +++ b/Extensions/NavigationExtensions.cs @@ -1,20 +1,197 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using SimpleNavigation.Common; -using SimpleNavigation.Interface; +using SimpleNavigation.Common.Managers; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; using SimpleNavigation.Services; +using System.Windows; +using System.Windows.Controls; namespace SimpleNavigation.Extensions { - public static class NavigationExtensions - { - public static IServiceCollection RegisterNavigationService(this IServiceCollection serviceCollection) - { - serviceCollection.TryAddSingleton(); - serviceCollection.TryAddSingleton(); - serviceCollection.TryAddSingleton(); - - return serviceCollection; - } - } + public static class NavigationExtensions + { + public static IServiceCollection RegisterNavigationService( + this IServiceCollection serviceCollection) + { + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(provider => + new NavigationRouteRegistry( + provider.GetServices())); + + return serviceCollection; + } + + public static IServiceCollection AddSingletonPage(this IServiceCollection services, string key) + where TPage : Page + { + AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); + services.TryAddSingleton(); + return services; + } + + public static IServiceCollection AddTransientPage(this IServiceCollection services, string key) + where TPage : Page + { + AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddSingletonPage(this IServiceCollection services) + where TPage : Page where TViewModel : class + { + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + public static IServiceCollection AddTransientPage(this IServiceCollection services) + where TPage : Page where TViewModel : class + { + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddSingletonPage(this IServiceCollection services, string key) + where TPage : Page where TViewModel : class + { + AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + public static IServiceCollection AddTransientPage(this IServiceCollection services, string key) + where TPage : Page where TViewModel : class + { + AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddSingletonContent(this IServiceCollection services, string key) + where TView : FrameworkElement + { + ValidateContentType(typeof(TView)); + AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); + services.TryAddSingleton(); + return services; + } + + public static IServiceCollection AddTransientContent(this IServiceCollection services, string key) + where TView : FrameworkElement + { + ValidateContentType(typeof(TView)); + AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddSingletonContent(this IServiceCollection services) + where TView : FrameworkElement where TViewModel : class + { + ValidateContentType(typeof(TView)); + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + public static IServiceCollection AddTransientContent(this IServiceCollection services) + where TView : FrameworkElement where TViewModel : class + { + ValidateContentType(typeof(TView)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddSingletonContent(this IServiceCollection services, string key) + where TView : FrameworkElement where TViewModel : class + { + ValidateContentType(typeof(TView)); + AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + public static IServiceCollection AddTransientContent(this IServiceCollection services, string key) + where TView : FrameworkElement where TViewModel : class + { + ValidateContentType(typeof(TView)); + AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddWindow(this IServiceCollection services, string key) + where TWindow : Window + { + AddRoute(services, NavigationRouteKind.Dialog, key, typeof(TWindow)); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddWindow(this IServiceCollection services) + where TWindow : Window where TViewModel : class + { + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + public static IServiceCollection AddWindow(this IServiceCollection services, string key) + where TWindow : Window where TViewModel : class + { + AddRoute(services, NavigationRouteKind.Dialog, key, typeof(TWindow)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + + private static void AddRoute(IServiceCollection services, NavigationRouteKind kind, string key, Type targetType) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException( + "Route key cannot be null or whitespace.", + nameof(key)); + } + + var duplicate = services.Any(descriptor => + descriptor.ServiceType == typeof(NavigationRouteRegistration) && + descriptor.ImplementationInstance is NavigationRouteRegistration route && + route.Kind == kind && + string.Equals(route.Key, key, StringComparison.Ordinal)); + + if (duplicate) + { + throw new ArgumentException( + $"A {kind.ToString().ToLowerInvariant()} route with key '{key}' is already registered.", + nameof(key)); + } + + services.AddSingleton(new NavigationRouteRegistration(kind, key, targetType)); + } + + private static void ValidateContentType(Type targetType) + { + if (typeof(Page).IsAssignableFrom(targetType) || + typeof(Window).IsAssignableFrom(targetType)) + { + throw new ArgumentException( + $"Content type '{targetType.FullName}' cannot derive from Page or Window.", + nameof(targetType)); + } + } + } } diff --git a/Interface/Adapters/IContentRegionHostAdapter.cs b/Interface/Adapters/IContentRegionHostAdapter.cs new file mode 100644 index 0000000..e0f2158 --- /dev/null +++ b/Interface/Adapters/IContentRegionHostAdapter.cs @@ -0,0 +1,17 @@ +锘縰sing System.Windows; + +namespace SimpleNavigation.Interface.Adapters +{ + /// + /// 瀹圭撼 瀵硅薄鐨勫涓婚傞厤鍣 + /// + internal interface IContentRegionHostAdapter : IRegionHostAdapter + { + /// + /// 瀵艰埅鏍稿績瀹炵幇鏂规硶锛氬皢闇瑕佸鑸殑鍐呭鍔犲叆鍒板涓荤殑瑙嗚鏍 + /// + /// 瀹夸富 + /// 闇瑕佸鑸殑鍐呭 + void Present(FrameworkElement host, FrameworkElement content); + } +} diff --git a/Interface/Adapters/IPageRegionHostAdapter.cs b/Interface/Adapters/IPageRegionHostAdapter.cs new file mode 100644 index 0000000..c750074 --- /dev/null +++ b/Interface/Adapters/IPageRegionHostAdapter.cs @@ -0,0 +1,16 @@ +锘縰sing System.Windows.Controls; + +namespace SimpleNavigation.Interface.Adapters +{ + /// + /// 瀹圭撼 瀵硅薄鐨勫涓婚傞厤鍣 + /// + internal interface IPageRegionHostAdapter : IRegionHostAdapter + { + bool Navigate(Frame frame, Page page); + + bool CanGoBack(Frame frame); + + void GoBack(Frame frame); + } +} diff --git a/Interface/Adapters/IRegionHostAdapter.cs b/Interface/Adapters/IRegionHostAdapter.cs new file mode 100644 index 0000000..acb0507 --- /dev/null +++ b/Interface/Adapters/IRegionHostAdapter.cs @@ -0,0 +1,24 @@ +锘縰sing SimpleNavigation.Common.Adapters; +using System.Windows; + +namespace SimpleNavigation.Interface.Adapters +{ + /// + /// Region 瀹夸富閫傞厤鍣 + /// 鑻ヨ瀹炵幇鍏朵粬绫诲瀷鎺т欢浣滀负瀹夸富鐨勮兘鍔涳紝蹇呴』瀹炵幇姝ゆ帴鍙 + /// + internal interface IRegionHostAdapter + { + /// + /// 瀹夸富绫诲瀷 + /// + RegionHostKind Kind { get; } + + /// + /// 鏍囧織褰撳墠 Adapter 鍙互澶勭悊鐨勫厓绱 + /// + /// 褰撳墠 Adapter 鎵鎷ユ湁锛堝搴旓級鐨 Region 瀹炰緥 + /// + bool CanHandle(FrameworkElement region); + } +} diff --git a/Interface/Awares/IDialogAware.cs b/Interface/Awares/IDialogAware.cs new file mode 100644 index 0000000..1aab2a2 --- /dev/null +++ b/Interface/Awares/IDialogAware.cs @@ -0,0 +1,22 @@ +锘縰sing SimpleNavigation.Common; + +namespace SimpleNavigation.Interface.Awares +{ + /// + /// Window瀵艰埅鍥炶皟鎺ュ彛锛 + /// 鑻ヨ瀹炵幇Window瀵艰埅鍚庢垨鍏抽棴鍚庢墽琛屽洖璋冿紙浼犻掑弬鏁帮級锛孷iew鎴朧iewModel蹇呴』瀹炵幇姝ゆ帴鍙 + /// + public interface IDialogAware : INavigationAware + { + /// + /// 褰揇ialog璇锋眰鍏抽棴鏃惰皟鐢紝鍙紶閫掑弬鏁 + /// + Action? RequestClose { get; set; } + + ///// + ///// 褰揇ialog瀵艰埅瀹屾垚鏃剁殑鍥炶皟鏂规硶 + ///// + ///// 瀵艰埅鍙傛暟 + //void OnNavigated(DialogParameters? parameters); + } +} diff --git a/Interface/Awares/INavigationAware.cs b/Interface/Awares/INavigationAware.cs new file mode 100644 index 0000000..2ecff3f --- /dev/null +++ b/Interface/Awares/INavigationAware.cs @@ -0,0 +1,19 @@ +using SimpleNavigation.Common; + +namespace SimpleNavigation.Interface.Awares +{ + /// + /// 导航完成后的回调接口, + /// 若需导航后执行回调(传递参数),View 或 ViewModel必须实现此接口 + /// + public interface INavigationAware + { + /// + /// 导航后的回调方法 + /// + /// 传递的参数 + void OnNavigated(DialogParameters? parameters); + } +} + + diff --git a/Interface/Awares/IPageAware.cs b/Interface/Awares/IPageAware.cs new file mode 100644 index 0000000..7a062c6 --- /dev/null +++ b/Interface/Awares/IPageAware.cs @@ -0,0 +1,17 @@ +锘縰sing SimpleNavigation.Common; + +namespace SimpleNavigation.Interface.Awares +{ + /// + /// Page瀵艰埅鍥炶皟鎺ュ彛锛 + /// 鑻ヨ瀹炵幇Page瀵艰埅鍚庢墽琛屽洖璋冿紙浼犻掑弬鏁帮級锛孷iew鎴朧iewModel蹇呴』瀹炵幇姝ゆ帴鍙 + /// + public interface IPageAware : INavigationAware + { + /// + /// 褰撻〉闈㈡帴鏀舵秷鎭椂鐨勫洖璋冩柟娉 + /// + /// 娑堟伅鍙傛暟 + event Action? Receive; + } +} diff --git a/Interface/Awares/IViewInitializeAware.cs b/Interface/Awares/IViewInitializeAware.cs new file mode 100644 index 0000000..97eb5fc --- /dev/null +++ b/Interface/Awares/IViewInitializeAware.cs @@ -0,0 +1,18 @@ +锘縩amespace SimpleNavigation.Interface.Awares +{ + /// + /// View 鍒濆鍖栧畬姣曞悗锛岃嫢闇瑕佸垵濮嬪寲UI锛屽垯寤鸿缁ф壙璇ユ帴鍙o紝灏哢I鍒濆鍖栭昏緫锛堝瀵艰埅銆佹樉绀哄唴瀹癸級鍐欏埌Initialize鏂规硶涓 + /// + public interface IViewInitializeAware + { + /// + /// 鐢ㄤ簬琛ㄧず鏄惁宸茶繘琛岃繃鍒濆鍖 + /// + bool IsViewInitialized { get; set; } + + /// + /// View 鍒濆鍖栧畬姣曞悗锛岃嫢闇瑕佸垵濮嬪寲UI锛屽垯寤鸿缁ф壙璇ユ帴鍙o紝灏哢I鍒濆鍖栭昏緫锛堝瀵艰埅銆佹樉绀哄唴瀹癸級鍐欏埌Initialize鏂规硶涓 + /// + void Initialize(); + } +} diff --git a/Interface/IDialogAware.cs b/Interface/IDialogAware.cs deleted file mode 100644 index 271218a..0000000 --- a/Interface/IDialogAware.cs +++ /dev/null @@ -1,18 +0,0 @@ -锘縰sing SimpleNavigation.Common; - -namespace SimpleNavigation.Interface -{ - public interface IDialogAware - { - /// - /// 褰揇ialog璇锋眰鍏抽棴鏃惰皟鐢 - /// - Action? RequestClose { get; set; } - - /// - /// 褰揇ialog瀵艰埅瀹屾垚鏃剁殑鍥炶皟鏂规硶 - /// - /// 瀵艰埅鍙傛暟 - void OnNavigated(DialogParameters? parameters); - } -} diff --git a/Interface/IDialogManager.cs b/Interface/IDialogManager.cs deleted file mode 100644 index 84fcddd..0000000 --- a/Interface/IDialogManager.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Windows; - -namespace SimpleNavigation.Interface -{ - public interface IDialogManager - { - /// - /// 鑾峰彇鎸囧畾绫诲瀷鐨勫璇濇绐楀彛瀹炰緥 - /// - /// 瀵硅瘽妗嗙獥鍙g被鍨 - /// 瀵硅瘽妗嗙獥鍙e疄渚 - public T? GetDialogWindow() where T : Window; - } -} diff --git a/Interface/IDialogService.cs b/Interface/IDialogService.cs deleted file mode 100644 index 8f4beb6..0000000 --- a/Interface/IDialogService.cs +++ /dev/null @@ -1,26 +0,0 @@ -using SimpleNavigation.Common; -using System.Windows; - -namespace SimpleNavigation.Interface -{ - /// - /// 绐楀彛鏈嶅姟鎺ュ彛锛岀敤浜庢墦寮鏂扮獥鍙 - /// - public interface IDialogService - { - /// - /// 鏄剧ず涓涓柊Dialog - /// - /// Dialog绫诲瀷 - /// Dialog鍙傛暟 - public void Show(DialogParameters? parameters = null) where T : Window; - - /// - /// 鏄剧ず涓涓柊Dialog骞剁瓑寰呯敤鎴峰叧闂 - /// - /// Dialog绫诲瀷 - /// Dialog鍙傛暟 - /// Dialog鍏抽棴鏃惰繑鍥炵殑鍙傛暟 - public DialogParameters? ShowDialog(DialogParameters? parameters = null) where T : Window; - } -} diff --git a/Interface/IPageAware.cs b/Interface/IPageAware.cs deleted file mode 100644 index 17b302b..0000000 --- a/Interface/IPageAware.cs +++ /dev/null @@ -1,19 +0,0 @@ -锘縰sing SimpleNavigation.Common; - -namespace SimpleNavigation.Interface -{ - public interface IPageAware - { - /// - /// 褰撻〉闈㈡帴鏀舵秷鎭椂鐨勫洖璋冩柟娉 - /// - /// 娑堟伅鍙傛暟 - event Action? Receive; - - /// - /// 褰撻〉闈㈠鑸畬鎴愭椂鐨勫洖璋冩柟娉 - /// - /// 瀵艰埅鍙傛暟 - void OnNavigated(DialogParameters? parameters); - } -} diff --git a/Interface/IPageService.cs b/Interface/IPageService.cs deleted file mode 100644 index cefd01a..0000000 --- a/Interface/IPageService.cs +++ /dev/null @@ -1,37 +0,0 @@ -锘縰sing SimpleNavigation.Common; -using System.Windows.Controls; - -namespace SimpleNavigation.Interface -{ - public interface IPageService - { - /// - /// 瀵艰埅鍒版寚瀹氶〉闈 - /// - /// 椤甸潰绫诲瀷 - /// 鍖哄煙鍚嶇О - /// 椤甸潰鍙傛暟 - void Navigate(string regionName, DialogParameters? parameters = null) where T : Page; - - /// - /// 瀵艰埅鍒版寚瀹氶〉闈 - /// - /// 鍖哄煙鍚嶇О - /// 鐩爣绫诲瀷 - /// 椤甸潰鍙傛暟 - void Navigate(string regionName, Type targetType, DialogParameters? parameters = null); - - /// - /// 鑾峰彇Page鐨勭埗瀹瑰櫒 - /// - /// 鍖哄煙鍚嶇О - /// Page鐨勭埗瀹瑰櫒 - Frame? GetRegion(string regionName); - - /// - /// 瀵艰埅杩斿洖涓婁竴椤 - /// - /// 鍖哄煙鍚嶇О - void Goback(string region); - } -} diff --git a/Interface/Managers/IDialogManager.cs b/Interface/Managers/IDialogManager.cs new file mode 100644 index 0000000..fec381e --- /dev/null +++ b/Interface/Managers/IDialogManager.cs @@ -0,0 +1,31 @@ +using System.Windows; + +namespace SimpleNavigation.Interface.Managers +{ + /// + /// Dialog 绠$悊瀵硅薄 + /// + public interface IDialogManager + { + /// + /// 鑾峰彇鎴栧垱寤烘寚瀹氱被鍨嬬殑瀵硅瘽妗嗙獥鍙e疄渚 + /// + /// 瀵硅瘽妗嗙獥鍙g被鍨 + /// 瀵硅瘽妗嗙獥鍙e疄渚 + Window GetOrCreateWindow(Type windowType); + + /// + /// 鑾峰彇宸插垱寤轰笖浠嶅瓨娲荤殑鎸囧畾绫诲瀷瀵硅瘽妗嗙獥鍙e疄渚 + /// + /// 瀵硅瘽妗嗙獥鍙g被鍨 + /// 鐜版湁绐楀彛瀹炰緥锛涘鏋滀笉瀛樺湪鍒欒繑鍥 + Window? GetExistingWindow(Type windowType); + + /// + /// 鑾峰彇鎸囧畾绫诲瀷鐨勫璇濇绐楀彛瀹炰緥 + /// + /// 瀵硅瘽妗嗙獥鍙g被鍨 + /// 瀵硅瘽妗嗙獥鍙e疄渚 + public T? GetDialogWindow() where T : Window; + } +} diff --git a/Interface/Managers/IRegionManager.cs b/Interface/Managers/IRegionManager.cs new file mode 100644 index 0000000..d8ab1c4 --- /dev/null +++ b/Interface/Managers/IRegionManager.cs @@ -0,0 +1,42 @@ +using System.Windows; + +namespace SimpleNavigation.Interface.Managers +{ + /// + /// Region管理对象,负责Region的注册、注销、获取 + /// + public interface IRegionManager + { + /// + /// 将指定控件注册为 Region + /// + /// Region名称 + /// Region 对象(指定为 Region 的实例控件) + void RegisterRegion(string regionName, FrameworkElement region); + + /// + /// 将指定控件从 Region 中注销 + /// + /// Region 名称 + /// Region 对象(指定为 Region 的实例控件) + /// 是否成功注销 + bool UnregisterRegion(string regionName, FrameworkElement region); + + /// + /// 获取指定 Region 实例 + /// + /// Region 名称 + /// 获得的 Region 实例 + FrameworkElement? GetRegion(string regionName); + + /// + /// 获取指定类型的 Region 实例 + /// + /// 指定的控件类型 + /// Region 名称 + /// 指定类型控件的实例 + TRegion? GetRegion(string regionName) where TRegion : FrameworkElement; + } +} + + diff --git a/Interface/Services/IContentService.cs b/Interface/Services/IContentService.cs new file mode 100644 index 0000000..1413fa0 --- /dev/null +++ b/Interface/Services/IContentService.cs @@ -0,0 +1,20 @@ +using SimpleNavigation.Common; +using System.Windows; + +namespace SimpleNavigation.Interface.Services +{ + /// + /// Content 导航接口 + /// + public interface IContentService + { + void Navigate(string regionName, DialogParameters? parameters = null) + where TContent : FrameworkElement; + + void Navigate(string regionName, Type targetType, DialogParameters? parameters = null); + + void Navigate(string regionName, string key, DialogParameters? parameters = null); + } +} + + diff --git a/Interface/Services/IDialogService.cs b/Interface/Services/IDialogService.cs new file mode 100644 index 0000000..7567a90 --- /dev/null +++ b/Interface/Services/IDialogService.cs @@ -0,0 +1,40 @@ +using SimpleNavigation.Common; +using System.Windows; + +namespace SimpleNavigation.Interface.Services +{ + /// + /// 绐楀彛鏈嶅姟鎺ュ彛锛岀敤浜庢墦寮鏂扮獥鍙 + /// + public interface IDialogService + { + /// + /// 鏄剧ず涓涓柊Dialog + /// + /// Dialog绫诲瀷 + /// Dialog鍙傛暟 + public void Show(DialogParameters? parameters = null) where TWindow : Window; + + public void Show(Type targetType, DialogParameters? parameters = null); + + public void Show(string key, DialogParameters? parameters = null); + + /// + /// 鏄剧ず涓涓柊Dialog骞剁瓑寰呯敤鎴峰叧闂 + /// + /// Dialog绫诲瀷 + /// Dialog鍙傛暟 + /// Dialog鍏抽棴鏃惰繑鍥炵殑鍙傛暟 + public DialogParameters? ShowDialog(DialogParameters? parameters = null) where TWindow : Window; + + public DialogParameters? ShowDialog(Type targetType, DialogParameters? parameters = null); + + public DialogParameters? ShowDialog(string key, DialogParameters? parameters = null); + + public bool Close() where TWindow : Window; + + public bool Close(Type targetType); + + public bool Close(string key); + } +} diff --git a/Interface/Services/IPageService.cs b/Interface/Services/IPageService.cs new file mode 100644 index 0000000..a5698d1 --- /dev/null +++ b/Interface/Services/IPageService.cs @@ -0,0 +1,22 @@ +using SimpleNavigation.Common; +using System.Windows.Controls; + +namespace SimpleNavigation.Interface.Services +{ + /// + /// Page 导航接口 + /// + public interface IPageService + { + void Navigate(string regionName, DialogParameters? parameters = null) where TPage : Page; + + void Navigate(string regionName, Type targetType, DialogParameters? parameters = null); + + void Navigate(string regionName, string key, DialogParameters? parameters = null); + + void GoBack(string regionName); + + [Obsolete("Use GoBack instead.")] + void Goback(string regionName); + } +} \ No newline at end of file diff --git a/README.md b/README.md index fe130ad..9e3d835 100644 --- a/README.md +++ b/README.md @@ -2,327 +2,303 @@ [![NuGet](https://img.shields.io/nuget/v/Junevy.SimpleNavigation.svg)](https://www.nuget.org/packages/Junevy.SimpleNavigation/) -涓涓秴杞婚噺绾х殑 WPF 瀵艰埅妗嗘灦锛屽熀浜 `Microsoft.Extensions.DependencyInjection` 鏋勫缓锛岃 WPF 搴旂敤鐨勯〉闈㈠鑸拰绐楀彛绠$悊鍙樺緱绠鍗曠洿瑙傘 +SimpleNavigation 鏄竴涓熀浜 `Microsoft.Extensions.DependencyInjection` 鐨勮交閲忕骇 WPF 瀵艰埅绫诲簱锛屾敮鎸 .NET 8 WPF 涓 .NET Framework 4.8銆 ---- +## 鍔熻兘姒傝 -## 椤圭洰浠嬬粛 +- `PageService`锛氬湪鍛藉悕鐨 `Frame` 鍖哄煙涓鑸 `Page`锛屾敮鎸佽繑鍥炰笂涓椤点 +- `ContentService`锛氬湪鍛藉悕鐨勯潪 `Frame` `ContentControl` 鍖哄煙涓樉绀 `UserControl`銆佽嚜瀹氫箟 `ContentControl` 鎴栧叾浠栭潪 `Page`銆侀潪 `Window` 鐨 `FrameworkElement`銆 +- `DialogService`锛氶氳繃 DI 鎴栫嫭绔嬬殑 Dialog 瀛楃涓茶矾鐢辨樉绀恒佹ā鎬佹樉绀哄拰鍏抽棴 `Window`锛屽苟鏀寔鍙傛暟涓庢ā鎬佺粨鏋滀紶閫掋 +- `RegionManager`锛氱粺涓绠$悊 XAML 澹版槑鎴栦唬鐮佹敞鍐岀殑鍛藉悕鍖哄煙锛屽苟浠ュ急寮曠敤淇濆瓨鍖哄煙瀹夸富銆 -SimpleNavigation 鏄竴涓彈 Prism 鍚彂鐨 WPF 瀵艰埅搴擄紝浣嗗幓闄や簡 Prism 鐨勫鏉傛э紝浠呬繚鐣欐渶鏍稿績鐨勫鑸兘鍔涖傛暣涓鏋朵粎鍖呭惈 11 涓 C# 婧愭枃浠讹紝闆堕澶栦緷璧栵紙鍙緷璧栧井杞畼鏂圭殑 DI 瀹瑰櫒锛夛紝鎻愪緵涓夊ぇ鏍稿績鍔熻兘锛 - -- **鍖哄煙锛圧egion锛夐〉闈㈠鑸** 鈥 鍦ㄥ懡鍚 `Frame` 鍖哄煙鍐呰繘琛 `Page` 鐨勫鑸笌鍥為 -- **绐楀彛锛圖ialog锛夌鐞** 鈥 鎵撳紑 WPF `Window`锛屾敮鎸佹ā鎬/闈炴ā鎬併佸弬鏁颁紶閫掍笌缁撴灉杩斿洖 -- **瀵艰埅鍙傛暟浼犻** 鈥 閫氳繃 `DialogParameters` 鍦ㄩ〉闈㈠拰绐楀彛涔嬮棿浼犻掑己绫诲瀷鍙傛暟 - -閰嶅悎 `CommunityToolkit.Mvvm` 浣跨敤鏁堟灉鏇翠匠銆 - ---- +瀵艰埅鐩爣鍏ㄩ儴鐢卞簲鐢ㄧ殑 DI 瀹瑰櫒鍒涘缓銆傜被搴撲笉璁剧疆 `DataContext`锛屽洜姝 View 涓 ViewModel 鐨勬瀯閫犳敞鍏ュ拰缁戝畾鏂瑰紡浠嶇敱搴旂敤鍐冲畾銆 ## 鐜瑕佹眰 -| 鐩爣妗嗘灦 | 璇存槑 | -|----------|------| -| `.NET 8.0` (net8.0-windows) | 鐜颁唬 .NET WPF 搴旂敤 | -| `.NET Framework 4.8` (net48) | 浼犵粺 .NET Framework WPF 搴旂敤 | - -寮鍙戠幆澧冿細Visual Studio 2022 (v17.14+)锛孋# 13銆 - ---- - -## 渚濊禆淇℃伅 - -### NuGet 鍖 - -``` -Junevy.SimpleNavigation -``` - -### 妗嗘灦渚濊禆 - -| 鐩爣妗嗘灦 | 渚濊禆椤 | 鐗堟湰 | -|----------|--------|------| -| net8.0-windows | `Microsoft.Extensions.DependencyInjection` | 8.0.0 | -| net48 | `Microsoft.Extensions.DependencyInjection` | 6.0.1 | - -浠呬緷璧栧井杞畼鏂圭殑 DI 瀹瑰櫒锛屾棤闇 Prism 鎴栧叾浠栭噸鍨嬫鏋躲 - ---- - -## 鍔熻兘绠浠嬩笌婕旂ず - -### 1. 椤圭洰缁撴瀯 - -``` -SimpleNavigation/ -鈹溾攢鈹 Common/ -鈹 鈹溾攢鈹 DialogManager.cs # 绐楀彛鐢熷懡鍛ㄦ湡绠$悊锛學eakReference 缂撳瓨闃叉鍐呭瓨娉勬紡 -鈹 鈹斺攢鈹 DialogParameters.cs # 瀵艰埅鍙傛暟瀵硅薄锛屾敮鎸佸瓧鍏/绱㈠紩/閿间笁绉嶆瀯閫 -鈹溾攢鈹 Extensions/ -鈹 鈹斺攢鈹 NavigationExtensions.cs # DI 瀹瑰櫒娉ㄥ唽鎵╁睍鏂规硶 -鈹溾攢鈹 Interface/ -鈹 鈹溾攢鈹 IPageService.cs # 椤甸潰瀵艰埅鏈嶅姟鎺ュ彛 -鈹 鈹溾攢鈹 IPageAware.cs # 椤甸潰鎰熺煡鎺ュ彛锛堟帴鏀跺鑸簨浠讹級 -鈹 鈹溾攢鈹 IDialogService.cs # 绐楀彛鏈嶅姟鎺ュ彛 -鈹 鈹溾攢鈹 IDialogAware.cs # 绐楀彛鎰熺煡鎺ュ彛锛堟帴鏀跺鑸簨浠 + 璇锋眰鍏抽棴锛 -鈹 鈹斺攢鈹 IDialogManager.cs # 绐楀彛瀹炰緥绠$悊鎺ュ彛 -鈹斺攢鈹 Services/ - 鈹溾攢鈹 PageService.cs # 椤甸潰瀵艰埅瀹炵幇 - 鈹溾攢鈹 DialogService.cs # 绐楀彛绠$悊瀹炵幇 - 鈹斺攢鈹 RegionService.cs # 鍖哄煙闄勫姞灞炴э紙XAML 澹版槑寮忔敞鍐岋級 -``` - -### 2. 蹇熷紑濮 - -#### 2.1 瀹夎 +| 鐩爣妗嗘灦 | DI 渚濊禆 | +| --- | --- | +| `net8.0-windows` | `Microsoft.Extensions.DependencyInjection` 8.0.0 | +| `net48` | `Microsoft.Extensions.DependencyInjection` 6.0.1 | -閫氳繃 NuGet 瀹夎锛 +瀹夎 NuGet 鍖咃細 ```bash dotnet add package Junevy.SimpleNavigation ``` -鎴栦娇鐢 Package Manager锛 +## 椤圭洰缁撴瀯 +```text +SimpleNavigation/ + Interface/ + IRegionManager.cs # 鍖哄煙娉ㄥ唽涓庢煡璇 + IPageService.cs # Page 瀵艰埅 + IContentService.cs # FrameworkElement 鍐呭瀵艰埅 + INavigationAware.cs # 瀵艰埅瀹屾垚閫氱煡 + IPageAware.cs # 鍏煎鏃т唬鐮侊紝缁ф壙 INavigationAware + IDialogService.cs # Window 鏄剧ず銆佹ā鎬佹樉绀轰笌鍏抽棴鏈嶅姟 + IDialogAware.cs # Window 瀵艰埅涓庡叧闂氱煡 + IDialogManager.cs # Window 鑾峰彇銆佸鐢ㄤ笌鐜版湁瀹炰緥鏌ヨ + Services/ + Region.cs # RegionName 闄勫姞灞炴 + PageService.cs # Frame/Page 瀵艰埅瀹炵幇 + ContentService.cs # ContentControl 鍐呭瀵艰埅瀹炵幇 + DialogService.cs # Window 鏄剧ず瀹炵幇 + Common/ + RegionManager.cs # 鍛藉悕鍖哄煙绠$悊 + DialogManager.cs # Window 瀹炰緥绠$悊 + DialogParameters.cs # 瀵艰埅鍙傛暟 + Extensions/ + NavigationExtensions.cs # DI 涓庤矾鐢辨敞鍐屾墿灞 ``` -Install-Package Junevy.SimpleNavigation -``` - -#### 2.2 娉ㄥ唽瀵艰埅鏈嶅姟 -鍦 `App.xaml.cs` 涓娇鐢ㄤ竴琛屼唬鐮佸畬鎴愭墍鏈夋湇鍔℃敞鍐岋細 +## 娉ㄥ唽鏈嶅姟鍜岀洰鏍 ```csharp using Microsoft.Extensions.DependencyInjection; using SimpleNavigation.Extensions; -public partial class App : Application -{ - public IServiceProvider Provider { get; private set; } - - public void InitialProvider() - { - var container = new ServiceCollection(); - - // 娉ㄥ唽瀵艰埅鏈嶅姟锛堜竴琛屾敞鍐 IDialogService / IPageService / IDialogManager锛 - container.RegisterNavigationService(); +var services = new ServiceCollection(); +services.RegisterNavigationService(); + +// 鏅 DI 娉ㄥ唽瓒充互鏀寔娉涘瀷瀵艰埅鍜 Type 瀵艰埅銆 +services.AddTransient(); +services.AddSingleton(); + +// 璺敱鎵╁睍鍙悓鏃舵敞鍐 View/ViewModel锛屽苟鍙夋嫨娣诲姞瀛楃涓插埆鍚嶃 +services.AddPage("settings"); +services.AddPage(); +services.AddPage("reports"); +services.AddContent("help"); +services.AddContent(); +services.AddContent("status"); +services.AddWindow("login"); +services.AddWindow(); +services.AddWindow("reports"); + +var provider = services.BuildServiceProvider(); +``` - // 娉ㄥ唽浣犵殑椤甸潰銆佺獥鍙e拰 ViewModel - container.AddTransient(); - container.AddSingleton(); - container.AddTransient(); - container.AddSingleton(); - container.AddSingleton((p) => new TestPage() { ShowsNavigationUI = false }); +`AddWindow` 浣跨敤 `TryAddTransient` 娉ㄥ唽 Window 鍜屽彲閫夌殑 ViewModel锛屼笉浼氳鐩栧湪瀹冧箣鍓嶆坊鍔犵殑娉ㄥ唽銆傞渶瑕佽嚜瀹氫箟鐢熷懡鍛ㄦ湡鎴栧伐鍘傛椂锛屽簲鍏堜娇鐢ㄦ爣鍑 DI 鏂规硶娉ㄥ唽瀵瑰簲鏈嶅姟锛涗笉杩囷紝瑕佸湪 `Closed` 鍚庨噸鏂版墦寮鐨 Window 娉ㄥ唽蹇呴』鑳藉浜х敓鏂板疄渚嬨俿ingleton 娉ㄥ唽锛屾垨鍦ㄥ悓涓涓粛瀛樻椿 scope 涓噸澶嶄娇鐢ㄧ殑 scoped 娉ㄥ唽锛屼細鍐嶆杩斿洖宸茬粡鍏抽棴鐨 WPF Window锛屽悗缁 `Show` 灏嗗け璐ャ俙AddWindow` 榛樿鐨 transient 娉ㄥ唽婊¤冻閲嶆柊鍒涘缓瑕佹眰锛涗娇鐢 scoped Window 鏃讹紝搴旂敤蹇呴』璁 scope 闅忓搴 UI 鐢熷懡鍛ㄦ湡涓璧峰垱寤哄拰閲婃斁銆傛墿灞曟柟娉曞彧璐熻矗娉ㄥ唽锛岀粷涓嶄細璁剧疆 Window 鐨 `DataContext`銆 - Provider = container.BuildServiceProvider(); - } -} -``` +褰撳墠 `AddPage` 涓 `AddContent` 浣跨敤 `TryAddSingleton` 娉ㄥ唽 View 鍜屽彲閫夌殑 ViewModel锛岃繖涓 `AddWindow` 鐨 `TryAddTransient` 鏄湁鎰忕殑鐢熷懡鍛ㄦ湡宸紓銆傚畠浠悓鏍蜂繚鐣欐洿鏃╃殑娉ㄥ唽锛屼篃涓嶄細鍒涘缓鎴栬缃 View 鐨 `DataContext`銆 -#### 2.3 娉ㄥ唽瀵艰埅鍖哄煙锛圧egion锛 +鍙屾硾鍨嬫棤 key 鐨勯噸杞藉彧娉ㄥ唽 View 鍜 ViewModel锛涘崟娉涘瀷鍔 key 鐨勯噸杞芥敞鍐 View 涓庤矾鐢卞埆鍚嶏紱鍙屾硾鍨嬪姞 key 鐨勯噸杞藉悓鏃舵敞鍐 View銆乂iewModel 涓庤矾鐢卞埆鍚嶃侾age銆丆ontent 涓 Dialog 鐨 key 绌洪棿鐩镐簰鐙珛锛屽潎閲囩敤 ordinal銆佸尯鍒嗗ぇ灏忓啓鐨勬瘮杈冿紱鍚屼竴涓 key 鍙互鍒嗗埆鐢ㄤ簬涓夌璺敱锛屼絾鍚屼竴绌洪棿鍐呬笉鑳介噸澶嶆敞鍐屻 -鍦 XAML 涓氳繃闄勫姞灞炴у0鏄庡鑸尯鍩燂細 +## 澹版槑瀵艰埅鍖哄煙 ```xml - - + - + - - - - - - + - - + ``` -> `RegionService.RegionName` 鍙兘闄勫姞鍒 `Frame` 鎺т欢涓婏紝鍚﹀垯浼氭姏鍑哄紓甯搞 +`PageService` 鐨勫尯鍩熷涓诲繀椤绘槸 `Frame`銆俙ContentService` 褰撳墠瑕佹眰鍖哄煙瀹夸富鏄潪 `Frame` 鐨 `ContentControl`銆備篃鍙互閫氳繃 `IRegionManager.RegisterRegion(...)` 鏄惧紡娉ㄥ唽瀹夸富锛屽苟閫氳繃 `GetRegion(...)` 鏌ヨ銆 -### 3. 椤甸潰瀵艰埅 +## Page 瀵艰埅 -#### 3.1 瀵艰埅鍒版寚瀹氶〉闈 +`IPageService` 鎻愪緵娉涘瀷銆乣Type` 鍜屽瓧绗︿覆 key 涓夌瀵艰埅鏂瑰紡锛 ```csharp -using SimpleNavigation.Interface; using SimpleNavigation.Common; +using SimpleNavigation.Interface; -public class MainWindowViewModel +public sealed class MainViewModel { private readonly IPageService pageService; - public MainWindowViewModel(IPageService pageService) + public MainViewModel(IPageService pageService) { this.pageService = pageService; } - [RelayCommand] - public void RegionTest() + public void OpenPages() { - // 娉涘瀷鏂瑰紡瀵艰埅 - pageService.Navigate("Main"); - - // 甯﹀弬鏁板鑸 - var parameters = new DialogParameters("key", "value"); - pageService.Navigate("Main", parameters); + var parameters = new DialogParameters("id", 42); - // Type 鏂瑰紡瀵艰埅 - pageService.Navigate("Main", typeof(TestPage), parameters); + pageService.Navigate("Pages", parameters); + pageService.Navigate("Pages", typeof(HomePage), parameters); + pageService.Navigate("Pages", "settings", parameters); } - [RelayCommand] - public void GoBack() + public void Back() { - // 杩斿洖涓婁竴椤 - pageService.Goback("Main"); + pageService.GoBack("Pages"); } } ``` -#### 3.2 椤甸潰鎺ユ敹鍙傛暟 +娉涘瀷閲嶈浇鐩存帴浠 DI 瑙f瀽娉涘瀷绫诲瀷锛宍Type` 閲嶈浇鐩存帴浠 DI 瑙f瀽浼犲叆绫诲瀷銆傚彧鏈夊瓧绗︿覆閲嶈浇鍏堜粠 Page 璺敱瀛楀吀鍙栧緱鏈缁堢被鍨嬶紱鍙栧緱绫诲瀷鍚庝粛閫氳繃鏅 DI 瑙f瀽 Page 瀹炰緥銆 -璁 ViewModel 瀹炵幇 `IPageAware` 鎺ュ彛锛 +## Content 瀵艰埅 + +`IContentService` 鍚屾牱鎻愪緵涓夌瀵艰埅鏂瑰紡锛 ```csharp using SimpleNavigation.Common; using SimpleNavigation.Interface; -public class TestViewModel : IPageAware +public sealed class ShellViewModel { - public event Action? Receive; + private readonly IContentService contentService; - public void OnNavigated(DialogParameters? parameters) + public ShellViewModel(IContentService contentService) { - if (parameters != null) - { - var value = parameters.Get("key"); - // 澶勭悊瀵艰埅鍙傛暟 - } + this.contentService = contentService; + } + + public void OpenContent() + { + var parameters = new DialogParameters("id", 42); + + contentService.Navigate("Content", parameters); + contentService.Navigate("Content", typeof(DashboardView), parameters); + contentService.Navigate("Content", "status", parameters); } } ``` -### 4. 绐楀彛锛圖ialog锛夌鐞 +娉涘瀷涓 `Type` 閲嶈浇涓嶈鍙栬矾鐢辫〃銆傚彧鏈夊瓧绗︿覆閲嶈浇浠 Content 璺敱瀛楀吀鍙栧緱鏈缁堢被鍨嬶紝闅忓悗閫氳繃鏅 DI 瑙f瀽瀹炰緥銆傚鑸洰鏍囧彲浠ユ槸 `UserControl`銆佹櫘閫氭垨鑷畾涔 `ContentControl`銆乣Grid`銆乣StackPanel` 绛 `FrameworkElement`锛屼絾涓嶈兘鏄 `Page` 鎴 `Window`銆 + +## 瀵艰埅閫氱煡 -#### 4.1 鎵撳紑绐楀彛 +Page 鎴 Content 瀵艰埅鎴愬姛鍚庯紝鐩爣 View 鍙婂叾 `DataContext` 涓疄鐜颁簡 `INavigationAware` 鐨勫璞¢兘浼氭敹鍒伴氱煡锛涘鏋滀簩鑰呮槸鍚屼竴瀹炰緥锛屽垯鍙氱煡涓娆° ```csharp -using SimpleNavigation.Interface; using SimpleNavigation.Common; +using SimpleNavigation.Interface; -public class MainWindowViewModel +public sealed class StatusViewModel : INavigationAware { - private readonly IDialogService dialogService; - - public MainWindowViewModel(IDialogService dialogService) - { - this.dialogService = dialogService; - } - - [RelayCommand] - public void OpenWindow() - { - // 闈炴ā鎬佹墦寮绐楀彛 - var param = new DialogParameters("key", "value"); - dialogService.Show(param); - } - - [RelayCommand] - public void OpenModalDialog() + public void OnNavigated(DialogParameters? parameters) { - // 妯℃佹墦寮绐楀彛锛岀瓑寰呯敤鎴峰叧闂苟杩斿洖缁撴灉 - var param = new DialogParameters("key", "value"); - var result = dialogService.ShowDialog(param); - - if (result != null) - { - var returnValue = result.Get("resultKey"); - } + var id = parameters?.Get("id"); } } ``` -#### 4.2 绐楀彛鎺ユ敹鍙傛暟骞惰繑鍥炵粨鏋 +绫诲簱鍙彂閫侀氱煡锛屼笉璐熻矗鎶 `StatusViewModel` 璁剧疆涓 View 鐨 `DataContext`銆 -璁 ViewModel 鎴 Window 瀹炵幇 `IDialogAware` 鎺ュ彛锛 +## DialogService + +Window 鍙婂叾渚濊禆闇瑕佸厛娉ㄥ唽鍒 DI锛涢渶瑕佸瓧绗︿覆 key 鏃跺啀娉ㄥ唽 Dialog 璺敱锛 ```csharp -using SimpleNavigation.Common; -using SimpleNavigation.Interface; +services.AddWindow("login"); +services.AddWindow(); +services.AddWindow("reports"); +``` + +`IDialogService` 鐨勯潪妯℃佹樉绀恒佹ā鎬佹樉绀轰笌鍏抽棴鎿嶄綔閮芥彁渚涙硾鍨嬨乣Type` 鍜屽瓧绗︿覆 key 涓夌褰㈠紡锛 -public class TestViewModel : IDialogAware +```csharp +dialogService.Show(); +dialogService.Show(typeof(LoginWindow)); +dialogService.Show("login"); + +DialogParameters? genericResult = dialogService.ShowDialog(); +DialogParameters? typeResult = dialogService.ShowDialog(typeof(LoginWindow)); +DialogParameters? keyResult = dialogService.ShowDialog("login"); + +bool closedByGeneric = dialogService.Close(); +bool closedByType = dialogService.Close(typeof(LoginWindow)); +bool closedByKey = dialogService.Close("login"); +``` + +`Show` 涓 `ShowDialog` 鐨勪笁绉嶉噸杞介兘鍙互棰濆鎺ユ敹 `DialogParameters`銆 + +娉涘瀷涓 `Type` 閲嶈浇鍙渶瑕佹櫘閫 DI 娉ㄥ唽锛屼笉璇诲彇璺敱琛ㄣ傚瓧绗︿覆閲嶈浇鍏堝湪鐙珛鐨 Dialog 璺敱绌洪棿涓寜 ordinal銆佸尯鍒嗗ぇ灏忓啓鐨 key 鏌ユ壘 Window 绫诲瀷锛岄殢鍚庝粛閫氳繃鏅 DI 瑙f瀽瀹炰緥锛涙湭鐭 key 浼氭姏鍑 `KeyNotFoundException`銆侾age銆丆ontent 涓 Dialog 鍙娇鐢ㄧ浉鍚 key锛屼簰涓嶅啿绐併 + +`AddWindow` 榛樿鎶 Window 娉ㄥ唽涓 transient锛屼絾 `DialogManager` 浼氬湪绐楀彛浠嶇劧瀛樻椿涓旀湭鍏抽棴鏃跺鐢ㄥ綋鍓嶅疄渚嬨傚洜姝わ紝瀵瑰悓涓 Window 绫诲瀷閲嶅 `Show` 浼氭樉绀烘垨婵娲诲綋鍓嶅疄渚嬶紱鍗充娇瀹冨綋鍓嶆病鏈夌劍鐐癸紝`Close` 涔熻兘鍏抽棴瀹冦傜獥鍙hЕ鍙 `Closed` 鍚庤褰曚細绉婚櫎锛屼笅涓娆 `Show` 鎵嶄粠 DI 瑙f瀽鏂板疄渚嬨傞粯璁 transient 娉ㄥ唽浼氫骇鐢熸柊鐨 Window锛涜嚜瀹氫箟 singleton 娉ㄥ唽鎴栧悓涓浠嶅瓨娲 scope 涓鐢ㄧ殑 scoped 娉ㄥ唽鍒欎細杩斿洖宸插叧闂疄渚嬶紝WPF 涓嶅厑璁稿啀娆℃樉绀鸿瀹炰緥銆俙Close` 鍙煡璇㈢幇鏈夊疄渚嬶紝缁濅笉浼氫负浜嗗叧闂屽垱寤虹獥鍙o紱娌℃湁鍙楃鐞嗙殑鐜版湁瀹炰緥鏃惰繑鍥 `false`锛宍Closing` 琚彇娑堟椂涔熻繑鍥 `false`銆 + +Window 浠ュ強瀹冪殑銆佷笖涓 Window 涓嶅悓鐨 `DataContext` 閮藉彲浠ュ疄鐜 `IDialogAware`锛屼袱鑰呬細鎸 Window銆丏ataContext 鐨勯『搴忔帴鏀跺弬鏁板拰鍏抽棴鍥炶皟銆傜被搴撲笉浼氳缃垨鏇挎崲 `DataContext`锛 + +```csharp +public sealed class LoginViewModel : IDialogAware { public Action? RequestClose { get; set; } public void OnNavigated(DialogParameters? parameters) { - if (parameters != null) - { - var value = parameters.Get("key"); - // 澶勭悊浼犲叆鍙傛暟 - } + var id = parameters?.Get("id"); } - [RelayCommand] - public void Close() + public void Accept() { - // 鍏抽棴绐楀彛骞惰繑鍥炵粨鏋 - var result = new DialogParameters("resultKey", "resultValue"); - RequestClose?.Invoke(result); + RequestClose?.Invoke(new DialogParameters("accepted", true)); } } ``` -> 妗嗘灦鏀寔 `IDialogAware` 鍚屾椂瀹炵幇鍦 Window 鑷韩锛坈ode-behind锛夋垨 DataContext锛圴iewModel锛変笂锛屼袱鑰呭彲鍏卞瓨銆 +鍦ㄦā鎬佹樉绀轰腑锛宍RequestClose(result)` 鍙湁鍦 Window 瀹為檯瀹屾垚鍏抽棴鍚庢墠鎻愪氦骞剁敱 `ShowDialog` 杩斿洖锛涘鏋滃叧闂鍙栨秷锛岃鍊欓夌粨鏋滀笉浼氭彁浜ゃ傜敤鎴烽氳繃绯荤粺鍏抽棴鎸夐挳绛夋柟寮忕洿鎺ュ叧闂ā鎬佺獥鍙f椂杩斿洖 `null`銆傞潪妯℃佷笌妯℃佹搷浣滀細浠ヤ簨鍔℃柟寮忓畨瑁呫佹竻鐞嗘垨鍦ㄥけ璐ユ椂鎭㈠鐩稿叧鍥炶皟锛屼笖涓嶄細娓呴櫎搴旂敤鑷鏇挎崲鐨勫洖璋冦傚鍚屼竴 Window 鐨勬椿鍔ㄦā鎬佹樉绀烘墽琛岄噸鍏ョ殑 `Show`/`ShowDialog` 浼氳鎷掔粷锛屼互鍏嶈鐩栧綋鍓嶆ā鎬佷簨鍔° -### 5. DialogParameters 鍙傛暟瀵硅薄 +Window 鐨勬樉绀恒佸叧闂互鍙 `RequestClose` 蹇呴』鍦ㄥ叾鎵灞 Dispatcher 绾跨▼璋冪敤銆 + +## DialogParameters ```csharp -// 鏂瑰紡涓锛氶敭鍊煎鏋勯 -var p1 = new DialogParameters("key", "value"); +var byKey = new DialogParameters("key", "value"); -// 鏂瑰紡浜岋細瀛楀吀鏋勯 -var p2 = new DialogParameters(new Dictionary +var byDictionary = new DialogParameters(new Dictionary { - { "id", 100 }, - { "name", "test" }, - { "data", someObject } + ["id"] = 100, + ["name"] = "test", }); -// 鏂瑰紡涓夛細绱㈠紩鏋勯 -var p3 = new DialogParameters("hello", 123, DateTime.Now); -var first = p3.Get("0"); // "hello" -var second = p3.Get("1"); // 123 +var byIndex = new DialogParameters("hello", 123, DateTime.Now); +var first = byIndex.Get("0"); +var second = byIndex.Get("1"); -// 鑾峰彇 / 璁剧疆鍙傛暟 -var value = p1.Get("key"); -p1.Set("key", "newValue"); // 鍏佽瑕嗙洊宸叉湁鍊 +byKey.Set("key", "newValue"); +var value = byKey.Get("key"); ``` -### 6. 鏋舵瀯璁捐瑕佺偣 +## DI 鐢熷懡鍛ㄦ湡 + +`RegisterNavigationService()` 榛樿鎶 `IRegionManager`銆乣IPageService`銆乣IContentService`銆乣IDialogService` 鍜 `IDialogManager` 娉ㄥ唽涓 singleton銆俙IRegionManager` 淇濆瓨鍛藉悕鍖哄煙瀹夸富鐨勫急寮曠敤锛屽苟鎸佹湁闈欐 `Region` 澹版槑璁㈤槄锛涘畠涓嶈В鏋 View 瀵硅薄鍥撅紝骞朵細鍦ㄦ墍灞 DI provider 閲婃斁鏃跺彇娑堣闃呫 + +singleton 鐨 `PageService`銆乣ContentService` 鍜 `DialogManager` 浼氭崟鑾峰垱寤哄畠浠殑 `IServiceProvider`锛堥氬父鏄牴瀹瑰櫒锛夛紱浠呭垱寤烘垨鎸佹湁涓涓瓙 scope 涓嶄細鎶婅繖浜 singleton 鐨勭洰鏍囪В鏋愬垏鎹㈠埌璇 scope銆俙DialogService` 鏋勯犳椂浠 provider 鍙栧緱骞朵繚瀛樿矾鐢辨敞鍐岃〃锛屼箣鍚庡彧鎸佹湁璇ユ敞鍐岃〃涓 `IDialogManager`锛屼笉浼氫繚鐣 provider 鎴栫洿鎺ヨВ鏋 Window锛沇indow 瀵硅薄鍥剧敱 `DialogManager` 浠庡畠鎹曡幏鐨 provider 瑙f瀽銆俙AddWindow` 榛樿娉ㄥ唽 transient Window 鍜 ViewModel锛屼絾浠庢牴 provider 瑙f瀽鐨 disposable transient 鎴 scoped 瀵硅薄鍥句粛鍏锋湁涓嬭堪鏃㈡湁鐢熷懡鍛ㄦ湡闄愬埗銆 + +鍥犳锛屽湪鍚敤 `ValidateScopes` 鏃讹紝浠庢牴瀹瑰櫒瑙f瀽 scoped View銆乂iewModel 鎴 Window 瀵硅薄鍥炬槸鏃犳晥鐨勶紱浠庢牴瀹瑰櫒瑙f瀽鐨 disposable transient 瀵硅薄浼氱敱 Microsoft DI 淇濈暀鍒版牴瀹瑰櫒閲婃斁銆傜被搴撲笉浼氫负瀵艰埅鎴栫獥鍙e垱寤恒佹寔鏈夋垨閲婃斁 scope锛屽洜涓哄凡鏄剧ず UI 鐨勭敓鍛藉懆鏈熷睘浜庡簲鐢ㄣ + +闇瑕 scoped 鎴 disposable View/ViewModel/Window 瀵硅薄鍥剧殑搴旂敤锛屽繀椤诲湪璋冪敤 `RegisterNavigationService()` 鍓嶈鐩栫浉鍏冲鑸湇鍔″拰绠$悊鍣ㄧ殑鐢熷懡鍛ㄦ湡鎴栬В鏋愮瓥鐣ワ紙鍏 `TryAdd` 娉ㄥ唽浼氫繚鐣欏厛鍓嶆敞鍐岋級锛屼粠搴旂敤鎸佹湁鐨 scope 瑙f瀽杩欎簺鏈嶅姟锛屽苟璁 scope 鐨勯噴鏀炬椂鏈轰笌瀵瑰簲 UI 鐢熷懡鍛ㄦ湡涓鑷淬傜壒鍒槸 scoped Window锛屾瘡娆″叧闂悗鑻ヨ繕瑕侀噸鏂版墦寮锛屽繀椤婚噴鏀炬棫 scope锛屽苟涓轰笅涓娆 UI 鐢熷懡鍛ㄦ湡鍒涘缓鏂扮殑 scope锛岀‘淇 DI 杩斿洖鍏ㄦ柊鐨 Window 瀹炰緥銆 -- **DI 椹卞姩**锛氭墍鏈 Page 鍜 Window 鍧囩敱 DI 瀹瑰櫒瑙f瀽锛屾敮鎸佹瀯閫犲嚱鏁版敞鍏 -- **WeakReference 绐楀彛缂撳瓨**锛歚DialogManager` 浣跨敤 `WeakReference` 闃叉鍐呭瓨娉勬紡 -- **闄勫姞灞炴ф敞鍐屽尯鍩**锛氶氳繃 `RegionService.RegionName` 鍦 XAML 涓0鏄庡紡娉ㄥ唽瀵艰埅鍖哄煙 -- **绾跨▼瀹夊叏**锛歚PageService` 浣跨敤 `ConcurrentDictionary` 瀛樺偍鍖哄煙 -- **鍙岀粦瀹氭劅鐭**锛氭鏋跺悓鏃舵鏌 Window/Page 鑷韩鍙婂叾 `DataContext` 鏄惁瀹炵幇浜嗘劅鐭ユ帴鍙 -- **澶氱洰鏍囨鏋**锛氬悓鏃舵敮鎸 .NET Framework 4.8 鍜 .NET 8.0 Windows +## 鍖哄煙瀹夸富鎵╁睍杈圭晫 -### 7. 鏁堟灉棰勮 +`Grid`銆乣StackPanel` 绛夊厓绱犵幇鍦ㄥ彲浠ヤ綔涓 Content 瀵艰埅鐩爣锛屼絾涓嶈兘浣滀负鍖哄煙瀹夸富锛沗TabControl` 涔熷皻鏈綔涓哄尯鍩熷涓诲惎鐢ㄣ + +鍐呴儴鍖哄煙閫傞厤鍣ㄤ互 `FrameworkElement` 鍜屽涓昏兘鍔涗负杈圭晫锛屽洜姝ゆ湭鏉ュ彲浠ュ湪 resolver 涓鍔犻傞厤鍣紝鑰屼笉蹇呬慨鏀 `ContentService`銆備笉杩囷紝鍦ㄦ敮鎸 `Panel` 鍓嶅繀椤绘槑纭尯鍩熸槸鍚︽嫢鏈夊叏閮ㄥ瓙鍏冪礌浠ュ強鏇挎崲/杩藉姞绛栫暐锛涘湪鏀寔 `TabControl` 鍓嶅繀椤绘槑纭爣绛惧垱寤恒侀夋嫨銆佸鐢ㄥ拰 header 绛栫暐銆 + +## 鐮村潖鎬у崌绾ц縼绉 + +鏈閲嶅缓閲囩敤浠ヤ笅 API 鏄犲皠锛 + +```text +RegionService.RegionName -> Region.RegionName +IPageService.GetRegion(...) -> IRegionManager.GetRegion(...) +IPageService.Goback(...) -> IPageService.GoBack(...) +``` -![preview](https://github.com/user-attachments/assets/f1692e72-eace-44f8-a6c7-171243d2f854) +`RegionService` 宸插垹闄ゃ俙Goback` 浠呬綔涓烘爣璁颁簡 `Obsolete` 鐨勮浆鍙戞柟娉曚繚鐣欙紝鐜版湁浠g爜搴旇縼绉诲埌 `GoBack`銆傜敱浜庨檮鍔犲睘鎬ф墍鏈夎呭彂鐢熷彉鍖栵紝寮曠敤鏃у睘鎬х殑宸茬紪璇 XAML/BAML 蹇呴』閲嶆柊鏋勫缓銆 ---- +Dialog API 涔熸湁鐮村潖鎬у彉鍖栵細`IDialogService` 鏂板浜 `Type`銆佸瓧绗︿覆 key 涓 `Close` 鎴愬憳锛涜嚜瀹氫箟 `IDialogService` 瀹炵幇蹇呴』琛ラ綈杩欎簺鎴愬憳銆傝嚜瀹氫箟 `IDialogManager` 瀹炵幇蹇呴』鏂板 `GetOrCreateWindow(Type)` 涓 `GetExistingWindow(Type)`銆傜洿鎺ユ瀯閫 `DialogService` 鐨勪唬鐮佺幇鍦ㄥ繀椤诲悓鏃朵紶鍏 `IServiceProvider` 鍜 `IDialogManager`銆傚洜姝ゅ寘鍚繖浜涘彉鏇寸殑 NuGet 鍖呭簲浣滀负鐮村潖鎬х殑 2.x 鐗堟湰鍗囩骇澶勭悊銆 ## License diff --git a/Services/ContentService.cs b/Services/ContentService.cs new file mode 100644 index 0000000..4dc0000 --- /dev/null +++ b/Services/ContentService.cs @@ -0,0 +1,143 @@ +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Common.Adapters; +using SimpleNavigation.Interface.Adapters; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Services +{ + public class ContentService : IContentService + { + private readonly IServiceProvider provider; + private readonly IRegionManager regionManager; + private readonly NavigationRouteRegistry routes; + + public ContentService(IServiceProvider provider, IRegionManager regionManager) + { + this.provider = provider; + this.regionManager = regionManager; + routes = provider.GetRequiredService(); + } + + public void Navigate(string regionName, DialogParameters? parameters = null) + where TContent : FrameworkElement + { + ValidateRegionName(regionName); + ValidateContentType(typeof(TContent)); + NavigateCore(regionName, provider.GetRequiredService(), parameters); + } + + public void Navigate(string regionName, Type targetType, DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + ValidateContentType(targetType); + var content = provider.GetRequiredService(targetType) as FrameworkElement + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a FrameworkElement."); + NavigateCore(regionName, content, parameters); + } + + public void Navigate(string regionName, string key, DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + var targetType = routes.GetRequiredContentType(key); + ValidateContentType(targetType); + var content = provider.GetRequiredService(targetType) as FrameworkElement + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a FrameworkElement."); + NavigateCore(regionName, content, parameters); + } + + /// + /// 导航功能的核心实现方法 + /// + /// 指定的 Region 名称 + /// 需要导航的UI控件或内容 + /// 传递的参数 + private void NavigateCore(string regionName, FrameworkElement content, DialogParameters? parameters) + { + var host = GetRequiredHost(regionName); + var hostAdapter = RegionHostAdapterResolver.GetRequired(host); + if (hostAdapter is not IContentRegionHostAdapter adapter) + throw CreateInvalidHostException(regionName, host); + + adapter.Present(host, content); + NavigationAwareNotifier.Notify(content, parameters); + } + + /// + /// 获取指定 Region 的实例 + /// + /// Region 名称 + /// Region 实例 + /// 该宿主未注册或查找失败 + private FrameworkElement GetRequiredHost(string regionName) + { + var region = regionManager.GetRegion(regionName); + if (region != null) + return region; + + throw CreateInvalidHostException(regionName, null); + } + + /// + /// 自定义异常:宿主未注册或查找失败 + /// + /// Region 名称 + /// 宿主实例 + /// CreateInvalidHostException异常 + private static InvalidOperationException CreateInvalidHostException( + string regionName, + FrameworkElement? host) + { + var actual = host?.GetType().FullName ?? "missing"; + return new InvalidOperationException( + $"Region '{regionName}' must be a non-Frame ContentControl or another host " + + $"with a content navigation adapter but was '{actual}'."); + } + + /// + /// 校验需要导航的 UI控件或内容 是否符合约束 + /// + /// 需要导航的内容类型 + /// + /// + private static void ValidateContentType(Type targetType) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(targetType); +#elif NET46_OR_GREATER + if (targetType == null) + throw new ArgumentNullException(nameof(targetType)); +#endif + if (!typeof(FrameworkElement).IsAssignableFrom(targetType) + || typeof(Page).IsAssignableFrom(targetType) + || typeof(Window).IsAssignableFrom(targetType)) + { + throw new ArgumentException( + $"Target type '{targetType.FullName}' must be a non-Page, non-Window FrameworkElement.", + nameof(targetType)); + } + } + + /// + /// 校验 Region 名称 + /// + /// + /// + private static void ValidateRegionName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) + { + throw new ArgumentException( + "Region name cannot be null or whitespace.", + nameof(regionName)); + } + } + } +} + + diff --git a/Services/DialogService.cs b/Services/DialogService.cs index c2a890f..ef896d2 100644 --- a/Services/DialogService.cs +++ b/Services/DialogService.cs @@ -1,5 +1,10 @@ +using Microsoft.Extensions.DependencyInjection; using SimpleNavigation.Common; -using SimpleNavigation.Interface; +using SimpleNavigation.Interface.Awares; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; +using System.Runtime.ExceptionServices; +using System.Runtime.CompilerServices; using System.Windows; namespace SimpleNavigation.Services @@ -10,92 +15,552 @@ namespace SimpleNavigation.Services public class DialogService : IDialogService { private readonly IDialogManager dialogManager; + private readonly NavigationRouteRegistry routes; + private readonly object subscriptionSyncRoot = new(); + private readonly Dictionary nonModalSubscriptions = + new(WindowReferenceComparer.Instance); + private readonly object modalSyncRoot = new(); + private readonly HashSet activeModalWindows = + new(WindowReferenceComparer.Instance); - public DialogService(IDialogManager dialogManager) + public DialogService(IServiceProvider provider, IDialogManager dialogManager) { - this.dialogManager = dialogManager; + if (provider == null) + throw new ArgumentNullException(nameof(provider)); + + this.dialogManager = dialogManager + ?? throw new ArgumentNullException(nameof(dialogManager)); + routes = provider.GetRequiredService(); } - public void Show(DialogParameters? parameters = null) where T : Window + public void Show(DialogParameters? parameters = null) where TWindow : Window { - var window = dialogManager.GetDialogWindow(); + Show(typeof(TWindow), parameters); + } - if (window == null) - return; + public void Show(Type targetType, DialogParameters? parameters = null) + { + ValidateWindowType(targetType); + ShowCore(dialogManager.GetOrCreateWindow(targetType), parameters); + } + + public void Show(string key, DialogParameters? parameters = null) + { + var targetType = routes.GetRequiredDialogType(key); + ValidateWindowType(targetType); + ShowCore(dialogManager.GetOrCreateWindow(targetType), parameters); + } + + public DialogParameters? ShowDialog(DialogParameters? parameters = null) + where TWindow : Window + { + return ShowDialog(typeof(TWindow), parameters); + } + + public DialogParameters? ShowDialog(Type targetType, DialogParameters? parameters = null) + { + ValidateWindowType(targetType); + return ShowDialogCore(dialogManager.GetOrCreateWindow(targetType), parameters); + } + + public DialogParameters? ShowDialog(string key, DialogParameters? parameters = null) + { + var targetType = routes.GetRequiredDialogType(key); + ValidateWindowType(targetType); + return ShowDialogCore(dialogManager.GetOrCreateWindow(targetType), parameters); + } + + public bool Close() where TWindow : Window + { + return Close(typeof(TWindow)); + } + + public bool Close(Type targetType) + { + ValidateWindowType(targetType); + return CloseCore(dialogManager.GetExistingWindow(targetType)); + } + + public bool Close(string key) + { + var targetType = routes.GetRequiredDialogType(key); + ValidateWindowType(targetType); + return CloseCore(dialogManager.GetExistingWindow(targetType)); + } - if (window.DataContext is IDialogAware vm) + private void ShowCore(Window window, DialogParameters? parameters) + { + window.Dispatcher.VerifyAccess(); + if (IsModalActive(window)) { - vm.OnNavigated(parameters); - vm.RequestClose = (p) => window.Close(); + throw new InvalidOperationException( + "Show cannot replace an active modal presentation for the same window."); } - if (window is IDialogAware w) + var priorSubscription = RemoveNonModalSubscription(window); + if (!IsCurrentWindow(window) || HasNonModalSubscription(window)) + return; + + var subscription = CreateNonModalSubscription(window); + + try { - w.OnNavigated(parameters); - w.RequestClose = (p) => window.Close(); + if (!AttachNonModalSubscription(window, subscription)) + return; + + NotifyAwareTargets(subscription.AwareTargets, parameters); + + if (!IsCurrentWindow(window)) + return; + + if (!window.IsVisible) + window.Show(); + + window.Activate(); + } + catch + { + var removedSubscription = + RemoveNonModalSubscription( + window, + subscription, + suppressCallbackExceptions: true); + if (removedSubscription + && priorSubscription != null + && IsCurrentWindow(window)) + { + TryRestoreNonModalSubscription( + window, + priorSubscription, + subscription.RequestClose); + } + + throw; } + } - window.Show(); - window.Activate(); + private DialogParameters? ShowDialogCore(Window window, DialogParameters? parameters) + { + window.Dispatcher.VerifyAccess(); + ClaimModalOwnership(window); + try + { + if (window.IsVisible) + { + throw new InvalidOperationException( + "ShowDialog cannot be called for a visible window."); + } + + return ShowDialogTransaction(window, parameters); + } + finally + { + ReleaseModalOwnership(window); + } } - public DialogParameters? ShowDialog(DialogParameters? parameters = null) where T : Window + private DialogParameters? ShowDialogTransaction( + Window window, + DialogParameters? parameters) { - var window = dialogManager.GetDialogWindow(); - if (window == null) + var priorSubscription = RemoveNonModalSubscription(window); + if (!IsCurrentWindow(window) || HasNonModalSubscription(window)) return null; DialogParameters? result = null; - - window.Closing += (s, e) => + var awareTargets = GetAwareTargets(window); + Action requestClose = closeResult => { - e.Cancel = true; - if (s is IDialogAware dialogAware) + window.Dispatcher.VerifyAccess(); + EventHandler? closedHandler = null; + closedHandler = (_, _) => result = closeResult; + window.Closed += closedHandler; + + try { - dialogAware.RequestClose?.Invoke(result); + window.Close(); } + finally + { + window.Closed -= closedHandler; + } + }; - if (s is Window w && w.DataContext is IDialogAware dialogVmAware) + var operationFailed = false; + try + { + SetRequestClose(awareTargets, requestClose); + NotifyAwareTargets(awareTargets, parameters); + + if (!IsCurrentWindow(window)) + return result; + + window.ShowDialog(); + return result; + } + catch + { + operationFailed = true; + ClearRequestClose( + awareTargets, + requestClose, + suppressExceptions: true); + if (priorSubscription != null && IsCurrentWindow(window)) { - dialogVmAware.RequestClose?.Invoke(result); + TryRestoreNonModalSubscription( + window, + priorSubscription, + requestClose); } + + throw; + } + finally + { + if (!operationFailed) + ClearRequestClose(awareTargets, requestClose); + } + } + + private bool CloseCore(Window? window) + { + if (window == null) + return false; + + window.Dispatcher.VerifyAccess(); + var closed = false; + EventHandler closedHandler = (_, _) => closed = true; + window.Closed += closedHandler; + + try + { + window.Close(); + return closed; + } + finally + { + window.Closed -= closedHandler; + } + } + + private NonModalSubscription CreateNonModalSubscription(Window window) + { + var awareTargets = GetAwareTargets(window); + Action requestClose = _ => + { + window.Dispatcher.VerifyAccess(); + window.Close(); + }; + NonModalSubscription? subscription = null; + EventHandler closedHandler = (_, _) => + { + if (subscription != null) + RemoveNonModalSubscription(window, subscription); }; + subscription = new NonModalSubscription(awareTargets, requestClose, closedHandler); + return subscription; + } + + private bool AttachNonModalSubscription( + Window window, + NonModalSubscription subscription) + { + lock (subscriptionSyncRoot) + { + if (nonModalSubscriptions.ContainsKey(window)) + return false; + + nonModalSubscriptions.Add(window, subscription); + } - if (window.DataContext is IDialogAware vm) + window.Closed += subscription.ClosedHandler; + return SetRequestClose(window, subscription); + } + + private bool TryRestoreNonModalSubscription( + Window window, + NonModalSubscription subscription, + Action failedRequestClose) + { + lock (subscriptionSyncRoot) { - vm.OnNavigated(parameters); - vm.RequestClose = (p) => + if (nonModalSubscriptions.ContainsKey(window)) + return false; + + nonModalSubscriptions.Add(window, subscription); + } + + try + { + window.Closed += subscription.ClosedHandler; + if (!RestoreRequestClose( + window, + subscription, + subscription.AwareTargets, + subscription.RequestClose, + failedRequestClose)) { - result = p; - window.Close(); - }; + RemoveNonModalSubscription( + window, + subscription, + suppressCallbackExceptions: true); + return false; + } + + return true; + } + catch + { + RemoveNonModalSubscription( + window, + subscription, + suppressCallbackExceptions: true); + return false; + } + } + + private NonModalSubscription? RemoveNonModalSubscription( + Window window, + bool suppressCallbackExceptions = false) + { + NonModalSubscription? subscription; + lock (subscriptionSyncRoot) + { + if (!nonModalSubscriptions.TryGetValue(window, out subscription)) + return null; + + nonModalSubscriptions.Remove(window); } - if (window is IDialogAware w) + window.Closed -= subscription.ClosedHandler; + ClearRequestClose( + subscription.AwareTargets, + subscription.RequestClose, + suppressCallbackExceptions); + return subscription; + } + + private bool RemoveNonModalSubscription( + Window window, + NonModalSubscription expectedSubscription, + bool suppressCallbackExceptions = false) + { + lock (subscriptionSyncRoot) { - w.OnNavigated(parameters); - w.RequestClose = (p) => + if (!nonModalSubscriptions.TryGetValue(window, out var currentSubscription) + || !ReferenceEquals(currentSubscription, expectedSubscription)) { - result = p; - window.Close(); - }; + return false; + } + + nonModalSubscriptions.Remove(window); } - try + window.Closed -= expectedSubscription.ClosedHandler; + ClearRequestClose( + expectedSubscription.AwareTargets, + expectedSubscription.RequestClose, + suppressCallbackExceptions); + return true; + } + + private bool IsExpectedNonModalSubscription( + Window window, + NonModalSubscription expectedSubscription) + { + lock (subscriptionSyncRoot) { - window.ShowDialog(); - window.Activate(); + return nonModalSubscriptions.TryGetValue(window, out var currentSubscription) + && ReferenceEquals(currentSubscription, expectedSubscription); } - finally + } + + private bool HasNonModalSubscription(Window window) + { + lock (subscriptionSyncRoot) + { + return nonModalSubscriptions.ContainsKey(window); + } + } + + private bool IsModalActive(Window window) + { + lock (modalSyncRoot) + { + return activeModalWindows.Contains(window); + } + } + + private void ClaimModalOwnership(Window window) + { + lock (modalSyncRoot) + { + if (!activeModalWindows.Add(window)) + { + throw new InvalidOperationException( + "A modal presentation is already active for this window."); + } + } + } + + private void ReleaseModalOwnership(Window window) + { + lock (modalSyncRoot) + { + activeModalWindows.Remove(window); + } + } + + private bool IsCurrentWindow(Window window) + { + return ReferenceEquals( + dialogManager.GetExistingWindow(window.GetType()), + window); + } + + private static IDialogAware[] GetAwareTargets(Window window) + { + var windowAware = window as IDialogAware; + var dataContextAware = window.DataContext as IDialogAware; + + if (windowAware == null) + return dataContextAware == null ? Array.Empty() : new[] { dataContextAware }; + + if (dataContextAware == null || ReferenceEquals(windowAware, dataContextAware)) + return new[] { windowAware }; + + return new[] { windowAware, dataContextAware }; + } + + private static void SetRequestClose( + IEnumerable awareTargets, + Action requestClose) + { + foreach (var aware in awareTargets) + aware.RequestClose = requestClose; + } + + private bool SetRequestClose( + Window window, + NonModalSubscription subscription) + { + foreach (var aware in subscription.AwareTargets) { - if (window.DataContext is IDialogAware cleanupVm) - cleanupVm.RequestClose = null; - else if (window is IDialogAware cleanupW) - cleanupW.RequestClose = null; + aware.RequestClose = subscription.RequestClose; + if (!IsExpectedNonModalSubscription(window, subscription)) + { + ClearRequestClose( + subscription.AwareTargets, + subscription.RequestClose); + return false; + } + } + + return true; + } + + private static void NotifyAwareTargets( + IEnumerable awareTargets, + DialogParameters? parameters) + { + foreach (var aware in awareTargets) + aware.OnNavigated(parameters); + } + private bool RestoreRequestClose( + Window window, + NonModalSubscription subscription, + IEnumerable awareTargets, + Action requestClose, + Action failedRequestClose) + { + foreach (var aware in awareTargets) + { + var currentRequestClose = aware.RequestClose; + if (!IsExpectedNonModalSubscription(window, subscription)) + return false; + + if (currentRequestClose == null + || ReferenceEquals(currentRequestClose, failedRequestClose)) + { + aware.RequestClose = requestClose; + if (!IsExpectedNonModalSubscription(window, subscription)) + return false; + } + } + + return true; + } + + private static void ClearRequestClose( + IEnumerable awareTargets, + Action requestClose, + bool suppressExceptions = false) + { + ExceptionDispatchInfo? cleanupException = null; + foreach (var aware in awareTargets) + { + try + { + if (ReferenceEquals(aware.RequestClose, requestClose)) + aware.RequestClose = null; + } + catch (Exception exception) + { + cleanupException ??= ExceptionDispatchInfo.Capture(exception); + } + } + + if (!suppressExceptions) + cleanupException?.Throw(); + } + + private static void ValidateWindowType(Type targetType) + { + if (targetType == null) + throw new ArgumentNullException(nameof(targetType)); + + if (!typeof(Window).IsAssignableFrom(targetType)) + { + throw new ArgumentException( + $"Target type '{targetType.FullName}' must derive from Window.", + nameof(targetType)); + } + } + + private sealed class NonModalSubscription + { + public NonModalSubscription( + IDialogAware[] awareTargets, + Action requestClose, + EventHandler closedHandler) + { + AwareTargets = awareTargets; + RequestClose = requestClose; + ClosedHandler = closedHandler; + } + + public IDialogAware[] AwareTargets { get; } + + public Action RequestClose { get; } + + public EventHandler ClosedHandler { get; } + } + + private sealed class WindowReferenceComparer : IEqualityComparer + { + public static WindowReferenceComparer Instance { get; } = new(); + + public bool Equals(Window? x, Window? y) + { + return ReferenceEquals(x, y); + } + + public int GetHashCode(Window obj) + { + return RuntimeHelpers.GetHashCode(obj); } - return result; } } } diff --git a/Services/PageService.cs b/Services/PageService.cs index 60bfb45..e88fea6 100644 --- a/Services/PageService.cs +++ b/Services/PageService.cs @@ -1,76 +1,133 @@ -锘縰sing Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using SimpleNavigation.Common; -using SimpleNavigation.Interface; -using System.Collections.Concurrent; +using SimpleNavigation.Common.Adapters; +using SimpleNavigation.Interface.Adapters; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; using System.Windows.Controls; -namespace SimpleNavigation.Services +namespace SimpleNavigation.Services; + +public class PageService : IPageService { - public class PageService : IPageService + private readonly IServiceProvider provider; + private readonly IRegionManager regionManager; + private readonly NavigationRouteRegistry routes; + + public PageService(IServiceProvider provider, IRegionManager regionManager) { - private readonly ConcurrentDictionary regions = new(); - private readonly IServiceProvider provider; + this.provider = provider; + this.regionManager = regionManager; + routes = provider.GetRequiredService(); + } - public PageService(IServiceProvider provider) - { - this.provider = provider; - RegionService.RegionRegisted += (regionName, frame) => RegisterRegion(regionName, frame); - } + public void Navigate(string regionName, DialogParameters? parameters = null) where TPage : Page + { + ValidateRegionName(regionName); + NavigateCore(regionName, provider.GetRequiredService(), parameters); + } - public void RegisterRegion(string regionName, Frame frame) - { - if (!string.IsNullOrWhiteSpace(regionName) && frame != null) - regions[regionName] = frame; - } + public void Navigate(string regionName, Type targetType, DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + ValidatePageType(targetType); + var page = provider.GetRequiredService(targetType) as Page + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a Page."); + NavigateCore(regionName, page, parameters); + } - public Frame? GetRegion(string regionName) - { - regions.TryGetValue(regionName, out var frame); - return frame; - } + public void Navigate(string regionName, string key, DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + var targetType = routes.GetRequiredPageType(key); + var page = provider.GetRequiredService(targetType) as Page + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a Page."); + NavigateCore(regionName, page, parameters); + } - public void Goback(string region) - { - if (regions.TryGetValue(region, out var frame)) - { - if (frame.CanGoBack) - frame.GoBack(); - } - } + public void GoBack(string regionName) + { + var frame = GetRequiredFrame(regionName); + var adapter = (IPageRegionHostAdapter) + RegionHostAdapterResolver.GetRequired(frame); + if (adapter.CanGoBack(frame)) + adapter.GoBack(frame); + } - public void Navigate(string regionName, DialogParameters? parameters = null) where T : Page - { - var region = GetRegion(regionName); - if (region != null) - { - var page = provider.GetRequiredService(); + [Obsolete("Use GoBack instead.")] + public void Goback(string regionName) + { + GoBack(regionName); + } + + /// + /// 导航功能的核心实现 + /// + /// 指定的 Region 名称 + /// 指定的Page + /// 传递的参数 + private void NavigateCore(string regionName, Page page, DialogParameters? parameters) + { + var frame = GetRequiredFrame(regionName); + var adapter = (IPageRegionHostAdapter) + RegionHostAdapterResolver.GetRequired(frame); + if (adapter.Navigate(frame, page)) + NavigationAwareNotifier.Notify(page, parameters); + } - //var oldContent = region.Content; + /// + /// 获取指定 Region(Frame) 的实例 + /// + /// Region 名称 + /// Region 实例 + /// Region 实例类型异常 + private Frame GetRequiredFrame(string regionName) + { + ValidateRegionName(regionName); + var region = regionManager.GetRegion(regionName); + if (region is Frame frame) + return frame; - region.Navigate(page); + var actual = region?.GetType().FullName ?? "missing"; + throw new InvalidOperationException( + $"Region '{regionName}' must be a Frame but was '{actual}'."); + } - if (page.DataContext is IPageAware pA && parameters != null) - { - pA.OnNavigated(parameters); - } - } + /// + /// 校验导航的目标类型是否负责约束 + /// + /// 导航目标的类型 + /// + private static void ValidatePageType(Type targetType) + { +#if NET5_0_OR_GREATER + ArgumentNullException.ThrowIfNull(targetType); +#elif NET46_OR_GREATER + if (targetType == null) + throw new ArgumentNullException(nameof(targetType)); +#endif + if (!typeof(Page).IsAssignableFrom(targetType)) + { + throw new ArgumentException( + $"Target type '{targetType.FullName}' must derive from Page.", + nameof(targetType)); } + } - public void Navigate(string regionName, Type targetType, DialogParameters? parameters = null) + /// + /// 校验 Region 名称是否负责规则 + /// + /// + /// + private static void ValidateRegionName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) { - if (targetType.IsSubclassOf(typeof(Page))) - { - var region = GetRegion(regionName); - if (region != null) - { - var page = provider.GetRequiredService(targetType) as Page; - region.Navigate(page); - if (page?.DataContext is IPageAware pA && parameters != null) - { - pA.OnNavigated(parameters); - } - } - } + throw new ArgumentException( + "Region name cannot be null or whitespace.", + nameof(regionName)); } } } diff --git a/Services/Region.cs b/Services/Region.cs new file mode 100644 index 0000000..aa90fbf --- /dev/null +++ b/Services/Region.cs @@ -0,0 +1,638 @@ +using SimpleNavigation.Common.Adapters; +using System.Runtime.ExceptionServices; +using System.Windows; + +namespace SimpleNavigation.Services +{ + public static class Region + { + private static readonly object SyncRoot = new(); + private static readonly object PublicationGate = new(); + private static readonly List Declarations = new(); + private static readonly List>> Subscribers = new(); + private static long nextActivationToken; + + #region Attached property + public static readonly DependencyProperty RegionNameProperty = + DependencyProperty.RegisterAttached( + "RegionName", + typeof(string), + typeof(Region), + new PropertyMetadata(null, OnRegionNameChanged)); + + public static string? GetRegionName(DependencyObject obj) + { + if (obj == null) + throw new ArgumentNullException(nameof(obj)); + + return (string?)obj.GetValue(RegionNameProperty); + } + + public static void SetRegionName(DependencyObject obj, string value) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + + // Before set value to confirm: + // 1) The region name is valid; + ValidateRegionName(value); + // 2) The container is valid (container must be FrameworkElement); + var host = GetRequiredFrameworkElement(obj); + // 3) The adapter has been existed (the container has been have a matching adapter); + RegionHostAdapterResolver.GetRequired(host); + // 4) The region name and container is valid; + ValidateAttachedNameAvailability(host, value); + + obj.SetValue(RegionNameProperty, value); + } + + /// + /// 附加属性 RegionName 注册回调 + /// + /// 被附加的宿主容器 + /// 事件信息 + private static void OnRegionNameChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs eventArgs) + { + if (eventArgs.NewValue == null) + { + ClearDeclaration(dependencyObject); // Region name为空,移除该宿主容器 + return; + } + + var regionName = (string)eventArgs.NewValue; + try + { + ValidateRegionName(regionName); + var host = GetRequiredFrameworkElement(dependencyObject); + RegionHostAdapterResolver.GetRequired(host); // 获取匹配的 Adapter + ApplyRegionName(host, regionName); + } + catch (Exception exception) + { + var originalFailure = ExceptionDispatchInfo.Capture(exception); + + if (!HasMatchingDeclaration(dependencyObject, regionName)) + { + try + { + RestoreDependencyPropertyValue(dependencyObject, eventArgs.OldValue); // 恢复发生异常前的 Region name + } + catch + { + // Preserve the validation or registration failure that caused the rollback. + } + } + + originalFailure.Throw(); + throw; + } + } + #endregion + + /// + /// 注册回调方法 + /// + /// + /// + internal static void Subscribe(Action subscriber) + { + if (subscriber == null) + throw new ArgumentNullException(nameof(subscriber)); + + lock (SyncRoot) + { + RemoveDeadSubscribersUnderLock(); + Subscribers.Add(new WeakReference>(subscriber)); + } + } + + internal static void Unsubscribe(Action subscriber) + { + if (subscriber == null) + return; + + lock (SyncRoot) + { + for (var index = Subscribers.Count - 1; index >= 0; index--) + { + if (!Subscribers[index].TryGetTarget(out var existingSubscriber) || + existingSubscriber.Equals(subscriber)) + { + Subscribers.RemoveAt(index); + } + } + } + } + + /// + /// 获取所有未取消注册的宿主容器 + /// + /// + internal static IReadOnlyList GetActiveSnapshot() + { + lock (SyncRoot) + { + RemoveDeadDeclarationsUnderLock(); + + var snapshot = new List(); + foreach (var declaration in Declarations) + { + if (declaration.IsActive && declaration.Host.TryGetTarget(out var host)) + { + snapshot.Add(CreateChange( + declaration, + host, + RegionDeclarationChangeKind.Add)); + } + } + + return snapshot; + } + } + + /// + /// 添加宿主容器,并指定 Region name + /// + /// 注册的宿主容器 + /// Region name + private static void ApplyRegionName(FrameworkElement host, string regionName) + { + lock (PublicationGate) + { + ApplyRegionNameUnderPublicationGate(host, regionName); + } + } + + private static void ApplyRegionNameUnderPublicationGate(FrameworkElement host, string regionName) + { + Declaration? createdDeclaration = null; + List changes; + Action[] subscribers; + + lock (SyncRoot) + { + RemoveDeadDeclarationsUnderLock(); + ValidateAttachedNameAvailabilityUnderLock(host, regionName); // 校验Region name是否合法 与 宿主容器是否重复注册 + + var declaration = FindDeclarationUnderLock(host); // 查找宿主容器实例是否存在 + + // 宿主容器已注册,且其Region name与 预注册的Region name相同,不执行任何操作 + if (declaration != null && string.Equals(declaration.Name, regionName, StringComparison.Ordinal)) + return; + + changes = new List(2); + + if (declaration == null) // 宿主容器未注册 + { + // 创建新的宿主容器 + declaration = new Declaration( + new WeakReference(host), + regionName, + isActive: true, + GetNextActivationTokenUnderLock()); + Declarations.Add(declaration); + createdDeclaration = declaration; + } + else // 宿主容器已存在,更新宿主容器信息 + { + if (declaration.IsActive) + { + changes.Add(CreateChange( + declaration, + host, + RegionDeclarationChangeKind.Remove)); + } + + declaration.Name = regionName; //更新 Region name + declaration.IsActive = true; + declaration.ActivationToken = GetNextActivationTokenUnderLock(); + } + + changes.Add(CreateChange( + declaration, + host, + RegionDeclarationChangeKind.Add)); + subscribers = GetLiveSubscribersUnderLock(); + } + + if (createdDeclaration != null) + { + try + { + AttachLifecycleHandlers(host); // 订阅Loaded 和 UnLoaded 事件 + } + catch + { + DetachLifecycleHandlers(host); // 取消订阅Loaded 和 UnLoaded 事件 + + lock (SyncRoot) + { + Declarations.Remove(createdDeclaration); // 移除宿主容器 + } + + throw; + } + } + + PublishChanges(changes, subscribers); + } + + /// + /// Remove 指定的宿主容器 + /// + /// + private static void ClearDeclaration(DependencyObject dependencyObject) + { + if (dependencyObject is not FrameworkElement host) + return; + + lock (PublicationGate) + { + ClearDeclarationUnderPublicationGate(host); + } + } + + private static void ClearDeclarationUnderPublicationGate(FrameworkElement host) + { + Declaration? removedDeclaration; + List changes; + Action[] subscribers; + + lock (SyncRoot) + { + RemoveDeadDeclarationsUnderLock(); + removedDeclaration = FindDeclarationUnderLock(host); + if (removedDeclaration == null) + { + return; + } + + changes = new List(1); + if (removedDeclaration.IsActive) + { + changes.Add(CreateChange( + removedDeclaration, + host, + RegionDeclarationChangeKind.Remove)); + } + + Declarations.Remove(removedDeclaration); + subscribers = GetLiveSubscribersUnderLock(); + } + + DetachLifecycleHandlers(host); + PublishChanges(changes, subscribers); + } + + #region 宿主容器Loaded 和 UnLoaded 事件 + private static void OnHostLoaded(object sender, RoutedEventArgs eventArgs) + { + if (sender is not FrameworkElement host) + return; + + lock (PublicationGate) + { + ActivateHostUnderPublicationGate(host); + } + } + + private static void ActivateHostUnderPublicationGate(FrameworkElement host) + { + RegionDeclarationChange? change = null; + Action[] subscribers = Array.Empty>(); + + lock (SyncRoot) + { + RemoveDeadDeclarationsUnderLock(); + var declaration = FindDeclarationUnderLock(host); + if (declaration == null || declaration.IsActive) + { + return; + } + + declaration.IsActive = true; + declaration.ActivationToken = GetNextActivationTokenUnderLock(); + change = CreateChange(declaration, host, RegionDeclarationChangeKind.Add); + subscribers = GetLiveSubscribersUnderLock(); + } + + PublishChanges(new[] { change }, subscribers); + } + + private static void OnHostUnloaded(object sender, RoutedEventArgs eventArgs) + { + if (sender is not FrameworkElement host) + return; + + lock (PublicationGate) + { + DeactivateHostUnderPublicationGate(host); + } + } + + private static void DeactivateHostUnderPublicationGate(FrameworkElement host) + { + RegionDeclarationChange? change = null; + Action[] subscribers = Array.Empty>(); + + lock (SyncRoot) + { + RemoveDeadDeclarationsUnderLock(); + var declaration = FindDeclarationUnderLock(host); + if (declaration == null || !declaration.IsActive) // 容器已失效并被移除 或 容器不存在 + return; + + declaration.IsActive = false; + change = CreateChange(declaration, host, RegionDeclarationChangeKind.Remove); // 创建移除容器事件 + subscribers = GetLiveSubscribersUnderLock(); + } + + PublishChanges(new[] { change }, subscribers); // 发布事件 + } + #endregion + + /// + /// 订阅宿主容器Loaded 和 UnLoaded 事件 + /// + /// + private static void AttachLifecycleHandlers(FrameworkElement host) + { + host.Loaded += OnHostLoaded; + host.Unloaded += OnHostUnloaded; + } + + /// + /// 取消订阅宿主容器Loaded 和 UnLoaded 事件 + /// + /// + private static void DetachLifecycleHandlers(FrameworkElement host) + { + host.Loaded -= OnHostLoaded; + host.Unloaded -= OnHostUnloaded; + } + + private static void ValidateAttachedNameAvailability(FrameworkElement host, string regionName) + { + lock (SyncRoot) + { + RemoveDeadDeclarationsUnderLock(); + ValidateAttachedNameAvailabilityUnderLock(host, regionName); + } + } + + /// + /// 查询是否存在符合条件的宿主容器 + /// + /// 宿主容器对象 + /// Region name + /// 是否存在符合条件的宿主容器 + private static bool HasMatchingDeclaration(DependencyObject dependencyObject, string regionName) + { + if (dependencyObject is not FrameworkElement host) + return false; + + lock (SyncRoot) + { + var declaration = FindDeclarationUnderLock(host); + return declaration != null && + string.Equals(declaration.Name, regionName, StringComparison.Ordinal); + } + } + + /// + /// 在注册 Region name 发生异常时,回滚 Region name + /// + /// 预被注册的宿主容器 + /// 发生异常前的Region name + private static void RestoreDependencyPropertyValue(DependencyObject dependencyObject, object? oldValue) + { + if (oldValue == null || ReferenceEquals(oldValue, DependencyProperty.UnsetValue)) + { + dependencyObject.ClearValue(RegionNameProperty); + return; + } + + dependencyObject.SetValue(RegionNameProperty, oldValue); + } + + /// + /// 检查 Region name 与 预注册的宿主容器是否重复注册 + /// + /// 宿主容器 + /// Region name + /// 宿主容器重复注册或Region name已存在 + private static void ValidateAttachedNameAvailabilityUnderLock(FrameworkElement host, string regionName) + { + foreach (var declaration in Declarations) + { + if (!string.Equals(declaration.Name, regionName, StringComparison.Ordinal) + || !declaration.Host.TryGetTarget(out var existingHost) + || ReferenceEquals(existingHost, host)) + { + continue; + } + + throw new InvalidOperationException( + $"Region '{regionName}' is already declared on another host."); + } + } + + /// + /// 将附加对象转换为 FrameworkElement + /// + /// 需要转换的附加对象 + /// 转为为 FrameworkElement 的附加对象 + /// 转换失败抛出异常 + private static FrameworkElement GetRequiredFrameworkElement(DependencyObject obj) + { + if (obj is FrameworkElement host) + return host; + + throw new ArgumentException( + $"Region host type '{obj.GetType().FullName}' is not a FrameworkElement.", + nameof(obj)); + } + + /// + /// 校验 Region name 是否合法 + /// + private static void ValidateRegionName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) + { + throw new ArgumentException( + "Region name cannot be null, empty, or whitespace.", + nameof(regionName)); + } + } + + /// + /// 循环查找当前宿主容器是否已经存在并返回 + /// 当前程序集中存在 Content 宿主容器集 和 Page宿主容器集,他们互相独立,且Region name 可相同 + /// + /// 预被注册为宿主容器的控件(元素) + /// 返回已被注册为宿主容器的控件,如果未被注册,则返回 null + private static Declaration? FindDeclarationUnderLock(FrameworkElement host) + { + foreach (var declaration in Declarations) + { + if (declaration.Host.TryGetTarget(out var existingHost) && ReferenceEquals(existingHost, host)) + return declaration; + } + + return null; + } + + /// + /// 移除失效的宿主容器 + /// + private static void RemoveDeadDeclarationsUnderLock() + { + for (var index = Declarations.Count - 1; index >= 0; index--) + { + if (!Declarations[index].Host.TryGetTarget(out _)) + { + Declarations.RemoveAt(index); + } + } + } + + /// + /// 移除失效的订阅者 + /// + private static void RemoveDeadSubscribersUnderLock() + { + for (var index = Subscribers.Count - 1; index >= 0; index--) + { + if (!Subscribers[index].TryGetTarget(out _)) + { + Subscribers.RemoveAt(index); + } + } + } + + /// + /// 获取活跃(未取消注册)的订阅者 + /// + /// + private static Action[] GetLiveSubscribersUnderLock() + { + var liveSubscribers = new List>(Subscribers.Count); + + for (var index = 0; index < Subscribers.Count;) + { + if (Subscribers[index].TryGetTarget(out var subscriber)) + { + liveSubscribers.Add(subscriber); + index++; + } + else + { + Subscribers.RemoveAt(index); + } + } + + return liveSubscribers.ToArray(); + } + + /// + /// 生成下一个宿主容器的 Token(Id) + /// + /// 宿主容器Id + private static long GetNextActivationTokenUnderLock() => ++nextActivationToken; + + private static RegionDeclarationChange CreateChange( + Declaration declaration, + FrameworkElement host, + RegionDeclarationChangeKind kind) + { + return new RegionDeclarationChange( + declaration.Name, + host, + declaration.ActivationToken, + kind); + } + + private static void PublishChanges( + IEnumerable changes, + IReadOnlyList> subscribers) + { + ExceptionDispatchInfo? firstFailure = null; + + foreach (var change in changes) + { + foreach (var subscriber in subscribers) + { + try + { + subscriber(change); + } + catch (Exception exception) + { + firstFailure ??= ExceptionDispatchInfo.Capture(exception); + } + } + } + + firstFailure?.Throw(); + } + + /// + /// 宿主容器对象 + /// + private sealed class Declaration + { + public Declaration(WeakReference host, string name, bool isActive, long activationToken) + { + Host = host; + Name = name; + IsActive = isActive; + ActivationToken = activationToken; + } + + public WeakReference Host { get; } + + public string Name { get; set; } + + public bool IsActive { get; set; } + + public long ActivationToken { get; set; } + } + } + + /// + /// Region 发生变化时的类型 + /// + internal enum RegionDeclarationChangeKind + { + Add, + Remove, + } + + /// + /// Region发生变化时的对象 + /// + internal sealed class RegionDeclarationChange + { + public RegionDeclarationChange( + string name, + FrameworkElement host, + long activationToken, + RegionDeclarationChangeKind kind) + { + Name = name; + Host = host; + ActivationToken = activationToken; + Kind = kind; + } + + public string Name { get; } + + public FrameworkElement Host { get; } + + public long ActivationToken { get; } + + public RegionDeclarationChangeKind Kind { get; } + } +} + + diff --git a/Services/RegionService.cs b/Services/RegionService.cs deleted file mode 100644 index 34455ab..0000000 --- a/Services/RegionService.cs +++ /dev/null @@ -1,40 +0,0 @@ -锘縰sing System.Windows; -using System.Windows.Controls; - -namespace SimpleNavigation.Services -{ - /// - /// 缁存姢鍖哄煙鍔熻兘 - /// - public static class RegionService - { - // 鍏佽鍦╔AML涓娇鐢≧egionService娉ㄥ唽瀵艰埅鍖哄煙锛屾敞鍐屽畬鎴愬悗浼氳Е鍙浜嬩欢锛屽鑸湇鍔′細璁㈤槄璇ヤ簨浠朵互瀹屾垚鍖哄煙鐨勬敞鍐屻 - public static event Action? RegionRegisted; - - public static string GetRegionName(DependencyObject obj) - { - return (string)obj.GetValue(RegionNameProperty); - } - - public static void SetRegionName(DependencyObject obj, string value) - { - obj.SetValue(RegionNameProperty, value); - } - - // 浣跨敤闄勫姞灞炴х殑鏂瑰紡鍏佽鍦╔AML涓负鎺т欢鎸囧畾RegionName锛孯egionService浼氱洃鍚灞炴х殑鍙樺寲浠ュ畬鎴愬鑸尯鍩熺殑娉ㄥ唽銆 - public static readonly DependencyProperty RegionNameProperty = - DependencyProperty.RegisterAttached("RegionName", typeof(string), typeof(RegionService), new PropertyMetadata("", OnRegionNameChanged)); - - - private static void OnRegionNameChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is not Frame frame) throw new InvalidOperationException("RegionName can only be attached to Frame controls."); - - var regionName = e.NewValue as string; - - if (string.IsNullOrWhiteSpace(regionName)) throw new ArgumentException("Region name cannot be null or whitespace.", nameof(regionName)); - - RegionRegisted?.Invoke(regionName, frame); - } - } -} diff --git a/SimpleNavigation.csproj b/SimpleNavigation.csproj index 99a88a9..6b4a25c 100644 --- a/SimpleNavigation.csproj +++ b/SimpleNavigation.csproj @@ -8,9 +8,10 @@ 13 true Junevy.SimpleNavigation - 1.0.1 + 2.3.1 Junevy Recommand to use it in conjunction with Communication.Toolkit.Mvvm. The package copywriting for Prism. + $(DefaultItemExcludesInProjectFolder);Tests/** diff --git a/SimpleNavigation.sln b/SimpleNavigation.sln index 48ccf79..5ba1bd4 100644 --- a/SimpleNavigation.sln +++ b/SimpleNavigation.sln @@ -5,6 +5,10 @@ VisualStudioVersion = 17.14.36401.2 d17.14 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleNavigation", "SimpleNavigation.csproj", "{78216B02-64FB-4739-A012-3422014F0B26}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleNavigation.Tests", "Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj", "{BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,10 +19,17 @@ Global {78216B02-64FB-4739-A012-3422014F0B26}.Debug|Any CPU.Build.0 = Debug|Any CPU {78216B02-64FB-4739-A012-3422014F0B26}.Release|Any CPU.ActiveCfg = Release|Any CPU {78216B02-64FB-4739-A012-3422014F0B26}.Release|Any CPU.Build.0 = Release|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {BCFBF2DD-8CDD-49E5-80AB-2537B6698EAC} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {BCCE3619-D9CD-41B6-99FD-C9B8BA074CD4} EndGlobalSection diff --git a/Tests/SimpleNavigation.Tests/ContentServiceTests.cs b/Tests/SimpleNavigation.Tests/ContentServiceTests.cs new file mode 100644 index 0000000..835513d --- /dev/null +++ b/Tests/SimpleNavigation.Tests/ContentServiceTests.cs @@ -0,0 +1,341 @@ +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Extensions; +using SimpleNavigation.Interface.Awares; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Tests; + +public class ContentServiceTests +{ + [Fact] + public void ContentHostAdapterContractAcceptsAnyFrameworkElementHost() + { + var adapterType = typeof(IContentService).Assembly.GetType( + "SimpleNavigation.Common.IContentRegionHostAdapter", + throwOnError: true)!; + var present = adapterType.GetMethod("Present"); + + Assert.NotNull(present); + Assert.Equal( + typeof(FrameworkElement), + present!.GetParameters()[0].ParameterType); + } + + [Fact] + public void GenericAndTypeNavigationDoNotRequireRoutes() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + var service = provider.GetRequiredService(); + + service.Navigate("main"); + Assert.IsType(host.Content); + + service.Navigate("main", typeof(Grid)); + Assert.IsType(host.Content); + }); + } + + [Fact] + public void StringNavigationUsesCaseSensitiveRouteThenOrdinaryDi() + { + StaTest.Run(() => + { + var expected = new TestContent(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(expected); + services.AddSingletonContent("content"); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + var service = provider.GetRequiredService(); + + service.Navigate("main", "content"); + + Assert.Same(expected, host.Content); + Assert.Throws(() => service.Navigate("main", "Content")); + }); + } + + [Fact] + public void ViewThenDistinctDataContextReceiveTheSameParameters() + { + StaTest.Run(() => + { + var calls = new List(); + var viewModel = new RecordingAware("context", calls); + var content = new RecordingAwareContent("view", calls) + { + DataContext = viewModel, + }; + var parameters = new DialogParameters("id", 9); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(content); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + + provider.GetRequiredService() + .Navigate("main", parameters); + + Assert.Equal(new[] { "view", "context" }, calls); + Assert.Same(parameters, content.Parameters); + Assert.Same(parameters, viewModel.Parameters); + Assert.Same(content, host.Content); + }); + } + + [Fact] + public void NullParametersStillNotifyAndSameInstanceIsDeduplicated() + { + StaTest.Run(() => + { + var content = new AwareContent(); + content.DataContext = content; + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(content); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + + provider.GetRequiredService() + .Navigate("main"); + + Assert.Equal(1, content.CallCount); + Assert.Null(content.Parameters); + Assert.Same(content, host.Content); + }); + } + + [Fact] + public void InvalidInputsAndUnsupportedTargetsFailFast() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var frame = new Frame(); + provider.GetRequiredService().RegisterRegion("frame", frame); + var service = provider.GetRequiredService(); + + Assert.Throws(() => service.Navigate(" ")); + Assert.Throws(() => service.Navigate("frame", (Type)null!)); + Assert.Throws(() => service.Navigate("frame", typeof(string))); + Assert.Throws(() => service.Navigate("frame")); + Assert.Throws(() => service.Navigate("frame", typeof(Window))); + var exception = Assert.Throws( + () => service.Navigate("frame")); + Assert.Contains("frame", exception.Message); + Assert.Contains(typeof(Frame).FullName!, exception.Message); + Assert.Contains("non-Frame ContentControl", exception.Message); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void MissingRegionUnknownKeyAndMissingDiFailFast() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + var service = provider.GetRequiredService(); + + var missingRegionException = Assert.Throws( + () => service.Navigate("missing")); + Assert.Contains("Region 'missing'", missingRegionException.Message); + Assert.Contains("was 'missing'", missingRegionException.Message); + Assert.Contains("non-Frame ContentControl", missingRegionException.Message); + Assert.Throws( + () => service.Navigate("main", "unknown")); + var exception = Assert.Throws( + () => service.Navigate("main", typeof(Grid))); + Assert.Contains(typeof(Grid).FullName!, exception.Message); + GC.KeepAlive(host); + }); + } + + [Fact] + public void DoubleGenericRegistrationWithoutKeyDoesNotCreateARoute() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingletonContent(); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + + Assert.Throws(() => + provider.GetRequiredService() + .Navigate("main", "content")); + GC.KeepAlive(host); + }); + } + + [Fact] + public void AwarenessExceptionPropagatesAfterPresentation() + { + StaTest.Run(() => + { + var content = new ThrowingAwareContent(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(content); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + + var exception = Assert.Throws(() => + provider.GetRequiredService() + .Navigate("main")); + + Assert.Equal("awareness failed", exception.Message); + Assert.Same(content, host.Content); + }); + } + + [Fact] + public void HostPresentationExceptionPropagatesWithoutNotifyingAwareness() + { + StaTest.Run(() => + { + var content = new AwareContent(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(content); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost( + provider, + "main", + new ThrowingContentControl()); + var regionManager = provider.GetRequiredService(); + + try + { + var exception = Assert.Throws(() => + provider.GetRequiredService() + .Navigate("main")); + + Assert.Equal("presentation failed", exception.Message); + Assert.Equal(0, content.CallCount); + } + finally + { + regionManager.UnregisterRegion("main", host); + } + }); + } + + [Fact] + public void NavigationRequiresTheHostDispatcherThread() + { + ServiceProvider? provider = null; + IContentService? service = null; + ContentControl? host = null; + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(new TestContent()); + provider = services.BuildServiceProvider(); + host = RegisterContentHost(provider, "main"); + service = provider.GetRequiredService(); + }); + + try + { + Assert.Throws(() => + service!.Navigate("main")); + GC.KeepAlive(host); + } + finally + { + provider!.Dispose(); + } + } + + private static ContentControl RegisterContentHost( + IServiceProvider provider, + string name) + { + return RegisterContentHost(provider, name, new ContentControl()); + } + + private static TContentControl RegisterContentHost( + IServiceProvider provider, + string name, + TContentControl host) + where TContentControl : ContentControl + { + provider.GetRequiredService().RegisterRegion(name, host); + return host; + } + + private sealed class ThrowingContentControl : ContentControl + { + protected override void OnContentChanged( + object oldContent, + object newContent) + { + throw new InvalidOperationException("presentation failed"); + } + } + + private sealed class RecordingAwareContent : UserControl, INavigationAware + { + private readonly string name; + private readonly IList calls; + + public RecordingAwareContent(string name, IList calls) + { + this.name = name; + this.calls = calls; + } + + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + calls.Add(name); + Parameters = parameters; + } + } + + private sealed class RecordingAware : INavigationAware + { + private readonly string name; + private readonly IList calls; + + public RecordingAware(string name, IList calls) + { + this.name = name; + this.calls = calls; + } + + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + calls.Add(name); + Parameters = parameters; + } + } +} diff --git a/Tests/SimpleNavigation.Tests/DialogManagerTests.cs b/Tests/SimpleNavigation.Tests/DialogManagerTests.cs new file mode 100644 index 0000000..0bd5d5d --- /dev/null +++ b/Tests/SimpleNavigation.Tests/DialogManagerTests.cs @@ -0,0 +1,598 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Windows; +using System.Windows.Threading; +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common.Managers; +using SimpleNavigation.Tests.TestInfrastructure; + +namespace SimpleNavigation.Tests; + +public sealed class DialogManagerTests +{ + [Fact] + public void GetExistingWindow_BeforeCreation_ReturnsNullWithoutResolvingService() + { + StaTest.Run(() => + { + var resolutionCount = 0; + using var provider = new ServiceCollection() + .AddTransient(_ => + { + resolutionCount++; + return new ManagedWindow(); + }) + .BuildServiceProvider(); + var manager = new DialogManager(provider); + + Assert.Null(manager.GetExistingWindow(typeof(ManagedWindow))); + Assert.Equal(0, resolutionCount); + }); + } + + [Fact] + public void GetOrCreateWindow_LiveWindow_ResolvesOnceAndReusesExactInstance() + { + StaTest.Run(() => + { + var resolutionCount = 0; + using var provider = new ServiceCollection() + .AddTransient(_ => + { + resolutionCount++; + return new ManagedWindow(); + }) + .BuildServiceProvider(); + var manager = new DialogManager(provider); + + var first = manager.GetOrCreateWindow(typeof(ManagedWindow)); + var second = manager.GetOrCreateWindow(typeof(ManagedWindow)); + + Assert.IsType(first); + Assert.Same(first, second); + Assert.Same(first, manager.GetExistingWindow(typeof(ManagedWindow))); + Assert.Same(first, manager.GetDialogWindow()); + Assert.Equal(1, resolutionCount); + + first.Close(); + }); + } + + [Fact] + public void GetOrCreateWindow_AfterShownWindowCloses_ResolvesNewTransientInstance() + { + StaTest.Run(() => + { + var resolutionCount = 0; + using var provider = new ServiceCollection() + .AddTransient(_ => + { + resolutionCount++; + return new ManagedWindow(); + }) + .BuildServiceProvider(); + var manager = new DialogManager(provider); + var first = manager.GetOrCreateWindow(typeof(ManagedWindow)); + + first.Show(); + StaTest.PumpDispatcher(); + first.Close(); + StaTest.PumpDispatcher(); + + Assert.Null(manager.GetExistingWindow(typeof(ManagedWindow))); + + var second = manager.GetOrCreateWindow(typeof(ManagedWindow)); + + Assert.NotSame(first, second); + Assert.Equal(2, resolutionCount); + second.Close(); + }); + } + + [Fact] + public void ClosedNotification_FromOlderWindow_DoesNotRemoveNewerReplacement() + { + StaTest.Run(() => + { + using var provider = new ServiceCollection() + .AddTransient() + .BuildServiceProvider(); + var manager = new DialogManager(provider); + var first = manager.GetOrCreateWindow(typeof(ManagedWindow)); + + first.Show(); + StaTest.PumpDispatcher(); + first.Close(); + StaTest.PumpDispatcher(); + var replacement = manager.GetOrCreateWindow(typeof(ManagedWindow)); + + var closeCallback = typeof(DialogManager).GetMethod( + "OnWindowClosed", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(closeCallback); + closeCallback!.Invoke(manager, new object?[] { first, EventArgs.Empty }); + + Assert.Same(replacement, manager.GetExistingWindow(typeof(ManagedWindow))); + replacement.Close(); + }); + } + + [Fact] + public void WindowType_Null_ThrowsArgumentNullException() + { + using var provider = new ServiceCollection().BuildServiceProvider(); + var manager = new DialogManager(provider); + + Assert.Equal( + "windowType", + Assert.Throws(() => manager.GetExistingWindow(null!)).ParamName); + Assert.Equal( + "windowType", + Assert.Throws(() => manager.GetOrCreateWindow(null!)).ParamName); + } + + [Fact] + public void WindowType_NonWindow_ThrowsArgumentException() + { + using var provider = new ServiceCollection().BuildServiceProvider(); + var manager = new DialogManager(provider); + + Assert.Equal( + "windowType", + Assert.Throws(() => manager.GetExistingWindow(typeof(string))).ParamName); + Assert.Equal( + "windowType", + Assert.Throws(() => manager.GetOrCreateWindow(typeof(string))).ParamName); + } + + [Fact] + public void GetOrCreateWindow_MissingRegistration_PreservesGetRequiredServiceException() + { + using var provider = new ServiceCollection().BuildServiceProvider(); + var manager = new DialogManager(provider); + + var exception = Assert.Throws( + () => manager.GetOrCreateWindow(typeof(ManagedWindow))); + + Assert.Contains(typeof(ManagedWindow).ToString(), exception.Message); + } + + [Fact] + public void GetOrCreateWindow_ProviderReturnsNonWindow_ThrowsClearInvalidOperationException() + { + var manager = new DialogManager(new WrongTypeServiceProvider()); + + var exception = Assert.Throws( + () => manager.GetOrCreateWindow(typeof(ManagedWindow))); + + Assert.Contains(typeof(ManagedWindow).FullName!, exception.Message); + Assert.Contains(typeof(string).FullName!, exception.Message); + } + + [Fact] + public void GetOrCreateWindow_SameTypeFactoryReentry_ThrowsAndCanRetry() + { + StaTest.Run(() => + { + DialogManager? manager = null; + var resolutionCount = 0; + using var provider = new ServiceCollection() + .AddTransient(_ => + { + resolutionCount++; + if (resolutionCount == 1) + { + return (ReentrantWindow)manager!.GetOrCreateWindow(typeof(ReentrantWindow)); + } + + return new ReentrantWindow(); + }) + .BuildServiceProvider(); + manager = new DialogManager(provider); + + var exception = Assert.Throws( + () => manager.GetOrCreateWindow(typeof(ReentrantWindow))); + + Assert.Contains(typeof(ReentrantWindow).FullName!, exception.Message); + Assert.Contains("already being resolved", exception.Message); + Assert.Equal(1, resolutionCount); + Assert.Null(manager.GetExistingWindow(typeof(ReentrantWindow))); + + var retry = manager.GetOrCreateWindow(typeof(ReentrantWindow)); + + Assert.IsType(retry); + Assert.Equal(2, resolutionCount); + retry.Close(); + }); + } + + [Fact] + public void GetOrCreateWindow_BlockedType_DoesNotBlockUnrelatedOperationsAndResolvesOnce() + { + StaTest.Run(() => + { + using var provider = new MultiTypeCoordinatedServiceProvider(); + var manager = new DialogManager(provider); + Window? ownerResult = null; + Window? waiterResult = null; + Window? existingResult = null; + Window? unrelatedResult = null; + Exception? ownerException = null; + Exception? waiterException = null; + Exception? existingException = null; + Exception? unrelatedException = null; + using var waiterStarted = new ManualResetEventSlim(); + using var existingCompleted = new ManualResetEventSlim(); + using var unrelatedCompleted = new ManualResetEventSlim(); + using var ownerMayClose = new ManualResetEventSlim(); + + var ownerThread = CreateStaThread(() => + { + try + { + ownerResult = manager.GetOrCreateWindow(typeof(FirstWindow)); + ownerMayClose.Wait(TimeSpan.FromSeconds(10)); + ownerResult.Close(); + } + catch (Exception exception) + { + ownerException = exception; + } + }); + var waiterThread = CreateStaThread(() => + { + waiterStarted.Set(); + try + { + waiterResult = manager.GetOrCreateWindow(typeof(FirstWindow)); + } + catch (Exception exception) + { + waiterException = exception; + } + }); + var existingThread = CreateStaThread(() => + { + try + { + existingResult = manager.GetExistingWindow(typeof(FirstWindow)); + } + catch (Exception exception) + { + existingException = exception; + } + finally + { + existingCompleted.Set(); + } + }); + var unrelatedThread = CreateStaThread(() => + { + try + { + unrelatedResult = manager.GetOrCreateWindow(typeof(SecondWindow)); + unrelatedResult.Close(); + } + catch (Exception exception) + { + unrelatedException = exception; + } + finally + { + unrelatedCompleted.Set(); + } + }); + + var existingMadeProgress = false; + var unrelatedMadeProgress = false; + ownerThread.Start(); + try + { + Assert.True(provider.FirstResolutionEntered.Wait(TimeSpan.FromSeconds(5))); + waiterThread.Start(); + Assert.True(waiterStarted.Wait(TimeSpan.FromSeconds(5))); + Assert.True( + SpinWait.SpinUntil( + () => (waiterThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(5)), + "The same-type caller did not wait for the in-flight resolution."); + + existingThread.Start(); + unrelatedThread.Start(); + existingMadeProgress = existingCompleted.Wait(TimeSpan.FromSeconds(2)); + unrelatedMadeProgress = unrelatedCompleted.Wait(TimeSpan.FromSeconds(2)); + } + finally + { + provider.ReleaseFirstResolution.Set(); + JoinIfStarted(waiterThread); + JoinIfStarted(existingThread); + JoinIfStarted(unrelatedThread); + ownerMayClose.Set(); + JoinIfStarted(ownerThread); + } + + Assert.True(existingMadeProgress, "GetExistingWindow blocked behind unrelated DI resolution."); + Assert.True(unrelatedMadeProgress, "A different window type blocked behind unrelated DI resolution."); + Assert.Null(ownerException); + Assert.Null(waiterException); + Assert.Null(existingException); + Assert.Null(unrelatedException); + Assert.Null(existingResult); + Assert.Equal(1, provider.FirstResolutionCount); + Assert.NotNull(ownerResult); + Assert.Same(ownerResult, waiterResult); + Assert.IsType(unrelatedResult); + GC.KeepAlive(ownerResult); + }); + } + + [Fact] + public void GetOrCreateWindow_FailedResolution_WakesWaitersAndLaterCallRetries() + { + StaTest.Run(() => + { + using var provider = new FailingThenSuccessfulServiceProvider(); + var manager = new DialogManager(provider); + Exception? ownerException = null; + Exception? waiterException = null; + Window? waiterResult = null; + using var waiterStarted = new ManualResetEventSlim(); + + var ownerThread = CreateStaThread(() => + { + try + { + manager.GetOrCreateWindow(typeof(FailureWindow)); + } + catch (Exception exception) + { + ownerException = exception; + } + }); + var waiterThread = CreateStaThread(() => + { + waiterStarted.Set(); + try + { + waiterResult = manager.GetOrCreateWindow(typeof(FailureWindow)); + } + catch (Exception exception) + { + waiterException = exception; + } + finally + { + if (waiterResult != null) + { + waiterResult.Close(); + } + } + }); + + ownerThread.Start(); + try + { + Assert.True(provider.FirstResolutionEntered.Wait(TimeSpan.FromSeconds(5))); + waiterThread.Start(); + Assert.True(waiterStarted.Wait(TimeSpan.FromSeconds(5))); + Assert.True( + SpinWait.SpinUntil( + () => (waiterThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(5)), + "The waiter did not wait for the failing in-flight resolution."); + } + finally + { + provider.ReleaseFirstResolution.Set(); + JoinIfStarted(ownerThread); + JoinIfStarted(waiterThread); + } + + Assert.Same(provider.ExpectedFailure, ownerException); + Assert.Same(provider.ExpectedFailure, waiterException); + Assert.Null(waiterResult); + Assert.Equal(1, provider.ResolutionCount); + + var retry = manager.GetOrCreateWindow(typeof(FailureWindow)); + + Assert.IsType(retry); + Assert.Equal(2, provider.ResolutionCount); + retry.Close(); + }); + } + + [Fact] + public void GetOrCreateWindow_RegistrationReturnsDifferentWindowType_ThrowsWithoutCaching() + { + StaTest.Run(() => + { + using var provider = new ServiceCollection() + .AddTransient(typeof(FirstWindow), _ => new SecondWindow()) + .BuildServiceProvider(); + var manager = new DialogManager(provider); + + var exception = Assert.Throws( + () => manager.GetOrCreateWindow(typeof(FirstWindow))); + + Assert.Contains(typeof(FirstWindow).FullName!, exception.Message); + Assert.Contains(typeof(SecondWindow).FullName!, exception.Message); + Assert.Null(manager.GetExistingWindow(typeof(FirstWindow))); + }); + } + + [Fact] + public void ClosedWindow_RetainedByCaller_DoesNotKeepManagerAlive() + { + StaTest.Run(() => + { + var retainedWindow = new ManagedWindow(); + var weakManager = CreateAndCloseManager(retainedWindow); + + ForceGarbageCollection(weakManager); + + Assert.False(weakManager.IsAlive); + GC.KeepAlive(retainedWindow); + }); + } + + [Fact] + public void JoinIfStarted_UnstartedBackgroundThread_DoesNothing() + { + var thread = CreateStaThread(() => { }); + + JoinIfStarted(thread); + + Assert.True((thread.ThreadState & ThreadState.Unstarted) != 0); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CreateAndCloseManager(ManagedWindow window) + { + var manager = new DialogManager(new FixedWindowServiceProvider(window)); + Assert.Same(window, manager.GetOrCreateWindow(typeof(ManagedWindow))); + + window.Show(); + StaTest.PumpDispatcher(); + window.Close(); + StaTest.PumpDispatcher(); + + return new WeakReference(manager); + } + + private static void ForceGarbageCollection(WeakReference weakReference) + { + const int attemptLimit = 10; + + for (var attempt = 0; attempt < attemptLimit && weakReference.IsAlive; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } + + private static Thread CreateStaThread(ThreadStart action) + { + var thread = new Thread(() => + { + try + { + action(); + } + finally + { + Dispatcher.CurrentDispatcher.InvokeShutdown(); + } + }) + { + IsBackground = true, + }; + thread.SetApartmentState(ApartmentState.STA); + return thread; + } + + private static void JoinIfStarted(Thread thread) + { + if ((thread.ThreadState & ThreadState.Unstarted) == 0) + { + Assert.True(thread.Join(TimeSpan.FromSeconds(10)), "An STA worker did not finish."); + } + } + + private sealed class ManagedWindow : Window + { + } + + private sealed class ReentrantWindow : Window + { + } + + private sealed class FailureWindow : Window + { + } + + private sealed class WrongTypeServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => "not a window"; + } + + private sealed class FixedWindowServiceProvider : IServiceProvider + { + private readonly Window window; + + public FixedWindowServiceProvider(Window window) + { + this.window = window; + } + + public object? GetService(Type serviceType) => window; + } + + private sealed class MultiTypeCoordinatedServiceProvider : IServiceProvider, IDisposable + { + public readonly ManualResetEventSlim FirstResolutionEntered = new(); + public readonly ManualResetEventSlim ReleaseFirstResolution = new(); + public int FirstResolutionCount; + + public object? GetService(Type serviceType) + { + if (serviceType == typeof(FirstWindow)) + { + Interlocked.Increment(ref FirstResolutionCount); + FirstResolutionEntered.Set(); + if (!ReleaseFirstResolution.Wait(TimeSpan.FromSeconds(15))) + { + throw new TimeoutException("The first resolution was not released by the test."); + } + + return new FirstWindow(); + } + + if (serviceType == typeof(SecondWindow)) + { + return new SecondWindow(); + } + + return null; + } + + public void Dispose() + { + FirstResolutionEntered.Dispose(); + ReleaseFirstResolution.Dispose(); + } + } + + private sealed class FailingThenSuccessfulServiceProvider : IServiceProvider, IDisposable + { + public readonly ManualResetEventSlim FirstResolutionEntered = new(); + public readonly ManualResetEventSlim ReleaseFirstResolution = new(); + public readonly InvalidOperationException ExpectedFailure = new("expected resolution failure"); + public int ResolutionCount; + + public object? GetService(Type serviceType) + { + var resolution = Interlocked.Increment(ref ResolutionCount); + if (resolution == 1) + { + FirstResolutionEntered.Set(); + if (!ReleaseFirstResolution.Wait(TimeSpan.FromSeconds(15))) + { + throw new TimeoutException("The failing resolution was not released by the test."); + } + + throw ExpectedFailure; + } + + return new FailureWindow(); + } + + public void Dispose() + { + FirstResolutionEntered.Dispose(); + ReleaseFirstResolution.Dispose(); + } + } +} diff --git a/Tests/SimpleNavigation.Tests/DialogServiceTests.cs b/Tests/SimpleNavigation.Tests/DialogServiceTests.cs new file mode 100644 index 0000000..b9c7acf --- /dev/null +++ b/Tests/SimpleNavigation.Tests/DialogServiceTests.cs @@ -0,0 +1,1066 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Windows; +using System.Windows.Threading; +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Extensions; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; +using SimpleNavigation.Services; +using SimpleNavigation.Tests.TestInfrastructure; + +namespace SimpleNavigation.Tests; + +public sealed class DialogServiceTests +{ + [Fact] + public void Show_GenericTypeAndKey_ResolveOrdinaryDiAndPresentWindows() + { + StaTest.Run(() => + { + var firstCount = 0; + var secondCount = 0; + FirstWindow? currentFirst = null; + SecondWindow? currentSecond = null; + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(_ => + { + firstCount++; + currentFirst = new FirstWindow(); + return currentFirst; + }); + services.AddTransient(_ => + { + secondCount++; + currentSecond = new SecondWindow(); + return currentSecond; + }); + services.AddWindow("second"); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + service.Show(); + Assert.NotNull(currentFirst); + Assert.True(currentFirst!.IsVisible); + currentFirst.Close(); + + service.Show(typeof(FirstWindow)); + Assert.NotNull(currentFirst); + Assert.True(currentFirst!.IsVisible); + currentFirst.Close(); + + service.Show("second"); + + Assert.NotNull(currentSecond); + Assert.True(currentSecond!.IsVisible); + Assert.Equal(2, firstCount); + Assert.Equal(1, secondCount); + currentSecond.Close(); + }); + } + + [Fact] + public void Show_KeyIsCaseSensitiveAndUnknownKeyFailsBeforeDiResolution() + { + StaTest.Run(() => + { + var resolutionCount = 0; + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddWindow("main"); + services.AddTransient(_ => + { + resolutionCount++; + return new FirstWindow(); + }); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + Assert.Throws(() => service.Show("Main")); + Assert.Throws(() => service.Show("missing")); + Assert.Equal(0, resolutionCount); + }); + } + + [Fact] + public void Show_ReusesVisibleWindowThenCreatesNewTransientAfterClosed() + { + StaTest.Run(() => + { + var instances = new List(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(_ => + { + var window = new ReuseWindow(); + instances.Add(window); + return window; + }); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + service.Show(); + service.Show(typeof(ReuseWindow)); + + Assert.Single(instances); + Assert.True(instances[0].IsVisible); + instances[0].Close(); + + service.Show(); + + Assert.Equal(2, instances.Count); + Assert.NotSame(instances[0], instances[1]); + instances[1].Close(); + }); + } + + [Fact] + public void Show_NotifiesWindowThenDistinctDataContextAndDeduplicatesSameReference() + { + StaTest.Run(() => + { + var calls = new List(); + var viewModel = new AwareDialogViewModel(calls); + var window = new AwareWindow(calls) { DataContext = viewModel }; + var parameters = new DialogParameters("answer", 42); + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + + service.Show(parameters); + + Assert.Equal(new[] { "window", "view-model" }, calls); + Assert.Same(parameters, window.Parameters); + Assert.Same(parameters, viewModel.Parameters); + Assert.NotNull(window.RequestClose); + Assert.NotNull(viewModel.RequestClose); + viewModel.RequestClose!(null); + Assert.False(window.IsVisible); + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + + var selfContext = new AwareWindow { DataContext = null }; + selfContext.DataContext = selfContext; + using var selfProvider = BuildProvider(services => services.AddSingleton(selfContext)); + selfProvider.GetRequiredService().Show(); + + Assert.Equal(1, selfContext.CallCount); + Assert.Null(selfContext.Parameters); + selfContext.Close(); + }); + } + + [Fact] + public void Show_AwarenessExceptionRollsBackNewSubscriptionImmediately() + { + StaTest.Run(() => + { + var viewModel = new ThrowingAwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + + var exception = Assert.Throws(() => + provider.GetRequiredService().Show()); + + Assert.Equal("dialog awareness failed", exception.Message); + Assert.False(window.IsVisible); + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + + [Fact] + public void Show_AwarenessFailureRestoresPriorLiveSubscription() + { + StaTest.Run(() => + { + var viewModel = new ToggleThrowAwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + var windowCallback = window.RequestClose; + var viewModelCallback = viewModel.RequestClose; + viewModel.ThrowOnNavigated = true; + + Assert.Throws(() => service.Show()); + + Assert.Same(windowCallback, window.RequestClose); + Assert.Same(viewModelCallback, viewModel.RequestClose); + Assert.Equal(1, GetNonModalSubscriptionCount(provider)); + windowCallback!(null); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + + [Fact] + public void Show_RollbackRestoresPriorOnlyWithoutOverwritingApplicationCallback() + { + StaTest.Run(() => + { + var viewModel = new ReplacingThrowingAwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + var priorWindowCallback = window.RequestClose; + var priorViewModelCallback = viewModel.RequestClose; + viewModel.ReplaceAndThrow = true; + + Assert.Throws(() => service.Show()); + + Assert.Same(priorWindowCallback, window.RequestClose); + Assert.NotSame(priorViewModelCallback, viewModel.RequestClose); + Assert.Same(viewModel.Replacement, viewModel.RequestClose); + Assert.Equal(1, GetNonModalSubscriptionCount(provider)); + priorWindowCallback!(null); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + Assert.Same(viewModel.Replacement, viewModel.RequestClose); + }); + } + + [Fact] + public void Show_ThrowingRequestCloseSetterRollsBackEarlierTargets() + { + StaTest.Run(() => + { + var viewModel = new ThrowingRequestCloseAwareViewModel + { + ThrowOnEveryNonNullSet = true, + }; + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + + var exception = Assert.Throws(() => + provider.GetRequiredService().Show()); + + Assert.Equal("request close setter failed", exception.Message); + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + window.Close(); + }); + } + + [Fact] + public void Show_ThrowingRequestCloseSetterRestoresPriorSubscription() + { + StaTest.Run(() => + { + var viewModel = new ThrowingRequestCloseAwareViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + var priorWindowCallback = window.RequestClose; + var priorViewModelCallback = viewModel.RequestClose; + window.Hide(); + viewModel.ThrowOnNextNonNullSet = true; + + var exception = Assert.Throws(() => + service.Show()); + + Assert.Equal("request close setter failed", exception.Message); + Assert.Same(priorWindowCallback, window.RequestClose); + Assert.Same(priorViewModelCallback, viewModel.RequestClose); + Assert.Equal(1, GetNonModalSubscriptionCount(provider)); + priorWindowCallback!(null); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + + [Fact] + public void Show_PriorRestoreSetterFailurePreservesOriginalExceptionAndCleansPartialRestore() + { + StaTest.Run(() => + { + var viewModel = new ThrowingRequestCloseAwareViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + window.Hide(); + viewModel.ThrowOnNavigatedAndArmNextNonNullSet = true; + + var exception = Assert.Throws(() => + service.Show()); + + Assert.Equal("dialog awareness failed", exception.Message); + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + window.Close(); + }); + } + + [Fact] + public void Show_NonModalSubscriptionsUseExplicitReferenceIdentityComparer() + { + StaTest.Run(() => + { + using var provider = BuildProvider(); + var service = Assert.IsType( + provider.GetRequiredService()); + var field = typeof(DialogService).GetField( + "nonModalSubscriptions", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + var subscriptions = field!.GetValue(service); + Assert.NotNull(subscriptions); + var comparer = subscriptions!.GetType() + .GetProperty("Comparer")! + .GetValue(subscriptions); + + var referenceComparer = Assert.IsAssignableFrom>(comparer); + var first = new Window(); + var second = new Window(); + Assert.Equal("WindowReferenceComparer", comparer!.GetType().Name); + Assert.True(referenceComparer.Equals(first, first)); + Assert.False(referenceComparer.Equals(first, second)); + Assert.Equal(RuntimeHelpers.GetHashCode(first), referenceComparer.GetHashCode(first)); + first.Close(); + second.Close(); + }); + } + + [Fact] + public void Show_RequestCloseSetterSynchronousCloseStopsRemainingCallbackInstallation() + { + StaTest.Run(() => + { + var viewModel = new AwareDialogViewModel(); + var window = new ClosingRequestCloseAwareWindow + { + CloseOnNonNullSet = true, + DataContext = viewModel, + }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + + provider.GetRequiredService() + .Show(); + + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + Assert.Null(provider.GetRequiredService() + .GetExistingWindow(typeof(ClosingRequestCloseAwareWindow))); + }); + } + + [Fact] + public void Show_NormalClosedCleanupContinuesAndPropagatesFirstCallbackException() + { + StaTest.Run(() => + { + var viewModel = new AwareDialogViewModel(); + var window = new ThrowingClearAwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + provider.GetRequiredService() + .Show(); + window.ThrowOnNullSet = true; + var closedHandler = GetNonModalClosedHandler(provider, window); + + var exception = Assert.Throws(() => + closedHandler(window, EventArgs.Empty)); + + Assert.Equal("request close cleanup failed", exception.Message); + Assert.NotNull(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + window.ThrowOnNullSet = false; + window.RequestClose = null; + window.Close(); + }); + } + + [Fact] + public void ShowDialog_NormalFinallyCleanupContinuesAndPropagatesFirstCallbackException() + { + StaTest.Run(() => + { + var viewModel = new AwareDialogViewModel(); + var window = new ThrowingClearAwareWindow + { + DataContext = viewModel, + ThrowOnNullSet = true, + }; + window.Dispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(window.Close)); + using var provider = BuildProvider(services => services.AddSingleton(window)); + + var exception = Assert.Throws(() => + provider.GetRequiredService() + .ShowDialog()); + + Assert.Equal("request close cleanup failed", exception.Message); + Assert.NotNull(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Null(provider.GetRequiredService() + .GetExistingWindow(typeof(ThrowingClearAwareWindow))); + window.ThrowOnNullSet = false; + window.RequestClose = null; + }); + } + + [Fact] + public void Show_PriorCleanupSetterSynchronousCloseDoesNotAttachToClosedWindow() + { + StaTest.Run(() => + { + var viewModel = new CleanupActionAwareViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + viewModel.CleanupAction = window.Close; + + service.Show(); + + Assert.Null(provider.GetRequiredService() + .GetExistingWindow(typeof(AwareWindow))); + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + + [Fact] + public void Show_PriorCleanupSetterReentrantShowPreservesReentrantSubscription() + { + StaTest.Run(() => + { + var viewModel = new CleanupActionAwareViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + Action? reentrantCallback = null; + viewModel.CleanupAction = () => + { + service.Show(); + reentrantCallback = window.RequestClose; + }; + + service.Show(); + + Assert.NotNull(reentrantCallback); + Assert.Same(reentrantCallback, window.RequestClose); + Assert.Same(reentrantCallback, viewModel.RequestClose); + Assert.Equal(1, GetNonModalSubscriptionCount(provider)); + reentrantCallback!(null); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + + [Fact] + public void Show_ReentrantSuccessfulReplacementSurvivesOuterRollback() + { + StaTest.Run(() => + { + var viewModel = new ThrowingAwareDialogViewModel(); + var window = new OneShotReentrantAwareWindow(); + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + window.Hide(); + + Action? replacementCallback = null; + window.DataContext = viewModel; + window.NavigatedAction = () => + { + window.DataContext = null; + service.Show(); + replacementCallback = window.RequestClose; + window.DataContext = viewModel; + }; + + Assert.Throws(() => + service.Show()); + + Assert.NotNull(replacementCallback); + Assert.Same(replacementCallback, window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(1, GetNonModalSubscriptionCount(provider)); + replacementCallback!(null); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + + [Fact] + public void Show_PresentationFailureRollsBackNewSubscriptionImmediately() + { + StaTest.Run(() => + { + var viewModel = new AwareDialogViewModel(); + var window = new ThrowingPresentationWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + + var exception = Assert.Throws(() => + provider.GetRequiredService().Show()); + + Assert.Equal("window presentation failed", exception.Message); + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + + [Fact] + public void Show_SynchronousRequestCloseReturnsWithoutPresentingClosedWindow() + { + StaTest.Run(() => + { + var window = new SynchronousCloseAwareWindow(); + using var provider = BuildProvider(services => services.AddSingleton(window)); + + provider.GetRequiredService().Show(); + + Assert.False(window.IsVisible); + Assert.Null(provider.GetRequiredService() + .GetExistingWindow(typeof(SynchronousCloseAwareWindow))); + Assert.Equal(0, GetNonModalSubscriptionCount(provider)); + }); + } + + [Fact] + public void Show_SynchronousCancelledRequestCloseContinuesPresentation() + { + StaTest.Run(() => + { + var window = new CancelFirstCloseAwareWindow + { + RequestCloseOnNavigated = true, + }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + + provider.GetRequiredService().Show(); + + Assert.True(window.IsVisible); + Assert.Equal(1, window.ClosingCount); + window.Close(); + }); + } + + [Fact] + public void ShowDialog_AllOverloadsCaptureRequestCloseResultAndCleanupAwareness() + { + StaTest.Run(() => + { + var results = new[] + { + new DialogParameters("path", "generic"), + new DialogParameters("path", "type"), + new DialogParameters("path", "key"), + }; + var windows = new List(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddWindow("aware"); + services.AddTransient(_ => + { + var viewModel = new AwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + var result = results[windows.Count]; + var invokeViewModel = windows.Count == 1; + windows.Add(window); + window.Dispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(() => + { + if (invokeViewModel) + viewModel.RequestClose!(result); + else + window.RequestClose!(result); + })); + return window; + }); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + Assert.Same(results[0], service.ShowDialog()); + Assert.Same(results[1], service.ShowDialog(typeof(AwareWindow))); + Assert.Same(results[2], service.ShowDialog("aware")); + + Assert.All(windows, window => + { + Assert.Null(window.RequestClose); + Assert.Null(((AwareDialogViewModel)window.DataContext).RequestClose); + }); + }); + } + + [Fact] + public void ShowDialog_DirectDispatcherCloseReturnsNullAndDoesNotCancelClosing() + { + StaTest.Run(() => + { + AwareWindow? window = null; + using var provider = BuildProvider(services => services.AddTransient(_ => + { + window = new AwareWindow { DataContext = new AwareDialogViewModel() }; + window.Dispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(window.Close)); + return window; + })); + + var result = provider.GetRequiredService().ShowDialog(); + + Assert.Null(result); + Assert.NotNull(window); + Assert.Null(window!.RequestClose); + Assert.Null(((AwareDialogViewModel)window.DataContext).RequestClose); + }); + } + + [Fact] + public void ShowDialog_AwarenessExceptionPropagatesAndCleansBothCallbacks() + { + StaTest.Run(() => + { + var viewModel = new ThrowingAwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + + var exception = Assert.Throws(() => + provider.GetRequiredService().ShowDialog()); + + Assert.Equal("dialog awareness failed", exception.Message); + Assert.Null(window.RequestClose); + Assert.Null(viewModel.RequestClose); + window.Close(); + }); + } + + [Fact] + public void ShowDialog_VisibleNonModalWindowFailsBeforeReplacingCallbacks() + { + StaTest.Run(() => + { + var viewModel = new AwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + var windowCallback = window.RequestClose; + var viewModelCallback = viewModel.RequestClose; + + var exception = Assert.Throws(() => + service.ShowDialog()); + + Assert.Contains("visible", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Same(windowCallback, window.RequestClose); + Assert.Same(viewModelCallback, viewModel.RequestClose); + windowCallback!(null); + Assert.False(window.IsVisible); + }); + } + + [Fact] + public void ShowDialog_ReentrantShowFailsWithoutReplacingModalCallback() + { + StaTest.Run(() => + { + var result = new DialogParameters("result", 42); + var window = new OneShotReentrantAwareWindow(); + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + Exception? reentrantFailure = null; + var callbackWasPreserved = false; + window.NavigatedAction = () => + { + var modalCallback = window.RequestClose; + try + { + service.Show(); + } + catch (Exception exception) + { + reentrantFailure = exception; + } + + callbackWasPreserved = ReferenceEquals(modalCallback, window.RequestClose); + if (reentrantFailure != null) + { + window.Dispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(() => window.RequestClose!(result))); + } + }; + + DialogParameters? actual = null; + try + { + actual = service.ShowDialog(); + } + finally + { + if (window.IsVisible) + window.Close(); + } + + var exception = Assert.IsType(reentrantFailure); + Assert.Contains("modal", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.True(callbackWasPreserved); + Assert.Same(result, actual); + Assert.Null(window.RequestClose); + }); + } + + [Fact] + public void ShowDialog_NestedSameWindowFailsBeforeReplacingOuterCallback() + { + StaTest.Run(() => + { + var window = new OneShotReentrantAwareWindow(); + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + Exception? nestedFailure = null; + var callbackWasPreserved = false; + window.NavigatedAction = () => + { + var outerCallback = window.RequestClose; + window.Dispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(window.Close)); + try + { + service.ShowDialog(); + } + catch (Exception exception) + { + nestedFailure = exception; + } + + callbackWasPreserved = ReferenceEquals(outerCallback, window.RequestClose); + }; + + Assert.Null(service.ShowDialog()); + + var exception = Assert.IsType(nestedFailure); + Assert.Contains("modal", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.True(callbackWasPreserved); + Assert.Null(window.RequestClose); + }); + } + + [Fact] + public void ShowDialog_SynchronousRequestCloseReturnsCommittedResultWithoutPresentation() + { + StaTest.Run(() => + { + var result = new DialogParameters("result", 42); + var window = new SynchronousCloseAwareWindow { CloseResult = result }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + + var actual = provider.GetRequiredService() + .ShowDialog(); + + Assert.Same(result, actual); + Assert.False(window.IsVisible); + Assert.Null(provider.GetRequiredService() + .GetExistingWindow(typeof(SynchronousCloseAwareWindow))); + }); + } + + [Fact] + public void ShowDialog_CancelledRequestCloseDoesNotCommitResultAndDirectCloseReturnsNull() + { + StaTest.Run(() => + { + var candidate = new DialogParameters("candidate", 1); + CancelFirstCloseAwareWindow? window = null; + using var provider = BuildProvider(services => + services.AddTransient(_ => + { + window = new CancelFirstCloseAwareWindow { CloseResult = candidate }; + window.Dispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(() => window.RequestClose!(candidate))); + window.Dispatcher.BeginInvoke( + DispatcherPriority.Background, + new Action(window.Close)); + return window; + })); + + var actual = provider.GetRequiredService() + .ShowDialog(); + + Assert.Null(actual); + Assert.NotNull(window); + Assert.Equal(2, window!.ClosingCount); + }); + } + + [Fact] + public void ShowDialog_SynchronousCancelledRequestCloseContinuesModalPresentation() + { + StaTest.Run(() => + { + var candidate = new DialogParameters("candidate", 1); + var window = new CancelFirstCloseAwareWindow + { + RequestCloseOnNavigated = true, + CloseResult = candidate, + }; + window.Dispatcher.BeginInvoke( + DispatcherPriority.Background, + new Action(window.Close)); + using var provider = BuildProvider(services => services.AddSingleton(window)); + + var actual = provider.GetRequiredService() + .ShowDialog(); + + Assert.Null(actual); + Assert.Equal(2, window.ClosingCount); + }); + } + + [Fact] + public void ShowDialog_FinallyDoesNotClearAnUnrelatedCallbackReplacement() + { + StaTest.Run(() => + { + var viewModel = new ReplacingAwareDialogViewModel(); + AwareWindow? window = null; + using var provider = BuildProvider(services => services.AddTransient(_ => + { + window = new AwareWindow { DataContext = viewModel }; + window.Dispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(window.Close)); + return window; + })); + + Assert.Null(provider.GetRequiredService().ShowDialog()); + + Assert.NotNull(window); + Assert.Null(window!.RequestClose); + Assert.Same(viewModel.Replacement, viewModel.RequestClose); + }); + } + + [Fact] + public void Close_GenericTypeAndKey_CloseExistingWindows() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddWindow("second"); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + service.Show(); + Assert.True(service.Close()); + service.Show(typeof(FirstWindow)); + Assert.True(service.Close(typeof(FirstWindow))); + service.Show("second"); + Assert.True(service.Close("second")); + }); + } + + [Fact] + public void Close_UnopenedWindowReturnsFalseWithoutInvokingDiFactory() + { + StaTest.Run(() => + { + var resolutionCount = 0; + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddWindow("first"); + services.AddTransient(_ => + { + resolutionCount++; + return new FirstWindow(); + }); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + Assert.False(service.Close()); + Assert.False(service.Close(typeof(FirstWindow))); + Assert.False(service.Close("first")); + Assert.Equal(0, resolutionCount); + }); + } + + [Fact] + public void Close_CancelledClosingReturnsFalseAndWindowRemainsManaged() + { + StaTest.Run(() => + { + var window = new CancelClosingWindow { CancelClosing = true }; + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + + Assert.False(service.Close()); + Assert.True(window.IsVisible); + + window.CancelClosing = false; + Assert.True(service.Close()); + }); + } + + [Fact] + public void Close_OpenNonFocusedWindowStillCloses() + { + StaTest.Run(() => + { + var window = new FirstWindow(); + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + var activeWindow = new SecondWindow(); + activeWindow.Show(); + activeWindow.Activate(); + StaTest.PumpDispatcher(); + + Assert.False(window.IsActive); + Assert.True(service.Close()); + activeWindow.Close(); + }); + } + + [Fact] + public void Close_RequiresOwningWindowDispatcherThread() + { + StaTest.Run(() => + { + var window = new FirstWindow(); + using var provider = BuildProvider(services => services.AddSingleton(window)); + var service = provider.GetRequiredService(); + service.Show(); + Exception? failure = null; + var worker = new Thread(() => + { + try + { + service.Close(); + } + catch (Exception exception) + { + failure = exception; + } + }); + worker.Start(); + Assert.True(worker.Join(TimeSpan.FromSeconds(5))); + + Assert.IsType(failure); + Assert.True(service.Close()); + }); + } + + [Fact] + public void ShowAndShowDialog_RequireOwningWindowDispatcherThread() + { + StaTest.Run(() => + { + var window = new FirstWindow(); + using var provider = BuildProvider(services => services.AddSingleton(window)); + provider.GetRequiredService() + .GetOrCreateWindow(typeof(FirstWindow)); + var service = provider.GetRequiredService(); + Exception? showFailure = null; + Exception? modalFailure = null; + var worker = new Thread(() => + { + try + { + service.Show(); + } + catch (Exception exception) + { + showFailure = exception; + } + + try + { + service.ShowDialog(); + } + catch (Exception exception) + { + modalFailure = exception; + } + }); + worker.Start(); + Assert.True(worker.Join(TimeSpan.FromSeconds(5))); + + Assert.IsType(showFailure); + Assert.IsType(modalFailure); + window.Close(); + }); + } + + [Fact] + public void InvalidTargetsAndKeysFailFastAndMissingDiExceptionIsPreserved() + { + using var provider = BuildProvider(); + var service = provider.GetRequiredService(); + + Assert.Equal("targetType", Assert.Throws( + () => service.Show((Type)null!)).ParamName); + Assert.Equal("targetType", Assert.Throws( + () => service.ShowDialog((Type)null!)).ParamName); + Assert.Equal("targetType", Assert.Throws( + () => service.Close((Type)null!)).ParamName); + Assert.Equal("targetType", Assert.Throws( + () => service.Show(typeof(string))).ParamName); + Assert.Equal("targetType", Assert.Throws( + () => service.ShowDialog(typeof(string))).ParamName); + Assert.Equal("targetType", Assert.Throws( + () => service.Close(typeof(string))).ParamName); + Assert.Throws(() => service.Show(" ")); + Assert.Throws(() => service.Close("missing")); + + var exception = Assert.Throws( + () => service.Show(typeof(FirstWindow))); + Assert.Contains(typeof(FirstWindow).ToString(), exception.Message); + } + + private static ServiceProvider BuildProvider( + Action? configure = null) + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + configure?.Invoke(services); + return services.BuildServiceProvider(); + } + + private static int GetNonModalSubscriptionCount(IServiceProvider provider) + { + var service = Assert.IsType( + provider.GetRequiredService()); + var field = typeof(DialogService).GetField( + "nonModalSubscriptions", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + var subscriptions = Assert.IsAssignableFrom( + field!.GetValue(service)); + return subscriptions.Count; + } + + private static EventHandler GetNonModalClosedHandler( + IServiceProvider provider, + Window window) + { + var service = Assert.IsType( + provider.GetRequiredService()); + var field = typeof(DialogService).GetField( + "nonModalSubscriptions", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + var subscriptions = Assert.IsAssignableFrom( + field!.GetValue(service)); + var subscription = subscriptions[window]; + Assert.NotNull(subscription); + var property = subscription!.GetType().GetProperty("ClosedHandler"); + Assert.NotNull(property); + return Assert.IsType(property!.GetValue(subscription)); + } +} diff --git a/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs new file mode 100644 index 0000000..3a519ef --- /dev/null +++ b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs @@ -0,0 +1,513 @@ +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common.Managers; +using SimpleNavigation.Extensions; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; +using SimpleNavigation.Services; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Windows; + +namespace SimpleNavigation.Tests; + +public sealed class NavigationExtensionsTests +{ + [Fact] + public void SingleGenericKeyOverloads_RegisterTransientViewsAndCoreServices() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransientPage("page"); + services.AddTransientContent("content"); + using var provider = services.BuildServiceProvider(); + + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + Assert.Same( + provider.GetRequiredService(), + provider.GetRequiredService()); + }); + } + + [Fact] + public void RegisterNavigationService_RegistersConcreteRegionManagerAsSingleton() + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + + var first = provider.GetRequiredService(); + var second = provider.GetRequiredService(); + + Assert.IsType(first); + Assert.Same(first, second); + } + + [Fact] + public void RegisterNavigationService_RegistersInternalRouteRegistryAsSingleton() + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + var descriptor = Assert.Single( + services, + item => item.ServiceType.FullName == + "SimpleNavigation.Common.NavigationRouteRegistry"); + using var provider = services.BuildServiceProvider(); + + var first = provider.GetRequiredService(descriptor.ServiceType); + var second = provider.GetRequiredService(descriptor.ServiceType); + + Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); + Assert.Same(first, second); + } + + [Fact] + public void RegisterNavigationService_PreservesEarlierCoreRegistrations() + { + var services = new ServiceCollection(); + using var regionManager = new RegionManager(); + services.AddSingleton(regionManager); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + var existingDescriptors = services.ToArray(); + + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + + Assert.Equal(existingDescriptors.Length + 1, services.Count); + foreach (var descriptor in existingDescriptors) + { + Assert.Same( + descriptor, + Assert.Single(services, item => item.ServiceType == descriptor.ServiceType)); + } + + Assert.Same(regionManager, provider.GetRequiredService()); + } + + [Fact] + public void RouteRegistry_MapsRoutesAddedBeforeAndAfterCoreRegistration() + { + var services = new ServiceCollection(); + services.AddSingletonPage("shared"); + services.RegisterNavigationService(); + services.AddSingletonContent("shared"); + services.AddSingletonPage("second"); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Equal( + typeof(FirstPage), + InvokeRouteLookup(registry, "GetRequiredPageType", "shared")); + Assert.Equal( + typeof(TestContent), + InvokeRouteLookup(registry, "GetRequiredContentType", "shared")); + Assert.Equal( + typeof(SecondPage), + InvokeRouteLookup(registry, "GetRequiredPageType", "second")); + } + + [Fact] + public void RouteRegistry_UsesOrdinalCaseSensitiveKeys() + { + var services = new ServiceCollection(); + services.AddSingletonPage("main"); + services.AddSingletonPage("Main"); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Equal( + typeof(FirstPage), + InvokeRouteLookup(registry, "GetRequiredPageType", "main")); + Assert.Equal( + typeof(SecondPage), + InvokeRouteLookup(registry, "GetRequiredPageType", "Main")); + } + + [Fact] + public void RouteRegistry_UnknownPageAndContentKeysThrowKeyNotFoundException() + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Throws( + () => InvokeRouteLookup(registry, "GetRequiredPageType", "missing")); + Assert.Throws( + () => InvokeRouteLookup(registry, "GetRequiredContentType", "missing")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void RouteRegistry_InvalidPageAndContentKeysThrowArgumentException(string? key) + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Throws( + () => InvokeRouteLookup(registry, "GetRequiredPageType", key)); + Assert.Throws( + () => InvokeRouteLookup(registry, "GetRequiredContentType", key)); + } + + [Fact] + public void DoubleGenericOverloads_RegisterViewAndViewModelWithoutSettingDataContext() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.AddTransientPage(); + services.AddTransientContent(); + using var provider = services.BuildServiceProvider(); + + var page = provider.GetRequiredService(); + var content = provider.GetRequiredService(); + + Assert.Null(page.DataContext); + Assert.Null(content.DataContext); + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + }); + } + + [Fact] + public void RegistrationHelpers_PreserveEarlierSingletonRegistrations() + { + StaTest.Run(() => + { + var page = new FirstPage(); + var content = new TestContent(); + var viewModel = new TestViewModel(); + var services = new ServiceCollection(); + services.AddSingleton(page); + services.AddSingleton(content); + services.AddSingleton(viewModel); + + services.AddSingletonPage("page"); + services.AddSingletonContent("content"); + using var provider = services.BuildServiceProvider(); + + Assert.Same(page, provider.GetRequiredService()); + Assert.Same(content, provider.GetRequiredService()); + Assert.Same(viewModel, provider.GetRequiredService()); + }); + } + + [Fact] + public void DuplicateKeyWithinOneCategory_IsRejectedUsingOrdinalMatching() + { + var services = new ServiceCollection(); + services.AddSingletonPage("main"); + services.AddSingletonPage("Main"); + + var exception = Assert.Throws( + () => services.AddSingletonPage("main")); + + Assert.Equal("key", exception.ParamName); + } + + [Fact] + public void DuplicateContentKey_IsRejected() + { + var services = new ServiceCollection(); + services.AddSingletonContent("main"); + + var exception = Assert.Throws( + () => services.AddSingletonContent("main")); + + Assert.Equal("key", exception.ParamName); + } + + [Fact] + public void SameKeyAcrossPageAndContentCategories_IsAllowed() + { + var services = new ServiceCollection(); + + services.AddSingletonPage("main"); + services.AddSingletonContent("main"); + } + + [Fact] + public void ContentRegistration_RejectsPageAndWindowTypes() + { + var services = new ServiceCollection(); + + Assert.Throws(() => services.AddSingletonContent("page")); + Assert.Throws(() => services.AddSingletonContent("window")); + Assert.Throws(() => services.AddSingletonContent()); + Assert.Throws(() => services.AddSingletonContent("window-vm")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void InvalidRouteKey_IsRejected(string? key) + { + var exception = Assert.Throws( + () => new ServiceCollection().AddSingletonPage(key!)); + + Assert.Equal("key", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void InvalidContentRouteKey_IsRejected(string? key) + { + var exception = Assert.Throws( + () => new ServiceCollection().AddSingletonContent(key!)); + + Assert.Equal("key", exception.ParamName); + } + + [Fact] + public void DialogRegistration_AddWindowWithKey_RegistersTransientWindowAndRoute() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.AddWindow("first"); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + Assert.Equal( + typeof(FirstWindow), + InvokeRouteLookup(registry, "GetRequiredDialogType", "first")); + }); + } + + [Fact] + public void DialogRegistration_AddWindowWithViewModel_RegistersBothTransientWithoutSettingDataContext() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.AddWindow(); + using var provider = services.BuildServiceProvider(); + + var firstWindow = provider.GetRequiredService(); + var secondWindow = provider.GetRequiredService(); + + Assert.NotSame(firstWindow, secondWindow); + Assert.Null(firstWindow.DataContext); + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + }); + } + + [Fact] + public void DialogRegistration_AddWindowWithViewModelAndKey_RegistersBothTransientAndRoute() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddWindow("first"); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + Assert.Equal( + typeof(FirstWindow), + InvokeRouteLookup(registry, "GetRequiredDialogType", "first")); + }); + } + + [Fact] + public void DialogRegistration_AddWindowPreservesEarlierSingletonRegistrations() + { + StaTest.Run(() => + { + var window = new FirstWindow(); + var viewModel = new DialogViewModel(); + var services = new ServiceCollection(); + services.AddSingleton(window); + services.AddSingleton(viewModel); + + services.AddWindow("first"); + using var provider = services.BuildServiceProvider(); + + Assert.Same(window, provider.GetRequiredService()); + Assert.Same(viewModel, provider.GetRequiredService()); + }); + } + + [Fact] + public void DialogRoutes_UseOrdinalCaseSensitiveKeysAndRejectDuplicatesWithinDialogOnly() + { + var services = new ServiceCollection(); + services.AddSingletonPage("main"); + services.AddSingletonContent("main"); + services.AddWindow("main"); + services.AddWindow("Main"); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + var exception = Assert.Throws( + () => services.AddWindow("main")); + + Assert.Equal("key", exception.ParamName); + Assert.Equal( + typeof(FirstWindow), + InvokeRouteLookup(registry, "GetRequiredDialogType", "main")); + Assert.Equal( + typeof(SecondWindow), + InvokeRouteLookup(registry, "GetRequiredDialogType", "Main")); + } + + [Fact] + public void DialogRoutes_RegisteredBeforeAndAfterCoreRegistration_AreAvailable() + { + var services = new ServiceCollection(); + services.AddWindow("first"); + services.RegisterNavigationService(); + services.AddWindow("second"); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Equal( + typeof(FirstWindow), + InvokeRouteLookup(registry, "GetRequiredDialogType", "first")); + Assert.Equal( + typeof(SecondWindow), + InvokeRouteLookup(registry, "GetRequiredDialogType", "second")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void DialogRoutes_InvalidKeysThrowArgumentException(string? key) + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Throws( + () => InvokeRouteLookup(registry, "GetRequiredDialogType", key)); + } + + [Fact] + public void DialogRoutes_UnknownKeyThrowsKeyNotFoundException() + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Throws( + () => InvokeRouteLookup(registry, "GetRequiredDialogType", "missing")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void DialogRegistration_InvalidKeyDoesNotModifyServiceCollection(string? key) + { + var services = new ServiceCollection(); + var originalDescriptors = services.ToArray(); + + var windowException = Assert.Throws( + () => services.AddWindow(key!)); + + Assert.Equal("key", windowException.ParamName); + Assert.Equal(originalDescriptors.Length, services.Count); + Assert.True(originalDescriptors.SequenceEqual(services)); + + var viewModelException = Assert.Throws( + () => services.AddWindow(key!)); + + Assert.Equal("key", viewModelException.ParamName); + Assert.Equal(originalDescriptors.Length, services.Count); + Assert.True(originalDescriptors.SequenceEqual(services)); + } + + [Fact] + public void DialogRegistration_DuplicateKeyDoesNotAddDescriptorsOrReplaceRoute() + { + var services = new ServiceCollection(); + services.AddWindow("main"); + var originalDescriptors = services.ToArray(); + + var exception = Assert.Throws( + () => services.AddWindow("main")); + + Assert.Equal("key", exception.ParamName); + Assert.Equal(originalDescriptors.Length, services.Count); + Assert.True(originalDescriptors.SequenceEqual(services)); + Assert.DoesNotContain(services, item => item.ServiceType == typeof(SecondWindow)); + Assert.DoesNotContain(services, item => item.ServiceType == typeof(DialogViewModel)); + + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var registry = GetRouteRegistry(services, provider); + + Assert.Equal( + typeof(FirstWindow), + InvokeRouteLookup(registry, "GetRequiredDialogType", "main")); + } + + private static object GetRouteRegistry( + IServiceCollection services, + IServiceProvider provider) + { + var descriptor = Assert.Single( + services, + item => item.ServiceType.FullName == + "SimpleNavigation.Common.NavigationRouteRegistry"); + return provider.GetRequiredService(descriptor.ServiceType); + } + + private static Type InvokeRouteLookup( + object registry, + string methodName, + string? key) + { + var method = registry.GetType().GetMethod( + methodName, + BindingFlags.Instance | BindingFlags.Public); + Assert.NotNull(method); + + try + { + return Assert.IsAssignableFrom( + method!.Invoke(registry, new object?[] { key })); + } + catch (TargetInvocationException exception) when (exception.InnerException != null) + { + ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); + throw; + } + } +} diff --git a/Tests/SimpleNavigation.Tests/PageServiceTests.cs b/Tests/SimpleNavigation.Tests/PageServiceTests.cs new file mode 100644 index 0000000..47c3e5f --- /dev/null +++ b/Tests/SimpleNavigation.Tests/PageServiceTests.cs @@ -0,0 +1,239 @@ +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Extensions; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Windows.Controls; +using System.Windows.Navigation; + +namespace SimpleNavigation.Tests; + +public class PageServiceTests +{ + [Fact] + public void GenericAndTypeNavigationDoNotRequireRoutes() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + var service = provider.GetRequiredService(); + + service.Navigate("main"); + StaTest.PumpUntil(() => frame.Content is FirstPage); + Assert.IsType(frame.Content); + + service.Navigate("main", typeof(SecondPage)); + StaTest.PumpUntil(() => frame.Content is SecondPage); + Assert.IsType(frame.Content); + }); + } + + [Fact] + public void StringNavigationUsesCaseSensitiveRouteThenOrdinaryDi() + { + StaTest.Run(() => + { + var expected = new FirstPage(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(expected); + services.AddSingletonPage("first"); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + var service = provider.GetRequiredService(); + + service.Navigate("main", "first"); + + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, expected)); + Assert.Same(expected, frame.Content); + Assert.Throws(() => service.Navigate("main", "First")); + }); + } + + [Fact] + public void ViewAndDataContextReceiveParametersOnceEach() + { + StaTest.Run(() => + { + var viewModel = new AwareViewModel(); + var page = new AwarePage { DataContext = viewModel }; + var parameters = new DialogParameters("id", 7); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(page); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + provider.GetRequiredService() + .Navigate("main", parameters); + + Assert.Equal(1, page.CallCount); + Assert.Equal(1, viewModel.CallCount); + Assert.Same(parameters, page.Parameters); + Assert.Same(parameters, viewModel.Parameters); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void NullParametersStillTriggerAwarenessAndSameInstanceIsDeduplicated() + { + StaTest.Run(() => + { + var page = new AwarePage(); + page.DataContext = page; + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(page); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + provider.GetRequiredService().Navigate("main"); + + Assert.Equal(1, page.CallCount); + Assert.Null(page.Parameters); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void InvalidInputsFailFast() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var contentHost = new ContentControl(); + provider.GetRequiredService().RegisterRegion("content", contentHost); + var service = provider.GetRequiredService(); + + Assert.Throws(() => service.Navigate(" ")); + Assert.Throws(() => service.Navigate("content", (Type)null!)); + Assert.Throws(() => service.Navigate("content", typeof(TestContent))); + Assert.Throws(() => service.Navigate("missing")); + Assert.Throws(() => service.Navigate("content")); + Assert.Throws(() => service.Navigate("content", "unknown")); + GC.KeepAlive(contentHost); + }); + } + + [Fact] + public void GoBackUsesTheFrameJournalAndLegacyMethodForwards() + { + StaTest.Run(() => + { + var first = new FirstPage(); + var second = new SecondPage(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(first); + services.AddSingleton(second); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + var service = provider.GetRequiredService(); + + service.GoBack("main"); + service.Navigate("main"); + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, first)); + service.Navigate("main"); + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, second)); + Assert.True(frame.CanGoBack); + + service.GoBack("main"); + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, first)); + Assert.Same(first, frame.Content); + + service.Navigate("main"); + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, second)); + +#pragma warning disable CS0618 + service.Goback("main"); +#pragma warning restore CS0618 + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, first)); + Assert.Same(first, frame.Content); + }); + } + + [Fact] + public void MissingDiRegistrationKeepsTheContainerException() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + var exception = Assert.Throws(() => + provider.GetRequiredService().Navigate("main")); + + Assert.Contains(typeof(FirstPage).FullName!, exception.Message); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void AwarenessExceptionPropagatesToTheCaller() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(new ThrowingAwarePage()); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + var exception = Assert.Throws(() => + provider.GetRequiredService().Navigate("main")); + + Assert.Equal("awareness failed", exception.Message); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void NavigationRequiresTheHostDispatcherThread() + { + ServiceProvider? provider = null; + IPageService? service = null; + Frame? frame = null; + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(new FirstPage()); + provider = services.BuildServiceProvider(); + frame = RegisterFrame(provider, "main"); + service = provider.GetRequiredService(); + }); + + try + { + Assert.Throws(() => service!.Navigate("main")); + GC.KeepAlive(frame); + } + finally + { + provider!.Dispose(); + } + } + + private static Frame RegisterFrame(IServiceProvider provider, string name) + { + var frame = new Frame + { + JournalOwnership = JournalOwnership.OwnsJournal, + NavigationUIVisibility = NavigationUIVisibility.Hidden + }; + provider.GetRequiredService().RegisterRegion(name, frame); + return frame; + } +} diff --git a/Tests/SimpleNavigation.Tests/RegionManagerTests.cs b/Tests/SimpleNavigation.Tests/RegionManagerTests.cs new file mode 100644 index 0000000..7423082 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/RegionManagerTests.cs @@ -0,0 +1,331 @@ +using System.Runtime.CompilerServices; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using SimpleNavigation.Common.Managers; +using SimpleNavigation.Tests.TestInfrastructure; + +namespace SimpleNavigation.Tests; + +public sealed class RegionManagerTests +{ + [Fact] + public void RegisterRegion_ContentHost_RoundTripsAndUnregistersExactInstance() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var region = new ContentControl(); + + manager.RegisterRegion("Main", region); + + Assert.Same(region, manager.GetRegion("Main")); + Assert.Same(region, manager.GetRegion("Main")); + Assert.Null(manager.GetRegion("Main")); + Assert.True(manager.UnregisterRegion("Main", region)); + Assert.Null(manager.GetRegion("Main")); + }); + } + + [Fact] + public void RegisterRegion_FrameHost_RoundTripsTypedAndUntyped() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var region = new Frame(); + + manager.RegisterRegion("Pages", region); + + Assert.Same(region, manager.GetRegion("Pages")); + Assert.Same(region, manager.GetRegion("Pages")); + }); + } + + [Fact] + public void RegisterRegion_SameHostRepeated_IsIdempotent() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var region = new ContentControl(); + + manager.RegisterRegion("Main", region); + manager.RegisterRegion("Main", region); + + Assert.Same(region, manager.GetRegion("Main")); + }); + } + + [Fact] + public void RegisterRegion_NamesAreOrdinalAndCaseSensitive() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var upperCaseRegion = new ContentControl(); + var lowerCaseRegion = new ContentControl(); + + manager.RegisterRegion("Main", upperCaseRegion); + manager.RegisterRegion("main", lowerCaseRegion); + + Assert.Same(upperCaseRegion, manager.GetRegion("Main")); + Assert.Same(lowerCaseRegion, manager.GetRegion("main")); + }); + } + + [Fact] + public void RegisterRegion_DifferentLiveHostForSameName_Throws() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var first = new ContentControl(); + var second = new ContentControl(); + manager.RegisterRegion("Main", first); + + var exception = Assert.Throws( + () => manager.RegisterRegion("Main", second)); + + Assert.Contains("Main", exception.Message); + GC.KeepAlive(first); + }); + } + + [Fact] + public void RegisterRegion_ConcurrentDifferentHosts_AllowsExactlyOneOwner() + { + StaTest.Run(() => + { + const int workerCount = 32; + const int roundCount = 20; + const string regionName = "Concurrent"; + + var hosts = new ContentControl[workerCount]; + for (var index = 0; index < hosts.Length; index++) + { + hosts[index] = new ContentControl(); + } + + var managers = new RegionManager[roundCount]; + for (var round = 0; round < managers.Length; round++) + { + managers[round] = new RegionManager(); + } + + var outcomes = new Exception?[roundCount, workerCount]; + var workers = new Thread[workerCount]; + using var barrier = new Barrier(workerCount + 1); + + for (var index = 0; index < workers.Length; index++) + { + var workerIndex = index; + workers[index] = new Thread(() => + { + for (var round = 0; round < roundCount; round++) + { + barrier.SignalAndWait(); + + try + { + managers[round].RegisterRegion(regionName, hosts[workerIndex]); + } + catch (Exception exception) + { + outcomes[round, workerIndex] = exception; + } + + barrier.SignalAndWait(); + } + }) + { + IsBackground = true, + }; + workers[index].Start(); + } + + for (var round = 0; round < roundCount; round++) + { + barrier.SignalAndWait(); + barrier.SignalAndWait(); + } + + foreach (var worker in workers) + { + Assert.True(worker.Join(TimeSpan.FromSeconds(5)), "A registration worker did not finish."); + } + + for (var round = 0; round < roundCount; round++) + { + var successCount = 0; + var successIndex = -1; + + for (var worker = 0; worker < workerCount; worker++) + { + var outcome = outcomes[round, worker]; + if (outcome == null) + { + successCount++; + successIndex = worker; + } + else + { + Assert.IsType(outcome); + } + } + + Assert.True( + successCount == 1, + $"Round {round} accepted {successCount} owners instead of exactly one."); + Assert.Same(hosts[successIndex], managers[round].GetRegion(regionName)); + } + + GC.KeepAlive(hosts); + }); + } + + [Fact] + public void UnregisterRegion_WrongHost_ReturnsFalseAndPreservesOwner() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var owner = new ContentControl(); + var other = new ContentControl(); + manager.RegisterRegion("Main", owner); + + Assert.False(manager.UnregisterRegion("Main", other)); + Assert.Same(owner, manager.GetRegion("Main")); + }); + } + + [Fact] + public void RegisterRegion_UnsupportedHost_ThrowsWithHostType() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + + var exception = Assert.Throws( + () => manager.RegisterRegion("Main", new Grid())); + + Assert.Contains(typeof(Grid).FullName!, exception.Message); + }); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void RegisterRegion_InvalidName_Throws(string? regionName) + { + StaTest.Run(() => + { + var manager = new RegionManager(); + + Assert.Throws( + () => manager.RegisterRegion(regionName!, new ContentControl())); + }); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetRegion_InvalidName_Throws(string? regionName) + { + var manager = new RegionManager(); + + Assert.Throws(() => manager.GetRegion(regionName!)); + Assert.Throws(() => manager.GetRegion(regionName!)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void UnregisterRegion_InvalidName_Throws(string? regionName) + { + StaTest.Run(() => + { + var manager = new RegionManager(); + + Assert.Throws( + () => manager.UnregisterRegion(regionName!, new ContentControl())); + }); + } + + [Fact] + public void RegisterRegion_NullHost_Throws() + { + var manager = new RegionManager(); + + var exception = Assert.Throws( + () => manager.RegisterRegion("Main", null!)); + + Assert.Equal("region", exception.ParamName); + } + + [Fact] + public void UnregisterRegion_NullHost_Throws() + { + var manager = new RegionManager(); + + var exception = Assert.Throws( + () => manager.UnregisterRegion("Main", null!)); + + Assert.Equal("region", exception.ParamName); + } + + [Fact] + public void RegionManager_DoesNotKeepAbandonedHostAlive() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var weakRegion = RegisterAbandonedRegion(manager, "Main"); + + ForceGarbageCollection(weakRegion); + + Assert.False(weakRegion.IsAlive); + Assert.Null(manager.GetRegion("Main")); + }); + } + + [Fact] + public void RegisterRegion_CollectedOwner_CanBeReplacedWithoutLookup() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var weakRegion = RegisterAbandonedRegion(manager, "Main"); + ForceGarbageCollection(weakRegion); + Assert.False(weakRegion.IsAlive); + + var replacement = new ContentControl(); + manager.RegisterRegion("Main", replacement); + + Assert.Same(replacement, manager.GetRegion("Main")); + }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference RegisterAbandonedRegion(RegionManager manager, string regionName) + { + var region = new ContentControl(); + manager.RegisterRegion(regionName, region); + return new WeakReference(region); + } + + private static void ForceGarbageCollection(WeakReference weakReference) + { + const int attemptLimit = 10; + + for (var attempt = 0; attempt < attemptLimit && weakReference.IsAlive; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } +} diff --git a/Tests/SimpleNavigation.Tests/RegionTests.cs b/Tests/SimpleNavigation.Tests/RegionTests.cs new file mode 100644 index 0000000..09fb76b --- /dev/null +++ b/Tests/SimpleNavigation.Tests/RegionTests.cs @@ -0,0 +1,802 @@ +using System.Runtime.CompilerServices; +using System.Reflection; +using System.Windows; +using System.Windows.Controls; +using SimpleNavigation.Services; +using SimpleNavigation.Tests.TestInfrastructure; +using SimpleNavigation.Common.Managers; + +namespace SimpleNavigation.Tests; + +public sealed class RegionTests +{ + [Fact] + public void SetRegionName_BeforeManagerCreation_ReplaysActiveFrame() + { + StaTest.Run(() => + { + var host = new Frame(); + + try + { + Region.SetRegionName(host, "Pages"); + + using var manager = new RegionManager(); + + Assert.Same(host, manager.GetRegion("Pages")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Fact] + public void SetRegionName_ContentControl_RegistersWithManager() + { + StaTest.Run(() => + { + var host = new ContentControl(); + + try + { + using var manager = new RegionManager(); + + Region.SetRegionName(host, "Main"); + + Assert.Same(host, manager.GetRegion("Main")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Fact] + public void SetRegionName_UnsupportedFrameworkElement_ThrowsWithHostType() + { + StaTest.Run(() => + { + var host = new Grid(); + + var exception = Assert.Throws( + () => Region.SetRegionName(host, "Main")); + + Assert.Contains(typeof(Grid).FullName!, exception.Message); + }); + } + + [Fact] + public void SetRegionName_NonFrameworkElement_ThrowsWithHostType() + { + StaTest.Run(() => + { + var host = new DependencyObject(); + + var exception = Assert.Throws( + () => Region.SetRegionName(host, "NonElement")); + + Assert.Contains(typeof(DependencyObject).FullName!, exception.Message); + }); + } + + [Fact] + public void SetRegionName_Rename_RemovesOldNameAndAddsNewName() + { + StaTest.Run(() => + { + var host = new ContentControl(); + + try + { + using var manager = new RegionManager(); + Region.SetRegionName(host, "Old"); + + Region.SetRegionName(host, "New"); + + Assert.Null(manager.GetRegion("Old")); + Assert.Same(host, manager.GetRegion("New")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Fact] + public void SetRegionName_RejectedRename_PreservesOldDeclaration() + { + StaTest.Run(() => + { + var first = new ContentControl(); + var second = new ContentControl(); + + try + { + using var manager = new RegionManager(); + Region.SetRegionName(first, "Preserved"); + Region.SetRegionName(second, "Occupied"); + + Assert.Throws( + () => Region.SetRegionName(first, "Occupied")); + + Assert.Equal("Preserved", Region.GetRegionName(first)); + Assert.Same(first, manager.GetRegion("Preserved")); + Assert.Same(second, manager.GetRegion("Occupied")); + } + finally + { + ClearRegionName(first); + ClearRegionName(second); + } + }); + } + + [Fact] + public void ClearValue_ActiveDeclaration_RemovesRegistration() + { + StaTest.Run(() => + { + var host = new ContentControl(); + + try + { + using var manager = new RegionManager(); + Region.SetRegionName(host, "Main"); + + host.ClearValue(Region.RegionNameProperty); + + Assert.Null(Region.GetRegionName(host)); + Assert.Null(manager.GetRegion("Main")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetRegionName_InvalidName_Throws(string? regionName) + { + StaTest.Run(() => + { + var host = new ContentControl(); + + Assert.Throws( + () => Region.SetRegionName(host, regionName!)); + }); + } + + [Fact] + public void UnloadedAndLoaded_AttachedOnlyDeclaration_RemovesThenRestoresHost() + { + StaTest.Run(() => + { + var host = new ContentControl(); + + try + { + using var manager = new RegionManager(); + Region.SetRegionName(host, "Main"); + + RaiseLifecycleEvent(host, FrameworkElement.UnloadedEvent); + Assert.Null(manager.GetRegion("Main")); + + RaiseLifecycleEvent(host, FrameworkElement.LoadedEvent); + Assert.Same(host, manager.GetRegion("Main")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Fact] + public void ClearValue_WhileUnloaded_PreventsLaterReactivation() + { + StaTest.Run(() => + { + var host = new ContentControl(); + + try + { + using var manager = new RegionManager(); + Region.SetRegionName(host, "UnloadedClear"); + RaiseLifecycleEvent(host, FrameworkElement.UnloadedEvent); + + host.ClearValue(Region.RegionNameProperty); + RaiseLifecycleEvent(host, FrameworkElement.LoadedEvent); + + Assert.Null(manager.GetRegion("UnloadedClear")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Fact] + public void SetRegionName_DuplicateAttachedNameOnLiveHosts_ThrowsWithoutManager() + { + StaTest.Run(() => + { + var first = new ContentControl(); + var second = new ContentControl(); + + try + { + Region.SetRegionName(first, "Main"); + + var exception = Assert.Throws( + () => Region.SetRegionName(second, "Main")); + + Assert.Contains("Main", exception.Message); + } + finally + { + ClearRegionName(first); + ClearRegionName(second); + } + }); + } + + [Fact] + public void DirectSetValue_DuplicateName_RollsBackLoserDependencyProperty() + { + StaTest.Run(() => + { + var first = new ContentControl(); + var second = new ContentControl(); + + try + { + using var manager = new RegionManager(); + first.SetValue(Region.RegionNameProperty, "DirectTaken"); + + var exception = Assert.Throws( + () => second.SetValue(Region.RegionNameProperty, "DirectTaken")); + + Assert.Contains("DirectTaken", exception.Message); + Assert.Null(Region.GetRegionName(second)); + Assert.Same(first, manager.GetRegion("DirectTaken")); + } + finally + { + ClearRegionName(first); + ClearRegionName(second); + } + }); + } + + [Fact] + public void DirectSetValue_RejectedRename_RestoresOldValueAndOwnership() + { + StaTest.Run(() => + { + var renamedHost = new ContentControl(); + var occupiedHost = new ContentControl(); + + try + { + using var manager = new RegionManager(); + renamedHost.SetValue(Region.RegionNameProperty, "DirectOld"); + occupiedHost.SetValue(Region.RegionNameProperty, "DirectOccupied"); + + Assert.Throws( + () => renamedHost.SetValue(Region.RegionNameProperty, "DirectOccupied")); + + Assert.Equal("DirectOld", Region.GetRegionName(renamedHost)); + Assert.Same(renamedHost, manager.GetRegion("DirectOld")); + Assert.Same(occupiedHost, manager.GetRegion("DirectOccupied")); + } + finally + { + ClearRegionName(renamedHost); + ClearRegionName(occupiedHost); + } + }); + } + + [Fact] + public void DirectSetValue_ConcurrentSameName_OneWinsAndLoserRollsBack() + { + StaTest.Run(() => + { + const string regionName = "DirectRace"; + var hosts = new ContentControl?[2]; + var outcomes = new Exception?[2]; + var effectiveNames = new string?[2]; + var unexpectedFailures = new Exception?[2]; + var workers = new Thread[2]; + using var hostsCreated = new CountdownEvent(2); + using var start = new ManualResetEventSlim(false); + using var setCompleted = new CountdownEvent(2); + using var releaseCleanup = new ManualResetEventSlim(false); + + for (var index = 0; index < workers.Length; index++) + { + var workerIndex = index; + workers[index] = new Thread(() => + { + ContentControl? host = null; + + try + { + host = new ContentControl(); + hosts[workerIndex] = host; + hostsCreated.Signal(); + start.Wait(); + + try + { + host.SetValue(Region.RegionNameProperty, regionName); + } + catch (Exception exception) + { + outcomes[workerIndex] = exception; + } + + effectiveNames[workerIndex] = Region.GetRegionName(host); + } + catch (Exception exception) + { + unexpectedFailures[workerIndex] = exception; + } + finally + { + setCompleted.Signal(); + releaseCleanup.Wait(TimeSpan.FromSeconds(5)); + + if (host != null) + { + ClearRegionName(host); + } + } + }) + { + IsBackground = true, + }; + workers[index].SetApartmentState(ApartmentState.STA); + workers[index].Start(); + } + + RegionManager? manager = null; + + try + { + Assert.True(hostsCreated.Wait(TimeSpan.FromSeconds(5)), "Region hosts were not created."); + start.Set(); + Assert.True(setCompleted.Wait(TimeSpan.FromSeconds(5)), "Region setters did not finish."); + Assert.All(unexpectedFailures, failure => Assert.Null(failure)); + + var winnerIndex = Array.FindIndex(outcomes, outcome => outcome == null); + var loserIndex = Array.FindIndex(outcomes, outcome => outcome != null); + + Assert.InRange(winnerIndex, 0, 1); + Assert.InRange(loserIndex, 0, 1); + Assert.NotEqual(winnerIndex, loserIndex); + Assert.IsType(outcomes[loserIndex]); + Assert.Equal(regionName, effectiveNames[winnerIndex]); + Assert.Null(effectiveNames[loserIndex]); + + manager = new RegionManager(); + Assert.Same(hosts[winnerIndex], manager.GetRegion(regionName)); + } + finally + { + manager?.Dispose(); + releaseCleanup.Set(); + + foreach (var worker in workers) + { + Assert.True(worker.Join(TimeSpan.FromSeconds(5)), "A region setter worker did not finish."); + } + } + }); + } + + [Fact] + public void DeclarationPublication_PreservesCatalogMutationOrderAcrossDispatchers() + { + StaTest.Run(() => + { + const string regionName = "PublicationOrder"; + using var blocker = new PublicationOrderBlocker(regionName); + var subscriber = SubscribePublicationBlocker(blocker); + var firstHostReady = new ManualResetEventSlim(false); + var secondHostReady = new ManualResetEventSlim(false); + var clearFirst = new ManualResetEventSlim(false); + var setSecond = new ManualResetEventSlim(false); + var secondSetStarted = new ManualResetEventSlim(false); + var firstCompleted = new ManualResetEventSlim(false); + var secondCompleted = new ManualResetEventSlim(false); + var releaseSecondCleanup = new ManualResetEventSlim(false); + ContentControl? firstHost = null; + ContentControl? secondHost = null; + Exception? firstFailure = null; + Exception? secondFailure = null; + + var firstWorker = new Thread(() => + { + try + { + firstHost = new ContentControl(); + firstHost.SetValue(Region.RegionNameProperty, regionName); + firstHostReady.Set(); + clearFirst.Wait(); + firstHost.ClearValue(Region.RegionNameProperty); + } + catch (Exception exception) + { + firstFailure = exception; + } + finally + { + firstHostReady.Set(); + firstCompleted.Set(); + } + }) + { + IsBackground = true, + }; + firstWorker.SetApartmentState(ApartmentState.STA); + + var secondWorker = new Thread(() => + { + try + { + secondHost = new ContentControl(); + secondHostReady.Set(); + setSecond.Wait(); + secondSetStarted.Set(); + secondHost.SetValue(Region.RegionNameProperty, regionName); + } + catch (Exception exception) + { + secondFailure = exception; + } + finally + { + secondHostReady.Set(); + secondSetStarted.Set(); + secondCompleted.Set(); + releaseSecondCleanup.Wait(TimeSpan.FromSeconds(10)); + + if (secondHost != null) + { + ClearRegionName(secondHost); + } + } + }) + { + IsBackground = true, + }; + secondWorker.SetApartmentState(ApartmentState.STA); + + RegionManager? manager = null; + RegionManager? lateManager = null; + firstWorker.Start(); + secondWorker.Start(); + + try + { + Assert.True(firstHostReady.Wait(TimeSpan.FromSeconds(5)), "The first host was not declared."); + Assert.True(secondHostReady.Wait(TimeSpan.FromSeconds(5)), "The second host was not created."); + Assert.Null(firstFailure); + manager = new RegionManager(); + Assert.Same(firstHost, manager.GetRegion(regionName)); + + blocker.IsArmed = true; + clearFirst.Set(); + Assert.True(blocker.RemoveObserved.Wait(TimeSpan.FromSeconds(5)), "Remove publication was not blocked."); + + setSecond.Set(); + Assert.True(secondSetStarted.Wait(TimeSpan.FromSeconds(5)), "The replacement setter did not start."); + + if (blocker.ReplacementAddObserved.Wait(TimeSpan.FromMilliseconds(500))) + { + Assert.True(secondCompleted.Wait(TimeSpan.FromSeconds(5)), "The overtaking add did not finish."); + } + + blocker.ReleaseRemove.Set(); + Assert.True(firstCompleted.Wait(TimeSpan.FromSeconds(5)), "The first clear did not finish."); + Assert.True(secondCompleted.Wait(TimeSpan.FromSeconds(5)), "The replacement setter did not finish."); + + Assert.Null(firstFailure); + Assert.Null(secondFailure); + Assert.Same(secondHost, manager.GetRegion(regionName)); + lateManager = new RegionManager(); + Assert.Same(secondHost, lateManager.GetRegion(regionName)); + } + finally + { + blocker.ReleaseRemove.Set(); + clearFirst.Set(); + setSecond.Set(); + releaseSecondCleanup.Set(); + manager?.Dispose(); + lateManager?.Dispose(); + Assert.True(firstWorker.Join(TimeSpan.FromSeconds(5)), "The first publication worker did not finish."); + Assert.True(secondWorker.Join(TimeSpan.FromSeconds(5)), "The second publication worker did not finish."); + UnsubscribePublicationBlocker(subscriber); + firstHostReady.Dispose(); + secondHostReady.Dispose(); + clearFirst.Dispose(); + setSecond.Dispose(); + secondSetStarted.Dispose(); + firstCompleted.Dispose(); + secondCompleted.Dispose(); + releaseSecondCleanup.Dispose(); + } + }); + } + + [Fact] + public void ProgrammaticAndAttachedOwnership_AreRemovedIndependently() + { + StaTest.Run(() => + { + var host = new ContentControl(); + + try + { + using var manager = new RegionManager(); + manager.RegisterRegion("Main", host); + Region.SetRegionName(host, "Main"); + + RaiseLifecycleEvent(host, FrameworkElement.UnloadedEvent); + Assert.Same(host, manager.GetRegion("Main")); + + Assert.True(manager.UnregisterRegion("Main", host)); + Assert.Null(manager.GetRegion("Main")); + + RaiseLifecycleEvent(host, FrameworkElement.LoadedEvent); + Assert.Same(host, manager.GetRegion("Main")); + + host.ClearValue(Region.RegionNameProperty); + Assert.Null(manager.GetRegion("Main")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Fact] + public void UnregisterRegion_AttachedOnlyOwnership_ReturnsFalseAndPreservesHost() + { + StaTest.Run(() => + { + var host = new ContentControl(); + + try + { + using var manager = new RegionManager(); + Region.SetRegionName(host, "AttachedOnly"); + + Assert.False(manager.UnregisterRegion("AttachedOnly", host)); + Assert.Same(host, manager.GetRegion("AttachedOnly")); + } + finally + { + ClearRegionName(host); + } + }); + } + + [Fact] + public void DeclarationFailure_InOneManager_StillPublishesToOtherManagers() + { + StaTest.Run(() => + { + var blocker = new ContentControl(); + var declaredHost = new ContentControl(); + + try + { + using var throwingManager = new RegionManager(); + using var receivingManager = new RegionManager(); + throwingManager.RegisterRegion("Fanout", blocker); + + var exception = Assert.Throws( + () => Region.SetRegionName(declaredHost, "Fanout")); + + Assert.Contains("Fanout", exception.Message); + Assert.Same(blocker, throwingManager.GetRegion("Fanout")); + Assert.Same(declaredHost, receivingManager.GetRegion("Fanout")); + } + finally + { + ClearRegionName(declaredHost); + } + }); + } + + [Fact] + public void DeclarationCatalog_DoesNotKeepAbandonedHostAlive() + { + StaTest.Run(() => + { + var weakHost = DeclareAbandonedRegion("Abandoned"); + + ForceGarbageCollection(weakHost); + + Assert.False(weakHost.IsAlive); + using var manager = new RegionManager(); + Assert.Null(manager.GetRegion("Abandoned")); + }); + } + + [Fact] + public void RegionSubscribers_DoNotKeepUndisposedManagerAlive() + { + StaTest.Run(() => + { + var weakManager = CreateAbandonedManager(); + + ForceGarbageCollection(weakManager); + + Assert.False(weakManager.IsAlive); + }); + } + + [Fact] + public void ManagersCreatedAroundDeclaration_ReceiveItAndDisposeUnsubscribesOne() + { + StaTest.Run(() => + { + var firstHost = new ContentControl(); + var secondHost = new Frame(); + var earlyManager = new RegionManager(); + + try + { + Region.SetRegionName(firstHost, "Main"); + using var lateManager = new RegionManager(); + + Assert.Same(firstHost, earlyManager.GetRegion("Main")); + Assert.Same(firstHost, lateManager.GetRegion("Main")); + + earlyManager.Dispose(); + Region.SetRegionName(secondHost, "Pages"); + + Assert.Same(secondHost, lateManager.GetRegion("Pages")); + Assert.Throws(() => earlyManager.GetRegion("Pages")); + } + finally + { + earlyManager.Dispose(); + ClearRegionName(firstHost); + ClearRegionName(secondHost); + } + }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference DeclareAbandonedRegion(string regionName) + { + var host = new ContentControl(); + Region.SetRegionName(host, regionName); + return new WeakReference(host); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CreateAbandonedManager() + { + var manager = new RegionManager(); + return new WeakReference(manager); + } + + private static Delegate SubscribePublicationBlocker(PublicationOrderBlocker blocker) + { + var changeType = typeof(Region).Assembly.GetType( + "SimpleNavigation.Services.RegionDeclarationChange", + throwOnError: true)!; + var actionType = typeof(Action<>).MakeGenericType(changeType); + var handler = typeof(PublicationOrderBlocker) + .GetMethod(nameof(PublicationOrderBlocker.Handle), BindingFlags.Instance | BindingFlags.Public)! + .MakeGenericMethod(changeType); + var subscriber = Delegate.CreateDelegate(actionType, blocker, handler); + var subscribe = typeof(Region).GetMethod("Subscribe", BindingFlags.Static | BindingFlags.NonPublic)!; + subscribe.Invoke(null, new object[] { subscriber }); + return subscriber; + } + + private static void UnsubscribePublicationBlocker(Delegate subscriber) + { + var unsubscribe = typeof(Region).GetMethod("Unsubscribe", BindingFlags.Static | BindingFlags.NonPublic)!; + unsubscribe.Invoke(null, new object[] { subscriber }); + } + + private static void ClearRegionName(FrameworkElement host) + { + if (host.ReadLocalValue(Region.RegionNameProperty) != DependencyProperty.UnsetValue) + { + host.ClearValue(Region.RegionNameProperty); + } + } + + private static void RaiseLifecycleEvent(FrameworkElement host, RoutedEvent routedEvent) + { + host.RaiseEvent(new RoutedEventArgs(routedEvent, host)); + } + + private static void ForceGarbageCollection(WeakReference weakReference) + { + const int attemptLimit = 10; + + for (var attempt = 0; attempt < attemptLimit && weakReference.IsAlive; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } + + private sealed class PublicationOrderBlocker : IDisposable + { + private readonly string regionName; + + public PublicationOrderBlocker(string regionName) + { + this.regionName = regionName; + } + + public bool IsArmed { get; set; } + + public ManualResetEventSlim RemoveObserved { get; } = new(false); + + public ManualResetEventSlim ReplacementAddObserved { get; } = new(false); + + public ManualResetEventSlim ReleaseRemove { get; } = new(false); + + public void Handle(TChange change) + { + if (!IsArmed || change == null) + { + return; + } + + var changeType = change.GetType(); + var name = (string?)changeType.GetProperty("Name")?.GetValue(change); + var kind = changeType.GetProperty("Kind")?.GetValue(change)?.ToString(); + if (!string.Equals(name, regionName, StringComparison.Ordinal)) + { + return; + } + + if (string.Equals(kind, "Remove", StringComparison.Ordinal)) + { + RemoveObserved.Set(); + if (!ReleaseRemove.Wait(TimeSpan.FromSeconds(10))) + { + throw new TimeoutException("Timed out waiting to release declaration removal."); + } + + return; + } + + if (string.Equals(kind, "Add", StringComparison.Ordinal)) + { + ReplacementAddObserved.Set(); + } + } + + public void Dispose() + { + RemoveObserved.Dispose(); + ReplacementAddObserved.Dispose(); + ReleaseRemove.Dispose(); + } + } +} diff --git a/Tests/SimpleNavigation.Tests/SimpleNavigation.Tests.csproj b/Tests/SimpleNavigation.Tests/SimpleNavigation.Tests.csproj new file mode 100644 index 0000000..7a689e6 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/SimpleNavigation.Tests.csproj @@ -0,0 +1,30 @@ + + + + net48;net8.0-windows + true + enable + enable + 13 + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/Tests/SimpleNavigation.Tests/SmokeTests.cs b/Tests/SimpleNavigation.Tests/SmokeTests.cs new file mode 100644 index 0000000..8f46495 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/SmokeTests.cs @@ -0,0 +1,72 @@ +using System.Threading; +using System.Windows.Threading; +using SimpleNavigation.Common; +using SimpleNavigation.Tests.TestInfrastructure; + +namespace SimpleNavigation.Tests; + +public sealed class SmokeTests +{ + [Fact] + public void DialogParameters_RoundTripsValue() + { + var parameters = new DialogParameters("answer", 42); + + Assert.Equal(42, parameters.Get("answer")); + } + + [Fact] + public void StaTest_Run_ExecutesOnStaThread() + { + ApartmentState? apartmentState = null; + + StaTest.Run(() => apartmentState = Thread.CurrentThread.GetApartmentState()); + + Assert.Equal(ApartmentState.STA, apartmentState); + } + + [Fact] + public void StaTest_PumpUntil_ProcessesQueuedDispatcherWork() + { + var workProcessed = false; + + StaTest.Run(() => + { + Dispatcher.CurrentDispatcher.BeginInvoke( + DispatcherPriority.Normal, + new Action(() => workProcessed = true)); + + StaTest.PumpUntil(() => workProcessed); + }); + + Assert.True(workProcessed); + } + + [Fact] + public void StaTest_PumpUntil_TimesOutWhenIdleWorkIsStarved() + { + var exception = Assert.Throws(() => StaTest.Run(() => + { + var dispatcher = Dispatcher.CurrentDispatcher; + DispatcherOperationCallback? keepDispatcherBusy = null; + + keepDispatcherBusy = _ => + { + dispatcher.BeginInvoke( + DispatcherPriority.Normal, + keepDispatcherBusy!, + null); + return null; + }; + + dispatcher.BeginInvoke( + DispatcherPriority.Normal, + keepDispatcherBusy, + null); + + StaTest.PumpUntil(() => false); + })); + + Assert.StartsWith("The dispatcher condition was not met", exception.Message); + } +} diff --git a/Tests/SimpleNavigation.Tests/TestInfrastructure/AssemblyInfo.cs b/Tests/SimpleNavigation.Tests/TestInfrastructure/AssemblyInfo.cs new file mode 100644 index 0000000..2171200 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/TestInfrastructure/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs b/Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs new file mode 100644 index 0000000..debda42 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs @@ -0,0 +1,117 @@ +using System.Diagnostics; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Windows.Threading; + +namespace SimpleNavigation.Tests.TestInfrastructure; + +internal static class StaTest +{ + private static readonly TimeSpan ThreadTimeout = TimeSpan.FromSeconds(20); + private static readonly TimeSpan PumpTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan PumpSlice = TimeSpan.FromMilliseconds(50); + + public static void Run(Action action) + { + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + ExceptionDispatchInfo? capturedException = null; + + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception exception) + { + capturedException = ExceptionDispatchInfo.Capture(exception); + } + finally + { + try + { + Dispatcher.CurrentDispatcher.InvokeShutdown(); + } + catch (Exception exception) + { + capturedException ??= ExceptionDispatchInfo.Capture(exception); + } + } + }) + { + IsBackground = true, + }; + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + if (!thread.Join(ThreadTimeout)) + { + throw new TimeoutException($"The STA test thread did not finish within {ThreadTimeout.TotalSeconds} seconds."); + } + + capturedException?.Throw(); + } + + public static void PumpDispatcher() + { + var dispatcher = Dispatcher.CurrentDispatcher; + var frame = new DispatcherFrame(); + + var idleOperation = dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new DispatcherOperationCallback(_ => + { + frame.Continue = false; + return null; + }), + null); + + var timer = new DispatcherTimer(DispatcherPriority.Send, dispatcher) + { + Interval = PumpSlice, + }; + EventHandler stopFrame = (_, _) => frame.Continue = false; + timer.Tick += stopFrame; + timer.Start(); + + try + { + Dispatcher.PushFrame(frame); + } + finally + { + timer.Stop(); + timer.Tick -= stopFrame; + + if (idleOperation.Status == DispatcherOperationStatus.Pending) + { + idleOperation.Abort(); + } + } + } + + public static void PumpUntil(Func condition) + { + if (condition is null) + { + throw new ArgumentNullException(nameof(condition)); + } + + var stopwatch = Stopwatch.StartNew(); + + while (!condition()) + { + if (stopwatch.Elapsed >= PumpTimeout) + { + throw new TimeoutException($"The dispatcher condition was not met within {PumpTimeout.TotalSeconds} seconds."); + } + + PumpDispatcher(); + } + } +} diff --git a/Tests/SimpleNavigation.Tests/TestTypes.cs b/Tests/SimpleNavigation.Tests/TestTypes.cs new file mode 100644 index 0000000..6ed8524 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/TestTypes.cs @@ -0,0 +1,377 @@ +using SimpleNavigation.Common; +using SimpleNavigation.Interface.Awares; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Tests; + +public sealed class FirstPage : Page +{ +} + +public sealed class SecondPage : Page +{ +} + +public sealed class FirstWindow : Window +{ +} + +public sealed class SecondWindow : Window +{ +} + +public sealed class TestContent : UserControl +{ +} + +public sealed class TestViewModel +{ +} + +public sealed class DialogViewModel +{ +} + +public class AwareDialogViewModel : IDialogAware +{ + private readonly IList? calls; + private readonly string name; + + public AwareDialogViewModel() + : this(null, "view-model") + { + } + + public AwareDialogViewModel(IList? calls, string name = "view-model") + { + this.calls = calls; + this.name = name; + } + + public int CallCount { get; private set; } + + public DialogParameters? Parameters { get; private set; } + + public Action? RequestClose { get; set; } + + public virtual void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + calls?.Add(name); + } +} + +public class AwareWindow : Window, IDialogAware +{ + private readonly IList? calls; + private readonly string name; + + public AwareWindow() + : this(null, "window") + { + } + + public AwareWindow(IList? calls, string name = "window") + { + this.calls = calls; + this.name = name; + } + + public int CallCount { get; private set; } + + public DialogParameters? Parameters { get; private set; } + + public Action? RequestClose { get; set; } + + public virtual void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + calls?.Add(name); + } +} + +public sealed class ThrowingAwareDialogViewModel : AwareDialogViewModel +{ + public override void OnNavigated(DialogParameters? parameters) + { + base.OnNavigated(parameters); + throw new InvalidOperationException("dialog awareness failed"); + } +} + +public sealed class ToggleThrowAwareDialogViewModel : AwareDialogViewModel +{ + public bool ThrowOnNavigated { get; set; } + + public override void OnNavigated(DialogParameters? parameters) + { + base.OnNavigated(parameters); + if (ThrowOnNavigated) + throw new InvalidOperationException("dialog awareness failed"); + } +} + +public sealed class OneShotReentrantAwareWindow : AwareWindow +{ + public Action? NavigatedAction { get; set; } + + public override void OnNavigated(DialogParameters? parameters) + { + base.OnNavigated(parameters); + var action = NavigatedAction; + NavigatedAction = null; + action?.Invoke(); + } +} + +public sealed class ReplacingThrowingAwareDialogViewModel : AwareDialogViewModel +{ + public bool ReplaceAndThrow { get; set; } + + public Action Replacement { get; } = _ => { }; + + public override void OnNavigated(DialogParameters? parameters) + { + base.OnNavigated(parameters); + if (!ReplaceAndThrow) + return; + + RequestClose = Replacement; + throw new InvalidOperationException("dialog awareness failed"); + } +} + +public sealed class ThrowingRequestCloseAwareViewModel : IDialogAware +{ + private Action? requestClose; + + public bool ThrowOnEveryNonNullSet { get; set; } + + public bool ThrowOnNextNonNullSet { get; set; } + + public bool ThrowOnNavigatedAndArmNextNonNullSet { get; set; } + + public Action? RequestClose + { + get => requestClose; + set + { + if (value != null && (ThrowOnEveryNonNullSet || ThrowOnNextNonNullSet)) + { + ThrowOnNextNonNullSet = false; + throw new InvalidOperationException("request close setter failed"); + } + + requestClose = value; + } + } + + public void OnNavigated(DialogParameters? parameters) + { + if (!ThrowOnNavigatedAndArmNextNonNullSet) + return; + + ThrowOnNextNonNullSet = true; + throw new InvalidOperationException("dialog awareness failed"); + } +} + +public sealed class ClosingRequestCloseAwareWindow : Window, IDialogAware +{ + private Action? requestClose; + + public bool CloseOnNonNullSet { get; set; } + + public Action? RequestClose + { + get => requestClose; + set + { + requestClose = value; + if (value != null && CloseOnNonNullSet) + Close(); + } + } + + public void OnNavigated(DialogParameters? parameters) + { + } +} + +public sealed class ThrowingClearAwareWindow : Window, IDialogAware +{ + private Action? requestClose; + + public bool ThrowOnNullSet { get; set; } + + public Action? RequestClose + { + get => requestClose; + set + { + if (value == null && ThrowOnNullSet) + throw new InvalidOperationException("request close cleanup failed"); + + requestClose = value; + } + } + + public void OnNavigated(DialogParameters? parameters) + { + } +} + +public sealed class CleanupActionAwareViewModel : IDialogAware +{ + private Action? requestClose; + + public Action? CleanupAction { get; set; } + + public Action? RequestClose + { + get => requestClose; + set + { + var cleanupAction = value == null && requestClose != null + ? CleanupAction + : null; + CleanupAction = null; + requestClose = value; + cleanupAction?.Invoke(); + } + } + + public void OnNavigated(DialogParameters? parameters) + { + } +} + +public sealed class ReplacingAwareDialogViewModel : AwareDialogViewModel +{ + public Action Replacement { get; } = _ => { }; + + public override void OnNavigated(DialogParameters? parameters) + { + base.OnNavigated(parameters); + RequestClose = Replacement; + } +} + +public sealed class CancelClosingWindow : Window +{ + public bool CancelClosing { get; set; } + + protected override void OnClosing(CancelEventArgs e) + { + e.Cancel = CancelClosing; + base.OnClosing(e); + } +} + +public sealed class ReuseWindow : Window +{ +} + +public sealed class ThrowingPresentationWindow : AwareWindow +{ + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + throw new InvalidOperationException("window presentation failed"); + } +} + +public sealed class SynchronousCloseAwareWindow : AwareWindow +{ + public DialogParameters? CloseResult { get; set; } + + public override void OnNavigated(DialogParameters? parameters) + { + base.OnNavigated(parameters); + RequestClose!(CloseResult); + } +} + +public sealed class CancelFirstCloseAwareWindow : AwareWindow +{ + public bool RequestCloseOnNavigated { get; set; } + + public DialogParameters? CloseResult { get; set; } + + public int ClosingCount { get; private set; } + + public override void OnNavigated(DialogParameters? parameters) + { + base.OnNavigated(parameters); + if (RequestCloseOnNavigated) + RequestClose!(CloseResult); + } + + protected override void OnClosing(CancelEventArgs e) + { + ClosingCount++; + if (ClosingCount == 1) + e.Cancel = true; + + base.OnClosing(e); + } +} + +public sealed class AwareViewModel : INavigationAware +{ + public int CallCount { get; private set; } + + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} + +public sealed class AwarePage : Page, INavigationAware +{ + public int CallCount { get; private set; } + + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} + +public sealed class AwareContent : UserControl, INavigationAware +{ + public int CallCount { get; private set; } + + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} + +public sealed class ThrowingAwareContent : UserControl, INavigationAware +{ + public void OnNavigated(DialogParameters? parameters) + { + throw new InvalidOperationException("awareness failed"); + } +} + +public sealed class ThrowingAwarePage : Page, INavigationAware +{ + public void OnNavigated(DialogParameters? parameters) + { + throw new InvalidOperationException("awareness failed"); + } +} diff --git a/docs/superpowers/plans/2026-07-11-navigation-rebuild.md b/docs/superpowers/plans/2026-07-11-navigation-rebuild.md new file mode 100644 index 0000000..e3a62b6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-navigation-rebuild.md @@ -0,0 +1,2786 @@ +# Navigation Rebuild Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild SimpleNavigation around `RegionManager`, add non-page content navigation and explicit string routes, and remove `RegionService` while preserving DI-driven instance creation on `net48` and `net8.0-windows`. + +**Architecture:** `Region` declares supported WPF hosts, `RegionManager` owns weak named lookup, and internal host adapters isolate `Frame` from ordinary `ContentControl` behavior. `PageService` and `ContentService` resolve views through ordinary Microsoft DI; only string overloads consult separate page/content route maps before resolving the mapped type. + +**Tech Stack:** C# 13, WPF, Microsoft.Extensions.DependencyInjection 6/8, xUnit 2.5.3, Microsoft.NET.Test.Sdk 17.11.1, .NET Framework 4.8, .NET 8 Windows. + +--- + +## File Map + +- `SimpleNavigation.csproj`: exclude the nested test tree from the root SDK project's default items. +- `SimpleNavigation.sln`: include the new test project. +- `Tests/SimpleNavigation.Tests/SimpleNavigation.Tests.csproj`: dual-target WPF test project. +- `Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs`: execute WPF assertions on an STA thread and pump the dispatcher. +- `Tests/SimpleNavigation.Tests/TestInfrastructure/AssemblyInfo.cs`: disable test parallelism because region declarations are process-static. +- `Tests/SimpleNavigation.Tests/TestTypes.cs`: focused page, view, view-model, and awareness fixtures. +- `Tests/SimpleNavigation.Tests/RegionManagerTests.cs`: programmatic region ownership tests. +- `Tests/SimpleNavigation.Tests/RegionTests.cs`: attached-property and lifecycle tests. +- `Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs`: DI and route registration tests. +- `Tests/SimpleNavigation.Tests/PageServiceTests.cs`: page navigation, awareness, error, and journal tests. +- `Tests/SimpleNavigation.Tests/ContentServiceTests.cs`: content navigation, awareness, and host-boundary tests. +- `Interface/IRegionManager.cs`: public region registry contract. +- `Common/RegionManager.cs`: weak region lookup and attached-declaration synchronization. +- `Common/RegionHostAdapter.cs`: internal host kind, base adapter contract, and ordered resolver. +- `Common/FrameRegionAdapter.cs`: `Frame.Navigate` and journal behavior. +- `Common/ContentControlRegionAdapter.cs`: non-`Frame` content replacement behavior. +- `Services/Region.cs`: attached property plus weak declaration catalog. +- `Common/NavigationRouteRegistry.cs`: immutable page/content key maps. +- `Common/NavigationAwareNotifier.cs`: view and DataContext callback de-duplication. +- `Interface/INavigationAware.cs`: shared navigation callback. +- `Interface/IContentService.cs`: public content navigation contract. +- `Services/ContentService.cs`: DI-driven content navigation. +- `Interface/IPageAware.cs`: inherit the shared awareness contract. +- `Interface/IPageService.cs`: add key navigation and `GoBack`, remove region lookup. +- `Services/PageService.cs`: delegate region ownership and support all three resolution paths. +- `Extensions/NavigationExtensions.cs`: register core services and the six `AddPage`/`AddContent` overloads. +- `README.md`: rebuilt API, examples, migration note, and future-host boundary. +- Delete `Services/RegionService.cs` after `PageService` no longer references it. + +Use this stable SDK invocation throughout because the machine's default .NET 10 RC SDK lacks its matching runtime: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" +``` + +### Task 1: Add The Dual-Target WPF Test Harness + +**Files:** +- Modify: `SimpleNavigation.csproj` +- Modify: `SimpleNavigation.sln` +- Create: `Tests/SimpleNavigation.Tests/SimpleNavigation.Tests.csproj` +- Create: `Tests/SimpleNavigation.Tests/TestInfrastructure/AssemblyInfo.cs` +- Create: `Tests/SimpleNavigation.Tests/TestInfrastructure/StaTest.cs` +- Create: `Tests/SimpleNavigation.Tests/SmokeTests.cs` + +- [ ] **Step 1: Exclude tests from the root library's default SDK items** + +Add this property to the main `PropertyGroup` in `SimpleNavigation.csproj`: + +```xml +$(DefaultItemExcludesInProjectFolder);Tests/** +``` + +- [ ] **Step 2: Create the test project** + +Create `Tests/SimpleNavigation.Tests/SimpleNavigation.Tests.csproj` with exactly: + +```xml + + + net48;net8.0-windows + true + enable + enable + 13 + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + +``` + +Add it to the solution: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" sln SimpleNavigation.sln add Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj +``` + +- [ ] **Step 3: Add deterministic STA infrastructure** + +Create `TestInfrastructure/AssemblyInfo.cs`: + +```csharp +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] +``` + +Create `TestInfrastructure/StaTest.cs`: + +```csharp +using System.Runtime.ExceptionServices; +using System.Diagnostics; +using System.Threading; +using System.Windows.Threading; + +namespace SimpleNavigation.Tests.TestInfrastructure +{ + internal static class StaTest + { + public static void Run(Action action) + { + Exception? failure = null; + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception exception) + { + failure = exception; + } + finally + { + Dispatcher.CurrentDispatcher.InvokeShutdown(); + } + }); + + thread.IsBackground = true; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + if (!thread.Join(TimeSpan.FromSeconds(20))) + throw new TimeoutException("The STA test did not finish within 20 seconds."); + + if (failure != null) + ExceptionDispatchInfo.Capture(failure).Throw(); + } + + public static void PumpDispatcher() + { + var frame = new DispatcherFrame(); + Dispatcher.CurrentDispatcher.BeginInvoke( + DispatcherPriority.Background, + new Action(() => frame.Continue = false)); + Dispatcher.PushFrame(frame); + } + + public static void PumpUntil(Func condition) + { + var timeout = Stopwatch.StartNew(); + while (!condition()) + { + if (timeout.Elapsed > TimeSpan.FromSeconds(5)) + throw new TimeoutException("The WPF condition was not reached within 5 seconds."); + PumpDispatcher(); + } + } + } +} +``` + +- [ ] **Step 4: Add and run a baseline smoke test** + +Create `SmokeTests.cs`: + +```csharp +using SimpleNavigation.Common; + +namespace SimpleNavigation.Tests +{ + public class SmokeTests + { + [Fact] + public void DialogParametersRoundTripsAValue() + { + var parameters = new DialogParameters("answer", 42); + + Assert.Equal(42, parameters.Get("answer")); + } + } +} +``` + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" restore SimpleNavigation.sln +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj --no-restore +``` + +Expected: both `net48` and `net8.0-windows` pass one test with zero failures. + +- [ ] **Step 5: Commit the test harness** + +```powershell +git add SimpleNavigation.csproj SimpleNavigation.sln Tests +git commit -m "test: add dual-target WPF test harness" +``` + +### Task 2: Move Programmatic Region Ownership Into RegionManager + +**Files:** +- Create: `Interface/IRegionManager.cs` +- Create: `Common/RegionHostAdapter.cs` +- Create: `Common/FrameRegionAdapter.cs` +- Create: `Common/ContentControlRegionAdapter.cs` +- Create: `Common/RegionManager.cs` +- Create: `Tests/SimpleNavigation.Tests/RegionManagerTests.cs` + +- [ ] **Step 1: Write failing public-contract tests** + +Create `RegionManagerTests.cs`: + +```csharp +using SimpleNavigation.Common; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Runtime.CompilerServices; +using System.Windows.Controls; + +namespace SimpleNavigation.Tests +{ + public class RegionManagerTests + { + [Fact] + public void RegisterGetAndUnregisterPreserveHostIdentity() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var host = new ContentControl(); + + manager.RegisterRegion("main", host); + + Assert.Same(host, manager.GetRegion("main")); + Assert.Same(host, manager.GetRegion("main")); + Assert.Null(manager.GetRegion("main")); + Assert.True(manager.UnregisterRegion("main", host)); + Assert.Null(manager.GetRegion("main")); + }); + } + + [Fact] + public void RegisteringTheSameHostTwiceIsIdempotent() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var host = new Frame(); + + manager.RegisterRegion("main", host); + manager.RegisterRegion("main", host); + + Assert.Same(host, manager.GetRegion("main")); + }); + } + + [Fact] + public void ASecondLiveHostWithTheSameNameIsRejected() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var first = new ContentControl(); + manager.RegisterRegion("main", first); + + var exception = Assert.Throws( + () => manager.RegisterRegion("main", new ContentControl())); + + Assert.Contains("main", exception.Message); + GC.KeepAlive(first); + }); + } + + [Fact] + public void UnregisterDoesNotRemoveAnotherHostsRegistration() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var owner = new ContentControl(); + manager.RegisterRegion("main", owner); + + Assert.False(manager.UnregisterRegion("main", new ContentControl())); + Assert.Same(owner, manager.GetRegion("main")); + }); + } + + [Fact] + public void UnsupportedProgrammaticHostIsRejected() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var exception = Assert.Throws( + () => manager.RegisterRegion("main", new Grid())); + + Assert.Contains(typeof(Grid).FullName!, exception.Message); + }); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void InvalidRegionNameIsRejected(string regionName) + { + StaTest.Run(() => + { + var manager = new RegionManager(); + Assert.Throws( + () => manager.RegisterRegion(regionName, new ContentControl())); + }); + } + + [Fact] + public void ManagerDoesNotKeepAnAbandonedHostAlive() + { + StaTest.Run(() => + { + var manager = new RegionManager(); + var weakHost = RegisterTemporaryHost(manager); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.False(weakHost.IsAlive); + Assert.Null(manager.GetRegion("temporary")); + }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference RegisterTemporaryHost(RegionManager manager) + { + var host = new ContentControl(); + manager.RegisterRegion("temporary", host); + return new WeakReference(host); + } + } +} +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~RegionManagerTests --no-restore +``` + +Expected: build fails because `RegionManager` and `IRegionManager` do not exist. + +- [ ] **Step 3: Add the minimal region contract and host classification** + +Create `Interface/IRegionManager.cs`: + +```csharp +using System.Windows; + +namespace SimpleNavigation.Interface +{ + public interface IRegionManager + { + void RegisterRegion(string regionName, FrameworkElement region); + bool UnregisterRegion(string regionName, FrameworkElement region); + FrameworkElement? GetRegion(string regionName); + TRegion? GetRegion(string regionName) where TRegion : FrameworkElement; + } +} +``` + +Create `Common/RegionHostAdapter.cs`: + +```csharp +using System.Windows; + +namespace SimpleNavigation.Common +{ + internal enum RegionHostKind + { + Page, + Content + } + + internal interface IRegionHostAdapter + { + RegionHostKind Kind { get; } + bool CanHandle(FrameworkElement region); + } + + internal static class RegionHostAdapterResolver + { + private static readonly IRegionHostAdapter[] Adapters = + { + new FrameRegionAdapter(), + new ContentControlRegionAdapter() + }; + + public static IRegionHostAdapter GetRequired(FrameworkElement region) + { + foreach (var adapter in Adapters) + { + if (adapter.CanHandle(region)) + return adapter; + } + + throw new ArgumentException( + $"Region host type '{region.GetType().FullName}' is not supported.", + nameof(region)); + } + } +} +``` + +Create `FrameRegionAdapter.cs`: + +```csharp +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Common +{ + internal sealed class FrameRegionAdapter : IRegionHostAdapter + { + public RegionHostKind Kind => RegionHostKind.Page; + + public bool CanHandle(FrameworkElement region) => region is Frame; + } +} +``` + +Create `ContentControlRegionAdapter.cs`: + +```csharp +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Common +{ + internal sealed class ContentControlRegionAdapter : IRegionHostAdapter + { + public RegionHostKind Kind => RegionHostKind.Content; + + public bool CanHandle(FrameworkElement region) => + region is ContentControl && region is not Frame; + } +} +``` + +- [ ] **Step 4: Implement weak programmatic region lookup** + +Create `Common/RegionManager.cs`: + +```csharp +using SimpleNavigation.Interface; +using System.Windows; + +namespace SimpleNavigation.Common +{ + public sealed class RegionManager : IRegionManager + { + private readonly Dictionary regions = + new(StringComparer.Ordinal); + private readonly object syncRoot = new(); + + public void RegisterRegion(string regionName, FrameworkElement region) + { + ValidateName(regionName); + if (region == null) + throw new ArgumentNullException(nameof(region)); + RegionHostAdapterResolver.GetRequired(region); + + lock (syncRoot) + { + if (regions.TryGetValue(regionName, out var existing) && + existing.Region.TryGetTarget(out var existingRegion)) + { + if (ReferenceEquals(existingRegion, region)) + { + existing.IsProgrammatic = true; + return; + } + + throw new InvalidOperationException( + $"Region '{regionName}' is already registered by '{existingRegion.GetType().FullName}'."); + } + + regions[regionName] = new RegionEntry(region) + { + IsProgrammatic = true + }; + } + } + + public bool UnregisterRegion(string regionName, FrameworkElement region) + { + ValidateName(regionName); + if (region == null) + throw new ArgumentNullException(nameof(region)); + + lock (syncRoot) + { + if (!regions.TryGetValue(regionName, out var existing) || + !existing.Region.TryGetTarget(out var existingRegion) || + !ReferenceEquals(existingRegion, region) || + !existing.IsProgrammatic) + return false; + + existing.IsProgrammatic = false; + if (existing.DeclarationTokens.Count == 0) + regions.Remove(regionName); + return true; + } + } + + public FrameworkElement? GetRegion(string regionName) + { + ValidateName(regionName); + + lock (syncRoot) + { + if (!regions.TryGetValue(regionName, out var entry)) + return null; + + if (entry.Region.TryGetTarget(out var region)) + return region; + + regions.Remove(regionName); + return null; + } + } + + public TRegion? GetRegion(string regionName) + where TRegion : FrameworkElement => GetRegion(regionName) as TRegion; + + private static void ValidateName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) + throw new ArgumentException("Region name cannot be null or whitespace.", nameof(regionName)); + } + + private sealed class RegionEntry + { + public RegionEntry(FrameworkElement region) + { + Region = new WeakReference(region); + } + + public WeakReference Region { get; } + public bool IsProgrammatic { get; set; } + public HashSet DeclarationTokens { get; } = new(); + } + } +} +``` + +- [ ] **Step 5: Run manager tests on both targets** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj --filter FullyQualifiedName~RegionManagerTests --no-restore +``` + +Expected: all `RegionManagerTests` pass for `net48` and `net8.0-windows`. + +- [ ] **Step 6: Commit programmatic region ownership** + +```powershell +git add Interface/IRegionManager.cs Common/RegionHostAdapter.cs Common/FrameRegionAdapter.cs Common/ContentControlRegionAdapter.cs Common/RegionManager.cs Tests/SimpleNavigation.Tests/RegionManagerTests.cs +git commit -m "feat: add region manager" +``` + +### Task 3: Replace Attached Region Registration With Region + +**Files:** +- Create: `Services/Region.cs` +- Modify: `Common/RegionManager.cs` +- Create: `Tests/SimpleNavigation.Tests/RegionTests.cs` + +- [ ] **Step 1: Write failing attached-property lifecycle tests** + +Create `RegionTests.cs` with these tests and a cleanup helper: + +```csharp +using SimpleNavigation.Common; +using SimpleNavigation.Services; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Tests +{ + public class RegionTests + { + [Fact] + public void FrameDeclaredBeforeManagerCreationIsReplayed() + { + StaTest.Run(() => + { + var frame = new Frame(); + try + { + Region.SetRegionName(frame, "main"); + using var manager = new RegionManager(); + + Assert.Same(frame, manager.GetRegion("main")); + } + finally + { + frame.ClearValue(Region.RegionNameProperty); + } + }); + } + + [Fact] + public void ContentControlIsSupportedAndGridHostIsRejected() + { + StaTest.Run(() => + { + var content = new ContentControl(); + try + { + Region.SetRegionName(content, "content"); + using var manager = new RegionManager(); + Assert.Same(content, manager.GetRegion("content")); + + Assert.Throws( + () => Region.SetRegionName(new Grid(), "grid")); + } + finally + { + content.ClearValue(Region.RegionNameProperty); + } + }); + } + + [Fact] + public void RenameUnregistersOldNameAndRegistersNewName() + { + StaTest.Run(() => + { + using var manager = new RegionManager(); + var host = new ContentControl(); + try + { + Region.SetRegionName(host, "old"); + Region.SetRegionName(host, "new"); + + Assert.Null(manager.GetRegion("old")); + Assert.Same(host, manager.GetRegion("new")); + } + finally + { + host.ClearValue(Region.RegionNameProperty); + } + }); + } + + [Fact] + public void ClearingTheAttachedPropertyUnregistersTheHost() + { + StaTest.Run(() => + { + using var manager = new RegionManager(); + var host = new ContentControl(); + Region.SetRegionName(host, "main"); + + host.ClearValue(Region.RegionNameProperty); + + Assert.Null(manager.GetRegion("main")); + }); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void AttachedPropertyRejectsInvalidNames(string name) + { + StaTest.Run(() => + Assert.Throws( + () => Region.SetRegionName(new ContentControl(), name))); + } + + [Fact] + public void UnloadRemovesAndLoadRestoresTheRegion() + { + StaTest.Run(() => + { + using var manager = new RegionManager(); + var host = new ContentControl(); + try + { + Region.SetRegionName(host, "main"); + host.RaiseEvent(new RoutedEventArgs(FrameworkElement.UnloadedEvent)); + Assert.Null(manager.GetRegion("main")); + + host.RaiseEvent(new RoutedEventArgs(FrameworkElement.LoadedEvent)); + Assert.Same(host, manager.GetRegion("main")); + } + finally + { + host.ClearValue(Region.RegionNameProperty); + } + }); + } + + [Fact] + public void AttachedUnloadDoesNotRemoveProgrammaticOwnership() + { + StaTest.Run(() => + { + using var manager = new RegionManager(); + var host = new ContentControl(); + try + { + manager.RegisterRegion("main", host); + Region.SetRegionName(host, "main"); + host.RaiseEvent(new RoutedEventArgs(FrameworkElement.UnloadedEvent)); + + Assert.Same(host, manager.GetRegion("main")); + Assert.True(manager.UnregisterRegion("main", host)); + Assert.Null(manager.GetRegion("main")); + + host.RaiseEvent(new RoutedEventArgs(FrameworkElement.LoadedEvent)); + Assert.Same(host, manager.GetRegion("main")); + } + finally + { + host.ClearValue(Region.RegionNameProperty); + } + }); + } + + [Fact] + public void DuplicateAttachedRegionNameIsRejected() + { + StaTest.Run(() => + { + var first = new ContentControl(); + var second = new ContentControl(); + try + { + Region.SetRegionName(first, "main"); + Assert.Throws( + () => Region.SetRegionName(second, "main")); + } + finally + { + first.ClearValue(Region.RegionNameProperty); + second.ClearValue(Region.RegionNameProperty); + } + }); + } + + [Fact] + public void DeclarationCatalogDoesNotKeepAnAbandonedHostAlive() + { + StaTest.Run(() => + { + var weakHost = DeclareTemporaryHost(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.False(weakHost.IsAlive); + using var manager = new RegionManager(); + Assert.Null(manager.GetRegion("temporary-declaration")); + }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference DeclareTemporaryHost() + { + var host = new ContentControl(); + Region.SetRegionName(host, "temporary-declaration"); + return new WeakReference(host); + } + } +} +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~RegionTests --no-restore +``` + +Expected: build fails because `SimpleNavigation.Services.Region` does not exist and `RegionManager` is not disposable. + +- [ ] **Step 3: Implement the weak declaration catalog** + +Create `Services/Region.cs` with: + +```csharp +using SimpleNavigation.Common; +using System.Runtime.ExceptionServices; +using System.Windows; +using System.Threading; + +namespace SimpleNavigation.Services +{ + public static class Region + { + private static readonly object SyncRoot = new(); + private static readonly List Declarations = new(); + private static long nextActivationToken; + + internal static event EventHandler? RegistrationChanged; + + public static string? GetRegionName(DependencyObject obj) => + (string?)obj.GetValue(RegionNameProperty); + + public static void SetRegionName(DependencyObject obj, string value) + { + if (obj == null) + throw new ArgumentNullException(nameof(obj)); + ValidateName(value); + if (obj is not FrameworkElement region) + throw new ArgumentException( + "RegionName can only be attached to FrameworkElement instances.", + nameof(obj)); + RegionHostAdapterResolver.GetRequired(region); + ValidateNameAvailable(value, region); + obj.SetValue(RegionNameProperty, value); + } + + public static readonly DependencyProperty RegionNameProperty = + DependencyProperty.RegisterAttached( + "RegionName", + typeof(string), + typeof(Region), + new PropertyMetadata(null, OnRegionNameChanged)); + + internal static IReadOnlyList GetActiveRegistrations() + { + lock (SyncRoot) + { + var result = new List(); + for (var index = Declarations.Count - 1; index >= 0; index--) + { + var declaration = Declarations[index]; + if (!declaration.Region.TryGetTarget(out var region)) + { + Declarations.RemoveAt(index); + continue; + } + + if (declaration.IsActive) + result.Add(new RegionRegistration( + declaration.Name, + region, + declaration.ActivationToken)); + } + + return result; + } + } + + private static void OnRegionNameChanged( + DependencyObject dependencyObject, + DependencyPropertyChangedEventArgs args) + { + if (dependencyObject is not FrameworkElement region) + throw new ArgumentException("RegionName can only be attached to FrameworkElement instances."); + + var newName = args.NewValue as string; + if (newName != null) + { + ValidateName(newName); + RegionHostAdapterResolver.GetRequired(region); + ValidateNameAvailable(newName, region); + } + + var oldName = args.OldValue as string; + if (oldName != null) + { + region.Loaded -= OnLoaded; + region.Unloaded -= OnUnloaded; + RemoveDeclaration(oldName, region); + } + + if (newName == null) + return; + + region.Loaded += OnLoaded; + region.Unloaded += OnUnloaded; + AddOrActivateDeclaration(newName, region); + } + + private static void OnLoaded(object sender, RoutedEventArgs args) + { + var region = (FrameworkElement)sender; + var name = GetRegionName(region); + if (name != null) + AddOrActivateDeclaration(name, region); + } + + private static void OnUnloaded(object sender, RoutedEventArgs args) + { + var region = (FrameworkElement)sender; + var name = GetRegionName(region); + if (name != null) + DeactivateDeclaration(name, region); + } + + private static void AddOrActivateDeclaration(string name, FrameworkElement region) + { + long activationToken; + lock (SyncRoot) + { + EnsureNameAvailableNoLock(name, region); + var declaration = Find(region); + if (declaration == null) + { + declaration = new Declaration( + name, + region, + Interlocked.Increment(ref nextActivationToken)); + Declarations.Add(declaration); + } + else + { + declaration.Name = name; + if (declaration.IsActive) + return; + declaration.IsActive = true; + declaration.ActivationToken = + Interlocked.Increment(ref nextActivationToken); + } + activationToken = declaration.ActivationToken; + } + + Publish(new RegionRegistrationChangedEventArgs( + name, + region, + activationToken, + true)); + } + + private static void DeactivateDeclaration(string name, FrameworkElement region) + { + long activationToken; + lock (SyncRoot) + { + var declaration = Find(region); + if (declaration == null || !declaration.IsActive) + return; + activationToken = declaration.ActivationToken; + declaration.IsActive = false; + } + + Publish(new RegionRegistrationChangedEventArgs( + name, + region, + activationToken, + false)); + } + + private static void RemoveDeclaration(string name, FrameworkElement region) + { + var notify = false; + long activationToken = 0; + lock (SyncRoot) + { + var declaration = Find(region); + if (declaration == null) + return; + notify = declaration.IsActive; + activationToken = declaration.ActivationToken; + Declarations.Remove(declaration); + } + + if (notify) + Publish(new RegionRegistrationChangedEventArgs( + name, + region, + activationToken, + false)); + } + + private static Declaration? Find(FrameworkElement region) + { + foreach (var declaration in Declarations) + { + if (declaration.Region.TryGetTarget(out var target) && + ReferenceEquals(target, region)) + return declaration; + } + + return null; + } + + private static void ValidateNameAvailable( + string name, + FrameworkElement region) + { + lock (SyncRoot) + EnsureNameAvailableNoLock(name, region); + } + + private static void EnsureNameAvailableNoLock( + string name, + FrameworkElement region) + { + for (var index = Declarations.Count - 1; index >= 0; index--) + { + var declaration = Declarations[index]; + if (!declaration.Region.TryGetTarget(out var existing)) + { + Declarations.RemoveAt(index); + continue; + } + + if (declaration.IsActive && + string.Equals(declaration.Name, name, StringComparison.Ordinal) && + !ReferenceEquals(existing, region)) + throw new InvalidOperationException( + $"Region '{name}' is already declared by '{existing.GetType().FullName}'."); + } + } + + private static void Publish(RegionRegistrationChangedEventArgs args) + { + var invocationList = RegistrationChanged?.GetInvocationList(); + if (invocationList == null) + return; + + Exception? firstFailure = null; + foreach (var callback in invocationList) + { + var handler = (EventHandler)callback; + try + { + handler(null, args); + } + catch (Exception exception) + { + firstFailure ??= exception; + } + } + + if (firstFailure != null) + ExceptionDispatchInfo.Capture(firstFailure).Throw(); + } + + private static void ValidateName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Region name cannot be null or whitespace.", nameof(name)); + } + + private sealed class Declaration + { + public Declaration( + string name, + FrameworkElement region, + long activationToken) + { + Name = name; + Region = new WeakReference(region); + ActivationToken = activationToken; + IsActive = true; + } + + public string Name { get; set; } + public WeakReference Region { get; } + public long ActivationToken { get; set; } + public bool IsActive { get; set; } + } + } + + internal sealed class RegionRegistration + { + public RegionRegistration( + string name, + FrameworkElement region, + long activationToken) + { + Name = name; + Region = region; + ActivationToken = activationToken; + } + + public string Name { get; } + public FrameworkElement Region { get; } + public long ActivationToken { get; } + } + + internal sealed class RegionRegistrationChangedEventArgs : EventArgs + { + public RegionRegistrationChangedEventArgs( + string name, + FrameworkElement region, + long activationToken, + bool isRegistered) + { + Name = name; + Region = region; + ActivationToken = activationToken; + IsRegistered = isRegistered; + } + + public string Name { get; } + public FrameworkElement Region { get; } + public long ActivationToken { get; } + public bool IsRegistered { get; } + } +} +``` + +- [ ] **Step 4: Subscribe RegionManager before importing the snapshot** + +Update `RegionManager` to implement `IDisposable`, subscribe before importing `Region.GetActiveRegistrations()`, and track attached activation tokens independently from public programmatic ownership: + +Add this field beside `syncRoot`: + +```csharp +private bool isDisposed; +``` + +```csharp +public RegionManager() +{ + Region.RegistrationChanged += OnRegistrationChanged; + try + { + foreach (var registration in Region.GetActiveRegistrations()) + RegisterDeclaration( + registration.Name, + registration.Region, + registration.ActivationToken); + } + catch + { + Region.RegistrationChanged -= OnRegistrationChanged; + throw; + } +} + +private void OnRegistrationChanged( + object? sender, + RegionRegistrationChangedEventArgs args) +{ + if (args.IsRegistered) + RegisterDeclaration(args.Name, args.Region, args.ActivationToken); + else + UnregisterDeclaration(args.Name, args.Region, args.ActivationToken); +} + +private void RegisterDeclaration( + string regionName, + FrameworkElement region, + long activationToken) +{ + ValidateName(regionName); + RegionHostAdapterResolver.GetRequired(region); + + lock (syncRoot) + { + if (isDisposed) + return; + + RegionEntry entry; + if (regions.TryGetValue(regionName, out var existing) && + existing.Region.TryGetTarget(out var existingRegion)) + { + if (!ReferenceEquals(existingRegion, region)) + throw new InvalidOperationException( + $"Region '{regionName}' is already registered by '{existingRegion.GetType().FullName}'."); + entry = existing; + } + else + { + entry = new RegionEntry(region); + regions[regionName] = entry; + } + + entry.DeclarationTokens.Add(activationToken); + } +} + +private void UnregisterDeclaration( + string regionName, + FrameworkElement region, + long activationToken) +{ + lock (syncRoot) + { + if (isDisposed) + return; + + if (!regions.TryGetValue(regionName, out var entry) || + !entry.Region.TryGetTarget(out var existingRegion) || + !ReferenceEquals(existingRegion, region)) + return; + + entry.DeclarationTokens.Remove(activationToken); + if (!entry.IsProgrammatic && entry.DeclarationTokens.Count == 0) + regions.Remove(regionName); + } +} + +public void Dispose() +{ + Region.RegistrationChanged -= OnRegistrationChanged; + lock (syncRoot) + { + if (isDisposed) + return; + isDisposed = true; + regions.Clear(); + } +} +``` + +Add `using SimpleNavigation.Services;` and change the declaration to: + +```csharp +public sealed class RegionManager : IRegionManager, IDisposable +``` + +- [ ] **Step 5: Run region lifecycle tests on both targets** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj --filter "FullyQualifiedName~RegionTests|FullyQualifiedName~RegionManagerTests" --no-restore +``` + +Expected: all region tests pass for both target frameworks. + +- [ ] **Step 6: Commit declarative region support** + +```powershell +git add Services/Region.cs Common/RegionManager.cs Tests/SimpleNavigation.Tests/RegionTests.cs +git commit -m "feat: add Region attached registration" +``` + +### Task 4: Add Explicit Page And Content Route Registration + +**Files:** +- Create: `Common/NavigationRouteRegistry.cs` +- Modify: `Extensions/NavigationExtensions.cs` +- Create: `Tests/SimpleNavigation.Tests/TestTypes.cs` +- Create: `Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs` + +- [ ] **Step 1: Add reusable test view types** + +Create `TestTypes.cs`: + +```csharp +using SimpleNavigation.Common; +using System.Windows.Controls; + +namespace SimpleNavigation.Tests +{ + public sealed class FirstPage : Page + { + } + + public sealed class SecondPage : Page + { + } + + public sealed class TestContent : UserControl + { + } + + public sealed class TestViewModel + { + } + +} +``` + +- [ ] **Step 2: Write failing extension tests** + +Create `NavigationExtensionsTests.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Extensions; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Windows; + +namespace SimpleNavigation.Tests +{ + public class NavigationExtensionsTests + { + [Fact] + public void SingleGenericKeyOverloadsRegisterTransientViews() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddPage("page"); + services.AddContent("content"); + using var provider = services.BuildServiceProvider(); + + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + }); + } + + [Fact] + public void DoubleGenericOverloadsRegisterViewAndViewModel() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.AddPage(); + services.AddContent("content"); + using var provider = services.BuildServiceProvider(); + + Assert.IsType(provider.GetRequiredService()); + var content = provider.GetRequiredService(); + Assert.IsType(content); + Assert.Null(content.DataContext); + Assert.IsType(provider.GetRequiredService()); + }); + } + + [Fact] + public void ExistingSingletonRegistrationIsPreserved() + { + StaTest.Run(() => + { + var page = new FirstPage(); + var services = new ServiceCollection(); + services.AddSingleton(page); + services.AddPage("page"); + using var provider = services.BuildServiceProvider(); + + Assert.Same(page, provider.GetRequiredService()); + }); + } + + [Fact] + public void DuplicateKeyWithinOneCategoryIsRejected() + { + var services = new ServiceCollection(); + services.AddPage("main"); + + Assert.Throws( + () => services.AddPage("main")); + } + + [Fact] + public void TheSameKeyCanExistInPageAndContentMaps() + { + var services = new ServiceCollection(); + + services.AddPage("main"); + services.AddContent("main"); + } + + [Fact] + public void ContentRegistrationRejectsPageAndWindowTypes() + { + var services = new ServiceCollection(); + + Assert.Throws( + () => services.AddContent("page")); + Assert.Throws( + () => services.AddContent("window")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void InvalidRouteKeyIsRejected(string key) + { + Assert.Throws( + () => new ServiceCollection().AddPage(key)); + } + } +} +``` + +- [ ] **Step 3: Run extension tests and verify RED** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~NavigationExtensionsTests --no-restore +``` + +Expected: build fails because the six `AddPage` and `AddContent` methods do not exist. + +- [ ] **Step 4: Implement immutable route descriptors and maps** + +Create `Common/NavigationRouteRegistry.cs`: + +```csharp +namespace SimpleNavigation.Common +{ + internal enum NavigationRouteKind + { + Page, + Content + } + + internal sealed class NavigationRouteRegistration + { + public NavigationRouteRegistration( + NavigationRouteKind kind, + string key, + Type targetType) + { + Kind = kind; + Key = key; + TargetType = targetType; + } + + public NavigationRouteKind Kind { get; } + public string Key { get; } + public Type TargetType { get; } + } + + internal sealed class NavigationRouteRegistry + { + private readonly IReadOnlyDictionary pages; + private readonly IReadOnlyDictionary contents; + + public NavigationRouteRegistry(IEnumerable registrations) + { + pages = Build(registrations, NavigationRouteKind.Page); + contents = Build(registrations, NavigationRouteKind.Content); + } + + public Type GetRequiredPageType(string key) => + GetRequired(pages, key, "page"); + + public Type GetRequiredContentType(string key) => + GetRequired(contents, key, "content"); + + private static IReadOnlyDictionary Build( + IEnumerable registrations, + NavigationRouteKind kind) + { + var routes = new Dictionary(StringComparer.Ordinal); + foreach (var registration in registrations.Where(item => item.Kind == kind)) + routes.Add(registration.Key, registration.TargetType); + return routes; + } + + private static Type GetRequired( + IReadOnlyDictionary routes, + string key, + string category) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Route key cannot be null or whitespace.", nameof(key)); + + if (routes.TryGetValue(key, out var targetType)) + return targetType; + + throw new KeyNotFoundException( + $"No {category} route is registered for key '{key}'."); + } + } +} +``` + +- [ ] **Step 5: Add the six extension overloads** + +In `NavigationExtensions`, add the following public methods and private helpers. Preserve the existing `TryAddSingleton` registrations for dialogs and pages. + +```csharp +public static IServiceCollection AddPage( + this IServiceCollection services, + string key) + where TPage : Page +{ + services.TryAddTransient(); + AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); + return services; +} + +public static IServiceCollection AddPage( + this IServiceCollection services) + where TPage : Page + where TViewModel : class +{ + services.TryAddTransient(); + services.TryAddTransient(); + return services; +} + +public static IServiceCollection AddPage( + this IServiceCollection services, + string key) + where TPage : Page + where TViewModel : class +{ + services.AddPage(); + AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); + return services; +} + +public static IServiceCollection AddContent( + this IServiceCollection services, + string key) + where TView : FrameworkElement +{ + ValidateContentType(typeof(TView)); + services.TryAddTransient(); + AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); + return services; +} + +public static IServiceCollection AddContent( + this IServiceCollection services) + where TView : FrameworkElement + where TViewModel : class +{ + ValidateContentType(typeof(TView)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; +} + +public static IServiceCollection AddContent( + this IServiceCollection services, + string key) + where TView : FrameworkElement + where TViewModel : class +{ + services.AddContent(); + AddRoute(services, NavigationRouteKind.Content, key, typeof(TView)); + return services; +} + +private static void AddRoute( + IServiceCollection services, + NavigationRouteKind kind, + string key, + Type targetType) +{ + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Route key cannot be null or whitespace.", nameof(key)); + + var duplicate = services.Any(descriptor => + descriptor.ServiceType == typeof(NavigationRouteRegistration) && + descriptor.ImplementationInstance is NavigationRouteRegistration route && + route.Kind == kind && + string.Equals(route.Key, key, StringComparison.Ordinal)); + + if (duplicate) + throw new ArgumentException( + $"A {kind.ToString().ToLowerInvariant()} route with key '{key}' is already registered.", + nameof(key)); + + services.AddSingleton(new NavigationRouteRegistration(kind, key, targetType)); +} + +private static void ValidateContentType(Type targetType) +{ + if (typeof(Page).IsAssignableFrom(targetType) || + typeof(Window).IsAssignableFrom(targetType)) + throw new ArgumentException( + $"Content type '{targetType.FullName}' cannot derive from Page or Window.", + nameof(targetType)); +} +``` + +Add these core registrations inside `RegisterNavigationService`: + +```csharp +serviceCollection.TryAddSingleton(); +serviceCollection.TryAddSingleton(provider => + new NavigationRouteRegistry( + provider.GetServices())); +``` + +Add explicit usings for `System.Windows` and `System.Windows.Controls` so both target frameworks compile. + +- [ ] **Step 6: Run extension and region tests** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj --filter "FullyQualifiedName~NavigationExtensionsTests|FullyQualifiedName~Region" --no-restore +``` + +Expected: all selected tests pass for both target frameworks. + +- [ ] **Step 7: Commit explicit route registration** + +```powershell +git add Common/NavigationRouteRegistry.cs Extensions/NavigationExtensions.cs Tests/SimpleNavigation.Tests/TestTypes.cs Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs +git commit -m "feat: add explicit navigation routes" +``` + +### Task 5: Refactor PageService Around RegionManager + +**Files:** +- Create: `Interface/INavigationAware.cs` +- Create: `Common/NavigationAwareNotifier.cs` +- Modify: `Interface/IPageAware.cs` +- Modify: `Interface/IPageService.cs` +- Modify: `Common/RegionHostAdapter.cs` +- Modify: `Common/FrameRegionAdapter.cs` +- Replace: `Services/PageService.cs` +- Delete: `Services/RegionService.cs` +- Modify: `Tests/SimpleNavigation.Tests/TestTypes.cs` +- Create: `Tests/SimpleNavigation.Tests/PageServiceTests.cs` + +- [ ] **Step 1: Add awareness fixtures and failing page navigation tests** + +Add `using SimpleNavigation.Interface;` to `TestTypes.cs`, then add these two types: + +```csharp +public sealed class AwareViewModel : INavigationAware +{ + public int CallCount { get; private set; } + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} + +public sealed class AwarePage : Page, INavigationAware +{ + public int CallCount { get; private set; } + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} + +public sealed class ThrowingAwarePage : Page, INavigationAware +{ + public void OnNavigated(DialogParameters? parameters) => + throw new InvalidOperationException("awareness failed"); +} +``` + +Create `PageServiceTests.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Extensions; +using SimpleNavigation.Interface; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Windows.Controls; +using System.Windows.Navigation; + +namespace SimpleNavigation.Tests +{ + public class PageServiceTests + { + [Fact] + public void GenericAndTypeNavigationDoNotRequireRoutes() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + var service = provider.GetRequiredService(); + + service.Navigate("main"); + StaTest.PumpUntil(() => frame.Content is FirstPage); + Assert.IsType(frame.Content); + + service.Navigate("main", typeof(SecondPage)); + StaTest.PumpUntil(() => frame.Content is SecondPage); + Assert.IsType(frame.Content); + }); + } + + [Fact] + public void StringNavigationUsesRouteThenOrdinaryDi() + { + StaTest.Run(() => + { + var expected = new FirstPage(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(expected); + services.AddPage("first"); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + provider.GetRequiredService() + .Navigate("main", "first"); + + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, expected)); + Assert.Same(expected, frame.Content); + Assert.Throws( + () => provider.GetRequiredService() + .Navigate("main", "First")); + }); + } + + [Fact] + public void ViewAndDataContextReceiveParametersOnceEach() + { + StaTest.Run(() => + { + var viewModel = new AwareViewModel(); + var page = new AwarePage { DataContext = viewModel }; + var parameters = new DialogParameters("id", 7); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(page); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + provider.GetRequiredService() + .Navigate("main", parameters); + + Assert.Equal(1, page.CallCount); + Assert.Equal(1, viewModel.CallCount); + Assert.Same(parameters, page.Parameters); + Assert.Same(parameters, viewModel.Parameters); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void NullParametersStillTriggerAwarenessAndSameInstanceIsDeduplicated() + { + StaTest.Run(() => + { + var page = new AwarePage(); + page.DataContext = page; + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(page); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + provider.GetRequiredService() + .Navigate("main"); + + Assert.Equal(1, page.CallCount); + Assert.Null(page.Parameters); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void MissingWrongAndUnknownInputsFailFast() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var contentHost = new ContentControl(); + provider.GetRequiredService() + .RegisterRegion("content", contentHost); + var service = provider.GetRequiredService(); + + Assert.Throws( + () => service.Navigate("missing")); + Assert.Throws( + () => service.Navigate("content")); + Assert.Throws( + () => service.Navigate("content", typeof(TestContent))); + Assert.Throws( + () => service.Navigate("content", "unknown")); + GC.KeepAlive(contentHost); + }); + } + + [Fact] + public void GoBackUsesTheFrameJournalAndLegacyMethodForwards() + { + StaTest.Run(() => + { + var first = new FirstPage(); + var second = new SecondPage(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(first); + services.AddSingleton(second); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + var service = provider.GetRequiredService(); + + service.Navigate("main"); + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, first)); + service.Navigate("main"); + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, second)); + Assert.True(frame.CanGoBack); + + service.GoBack("main"); + StaTest.PumpUntil(() => ReferenceEquals(frame.Content, first)); + Assert.Same(first, frame.Content); + +#pragma warning disable CS0618 + service.Goback("main"); +#pragma warning restore CS0618 + }); + } + + [Fact] + public void MissingDiRegistrationKeepsTheContainerException() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + var exception = Assert.Throws( + () => provider.GetRequiredService() + .Navigate("main")); + + Assert.Contains(typeof(FirstPage).FullName!, exception.Message); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void AwarenessExceptionPropagatesToTheCaller() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(new ThrowingAwarePage()); + using var provider = services.BuildServiceProvider(); + var frame = RegisterFrame(provider, "main"); + + var exception = Assert.Throws( + () => provider.GetRequiredService() + .Navigate("main")); + + Assert.Equal("awareness failed", exception.Message); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void NavigationRequiresTheHostDispatcherThread() + { + ServiceProvider? provider = null; + IPageService? service = null; + Frame? frame = null; + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(new FirstPage()); + provider = services.BuildServiceProvider(); + frame = RegisterFrame(provider, "main"); + service = provider.GetRequiredService(); + }); + + try + { + Assert.Throws( + () => service!.Navigate("main")); + GC.KeepAlive(frame); + } + finally + { + provider!.Dispose(); + } + } + + private static Frame RegisterFrame(IServiceProvider provider, string name) + { + var frame = new Frame + { + JournalOwnership = JournalOwnership.OwnsJournal, + NavigationUIVisibility = NavigationUIVisibility.Hidden + }; + provider.GetRequiredService().RegisterRegion(name, frame); + return frame; + } + } +} +``` + +- [ ] **Step 2: Run page tests and verify RED** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~PageServiceTests --no-restore +``` + +Expected: build fails because `INavigationAware`, string navigation, and `GoBack` do not exist. + +- [ ] **Step 3: Add the shared awareness contract and notifier** + +Create `Interface/INavigationAware.cs`: + +```csharp +using SimpleNavigation.Common; + +namespace SimpleNavigation.Interface +{ + public interface INavigationAware + { + void OnNavigated(DialogParameters? parameters); + } +} +``` + +Change `IPageAware` to inherit `INavigationAware`, retain `Receive`, and remove its duplicate `OnNavigated` declaration: + +```csharp +public interface IPageAware : INavigationAware +{ + event Action? Receive; +} +``` + +Create `Common/NavigationAwareNotifier.cs`: + +```csharp +using SimpleNavigation.Interface; +using System.Windows; + +namespace SimpleNavigation.Common +{ + internal static class NavigationAwareNotifier + { + public static void Notify( + FrameworkElement target, + DialogParameters? parameters) + { + if (target is INavigationAware targetAware) + targetAware.OnNavigated(parameters); + + var dataContext = target.DataContext; + if (dataContext is INavigationAware contextAware && + !ReferenceEquals(dataContext, target)) + contextAware.OnNavigated(parameters); + } + } +} +``` + +- [ ] **Step 4: Add frame navigation capability to the adapter** + +Add this interface to `RegionHostAdapter.cs`: + +```csharp +internal interface IPageRegionHostAdapter : IRegionHostAdapter +{ + bool Navigate(Frame frame, Page page); + bool CanGoBack(Frame frame); + void GoBack(Frame frame); +} +``` + +Add `using System.Windows.Controls;`. Change `FrameRegionAdapter` to implement it: + +```csharp +internal sealed class FrameRegionAdapter : IPageRegionHostAdapter +{ + public RegionHostKind Kind => RegionHostKind.Page; + + public bool CanHandle(FrameworkElement region) => region is Frame; + + public bool Navigate(Frame frame, Page page) + { + frame.Dispatcher.VerifyAccess(); + return frame.Navigate(page); + } + + public bool CanGoBack(Frame frame) + { + frame.Dispatcher.VerifyAccess(); + return frame.CanGoBack; + } + + public void GoBack(Frame frame) + { + frame.Dispatcher.VerifyAccess(); + frame.GoBack(); + } +} +``` + +- [ ] **Step 5: Replace the page contract and implementation** + +Replace `IPageService` with the exact public API approved in the design: + +```csharp +using SimpleNavigation.Common; +using System.Windows.Controls; + +namespace SimpleNavigation.Interface +{ + public interface IPageService + { + void Navigate(string regionName, DialogParameters? parameters = null) + where TPage : Page; + void Navigate(string regionName, Type targetType, DialogParameters? parameters = null); + void Navigate(string regionName, string key, DialogParameters? parameters = null); + void GoBack(string regionName); + + [Obsolete("Use GoBack instead.")] + void Goback(string regionName); + } +} +``` + +Replace `PageService.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Interface; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Services +{ + public class PageService : IPageService + { + private readonly IServiceProvider provider; + private readonly IRegionManager regionManager; + private readonly NavigationRouteRegistry routes; + + public PageService(IServiceProvider provider, IRegionManager regionManager) + { + this.provider = provider; + this.regionManager = regionManager; + routes = provider.GetRequiredService(); + } + + public void Navigate( + string regionName, + DialogParameters? parameters = null) + where TPage : Page + { + ValidateRegionName(regionName); + NavigateCore(regionName, provider.GetRequiredService(), parameters); + } + + public void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + ValidatePageType(targetType); + var page = provider.GetRequiredService(targetType) as Page + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a Page."); + NavigateCore(regionName, page, parameters); + } + + public void Navigate( + string regionName, + string key, + DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + var targetType = routes.GetRequiredPageType(key); + var page = provider.GetRequiredService(targetType) as Page + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a Page."); + NavigateCore(regionName, page, parameters); + } + + public void GoBack(string regionName) + { + var frame = GetRequiredFrame(regionName); + var adapter = (IPageRegionHostAdapter)RegionHostAdapterResolver.GetRequired(frame); + if (adapter.CanGoBack(frame)) + adapter.GoBack(frame); + } + + [Obsolete("Use GoBack instead.")] + public void Goback(string regionName) => GoBack(regionName); + + private void NavigateCore( + string regionName, + Page page, + DialogParameters? parameters) + { + var frame = GetRequiredFrame(regionName); + var adapter = (IPageRegionHostAdapter)RegionHostAdapterResolver.GetRequired(frame); + if (adapter.Navigate(frame, page)) + NavigationAwareNotifier.Notify(page, parameters); + } + + private Frame GetRequiredFrame(string regionName) + { + ValidateRegionName(regionName); + var region = regionManager.GetRegion(regionName); + if (region is Frame frame) + return frame; + + var actual = region?.GetType().FullName ?? "missing"; + throw new InvalidOperationException( + $"Region '{regionName}' must be a Frame but was '{actual}'."); + } + + private static void ValidatePageType(Type targetType) + { + if (targetType == null) + throw new ArgumentNullException(nameof(targetType)); + if (!typeof(Page).IsAssignableFrom(targetType)) + throw new ArgumentException( + $"Target type '{targetType.FullName}' must derive from Page.", + nameof(targetType)); + } + + private static void ValidateRegionName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) + throw new ArgumentException("Region name cannot be null or whitespace.", nameof(regionName)); + } + } +} +``` + +Delete `Services/RegionService.cs`; the new `Region` type is now the only attached-property owner. + +- [ ] **Step 6: Run page, region, and extension tests** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj --filter "FullyQualifiedName~PageServiceTests|FullyQualifiedName~Region|FullyQualifiedName~NavigationExtensionsTests" --no-restore +``` + +Expected: all selected tests pass on both target frameworks and no source file references `RegionService`. + +- [ ] **Step 7: Commit the page-service refactor** + +```powershell +git add Interface/INavigationAware.cs Interface/IPageAware.cs Interface/IPageService.cs Common/NavigationAwareNotifier.cs Common/RegionHostAdapter.cs Common/FrameRegionAdapter.cs Services/PageService.cs Services/RegionService.cs Tests/SimpleNavigation.Tests/TestTypes.cs Tests/SimpleNavigation.Tests/PageServiceTests.cs +git commit -m "feat: rebuild page navigation around regions" +``` + +### Task 6: Add ContentService For Non-Page FrameworkElements + +**Files:** +- Create: `Interface/IContentService.cs` +- Create: `Services/ContentService.cs` +- Modify: `Common/RegionHostAdapter.cs` +- Modify: `Common/ContentControlRegionAdapter.cs` +- Modify: `Extensions/NavigationExtensions.cs` +- Modify: `Tests/SimpleNavigation.Tests/TestTypes.cs` +- Create: `Tests/SimpleNavigation.Tests/ContentServiceTests.cs` + +- [ ] **Step 1: Add an aware content fixture** + +Append to `TestTypes.cs`: + +```csharp +public sealed class AwareContent : UserControl, INavigationAware +{ + public int CallCount { get; private set; } + public DialogParameters? Parameters { get; private set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} +``` + +- [ ] **Step 2: Write failing content navigation tests** + +Create `ContentServiceTests.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Extensions; +using SimpleNavigation.Interface; +using SimpleNavigation.Tests.TestInfrastructure; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Tests +{ + public class ContentServiceTests + { + [Fact] + public void GenericAndTypeNavigationDoNotRequireRoutes() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + var service = provider.GetRequiredService(); + + service.Navigate("main"); + Assert.IsType(host.Content); + + service.Navigate("main", typeof(Grid)); + Assert.IsType(host.Content); + }); + } + + [Fact] + public void StringNavigationUsesRouteThenOrdinaryDi() + { + StaTest.Run(() => + { + var expected = new TestContent(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(expected); + services.AddContent("content"); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + + provider.GetRequiredService() + .Navigate("main", "content"); + + Assert.Same(expected, host.Content); + }); + } + + [Fact] + public void ViewAndDataContextReceiveParametersOnceEach() + { + StaTest.Run(() => + { + var viewModel = new AwareViewModel(); + var content = new AwareContent { DataContext = viewModel }; + var parameters = new DialogParameters("id", 9); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(content); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + + provider.GetRequiredService() + .Navigate("main", parameters); + + Assert.Equal(1, content.CallCount); + Assert.Equal(1, viewModel.CallCount); + Assert.Same(parameters, content.Parameters); + Assert.Same(parameters, viewModel.Parameters); + GC.KeepAlive(host); + }); + } + + [Fact] + public void PageWindowAndFrameHostAreRejected() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var frame = new Frame(); + provider.GetRequiredService() + .RegisterRegion("frame", frame); + var service = provider.GetRequiredService(); + + Assert.Throws( + () => service.Navigate("frame")); + Assert.Throws( + () => service.Navigate("frame", typeof(Window))); + Assert.Throws( + () => service.Navigate("frame")); + GC.KeepAlive(frame); + }); + } + + [Fact] + public void MissingRegionUnknownKeyAndMissingDiFailFast() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddContent("known"); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + Assert.Throws( + () => service.Navigate("missing")); + Assert.Throws( + () => service.Navigate("missing", "unknown")); + Assert.Throws( + () => service.Navigate("missing", typeof(Grid))); + }); + } + + [Fact] + public void DoubleGenericRegistrationWithoutKeyDoesNotCreateARoute() + { + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddContent(); + using var provider = services.BuildServiceProvider(); + var host = RegisterContentHost(provider, "main"); + + Assert.Throws( + () => provider.GetRequiredService() + .Navigate("main", "content")); + GC.KeepAlive(host); + }); + } + + private static ContentControl RegisterContentHost( + IServiceProvider provider, + string name) + { + var host = new ContentControl(); + provider.GetRequiredService().RegisterRegion(name, host); + return host; + } + } +} +``` + +- [ ] **Step 3: Run content tests and verify RED** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~ContentServiceTests --no-restore +``` + +Expected: build fails because `IContentService` and `ContentService` do not exist. + +- [ ] **Step 4: Add the public content contract** + +Create `Interface/IContentService.cs`: + +```csharp +using SimpleNavigation.Common; +using System.Windows; + +namespace SimpleNavigation.Interface +{ + public interface IContentService + { + void Navigate( + string regionName, + DialogParameters? parameters = null) + where TContent : FrameworkElement; + + void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null); + + void Navigate( + string regionName, + string key, + DialogParameters? parameters = null); + } +} +``` + +- [ ] **Step 5: Add content presentation capability to the adapter** + +Add to `RegionHostAdapter.cs`: + +```csharp +internal interface IContentRegionHostAdapter : IRegionHostAdapter +{ + void Present(ContentControl host, FrameworkElement content); +} +``` + +Change `ContentControlRegionAdapter` to: + +```csharp +internal sealed class ContentControlRegionAdapter : IContentRegionHostAdapter +{ + public RegionHostKind Kind => RegionHostKind.Content; + + public bool CanHandle(FrameworkElement region) => + region is ContentControl && region is not Frame; + + public void Present(ContentControl host, FrameworkElement content) + { + host.Dispatcher.VerifyAccess(); + host.Content = content; + } +} +``` + +- [ ] **Step 6: Implement ContentService** + +Create `Services/ContentService.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using SimpleNavigation.Common; +using SimpleNavigation.Interface; +using System.Windows; +using System.Windows.Controls; + +namespace SimpleNavigation.Services +{ + public class ContentService : IContentService + { + private readonly IServiceProvider provider; + private readonly IRegionManager regionManager; + private readonly NavigationRouteRegistry routes; + + public ContentService(IServiceProvider provider, IRegionManager regionManager) + { + this.provider = provider; + this.regionManager = regionManager; + routes = provider.GetRequiredService(); + } + + public void Navigate( + string regionName, + DialogParameters? parameters = null) + where TContent : FrameworkElement + { + ValidateRegionName(regionName); + ValidateContentType(typeof(TContent)); + NavigateCore( + regionName, + provider.GetRequiredService(), + parameters); + } + + public void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + ValidateContentType(targetType); + var content = provider.GetRequiredService(targetType) as FrameworkElement + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a FrameworkElement."); + NavigateCore(regionName, content, parameters); + } + + public void Navigate( + string regionName, + string key, + DialogParameters? parameters = null) + { + ValidateRegionName(regionName); + var targetType = routes.GetRequiredContentType(key); + ValidateContentType(targetType); + var content = provider.GetRequiredService(targetType) as FrameworkElement + ?? throw new InvalidOperationException( + $"Resolved service '{targetType.FullName}' is not a FrameworkElement."); + NavigateCore(regionName, content, parameters); + } + + private void NavigateCore( + string regionName, + FrameworkElement content, + DialogParameters? parameters) + { + var host = GetRequiredHost(regionName); + var adapter = (IContentRegionHostAdapter) + RegionHostAdapterResolver.GetRequired(host); + adapter.Present(host, content); + NavigationAwareNotifier.Notify(content, parameters); + } + + private ContentControl GetRequiredHost(string regionName) + { + var region = regionManager.GetRegion(regionName); + if (region is ContentControl host && region is not Frame) + return host; + + var actual = region?.GetType().FullName ?? "missing"; + throw new InvalidOperationException( + $"Region '{regionName}' must be a non-Frame ContentControl but was '{actual}'."); + } + + private static void ValidateContentType(Type targetType) + { + if (targetType == null) + throw new ArgumentNullException(nameof(targetType)); + if (!typeof(FrameworkElement).IsAssignableFrom(targetType) || + typeof(Page).IsAssignableFrom(targetType) || + typeof(Window).IsAssignableFrom(targetType)) + throw new ArgumentException( + $"Target type '{targetType.FullName}' must be a non-Page, non-Window FrameworkElement.", + nameof(targetType)); + } + + private static void ValidateRegionName(string regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) + throw new ArgumentException("Region name cannot be null or whitespace.", nameof(regionName)); + } + } +} +``` + +- [ ] **Step 7: Register ContentService and run the navigation suite** + +Add this line to `RegisterNavigationService`: + +```csharp +serviceCollection.TryAddSingleton(); +``` + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj --no-restore +``` + +Expected: every test passes for both `net48` and `net8.0-windows`. + +- [ ] **Step 8: Commit content navigation** + +```powershell +git add Interface/IContentService.cs Services/ContentService.cs Common/RegionHostAdapter.cs Common/ContentControlRegionAdapter.cs Extensions/NavigationExtensions.cs Tests/SimpleNavigation.Tests/TestTypes.cs Tests/SimpleNavigation.Tests/ContentServiceTests.cs +git commit -m "feat: add content navigation service" +``` + +### Task 7: Update Documentation And Migration Guidance + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Replace the project structure and feature summary** + +Update the README to describe four public capabilities: `PageService`, `ContentService`, `DialogService`, and `RegionManager`. Replace `RegionService.cs` in the tree with these entries: + +```text +Interface/ + IRegionManager.cs + IPageService.cs + IContentService.cs + INavigationAware.cs +Services/ + Region.cs + PageService.cs + ContentService.cs + DialogService.cs +Common/ + RegionManager.cs +``` + +- [ ] **Step 2: Replace XAML region examples** + +Use `Region.RegionName` and show both supported host types: + +```xml + + + + + + + + + + + + +``` + +State directly below the example that `PageService` requires a `Frame`, while `ContentService` requires a non-`Frame` `ContentControl`. + +- [ ] **Step 3: Document DI and route registration without DataContext ownership** + +Add this complete registration example: + +```csharp +var services = new ServiceCollection(); +services.RegisterNavigationService(); + +// Ordinary DI registration is enough for generic and Type navigation. +services.AddTransient(); +services.AddSingleton(); + +// Route helpers optionally register the view/view model and a string alias. +services.AddPage("settings"); +services.AddPage("reports"); +services.AddContent(); +services.AddContent("status"); +``` + +Explain that the helpers use `TryAddTransient`, preserve registrations made earlier, and never set `DataContext`. + +- [ ] **Step 4: Document all three navigation paths** + +Add page examples: + +```csharp +pageService.Navigate("Pages"); +pageService.Navigate("Pages", typeof(HomePage)); +pageService.Navigate("Pages", "settings"); +pageService.GoBack("Pages"); +``` + +Add content examples: + +```csharp +contentService.Navigate("Content"); +contentService.Navigate("Content", typeof(DashboardView)); +contentService.Navigate("Content", "status"); +``` + +State that only the string overload reads the route dictionary; all three paths resolve the final type through ordinary DI. + +- [ ] **Step 5: Document awareness and migration** + +Use this awareness example: + +```csharp +public sealed class StatusViewModel : INavigationAware +{ + public void OnNavigated(DialogParameters? parameters) + { + var id = parameters?.Get("id"); + } +} +``` + +Add a breaking migration note with these exact mappings: + +```text +RegionService.RegionName -> Region.RegionName +IPageService.GetRegion(...) -> IRegionManager.GetRegion(...) +IPageService.Goback(...) -> IPageService.GoBack(...) +``` + +State that `RegionService` is deleted, `Goback` remains only as an obsolete forwarding method, and compiled XAML/BAML must be rebuilt. + +- [ ] **Step 6: Document future host extension limits** + +State that `Grid` and `StackPanel` may be navigation targets today but are not region hosts. Explain that future panel and tab adapters require explicit child ownership, tab creation, selection, reuse, and header policies before those hosts can be enabled. + +- [ ] **Step 7: Verify README names and examples** + +Run: + +```powershell +rg -n "RegionService\.RegionName|pageService\.GetRegion|\.Goback\(" README.md +rg -n "Region\.RegionName|AddPage|AddContent|IContentService|INavigationAware|IRegionManager" README.md +``` + +Expected: the first command finds only the intentional migration mapping, and the second command finds every rebuilt API section. + +- [ ] **Step 8: Commit documentation** + +```powershell +git add README.md +git commit -m "docs: document rebuilt navigation APIs" +``` + +### Task 8: Complete Cross-Target Verification And Review + +**Files:** +- Modify only files required by failures or review findings from Tasks 1-7. + +- [ ] **Step 1: Verify no production reference to RegionService remains** + +Run: + +```powershell +rg -n "RegionService" Common Extensions Interface Services SimpleNavigation.csproj +``` + +Expected: exit code 1 with no matches. + +- [ ] **Step 2: Restore and build Debug once** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" restore SimpleNavigation.sln +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" build SimpleNavigation.sln -c Debug --no-restore --nologo +``` + +Expected: both library targets and both test targets build with zero errors. Investigate and remove warnings introduced by the rebuild. + +- [ ] **Step 3: Run each target's complete test suite without rebuilding** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -c Debug -f net48 --no-build --nologo +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -c Debug -f net8.0-windows --no-build --nologo +``` + +Expected: all tests pass on each target with zero failures. + +- [ ] **Step 4: Build the complete Release solution and package** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" build SimpleNavigation.sln -c Release --no-restore --nologo +``` + +Expected: `net48` and `net8.0-windows` library outputs, both test assemblies, and the NuGet package build successfully with zero errors. + +- [ ] **Step 5: Audit the public surface and worktree** + +Run: + +```powershell +rg -n "public (class|interface|static class)|public static IServiceCollection" Common Extensions Interface Services -g "*.cs" +git diff --check +git status --short --branch +``` + +Expected: public APIs match the approved spec, `git diff --check` is empty, and only intentional implementation changes remain. + +- [ ] **Step 6: Request focused code review** + +Dispatch a reviewer with the design spec, this plan, the pre-implementation commit, and current HEAD. Require findings on region ownership tokens, static subscription disposal, both route namespaces, DI lifetime preservation, dispatcher access, awareness ordering, and public API completeness. + +Expected: no Critical or Important findings remain unresolved. + +- [ ] **Step 7: Apply review fixes through TDD and re-run full verification** + +For each valid finding, first add a focused failing test to the owning test file, run it to verify the expected failure, apply the smallest production fix, and rerun the focused plus complete suites from Steps 2-4. + +- [ ] **Step 8: Commit review fixes when needed** + +If review required changes, stage only those tests and production files and commit: + +```powershell +git commit -m "fix: address navigation rebuild review" +``` + +If no files changed, do not create an empty commit. diff --git a/docs/superpowers/plans/2026-07-16-dialog-service-rebuild-plan.md b/docs/superpowers/plans/2026-07-16-dialog-service-rebuild-plan.md new file mode 100644 index 0000000..4956a5d --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-dialog-service-rebuild-plan.md @@ -0,0 +1,649 @@ +# DialogService Rebuild Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add transient Window registration, independent Dialog routes, key-based Show/ShowDialog/Close operations, safe Window recreation, and accurate README guidance. + +**Architecture:** Extend the existing `NavigationRouteRegistry` with an independent Dialog map. `DialogManager` owns weak references to current Window instances by Window type and exposes separate get-or-create and get-existing paths. `DialogService` resolves generic/Type/key targets through ordinary DI, delegates instance lifecycle to `DialogManager`, and keeps all Window operations on the Window Dispatcher. + +**Tech Stack:** C# 13, WPF, Microsoft.Extensions.DependencyInjection 6/8, xUnit 2.5.3, .NET Framework 4.8, .NET 8 Windows. + +--- + +## Scope and baseline + +The current workspace contains user-owned uncommitted changes in `Services/Region.cs` and `SimpleNavigation.csproj`; preserve them. The current baseline also has unrelated failures caused by the user's singleton Page/Content registrations, Region namespace reorganization, and stale reflection test names. Dialog implementation must not revert those changes. Use the stable SDK invocation: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" +``` + +Existing public namespaces are currently split as follows: + +- `Interface.Services`: `IDialogService`, `IPageService`, `IContentService`. +- `Interface.Managers`: `IDialogManager`, `IRegionManager`. +- `Interface.Awares`: `IDialogAware`, `INavigationAware`. +- `Common.Managers`: `DialogManager`, `RegionManager`. + +Do not stage `Services/Region.cs`, `SimpleNavigation.csproj`, or the untracked design-copy file. + +## File map + +- Modify `Common/NavigationRouteRegistry.cs`: add the Dialog route kind, Dialog map, and `GetRequiredDialogType`. +- Modify `Extensions/NavigationExtensions.cs`: add the three `AddWindow` overloads and Dialog route registration. +- Modify `Interface/Managers/IDialogManager.cs`: add type-based get-or-create and get-existing operations. +- Modify `Common/Managers/DialogManager.cs`: maintain weak current Window instances by exact type and remove only the exact closed instance. +- Modify `Interface/Services/IDialogService.cs`: add generic, Type, and key Show/ShowDialog/Close overloads. +- Modify `Services/DialogService.cs`: implement all resolution paths, awareness, non-modal display, modal result handling, and bool Close behavior. +- Modify `Tests/SimpleNavigation.Tests/TestTypes.cs`: add Window, dialog-aware ViewModel, and close-cancel fixtures. +- Modify `Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs`: add Dialog route and AddWindow coverage; preserve existing user changes. +- Create `Tests/SimpleNavigation.Tests/DialogManagerTests.cs`: test instance creation, reuse, exact Closed removal, and no-create lookup. +- Create `Tests/SimpleNavigation.Tests/DialogServiceTests.cs`: test Show, ShowDialog, Close, key resolution, awareness, Dispatcher, and error behavior. +- Modify `README.md`: document AddWindow, all Dialog service overloads, key-based close, transient recreation, and the repaired modal behavior. + +## Task 1: Add Dialog route registration and Window extensions + +**Files:** + +- Modify: `Common/NavigationRouteRegistry.cs` +- Modify: `Extensions/NavigationExtensions.cs` +- Modify: `Tests/SimpleNavigation.Tests/TestTypes.cs` +- Modify: `Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs` + +- [ ] **Step 1: Add Window and ViewModel fixtures first** + +Add these test-only types to `TestTypes.cs`: + +```csharp +public sealed class FirstWindow : Window +{ +} + +public sealed class SecondWindow : Window +{ +} + +public sealed class DialogViewModel +{ +} +``` + +Use `System.Windows` and the existing `SimpleNavigation.Interface.Awares` namespace only where a fixture needs awareness. + +- [ ] **Step 2: Add failing registration tests** + +Add tests to `NavigationExtensionsTests.cs`: + +```csharp +[Fact] +public void AddWindowKeyRegistersTransientWindowAndDialogRoute() +{ + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddWindow("first"); + using var provider = services.BuildServiceProvider(); + + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + }); +} + +[Fact] +public void AddWindowDoubleGenericRegistersWindowAndViewModelWithoutDataContext() +{ + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.AddWindow(); + using var provider = services.BuildServiceProvider(); + + var window = provider.GetRequiredService(); + Assert.Null(window.DataContext); + Assert.NotSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + }); +} + +[Fact] +public void DialogKeysAreIndependentFromPageAndContentKeys() +{ + var services = new ServiceCollection(); + services.AddPage("main"); + services.AddContent("main"); + services.AddWindow("main"); +} + +[Fact] +public void DuplicateDialogKeyIsRejectedUsingOrdinalMatching() +{ + var services = new ServiceCollection(); + services.AddWindow("main"); + + Assert.Throws( + () => services.AddWindow("main")); +} +``` + +- [ ] **Step 3: Run the focused tests and verify RED** + +Run: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter "FullyQualifiedName~AddWindow|FullyQualifiedName~DialogKey|FullyQualifiedName~DuplicateDialog" --no-restore +``` + +Expected: compile failures for missing `AddWindow` and missing Dialog route support. Do not change production code before observing this failure. + +- [ ] **Step 4: Extend the route registry** + +Change the internal enum and registry with the following behavior: + +```csharp +internal enum NavigationRouteKind +{ + Page, + Content, + Dialog, +} + +private readonly IReadOnlyDictionary dialogs; + +public Type GetRequiredDialogType(string key) => + GetRequiredTarget(dialogs, key, "dialog"); +``` + +Build `dialogs` with the same `StringComparer.Ordinal` and category filter used by the Page and Content maps. Do not combine the dictionaries. + +- [ ] **Step 5: Add the three Window extensions** + +Add these methods to `NavigationExtensions`: + +```csharp +public static IServiceCollection AddWindow( + this IServiceCollection services, + string key) + where TWindow : Window +{ + AddRoute(services, NavigationRouteKind.Dialog, key, typeof(TWindow)); + services.TryAddTransient(); + return services; +} + +public static IServiceCollection AddWindow( + this IServiceCollection services) + where TWindow : Window + where TViewModel : class +{ + services.TryAddTransient(); + services.TryAddTransient(); + return services; +} + +public static IServiceCollection AddWindow( + this IServiceCollection services, + string key) + where TWindow : Window + where TViewModel : class +{ + AddRoute(services, NavigationRouteKind.Dialog, key, typeof(TWindow)); + services.TryAddTransient(); + services.TryAddTransient(); + return services; +} +``` + +Reuse the existing whitespace validation and category-local ordinal duplicate detection in `AddRoute`. Do not set `DataContext`. + +- [ ] **Step 6: Add route lookup and registration-order tests** + +Extend the existing reflection helper in `NavigationExtensionsTests` to call `GetRequiredDialogType`. Test a Dialog route registered before `RegisterNavigationService`, one registered after it but before `BuildServiceProvider`, ordinal casing, unknown key, and invalid key. Tests that already pass because the implementation is present are coverage additions; report them honestly rather than claiming a false RED cycle. + +- [ ] **Step 7: Run focused GREEN and commit Task 1** + +Run the focused extension tests on both targets: + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net48 --filter "FullyQualifiedName~AddWindow|FullyQualifiedName~DialogKey|FullyQualifiedName~DuplicateDialog|FullyQualifiedName~DialogRoute" --no-restore +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter "FullyQualifiedName~AddWindow|FullyQualifiedName~DialogKey|FullyQualifiedName~DuplicateDialog|FullyQualifiedName~DialogRoute" --no-restore +``` + +Commit only the route/extension and test changes: + +```powershell +git add Common/NavigationRouteRegistry.cs Extensions/NavigationExtensions.cs Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs Tests/SimpleNavigation.Tests/TestTypes.cs +git commit -m "feat: add dialog window registration routes" +``` + +## Task 2: Refactor DialogManager instance lifecycle + +**Files:** + +- Modify: `Interface/Managers/IDialogManager.cs` +- Modify: `Common/Managers/DialogManager.cs` +- Create: `Tests/SimpleNavigation.Tests/DialogManagerTests.cs` + +- [ ] **Step 1: Write failing manager tests** + +Add tests that express the desired type-based operations: + +```csharp +[Fact] +public void GetOrCreateReusesLiveWindowAndGetExistingDoesNotCreate() +{ + StaTest.Run(() => + { + var expected = new FirstWindow(); + var services = new ServiceCollection(); + services.AddSingleton(expected); + using var provider = services.BuildServiceProvider(); + var manager = new DialogManager(provider); + + Assert.Null(manager.GetExistingWindow(typeof(FirstWindow))); + Assert.Same(expected, manager.GetOrCreateWindow(typeof(FirstWindow))); + Assert.Same(expected, manager.GetExistingWindow(typeof(FirstWindow))); + }); +} + +[Fact] +public void ClosedWindowIsRemovedAndNextGetOrCreateUsesDIAgain() +{ + StaTest.Run(() => + { + var created = 0; + var services = new ServiceCollection(); + services.AddTransient(_ => + { + created++; + return new FirstWindow(); + }); + using var provider = services.BuildServiceProvider(); + var manager = new DialogManager(provider); + + var first = manager.GetOrCreateWindow(typeof(FirstWindow)); + first.Show(); + first.Close(); + Assert.Null(manager.GetExistingWindow(typeof(FirstWindow))); + + var second = manager.GetOrCreateWindow(typeof(FirstWindow)); + Assert.NotSame(first, second); + Assert.Equal(2, created); + second.Show(); + second.Close(); + }); +} +``` + +Every created Window is shown and closed on the STA thread so the real WPF `Closed` event drives cache removal. Do not add production-only test hooks. + +- [ ] **Step 2: Run manager tests and verify RED** + +Run the new test class on `net8.0-windows`; expected failure is missing `GetExistingWindow`/`GetOrCreateWindow` methods or the old type cache behavior. + +- [ ] **Step 3: Implement type-based manager operations** + +Refactor `IDialogManager` to expose: + +```csharp +Window GetOrCreateWindow(Type windowType); +Window? GetExistingWindow(Type windowType); +``` + +`DialogManager` must: + +- Validate that `windowType` is non-null and derives from `Window`. +- Use ordinary `provider.GetRequiredService(windowType)` only in `GetOrCreateWindow`. +- Store `WeakReference` by exact `Type`. +- Subscribe once to each Window's `Closed` event. +- Remove a cache entry only when both cached type and cached Window reference match the closed sender. +- Return null from `GetExistingWindow` for an absent or dead weak reference without resolving DI. + +Keep the generic `GetDialogWindow` only if existing consumers require source compatibility; implement it as a thin call to `GetOrCreateWindow(typeof(T))`. + +- [ ] **Step 4: Run manager tests on both targets and commit** + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net48 --filter FullyQualifiedName~DialogManagerTests --no-restore +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~DialogManagerTests --no-restore +``` + +Commit: + +```powershell +git add Interface/Managers/IDialogManager.cs Common/Managers/DialogManager.cs Tests/SimpleNavigation.Tests/DialogManagerTests.cs +git commit -m "refactor: manage dialog windows by type" +``` + +## Task 3: Rebuild IDialogService and DialogService + +**Files:** + +- Modify: `Interface/Services/IDialogService.cs` +- Modify: `Services/DialogService.cs` +- Modify: `Tests/SimpleNavigation.Tests/TestTypes.cs` +- Create: `Tests/SimpleNavigation.Tests/DialogServiceTests.cs` + +- [ ] **Step 1: Add dialog-aware and cancellation fixtures** + +Add these test types: + +```csharp +public sealed class AwareDialogViewModel : IDialogAware +{ + public int CallCount { get; private set; } + public DialogParameters? Parameters { get; private set; } + public Action? RequestClose { get; set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} + +public sealed class AwareWindow : Window, IDialogAware +{ + public int CallCount { get; private set; } + public DialogParameters? Parameters { get; private set; } + public Action? RequestClose { get; set; } + + public void OnNavigated(DialogParameters? parameters) + { + CallCount++; + Parameters = parameters; + } +} + +public sealed class CancelClosingWindow : Window +{ + public bool CancelClose { get; set; } = true; + + public CancelClosingWindow() + { + Closing += (_, args) => args.Cancel = CancelClose; + } +} +``` + +- [ ] **Step 2: Write failing service tests** + +Create `DialogServiceTests.cs` with focused tests for: + +```csharp +[Fact] +public void KeyShowResolvesTheDialogRouteThroughOrdinaryDI() +{ + StaTest.Run(() => + { + var expected = new FirstWindow(); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(expected); + services.AddWindow("first"); + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().Show("first"); + Assert.True(expected.IsVisible); + expected.Close(); + }); +} + +[Fact] +public void CloseBeforeShowReturnsFalseWithoutResolvingAWindow() +{ + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddWindow("first"); + using var provider = services.BuildServiceProvider(); + + Assert.False(provider.GetRequiredService().Close("first")); + }); +} + +[Fact] +public void UnknownDialogKeyThrowsKeyNotFoundExceptionForShowAndClose() +{ + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + Assert.Throws(() => service.Show("missing")); + Assert.Throws(() => service.Close("missing")); + }); +} +``` + +Add these concrete tests in the same class: + +```csharp +[Fact] +public void GenericAndTypeShowDoNotRequireRoutes() +{ + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + service.Show(); + Assert.True(provider.GetRequiredService() + .GetExistingWindow(typeof(FirstWindow))!.IsVisible); + Assert.True(service.Close()); + + service.Show(typeof(SecondWindow)); + Assert.True(provider.GetRequiredService() + .GetExistingWindow(typeof(SecondWindow))!.IsVisible); + Assert.True(service.Close(typeof(SecondWindow))); + }); +} + +[Fact] +public void WindowAndDistinctDataContextReceiveParametersOnceEach() +{ + StaTest.Run(() => + { + var viewModel = new AwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + var parameters = new DialogParameters("id", 7); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(window); + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService() + .Show(parameters); + + Assert.Equal(1, window.CallCount); + Assert.Equal(1, viewModel.CallCount); + Assert.Same(parameters, window.Parameters); + Assert.Same(parameters, viewModel.Parameters); + window.Close(); + }); +} + +[Fact] +public void CancelledCloseReturnsFalseAndKeepsTheWindowManaged() +{ + StaTest.Run(() => + { + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddTransient(); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + service.Show(); + Assert.False(service.Close()); + var window = Assert.IsType( + provider.GetRequiredService() + .GetExistingWindow(typeof(CancelClosingWindow))); + window.CancelClose = false; + window.Close(); + }); +} +``` + +Also add one cross-thread test that creates and shows the Window on an STA thread, then calls `Close` from the test thread and asserts `InvalidOperationException`. Never leave a shown Window open at the end of a test; set `CancelClose = false` before closing cancellation fixtures during cleanup. + +Add this modal result regression test: + +```csharp +[Fact] +public void ShowDialogReturnsParametersFromRequestClose() +{ + StaTest.Run(() => + { + var viewModel = new AwareDialogViewModel(); + var window = new AwareWindow { DataContext = viewModel }; + var expected = new DialogParameters("saved", true); + var services = new ServiceCollection(); + services.RegisterNavigationService(); + services.AddSingleton(window); + using var provider = services.BuildServiceProvider(); + + window.Dispatcher.BeginInvoke(new Action(() => + viewModel.RequestClose!.Invoke(expected))); + + var actual = provider.GetRequiredService() + .ShowDialog(); + + Assert.Same(expected, actual); + Assert.Null(viewModel.RequestClose); + Assert.Null(window.RequestClose); + }); +} +``` + +Add a second modal test that schedules `window.Close()` instead of `RequestClose` and asserts a null result. Both tests must fail against the current unconditional `Closing` cancellation implementation. + +- [ ] **Step 3: Run focused tests and verify RED** + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~DialogServiceTests --no-restore +``` + +Expected failures include missing overloads and the current modal close/result re-entry behavior. + +- [ ] **Step 4: Implement the public service contract** + +Replace `IDialogService` with the approved generic/Type/key overloads for `Show`, `ShowDialog`, and `Close`. Keep the existing namespace `SimpleNavigation.Interface.Services`. + +- [ ] **Step 5: Implement resolution and validation helpers** + +`DialogService` should inject `IServiceProvider`, `IDialogManager`, and `NavigationRouteRegistry` resolved from DI. Implement: + +```csharp +private static Type ValidateWindowType(Type targetType) +{ + if (targetType == null) + throw new ArgumentNullException(nameof(targetType)); + if (!typeof(Window).IsAssignableFrom(targetType)) + throw new ArgumentException( + $"Target type '{targetType.FullName}' must derive from Window.", + nameof(targetType)); + return targetType; +} + +private Type ResolveDialogKey(string key) => + routes.GetRequiredDialogType(key); + +private Window GetOrCreateWindow(Type targetType) => + dialogManager.GetOrCreateWindow(ValidateWindowType(targetType)); +``` + +Generic and Type operations use ordinary DI through `DialogManager`; key operations resolve the route type first. A non-Window Type throws `ArgumentException`, null Type throws `ArgumentNullException`, and missing DI registrations propagate. + +- [ ] **Step 6: Implement awareness and non-modal Show** + +Create one helper that notifies Window first, then a distinct DataContext. Set `RequestClose` on both distinct awareness objects to close the same Window. For an already visible Window, call `Activate()` without calling `Show()` again. For a hidden but existing Window, call `Show()` then `Activate()`. + +- [ ] **Step 7: Implement modal ShowDialog and Close** + +For `ShowDialog`, attach close delegates that store the result then call `Close()`, allow the `Closing` event to proceed, call `ShowDialog()`, and clear both delegates in `finally`. + +For `Close`, resolve the type/key, call `GetExistingWindow`, return false if null, call `Dispatcher.VerifyAccess`, subscribe a one-shot `Closed` observer, call `Close()`, and return whether the instance actually closed. Do not use `Window.IsActive` as a prerequisite. Detach the one-shot handler in all paths. + +- [ ] **Step 8: Run service tests on both targets and commit Task 3** + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net48 --filter FullyQualifiedName~DialogServiceTests --no-restore +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --filter FullyQualifiedName~DialogServiceTests --no-restore +``` + +Then run the Dialog manager, extension, Page, Content, and Region tests. Commit only the Dialog interface/service/manager and associated tests: + +```powershell +git add Interface/Services/IDialogService.cs Services/DialogService.cs Interface/Managers/IDialogManager.cs Common/Managers/DialogManager.cs Common/NavigationRouteRegistry.cs Extensions/NavigationExtensions.cs Tests/SimpleNavigation.Tests/DialogServiceTests.cs Tests/SimpleNavigation.Tests/DialogManagerTests.cs Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs Tests/SimpleNavigation.Tests/TestTypes.cs +git commit -m "feat: rebuild dialog service around keyed windows" +``` + +## Task 4: Update README and complete verification + +**Files:** + +- Modify: `README.md` + +- [ ] **Step 1: Document Window registration and navigation** + +Add examples: + +```csharp +services.AddWindow("login"); +services.AddWindow(); +services.AddWindow("reports"); + +dialogService.Show(); +dialogService.Show("reports"); +dialogService.ShowDialog("login"); +dialogService.Close("reports"); +``` + +Explain that keys are independent from Page and Content keys, Window registrations are transient, instances are reused while open, and a fresh instance is created after `Closed`. + +Remove the old warning that `ShowDialog` is broken after the repaired tests pass. Document that `Close` returns false when no instance is currently managed and that unknown keys throw. + +- [ ] **Step 2: Verify README names and migration guidance** + +Run: + +```powershell +rg -n "AddWindow|ShowDialog|Close\(|Dialog key|transient|RegionService" README.md +git diff --check +``` + +Ensure the existing Region migration text and current user-owned changes are untouched. + +- [ ] **Step 3: Run final verification without reverting baseline user changes** + +First run the Dialog-focused suites on both targets and record results. Then run the full suite with the stable SDK. If the five baseline failures remain, report them separately and do not change unrelated Region/Page tests unless the user explicitly requests baseline repair. + +```powershell +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" restore SimpleNavigation.sln --nologo +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" build SimpleNavigation.sln -c Debug --no-restore --nologo +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net48 --no-build --nologo +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" test Tests\SimpleNavigation.Tests\SimpleNavigation.Tests.csproj -f net8.0-windows --no-build --nologo +dotnet "C:\Program Files\dotnet\sdk\9.0.305\dotnet.dll" build SimpleNavigation.sln -c Release --no-restore --nologo +``` + +- [ ] **Step 4: Audit the final diff** + +Run: + +```powershell +rg -n "RegionService" Common Extensions Interface Services SimpleNavigation.csproj +git diff --check +git status --short --branch +``` + +Do not stage or commit `Services/Region.cs`, `SimpleNavigation.csproj`, or any untracked user file. The final report must distinguish Dialog tests from the pre-existing five baseline failures if they remain. diff --git "a/docs/superpowers/specs/2026-07-11-navigation-rebuild-design - \345\211\257\346\234\254.md" "b/docs/superpowers/specs/2026-07-11-navigation-rebuild-design - \345\211\257\346\234\254.md" new file mode 100644 index 0000000..3114ea9 --- /dev/null +++ "b/docs/superpowers/specs/2026-07-11-navigation-rebuild-design - \345\211\257\346\234\254.md" @@ -0,0 +1,331 @@ +# SimpleNavigation Rebuild Design + +## Status + +This design was approved section by section on 2026-07-11. All implementation work is based on the `rebuild` branch. + +## Summary + +The rebuild separates region ownership from navigation services, replaces the public `RegionService` attached-property owner with `Region`, adds navigation for non-page WPF content, and adds explicit string route aliases without replacing the existing generic and `Type` navigation paths. + +The library remains DI-driven and continues to target `net48` and `net8.0-windows`. The rebuild is intentionally breaking: `RegionService` is deleted rather than retained as a compatibility facade. + +## Goals + +- Move region registration, removal, and lookup out of `PageService` into `IRegionManager` and `RegionManager`. +- Rename the XAML attached-property owner from `RegionService` to `Region` and support `Frame` plus non-`Frame` `ContentControl` hosts. +- Add `IContentService` and `ContentService` for navigating to `FrameworkElement` content other than `Page` and `Window`. +- Add string key navigation to both page and content services through explicit route registration. +- Add a shared `INavigationAware` callback contract for navigated views and view models. +- Preserve generic and `Type` navigation as direct DI resolution paths. +- Keep host behavior extensible through internal adapters so `Panel` and `TabControl` can be added later without rewriting navigation services. +- Add automated tests for both target frameworks and update the README for the rebuilt API. + +## Non-goals + +- Do not implement `Grid`, `StackPanel`, `Panel`, or `TabControl` hosts in this change. +- Do not expose a public adapter plug-in API before those host semantics are defined. +- Do not set or replace a view's `DataContext`. +- Do not infer routes from type names, attributes, reflection scanning, or DI keyed services. +- Do not add back navigation to ordinary `ContentControl` regions. +- Do not add asynchronous navigation, navigation scopes, view caching, or automatic cross-thread dispatch. + +## Breaking Changes + +- Delete `SimpleNavigation.Services.RegionService` and its `RegionRegisted` event. +- Add `SimpleNavigation.Services.Region` as the only attached-property owner. XAML consumers must change `RegionService.RegionName` to `Region.RegionName`. +- Remove `GetRegion` from `IPageService`. +- Remove `RegisterRegion` and the region dictionary from `PageService`. +- Add correctly spelled `GoBack`. Retain `Goback` only as an obsolete forwarding member to avoid an unrelated hard break. +- Change page navigation awareness so the navigated view and its `DataContext` can both receive the shared callback. +- Change invalid navigation configuration from silent no-op behavior to explicit exceptions. + +## Architecture + +### Region declaration + +`Region` is a static attached-property owner. It declares `RegionNameProperty` and the standard `GetRegionName` and `SetRegionName` accessors. It contains no page or content navigation logic. + +An internal host adapter resolver validates each declared host. The resolver checks `Frame` before `ContentControl` because `Frame` inherits `ContentControl`. The built-in host set is: + +- `Frame`, owned by `PageService` and the frame adapter. +- Non-`Frame` `ContentControl`, owned by `ContentService` and the content-control adapter. + +The static declaration layer maintains weak registration records and publishes internal registration changes. A newly created `RegionManager` subscribes to changes before importing the current weak snapshot; registering the same name and host twice is idempotent. This ordering prevents both a snapshot-to-subscription race and regions declared during XAML initialization from being lost when DI creates the manager later. + +Changing a region name removes the old registration before adding the new registration. Clearing the property or unloading the host unregisters its attached-property ownership. Loading it again restores that ownership with a new activation token, so a delayed notification from an older load cycle cannot remove the new registration. Static declarations use weak references so an abandoned visual tree is not retained by the attached-property infrastructure. + +### Region manager + +`IRegionManager` is the public region ownership boundary: + +```csharp +public interface IRegionManager +{ + void RegisterRegion(string regionName, FrameworkElement region); + + bool UnregisterRegion(string regionName, FrameworkElement region); + + FrameworkElement? GetRegion(string regionName); + + TRegion? GetRegion(string regionName) + where TRegion : FrameworkElement; +} +``` + +`RegionManager` is registered as a singleton by `RegisterNavigationService`. It owns named lookup, imports attached-property declarations, and unsubscribes from static declaration notifications when disposed. The element argument on `UnregisterRegion` prevents a stale unload notification from removing a newer host that reused the same name. + +Both attached-property and programmatic registration pass through the same adapter resolver. Programmatic callers cannot register an unsupported host type to bypass `Region` validation. Named manager entries hold weak host references and remove dead entries during lookup or replacement. + +Programmatic and attached-property ownership are tracked independently for the same name and host. Public `RegisterRegion` and `UnregisterRegion` add or remove only programmatic ownership. Internal declaration notifications add or remove a specific activation token. A region remains available while either ownership source is active. This prevents `Unloaded` from accidentally removing a programmatically registered region and prevents a stale unload token from removing a reloaded declaration. + +Only one live host may own a region name. A second live host with the same name is a configuration error. A dead weak registration may be cleaned up and replaced. + +### Host adapters + +Adapters remain internal in this release: + +- `FrameRegionAdapter` verifies dispatcher access, calls `Frame.Navigate`, and implements journal back navigation. +- `ContentControlRegionAdapter` verifies dispatcher access and assigns `ContentControl.Content`. It explicitly refuses `Frame` hosts and has no journal capability. + +The adapter resolver is the only component that distinguishes concrete host types. Adding a future `PanelRegionAdapter` or `TabControlRegionAdapter` will extend this resolver and define the new host's presentation semantics without changing `PageService`, `ContentService`, or `RegionManager`. + +`Panel` support must eventually define whether the region owns all children before using `Children.Clear()` and `Children.Add()`. `TabControl` support must define whether navigation creates a tab, replaces the selected tab, or reuses an existing item, including how headers are supplied. Those policies are intentionally deferred. + +### Route registry + +An internal route registry holds two independent, ordinal, case-sensitive maps: + +- Page key to a type assignable to `Page`. +- Content key to a type assignable to `FrameworkElement`, excluding `Page` and `Window`. + +The same key may exist once in each map. A key may not be null, empty, or whitespace. Registering the same key twice in one map throws during service collection configuration, including when both registrations point to the same type. + +The route registry stores only aliases. It does not create instances and does not use Microsoft DI keyed services. This keeps behavior identical for the DI 6 dependency used by `net48` and the DI 8 dependency used by `net8.0-windows`. + +## Public Navigation APIs + +### Page navigation + +```csharp +public interface IPageService +{ + void Navigate( + string regionName, + DialogParameters? parameters = null) + where TPage : Page; + + void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null); + + void Navigate( + string regionName, + string key, + DialogParameters? parameters = null); + + void GoBack(string regionName); + + [Obsolete("Use GoBack instead.")] + void Goback(string regionName); +} +``` + +`PageService` accepts only `Frame` regions and only `Page` targets. + +### Content navigation + +```csharp +public interface IContentService +{ + void Navigate( + string regionName, + DialogParameters? parameters = null) + where TContent : FrameworkElement; + + void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null); + + void Navigate( + string regionName, + string key, + DialogParameters? parameters = null); +} +``` + +`ContentService` accepts only non-`Frame` `ContentControl` regions. A content target may be a `UserControl`, ordinary `ContentControl`, custom control, `Grid`, `StackPanel`, or another `FrameworkElement`; `Page` and `Window` targets are rejected. A `Frame` may be content, but a `Frame` may not be the region host used by `ContentService`. + +### Navigation awareness + +```csharp +public interface INavigationAware +{ + void OnNavigated(DialogParameters? parameters); +} +``` + +`IPageAware` inherits `INavigationAware` and retains its existing `Receive` event. A successful navigation synchronously checks both the target view and its `DataContext`. Every distinct object that implements `INavigationAware` is invoked once. If the view and `DataContext` reference the same instance, it is invoked only once. The callback is invoked even when `parameters` is null. + +The library never assigns `DataContext`. Constructor injection, factories, XAML, and view-to-view-model wiring remain application responsibilities. + +## Service Collection Extensions + +`NavigationExtensions` adds the following overloads: + +```csharp +IServiceCollection AddPage(string key) + where TPage : Page; + +IServiceCollection AddPage() + where TPage : Page + where TViewModel : class; + +IServiceCollection AddPage(string key) + where TPage : Page + where TViewModel : class; + +IServiceCollection AddContent(string key) + where TView : FrameworkElement; + +IServiceCollection AddContent() + where TView : FrameworkElement + where TViewModel : class; + +IServiceCollection AddContent(string key) + where TView : FrameworkElement + where TViewModel : class; +``` + +Registration behavior is: + +- Single-generic key overload: `TryAddTransient` the view and add its route alias. +- Double-generic overload without key: `TryAddTransient` the view and view model, with no route alias. +- Double-generic key overload: `TryAddTransient` the view and view model, then add the route alias. +- `AddContent` performs a runtime guard that rejects `Page` and `Window`, which cannot be expressed as a negative generic constraint. +- Existing application registrations are not replaced. To select a custom lifetime or factory, register it before calling the navigation extension. +- The extension does not set `DataContext` and does not infer view-model interfaces. + +`AddPage` and `AddContent` may be called before or after `RegisterNavigationService` as long as all calls occur before `BuildServiceProvider`. + +## Navigation Data Flow + +The three overload families deliberately have separate resolution paths. + +### Generic navigation + +```csharp +var target = provider.GetRequiredService(); +``` + +Generic navigation never reads the route registry. + +### Type navigation + +```csharp +var target = provider.GetRequiredService(targetType) as TExpectedBase; +``` + +The service validates assignability before resolving. Type navigation never reads the route registry. + +### String key navigation + +```csharp +var targetType = routes.GetRequiredType(key); +var target = provider.GetRequiredService(targetType) as TExpectedBase; +``` + +String navigation first resolves the alias to a type in the appropriate page or content map, then uses the ordinary DI container to create the instance. It does not call `GetRequiredKeyedService`. + +After target resolution, every navigation follows this order: + +1. Validate the region name and target input. +2. Resolve the target instance through the selected path above. +3. Retrieve the named region and require the correct host adapter. +4. Verify access to the host dispatcher. +5. Ask the adapter to present or navigate to the target. +6. If the host accepted navigation, invoke navigation awareness on the view and its `DataContext`. + +For `Frame`, the awareness callback runs synchronously after `Frame.Navigate` returns `true`; it does not wait for `Loaded`, `Navigated`, or `LoadCompleted`. For `ContentControl`, it runs after the `Content` assignment succeeds. + +`GoBack` retrieves the named `Frame`, verifies dispatcher access, and calls `GoBack` only when `CanGoBack` is true. A valid frame with no journal entry is a no-op. `Goback` forwards to `GoBack`. + +## Error Handling + +- Null, empty, or whitespace region names and route keys throw `ArgumentException`. +- An unregistered key throws `KeyNotFoundException` and names the key plus the page or content route category. +- A duplicate key in one route category throws `ArgumentException` while configuring `IServiceCollection`. +- A missing region or a region with the wrong host type throws `InvalidOperationException` and names the region, actual type when available, and expected host type. +- A non-`Page` target passed to `PageService` throws `ArgumentException`. +- A `Page` or `Window` target passed to `ContentService` throws `ArgumentException`. +- A missing DI registration retains the original `GetRequiredService` exception. +- A resolved object that does not match the validated expected base type throws `InvalidOperationException` rather than producing a null navigation target. +- Host, dispatcher, content assignment, navigation, and `INavigationAware` exceptions propagate to the caller. +- If `Frame.Navigate` returns `false`, awareness callbacks are not invoked. +- A second live region with an existing name throws `InvalidOperationException`. +- Attaching `RegionName` to, or programmatically registering, a host without a built-in adapter throws `ArgumentException` and names the unsupported type. +- `GetRegion` returns null when a name is absent; navigation services convert absence into the fail-fast exception above. +- `UnregisterRegion` returns false when the name is absent or belongs to a different element. + +## Threading And Lifetime + +Navigation APIs are synchronous and must be called on the region host's dispatcher thread. Adapters call `VerifyAccess`; the library does not marshal work to another dispatcher. + +`IRegionManager`, `IPageService`, `IContentService`, and the internal route registry are DI singletons. Views and view models added by navigation extensions default to transient through `TryAddTransient`. Existing application registrations control their actual lifetime. + +Region declarations and manager lookups use weak references where ownership is not required. Active WPF hosts unregister on unload, and `RegionManager` releases static subscriptions when disposed. + +## Planned File Structure + +- Delete `Services/RegionService.cs`. +- Create `Services/Region.cs` for the attached property and weak declaration notifications. +- Create `Interface/IRegionManager.cs` and `Common/RegionManager.cs` for region ownership. +- Create focused internal adapter files for `Frame` and `ContentControl` host behavior. +- Create `Interface/IContentService.cs` and `Services/ContentService.cs`. +- Create `Interface/INavigationAware.cs` and update `Interface/IPageAware.cs`. +- Update `Interface/IPageService.cs` and `Services/PageService.cs`. +- Add an internal page/content route registry and route descriptors. +- Update `Extensions/NavigationExtensions.cs` with core service registration and the six route/view overloads. +- Add `Tests/SimpleNavigation.Tests` to the solution. +- Update `README.md` with the rebuilt API, examples, limitations, and migration note. + +## Testing Strategy + +Tests use xUnit and an STA helper for WPF behavior. The test project targets `net48` and `net8.0-windows`. + +Coverage includes: + +- `Region` declaration on `Frame` and ordinary `ContentControl`. +- Rename, clear, unload, reload, weak cleanup, late `RegionManager` creation, and duplicate region detection. +- Programmatic register, unregister, unregistration ownership, untyped lookup, and typed lookup. +- Mixed programmatic and attached ownership, including unload/reload activation-token ordering. +- Rejection of unsupported hosts through both attached-property and programmatic registration paths. +- All six `AddPage` and `AddContent` overloads. +- Default transient registration, preservation of an existing singleton, duplicate keys, separate page/content key spaces, and key validation. +- Generic, `Type`, and string navigation for pages and content. +- Proof that generic and `Type` paths do not require a route alias. +- Proof that string paths resolve the dictionary type and then use the ordinary DI registration. +- Content host replacement and frame journal navigation. +- `GoBack`, no-journal behavior, and obsolete `Goback` forwarding. +- Rejection of page targets, window targets, frame content hosts, wrong region hosts, missing regions, invalid types, missing DI registrations, and unknown keys. +- View and `DataContext` awareness, reference de-duplication, null parameters, callback ordering, rejected navigation, and callback exception propagation. +- Dispatcher-thread enforcement. +- Successful Debug and Release builds for both library target frameworks. + +Each production behavior is implemented with a red-green-refactor cycle. The final verification runs the complete test suite and builds the solution for both targets. + +## Documentation + +The README will: + +- Replace every `RegionService.RegionName` example with `Region.RegionName`. +- Document the removal of `RegionService` as a breaking migration. +- Document `IRegionManager`, `IContentService`, and `INavigationAware`. +- Show the six `AddPage` and `AddContent` registration forms. +- Show generic, `Type`, and string key navigation. +- Explain that route aliases map strings to types while DI remains responsible for instances. +- State that the library never assigns `DataContext`. +- State that content regions currently require a non-`Frame` `ContentControl`. +- Explain how the internal adapter boundary permits future `Panel` and `TabControl` support without claiming those hosts work today. diff --git a/docs/superpowers/specs/2026-07-11-navigation-rebuild-design.md b/docs/superpowers/specs/2026-07-11-navigation-rebuild-design.md new file mode 100644 index 0000000..3114ea9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-navigation-rebuild-design.md @@ -0,0 +1,331 @@ +# SimpleNavigation Rebuild Design + +## Status + +This design was approved section by section on 2026-07-11. All implementation work is based on the `rebuild` branch. + +## Summary + +The rebuild separates region ownership from navigation services, replaces the public `RegionService` attached-property owner with `Region`, adds navigation for non-page WPF content, and adds explicit string route aliases without replacing the existing generic and `Type` navigation paths. + +The library remains DI-driven and continues to target `net48` and `net8.0-windows`. The rebuild is intentionally breaking: `RegionService` is deleted rather than retained as a compatibility facade. + +## Goals + +- Move region registration, removal, and lookup out of `PageService` into `IRegionManager` and `RegionManager`. +- Rename the XAML attached-property owner from `RegionService` to `Region` and support `Frame` plus non-`Frame` `ContentControl` hosts. +- Add `IContentService` and `ContentService` for navigating to `FrameworkElement` content other than `Page` and `Window`. +- Add string key navigation to both page and content services through explicit route registration. +- Add a shared `INavigationAware` callback contract for navigated views and view models. +- Preserve generic and `Type` navigation as direct DI resolution paths. +- Keep host behavior extensible through internal adapters so `Panel` and `TabControl` can be added later without rewriting navigation services. +- Add automated tests for both target frameworks and update the README for the rebuilt API. + +## Non-goals + +- Do not implement `Grid`, `StackPanel`, `Panel`, or `TabControl` hosts in this change. +- Do not expose a public adapter plug-in API before those host semantics are defined. +- Do not set or replace a view's `DataContext`. +- Do not infer routes from type names, attributes, reflection scanning, or DI keyed services. +- Do not add back navigation to ordinary `ContentControl` regions. +- Do not add asynchronous navigation, navigation scopes, view caching, or automatic cross-thread dispatch. + +## Breaking Changes + +- Delete `SimpleNavigation.Services.RegionService` and its `RegionRegisted` event. +- Add `SimpleNavigation.Services.Region` as the only attached-property owner. XAML consumers must change `RegionService.RegionName` to `Region.RegionName`. +- Remove `GetRegion` from `IPageService`. +- Remove `RegisterRegion` and the region dictionary from `PageService`. +- Add correctly spelled `GoBack`. Retain `Goback` only as an obsolete forwarding member to avoid an unrelated hard break. +- Change page navigation awareness so the navigated view and its `DataContext` can both receive the shared callback. +- Change invalid navigation configuration from silent no-op behavior to explicit exceptions. + +## Architecture + +### Region declaration + +`Region` is a static attached-property owner. It declares `RegionNameProperty` and the standard `GetRegionName` and `SetRegionName` accessors. It contains no page or content navigation logic. + +An internal host adapter resolver validates each declared host. The resolver checks `Frame` before `ContentControl` because `Frame` inherits `ContentControl`. The built-in host set is: + +- `Frame`, owned by `PageService` and the frame adapter. +- Non-`Frame` `ContentControl`, owned by `ContentService` and the content-control adapter. + +The static declaration layer maintains weak registration records and publishes internal registration changes. A newly created `RegionManager` subscribes to changes before importing the current weak snapshot; registering the same name and host twice is idempotent. This ordering prevents both a snapshot-to-subscription race and regions declared during XAML initialization from being lost when DI creates the manager later. + +Changing a region name removes the old registration before adding the new registration. Clearing the property or unloading the host unregisters its attached-property ownership. Loading it again restores that ownership with a new activation token, so a delayed notification from an older load cycle cannot remove the new registration. Static declarations use weak references so an abandoned visual tree is not retained by the attached-property infrastructure. + +### Region manager + +`IRegionManager` is the public region ownership boundary: + +```csharp +public interface IRegionManager +{ + void RegisterRegion(string regionName, FrameworkElement region); + + bool UnregisterRegion(string regionName, FrameworkElement region); + + FrameworkElement? GetRegion(string regionName); + + TRegion? GetRegion(string regionName) + where TRegion : FrameworkElement; +} +``` + +`RegionManager` is registered as a singleton by `RegisterNavigationService`. It owns named lookup, imports attached-property declarations, and unsubscribes from static declaration notifications when disposed. The element argument on `UnregisterRegion` prevents a stale unload notification from removing a newer host that reused the same name. + +Both attached-property and programmatic registration pass through the same adapter resolver. Programmatic callers cannot register an unsupported host type to bypass `Region` validation. Named manager entries hold weak host references and remove dead entries during lookup or replacement. + +Programmatic and attached-property ownership are tracked independently for the same name and host. Public `RegisterRegion` and `UnregisterRegion` add or remove only programmatic ownership. Internal declaration notifications add or remove a specific activation token. A region remains available while either ownership source is active. This prevents `Unloaded` from accidentally removing a programmatically registered region and prevents a stale unload token from removing a reloaded declaration. + +Only one live host may own a region name. A second live host with the same name is a configuration error. A dead weak registration may be cleaned up and replaced. + +### Host adapters + +Adapters remain internal in this release: + +- `FrameRegionAdapter` verifies dispatcher access, calls `Frame.Navigate`, and implements journal back navigation. +- `ContentControlRegionAdapter` verifies dispatcher access and assigns `ContentControl.Content`. It explicitly refuses `Frame` hosts and has no journal capability. + +The adapter resolver is the only component that distinguishes concrete host types. Adding a future `PanelRegionAdapter` or `TabControlRegionAdapter` will extend this resolver and define the new host's presentation semantics without changing `PageService`, `ContentService`, or `RegionManager`. + +`Panel` support must eventually define whether the region owns all children before using `Children.Clear()` and `Children.Add()`. `TabControl` support must define whether navigation creates a tab, replaces the selected tab, or reuses an existing item, including how headers are supplied. Those policies are intentionally deferred. + +### Route registry + +An internal route registry holds two independent, ordinal, case-sensitive maps: + +- Page key to a type assignable to `Page`. +- Content key to a type assignable to `FrameworkElement`, excluding `Page` and `Window`. + +The same key may exist once in each map. A key may not be null, empty, or whitespace. Registering the same key twice in one map throws during service collection configuration, including when both registrations point to the same type. + +The route registry stores only aliases. It does not create instances and does not use Microsoft DI keyed services. This keeps behavior identical for the DI 6 dependency used by `net48` and the DI 8 dependency used by `net8.0-windows`. + +## Public Navigation APIs + +### Page navigation + +```csharp +public interface IPageService +{ + void Navigate( + string regionName, + DialogParameters? parameters = null) + where TPage : Page; + + void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null); + + void Navigate( + string regionName, + string key, + DialogParameters? parameters = null); + + void GoBack(string regionName); + + [Obsolete("Use GoBack instead.")] + void Goback(string regionName); +} +``` + +`PageService` accepts only `Frame` regions and only `Page` targets. + +### Content navigation + +```csharp +public interface IContentService +{ + void Navigate( + string regionName, + DialogParameters? parameters = null) + where TContent : FrameworkElement; + + void Navigate( + string regionName, + Type targetType, + DialogParameters? parameters = null); + + void Navigate( + string regionName, + string key, + DialogParameters? parameters = null); +} +``` + +`ContentService` accepts only non-`Frame` `ContentControl` regions. A content target may be a `UserControl`, ordinary `ContentControl`, custom control, `Grid`, `StackPanel`, or another `FrameworkElement`; `Page` and `Window` targets are rejected. A `Frame` may be content, but a `Frame` may not be the region host used by `ContentService`. + +### Navigation awareness + +```csharp +public interface INavigationAware +{ + void OnNavigated(DialogParameters? parameters); +} +``` + +`IPageAware` inherits `INavigationAware` and retains its existing `Receive` event. A successful navigation synchronously checks both the target view and its `DataContext`. Every distinct object that implements `INavigationAware` is invoked once. If the view and `DataContext` reference the same instance, it is invoked only once. The callback is invoked even when `parameters` is null. + +The library never assigns `DataContext`. Constructor injection, factories, XAML, and view-to-view-model wiring remain application responsibilities. + +## Service Collection Extensions + +`NavigationExtensions` adds the following overloads: + +```csharp +IServiceCollection AddPage(string key) + where TPage : Page; + +IServiceCollection AddPage() + where TPage : Page + where TViewModel : class; + +IServiceCollection AddPage(string key) + where TPage : Page + where TViewModel : class; + +IServiceCollection AddContent(string key) + where TView : FrameworkElement; + +IServiceCollection AddContent() + where TView : FrameworkElement + where TViewModel : class; + +IServiceCollection AddContent(string key) + where TView : FrameworkElement + where TViewModel : class; +``` + +Registration behavior is: + +- Single-generic key overload: `TryAddTransient` the view and add its route alias. +- Double-generic overload without key: `TryAddTransient` the view and view model, with no route alias. +- Double-generic key overload: `TryAddTransient` the view and view model, then add the route alias. +- `AddContent` performs a runtime guard that rejects `Page` and `Window`, which cannot be expressed as a negative generic constraint. +- Existing application registrations are not replaced. To select a custom lifetime or factory, register it before calling the navigation extension. +- The extension does not set `DataContext` and does not infer view-model interfaces. + +`AddPage` and `AddContent` may be called before or after `RegisterNavigationService` as long as all calls occur before `BuildServiceProvider`. + +## Navigation Data Flow + +The three overload families deliberately have separate resolution paths. + +### Generic navigation + +```csharp +var target = provider.GetRequiredService(); +``` + +Generic navigation never reads the route registry. + +### Type navigation + +```csharp +var target = provider.GetRequiredService(targetType) as TExpectedBase; +``` + +The service validates assignability before resolving. Type navigation never reads the route registry. + +### String key navigation + +```csharp +var targetType = routes.GetRequiredType(key); +var target = provider.GetRequiredService(targetType) as TExpectedBase; +``` + +String navigation first resolves the alias to a type in the appropriate page or content map, then uses the ordinary DI container to create the instance. It does not call `GetRequiredKeyedService`. + +After target resolution, every navigation follows this order: + +1. Validate the region name and target input. +2. Resolve the target instance through the selected path above. +3. Retrieve the named region and require the correct host adapter. +4. Verify access to the host dispatcher. +5. Ask the adapter to present or navigate to the target. +6. If the host accepted navigation, invoke navigation awareness on the view and its `DataContext`. + +For `Frame`, the awareness callback runs synchronously after `Frame.Navigate` returns `true`; it does not wait for `Loaded`, `Navigated`, or `LoadCompleted`. For `ContentControl`, it runs after the `Content` assignment succeeds. + +`GoBack` retrieves the named `Frame`, verifies dispatcher access, and calls `GoBack` only when `CanGoBack` is true. A valid frame with no journal entry is a no-op. `Goback` forwards to `GoBack`. + +## Error Handling + +- Null, empty, or whitespace region names and route keys throw `ArgumentException`. +- An unregistered key throws `KeyNotFoundException` and names the key plus the page or content route category. +- A duplicate key in one route category throws `ArgumentException` while configuring `IServiceCollection`. +- A missing region or a region with the wrong host type throws `InvalidOperationException` and names the region, actual type when available, and expected host type. +- A non-`Page` target passed to `PageService` throws `ArgumentException`. +- A `Page` or `Window` target passed to `ContentService` throws `ArgumentException`. +- A missing DI registration retains the original `GetRequiredService` exception. +- A resolved object that does not match the validated expected base type throws `InvalidOperationException` rather than producing a null navigation target. +- Host, dispatcher, content assignment, navigation, and `INavigationAware` exceptions propagate to the caller. +- If `Frame.Navigate` returns `false`, awareness callbacks are not invoked. +- A second live region with an existing name throws `InvalidOperationException`. +- Attaching `RegionName` to, or programmatically registering, a host without a built-in adapter throws `ArgumentException` and names the unsupported type. +- `GetRegion` returns null when a name is absent; navigation services convert absence into the fail-fast exception above. +- `UnregisterRegion` returns false when the name is absent or belongs to a different element. + +## Threading And Lifetime + +Navigation APIs are synchronous and must be called on the region host's dispatcher thread. Adapters call `VerifyAccess`; the library does not marshal work to another dispatcher. + +`IRegionManager`, `IPageService`, `IContentService`, and the internal route registry are DI singletons. Views and view models added by navigation extensions default to transient through `TryAddTransient`. Existing application registrations control their actual lifetime. + +Region declarations and manager lookups use weak references where ownership is not required. Active WPF hosts unregister on unload, and `RegionManager` releases static subscriptions when disposed. + +## Planned File Structure + +- Delete `Services/RegionService.cs`. +- Create `Services/Region.cs` for the attached property and weak declaration notifications. +- Create `Interface/IRegionManager.cs` and `Common/RegionManager.cs` for region ownership. +- Create focused internal adapter files for `Frame` and `ContentControl` host behavior. +- Create `Interface/IContentService.cs` and `Services/ContentService.cs`. +- Create `Interface/INavigationAware.cs` and update `Interface/IPageAware.cs`. +- Update `Interface/IPageService.cs` and `Services/PageService.cs`. +- Add an internal page/content route registry and route descriptors. +- Update `Extensions/NavigationExtensions.cs` with core service registration and the six route/view overloads. +- Add `Tests/SimpleNavigation.Tests` to the solution. +- Update `README.md` with the rebuilt API, examples, limitations, and migration note. + +## Testing Strategy + +Tests use xUnit and an STA helper for WPF behavior. The test project targets `net48` and `net8.0-windows`. + +Coverage includes: + +- `Region` declaration on `Frame` and ordinary `ContentControl`. +- Rename, clear, unload, reload, weak cleanup, late `RegionManager` creation, and duplicate region detection. +- Programmatic register, unregister, unregistration ownership, untyped lookup, and typed lookup. +- Mixed programmatic and attached ownership, including unload/reload activation-token ordering. +- Rejection of unsupported hosts through both attached-property and programmatic registration paths. +- All six `AddPage` and `AddContent` overloads. +- Default transient registration, preservation of an existing singleton, duplicate keys, separate page/content key spaces, and key validation. +- Generic, `Type`, and string navigation for pages and content. +- Proof that generic and `Type` paths do not require a route alias. +- Proof that string paths resolve the dictionary type and then use the ordinary DI registration. +- Content host replacement and frame journal navigation. +- `GoBack`, no-journal behavior, and obsolete `Goback` forwarding. +- Rejection of page targets, window targets, frame content hosts, wrong region hosts, missing regions, invalid types, missing DI registrations, and unknown keys. +- View and `DataContext` awareness, reference de-duplication, null parameters, callback ordering, rejected navigation, and callback exception propagation. +- Dispatcher-thread enforcement. +- Successful Debug and Release builds for both library target frameworks. + +Each production behavior is implemented with a red-green-refactor cycle. The final verification runs the complete test suite and builds the solution for both targets. + +## Documentation + +The README will: + +- Replace every `RegionService.RegionName` example with `Region.RegionName`. +- Document the removal of `RegionService` as a breaking migration. +- Document `IRegionManager`, `IContentService`, and `INavigationAware`. +- Show the six `AddPage` and `AddContent` registration forms. +- Show generic, `Type`, and string key navigation. +- Explain that route aliases map strings to types while DI remains responsible for instances. +- State that the library never assigns `DataContext`. +- State that content regions currently require a non-`Frame` `ContentControl`. +- Explain how the internal adapter boundary permits future `Panel` and `TabControl` support without claiming those hosts work today. diff --git a/docs/superpowers/specs/2026-07-16-dialog-service-rebuild-design.md b/docs/superpowers/specs/2026-07-16-dialog-service-rebuild-design.md new file mode 100644 index 0000000..50f0cc3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-dialog-service-rebuild-design.md @@ -0,0 +1,183 @@ +# DialogService Rebuild Design + +## Goal + +Rebuild `IDialogService` so Window registration and resolution follow the same ordinary-DI plus explicit-route model used by Page and Content navigation. Add generic, `Type`, and string-key operations for non-modal display, modal display, and closing an existing Window. + +## Scope + +This change will: + +- Add Window and optional ViewModel registration extensions. +- Add an independent, ordinal, case-sensitive Dialog route namespace. +- Add generic, `Type`, and key overloads to `IDialogService`. +- Make `Close` operate only on existing managed Window instances and return a success result. +- Rework `DialogManager` to safely create, reuse, remove, and recreate transient Window instances. +- Fix the existing `ShowDialog` close/result re-entry bug. +- Preserve application ownership of `DataContext` and DI lifetimes. + +This change will not: + +- Set or replace a Window's `DataContext`. +- Use Microsoft DI keyed services. +- Create or dispose DI scopes for Window lifetimes. +- Automatically marshal Window operations across Dispatcher threads. + +## Registration API + +Add three `IServiceCollection` extensions: + +```csharp +AddWindow(string key) +AddWindow() +AddWindow(string key) +``` + +Constraints: + +```csharp +where TWindow : Window +where TViewModel : class +``` + +All extensions use `TryAddTransient`. Earlier application registrations are preserved, and the extensions never assign `DataContext`. + +The single-generic overload registers the Window and its key. The double-generic overload without a key registers the Window and ViewModel only. The double-generic keyed overload registers both services and the Window route. + +## Route Registry + +Extend `NavigationRouteKind` with `Dialog` and add a third immutable route map to `NavigationRouteRegistry`. + +```csharp +Type GetRequiredDialogType(string key); +``` + +Page, Content, and Dialog key spaces are independent. Each space uses `StringComparer.Ordinal`, so keys are case-sensitive. A duplicate key within Dialog registration throws `ArgumentException`, while the same key may exist in Page, Content, and Dialog maps. + +String operations resolve in two stages: + +```text +key -> Dialog route Type -> ordinary IServiceProvider.GetRequiredService(Type) +``` + +DI keyed services are not used. + +## IDialogService Contract + +The rebuilt interface is: + +```csharp +public interface IDialogService +{ + void Show(DialogParameters? parameters = null) + where TWindow : Window; + + void Show(Type targetType, DialogParameters? parameters = null); + + void Show(string key, DialogParameters? parameters = null); + + DialogParameters? ShowDialog( + DialogParameters? parameters = null) + where TWindow : Window; + + DialogParameters? ShowDialog( + Type targetType, + DialogParameters? parameters = null); + + DialogParameters? ShowDialog( + string key, + DialogParameters? parameters = null); + + bool Close() where TWindow : Window; + + bool Close(Type targetType); + + bool Close(string key); +} +``` + +Generic and `Type` operations require only ordinary DI registration. String operations require an explicit Dialog route and then resolve the mapped type through ordinary DI. + +## DialogManager Responsibilities + +`DialogManager` manages the current Window instance by Window `Type`, not by route key. Two keys mapped to the same Window type therefore address the same current instance. + +It exposes capabilities equivalent to: + +- Get or create a Window of a validated type through ordinary DI. +- Get an existing managed Window without creating one. +- Remove a Window when that exact instance raises `Closed`. + +The instance map holds weak references. Removal checks both the type and exact Window reference so a delayed `Closed` notification from an old instance cannot remove a replacement instance. + +### Window lifetime + +1. The first `Show` or `ShowDialog` resolves a transient Window through DI, stores a weak reference, and subscribes to `Closed`. +2. Repeated `Show` while the Window is open reuses the same instance. It sends the new navigation parameters, calls `Show()` only when the Window is not visible, and then calls `Activate()`. +3. When the Window closes, `DialogManager` removes the exact cached instance. +4. A later display resolves a new transient Window through DI. +5. `Close` never creates a Window. + +## Show And ShowDialog Behavior + +Before presentation, the service validates the target type and obtains the Window from `DialogManager`. + +Navigation awareness is delivered in this order: + +1. Window when it implements `IDialogAware`. +2. Window `DataContext` when it implements `IDialogAware` and is not the same reference as the Window. + +Both receive `OnNavigated(parameters)`. The service never assigns `DataContext`. + +For non-modal `Show`, `RequestClose` closes the current Window. Repeated calls update awareness and close delegates for the current instance. + +For modal `ShowDialog`: + +- `RequestClose(result)` stores the result and calls `Window.Close()`. +- The current unconditional `Closing` cancellation and recursive close path are removed. +- Closing through the system close button is allowed and returns `null` when no result was supplied. +- Both Window and distinct DataContext close delegates are cleared when modal display ends. +- Window, DI, Dispatcher, and awareness exceptions propagate to the caller. + +## Close Behavior + +All close overloads resolve a Window type, then query `DialogManager` for an existing instance without using DI. + +- Unknown string key throws `KeyNotFoundException`. +- A registered type with no current Window returns `false`. +- A Window that exists but is not focused may still be closed; WPF `IsActive` is not a prerequisite. +- `Close()` is called on the Window's Dispatcher thread after `VerifyAccess()`. +- If `Closing` cancels the operation, the method returns `false`. +- If the Window raises `Closed` and is removed from the manager, the method returns `true`. + +## Validation And Error Handling + +- Null `Type` throws `ArgumentNullException`. +- A non-Window `Type` throws `ArgumentException`. +- A null, empty, or whitespace route key throws `ArgumentException`. +- An unknown Dialog key throws `KeyNotFoundException`. +- Missing DI registration preserves the original `GetRequiredService` exception. +- Window operations require the Window Dispatcher thread; the library does not marshal automatically. +- `IDialogAware` exceptions propagate. + +## Testing Strategy + +Tests target both `net48` and `net8.0-windows` and cover: + +- All three `AddWindow` overloads and transient registration. +- Preservation of earlier application registrations. +- Independent Page, Content, and Dialog route namespaces. +- Ordinal key casing, duplicate Dialog keys, invalid keys, and unknown keys. +- Generic, `Type`, and key `Show` paths. +- Generic, `Type`, and key `ShowDialog` paths and result propagation. +- Generic, `Type`, and key `Close` paths. +- Close-before-show returning `false` without creating a Window. +- Closing cancellation returning `false`. +- Reuse while open and recreation after `Closed`. +- Window and DataContext awareness order, de-duplication, and exception propagation. +- Dispatcher enforcement and DI exception preservation. +- Regression coverage for Page, Content, Region, and registration behavior. + +## Documentation And Migration + +README examples will show the three Window registration extensions, generic/`Type`/key display operations, key-based close, and transient recreation after close. The existing warning about broken modal closure will be removed only after the repaired behavior is verified by tests.