From eec3e9fb1b4135f7714107e49354426490d6b2b5 Mon Sep 17 00:00:00 2001 From: Steve Streza Date: Tue, 24 Mar 2026 10:48:01 -0700 Subject: [PATCH 1/6] RichText parsing --- .../ServerDrivenUI/RichTextElement.swift | 104 +++++ .../RichTextGQLConversions.swift | 419 ++++++++++++++++++ .../RichTextItemFragments.swift | 0 .../ServerDrivenUI/RichTextParser.swift | 0 4 files changed, 523 insertions(+) create mode 100644 ServerDrivenUI/Sources/ServerDrivenUI/RichTextGQLConversions.swift delete mode 100644 ServerDrivenUI/Sources/ServerDrivenUI/RichTextItemFragments.swift delete mode 100644 ServerDrivenUI/Sources/ServerDrivenUI/RichTextParser.swift diff --git a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElement.swift b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElement.swift index e69de29bb2..bfa8ce0e3f 100644 --- a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElement.swift +++ b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElement.swift @@ -0,0 +1,104 @@ +import Foundation +import GraphAPI + +/// Represents a single block-level element in a RichText list. Used to make implementing +/// the different views in SwiftUI more easily and with a consistent interface. +public indirect enum RichTextElement { + case text(Text, HeaderLevel?) + case listItemOpen + case listItemClose + case listItem(Text) + case audio(Audio) + case photo(Photo) + case video(Video) + case oembed(Oembed) + + public struct Text { + let text: String + let link: URL? + let styles: [Style] + let children: [RichTextElement] + + public enum Style: String { + case strong = "STRONG" + case emphasis = "EMPHASIS" + case heading1 = "HEADING_1" + case heading2 = "HEADING_2" + case heading3 = "HEADING_3" + case heading4 = "HEADING_4" + + public init?(rawValue: String) { + switch rawValue { + case "STRONG": + self = .strong + case "EMPHASIS": + self = .emphasis + case "HEADING_1": + self = .heading1 + case "HEADING_2": + self = .heading2 + case "HEADING_3": + self = .heading3 + case "HEADING_4": + self = .heading4 + default: + assertionFailure("unknown style type: \(rawValue)") + return nil + } + } + } + } + + public enum HeaderLevel: String { + case one = "HEADING_1" + case two = "HEADING_2" + case three = "HEADING_3" + case four = "HEADING_4" + + init?(_ rawValues: [String]?) { + for rawValue in rawValues ?? [] { + if let level = HeaderLevel(rawValue: rawValue) { + self = level + return + } + } + return nil + } + } + + public struct Audio { + let altText: String? + let assetID: String? + let caption: String? + let url: String? + } + + public struct Photo { + let altText: String? + let assetID: String? + let caption: String? + let url: String? + } + + public struct Video { + let altText: String? + let assetID: String? + let caption: String? + let url: String? + } + + public struct Oembed { + let width: Int + let height: Int + let version: String + let title: String + let type: String + + let iframeUrl: String? + let originalUrl: String? + + let thumbnailUrl: String? + let thumbnailWidth: Int? + let thumbnailHeight: Int? + } +} diff --git a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextGQLConversions.swift b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextGQLConversions.swift new file mode 100644 index 0000000000..21acb65773 --- /dev/null +++ b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextGQLConversions.swift @@ -0,0 +1,419 @@ +import Foundation +import GraphAPI + +extension RichTextComponentFragment { + public func asRichTextElements() -> [RichTextElement] { + self.items.map { $0.asRichTextElement() } + } +} + +extension RichTextComponentFragment.Item { + func asRichTextElement() -> RichTextElement { + if let x = asRichText { return x.asRichTextElement } + if let x = asRichTextHeader { return x.asRichTextElement } + if let x = asRichTextListItem { return x.asRichTextElement } + if let x = asRichTextListOpen { return x.asRichTextElement } + if let x = asRichTextListClose { return x.asRichTextElement } + if let x = asRichTextPhoto { return x.asRichTextElement } + if let x = asRichTextAudio { return x.asRichTextElement } + if let x = asRichTextVideo { return x.asRichTextElement } + if let x = asRichTextOembed { return x.asRichTextElement } + return .text(makeText(text: nil, link: nil, styles: nil, children: []), nil) + } +} + +extension RichTextComponentFragment.Item.AsRichText.Child { + func asRichTextElement() -> RichTextElement { + if let x = asRichText { return x.asRichTextElement } + if let x = asRichTextHeader { return x.asRichTextElement } + if let x = asRichTextListItem { return x.asRichTextElement } + if let x = asRichTextListOpen { return x.asRichTextElement } + if let x = asRichTextListClose { return x.asRichTextElement } + if let x = asRichTextPhoto { return x.asRichTextElement } + if let x = asRichTextAudio { return x.asRichTextElement } + if let x = asRichTextVideo { return x.asRichTextElement } + if let x = asRichTextOembed { return x.asRichTextElement } + return .text(makeText(text: nil, link: nil, styles: nil, children: []), nil) + } +} + +extension RichTextComponentFragment.Item.AsRichTextHeader.Child { + func asRichTextElement() -> RichTextElement { + if let x = asRichText { return x.asRichTextElement } + if let x = asRichTextHeader { return x.asRichTextElement } + if let x = asRichTextListItem { return x.asRichTextElement } + if let x = asRichTextListOpen { return x.asRichTextElement } + if let x = asRichTextListClose { return x.asRichTextElement } + if let x = asRichTextPhoto { return x.asRichTextElement } + if let x = asRichTextAudio { return x.asRichTextElement } + if let x = asRichTextVideo { return x.asRichTextElement } + if let x = asRichTextOembed { return x.asRichTextElement } + return .text(makeText(text: nil, link: nil, styles: nil, children: []), nil) + } +} + +extension RichTextComponentFragment.Item.AsRichTextListItem.Child { + func asRichTextElement() -> RichTextElement { + if let x = asRichText { return x.asRichTextElement } + if let x = asRichTextHeader { return x.asRichTextElement } + if let x = asRichTextListItem { return x.asRichTextElement } + if let x = asRichTextListOpen { return x.asRichTextElement } + if let x = asRichTextListClose { return x.asRichTextElement } + if let x = asRichTextPhoto { return x.asRichTextElement } + if let x = asRichTextAudio { return x.asRichTextElement } + if let x = asRichTextVideo { return x.asRichTextElement } + if let x = asRichTextOembed { return x.asRichTextElement } + return .text(makeText(text: nil, link: nil, styles: nil, children: []), nil) + } +} + +extension RichTextComponentFragment.Item.AsRichText { + var asRichTextElement: RichTextElement { + let children = (children ?? []).map { $0.asRichTextElement() } + return .text(makeText(text: text, link: link, styles: styles, children: children), nil) + } +} + +extension RichTextComponentFragment.Item.AsRichText.Child.AsRichText { + var asRichTextElement: RichTextElement { + .text(makeText(text: text, link: link, styles: styles, children: []), nil) + } +} + +extension RichTextComponentFragment.Item.AsRichText.Child.AsRichTextHeader { + var asRichTextElement: RichTextElement { + .text(makeText(text: text, link: link, styles: styles, children: []), .init(styles)) + } +} + +extension RichTextComponentFragment.Item.AsRichText.Child.AsRichTextListItem { + var asRichTextElement: RichTextElement { + .listItem(makeText(text: text, link: link, styles: styles, children: [])) + } +} + +extension RichTextComponentFragment.Item.AsRichTextHeader { + var asRichTextElement: RichTextElement { + let children = (children ?? []).map { $0.asRichTextElement() } + return .text(makeText(text: text, link: link, styles: styles, children: children), .init(styles)) + } +} + +extension RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichText { + var asRichTextElement: RichTextElement { + .text(makeText(text: text, link: link, styles: styles, children: []), nil) + } +} + +extension RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextHeader { + var asRichTextElement: RichTextElement { + .text(makeText(text: text, link: link, styles: styles, children: []), .init(styles)) + } +} + +extension RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextListItem { + var asRichTextElement: RichTextElement { + .listItem(makeText(text: text, link: link, styles: styles, children: [])) + } +} + +extension RichTextComponentFragment.Item.AsRichTextListItem { + var asRichTextElement: RichTextElement { + let children = (children ?? []).map { $0.asRichTextElement() } + return .listItem(makeText(text: text, link: link, styles: styles, children: children)) + } +} + +extension RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichText { + var asRichTextElement: RichTextElement { + .text(makeText(text: text, link: link, styles: styles, children: []), nil) + } +} + +extension RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextHeader { + var asRichTextElement: RichTextElement { + .text(makeText(text: text, link: link, styles: styles, children: []), .init(styles)) + } +} + +extension RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextListItem { + var asRichTextElement: RichTextElement { + .listItem(makeText(text: text, link: link, styles: styles, children: [])) + } +} + +extension RichTextComponentFragment.Item.AsRichTextListOpen { + var asRichTextElement: RichTextElement { + .listItemOpen + } +} + +extension RichTextComponentFragment.Item.AsRichText.Child.AsRichTextListOpen { + var asRichTextElement: RichTextElement { + .listItemOpen + } +} + +extension RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextListOpen { + var asRichTextElement: RichTextElement { + .listItemOpen + } +} + +extension RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextListOpen { + var asRichTextElement: RichTextElement { + .listItemOpen + } +} + +extension RichTextComponentFragment.Item.AsRichTextListClose { + var asRichTextElement: RichTextElement { + .listItemClose + } +} + +extension RichTextComponentFragment.Item.AsRichText.Child.AsRichTextListClose { + var asRichTextElement: RichTextElement { + .listItemClose + } +} + +extension RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextListClose { + var asRichTextElement: RichTextElement { + .listItemClose + } +} + +extension RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextListClose { + var asRichTextElement: RichTextElement { + .listItemClose + } +} + +extension RichTextComponentFragment.Item.AsRichTextAudio { + var asRichTextElement: RichTextElement { + .audio(RichTextElement.Audio( + altText: altText, + assetID: asset?.id, + caption: caption, + url: url + )) + } +} + +extension RichTextComponentFragment.Item.AsRichText.Child.AsRichTextAudio { + var asRichTextElement: RichTextElement { + .audio(RichTextElement.Audio( + altText: altText, + assetID: asset?.id, + caption: caption, + url: url + )) + } +} + +extension RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextAudio { + var asRichTextElement: RichTextElement { + .audio(RichTextElement.Audio( + altText: altText, + assetID: asset?.id, + caption: caption, + url: url + )) + } +} + +extension RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextAudio { + var asRichTextElement: RichTextElement { + .audio(RichTextElement.Audio( + altText: altText, + assetID: asset?.id, + caption: caption, + url: url + )) + } +} + +extension RichTextComponentFragment.Item.AsRichTextPhoto { + var asRichTextElement: RichTextElement { + .photo(RichTextElement.Photo( + altText: altText, + assetID: asset?.id, + caption: caption, + url: url + )) + } +} + +extension RichTextComponentFragment.Item.AsRichText.Child.AsRichTextPhoto { + var asRichTextElement: RichTextElement { + .photo(RichTextElement.Photo( + altText: altText, + assetID: asset?.id, + caption: caption, + url: url + )) + } +} + +extension RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextPhoto { + var asRichTextElement: RichTextElement { + .photo(RichTextElement.Photo( + altText: altText, + assetID: asset?.id, + caption: caption, + url: url + )) + } +} + +extension RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextPhoto { + var asRichTextElement: RichTextElement { + .photo(RichTextElement.Photo( + altText: altText, + assetID: asset?.id, + caption: caption, + url: url + )) + } +} + +extension RichTextComponentFragment.Item.AsRichTextVideo { + var asRichTextElement: RichTextElement { + .video(RichTextElement.Video( + altText: altText, + assetID: asset?.id, + caption: caption, + url: url + )) + } +} + +extension RichTextComponentFragment.Item.AsRichText.Child.AsRichTextVideo { + var asRichTextElement: RichTextElement { + .video(RichTextElement.Video( + altText: altText, + assetID: asset?.id, + caption: caption, + url: url + )) + } +} + +extension RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextVideo { + var asRichTextElement: RichTextElement { + .video(RichTextElement.Video( + altText: altText, + assetID: asset?.id, + caption: caption, + url: url + )) + } +} + +extension RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextVideo { + var asRichTextElement: RichTextElement { + .video(RichTextElement.Video( + altText: altText, + assetID: asset?.id, + caption: caption, + url: url + )) + } +} + +extension RichTextComponentFragment.Item.AsRichTextOembed { + var asRichTextElement: RichTextElement { + .oembed(RichTextElement.Oembed( + width: width, + height: height, + version: version, + title: title, + type: type, + iframeUrl: iframeUrl, + originalUrl: originalUrl, + thumbnailUrl: thumbnailUrl, + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight + )) + } +} + +extension RichTextComponentFragment.Item.AsRichText.Child.AsRichTextOembed { + var asRichTextElement: RichTextElement { + .oembed(RichTextElement.Oembed( + width: width, + height: height, + version: version, + title: title, + type: type, + iframeUrl: iframeUrl, + originalUrl: originalUrl, + thumbnailUrl: thumbnailUrl, + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight + )) + } +} + +extension RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextOembed { + var asRichTextElement: RichTextElement { + .oembed(RichTextElement.Oembed( + width: width, + height: height, + version: version, + title: title, + type: type, + iframeUrl: iframeUrl, + originalUrl: originalUrl, + thumbnailUrl: thumbnailUrl, + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight + )) + } +} + +extension RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextOembed { + var asRichTextElement: RichTextElement { + .oembed(RichTextElement.Oembed( + width: width, + height: height, + version: version, + title: title, + type: type, + iframeUrl: iframeUrl, + originalUrl: originalUrl, + thumbnailUrl: thumbnailUrl, + thumbnailWidth: thumbnailWidth, + thumbnailHeight: thumbnailHeight + )) + } +} + +private func makeTextElement( + text: String?, + link: String?, + styles: [RichTextElement.Text.Style]?, + children: [RichTextElement] +) -> RichTextElement.Text { + RichTextElement.Text( + text: text ?? "", + link: link.flatMap { URL(string: $0) }, + styles: styles ?? [], + children: children + ) +} + +private func makeText( + text: String?, + link: String?, + styles: [String]?, + children: [RichTextElement] +) -> RichTextElement.Text { + makeTextElement( + text: text, + link: link, + styles: styles?.compactMap(RichTextElement.Text.Style.init(rawValue:)), + children: children + ) +} diff --git a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextItemFragments.swift b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextItemFragments.swift deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextParser.swift b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextParser.swift deleted file mode 100644 index e69de29bb2..0000000000 From a36933d27aacfb63733dc91d1d3186e417ba4c14 Mon Sep 17 00:00:00 2001 From: Steve Streza Date: Tue, 24 Mar 2026 12:57:16 -0700 Subject: [PATCH 2/6] Add unit tests --- .../RichTextGQLConversionTests.swift | 439 ++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 ServerDrivenUI/Tests/ServerDrivenUITests/RichTextGQLConversionTests.swift diff --git a/ServerDrivenUI/Tests/ServerDrivenUITests/RichTextGQLConversionTests.swift b/ServerDrivenUI/Tests/ServerDrivenUITests/RichTextGQLConversionTests.swift new file mode 100644 index 0000000000..d23970be28 --- /dev/null +++ b/ServerDrivenUI/Tests/ServerDrivenUITests/RichTextGQLConversionTests.swift @@ -0,0 +1,439 @@ +import Foundation +import GraphAPI +@testable import ServerDrivenUI +import Testing + +@Test func asRichTextItemAsRichText() async throws { + let gql = RichTextComponentFragment.Item.AsRichText( + children: nil, + text: "foo", + link: "https://kickstarter.com/", + styles: ["STRONG"] + ) + let el = gql.asRichTextElement + guard case let .text(t, level) = el else { Issue.record("expected .text"); return } + #expect(t.text == "foo") + #expect(t.link == URL(string: "https://kickstarter.com/")) + #expect(t.styles == [.strong]) + #expect(t.children.isEmpty) + #expect(level == nil) +} + +@Test func asRichTextChildAsRichText() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichText(text: "bar", link: nil, styles: []) + let el = gql.asRichTextElement + guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } + #expect(t.text == "bar") + #expect(t.children.isEmpty) +} + +@Test func asRichTextHeaderChildAsRichText() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichText( + text: "h", + link: nil, + styles: ["EMPHASIS"] + ) + let el = gql.asRichTextElement + guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } + #expect(t.text == "h") + #expect(t.styles == [.emphasis]) +} + +@Test func asRichTextListItemChildAsRichText() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichText( + text: "li", + link: nil, + styles: nil + ) + let el = gql.asRichTextElement + guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } + #expect(t.text == "li") +} + +@Test func asRichTextHeader() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader( + children: nil, + text: "header", + link: nil, + styles: [] + ) + let el = gql.asRichTextElement + guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } + #expect(t.text == "header") +} + +@Test func asRichTextHeaderChildAsRichTextHeader() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextHeader( + text: "h2", + link: nil, + styles: [] + ) + let el = gql.asRichTextElement + guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } + #expect(t.text == "h2") +} + +@Test func asRichTextChildAsRichTextHeader() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextHeader( + text: "x", + link: nil, + styles: nil + ) + let el = gql.asRichTextElement + guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } + #expect(t.text == "x") +} + +@Test func asRichTextListItemChildAsRichTextHeader() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextHeader( + text: "y", + link: nil, + styles: nil + ) + let el = gql.asRichTextElement + guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } + #expect(t.text == "y") +} + +@Test func asRichTextListItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem( + children: nil, + text: "bullet", + link: nil, + styles: [] + ) + let el = gql.asRichTextElement + guard case let .listItem(t) = el else { Issue.record("expected .listItem"); return } + #expect(t.text == "bullet") + #expect(t.children.isEmpty) +} + +@Test func asRichTextListItemChildAsRichTextListItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextListItem( + text: "nested", + link: nil, + styles: [] + ) + let el = gql.asRichTextElement + guard case let .listItem(t) = el else { Issue.record("expected .listItem"); return } + #expect(t.text == "nested") +} + +@Test func asRichTextChildAsRichTextListItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextListItem( + text: "a", + link: nil, + styles: nil + ) + let el = gql.asRichTextElement + guard case let .listItem(t) = el else { Issue.record("expected .listItem"); return } + #expect(t.text == "a") +} + +@Test func asRichTextHeaderChildAsRichTextListItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextListItem( + text: "b", + link: nil, + styles: nil + ) + let el = gql.asRichTextElement + guard case let .listItem(t) = el else { Issue.record("expected .listItem"); return } + #expect(t.text == "b") +} + +@Test func asRichTextListOpenItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListOpen(_present: true) + let el = gql.asRichTextElement + guard case .listItemOpen = el else { Issue.record("expected .listItemOpen"); return } +} + +@Test func asRichTextListOpenChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextListOpen(_present: true) + let el = gql.asRichTextElement + guard case .listItemOpen = el else { Issue.record("expected .listItemOpen"); return } +} + +@Test func asRichTextListOpenHeaderChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextListOpen(_present: true) + let el = gql.asRichTextElement + guard case .listItemOpen = el else { Issue.record("expected .listItemOpen"); return } +} + +@Test func asRichTextListOpenListItemChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextListOpen(_present: true) + let el = gql.asRichTextElement + guard case .listItemOpen = el else { Issue.record("expected .listItemOpen"); return } +} + +@Test func asRichTextListCloseItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListClose(_present: true) + let el = gql.asRichTextElement + guard case .listItemClose = el else { Issue.record("expected .listItemClose"); return } +} + +@Test func asRichTextListCloseChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextListClose(_present: true) + let el = gql.asRichTextElement + guard case .listItemClose = el else { Issue.record("expected .listItemClose"); return } +} + +@Test func asRichTextListCloseHeaderChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextListClose(_present: true) + let el = gql.asRichTextElement + guard case .listItemClose = el else { Issue.record("expected .listItemClose"); return } +} + +@Test func asRichTextListCloseListItemChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextListClose(_present: true) + let el = gql.asRichTextElement + guard case .listItemClose = el else { Issue.record("expected .listItemClose"); return } +} + +@Test func asRichTextAudioItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextAudio( + altText: "alt", + asset: nil, + caption: "cap", + url: "https://audio" + ) + let el = gql.asRichTextElement + guard case let .audio(a) = el else { Issue.record("expected .audio"); return } + #expect(a.altText == "alt") + #expect(a.assetID == nil) + #expect(a.caption == "cap") + #expect(a.url == "https://audio") +} + +@Test func asRichTextAudioChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextAudio( + altText: "a", + asset: nil, + caption: "c", + url: "u" + ) + let el = gql.asRichTextElement + guard case let .audio(a) = el else { Issue.record("expected .audio"); return } + #expect(a.altText == "a") + #expect(a.url == "u") +} + +@Test func asRichTextAudioWithAsset() async throws { + let asset = RichTextItemFragment.AsRichTextAudio.Asset(id: "asset-1") + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextAudio( + altText: "", + asset: asset, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .audio(a) = el else { Issue.record("expected .audio"); return } + #expect(a.assetID == "asset-1") +} + +@Test func asRichTextPhotoItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextPhoto( + altText: "photo", + asset: nil, + caption: "cap", + url: "https://img" + ) + let el = gql.asRichTextElement + guard case let .photo(p) = el else { Issue.record("expected .photo"); return } + #expect(p.altText == "photo") + #expect(p.assetID == nil) + #expect(p.url == "https://img") +} + +@Test func asRichTextPhotoChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextPhoto( + altText: "p", + asset: nil, + caption: "c", + url: "u" + ) + let el = gql.asRichTextElement + guard case let .photo(p) = el else { Issue.record("expected .photo"); return } + #expect(p.altText == "p") +} + +@Test func asRichTextVideoItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextVideo( + altText: "v", + asset: nil, + caption: "cap", + url: "https://vid" + ) + let el = gql.asRichTextElement + guard case let .video(v) = el else { Issue.record("expected .video"); return } + #expect(v.altText == "v") + #expect(v.url == "https://vid") +} + +@Test func asRichTextVideoChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextVideo( + altText: "x", + asset: nil, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .video(v) = el else { Issue.record("expected .video"); return } + #expect(v.altText == "x") +} + +@Test func asRichTextOembedItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextOembed( + width: 200, + height: 100, + version: "1.0", + title: "Title", + type: "video", + iframeUrl: "https://iframe", + originalUrl: "https://orig", + thumbnailHeight: 50, + thumbnailUrl: "https://thumb", + thumbnailWidth: 60 + ) + let el = gql.asRichTextElement + guard case let .oembed(o) = el else { Issue.record("expected .oembed"); return } + #expect(o.width == 200) + #expect(o.height == 100) + #expect(o.title == "Title") + #expect(o.type == "video") + #expect(o.version == "1.0") + #expect(o.iframeUrl == "https://iframe") + #expect(o.originalUrl == "https://orig") + #expect(o.thumbnailUrl == "https://thumb") + #expect(o.thumbnailWidth == 60) + #expect(o.thumbnailHeight == 50) +} + +@Test func asRichTextOembedChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextOembed( + width: 4, + height: 1, + version: "1.0", + title: "T", + type: "rich", + iframeUrl: "i", + originalUrl: "o", + thumbnailHeight: 2, + thumbnailUrl: "t", + thumbnailWidth: 3 + ) + let el = gql.asRichTextElement + guard case let .oembed(o) = el else { Issue.record("expected .oembed"); return } + #expect(o.width == 4) + #expect(o.height == 1) + #expect(o.title == "T") + #expect(o.type == "rich") +} + +@Test func asRichTextHeaderChildAsRichTextAudio() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextAudio( + altText: "h", + asset: nil, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .audio(a) = el else { Issue.record("expected .audio"); return } + #expect(a.altText == "h") +} + +@Test func asRichTextListItemChildAsRichTextAudio() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextAudio( + altText: "li", + asset: nil, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .audio(a) = el else { Issue.record("expected .audio"); return } + #expect(a.altText == "li") +} + +@Test func asRichTextHeaderChildAsRichTextPhoto() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextPhoto( + altText: "hp", + asset: nil, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .photo(p) = el else { Issue.record("expected .photo"); return } + #expect(p.altText == "hp") +} + +@Test func asRichTextListItemChildAsRichTextPhoto() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextPhoto( + altText: "lip", + asset: nil, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .photo(p) = el else { Issue.record("expected .photo"); return } + #expect(p.altText == "lip") +} + +@Test func asRichTextHeaderChildAsRichTextVideo() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextVideo( + altText: "hv", + asset: nil, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .video(v) = el else { Issue.record("expected .video"); return } + #expect(v.altText == "hv") +} + +@Test func asRichTextListItemChildAsRichTextVideo() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextVideo( + altText: "liv", + asset: nil, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .video(v) = el else { Issue.record("expected .video"); return } + #expect(v.altText == "liv") +} + +@Test func asRichTextHeaderChildAsRichTextOembed() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextOembed( + width: 10, + height: 10, + version: "1.0", + title: "H", + type: "link", + iframeUrl: "", + originalUrl: "", + thumbnailHeight: 10, + thumbnailUrl: "", + thumbnailWidth: 10 + ) + let el = gql.asRichTextElement + guard case let .oembed(o) = el else { Issue.record("expected .oembed"); return } + #expect(o.title == "H") +} + +@Test func asRichTextListItemChildAsRichTextOembed() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextOembed( + width: 5, + height: 5, + version: "1.0", + title: "L", + type: "photo", + iframeUrl: "", + originalUrl: "", + thumbnailHeight: 5, + thumbnailUrl: "", + thumbnailWidth: 5 + ) + let el = gql.asRichTextElement + guard case let .oembed(o) = el else { Issue.record("expected .oembed"); return } + #expect(o.title == "L") + #expect(o.width == 5) +} From 01b455d60d7f9a52d10a6d82d591509bc548e524 Mon Sep 17 00:00:00 2001 From: Steve Streza Date: Mon, 30 Mar 2026 18:59:10 -0700 Subject: [PATCH 3/6] Fixes for code review comments --- .../ServerDrivenUI/RichTextElement.swift | 81 +++++++------------ .../RichTextGQLConversions.swift | 8 +- 2 files changed, 35 insertions(+), 54 deletions(-) diff --git a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElement.swift b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElement.swift index bfa8ce0e3f..f726d8a039 100644 --- a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElement.swift +++ b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElement.swift @@ -1,5 +1,4 @@ import Foundation -import GraphAPI /// Represents a single block-level element in a RichText list. Used to make implementing /// the different views in SwiftUI more easily and with a consistent interface. @@ -14,10 +13,10 @@ public indirect enum RichTextElement { case oembed(Oembed) public struct Text { - let text: String - let link: URL? - let styles: [Style] - let children: [RichTextElement] + public let text: String + public let link: URL? + public let styles: [Style] + public let children: [RichTextElement] public enum Style: String { case strong = "STRONG" @@ -26,26 +25,6 @@ public indirect enum RichTextElement { case heading2 = "HEADING_2" case heading3 = "HEADING_3" case heading4 = "HEADING_4" - - public init?(rawValue: String) { - switch rawValue { - case "STRONG": - self = .strong - case "EMPHASIS": - self = .emphasis - case "HEADING_1": - self = .heading1 - case "HEADING_2": - self = .heading2 - case "HEADING_3": - self = .heading3 - case "HEADING_4": - self = .heading4 - default: - assertionFailure("unknown style type: \(rawValue)") - return nil - } - } } } @@ -55,9 +34,11 @@ public indirect enum RichTextElement { case three = "HEADING_3" case four = "HEADING_4" - init?(_ rawValues: [String]?) { - for rawValue in rawValues ?? [] { - if let level = HeaderLevel(rawValue: rawValue) { + /// Create a HeaderLevel from an array of style strings from GraphQL, if + /// one is present + internal init?(styles: [String]?) { + for style in styles ?? [] { + if let level = HeaderLevel(rawValue: style) { self = level return } @@ -67,38 +48,38 @@ public indirect enum RichTextElement { } public struct Audio { - let altText: String? - let assetID: String? - let caption: String? - let url: String? + public let altText: String? + public let assetID: String? + public let caption: String? + public let url: String? } public struct Photo { - let altText: String? - let assetID: String? - let caption: String? - let url: String? + public let altText: String? + public let assetID: String? + public let caption: String? + public let url: String? } public struct Video { - let altText: String? - let assetID: String? - let caption: String? - let url: String? + public let altText: String? + public let assetID: String? + public let caption: String? + public let url: String? } public struct Oembed { - let width: Int - let height: Int - let version: String - let title: String - let type: String + public let width: Int + public let height: Int + public let version: String + public let title: String + public let type: String - let iframeUrl: String? - let originalUrl: String? + public let iframeUrl: String? + public let originalUrl: String? - let thumbnailUrl: String? - let thumbnailWidth: Int? - let thumbnailHeight: Int? + public let thumbnailUrl: String? + public let thumbnailWidth: Int? + public let thumbnailHeight: Int? } } diff --git a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextGQLConversions.swift b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextGQLConversions.swift index 21acb65773..8af61401c0 100644 --- a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextGQLConversions.swift +++ b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextGQLConversions.swift @@ -82,7 +82,7 @@ extension RichTextComponentFragment.Item.AsRichText.Child.AsRichText { extension RichTextComponentFragment.Item.AsRichText.Child.AsRichTextHeader { var asRichTextElement: RichTextElement { - .text(makeText(text: text, link: link, styles: styles, children: []), .init(styles)) + .text(makeText(text: text, link: link, styles: styles, children: []), .init(styles: styles)) } } @@ -95,7 +95,7 @@ extension RichTextComponentFragment.Item.AsRichText.Child.AsRichTextListItem { extension RichTextComponentFragment.Item.AsRichTextHeader { var asRichTextElement: RichTextElement { let children = (children ?? []).map { $0.asRichTextElement() } - return .text(makeText(text: text, link: link, styles: styles, children: children), .init(styles)) + return .text(makeText(text: text, link: link, styles: styles, children: children), .init(styles: styles)) } } @@ -107,7 +107,7 @@ extension RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichText { extension RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextHeader { var asRichTextElement: RichTextElement { - .text(makeText(text: text, link: link, styles: styles, children: []), .init(styles)) + .text(makeText(text: text, link: link, styles: styles, children: []), .init(styles: styles)) } } @@ -132,7 +132,7 @@ extension RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichText { extension RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextHeader { var asRichTextElement: RichTextElement { - .text(makeText(text: text, link: link, styles: styles, children: []), .init(styles)) + .text(makeText(text: text, link: link, styles: styles, children: []), .init(styles: styles)) } } From b18e52f048bd8842baf140ad2601305cebda86cd Mon Sep 17 00:00:00 2001 From: Steve Streza Date: Tue, 7 Apr 2026 10:23:36 -0700 Subject: [PATCH 4/6] Code review comments --- .../ServerDrivenUI/RichTextElement.swift | 1 + .../RichTextElementHeaderLevelTests.swift | 48 +++++++++++++++++++ .../RichTextGQLConversions.swift | 28 ++++++----- 3 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 ServerDrivenUI/Sources/ServerDrivenUI/RichTextElementHeaderLevelTests.swift diff --git a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElement.swift b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElement.swift index f726d8a039..99535a0404 100644 --- a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElement.swift +++ b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElement.swift @@ -11,6 +11,7 @@ public indirect enum RichTextElement { case photo(Photo) case video(Video) case oembed(Oembed) + case unknown public struct Text { public let text: String diff --git a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElementHeaderLevelTests.swift b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElementHeaderLevelTests.swift new file mode 100644 index 0000000000..3f694f0f14 --- /dev/null +++ b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElementHeaderLevelTests.swift @@ -0,0 +1,48 @@ +import Testing + +@Suite("RichTextElement.HeaderLevel parsing") +struct RichTextElementHeaderLevelTests { + @Test("Parses single valid heading style: HEADING_1") + func parsesSingleValidHeading1() async throws { + let level = RichTextElement.HeaderLevel(styles: ["HEADING_1"]) + #expect(level == .one) + } + + @Test("Parses single valid heading style: HEADING_4") + func parsesSingleValidHeading4() async throws { + let level = RichTextElement.HeaderLevel(styles: ["HEADING_4"]) + #expect(level == .four) + } + + @Test("Returns first matching header when multiple styles present") + func returnsFirstMatchingHeader() async throws { + // Contains a non-header first, then a valid header + let level1 = RichTextElement.HeaderLevel(styles: ["STRONG", "HEADING_2"]) + #expect(level1 == .two) + + // Contains two headers; should pick the first header in order + let level2 = RichTextElement.HeaderLevel(styles: ["HEADING_3", "HEADING_1"]) + #expect(level2 == .three) + } + + @Test("Ignores unknown/invalid styles and returns nil if no header present") + func ignoresInvalidAndReturnsNil() async throws { + let level = RichTextElement.HeaderLevel(styles: ["STRONG", "EMPHASIS"]) + #expect(level == nil) + } + + @Test("Is case-sensitive and does not parse lowercase values") + func caseSensitive() async throws { + let level = RichTextElement.HeaderLevel(styles: ["heading_1"]) + #expect(level == nil) + } + + @Test("Returns nil for empty or nil arrays") + func emptyOrNilArrays() async throws { + let empty = RichTextElement.HeaderLevel(styles: []) + #expect(empty == nil) + + let nilArray = RichTextElement.HeaderLevel(styles: nil) + #expect(nilArray == nil) + } +} diff --git a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextGQLConversions.swift b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextGQLConversions.swift index 8af61401c0..9f072bffd3 100644 --- a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextGQLConversions.swift +++ b/ServerDrivenUI/Sources/ServerDrivenUI/RichTextGQLConversions.swift @@ -3,12 +3,12 @@ import GraphAPI extension RichTextComponentFragment { public func asRichTextElements() -> [RichTextElement] { - self.items.map { $0.asRichTextElement() } + self.items.map { $0.asRichTextElement } } } extension RichTextComponentFragment.Item { - func asRichTextElement() -> RichTextElement { + var asRichTextElement: RichTextElement { if let x = asRichText { return x.asRichTextElement } if let x = asRichTextHeader { return x.asRichTextElement } if let x = asRichTextListItem { return x.asRichTextElement } @@ -18,12 +18,13 @@ extension RichTextComponentFragment.Item { if let x = asRichTextAudio { return x.asRichTextElement } if let x = asRichTextVideo { return x.asRichTextElement } if let x = asRichTextOembed { return x.asRichTextElement } - return .text(makeText(text: nil, link: nil, styles: nil, children: []), nil) + assertionFailure("Could not detect a usable rich text element") + return RichTextElement.unknown } } extension RichTextComponentFragment.Item.AsRichText.Child { - func asRichTextElement() -> RichTextElement { + var asRichTextElement: RichTextElement { if let x = asRichText { return x.asRichTextElement } if let x = asRichTextHeader { return x.asRichTextElement } if let x = asRichTextListItem { return x.asRichTextElement } @@ -33,12 +34,13 @@ extension RichTextComponentFragment.Item.AsRichText.Child { if let x = asRichTextAudio { return x.asRichTextElement } if let x = asRichTextVideo { return x.asRichTextElement } if let x = asRichTextOembed { return x.asRichTextElement } - return .text(makeText(text: nil, link: nil, styles: nil, children: []), nil) + assertionFailure("Could not detect a usable rich text element") + return RichTextElement.unknown } } extension RichTextComponentFragment.Item.AsRichTextHeader.Child { - func asRichTextElement() -> RichTextElement { + var asRichTextElement: RichTextElement { if let x = asRichText { return x.asRichTextElement } if let x = asRichTextHeader { return x.asRichTextElement } if let x = asRichTextListItem { return x.asRichTextElement } @@ -48,12 +50,13 @@ extension RichTextComponentFragment.Item.AsRichTextHeader.Child { if let x = asRichTextAudio { return x.asRichTextElement } if let x = asRichTextVideo { return x.asRichTextElement } if let x = asRichTextOembed { return x.asRichTextElement } - return .text(makeText(text: nil, link: nil, styles: nil, children: []), nil) + assertionFailure("Could not detect a usable rich text element") + return RichTextElement.unknown } } extension RichTextComponentFragment.Item.AsRichTextListItem.Child { - func asRichTextElement() -> RichTextElement { + var asRichTextElement: RichTextElement { if let x = asRichText { return x.asRichTextElement } if let x = asRichTextHeader { return x.asRichTextElement } if let x = asRichTextListItem { return x.asRichTextElement } @@ -63,13 +66,14 @@ extension RichTextComponentFragment.Item.AsRichTextListItem.Child { if let x = asRichTextAudio { return x.asRichTextElement } if let x = asRichTextVideo { return x.asRichTextElement } if let x = asRichTextOembed { return x.asRichTextElement } - return .text(makeText(text: nil, link: nil, styles: nil, children: []), nil) + assertionFailure("Could not detect a usable rich text element") + return RichTextElement.unknown } } extension RichTextComponentFragment.Item.AsRichText { var asRichTextElement: RichTextElement { - let children = (children ?? []).map { $0.asRichTextElement() } + let children = (children ?? []).map { $0.asRichTextElement } return .text(makeText(text: text, link: link, styles: styles, children: children), nil) } } @@ -94,7 +98,7 @@ extension RichTextComponentFragment.Item.AsRichText.Child.AsRichTextListItem { extension RichTextComponentFragment.Item.AsRichTextHeader { var asRichTextElement: RichTextElement { - let children = (children ?? []).map { $0.asRichTextElement() } + let children = (children ?? []).map { $0.asRichTextElement } return .text(makeText(text: text, link: link, styles: styles, children: children), .init(styles: styles)) } } @@ -119,7 +123,7 @@ extension RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextListIt extension RichTextComponentFragment.Item.AsRichTextListItem { var asRichTextElement: RichTextElement { - let children = (children ?? []).map { $0.asRichTextElement() } + let children = (children ?? []).map { $0.asRichTextElement } return .listItem(makeText(text: text, link: link, styles: styles, children: children)) } } From 0a39e44d138ddeb40db05c6f637b203e34586da9 Mon Sep 17 00:00:00 2001 From: Steve Streza Date: Tue, 7 Apr 2026 11:40:24 -0700 Subject: [PATCH 5/6] Whoops wrong spot --- .../ServerDrivenUITests}/RichTextElementHeaderLevelTests.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename ServerDrivenUI/{Sources/ServerDrivenUI => Tests/ServerDrivenUITests}/RichTextElementHeaderLevelTests.swift (100%) diff --git a/ServerDrivenUI/Sources/ServerDrivenUI/RichTextElementHeaderLevelTests.swift b/ServerDrivenUI/Tests/ServerDrivenUITests/RichTextElementHeaderLevelTests.swift similarity index 100% rename from ServerDrivenUI/Sources/ServerDrivenUI/RichTextElementHeaderLevelTests.swift rename to ServerDrivenUI/Tests/ServerDrivenUITests/RichTextElementHeaderLevelTests.swift From b5f63d4dca0fb0bb7bc7163979c435bf810c61fa Mon Sep 17 00:00:00 2001 From: Steve Streza Date: Tue, 7 Apr 2026 15:16:04 -0700 Subject: [PATCH 6/6] Fix build and use Suite and Test labels for GQL tests --- .../RichTextElementHeaderLevelTests.swift | 1 + .../RichTextGQLConversionTests.swift | 906 +++++++++--------- 2 files changed, 476 insertions(+), 431 deletions(-) diff --git a/ServerDrivenUI/Tests/ServerDrivenUITests/RichTextElementHeaderLevelTests.swift b/ServerDrivenUI/Tests/ServerDrivenUITests/RichTextElementHeaderLevelTests.swift index 3f694f0f14..566e6d2f0c 100644 --- a/ServerDrivenUI/Tests/ServerDrivenUITests/RichTextElementHeaderLevelTests.swift +++ b/ServerDrivenUI/Tests/ServerDrivenUITests/RichTextElementHeaderLevelTests.swift @@ -1,3 +1,4 @@ +@testable import ServerDrivenUI import Testing @Suite("RichTextElement.HeaderLevel parsing") diff --git a/ServerDrivenUI/Tests/ServerDrivenUITests/RichTextGQLConversionTests.swift b/ServerDrivenUI/Tests/ServerDrivenUITests/RichTextGQLConversionTests.swift index d23970be28..c4c34ae7a1 100644 --- a/ServerDrivenUI/Tests/ServerDrivenUITests/RichTextGQLConversionTests.swift +++ b/ServerDrivenUI/Tests/ServerDrivenUITests/RichTextGQLConversionTests.swift @@ -3,437 +3,481 @@ import GraphAPI @testable import ServerDrivenUI import Testing -@Test func asRichTextItemAsRichText() async throws { - let gql = RichTextComponentFragment.Item.AsRichText( - children: nil, - text: "foo", - link: "https://kickstarter.com/", - styles: ["STRONG"] +@Suite("RichText GraphQL parsing") +struct RichTextGQLConversionTests { + @Test("AsRichText item -> .text element with styles, link, no children") + func asRichTextItemAsRichText() async throws { + let gql = RichTextComponentFragment.Item.AsRichText( + children: nil, + text: "foo", + link: "https://kickstarter.com/", + styles: ["STRONG"] + ) + let el = gql.asRichTextElement + guard case let .text(t, level) = el else { Issue.record("expected .text"); return } + #expect(t.text == "foo") + #expect(t.link == URL(string: "https://kickstarter.com/")) + #expect(t.styles == [.strong]) + #expect(t.children.isEmpty) + #expect(level == nil) + } + + @Test("Child.AsRichText -> .text element, no children") + func asRichTextChildAsRichText() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichText(text: "bar", link: nil, styles: []) + let el = gql.asRichTextElement + guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } + #expect(t.text == "bar") + #expect(t.children.isEmpty) + } + + @Test("Header.Child.AsRichText -> .text element with emphasis style") + func asRichTextHeaderChildAsRichText() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichText( + text: "h", + link: nil, + styles: ["EMPHASIS"] + ) + let el = gql.asRichTextElement + guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } + #expect(t.text == "h") + #expect(t.styles == [.emphasis]) + } + + @Test("ListItem.Child.AsRichText -> .text element") + func asRichTextListItemChildAsRichText() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichText( + text: "li", + link: nil, + styles: nil + ) + let el = gql.asRichTextElement + guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } + #expect(t.text == "li") + } + + @Test("AsRichTextHeader item -> .text element") + func asRichTextHeader() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader( + children: nil, + text: "header", + link: nil, + styles: [] + ) + let el = gql.asRichTextElement + guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } + #expect(t.text == "header") + } + + @Test("Header.Child.AsRichTextHeader -> .text element") + func asRichTextHeaderChildAsRichTextHeader() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextHeader( + text: "h2", + link: nil, + styles: [] + ) + let el = gql.asRichTextElement + guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } + #expect(t.text == "h2") + } + + @Test("Child.AsRichTextHeader -> .text element") + func asRichTextChildAsRichTextHeader() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextHeader( + text: "x", + link: nil, + styles: nil + ) + let el = gql.asRichTextElement + guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } + #expect(t.text == "x") + } + + @Test("ListItem.Child.AsRichTextHeader -> .text element") + func asRichTextListItemChildAsRichTextHeader() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextHeader( + text: "y", + link: nil, + styles: nil + ) + let el = gql.asRichTextElement + guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } + #expect(t.text == "y") + } + + @Test("AsRichTextListItem item -> .listItem element with no children") + func asRichTextListItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem( + children: nil, + text: "bullet", + link: nil, + styles: [] + ) + let el = gql.asRichTextElement + guard case let .listItem(t) = el else { Issue.record("expected .listItem"); return } + #expect(t.text == "bullet") + #expect(t.children.isEmpty) + } + + @Test( + "ListItem.Child.AsRichTextListItem -> .listItem element" ) - let el = gql.asRichTextElement - guard case let .text(t, level) = el else { Issue.record("expected .text"); return } - #expect(t.text == "foo") - #expect(t.link == URL(string: "https://kickstarter.com/")) - #expect(t.styles == [.strong]) - #expect(t.children.isEmpty) - #expect(level == nil) -} - -@Test func asRichTextChildAsRichText() async throws { - let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichText(text: "bar", link: nil, styles: []) - let el = gql.asRichTextElement - guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } - #expect(t.text == "bar") - #expect(t.children.isEmpty) -} - -@Test func asRichTextHeaderChildAsRichText() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichText( - text: "h", - link: nil, - styles: ["EMPHASIS"] - ) - let el = gql.asRichTextElement - guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } - #expect(t.text == "h") - #expect(t.styles == [.emphasis]) -} - -@Test func asRichTextListItemChildAsRichText() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichText( - text: "li", - link: nil, - styles: nil - ) - let el = gql.asRichTextElement - guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } - #expect(t.text == "li") -} - -@Test func asRichTextHeader() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextHeader( - children: nil, - text: "header", - link: nil, - styles: [] - ) - let el = gql.asRichTextElement - guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } - #expect(t.text == "header") -} - -@Test func asRichTextHeaderChildAsRichTextHeader() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextHeader( - text: "h2", - link: nil, - styles: [] - ) - let el = gql.asRichTextElement - guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } - #expect(t.text == "h2") -} - -@Test func asRichTextChildAsRichTextHeader() async throws { - let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextHeader( - text: "x", - link: nil, - styles: nil - ) - let el = gql.asRichTextElement - guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } - #expect(t.text == "x") -} - -@Test func asRichTextListItemChildAsRichTextHeader() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextHeader( - text: "y", - link: nil, - styles: nil - ) - let el = gql.asRichTextElement - guard case .text(let t, nil) = el else { Issue.record("expected .text"); return } - #expect(t.text == "y") -} - -@Test func asRichTextListItem() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextListItem( - children: nil, - text: "bullet", - link: nil, - styles: [] - ) - let el = gql.asRichTextElement - guard case let .listItem(t) = el else { Issue.record("expected .listItem"); return } - #expect(t.text == "bullet") - #expect(t.children.isEmpty) -} - -@Test func asRichTextListItemChildAsRichTextListItem() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextListItem( - text: "nested", - link: nil, - styles: [] - ) - let el = gql.asRichTextElement - guard case let .listItem(t) = el else { Issue.record("expected .listItem"); return } - #expect(t.text == "nested") -} - -@Test func asRichTextChildAsRichTextListItem() async throws { - let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextListItem( - text: "a", - link: nil, - styles: nil - ) - let el = gql.asRichTextElement - guard case let .listItem(t) = el else { Issue.record("expected .listItem"); return } - #expect(t.text == "a") -} - -@Test func asRichTextHeaderChildAsRichTextListItem() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextListItem( - text: "b", - link: nil, - styles: nil - ) - let el = gql.asRichTextElement - guard case let .listItem(t) = el else { Issue.record("expected .listItem"); return } - #expect(t.text == "b") -} - -@Test func asRichTextListOpenItem() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextListOpen(_present: true) - let el = gql.asRichTextElement - guard case .listItemOpen = el else { Issue.record("expected .listItemOpen"); return } -} - -@Test func asRichTextListOpenChild() async throws { - let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextListOpen(_present: true) - let el = gql.asRichTextElement - guard case .listItemOpen = el else { Issue.record("expected .listItemOpen"); return } -} - -@Test func asRichTextListOpenHeaderChild() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextListOpen(_present: true) - let el = gql.asRichTextElement - guard case .listItemOpen = el else { Issue.record("expected .listItemOpen"); return } -} - -@Test func asRichTextListOpenListItemChild() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextListOpen(_present: true) - let el = gql.asRichTextElement - guard case .listItemOpen = el else { Issue.record("expected .listItemOpen"); return } -} - -@Test func asRichTextListCloseItem() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextListClose(_present: true) - let el = gql.asRichTextElement - guard case .listItemClose = el else { Issue.record("expected .listItemClose"); return } -} - -@Test func asRichTextListCloseChild() async throws { - let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextListClose(_present: true) - let el = gql.asRichTextElement - guard case .listItemClose = el else { Issue.record("expected .listItemClose"); return } -} - -@Test func asRichTextListCloseHeaderChild() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextListClose(_present: true) - let el = gql.asRichTextElement - guard case .listItemClose = el else { Issue.record("expected .listItemClose"); return } -} - -@Test func asRichTextListCloseListItemChild() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextListClose(_present: true) - let el = gql.asRichTextElement - guard case .listItemClose = el else { Issue.record("expected .listItemClose"); return } -} - -@Test func asRichTextAudioItem() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextAudio( - altText: "alt", - asset: nil, - caption: "cap", - url: "https://audio" - ) - let el = gql.asRichTextElement - guard case let .audio(a) = el else { Issue.record("expected .audio"); return } - #expect(a.altText == "alt") - #expect(a.assetID == nil) - #expect(a.caption == "cap") - #expect(a.url == "https://audio") -} - -@Test func asRichTextAudioChild() async throws { - let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextAudio( - altText: "a", - asset: nil, - caption: "c", - url: "u" - ) - let el = gql.asRichTextElement - guard case let .audio(a) = el else { Issue.record("expected .audio"); return } - #expect(a.altText == "a") - #expect(a.url == "u") -} - -@Test func asRichTextAudioWithAsset() async throws { - let asset = RichTextItemFragment.AsRichTextAudio.Asset(id: "asset-1") - let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextAudio( - altText: "", - asset: asset, - caption: "", - url: "" - ) - let el = gql.asRichTextElement - guard case let .audio(a) = el else { Issue.record("expected .audio"); return } - #expect(a.assetID == "asset-1") -} - -@Test func asRichTextPhotoItem() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextPhoto( - altText: "photo", - asset: nil, - caption: "cap", - url: "https://img" - ) - let el = gql.asRichTextElement - guard case let .photo(p) = el else { Issue.record("expected .photo"); return } - #expect(p.altText == "photo") - #expect(p.assetID == nil) - #expect(p.url == "https://img") -} - -@Test func asRichTextPhotoChild() async throws { - let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextPhoto( - altText: "p", - asset: nil, - caption: "c", - url: "u" - ) - let el = gql.asRichTextElement - guard case let .photo(p) = el else { Issue.record("expected .photo"); return } - #expect(p.altText == "p") -} - -@Test func asRichTextVideoItem() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextVideo( - altText: "v", - asset: nil, - caption: "cap", - url: "https://vid" - ) - let el = gql.asRichTextElement - guard case let .video(v) = el else { Issue.record("expected .video"); return } - #expect(v.altText == "v") - #expect(v.url == "https://vid") -} - -@Test func asRichTextVideoChild() async throws { - let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextVideo( - altText: "x", - asset: nil, - caption: "", - url: "" - ) - let el = gql.asRichTextElement - guard case let .video(v) = el else { Issue.record("expected .video"); return } - #expect(v.altText == "x") -} - -@Test func asRichTextOembedItem() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextOembed( - width: 200, - height: 100, - version: "1.0", - title: "Title", - type: "video", - iframeUrl: "https://iframe", - originalUrl: "https://orig", - thumbnailHeight: 50, - thumbnailUrl: "https://thumb", - thumbnailWidth: 60 - ) - let el = gql.asRichTextElement - guard case let .oembed(o) = el else { Issue.record("expected .oembed"); return } - #expect(o.width == 200) - #expect(o.height == 100) - #expect(o.title == "Title") - #expect(o.type == "video") - #expect(o.version == "1.0") - #expect(o.iframeUrl == "https://iframe") - #expect(o.originalUrl == "https://orig") - #expect(o.thumbnailUrl == "https://thumb") - #expect(o.thumbnailWidth == 60) - #expect(o.thumbnailHeight == 50) -} - -@Test func asRichTextOembedChild() async throws { - let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextOembed( - width: 4, - height: 1, - version: "1.0", - title: "T", - type: "rich", - iframeUrl: "i", - originalUrl: "o", - thumbnailHeight: 2, - thumbnailUrl: "t", - thumbnailWidth: 3 - ) - let el = gql.asRichTextElement - guard case let .oembed(o) = el else { Issue.record("expected .oembed"); return } - #expect(o.width == 4) - #expect(o.height == 1) - #expect(o.title == "T") - #expect(o.type == "rich") -} - -@Test func asRichTextHeaderChildAsRichTextAudio() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextAudio( - altText: "h", - asset: nil, - caption: "", - url: "" - ) - let el = gql.asRichTextElement - guard case let .audio(a) = el else { Issue.record("expected .audio"); return } - #expect(a.altText == "h") -} - -@Test func asRichTextListItemChildAsRichTextAudio() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextAudio( - altText: "li", - asset: nil, - caption: "", - url: "" - ) - let el = gql.asRichTextElement - guard case let .audio(a) = el else { Issue.record("expected .audio"); return } - #expect(a.altText == "li") -} - -@Test func asRichTextHeaderChildAsRichTextPhoto() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextPhoto( - altText: "hp", - asset: nil, - caption: "", - url: "" - ) - let el = gql.asRichTextElement - guard case let .photo(p) = el else { Issue.record("expected .photo"); return } - #expect(p.altText == "hp") -} - -@Test func asRichTextListItemChildAsRichTextPhoto() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextPhoto( - altText: "lip", - asset: nil, - caption: "", - url: "" - ) - let el = gql.asRichTextElement - guard case let .photo(p) = el else { Issue.record("expected .photo"); return } - #expect(p.altText == "lip") -} - -@Test func asRichTextHeaderChildAsRichTextVideo() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextVideo( - altText: "hv", - asset: nil, - caption: "", - url: "" - ) - let el = gql.asRichTextElement - guard case let .video(v) = el else { Issue.record("expected .video"); return } - #expect(v.altText == "hv") -} - -@Test func asRichTextListItemChildAsRichTextVideo() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextVideo( - altText: "liv", - asset: nil, - caption: "", - url: "" - ) - let el = gql.asRichTextElement - guard case let .video(v) = el else { Issue.record("expected .video"); return } - #expect(v.altText == "liv") -} - -@Test func asRichTextHeaderChildAsRichTextOembed() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextOembed( - width: 10, - height: 10, - version: "1.0", - title: "H", - type: "link", - iframeUrl: "", - originalUrl: "", - thumbnailHeight: 10, - thumbnailUrl: "", - thumbnailWidth: 10 - ) - let el = gql.asRichTextElement - guard case let .oembed(o) = el else { Issue.record("expected .oembed"); return } - #expect(o.title == "H") -} - -@Test func asRichTextListItemChildAsRichTextOembed() async throws { - let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextOembed( - width: 5, - height: 5, - version: "1.0", - title: "L", - type: "photo", - iframeUrl: "", - originalUrl: "", - thumbnailHeight: 5, - thumbnailUrl: "", - thumbnailWidth: 5 + func asRichTextListItemChildAsRichTextListItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextListItem( + text: "nested", + link: nil, + styles: [] + ) + let el = gql.asRichTextElement + guard case let .listItem(t) = el else { Issue.record("expected .listItem"); return } + #expect(t.text == "nested") + } + + @Test("Child.AsRichTextListItem -> .listItem element") + func asRichTextChildAsRichTextListItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextListItem( + text: "a", + link: nil, + styles: nil + ) + let el = gql.asRichTextElement + guard case let .listItem(t) = el else { Issue.record("expected .listItem"); return } + #expect(t.text == "a") + } + + @Test("Header.Child.AsRichTextListItem -> .listItem element") + func asRichTextHeaderChildAsRichTextListItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextListItem( + text: "b", + link: nil, + styles: nil + ) + let el = gql.asRichTextElement + guard case let .listItem(t) = el else { Issue.record("expected .listItem"); return } + #expect(t.text == "b") + } + + @Test("AsRichTextListOpen item -> .listItemOpen element") + func asRichTextListOpenItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListOpen(_present: true) + let el = gql.asRichTextElement + guard case .listItemOpen = el else { Issue.record("expected .listItemOpen"); return } + } + + @Test("Child.AsRichTextListOpen -> .listItemOpen element") + func asRichTextListOpenChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextListOpen(_present: true) + let el = gql.asRichTextElement + guard case .listItemOpen = el else { Issue.record("expected .listItemOpen"); return } + } + + @Test("Header.Child.AsRichTextListOpen -> .listItemOpen element") + func asRichTextListOpenHeaderChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextListOpen(_present: true) + let el = gql.asRichTextElement + guard case .listItemOpen = el else { Issue.record("expected .listItemOpen"); return } + } + + @Test("ListItem.Child.AsRichTextListOpen -> .listItemOpen element") + func asRichTextListOpenListItemChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextListOpen(_present: true) + let el = gql.asRichTextElement + guard case .listItemOpen = el else { Issue.record("expected .listItemOpen"); return } + } + + @Test("AsRichTextListClose item -> .listItemClose element") + func asRichTextListCloseItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListClose(_present: true) + let el = gql.asRichTextElement + guard case .listItemClose = el else { Issue.record("expected .listItemClose"); return } + } + + @Test("Child.AsRichTextListClose -> .listItemClose element") + func asRichTextListCloseChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextListClose(_present: true) + let el = gql.asRichTextElement + guard case .listItemClose = el else { Issue.record("expected .listItemClose"); return } + } + + @Test("Header.Child.AsRichTextListClose -> .listItemClose element") + func asRichTextListCloseHeaderChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextListClose(_present: true) + let el = gql.asRichTextElement + guard case .listItemClose = el else { Issue.record("expected .listItemClose"); return } + } + + @Test("ListItem.Child.AsRichTextListClose -> .listItemClose element") + func asRichTextListCloseListItemChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextListClose(_present: true) + let el = gql.asRichTextElement + guard case .listItemClose = el else { Issue.record("expected .listItemClose"); return } + } + + @Test("AsRichTextAudio item -> .audio element with alt, caption, url") + func asRichTextAudioItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextAudio( + altText: "alt", + asset: nil, + caption: "cap", + url: "https://audio" + ) + let el = gql.asRichTextElement + guard case let .audio(a) = el else { Issue.record("expected .audio"); return } + #expect(a.altText == "alt") + #expect(a.assetID == nil) + #expect(a.caption == "cap") + #expect(a.url == "https://audio") + } + + @Test("Child.AsRichTextAudio -> .audio element") + func asRichTextAudioChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextAudio( + altText: "a", + asset: nil, + caption: "c", + url: "u" + ) + let el = gql.asRichTextElement + guard case let .audio(a) = el else { Issue.record("expected .audio"); return } + #expect(a.altText == "a") + #expect(a.url == "u") + } + + @Test("AsRichTextAudio with Asset -> .audio element with assetID") + func asRichTextAudioWithAsset() async throws { + let asset = RichTextItemFragment.AsRichTextAudio.Asset(id: "asset-1") + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextAudio( + altText: "", + asset: asset, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .audio(a) = el else { Issue.record("expected .audio"); return } + #expect(a.assetID == "asset-1") + } + + @Test("AsRichTextPhoto item -> .photo element with alt, caption, url") + func asRichTextPhotoItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextPhoto( + altText: "photo", + asset: nil, + caption: "cap", + url: "https://img" + ) + let el = gql.asRichTextElement + guard case let .photo(p) = el else { Issue.record("expected .photo"); return } + #expect(p.altText == "photo") + #expect(p.assetID == nil) + #expect(p.url == "https://img") + } + + @Test("Child.AsRichTextPhoto -> .photo element") + func asRichTextPhotoChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextPhoto( + altText: "p", + asset: nil, + caption: "c", + url: "u" + ) + let el = gql.asRichTextElement + guard case let .photo(p) = el else { Issue.record("expected .photo"); return } + #expect(p.altText == "p") + } + + @Test("AsRichTextVideo item -> .video element with alt, caption, url") + func asRichTextVideoItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextVideo( + altText: "v", + asset: nil, + caption: "cap", + url: "https://vid" + ) + let el = gql.asRichTextElement + guard case let .video(v) = el else { Issue.record("expected .video"); return } + #expect(v.altText == "v") + #expect(v.url == "https://vid") + } + + @Test("Child.AsRichTextVideo -> .video element") + func asRichTextVideoChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextVideo( + altText: "x", + asset: nil, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .video(v) = el else { Issue.record("expected .video"); return } + #expect(v.altText == "x") + } + + @Test( + "AsRichTextOembed item -> .oembed element with dimensions, urls, and thumbnail" ) - let el = gql.asRichTextElement - guard case let .oembed(o) = el else { Issue.record("expected .oembed"); return } - #expect(o.title == "L") - #expect(o.width == 5) + func asRichTextOembedItem() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextOembed( + width: 200, + height: 100, + version: "1.0", + title: "Title", + type: "video", + iframeUrl: "https://iframe", + originalUrl: "https://orig", + thumbnailHeight: 50, + thumbnailUrl: "https://thumb", + thumbnailWidth: 60 + ) + let el = gql.asRichTextElement + guard case let .oembed(o) = el else { Issue.record("expected .oembed"); return } + #expect(o.width == 200) + #expect(o.height == 100) + #expect(o.title == "Title") + #expect(o.type == "video") + #expect(o.version == "1.0") + #expect(o.iframeUrl == "https://iframe") + #expect(o.originalUrl == "https://orig") + #expect(o.thumbnailUrl == "https://thumb") + #expect(o.thumbnailWidth == 60) + #expect(o.thumbnailHeight == 50) + } + + @Test("Child.AsRichTextOembed -> .oembed element") + func asRichTextOembedChild() async throws { + let gql = RichTextComponentFragment.Item.AsRichText.Child.AsRichTextOembed( + width: 4, + height: 1, + version: "1.0", + title: "T", + type: "rich", + iframeUrl: "i", + originalUrl: "o", + thumbnailHeight: 2, + thumbnailUrl: "t", + thumbnailWidth: 3 + ) + let el = gql.asRichTextElement + guard case let .oembed(o) = el else { Issue.record("expected .oembed"); return } + #expect(o.width == 4) + #expect(o.height == 1) + #expect(o.title == "T") + #expect(o.type == "rich") + } + + @Test("Header.Child.AsRichTextAudio -> .audio element") + func asRichTextHeaderChildAsRichTextAudio() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextAudio( + altText: "h", + asset: nil, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .audio(a) = el else { Issue.record("expected .audio"); return } + #expect(a.altText == "h") + } + + @Test("ListItem.Child.AsRichTextAudio -> .audio element") + func asRichTextListItemChildAsRichTextAudio() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextAudio( + altText: "li", + asset: nil, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .audio(a) = el else { Issue.record("expected .audio"); return } + #expect(a.altText == "li") + } + + @Test("Header.Child.AsRichTextPhoto -> .photo element") + func asRichTextHeaderChildAsRichTextPhoto() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextPhoto( + altText: "hp", + asset: nil, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .photo(p) = el else { Issue.record("expected .photo"); return } + #expect(p.altText == "hp") + } + + @Test("ListItem.Child.AsRichTextPhoto -> .photo element") + func asRichTextListItemChildAsRichTextPhoto() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextPhoto( + altText: "lip", + asset: nil, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .photo(p) = el else { Issue.record("expected .photo"); return } + #expect(p.altText == "lip") + } + + @Test("Header.Child.AsRichTextVideo -> .video element") + func asRichTextHeaderChildAsRichTextVideo() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextVideo( + altText: "hv", + asset: nil, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .video(v) = el else { Issue.record("expected .video"); return } + #expect(v.altText == "hv") + } + + @Test("ListItem.Child.AsRichTextVideo -> .video element") + func asRichTextListItemChildAsRichTextVideo() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextVideo( + altText: "liv", + asset: nil, + caption: "", + url: "" + ) + let el = gql.asRichTextElement + guard case let .video(v) = el else { Issue.record("expected .video"); return } + #expect(v.altText == "liv") + } + + @Test("Header.Child.AsRichTextOembed -> .oembed element") + func asRichTextHeaderChildAsRichTextOembed() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextHeader.Child.AsRichTextOembed( + width: 10, + height: 10, + version: "1.0", + title: "H", + type: "link", + iframeUrl: "", + originalUrl: "", + thumbnailHeight: 10, + thumbnailUrl: "", + thumbnailWidth: 10 + ) + let el = gql.asRichTextElement + guard case let .oembed(o) = el else { Issue.record("expected .oembed"); return } + #expect(o.title == "H") + } + + @Test("ListItem.Child.AsRichTextOembed -> .oembed element") + func asRichTextListItemChildAsRichTextOembed() async throws { + let gql = RichTextComponentFragment.Item.AsRichTextListItem.Child.AsRichTextOembed( + width: 5, + height: 5, + version: "1.0", + title: "L", + type: "photo", + iframeUrl: "", + originalUrl: "", + thumbnailHeight: 5, + thumbnailUrl: "", + thumbnailWidth: 5 + ) + let el = gql.asRichTextElement + guard case let .oembed(o) = el else { Issue.record("expected .oembed"); return } + #expect(o.title == "L") + #expect(o.width == 5) + } }