// // AttributedStringConverterTests.swift // // // Created by Shadowfacts on 11/24/23. // import XCTest @testable import HTMLStreamer final class AttributedStringConverterTests: XCTestCase { #if os(iOS) private let font = UIFont.systemFont(ofSize: 13) private let color = UIColor.black private lazy var italicFont = UIFont(descriptor: font.fontDescriptor.withSymbolicTraits([.traitItalic])!, size: 13) private lazy var boldFont = UIFont(descriptor: font.fontDescriptor.withSymbolicTraits([.traitBold])!, size: 13) private lazy var boldItalicFont = UIFont(descriptor: font.fontDescriptor.withSymbolicTraits([.traitBold, .traitItalic])!, size: 13) private let monospaceFont = UIFont.monospacedSystemFont(ofSize: 13, weight: .regular) #elseif os(macOS) private let font = NSFont.systemFont(ofSize: 13) private let color = NSColor.black private lazy var italicFont = NSFont(descriptor: font.fontDescriptor.withSymbolicTraits(.italic), size: 13)! private lazy var boldFont = NSFont(descriptor: font.fontDescriptor.withSymbolicTraits(.bold), size: 13)! private lazy var boldItalicFont = NSFont(descriptor: font.fontDescriptor.withSymbolicTraits([.bold, .italic]), size: 13)! private let monospaceFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) #endif private let blockquoteParagraphStyle: NSParagraphStyle = { let style = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle style.headIndent = 32 style.firstLineHeadIndent = 32 return style }() private let listParagraphStyle: NSParagraphStyle = { let style = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle style.headIndent = 32 style.firstLineHeadIndent = 0 style.tabStops = [NSTextTab(textAlignment: .right, location: 28), NSTextTab(textAlignment: .natural, location: 32)] return style }() private func convert(_ html: String) -> NSAttributedString { convert(html, callbacks: DefaultCallbacks.self) } private func convert(_ html: String, callbacks _: Callbacks.Type = Callbacks.self) -> NSAttributedString { let config = AttributedStringConverterConfiguration( font: font, monospaceFont: monospaceFont, color: color, paragraphStyle: .default ) let converter = AttributedStringConverter(configuration: config) return converter.convert(html: html) } func testConvertBR() { XCTAssertEqual(convert("a
b"), NSAttributedString(string: "a\nb", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) XCTAssertEqual(convert("a
b"), NSAttributedString(string: "a\nb", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) } func testConvertA() { XCTAssertEqual(convert("link"), NSAttributedString(string: "link", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, .link: URL(string: "https://example.com")!, ])) XCTAssertEqual(convert("link"), NSAttributedString(string: "link", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) } func testConvertP() { XCTAssertEqual(convert("

a

b

"), NSAttributedString(string: "a\n\nb", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) } func testConvertEm() { XCTAssertEqual(convert("hello"), NSAttributedString(string: "hello", attributes: [ .font: italicFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) XCTAssertEqual(convert("hello"), NSAttributedString(string: "hello", attributes: [ .font: italicFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) } func testConvertStrong() { XCTAssertEqual(convert("hello"), NSAttributedString(string: "hello", attributes: [ .font: boldFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) XCTAssertEqual(convert("hello"), NSAttributedString(string: "hello", attributes: [ .font: boldFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) } func testConvertBoldItalic() { XCTAssertEqual(convert("hello"), NSAttributedString(string: "hello", attributes: [ .font: boldItalicFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) } func testIncorrectNesting() { let result = NSMutableAttributedString() result.append(NSAttributedString(string: "bold ", attributes: [ .font: boldFont, ])) result.append(NSAttributedString(string: "both", attributes: [ .font: boldItalicFont, ])) result.append(NSAttributedString(string: " italic", attributes: [ .font: italicFont, ])) result.addAttribute(.paragraphStyle, value: NSParagraphStyle.default, range: NSRange(location: 0, length: result.length)) result.addAttribute(.foregroundColor, value: color, range: NSRange(location: 0, length: result.length)) XCTAssertEqual(convert("bold both italic"), result) } func testMisnestedLink() { let result = NSMutableAttributedString() result.append(NSAttributedString(string: "hello ", attributes: [ .font: font, ])) result.append(NSAttributedString(string: "world", attributes: [ .font: boldFont, ])) result.addAttribute(.link, value: URL(string: "https://example.com")!, range: NSRange(location: 0, length: result.length)) result.addAttribute(.paragraphStyle, value: NSParagraphStyle.default, range: NSRange(location: 0, length: result.length)) result.addAttribute(.foregroundColor, value: color, range: NSRange(location: 0, length: result.length)) XCTAssertEqual(convert("hello world"), result) } func testDel() { XCTAssertEqual(convert("blah"), NSAttributedString(string: "blah", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, .strikethroughStyle: NSUnderlineStyle.single.rawValue, ])) } func testCode() { XCTAssertEqual(convert("wee"), NSAttributedString(string: "wee", attributes: [ .font: monospaceFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) } func testPre() { XCTAssertEqual(convert("
wee
"), NSAttributedString(string: "wee", attributes: [ .font: monospaceFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) } func testBlockquote() { XCTAssertEqual(convert("
hello
"), NSAttributedString(string: "hello", attributes: [ .font: italicFont, .paragraphStyle: blockquoteParagraphStyle, .foregroundColor: color, ])) XCTAssertEqual(convert("
hello
"), NSAttributedString(string: "hello", attributes: [ .font: boldItalicFont, .paragraphStyle: blockquoteParagraphStyle, .foregroundColor: color, ])) } func testTextAfterBlockquote() { let result = NSMutableAttributedString() result.append(NSAttributedString(string: "wee", attributes: [ .font: italicFont, .paragraphStyle: blockquoteParagraphStyle, ])) result.append(NSAttributedString(string: "\n\nafter", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, ])) result.addAttribute(.foregroundColor, value: color, range: NSRange(location: 0, length: result.length)) XCTAssertEqual(convert("
wee
after"), result) } func testMultipleBlockElements() { let result = NSAttributedString(string: "a\n\nb", attributes: [ .font: italicFont, .paragraphStyle: blockquoteParagraphStyle, .foregroundColor: color, ]) XCTAssertEqual(convert("
a
b
"), result) } func testSelfClosing() { XCTAssertEqual(convert("asdf"), NSAttributedString(string: "asdf", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) } func testMakeURLCallback() { struct Callbacks: HTMLConversionCallbacks { static func makeURL(string: String) -> URL? { URL(string: "https://apple.com") } } let result = convert("test", callbacks: Callbacks.self) XCTAssertEqual(result, NSAttributedString(string: "test", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .link: URL(string: "https://apple.com")!, .foregroundColor: color, ])) } func testElementActionCallback() { struct Callbacks: HTMLConversionCallbacks { static func elementAction(name: String, attributes: [Attribute]) -> ElementAction { let clazz = attributes.attributeValue(for: "class") if clazz == "skip" { return .skip } else if clazz == "replace" { return .replace("…") } else if clazz == "append" { return .append("…") } else { return .default } } } let skipped = convert("test", callbacks: Callbacks.self) XCTAssertEqual(skipped, NSAttributedString()) let skipNested = convert("test", callbacks: Callbacks.self) XCTAssertEqual(skipNested, NSAttributedString()) let skipNestped2 = convert("test", callbacks: Callbacks.self) XCTAssertEqual(skipNestped2, NSAttributedString()) let replaced = convert("test", callbacks: Callbacks.self) XCTAssertEqual(replaced, NSAttributedString(string: "…", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) let replaceNested = convert("a", callbacks: Callbacks.self) XCTAssertEqual(replaceNested, NSAttributedString(string: "…", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) let appended = convert("test", callbacks: Callbacks.self) XCTAssertEqual(appended, NSAttributedString(string: "test…", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) let appended2 = convert("test blah", callbacks: Callbacks.self) XCTAssertEqual(appended2, NSAttributedString(string: "test blah…", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) } func testOrderedList() { let result = convert("
  1. a
  2. b
") XCTAssertEqual(result, NSAttributedString(string: "\t1.\ta\n\t2.\tb", attributes: [ .font: font, .paragraphStyle: listParagraphStyle, .foregroundColor: color, ])) } func testMultiScalar() { XCTAssertEqual(convert("🇺🇸"), NSAttributedString(string: "🇺🇸", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) } func testEmptyBlockElements() { let result = NSMutableAttributedString() result.append(NSAttributedString(string: "inside\nquote", attributes: [ .font: italicFont, .paragraphStyle: blockquoteParagraphStyle, .foregroundColor: color, ])) result.append(NSAttributedString(string: "\n\nafter", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) XCTAssertEqual(convert("

inside
quote
after

"), result) } func testFollowedByList() { let result = NSMutableAttributedString() result.append(NSAttributedString(string: "a", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) result.append(NSAttributedString(string: "\n\n\t1.\tb\n\t2.\tc", attributes: [ .font: font, .paragraphStyle: listParagraphStyle, .foregroundColor: color, ])) XCTAssertEqual(convert("

a

  1. b
  2. c
"), result) XCTAssertEqual(convert("a
  1. b
  2. c
"), result) XCTAssertEqual(convert("a
  1. b
  2. c
"), result) } func testListItemOutsideList() { let result = NSAttributedString(string: "a", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("
  • a
  • "), result) } func testSkipElementActionFollowingUnfinishedRun() { struct Callbacks: HTMLConversionCallbacks { static func elementAction(name: String, attributes: [Attribute]) -> ElementAction { attributes.attributeValue(for: "class") == "invisible" ? .skip : .default } } let result = NSMutableAttributedString() result.append(NSAttributedString(string: "example.com", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, .link: URL(string: "https://example.com")!, ])) XCTAssertEqual(convert(#"example.com"#, callbacks: Callbacks.self), result) } func testMalformedOnlyClosingTag() { XCTAssertEqual(convert(""), .init()) } func testMultipleClosingBlockTagsBeforeOpeningBlockTag() { let result = NSMutableAttributedString() result.append(NSAttributedString(string: "a", attributes: [ .font: italicFont, .paragraphStyle: blockquoteParagraphStyle, .foregroundColor: color, ])) result.append(NSAttributedString(string: "\n\nb", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) XCTAssertEqual(convert(#"

    a

    b

    "#), result) } func testNewlineBetweenClosingAndOpeningBlockTag() { let result = NSAttributedString(string: "a\n\nb", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("

    a

    \n

    b

    "), result) XCTAssertEqual(convert("

    a

    \nb

    "), result) } func testEndAfterNewlineInBlockContent() { let result = NSAttributedString(string: "a", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("

    a\n\n

    "), result) XCTAssertEqual(convert("

    a\n\n

    \n"), result) XCTAssertEqual(convert("

    \n\na

    "), result) XCTAssertEqual(convert("

    \n\na

    \n"), result) let result2 = NSAttributedString(string: "a\n\n\nb", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("

    a\n\n\nb

    "), result2) } func testBRAtBlockElementBoundary() { let two = NSAttributedString(string: "a\n\nb", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("

    a

    b

    "), two) let three = NSAttributedString(string: "a\n\n\nb", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("

    a


    b

    "), three) } func testPreFollowedByP() { let result = NSMutableAttributedString() result.append(NSAttributedString(string: "a", attributes: [ .font: monospaceFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) result.append(NSAttributedString(string: "\n\nb", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) XCTAssertEqual(convert("
    a

    b

    "), result) } func testPreFollowedByPre() { let result = NSAttributedString(string: "a\n\nb", attributes: [ .font: monospaceFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("
    a
    b
    "), result) } func testBRAtPreBoundary() { let two = NSAttributedString(string: "a\n\nb", attributes: [ .font: monospaceFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("
    a
    b
    "), two) let three = NSAttributedString(string: "a\n\n\nb", attributes: [ .font: monospaceFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("
    a

    b
    "), three) } func testNestedPre() { let one = NSAttributedString(string: "a", attributes: [ .font: monospaceFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("
    a
    "), one) let two = NSAttributedString(string: "a\n\nb", attributes: [ .font: monospaceFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("
    a
    b
    "), two) XCTAssertEqual(convert("
    a
    b
    "), two) let three = NSAttributedString(string: "a\n\n\nb", attributes: [ .font: monospaceFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("
    a

    b
    "), three) } func testIgnoreLeadingNewlineInPre() { let one = NSAttributedString(string: "a", attributes: [ .font: monospaceFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("
    \na
    "), one) let two = NSMutableAttributedString() two.append(NSAttributedString(string: "a", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) two.append(NSAttributedString(string: "\n\nb", attributes: [ .font: monospaceFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) XCTAssertEqual(convert("a
    \nb
    "), two) } func testPreFollowingChar() { let result = NSMutableAttributedString() result.append(NSAttributedString(string: "a", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) result.append(NSAttributedString(string: "\n\nb", attributes: [ .font: monospaceFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ])) XCTAssertEqual(convert("a
    b
    "), result) } func testSkipLeadingTrailingWhitespace() { let result = NSAttributedString(string: "a", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert(" \n\ta"), result) XCTAssertEqual(convert(" \n\t

    a

    "), result) XCTAssertEqual(convert("a\n\t"), result) XCTAssertEqual(convert("

    a

    \n\t"), result) let result2 = NSAttributedString(string: "a ", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("a \n\t"), result2) let pre = NSAttributedString(string: "a", attributes: [ .font: monospaceFont, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert(" \n\t
    a
    "), pre) XCTAssertEqual(convert("
    a
    \n\t"), pre) } func testDoesNotCollapseWhitespace() { let result = NSAttributedString(string: "a \t\nb", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) XCTAssertEqual(convert("

    a \t\nb

    "), result) } func testParagraphInsideListItem() { let result = NSAttributedString(string: "\t1.\ta\n\t2.\tb", attributes: [ .font: font, .paragraphStyle: listParagraphStyle, .foregroundColor: color, ]) XCTAssertEqual(convert("
    1. a

    2. b

    "), result) } func testBreakBetweenListItems() { let result = NSAttributedString(string: "\t1.\ta\n\n\t2.\tb", attributes: [ .font: font, .paragraphStyle: listParagraphStyle, .foregroundColor: color, ]) XCTAssertEqual(convert("
    1. a

    2. b
    "), result) } func testCharacterBetweenListItems() { let result = NSAttributedString(string: "\t1.\ta\n\t\tc\n\t2.\tb", attributes: [ .font: font, .paragraphStyle: listParagraphStyle, .foregroundColor: color, ]) XCTAssertEqual(convert("
    1. a
    2. c
    3. b
    "), result) let result2 = NSAttributedString(string: "\t1.\ta\n\t\tc \n\t2.\tb", attributes: [ .font: font, .paragraphStyle: listParagraphStyle, .foregroundColor: color, ]) XCTAssertEqual(convert("
    1. a
    2. c
    3. b
    "), result2) } func testWhitespaceCollapsingInTextBetweenListItems() { let result = NSAttributedString(string: "\t1.\ta\n\t\tc d\n\t2.\tb", attributes: [ .font: font, .paragraphStyle: listParagraphStyle, .foregroundColor: color, ]) XCTAssertEqual(convert("
    1. a
    2. c d
    3. b
    "), result) } func testImplicitlyClosedListItem() { let result = NSAttributedString(string: "\t1.\ta\n\t2.\tb", attributes: [ .font: font, .paragraphStyle: listParagraphStyle, .foregroundColor: color, ]) XCTAssertEqual(convert("
    1. a
    2. b
    "), result) } func testPreInsideListItem() { let result = NSMutableAttributedString() result.append(NSAttributedString(string: "\t1.\t", attributes: [ .font: font, .paragraphStyle: listParagraphStyle, .foregroundColor: color, ])) result.append(NSAttributedString(string: "a", attributes: [ .font: monospaceFont, .paragraphStyle: listParagraphStyle, .foregroundColor: color, ])) XCTAssertEqual(convert("
    1. a
    "), result) } func testInvisibleAtBeginningOfParagraphDoesNotPreventParagraphBreak() { struct Invisible: HTMLConversionCallbacks { static func elementAction(name: String, attributes: [Attribute]) -> ElementAction { if attributes.attributeValue(for: "class") == "invisible" { .skip } else { .default } } } let result = NSAttributedString(string: "a\n\nc", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) let html = """

    a

    c

    """ XCTAssertEqual(convert(html, callbacks: Invisible.self), result) } func testReplaceAtBeginningOfParagraphDoesNotPreventParagraphBreak() { struct Replace: HTMLConversionCallbacks { static func elementAction(name: String, attributes: [Attribute]) -> ElementAction { if attributes.attributeValue(for: "class") == "replace" { .replace("c") } else { .default } } } let result = NSAttributedString(string: "a\n\nc", attributes: [ .font: font, .paragraphStyle: NSParagraphStyle.default, .foregroundColor: color, ]) let html = """

    a

    b

    """ XCTAssertEqual(convert(html, callbacks: Replace.self), result) } }