From 23f88cdabf4f2cb5c8c4db3ab368cbdd67bfc5f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 18 Dec 2025 14:30:40 +0100 Subject: [PATCH] Add data loading test helpers for Swift Testing and draft contributor information about writing tests (#1362) * Add data loading test helpers for Swift Testing * Update existing test helpers to call into the Swift Testing helpers where possible * Draft new contributor information about adding and updating tests * Update a few test suites to Swift Testing to use as examples/inspiration for new tests * Update more tests to use Swift Testing * Apply phrasing suggestions from code review Co-authored-by: Joseph Heck * Update one more test class to use Swift Testing * Elaborate on recommendations for writing new tests * Fix whitespace in license comments to work with check-source script * Apply suggestions from code review Co-authored-by: Maya Epps <53411851+mayaepps@users.noreply.github.com> * Un-indent symbol graph creation helpers * Add documentation comments for new data loading test helpers * Remove "test" prefix in names of existing Swift Testing tests * Add paragraph about naming tests --------- Co-authored-by: Joseph Heck Co-authored-by: Maya Epps <53411851+mayaepps@users.noreply.github.com> --- CONTRIBUTING.md | 87 ++++- .../SymbolGraphCreation.swift | 342 +++++++++--------- .../FixedSizeBitSetTests.swift | 10 +- .../SmallSourceLanguageSetTests.swift | 6 +- .../DocCCommonTests/SourceLanguageTests.swift | 12 +- .../MarkdownRenderer+PageElementsTests.swift | 20 +- .../DocCHTMLTests/MarkdownRendererTests.swift | 20 +- Tests/DocCHTMLTests/WordBreakTests.swift | 2 +- .../NonInclusiveLanguageCheckerTests.swift | 208 +++++------ .../AutoCapitalizationTests.swift | 2 +- .../DocumentationCuratorTests.swift | 288 +++++++-------- .../ParseDirectiveArgumentsTests.swift | 53 +-- .../PathHierarchyBasedLinkResolverTests.swift | 26 +- ...tendedTypesFormatTransformationTests.swift | 1 + .../Model/DocumentationNodeTests.swift | 1 + .../ParametersAndReturnValidatorTests.swift | 2 +- .../Semantics/SymbolTests.swift | 2 +- .../Testing+LoadingTestData.swift | 141 ++++++++ .../Testing+ParseDirective.swift | 183 ++++++++++ .../XCTestCase+LoadingTestData.swift | 82 ++--- .../Utility/DirectedGraphTests.swift | 213 +++++------ 21 files changed, 1036 insertions(+), 665 deletions(-) create mode 100644 Tests/SwiftDocCTests/Testing+LoadingTestData.swift create mode 100644 Tests/SwiftDocCTests/Testing+ParseDirective.swift diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da6d76fb8f..2dedb89704 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,90 @@ 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 + +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. + + 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, or it could also be that they're working on something entirely different but 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. + +- Use a descriptive and readable phrase as the test name. + + It can be easier to understand a test's implementation if its name describes the _behavior_ that the test verifies. + A phrase that start with a verb can often help make a test's name a more readable description of what it's verifying. + For example: `sortsSwiftFirstAndThenByID`, `raisesDiagnosticAboutCyclicCuration`, `isDisabledByDefault`, and `considersCurationInUncuratedAPICollection`. + +### Updating existing tests + +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, +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`: diff --git a/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift b/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift index 8bd48ead94..1823d055c5 100644 --- a/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift +++ b/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift @@ -1,202 +1,198 @@ /* 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(), - symbols: [SymbolGraph.Symbol] = [], - relationships: [SymbolGraph.Relationship] = [] - ) -> SymbolGraph { - return SymbolGraph( - metadata: makeMetadata(), - module: makeModule(moduleName: moduleName, platform: platform), - symbols: symbols, - relationships: relationships - ) - } - - package func makeMetadata(major: Int = 0, minor: Int = 6, patch: Int = 0) -> SymbolGraph.Metadata { - SymbolGraph.Metadata( - formatVersion: SymbolGraph.SemanticVersion(major: major, minor: minor, patch: patch), - generator: "unit-test" - ) - } - - package func makeModule(moduleName: String, platform: SymbolGraph.Platform = .init()) -> SymbolGraph.Module { - SymbolGraph.Module(name: moduleName, platform: platform) - } - - // MARK: Line List - - package func makeLineList( - docComment: String, - moduleName: String?, - startOffset: SymbolGraph.LineList.SourceRange.Position = defaultSymbolPosition, - url: URL = defaultSymbolURL - ) -> SymbolGraph.LineList { - SymbolGraph.LineList( - // Create a `LineList/Line` for each line of the doc comment and calculate a realistic range for each line. - docComment.components(separatedBy: .newlines) - .enumerated() - .map { lineOffset, line in - SymbolGraph.LineList.Line( - text: line, - range: SymbolGraph.LineList.SourceRange( - start: .init(line: startOffset.line + lineOffset, character: startOffset.character), - end: .init(line: startOffset.line + lineOffset, character: startOffset.character + line.count) - ) +package func makeSymbolGraph( + moduleName: String, + platform: SymbolGraph.Platform = .init(), + symbols: [SymbolGraph.Symbol] = [], + relationships: [SymbolGraph.Relationship] = [] +) -> SymbolGraph { + return SymbolGraph( + metadata: makeMetadata(), + module: makeModule(moduleName: moduleName, platform: platform), + symbols: symbols, + relationships: relationships + ) +} + +package func makeMetadata(major: Int = 0, minor: Int = 6, patch: Int = 0) -> SymbolGraph.Metadata { + SymbolGraph.Metadata( + formatVersion: SymbolGraph.SemanticVersion(major: major, minor: minor, patch: patch), + generator: "unit-test" + ) +} + +package func makeModule(moduleName: String, platform: SymbolGraph.Platform = .init()) -> SymbolGraph.Module { + SymbolGraph.Module(name: moduleName, platform: platform) +} + +// MARK: Line List + +package func makeLineList( + docComment: String, + moduleName: String?, + startOffset: SymbolGraph.LineList.SourceRange.Position = defaultSymbolPosition, + url: URL = defaultSymbolURL +) -> SymbolGraph.LineList { + SymbolGraph.LineList( + // Create a `LineList/Line` for each line of the doc comment and calculate a realistic range for each line. + docComment.components(separatedBy: .newlines) + .enumerated() + .map { lineOffset, line in + SymbolGraph.LineList.Line( + text: line, + range: SymbolGraph.LineList.SourceRange( + start: .init(line: startOffset.line + lineOffset, character: startOffset.character), + end: .init(line: startOffset.line + lineOffset, character: startOffset.character + line.count) ) - }, - // We want to include the file:// scheme here - uri: url.absoluteString, - moduleName: moduleName - ) - } - - package func makeMixins(_ mixins: [any Mixin]) -> [String: any Mixin] { - [String: any Mixin]( - mixins.map { (type(of: $0).mixinKey, $0) }, - uniquingKeysWith: { old, _ in old /* Keep the first encountered value */ } - ) - } - - // MARK: Symbol - - package func makeSymbol( - id: String, - language: SourceLanguage = .swift, - kind kindID: SymbolGraph.Symbol.KindIdentifier, - pathComponents: [String], - docComment: String? = nil, - moduleName: String? = nil, - accessLevel: SymbolGraph.Symbol.AccessControl = .init(rawValue: "public"), // Defined internally in SwiftDocC - location: (position: SymbolGraph.LineList.SourceRange.Position, url: URL)? = (defaultSymbolPosition, defaultSymbolURL), - signature: SymbolGraph.Symbol.FunctionSignature? = nil, - availability: [SymbolGraph.Symbol.Availability.AvailabilityItem]? = nil, - declaration: [SymbolGraph.Symbol.DeclarationFragments.Fragment]? = nil, - otherMixins: [any Mixin] = [] - ) -> SymbolGraph.Symbol { - precondition(!pathComponents.isEmpty, "Need at least one path component to name the symbol") - - var mixins = otherMixins // Earlier mixins are prioritized if there are duplicates - if let location { - mixins.append(SymbolGraph.Symbol.Location(uri: location.url.absoluteString /* we want to include the file:// scheme */, position: location.position)) - } - if let signature { - mixins.append(signature) - } - if let availability { - mixins.append(SymbolGraph.Symbol.Availability(availability: availability)) - } - if let declaration { - mixins.append(SymbolGraph.Symbol.DeclarationFragments(declarationFragments: declaration)) - } - - let names = if let declaration { - SymbolGraph.Symbol.Names( - title: pathComponents.last!, // Verified above to exist - navigator: declaration, - subHeading: declaration, - prose: nil - ) - } else { - makeSymbolNames(name: pathComponents.last!) // Verified above to exist - } - - return SymbolGraph.Symbol( - identifier: SymbolGraph.Symbol.Identifier(precise: id, interfaceLanguage: language.id), - names: names, - pathComponents: pathComponents, - docComment: docComment.map { - makeLineList( - docComment: $0, - moduleName: moduleName, - startOffset: location?.position ?? defaultSymbolPosition, - url: location?.url ?? defaultSymbolURL ) }, - accessLevel: accessLevel, - kind: makeSymbolKind(kindID), - mixins: makeMixins(mixins) - ) - } + // We want to include the file:// scheme here + uri: url.absoluteString, + moduleName: moduleName + ) +} + +package func makeMixins(_ mixins: [any Mixin]) -> [String: any Mixin] { + [String: any Mixin]( + mixins.map { (type(of: $0).mixinKey, $0) }, + uniquingKeysWith: { old, _ in old /* Keep the first encountered value */ } + ) +} + +// MARK: Symbol + +package func makeSymbol( + id: String, + language: SourceLanguage = .swift, + kind kindID: SymbolGraph.Symbol.KindIdentifier, + pathComponents: [String], + docComment: String? = nil, + moduleName: String? = nil, + accessLevel: SymbolGraph.Symbol.AccessControl = .init(rawValue: "public"), // Defined internally in SwiftDocC + location: (position: SymbolGraph.LineList.SourceRange.Position, url: URL)? = (defaultSymbolPosition, defaultSymbolURL), + signature: SymbolGraph.Symbol.FunctionSignature? = nil, + availability: [SymbolGraph.Symbol.Availability.AvailabilityItem]? = nil, + declaration: [SymbolGraph.Symbol.DeclarationFragments.Fragment]? = nil, + otherMixins: [any Mixin] = [] +) -> SymbolGraph.Symbol { + precondition(!pathComponents.isEmpty, "Need at least one path component to name the symbol") - package func makeAvailabilityItem( - domainName: String, - introduced: SymbolGraph.SemanticVersion? = nil, - deprecated: SymbolGraph.SemanticVersion? = nil, - obsoleted: SymbolGraph.SemanticVersion? = nil, - unconditionallyUnavailable: Bool = false - ) -> SymbolGraph.Symbol.Availability.AvailabilityItem { - return SymbolGraph.Symbol.Availability.AvailabilityItem(domain: .init(rawValue: domainName), introducedVersion: introduced, deprecatedVersion: deprecated, obsoletedVersion: obsoleted, message: nil, renamed: nil, isUnconditionallyDeprecated: false, isUnconditionallyUnavailable: unconditionallyUnavailable, willEventuallyBeDeprecated: false) + var mixins = otherMixins // Earlier mixins are prioritized if there are duplicates + if let location { + mixins.append(SymbolGraph.Symbol.Location(uri: location.url.absoluteString /* we want to include the file:// scheme */, position: location.position)) + } + if let signature { + mixins.append(signature) + } + if let availability { + mixins.append(SymbolGraph.Symbol.Availability(availability: availability)) + } + if let declaration { + mixins.append(SymbolGraph.Symbol.DeclarationFragments(declarationFragments: declaration)) } - package func makeSymbolNames(name: String) -> SymbolGraph.Symbol.Names { + let names = if let declaration { SymbolGraph.Symbol.Names( - title: name, - navigator: [.init(kind: .identifier, spelling: name, preciseIdentifier: nil)], - subHeading: [.init(kind: .identifier, spelling: name, preciseIdentifier: nil)], + title: pathComponents.last!, // Verified above to exist + navigator: declaration, + subHeading: declaration, prose: nil ) + } else { + makeSymbolNames(name: pathComponents.last!) // Verified above to exist } - package func makeSymbolKind(_ kindID: SymbolGraph.Symbol.KindIdentifier) -> SymbolGraph.Symbol.Kind { - var documentationNodeKind: DocumentationNode.Kind { - switch kindID { - case .associatedtype: .associatedType - case .class: .class - case .deinit: .deinitializer - case .enum: .enumeration - case .case: .enumerationCase - case .func: .function - case .operator: .operator - case .`init`: .initializer - case .ivar: .instanceVariable - case .macro: .macro - case .method: .instanceMethod - case .namespace: .namespace - case .property: .instanceProperty - case .protocol: .protocol - case .snippet: .snippet - case .struct: .structure - case .subscript: .instanceSubscript - case .typeMethod: .typeMethod - case .typeProperty: .typeProperty - case .typeSubscript: .typeSubscript - case .typealias: .typeAlias - case .union: .union - case .var: .globalVariable - case .module: .module - case .extension: .extension - case .dictionary: .dictionary - case .dictionaryKey: .dictionaryKey - case .httpRequest: .httpRequest - case .httpParameter: .httpParameter - case .httpResponse: .httpResponse - case .httpBody: .httpBody - default: .unknown - } + return SymbolGraph.Symbol( + identifier: SymbolGraph.Symbol.Identifier(precise: id, interfaceLanguage: language.id), + names: names, + pathComponents: pathComponents, + docComment: docComment.map { + makeLineList( + docComment: $0, + moduleName: moduleName, + startOffset: location?.position ?? defaultSymbolPosition, + url: location?.url ?? defaultSymbolURL + ) + }, + accessLevel: accessLevel, + kind: makeSymbolKind(kindID), + mixins: makeMixins(mixins) + ) +} + +package func makeAvailabilityItem( + domainName: String, + introduced: SymbolGraph.SemanticVersion? = nil, + deprecated: SymbolGraph.SemanticVersion? = nil, + obsoleted: SymbolGraph.SemanticVersion? = nil, + unconditionallyUnavailable: Bool = false +) -> SymbolGraph.Symbol.Availability.AvailabilityItem { + return SymbolGraph.Symbol.Availability.AvailabilityItem(domain: .init(rawValue: domainName), introducedVersion: introduced, deprecatedVersion: deprecated, obsoletedVersion: obsoleted, message: nil, renamed: nil, isUnconditionallyDeprecated: false, isUnconditionallyUnavailable: unconditionallyUnavailable, willEventuallyBeDeprecated: false) +} + +package func makeSymbolNames(name: String) -> SymbolGraph.Symbol.Names { + SymbolGraph.Symbol.Names( + title: name, + navigator: [.init(kind: .identifier, spelling: name, preciseIdentifier: nil)], + subHeading: [.init(kind: .identifier, spelling: name, preciseIdentifier: nil)], + prose: nil + ) +} + +package func makeSymbolKind(_ kindID: SymbolGraph.Symbol.KindIdentifier) -> SymbolGraph.Symbol.Kind { + var documentationNodeKind: DocumentationNode.Kind { + switch kindID { + case .associatedtype: .associatedType + case .class: .class + case .deinit: .deinitializer + case .enum: .enumeration + case .case: .enumerationCase + case .func: .function + case .operator: .operator + case .`init`: .initializer + case .ivar: .instanceVariable + case .macro: .macro + case .method: .instanceMethod + case .namespace: .namespace + case .property: .instanceProperty + case .protocol: .protocol + case .snippet: .snippet + case .struct: .structure + case .subscript: .instanceSubscript + case .typeMethod: .typeMethod + case .typeProperty: .typeProperty + case .typeSubscript: .typeSubscript + case .typealias: .typeAlias + case .union: .union + case .var: .globalVariable + case .module: .module + case .extension: .extension + case .dictionary: .dictionary + case .dictionaryKey: .dictionaryKey + case .httpRequest: .httpRequest + case .httpParameter: .httpParameter + case .httpResponse: .httpResponse + case .httpBody: .httpBody + default: .unknown } - return SymbolGraph.Symbol.Kind(parsedIdentifier: kindID, displayName: documentationNodeKind.name) } + 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/DocCCommonTests/FixedSizeBitSetTests.swift b/Tests/DocCCommonTests/FixedSizeBitSetTests.swift index 22a1d91de0..5f19d225eb 100644 --- a/Tests/DocCCommonTests/FixedSizeBitSetTests.swift +++ b/Tests/DocCCommonTests/FixedSizeBitSetTests.swift @@ -13,7 +13,7 @@ import Testing struct FixedSizeBitSetTests { @Test - func testBehavesSameAsSet() { + func behavesSameAsSet() { var tiny = _FixedSizeBitSet() var real = Set() @@ -83,7 +83,7 @@ struct FixedSizeBitSetTests { [ 7,8, 11, 14], [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15] ]) - func testBehavesSameAsArray(_ real: [Int]) throws { + func behavesSameAsArray(_ real: [Int]) throws { let tiny = _FixedSizeBitSet(real) #expect(tiny.elementsEqual(real)) @@ -159,8 +159,8 @@ struct FixedSizeBitSetTests { } } - @Test() - func testCombinations() { + @Test + func producingCombinations() { do { let tiny: _FixedSizeBitSet = [0,1,2] #expect(tiny.allCombinationsOfValues().map { $0.sorted() } == [ @@ -229,7 +229,7 @@ struct FixedSizeBitSetTests { } @Test - func testIsSameSizeAsWrappedStorageType() async { + func isSameSizeAsWrappedStorageType() async { // Size #expect(MemoryLayout<_FixedSizeBitSet< Int8 >>.size == MemoryLayout< Int8 >.size) #expect(MemoryLayout<_FixedSizeBitSet< UInt8 >>.size == MemoryLayout< UInt8 >.size) diff --git a/Tests/DocCCommonTests/SmallSourceLanguageSetTests.swift b/Tests/DocCCommonTests/SmallSourceLanguageSetTests.swift index 6270ce6225..e6b3bb63b1 100644 --- a/Tests/DocCCommonTests/SmallSourceLanguageSetTests.swift +++ b/Tests/DocCCommonTests/SmallSourceLanguageSetTests.swift @@ -14,7 +14,7 @@ import Foundation struct SmallSourceLanguageSetTests { @Test - func testBehavesSameAsSet() { + func behavesSameAsSet() { var tiny = SmallSourceLanguageSet() var real = Set() @@ -86,7 +86,7 @@ struct SmallSourceLanguageSetTests { } @Test - func testSortsSwiftFirstAndThenByID() { + func sortsSwiftFirstAndThenByID() { var languages = SmallSourceLanguageSet(SourceLanguage.knownLanguages) #expect(languages.min()?.name == "Swift") #expect(languages.count == 5) @@ -146,7 +146,7 @@ struct SmallSourceLanguageSetTests { } @Test - func testIsSameSizeAsUInt64() { + func isSameSizeAsUInt64() { #expect(MemoryLayout.size == MemoryLayout.size) #expect(MemoryLayout.stride == MemoryLayout.stride) #expect(MemoryLayout.alignment == MemoryLayout.alignment) diff --git a/Tests/DocCCommonTests/SourceLanguageTests.swift b/Tests/DocCCommonTests/SourceLanguageTests.swift index 111c79e6e5..36a11e3d35 100644 --- a/Tests/DocCCommonTests/SourceLanguageTests.swift +++ b/Tests/DocCCommonTests/SourceLanguageTests.swift @@ -14,7 +14,7 @@ import Foundation struct SourceLanguageTests { @Test(arguments: SourceLanguage.knownLanguages) - func testUsesIDAliasesWhenQueryingFirstKnownLanguage(_ language: SourceLanguage) { + func usesIDAliasesWhenQueryingFirstKnownLanguage(_ language: SourceLanguage) { #expect(SourceLanguage(id: language.id) == language) for alias in language.idAliases { #expect(SourceLanguage(id: alias) == language, "Unexpectedly found different language for id alias '\(alias)'") @@ -25,7 +25,7 @@ struct SourceLanguageTests { // Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. @available(*, deprecated) @Test - func testHasValueSemanticsForBothKnownAndUnknownLanguages() throws { + func hasValueSemanticsForBothKnownAndUnknownLanguages() throws { var original = SourceLanguage.swift var copy = original copy.name = "First" @@ -46,7 +46,7 @@ struct SourceLanguageTests { } @Test - func testReusesExistingValuesWhenCreatingLanguages() throws { + func reusesExistingValuesWhenCreatingLanguages() throws { // Creating more than 256 languages would fail if SourceLanguage initializer didn't reuse existing values let numberOfIterations = 300 // anything more than `UInt8.max` @@ -88,7 +88,7 @@ struct SourceLanguageTests { // Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. @available(*, deprecated) @Test - func testReusesExistingValuesModifyingProperties() { + func reusesExistingValuesModifyingProperties() { // Creating more than 256 languages would fail if SourceLanguage initializer didn't reuse existing values let numberOfIterations = 300 // anything more than `UInt8.max` @@ -105,13 +105,13 @@ struct SourceLanguageTests { (SourceLanguage.javaScript, "JavaScript"), (SourceLanguage.metal, "Metal"), ]) - func testNameOfKnownLanguage(language: SourceLanguage, matches expectedName: String) { + func nameOfKnownLanguage(_ language: SourceLanguage, matches expectedName: String) { // Known languages have their own dedicated implementation that requires two implementation detail values to be consistent. #expect(language.name == expectedName) } @Test - func testSortsSwiftFirstAndThenByID() throws { + func sortsSwiftFirstAndThenByID() throws { var languages = SourceLanguage.knownLanguages #expect(languages.min()?.name == "Swift") #expect(languages.sorted().map(\.name) == [ diff --git a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift index e606d648a2..0fcbbe58c8 100644 --- a/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift +++ b/Tests/DocCHTMLTests/MarkdownRenderer+PageElementsTests.swift @@ -22,7 +22,7 @@ import Markdown struct MarkdownRenderer_PageElementsTests { @Test(arguments: RenderGoal.allCases) - func testRenderBreadcrumbs(goal: RenderGoal) { + func renderingBreadcrumbs(goal: RenderGoal) { let elements = [ LinkedElement( path: URL(string: "/documentation/ModuleName/index.html")!, @@ -84,7 +84,7 @@ struct MarkdownRenderer_PageElementsTests { } @Test(arguments: RenderGoal.allCases) - func testRenderAvailability(goal: RenderGoal) { + func renderingAvailability(goal: RenderGoal) { let availability = makeRenderer(goal: goal).availability([ .init(name: "First", introduced: "1.2", deprecated: "3.4", isBeta: false), .init(name: "Second", introduced: "1.2.3", isBeta: false), @@ -111,7 +111,7 @@ struct MarkdownRenderer_PageElementsTests { } @Test(arguments: RenderGoal.allCases) - func testRenderSingleLanguageParameters(goal: RenderGoal) { + func renderingSingleLanguageParameters(goal: RenderGoal) { let parameters = makeRenderer(goal: goal).parameters([ .swift: [ .init(name: "First", content: parseMarkup(string: "Some _formatted_ description with `code`")), @@ -175,7 +175,7 @@ struct MarkdownRenderer_PageElementsTests { } @Test - func testRenderLanguageSpecificParameters() { + func renderingLanguageSpecificParameters() { let parameters = makeRenderer(goal: .richness).parameters([ .swift: [ .init(name: "FirstCommon", content: parseMarkup(string: "Available in both languages")), @@ -224,7 +224,7 @@ struct MarkdownRenderer_PageElementsTests { } @Test - func testRenderManyLanguageSpecificParameters() { + func renderingManyLanguageSpecificParameters() { let parameters = makeRenderer(goal: .richness).parameters([ .swift: [ .init(name: "First", content: parseMarkup(string: "Some description")), @@ -270,7 +270,7 @@ struct MarkdownRenderer_PageElementsTests { } @Test(arguments: RenderGoal.allCases) - func testRenderSingleLanguageReturnSections(goal: RenderGoal) { + func renderingSingleLanguageReturnSections(goal: RenderGoal) { let returns = makeRenderer(goal: goal).returns([ .swift: parseMarkup(string: "First paragraph\n\nSecond paragraph") ]) @@ -299,7 +299,7 @@ struct MarkdownRenderer_PageElementsTests { } @Test(arguments: RenderGoal.allCases) - func testRenderLanguageSpecificReturnSections(goal: RenderGoal) { + func renderingLanguageSpecificReturnSections(goal: RenderGoal) { let returns = makeRenderer(goal: goal).returns([ .swift: parseMarkup(string: "First paragraph\n\nSecond paragraph"), .objectiveC: parseMarkup(string: "Other language's paragraph"), @@ -330,7 +330,7 @@ struct MarkdownRenderer_PageElementsTests { } @Test(arguments: RenderGoal.allCases) - func testRenderSwiftDeclaration(goal: RenderGoal) { + func renderingSwiftDeclaration(goal: RenderGoal) { let symbolPaths = [ "first-parameter-symbol-id": URL(string: "/documentation/ModuleName/FirstParameterValue/index.html")!, "second-parameter-symbol-id": URL(string: "/documentation/ModuleName/SecondParameterValue/index.html")!, @@ -388,7 +388,7 @@ struct MarkdownRenderer_PageElementsTests { } @Test(arguments: RenderGoal.allCases) - func testRenderLanguageSpecificDeclarations(goal: RenderGoal) { + func renderingLanguageSpecificDeclarations(goal: RenderGoal) { let symbolPaths = [ "first-parameter-symbol-id": URL(string: "/documentation/ModuleName/FirstParameterValue/index.html")!, "second-parameter-symbol-id": URL(string: "/documentation/ModuleName/SecondParameterValue/index.html")!, @@ -483,7 +483,7 @@ struct MarkdownRenderer_PageElementsTests { } @Test(arguments: RenderGoal.allCases, ["Topics", "See Also"]) - func testRenderSingleLanguageGroupedSectionsWithMultiLanguageLinks(goal: RenderGoal, expectedGroupTitle: String) { + func renderingSingleLanguageGroupedSectionsWithMultiLanguageLinks(goal: RenderGoal, expectedGroupTitle: String) { let elements = [ LinkedElement( path: URL(string: "/documentation/ModuleName/SomeClass/index.html")!, diff --git a/Tests/DocCHTMLTests/MarkdownRendererTests.swift b/Tests/DocCHTMLTests/MarkdownRendererTests.swift index 2f8eeec8de..31bd54ff23 100644 --- a/Tests/DocCHTMLTests/MarkdownRendererTests.swift +++ b/Tests/DocCHTMLTests/MarkdownRendererTests.swift @@ -22,7 +22,7 @@ import Markdown struct MarkdownRendererTests { @Test - func testRenderParagraphsWithFormattedText() { + func renderingParagraphsWithFormattedText() { assert( rendering: "This is a paragraph with _emphasized_ and **strong** text.", matches: "

This is a paragraph with emphasized and strong text.

" @@ -40,7 +40,7 @@ struct MarkdownRendererTests { } @Test - func testRenderHeadings() { + func renderingHeadings() { assert( rendering: """ # One @@ -112,7 +112,7 @@ struct MarkdownRendererTests { } @Test - func testRenderTables() { + func renderingTables() { assert( rendering: """ First | Second | @@ -218,7 +218,7 @@ struct MarkdownRendererTests { } @Test - func testRenderLists() { + func renderingLists() { assert( rendering: """ - First @@ -269,7 +269,7 @@ struct MarkdownRendererTests { } @Test - func testRenderAsides() async throws { + func renderingAsides() async throws { assert( rendering: """ > Note: Something noteworthy @@ -291,7 +291,7 @@ struct MarkdownRendererTests { } @Test - func testRenderCodeBlocks() async throws { + func renderingCodeBlocks() async throws { assert( rendering: """ ~~~ @@ -337,7 +337,7 @@ struct MarkdownRendererTests { } @Test - func testRenderMiscellaneousElements() { + func renderingMiscellaneousElements() { assert( rendering: "First\nSecond", // new lines usually have no special meaning in markdown... matches: "

First Second

" @@ -357,7 +357,7 @@ struct MarkdownRendererTests { } @Test - func testRelativeLinksToOtherPages() throws { + func renderingRelativeLinksToOtherPages() throws { // Link to article assert( rendering: "", // Simulate a link that's been locally resolved already @@ -467,7 +467,7 @@ struct MarkdownRendererTests { } @Test - func testRelativeLinksToImages() throws { + func renderingRelativeLinksToImages() throws { // Only a single image representation assert( rendering: "![Some alt text](some-image.png)", @@ -549,7 +549,7 @@ struct MarkdownRendererTests { } @Test - func testParsesAndPreservesHTMLExceptComments() { + func parsesAndPreservesHTMLExceptComments() { assert( rendering: "This is a formatted paragraph.", matches: "

This is a formatted paragraph.

" diff --git a/Tests/DocCHTMLTests/WordBreakTests.swift b/Tests/DocCHTMLTests/WordBreakTests.swift index 41b240dac8..29623beb31 100644 --- a/Tests/DocCHTMLTests/WordBreakTests.swift +++ b/Tests/DocCHTMLTests/WordBreakTests.swift @@ -21,7 +21,7 @@ import DocCHTML struct WordBreakTests { @Test - func testWordBreaks() { + func insertsWordBreaks() { assertWordBreaks(for: "doSomething(withFirst:andSecond:)", matches: """ do diff --git a/Tests/SwiftDocCTests/Checker/Checkers/NonInclusiveLanguageCheckerTests.swift b/Tests/SwiftDocCTests/Checker/Checkers/NonInclusiveLanguageCheckerTests.swift index 1073bc693e..ce2ea26ab5 100644 --- a/Tests/SwiftDocCTests/Checker/Checkers/NonInclusiveLanguageCheckerTests.swift +++ b/Tests/SwiftDocCTests/Checker/Checkers/NonInclusiveLanguageCheckerTests.swift @@ -8,31 +8,32 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -import XCTest +import Testing import Markdown @testable import SwiftDocC import SwiftDocCTestUtilities -class NonInclusiveLanguageCheckerTests: XCTestCase { - - func testMatchTermInTitle() throws { +struct NonInclusiveLanguageCheckerTests { + @Test + func matchesTermsInTitle() throws { let source = """ # A Whitelisted title """ 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) } - func testMatchTermWithSpaces() throws { + @Test + func matchesTermsWithSpaces() throws { let source = """ # A White listed title # A Black listed title @@ -41,31 +42,32 @@ 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) } - func testMatchTermInAbstract() throws { + @Test + func matchesTermsInAbstract() throws { let source = """ # Title @@ -74,17 +76,18 @@ 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) } - func testMatchTermInParagraph() throws { + @Test + func matchesTermsInParagraph() throws { let source = """ # Title @@ -98,17 +101,18 @@ 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) } - func testMatchTermInList() throws { + @Test + func matchesTermsInList() throws { let source = """ - Item 1 is ok - Item 2 is blacklisted @@ -117,34 +121,36 @@ 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) } - func testMatchTermInInlineCode() throws { + @Test + func matchesTermsInInlineCode() throws { let source = """ 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) } - func testMatchTermInCodeBlock() throws { + @Test + func matchesTermsInCodeBlock() 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 """ - func testDisabledByDefault() async throws { + @Test + func isDisabledByDefault() 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 raisesDiagnostics(configuredDiagnosticFilterLevel: DiagnosticSeverity, expectsToIncludeNonInclusiveDiagnostics: Bool) async throws { + let catalog = Folder(name: "unit-test.docc", content: [ + TextFile(name: "Root.md", utf8Content: nonInclusiveContent) + ]) + var configuration = DocumentationContext.Configuration() + configuration.externalMetadata.diagnosticLevel = configuredDiagnosticFilterLevel + let context = try await load(catalog: catalog, diagnosticFilterLevel: configuredDiagnosticFilterLevel, 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" }) == expectsToIncludeNonInclusiveDiagnostics) } } 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..8117f868f3 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 raisesDiagnosticAboutCyclicCuration() 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 considersCurationInUncuratedAPICollection(shouldExplicitlyCurateAPICollection: 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 \(shouldExplicitlyCurateAPICollection ? "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`` + + \(shouldExplicitlyCurateAPICollection ? "## 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 #require(context.soleRootModuleReference) + let rootRenderNode = converter.convert(try context.entity(with: moduleReference)) + + #expect(rootRenderNode.topicSections.map(\.title) == [shouldExplicitlyCurateAPICollection ? "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/ParseDirectiveArgumentsTests.swift b/Tests/SwiftDocCTests/Infrastructure/ParseDirectiveArgumentsTests.swift index f8881ac128..721572e27e 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 emitsWarningsForInvalidMarkup(_ 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) } } diff --git a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyBasedLinkResolverTests.swift b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyBasedLinkResolverTests.swift index 4c82a540a7..8c5de27f48 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 { - - func testOverloadedSymbolsWithOverloadGroups() async throws { - enableFeatureFlag(\.isExperimentalOverloadedSymbolPresentationEnabled) +struct PathHierarchyBasedLinkResolverTests { + @Test + func listingOverloadedSymbolsBelogningToOverloadGroups() async throws { + 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. diff --git a/Tests/SwiftDocCTests/Testing+LoadingTestData.swift b/Tests/SwiftDocCTests/Testing+LoadingTestData.swift new file mode 100644 index 0000000000..43a2add20d --- /dev/null +++ b/Tests/SwiftDocCTests/Testing+LoadingTestData.swift @@ -0,0 +1,141 @@ +/* + 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 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 catalog from the given source URL on the real file system and creates a documentation context. +/// +/// - Parameters: +/// - catalogURL: The file url of the documentation catalog to load from the real file system. +/// - externalResolvers: A collection of resolvers for documentation from other sources, grouped by their identifier. +/// - externalSymbolResolver: A resolver for symbol identifiers of all symbols from non-local modules. +/// - fallbackResolver: An optional fallback resolver for local for testing behaviors specific to a ``ConvertService``. +/// - diagnosticEngine: The diagnostic engine to configure the documentation context with. +/// - configuration: Configuration to apply to a documentation context during initialization. +/// - Returns: The loaded documentation context for the given catalog input. +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 (inputs, dataProvider) = try DocumentationContext.InputsProvider() + .inputsAndDataProvider(startingPoint: catalogURL, options: .init()) + + return try await DocumentationContext(bundle: inputs, dataProvider: dataProvider, diagnosticEngine: diagnosticEngine, configuration: configuration) +} + +/// Loads a documentation catalog for a test fixture with the given catalog name from the real file system and creates a documentation context. +/// +/// - Parameters: +/// - catalogName: The name of the documentation catalog fixture to load. +/// - externalResolvers: A collection of resolvers for documentation from other sources, grouped by their identifier. +/// - fallbackResolver: An optional fallback resolver for local for testing behaviors specific to a ``ConvertService``. +/// - configuration: Configuration to apply to a documentation context during initialization. +/// - Returns: The loaded documentation context for the given catalog input. +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..8362fd4d2d --- /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) +} 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) } } diff --git a/Tests/SwiftDocCUtilitiesTests/Utility/DirectedGraphTests.swift b/Tests/SwiftDocCUtilitiesTests/Utility/DirectedGraphTests.swift index 98c3b61201..87a38506f9 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 traversingGraphWithSingleAdjacency() { // 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 traversingGraphWithTreeStructure() { // ┌▶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 traversingGraphWithTreeStructureAndMultipleAdjacency() { // ┌─▶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 traversingComplexGraphWithMultipleAdjacency() { // 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 detectsCyclesInSimpleGraph() { 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 detectsRotationsOfSameCycleInGraph() { 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 traversingGraphWithCycleAndSingleAdjacency() { // 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 traversingGraphWithCycleAndManyLeafNodes() { 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 traversingGraphWithManyCycles() { // ┌──┐ ┌───▶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 traversingGraphWithMultiplePathsToEnterCycle() { // ┌─▶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) }