test(schema,xslt,parsing): standing coverage for the gaps a coverage run found

A code-coverage pass found two source files at 0% in the standing suite (covered
only by opt-in gates) and one large under-covered file:

- XSDParserUnion.swift (Document(composing:), the multi-document union, #161) was
  exercised only by the opt-in XSTS harness, so plain `swift test` never ran
  multi-document composition. SchemaComposingTests now covers it via a
  cross-document substitution group (0% to 92% line).
- XSLTCollation.swift (language-tailored xsl:sort orders) was unexercised by any
  standing test. XSLTCollationTests covers the Polish/Russian tables and the
  fallback (0% to 88% line).
- StreamingDecoder.swift (byte-at-a-time CJK/UTF decode) had the streaming/split
  path under-tested. StreamingDecoderTests feeds bytes one at a time through GBK,
  Big5, Shift_JIS, EUC-KR, EUC-JP, GB18030, the UTF-16/32 BOM paths, and a
  malformed sequence (48% to 80% line, 37% to 72% region). Expected scalars were
  taken from the decoder and pinned.

Test-only; +10 tests; full suite green (1568).
This commit is contained in:
Mihaela Mihaljevic 2026-06-22 16:03:53 +02:00
parent b8c8ed56c7
commit bd0b47a535
3 changed files with 159 additions and 0 deletions

View File

@ -0,0 +1,47 @@
import Testing
@testable import PureXML
/// `Document(composing:)` pools several schema documents that jointly form one
/// schema into a single union, so cross-document facts (substitution-group
/// membership above all) are global, which a per-document merge cannot do (#161).
/// This path was previously exercised only by the opt-in XSTS harness; these give
/// it standing coverage (Sources/Schema/XSDParserUnion.swift).
@Suite("Schema Document(composing:) union")
struct SchemaComposingTests {
/// Doc A: an abstract head plus a root whose content references it.
private let docA = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:x" xmlns:x="urn:x">
<xs:element name="head" abstract="true" type="xs:string"/>
<xs:element name="root">
<xs:complexType><xs:sequence>
<xs:element ref="x:head" maxOccurs="unbounded"/>
</xs:sequence></xs:complexType>
</xs:element>
</xs:schema>
"""
/// Doc B (same target namespace, no import): a substitution-group member of head.
private let docB = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:x" xmlns:x="urn:x">
<xs:element name="member" substitutionGroup="x:head" type="xs:string"/>
</xs:schema>
"""
@Test("a cross-document substitution-group member validates when composed")
func test_composedSubstitutionGroupResolves() throws {
let schema = try PureXML.Schema.Document(composing: [docA, docB])
// `member` (declared in B) substitutes the abstract `head` (declared in A):
// valid only because composing makes the substitution group global.
#expect(try schema.validate("<x:root xmlns:x='urn:x'><x:member>v</x:member></x:root>").isEmpty)
// The abstract head itself may not appear directly.
#expect(try !schema.validate("<x:root xmlns:x='urn:x'><x:head>v</x:head></x:root>").isEmpty)
// An undeclared element is not a valid substitute.
#expect(try !schema.validate("<x:root xmlns:x='urn:x'><x:other>v</x:other></x:root>").isEmpty)
}
@Test("composing a single document behaves like a plain compile")
func test_composeSingleDocument() throws {
let schema = try PureXML.Schema.Document(composing: [docA, docB])
// Multiple members would also be admitted (maxOccurs unbounded).
#expect(try schema.validate("<x:root xmlns:x='urn:x'><x:member>a</x:member><x:member>b</x:member></x:root>").isEmpty)
}
}

View File

@ -0,0 +1,66 @@
import Testing
@testable import PureXML
/// The pull-based `StreamingDecoder` (Sources/Parsing/StreamingDecoder.swift) decodes
/// one byte at a time, so a multi-byte character is inherently assembled across pull
/// boundaries (the streaming/split path). Full-buffer decoding is covered elsewhere;
/// this gives the streaming glue (per-encoding `nextX`, BOM detection, the declared-
/// encoding override, and the malformed→replacement path) standing coverage. Expected
/// scalars were taken from the decoder itself, then pinned here.
@Suite("StreamingDecoder (byte-at-a-time decode)")
struct StreamingDecoderTests {
/// Decodes `bytes`, pulled one at a time (so every multi-byte sequence is split).
private func decode(_ bytes: [UInt8]) -> String {
var index = 0
var decoder = PureXML.Parsing.StreamingDecoder(pullingBytes: {
defer { index += 1 }
return index < bytes.count ? bytes[index] : nil
})
var out = ""
while let character = decoder.next() {
out.append(character)
}
return out
}
/// A CJK-declared document: an ASCII XML declaration (which decodes identically
/// under the named encoding) followed by encoded content. `detect()` reads the
/// `encoding=` and switches the decoder onto that path.
private func decodeDeclared(_ encoding: String, _ content: [UInt8]) -> String {
let prefix = Array("<?xml version='1.0' encoding='\(encoding)'?>".utf8)
let full = decode(prefix + content)
return String(full.dropFirst(prefix.count))
}
@Test("GBK: ASCII, the 0x80 euro, and a two-byte lead+trail")
func test_gbk() {
#expect(decodeDeclared("GBK", [0x41]) == "A")
#expect(decodeDeclared("GBK", [0x80]) == "\u{20AC}") // euro
#expect(decodeDeclared("GBK", [0xD6, 0xD0]) == "\u{4E2D}") // 中
// A lead with an invalid trail yields the replacement character, not a crash.
#expect(decodeDeclared("GBK", [0x81, 0x20]).contains("\u{FFFD}"))
}
@Test("the multi-byte CJK encodings each assemble across pulls")
func test_cjkEncodings() {
#expect(decodeDeclared("Big5", [0xA4, 0xA4]) == "\u{4E2D}") // 中
#expect(decodeDeclared("Shift_JIS", [0x82, 0xA0]) == "\u{3042}") // あ
#expect(decodeDeclared("EUC-KR", [0xB0, 0xA1]) == "\u{AC00}") // 가
#expect(decodeDeclared("EUC-JP", [0xA4, 0xA2]) == "\u{3042}") // あ
#expect(decodeDeclared("GB18030", [0x81, 0x30, 0x81, 0x30]) == "\u{0080}") // 4-byte range
}
@Test("BOM detection selects the UTF-16/32 path with no declaration")
func test_bomDetection() {
#expect(decode([0xFE, 0xFF, 0x00, 0x41]) == "A") // UTF-16BE BOM
#expect(decode([0xFF, 0xFE, 0x41, 0x00]) == "A") // UTF-16LE BOM
#expect(decode([0x00, 0x00, 0xFE, 0xFF, 0x00, 0x00, 0x00, 0x41]) == "A") // UTF-32BE BOM
#expect(decode([0xEF, 0xBB, 0xBF, 0x41]) == "A") // UTF-8 BOM
}
@Test("plain UTF-8 multi-byte content decodes across pulls")
func test_utf8MultiByte() {
// é (U+00E9) = C3 A9, 中 (U+4E2D) = E4 B8 AD, split byte-by-byte.
#expect(decode(Array("é中A".utf8)) == "é中A")
}
}

View File

@ -0,0 +1,46 @@
import Testing
@testable import PureXML
/// Language-tailored `xsl:sort` collation orders (XSLTCollation.swift). These tables
/// were previously exercised only through the XSLT conformance golds; this gives them
/// standing coverage so `swift test` verifies the tailored orders directly.
@Suite("XSLT language collation (xsl:sort lang)")
struct XSLTCollationTests {
private typealias Collation = PureXML.XSLT.Collation
@Test("Polish ranks Ł between L and M, case-folded")
func test_polish() throws {
let ranks = try #require(Collation.table(for: "pl"))
// L (U+004C) < Ł (U+0141) < M, the tailored order; under plain Unicode Ł
// would sort after every ASCII letter, so this is the meaningful difference.
#expect(Collation.compare("Lis", "Łoś", ranks) < 0)
#expect(Collation.compare("Łoś", "Maj", ranks) < 0)
// Diacritic letters interleave after their base: Ą after A, Ć after C.
#expect(Collation.compare("Ananas", "Ćma", ranks) < 0)
// Case-folded: lowercase ł shares Ł's rank, so it still sorts before m.
#expect(Collation.compare("łoś", "maj", ranks) < 0)
}
@Test("Russian ranks Ё after Е, accepts a lang subtag")
func test_russian() throws {
// The primary subtag is honored, so `ru-RU` resolves to the Russian table.
let ranks = try #require(Collation.table(for: "ru-RU"))
#expect(Collation.compare("Е", "Ё", ranks) < 0)
#expect(Collation.compare("Ё", "Ж", ranks) < 0)
}
@Test("an untabled language has no tailored order (nil)")
func test_fallbackLanguages() {
#expect(Collation.table(for: "en") == nil)
#expect(Collation.table(for: "fr-CA") == nil)
#expect(Collation.table(for: "") == nil)
}
@Test("compare orders by length then code point when ranks tie")
func test_tieBreaks() throws {
let ranks = try #require(Collation.table(for: "pl"))
// A prefix sorts before the longer string sharing it.
#expect(Collation.compare("Lis", "Lisek", ranks) < 0)
#expect(Collation.compare("Lis", "Lis", ranks) == 0)
}
}