eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import 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.
11final 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).
170final 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