import Testing @testable import PureXML /// `EntityExpander.expand` copies the literal text between references in bulk /// rather than one character at a time. These pin that the bulk path produces /// exactly the per-character result it replaced: references at the start, end, /// and adjacent; literal runs of every length around them; decimal and hex /// character references; declared-entity replacement (the budget-counted path, /// including a bulk run that overflows the amplification budget); and a CDATA /// section in replacement text copied verbatim without reference recognition. @Suite("Entity decode bulk-copy equivalence") struct EntityDecodeBulkTests { private typealias Decoder = PureXML.Parsing.EntityDecoder private static let mark = PureXML.Parsing.Mark(line: 1, column: 1, offset: 0) private func decode(_ raw: String, entities: [String: String] = [:], budget: Int = 1_000_000) throws -> String { var remaining = budget return try Decoder.decode(raw, entities: entities, budget: &remaining, at: Self.mark) } @Test("predefined references inside literal runs decode in place") func test_predefinedRuns() throws { #expect(try decode("plain text with no references") == "plain text with no references") #expect(try decode("a&b") == "a&b") #expect(try decode("x & y < z > w "q" 'a'") == "x & y < z > w \"q\" 'a'") #expect(try decode("&<>") == "&<>") #expect(try decode("&tail") == "&tail") #expect(try decode("head&") == "head&") // A long literal run on each side of a single reference (the bulk case). let long = String(repeating: "abcde ", count: 200) #expect(try decode("\(long)&\(long)") == "\(long)&\(long)") } @Test("character references decode decimal and hex") func test_characterReferences() throws { #expect(try decode("ABC") == "ABC") #expect(try decode("pre ☺ post") == "pre \u{263A} post") } @Test("multibyte literal text around references decodes correctly") func test_multibyteRuns() throws { // The marker scan is at the byte level; these confirm a reference byte // that follows multibyte UTF-8 (precomposed, combining, and astral) is // still located at a valid grapheme boundary, so the run splits cleanly. #expect(try decode("café&thé") == "café&thé") #expect(try decode("e\u{0301}&e\u{0301}") == "e\u{0301}&e\u{0301}") #expect(try decode("\u{1F600}<\u{1F601}") == "\u{1F600}<\u{1F601}") #expect(try decode("naïve text with no references") == "naïve text with no references") } @Test("declared entity replacement expands and counts against the budget") func test_declaredReplacement() throws { #expect(try decode("<&e;>", entities: ["e": "REPLACEMENT"]) == "") // A replacement run longer than the remaining budget overflows: the // per-character path threw at the overrunning character, the bulk path // throws on the whole run; both reject the decode. #expect(throws: (any Error).self) { try decode("&e;", entities: ["e": "ABCDE"], budget: 3) } // Exactly the budget is allowed (the boundary the per-character guard // permitted). #expect(try decode("&e;", entities: ["e": "ABCDE"], budget: 5) == "ABCDE") } @Test("a CDATA section in replacement text is copied verbatim") func test_cdataInReplacement() throws { // The '¬' inside CDATA must not be treated as a reference. #expect(try decode("&e;", entities: ["e": ""]) == "") } @Test("an unterminated reference is rejected") func test_unterminatedReference() throws { #expect(throws: (any Error).self) { try decode("text & without a semicolon") } } @Test("text with references decodes through the parser to one text node") func test_throughParser() throws { let node = try PureXML.parse("x & y < z > w") guard case let .document(children) = node, let root = children.compactMap(\.element).first else { Issue.record("no root") return } let text = root.children.compactMap { if case let .text(value) = $0 { value } else { nil } }.joined() #expect(text == "x & y < z > w") } }