| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // The storage→model adapter (#890 stage 2): synthetic attributed strings in, |
| 5 | // wire bytes out — headless, because .composerStyle is the style truth and |
| 6 | // no UIKit/AppKit is needed to read it. |
| 7 | final class ComposerAttributesTests: XCTestCase { |
| 8 | private func attr(_ parts: [(String, InlineStyle)]) -> NSAttributedString { |
| 9 | let out = NSMutableAttributedString() |
| 10 | for (text, style) in parts { |
| 11 | var attrs: [NSAttributedString.Key: Any] = [:] |
| 12 | if !style.isPlain { attrs[.composerStyle] = ComposerStyleAttr(style) } |
| 13 | out.append(NSAttributedString(string: text, attributes: attrs)) |
| 14 | } |
| 15 | return out |
| 16 | } |
| 17 | |
| 18 | private let bold = InlineStyle(bold: true) |
| 19 | |
| 20 | func testPlainPassesVerbatim() { |
| 21 | for s in ["hello", "a\nb", "2*3 and ~x~", "", "líne ✨"] { |
| 22 | XCTAssertEqual(composerMarkdown(of: attr([(s, .plain)])), s) |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | func testMarksSerialize() { |
| 27 | XCTAssertEqual(composerMarkdown(of: attr([("b", bold)])), "**b**") |
| 28 | XCTAssertEqual(composerMarkdown(of: attr([("i", InlineStyle(italic: true))])), "*i*") |
| 29 | XCTAssertEqual(composerMarkdown(of: attr([("s", InlineStyle(strike: true))])), "~s~") |
| 30 | XCTAssertEqual(composerMarkdown(of: attr([("c", InlineStyle(code: true))])), "`c`") |
| 31 | XCTAssertEqual( |
| 32 | composerMarkdown(of: attr([("say ", .plain), ("it", InlineStyle(bold: true, italic: true)), ("!", .plain)])), |
| 33 | "say ***it***!") |
| 34 | } |
| 35 | |
| 36 | func testTypedNewlineStaysPlain() { |
| 37 | // The marked-break contract: a typed Shift+Return inserts a PLAIN |
| 38 | // "\n" — the mark closes and reopens on the wire. |
| 39 | let a = attr([("a", bold), ("\n", .plain), ("b", bold)]) |
| 40 | XCTAssertEqual(composerMarkdown(of: a), "**a**\n**b**") |
| 41 | } |
| 42 | |
| 43 | func testStyledNewlineSpansTheMark() { |
| 44 | // Select-across-lines ⌘B styles the "\n" too — one marked span. |
| 45 | let a = attr([("a\nb", bold)]) |
| 46 | XCTAssertEqual(composerMarkdown(of: a), "**a\nb**") |
| 47 | } |
| 48 | |
| 49 | func testAdjacentSameStyleRunsCoalesce() { |
| 50 | // Rendering-only attribute churn fragments runs; the grammar must not |
| 51 | // see the seams (an in-mark escape proves the runs merged). |
| 52 | let a = attr([("a", bold), ("*", bold), ("b", bold)]) |
| 53 | XCTAssertEqual(composerMarkdown(of: a), "**a\\*b**") |
| 54 | } |
| 55 | |
| 56 | func testDisplayEqualsStorageString() { |
| 57 | let a = attr([("plain ", .plain), ("bold", bold), ("\n", .plain), ("tail", .plain)]) |
| 58 | XCTAssertEqual(displayText([.paragraph(composerInlineNodes(of: a))]), a.string) |
| 59 | } |
| 60 | |
| 61 | func testStyleAttrEquality() { |
| 62 | // Attribute-run coalescing depends on isEqual across separately |
| 63 | // created instances. |
| 64 | XCTAssertEqual(ComposerStyleAttr(bold), ComposerStyleAttr(InlineStyle(bold: true))) |
| 65 | XCTAssertNotEqual(ComposerStyleAttr(bold), ComposerStyleAttr(.plain)) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | // Opt-out escaping (format-as-you-type OFF): typed markers are literal text |
| 70 | // and must RENDER literally; only WYSIWYG-applied marks keep their markers. |
| 71 | // The cases mirror test/composer.test.mjs's escaped-mode block byte-for-byte. |
| 72 | extension ComposerAttributesTests { |
| 73 | func testEscapedModeEscapesPlainMarkers() { |
| 74 | let a = NSMutableAttributedString(string: "lit *x* _y_ ") |
| 75 | a.append(NSAttributedString(string: "bold", attributes: [.composerStyle: ComposerStyleAttr(InlineStyle(bold: true))])) |
| 76 | XCTAssertEqual(composerMarkdown(of: a), "lit *x* _y_ **bold**") |
| 77 | XCTAssertEqual(composerMarkdown(of: a, escapeLiterals: true), "lit \\*x\\* \\_y\\_ **bold**") |
| 78 | } |
| 79 | |
| 80 | func testEscapedModeGuardsLineLeadingBlocks() { |
| 81 | let a = NSAttributedString(string: "- a\n2. b\n> q") |
| 82 | XCTAssertEqual(composerMarkdown(of: a, escapeLiterals: true), "\\- a\n2\\. b\n\\> q") |
| 83 | } |
| 84 | |
| 85 | func testEscapedModeBackslashAndBacktick() { |
| 86 | let a = NSAttributedString(string: "\\* and `tick`") |
| 87 | XCTAssertEqual(composerMarkdown(of: a, escapeLiterals: true), "\\\\\\* and \\`tick\\`") |
| 88 | } |
| 89 | } |
| 90 | |