import XCTest @testable import LChatCore // The storage→model adapter (#890 stage 2): synthetic attributed strings in, // wire bytes out — headless, because .composerStyle is the style truth and // no UIKit/AppKit is needed to read it. final class ComposerAttributesTests: XCTestCase { private func attr(_ parts: [(String, InlineStyle)]) -> NSAttributedString { let out = NSMutableAttributedString() for (text, style) in parts { var attrs: [NSAttributedString.Key: Any] = [:] if !style.isPlain { attrs[.composerStyle] = ComposerStyleAttr(style) } out.append(NSAttributedString(string: text, attributes: attrs)) } return out } private let bold = InlineStyle(bold: true) func testPlainPassesVerbatim() { for s in ["hello", "a\nb", "2*3 and ~x~", "", "líne ✨"] { XCTAssertEqual(composerMarkdown(of: attr([(s, .plain)])), s) } } func testMarksSerialize() { XCTAssertEqual(composerMarkdown(of: attr([("b", bold)])), "**b**") XCTAssertEqual(composerMarkdown(of: attr([("i", InlineStyle(italic: true))])), "*i*") XCTAssertEqual(composerMarkdown(of: attr([("s", InlineStyle(strike: true))])), "~s~") XCTAssertEqual(composerMarkdown(of: attr([("c", InlineStyle(code: true))])), "`c`") XCTAssertEqual( composerMarkdown(of: attr([("say ", .plain), ("it", InlineStyle(bold: true, italic: true)), ("!", .plain)])), "say ***it***!") } func testTypedNewlineStaysPlain() { // The marked-break contract: a typed Shift+Return inserts a PLAIN // "\n" — the mark closes and reopens on the wire. let a = attr([("a", bold), ("\n", .plain), ("b", bold)]) XCTAssertEqual(composerMarkdown(of: a), "**a**\n**b**") } func testStyledNewlineSpansTheMark() { // Select-across-lines ⌘B styles the "\n" too — one marked span. let a = attr([("a\nb", bold)]) XCTAssertEqual(composerMarkdown(of: a), "**a\nb**") } func testAdjacentSameStyleRunsCoalesce() { // Rendering-only attribute churn fragments runs; the grammar must not // see the seams (an in-mark escape proves the runs merged). let a = attr([("a", bold), ("*", bold), ("b", bold)]) XCTAssertEqual(composerMarkdown(of: a), "**a\\*b**") } func testDisplayEqualsStorageString() { let a = attr([("plain ", .plain), ("bold", bold), ("\n", .plain), ("tail", .plain)]) XCTAssertEqual(displayText([.paragraph(composerInlineNodes(of: a))]), a.string) } func testStyleAttrEquality() { // Attribute-run coalescing depends on isEqual across separately // created instances. XCTAssertEqual(ComposerStyleAttr(bold), ComposerStyleAttr(InlineStyle(bold: true))) XCTAssertNotEqual(ComposerStyleAttr(bold), ComposerStyleAttr(.plain)) } } // Opt-out escaping (format-as-you-type OFF): typed markers are literal text // and must RENDER literally; only WYSIWYG-applied marks keep their markers. // The cases mirror test/composer.test.mjs's escaped-mode block byte-for-byte. extension ComposerAttributesTests { func testEscapedModeEscapesPlainMarkers() { let a = NSMutableAttributedString(string: "lit *x* _y_ ") a.append(NSAttributedString(string: "bold", attributes: [.composerStyle: ComposerStyleAttr(InlineStyle(bold: true))])) XCTAssertEqual(composerMarkdown(of: a), "lit *x* _y_ **bold**") XCTAssertEqual(composerMarkdown(of: a, escapeLiterals: true), "lit \\*x\\* \\_y\\_ **bold**") } func testEscapedModeGuardsLineLeadingBlocks() { let a = NSAttributedString(string: "- a\n2. b\n> q") XCTAssertEqual(composerMarkdown(of: a, escapeLiterals: true), "\\- a\n2\\. b\n\\> q") } func testEscapedModeBackslashAndBacktick() { let a = NSAttributedString(string: "\\* and `tick`") XCTAssertEqual(composerMarkdown(of: a, escapeLiterals: true), "\\\\\\* and \\`tick\\`") } }