From 951cab6905b04e4695ec8f6b0e3ff53551845a74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 20 Nov 2025 18:23:32 +0000 Subject: [PATCH 1/9] Add data loading test helpers for Swift Testing --- .../Testing+LoadingTestData.swift | 124 ++++++++++++ .../Testing+ParseDirective.swift | 183 ++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 Tests/SwiftDocCTests/Testing+LoadingTestData.swift create mode 100644 Tests/SwiftDocCTests/Testing+ParseDirective.swift diff --git a/Tests/SwiftDocCTests/Testing+LoadingTestData.swift b/Tests/SwiftDocCTests/Testing+LoadingTestData.swift new file mode 100644 index 0000000000..839f543916 --- /dev/null +++ b/Tests/SwiftDocCTests/Testing+LoadingTestData.swift @@ -0,0 +1,124 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021-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 Testing +@testable import SwiftDocC +import Markdown +import SwiftDocCTestUtilities + +// MARK: Using an in-memory file system + +/// Loads a documentation catalog from an in-memory test file system. +/// +/// - Parameters: +/// - catalog: The directory structure of the documentation catalog +/// - otherFileSystemDirectories: Any other directories in the test file system. +/// - diagnosticFilterLevel: The minimum severity for diagnostics to emit. +/// - logOutput: An output stream to capture log output from creating the context. +/// - configuration: Configuration for the created context. +/// - Returns: The loaded documentation bundle and context for the given catalog input. +func load( + catalog: Folder, + otherFileSystemDirectories: [Folder] = [], + diagnosticFilterLevel: DiagnosticSeverity = .warning, + logOutput: some TextOutputStream = LogHandle.none, + configuration: DocumentationContext.Configuration = .init() +) async throws -> DocumentationContext { + let fileSystem = try TestFileSystem(folders: [catalog] + otherFileSystemDirectories) + let catalogURL = URL(fileURLWithPath: "/\(catalog.name)") + + let diagnosticEngine = DiagnosticEngine(filterLevel: diagnosticFilterLevel) + diagnosticEngine.add(DiagnosticConsoleWriter(logOutput, formattingOptions: [], baseURL: catalogURL, highlight: true, dataProvider: fileSystem)) + + let (inputs, dataProvider) = try DocumentationContext.InputsProvider(fileManager: fileSystem) + .inputsAndDataProvider(startingPoint: catalogURL, options: .init()) + + defer { + diagnosticEngine.flush() // Write to the logOutput + } + return try await DocumentationContext(bundle: inputs, dataProvider: dataProvider, diagnosticEngine: diagnosticEngine, configuration: configuration) +} + +func makeEmptyContext() async throws -> DocumentationContext { + let bundle = DocumentationBundle( + info: DocumentationBundle.Info( + displayName: "Test", + id: "com.example.test" + ), + baseURL: URL(string: "https://example.com/example")!, + symbolGraphURLs: [], + markupURLs: [], + miscResourceURLs: [] + ) + + return try await DocumentationContext(bundle: bundle, dataProvider: TestFileSystem(folders: [])) +} + +// MARK: Using the real file system + +/// Loads a documentation bundle from the given source URL and creates a documentation context. +func loadFromDisk( + catalogURL: URL, + externalResolvers: [DocumentationBundle.Identifier: any ExternalDocumentationSource] = [:], + externalSymbolResolver: (any GlobalExternalSymbolResolver)? = nil, + fallbackResolver: (any ConvertServiceFallbackResolver)? = nil, + diagnosticEngine: DiagnosticEngine = .init(filterLevel: .hint), + configuration: DocumentationContext.Configuration = .init() +) async throws -> DocumentationContext { + var configuration = configuration + configuration.externalDocumentationConfiguration.sources = externalResolvers + configuration.externalDocumentationConfiguration.globalSymbolResolver = externalSymbolResolver + configuration.convertServiceConfiguration.fallbackResolver = fallbackResolver + configuration.externalMetadata.diagnosticLevel = diagnosticEngine.filterLevel + + let (bundle, dataProvider) = try DocumentationContext.InputsProvider() + .inputsAndDataProvider(startingPoint: catalogURL, options: .init()) + + return try await DocumentationContext(bundle: bundle, dataProvider: dataProvider, diagnosticEngine: diagnosticEngine, configuration: configuration) +} + +func loadFromDisk( + catalogName: String, + externalResolvers: [DocumentationBundle.Identifier: any ExternalDocumentationSource] = [:], + fallbackResolver: (any ConvertServiceFallbackResolver)? = nil, + configuration: DocumentationContext.Configuration = .init(), + sourceLocation: Testing.SourceLocation = #_sourceLocation +) async throws -> DocumentationContext { + try await loadFromDisk( + catalogURL: try #require(Bundle.module.url(forResource: catalogName, withExtension: "docc", subdirectory: "Test Bundles"), sourceLocation: sourceLocation), + externalResolvers: externalResolvers, + fallbackResolver: fallbackResolver, + configuration: configuration + ) +} + +// MARK: Render node loading helpers + +func renderNode(atPath path: String, fromOnDiskTestCatalogNamed catalogName: String, sourceLocation: Testing.SourceLocation = #_sourceLocation) async throws -> RenderNode { + let context = try await loadFromDisk(catalogName: catalogName) + let node = try context.entity(with: ResolvedTopicReference(bundleID: context.inputs.id, path: path, sourceLanguage: .swift)) + var translator = RenderNodeTranslator(context: context, identifier: node.reference) + return try #require(translator.visit(node.semantic) as? RenderNode, sourceLocation: sourceLocation) +} + +extension RenderNode { + func applying(variant: String) throws -> RenderNode { + let variantData = try RenderNodeVariantOverridesApplier().applyVariantOverrides( + in: RenderJSONEncoder.makeEncoder().encode(self), + for: [.interfaceLanguage(variant)] + ) + + return try RenderJSONDecoder.makeDecoder().decode( + RenderNode.self, + from: variantData + ) + } +} diff --git a/Tests/SwiftDocCTests/Testing+ParseDirective.swift b/Tests/SwiftDocCTests/Testing+ParseDirective.swift new file mode 100644 index 0000000000..7672b6249a --- /dev/null +++ b/Tests/SwiftDocCTests/Testing+ParseDirective.swift @@ -0,0 +1,183 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021-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 Testing +@testable import SwiftDocC +import Markdown +import SwiftDocCTestUtilities + +// MARK: Using an in-memory file system + +func parseDirective( + _ directive: Directive.Type, + content: () -> String, + sourceLocation: Testing.SourceLocation = #_sourceLocation +) async throws -> (problemIdentifiers: [String], directive: Directive?) { + let source = URL(fileURLWithPath: "/path/to/test-source-\(ProcessInfo.processInfo.globallyUniqueString)") + let document = Document(parsing: content(), source: source, options: .parseBlockDirectives) + + let blockDirectiveContainer = try #require(document.child(at: 0) as? BlockDirective, sourceLocation: sourceLocation) + + var problems = [Problem]() + let inputs = try await makeEmptyContext().inputs + let directive = directive.init(from: blockDirectiveContainer, source: source, for: inputs, problems: &problems) + + let problemIDs = problems.map { problem -> String in + #expect(problem.diagnostic.source != nil, "Problem \(problem.diagnostic.identifier) is missing a source URL.", sourceLocation: sourceLocation) + let line = problem.diagnostic.range?.lowerBound.line.description ?? "unknown-line" + + return "\(line): \(problem.diagnostic.severity) – \(problem.diagnostic.identifier)" + }.sorted() + + return (problemIDs, directive) +} + +func parseDirective( + _ directive: Directive.Type, + catalog: Folder, + content: () -> String, + sourceLocation: Testing.SourceLocation = #_sourceLocation +) async throws -> ( + renderBlockContent: [RenderBlockContent], + problemIdentifiers: [String], + directive: Directive?, + collectedReferences: [String : any RenderReference] +) { + let context = try await load(catalog: catalog) + return try parseDirective(directive, context: context, content: content, sourceLocation: sourceLocation) +} + +func parseDirective( + _ directive: Directive.Type, + withAvailableAssetNames assetNames: [String], + content: () -> String, + sourceLocation: Testing.SourceLocation = #_sourceLocation +) async throws -> (renderBlockContent: [RenderBlockContent], problemIdentifiers: [String], directive: Directive?) { + let context = try await load(catalog: Folder(name: "Something.docc", content: assetNames.map { + DataFile(name: $0, data: Data()) + })) + + let (renderedContent, problems, directive, _) = try parseDirective(directive, context: context, content: content, sourceLocation: sourceLocation) + return (renderedContent, problems, directive) +} + +// MARK: Using the real file system + +func parseDirective( + _ directive: Directive.Type, + loadingOnDiskCatalogNamed catalogName: String? = nil, + content: () -> String, + sourceLocation: Testing.SourceLocation = #_sourceLocation +) async throws -> ( + renderBlockContent: [RenderBlockContent], + problemIdentifiers: [String], + directive: Directive? +) { + let (renderedContent, problems, directive, _) = try await parseDirective(directive, loadingOnDiskCatalogNamed: catalogName, content: content, sourceLocation: sourceLocation) + return (renderedContent, problems, directive) +} + +func parseDirective( + _ directive: Directive.Type, + loadingOnDiskCatalogNamed catalogName: String? = nil, + content: () -> String, + sourceLocation: Testing.SourceLocation = #_sourceLocation +) async throws -> ( + renderBlockContent: [RenderBlockContent], + problemIdentifiers: [String], + directive: Directive?, + collectedReferences: [String : any RenderReference] +) { + let context: DocumentationContext = if let catalogName { + try await loadFromDisk(catalogName: catalogName) + } else { + try await makeEmptyContext() + } + return try parseDirective(directive, context: context, content: content, sourceLocation: sourceLocation) +} + +// MARK: - Private implementation + +private func parseDirective( + _ directive: Directive.Type, + context: DocumentationContext, + content: () -> String, + sourceLocation: Testing.SourceLocation = #_sourceLocation +) throws -> ( + renderBlockContent: [RenderBlockContent], + problemIdentifiers: [String], + directive: Directive?, + collectedReferences: [String : any RenderReference] +) { + context.diagnosticEngine.clearDiagnostics() + + let source = URL(fileURLWithPath: "/path/to/test-source-\(ProcessInfo.processInfo.globallyUniqueString)") + let document = Document(parsing: content(), source: source, options: [.parseBlockDirectives, .parseSymbolLinks]) + + let blockDirectiveContainer = try #require(document.child(at: 0) as? BlockDirective, sourceLocation: sourceLocation) + + var analyzer = SemanticAnalyzer(source: source, bundle: context.inputs) + let result = analyzer.visit(blockDirectiveContainer) + context.diagnosticEngine.emit(analyzer.problems) + + var referenceResolver = MarkupReferenceResolver(context: context, rootReference: context.inputs.rootReference) + + _ = referenceResolver.visit(blockDirectiveContainer) + context.diagnosticEngine.emit(referenceResolver.problems) + + func problemIDs() throws -> [String] { + try context.problems.map { problem -> (line: Int, severity: String, id: String) in + #expect(problem.diagnostic.source != nil, "Problem \(problem.diagnostic.identifier) is missing a source URL.", sourceLocation: sourceLocation) + let line = try #require(problem.diagnostic.range, sourceLocation: sourceLocation).lowerBound.line + return (line, problem.diagnostic.severity.description, problem.diagnostic.identifier) + } + .sorted { lhs, rhs in + let (lhsLine, _, lhsID) = lhs + let (rhsLine, _, rhsID) = rhs + + if lhsLine != rhsLine { + return lhsLine < rhsLine + } else { + return lhsID < rhsID + } + } + .map { (line, severity, id) in + return "\(line): \(severity) – \(id)" + } + } + + guard let directive = result as? Directive else { + return ([], try problemIDs(), nil, [:]) + } + + var contentCompiler = RenderContentCompiler( + context: context, + identifier: ResolvedTopicReference( + bundleID: context.inputs.id, + path: "/test-path-123", + sourceLanguage: .swift + ) + ) + + let renderedContent = try #require(directive.render(with: &contentCompiler) as? [RenderBlockContent], sourceLocation: sourceLocation) + + let collectedReferences = contentCompiler.videoReferences + .mapValues { $0 as (any RenderReference) } + .merging( + contentCompiler.imageReferences, + uniquingKeysWith: { videoReference, _ in + Issue.record("Non-unique references.", sourceLocation: sourceLocation) + return videoReference + } + ) + + return (renderedContent, try problemIDs(), directive, collectedReferences) +} From 9cb71ea19feead53456ef403cc2879ed0bfe2c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 20 Nov 2025 18:23:57 +0000 Subject: [PATCH 2/9] Update existing test helpers to call into the Swift Testing helpers where possible --- .../XCTestCase+LoadingTestData.swift | 82 +++++++------------ 1 file changed, 28 insertions(+), 54 deletions(-) diff --git a/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift b/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift index 4103f4f92b..4540d73984 100644 --- a/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift +++ b/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift @@ -25,17 +25,15 @@ extension XCTestCase { diagnosticEngine: DiagnosticEngine = .init(filterLevel: .hint), configuration: DocumentationContext.Configuration = .init() ) async throws -> (URL, DocumentationBundle, DocumentationContext) { - var configuration = configuration - configuration.externalDocumentationConfiguration.sources = externalResolvers - configuration.externalDocumentationConfiguration.globalSymbolResolver = externalSymbolResolver - configuration.convertServiceConfiguration.fallbackResolver = fallbackResolver - configuration.externalMetadata.diagnosticLevel = diagnosticEngine.filterLevel - - let (bundle, dataProvider) = try DocumentationContext.InputsProvider() - .inputsAndDataProvider(startingPoint: catalogURL, options: .init()) - - let context = try await DocumentationContext(bundle: bundle, dataProvider: dataProvider, diagnosticEngine: diagnosticEngine, configuration: configuration) - return (catalogURL, bundle, context) + let context = try await loadFromDisk( + catalogURL: catalogURL, + externalResolvers: externalResolvers, + externalSymbolResolver: externalSymbolResolver, + fallbackResolver: fallbackResolver, + diagnosticEngine: diagnosticEngine, + configuration: configuration + ) + return (catalogURL, context.inputs, context) } /// Loads a documentation catalog from an in-memory test file system. @@ -54,20 +52,14 @@ extension XCTestCase { logOutput: some TextOutputStream = LogHandle.none, configuration: DocumentationContext.Configuration = .init() ) async throws -> (DocumentationBundle, DocumentationContext) { - let fileSystem = try TestFileSystem(folders: [catalog] + otherFileSystemDirectories) - let catalogURL = URL(fileURLWithPath: "/\(catalog.name)") - - let diagnosticEngine = DiagnosticEngine(filterLevel: diagnosticFilterLevel) - diagnosticEngine.add(DiagnosticConsoleWriter(logOutput, formattingOptions: [], baseURL: catalogURL, highlight: true, dataProvider: fileSystem)) - - let (bundle, dataProvider) = try DocumentationContext.InputsProvider(fileManager: fileSystem) - .inputsAndDataProvider(startingPoint: catalogURL, options: .init()) - - let context = try await DocumentationContext(bundle: bundle, dataProvider: dataProvider, diagnosticEngine: diagnosticEngine, configuration: configuration) - - diagnosticEngine.flush() // Write to the logOutput - - return (bundle, context) + let context = try await SwiftDocCTests.load( + catalog: catalog, + otherFileSystemDirectories: otherFileSystemDirectories, + diagnosticFilterLevel: diagnosticFilterLevel, + logOutput: logOutput, + configuration: configuration + ) + return (context.inputs, context) } func testCatalogURL(named name: String, file: StaticString = #filePath, line: UInt = #line) throws -> URL { @@ -122,24 +114,25 @@ extension XCTestCase { configuration: DocumentationContext.Configuration = .init() ) async throws -> (URL, DocumentationBundle, DocumentationContext) { let catalogURL = try testCatalogURL(named: name) - return try await loadBundle(from: catalogURL, externalResolvers: externalResolvers, fallbackResolver: fallbackResolver, configuration: configuration) + let context = try await loadFromDisk(catalogURL: catalogURL, externalResolvers: externalResolvers, fallbackResolver: fallbackResolver, configuration: configuration) + return (catalogURL, context.inputs, context) } func testBundleAndContext(named name: String, externalResolvers: [DocumentationBundle.Identifier: any ExternalDocumentationSource] = [:]) async throws -> (DocumentationBundle, DocumentationContext) { - let (_, bundle, context) = try await testBundleAndContext(named: name, externalResolvers: externalResolvers) - return (bundle, context) + let context = try await loadFromDisk(catalogURL: try testCatalogURL(named: name), externalResolvers: externalResolvers) + return (context.inputs, context) } - func renderNode(atPath path: String, fromTestBundleNamed testBundleName: String) async throws -> RenderNode { - let (_, context) = try await testBundleAndContext(named: testBundleName) + func renderNode(atPath path: String, fromTestBundleNamed testCatalogName: String) async throws -> RenderNode { + let context = try await loadFromDisk(catalogURL: try testCatalogURL(named: testCatalogName)) let node = try context.entity(with: ResolvedTopicReference(bundleID: context.inputs.id, path: path, sourceLanguage: .swift)) var translator = RenderNodeTranslator(context: context, identifier: node.reference) return try XCTUnwrap(translator.visit(node.semantic) as? RenderNode) } func testBundle(named name: String) async throws -> DocumentationBundle { - let (bundle, _) = try await testBundleAndContext(named: name) - return bundle + let context = try await loadFromDisk(catalogURL: try testCatalogURL(named: name)) + return context.inputs } func testBundleFromRootURL(named name: String) throws -> DocumentationBundle { @@ -150,19 +143,8 @@ extension XCTestCase { } func testBundleAndContext() async throws -> (bundle: DocumentationBundle, context: DocumentationContext) { - let bundle = DocumentationBundle( - info: DocumentationBundle.Info( - displayName: "Test", - id: "com.example.test" - ), - baseURL: URL(string: "https://example.com/example")!, - symbolGraphURLs: [], - markupURLs: [], - miscResourceURLs: [] - ) - - let context = try await DocumentationContext(bundle: bundle, dataProvider: TestFileSystem(folders: [])) - return (bundle, context) + let context = try await makeEmptyContext() + return (context.inputs, context) } func parseDirective( @@ -347,14 +329,6 @@ extension XCTestCase { } func renderNodeApplying(variant: String, to renderNode: RenderNode) throws -> RenderNode { - let variantData = try RenderNodeVariantOverridesApplier().applyVariantOverrides( - in: RenderJSONEncoder.makeEncoder().encode(renderNode), - for: [.interfaceLanguage(variant)] - ) - - return try RenderJSONDecoder.makeDecoder().decode( - RenderNode.self, - from: variantData - ) + try renderNode.applying(variant: variant) } } From bf746cde6437443c377e25439c65d72b80d37bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 20 Nov 2025 18:24:51 +0000 Subject: [PATCH 3/9] Draft new contributor information about adding and updating tests --- CONTRIBUTING.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da6d76fb8f..670b3cee95 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -166,7 +166,7 @@ If you have commit access, you can run the required tests by commenting the foll If you do not have commit access, please ask one of the code owners to trigger them for you. For more details on Swift-DocC's continuous integration, see the -[Continous Integration](#continuous-integration) section below. +[Continuous Integration](#continuous-integration) section below. ### Introducing source breaking changes @@ -207,7 +207,23 @@ by navigating to the root of the repository and running the following: By running tests locally with the `test` script you will be best prepared for automated testing in CI as well. -### Testing in Xcode +### Adding new tests + +We recommend that you use [Swift Testing](https://developer.apple.com/documentation/testing) when you add new tests. +Currently there are few existing tests to draw inspiration from, so here are a few recommendations: + +- Prefer small test inputs that ideally use a virtual file system for both reading and writing. +- Consider using parameterized tests if you're making the same verifications in multiple configurations or on multiple elements. +- Think about what information would be helpful to someone else who might debug that test case if it fails in the future. +- Use `#require` rather that force unwrapping for behaviors that would change due to unexpected bugs in the code you're testing. + +If you're updating an existing test case with additional logic, we appreciate it if you also modernize that test, but we don't expect it. +If the test case is part of a large file, you can create new test suite which contains just the test case that you're modernizing. + +If you modernize an existing test case, consider not only the syntactical differences between Swift Testing and XCTest, +but also if there are any Swift Testing features or other changes that would make the test case easier to read, maintain, or debug. + +### Testing DocC's integration with Xcode You can test a locally built version of Swift-DocC in Xcode 13 or later by setting the `DOCC_EXEC` build setting to the path of your local `docc`: From 8c0a4afdb1aba2b0c531a5d664cc13afb87420bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 20 Nov 2025 18:25:42 +0000 Subject: [PATCH 4/9] Update a few test suites to Swift Testing to use as examples/inspiration for new tests --- .../NonInclusiveLanguageCheckerTests.swift | 192 +++++++++--------- .../ParseDirectiveArgumentsTests.swift | 53 ++--- 2 files changed, 115 insertions(+), 130 deletions(-) diff --git a/Tests/SwiftDocCTests/Checker/Checkers/NonInclusiveLanguageCheckerTests.swift b/Tests/SwiftDocCTests/Checker/Checkers/NonInclusiveLanguageCheckerTests.swift index 1073bc693e..87e928581e 100644 --- a/Tests/SwiftDocCTests/Checker/Checkers/NonInclusiveLanguageCheckerTests.swift +++ b/Tests/SwiftDocCTests/Checker/Checkers/NonInclusiveLanguageCheckerTests.swift @@ -8,13 +8,13 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -import XCTest +import Testing import Markdown @testable import SwiftDocC import SwiftDocCTestUtilities -class NonInclusiveLanguageCheckerTests: XCTestCase { - +struct NonInclusiveLanguageCheckerTests { + @Test func testMatchTermInTitle() throws { let source = """ # A Whitelisted title @@ -22,16 +22,17 @@ class NonInclusiveLanguageCheckerTests: XCTestCase { let document = Document(parsing: source) var checker = NonInclusiveLanguageChecker(sourceFile: nil) checker.visit(document) - XCTAssertEqual(checker.problems.count, 1) - - let problem = try XCTUnwrap(checker.problems.first) - let range = try XCTUnwrap(problem.diagnostic.range) - XCTAssertEqual(range.lowerBound.line, 1) - XCTAssertEqual(range.lowerBound.column, 5) - XCTAssertEqual(range.upperBound.line, 1) - XCTAssertEqual(range.upperBound.column, 16) + #expect(checker.problems.count == 1) + + let problem = try #require(checker.problems.first) + let range = try #require(problem.diagnostic.range) + #expect(range.lowerBound.line == 1) + #expect(range.lowerBound.column == 5) + #expect(range.upperBound.line == 1) + #expect(range.upperBound.column == 16) } + @Test func testMatchTermWithSpaces() throws { let source = """ # A White listed title @@ -41,30 +42,31 @@ class NonInclusiveLanguageCheckerTests: XCTestCase { let document = Document(parsing: source) var checker = NonInclusiveLanguageChecker(sourceFile: nil) checker.visit(document) - XCTAssertEqual(checker.problems.count, 3) - - let problem = try XCTUnwrap(checker.problems.first) - let range = try XCTUnwrap(problem.diagnostic.range) - XCTAssertEqual(range.lowerBound.line, 1) - XCTAssertEqual(range.lowerBound.column, 5) - XCTAssertEqual(range.upperBound.line, 1) - XCTAssertEqual(range.upperBound.column, 18) - - let problemTwo = try XCTUnwrap(checker.problems[1]) - let rangeTwo = try XCTUnwrap(problemTwo.diagnostic.range) - XCTAssertEqual(rangeTwo.lowerBound.line, 2) - XCTAssertEqual(rangeTwo.lowerBound.column, 5) - XCTAssertEqual(rangeTwo.upperBound.line, 2) - XCTAssertEqual(rangeTwo.upperBound.column, 20) - - let problemThree = try XCTUnwrap(checker.problems[2]) - let rangeThree = try XCTUnwrap(problemThree.diagnostic.range) - XCTAssertEqual(rangeThree.lowerBound.line, 3) - XCTAssertEqual(rangeThree.lowerBound.column, 5) - XCTAssertEqual(rangeThree.upperBound.line, 3) - XCTAssertEqual(rangeThree.upperBound.column, 17) + #expect(checker.problems.count == 3) + + let problem = try #require(checker.problems.first) + let range = try #require(problem.diagnostic.range) + #expect(range.lowerBound.line == 1) + #expect(range.lowerBound.column == 5) + #expect(range.upperBound.line == 1) + #expect(range.upperBound.column == 18) + + let problemTwo = try #require(checker.problems.dropFirst(1).first) + let rangeTwo = try #require(problemTwo.diagnostic.range) + #expect(rangeTwo.lowerBound.line == 2) + #expect(rangeTwo.lowerBound.column == 5) + #expect(rangeTwo.upperBound.line == 2) + #expect(rangeTwo.upperBound.column == 20) + + let problemThree = try #require(checker.problems.dropFirst(2).first) + let rangeThree = try #require(problemThree.diagnostic.range) + #expect(rangeThree.lowerBound.line == 3) + #expect(rangeThree.lowerBound.column == 5) + #expect(rangeThree.upperBound.line == 3) + #expect(rangeThree.upperBound.column == 17) } + @Test func testMatchTermInAbstract() throws { let source = """ # Title @@ -74,16 +76,17 @@ The blacklist is in the abstract. let document = Document(parsing: source) var checker = NonInclusiveLanguageChecker(sourceFile: nil) checker.visit(document) - XCTAssertEqual(checker.problems.count, 1) - - let problem = try XCTUnwrap(checker.problems.first) - let range = try XCTUnwrap(problem.diagnostic.range) - XCTAssertEqual(range.lowerBound.line, 3) - XCTAssertEqual(range.lowerBound.column, 5) - XCTAssertEqual(range.upperBound.line, 3) - XCTAssertEqual(range.upperBound.column, 14) + #expect(checker.problems.count == 1) + + let problem = try #require(checker.problems.first) + let range = try #require(problem.diagnostic.range) + #expect(range.lowerBound.line == 3) + #expect(range.lowerBound.column == 5) + #expect(range.upperBound.line == 3) + #expect(range.upperBound.column == 14) } + @Test func testMatchTermInParagraph() throws { let source = """ # Title @@ -98,16 +101,17 @@ master branch is the default. let document = Document(parsing: source) var checker = NonInclusiveLanguageChecker(sourceFile: nil) checker.visit(document) - XCTAssertEqual(checker.problems.count, 1) - - let problem = try XCTUnwrap(checker.problems.first) - let range = try XCTUnwrap(problem.diagnostic.range) - XCTAssertEqual(range.lowerBound.line, 8) - XCTAssertEqual(range.lowerBound.column, 1) - XCTAssertEqual(range.upperBound.line, 8) - XCTAssertEqual(range.upperBound.column, 7) + #expect(checker.problems.count == 1) + + let problem = try #require(checker.problems.first) + let range = try #require(problem.diagnostic.range) + #expect(range.lowerBound.line == 8) + #expect(range.lowerBound.column == 1) + #expect(range.upperBound.line == 8) + #expect(range.upperBound.column == 7) } + @Test func testMatchTermInList() throws { let source = """ - Item 1 is ok @@ -117,16 +121,17 @@ master branch is the default. let document = Document(parsing: source) var checker = NonInclusiveLanguageChecker(sourceFile: nil) checker.visit(document) - XCTAssertEqual(checker.problems.count, 1) - - let problem = try XCTUnwrap(checker.problems.first) - let range = try XCTUnwrap(problem.diagnostic.range) - XCTAssertEqual(range.lowerBound.line, 2) - XCTAssertEqual(range.lowerBound.column, 13) - XCTAssertEqual(range.upperBound.line, 2) - XCTAssertEqual(range.upperBound.column, 24) + #expect(checker.problems.count == 1) + + let problem = try #require(checker.problems.first) + let range = try #require(problem.diagnostic.range) + #expect(range.lowerBound.line == 2) + #expect(range.lowerBound.column == 13) + #expect(range.upperBound.line == 2) + #expect(range.upperBound.column == 24) } + @Test func testMatchTermInInlineCode() throws { let source = """ The name `MachineSlave` is unacceptable. @@ -134,16 +139,17 @@ The name `MachineSlave` is unacceptable. let document = Document(parsing: source) var checker = NonInclusiveLanguageChecker(sourceFile: nil) checker.visit(document) - XCTAssertEqual(checker.problems.count, 1) - - let problem = try XCTUnwrap(checker.problems.first) - let range = try XCTUnwrap(problem.diagnostic.range) - XCTAssertEqual(range.lowerBound.line, 1) - XCTAssertEqual(range.lowerBound.column, 18) - XCTAssertEqual(range.upperBound.line, 1) - XCTAssertEqual(range.upperBound.column, 23) + #expect(checker.problems.count == 1) + + let problem = try #require(checker.problems.first) + let range = try #require(problem.diagnostic.range) + #expect(range.lowerBound.line == 1) + #expect(range.lowerBound.column == 18) + #expect(range.upperBound.line == 1) + #expect(range.upperBound.column == 23) } + @Test func testMatchTermInCodeBlock() throws { let source = """ A code block: @@ -158,13 +164,13 @@ func aBlackListedFunc() { let document = Document(parsing: source) var checker = NonInclusiveLanguageChecker(sourceFile: nil) checker.visit(document) - XCTAssertEqual(checker.problems.count, 1) - let problem = try XCTUnwrap(checker.problems.first) - let range = try XCTUnwrap(problem.diagnostic.range) - XCTAssertEqual(range.lowerBound.line, 5) - XCTAssertEqual(range.lowerBound.column, 7) - XCTAssertEqual(range.upperBound.line, 5) - XCTAssertEqual(range.upperBound.column, 18) + #expect(checker.problems.count == 1) + let problem = try #require(checker.problems.first) + let range = try #require(problem.diagnostic.range) + #expect(range.lowerBound.line == 5) + #expect(range.lowerBound.column == 7) + #expect(range.upperBound.line == 5) + #expect(range.upperBound.column == 18) } private let nonInclusiveContent = """ @@ -177,36 +183,32 @@ func aBlackListedFunc() { - item three """ + @Test func testDisabledByDefault() async throws { // Create a test bundle with some non-inclusive content. let catalog = Folder(name: "unit-test.docc", content: [ TextFile(name: "Root.md", utf8Content: nonInclusiveContent) ]) - let (_, context) = try await loadBundle(catalog: catalog) + let context = try await load(catalog: catalog) - XCTAssertEqual(context.problems.count, 0) // Non-inclusive content is an info-level diagnostic, so it's filtered out. + #expect(context.problems.isEmpty) // Non-inclusive content is an info-level diagnostic, so it's filtered out. } - func testEnablingTheChecker() async throws { - // The expectations of the checker being run, depending on the diagnostic level - // set to to the documentation context for the compilation. - let expectations: [(DiagnosticSeverity, Bool)] = [ - (.hint, true), - (.information, true), - (.warning, false), - (.error, false), - ] - - for (severity, enabled) in expectations { - let catalog = Folder(name: "unit-test.docc", content: [ - TextFile(name: "Root.md", utf8Content: nonInclusiveContent) - ]) - var configuration = DocumentationContext.Configuration() - configuration.externalMetadata.diagnosticLevel = severity - let (_, context) = try await loadBundle(catalog: catalog, diagnosticFilterLevel: severity, configuration: configuration) - - // Verify that checker diagnostics were emitted or not, depending on the diagnostic level set. - XCTAssertEqual(context.problems.contains(where: { $0.diagnostic.identifier == "org.swift.docc.NonInclusiveLanguage" }), enabled) - } + @Test(arguments: [ + DiagnosticSeverity.hint: true, + DiagnosticSeverity.information: true, + DiagnosticSeverity.warning: false, + DiagnosticSeverity.error: false, + ]) + func testEnablingTheChecker(configuredDiagnosticSeverity: DiagnosticSeverity, expectsToIncludeANonInclusiveDiagnostic: Bool) async throws { + let catalog = Folder(name: "unit-test.docc", content: [ + TextFile(name: "Root.md", utf8Content: nonInclusiveContent) + ]) + var configuration = DocumentationContext.Configuration() + configuration.externalMetadata.diagnosticLevel = configuredDiagnosticSeverity + let context = try await load(catalog: catalog, diagnosticFilterLevel: configuredDiagnosticSeverity, configuration: configuration) + + // Verify that checker diagnostics were emitted or not, depending on the diagnostic level set. + #expect(context.problems.contains(where: { $0.diagnostic.identifier == "org.swift.docc.NonInclusiveLanguage" }) == expectsToIncludeANonInclusiveDiagnostic) } } diff --git a/Tests/SwiftDocCTests/Infrastructure/ParseDirectiveArgumentsTests.swift b/Tests/SwiftDocCTests/Infrastructure/ParseDirectiveArgumentsTests.swift index f8881ac128..49920dd78d 100644 --- a/Tests/SwiftDocCTests/Infrastructure/ParseDirectiveArgumentsTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/ParseDirectiveArgumentsTests.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2021 Apple Inc. and the Swift project authors + Copyright (c) 2021-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 @@ -9,45 +9,28 @@ */ import Foundation -import XCTest - +import Testing import Markdown @testable import SwiftDocC -class ParseDirectiveArgumentsTests: XCTestCase { - func testEmitsWarningForMissingExpectedCharacter() throws { - let diagnostic = try XCTUnwrap( - parse(rawDirective: "@Directive(argument: multiple words)").first - ) - - XCTAssertEqual(diagnostic.identifier, "org.swift.docc.Directive.MissingExpectedCharacter") - XCTAssertEqual(diagnostic.severity, .warning) - } - - func testEmitsWarningForUnexpectedCharacter() throws { - let diagnostic = try XCTUnwrap( - parse(rawDirective: "@Directive(argumentA: value, argumentB: multiple words").first - ) - - XCTAssertEqual(diagnostic.identifier, "org.swift.docc.Directive.MissingExpectedCharacter") - XCTAssertEqual(diagnostic.severity, .warning) - } - - func testEmitsWarningsForDuplicateArgument() throws { - let diagnostic = try XCTUnwrap( - parse(rawDirective: "@Directive(argumentA: value, argumentA: value").first - ) - - XCTAssertEqual(diagnostic.identifier, "org.swift.docc.Directive.DuplicateArgument") - XCTAssertEqual(diagnostic.severity, .warning) - } - - func parse(rawDirective: String) -> [Diagnostic] { - let document = Document(parsing: rawDirective, options: .parseBlockDirectives) - +struct ParseDirectiveArgumentsTests { + @Test(arguments: [ + // Missing quotation marks around string parameter + "@Directive(argument: multiple words)": "org.swift.docc.Directive.MissingExpectedCharacter", + // Missing quotation marks around string parameter in 2nd parameter + "@Directive(argumentA: value, argumentB: multiple words)": "org.swift.docc.Directive.MissingExpectedCharacter", + // Duplicate argument + "@Directive(argumentA: value, argumentA: value)": "org.swift.docc.Directive.DuplicateArgument", + ]) + func testEmitsWarningsForInvalidMarkup(invalidMarkup: String, expectedDiagnosticID: String) throws { + let document = Document(parsing: invalidMarkup, options: .parseBlockDirectives) var problems = [Problem]() _ = (document.child(at: 0) as? BlockDirective)?.arguments(problems: &problems) - return problems.map(\.diagnostic) + + let diagnostic = try #require(problems.first?.diagnostic) + + #expect(diagnostic.identifier == expectedDiagnosticID) + #expect(diagnostic.severity == .warning) } } From ffa6c578f7faf8f128a97b29b9ae35f7e11d718e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Tue, 16 Dec 2025 14:01:10 +0100 Subject: [PATCH 5/9] Update more tests to use Swift Testing --- .../SymbolGraphCreation.swift | 14 +- .../AutoCapitalizationTests.swift | 2 +- .../DocumentationCuratorTests.swift | 288 +++++++++--------- .../PathHierarchyBasedLinkResolverTests.swift | 24 +- ...tendedTypesFormatTransformationTests.swift | 1 + .../Model/DocumentationNodeTests.swift | 1 + .../ParametersAndReturnValidatorTests.swift | 2 +- .../Semantics/SymbolTests.swift | 2 +- 8 files changed, 164 insertions(+), 170 deletions(-) diff --git a/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift b/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift index 8bd48ead94..8f2a172297 100644 --- a/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift +++ b/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift @@ -1,23 +1,19 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2021-2024 Apple Inc. and the Swift project authors + Copyright (c) 2021-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 -public import XCTest +package import Foundation package import SymbolKit package import SwiftDocC // MARK: - Symbol Graph objects -extension XCTestCase { - package func makeSymbolGraph( moduleName: String, platform: SymbolGraph.Platform = .init(), @@ -196,7 +192,7 @@ extension XCTestCase { } return SymbolGraph.Symbol.Kind(parsedIdentifier: kindID, displayName: documentationNodeKind.name) } -} + // MARK: Constants @@ -205,8 +201,10 @@ private let defaultSymbolURL = URL(fileURLWithPath: "/Users/username/path/to/Som // MARK: - JSON strings +package import XCTest + extension XCTestCase { - public func makeSymbolGraphString(moduleName: String, symbols: String = "", relationships: String = "", platform: String = "") -> String { + package func makeSymbolGraphString(moduleName: String, symbols: String = "", relationships: String = "", platform: String = "") -> String { return """ { "metadata": { diff --git a/Tests/SwiftDocCTests/Infrastructure/AutoCapitalizationTests.swift b/Tests/SwiftDocCTests/Infrastructure/AutoCapitalizationTests.swift index f45a2d7119..401d9f7321 100644 --- a/Tests/SwiftDocCTests/Infrastructure/AutoCapitalizationTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/AutoCapitalizationTests.swift @@ -19,7 +19,7 @@ class AutoCapitalizationTests: XCTestCase { // MARK: Test helpers private func makeSymbolGraph(docComment: String, parameters: [String]) -> SymbolGraph { - makeSymbolGraph( + SwiftDocCTestUtilities.makeSymbolGraph( moduleName: "ModuleName", symbols: [ makeSymbol( diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationCuratorTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationCuratorTests.swift index cab832ec10..65a9bc8ca2 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationCuratorTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationCuratorTests.swift @@ -136,155 +136,6 @@ class DocumentationCuratorTests: XCTestCase { """) } - func testCyclicCurationDiagnostic() async throws { - let (_, context) = try await loadBundle(catalog: - Folder(name: "unit-test.docc", content: [ - // A number of articles with this cyclic curation: - // - // Root──▶First──▶Second──▶Third─┐ - // ▲ │ - // └────────────────────┘ - TextFile(name: "Root.md", utf8Content: """ - # Root - - @Metadata { - @TechnologyRoot - } - - Curate the first article - - ## Topics - - - """), - - TextFile(name: "First.md", utf8Content: """ - # First - - Curate the second article - - ## Topics - - - """), - - TextFile(name: "Second.md", utf8Content: """ - # Second - - Curate the third article - - ## Topics - - - """), - - TextFile(name: "Third.md", utf8Content: """ - # Third - - Form a cycle by curating the first article - ## Topics - - - """), - ]) - ) - - XCTAssertEqual(context.problems.map(\.diagnostic.identifier), ["org.swift.docc.CyclicReference"]) - let curationProblem = try XCTUnwrap(context.problems.first) - - XCTAssertEqual(curationProblem.diagnostic.source?.lastPathComponent, "Third.md") - XCTAssertEqual(curationProblem.diagnostic.summary, "Organizing 'unit-test/First' under 'unit-test/Third' forms a cycle") - - XCTAssertEqual(curationProblem.diagnostic.explanation, """ - Links in a "Topics section" are used to organize documentation into a hierarchy. The documentation hierarchy shouldn't contain cycles. - If this link contributed to the documentation hierarchy it would introduce this cycle: - ╭─▶︎ Third ─▶︎ First ─▶︎ Second ─╮ - ╰─────────────────────────────╯ - """) - - XCTAssertEqual(curationProblem.possibleSolutions.map(\.summary), ["Remove '- '"]) - } - - func testCurationInUncuratedAPICollection() async throws { - // Everything should behave the same when an API Collection is automatically curated as when it is explicitly curated - for shouldCurateAPICollection in [true, false] { - let assertionMessageDescription = "when the API collection is \(shouldCurateAPICollection ? "explicitly curated" : "auto-curated as an article under the module")." - - let catalog = Folder(name: "unit-test.docc", content: [ - JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(moduleName: "ModuleName", symbols: [ - makeSymbol(id: "some-symbol-id", kind: .class, pathComponents: ["SomeClass"]) - ])), - - TextFile(name: "ModuleName.md", utf8Content: """ - # ``ModuleName`` - - \(shouldCurateAPICollection ? "## Topics\n\n### Explicit curation\n\n- " : "") - """), - - TextFile(name: "API-Collection.md", utf8Content: """ - # Some API collection - - Curate the only symbol - - ## Topics - - - ``SomeClass`` - - ``NotFound`` - """), - ]) - let (_, context) = try await loadBundle(catalog: catalog) - XCTAssertEqual( - context.problems.map(\.diagnostic.summary), - [ - // There should only be a single problem about the unresolvable link in the API collection. - "'NotFound' doesn't exist at '/unit-test/API-Collection'" - ], - "Unexpected problems: \(context.problems.map(\.diagnostic.summary).joined(separator: "\n")) \(assertionMessageDescription)" - ) - - // Verify that the topic graph paths to the symbol (although not used for its breadcrumbs) doesn't have the automatic edge anymore. - let symbolReference = try XCTUnwrap(context.knownPages.first(where: { $0.lastPathComponent == "SomeClass" })) - XCTAssertEqual( - context.finitePaths(to: symbolReference).map { $0.map(\.path) }, - [ - // The automatic default `["/documentation/ModuleName"]` curation _shouldn't_ be here. - - // The authored curation in the uncurated API collection - ["/documentation/ModuleName", "/documentation/unit-test/API-Collection"], - ], - "Unexpected 'paths' to the symbol page \(assertionMessageDescription)" - ) - - // Verify that the symbol page shouldn't auto-curate in its canonical location. - let symbolTopicNode = try XCTUnwrap(context.topicGraph.nodeWithReference(symbolReference)) - XCTAssertFalse(symbolTopicNode.shouldAutoCurateInCanonicalLocation, "Symbol node is unexpectedly configured to auto-curate \(assertionMessageDescription)") - - // Verify that the topic graph doesn't have the automatic edge anymore. - XCTAssertEqual(context.dumpGraph(), """ - doc://unit-test/documentation/ModuleName - ╰ doc://unit-test/documentation/unit-test/API-Collection - ╰ doc://unit-test/documentation/ModuleName/SomeClass - - """, - "Unexpected topic graph \(assertionMessageDescription)" - ) - - // Verify that the rendered top-level page doesn't have an automatic "Classes" topic section anymore. - let converter = DocumentationNodeConverter(context: context) - let moduleReference = try XCTUnwrap(context.soleRootModuleReference) - let rootRenderNode = converter.convert(try context.entity(with: moduleReference)) - - XCTAssertEqual( - rootRenderNode.topicSections.map(\.title), - [shouldCurateAPICollection ? "Explicit curation" : "Articles"], - "Unexpected rendered topic sections on the module page \(assertionMessageDescription)" - ) - XCTAssertEqual( - rootRenderNode.topicSections.map(\.identifiers), - [ - ["doc://unit-test/documentation/unit-test/API-Collection"], - ], - "Unexpected rendered topic sections on the module page \(assertionMessageDescription)" - ) - } - } - func testModuleUnderTechnologyRoot() async throws { let (_, _, context) = try await testBundleAndContext(copying: "SourceLocations") { url in try """ @@ -724,3 +575,142 @@ class DocumentationCuratorTests: XCTestCase { ]) } } + +import Testing + +struct DocumentationCuratorTests_New { + @Test + func testCyclicCurationDiagnostic() async throws { + let context = try await load(catalog: + Folder(name: "unit-test.docc", content: [ + // A number of articles with this cyclic curation: + // + // Root──▶First──▶Second──▶Third─┐ + // ▲ │ + // └────────────────────┘ + TextFile(name: "Root.md", utf8Content: """ + # Root + + @Metadata { + @TechnologyRoot + } + + Curate the first article + + ## Topics + - + """), + + TextFile(name: "First.md", utf8Content: """ + # First + + Curate the second article + + ## Topics + - + """), + + TextFile(name: "Second.md", utf8Content: """ + # Second + + Curate the third article + + ## Topics + - + """), + + TextFile(name: "Third.md", utf8Content: """ + # Third + + Form a cycle by curating the first article + ## Topics + - + """), + ]) + ) + + #expect(context.problems.map(\.diagnostic.identifier) == ["org.swift.docc.CyclicReference"]) + let curationProblem = try #require(context.problems.first) + + #expect(curationProblem.diagnostic.source?.lastPathComponent == "Third.md") + #expect(curationProblem.diagnostic.summary == "Organizing 'unit-test/First' under 'unit-test/Third' forms a cycle") + + #expect(curationProblem.diagnostic.explanation == """ + Links in a "Topics section" are used to organize documentation into a hierarchy. The documentation hierarchy shouldn't contain cycles. + If this link contributed to the documentation hierarchy it would introduce this cycle: + ╭─▶︎ Third ─▶︎ First ─▶︎ Second ─╮ + ╰─────────────────────────────╯ + """) + + #expect(curationProblem.possibleSolutions.map(\.summary) == ["Remove '- '"]) + } + + @Test(arguments: [true, false]) + func testCurationInUncuratedAPICollection(shouldCurateAPICollection: Bool) async throws { + // Everything should behave the same when an API Collection is automatically curated as when it is explicitly curated + let assertionMessageDescription = "when the API collection is \(shouldCurateAPICollection ? "explicitly curated" : "auto-curated as an article under the module")." + + let catalog = Folder(name: "unit-test.docc", content: [ + JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(moduleName: "ModuleName", symbols: [ + makeSymbol(id: "some-symbol-id", kind: .class, pathComponents: ["SomeClass"]) + ])), + + TextFile(name: "ModuleName.md", utf8Content: """ + # ``ModuleName`` + + \(shouldCurateAPICollection ? "## Topics\n\n### Explicit curation\n\n- " : "") + """), + + TextFile(name: "API-Collection.md", utf8Content: """ + # Some API collection + + Curate the only symbol + + ## Topics + + - ``SomeClass`` + - ``NotFound`` + """), + ]) + let context = try await load(catalog: catalog) + #expect(context.problems.map(\.diagnostic.summary) == [ + // There should only be a single problem about the unresolvable link in the API collection. + "'NotFound' doesn't exist at '/unit-test/API-Collection'" + ], "Unexpected problems: \(context.problems.map(\.diagnostic.summary).joined(separator: "\n")) \(assertionMessageDescription)") + + // Verify that the topic graph paths to the symbol (although not used for its breadcrumbs) doesn't have the automatic edge anymore. + let symbolReference = try #require(context.knownPages.first(where: { $0.lastPathComponent == "SomeClass" })) + #expect(context.finitePaths(to: symbolReference).map { $0.map(\.path) } == [ + // The automatic default `["/documentation/ModuleName"]` curation _shouldn't_ be here. + + // The authored curation in the uncurated API collection + ["/documentation/ModuleName", "/documentation/unit-test/API-Collection"], + ], "Unexpected 'paths' to the symbol page \(assertionMessageDescription)") + + // Verify that the symbol page shouldn't auto-curate in its canonical location. + let symbolTopicNode = try #require(context.topicGraph.nodeWithReference(symbolReference)) + #expect(!symbolTopicNode.shouldAutoCurateInCanonicalLocation, "Symbol node is unexpectedly configured to auto-curate \(assertionMessageDescription)") + + // Verify that the topic graph doesn't have the automatic edge anymore. + #expect(context.dumpGraph() == """ + doc://unit-test/documentation/ModuleName + ╰ doc://unit-test/documentation/unit-test/API-Collection + ╰ doc://unit-test/documentation/ModuleName/SomeClass + + """, + "Unexpected topic graph \(assertionMessageDescription)" + ) + + // Verify that the rendered top-level page doesn't have an automatic "Classes" topic section anymore. + let converter = DocumentationNodeConverter(context: context) + let moduleReference = try XCTUnwrap(context.soleRootModuleReference) + let rootRenderNode = converter.convert(try context.entity(with: moduleReference)) + + #expect(rootRenderNode.topicSections.map(\.title) == [shouldCurateAPICollection ? "Explicit curation" : "Articles"], + "Unexpected rendered topic sections on the module page \(assertionMessageDescription)" + ) + #expect(rootRenderNode.topicSections.map(\.identifiers) == [ + ["doc://unit-test/documentation/unit-test/API-Collection"], + ], "Unexpected rendered topic sections on the module page \(assertionMessageDescription)") + } +} diff --git a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyBasedLinkResolverTests.swift b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyBasedLinkResolverTests.swift index 4c82a540a7..cb8bd7109f 100644 --- a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyBasedLinkResolverTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyBasedLinkResolverTests.swift @@ -8,29 +8,33 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -import XCTest +import Testing @testable import SwiftDocC -class PathHierarchyBasedLinkResolverTests: XCTestCase { - +struct PathHierarchyBasedLinkResolverTests { + @Test() func testOverloadedSymbolsWithOverloadGroups() async throws { - enableFeatureFlag(\.isExperimentalOverloadedSymbolPresentationEnabled) + let currentValue = FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled + FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled = true + defer { + FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled = currentValue + } - let (_, context) = try await testBundleAndContext(named: "OverloadedSymbols") - let moduleReference = try XCTUnwrap(context.soleRootModuleReference) + let context = try await loadFromDisk(catalogName: "OverloadedSymbols") + let moduleReference = try #require(context.soleRootModuleReference) // Returns nil for all non-overload groups for reference in context.knownIdentifiers { let node = try context.entity(with: reference) guard node.symbol?.isOverloadGroup != true else { continue } - XCTAssertNil(context.linkResolver.localResolver.overloads(ofGroup: reference), "Unexpectedly found overloads for non-overload group \(reference.path)" ) + #expect(context.linkResolver.localResolver.overloads(ofGroup: reference) == nil, "Unexpectedly found overloads for non-overload group \(reference.path)" ) } let firstOverloadGroup = moduleReference.appendingPath("OverloadedEnum/firstTestMemberName(_:)-8v5g7") let secondOverloadGroup = moduleReference.appendingPath("OverloadedProtocol/fourthTestMemberName(test:)") - XCTAssertEqual(context.linkResolver.localResolver.overloads(ofGroup: firstOverloadGroup)?.map(\.path).sorted(), [ + #expect(context.linkResolver.localResolver.overloads(ofGroup: firstOverloadGroup)?.map(\.path).sorted() == [ "/documentation/ShapeKit/OverloadedEnum/firstTestMemberName(_:)-14g8s", "/documentation/ShapeKit/OverloadedEnum/firstTestMemberName(_:)-14ife", "/documentation/ShapeKit/OverloadedEnum/firstTestMemberName(_:)-14ob0", @@ -38,8 +42,8 @@ class PathHierarchyBasedLinkResolverTests: XCTestCase { "/documentation/ShapeKit/OverloadedEnum/firstTestMemberName(_:)-88rbf", ]) - XCTAssertEqual(context.linkResolver.localResolver.overloads(ofGroup: secondOverloadGroup)?.map(\.path).sorted(), [ - "/documentation/ShapeKit/OverloadedProtocol/fourthTestMemberName(test:)-1h173", + #expect(context.linkResolver.localResolver.overloads(ofGroup: secondOverloadGroup)?.map(\.path).sorted() == [ + "/documentation/ShapeKit/OverloadedProtocol/fourthTestMemberName(test:)-1h173", "/documentation/ShapeKit/OverloadedProtocol/fourthTestMemberName(test:)-8iuz7", "/documentation/ShapeKit/OverloadedProtocol/fourthTestMemberName(test:)-91hxs", "/documentation/ShapeKit/OverloadedProtocol/fourthTestMemberName(test:)-961zx", diff --git a/Tests/SwiftDocCTests/Infrastructure/SymbolGraph/ExtendedTypesFormatTransformationTests.swift b/Tests/SwiftDocCTests/Infrastructure/SymbolGraph/ExtendedTypesFormatTransformationTests.swift index 5c2fbe83a0..a5ba6eb33b 100644 --- a/Tests/SwiftDocCTests/Infrastructure/SymbolGraph/ExtendedTypesFormatTransformationTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/SymbolGraph/ExtendedTypesFormatTransformationTests.swift @@ -12,6 +12,7 @@ import Foundation import XCTest import SymbolKit @testable import SwiftDocC +import SwiftDocCTestUtilities class ExtendedTypesFormatTransformationTests: XCTestCase { /// Tests the general transformation structure of ``ExtendedTypesFormatTransformation/transformExtensionBlockFormatToExtendedTypeFormat(_:)`` diff --git a/Tests/SwiftDocCTests/Model/DocumentationNodeTests.swift b/Tests/SwiftDocCTests/Model/DocumentationNodeTests.swift index 669c3c3146..435b5aeaa8 100644 --- a/Tests/SwiftDocCTests/Model/DocumentationNodeTests.swift +++ b/Tests/SwiftDocCTests/Model/DocumentationNodeTests.swift @@ -13,6 +13,7 @@ import Markdown @testable import SwiftDocC import SymbolKit import XCTest +import SwiftDocCTestUtilities class DocumentationNodeTests: XCTestCase { func testH4AndUpAnchorSections() throws { diff --git a/Tests/SwiftDocCTests/Model/ParametersAndReturnValidatorTests.swift b/Tests/SwiftDocCTests/Model/ParametersAndReturnValidatorTests.swift index df4061c5d4..c85b1c8434 100644 --- a/Tests/SwiftDocCTests/Model/ParametersAndReturnValidatorTests.swift +++ b/Tests/SwiftDocCTests/Model/ParametersAndReturnValidatorTests.swift @@ -971,7 +971,7 @@ class ParametersAndReturnValidatorTests: XCTestCase { parameters: [(name: String, externalName: String?)], returnValue: SymbolGraph.Symbol.DeclarationFragments.Fragment ) -> SymbolGraph { - return makeSymbolGraph( + SwiftDocCTestUtilities.makeSymbolGraph( moduleName: "ModuleName", // Don't use `docCommentModuleName` here. platform: platform, symbols: [ diff --git a/Tests/SwiftDocCTests/Semantics/SymbolTests.swift b/Tests/SwiftDocCTests/Semantics/SymbolTests.swift index a2ea0fdf21..de79d2a837 100644 --- a/Tests/SwiftDocCTests/Semantics/SymbolTests.swift +++ b/Tests/SwiftDocCTests/Semantics/SymbolTests.swift @@ -1110,7 +1110,7 @@ class SymbolTests: XCTestCase { let (_, _, context) = try await testBundleAndContext(copying: "LegacyBundle_DoNotUseInNewTests") { url in var graph = try JSONDecoder().decode(SymbolGraph.self, from: Data(contentsOf: url.appendingPathComponent("mykit-iOS.symbols.json"))) - let newDocComment = self.makeLineList( + let newDocComment = makeLineList( docComment: """ A cool API to call. From 13e6a8b5e6eae0b0584d9921cf8fb8589a0fd0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Tue, 16 Dec 2025 14:26:11 +0100 Subject: [PATCH 6/9] Apply phrasing suggestions from code review Co-authored-by: Joseph Heck --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 670b3cee95..f7555ecf4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -209,7 +209,7 @@ automated testing in CI as well. ### Adding new tests -We recommend that you use [Swift Testing](https://developer.apple.com/documentation/testing) when you add new tests. +Please use [Swift Testing](https://developer.apple.com/documentation/testing) when you add new tests. Currently there are few existing tests to draw inspiration from, so here are a few recommendations: - Prefer small test inputs that ideally use a virtual file system for both reading and writing. @@ -217,7 +217,7 @@ Currently there are few existing tests to draw inspiration from, so here are a f - Think about what information would be helpful to someone else who might debug that test case if it fails in the future. - Use `#require` rather that force unwrapping for behaviors that would change due to unexpected bugs in the code you're testing. -If you're updating an existing test case with additional logic, we appreciate it if you also modernize that test, but we don't expect it. +If you're updating an existing test case with additional logic, we appreciate if you also modernize that test while updating it, but we don't expect it. If the test case is part of a large file, you can create new test suite which contains just the test case that you're modernizing. If you modernize an existing test case, consider not only the syntactical differences between Swift Testing and XCTest, From c9984dc15a5dc21cd93c212ea7f49540a4abf799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Tue, 16 Dec 2025 16:28:02 +0100 Subject: [PATCH 7/9] Update one more test class to use Swift Testing --- .../Utility/DirectedGraphTests.swift | 213 +++++++++--------- 1 file changed, 112 insertions(+), 101 deletions(-) diff --git a/Tests/SwiftDocCUtilitiesTests/Utility/DirectedGraphTests.swift b/Tests/SwiftDocCUtilitiesTests/Utility/DirectedGraphTests.swift index 98c3b61201..d05762f8a8 100644 --- a/Tests/SwiftDocCUtilitiesTests/Utility/DirectedGraphTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/Utility/DirectedGraphTests.swift @@ -1,19 +1,19 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2024 Apple Inc. and the Swift project authors + Copyright (c) 2024-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 XCTest +import Testing @testable import SwiftDocC -final class DirectedGraphTests: XCTestCase { - - func testGraphWithSingleAdjacency() throws { +struct DirectedGraphTests { + @Test + func testGraphWithSingleAdjacency() { // 1───▶2◀───3 // │ // ▼ @@ -33,44 +33,45 @@ final class DirectedGraphTests: XCTestCase { ]) // With only a single neighbor per node, breadth first and depth first perform the same traversal - assertEqual(graph.breadthFirstSearch(from: 1), [1,2,5,8]) - assertEqual(graph.breadthFirstSearch(from: 2), [2,5,8]) - assertEqual(graph.breadthFirstSearch(from: 3), [3,2,5,8]) - assertEqual(graph.breadthFirstSearch(from: 4), [4,5,8]) - assertEqual(graph.breadthFirstSearch(from: 5), [5,8]) - assertEqual(graph.breadthFirstSearch(from: 6), [6,9,8]) - assertEqual(graph.breadthFirstSearch(from: 7), [7,8]) - assertEqual(graph.breadthFirstSearch(from: 8), [8]) - assertEqual(graph.breadthFirstSearch(from: 9), [9,8]) + #expect(graph.breadthFirstSearch(from: 1) == [1,2,5,8]) + #expect(graph.breadthFirstSearch(from: 2) == [2,5,8]) + #expect(graph.breadthFirstSearch(from: 3) == [3,2,5,8]) + #expect(graph.breadthFirstSearch(from: 4) == [4,5,8]) + #expect(graph.breadthFirstSearch(from: 5) == [5,8]) + #expect(graph.breadthFirstSearch(from: 6) == [6,9,8]) + #expect(graph.breadthFirstSearch(from: 7) == [7,8]) + #expect(graph.breadthFirstSearch(from: 8) == [8]) + #expect(graph.breadthFirstSearch(from: 9) == [9,8]) - assertEqual(graph.depthFirstSearch(from: 1), [1,2,5,8]) - assertEqual(graph.depthFirstSearch(from: 2), [2,5,8]) - assertEqual(graph.depthFirstSearch(from: 3), [3,2,5,8]) - assertEqual(graph.depthFirstSearch(from: 4), [4,5,8]) - assertEqual(graph.depthFirstSearch(from: 5), [5,8]) - assertEqual(graph.depthFirstSearch(from: 6), [6,9,8]) - assertEqual(graph.depthFirstSearch(from: 7), [7,8]) - assertEqual(graph.depthFirstSearch(from: 8), [8]) - assertEqual(graph.depthFirstSearch(from: 9), [9,8]) + #expect(graph.depthFirstSearch(from: 1) == [1,2,5,8]) + #expect(graph.depthFirstSearch(from: 2) == [2,5,8]) + #expect(graph.depthFirstSearch(from: 3) == [3,2,5,8]) + #expect(graph.depthFirstSearch(from: 4) == [4,5,8]) + #expect(graph.depthFirstSearch(from: 5) == [5,8]) + #expect(graph.depthFirstSearch(from: 6) == [6,9,8]) + #expect(graph.depthFirstSearch(from: 7) == [7,8]) + #expect(graph.depthFirstSearch(from: 8) == [8]) + #expect(graph.depthFirstSearch(from: 9) == [9,8]) // With only a single neighbor per node, the path is the same as the traversal - XCTAssertEqual(graph.allFinitePaths(from: 1), [[1,2,5,8]]) - XCTAssertEqual(graph.allFinitePaths(from: 2), [[2,5,8]]) - XCTAssertEqual(graph.allFinitePaths(from: 3), [[3,2,5,8]]) - XCTAssertEqual(graph.allFinitePaths(from: 4), [[4,5,8]]) - XCTAssertEqual(graph.allFinitePaths(from: 5), [[5,8]]) - XCTAssertEqual(graph.allFinitePaths(from: 6), [[6,9,8]]) - XCTAssertEqual(graph.allFinitePaths(from: 7), [[7,8]]) - XCTAssertEqual(graph.allFinitePaths(from: 8), [[8]]) - XCTAssertEqual(graph.allFinitePaths(from: 9), [[9,8]]) + #expect(graph.allFinitePaths(from: 1) == [[1,2,5,8]]) + #expect(graph.allFinitePaths(from: 2) == [[2,5,8]]) + #expect(graph.allFinitePaths(from: 3) == [[3,2,5,8]]) + #expect(graph.allFinitePaths(from: 4) == [[4,5,8]]) + #expect(graph.allFinitePaths(from: 5) == [[5,8]]) + #expect(graph.allFinitePaths(from: 6) == [[6,9,8]]) + #expect(graph.allFinitePaths(from: 7) == [[7,8]]) + #expect(graph.allFinitePaths(from: 8) == [[8]]) + #expect(graph.allFinitePaths(from: 9) == [[9,8]]) for node in 1...9 { - XCTAssertNil(graph.firstCycle(from: node)) - XCTAssertEqual(graph.cycles(from: node), []) + #expect(graph.firstCycle(from: node) == nil) + #expect(graph.cycles(from: node) == []) } } - func testGraphWithTreeStructure() throws { + @Test + func testGraphWithTreeStructure() { // ┌▶5 // ┌─▶2─┤ // │ └▶6 @@ -84,27 +85,28 @@ final class DirectedGraphTests: XCTestCase { 7: [8], ]) - assertEqual(graph.breadthFirstSearch(from: 1), [1,2,3,4,5,6,7,8]) + #expect(graph.breadthFirstSearch(from: 1) == [1,2,3,4,5,6,7,8]) - assertEqual(graph.depthFirstSearch(from: 1), [1,4,7,8,3,2,6,5]) + #expect(graph.depthFirstSearch(from: 1) == [1,4,7,8,3,2,6,5]) - XCTAssertEqual(graph.allFinitePaths(from: 1), [ + #expect(graph.allFinitePaths(from: 1) == [ [1,3], [1,2,5], [1,2,6], [1,4,7,8], ]) - XCTAssertEqual(graph.shortestFinitePaths(from: 1), [ + #expect(graph.shortestFinitePaths(from: 1) == [ [1,3], ]) for node in 1...8 { - XCTAssertNil(graph.firstCycle(from: node)) + #expect(graph.firstCycle(from: node) == nil) } } - func testGraphWithTreeStructureAndMultipleAdjacency() throws { + @Test + func testGraphWithTreeStructureAndMultipleAdjacency() { // ┌─▶2─┐ // │ │ // 1─┼─▶3─┼▶5──▶6 @@ -118,27 +120,28 @@ final class DirectedGraphTests: XCTestCase { 5: [6], ]) - assertEqual(graph.breadthFirstSearch(from: 1), [1,2,3,4,5,6]) - assertEqual(graph.depthFirstSearch(from: 1), [1,4,5,6,3,2]) + #expect(graph.breadthFirstSearch(from: 1) == [1,2,3,4,5,6]) + #expect(graph.depthFirstSearch(from: 1) == [1,4,5,6,3,2]) - XCTAssertEqual(graph.allFinitePaths(from: 1), [ + #expect(graph.allFinitePaths(from: 1) == [ [1,2,5,6], [1,3,5,6], [1,4,5,6], ]) - XCTAssertEqual(graph.shortestFinitePaths(from: 1), [ + #expect(graph.shortestFinitePaths(from: 1) == [ [1,2,5,6], [1,3,5,6], [1,4,5,6], ]) for node in 1...6 { - XCTAssertNil(graph.firstCycle(from: node)) + #expect(graph.firstCycle(from: node) == nil) } } - func testComplexGraphWithMultipleAdjacency() throws { + @Test + func testComplexGraphWithMultipleAdjacency() { // 1 ┌──▶5 // │ │ │ // ▼ │ ▼ @@ -156,10 +159,10 @@ final class DirectedGraphTests: XCTestCase { 7: [8], ]) - assertEqual(graph.breadthFirstSearch(from: 1), [1,2,3,4,5,6,7,8]) - assertEqual(graph.depthFirstSearch(from: 1), [1,2,4,7,8,6,5,3]) + #expect(graph.breadthFirstSearch(from: 1) == [1,2,3,4,5,6,7,8]) + #expect(graph.depthFirstSearch(from: 1) == [1,2,4,7,8,6,5,3]) - XCTAssertEqual(graph.allFinitePaths(from: 1), [ + #expect(graph.allFinitePaths(from: 1) == [ [1,2,4,6,8], [1,2,4,7,8], [1,2,3,4,6,8], @@ -168,17 +171,18 @@ final class DirectedGraphTests: XCTestCase { [1,2,3,4,5,6,8], ]) - XCTAssertEqual(graph.shortestFinitePaths(from: 1), [ + #expect(graph.shortestFinitePaths(from: 1) == [ [1,2,4,6,8], [1,2,4,7,8], ]) for node in 1...8 { - XCTAssertNil(graph.firstCycle(from: node)) + #expect(graph.firstCycle(from: node) == nil) } } - func testSimpleCycle() throws { + @Test + func testSimpleCycle() { do { // ┌──────▶2 // │ │ @@ -192,27 +196,28 @@ final class DirectedGraphTests: XCTestCase { 3: [1], ]) - XCTAssertEqual(graph.cycles(from: 1), [ + #expect(graph.cycles(from: 1) == [ [1,3], ]) - XCTAssertEqual(graph.cycles(from: 2), [ + #expect(graph.cycles(from: 2) == [ [2,3,1], [3,1], ]) - XCTAssertEqual(graph.cycles(from: 3), [ + #expect(graph.cycles(from: 3) == [ [3,1], [3,1,2], ]) for id in [1,2,3] { - XCTAssertEqual(graph.allFinitePaths(from: id), [], "The only path from '\(id)' is infinite (cyclic)") - XCTAssertEqual(graph.shortestFinitePaths(from: id), [], "The only path from '\(id)' is infinite (cyclic)") - XCTAssertEqual(graph.reachableLeafNodes(from: id), [], "The only path from '\(id)' is infinite (cyclic)") + #expect(graph.allFinitePaths(from: id) == [], "The only path from '\(id)' is infinite (cyclic)") + #expect(graph.shortestFinitePaths(from: id) == [], "The only path from '\(id)' is infinite (cyclic)") + #expect(graph.reachableLeafNodes(from: id) == [], "The only path from '\(id)' is infinite (cyclic)") } } } - func testSimpleCycleRotation() throws { + @Test + func testSimpleCycleRotation() { do { // ┌───▶1───▶2 // │ ▲ │ @@ -225,14 +230,15 @@ final class DirectedGraphTests: XCTestCase { 3: [1], ]) - XCTAssertEqual(graph.cycles(from: 0), [ + #expect(graph.cycles(from: 0) == [ [1,2,3], // '3,1,2' and '2,3,1' are both rotations of '1,2,3'. ]) } } - func testGraphWithCycleAndSingleAdjacency() throws { + @Test + func testGraphWithCycleAndSingleAdjacency() { // 1───▶2◀───3 // │ // ▼ @@ -253,30 +259,31 @@ final class DirectedGraphTests: XCTestCase { ]) // With only a single neighbor per node, breadth first and depth first perform the same traversal - assertEqual(graph.breadthFirstSearch(from: 1), [1,2,5,8,9,6]) - assertEqual(graph.depthFirstSearch(from: 1), [1,2,5,8,9,6]) + #expect(graph.breadthFirstSearch(from: 1) == [1,2,5,8,9,6]) + #expect(graph.depthFirstSearch(from: 1) == [1,2,5,8,9,6]) - XCTAssertEqual(graph.allFinitePaths(from: 1), [], "The only path from '1' is infinite (cyclic)") - XCTAssertEqual(graph.shortestFinitePaths(from: 1), [], "The only path from '1' is infinite (cyclic)") - XCTAssertEqual(graph.reachableLeafNodes(from: 1), [], "The only path from '1' is infinite (cyclic)") + #expect(graph.allFinitePaths(from: 1) == [], "The only path from '1' is infinite (cyclic)") + #expect(graph.shortestFinitePaths(from: 1) == [], "The only path from '1' is infinite (cyclic)") + #expect(graph.reachableLeafNodes(from: 1) == [], "The only path from '1' is infinite (cyclic)") for node in [1,2,3,4,5] { - XCTAssertEqual(graph.firstCycle(from: node), [5,8,9,6]) - XCTAssertEqual(graph.cycles(from: node), [[5,8,9,6]]) + #expect(graph.firstCycle(from: node) == [5,8,9,6]) + #expect(graph.cycles(from: node) == [[5,8,9,6]]) } for node in [7,8] { - XCTAssertEqual(graph.firstCycle(from: node), [8,9,6,5]) - XCTAssertEqual(graph.cycles(from: node), [[8,9,6,5]]) + #expect(graph.firstCycle(from: node) == [8,9,6,5]) + #expect(graph.cycles(from: node) == [[8,9,6,5]]) } - XCTAssertEqual(graph.firstCycle(from: 6), [6,5,8,9]) - XCTAssertEqual(graph.cycles(from: 6), [[6,5,8,9]]) + #expect(graph.firstCycle(from: 6) == [6,5,8,9]) + #expect(graph.cycles(from: 6) == [[6,5,8,9]]) - XCTAssertEqual(graph.firstCycle(from: 9), [9,6,5,8]) - XCTAssertEqual(graph.cycles(from: 9), [[9,6,5,8]]) + #expect(graph.firstCycle(from: 9) == [9,6,5,8]) + #expect(graph.cycles(from: 9) == [[9,6,5,8]]) } - func testGraphsWithCycleAndManyLeafNodes() throws { + @Test + func testGraphsWithCycleAndManyLeafNodes() { do { // 6 10 // ▲ ▲ @@ -298,15 +305,15 @@ final class DirectedGraphTests: XCTestCase { 9: [11,7], ]) - XCTAssertEqual(graph.firstCycle(from: 0), [7,9]) - XCTAssertEqual(graph.firstCycle(from: 4), [7,9]) - XCTAssertEqual(graph.firstCycle(from: 5), [9,7]) + #expect(graph.firstCycle(from: 0) == [7,9]) + #expect(graph.firstCycle(from: 4) == [7,9]) + #expect(graph.firstCycle(from: 5) == [9,7]) - XCTAssertEqual(graph.cycles(from: 0), [ + #expect(graph.cycles(from: 0) == [ [7,9], // through breadth-first-traversal, 7 is reached before 9. ]) - XCTAssertEqual(graph.allFinitePaths(from: 0), [ + #expect(graph.allFinitePaths(from: 0) == [ [0,1], [0,2,3], [0,2,4,6], @@ -320,15 +327,16 @@ final class DirectedGraphTests: XCTestCase { [0,2,4,5,9,7,10] ]) - XCTAssertEqual(graph.shortestFinitePaths(from: 0), [ + #expect(graph.shortestFinitePaths(from: 0) == [ [0,1], ]) - XCTAssertEqual(graph.reachableLeafNodes(from: 0), [1,3,6,8,10,11]) + #expect(graph.reachableLeafNodes(from: 0) == [1,3,6,8,10,11]) } } - func testGraphWithManyCycles() throws { + @Test + func testGraphWithManyCycles() { // ┌──┐ ┌───▶4────┐ // │ │ │ │ │ // │ │ │ ▼ ▼ @@ -348,12 +356,12 @@ final class DirectedGraphTests: XCTestCase { 9: [11,7], ]) - XCTAssertEqual(graph.firstCycle(from: 1), [1]) - XCTAssertEqual(graph.firstCycle(from: 2), [2,3]) - XCTAssertEqual(graph.firstCycle(from: 4), [7,9]) - XCTAssertEqual(graph.firstCycle(from: 8), [9,7]) + #expect(graph.firstCycle(from: 1) == [1]) + #expect(graph.firstCycle(from: 2) == [2,3]) + #expect(graph.firstCycle(from: 4) == [7,9]) + #expect(graph.firstCycle(from: 8) == [9,7]) - XCTAssertEqual(graph.cycles(from: 1), [ + #expect(graph.cycles(from: 1) == [ [1], [1,3], // There's also a [1,2,3] cycle but that can also be broken by removing the edge from 3 ──▶ 1. @@ -361,7 +369,7 @@ final class DirectedGraphTests: XCTestCase { [7,9] ]) - XCTAssertEqual(graph.allFinitePaths(from: 1), [ + #expect(graph.allFinitePaths(from: 1) == [ [1, 2, 4, 7, 10], [1, 2, 5, 7, 10], [1, 2, 4, 5, 7, 10], @@ -384,15 +392,16 @@ final class DirectedGraphTests: XCTestCase { [1, 3, 2, 4, 5, 8, 9, 7, 10] ]) - XCTAssertEqual(graph.shortestFinitePaths(from: 1), [ + #expect(graph.shortestFinitePaths(from: 1) == [ [1, 2, 4, 7, 10], [1, 2, 5, 7, 10], ]) - XCTAssertEqual(graph.reachableLeafNodes(from: 1), [10, 11]) + #expect(graph.reachableLeafNodes(from: 1) == [10, 11]) } - func testGraphWithMultiplePathsToEnterCycle() throws { + @Test + func testGraphWithMultiplePathsToEnterCycle() { // ┌─▶2◀─┐ // │ │ │ // │ ▼ │ @@ -409,19 +418,19 @@ final class DirectedGraphTests: XCTestCase { ]) // With only a single neighbor per node, breadth first and depth first perform the same traversal - assertEqual(graph.breadthFirstSearch(from: 1), [1,2,3,4,5]) - assertEqual(graph.depthFirstSearch(from: 1), [1,4,5,2,3]) + #expect(graph.breadthFirstSearch(from: 1) == [1,2,3,4,5]) + #expect(graph.depthFirstSearch(from: 1) == [1,4,5,2,3]) - XCTAssertEqual(graph.allFinitePaths(from: 1), [ + #expect(graph.allFinitePaths(from: 1) == [ // The only path from 1 is cyclic ]) - XCTAssertEqual(graph.shortestFinitePaths(from: 1), [ + #expect(graph.shortestFinitePaths(from: 1) == [ // The only path from 1 is cyclic ]) - XCTAssertEqual(graph.firstCycle(from: 1), [2,3,4,5]) - XCTAssertEqual(graph.cycles(from: 1), [ + #expect(graph.firstCycle(from: 1) == [2,3,4,5]) + #expect(graph.cycles(from: 1) == [ [2,3,4,5] // The other cycles are rotations of the first one. ]) @@ -429,6 +438,8 @@ final class DirectedGraphTests: XCTestCase { } // A private helper to avoid needing to wrap the breadth first and depth first sequences into arrays to compare them. -private func assertEqual(_ lhs: some Sequence, _ rhs: some Sequence, file: StaticString = #filePath, line: UInt = #line) { - XCTAssertEqual(Array(lhs), Array(rhs), file: file, line: line) + +@_disfavoredOverload // Don't use this overload if the type is known (for example `Set`) +private func == (lhs: some Sequence, rhs: some Sequence) -> Bool { + lhs.elementsEqual(rhs) } From 82c7138dc50dc5652e8a29415542ec614aa16417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Tue, 16 Dec 2025 16:29:17 +0100 Subject: [PATCH 8/9] Elaborate on recommendations for writing new tests --- CONTRIBUTING.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7555ecf4d..ca355ab5f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -213,10 +213,69 @@ Please use [Swift Testing](https://developer.apple.com/documentation/testing) wh Currently there are few existing tests to draw inspiration from, so here are a few recommendations: - Prefer small test inputs that ideally use a virtual file system for both reading and writing. + + For example, if you want to test a behavior related to a symbol's in-source documentation and its documentation extension file, you only need one symbol for that. + You can use `load(catalog:...)`, `makeSymbolGraph(...)`, and `makeSymbol(...)` to define such inputs in a virtual file system and create a `DocumentationContext` from it: + + ```swift + let catalog = Folder(name: "Something.docc", content: [ + JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(moduleName: "ModuleName", symbols: [ + makeSymbol(id: "some-symbol-id", kind: .class, pathComponents: ["SomeClass"], docComment: """ + This is the in-source documentation for this class. + """) + ])), + + TextFile(name: "Something.md", utf8Content: """ + # ``SomeClass`` + + This is additional documentation for this class + """), + ]) + let context = try await load(catalog: catalog) + // Test rest of your test + ``` + - Consider using parameterized tests if you're making the same verifications in multiple configurations or on multiple elements. + + You can find some examples of this if you search for `@Test(arguments:`. + Additionally, you might encounter a `XCTestCase` test that loops over one or more values and performs the same validation for all combinations: + ```swift + for withExplicitTechnologyRoot in [true, false] { + for withPageColor in [true, false] { + ... + ``` + Such `XCTestCase` tests can sometimes be expressed more nicely as parameterized tests in Swift Testing. + - Think about what information would be helpful to someone else who might debug that test case if it fails in the future. + + In an open source project like Swift-DocC, it's possible that a person you've never met will continue to work on code that you wrote. + It could be that they're working on the same feature as you but it could also be that they're working on something entirely different but that their changes broke a test that you wrote. + To help make their experience better, we appreciate any time that you spend considering if there's any information that you would have wanted to tell that person, as if they were a colleague. + + One way to convey this information could be to verify assumptions (like "this test content has no user-facing warnings") using `#expect`. + Additionally, if there's any information that you can surface right in the test failure that will save the next developer from needing to add a breakpoint and run the test again to inspect the value, + that's a nice small little thing that you can do for the developer coming after you: + ```swift + #expect(problems.isEmpty, "Unexpected problems: \(problems.map(\.diagnostic.summary))") + ``` + + Similarly, code comments or `#expect` descriptions can be a way to convey information about _why_ the test is expecting a _specific_ value. + ```swift + #expect(graph.cycles(from: 0) == [ + [7,9], // through breadth-first-traversal, 7 is reached before 9. + ]) + ``` + That reason may be clear to you, but could be a mystery to a person who is unfamiliar with that part of the code base---or even a future you that may have forgotten certain details about how the code works. + - Use `#require` rather that force unwrapping for behaviors that would change due to unexpected bugs in the code you're testing. + If you know that some value will always be non-`nil` only _because_ the rest of the code behaves correctly, consider writing the test more defensively using `#require` instead of force unwrapping the value. + This has the benefit that if someone else working on Swift-DocC introduces a bug in that behavior that the test relied on, then the test will fail gracefully rather than crashing and aborting the rest of the test execution. + + A similar situation occurs when you "know" that an array contains _N_ elements. If your test accesses them through indexed subscripting, it will trap if that array was unexpectedly short due to a bug that someone introduced. + In this situation you can use `problems.dropFirst(N-1).first` to access the _Nth_ element safely. + This could either be used as an optional value in a `#expect` call, or be unwrapped using `#require` depending on how the element is used in the test. + If you're updating an existing test case with additional logic, we appreciate if you also modernize that test while updating it, but we don't expect it. If the test case is part of a large file, you can create new test suite which contains just the test case that you're modernizing. From b7a80330e1d975000130fb0832420f46afa5e015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Tue, 16 Dec 2025 18:54:33 +0100 Subject: [PATCH 9/9] Fix whitespace in license comments to work with check-source script --- Tests/SwiftDocCTests/Testing+LoadingTestData.swift | 6 +++--- Tests/SwiftDocCTests/Testing+ParseDirective.swift | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Tests/SwiftDocCTests/Testing+LoadingTestData.swift b/Tests/SwiftDocCTests/Testing+LoadingTestData.swift index 839f543916..38dca73f2a 100644 --- a/Tests/SwiftDocCTests/Testing+LoadingTestData.swift +++ b/Tests/SwiftDocCTests/Testing+LoadingTestData.swift @@ -1,12 +1,12 @@ /* This source file is part of the Swift.org open source project - + Copyright (c) 2021-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 Testing diff --git a/Tests/SwiftDocCTests/Testing+ParseDirective.swift b/Tests/SwiftDocCTests/Testing+ParseDirective.swift index 7672b6249a..8362fd4d2d 100644 --- a/Tests/SwiftDocCTests/Testing+ParseDirective.swift +++ b/Tests/SwiftDocCTests/Testing+ParseDirective.swift @@ -1,12 +1,12 @@ /* This source file is part of the Swift.org open source project - + Copyright (c) 2021-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 Testing