diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs index 03b09f5517..d46892c4b8 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs @@ -44,7 +44,8 @@ private void AssignDocumentMetadata(DocumentationDocument doc) private static void AssignContentHash(DocumentationDocument doc) => doc.ContentBodyHash = ContentHash.CreateNormalized(doc.StrippedBody ?? string.Empty); - private static void CommonEnrichments(DocumentationDocument doc, INavigationItem? navigationItem) + /// Internal (rather than private) so tests can assert navigation_* rank features never fall back to the contract's penalty default. + internal static void CommonEnrichments(DocumentationDocument doc, INavigationItem? navigationItem) { doc.SearchTitle = CreateSearchTitle(); // if we have no navigation, initialize to 20 since rank_feature would score 0 too high @@ -59,7 +60,9 @@ private static void CommonEnrichments(DocumentationDocument doc, INavigationItem _ => 100 }; doc.NavigationSection = navigationItem?.NavigationSection; - if (doc.Type == "api") + // doc.Type is the fixed CLR/$type discriminator ("docs" for every DocumentationDocument) — + // ContentType is the field OpenApiDocumentExporter actually sets to "api" per-document. + if (doc.ContentType == "api") doc.NavigationSection = "api"; // this section gets promoted in the navigation we don't want it to be promoted in the search results diff --git a/tests/Elastic.Markdown.Tests/Search/NavigationEnrichmentTests.cs b/tests/Elastic.Markdown.Tests/Search/NavigationEnrichmentTests.cs new file mode 100644 index 0000000000..b8f5e431ee --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Search/NavigationEnrichmentTests.cs @@ -0,0 +1,150 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using AwesomeAssertions; +using Elastic.Documentation.Navigation; +using Elastic.Documentation.Search; +using Elastic.Markdown.Exporters.Elasticsearch; + +namespace Elastic.Markdown.Tests.Search; + +/// +/// `navigation_depth` and `navigation_table_of_contents` are `rank_feature` fields with NEGATIVE +/// score impact — the shared contract defaults missing values to 50, which is a penalty. These +/// tests guard that never leaves a +/// docs page relying on that default, across every navigation shape it can be called with +/// (root, node, leaf, and the OpenAPI/no-navigation path). +/// +public class NavigationEnrichmentTests +{ + private const int PenaltyDefault = 50; + + private static DocumentationDocument NewDoc() => new() + { + Url = "/docs/reference/some-page", + Title = "Some Page", + SearchTitle = "Some Page" + }; + + [Fact] + public void LandingPageRoot_GetsLowDepthAndNonDefaultToc() + { + var root = new FakeRootNavigationItem { NavigationTitle = "Reference" }; + var doc = NewDoc(); + + ElasticsearchMarkdownExporter.CommonEnrichments(doc, root); + + doc.NavigationDepth.Should().Be(0); + doc.NavigationDepth.Should().NotBe(PenaltyDefault); + doc.NavigationTableOfContents.Should().NotBe(PenaltyDefault); + doc.NavigationSection.Should().Be("reference"); + } + + [Fact] + public void ReleaseNotesRoot_IsDampenedRelativeToOtherRootsAtTheSameDepth() + { + var releaseNotesRoot = new FakeRootNavigationItem { NavigationTitle = "Release Notes", Parent = new FakeNodeNavigationItem { NavigationTitle = "Parent" } }; + var releaseNotesDoc = NewDoc(); + ElasticsearchMarkdownExporter.CommonEnrichments(releaseNotesDoc, releaseNotesRoot); + + var otherRoot = new FakeRootNavigationItem { NavigationTitle = "Reference", Parent = new FakeNodeNavigationItem { NavigationTitle = "Parent" } }; + var otherDoc = NewDoc(); + ElasticsearchMarkdownExporter.CommonEnrichments(otherDoc, otherRoot); + + releaseNotesDoc.NavigationSection.Should().Be("release notes"); + releaseNotesDoc.NavigationTableOfContents.Should().NotBe(PenaltyDefault); + // navigation_table_of_contents has NEGATIVE score impact, so a HIGHER value means MORE + // penalty. Release notes get effectively flattened by product, so its ranking is dampened + // via a steeper multiplier (4x vs 2x depth, both capped at 48) than a regular root. + releaseNotesDoc.NavigationTableOfContents.Should().BeGreaterThan(otherDoc.NavigationTableOfContents); + } + + [Fact] + public void SectionNode_GetsFixedNonDefaultToc() + { + var root = new FakeRootNavigationItem { NavigationTitle = "Reference" }; + var node = new FakeNodeNavigationItem { NavigationTitle = "Elasticsearch", Parent = root }; + var doc = NewDoc(); + + ElasticsearchMarkdownExporter.CommonEnrichments(doc, node); + + doc.NavigationDepth.Should().BeGreaterThan(0); + doc.NavigationDepth.Should().NotBe(PenaltyDefault); + doc.NavigationTableOfContents.Should().Be(50); + } + + [Fact] + public void DeepLeafPage_GetsStructuralDepthAndNonDefaultToc() + { + var root = new FakeRootNavigationItem { NavigationTitle = "Reference" }; + var section = new FakeNodeNavigationItem { NavigationTitle = "Elasticsearch", Parent = root }; + var leaf = new FakeLeafNavigationItem { NavigationTitle = "Settings", Parent = section }; + var doc = NewDoc(); + + ElasticsearchMarkdownExporter.CommonEnrichments(doc, leaf); + + doc.NavigationDepth.Should().Be(2); + doc.NavigationDepth.Should().NotBe(PenaltyDefault); + doc.NavigationTableOfContents.Should().Be(100); + doc.NavigationSection.Should().Be("elasticsearch"); + } + + [Fact] + public void NoNavigation_OpenApiPath_FallsBackToExplicitNonPenaltyValues() + { + var doc = NewDoc(); + + ElasticsearchMarkdownExporter.CommonEnrichments(doc, null); + + // explicit fallback (20), not the contract's penalty default (50) + doc.NavigationDepth.Should().Be(20); + doc.NavigationDepth.Should().NotBe(PenaltyDefault); + doc.NavigationTableOfContents.Should().Be(100); + doc.NavigationTableOfContents.Should().NotBe(PenaltyDefault); + } + + [Fact] + public void ApiContentType_OverridesNavigationSectionRegardlessOfNavigation() + { + var root = new FakeRootNavigationItem { NavigationTitle = "Reference" }; + var doc = NewDoc(); + doc.ContentType = "api"; + + ElasticsearchMarkdownExporter.CommonEnrichments(doc, root); + + doc.NavigationSection.Should().Be("api"); + } + + private sealed class FakeNavigationModel : INavigationModel; + + private abstract class FakeNavigationItemBase : INavigationItem + { + // GetParents() dedupes by Url, so each fake needs a distinct one — not a fixed constant. + public required string NavigationTitle { get; init; } + public string Url { get; init; } = Guid.NewGuid().ToString(); + public IRootNavigationItem NavigationRoot => null!; + public INodeNavigationItem? Parent { get; set; } + public bool Hidden { get; init; } + public int NavigationIndex { get; set; } + } + + private sealed class FakeLeafNavigationItem : FakeNavigationItemBase, ILeafNavigationItem + { + public INavigationModel Model { get; } = new FakeNavigationModel(); + } + + private class FakeNodeNavigationItem : FakeNavigationItemBase, INodeNavigationItem + { + public string Id => NavigationTitle; + public ILeafNavigationItem Index => null!; + public IReadOnlyCollection NavigationItems { get; init; } = []; + } + + private sealed class FakeRootNavigationItem : FakeNodeNavigationItem, IRootNavigationItem + { + public bool IsUsingNavigationDropdown => false; + public Uri Identifier => new("https://elastic.co/docs"); + public void SetNavigationItems(IReadOnlyCollection navigationItems) { } + } +}