diff --git a/Common/Adapters/ContentControlRegionAdapter.cs b/Common/Adapters/ContentControlRegionAdapter.cs new file mode 100644 index 0000000..c632970 --- /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; + + 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..fdc0ee2 --- /dev/null +++ b/Common/Adapters/RegionHostAdapter.cs @@ -0,0 +1,49 @@ +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 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/DialogManager.cs b/Common/Managers/DialogManager.cs similarity index 93% rename from Common/DialogManager.cs rename to Common/Managers/DialogManager.cs index 697556c..863dbbd 100644 --- a/Common/DialogManager.cs +++ b/Common/Managers/DialogManager.cs @@ -1,8 +1,8 @@ using System.Windows; using Microsoft.Extensions.DependencyInjection; -using SimpleNavigation.Interface; +using SimpleNavigation.Interface.Managers; -namespace SimpleNavigation.Common +namespace SimpleNavigation.Common.Managers { public class DialogManager : IDialogManager { 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..6601191 --- /dev/null +++ b/Common/NavigationRouteRegistry.cs @@ -0,0 +1,80 @@ +namespace SimpleNavigation.Common +{ + /// + /// 被导航对象的类型 + /// + internal enum NavigationRouteKind + { + Page, + Content, + } + + /// + /// 导航对象 + /// + 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; + + public NavigationRouteRegistry(IEnumerable registrations) + { + pages = BuildRoute(registrations, NavigationRouteKind.Page); + contents = BuildRoute(registrations, NavigationRouteKind.Content); + } + + public Type GetRequiredPageType(string key) => GetRequiredTarget(pages, key, "page"); + + public Type GetRequiredContentType(string key) => GetRequiredTarget(contents, key, "content"); + + /// + /// 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..5f1f0c0 100644 --- a/Extensions/NavigationExtensions.cs +++ b/Extensions/NavigationExtensions.cs @@ -1,20 +1,119 @@ 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 AddPage(this IServiceCollection services, string key) + where TPage : Page + { + AddRoute(services, NavigationRouteKind.Page, key, typeof(TPage)); + services.TryAddSingleton(); + return services; + } + + public static IServiceCollection AddPage(this IServiceCollection services) + where TPage : Page where TViewModel : class + { + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + public static IServiceCollection AddPage(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 AddContent(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 AddContent(this IServiceCollection services) + where TView : FrameworkElement where TViewModel : class + { + ValidateContentType(typeof(TView)); + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + public static IServiceCollection AddContent(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; + } + + 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/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/IDialogManager.cs b/Interface/Managers/IDialogManager.cs similarity index 75% rename from Interface/IDialogManager.cs rename to Interface/Managers/IDialogManager.cs index 84fcddd..9d0801c 100644 --- a/Interface/IDialogManager.cs +++ b/Interface/Managers/IDialogManager.cs @@ -1,7 +1,10 @@ using System.Windows; -namespace SimpleNavigation.Interface +namespace SimpleNavigation.Interface.Managers { + /// + /// Dialog 绠$悊瀵硅薄 + /// public interface IDialogManager { /// 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/IDialogService.cs b/Interface/Services/IDialogService.cs similarity index 94% rename from Interface/IDialogService.cs rename to Interface/Services/IDialogService.cs index 8f4beb6..c6c6b1c 100644 --- a/Interface/IDialogService.cs +++ b/Interface/Services/IDialogService.cs @@ -1,7 +1,7 @@ using SimpleNavigation.Common; using System.Windows; -namespace SimpleNavigation.Interface +namespace SimpleNavigation.Interface.Services { /// /// 绐楀彛鏈嶅姟鎺ュ彛锛岀敤浜庢墦寮鏂扮獥鍙 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..db6ec93 100644 --- a/README.md +++ b/README.md @@ -2,327 +2,281 @@ [![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 鍒涘缓骞舵樉绀 `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 -``` +| 鐩爣妗嗘灦 | DI 渚濊禆 | +| --- | --- | +| `net8.0-windows` | `Microsoft.Extensions.DependencyInjection` 8.0.0 | +| `net48` | `Microsoft.Extensions.DependencyInjection` 6.0.1 | -### 妗嗘灦渚濊禆 - -| 鐩爣妗嗘灦 | 渚濊禆椤 | 鐗堟湰 | -|----------|--------|------| -| 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 瀹夎 - -閫氳繃 NuGet 瀹夎锛 +瀹夎 NuGet 鍖咃細 ```bash dotnet add package Junevy.SimpleNavigation ``` -鎴栦娇鐢 Package Manager锛 +## 椤圭洰缁撴瀯 -``` -Install-Package Junevy.SimpleNavigation +```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 涓庤矾鐢辨敞鍐屾墿灞 ``` -#### 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(); +var services = new ServiceCollection(); +services.RegisterNavigationService(); - // 娉ㄥ唽瀵艰埅鏈嶅姟锛堜竴琛屾敞鍐 IDialogService / IPageService / IDialogManager锛 - container.RegisterNavigationService(); +// 鏅 DI 娉ㄥ唽瓒充互鏀寔娉涘瀷瀵艰埅鍜 Type 瀵艰埅銆 +services.AddTransient(); +services.AddSingleton(); - // 娉ㄥ唽浣犵殑椤甸潰銆佺獥鍙e拰 ViewModel - container.AddTransient(); - container.AddSingleton(); - container.AddTransient(); - container.AddSingleton(); - container.AddSingleton((p) => new TestPage() { ShowsNavigationUI = false }); +// 璺敱鎵╁睍鍙悓鏃舵敞鍐 View/ViewModel锛屽苟鍙夋嫨娣诲姞瀛楃涓插埆鍚嶃 +services.AddPage("settings"); +services.AddPage(); +services.AddPage("reports"); +services.AddContent("help"); +services.AddContent(); +services.AddContent("status"); - Provider = container.BuildServiceProvider(); - } -} +var provider = services.BuildServiceProvider(); ``` -#### 2.3 娉ㄥ唽瀵艰埅鍖哄煙锛圧egion锛 +`AddPage` 涓 `AddContent` 浣跨敤 `TryAddTransient` 娉ㄥ唽 View 鍜屽彲閫夌殑 ViewModel锛屼笉浼氳鐩栧湪瀹冧滑涔嬪墠娣诲姞鐨勬敞鍐屻傞渶瑕佽嚜瀹氫箟鐢熷懡鍛ㄦ湡鎴栧伐鍘傛椂锛屽簲鍏堜娇鐢ㄦ爣鍑 DI 鏂规硶娉ㄥ唽瀵瑰簲鏈嶅姟銆傝繖浜涙墿灞曟柟娉曚笉浼氬垱寤烘垨璁剧疆 View 鐨 `DataContext`銆 -鍦 XAML 涓氳繃闄勫姞灞炴у0鏄庡鑸尯鍩燂細 +鍙屾硾鍨嬫棤 key 鐨勯噸杞藉彧娉ㄥ唽 View 鍜 ViewModel锛涘崟娉涘瀷鍔 key 鐨勯噸杞芥敞鍐 View 涓庤矾鐢卞埆鍚嶏紱鍙屾硾鍨嬪姞 key 鐨勯噸杞藉悓鏃舵敞鍐 View銆乂iewModel 涓庤矾鐢卞埆鍚嶃侾age 涓 Content 鐨 key 绌洪棿鐩镐簰鐙珛銆佸尯鍒嗗ぇ灏忓啓锛屽悓涓绌洪棿鍐呬笉鑳介噸澶嶆敞鍐 key銆 -```xml - +## 澹版槑瀵艰埅鍖哄煙 +```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("id", 42); - // 甯﹀弬鏁板鑸 - var parameters = new DialogParameters("key", "value"); - pageService.Navigate("Main", parameters); - - // 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 瀹炰緥銆 + +## Content 瀵艰埅 -璁 ViewModel 瀹炵幇 `IPageAware` 鎺ュ彛锛 +`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锛 ```csharp -using SimpleNavigation.Common; -using SimpleNavigation.Interface; +services.AddTransient(); +services.AddTransient(); +``` + +浣跨敤 `IDialogService` 鏄剧ず闈炴ā鎬佺獥鍙o細 + +```csharp +var input = new DialogParameters("id", 42); -public class TestViewModel : IDialogAware +dialogService.Show(input); +``` + +瀵逛簬 `Show`锛學indow 鎴栧畠鐨 `DataContext` 鍙互瀹炵幇 `IDialogAware` 鏉ユ帴鏀跺弬鏁板拰璇锋眰鍏抽棴锛 + +```csharp +public sealed class TestViewModel : 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() { - // 鍏抽棴绐楀彛骞惰繑鍥炵粨鏋 - var result = new DialogParameters("resultKey", "resultValue"); - RequestClose?.Invoke(result); + RequestClose?.Invoke(null); } } ``` -> 妗嗘灦鏀寔 `IDialogAware` 鍚屾椂瀹炵幇鍦 Window 鑷韩锛坈ode-behind锛夋垨 DataContext锛圴iewModel锛変笂锛屼袱鑰呭彲鍏卞瓨銆 +`ShowDialog` 褰撳墠鐨勫叧闂/缁撴灉娴佺▼瀛樺湪宸茬煡闄愬埗锛氬叾 `Closing` 澶勭悊浼氬彇娑堝叧闂紝骞跺彲鑳藉湪鍏抽棴璇锋眰涓啀娆¤皟鐢 `Close`銆傚湪 `DialogService` 淇鍓嶏紝涓嶅簲渚濊禆璇ユ柟娉曞畬鎴愭ā鎬佸叧闂垨杩斿洖缁撴灉銆 -### 5. DialogParameters 鍙傛暟瀵硅薄 +`DialogManager` 浣跨敤寮卞紩鐢ㄧ紦瀛樺悓绫诲瀷 Window锛屽苟鍦 Window 鍏抽棴鍚庣Щ闄よ褰曘 + +## 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 閲婃斁鏃跺彇娑堣闃呫 + +Page/Content 瀵艰埅鏈嶅姟涓 `DialogManager` 浼氭崟鑾峰垱寤哄畠浠殑 `IServiceProvider`锛堥氬父鏄牴瀹瑰櫒锛夛紱浠呭垱寤烘垨鎸佹湁涓涓瓙 scope 涓嶄細鎶婅繖浜 singleton 鐨勭洰鏍囪В鏋愬垏鎹㈠埌璇 scope銆 + +鍥犳锛屽湪鍚敤 `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 鐢熷懡鍛ㄦ湡涓鑷淬 -- **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` 涔熷皻鏈綔涓哄尯鍩熷涓诲惎鐢ㄣ -![preview](https://github.com/user-attachments/assets/f1692e72-eace-44f8-a6c7-171243d2f854) +鍐呴儴鍖哄煙閫傞厤鍣ㄤ互 `FrameworkElement` 鍜屽涓昏兘鍔涗负杈圭晫锛屽洜姝ゆ湭鏉ュ彲浠ュ湪 resolver 涓鍔犻傞厤鍣紝鑰屼笉蹇呬慨鏀 `ContentService`銆備笉杩囷紝鍦ㄦ敮鎸 `Panel` 鍓嶅繀椤绘槑纭尯鍩熸槸鍚︽嫢鏈夊叏閮ㄥ瓙鍏冪礌浠ュ強鏇挎崲/杩藉姞绛栫暐锛涘湪鏀寔 `TabControl` 鍓嶅繀椤绘槑纭爣绛惧垱寤恒侀夋嫨銆佸鐢ㄥ拰 header 绛栫暐銆 + +## 鐮村潖鎬у崌绾ц縼绉 + +鏈閲嶅缓閲囩敤浠ヤ笅 API 鏄犲皠锛 + +```text +RegionService.RegionName -> Region.RegionName +IPageService.GetRegion(...) -> IRegionManager.GetRegion(...) +IPageService.Goback(...) -> IPageService.GoBack(...) +``` ---- +`RegionService` 宸插垹闄ゃ俙Goback` 浠呬綔涓烘爣璁颁簡 `Obsolete` 鐨勮浆鍙戞柟娉曚繚鐣欙紝鐜版湁浠g爜搴旇縼绉诲埌 `GoBack`銆傜敱浜庨檮鍔犲睘鎬ф墍鏈夎呭彂鐢熷彉鍖栵紝寮曠敤鏃у睘鎬х殑宸茬紪璇 XAML/BAML 蹇呴』閲嶆柊鏋勫缓銆 ## 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..b58c3d4 100644 --- a/Services/DialogService.cs +++ b/Services/DialogService.cs @@ -1,5 +1,7 @@ using SimpleNavigation.Common; -using SimpleNavigation.Interface; +using SimpleNavigation.Interface.Awares; +using SimpleNavigation.Interface.Managers; +using SimpleNavigation.Interface.Services; using System.Windows; namespace SimpleNavigation.Services @@ -52,14 +54,10 @@ public void Show(DialogParameters? parameters = null) where T : Window { e.Cancel = true; if (s is IDialogAware dialogAware) - { dialogAware.RequestClose?.Invoke(result); - } if (s is Window w && w.DataContext is IDialogAware dialogVmAware) - { dialogVmAware.RequestClose?.Invoke(result); - } }; if (window.DataContext is IDialogAware vm) 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..f881e58 100644 --- a/SimpleNavigation.csproj +++ b/SimpleNavigation.csproj @@ -8,9 +8,10 @@ 13 true Junevy.SimpleNavigation - 1.0.1 + 2.0.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..6b26ac4 --- /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.AddContent("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.AddContent(); + 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/NavigationExtensionsTests.cs b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs new file mode 100644 index 0000000..3d5a8b6 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/NavigationExtensionsTests.cs @@ -0,0 +1,313 @@ +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.AddPage("page"); + services.AddContent("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.AddPage("shared"); + services.RegisterNavigationService(); + services.AddContent("shared"); + services.AddPage("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.AddPage("main"); + services.AddPage("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.AddPage(); + services.AddContent(); + 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.AddPage("page"); + services.AddContent("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.AddPage("main"); + services.AddPage("Main"); + + var exception = Assert.Throws( + () => services.AddPage("main")); + + Assert.Equal("key", exception.ParamName); + } + + [Fact] + public void DuplicateContentKey_IsRejected() + { + var services = new ServiceCollection(); + services.AddContent("main"); + + var exception = Assert.Throws( + () => services.AddContent("main")); + + Assert.Equal("key", exception.ParamName); + } + + [Fact] + public void SameKeyAcrossPageAndContentCategories_IsAllowed() + { + var services = new ServiceCollection(); + + services.AddPage("main"); + services.AddContent("main"); + } + + [Fact] + public void ContentRegistration_RejectsPageAndWindowTypes() + { + var services = new ServiceCollection(); + + Assert.Throws(() => services.AddContent("page")); + Assert.Throws(() => services.AddContent("window")); + Assert.Throws(() => services.AddContent()); + Assert.Throws(() => services.AddContent("window-vm")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void InvalidRouteKey_IsRejected(string? key) + { + var exception = Assert.Throws( + () => new ServiceCollection().AddPage(key!)); + + Assert.Equal("key", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void InvalidContentRouteKey_IsRejected(string? key) + { + var exception = Assert.Throws( + () => new ServiceCollection().AddContent(key!)); + + Assert.Equal("key", exception.ParamName); + } + + 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..d0e943b --- /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.AddPage("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..0765008 --- /dev/null +++ b/Tests/SimpleNavigation.Tests/TestTypes.cs @@ -0,0 +1,76 @@ +using SimpleNavigation.Common; +using SimpleNavigation.Interface.Awares; +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 +{ +} + +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/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.