diff --git a/Sources/CLI/client/AppleDocumentationClient+DTO.swift b/Sources/CLI/client/AppleDocumentationClient+DTO.swift index 056081b..c627531 100644 --- a/Sources/CLI/client/AppleDocumentationClient+DTO.swift +++ b/Sources/CLI/client/AppleDocumentationClient+DTO.swift @@ -17,11 +17,56 @@ struct DocumentationVariantDTO: Decodable, Sendable { struct DocumentationTextDTO: Decodable, Sendable { let code: String? let identifier: String? + let inlineContent: [DocumentationTextDTO]? let text: String? } -struct DocumentationBlockDTO: Decodable, Sendable { - let inlineContent: [DocumentationTextDTO]? +enum DocumentationBlockDTO: Decodable, Sendable { + case paragraph([DocumentationTextDTO]) + case heading(String) + case codeListing(code: [String], syntax: String?) + case orderedList(items: [DocumentationListItemDTO], startIndex: Int) + case unorderedList([DocumentationListItemDTO]) + case aside(content: [DocumentationBlockDTO], style: String, name: String?) + case unsupported + + private enum CodingKeys: CodingKey { + case code, content, inlineContent, items, name, startIndex, style, syntax, text, type + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .type) { + case "paragraph": + self = .paragraph(try container.decode([DocumentationTextDTO].self, forKey: .inlineContent)) + case "heading": + self = .heading(try container.decode(String.self, forKey: .text)) + case "codeListing": + self = .codeListing( + code: try container.decode([String].self, forKey: .code), + syntax: try container.decodeIfPresent(String.self, forKey: .syntax) + ) + case "orderedList": + self = .orderedList( + items: try container.decode([DocumentationListItemDTO].self, forKey: .items), + startIndex: try container.decodeIfPresent(Int.self, forKey: .startIndex) ?? 1 + ) + case "unorderedList": + self = .unorderedList(try container.decode([DocumentationListItemDTO].self, forKey: .items)) + case "aside": + self = .aside( + content: try container.decode([DocumentationBlockDTO].self, forKey: .content), + style: try container.decode(String.self, forKey: .style), + name: try container.decodeIfPresent(String.self, forKey: .name) + ) + default: + self = .unsupported + } + } +} + +struct DocumentationListItemDTO: Decodable, Sendable { + let content: [DocumentationBlockDTO] } struct DocumentationReferenceDTO: Decodable, Sendable { @@ -48,6 +93,7 @@ struct DocumentationReferenceSectionDTO: Decodable, Sendable { } struct DocumentationContentSectionDTO: Decodable, Sendable { + let content: [DocumentationBlockDTO]? let declarations: [DocumentationDeclarationDTO]? let kind: String } diff --git a/Sources/CLI/renderer/DocumentationContentRenderer.swift b/Sources/CLI/renderer/DocumentationContentRenderer.swift new file mode 100644 index 0000000..bc454fb --- /dev/null +++ b/Sources/CLI/renderer/DocumentationContentRenderer.swift @@ -0,0 +1,65 @@ +import Foundation + +struct DocumentationContentRenderer: Sendable { + let references: [String: DocumentationReferenceDTO] + private let layout = DocumentationTextLayout() + + func inlineText(_ content: [DocumentationTextDTO]) -> String { + content.map { item in + if let text = item.text { + return text + } + if let code = item.code { + return "`\(code)`" + } + if let identifier = item.identifier { + return references[identifier]?.title ?? identifier + } + return inlineText(item.inlineContent ?? []) + }.joined() + } + + func render(_ blocks: [DocumentationBlockDTO], indent: String = " ") -> String { + blocks.map { render($0, indent: indent) } + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + } + + private func render(_ block: DocumentationBlockDTO, indent: String) -> String { + switch block { + case .paragraph(let content): + return layout.paragraph(inlineText(content), indent: indent) + case .heading(let text): + return text.isEmpty ? "" : layout.heading(text) + case .codeListing(let code, let syntax): + return layout.codeBlock(code, language: syntax, indent: indent) + case .orderedList(let items, let startIndex): + return renderList(items, startIndex: startIndex, indent: indent) + case .unorderedList(let items): + return renderList(items, indent: indent) + case .aside(let content, let style, let name): + let body = render(content, indent: indent + "│ ") + guard !body.isEmpty else { return "" } + return indent + (name ?? style.capitalized) + "\n" + body + case .unsupported: + return "" + } + } + + private func renderList( + _ items: [DocumentationListItemDTO], + startIndex: Int? = nil, + indent: String + ) -> String { + items.enumerated().compactMap { index, item -> String? in + let marker = startIndex.map { "\($0 + index). " } ?? "• " + let continuation = indent + String(repeating: " ", count: marker.count) + let body = render(item.content, indent: continuation) + guard !body.isEmpty else { return nil } + if body.hasPrefix(continuation) { + return indent + marker + body.dropFirst(continuation.count) + } + return indent + marker + "\n" + body + }.joined(separator: "\n") + } +} diff --git a/Sources/CLI/renderer/DocumentationTextLayout.swift b/Sources/CLI/renderer/DocumentationTextLayout.swift new file mode 100644 index 0000000..4bd407c --- /dev/null +++ b/Sources/CLI/renderer/DocumentationTextLayout.swift @@ -0,0 +1,56 @@ +struct DocumentationTextLayout: Sendable { + private let width = 80 + + func heading(_ title: String, prominent: Bool = false) -> String { + title + "\n" + String(repeating: prominent ? "━" : "─", count: min(title.count, width)) + } + + func paragraph(_ text: String, indent: String = " ", firstPrefix: String? = nil) -> String { + var lines: [String] = [] + var prefix = firstPrefix ?? indent + var line = "" + for word in text.split(whereSeparator: \.isWhitespace) { + if !line.isEmpty && prefix.count + line.count + 1 + word.count > width { + lines.append(prefix + line) + prefix = indent + line = "" + } + if !line.isEmpty { + line += " " + } + line += word + } + if !line.isEmpty { + lines.append(prefix + line) + } + return lines.joined(separator: "\n") + } + + func codeBlock(_ lines: [String], language: String?, indent: String = " ") -> String { + guard !lines.isEmpty else { return "" } + let label = language.map { $0 == "swift" ? "Swift" : $0 } ?? "Code" + let top = "─ \(label) " + let width = max(top.count, (lines.map(\.count).max() ?? 0) + 2) + let rows = lines.map { line in + indent + "│ " + line + String(repeating: " ", count: width - line.count - 1) + "│" + } + return + ([indent + "╭" + top + String(repeating: "─", count: width - top.count) + "╮"] + + rows + [indent + "╰" + String(repeating: "─", count: width) + "╯"]) + .joined(separator: "\n") + } + + func table(_ rows: [(String, String)]) -> String { + guard !rows.isEmpty else { return "" } + let keyWidth = rows.map { $0.0.count }.max() ?? 0 + let valueWidth = rows.map { $0.1.count }.max() ?? 0 + let keyRule = String(repeating: "─", count: keyWidth + 2) + let valueRule = String(repeating: "─", count: valueWidth + 2) + let body = rows.map { key, value in + " │ " + key + String(repeating: " ", count: keyWidth - key.count) + + " │ " + value + String(repeating: " ", count: valueWidth - value.count) + " │" + } + return ([" ╭\(keyRule)┬\(valueRule)╮"] + body + [" ╰\(keyRule)┴\(valueRule)╯"]) + .joined(separator: "\n") + } +} diff --git a/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift b/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift index 4753560..22ada15 100644 --- a/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift @@ -1,55 +1,47 @@ struct TextTypeDocumentationRenderer: Sendable { + private let layout = DocumentationTextLayout() + func render(_ page: TypeDocumentationPageDTO) -> String { - let modules = page.metadata.modules.map(\.name).joined(separator: ", ") - var sections = ["\(page.metadata.title)\n\(page.metadata.roleHeading) · \(modules)"] + let content = DocumentationContentRenderer(references: page.references) + let metadata = ([page.metadata.roleHeading] + page.metadata.modules.map(\.name)) + .filter { !$0.isEmpty }.joined(separator: " · ") + var sections = [layout.heading(page.metadata.title, prominent: true) + "\n" + metadata] - let abstract = page.abstract.compactMap(\.text).joined() + let abstract = content.inlineText(page.abstract) if !abstract.isEmpty { - sections.append(abstract) + sections.append(layout.paragraph(abstract)) } - let deprecation = - page.deprecationSummary? - .compactMap(\.inlineContent) - .map { inlineText($0, references: page.references) } - .joined(separator: "\n") ?? "" + let deprecation = content.render(page.deprecationSummary ?? []) if !deprecation.isEmpty { - sections.append("Deprecated\n\n" + deprecation) - } - - let declarations = swiftDeclarations(in: page) - - if !declarations.isEmpty { - sections.append("Declaration\n\n" + declarations.joined(separator: "\n")) + sections.append(layout.heading("Deprecated") + "\n" + deprecation) } let availability = page.metadata.platforms.map { platform in - " \(platform.name) \(availabilityRange(for: platform))" + (platform.name, availabilityRange(for: platform)) } if !availability.isEmpty { - sections.append("Availability\n\n" + availability.joined(separator: "\n")) + sections.append(layout.heading("Availability") + "\n" + layout.table(availability)) } - appendReferenceSections( - page.relationshipsSections ?? [], - references: page.references, - to: §ions - ) - appendReferenceSections( - page.topicSections ?? [], - references: page.references, - includesAbstract: true, - to: §ions - ) - appendReferenceSections( - page.seeAlsoSections ?? [], - references: page.references, - titlePrefix: "See Also: ", - to: §ions + let declarations = swiftDeclarations(in: page) + if !declarations.isEmpty { + sections.append(layout.heading("Declaration") + "\n" + declarations.joined(separator: "\n\n")) + } + + let overview = content.render( + page.primaryContentSections.filter { $0.kind == "content" }.flatMap { $0.content ?? [] } ) + if !overview.isEmpty { + sections.append(overview) + } + + sections += referenceSections(page.relationshipsSections ?? [], content: content) + appendGroup("Topics", page.topicSections ?? [], content: content, includesAbstract: true, to: §ions) + appendGroup("See Also", page.seeAlsoSections ?? [], content: content, to: §ions) if let path = page.variants?.lazy.flatMap(\.paths).first { - sections.append("https://developer.apple.com\(path)") + sections.append(layout.heading("Documentation") + "\n " + documentationURL(for: path)) } return sections.joined(separator: "\n\n") @@ -60,41 +52,49 @@ struct TextTypeDocumentationRenderer: Sendable { .filter { $0.kind == "declarations" } .flatMap { $0.declarations ?? [] } .filter { $0.languages.contains("swift") } - .map { " " + $0.tokens.map(\.text).joined() } + .map { + let lines = $0.tokens.map(\.text).joined().split(separator: "\n", omittingEmptySubsequences: false) + return layout.codeBlock(lines.map(String.init), language: "swift") + } } - private func appendReferenceSections( - _ referenceSections: [DocumentationReferenceSectionDTO], - references: [String: DocumentationReferenceDTO], - titlePrefix: String = "", + private func appendGroup( + _ title: String, + _ groups: [DocumentationReferenceSectionDTO], + content: DocumentationContentRenderer, includesAbstract: Bool = false, to sections: inout [String] ) { - for referenceSection in referenceSections { - let items = referenceSection.identifiers.compactMap { identifier -> String? in - guard let reference = references[identifier], let title = reference.title else { + let rendered = referenceSections(groups, content: content, includesAbstract: includesAbstract) + if !rendered.isEmpty { + sections.append(layout.heading(title, prominent: true)) + sections += rendered + } + } + + private func referenceSections( + _ groups: [DocumentationReferenceSectionDTO], + content: DocumentationContentRenderer, + includesAbstract: Bool = false + ) -> [String] { + groups.compactMap { group in + let items = group.identifiers.compactMap { identifier -> String? in + guard let reference = content.references[identifier], let title = reference.title else { return nil } - - var item = " \(title)" - let abstract = - reference.abstract.map { - inlineText($0, references: references) - } ?? "" + var item = layout.paragraph(title, indent: " ", firstPrefix: " • ") + let abstract = content.inlineText(reference.abstract ?? []) if includesAbstract && !abstract.isEmpty { - item += " — \(abstract)" + item += "\n" + layout.paragraph(abstract, indent: " ") } if let url = reference.url { - item += "\n \(documentationURL(for: url))" + // Keep URLs intact so terminal link detection and copy/paste still work. + item += "\n " + documentationURL(for: url) } return item } - - if !items.isEmpty { - sections.append( - titlePrefix + referenceSection.title + "\n\n" + items.joined(separator: "\n") - ) - } + guard !items.isEmpty else { return nil } + return layout.heading(group.title) + "\n" + items.joined(separator: "\n\n") } } @@ -117,20 +117,4 @@ struct TextTypeDocumentationRenderer: Sendable { } return "https://developer.apple.com\(path)" } - - private func inlineText( - _ content: [DocumentationTextDTO], - references: [String: DocumentationReferenceDTO] - ) -> String { - content.map { item in - if let text = item.text { - return text - } - if let identifier = item.identifier { - return references[identifier]?.title ?? identifier - } - // DocC encodes inline symbol spelling such as AppIntent as codeVoice, not text. - return item.code ?? "" - }.joined() - } } diff --git a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift index f8b5065..128551d 100644 --- a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift +++ b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift @@ -49,8 +49,10 @@ struct AppleDocsCommandIntegrationTests { let output = try runAppleDocs(arguments) // -- Assert -- - #expect(output.hasPrefix("String\nStructure · Swift\n")) - #expect(output.contains("Declaration\n\n @frozen struct String")) + #expect(output.hasPrefix("String\n━━━━━━\nStructure · Swift\n")) + #expect(output.contains("Declaration\n───────────")) + #expect(output.contains("│ @frozen struct String")) + #expect(output.contains("Overview\n────────")) } @Test("lists MetricKit root types as JSON") diff --git a/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift b/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift index 47206f0..04dd682 100644 --- a/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift +++ b/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift @@ -7,6 +7,7 @@ import Testing struct DefaultTypeDocumentationRendererTests { @Test("renders text output") func rendersText() throws { + // -- Arrange -- let rawJSON = """ { "abstract": [{"text": "A diagnostic report.", "type": "text"}], @@ -24,14 +25,17 @@ struct DefaultTypeDocumentationRendererTests { let document = try makeDocument(rawJSON) let renderer = DefaultTypeDocumentationRenderer(output: .text) + // -- Act -- let output = try renderer.render(document) + // -- Assert -- #expect( output == """ MXHangDiagnostic + ━━━━━━━━━━━━━━━━ Class · MetricKit - A diagnostic report. + A diagnostic report. """ ) } @@ -62,9 +66,10 @@ struct DefaultTypeDocumentationRendererTests { #expect( output == """ Package + ━━━━━━━ Structure · PackageDescription - The Swift package manifest representation. + The Swift package manifest representation. """ ) } diff --git a/Tests/CLITests/renderer/TextTypeDocumentationRendererContentTests.swift b/Tests/CLITests/renderer/TextTypeDocumentationRendererContentTests.swift new file mode 100644 index 0000000..944d98c --- /dev/null +++ b/Tests/CLITests/renderer/TextTypeDocumentationRendererContentTests.swift @@ -0,0 +1,174 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Text type documentation content rendering") +struct TextTypeDocumentationRendererContentTests { + @Test("renders overview prose, inline symbols, and indented code examples") + func rendersOverview() throws { + // -- Arrange -- + let page = try makePage( + content: #""" + {"type": "heading", "level": 2, "text": "Overview"}, + {"type": "paragraph", "inlineContent": [ + {"type": "text", "text": "Implement "}, + {"type": "reference", "identifier": "doc://body"}, + {"type": "text", "text": " using "}, + {"type": "codeVoice", "code": "Text"}, + {"type": "text", "text": "."} + ]}, + {"type": "codeListing", "syntax": "swift", "code": [ + "var body: some View {", " Text(\"Hello\")", "", "}" + ]} + """# + ) + + // -- Act -- + let output = TextTypeDocumentationRenderer().render(page) + + // -- Assert -- + #expect(output.contains("Overview\n────────\n\n Implement body using `Text`.")) + #expect( + output.contains( + """ + ╭─ Swift ───────────────╮ + │ var body: some View { │ + │ Text("Hello") │ + │ │ + │ } │ + ╰───────────────────────╯ + """ + ) + ) + } + + @Test("wraps prose to 80 columns with consistent indentation") + func wrapsProse() throws { + // -- Arrange -- + let page = try makePage( + content: """ + {"type": "paragraph", "inlineContent": [ + {"text": "This paragraph contains enough words to extend beyond the usual terminal width. "}, + {"text": "The next sentence should continue on an indented line."} + ]} + """ + ) + + // -- Act -- + let output = TextTypeDocumentationRenderer().render(page) + + // -- Assert -- + #expect( + output.contains( + """ + This paragraph contains enough words to extend beyond the usual terminal + width. The next sentence should continue on an indented line. + """ + ) + ) + } + + @Test("renders nested lists and note callouts") + func rendersListsAndNotes() throws { + // -- Arrange -- + let page = try makePage( + content: """ + {"type": "orderedList", "startIndex": 3, "items": [ + {"content": [ + {"type": "paragraph", "inlineContent": [{"text": "Create a view."}]}, + {"type": "unorderedList", "items": [{"content": [ + {"type": "paragraph", "inlineContent": [{"text": "Add a body."}]} + ]}]} + ]}, + {"content": [{"type": "paragraph", "inlineContent": [{"text": "Preview it."}]}]} + ]}, + {"type": "aside", "style": "note", "name": "Note", "content": [ + {"type": "paragraph", "inlineContent": [{"text": "Keep the body lightweight."}]} + ]} + """ + ) + + // -- Act -- + let output = TextTypeDocumentationRenderer().render(page) + + // -- Assert -- + #expect(output.contains(" 3. Create a view.\n\n • Add a body.\n 4. Preview it.")) + #expect(output.contains(" Note\n │ Keep the body lightweight.")) + } + + @Test("preserves mixed inline content in the summary") + func rendersMixedSummary() throws { + // -- Arrange -- + let page = try makePage( + abstract: """ + {"text": "A "}, {"type": "codeVoice", "code": "View"}, + {"text": " with "}, {"type": "reference", "identifier": "doc://body"}, + {"text": " and "}, {"type": "emphasis", "inlineContent": [{"text": "style"}]}, + {"text": "."} + """ + ) + + // -- Act -- + let output = TextTypeDocumentationRenderer().render(page) + + // -- Assert -- + #expect(output.contains(" A `View` with body and style.")) + } + + @Test("omits empty content and unresolved reference groups") + func omitsEmptySections() throws { + // -- Arrange -- + let page = try makePage( + content: """ + {"type": "futureBlock", "items": [{"title": "An unsupported item"}]}, + {"type": "paragraph", "inlineContent": []} + """) + + // -- Act -- + let output = TextTypeDocumentationRenderer().render(page) + + // -- Assert -- + #expect(output == "View\n━━━━\nProtocol · SwiftUI") + } + + @Test( + "rejects recognized blocks missing required content", + arguments: [ + #"{"type": "paragraph"}"#, + #"{"type": "heading"}"#, + #"{"type": "codeListing", "syntax": "swift"}"#, + #"{"type": "unorderedList", "items": [{}]}"#, + #"{"type": "orderedList", "items": [{}]}"#, + #"{"type": "aside", "style": "note"}"#, + ] + ) + func rejectsMalformedKnownBlocks(block: String) { + // -- Arrange -- + let data = Data(block.utf8) + + // -- Act -- + let decode = { try JSONDecoder().decode(DocumentationBlockDTO.self, from: data) } + + // -- Assert -- + #expect(throws: DecodingError.self) { try decode() } + } + + private func makePage(abstract: String = "", content: String = "") throws -> TypeDocumentationPageDTO { + let data = Data( + """ + { + "abstract": [\(abstract)], + "metadata": { + "modules": [{"name": "SwiftUI"}], "platforms": [], + "roleHeading": "Protocol", "symbolKind": "protocol", "title": "View" + }, + "primaryContentSections": [{"kind": "content", "content": [\(content)]}], + "references": {"doc://body": {"title": "body", "url": "/documentation/swiftui/view/body"}}, + "topicSections": [{"title": "Missing", "identifiers": ["doc://missing"]}] + } + """.utf8 + ) + return try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + } +} diff --git a/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift b/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift index a49260c..e4954d8 100644 --- a/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift +++ b/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift @@ -5,6 +5,42 @@ import Testing @Suite("Text type documentation reference rendering") struct TextTypeDocumentationRendererReferenceTests { + @Test("renders the canonical documentation URL") + func rendersCanonicalURL() throws { + // -- Arrange -- + let data = Data( + """ + { + "abstract": [], + "metadata": { + "modules": [{"name": "MetricKit"}], + "platforms": [], + "roleHeading": "Class", + "symbolKind": "class", + "title": "MXHangDiagnostic" + }, + "primaryContentSections": [], + "references": {}, + "variants": [{ + "paths": ["/documentation/metrickit/mxhangdiagnostic"], + "traits": [{"interfaceLanguage": "swift"}] + }] + } + """.utf8 + ) + let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + + // -- Act -- + let output = TextTypeDocumentationRenderer().render(page) + + // -- Assert -- + #expect( + output.hasSuffix( + "https://developer.apple.com/documentation/metrickit/mxhangdiagnostic" + ) + ) + } + @Test("renders inline references and follow-up links in documentation sections") func rendersInlineReferencesAndLinks() throws { // -- Arrange -- @@ -47,7 +83,7 @@ struct TextTypeDocumentationRendererReferenceTests { let output = TextTypeDocumentationRenderer().render(page) // -- Assert -- - #expect(output.contains("init(intent:label:) — Creates a button that performs an AppIntent.")) + #expect(output.contains(" • init(intent:label:)\n Creates a button that performs an `AppIntent`.")) #expect( output.contains( "https://developer.apple.com/documentation/swiftui/button/init(intent:label:)" diff --git a/Tests/CLITests/renderer/TextTypeDocumentationRendererTests.swift b/Tests/CLITests/renderer/TextTypeDocumentationRendererTests.swift index 93c50f6..4e1e905 100644 --- a/Tests/CLITests/renderer/TextTypeDocumentationRendererTests.swift +++ b/Tests/CLITests/renderer/TextTypeDocumentationRendererTests.swift @@ -7,6 +7,7 @@ import Testing struct TextTypeDocumentationRendererTests { @Test("renders the type summary and declaration") func rendersSummaryAndDeclaration() throws { + // -- Arrange -- let data = Data( """ { @@ -36,24 +37,30 @@ struct TextTypeDocumentationRendererTests { ) let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + // -- Act -- let output = TextTypeDocumentationRenderer().render(page) + // -- Assert -- #expect( output == """ MXHangDiagnostic + ━━━━━━━━━━━━━━━━ Class · MetricKit - An object representing a diagnostic report. + An object representing a diagnostic report. Declaration - - class MXHangDiagnostic + ─────────── + ╭─ Swift ────────────────╮ + │ class MXHangDiagnostic │ + ╰────────────────────────╯ """ ) } @Test("renders referenced deprecation guidance") func rendersDeprecationGuidance() throws { + // -- Arrange -- let data = Data( """ { @@ -92,13 +99,16 @@ struct TextTypeDocumentationRendererTests { ) let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + // -- Act -- let output = TextTypeDocumentationRenderer().render(page) - #expect(output.contains("Deprecated\n\nUse HangDiagnostic instead.")) + // -- Assert -- + #expect(output.contains("Deprecated\n──────────\n Use HangDiagnostic instead.")) } @Test("renders platform availability ranges") func rendersPlatformAvailability() throws { + // -- Arrange -- let data = Data( """ { @@ -120,15 +130,29 @@ struct TextTypeDocumentationRendererTests { ) let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + // -- Act -- let output = TextTypeDocumentationRenderer().render(page) - #expect(output.contains("Availability\n\n iOS 14.0–27.0\n macOS 12.0+")) + // -- Assert -- + #expect( + output.contains( + """ + Availability + ──────────── + ╭───────┬───────────╮ + │ iOS │ 14.0–27.0 │ + │ macOS │ 12.0+ │ + ╰───────┴───────────╯ + """ + ) + ) } @Test("renders linked documentation sections") // Most of this function is the DocC fixture covering several linked section kinds. // swiftlint:disable:next function_body_length func rendersLinkedSections() throws { + // -- Arrange -- let data = Data( """ { @@ -184,15 +208,24 @@ struct TextTypeDocumentationRendererTests { ) let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + // -- Act -- let output = TextTypeDocumentationRenderer().render(page) - #expect(output.contains("Inherits From\n\n MXDiagnostic")) + // -- Assert -- + #expect(output.contains("Inherits From\n─────────────\n • MXDiagnostic")) #expect( output.contains( - "Reading total app hang time\n\n hangDuration — The total duration of hangs." + """ + Reading total app hang time + ─────────────────────────── + • hangDuration + The total duration of hangs. + """ ) ) - #expect(output.contains("See Also: Performance diagnostics\n\n MXCrashDiagnostic")) + #expect(output.contains("Topics\n━━━━━━")) + #expect(output.contains("See Also\n━━━━━━━━")) + #expect(output.contains("Performance diagnostics\n───────────────────────\n • MXCrashDiagnostic")) } @Test("omits untitled references from documentation sections") @@ -229,39 +262,6 @@ struct TextTypeDocumentationRendererTests { let output = TextTypeDocumentationRenderer().render(page) // -- Assert -- - #expect(output == "String\nStructure · Swift") - } - - @Test("renders the canonical documentation URL") - func rendersCanonicalURL() throws { - let data = Data( - """ - { - "abstract": [], - "metadata": { - "modules": [{"name": "MetricKit"}], - "platforms": [], - "roleHeading": "Class", - "symbolKind": "class", - "title": "MXHangDiagnostic" - }, - "primaryContentSections": [], - "references": {}, - "variants": [{ - "paths": ["/documentation/metrickit/mxhangdiagnostic"], - "traits": [{"interfaceLanguage": "swift"}] - }] - } - """.utf8 - ) - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) - - let output = TextTypeDocumentationRenderer().render(page) - - #expect( - output.hasSuffix( - "https://developer.apple.com/documentation/metrickit/mxhangdiagnostic" - ) - ) + #expect(output == "String\n━━━━━━\nStructure · Swift") } }