| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // The markdown rendering contract (issue #531): every vector in |
| 5 | // Fixtures/markdown-vector.json — a byte-identical copy of the canonical |
| 6 | // test/event-vectors/markdown.json, gated by test/markdown.test.mjs — must |
| 7 | // normalize to the same semantic tree on both twins. The web side asserts |
| 8 | // parseMessage(input) == tree; this suite asserts markdownTree(input) == tree. |
| 9 | // When a vector fails here, the twins have diverged: fix MarkdownTree.swift |
| 10 | // (or markdown.js), never the vector. |
| 11 | final class MarkdownVectorTests: XCTestCase { |
| 12 | struct VectorFile: Decodable { |
| 13 | let vectors: [Vector] |
| 14 | } |
| 15 | struct Vector: Decodable { |
| 16 | let name: String |
| 17 | let input: String |
| 18 | let tree: [MDBlock] |
| 19 | let plain: String? // pins markdownPlainText (previews/notifications) when present |
| 20 | let note: String? |
| 21 | } |
| 22 | |
| 23 | private func loadVectors() throws -> [Vector] { |
| 24 | let url = try XCTUnwrap( |
| 25 | Bundle.module.url(forResource: "Fixtures/markdown-vector", withExtension: "json"), |
| 26 | "Fixtures/markdown-vector.json missing — run cp test/event-vectors/markdown.json ios/LChatCore/Tests/LChatCoreTests/Fixtures/markdown-vector.json") |
| 27 | return try JSONDecoder().decode(VectorFile.self, from: Data(contentsOf: url)).vectors |
| 28 | } |
| 29 | |
| 30 | private func json(_ blocks: [MDBlock]) -> String { |
| 31 | let enc = JSONEncoder() |
| 32 | enc.outputFormatting = [.sortedKeys] |
| 33 | return (try? String(decoding: enc.encode(blocks), as: UTF8.self)) ?? "<encode failed>" |
| 34 | } |
| 35 | |
| 36 | func testVectorCoverage() throws { |
| 37 | let vectors = try loadVectors() |
| 38 | XCTAssertGreaterThanOrEqual(vectors.count, 60, "vector corpus shrank — regenerate from web") |
| 39 | } |
| 40 | |
| 41 | func testAllVectors() throws { |
| 42 | for v in try loadVectors() { |
| 43 | let got = markdownTree(v.input) |
| 44 | XCTAssertEqual( |
| 45 | got, v.tree, |
| 46 | """ |
| 47 | vector '\(v.name)' diverged\(v.note.map { " (\($0))" } ?? "") |
| 48 | input: \(v.input.debugDescription) |
| 49 | expected: \(json(v.tree)) |
| 50 | got: \(json(got)) |
| 51 | """) |
| 52 | if let plain = v.plain { |
| 53 | XCTAssertEqual(markdownPlainText(v.input), plain, |
| 54 | "vector '\(v.name)' plain projection diverged from the web twin") |
| 55 | } |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // Beyond the shared corpus: edges the web suite pins in test/markdown.test.mjs. |
| 60 | func testHostileSchemesNeverLink() { |
| 61 | let tree = markdownTree("[a](data:text/html,x) [b](vbscript:x) <ftp://x.com>") |
| 62 | func assertNoLinks(_ inlines: [MDInline]) { |
| 63 | for node in inlines { |
| 64 | switch node { |
| 65 | case .link(let href, _): |
| 66 | XCTAssertTrue(href.lowercased().hasPrefix("http"), "non-http(s) link leaked: \(href)") |
| 67 | case .strong(let c), .em(let c), .strike(let c): |
| 68 | assertNoLinks(c) |
| 69 | default: break |
| 70 | } |
| 71 | } |
| 72 | } |
| 73 | for block in tree { |
| 74 | if case .p(let children) = block { assertNoLinks(children) } |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | func testMismatchedTildesStayLiteral() { |
| 79 | XCTAssertEqual(markdownTree("~a~~"), [.p([.text("~a~~")])]) |
| 80 | } |
| 81 | |
| 82 | func testAdjacentTextMerges() { |
| 83 | XCTAssertEqual(markdownTree("a \\* b & c"), [.p([.text("a * b & c")])]) |
| 84 | } |
| 85 | |
| 86 | func testEmptyInput() { |
| 87 | XCTAssertEqual(markdownTree(""), []) |
| 88 | XCTAssertEqual(markdownTree(" "), []) |
| 89 | } |
| 90 | } |
| 91 | |