diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 7c179232..00000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,102 +0,0 @@ -# Copilot Instructions for Kampute.DocToolkit - -## Project Overview -This is a .NET library that extracts metadata from .NET assemblies and transforms XML documentation comments into structured documentation models. It provides a **pipeline architecture** rather than fixed output - you implement custom renderers while the library handles reflection, XML processing, and cross-reference resolution. - -## Core Architecture Pattern -The library follows a **three-phase pipeline**: -1. **Metadata Extraction**: Reflects over .NET assemblies to build object models (`src/Models/`, `src/Collections/`) -2. **XML Processing**: XSLT-based transformation of XML documentation (`src/XmlDoc/`, `src/Xslt/`) -3. **Output Generation**: Custom renderers process models into final formats (`DocumentPageRenderer`) - -## Key Components & Workflows - -### Documentation Context Setup -Always use the builder pattern via `DocumentationContextBuilder`: -```csharp -var context = DocumentationContextBuilder - .DevOpsWiki() // or .DocFx(), .DotNetApi() - .AddAssembly("path/to/assembly.dll") - .AddXmlDoc("path/to/assembly.xml") - .Build(); -``` - -### Custom Renderers -Extend `DocumentPageRenderer` and implement the abstract `Render` method: -- Input: `PageCategory` enum + `IModel` (different model types for types vs members) -- Output: Write to `TextWriter` in your target format -- The `PageCategory` enum drives different rendering logic (see `src/PageCategory.cs`) - -### Models Architecture -The library uses distinct model hierarchies in `src/Models/`: -- **Base**: `MemberModel` (implements `IDocumentModel`) - base for types and type members -- **Types**: `TypeModel` → `CompositeTypeModel` (classes, structs) - represent .NET types with metadata -- **Members**: `TypeMemberModel` (methods, properties, fields, etc.) - represent type members with signatures -- **Namespace/Topic**: `NamespaceModel`, `TopicModel` - implement `IDocumentModel` directly -- **Assembly**: `AssemblyModel` - container for types, does not implement `IDocumentModel` -- **Documentation**: `XmlDocEntry` objects contain structured XML documentation (summary, remarks, parameters, etc.) -- **Collections**: Specialized collections (`TopicCollection`, `TypesCollection`, `OverloadCollection`, `PatternCollection`) with filtering/grouping - -Concrete model classes: `ClassModel`, `MethodModel`, `PropertyModel`, `FieldModel`, etc. - -### XSLT Integration Pattern -XML doc transformation uses embedded XSLT resources: -- HTML: `src/Xslt/Resources/html.xslt` -- Markdown: `src/Xslt/Resources/md.xslt` -- Access via `XsltCompiler.CompileEmbeddedResource("html"|"md")` -- Custom transformers extend `XmlDocTransformer` class - -## Testing Patterns -- Uses **NUnit** with **Moq** for mocking -- Use `[TestCase(..., ExpectedResult=...)]` for parameterized tests. -- Name test methods: `MemberName_StateUnderTest_ExpectedBehavior`. -- When applicable, use types and members defined in `Acme.cs` for test cases. -- Prefer constraint-based assertions. -- Write only tests for public members and business logic (skip private methods and parameter validation). -- `tests/MockHelper.cs` provides common test utilities -- Mock contexts via `MockHelper.CreateDocumentationContext(assemblies, topics)` -- Test files mirror `src/` structure in `tests/` - -## Build & Development -- **Target**: netstandard2.1 -- **Signed assemblies** (when `SigningKey.snk` exists) -- Build: `dotnet build DocToolkit.sln` -- Test: `dotnet test` -- Package: Auto-generates NuGet on build (`GeneratePackageOnBuild=true`) - -## File Organization Conventions -- **Namespace folders**: Each namespace gets its own folder (`Collections/`, `Formatters/`, etc.) -- **NamespaceDoc.cs**: Every namespace folder contains this file with namespace-level XML documentation -- **Embedded resources**: XSLT files in `src/Xslt/Resources/` are embedded as resources -- **Interface segregation**: Small, focused interfaces (e.g., `IXmlDocTransformer`, `IDocumentRenderer`) - -## Configuration & Target Platforms -Built-in support for common documentation platforms via addressing strategies: -- **Azure DevOps Wiki**: Markdown with `.md` extensions in URLs -- **DocFx**: HTML-based with specific URL patterns -- **Microsoft Docs**: .NET API browser conventions -- Each has corresponding `*Options` and `*Page` classes - -## Critical Dependencies -- **System.Xml.Xsl**: Core XSLT transformation engine -- **System.Reflection**: Assembly metadata extraction -- **Embedded XSLT**: Never modify XSLT files without rebuilding - they're embedded resources - -When adding new output formats, follow the `HtmlFormat`/`MarkdownFormat` pattern: implement `IDocumentFormatter`, create corresponding XSLT transformer, and register text transformers for link processing. - -## Development Guidelines -- **SOLID principles**: Prioritize Single Responsibility and Open/Closed principles -- **Simplicity over complexity**: Avoid excessive helper methods and unnecessary abstractions -- **Self-documenting code**: Code should be readable without redundant inline comments -- **Interface pragmatism**: Use interfaces judiciously - avoid "Ravioli" (too many small interfaces) and "Lasagna" (too many layers) -- **Performance & clarity**: Optimize for both execution speed and code understanding -- **Problem-solving approach**: Question existing solutions, propose improvements, document limitations when needed - -## Documentation Guidelines -- Avoid promotional, flowery, or overly embellished language, and use adjectives and adverbs only when strictly necessary. -- Emphasize purpose and usage; do not document implementation details or obvious information. -- Provide meaningful context and call out important behavioral nuances or edge cases. -- Keep content concise and focused by using short sentences and brief paragraphs to convey information clearly. -- Organize content using bullet points, numbered lists, or tables when appropriate, and explain the context or purpose of the list or table in at least one paragraph. -- Numbered lists should be used for steps in a process or sequence only. -- When writing XML documentation comments, use appropriate XML tags for references, language keywords, and for organizing information in lists and tables. Ensure tags are used to clarify context, structure information, and improve readability for consumers of the documentation. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6546d9a3..640b78d4 100644 --- a/.gitignore +++ b/.gitignore @@ -369,5 +369,11 @@ FodyWeavers.xsd *.pfx *.snk +# AI Agents +.agents/ +.codex/ +.claude/ +.junie/ + # Documentation Site .site/ \ No newline at end of file diff --git a/kampose.json b/kampose.json index edec0719..ac392529 100644 --- a/kampose.json +++ b/kampose.json @@ -32,13 +32,19 @@ "projectLogoLightUri": "logo-dark.png", "projectLogoDarkUri": "logo-light.png", "faviconUri": "ICON.png", - "pageFooter": "Copyright © {{now 'yyyy'}} [Kampute](https://kampute.com)", - "pageFooterRight": "Built with [Kampose](https://kampute.github.io/kampose/)", "groupTypesByNamespace": true, "showTypeMembersSummary": true, "seeAlsoSubtopics": true, "menuItems": [ - "*" + "*" + ], + "pageFooter": [ + "- Copyright © {{now 'yyyy'}} Kampute", + "- [MIT License](~/LICENSE)", + "- [Source code](https://github.com/kampute/doc-toolkit)" + ], + "pageFooterRight": [ + "Built with [Kampose](https://kampute.github.io/kampose/)" ] } } \ No newline at end of file diff --git a/src/Collections/TopicCollection.cs b/src/Collections/TopicCollection.cs index 36a96ba3..2aecb993 100644 --- a/src/Collections/TopicCollection.cs +++ b/src/Collections/TopicCollection.cs @@ -313,7 +313,7 @@ public bool TryFindBySubpath(string filePath, [NotNullWhen(true)] out TopicModel foreach (var (sourceFilePath, fileBasedTopic) in topicsByPath) { - if (PathHelper.IsSubpath(sourceFilePath, subPath)) + if (PathHelper.IsSubpath(subPath, sourceFilePath)) { if (topic is not null) { diff --git a/src/DocumentationContext.cs b/src/DocumentationContext.cs index 6b0f15ea..b50b6a72 100644 --- a/src/DocumentationContext.cs +++ b/src/DocumentationContext.cs @@ -1,4 +1,4 @@ -// Copyright (C) Kampute +// Copyright (C) Kampute // // Released under the terms of the MIT license. // See the LICENSE file in the project root for the full license text. @@ -127,10 +127,10 @@ public DocumentationContext public IDocumentAddressProvider AddressProvider { get; } /// - /// Gets the object responsible for transforming non-API site-root-relative URLs to an absolute or document-relative URL. + /// Gets the object responsible for transforming non-API documentation-root-relative URLs to absolute or document-relative URLs. /// /// - /// The object responsible for transforming non-API site-root-relative URLs to an absolute or document-relative URL. + /// The object responsible for transforming non-API documentation-root-relative URLs to absolute or document-relative URLs. /// public IUrlTransformer UrlTransformer { get; } @@ -222,13 +222,13 @@ protected void Dispose(bool disposing) /// The URL transformer to use for transforming non-API URLs in the documentation. /// /// This method creates an implementation of for transforming non-API - /// site-root-relative URLs to absolute or document-relative URLs. + /// documentation-root-relative URLs to absolute or document-relative URLs. /// - /// The default implementation returns an instance of the class. + /// The default implementation returns an instance of the class. /// Override this method in derived classes to provide a custom URL transformer implementation if needed. /// /// - protected virtual IUrlTransformer CreateUrlTransformer() => new ContextAwareUrlTransformer(this); + protected virtual IUrlTransformer CreateUrlTransformer() => new DocumentationUrlTransformer(this); /// /// Retrieves all unique namespaces with exported types from the assemblies in the documentation context. diff --git a/src/IDocumentationContext.cs b/src/IDocumentationContext.cs index 04df009a..e8a04fbf 100644 --- a/src/IDocumentationContext.cs +++ b/src/IDocumentationContext.cs @@ -106,11 +106,11 @@ public interface IDocumentationContext : IDisposable IDocumentAddressProvider AddressProvider { get; } /// - /// Gets the object responsible for transforming non-API site-root-relative URLs to an absolute or document-relative URL. + /// Gets the object responsible for transforming non-API documentation-root-relative URLs to absolute or document-relative URLs. /// /// - /// The object responsible for transforming non-API site-root-relative URLs to an absolute or document-relative URL. + /// The object responsible for transforming non-API documentation-root-relative URLs to absolute or document-relative URLs. /// IUrlTransformer UrlTransformer { get; } } -} \ No newline at end of file +} diff --git a/src/Kampute.DocToolkit.csproj b/src/Kampute.DocToolkit.csproj index b5b794b3..629ef5d8 100644 --- a/src/Kampute.DocToolkit.csproj +++ b/src/Kampute.DocToolkit.csproj @@ -4,7 +4,7 @@ netstandard2.1 Kampute.DocToolkit Provides extensible pipeline for generating .NET API documentation by transforming assembly metadata and XML documentation into structured models, with automatic cross-reference resolution, support for multiple output formats (HTML, Markdown), and integration of conceptual topics. - 2.3.0 + 3.0.0 Kampute Kambiz Khojasteh Copyright (C) Kampute diff --git a/src/Routing/RelativeToAbsoluteUrlNormalizer.cs b/src/Routing/AbsoluteUrlContextManager.cs similarity index 62% rename from src/Routing/RelativeToAbsoluteUrlNormalizer.cs rename to src/Routing/AbsoluteUrlContextManager.cs index a60b95fc..4b250860 100644 --- a/src/Routing/RelativeToAbsoluteUrlNormalizer.cs +++ b/src/Routing/AbsoluteUrlContextManager.cs @@ -6,28 +6,27 @@ namespace Kampute.DocToolkit.Routing { using System; - using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; /// - /// Converts relative URLs to absolute URLs using a fixed base URL. + /// Converts documentation-root-relative URLs to absolute URLs using a fixed documentation root URL. /// /// - /// This class provides a simple implementation of the URL adjustment infrastructure that converts all relative URLs to - /// absolute URLs by combining them with a configurable base URL. + /// This class provides a simple implementation of the URL adjustment infrastructure that converts documentation-root-relative + /// URLs to absolute URLs by combining them with a configurable documentation root URL. /// /// - public sealed class RelativeToAbsoluteUrlNormalizer : DocumentUrlContextManager + public sealed class AbsoluteUrlContextManager : DocumentUrlContextManager { private readonly string baseUrlString; /// - /// Initializes a new instance of the class with the specified base URL. + /// Initializes a new instance of the class with the specified base URL. /// /// The base URL to use for creating absolute URLs from relative URLs. /// Thrown when is . /// Thrown when is not an absolute URL. - public RelativeToAbsoluteUrlNormalizer(Uri baseUrl) + public AbsoluteUrlContextManager(Uri baseUrl) : base() { if (baseUrl is null) @@ -40,12 +39,12 @@ public RelativeToAbsoluteUrlNormalizer(Uri baseUrl) } /// - /// Initializes a new instance of the class with the specified base URL string. + /// Initializes a new instance of the class with the specified base URL string. /// /// The base URL to use for creating absolute URLs from relative URLs. /// Thrown when is . /// Thrown when is not an absolute URL. - public RelativeToAbsoluteUrlNormalizer(string baseUrlString) + public AbsoluteUrlContextManager(string baseUrlString) : base() { if (baseUrlString is null) @@ -77,50 +76,53 @@ public RelativeToAbsoluteUrlNormalizer(string baseUrlString) protected override UrlContext CreateScope(string directory, IDocumentModel? model) => new AbsoluteUrlContext(this, directory, model); /// - /// Represents a disposable URL context that converts relative URLs to absolute URLs. + /// Represents a disposable URL context that converts documentation-root-relative URLs to absolute URLs. /// private sealed class AbsoluteUrlContext : UrlContext { - private readonly string rootUrlString; + private readonly string documentationRootUrlString; /// /// Initializes a new instance of the class. /// - /// The owning normalizer instance. + /// The owning context manager. /// The directory path of the document being rendered relative to the documentation root. /// The document model associated with the current context or if not applicable. - public AbsoluteUrlContext(RelativeToAbsoluteUrlNormalizer owner, string directory, IDocumentModel? model) + public AbsoluteUrlContext(AbsoluteUrlContextManager owner, string directory, IDocumentModel? model) : base(owner, directory, model) { - RootUrl = owner.BaseUrl; - rootUrlString = owner.baseUrlString; + DocumentationRootUrl = owner.BaseUrl; + documentationRootUrlString = owner.baseUrlString; } /// /// Gets the absolute URL to the root of the documentation site for the current document. /// /// - /// The URL that serves as the reference point for resolving relative URLs within the current document. + /// The configured absolute URL of the documentation root, including any path below the web site's root. /// - public override Uri RootUrl { get; } + public override Uri DocumentationRootUrl { get; } /// - /// Attempts to transform a site-relative URL string into an absolute URL. + /// Resolves a documentation-root-relative URL into an absolute URL. /// - /// The URL string relative to site root to transform. - /// When this method returns, contains the transformed URL if the transformation succeeded; otherwise, . - /// if the URL was successfully transformed; otherwise, . - public override bool TryTransformSiteRelativeUrl(string siteRelativeUrl, [NotNullWhen(true)] out string? transformedUrl) + /// + /// A URL string relative to the documentation root (without the ~/ marker). The URL consists of a normalized path + /// component and may optionally include a query string and/or fragment. + /// + /// An absolute URL string combining the base URL with the provided URL. + /// + /// The method combines the configured documentation root URL with the provided URL string. Query strings and fragments + /// are preserved in the result. + /// + /// Thrown when is . + public override string ResolveFromDocumentationRoot(string urlString) { - if (!IsSiteRelativeUrl(siteRelativeUrl)) - { - transformedUrl = null; - return false; - } + if (urlString is null) + throw new ArgumentNullException(nameof(urlString)); - transformedUrl = rootUrlString + siteRelativeUrl; - return true; + return documentationRootUrlString + urlString; } } } -} \ No newline at end of file +} diff --git a/src/Routing/DocumentAddressProvider.cs b/src/Routing/DocumentAddressProvider.cs index a01766fb..fae20ed3 100644 --- a/src/Routing/DocumentAddressProvider.cs +++ b/src/Routing/DocumentAddressProvider.cs @@ -1,4 +1,4 @@ -// Copyright (C) Kampute +// Copyright (C) Kampute // // Released under the terms of the MIT license. // See the LICENSE file in the project root for the full license text. @@ -49,7 +49,7 @@ public DocumentAddressProvider(IDocumentAddressingStrategy strategy, IEnumerable Strategy = strategy; documentAssemblies = new(assemblies, ReferenceEqualityComparer.Instance); - urlContext = baseUrl is null ? new ContextAwareUrlNormalizer() : new RelativeToAbsoluteUrlNormalizer(baseUrl); + urlContext = baseUrl is null ? new RelativeUrlContextManager() : new AbsoluteUrlContextManager(baseUrl); } /// @@ -242,16 +242,14 @@ public virtual bool TryGetTopicFile(ITopic topic, [NotNullWhen(true)] out string protected IRemoteApiDocUrlResolver? FindExternalProviderForMember(IMember member) => member is not null ? FindExternalProviderForNamespace(member.Namespace) : null; /// - /// Creates an absolute or document-relative URL from a site-relative URL string. + /// Creates an absolute or document-relative URL from a path relative to the documentation root. /// - /// The site-relative URL string to create an absolute or document-relative URL from. + /// The path relative to the documentation root. /// A URL that correctly navigates from the current document's location to the target. [MethodImpl(MethodImplOptions.AggressiveInlining)] - protected Uri ToDocumentUrl(string siteRelativeUrl) + protected Uri ToDocumentUrl(string documentationRelativePath) { - if (!ActiveScope.TryTransformSiteRelativeUrl(siteRelativeUrl, out var documentUrl)) - documentUrl = siteRelativeUrl; - + var documentUrl = ActiveScope.ResolveFromDocumentationRoot(documentationRelativePath); return new RawUri(documentUrl, UriKind.RelativeOrAbsolute); } diff --git a/src/Routing/DocumentUrlContext.cs b/src/Routing/DocumentUrlContext.cs index aff7fec0..61cfada2 100644 --- a/src/Routing/DocumentUrlContext.cs +++ b/src/Routing/DocumentUrlContext.cs @@ -8,7 +8,6 @@ namespace Kampute.DocToolkit.Routing using Kampute.DocToolkit.Support; using System; using System.Diagnostics.CodeAnalysis; - using System.Runtime.CompilerServices; /// /// Represents a disposable context that manages relative URL resolution within a document context. @@ -41,24 +40,23 @@ protected DocumentUrlContext(string directory, IDocumentModel? model) } /// - /// Gets a value indicating whether the current document is at the root level of the documentation site. + /// Gets a value indicating whether the current document is at the documentation root. /// /// - /// if the current document is at the root level; otherwise, . + /// if the current document is at the documentation root; otherwise, . /// - public bool IsRoot => Directory.Length == 0; + public bool IsDocumentationRoot => Directory.Length == 0; /// - /// Gets the absolute or relative URL to the root of the documentation site for the current context. + /// Gets the absolute or document-relative URL to the documentation root for the current context. /// /// - /// The URL that serves as the reference point for resolving relative URLs within the current context. + /// The URL that identifies the documentation root from the current document. /// /// - /// The property serves as a critical reference point for constructing site-wide resource URLs - /// and cross-document navigation that works consistently regardless of the document's depth in the hierarchy. It is - /// especially useful for breadcrumb navigation and referencing shared resources like CSS, JavaScript files, and images - /// from documents at any level in the site hierarchy. + /// The documentation root may differ from the web site's root, such as when documentation is published below a repository + /// path. A relative value describes navigation from the current document to the documentation root; an absolute value + /// identifies the published documentation root directly. /// /// When the root URL is a relative URL, it represents the path from the current document to the root of the documentation /// site. The following examples illustrate how a relative root URL is computed based on document location: @@ -69,7 +67,7 @@ protected DocumentUrlContext(string directory, IDocumentModel? model) /// /// /// - public abstract Uri RootUrl { get; } + public abstract Uri DocumentationRootUrl { get; } /// /// Gets the current document directory path relative to the documentation root. @@ -93,34 +91,68 @@ protected DocumentUrlContext(string directory, IDocumentModel? model) public IDocumentModel? Model { get; } /// - /// Attempts to transform a site-relative URL string into an absolute or document-relative URL based on the current context. + /// Resolves a documentation-root-relative URL into an absolute or document-relative URL. /// - /// The URL string relative to site root to transform. - /// When this method returns, contains the transformed URL if the transformation succeeded; otherwise, . - /// if the URL was successfully transformed; otherwise, . + /// + /// A URL string relative to the documentation root (without the ~/ marker). The URL consists of a normalized path + /// component and may optionally include a query string and/or fragment. + /// + /// An absolute or document-relative URL string that correctly navigates from the current document's location to the target. /// - /// This method transforms URLs that are specified relative to the site root into URLs that work correctly when referenced - /// from the current context, regardless of the document's depth in the documentation hierarchy. + /// This method performs the core URL resolution logic based on the current document context. The input URL must be relative + /// to the documentation root with its path component already normalized (no .. segments that escape the root). + /// + /// Query strings and fragments in the URL are preserved in the resolved result. + /// /// - public abstract bool TryTransformSiteRelativeUrl(string siteRelativeUrl, [NotNullWhen(true)] out string? transformedUrl); + /// Thrown when is . + public abstract string ResolveFromDocumentationRoot(string urlString); /// - /// Disposes the current instance and restores the previous URL context. + /// Attempts to resolve a documentation-root-relative URL into an absolute or document-relative URL. /// - public abstract void Dispose(); + /// + /// A URL beginning with ~/, where the marker represents the documentation root rather than the web site's root. + /// + /// When this method returns, contains the resolved URL if resolution succeeded; otherwise, . + /// if the URL was successfully resolved; otherwise, . + /// + /// URLs are interpreted according to their prefix and the active document context: + /// + /// A documentation-root-relative URL beginning with ~/ is resolved from the documentation root. + /// A site-root-relative URL beginning with / is not resolved. + /// An ordinary document-relative URL is not resolved. + /// Query strings and fragments are preserved. + /// Dot segments are resolved within the documentation root; a path that navigates above that root is not resolved. + /// + /// The operation preserves the query string and fragment components of the URL, if present. + /// + public bool TryResolveUrl(string url, [NotNullWhen(true)] out string? resolvedUrl) + { + if (url is null || !url.StartsWith("~/", StringComparison.Ordinal)) + { + resolvedUrl = null; + return false; + } + + var (path, suffix) = UriHelper.SplitPathAndSuffix(url[2..]); + if (UriHelper.IsAbsoluteOrRooted(path) || !PathHelper.TryNormalizePath(path, out var normalizedPath) || PathHelper.StartsWithDotSegment(normalizedPath)) + { + resolvedUrl = null; + return false; + } + + resolvedUrl = ResolveFromDocumentationRoot(normalizedPath + suffix); + return true; + } /// - /// Determines whether the specified URL string is a site-relative URL. + /// Disposes the current instance and restores the previous URL context. /// - /// The URL string to check. - /// if the URL is site-relative; otherwise, . - [MethodImpl(MethodImplOptions.AggressiveInlining)] - protected static bool IsSiteRelativeUrl(string urlString) - { - return !string.IsNullOrEmpty(urlString) - && !UriHelper.IsQueryOrFragmentOnly(urlString) - && !UriHelper.IsAbsoluteOrRooted(urlString) - && !PathHelper.StartsWithDotSegment(urlString); - } + /// + /// Disposing a nested context makes its parent context active again. Implementations may allow repeated disposal without + /// producing additional state changes. + /// + public abstract void Dispose(); } -} \ No newline at end of file +} diff --git a/src/Routing/ContextAwareUrlTransformer.cs b/src/Routing/DocumentationUrlTransformer.cs similarity index 75% rename from src/Routing/ContextAwareUrlTransformer.cs rename to src/Routing/DocumentationUrlTransformer.cs index d665bd0d..b64ade97 100644 --- a/src/Routing/ContextAwareUrlTransformer.cs +++ b/src/Routing/DocumentationUrlTransformer.cs @@ -14,23 +14,23 @@ namespace Kampute.DocToolkit.Routing using System.IO; /// - /// Provides a context-aware URL transformer for transforming non-API site-root-relative URLs into absolute or document-relative URLs. + /// Resolves documentation references according to their URL scope and the current document context. /// /// /// This class implements the interface and is designed to work with a specific documentation context. - /// It allows for the transformation of URLs based on the current documentation context, ensuring that relative links are correctly - /// resolved to their target topics or resources. + /// Documentation-root-relative URLs use the ~/ marker. Site-root-relative URLs beginning with / and ordinary + /// document-relative URLs retain their standard URL meaning. /// /// /// - public class ContextAwareUrlTransformer : IUrlTransformer + public class DocumentationUrlTransformer : IUrlTransformer { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The documentation context to use for transforming URLs. /// Thrown when is . - public ContextAwareUrlTransformer(IDocumentationContext context) + public DocumentationUrlTransformer(IDocumentationContext context) { Context = context ?? throw new ArgumentNullException(nameof(context)); } @@ -66,15 +66,17 @@ public ContextAwareUrlTransformer(IDocumentationContext context) /// /// Topic references are resolved to their corresponding URLs. /// File references in topics are resolved to either absolute or document-relative URLs. - /// URLs relative to the root of the documentation site are resolved to either absolute or document-relative URLs. + /// URLs beginning with ~/ are resolved from the documentation root. /// /// The method will not transform the provided URL when it matches any of the following: /// /// Empty or consists only of white-space. /// A query or fragment-only URL (for example, "#section" or "?q=1"). - /// An absolute or rooted URL (for example, starting with a scheme like "http:" or a leading "/"). - /// Neither a known topic, a topic-relative file reference, nor a site-root-relative URL. + /// An absolute or site-root-relative URL (for example, starting with a scheme like "http:" or a leading "/"). + /// An unresolved document-relative URL without the ~/ marker. /// + /// Resolving a topic-relative file reference may read file-system metadata to determine whether the referenced file exists. + /// The operation does not create or modify files and does not change the active document URL context. /// public virtual bool TryTransformUrl(string urlString, [NotNullWhen(true)] out Uri? transformedUrl) { @@ -84,8 +86,16 @@ public virtual bool TryTransformUrl(string urlString, [NotNullWhen(true)] out Ur return false; } - var (urlPath, urlSuffix) = UriHelper.SplitPathAndSuffix(urlString); + var scope = Context.AddressProvider.ActiveScope; + // Documentation-root-relative resolution + if (scope.TryResolveUrl(urlString, out var scopedUrlString)) + { + transformedUrl = new RawUri(scopedUrlString, UriKind.RelativeOrAbsolute); + return true; + } + + var (urlPath, urlSuffix) = UriHelper.SplitPathAndSuffix(urlString); // Topic resolution if (Context.Topics.TryResolve(urlPath, out var topic)) { @@ -93,21 +103,19 @@ public virtual bool TryTransformUrl(string urlString, [NotNullWhen(true)] out Ur return true; } - var scope = Context.AddressProvider.ActiveScope; - // Asset resolution if (scope.Model is TopicModel currentTopic && currentTopic.Source is IFileBasedTopic sourceTopic) { var topicDirectory = Path.GetDirectoryName(sourceTopic.FilePath) ?? string.Empty; if (PathHelper.TryNormalizePath(Path.Combine(topicDirectory, urlPath), out var filePath) && File.Exists(filePath)) { - if (scope.RootUrl.IsAbsoluteUri) + if (scope.DocumentationRootUrl.IsAbsoluteUri) { - transformedUrl = scope.RootUrl.Combine(urlString); + transformedUrl = scope.DocumentationRootUrl.Combine(urlString); } else { - var relativePath = "../" + scope.RootUrl + urlString; + var relativePath = "../" + scope.DocumentationRootUrl + urlString; transformedUrl = currentTopic.Url.IsAbsoluteUri ? new Uri(currentTopic.Url, relativePath) : currentTopic.Url.Combine(relativePath); @@ -116,14 +124,6 @@ public virtual bool TryTransformUrl(string urlString, [NotNullWhen(true)] out Ur } } - // Site-relative resolution - if (scope.TryTransformSiteRelativeUrl(urlPath, out var scopedUrlString)) - { - var fullScopedUrlString = string.IsNullOrEmpty(urlSuffix) ? scopedUrlString : scopedUrlString + urlSuffix; - transformedUrl = new RawUri(fullScopedUrlString, UriKind.RelativeOrAbsolute); - return true; - } - transformedUrl = null; return false; } diff --git a/src/Routing/ContextAwareUrlNormalizer.cs b/src/Routing/RelativeUrlContextManager.cs similarity index 66% rename from src/Routing/ContextAwareUrlNormalizer.cs rename to src/Routing/RelativeUrlContextManager.cs index dd4b0465..452f747a 100644 --- a/src/Routing/ContextAwareUrlNormalizer.cs +++ b/src/Routing/RelativeUrlContextManager.cs @@ -8,33 +8,31 @@ namespace Kampute.DocToolkit.Routing using Kampute.DocToolkit.Support; using System; using System.Collections.Concurrent; - using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; using System.Text; /// - /// Normalizes relative URLs based on the current document context to ensure proper navigation. + /// Manages URL contexts that resolve documentation-root-relative URLs relative to the current document. /// /// - /// This class adjusts relative URLs based on the location of the current document being rendered. It ensures that navigation remains - /// consistent regardless of the document hierarchy by calculating the correct relative path between documents. + /// The manager calculates URLs from each rendered document to resources identified from the documentation root. /// /// For example, if a document at api/namespace/class.html references another document at api/other-namespace/interface.html, - /// this normalizer ensures the relative path is correctly calculated as ../other-namespace/interface.html. In this case, the base - /// URL of the documentation site relative to the current document is ../../. + /// the managed context produces ../other-namespace/interface.html. In this case, the documentation root URL relative + /// to the current document is ../../. /// /// /// /// - public sealed class ContextAwareUrlNormalizer : DocumentUrlContextManager + public sealed class RelativeUrlContextManager : DocumentUrlContextManager { private readonly ConcurrentDictionary directoryCache = []; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public ContextAwareUrlNormalizer() + public RelativeUrlContextManager() : base() { } @@ -44,28 +42,28 @@ public ContextAwareUrlNormalizer() /// /// The relative directory path of the document being rendered within the documentation structure. /// The document model being processed, or if not applicable. - /// A new for the specified directory and model. + /// A new for the specified directory and model. [MethodImpl(MethodImplOptions.AggressiveInlining)] protected override UrlContext CreateScope(string directory, IDocumentModel? model) { var dirData = directoryCache.GetOrAdd(directory, static dir => new DirectoryMetadata(dir)); - return new ContextAwareUrlContext(this, dirData, model); + return new RelativeUrlContext(this, dirData, model); } /// - /// Represents a disposable URL context that converts site-root-relative URLs to document-relative URLs. + /// Represents a disposable URL context that converts documentation-root-relative URLs to document-relative URLs. /// - private sealed class ContextAwareUrlContext : UrlContext + private sealed class RelativeUrlContext : UrlContext { private readonly DirectoryMetadata directory; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The owning normalizer instance. + /// The owning context manager. /// The directory metadata for URL resolution. /// The document model associated with the current context or if not applicable. - public ContextAwareUrlContext(ContextAwareUrlNormalizer owner, DirectoryMetadata directory, IDocumentModel? model) + public RelativeUrlContext(RelativeUrlContextManager owner, DirectoryMetadata directory, IDocumentModel? model) : base(owner, directory.Path, model) { this.directory = directory; @@ -75,36 +73,39 @@ public ContextAwareUrlContext(ContextAwareUrlNormalizer owner, DirectoryMetadata /// Gets the relative URL to the root of the documentation site for the current context. /// /// - /// The URL that serves as the reference point for resolving relative URLs within the current context. + /// A relative URL containing the parent segments needed to reach the documentation root, or an empty relative URL + /// when the current document is already at that root. /// - public override Uri RootUrl => directory.RelativeRootUrl; + public override Uri DocumentationRootUrl => directory.RelativeDocumentationRootUrl; /// - /// Attempts to transform a site-relative URL string into a document-relative URL based on the current context. + /// Resolves a documentation-root-relative URL into a URL relative to the current document. /// - /// The URL string relative to site root to transform. - /// When this method returns, contains the transformed URL if the transformation succeeded; otherwise, . - /// if the URL was successfully transformed; otherwise, . - public override bool TryTransformSiteRelativeUrl(string siteRelativeUrl, [NotNullWhen(true)] out string? transformedUrl) + /// + /// A URL string relative to the documentation root (without the ~/ marker). The URL consists of a normalized path + /// component and may optionally include a query string and/or fragment. + /// + /// A document-relative URL string that navigates from the current document's location to the target. + /// + /// The method computes the relative path from the current document's directory to the target resource. If the target is in + /// the same directory as the current document, only the filename is returned. Query strings and fragments are preserved + /// in the result. + /// + /// Thrown when is . + public override string ResolveFromDocumentationRoot(string urlString) { - if (!IsSiteRelativeUrl(siteRelativeUrl)) - { - transformedUrl = null; - return false; - } + if (urlString is null) + throw new ArgumentNullException(nameof(urlString)); if (directory.Segments.Length == 0) - { - transformedUrl = siteRelativeUrl; - return true; - } + return urlString; using var reusable = StringBuilderPool.Shared.GetBuilder(); var href = reusable.Builder; - href.EnsureCapacity(directory.RelativeRootPath.Length + siteRelativeUrl.Length); + href.EnsureCapacity(directory.RelativeRootPath.Length + urlString.Length); - var (urlPath, urlSuffix) = UriHelper.SplitPathAndSuffix(siteRelativeUrl); + var (urlPath, urlSuffix) = UriHelper.SplitPathAndSuffix(urlString); var (resourcePath, resourceName) = urlPath.SplitLast('/'); if (!EqualsIgnoreCase(directory.Path, resourcePath)) @@ -112,16 +113,15 @@ public override bool TryTransformSiteRelativeUrl(string siteRelativeUrl, [NotNul href.Append(resourceName).Append(urlSuffix); - transformedUrl = href.ToString(); - return true; + return href.ToString(); } /// - /// Converts a site-root-relative path to a document-relative path based on the current directory and appends + /// Converts a path relative to the documentation root to a document-relative path based on the current directory and appends /// it to the provided . /// /// The to append the adjusted path to. - /// The path relative to site root to normalize. + /// The path relative to the documentation root to normalize. private void AppendAdjustedRelativePath(StringBuilder sb, string relativePath) { var targetSegments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries); @@ -173,7 +173,7 @@ private sealed class DirectoryMetadata /// /// The relative URL to navigate from this directory to the documentation root. /// - public readonly Uri RelativeRootUrl; + public readonly Uri RelativeDocumentationRootUrl; /// /// Initializes a new instance of the class. @@ -186,7 +186,7 @@ public DirectoryMetadata(string path) RelativeRootPath = Segments.Length > 0 ? string.Join(string.Empty, Enumerable.Repeat("../", Segments.Length)) : string.Empty; - RelativeRootUrl = string.IsNullOrEmpty(RelativeRootPath) + RelativeDocumentationRootUrl = string.IsNullOrEmpty(RelativeRootPath) ? UriHelper.EmptyUri : new RawUri(RelativeRootPath, UriKind.Relative); } diff --git a/src/Support/PathHelper.cs b/src/Support/PathHelper.cs index 2450cabe..b7654124 100644 --- a/src/Support/PathHelper.cs +++ b/src/Support/PathHelper.cs @@ -121,24 +121,24 @@ public static List NormalizePathSegments(IEnumerable pathSegment } /// - /// Determines if a sub-path is a valid sub-path of a full path. + /// Determines whether a subpath matches the end of a full path at a path-segment boundary. /// - /// The full path to check against. /// The sub-path to check. - /// if the full path matches the sub-path; otherwise, . + /// The full path to check against. + /// if matches the complete or its trailing path segments; otherwise, . /// Thrown when or is . /// - /// This method checks if the is a valid sub-path of the . The match is case-insensitive. + /// Matching is case-insensitive. An empty subpath matches only an empty full path. /// /// The method assumes that both paths are normalized and use the same directory separator. /// /// - public static bool IsSubpath(string fullPath, string subPath) + public static bool IsSubpath(string subPath, string fullPath) { - if (fullPath is null) - throw new ArgumentNullException(nameof(fullPath)); if (subPath is null) throw new ArgumentNullException(nameof(subPath)); + if (fullPath is null) + throw new ArgumentNullException(nameof(fullPath)); if (fullPath.Length < subPath.Length) return false; diff --git a/src/XmlDoc/IXmlDocReferenceResolver.cs b/src/XmlDoc/IXmlDocReferenceResolver.cs index 0f9eb8a6..ac085fcc 100644 --- a/src/XmlDoc/IXmlDocReferenceResolver.cs +++ b/src/XmlDoc/IXmlDocReferenceResolver.cs @@ -21,7 +21,7 @@ namespace Kampute.DocToolkit.XmlDoc /// Resolving name and URL of code references to types and members /// Resolving title and URL of topic references to documentation topics /// Resolving official documentation URLs to language-specific keywords - /// Converting site-root-relative URLs to absolute or document-relative URLs + /// Converting documentation-root-relative URLs to absolute or document-relative URLs /// Formatting code blocks for display in documentation /// Detecting and identifying programming languages for code snippets /// diff --git a/tests/MockHelper.cs b/tests/MockHelper.cs index 0f0d7db9..905966cd 100644 --- a/tests/MockHelper.cs +++ b/tests/MockHelper.cs @@ -1,4 +1,4 @@ -// Copyright (C) Kampute +// Copyright (C) Kampute // // Released under the terms of the MIT license. // See the LICENSE file in the project root for the full license text. @@ -72,7 +72,7 @@ public static IDocumentationContext CreateDocumentationContext(IEnumera /// A mocked address provider. public static IDocumentAddressProvider CreateAddressProvider() { - var urlContext = new ContextAwareUrlNormalizer(); + var urlContext = new RelativeUrlContextManager(); var addressProviderMock = new Mock(); var addressProvider = addressProviderMock.Object; @@ -86,7 +86,7 @@ public static IDocumentAddressProvider CreateAddressProvider() .Returns((string ns, out string? path) => { path = ns.ToLowerInvariant(); - if (!addressProvider.ActiveScope.IsRoot) + if (!addressProvider.ActiveScope.IsDocumentationRoot) path = $"{addressProvider.ActiveScope.Directory}/{path}"; return true; @@ -98,7 +98,7 @@ public static IDocumentAddressProvider CreateAddressProvider() if (member.IsDirectDeclaration) { path = member.CodeReference[2..].ReplaceChars(['`', '#'], '-').ToLowerInvariant(); - if (!addressProvider.ActiveScope.IsRoot) + if (!addressProvider.ActiveScope.IsDocumentationRoot) path = $"{addressProvider.ActiveScope.Directory}/{path}"; return true; @@ -116,7 +116,7 @@ public static IDocumentAddressProvider CreateAddressProvider() for (var current = topic; current is not null; current = current.ParentTopic) segments.Add(current.Id); - if (!addressProvider.ActiveScope.IsRoot) + if (!addressProvider.ActiveScope.IsDocumentationRoot) segments.Add(addressProvider.ActiveScope.Directory); segments.Reverse(); diff --git a/tests/Routing/AbsoluteUrlContextManagerTests.cs b/tests/Routing/AbsoluteUrlContextManagerTests.cs new file mode 100644 index 00000000..6808d475 --- /dev/null +++ b/tests/Routing/AbsoluteUrlContextManagerTests.cs @@ -0,0 +1,281 @@ +// Copyright (C) Kampute +// +// Released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.DocToolkit.Test.Routing +{ + using Kampute.DocToolkit.Routing; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + [TestFixture] + public class AbsoluteUrlContextManagerTests + { + [Test] + public void Constructor_WithRelativeBaseUrl_ThrowsArgumentException() + { + var relativeUrl = new Uri("/docs/", UriKind.Relative); + + Assert.That(() => new AbsoluteUrlContextManager(relativeUrl), Throws.ArgumentException); + } + + [Test] + public void Constructor_WithAbsoluteBaseUrl_InitializesCorrectly() + { + var baseUrl = new Uri("https://example.com/docs/"); + + var manager = new AbsoluteUrlContextManager(baseUrl); + + Assert.That(manager.BaseUrl, Is.EqualTo(baseUrl)); + } + + [Test] + public void Constructor_WithRelativeBaseUrlString_ThrowsArgumentException() + { + Assert.Throws(static () => new AbsoluteUrlContextManager("docs/")); + } + + [Test] + public void Constructor_WithAbsoluteBaseUrlStringWithoutTrailingSlash_InitializesCorrectly() + { + var baseUrlString = "https://example.com/docs"; + var manager = new AbsoluteUrlContextManager(baseUrlString); + + Assert.That(manager.BaseUrl, Is.EqualTo(new Uri("https://example.com/docs/"))); + } + + [Test] + public void Constructor_WithAbsoluteBaseUrlStringWithTrailingSlash_InitializesCorrectly() + { + var baseUrlString = "https://example.com/docs/"; + var manager = new AbsoluteUrlContextManager(baseUrlString); + + Assert.That(manager.BaseUrl, Is.EqualTo(new Uri("https://example.com/docs/"))); + } + + [Test] + public void ActiveScope_WhenNoScopeActive_ReturnsDefaultScope() + { + var baseUrl = new Uri("https://example.com/docs/"); + var manager = new AbsoluteUrlContextManager(baseUrl); + + var scope = manager.ActiveScope; + + Assert.That(scope, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(scope.DocumentationRootUrl, Is.EqualTo(baseUrl)); + Assert.That(scope.Directory, Is.Empty); + } + } + + [TestCase("", ExpectedResult = null)] + [TestCase("#section", ExpectedResult = null)] + [TestCase("?query=param", ExpectedResult = null)] + [TestCase("/other/api/index.html", ExpectedResult = null)] + [TestCase("api/index.html", ExpectedResult = null)] + [TestCase("~/api/index.html", ExpectedResult = "https://example.com/docs/api/index.html")] + [TestCase("~/api/namespace/class.html", ExpectedResult = "https://example.com/docs/api/namespace/class.html")] + [TestCase("~/api/../index.html", ExpectedResult = "https://example.com/docs/index.html")] + [TestCase("~/../index.html", ExpectedResult = null)] + [TestCase("https://other.com/api/index.html", ExpectedResult = null)] + public string? ActiveScope_TryResolveUrl_ReturnsExpectedUrl(string uriString) + { + var baseUrl = new Uri("https://example.com/docs/"); + var manager = new AbsoluteUrlContextManager(baseUrl); + + return manager.ActiveScope.TryResolveUrl(uriString, out var transformedUrl) ? transformedUrl : null; + } + + [Test] + public void BeginScope_ReturnsValidScope() + { + var baseUrl = new Uri("https://example.com/docs/"); + var manager = new AbsoluteUrlContextManager(baseUrl); + var directory = "some/dir"; + + var scope = manager.BeginScope(directory, null); + + using (Assert.EnterMultipleScope()) + { + Assert.That(scope, Is.Not.Null); + Assert.That(scope, Is.SameAs(manager.ActiveScope)); + Assert.That(scope.Directory, Is.EqualTo(directory)); + } + } + + [Test] + public void Scope_TryResolveUrl_UsesBaseUrl() + { + var baseUrl = new Uri("https://example.com/docs/"); + var relativeUrlString = "~/api/index.html"; + var manager = new AbsoluteUrlContextManager(baseUrl); + var expected = new Uri("https://example.com/docs/api/index.html"); + + using var scope = manager.BeginScope("some/dir", null); + var success = scope.TryResolveUrl(relativeUrlString, out var transformedUrl); + + using (Assert.EnterMultipleScope()) + { + Assert.That(success, Is.True); + Assert.That(new Uri(transformedUrl!), Is.EqualTo(expected)); + } + } + + [Test] + public void Scope_Dispose_RestoresPreviousScope() + { + var baseUrl = new Uri("https://example.com/docs/"); + var manager = new AbsoluteUrlContextManager(baseUrl); + using var scope = manager.BeginScope("api/namespace", null); + + Assert.That(manager.ActiveScope, Is.SameAs(scope)); + using (var nestedScope = manager.BeginScope("api/namespace/class", null)) + { + Assert.That(manager.ActiveScope, Is.SameAs(nestedScope)); + } + Assert.That(manager.ActiveScope, Is.SameAs(scope)); + } + + [Test] + public void Scope_Dispose_CanBeCalledMultipleTimes() + { + var baseUrl = new Uri("https://example.com/docs/"); + var manager = new AbsoluteUrlContextManager(baseUrl); + using var scope1 = manager.BeginScope("api", null); + var scope2 = manager.BeginScope("api/namespace", null); + + scope2.Dispose(); + scope2.Dispose(); + + Assert.That(manager.ActiveScope, Is.SameAs(scope1)); + } + + [TestCase("api/index.html", ExpectedResult = "https://example.com/docs/api/index.html")] + [TestCase("api/namespace/class.html", ExpectedResult = "https://example.com/docs/api/namespace/class.html")] + [TestCase("index.html", ExpectedResult = "https://example.com/docs/index.html")] + [TestCase("api/page.html?query=param", ExpectedResult = "https://example.com/docs/api/page.html?query=param")] + [TestCase("api/page.html#fragment", ExpectedResult = "https://example.com/docs/api/page.html#fragment")] + [TestCase("api/page.html?query=param#fragment", ExpectedResult = "https://example.com/docs/api/page.html?query=param#fragment")] + [TestCase("", ExpectedResult = "https://example.com/docs/")] + public string ActiveScope_ResolveFromDocumentationRoot_ReturnsAbsoluteUrl(string urlString) + { + var baseUrl = new Uri("https://example.com/docs/"); + var manager = new AbsoluteUrlContextManager(baseUrl); + + return manager.ActiveScope.ResolveFromDocumentationRoot(urlString); + } + + [Test] + public void ActiveScope_ResolveFromDocumentationRoot_WithNullUrlString_ThrowsArgumentNullException() + { + var baseUrl = new Uri("https://example.com/docs/"); + var manager = new AbsoluteUrlContextManager(baseUrl); + + Assert.Throws(() => manager.ActiveScope.ResolveFromDocumentationRoot(null!)); + } + + [TestCase("api", "api/index.html", ExpectedResult = "https://example.com/docs/api/index.html")] + [TestCase("api/namespace", "api/namespace/class.html", ExpectedResult = "https://example.com/docs/api/namespace/class.html")] + [TestCase("api/namespace", "api/other-namespace/interface.html", ExpectedResult = "https://example.com/docs/api/other-namespace/interface.html")] + [TestCase("api/namespace", "index.html", ExpectedResult = "https://example.com/docs/index.html")] + [TestCase("api", "api/page.html?query=param#fragment", ExpectedResult = "https://example.com/docs/api/page.html?query=param#fragment")] + public string Scope_ResolveFromDocumentationRoot_ReturnsAbsoluteUrlIndependentOfCurrentDirectory(string currentDir, string urlString) + { + var baseUrl = new Uri("https://example.com/docs/"); + var manager = new AbsoluteUrlContextManager(baseUrl); + + using var scope = manager.BeginScope(currentDir, null); + return scope.ResolveFromDocumentationRoot(urlString); + } + + [Test] + public void Scope_ResolveFromDocumentationRoot_WithNullUrlString_ThrowsArgumentNullException() + { + var baseUrl = new Uri("https://example.com/docs/"); + var manager = new AbsoluteUrlContextManager(baseUrl); + + using var scope = manager.BeginScope("api/namespace", null); + Assert.Throws(() => scope.ResolveFromDocumentationRoot(null!)); + } + + [Test] + public void NestedScopes_WhenDisposed_CorrectlyRestorePreviousScopes() + { + var baseUrl = new Uri("https://example.com/docs/"); + var manager = new AbsoluteUrlContextManager(baseUrl); + var originalScope = manager.ActiveScope; + + using (var outerScope = manager.BeginScope("api/namespace", null)) + { + Assert.That(manager.ActiveScope, Is.SameAs(outerScope)); + Assert.That(manager.ActiveScope.Directory, Is.EqualTo("api/namespace")); + + using (var innerScope = manager.BeginScope("api/namespace/class", null)) + { + Assert.That(manager.ActiveScope, Is.SameAs(innerScope)); + Assert.That(manager.ActiveScope.Directory, Is.EqualTo("api/namespace/class")); + } + + Assert.That(manager.ActiveScope, Is.SameAs(outerScope)); + Assert.That(manager.ActiveScope.Directory, Is.EqualTo("api/namespace")); + } + + Assert.That(manager.ActiveScope, Is.SameAs(originalScope)); + Assert.That(manager.ActiveScope.Directory, Is.EqualTo(string.Empty)); + } + + [Test] + public async Task AsyncOperations_MaintainCorrectContext() + { + var baseUrl = new Uri("https://example.com/docs/"); + var manager = new AbsoluteUrlContextManager(baseUrl); + var targetUrlString = "~/page.html"; + + using var scope = manager.BeginScope("api/namespace", null); + var success = scope.TryResolveUrl(targetUrlString, out var initialResult); + Assert.That(success, Is.True); + + var asyncResult = await Task.Run(() => + { + return manager.ActiveScope.TryResolveUrl(targetUrlString, out var result) ? result : null; + }); + + Assert.That(asyncResult, Is.EqualTo("https://example.com/docs/page.html")); + Assert.That(asyncResult, Is.EqualTo(initialResult)); + } + + [Test] + public async Task AsyncOperations_Parallel_MaintainIsolatedContexts() + { + var path1 = "api/namespace"; + var path2 = "api/namespace/class"; + var baseUrl = new Uri("https://example.com/docs/"); + var manager = new AbsoluteUrlContextManager(baseUrl); + + var task1 = Task.Run(async () => + { + using var scope = manager.BeginScope(path1, null); + await Task.Delay(50); + return scope.Directory; + }); + + var task2 = Task.Run(async () => + { + using var scope = manager.BeginScope(path2, null); + await Task.Delay(50); + return scope.Directory; + }); + + var results = await Task.WhenAll(task1, task2); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results[0], Is.EqualTo(path1)); + Assert.That(results[1], Is.EqualTo(path2)); + } + } + } +} diff --git a/tests/Routing/ContextAwareUrlNormalizerTests.cs b/tests/Routing/ContextAwareUrlNormalizerTests.cs deleted file mode 100644 index a5afba38..00000000 --- a/tests/Routing/ContextAwareUrlNormalizerTests.cs +++ /dev/null @@ -1,203 +0,0 @@ -// Copyright (C) Kampute -// -// Released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.DocToolkit.Test.Routing -{ - using Kampute.DocToolkit.Routing; - using Kampute.DocToolkit.Support; - using NUnit.Framework; - using System.Threading.Tasks; - - [TestFixture] - public class ContextAwareUrlNormalizerTests - { - [Test] - public void ActiveScope_WhenNoScopeActive_ReturnsRootScope() - { - var normalizer = new ContextAwareUrlNormalizer(); - - Assert.That(normalizer.ActiveScope, Is.Not.Null); - using (Assert.EnterMultipleScope()) - { - Assert.That(normalizer.ActiveScope.Directory, Is.Empty); - Assert.That(normalizer.ActiveScope.RootUrl, Is.EqualTo(UriHelper.EmptyUri)); - } - } - - [Test] - public void BeginScope_ReturnsValidScope() - { - var normalizer = new ContextAwareUrlNormalizer(); - using var scope = normalizer.BeginScope("api/namespace", null); - - using (Assert.EnterMultipleScope()) - { - Assert.That(scope, Is.Not.Null); - Assert.That(normalizer.ActiveScope, Is.SameAs(scope)); - Assert.That(scope.Directory, Is.EqualTo("api/namespace")); - } - } - - [TestCase("", ExpectedResult = "")] - [TestCase("api", ExpectedResult = "../")] - [TestCase("api/namespace", ExpectedResult = "../../")] - [TestCase("api/namespace/class", ExpectedResult = "../../../")] - public string Scope_RootUrl_ReturnsCorrectRelativePathToRoot(string currentDir) - { - var normalizer = new ContextAwareUrlNormalizer(); - using var scope = normalizer.BeginScope(currentDir, null); - return scope.RootUrl.ToString(); - } - - [TestCase("api/namespace", "", ExpectedResult = null)] - [TestCase("api", "api", ExpectedResult = "../api")] - [TestCase("api", "page.html", ExpectedResult = "../page.html")] - [TestCase("api", "/page.html", ExpectedResult = null)] - [TestCase("api", "api/page.html", ExpectedResult = "page.html")] - [TestCase("api/namespace", "page.html", ExpectedResult = "../../page.html")] - [TestCase("api/namespace", "api/page.html", ExpectedResult = "../page.html")] - [TestCase("api", "api/namespace/page.html", ExpectedResult = "namespace/page.html")] - [TestCase("api/namespace", "api/namespace/page.html", ExpectedResult = "page.html")] - [TestCase("api/namespace", "api/other-namespace/interface.html", ExpectedResult = "../other-namespace/interface.html")] - [TestCase("api/namespace", "api/other-namespace/interface.html?query=param", ExpectedResult = "../other-namespace/interface.html?query=param")] - [TestCase("api/namespace/classes", "api/page.html#fragment", ExpectedResult = "../../page.html#fragment")] - [TestCase("api/namespace/classes", "api/other-namespace/page.html?query=param#fragment", ExpectedResult = "../../other-namespace/page.html?query=param#fragment")] - [TestCase("api/namespace", "api/namespace/class", ExpectedResult = "class")] - [TestCase("api/namespace", "api/other-namespace/class", ExpectedResult = "../other-namespace/class")] - [TestCase("api", "https://example.com/page?query=param#fragment", ExpectedResult = null)] - public string? Scope_TryTransformSiteRelativeUrl_ReturnsExpectedUrl(string currentDir, string urlString) - { - var normalizer = new ContextAwareUrlNormalizer(); - using var scope = normalizer.BeginScope(currentDir, null); - return scope.TryTransformSiteRelativeUrl(urlString, out var transformedUrl) ? transformedUrl : null; - } - - [Test] - public void Scope_Dispose_RestoresPreviousContext() - { - var normalizer = new ContextAwareUrlNormalizer(); - using var scope = normalizer.BeginScope("api/namespace", null); - - Assert.That(normalizer.ActiveScope, Is.SameAs(scope)); - using (var nestedScope = normalizer.BeginScope("api/namespace/class", null)) - { - Assert.That(normalizer.ActiveScope, Is.SameAs(nestedScope)); - } - Assert.That(normalizer.ActiveScope, Is.SameAs(scope)); - } - - [Test] - public void Scope_Dispose_CanBeCalledMultipleTimes() - { - var normalizer = new ContextAwareUrlNormalizer(); - using var scope1 = normalizer.BeginScope("api", null); - var scope2 = normalizer.BeginScope("api/namespace", null); - - scope2.Dispose(); - scope2.Dispose(); - - Assert.That(normalizer.ActiveScope, Is.SameAs(scope1)); - } - - [Test] - public void NestedScopes_WhenDisposed_CorrectlyRestorePreviousContext() - { - var outerPath = "api/namespace"; - var innerPath = "api/namespace/class"; - var targetUrlString = "page.html"; - - var normalizer = new ContextAwareUrlNormalizer(); - using (var outerScope = normalizer.BeginScope(outerPath, null)) - { - var success1 = outerScope.TryTransformSiteRelativeUrl(targetUrlString, out var result1); - using (Assert.EnterMultipleScope()) - { - Assert.That(success1, Is.True); - Assert.That(result1, Is.EqualTo("../../page.html")); - Assert.That(outerScope.RootUrl.ToString(), Is.EqualTo("../../")); - } - - using (var innerScope = normalizer.BeginScope(innerPath, null)) - { - var success2 = innerScope.TryTransformSiteRelativeUrl(targetUrlString, out var result2); - using (Assert.EnterMultipleScope()) - { - Assert.That(success2, Is.True); - Assert.That(result2, Is.EqualTo("../../../page.html")); - Assert.That(innerScope.RootUrl.ToString(), Is.EqualTo("../../../")); - } - } - - var success3 = outerScope.TryTransformSiteRelativeUrl(targetUrlString, out var result3); - using (Assert.EnterMultipleScope()) - { - Assert.That(success3, Is.True); - Assert.That(result3, Is.EqualTo("../../page.html")); - Assert.That(outerScope.RootUrl.ToString(), Is.EqualTo("../../")); - } - } - - using (Assert.EnterMultipleScope()) - { - var success4 = normalizer.ActiveScope.TryTransformSiteRelativeUrl(targetUrlString, out var result4); - Assert.That(success4, Is.True); - Assert.That(result4, Is.EqualTo("page.html")); - Assert.That(normalizer.ActiveScope.RootUrl.ToString(), Is.Empty); - } - } - - [Test] - public async Task AsyncOperations_MaintainCorrectContext() - { - var path = "api/namespace"; - var targetUrlString = "page.html"; - var normalizer = new ContextAwareUrlNormalizer(); - - using var scope = normalizer.BeginScope(path, null); - var success = scope.TryTransformSiteRelativeUrl(targetUrlString, out var initialResult); - Assert.That(success, Is.True); - - var asyncResult = await Task.Run(() => - { - var success2 = normalizer.ActiveScope.TryTransformSiteRelativeUrl(targetUrlString, out var res); - return success2 ? res : null; - }); - - Assert.That(asyncResult, Is.EqualTo("../../page.html")); - Assert.That(asyncResult, Is.EqualTo(initialResult)); - } - - [Test] - public async Task AsyncOperations_Parallel_MaintainIsolatedContexts() - { - var path1 = "api/namespace"; - var path2 = "api/namespace/class"; - var targetUrlString = "page.html"; - var normalizer = new ContextAwareUrlNormalizer(); - - var task1 = Task.Run(async () => - { - using var scope = normalizer.BeginScope(path1, null); - await Task.Delay(50); - return scope.TryTransformSiteRelativeUrl(targetUrlString, out var result) ? result : null; - }); - - var task2 = Task.Run(async () => - { - using var scope = normalizer.BeginScope(path2, null); - await Task.Delay(50); - return scope.TryTransformSiteRelativeUrl(targetUrlString, out var result) ? result : null; - }); - - var results = await Task.WhenAll(task1, task2); - - using (Assert.EnterMultipleScope()) - { - Assert.That(results[0], Is.EqualTo("../../page.html")); - Assert.That(results[1], Is.EqualTo("../../../page.html")); - } - } - } -} \ No newline at end of file diff --git a/tests/Routing/DocumentAddressProviderTests.cs b/tests/Routing/DocumentAddressProviderTests.cs index f32ac2d8..58bf1acb 100644 --- a/tests/Routing/DocumentAddressProviderTests.cs +++ b/tests/Routing/DocumentAddressProviderTests.cs @@ -269,12 +269,12 @@ public void BeginScope_AffectsRelativeUrls() } [Test] - public void ActiveScope_Default_IsRoot() + public void ActiveScope_Default_IsDocumentationRoot() { var addressProvider = DocumentAddressProvider.Create([]); Assert.That(addressProvider.ActiveScope, Is.Not.Null); - Assert.That(addressProvider.ActiveScope.IsRoot, Is.True); + Assert.That(addressProvider.ActiveScope.IsDocumentationRoot, Is.True); } [Test] diff --git a/tests/Routing/ContextAwareUrlTransformerTests.cs b/tests/Routing/DocumentationUrlTransformerTests.cs similarity index 84% rename from tests/Routing/ContextAwareUrlTransformerTests.cs rename to tests/Routing/DocumentationUrlTransformerTests.cs index c09b77bb..4163ad6b 100644 --- a/tests/Routing/ContextAwareUrlTransformerTests.cs +++ b/tests/Routing/DocumentationUrlTransformerTests.cs @@ -13,19 +13,19 @@ namespace Kampute.DocToolkit.Test.Routing using System.IO; [TestFixture] - public class ContextAwareUrlTransformerTests + public class DocumentationUrlTransformerTests { [Test] public void Constructor_WithNullContext_ThrowsArgumentNullException() { - Assert.That(static () => new ContextAwareUrlTransformer(null!), Throws.ArgumentNullException.With.Property("ParamName").EqualTo("context")); + Assert.That(static () => new DocumentationUrlTransformer(null!), Throws.ArgumentNullException.With.Property("ParamName").EqualTo("context")); } [Test] public void Context_Property_ReturnsProvidedContext() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); Assert.That(transformer.Context, Is.SameAs(context)); } @@ -34,7 +34,7 @@ public void Context_Property_ReturnsProvidedContext() public void MayTransformUrls_Property_Always_ReturnsTrue() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); Assert.That(transformer.MayTransformUrls, Is.True); } @@ -44,7 +44,7 @@ public void TryTransformUrl_WhenTopicFound_ReturnsTopicUrl() { var topic = MockTopicBuilder.Topic("TestTopic").Build(); using var context = MockHelper.CreateDocumentationContext([topic]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById(topic.Id, out var contextualTopic); @@ -60,14 +60,14 @@ public void TryTransformUrl_WhenTopicFound_ReturnsTopicUrl() } [Test] - public void TryTransformUrl_WithActiveScope_WhenTopicNotFound_ReturnsTransformedUrl() + public void TryTransformUrl_WithDocumentationRootMarker_ReturnsTransformedUrl() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); - var result = transformer.TryTransformUrl("testTopic.html", out var transformedUrl); + var result = transformer.TryTransformUrl("~/testTopic.html", out var transformedUrl); using (Assert.EnterMultipleScope()) { @@ -81,7 +81,7 @@ public void TryTransformUrl_WithActiveScope_WhenTopicNotFound_ReturnsTransformed public void TryTransformUrl_WithNullOrEmptyUrlString_ReturnsFalse(string? urlString) { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); @@ -98,7 +98,7 @@ public void TryTransformUrl_WithNullOrEmptyUrlString_ReturnsFalse(string? urlStr public void TryTransformUrl_WithAbsoluteUrl_ReturnsFalse() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); @@ -118,7 +118,7 @@ public void TryTransformUrl_WithAbsoluteUrl_ReturnsFalse() public void TryTransformUrl_WithLeadingSpecialCharacters_ReturnsFalse(string urlString) { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); @@ -135,11 +135,11 @@ public void TryTransformUrl_WithLeadingSpecialCharacters_ReturnsFalse(string url public void TryTransformUrl_WithSpecialCharactersInPath_HandlesCorrectly() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); - var result = transformer.TryTransformUrl("special%20page.html", out var transformedUrl); + var result = transformer.TryTransformUrl("~/special%20page.html", out var transformedUrl); using (Assert.EnterMultipleScope()) { @@ -152,11 +152,11 @@ public void TryTransformUrl_WithSpecialCharactersInPath_HandlesCorrectly() public void TryTransformUrl_WithQueryString_PreservesQueryString() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); - var result = transformer.TryTransformUrl("page.html?query=value¶m=test", out var transformedUrl); + var result = transformer.TryTransformUrl("~/page.html?query=value¶m=test", out var transformedUrl); using (Assert.EnterMultipleScope()) { @@ -169,11 +169,11 @@ public void TryTransformUrl_WithQueryString_PreservesQueryString() public void TryTransformUrl_WithFragment_PreservesFragment() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); - var result = transformer.TryTransformUrl("page.html#section", out var transformedUrl); + var result = transformer.TryTransformUrl("~/page.html#section", out var transformedUrl); using (Assert.EnterMultipleScope()) { @@ -186,11 +186,11 @@ public void TryTransformUrl_WithFragment_PreservesFragment() public void TryTransformUrl_WithQueryStringAndFragment_PreservesBoth() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); - var result = transformer.TryTransformUrl("page.html?query=value#section", out var transformedUrl); + var result = transformer.TryTransformUrl("~/page.html?query=value#section", out var transformedUrl); using (Assert.EnterMultipleScope()) { @@ -203,11 +203,11 @@ public void TryTransformUrl_WithQueryStringAndFragment_PreservesBoth() public void TryTransformUrl_InNestedScope_AddsCorrectRelativePath() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("api/namespace", null); - var result = transformer.TryTransformUrl("guide.html", out var transformedUrl); + var result = transformer.TryTransformUrl("~/guide.html", out var transformedUrl); using (Assert.EnterMultipleScope()) { @@ -221,7 +221,7 @@ public void TryTransformUrl_WithMixedCaseTopic_ResolvesCorrectly() { var topic = MockTopicBuilder.Topic("TestTopic").Build(); using var context = MockHelper.CreateDocumentationContext([topic]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById(topic.Id, out var contextualTopic); @@ -241,7 +241,7 @@ public void TryTransformUrl_WithTopicAndQueryString_PreservesQueryString() { var topic = MockTopicBuilder.Topic("TestTopic").Build(); using var context = MockHelper.CreateDocumentationContext([topic]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById(topic.Id, out var contextualTopic); @@ -261,7 +261,7 @@ public void TryTransformUrl_WithTopicAndFragment_PreservesFragment() { var topic = MockTopicBuilder.Topic("TestTopic").Build(); using var context = MockHelper.CreateDocumentationContext([topic]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById(topic.Id, out var contextualTopic); @@ -281,7 +281,7 @@ public void TryTransformUrl_WithTopicAndBothQueryAndFragment_PreservesBoth() { var topic = MockTopicBuilder.Topic("TestTopic").Build(); using var context = MockHelper.CreateDocumentationContext([topic]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById(topic.Id, out var contextualTopic); @@ -302,7 +302,7 @@ public void TryTransformUrl_WithChildTopicFromRoot_ResolvesToChildUrl() var topic = MockTopicBuilder.Topic("parent").WithChildren("child").Build(); using var context = MockHelper.CreateDocumentationContext([topic]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById("parent/child", out var contextualChild); @@ -323,7 +323,7 @@ public void TryTransformUrl_WithChildTopicFromParent_ResolvesToChildUrl() var topic = MockTopicBuilder.Topic("parent").WithChildren("child").Build(); using var context = MockHelper.CreateDocumentationContext([topic]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById("parent", out var contextualParent); context.Topics.TryGetById("parent/child", out var contextualChild); @@ -345,7 +345,7 @@ public void TryTransformUrl_WithSiblingTopicFromChild_ResolvesToChildUrl() var topic = MockTopicBuilder.Topic("parent").WithChildren("child1", "child2").Build(); using var context = MockHelper.CreateDocumentationContext([topic]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById("parent", out var contextualParent); context.Topics.TryGetById("parent/child1", out var contextualChild1); @@ -369,7 +369,7 @@ public void TryTransformUrl_WithNestedTopicFromDifferentBranch_ResolvesToCorrect var topic2 = MockTopicBuilder.Topic("Topic2").WithChildren("SubB").Build(); using var context = MockHelper.CreateDocumentationContext([topic1, topic2]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById("Topic1/SubA", out var contextualSubA); context.Topics.TryGetById("Topic2/SubB", out var contextualSubB); @@ -398,7 +398,7 @@ public void TryTransformUrl_WithFileBasedTopicAndExistingAsset_SameFolder_Return var fileBasedTopic = new MarkdownFileTopic("TestTopic", topicFile); using var context = MockHelper.CreateDocumentationContext([fileBasedTopic]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById("TestTopic", out var contextualTopic); @@ -434,7 +434,7 @@ public void TryTransformUrl_WithFileBasedTopicAndExistingAsset_SiblingFolders_Re var fileBasedTopic = new MarkdownFileTopic("TestTopic", topicFile); using var context = MockHelper.CreateDocumentationContext([fileBasedTopic]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById("TestTopic", out var contextualTopic); @@ -470,7 +470,7 @@ public void TryTransformUrl_WithFileBasedTopicAndExistingAsset_UnrelatedFolders_ var fileBasedTopic = new MarkdownFileTopic("TestTopic", topicFile); using var context = MockHelper.CreateDocumentationContext([fileBasedTopic]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById("TestTopic", out var contextualTopic); @@ -498,7 +498,7 @@ public void TryTransformUrl_WithFileBasedTopicAndBeyondRootRelativeAsset_Returns { var fileBasedTopic = new MarkdownFileTopic("TestTopic", "/topics/test-topic.md"); using var context = MockHelper.CreateDocumentationContext([fileBasedTopic]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById("TestTopic", out var contextualTopic); @@ -514,7 +514,7 @@ public void TryTransformUrl_WithFileBasedTopicAndBeyondRootRelativeAsset_Returns } [Test] - public void TryTransformUrl_WithNonFileBasedTopicAndExistingAsset_FallsBackToSiteRelative() + public void TryTransformUrl_WithNonFileBasedTopicAndRelativeUrl_LeavesUrlUnchanged() { var directory = Path.GetTempPath(); var assetFile = Path.Combine(directory, "assets", "license.txt"); @@ -525,7 +525,7 @@ public void TryTransformUrl_WithNonFileBasedTopicAndExistingAsset_FallsBackToSit var nonFileBasedTopic = MockTopicBuilder.Topic("NonFileBasedTopic").Build(); using var context = MockHelper.CreateDocumentationContext([nonFileBasedTopic]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById("NonFileBasedTopic", out var contextualTopic); @@ -535,8 +535,8 @@ public void TryTransformUrl_WithNonFileBasedTopicAndExistingAsset_FallsBackToSit using (Assert.EnterMultipleScope()) { - Assert.That(result, Is.True); - Assert.That(transformedUrl?.ToString(), Is.EqualTo("../../license.txt")); + Assert.That(result, Is.False); + Assert.That(transformedUrl, Is.Null); } } finally @@ -549,11 +549,11 @@ public void TryTransformUrl_WithNonFileBasedTopicAndExistingAsset_FallsBackToSit } [Test] - public void TryTransformUrl_WithFileBasedTopicAndNonExistingAsset_FallsBackToSiteRelative() + public void TryTransformUrl_WithFileBasedTopicAndNonExistingRelativeAsset_LeavesUrlUnchanged() { var fileBasedTopic = new MarkdownFileTopic("TestTopic", "/topics/test-topic.md"); using var context = MockHelper.CreateDocumentationContext([fileBasedTopic]); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); context.Topics.TryGetById("TestTopic", out var contextualTopic); @@ -563,18 +563,18 @@ public void TryTransformUrl_WithFileBasedTopicAndNonExistingAsset_FallsBackToSit using (Assert.EnterMultipleScope()) { - Assert.That(result, Is.True); - Assert.That(transformedUrl?.ToString(), Is.EqualTo("../../nonexistent.png")); + Assert.That(result, Is.False); + Assert.That(transformedUrl, Is.Null); } } [Test] - public void TryTransformUrl_WithNoActiveScope_FallsBackToSiteRelative() + public void TryTransformUrl_WithDocumentationRootMarker_ResolvesFromDocumentationRoot() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); - var result = transformer.TryTransformUrl("some-asset.png", out var transformedUrl); + var result = transformer.TryTransformUrl("~/some-asset.png", out var transformedUrl); using (Assert.EnterMultipleScope()) { @@ -587,7 +587,7 @@ public void TryTransformUrl_WithNoActiveScope_FallsBackToSiteRelative() public void TryTransformUrl_WithEmptyPathAndQueryString_HandlesCorrectly() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); @@ -607,7 +607,7 @@ public void TryTransformUrl_WithEmptyPathAndQueryString_HandlesCorrectly() public void TryTransformUrl_WithDifferentAbsoluteSchemes_ReturnsFalse(string urlString) { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); @@ -627,7 +627,7 @@ public void TryTransformUrl_WithDifferentAbsoluteSchemes_ReturnsFalse(string url public void TryTransformUrl_WithWhitespaceOnly_ReturnsFalse(string urlString) { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); @@ -644,11 +644,11 @@ public void TryTransformUrl_WithWhitespaceOnly_ReturnsFalse(string urlString) public void TryTransformUrl_WithDeeplyNestedScope_AddsCorrectRelativePath() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("api/namespace/type/member", null); - var result = transformer.TryTransformUrl("guide.html", out var transformedUrl); + var result = transformer.TryTransformUrl("~/guide.html", out var transformedUrl); using (Assert.EnterMultipleScope()) { @@ -661,7 +661,7 @@ public void TryTransformUrl_WithDeeplyNestedScope_AddsCorrectRelativePath() public void TryTransformUrl_WithRelativePathInUrlWithoutTopicScope_ReturnsFalse() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("api/namespace", null); @@ -678,11 +678,11 @@ public void TryTransformUrl_WithRelativePathInUrlWithoutTopicScope_ReturnsFalse( public void TryTransformUrl_WithPercentEncodedCharacters_PreservesEncoding() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); - var result = transformer.TryTransformUrl("file%20with%20spaces.html", out var transformedUrl); + var result = transformer.TryTransformUrl("~/file%20with%20spaces.html", out var transformedUrl); using (Assert.EnterMultipleScope()) { @@ -695,11 +695,11 @@ public void TryTransformUrl_WithPercentEncodedCharacters_PreservesEncoding() public void TryTransformUrl_WithUnicodeCharacters_HandlesCorrectly() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); - var result = transformer.TryTransformUrl("файл.html", out var transformedUrl); + var result = transformer.TryTransformUrl("~/файл.html", out var transformedUrl); using (Assert.EnterMultipleScope()) { @@ -712,11 +712,11 @@ public void TryTransformUrl_WithUnicodeCharacters_HandlesCorrectly() public void TryTransformUrl_WithEmptyFragment_PreservesEmptyFragment() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); - var result = transformer.TryTransformUrl("page.html#", out var transformedUrl); + var result = transformer.TryTransformUrl("~/page.html#", out var transformedUrl); using (Assert.EnterMultipleScope()) { @@ -729,11 +729,11 @@ public void TryTransformUrl_WithEmptyFragment_PreservesEmptyFragment() public void TryTransformUrl_WithEmptyQueryString_PreservesEmptyQuery() { using var context = MockHelper.CreateDocumentationContext(); - var transformer = new ContextAwareUrlTransformer(context); + var transformer = new DocumentationUrlTransformer(context); using var _ = context.AddressProvider.BeginScope("test", null); - var result = transformer.TryTransformUrl("page.html?", out var transformedUrl); + var result = transformer.TryTransformUrl("~/page.html?", out var transformedUrl); using (Assert.EnterMultipleScope()) { diff --git a/tests/Routing/RelativeToAbsoluteUrlNormalizerTests.cs b/tests/Routing/RelativeToAbsoluteUrlNormalizerTests.cs deleted file mode 100644 index b54cee3a..00000000 --- a/tests/Routing/RelativeToAbsoluteUrlNormalizerTests.cs +++ /dev/null @@ -1,230 +0,0 @@ -// Copyright (C) Kampute -// -// Released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.DocToolkit.Test.Routing -{ - using Kampute.DocToolkit.Routing; - using NUnit.Framework; - using System; - using System.Threading.Tasks; - - [TestFixture] - public class RelativeToAbsoluteUrlNormalizerTests - { - [Test] - public void Constructor_WithRelativeBaseUrl_ThrowsArgumentException() - { - var relativeUrl = new Uri("/docs/", UriKind.Relative); - - Assert.That(() => new RelativeToAbsoluteUrlNormalizer(relativeUrl), Throws.ArgumentException); - } - - [Test] - public void Constructor_WithAbsoluteBaseUrl_InitializesCorrectly() - { - var baseUrl = new Uri("https://example.com/docs/"); - - var normalizer = new RelativeToAbsoluteUrlNormalizer(baseUrl); - - Assert.That(normalizer.BaseUrl, Is.EqualTo(baseUrl)); - } - - [Test] - public void Constructor_WithRelativeBaseUrlString_ThrowsArgumentException() - { - Assert.Throws(static () => new RelativeToAbsoluteUrlNormalizer("docs/")); - } - - [Test] - public void Constructor_WithAbsoluteBaseUrlStringWithoutTrailingSlash_InitializesCorrectly() - { - var baseUrlString = "https://example.com/docs"; - var normalizer = new RelativeToAbsoluteUrlNormalizer(baseUrlString); - - Assert.That(normalizer.BaseUrl, Is.EqualTo(new Uri("https://example.com/docs/"))); - } - - [Test] - public void Constructor_WithAbsoluteBaseUrlStringWithTrailingSlash_InitializesCorrectly() - { - var baseUrlString = "https://example.com/docs/"; - var normalizer = new RelativeToAbsoluteUrlNormalizer(baseUrlString); - - Assert.That(normalizer.BaseUrl, Is.EqualTo(new Uri("https://example.com/docs/"))); - } - - [Test] - public void ActiveScope_WhenNoScopeActive_ReturnsDefaultScope() - { - var baseUrl = new Uri("https://example.com/docs/"); - var normalizer = new RelativeToAbsoluteUrlNormalizer(baseUrl); - - var scope = normalizer.ActiveScope; - - Assert.That(scope, Is.Not.Null); - using (Assert.EnterMultipleScope()) - { - Assert.That(scope.RootUrl, Is.EqualTo(baseUrl)); - Assert.That(scope.Directory, Is.Empty); - } - } - - [TestCase("", ExpectedResult = null)] - [TestCase("#section", ExpectedResult = null)] - [TestCase("?query=param", ExpectedResult = null)] - [TestCase("/other/api/index.html", ExpectedResult = null)] - [TestCase("api/index.html", ExpectedResult = "https://example.com/docs/api/index.html")] - [TestCase("api/namespace/class.html", ExpectedResult = "https://example.com/docs/api/namespace/class.html")] - [TestCase("https://other.com/api/index.html", ExpectedResult = null)] - public string? ActiveScope_TryTransformSiteRelativeUrl_ReturnsExpectedUrl(string uriString) - { - var baseUrl = new Uri("https://example.com/docs/"); - var normalizer = new RelativeToAbsoluteUrlNormalizer(baseUrl); - - return normalizer.ActiveScope.TryTransformSiteRelativeUrl(uriString, out var transformedUrl) ? transformedUrl : null; - } - - [Test] - public void BeginScope_ReturnsValidScope() - { - var baseUrl = new Uri("https://example.com/docs/"); - var normalizer = new RelativeToAbsoluteUrlNormalizer(baseUrl); - var directory = "some/dir"; - - var scope = normalizer.BeginScope(directory, null); - - using (Assert.EnterMultipleScope()) - { - Assert.That(scope, Is.Not.Null); - Assert.That(scope, Is.SameAs(normalizer.ActiveScope)); - Assert.That(scope.Directory, Is.EqualTo(directory)); - } - } - - [Test] - public void Scope_TryTransformSiteRelativeUrl_UsesBaseUrl() - { - var baseUrl = new Uri("https://example.com/docs/"); - var relativeUrlString = "api/index.html"; - var normalizer = new RelativeToAbsoluteUrlNormalizer(baseUrl); - var expected = new Uri("https://example.com/docs/api/index.html"); - - using var scope = normalizer.BeginScope("some/dir", null); - var success = scope.TryTransformSiteRelativeUrl(relativeUrlString, out var transformedUrl); - - using (Assert.EnterMultipleScope()) - { - Assert.That(success, Is.True); - Assert.That(new Uri(transformedUrl!), Is.EqualTo(expected)); - } - } - - [Test] - public void Scope_Dispose_RestoresPreviousScope() - { - var baseUrl = new Uri("https://example.com/docs/"); - var normalizer = new RelativeToAbsoluteUrlNormalizer(baseUrl); - using var scope = normalizer.BeginScope("api/namespace", null); - - Assert.That(normalizer.ActiveScope, Is.SameAs(scope)); - using (var nestedScope = normalizer.BeginScope("api/namespace/class", null)) - { - Assert.That(normalizer.ActiveScope, Is.SameAs(nestedScope)); - } - Assert.That(normalizer.ActiveScope, Is.SameAs(scope)); - } - - [Test] - public void Scope_Dispose_CanBeCalledMultipleTimes() - { - var baseUrl = new Uri("https://example.com/docs/"); - var normalizer = new RelativeToAbsoluteUrlNormalizer(baseUrl); - using var scope1 = normalizer.BeginScope("api", null); - var scope2 = normalizer.BeginScope("api/namespace", null); - - scope2.Dispose(); - scope2.Dispose(); - - Assert.That(normalizer.ActiveScope, Is.SameAs(scope1)); - } - - [Test] - public void NestedScopes_WhenDisposed_CorrectlyRestorePreviousScopes() - { - var baseUrl = new Uri("https://example.com/docs/"); - var normalizer = new RelativeToAbsoluteUrlNormalizer(baseUrl); - var originalScope = normalizer.ActiveScope; - - using (var outerScope = normalizer.BeginScope("api/namespace", null)) - { - Assert.That(normalizer.ActiveScope, Is.SameAs(outerScope)); - Assert.That(normalizer.ActiveScope.Directory, Is.EqualTo("api/namespace")); - - using (var innerScope = normalizer.BeginScope("api/namespace/class", null)) - { - Assert.That(normalizer.ActiveScope, Is.SameAs(innerScope)); - Assert.That(normalizer.ActiveScope.Directory, Is.EqualTo("api/namespace/class")); - } - - Assert.That(normalizer.ActiveScope, Is.SameAs(outerScope)); - Assert.That(normalizer.ActiveScope.Directory, Is.EqualTo("api/namespace")); - } - - Assert.That(normalizer.ActiveScope, Is.SameAs(originalScope)); - Assert.That(normalizer.ActiveScope.Directory, Is.EqualTo(string.Empty)); - } - - [Test] - public async Task AsyncOperations_MaintainCorrectContext() - { - var baseUrl = new Uri("https://example.com/docs/"); - var normalizer = new RelativeToAbsoluteUrlNormalizer(baseUrl); - var targetUrlString = "page.html"; - - using var scope = normalizer.BeginScope("api/namespace", null); - var success = scope.TryTransformSiteRelativeUrl(targetUrlString, out var initialResult); - Assert.That(success, Is.True); - - var asyncResult = await Task.Run(() => - { - return normalizer.ActiveScope.TryTransformSiteRelativeUrl(targetUrlString, out var result) ? result : null; - }); - - Assert.That(asyncResult, Is.EqualTo("https://example.com/docs/page.html")); - Assert.That(asyncResult, Is.EqualTo(initialResult)); - } - - [Test] - public async Task AsyncOperations_Parallel_MaintainIsolatedContexts() - { - var path1 = "api/namespace"; - var path2 = "api/namespace/class"; - var baseUrl = new Uri("https://example.com/docs/"); - var normalizer = new RelativeToAbsoluteUrlNormalizer(baseUrl); - - var task1 = Task.Run(async () => - { - using var scope = normalizer.BeginScope(path1, null); - await Task.Delay(50); - return scope.Directory; - }); - - var task2 = Task.Run(async () => - { - using var scope = normalizer.BeginScope(path2, null); - await Task.Delay(50); - return scope.Directory; - }); - - var results = await Task.WhenAll(task1, task2); - - using (Assert.EnterMultipleScope()) - { - Assert.That(results[0], Is.EqualTo(path1)); - Assert.That(results[1], Is.EqualTo(path2)); - } - } - } -} \ No newline at end of file diff --git a/tests/Routing/RelativeUrlContextManagerTests.cs b/tests/Routing/RelativeUrlContextManagerTests.cs new file mode 100644 index 00000000..cd1b93d8 --- /dev/null +++ b/tests/Routing/RelativeUrlContextManagerTests.cs @@ -0,0 +1,234 @@ +// Copyright (C) Kampute +// +// Released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.DocToolkit.Test.Routing +{ + using Kampute.DocToolkit.Routing; + using Kampute.DocToolkit.Support; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + [TestFixture] + public class RelativeUrlContextManagerTests + { + [Test] + public void ActiveScope_WhenNoScopeActive_ReturnsRootScope() + { + var manager = new RelativeUrlContextManager(); + + Assert.That(manager.ActiveScope, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(manager.ActiveScope.Directory, Is.Empty); + Assert.That(manager.ActiveScope.DocumentationRootUrl, Is.EqualTo(UriHelper.EmptyUri)); + } + } + + [Test] + public void BeginScope_ReturnsValidScope() + { + var manager = new RelativeUrlContextManager(); + using var scope = manager.BeginScope("api/namespace", null); + + using (Assert.EnterMultipleScope()) + { + Assert.That(scope, Is.Not.Null); + Assert.That(manager.ActiveScope, Is.SameAs(scope)); + Assert.That(scope.Directory, Is.EqualTo("api/namespace")); + } + } + + [TestCase("", ExpectedResult = "")] + [TestCase("api", ExpectedResult = "../")] + [TestCase("api/namespace", ExpectedResult = "../../")] + [TestCase("api/namespace/class", ExpectedResult = "../../../")] + public string Scope_DocumentationRootUrl_ReturnsCorrectRelativePathToRoot(string currentDir) + { + var manager = new RelativeUrlContextManager(); + using var scope = manager.BeginScope(currentDir, null); + return scope.DocumentationRootUrl.ToString(); + } + + [TestCase("api/namespace", "", ExpectedResult = null)] + [TestCase("api", "api", ExpectedResult = null)] + [TestCase("api", "~/page.html", ExpectedResult = "../page.html")] + [TestCase("api", "/page.html", ExpectedResult = null)] + [TestCase("api", "~/api/page.html", ExpectedResult = "page.html")] + [TestCase("api/namespace", "~/api/page.html", ExpectedResult = "../page.html")] + [TestCase("api", "~/api/namespace/page.html", ExpectedResult = "namespace/page.html")] + [TestCase("api/namespace", "~/api/namespace/page.html", ExpectedResult = "page.html")] + [TestCase("api/namespace", "~/api/other-namespace/interface.html", ExpectedResult = "../other-namespace/interface.html")] + [TestCase("api/namespace", "~/api/other-namespace/interface.html?query=param", ExpectedResult = "../other-namespace/interface.html?query=param")] + [TestCase("api/namespace/classes", "~/api/page.html#fragment", ExpectedResult = "../../page.html#fragment")] + [TestCase("api/namespace/classes", "~/api/other-namespace/page.html?query=param#fragment", ExpectedResult = "../../other-namespace/page.html?query=param#fragment")] + [TestCase("api/namespace", "~/api/namespace/class", ExpectedResult = "class")] + [TestCase("api/namespace", "~/api/other-namespace/class", ExpectedResult = "../other-namespace/class")] + [TestCase("api/namespace", "~/api/../docs", ExpectedResult = "../../docs")] + [TestCase("api/namespace", "~/../docs", ExpectedResult = null)] + [TestCase("api", "https://example.com/page?query=param#fragment", ExpectedResult = null)] + public string? Scope_TryResolveUrl_ReturnsExpectedUrl(string currentDir, string urlString) + { + var manager = new RelativeUrlContextManager(); + using var scope = manager.BeginScope(currentDir, null); + return scope.TryResolveUrl(urlString, out var transformedUrl) ? transformedUrl : null; + } + + [Test] + public void Scope_Dispose_RestoresPreviousContext() + { + var manager = new RelativeUrlContextManager(); + using var scope = manager.BeginScope("api/namespace", null); + + Assert.That(manager.ActiveScope, Is.SameAs(scope)); + using (var nestedScope = manager.BeginScope("api/namespace/class", null)) + { + Assert.That(manager.ActiveScope, Is.SameAs(nestedScope)); + } + Assert.That(manager.ActiveScope, Is.SameAs(scope)); + } + + [Test] + public void Scope_Dispose_CanBeCalledMultipleTimes() + { + var manager = new RelativeUrlContextManager(); + using var scope1 = manager.BeginScope("api", null); + var scope2 = manager.BeginScope("api/namespace", null); + + scope2.Dispose(); + scope2.Dispose(); + + Assert.That(manager.ActiveScope, Is.SameAs(scope1)); + } + + [Test] + public void NestedScopes_WhenDisposed_CorrectlyRestorePreviousContext() + { + var outerPath = "api/namespace"; + var innerPath = "api/namespace/class"; + var targetUrlString = "~/page.html"; + + var manager = new RelativeUrlContextManager(); + using (var outerScope = manager.BeginScope(outerPath, null)) + { + var success1 = outerScope.TryResolveUrl(targetUrlString, out var result1); + using (Assert.EnterMultipleScope()) + { + Assert.That(success1, Is.True); + Assert.That(result1, Is.EqualTo("../../page.html")); + Assert.That(outerScope.DocumentationRootUrl.ToString(), Is.EqualTo("../../")); + } + + using (var innerScope = manager.BeginScope(innerPath, null)) + { + var success2 = innerScope.TryResolveUrl(targetUrlString, out var result2); + using (Assert.EnterMultipleScope()) + { + Assert.That(success2, Is.True); + Assert.That(result2, Is.EqualTo("../../../page.html")); + Assert.That(innerScope.DocumentationRootUrl.ToString(), Is.EqualTo("../../../")); + } + } + + var success3 = outerScope.TryResolveUrl(targetUrlString, out var result3); + using (Assert.EnterMultipleScope()) + { + Assert.That(success3, Is.True); + Assert.That(result3, Is.EqualTo("../../page.html")); + Assert.That(outerScope.DocumentationRootUrl.ToString(), Is.EqualTo("../../")); + } + } + + using (Assert.EnterMultipleScope()) + { + var success4 = manager.ActiveScope.TryResolveUrl(targetUrlString, out var result4); + Assert.That(success4, Is.True); + Assert.That(result4, Is.EqualTo("page.html")); + Assert.That(manager.ActiveScope.DocumentationRootUrl.ToString(), Is.Empty); + } + } + + [TestCase("", "page.html", ExpectedResult = "page.html")] + [TestCase("", "api/index.html", ExpectedResult = "api/index.html")] + [TestCase("", "", ExpectedResult = "")] + [TestCase("api", "page.html", ExpectedResult = "../page.html")] + [TestCase("api", "api/page.html", ExpectedResult = "page.html")] + [TestCase("api", "api/namespace/class.html", ExpectedResult = "namespace/class.html")] + [TestCase("api/namespace", "api/page.html", ExpectedResult = "../page.html")] + [TestCase("api/namespace", "api/namespace/page.html", ExpectedResult = "page.html")] + [TestCase("api/namespace", "api/other-namespace/interface.html", ExpectedResult = "../other-namespace/interface.html")] + [TestCase("api/namespace", "index.html", ExpectedResult = "../../index.html")] + [TestCase("api/namespace/class", "api/page.html", ExpectedResult = "../../page.html")] + [TestCase("api", "api/page.html?query=param", ExpectedResult = "page.html?query=param")] + [TestCase("api/namespace", "api/page.html#fragment", ExpectedResult = "../page.html#fragment")] + [TestCase("api/namespace", "api/page.html?query=param#fragment", ExpectedResult = "../page.html?query=param#fragment")] + public string Scope_ResolveFromDocumentationRoot_ReturnsRelativeUrl(string currentDir, string urlString) + { + var manager = new RelativeUrlContextManager(); + using var scope = manager.BeginScope(currentDir, null); + return scope.ResolveFromDocumentationRoot(urlString); + } + + [Test] + public void Scope_ResolveFromDocumentationRoot_WithNullUrlString_ThrowsArgumentNullException() + { + var manager = new RelativeUrlContextManager(); + using var scope = manager.BeginScope("api/namespace", null); + Assert.Throws(() => scope.ResolveFromDocumentationRoot(null!)); + } + + [Test] + public async Task AsyncOperations_MaintainCorrectContext() + { + var path = "api/namespace"; + var targetUrlString = "~/page.html"; + var manager = new RelativeUrlContextManager(); + + using var scope = manager.BeginScope(path, null); + var success = scope.TryResolveUrl(targetUrlString, out var initialResult); + Assert.That(success, Is.True); + + var asyncResult = await Task.Run(() => + { + var success2 = manager.ActiveScope.TryResolveUrl(targetUrlString, out var res); + return success2 ? res : null; + }); + + Assert.That(asyncResult, Is.EqualTo("../../page.html")); + Assert.That(asyncResult, Is.EqualTo(initialResult)); + } + + [Test] + public async Task AsyncOperations_Parallel_MaintainIsolatedContexts() + { + var path1 = "api/namespace"; + var path2 = "api/namespace/class"; + var targetUrlString = "~/page.html"; + var manager = new RelativeUrlContextManager(); + + var task1 = Task.Run(async () => + { + using var scope = manager.BeginScope(path1, null); + await Task.Delay(50); + return scope.TryResolveUrl(targetUrlString, out var result) ? result : null; + }); + + var task2 = Task.Run(async () => + { + using var scope = manager.BeginScope(path2, null); + await Task.Delay(50); + return scope.TryResolveUrl(targetUrlString, out var result) ? result : null; + }); + + var results = await Task.WhenAll(task1, task2); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results[0], Is.EqualTo("../../page.html")); + Assert.That(results[1], Is.EqualTo("../../../page.html")); + } + } + } +} diff --git a/tests/Support/PathHelperTests.cs b/tests/Support/PathHelperTests.cs index 5906df25..839a3c53 100644 --- a/tests/Support/PathHelperTests.cs +++ b/tests/Support/PathHelperTests.cs @@ -112,7 +112,7 @@ public void TryNormalizePath_WhenPathNavigatesBeyondRoot_ReturnsFalse() [TestCase("another-file.txt", "file.txt", ExpectedResult = false)] public bool IsSubpath_ReturnsExpectedResult(string fullPath, string subPath) { - return PathHelper.IsSubpath(fullPath, subPath); + return PathHelper.IsSubpath(subPath, fullPath); } [TestCase("", ExpectedResult = false)]