PureXML/Tests/XSDDatatypeTests.swift
Mihaela Mihaljevic e862f364aa feat(schema): XSD simple-type datatype library with facets
Add the PureXML.Schema namespace validating XSD Part 2 built-in datatypes
and constraining facets. Built-ins span string/boolean/decimal, the bounded
integer family with intrinsic bounds, float/double, duration, the eight
date/time types, hexBinary, base64Binary, anyURI, QName, and the
Name/NCName/NMTOKEN/language family. Facets: length/minLength/maxLength,
pattern (via the regex engine), enumeration (value-space comparison),
whiteSpace, min/maxInclusive/Exclusive, totalDigits, fractionDigits.
Decimals compare exactly with no floating-point loss; date/time fields are
range-validated with leap years, 24:00:00, and timezone bounds.

Closes #8
2026-06-08 06:29:37 +02:00

125 lines
5.3 KiB
Swift

@testable import PureXML
import Testing
@Suite("XSD datatypes")
struct XSDDatatypeTests {
private typealias Schema = PureXML.Schema
private func valid(_ value: String, _ type: Schema.BuiltinType) -> Bool {
Schema.isValid(value, type: type)
}
// MARK: Primitives
@Test("boolean accepts the four lexical forms")
func test_boolean() {
#expect(valid("true", .boolean) && valid("false", .boolean))
#expect(valid("0", .boolean) && valid("1", .boolean))
#expect(!valid("True", .boolean) && !valid("yes", .boolean))
}
@Test("decimal and integer lexical spaces")
func test_decimalInteger() {
#expect(valid("-12.345", .decimal) && valid("+.5", .decimal) && valid("100", .decimal))
#expect(!valid("1.2.3", .decimal) && !valid("1e3", .decimal))
#expect(valid("-42", .integer) && !valid("4.0", .integer))
}
@Test("double accepts exponents and the special values")
func test_double() {
#expect(valid("1.5e-9", .double) && valid("INF", .double) && valid("-INF", .double) && valid("NaN", .double))
#expect(!valid("1.5e", .double) && !valid("inf", .double))
}
@Test("bounded integer types enforce their ranges")
func test_integerBounds() {
#expect(valid("127", .byte) && !valid("128", .byte))
#expect(valid("-128", .byte) && !valid("-129", .byte))
#expect(valid("255", .unsignedByte) && !valid("-1", .unsignedByte))
#expect(valid("1", .positiveInteger) && !valid("0", .positiveInteger))
#expect(valid("0", .nonNegativeInteger) && !valid("-1", .nonNegativeInteger))
}
// MARK: Dates
@Test("date validates field ranges and leap years")
func test_date() {
#expect(valid("2024-02-29", .date) && !valid("2023-02-29", .date))
#expect(!valid("2024-02-30", .date) && !valid("2024-13-01", .date))
#expect(valid("-0044-03-15", .date))
}
@Test("dateTime accepts 24:00:00 but not 24:00:01, and timezones")
func test_dateTime() {
#expect(valid("2026-06-08T24:00:00", .dateTime))
#expect(!valid("2026-06-08T24:00:01", .dateTime))
#expect(valid("2026-06-08T12:30:00Z", .dateTime) && valid("2026-06-08T12:30:00+05:30", .dateTime))
#expect(!valid("2026-06-08T12:30:00+15:00", .dateTime))
}
@Test("the g* date types parse their reduced forms")
func test_gTypes() {
#expect(valid("--02-29", .gMonthDay) && !valid("--02-30", .gMonthDay))
#expect(valid("---31", .gDay) && valid("--12", .gMonth) && valid("2026", .gYear))
}
// MARK: Other primitives
@Test("hexBinary, base64Binary, and names")
func test_binaryAndNames() {
#expect(valid("0fB7", .hexBinary) && !valid("0fB", .hexBinary))
#expect(valid("YW55", .base64Binary) && valid("YW4=", .base64Binary))
#expect(valid("ns:local", .qName) && !valid("ns:1bad", .qName))
#expect(valid("Hello", .ncName) && !valid("a:b", .ncName))
#expect(valid("en-US", .language) && !valid("english-", .language))
#expect(valid("P1Y2M3DT4H", .duration) && !valid("P", .duration) && !valid("PT", .duration))
}
// MARK: Facets
@Test("whiteSpace collapse is applied before validation")
func test_whiteSpace() {
#expect(Schema.SimpleType(base: .token).isValid(" a b "))
#expect(Schema.SimpleType(base: .int).isValid(" 42 "))
}
@Test("length, minLength, and maxLength count characters")
func test_lengthFacets() {
let type = Schema.SimpleType(base: .string, facets: .init(minLength: 2, maxLength: 4))
#expect(type.isValid("abc") && !type.isValid("a") && !type.isValid("abcde"))
}
@Test("pattern facets use the regex engine")
func test_patternFacet() {
let type = Schema.SimpleType(base: .string, facets: .init(patterns: ["[A-Z]{3}-\\d{2}"]))
#expect(type.isValid("XYZ-42") && !type.isValid("xyz-42"))
}
@Test("enumeration compares in value space")
func test_enumerationFacet() {
let type = Schema.SimpleType(base: .decimal, facets: .init(enumeration: ["1.0", "2.5"]))
#expect(type.isValid("1.00") && type.isValid("2.5") && !type.isValid("3"))
}
@Test("inclusive and exclusive ranges")
func test_rangeFacets() {
let inclusive = Schema.SimpleType(base: .integer, facets: .init(minInclusive: "1", maxInclusive: "10"))
#expect(inclusive.isValid("1") && inclusive.isValid("10") && !inclusive.isValid("11"))
let exclusive = Schema.SimpleType(base: .integer, facets: .init(minExclusive: "1", maxExclusive: "10"))
#expect(!exclusive.isValid("1") && exclusive.isValid("2") && !exclusive.isValid("10"))
}
@Test("totalDigits and fractionDigits")
func test_digitFacets() {
let type = Schema.SimpleType(base: .decimal, facets: .init(totalDigits: 4, fractionDigits: 2))
#expect(type.isValid("12.34") && type.isValid("12") && !type.isValid("123.45") && !type.isValid("1.234"))
}
@Test("exact decimal comparison avoids floating-point error")
func test_exactDecimal() {
// 0.1 + 0.2 != 0.3 in binary; exact comparison handles boundary values.
let type = Schema.SimpleType(base: .decimal, facets: .init(maxInclusive: "0.3"))
#expect(type.isValid("0.3") && type.isValid("0.30") && !type.isValid("0.30000000001"))
}
}