import XCTest @testable import LChatCore // The composer serialization contract (issue #890): every vector in // Fixtures/composer-serialize-vector.json — a byte-identical copy of // test/event-vectors/composer-serialize.json, generated FROM the web // serializer (composer-md.js, the authority) and gated by // test/composer-vectors.test.mjs — must serialize to the same bytes here. // The reparse chain then feeds each vector's md through markdownTree, // linking this suite into the already-gated renderer twin. final class ComposerSerializeTests: XCTestCase { struct VectorFile: Decodable { let vectors: [Vector] } struct Vector: Decodable { let name: String let tree: [MDBlock] let md: String let reparse: [MDBlock]? let note: String? } private func loadVectors() throws -> [Vector] { let url = try XCTUnwrap( Bundle.module.url(forResource: "Fixtures/composer-serialize-vector", withExtension: "json"), "fixture missing — run cp test/event-vectors/composer-serialize.json ios/LChatCore/Tests/LChatCoreTests/Fixtures/composer-serialize-vector.json") return try JSONDecoder().decode(VectorFile.self, from: Data(contentsOf: url)).vectors } // MDBlock (the vector tree vocabulary) → the composer model. Inline // nesting flattens to style flags; a br inside a styled wrapper carries // the wrapper's style (the marked-break case). private func composerModel(_ tree: [MDBlock]) throws -> [ComposerBlock] { try tree.map { block in switch block { case .p(let children): return .paragraph(try inlineNodes(children)) case .quote(let children): guard children.count == 1, case .p(let inner) = children[0] else { throw XCTSkip("quote shapes beyond one paragraph are out of composer scope") } return .quote(try inlineNodes(inner)) case .ul(let items): return .bullet(items: try items.map { try itemRuns($0) }) case .ol(let start, let items): return .ordered(start: start, items: try items.map { try itemRuns($0) }) case .codeBlock(let text, let lang): return .fence(lang: lang, text: text) } } } private func itemRuns(_ blocks: [MDBlock]) throws -> [RichRun] { guard blocks.count == 1, case .p(let children) = blocks[0] else { throw XCTSkip("multi-block list items are out of composer scope") } return try inlineNodes(children).map { node in guard case .run(let run) = node else { throw Err.breakInListItem } return run } } private enum Err: Error { case breakInListItem, unsupportedInline } private func inlineNodes(_ children: [MDInline], style: InlineStyle = .plain) throws -> [InlineNode] { var out: [InlineNode] = [] for child in children { switch child { case .text(let s): out.append(.run(RichRun(s, style: style))) case .br: out.append(.brk(style)) case .code(let s): var codeStyle = style codeStyle.code = true out.append(.run(RichRun(s, style: codeStyle))) case .strong(let inner): var s = style s.bold = true out.append(contentsOf: try inlineNodes(inner, style: s)) case .em(let inner): var s = style s.italic = true out.append(contentsOf: try inlineNodes(inner, style: s)) case .strike(let inner): var s = style s.strike = true out.append(contentsOf: try inlineNodes(inner, style: s)) case .link: throw Err.unsupportedInline // links are out of composer typing scope } } return out } func testVectorCoverage() throws { XCTAssertGreaterThanOrEqual(try loadVectors().count, 40) } func testAllVectorsSerialize() throws { for v in try loadVectors() { let model = try composerModel(v.tree) XCTAssertEqual( serializeComposer(model), v.md, "vector '\(v.name)' diverged\(v.note.map { " (\($0))" } ?? "")") } } // The semantic loop: what this serializer emits must parse back (on the // already-vector-gated renderer twin) to the documented tree. func testAllVectorsReparse() throws { for v in try loadVectors() { XCTAssertEqual(markdownTree(v.md), v.reparse ?? v.tree, "reparse of '\(v.name)' diverged") } } // Edit round-trip identity, by construction: literal edit-load through // plainModel emits every byte verbatim. Fuzz + fixed corpus. func testPlainModelRoundTripsVerbatim() { var corpus = [ "hello", "a\nb", "a\n\nb", "", " leading and trailing ", "2*3 and ~x~ and `tick` and **markers**", "- looks like a list\n1. and numbers\n> and quotes\n```\nand fences\n```", "líne ✨ emoji 👨‍👩‍👧‍👦 and \t tabs", "\\*already escaped\\* and \\ lone backslash", ] var seed: UInt64 = 0x11C4A7 func rand() -> UInt64 { seed ^= seed << 13 seed ^= seed >> 7 seed ^= seed << 17 return seed } let alphabet = Array("ab *~`_->#\n\\1.✨é") for _ in 0..<200 { let len = Int(rand() % 40) corpus.append(String((0.. bye") } func testOrderedStartFromFirstLine() { let lines = [ ComposerLine(kind: .ordered(3), runs: [RichRun("a")]), ComposerLine(kind: .ordered(9), runs: [RichRun("b")]), // later numbers ignored ] XCTAssertEqual(serializeComposer(composerBlocks(lines)), "3. a\n4. b") } func testFenceGroupsAndKeepsFirstLang() { let lines = [ ComposerLine(kind: .fence(lang: "js"), runs: [RichRun("x = 1")]), ComposerLine(kind: .fence(lang: ""), runs: [RichRun("y = 2")]), ] XCTAssertEqual(serializeComposer(composerBlocks(lines)), "```js\nx = 1\ny = 2\n```") } func testEmptyModelSerializesEmpty() { XCTAssertEqual(serializeComposer(plainModel("")), "") XCTAssertEqual(displayText(plainModel("")), "") } }