From 48304be22293c708cb84964685f560cea8b79fb0 Mon Sep 17 00:00:00 2001 From: Kambiz Khojasteh Date: Thu, 25 Dec 2025 19:55:36 +0800 Subject: [PATCH] Add partial topic ID resolution and improve hyperlink logic - Add TopicCollection.TryFindByPartialId for unique partial ID lookup, and use it as a fallback in TryResolve - Add comprehensive unit tests for partial ID resolution - Refactor TryFindBySubpath for clarity and reliability - Improve MockHelper address provider for realistic file/URL resolution - Update SeeAlsoComment.WithResolvedHyperlink to handle absolute URLs, resolve relative URLs, and fill in topic titles when missing - Expand and revise unit tests for SeeAlsoComment hyperlink resolution - Update ContextAwareUrlTransformerTests to expect fully qualified URLs - Refactor XmlDocExtensionsTests to use local test types and add overload documentation tests - Update XML doc tags to new paths and clarify UrlReference.TargetUrl remarks - Bump version to 2.2.0 in project file --- src/Collections/TopicCollection.cs | 44 ++++- src/Kampute.DocToolkit.csproj | 2 +- src/Routing/UrlReference.cs | 9 +- src/XmlDoc/Comments/SeeAlsoComment.cs | 11 +- src/XmlDoc/XmlDocToHtmlTransformer.cs | 2 +- src/XmlDoc/XmlDocToMarkdownTransformer.cs | 2 +- src/Xslt/NamespaceDoc.cs | 4 +- tests/Collections/TopicCollectionTests.cs | 129 +++++++++++++- tests/MockHelper.cs | 89 +++++++--- .../ContextAwareUrlTransformerTests.cs | 3 +- tests/XmlDoc/Comments/SeeAlsoCommentTests.cs | 74 +++++++- tests/XmlDoc/XmlDocExtensionsTests.cs | 162 +++++++++--------- 12 files changed, 392 insertions(+), 139 deletions(-) diff --git a/src/Collections/TopicCollection.cs b/src/Collections/TopicCollection.cs index cf456602..e1e0465a 100644 --- a/src/Collections/TopicCollection.cs +++ b/src/Collections/TopicCollection.cs @@ -249,6 +249,9 @@ public bool TryResolve(string reference, [NotNullWhen(true)] out TopicModel? top if (TryFindBySubpath(reference, out topic)) return true; + + if (TryFindByPartialId(reference, out topic)) + return true; } topic = null; @@ -303,13 +306,11 @@ public bool TryFindBySubpath(string filePath, [NotNullWhen(true)] out TopicModel if (string.IsNullOrEmpty(filePath)) throw new ArgumentException($"{nameof(filePath)} cannot be null or empty.", nameof(filePath)); + topic = null; + if (!PathHelper.TryNormalizePath(filePath, out var subPath)) - { - topic = null; return false; - } - topic = null; foreach (var (sourceFilePath, fileBasedTopic) in topicsByPath) { if (PathHelper.IsSubpath(sourceFilePath, subPath)) @@ -324,6 +325,41 @@ public bool TryFindBySubpath(string filePath, [NotNullWhen(true)] out TopicModel topic = fileBasedTopic; } } + + return topic is not null; + } + + /// + /// Attempts to find a topic in the topic hierarchy by its unqualified identifier. + /// + /// The unqualified identifier the topic to lookup. + /// When this method returns, contains the topic that uniquely matches the specified identifier; otherwise, if no match or if ambiguous. + /// if a unique matching topic was found; otherwise, . + /// Thrown when is or empty. + public bool TryFindByPartialId(string id, [NotNullWhen(true)] out TopicModel? topic) + { + if (string.IsNullOrEmpty(id)) + throw new ArgumentException($"{nameof(id)} cannot be null or empty.", nameof(id)); + + topic = null; + foreach (var candidate in allTopics.Values) + { + if (!candidate.Id.EndsWith(id, StringComparison.Ordinal)) + continue; + + if (candidate.Id.Length > id.Length && candidate.Id[candidate.Id.Length - id.Length - 1] != '/') + continue; + + if (topic is not null) + { + // Ambiguous match + topic = null; + return false; + } + + topic = candidate; + } + return topic is not null; } diff --git a/src/Kampute.DocToolkit.csproj b/src/Kampute.DocToolkit.csproj index 65838d1e..4e27d46c 100644 --- a/src/Kampute.DocToolkit.csproj +++ b/src/Kampute.DocToolkit.csproj @@ -4,7 +4,7 @@ netstandard2.1 Kampute.DocDotLib 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.1.0 + 2.2.0 Kampute Kambiz Khojasteh Copyright (c) 2025 Kampute diff --git a/src/Routing/UrlReference.cs b/src/Routing/UrlReference.cs index 746b17f4..0e4e01bf 100644 --- a/src/Routing/UrlReference.cs +++ b/src/Routing/UrlReference.cs @@ -84,7 +84,7 @@ public UrlReference(IDocumentModel referencingModel, string baseDirectory, strin /// A representing the target URL, or if the URL could not be resolved. /// /// - /// If the could not be resolved to a URL of an internal resource, this property will be . + /// If the could not be resolved to a URL of an internal resource, this property will be . /// Common reasons for a value include: /// /// The source string is not a well-formed absolute or relative URI. @@ -93,6 +93,13 @@ public UrlReference(IDocumentModel referencingModel, string baseDirectory, strin /// /// When the is a relative URL, it is relative to the directory of the referencing model's /// documentation page as indicated by the property. + /// + /// + /// A non- indicates a resolved URL, but it does not guarantee that the URL points to + /// an existing resource within the documentation. This is because the target resource may not have been generated yet or might not + /// be included in the documentation set at the time of URL resolution. + /// + /// /// public Uri? TargetUrl { get; } diff --git a/src/XmlDoc/Comments/SeeAlsoComment.cs b/src/XmlDoc/Comments/SeeAlsoComment.cs index 32616dd8..dc2c320c 100644 --- a/src/XmlDoc/Comments/SeeAlsoComment.cs +++ b/src/XmlDoc/Comments/SeeAlsoComment.cs @@ -80,17 +80,16 @@ public SeeAlsoComment WithResolvedHyperlink(IDocumentationContext context) if (context is null) throw new ArgumentNullException(nameof(context)); - if (!IsHyperlink || !Uri.TryCreate(Target, UriKind.Relative, out var relativeUrl)) + if (IsCodeReference || Uri.IsWellFormedUriString(Target, UriKind.Absolute)) return this; - var href = relativeUrl.ToString(); + if (!context.UrlTransformer.TryTransformUrl(Target, out var adjustedUrl)) + return this; - if (IsEmpty && context.Topics.TryResolve(UriHelper.GetPathPart(href), out var topic)) + if (IsEmpty && context.Topics.TryResolve(UriHelper.GetPathPart(Target), out var topic)) Content.Value = topic.Name; - return context.UrlTransformer.TryTransformUrl(href, out var adjustedUrl) - ? new SeeAlsoComment(adjustedUrl.ToString(), Content) - : this; + return new SeeAlsoComment(adjustedUrl.ToString(), Content); } /// diff --git a/src/XmlDoc/XmlDocToHtmlTransformer.cs b/src/XmlDoc/XmlDocToHtmlTransformer.cs index 0c314962..adb8501e 100644 --- a/src/XmlDoc/XmlDocToHtmlTransformer.cs +++ b/src/XmlDoc/XmlDocToHtmlTransformer.cs @@ -23,7 +23,7 @@ namespace Kampute.DocToolkit.XmlDoc /// additional text processing capabilities during the transformation process, such as text escaping, formatting, /// and whitespace normalization. /// - /// + /// public class XmlDocToHtmlTransformer : XmlDocTransformer { /// diff --git a/src/XmlDoc/XmlDocToMarkdownTransformer.cs b/src/XmlDoc/XmlDocToMarkdownTransformer.cs index 1d9a84cc..3cd6cbcd 100644 --- a/src/XmlDoc/XmlDocToMarkdownTransformer.cs +++ b/src/XmlDoc/XmlDocToMarkdownTransformer.cs @@ -25,7 +25,7 @@ namespace Kampute.DocToolkit.XmlDoc /// and extension methods, which provide specialized /// formatting for Markdown-specific elements beyond basic text processing. /// - /// + /// public class XmlDocToMarkdownTransformer : XmlDocTransformer { /// diff --git a/src/Xslt/NamespaceDoc.cs b/src/Xslt/NamespaceDoc.cs index b11cf1e6..00f1a4e2 100644 --- a/src/Xslt/NamespaceDoc.cs +++ b/src/Xslt/NamespaceDoc.cs @@ -13,8 +13,8 @@ namespace Kampute.DocToolkit.Xslt /// transform XML documentation. These utilities implement a declarative approach to transforming XML documentation /// into various output formats using XSLT stylesheets. /// - /// - /// + /// + /// internal static class NamespaceDoc { // This class doesn't contain any code diff --git a/tests/Collections/TopicCollectionTests.cs b/tests/Collections/TopicCollectionTests.cs index 81bf1443..c6431124 100644 --- a/tests/Collections/TopicCollectionTests.cs +++ b/tests/Collections/TopicCollectionTests.cs @@ -514,6 +514,129 @@ public void TryFindBySubpath_WithMultipleMatches_ReturnsNull() } } + [Test] + public void TryFindByPartialId_WithNullId_ThrowsArgumentException() + { + var collection = new TopicCollection(context); + + Assert.That(() => collection.TryFindByPartialId(null!, out _), Throws.ArgumentException + .With.Property("ParamName").EqualTo("id")); + } + + [Test] + public void TryFindByPartialId_WithEmptyId_ThrowsArgumentException() + { + var collection = new TopicCollection(context); + + Assert.That(() => collection.TryFindByPartialId("", out _), Throws.ArgumentException + .With.Property("ParamName").EqualTo("id")); + } + + [Test] + public void TryFindByPartialId_WithNoMatches_ReturnsFalse() + { + var collection = new TopicCollection(context) + { + CreateMockTopic("Topic1"), + CreateMockTopicWithChildren("Topic2", "Child1") + }; + + var result = collection.TryFindByPartialId("NonExistent", out var found); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(found, Is.Null); + } + } + + [Test] + public void TryFindByPartialId_WithExactMatch_ReturnsTrue() + { + var collection = new TopicCollection(context) + { + CreateMockTopic("Topic1"), + CreateMockTopic("Topic2") + }; + + var result = collection.TryFindByPartialId("Topic1", out var found); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(found?.Id, Is.EqualTo("Topic1")); + } + } + + [Test] + public void TryFindByPartialId_WithPartialMatch_ReturnsTrue() + { + var collection = new TopicCollection(context) + { + CreateMockTopicWithChildren("Parent", "Child") + }; + + var result = collection.TryFindByPartialId("Child", out var found); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(found?.Id, Is.EqualTo("Parent/Child")); + } + } + + [Test] + public void TryFindByPartialId_WithAmbiguousMatches_ReturnsFalse() + { + var collection = new TopicCollection(context) + { + CreateMockTopicWithChildren("Parent1", "Child"), + CreateMockTopicWithChildren("Parent2", "Child") + }; + + var result = collection.TryFindByPartialId("Child", out var found); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(found, Is.Null); + } + } + + [Test] + public void TryFindByPartialId_WithNoSeparator_ReturnsFalse() + { + var collection = new TopicCollection(context) + { + CreateMockTopic("ParentChild") + }; + + var result = collection.TryFindByPartialId("Child", out var found); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(found, Is.Null); + } + } + + [Test] + public void TryFindByPartialId_WithNestedPartialMatch_ReturnsTrue() + { + var collection = new TopicCollection(context) + { + CreateMockTopic("A/B/C") + }; + + var result = collection.TryFindByPartialId("C", out var found); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(found?.Id, Is.EqualTo("A/B/C")); + } + } + [Test] public void TryResolve_WithNullReference_ReturnsFalse() { @@ -676,7 +799,7 @@ public void TryResolve_WithRelativeTopicId_ToRoot_ResolvesCorrectly() } [Test] - public void TryResolve_WithRelativeTopicId_OutOfScope_ReturnsFalse() + public void TryResolve_WithUniqueRelativeTopicId_OutOfScope_ResolvesCorrectly() { var collection = new TopicCollection(context) { @@ -687,8 +810,8 @@ public void TryResolve_WithRelativeTopicId_OutOfScope_ReturnsFalse() using (Assert.EnterMultipleScope()) { - Assert.That(result, Is.False); - Assert.That(resolvedTopic, Is.Null); + Assert.That(result, Is.True); + Assert.That(resolvedTopic?.Id, Is.EqualTo("guides/installation")); } } diff --git a/tests/MockHelper.cs b/tests/MockHelper.cs index d4d364ac..2dec03c9 100644 --- a/tests/MockHelper.cs +++ b/tests/MockHelper.cs @@ -59,9 +59,11 @@ public static IDocumentationContext CreateDocumentationContext(IEnumera public static IDocumentationContext CreateDocumentationContext(IEnumerable assemblies, IEnumerable topics) where TFormat : IDocumentFormatter, new() { + var language = new CSharp(); var addressProvider = CreateAddressProvider(); var xmlDocProvider = new XmlDocProvider(CreateXmlDocResolver()); - return new DocumentationContext(new CSharp(), addressProvider, xmlDocProvider, new TFormat(), assemblies, topics); + var formatter = new TFormat(); + return new DocumentationContext(language, addressProvider, xmlDocProvider, formatter, assemblies, topics); } /// @@ -72,60 +74,96 @@ public static IDocumentAddressProvider CreateAddressProvider() { var urlContext = new ContextAwareUrlNormalizer(); var addressProviderMock = new Mock(); + var addressProvider = addressProviderMock.Object; + addressProviderMock.SetupGet(x => x.Granularity).Returns(PageGranularity.NamespaceTypeMember); addressProviderMock.SetupGet(x => x.ActiveScope).Returns(() => urlContext.ActiveScope); + addressProviderMock.Setup(x => x.BeginScope(It.IsAny(), It.IsAny())) .Returns((string directory, IDocumentModel? model) => urlContext.BeginScope(directory, model)); - addressProviderMock.Setup(x => x.TryGetNamespaceUrl(It.IsAny(), out It.Ref.IsAny)) - .Returns((string ns, out Uri? url) => + + addressProviderMock.Setup(x => x.TryGetNamespaceFile(It.IsAny(), out It.Ref.IsAny)) + .Returns((string ns, out string? path) => { - url = new RawUri($"https://example.com/{ns.ToLowerInvariant()}", UriKind.Absolute); + path = ns.ToLowerInvariant(); + if (!addressProvider.ActiveScope.IsRoot) + path = $"{addressProvider.ActiveScope.Directory}/{path}"; + return true; }); - addressProviderMock.Setup(x => x.TryGetMemberUrl(It.IsAny(), out It.Ref.IsAny)) - .Returns((IMember member, out Uri? url) => + + addressProviderMock.Setup(x => x.TryGetMemberFile(It.IsAny(), out It.Ref.IsAny)) + .Returns((IMember member, out string? path) => { if (member.IsDirectDeclaration) { - var resourceName = member.CodeReference[2..].ReplaceChars(['`', '#'], '-').ToLowerInvariant(); - url = new RawUri($"https://example.com/{resourceName}", UriKind.Absolute); + path = member.CodeReference[2..].ReplaceChars(['`', '#'], '-').ToLowerInvariant(); + if (!addressProvider.ActiveScope.IsRoot) + path = $"{addressProvider.ActiveScope.Directory}/{path}"; + return true; } - url = null; + path = null; return false; }); - addressProviderMock.Setup(x => x.TryGetTopicUrl(It.IsAny(), out It.Ref.IsAny)) - .Returns((ITopic topic, out Uri? url) => + + addressProviderMock.Setup(x => x.TryGetTopicFile(It.IsAny(), out It.Ref.IsAny)) + .Returns((ITopic topic, out string? path) => { - url = new RawUri($"~/{topic.Id.ToLowerInvariant()}", UriKind.Relative); + var segments = new List(); + + for (var current = topic; current is not null; current = current.ParentTopic) + segments.Add(current.Id); + + if (!addressProvider.ActiveScope.IsRoot) + segments.Add(addressProvider.ActiveScope.Directory); + + segments.Reverse(); + path = string.Join('/', segments).ToLowerInvariant(); return true; }); - addressProviderMock.Setup(x => x.TryGetNamespaceFile(It.IsAny(), out It.Ref.IsAny)) - .Returns((string ns, out string? path) => + + addressProviderMock.Setup(x => x.TryGetNamespaceUrl(It.IsAny(), out It.Ref.IsAny)) + .Returns((string ns, out Uri? url) => { - path = ns.ToLowerInvariant(); - return true; + if (addressProvider.TryGetNamespaceFile(ns, out var path)) + { + url = new RawUri($"https://example.com/{path}", UriKind.Absolute); + return true; + } + + url = null; + return false; }); - addressProviderMock.Setup(x => x.TryGetMemberFile(It.IsAny(), out It.Ref.IsAny)) - .Returns((IMember member, out string? path) => + + addressProviderMock.Setup(x => x.TryGetMemberUrl(It.IsAny(), out It.Ref.IsAny)) + .Returns((IMember member, out Uri? url) => { - if (member.IsDirectDeclaration) + if (addressProvider.TryGetMemberFile(member, out var path)) { - path = member.CodeReference[2..].ReplaceChars(['`', '#'], '-').ToLowerInvariant(); + url = new RawUri($"https://example.com/{path}", UriKind.Absolute); return true; } - path = null; + url = null; return false; }); - addressProviderMock.Setup(x => x.TryGetTopicFile(It.IsAny(), out It.Ref.IsAny)) - .Returns((ITopic topic, out string? path) => + + addressProviderMock.Setup(x => x.TryGetTopicUrl(It.IsAny(), out It.Ref.IsAny)) + .Returns((ITopic topic, out Uri? url) => { - path = topic.Id.ToLowerInvariant(); + if (addressProvider.TryGetTopicFile(topic, out var path)) + { + url = new RawUri($"https://example.com/{path}", UriKind.Absolute); + return true; + } + + url = null; return false; }); - return addressProviderMock.Object; + + return addressProvider; } /// @@ -137,6 +175,7 @@ public static IXmlDocResolver CreateXmlDocResolver() var xmlDocResolverMock = new Mock(); xmlDocResolverMock.SetupGet(static x => x.HasDocumentation).Returns(true); + xmlDocResolverMock.Setup(static x => x.TryGetXmlDoc(It.IsAny(), out It.Ref.IsAny)) .Returns(static (string cref, out XElement? xmlDoc) => { diff --git a/tests/Routing/ContextAwareUrlTransformerTests.cs b/tests/Routing/ContextAwareUrlTransformerTests.cs index 46e1a5ef..095b1b67 100644 --- a/tests/Routing/ContextAwareUrlTransformerTests.cs +++ b/tests/Routing/ContextAwareUrlTransformerTests.cs @@ -9,6 +9,7 @@ namespace Kampute.DocToolkit.Test.Routing using Kampute.DocToolkit.Routing; using Kampute.DocToolkit.Topics; using NUnit.Framework; + using System; using System.IO; [TestFixture] @@ -408,7 +409,7 @@ public void TryTransformUrl_WithFileBasedTopicAndExistingAsset_ReturnsAssetUrl() using (Assert.EnterMultipleScope()) { Assert.That(result, Is.True); - Assert.That(transformedUrl?.ToString(), Is.EqualTo("assets/license.txt")); + Assert.That(transformedUrl, Is.EqualTo(new Uri("https://example.com/assets/license.txt"))); } } finally diff --git a/tests/XmlDoc/Comments/SeeAlsoCommentTests.cs b/tests/XmlDoc/Comments/SeeAlsoCommentTests.cs index 385bfca2..f4a87b71 100644 --- a/tests/XmlDoc/Comments/SeeAlsoCommentTests.cs +++ b/tests/XmlDoc/Comments/SeeAlsoCommentTests.cs @@ -28,11 +28,11 @@ public void WithResolvedHyperlink_WithCodeReference_ReturnsSameComment() } [Test] - public void WithResolvedHyperlink_WithUnresolvableUrl_ReturnsSameComment() + public void WithResolvedHyperlink_WithAbsoluteUrl_ReturnsSameComment() { using var docContext = MockHelper.CreateDocumentationContext(); - var element = XElement.Parse(""); + var element = XElement.Parse(""); SeeAlsoComment.TryCreate(element, out var seeAlso); var result = seeAlso!.WithResolvedHyperlink(docContext); @@ -41,9 +41,22 @@ public void WithResolvedHyperlink_WithUnresolvableUrl_ReturnsSameComment() } [Test] - public void WithResolvedHyperlink_WithResolvableUrl_UpdatesHrefAttribute() + public void WithResolvedHyperlink_WithUnresolvableRelativeUrl_ReturnsSameComment() { - var topic = MockTopicBuilder.Topic("api-guide", "API Guide").WithChildren("cs").Build(); + using var docContext = MockHelper.CreateDocumentationContext(); + + var element = XElement.Parse(""); + SeeAlsoComment.TryCreate(element, out var seeAlso); + + var result = seeAlso!.WithResolvedHyperlink(docContext); + + Assert.That(result, Is.SameAs(seeAlso)); + } + + [Test] + public void WithResolvedHyperlink_WithResolvableRelativeUrl_UpdatesHrefAttribute() + { + var topic = MockTopicBuilder.Topic("api-guide", "API Guide").Build(); using var docContext = MockHelper.CreateDocumentationContext([topic]); var element = XElement.Parse("Original text"); @@ -54,18 +67,18 @@ public void WithResolvedHyperlink_WithResolvableUrl_UpdatesHrefAttribute() using (Assert.EnterMultipleScope()) { Assert.That(result, Is.Not.SameAs(seeAlso)); - Assert.That(result.Target, Is.EqualTo("~/api-guide")); + Assert.That(result.Target, Is.EqualTo("https://example.com/api-guide")); Assert.That(result.Content.Value, Is.EqualTo("Original text")); } } [Test] - public void WithResolvedHyperlink_WithResolvableUrlAndEmptyComment_AddsTopicTitle() + public void WithResolvedHyperlink_WithResolvableRelativeUrlAndEmptyComment_AddsTopicTitle() { - var topic = MockTopicBuilder.Topic("api-guide", "API Guide").WithChildren("cs").Build(); + var topic = MockTopicBuilder.Topic("api-guide", "API Guide").Build(); using var docContext = MockHelper.CreateDocumentationContext([topic]); - var element = XElement.Parse(""); + var element = XElement.Parse(""); SeeAlsoComment.TryCreate(element, out var seeAlso); var result = seeAlso!.WithResolvedHyperlink(docContext); @@ -73,11 +86,49 @@ public void WithResolvedHyperlink_WithResolvableUrlAndEmptyComment_AddsTopicTitl using (Assert.EnterMultipleScope()) { Assert.That(result, Is.Not.SameAs(seeAlso)); - Assert.That(result.Target, Is.EqualTo("~/api-guide")); + Assert.That(result.Target, Is.EqualTo("https://example.com/api-guide")); Assert.That(result.Content.Value, Is.EqualTo("API Guide")); } } + [Test] + public void WithResolvedHyperlink_WithResolvableChildTopicUrl_UpdatesHrefAttribute() + { + var parentTopic = MockTopicBuilder.Topic("parent", "Parent Guide").WithChild("child", "Child Guide").Build(); + using var docContext = MockHelper.CreateDocumentationContext([parentTopic]); + + var element = XElement.Parse("Original text"); + SeeAlsoComment.TryCreate(element, out var seeAlso); + + var result = seeAlso!.WithResolvedHyperlink(docContext); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.Not.SameAs(seeAlso)); + Assert.That(result.Target, Is.EqualTo("https://example.com/parent/child")); + Assert.That(result.Content.Value, Is.EqualTo("Original text")); + } + } + + [Test] + public void WithResolvedHyperlink_WithResolvableChildTopicUrlAndEmptyComment_AddsTopicTitle() + { + var parentTopic = MockTopicBuilder.Topic("parent", "Parent Guide").WithChild("child", "Child Guide").Build(); + using var docContext = MockHelper.CreateDocumentationContext([parentTopic]); + + var element = XElement.Parse(""); + SeeAlsoComment.TryCreate(element, out var seeAlso); + + var result = seeAlso!.WithResolvedHyperlink(docContext); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.Not.SameAs(seeAlso)); + Assert.That(result.Target, Is.EqualTo("https://example.com/parent/child")); + Assert.That(result.Content.Value, Is.EqualTo("Child Guide")); + } + } + [Test] public void TryCreate_cref_CreatesComment() { @@ -130,6 +181,11 @@ public void Collect_WithMixedElements_ReturnsValidComments() var comments = SeeAlsoComment.Collect(elements).ToList(); Assert.That(comments, Has.Count.EqualTo(2)); + using (Assert.EnterMultipleScope()) + { + Assert.That(comments[0].IsCodeReference, Is.True); + Assert.That(comments[1].IsHyperlink, Is.True); + } } } } \ No newline at end of file diff --git a/tests/XmlDoc/XmlDocExtensionsTests.cs b/tests/XmlDoc/XmlDocExtensionsTests.cs index 715d07dc..a4b028f8 100644 --- a/tests/XmlDoc/XmlDocExtensionsTests.cs +++ b/tests/XmlDoc/XmlDocExtensionsTests.cs @@ -17,6 +17,7 @@ public class XmlDocExtensionsTests { private string xmlFilePath = null!; private IXmlDocProvider xmlDocProvider = null!; + private ITypeMember testMember = null!; [SetUp] public void Setup() @@ -27,15 +28,21 @@ public void Setup() @" - + A documented type but without member documentation. - + + + Overload with documentation. + + Overloaded method. + + "; @@ -45,6 +52,8 @@ public void Setup() var repository = new XmlDocRepository(); repository.ImportFile(xmlFilePath); xmlDocProvider = new XmlDocProvider(repository); + + testMember = typeof(TestSample).GetMethod(nameof(TestSample.Member))!.GetMetadata(); } [TearDown] @@ -57,9 +66,7 @@ public void TearDown() [Test] public void InspectDocumentation_WithNullProvider_ThrowsArgumentNullException() { - var member = typeof(Acme.ISampleInterface).GetMetadata(); - - Assert.That(() => ((IXmlDocProvider)null!).InspectDocumentation(member), + Assert.That(() => ((IXmlDocProvider)null!).InspectDocumentation(testMember), Throws.ArgumentNullException.With.Property("ParamName").EqualTo("xmlDocProvider")); } @@ -73,9 +80,7 @@ public void InspectDocumentation_WithNullMember_ThrowsArgumentNullException() [Test] public void InspectDocumentation_WithNoneOptions_ReturnsEmpty() { - var member = typeof(Acme.ISampleInterface).GetMetadata(); - - var issues = xmlDocProvider.InspectDocumentation(member, XmlDocInspectionOptions.None).ToList(); + var issues = xmlDocProvider.InspectDocumentation(testMember, XmlDocInspectionOptions.None).ToList(); Assert.That(issues, Is.Empty); } @@ -83,21 +88,16 @@ public void InspectDocumentation_WithNoneOptions_ReturnsEmpty() [Test] public void InspectDocumentation_Documented_WithRequiredOnly_ReturnsNoIssues() { - var member = typeof(Acme.ISampleInterface).GetMetadata(); - - var issues = xmlDocProvider.InspectDocumentation(member, XmlDocInspectionOptions.Required).ToList(); + var issues = xmlDocProvider.InspectDocumentation(typeof(TestSample).GetMetadata(), XmlDocInspectionOptions.Required).ToList(); Assert.That(issues, Is.Empty); } [Test] - public void InspectDocumentation_MissingSummary_WithRequiredOption_ReportsIssue() + public void InspectDocumentation_MissingSummaryComment_WithRequiredOption_ReportsIssue() { - var type = typeof(Acme.SampleDerivedGenericClass<,,>).GetMetadata(); - var member = type.Methods.First(static m => m.Name == nameof(Acme.SampleDerivedGenericClass<,,>.GenericMethod)); - var summaryIssues = xmlDocProvider - .InspectDocumentation(member, XmlDocInspectionOptions.Required) + .InspectDocumentation(testMember, XmlDocInspectionOptions.Required) .Where(static i => i.XmlTag == XmlDocTag.Summary) .ToList(); @@ -105,18 +105,15 @@ public void InspectDocumentation_MissingSummary_WithRequiredOption_ReportsIssue( using (Assert.EnterMultipleScope()) { Assert.That(summaryIssues[0].IssueType, Is.EqualTo(XmlDocInspectionIssueType.MissingRequiredTag)); - Assert.That(summaryIssues[0].Member, Is.EqualTo(member)); + Assert.That(summaryIssues[0].Member, Is.EqualTo(testMember)); } } [Test] - public void InspectDocumentation_MissingTypeParam_WithRequiredOption_ReportsIssue() + public void InspectDocumentation_MissingTypeParamComment_WithRequiredOption_ReportsIssue() { - var type = typeof(Acme.SampleDerivedGenericClass<,,>).GetMetadata(); - var member = type.Methods.First(m => m.Name == nameof(Acme.SampleDerivedGenericClass<,,>.GenericMethod)); - var typeParamIssues = xmlDocProvider - .InspectDocumentation(member, XmlDocInspectionOptions.Required) + .InspectDocumentation(testMember, XmlDocInspectionOptions.Required) .Where(i => i.XmlTag == XmlDocTag.TypeParam) .ToList(); @@ -124,19 +121,16 @@ public void InspectDocumentation_MissingTypeParam_WithRequiredOption_ReportsIssu using (Assert.EnterMultipleScope()) { Assert.That(typeParamIssues.All(i => i.IssueType == XmlDocInspectionIssueType.MissingRequiredTag)); - Assert.That(typeParamIssues.All(i => i.Member == member)); + Assert.That(typeParamIssues.All(i => i.Member == testMember)); Assert.That(typeParamIssues.All(i => i.TypeParameter is not null)); } } [Test] - public void InspectDocumentation_MissingParam_WithRequiredOption_ReportsIssue() + public void InspectDocumentation_MissingParamComment_WithRequiredOption_ReportsIssue() { - var type = typeof(Acme.SampleDerivedGenericClass<,,>).GetMetadata(); - var member = type.Methods.First(m => m.Name == nameof(Acme.SampleDerivedGenericClass<,,>.GenericMethod)); - var paramIssues = xmlDocProvider - .InspectDocumentation(member, XmlDocInspectionOptions.Required) + .InspectDocumentation(testMember, XmlDocInspectionOptions.Required) .Where(i => i.XmlTag == XmlDocTag.Param) .ToList(); @@ -144,19 +138,16 @@ public void InspectDocumentation_MissingParam_WithRequiredOption_ReportsIssue() using (Assert.EnterMultipleScope()) { Assert.That(paramIssues.All(i => i.IssueType == XmlDocInspectionIssueType.MissingRequiredTag)); - Assert.That(paramIssues.All(i => i.Member == member)); + Assert.That(paramIssues.All(i => i.Member == testMember)); Assert.That(paramIssues.All(i => i.Parameter is not null)); } } [Test] - public void InspectDocumentation_MissingReturns_WithRequiredOption_ReportsIssue() + public void InspectDocumentation_MissingReturnsComment_WithRequiredOption_ReportsIssue() { - var type = typeof(Acme.SampleDerivedGenericClass<,,>).GetMetadata(); - var member = type.Methods.First(m => m.Name == nameof(Acme.SampleDerivedGenericClass<,,>.GenericMethod)); - var returnsIssues = xmlDocProvider - .InspectDocumentation(member, XmlDocInspectionOptions.Required) + .InspectDocumentation(testMember, XmlDocInspectionOptions.Required) .Where(i => i.XmlTag == XmlDocTag.Returns) .ToList(); @@ -164,20 +155,17 @@ public void InspectDocumentation_MissingReturns_WithRequiredOption_ReportsIssue( using (Assert.EnterMultipleScope()) { Assert.That(returnsIssues.All(i => i.IssueType == XmlDocInspectionIssueType.MissingRequiredTag)); - Assert.That(returnsIssues.All(i => i.Member == member)); + Assert.That(returnsIssues.All(i => i.Member == testMember)); Assert.That(returnsIssues.All(i => i.Parameter is not null)); } } [Test] - public void InspectDocumentation_MissingRemarks_WithRemarksOption_ReportsIssue() + public void InspectDocumentation_MissingRemarksComment_WithRemarksOption_ReportsIssue() { - var type = typeof(Acme.SampleDerivedGenericClass<,,>).GetMetadata(); - var member = type.Methods.First(m => m.Name == nameof(Acme.SampleDerivedGenericClass<,,>.GenericMethod)); - var remarksIssues = xmlDocProvider - .InspectDocumentation(member, XmlDocInspectionOptions.Remarks) + .InspectDocumentation(testMember, XmlDocInspectionOptions.Remarks) .Where(i => i.XmlTag == XmlDocTag.Remarks) .ToList(); @@ -185,18 +173,15 @@ public void InspectDocumentation_MissingRemarks_WithRemarksOption_ReportsIssue() using (Assert.EnterMultipleScope()) { Assert.That(remarksIssues.All(i => i.IssueType == XmlDocInspectionIssueType.MissingOptionalTag)); - Assert.That(remarksIssues.All(i => i.Member == member)); + Assert.That(remarksIssues.All(i => i.Member == testMember)); } } [Test] - public void InspectDocumentation_MissingExample_WithExampleOption_ReportsIssue() + public void InspectDocumentation_MissingExampleComment_WithExampleOption_ReportsIssue() { - var type = typeof(Acme.SampleDerivedGenericClass<,,>).GetMetadata(); - var member = type.Methods.First(m => m.Name == nameof(Acme.SampleDerivedGenericClass<,,>.GenericMethod)); - var exampleIssues = xmlDocProvider - .InspectDocumentation(member, XmlDocInspectionOptions.Example) + .InspectDocumentation(testMember, XmlDocInspectionOptions.Example) .Where(i => i.XmlTag == XmlDocTag.Example) .ToList(); @@ -204,18 +189,15 @@ public void InspectDocumentation_MissingExample_WithExampleOption_ReportsIssue() using (Assert.EnterMultipleScope()) { Assert.That(exampleIssues.All(i => i.IssueType == XmlDocInspectionIssueType.MissingOptionalTag)); - Assert.That(exampleIssues.All(i => i.Member == member)); + Assert.That(exampleIssues.All(i => i.Member == testMember)); } } [Test] - public void InspectDocumentation_MissingException_WithExceptionOption_ReportsIssue() + public void InspectDocumentation_MissingExceptionDescription_WithExceptionOption_ReportsIssue() { - var type = typeof(Acme.SampleDerivedGenericClass<,,>).GetMetadata(); - var member = type.Methods.First(m => m.Name == nameof(Acme.SampleDerivedGenericClass<,,>.GenericMethod)); - var exceptionIssues = xmlDocProvider - .InspectDocumentation(member, XmlDocInspectionOptions.Exception) + .InspectDocumentation(testMember, XmlDocInspectionOptions.Exception) .Where(i => i.XmlTag == XmlDocTag.Exception) .ToList(); @@ -223,19 +205,16 @@ public void InspectDocumentation_MissingException_WithExceptionOption_ReportsIss using (Assert.EnterMultipleScope()) { Assert.That(exceptionIssues.All(i => i.IssueType == XmlDocInspectionIssueType.UndocumentedReference)); - Assert.That(exceptionIssues.All(i => i.Member == member)); + Assert.That(exceptionIssues.All(i => i.Member == testMember)); Assert.That(exceptionIssues.All(i => i.CodeReference is not null)); } } [Test] - public void InspectDocumentation_MissingPermission_WithPermissionOption_ReportsIssue() + public void InspectDocumentation_MissingPermissionDescription_WithPermissionOption_ReportsIssue() { - var type = typeof(Acme.SampleDerivedGenericClass<,,>).GetMetadata(); - var member = type.Methods.First(m => m.Name == nameof(Acme.SampleDerivedGenericClass<,,>.GenericMethod)); - var permissionIssues = xmlDocProvider - .InspectDocumentation(member, XmlDocInspectionOptions.Permission) + .InspectDocumentation(testMember, XmlDocInspectionOptions.Permission) .Where(i => i.XmlTag == XmlDocTag.Permission) .ToList(); @@ -243,37 +222,31 @@ public void InspectDocumentation_MissingPermission_WithPermissionOption_ReportsI using (Assert.EnterMultipleScope()) { Assert.That(permissionIssues.All(i => i.IssueType == XmlDocInspectionIssueType.UndocumentedReference)); - Assert.That(permissionIssues.All(i => i.Member == member)); + Assert.That(permissionIssues.All(i => i.Member == testMember)); Assert.That(permissionIssues.All(i => i.CodeReference is not null)); } } [Test] - public void InspectDocumentation_MissingEvent_WithEventOption_ReportsIssue() + public void InspectDocumentation_MissingEventDescription_WithEventOption_ReportsIssue() { - var type = typeof(Acme.SampleDerivedGenericClass<,,>).GetMetadata(); - var member = type.Methods.First(m => m.Name == nameof(Acme.SampleDerivedGenericClass<,,>.GenericMethod)); - var eventIssues = xmlDocProvider - .InspectDocumentation(member, XmlDocInspectionOptions.Event) + .InspectDocumentation(testMember, XmlDocInspectionOptions.Event) .Where(i => i.XmlTag == XmlDocTag.Event); using (Assert.EnterMultipleScope()) { Assert.That(eventIssues.All(i => i.IssueType == XmlDocInspectionIssueType.UndocumentedReference)); - Assert.That(eventIssues.All(i => i.Member == member)); + Assert.That(eventIssues.All(i => i.Member == testMember)); Assert.That(eventIssues.All(i => i.CodeReference is not null)); } } [Test] - public void InspectDocumentation_MissingSeeAlso_WithSeeAlsoOption_ReportsIssue() + public void InspectDocumentation_MissingHyperlinkSeeAlsoDescription_WithSeeAlsoOption_ReportsIssue() { - var type = typeof(Acme.SampleDerivedGenericClass<,,>).GetMetadata(); - var member = type.Methods.First(static m => m.Name == nameof(Acme.SampleDerivedGenericClass<,,>.GenericMethod)); - var seeAlsoIssues = xmlDocProvider - .InspectDocumentation(member, XmlDocInspectionOptions.SeeAlso) + .InspectDocumentation(testMember, XmlDocInspectionOptions.SeeAlso) .Where(static i => i.XmlTag == XmlDocTag.SeeAlso) .ToList(); @@ -281,18 +254,18 @@ public void InspectDocumentation_MissingSeeAlso_WithSeeAlsoOption_ReportsIssue() using (Assert.EnterMultipleScope()) { Assert.That(seeAlsoIssues[0].IssueType, Is.EqualTo(XmlDocInspectionIssueType.UntitledSeeAlso)); - Assert.That(seeAlsoIssues[0].Member, Is.EqualTo(member)); + Assert.That(seeAlsoIssues[0].Member, Is.EqualTo(testMember)); Assert.That(seeAlsoIssues[0].Hyperlink, Is.Not.Null); } } [Test] - public void InspectDocumentation_MissingThreadSafety_WithThreadSafetyOption_ReportsIssue() + public void InspectDocumentation_MissingThreadSafetyComment_WithThreadSafetyOption_ReportsIssue() { - var member = typeof(Acme.SampleDerivedGenericClass<,,>).GetMetadata(); + var type = typeof(TestSample).GetMetadata(); var threadSafetyIssues = xmlDocProvider - .InspectDocumentation(member, XmlDocInspectionOptions.ThreadSafety) + .InspectDocumentation(type, XmlDocInspectionOptions.ThreadSafety) .Where(i => i.XmlTag == XmlDocTag.ThreadSafety) .ToList(); @@ -300,15 +273,15 @@ public void InspectDocumentation_MissingThreadSafety_WithThreadSafetyOption_Repo using (Assert.EnterMultipleScope()) { Assert.That(threadSafetyIssues.All(i => i.IssueType == XmlDocInspectionIssueType.MissingOptionalTag)); - Assert.That(threadSafetyIssues.All(i => i.Member == member)); + Assert.That(threadSafetyIssues.All(i => i.Member == type)); } } [Test] public void InspectDocumentation_MissingOverloads_WithOverloadsOption_ReportsIssue() { - var type = typeof(Acme.SampleMethods).GetMetadata(); - var member = type.Methods.First(m => m.Name == nameof(Acme.SampleMethods.OverloadedMethod)); + var type = typeof(TestSample).GetMetadata(); + var member = type.Methods.First(m => m.Name == nameof(TestSample.OverloadAbsent)); var overloadsIssues = xmlDocProvider .InspectDocumentation(member, XmlDocInspectionOptions.Overloads) @@ -323,12 +296,23 @@ public void InspectDocumentation_MissingOverloads_WithOverloadsOption_ReportsIss } [Test] - public void InspectDocumentation_MultipleOptions_ReportsAllIssues() + public void InspectDocumentation_HavingOverloads_WithOverloadsOption_ReportsNoIssue() { - var type = typeof(Acme.SampleDerivedGenericClass<,,>).GetMetadata(); - var member = type.Methods.First(static m => m.Name == nameof(Acme.SampleDerivedGenericClass<,,>.GenericMethod)); + var type = typeof(TestSample).GetMetadata(); + var member = type.Methods.First(m => m.Name == nameof(TestSample.OverloadPresent)); - var issues = xmlDocProvider.InspectDocumentation(member, XmlDocInspectionOptions.Remarks | XmlDocInspectionOptions.Example).ToList(); + var overloadsIssues = xmlDocProvider + .InspectDocumentation(member, XmlDocInspectionOptions.Overloads) + .Where(i => i.XmlTag == XmlDocTag.Overloads) + .ToList(); + + Assert.That(overloadsIssues, Is.Empty); + } + + [Test] + public void InspectDocumentation_MultipleOptions_ReportsAllIssues() + { + var issues = xmlDocProvider.InspectDocumentation(testMember, XmlDocInspectionOptions.Remarks | XmlDocInspectionOptions.Example).ToList(); using (Assert.EnterMultipleScope()) { @@ -340,10 +324,7 @@ public void InspectDocumentation_MultipleOptions_ReportsAllIssues() [Test] public void InspectDocumentation_WithAllOptions_ReportsAllRelevantMissingTags() { - var type = typeof(Acme.SampleDerivedGenericClass<,,>).GetMetadata(); - var member = type.Methods.First(static m => m.Name == nameof(Acme.SampleDerivedGenericClass<,,>.GenericMethod)); - - var issues = xmlDocProvider.InspectDocumentation(member, XmlDocInspectionOptions.All).ToList(); + var issues = xmlDocProvider.InspectDocumentation(testMember, XmlDocInspectionOptions.All).ToList(); using (Assert.EnterMultipleScope()) { @@ -421,5 +402,16 @@ public void InspectDocumentation_ExplicitConstructor_WithOmitFlag_ReportsMissing Assert.That(issues[0].Member, Is.EqualTo(explicitConstructor)); } } + + private static class TestSample + { + public static T Member(T x) => throw new NotImplementedException(); + + public static void OverloadAbsent() => throw new NotImplementedException(); + public static void OverloadAbsent(int x) => throw new NotImplementedException(); + + public static void OverloadPresent() => throw new NotImplementedException(); + public static void OverloadPresent(int x) => throw new NotImplementedException(); + } } }