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();
+ }
}
}