Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation

extension Array where Element == RichTextElement {
/// The server's rich text parser sits on top of an HTML parser, so a `<div>` containing an
/// inline `<img>` (e.g. `<div>Here's an image: <img /> Isn't it nice?</div>`) is represented
/// as a single `Text` element whose `children` are `[Text, Photo, Text]`. Our conversion
/// architecture maps each GraphQL node to exactly one `RichTextElement`, and `TextBlock`
/// renders a `Text` element as a single attributed string, which can't host a `Photo`. This
/// splits any such `Text` into standalone sibling elements so the `Photo` renders on its own,
/// relying on the server's guarantee that this nesting never goes deeper than one level.
func withNormalizedNestedElements() -> [RichTextElement] {
self.flatMap { $0.withNormalizedNestedElements() }
}
}

extension RichTextElement {
func withNormalizedNestedElements() -> [RichTextElement] {
guard case let .text(text, header) = self, text.children.contains(where: { $0.isPhoto }) else {
return [self]
}

var elements: [RichTextElement] = []
var currentChildren: [RichTextElement] = text.text.isEmpty
? []
: [.text(RichTextElement.Text(text: text.text, link: text.link, styles: text.styles), nil)]

for child in text.children {
if child.isPhoto {
elements.append(.text(RichTextElement.Text(text: "", children: currentChildren), header))
elements.append(child)
currentChildren = []
} else {
currentChildren.append(child)
}
}
elements.append(.text(RichTextElement.Text(text: "", children: currentChildren), header))

return elements.filter { !$0.isEmptyText }
}

private var isPhoto: Bool {
if case .photo = self { return true }
return false
}

private var isEmptyText: Bool {
if case let .text(text, _) = self {
return text.text.isEmpty && text.children.isEmpty
}
return false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import GraphAPI

extension RichTextComponentFragment {
public func asRichTextElements() -> [RichTextElement] {
self.items.map { $0.asRichTextElement }
self.items.map { $0.asRichTextElement }.withNormalizedNestedElements()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import Foundation
@testable import ServerDrivenUI
import XCTest

final class RichTextElementNormalizingTests: XCTestCase {
/* Text with no children is left unchanged */
func testLeavesPlainTextUnchanged() throws {
let element = RichTextElement.text(.init(text: "hello"), nil)
let result = element.withNormalizedNestedElements()
XCTAssertEqual(result, [element])
}

/* Text whose children contain no Photo is left unchanged */
func testLeavesTextWithNonPhotoChildrenUnchanged() throws {
let element = RichTextElement.text(
.init(text: "", children: [.text(.init(text: "a"), nil), .text(.init(text: "b"), nil)]),
nil
)
let result = element.withNormalizedNestedElements()
XCTAssertEqual(result, [element])
}

/* Non-text elements are left unchanged */
func testLeavesNonTextElementsUnchanged() throws {
let photo = RichTextElement.Photo(altText: nil, assetID: nil, caption: nil, url: nil)
let element = RichTextElement.photo(photo)
XCTAssertEqual(element.withNormalizedNestedElements(), [element])
}

/* Text/Photo/Text children split into three sibling elements */
func testSplitsTextPhotoTextIntoThreeElements() throws {
let photo = RichTextElement.Photo(altText: "alt", assetID: "1", caption: "cap", url: "https://img")
let element = RichTextElement.text(
.init(text: "", children: [
.text(.init(text: "Here's an image: "), nil),
.photo(photo),
.text(.init(text: " Isn't it nice?"), nil)
]),
nil
)

let result = element.withNormalizedNestedElements()
XCTAssertEqual(result.count, 3)

guard case let .text(first, _) = result[0] else { return XCTFail("expected .text first") }
XCTAssertEqual(first.children, [.text(.init(text: "Here's an image: "), nil)])

guard case let .photo(middle) = result[1] else { return XCTFail("expected .photo second") }
XCTAssertEqual(middle, photo)

guard case let .text(last, _) = result[2] else { return XCTFail("expected .text third") }
XCTAssertEqual(last.children, [.text(.init(text: " Isn't it nice?"), nil)])
}

/* Leading Photo with no preceding text does not produce an empty leading Text */
func testLeadingPhotoProducesNoEmptyLeadingText() throws {
let photo = RichTextElement.Photo(altText: nil, assetID: nil, caption: nil, url: nil)
let element = RichTextElement.text(
.init(text: "", children: [.photo(photo), .text(.init(text: "after"), nil)]),
nil
)

let result = element.withNormalizedNestedElements()
XCTAssertEqual(result.count, 2)
guard case .photo = result[0] else { return XCTFail("expected .photo first") }
guard case let .text(last, _) = result[1] else { return XCTFail("expected .text second") }
XCTAssertEqual(last.children, [.text(.init(text: "after"), nil)])
}

/* Trailing Photo with no following text does not produce an empty trailing Text */
func testTrailingPhotoProducesNoEmptyTrailingText() throws {
let photo = RichTextElement.Photo(altText: nil, assetID: nil, caption: nil, url: nil)
let element = RichTextElement.text(
.init(text: "", children: [.text(.init(text: "before"), nil), .photo(photo)]),
nil
)

let result = element.withNormalizedNestedElements()
XCTAssertEqual(result.count, 2)
guard case let .text(first, _) = result[0] else { return XCTFail("expected .text first") }
XCTAssertEqual(first.children, [.text(.init(text: "before"), nil)])
guard case .photo = result[1] else { return XCTFail("expected .photo second") }
}

/* Consecutive Photos produce no empty Text between them */
func testConsecutivePhotosProduceNoEmptyTextBetween() throws {
let photo1 = RichTextElement.Photo(altText: "1", assetID: nil, caption: nil, url: nil)
let photo2 = RichTextElement.Photo(altText: "2", assetID: nil, caption: nil, url: nil)
let element = RichTextElement.text(.init(text: "", children: [.photo(photo1), .photo(photo2)]), nil)

let result = element.withNormalizedNestedElements()
XCTAssertEqual(result, [.photo(photo1), .photo(photo2)])
}

/* The Text element's own text/link/styles are preserved as a leading segment */
func testPreservesOwnTextAsLeadingSegment() throws {
let photo = RichTextElement.Photo(altText: nil, assetID: nil, caption: nil, url: nil)
let link = URL(string: "https://kickstarter.com")
let element = RichTextElement.text(
.init(text: "prefix", link: link, styles: [.strong], children: [.photo(photo)]),
nil
)

let result = element.withNormalizedNestedElements()
XCTAssertEqual(result.count, 2)
guard case let .text(first, _) = result[0] else { return XCTFail("expected .text first") }
XCTAssertEqual(first.children, [.text(.init(text: "prefix", link: link, styles: [.strong]), nil)])
guard case .photo = result[1] else { return XCTFail("expected .photo second") }
}

/* Header level is preserved across all split segments */
func testPreservesHeaderLevelAcrossSplits() throws {
let photo = RichTextElement.Photo(altText: nil, assetID: nil, caption: nil, url: nil)
let element = RichTextElement.text(
.init(text: "", children: [.text(.init(text: "a"), nil), .photo(photo), .text(.init(text: "b"), nil)]),
.two
)

let result = element.withNormalizedNestedElements()
XCTAssertEqual(result.count, 3)
guard case let .text(_, headerA) = result[0] else { return XCTFail("expected .text first") }
XCTAssertEqual(headerA, .two)
guard case let .text(_, headerB) = result[2] else { return XCTFail("expected .text third") }
XCTAssertEqual(headerB, .two)
}

/* Array-level splitting flattens across multiple top-level elements, preserving surrounding elements */
func testArraySplittingFlattensAcrossElements() throws {
let photo = RichTextElement.Photo(altText: nil, assetID: nil, caption: nil, url: nil)
let textWithNestedPhoto = RichTextElement.text(
.init(text: "", children: [.text(.init(text: "a"), nil), .photo(photo), .text(.init(text: "b"), nil)]),
nil
)
let plainText = RichTextElement.text(.init(text: "plain"), nil)
let elements: [RichTextElement] = [plainText, textWithNestedPhoto, .listItemOpen]

let result = elements.withNormalizedNestedElements()

XCTAssertEqual(result.count, 5)
XCTAssertEqual(result[0], plainText)
XCTAssertEqual(result[4], .listItemOpen)
}
}