diff --git a/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift b/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift index 63a427ff8..633c604f9 100644 --- a/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift +++ b/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift @@ -804,7 +804,8 @@ extension NavigatorIndex { platformMask: platformID, availabilityID: UInt64(availabilityID), icon: renderNode.icon, - isExternal: external + isExternal: external, + isBeta: renderNode.metadata.isBeta ) navigationItem.path = identifierPath diff --git a/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift b/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift index 32192682f..b95d27962 100644 --- a/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift +++ b/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift @@ -49,6 +49,11 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString var icon: RenderReferenceIdentifier? = nil + /// A value that indicates whether this item is built for a beta platform. + /// + /// This value is `false` if the referenced item is not a symbol. + var isBeta: Bool = false + /// Whether the item has originated from an external reference. /// /// Used for determining whether stray navigation items should remain part of the final navigator. @@ -66,7 +71,7 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString - path: The path to load the content. - icon: A reference to a custom image for this navigator item. */ - init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, path: String, icon: RenderReferenceIdentifier? = nil, isExternal: Bool = false) { + init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, path: String, icon: RenderReferenceIdentifier? = nil, isExternal: Bool = false, isBeta: Bool = false) { self.pageType = pageType self.languageID = languageID self.title = title @@ -75,6 +80,7 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString self.path = path self.icon = icon self.isExternal = isExternal + self.isBeta = isBeta } /** @@ -87,8 +93,10 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString - platformMask: The mask indicating for which platform the page is available. - availabilityID: The identifier of the availability information of the page. - icon: A reference to a custom image for this navigator item. + - isExternal: A flag indicating whether the navigator item belongs to an external documentation archive. + - isBeta: A flag indicating whether the navigator item is in beta. */ - public init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, icon: RenderReferenceIdentifier? = nil, isExternal: Bool = false) { + public init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, icon: RenderReferenceIdentifier? = nil, isExternal: Bool = false, isBeta: Bool = false) { self.pageType = pageType self.languageID = languageID self.title = title @@ -96,6 +104,7 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString self.availabilityID = availabilityID self.icon = icon self.isExternal = isExternal + self.isBeta = isBeta } // MARK: - Serialization and Deserialization @@ -137,8 +146,27 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString let pathData = data[cursor...stride + // To ensure backwards compatibility, handle both when `isBeta` has been encoded and when it hasn't + if cursor < data.count { + let betaValue: UInt8 = unpackedValueFromData(data[cursor.. Bool { + return lhs.pageType == rhs.pageType && + lhs.languageID == rhs.languageID && + lhs.title == rhs.title && + lhs.platformMask == rhs.platformMask && + lhs.availabilityID == rhs.availabilityID && + lhs.isBeta == rhs.isBeta && + lhs.isExternal == rhs.isExternal + } + + // MARK: - Hashable + // Needed because a Swift class's synthesized Hashable conformance doesn't take into account properties which have default values as part of the designated initializer. + + public func hash(into hasher: inout Hasher) { + hasher.combine(pageType) + hasher.combine(languageID) + hasher.combine(title) + hasher.combine(platformMask) + hasher.combine(availabilityID) + hasher.combine(isBeta) + hasher.combine(isExternal) + } + // MARK: - Description public var description: String { @@ -167,7 +224,9 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString languageID: \(languageID), title: \(title), platformMask: \(platformMask), - availabilityID: \(availabilityID) + availabilityID: \(availabilityID), + isBeta: \(isBeta), + isExternal: \(isExternal) } """ } diff --git a/Sources/SwiftDocC/Indexing/Navigator/RenderNode+NavigatorIndex.swift b/Sources/SwiftDocC/Indexing/Navigator/RenderNode+NavigatorIndex.swift index 88ec8bf36..7200c5fb0 100644 --- a/Sources/SwiftDocC/Indexing/Navigator/RenderNode+NavigatorIndex.swift +++ b/Sources/SwiftDocC/Indexing/Navigator/RenderNode+NavigatorIndex.swift @@ -40,6 +40,7 @@ protocol NavigatorIndexableRenderMetadataRepresentation { var roleHeading: String? { get } var symbolKind: String? { get } var platforms: [AvailabilityRenderItem]? { get } + var isBeta: Bool { get } } extension NavigatorIndexableRenderNodeRepresentation { @@ -122,6 +123,16 @@ struct RenderNodeVariantView: NavigatorIndexableRenderNodeRepresentation { } } +extension NavigatorIndexableRenderMetadataRepresentation { + var isBeta: Bool { + guard let platforms, !platforms.isEmpty else { + return false + } + + return platforms.allSatisfy { $0.isBeta == true } + } +} + private let typesThatShouldNotUseNavigatorTitle: Set = [ .framework, .class, .structure, .enumeration, .protocol, .typeAlias, .associatedType, .extension ] diff --git a/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift b/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift index 5140e35fd..250d745b0 100644 --- a/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift +++ b/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift @@ -92,6 +92,7 @@ public struct RenderIndex: Codable, Equatable { pageType: .framework, isDeprecated: false, isExternal: false, + isBeta: false, children: nodes, icon: nil ) @@ -245,6 +246,7 @@ extension RenderIndex { pageType: NavigatorIndex.PageType?, isDeprecated: Bool, isExternal: Bool, + isBeta: Bool, children: [Node], icon: RenderReferenceIdentifier? ) { @@ -253,10 +255,8 @@ extension RenderIndex { self.isDeprecated = isDeprecated self.isExternal = isExternal - - // Currently Swift-DocC doesn't support marking a node as beta in the navigation index - // so we default to `false` here. - self.isBeta = false + self.isBeta = isBeta + self.icon = icon guard let pageType else { @@ -327,6 +327,7 @@ extension RenderIndex.Node { pageType: NavigatorIndex.PageType(rawValue: node.item.pageType), isDeprecated: isDeprecated, isExternal: node.item.isExternal, + isBeta: node.item.isBeta, children: node.children.map { RenderIndex.Node.fromNavigatorTreeNode($0, in: navigatorIndex, with: builder) }, diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift index c23a850f7..005131c92 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift @@ -82,6 +82,13 @@ package struct ExternalRenderNode { RenderNode.Variant(traits: [.interfaceLanguage($0.id)], paths: [externalEntity.topicRenderReference.url]) } } + + /// A value that indicates whether this symbol is built for a beta platform + /// + /// This value is `false` if the referenced page is not a symbol. + var isBeta: Bool { + externalEntity.topicRenderReference.isBeta + } } /// A language specific representation of an external render node value for building a navigator index. @@ -117,7 +124,8 @@ struct NavigatorExternalRenderNode: NavigatorIndexableRenderNodeRepresentation { externalID: renderNode.externalIdentifier.identifier, role: renderNode.role, symbolKind: renderNode.symbolKind?.identifier, - images: renderNode.images + images: renderNode.images, + isBeta: renderNode.isBeta ) } } @@ -130,6 +138,7 @@ struct ExternalRenderNodeMetadataRepresentation: NavigatorIndexableRenderMetadat var role: String? var symbolKind: String? var images: [TopicImage] + var isBeta: Bool // Values that we have insufficient information to derive. // These are needed to conform to the navigator indexable metadata protocol. @@ -145,4 +154,4 @@ struct ExternalRenderNodeMetadataRepresentation: NavigatorIndexableRenderMetadat var fragments: [DeclarationRenderSection.Token]? = nil var roleHeading: String? = nil var platforms: [AvailabilityRenderItem]? = nil -} \ No newline at end of file +} diff --git a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift index 43243e342..1c7649642 100644 --- a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift +++ b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift @@ -21,7 +21,8 @@ class ExternalRenderNodeTests: XCTestCase { referencePath: "/path/to/external/swiftArticle", title: "SwiftArticle", kind: .article, - language: .swift + language: .swift, + platforms: [.init(name: "iOS", introduced: nil, isBeta: false)] ) ) externalResolver.entitiesToReturn["/path/to/external/objCArticle"] = .success( @@ -29,7 +30,8 @@ class ExternalRenderNodeTests: XCTestCase { referencePath: "/path/to/external/objCArticle", title: "ObjCArticle", kind: .article, - language: .objectiveC + language: .objectiveC, + platforms: [.init(name: "macOS", introduced: nil, isBeta: true)] ) ) externalResolver.entitiesToReturn["/path/to/external/swiftSymbol"] = .success( @@ -37,7 +39,8 @@ class ExternalRenderNodeTests: XCTestCase { referencePath: "/path/to/external/swiftSymbol", title: "SwiftSymbol", kind: .class, - language: .swift + language: .swift, + platforms: [.init(name: "iOS", introduced: nil, isBeta: true)] ) ) externalResolver.entitiesToReturn["/path/to/external/objCSymbol"] = .success( @@ -45,7 +48,8 @@ class ExternalRenderNodeTests: XCTestCase { referencePath: "/path/to/external/objCSymbol", title: "ObjCSymbol", kind: .function, - language: .objectiveC + language: .objectiveC, + platforms: [.init(name: "macOS", introduced: nil, isBeta: false)] ) ) return externalResolver @@ -89,24 +93,28 @@ class ExternalRenderNodeTests: XCTestCase { XCTAssertEqual(externalRenderNodes[0].symbolKind, nil) XCTAssertEqual(externalRenderNodes[0].role, "article") XCTAssertEqual(externalRenderNodes[0].externalIdentifier.identifier, "doc://com.test.external/path/to/external/objCArticle") - + XCTAssertTrue(externalRenderNodes[0].isBeta) + XCTAssertEqual(externalRenderNodes[1].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/objCSymbol") XCTAssertEqual(externalRenderNodes[1].kind, .symbol) XCTAssertEqual(externalRenderNodes[1].symbolKind, nil) XCTAssertEqual(externalRenderNodes[1].role, "symbol") XCTAssertEqual(externalRenderNodes[1].externalIdentifier.identifier, "doc://com.test.external/path/to/external/objCSymbol") + XCTAssertFalse(externalRenderNodes[1].isBeta) XCTAssertEqual(externalRenderNodes[2].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/swiftArticle") XCTAssertEqual(externalRenderNodes[2].kind, .article) XCTAssertEqual(externalRenderNodes[2].symbolKind, nil) XCTAssertEqual(externalRenderNodes[2].role, "article") XCTAssertEqual(externalRenderNodes[2].externalIdentifier.identifier, "doc://com.test.external/path/to/external/swiftArticle") + XCTAssertFalse(externalRenderNodes[2].isBeta) XCTAssertEqual(externalRenderNodes[3].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/swiftSymbol") XCTAssertEqual(externalRenderNodes[3].kind, .symbol) XCTAssertEqual(externalRenderNodes[3].symbolKind, nil) XCTAssertEqual(externalRenderNodes[3].role, "symbol") XCTAssertEqual(externalRenderNodes[3].externalIdentifier.identifier, "doc://com.test.external/path/to/external/swiftSymbol") + XCTAssertTrue(externalRenderNodes[3].isBeta) } func testExternalRenderNodeVariantRepresentation() throws { @@ -146,14 +154,64 @@ class ExternalRenderNodeTests: XCTestCase { ) XCTAssertEqual(swiftNavigatorExternalRenderNode.metadata.title, swiftTitle) XCTAssertEqual(swiftNavigatorExternalRenderNode.metadata.navigatorTitle, navigatorTitle) - + XCTAssertFalse(swiftNavigatorExternalRenderNode.metadata.isBeta) + let objcNavigatorExternalRenderNode = try XCTUnwrap( NavigatorExternalRenderNode(renderNode: externalRenderNode, trait: .interfaceLanguage("objc")) ) XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.title, occTitle) XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.navigatorTitle, occNavigatorTitle) + XCTAssertFalse(objcNavigatorExternalRenderNode.metadata.isBeta) } + func testExternalRenderNodeVariantRepresentationWhenIsBeta() throws { + let renderReferenceIdentifier = RenderReferenceIdentifier(forExternalLink: "doc://com.test.external/path/to/external/symbol") + + // Variants for the title + let swiftTitle = "Swift Symbol" + let occTitle = "Occ Symbol" + + // Variants for the navigator title + let navigatorTitle: [DeclarationRenderSection.Token] = [.init(text: "symbol", kind: .identifier)] + let occNavigatorTitle: [DeclarationRenderSection.Token] = [.init(text: "occ_symbol", kind: .identifier)] + + // Variants for the fragments + let fragments: [DeclarationRenderSection.Token] = [.init(text: "func", kind: .keyword), .init(text: "symbol", kind: .identifier)] + let occFragments: [DeclarationRenderSection.Token] = [.init(text: "func", kind: .keyword), .init(text: "occ_symbol", kind: .identifier)] + + let externalEntity = LinkResolver.ExternalEntity( + topicRenderReference: .init( + identifier: renderReferenceIdentifier, + titleVariants: .init(defaultValue: swiftTitle, objectiveCValue: occTitle), + abstractVariants: .init(defaultValue: []), + url: "/example/path/to/external/symbol", + kind: .symbol, + fragmentsVariants: .init(defaultValue: fragments, objectiveCValue: occFragments), + navigatorTitleVariants: .init(defaultValue: navigatorTitle, objectiveCValue: occNavigatorTitle), + isBeta: true + ), + renderReferenceDependencies: .init(), + sourceLanguages: [SourceLanguage(name: "swift"), SourceLanguage(name: "objc")]) + let externalRenderNode = ExternalRenderNode( + externalEntity: externalEntity, + bundleIdentifier: "com.test.external" + ) + + let swiftNavigatorExternalRenderNode = try XCTUnwrap( + NavigatorExternalRenderNode(renderNode: externalRenderNode) + ) + XCTAssertEqual(swiftNavigatorExternalRenderNode.metadata.title, swiftTitle) + XCTAssertEqual(swiftNavigatorExternalRenderNode.metadata.navigatorTitle, navigatorTitle) + XCTAssertTrue(swiftNavigatorExternalRenderNode.metadata.isBeta) + + let objcNavigatorExternalRenderNode = try XCTUnwrap( + NavigatorExternalRenderNode(renderNode: externalRenderNode, trait: .interfaceLanguage("objc")) + ) + XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.title, occTitle) + XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.navigatorTitle, occNavigatorTitle) + XCTAssertTrue(objcNavigatorExternalRenderNode.metadata.isBeta) + } + func testNavigatorWithExternalNodes() throws { let externalResolver = generateExternalResover() let (_, bundle, context) = try testBundleAndContext( @@ -208,6 +266,10 @@ class ExternalRenderNodeTests: XCTestCase { XCTAssertEqual(occExternalNodes.map(\.title), ["ObjCArticle", "ObjCSymbol"]) XCTAssert(swiftExternalNodes.allSatisfy(\.isExternal)) XCTAssert(occExternalNodes.allSatisfy(\.isExternal)) + XCTAssert(swiftExternalNodes.first { $0.title == "SwiftArticle" }?.isBeta == false) + XCTAssert(swiftExternalNodes.first { $0.title == "SwiftSymbol" }?.isBeta == true) + XCTAssert(occExternalNodes.first { $0.title == "ObjCArticle" }?.isBeta == true) + XCTAssert(occExternalNodes.first { $0.title == "ObjCSymbol" }?.isBeta == false) } func testNavigatorWithExternalNodesOnlyAddsCuratedNodesToNavigator() throws { diff --git a/Tests/SwiftDocCTests/Indexing/NavigatorIndexTests.swift b/Tests/SwiftDocCTests/Indexing/NavigatorIndexTests.swift index c81a56997..0f3fcd464 100644 --- a/Tests/SwiftDocCTests/Indexing/NavigatorIndexTests.swift +++ b/Tests/SwiftDocCTests/Indexing/NavigatorIndexTests.swift @@ -138,6 +138,66 @@ Root XCTAssertEqual(item, fromData) } + func testNavigatorEquality() { + // Test for equal + var item1 = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1024, isExternal: true, isBeta: true) + var item2 = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1024, isExternal: true, isBeta: true) + XCTAssertEqual(item1, item2) + + // Tests for not equal + item1 = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1024, isBeta: true) + item2 = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1024, isBeta: false) + XCTAssertNotEqual(item1, item2) + + item1 = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1024, isExternal: true) + item2 = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1024, isExternal: false) + XCTAssertNotEqual(item1, item2) + + item1 = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1024) + item2 = NavigatorItem(pageType: 2, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1024) + XCTAssertNotEqual(item1, item2) + + item1 = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1024) + item2 = NavigatorItem(pageType: 1, languageID: 5, title: "My Title", platformMask: 256, availabilityID: 1024) + XCTAssertNotEqual(item1, item2) + + item1 = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1024) + item2 = NavigatorItem(pageType: 1, languageID: 4, title: "My Other Title", platformMask: 256, availabilityID: 1024) + XCTAssertNotEqual(item1, item2) + + item1 = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1024) + item2 = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 257, availabilityID: 1024) + XCTAssertNotEqual(item1, item2) + + item1 = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1024) + item2 = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1025) + XCTAssertNotEqual(item1, item2) + } + + func testNavigatorItemRawDumpWithExtraProperties() { + let item = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1024, isExternal: true, isBeta: true) + let data = item.rawValue + let fromData = NavigatorItem(rawValue: data) + XCTAssertEqual(item, fromData) + } + + func testNavigatorItemRawDumpBackwardCompatibility() { + let item = NavigatorItem(pageType: 1, languageID: 4, title: "My Title", platformMask: 256, availabilityID: 1024) + var data = Data() + data.append(packedDataFromValue(item.pageType)) + data.append(packedDataFromValue(item.languageID)) + data.append(packedDataFromValue(item.platformMask)) + data.append(packedDataFromValue(item.availabilityID)) + data.append(packedDataFromValue(UInt64(item.title.utf8.count))) + data.append(packedDataFromValue(UInt64(item.path.utf8.count))) + data.append(Data(item.title.utf8)) + data.append(Data(item.path.utf8)) + // Note: NOT adding isBeta and isExternal flags to simulate when they were not supported + + let fromData = NavigatorItem(rawValue: data) + XCTAssertEqual(item, fromData) + } + func testObjCLanguage() { let root = generateLargeTree() var objcFiltered: Node? @@ -1977,6 +2037,61 @@ Root ) } + func testNavigatorIndexCapturesBetaStatus() throws { + // Set up configuration with beta platforms + let platformMetadata = [ + "macOS": PlatformVersion(VersionTriplet(1, 0, 0), beta: true), + "watchOS": PlatformVersion(VersionTriplet(2, 0, 0), beta: true), + "tvOS": PlatformVersion(VersionTriplet(3, 0, 0), beta: true), + "iOS": PlatformVersion(VersionTriplet(4, 0, 0), beta: true), + "Mac Catalyst": PlatformVersion(VersionTriplet(4, 0, 0), beta: true), + "iPadOS": PlatformVersion(VersionTriplet(4, 0, 0), beta: true), + ] + var configuration = DocumentationContext.Configuration() + configuration.externalMetadata.currentPlatforms = platformMetadata + + let (_, bundle, context) = try testBundleAndContext(named: "AvailabilityBetaBundle", configuration: configuration) + let renderContext = RenderContext(documentationContext: context, bundle: bundle) + let converter = DocumentationContextConverter(bundle: bundle, context: context, renderContext: renderContext) + let targetURL = try createTemporaryDirectory() + let builder = NavigatorIndex.Builder(outputURL: targetURL, bundleIdentifier: bundle.id.rawValue, sortRootChildrenByName: true) + builder.setup() + for identifier in context.knownPages { + let entity = try context.entity(with: identifier) + let renderNode = try XCTUnwrap(converter.renderNode(for: entity)) + try builder.index(renderNode: renderNode) + } + builder.finalize() + let renderIndex = try RenderIndex.fromURL(targetURL.appendingPathComponent("index.json")) + + // Find nodes that should have beta status + let swiftNodes = renderIndex.interfaceLanguages["swift"] ?? [] + let betaNodes = findNodesWithBetaStatus(in: swiftNodes, isBeta: true) + let nonBetaNodes = findNodesWithBetaStatus(in: swiftNodes, isBeta: false) + + // Verify that beta status was captured in the render index + XCTAssertEqual(betaNodes.map(\.title), ["MyClass"]) + XCTAssert(betaNodes.allSatisfy(\.isBeta)) // Sanity check + XCTAssertEqual(nonBetaNodes.map(\.title).sorted(), ["Classes", "MyOtherClass", "MyThirdClass"]) + XCTAssert(nonBetaNodes.allSatisfy { $0.isBeta == false }) // Sanity check + } + + private func findNodesWithBetaStatus(in nodes: [RenderIndex.Node], isBeta: Bool) -> [RenderIndex.Node] { + var betaNodes: [RenderIndex.Node] = [] + + for node in nodes { + if node.isBeta == isBeta { + betaNodes.append(node) + } + + if let children = node.children { + betaNodes.append(contentsOf: findNodesWithBetaStatus(in: children, isBeta: isBeta)) + } + } + + return betaNodes + } + func generatedNavigatorIndex(for testBundleName: String, bundleIdentifier: String) throws -> NavigatorIndex { let (bundle, context) = try testBundleAndContext(named: testBundleName) let renderContext = RenderContext(documentationContext: context, bundle: bundle) diff --git a/Tests/SwiftDocCTests/Indexing/NavigatorIndexableRenderMetadataTests.swift b/Tests/SwiftDocCTests/Indexing/NavigatorIndexableRenderMetadataTests.swift new file mode 100644 index 000000000..3a997e259 --- /dev/null +++ b/Tests/SwiftDocCTests/Indexing/NavigatorIndexableRenderMetadataTests.swift @@ -0,0 +1,134 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2025 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import XCTest +@testable import SwiftDocC + +class NavigatorIndexableRenderMetadataTests: XCTestCase { + + // MARK: - Test Helper Methods + + /// Creates a test platform with the specified beta status + private func createPlatform(name: String, isBeta: Bool) -> AvailabilityRenderItem { + return AvailabilityRenderItem(name: name, introduced: "1.0", isBeta: isBeta) + } + + /// Creates a RenderMetadata instance with the specified platforms + private func createRenderMetadata(platforms: [AvailabilityRenderItem]?) -> RenderMetadata { + var metadata = RenderMetadata() + metadata.platforms = platforms + return metadata + } + + /// Creates a RenderMetadataVariantView with the specified platforms + private func createRenderMetadataVariantView(platforms: [AvailabilityRenderItem]?) -> RenderMetadataVariantView { + let metadata = createRenderMetadata(platforms: platforms) + return RenderMetadataVariantView(wrapped: metadata, traits: []) + } + + // MARK: - RenderMetadataVariantView Tests + + func testRenderMetadataVariantViewIsBeta() { + var metadataView = createRenderMetadataVariantView(platforms: nil) + XCTAssertFalse(metadataView.isBeta, "isBeta should be false when no platforms are defined") + + metadataView = createRenderMetadataVariantView(platforms: []) + XCTAssertFalse(metadataView.isBeta, "isBeta should be false when platforms array is empty") + + metadataView = createRenderMetadataVariantView(platforms: [ + createPlatform(name: "iOS", isBeta: false) + ]) + XCTAssertFalse(metadataView.isBeta, "isBeta should be false when single platform is non-beta") + + metadataView = createRenderMetadataVariantView(platforms: [ + createPlatform(name: "iOS", isBeta: false), + createPlatform(name: "macOS", isBeta: false), + createPlatform(name: "watchOS", isBeta: false) + ]) + XCTAssertFalse(metadataView.isBeta, "isBeta should be false when multiple platforms are non-beta") + + var platform1 = AvailabilityRenderItem(name: "iOS", introduced: "1.0", isBeta: false) + platform1.isBeta = nil + var platform2 = AvailabilityRenderItem(name: "macOS", introduced: "1.0", isBeta: false) + platform2.isBeta = nil + + metadataView = createRenderMetadataVariantView(platforms: [platform1, platform2]) + XCTAssertFalse(metadataView.isBeta, "isBeta should be false when platforms have nil beta status") + + metadataView = createRenderMetadataVariantView(platforms: [ + createPlatform(name: "iOS", isBeta: true), + createPlatform(name: "macOS", isBeta: false), + createPlatform(name: "watchOS", isBeta: true) + ]) + XCTAssertFalse(metadataView.isBeta, "isBeta should be false when some platforms are beta and some are non-beta") + + metadataView = createRenderMetadataVariantView(platforms: [ + createPlatform(name: "iOS", isBeta: true) + ]) + XCTAssertTrue(metadataView.isBeta, "isBeta should be true when single platform is beta") + + metadataView = createRenderMetadataVariantView(platforms: [ + createPlatform(name: "iOS", isBeta: true), + createPlatform(name: "macOS", isBeta: true), + createPlatform(name: "watchOS", isBeta: true) + ]) + XCTAssertTrue(metadataView.isBeta, "isBeta should be true when multiple platforms are beta") + } + + // MARK: - RenderMetadata Tests + + func testRenderMetadataIsBeta() { + var metadata = createRenderMetadata(platforms: nil) + XCTAssertFalse(metadata.isBeta, "isBeta should be false when no platforms are defined") + + metadata = createRenderMetadata(platforms: []) + XCTAssertFalse(metadata.isBeta, "isBeta should be false when platforms array is empty") + + metadata = createRenderMetadata(platforms: [ + createPlatform(name: "macOS", isBeta: false) + ]) + XCTAssertFalse(metadata.isBeta, "isBeta should be false when single platform is non-beta") + + metadata = createRenderMetadata(platforms: [ + createPlatform(name: "iOS", isBeta: false), + createPlatform(name: "macOS", isBeta: false), + createPlatform(name: "tvOS", isBeta: false) + ]) + XCTAssertFalse(metadata.isBeta, "isBeta should be false when all platforms are non-beta") + + var platform1 = AvailabilityRenderItem(name: "iOS", introduced: "1.0", isBeta: false) + platform1.isBeta = nil + var platform2 = AvailabilityRenderItem(name: "macOS", introduced: "1.0", isBeta: false) + platform2.isBeta = nil + + metadata = createRenderMetadata(platforms: [platform1, platform2]) + XCTAssertFalse(metadata.isBeta, "isBeta should be false when platforms have nil beta status") + + metadata = createRenderMetadata(platforms: [ + createPlatform(name: "iOS", isBeta: false), + createPlatform(name: "macOS", isBeta: true), + createPlatform(name: "tvOS", isBeta: false) + ]) + XCTAssertFalse(metadata.isBeta, "isBeta should be false when some platforms are beta and some are non-beta") + + metadata = createRenderMetadata(platforms: [ + createPlatform(name: "macOS", isBeta: true) + ]) + XCTAssertTrue(metadata.isBeta, "isBeta should be true when single platform is beta") + + metadata = createRenderMetadata(platforms: [ + createPlatform(name: "iOS", isBeta: true), + createPlatform(name: "macOS", isBeta: true), + createPlatform(name: "tvOS", isBeta: true) + ]) + XCTAssertTrue(metadata.isBeta, "isBeta should be true when all platforms are beta") + } +} diff --git a/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift b/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift index 129d86633..36c9099d8 100644 --- a/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift +++ b/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift @@ -29,6 +29,7 @@ class TestMultiResultExternalReferenceResolver: ExternalDocumentationSource { var language = SourceLanguage.swift var declarationFragments: SymbolGraph.Symbol.DeclarationFragments? = nil var topicImages: [(TopicImage, alt: String)]? = nil + var platforms: [AvailabilityRenderItem]? = nil } // When more tests use this we may find that there's a better way to describe this (for example by separating @@ -105,6 +106,7 @@ class TestMultiResultExternalReferenceResolver: ExternalDocumentationSource { fragments: entityInfo.declarationFragments?.declarationFragments.map { fragment in return DeclarationRenderSection.Token(fragment: fragment, identifier: nil) }, + isBeta: entityInfo.platforms?.allSatisfy({$0.isBeta == true}) ?? false, images: entityInfo.topicImages?.map(\.0) ?? [] ), renderReferenceDependencies: dependencies,