| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // The composer serialization contract (issue #890): every vector in |
| 5 | // Fixtures/composer-serialize-vector.json — a byte-identical copy of |
| 6 | // test/event-vectors/composer-serialize.json, generated FROM the web |
| 7 | // serializer (composer-md.js, the authority) and gated by |
| 8 | // test/composer-vectors.test.mjs — must serialize to the same bytes here. |
| 9 | // The reparse chain then feeds each vector's md through markdownTree, |
| 10 | // linking this suite into the already-gated renderer twin. |
| 11 | final class ComposerSerializeTests: XCTestCase { |
| 12 | struct VectorFile: Decodable { let vectors: [Vector] } |
| 13 | struct Vector: Decodable { |
| 14 | let name: String |
| 15 | let tree: [MDBlock] |
| 16 | let md: String |
| 17 | let reparse: [MDBlock]? |
| 18 | let note: String? |
| 19 | } |
| 20 | |
| 21 | private func loadVectors() throws -> [Vector] { |
| 22 | let url = try XCTUnwrap( |
| 23 | Bundle.module.url(forResource: "Fixtures/composer-serialize-vector", withExtension: "json"), |
| 24 | "fixture missing — run cp test/event-vectors/composer-serialize.json ios/LChatCore/Tests/LChatCoreTests/Fixtures/composer-serialize-vector.json") |
| 25 | return try JSONDecoder().decode(VectorFile.self, from: Data(contentsOf: url)).vectors |
| 26 | } |
| 27 | |
| 28 | // MDBlock (the vector tree vocabulary) → the composer model. Inline |
| 29 | // nesting flattens to style flags; a br inside a styled wrapper carries |
| 30 | // the wrapper's style (the marked-break case). |
| 31 | private func composerModel(_ tree: [MDBlock]) throws -> [ComposerBlock] { |
| 32 | try tree.map { block in |
| 33 | switch block { |
| 34 | case .p(let children): |
| 35 | return .paragraph(try inlineNodes(children)) |
| 36 | case .quote(let children): |
| 37 | guard children.count == 1, case .p(let inner) = children[0] else { |
| 38 | throw XCTSkip("quote shapes beyond one paragraph are out of composer scope") |
| 39 | } |
| 40 | return .quote(try inlineNodes(inner)) |
| 41 | case .ul(let items): |
| 42 | return .bullet(items: try items.map { try itemRuns($0) }) |
| 43 | case .ol(let start, let items): |
| 44 | return .ordered(start: start, items: try items.map { try itemRuns($0) }) |
| 45 | case .codeBlock(let text, let lang): |
| 46 | return .fence(lang: lang, text: text) |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | private func itemRuns(_ blocks: [MDBlock]) throws -> [RichRun] { |
| 52 | guard blocks.count == 1, case .p(let children) = blocks[0] else { |
| 53 | throw XCTSkip("multi-block list items are out of composer scope") |
| 54 | } |
| 55 | return try inlineNodes(children).map { node in |
| 56 | guard case .run(let run) = node else { throw Err.breakInListItem } |
| 57 | return run |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | private enum Err: Error { case breakInListItem, unsupportedInline } |
| 62 | |
| 63 | private func inlineNodes(_ children: [MDInline], style: InlineStyle = .plain) throws -> [InlineNode] { |
| 64 | var out: [InlineNode] = [] |
| 65 | for child in children { |
| 66 | switch child { |
| 67 | case .text(let s): |
| 68 | out.append(.run(RichRun(s, style: style))) |
| 69 | case .br: |
| 70 | out.append(.brk(style)) |
| 71 | case .code(let s): |
| 72 | var codeStyle = style |
| 73 | codeStyle.code = true |
| 74 | out.append(.run(RichRun(s, style: codeStyle))) |
| 75 | case .strong(let inner): |
| 76 | var s = style |
| 77 | s.bold = true |
| 78 | out.append(contentsOf: try inlineNodes(inner, style: s)) |
| 79 | case .em(let inner): |
| 80 | var s = style |
| 81 | s.italic = true |
| 82 | out.append(contentsOf: try inlineNodes(inner, style: s)) |
| 83 | case .strike(let inner): |
| 84 | var s = style |
| 85 | s.strike = true |
| 86 | out.append(contentsOf: try inlineNodes(inner, style: s)) |
| 87 | case .link: |
| 88 | throw Err.unsupportedInline // links are out of composer typing scope |
| 89 | } |
| 90 | } |
| 91 | return out |
| 92 | } |
| 93 | |
| 94 | func testVectorCoverage() throws { |
| 95 | XCTAssertGreaterThanOrEqual(try loadVectors().count, 40) |
| 96 | } |
| 97 | |
| 98 | func testAllVectorsSerialize() throws { |
| 99 | for v in try loadVectors() { |
| 100 | let model = try composerModel(v.tree) |
| 101 | XCTAssertEqual( |
| 102 | serializeComposer(model), v.md, |
| 103 | "vector '\(v.name)' diverged\(v.note.map { " (\($0))" } ?? "")") |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // The semantic loop: what this serializer emits must parse back (on the |
| 108 | // already-vector-gated renderer twin) to the documented tree. |
| 109 | func testAllVectorsReparse() throws { |
| 110 | for v in try loadVectors() { |
| 111 | XCTAssertEqual(markdownTree(v.md), v.reparse ?? v.tree, "reparse of '\(v.name)' diverged") |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // Edit round-trip identity, by construction: literal edit-load through |
| 116 | // plainModel emits every byte verbatim. Fuzz + fixed corpus. |
| 117 | func testPlainModelRoundTripsVerbatim() { |
| 118 | var corpus = [ |
| 119 | "hello", "a\nb", "a\n\nb", "", " leading and trailing ", |
| 120 | "2*3 and ~x~ and `tick` and **markers**", |
| 121 | "- looks like a list\n1. and numbers\n> and quotes\n```\nand fences\n```", |
| 122 | "líne ✨ emoji 👨👩👧👦 and \t tabs", |
| 123 | "\\*already escaped\\* and \\ lone backslash", |
| 124 | ] |
| 125 | var seed: UInt64 = 0x11C4A7 |
| 126 | func rand() -> UInt64 { |
| 127 | seed ^= seed << 13 |
| 128 | seed ^= seed >> 7 |
| 129 | seed ^= seed << 17 |
| 130 | return seed |
| 131 | } |
| 132 | let alphabet = Array("ab *~`_->#\n\\1.✨é") |
| 133 | for _ in 0..<200 { |
| 134 | let len = Int(rand() % 40) |
| 135 | corpus.append(String((0..<len).map { _ in alphabet[Int(rand() % UInt64(alphabet.count))] })) |
| 136 | } |
| 137 | for s in corpus { |
| 138 | XCTAssertEqual(serializeComposer(plainModel(s)), s, "plain round-trip broke for \(s.debugDescription)") |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // displayText is the storage-string twin (the typeahead space): plain |
| 143 | // content round-trips, markers/prefixes never appear. |
| 144 | func testDisplayTextMatchesPlainContent() throws { |
| 145 | // Display text is content-only: no marker or prefix bytes are ADDED |
| 146 | // by styling or block kinds (fence CONTENT may hold anything). |
| 147 | let styled: [ComposerBlock] = [ |
| 148 | .paragraph([.run(RichRun("b", style: InlineStyle(bold: true))), .brk(.plain), .run(RichRun("i", style: InlineStyle(italic: true)))]), |
| 149 | .bullet(items: [[RichRun("a")]]), |
| 150 | .quote([.run(RichRun("q", style: InlineStyle(strike: true)))]), |
| 151 | ] |
| 152 | XCTAssertEqual(displayText(styled), "b\ni\na\nq") |
| 153 | XCTAssertEqual(displayText(plainModel("a\nb")), "a\nb") |
| 154 | XCTAssertEqual(displayText([.bullet(items: [[RichRun("a")], [RichRun("b")]])]), "a\nb") |
| 155 | XCTAssertEqual(displayText([.quote([.run(RichRun("q"))]), .fence(lang: "js", text: "x\ny")]), "q\nx\ny") |
| 156 | } |
| 157 | |
| 158 | // The wedge regression pin: serializing a 100KB doc must be quick. |
| 159 | func testLargeDocSerializesFast() { |
| 160 | let big = String(repeating: "the quick brown fox jumps over the lazy dog \n", count: 2500) // ~112KB |
| 161 | let model = plainModel(big) |
| 162 | let start = Date() |
| 163 | _ = serializeComposer(model) |
| 164 | XCTAssertLessThan(Date().timeIntervalSince(start), 0.5, "100KB serialization should be far under 500ms") |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | // Grouping + line-model unit tests (the editor derives lines; grouping must |
| 169 | // be deterministic). |
| 170 | final class ComposerModelTests: XCTestCase { |
| 171 | func testParagraphLinesJoinWithBreaks() { |
| 172 | let lines = [ |
| 173 | ComposerLine(kind: .paragraph, runs: [RichRun("a")], joinStyle: .plain), |
| 174 | ComposerLine(kind: .paragraph, runs: [RichRun("b")]), |
| 175 | ] |
| 176 | XCTAssertEqual(composerBlocks(lines), [.paragraph([.run(RichRun("a")), .brk(.plain), .run(RichRun("b"))])]) |
| 177 | } |
| 178 | |
| 179 | func testMarkedJoinStyleCarries() { |
| 180 | let bold = InlineStyle(bold: true) |
| 181 | let lines = [ |
| 182 | ComposerLine(kind: .paragraph, runs: [RichRun("a", style: bold)], joinStyle: bold), |
| 183 | ComposerLine(kind: .paragraph, runs: [RichRun("b", style: bold)]), |
| 184 | ] |
| 185 | XCTAssertEqual(serializeComposer(composerBlocks(lines)), "**a\nb**") |
| 186 | } |
| 187 | |
| 188 | func testKindTransitionsSplitBlocks() { |
| 189 | let lines = [ |
| 190 | ComposerLine(kind: .paragraph, runs: [RichRun("intro")]), |
| 191 | ComposerLine(kind: .bullet, runs: [RichRun("one")]), |
| 192 | ComposerLine(kind: .bullet, runs: [RichRun("two")]), |
| 193 | ComposerLine(kind: .quote, runs: [RichRun("bye")]), |
| 194 | ] |
| 195 | XCTAssertEqual(serializeComposer(composerBlocks(lines)), "intro\n\n- one\n- two\n\n> bye") |
| 196 | } |
| 197 | |
| 198 | func testOrderedStartFromFirstLine() { |
| 199 | let lines = [ |
| 200 | ComposerLine(kind: .ordered(3), runs: [RichRun("a")]), |
| 201 | ComposerLine(kind: .ordered(9), runs: [RichRun("b")]), // later numbers ignored |
| 202 | ] |
| 203 | XCTAssertEqual(serializeComposer(composerBlocks(lines)), "3. a\n4. b") |
| 204 | } |
| 205 | |
| 206 | func testFenceGroupsAndKeepsFirstLang() { |
| 207 | let lines = [ |
| 208 | ComposerLine(kind: .fence(lang: "js"), runs: [RichRun("x = 1")]), |
| 209 | ComposerLine(kind: .fence(lang: ""), runs: [RichRun("y = 2")]), |
| 210 | ] |
| 211 | XCTAssertEqual(serializeComposer(composerBlocks(lines)), "```js\nx = 1\ny = 2\n```") |
| 212 | } |
| 213 | |
| 214 | func testEmptyModelSerializesEmpty() { |
| 215 | XCTAssertEqual(serializeComposer(plainModel("")), "") |
| 216 | XCTAssertEqual(displayText(plainModel("")), "") |
| 217 | } |
| 218 | } |
| 219 | |