From 45931ded4d455d89af891975cb8d2a8192c0dd4f Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Thu, 24 Sep 2026 20:07:09 +0200 Subject: [PATCH] feat(search)!: Report coverage and return empty search results Propagate cancellation instead of swallowing it as an unavailable collection. Preserve successful matches and report incomplete coverage on stderr. BREAKING CHANGE: Searches with no matches now succeed with an empty result instead of exiting with an error. --- README.md | 2 + .../AppleDocumentationClient+Error.swift | 15 +- .../AppleDocumentationClient+Search.swift | 92 +++++----- .../DocumentationTypeSearchClient.swift | 7 +- .../CLI/cmd/types/TypesSearchCommand.swift | 7 + .../cmd/types/TypesSearchCommandRunner.swift | 8 +- ...DefaultDocumentationTypeListRenderer.swift | 1 + ...AppleDocumentationClientLoggingTests.swift | 11 +- .../AppleDocumentationClientRootTests.swift | 2 +- .../AppleDocumentationClientSearchTests.swift | 158 +++++++++++------- .../types/TypesSearchCommandRunnerTests.swift | 21 ++- ...ltDocumentationTypeListRendererTests.swift | 12 ++ 12 files changed, 206 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 11766c0..d6e758c 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ apple-docs types search Button --technology SwiftUI Search deliberately does not crawl individual symbol pages, which keeps requests bounded. Collection pages can directly reference some nested members, so those may appear, but search is not an exhaustive nested-member index. A missing search result does not necessarily mean the API is undocumented. If you know the exact type or DocC path, try `types view` directly. +No matches is a successful empty result, rendered as an empty array in JSON. If some collection pages cannot be fetched, available matches are still returned and an incomplete-coverage warning is written to stderr. Cancellation stops the search instead of returning partial results. + ### Type documentation Pass the exact type and technology names: diff --git a/Sources/CLI/client/AppleDocumentationClient+Error.swift b/Sources/CLI/client/AppleDocumentationClient+Error.swift index 3303264..7d08911 100644 --- a/Sources/CLI/client/AppleDocumentationClient+Error.swift +++ b/Sources/CLI/client/AppleDocumentationClient+Error.swift @@ -11,16 +11,11 @@ extension DefaultAppleDocumentationClient { suggestion: DocumentationType?, technologyURL: String ) - case typeSearchNoResults( - query: String, - technology: String, - technologyURL: String - ) case unsupportedTechnology(name: String, url: String) var isExpected: Bool { switch self { - case .technologyNotFound, .typeNotFound, .typeSearchNoResults, .unsupportedTechnology: + case .technologyNotFound, .typeNotFound, .unsupportedTechnology: return true case .httpStatus, .invalidResponse: return false @@ -59,14 +54,6 @@ extension DefaultAppleDocumentationClient { """ ) return sections.joined(separator: "\n\n") - case .typeSearchNoResults(let query, let technology, let technologyURL): - return """ - No types matching '\(query)' found in \(technology). - - Browse available types: - apple-docs types list --technology "\(technology)" - \(technologyURL) - """ case .unsupportedTechnology(let name, let url): return """ Type retrieval is unavailable for \(name). diff --git a/Sources/CLI/client/AppleDocumentationClient+Search.swift b/Sources/CLI/client/AppleDocumentationClient+Search.swift index 70e0a9a..80e86db 100644 --- a/Sources/CLI/client/AppleDocumentationClient+Search.swift +++ b/Sources/CLI/client/AppleDocumentationClient+Search.swift @@ -1,26 +1,17 @@ import Foundation extension DefaultAppleDocumentationClient { - func searchTypes(query: String, technology: String) async throws -> [DocumentationType] { - logger.debug( - "Searching documentation types", - metadata: [ - "query": .string(query), "apple_docs.technology": .string(technology), - ]) + func searchTypes(query: String, technology: String) async throws -> DocumentationSearchResult { let root = try await fetchDocumentationRoot(technology: technology) - let matches = try await searchTypes(query: query, root: root) - logger.info( - "Documentation search completed", - metadata: [ - "apple_docs.technology": .string(root.name), "matches": .stringConvertible(matches.count), - ]) - return matches + return try await searchDocumentation(query: query, root: root) } - private func searchTypes(query: String, root: DocumentationRoot) async throws -> [DocumentationType] { + private func searchDocumentation(query: String, root: DocumentationRoot) async throws -> DocumentationSearchResult { + try Task.checkCancellation() let rootPath = "/documentation/\(root.slug.lowercased())" logger.debug("Traversing documentation collection groups", metadata: ["path": .string(rootPath)]) var typesByPath: [String: DocumentationType] = [:] + var unavailablePaths: [String] = [] for type in documentationTypes(in: root.page, technology: root.slug) { typesByPath[type.path] = type } @@ -32,6 +23,7 @@ extension DefaultAppleDocumentationClient { // Collection groups form a small curated graph. Batching limits pressure on Apple's service // while avoiding the thousands of requests required to crawl every individual symbol page. while !pendingPaths.isEmpty { + try Task.checkCancellation() let batch = Array(pendingPaths.prefix(6)) pendingPaths.removeFirst(batch.count) logger.trace( @@ -39,9 +31,13 @@ extension DefaultAppleDocumentationClient { metadata: [ "batch_size": .stringConvertible(batch.count), "pending": .stringConvertible(pendingPaths.count), ]) - let pages = await fetchDocumentationPages(paths: batch) + let pages = try await fetchDocumentationPages(paths: batch) - for page in pages { + for result in pages { + guard case .page(let page) = result else { + if case .unavailable(let path) = result { unavailablePaths.append(path) } + continue + } for type in documentationTypes(in: page, technology: root.slug) { typesByPath[type.path] = type } @@ -52,58 +48,74 @@ extension DefaultAppleDocumentationClient { } } + try Task.checkCancellation() + return searchResult( + query: query, root: root, types: Array(typesByPath.values), unavailablePaths: unavailablePaths) + } + + private func searchResult( + query: String, root: DocumentationRoot, types: [DocumentationType], unavailablePaths: [String] + ) -> DocumentationSearchResult { + logger.debug( + "Documentation search coverage", + metadata: [ + "apple_docs.technology": .string(root.slug), "candidates": .stringConvertible(types.count), + "unavailable": .stringConvertible(unavailablePaths.count), + ]) let normalizedQuery = query.lowercased() let matches = sortTypes( - typesByPath.values.filter { + types.filter { $0.name.lowercased().contains(normalizedQuery) || $0.path.lowercased().contains(normalizedQuery) } ) - guard !matches.isEmpty else { + if matches.isEmpty { logger.notice( "No matching documentation types", metadata: [ "query": .string(query), "apple_docs.technology": .string(root.name), - "candidates": .stringConvertible(typesByPath.count), + "candidates": .stringConvertible(types.count), ]) - throw Error.typeSearchNoResults( - query: query, - technology: root.name, - technologyURL: root.url - ) } - return matches + logger.info( + "Documentation search completed", + metadata: [ + "apple_docs.technology": .string(root.name), "matches": .stringConvertible(matches.count), + ]) + return DocumentationSearchResult(types: matches, unavailableCollectionPaths: unavailablePaths.sorted()) + } + + private enum CollectionResult: Sendable { + case page(TechnologyDocumentationPageDTO) + case unavailable(String) } private func fetchDocumentationPages( paths: [String] - ) async -> [TechnologyDocumentationPageDTO] { + ) async throws -> [CollectionResult] { logger.debug("Fetching collection group batch", metadata: ["count": .stringConvertible(paths.count)]) - return await withTaskGroup( - of: TechnologyDocumentationPageDTO?.self, - returning: [TechnologyDocumentationPageDTO].self - ) { group in + return try await withThrowingTaskGroup(of: CollectionResult.self) { group in for path in paths { group.addTask { do { - return try await fetchDocumentationPage(path: path) + return .page(try await fetchDocumentationPage(path: path)) } catch { - let cancelled = error is CancellationError || (error as? URLError)?.code == .cancelled - logger.log( - level: cancelled ? .debug : .warning, "Skipping unavailable collection group", + if error is CancellationError || (error as? URLError)?.code == .cancelled { + throw CancellationError() + } + logger.warning( + "Skipping unavailable collection group", metadata: [ "path": .string(path), "error_type": .string(String(reflecting: type(of: error))), ]) - return nil + return .unavailable(path) } } } - var pages: [TechnologyDocumentationPageDTO] = [] - for await page in group { - if let page { - pages.append(page) - } + var pages: [CollectionResult] = [] + for try await page in group { + pages.append(page) } logger.debug( "Fetched collection group batch", diff --git a/Sources/CLI/client/DocumentationTypeSearchClient.swift b/Sources/CLI/client/DocumentationTypeSearchClient.swift index dc49f96..4adcf98 100644 --- a/Sources/CLI/client/DocumentationTypeSearchClient.swift +++ b/Sources/CLI/client/DocumentationTypeSearchClient.swift @@ -4,9 +4,14 @@ import Foundation import FoundationNetworking #endif +struct DocumentationSearchResult: Equatable, Sendable { + let types: [DocumentationType] + let unavailableCollectionPaths: [String] +} + #if DEBUG protocol DocumentationTypeSearchClient: Sendable { - func searchTypes(query: String, technology: String) async throws -> [DocumentationType] + func searchTypes(query: String, technology: String) async throws -> DocumentationSearchResult } extension DefaultAppleDocumentationClient: DocumentationTypeSearchClient {} diff --git a/Sources/CLI/cmd/types/TypesSearchCommand.swift b/Sources/CLI/cmd/types/TypesSearchCommand.swift index e8c8420..386f04e 100644 --- a/Sources/CLI/cmd/types/TypesSearchCommand.swift +++ b/Sources/CLI/cmd/types/TypesSearchCommand.swift @@ -1,4 +1,5 @@ import ArgumentParser +import Foundation struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { @OptionGroup var global: GlobalOptions @@ -33,6 +34,12 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { renderer: Dependencies.documentationTypeListRenderer(json: json) ).run(query: query, technology: technology) telemetry.record(.typeSearch(matches: result.matchCount), context: context) + if result.unavailableCollectionCount > 0 { + let warning = + "Warning: search results are incomplete. " + + "\(result.unavailableCollectionCount) collections were unavailable.\n" + FileHandle.standardError.write(Data(warning.utf8)) + } print(result.output) } } diff --git a/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift b/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift index 26066e8..799c861 100644 --- a/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift +++ b/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift @@ -2,6 +2,7 @@ struct TypesSearchCommandRunner: Sendable { struct Result: Sendable { let output: String let matchCount: Int + let unavailableCollectionCount: Int } private let client: DocumentationTypeSearchClient @@ -16,10 +17,11 @@ struct TypesSearchCommandRunner: Sendable { } func run(query: String, technology: String) async throws -> Result { - let types = try await client.searchTypes(query: query, technology: technology) + let result = try await client.searchTypes(query: query, technology: technology) return Result( - output: try renderer.render(types), - matchCount: types.count + output: try renderer.render(result.types), + matchCount: result.types.count, + unavailableCollectionCount: result.unavailableCollectionPaths.count ) } } diff --git a/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift b/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift index 0cc8b6b..242a0a0 100644 --- a/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift +++ b/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift @@ -26,6 +26,7 @@ struct DefaultDocumentationTypeListRenderer: Sendable { } private func renderTable(_ types: [DocumentationType]) -> String { + guard !types.isEmpty else { return "No symbols found." } let nameWidth = max("SYMBOL".count, types.map(\.name.count).max() ?? 0) let kindWidth = max("KIND".count, types.map(\.kind.count).max() ?? 0) let pathWidth = max("PATH".count, types.map(\.path.count).max() ?? 0) diff --git a/Tests/CLITests/client/AppleDocumentationClientLoggingTests.swift b/Tests/CLITests/client/AppleDocumentationClientLoggingTests.swift index 81e4303..bf79e87 100644 --- a/Tests/CLITests/client/AppleDocumentationClientLoggingTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientLoggingTests.swift @@ -166,15 +166,10 @@ struct AppleDocumentationClientLoggingTests { ) // -- Act -- - await #expect( - throws: DefaultAppleDocumentationClient.Error.typeSearchNoResults( - query: "Missing", technology: "Swift", technologyURL: "https://developer.apple.com/documentation/swift" - ) - ) { - try await client.searchTypes(query: "Missing", technology: "Swift") - } + let result = try await client.searchTypes(query: "Missing", technology: "Swift") // -- Assert -- + #expect(result.types.isEmpty) #expect( recorder.events.contains { $0.level == .notice && $0.message.description == "No matching documentation types" @@ -205,7 +200,7 @@ struct AppleDocumentationClientLoggingTests { let types = try await client.searchTypes(query: "button", technology: "SwiftUI") // -- Assert -- - #expect(types.map(\.name) == ["Button"]) + #expect(types.types.map(\.name) == ["Button"]) let skipped = try #require( recorder.events.first { $0.message.description == "Skipping unavailable collection group" }) #expect(skipped.level == .warning) diff --git a/Tests/CLITests/client/AppleDocumentationClientRootTests.swift b/Tests/CLITests/client/AppleDocumentationClientRootTests.swift index 73de47a..83e1064 100644 --- a/Tests/CLITests/client/AppleDocumentationClientRootTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientRootTests.swift @@ -109,7 +109,7 @@ struct AppleDocumentationClientRootTests { // -- Act -- let types = try await search - ? client.searchTypes(query: "AES", technology: "Apple CryptoKit") + ? client.searchTypes(query: "AES", technology: "Apple CryptoKit").types : client.fetchTypes(technology: "Apple CryptoKit") // -- Assert -- diff --git a/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift b/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift index 40b36fe..d6daef8 100644 --- a/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift @@ -6,24 +6,83 @@ import Testing @Suite("Apple documentation type search client") struct AppleDocumentationClientSearchTests { - @Test("searches symbols across nested collection groups") - func searchesCollectionGroups() async throws { + @available(macOS 15, *) + @Test("detailed search returns partial-result paths and successful matches") + func reportsPartialResults() async throws { // -- Arrange -- - let rootURL = try #require( - URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftui.json") - ) - let controlsURL = try #require( - URL( - string: - "https://developer.apple.com/tutorials/data/documentation/swiftui/controls.json" - ) + let rootURL = try searchURL("swiftui") + let controlsURL = try searchURL("swiftui/controls") + let stylesURL = try searchURL("swiftui/styles") + let transport = HTTPTestTransport(responses: [ + rootURL: .http(data: partialFailureRootSearchPage), + controlsURL: .http(statusCode: 404, data: Data()), stylesURL: .http(data: stylesSearchPage), + ]) + let recorder = ClientLogRecorder() + let client = DefaultAppleDocumentationClient(logger: recorder.logger(), dependencies: transport) + + // -- Act -- + let result = try await client.searchTypes(query: "buttonstyle", technology: "SwiftUI") + + // -- Assert -- + #expect(result.types.map(\.name) == ["ButtonStyle"]) + #expect(result.unavailableCollectionPaths == ["/documentation/swiftui/controls"]) + #expect(Set(await transport.requestedURLs) == Set([rootURL, controlsURL, stylesURL])) + let coverage = try #require(recorder.events.first { $0.message.description == "Documentation search coverage" }) + #expect(coverage.level == .debug) + #expect(coverage.metadata?["unavailable"]?.description == "1") + #expect(coverage.metadata?["query"] == nil) + } + + @Test("detailed search treats no matches as a successful empty result") + func returnsEmptyResults() async throws { + // -- Arrange -- + let rootURL = try searchURL("swiftui") + let transport = HTTPTestTransport(responses: [rootURL: .http(data: duplicateRootSearchPage)]) + let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport ) - let stylesURL = try #require( - URL( - string: - "https://developer.apple.com/tutorials/data/documentation/swiftui/styles.json" - ) + + // -- Act -- + let result = try await client.searchTypes(query: "Missing", technology: "SwiftUI") + + // -- Assert -- + #expect(result.types.isEmpty) + #expect(result.unavailableCollectionPaths.isEmpty) + #expect(await transport.requestedURLs == [rootURL]) + } + + @Test("collection cancellation propagates instead of returning partial results", arguments: [true, false]) + func propagatesCancellation(urlCancellation: Bool) async throws { + // -- Arrange -- + let rootURL = try searchURL("swiftui") + let controlsURL = try searchURL("swiftui/controls") + let error: any Error = urlCancellation ? URLError(.cancelled) : CancellationError() + let transport = HTTPTestTransport(responses: [ + rootURL: .http(data: rootSearchPage), controlsURL: .failure(error), + ]) + let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport ) + + // -- Act -- + await #expect(throws: CancellationError.self) { + try await client.searchTypes(query: "View", technology: "SwiftUI") + } + + // -- Assert -- + #expect(await transport.requestedURLs == [rootURL, controlsURL]) + } + + private func searchURL(_ path: String) throws -> URL { + try #require(URL(string: "https://developer.apple.com/tutorials/data/documentation/\(path).json")) + } + + @Test("searches symbols across nested collection groups") + func searchesCollectionGroups() async throws { + // -- Arrange -- + let rootURL = try searchURL("swiftui") + let controlsURL = try searchURL("swiftui/controls") + let stylesURL = try searchURL("swiftui/styles") let client = DefaultAppleDocumentationClient( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: HTTPTestTransport(responses: [ @@ -36,8 +95,8 @@ struct AppleDocumentationClientSearchTests { let types = try await client.searchTypes(query: "button", technology: "SwiftUI") // -- Assert -- - #expect(types.map(\.name) == ["Button", "ButtonStyle"]) - #expect(types.map(\.path) == ["button", "buttonstyle"]) + #expect(types.types.map(\.name) == ["Button", "ButtonStyle"]) + #expect(types.types.map(\.path) == ["button", "buttonstyle"]) } @Test("deduplicates root symbols with the same path") @@ -57,8 +116,8 @@ struct AppleDocumentationClientSearchTests { let types = try await client.searchTypes(query: "button", technology: "SwiftUI") // -- Assert -- - #expect(types.map(\.name) == ["Button"]) - #expect(types.map(\.path) == ["button"]) + #expect(types.types.map(\.name) == ["Button"]) + #expect(types.types.map(\.path) == ["button"]) } @Test("continues searching when a collection group is unavailable") @@ -94,8 +153,8 @@ struct AppleDocumentationClientSearchTests { let types = try await client.searchTypes(query: "buttonstyle", technology: "SwiftUI") // -- Assert -- - #expect(types.map(\.name) == ["ButtonStyle"]) - #expect(types.map(\.path) == ["buttonstyle"]) + #expect(types.types.map(\.name) == ["ButtonStyle"]) + #expect(types.types.map(\.path) == ["buttonstyle"]) } @Test("maps technology display names to DocC slugs") @@ -131,53 +190,30 @@ struct AppleDocumentationClientSearchTests { let types = try await client.searchTypes(query: "AES", technology: "Apple CryptoKit") // -- Assert -- - #expect(types.map(\.name) == ["AES"]) - #expect(types.map(\.path) == ["aes"]) + #expect(types.types.map(\.name) == ["AES"]) + #expect(types.types.map(\.path) == ["aes"]) } - @Test("maps an empty search to discovery guidance") - func mapsEmptySearchToDiscoveryGuidance() async throws { + @Test("returns an empty result after searching all collections") + func returnsEmptySearchAfterTraversal() async throws { // -- Arrange -- - let rootURL = try #require( - URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftui.json") - ) - let controlsURL = try #require( - URL( - string: - "https://developer.apple.com/tutorials/data/documentation/swiftui/controls.json" - ) - ) - let stylesURL = try #require( - URL( - string: - "https://developer.apple.com/tutorials/data/documentation/swiftui/styles.json" - ) - ) + let transport = HTTPTestTransport(responses: [ + try searchURL("swiftui"): .http(data: rootSearchPage), + try searchURL("swiftui/controls"): .http(data: controlsSearchPage), + try searchURL("swiftui/styles"): .http(data: stylesSearchPage), + ]) let client = DefaultAppleDocumentationClient( - logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, - dependencies: HTTPTestTransport(responses: [ - rootURL: .http(data: rootSearchPage), controlsURL: .http(data: controlsSearchPage), - stylesURL: .http(data: stylesSearchPage), - ]) - ) + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport) // -- Act -- - do { - _ = try await client.searchTypes(query: "Picker", technology: "SwiftUI") - Issue.record("Expected the search to fail") - } catch { - // -- Assert -- - #expect( - error.localizedDescription == """ - No types matching 'Picker' found in SwiftUI. - - Browse available types: - apple-docs types list --technology "SwiftUI" - https://developer.apple.com/documentation/swiftui - """ - ) - } + let result = try await client.searchTypes(query: "Picker", technology: "SwiftUI") + + // -- Assert -- + #expect(result.types.isEmpty) + #expect(result.unavailableCollectionPaths.isEmpty) + #expect(await transport.requestedURLs.count == 3) } + } private let rootSearchPage = Data( diff --git a/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift index f03ce7b..f7625fa 100644 --- a/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift @@ -26,17 +26,34 @@ struct TypesSearchCommandRunnerTests { // -- Assert -- #expect(result.output == "rendered matches") #expect(result.matchCount == 1) + #expect(result.unavailableCollectionCount == 1) + } + + @Test("empty searches are successful JSON arrays") + func rendersEmptySearch() async throws { + // -- Arrange -- + let runner = TypesSearchCommandRunner( + client: RequestedTypeSearchClient(types: []), + renderer: DefaultDocumentationTypeListRenderer(output: .json)) + + // -- Act -- + let result = try await runner.run(query: "Button", technology: "SwiftUI") + + // -- Assert -- + #expect(result.output == "[\n\n]") + #expect(result.matchCount == 0) + #expect(result.unavailableCollectionCount == 1) } } private struct RequestedTypeSearchClient: DocumentationTypeSearchClient { let types: [DocumentationType] - func searchTypes(query: String, technology: String) async throws -> [DocumentationType] { + func searchTypes(query: String, technology: String) async throws -> DocumentationSearchResult { guard query == "Button", technology == "SwiftUI" else { throw TypesSearchRunnerTestError.unexpectedRequest } - return types + return DocumentationSearchResult(types: types, unavailableCollectionPaths: ["/documentation/swiftui/styles"]) } } diff --git a/Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift b/Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift index bd9dede..f35109e 100644 --- a/Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift +++ b/Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift @@ -4,6 +4,18 @@ import Testing @Suite("Default documentation type list renderer") struct DefaultDocumentationTypeListRendererTests { + @Test("explains an empty result in human output") + func rendersEmptyResult() throws { + // -- Arrange -- + let renderer = DefaultDocumentationTypeListRenderer(output: .table) + + // -- Act -- + let output = try renderer.render([]) + + // -- Assert -- + #expect(output == "No symbols found.") + } + @Test("renders documentation types as a table") func rendersTable() throws { // -- Arrange --