PureXML/Tests/XalanConfSuiteTests.swift
Mihaela Mihaljevic c355daa00e
test(xslt): decode non-UTF-8 conformance sources via the library's encoding detection (#261)
The Xalan harness read source and stylesheet files as UTF-8, so a UTF-16
source (output80) or other non-UTF-8 input (attribvaltemplate08) was refused
as unreadable before the transform ran. Read them through PureXML's own
ByteDecoder (BOM or XML-declaration detection, UTF-8 fallback), as the harness
already does for gold files. The library round-trips them correctly, so both
cases now pass.

Closes Apache Xalan conformance cases output80 and attribvaltemplate08.
2026-06-24 03:04:45 +02:00

152 lines
7.6 KiB
Swift

import Foundation
import Testing
@testable import PureXML
/// Opt-in runner for the Apache xalan-test XSLT 1.0 conformance corpus (the
/// de facto OASIS-era suite: per-category stylesheet/source pairs with gold
/// outputs, Apache License 2.0). Point `XALAN_TS_ROOT` at the checkout's
/// `tests` directory to run; the corpus is never vendored. Comparison
/// normalizes deliberately: a gold and an actual that both parse as XML are
/// compared by canonical form (Xalan's indentation is not normative); other
/// outputs compare with whitespace runs collapsed. Foundation is used for
/// file access only.
@Suite("Xalan XSLT conformance (opt-in via XALAN_TS_ROOT)")
struct XalanConfSuiteTests {
private var root: String? {
ProcessInfo.processInfo.environment["XALAN_TS_ROOT"]
}
/// Cases this implementation knowingly fails, kept exact in
/// Tests/Fixtures/xalan-baseline.txt (one category/name per line): a
/// fixed case must leave the file and a regression shows as a new
/// failure.
private func knownFailures() -> Set<String> {
guard let url = Bundle.module.url(forResource: "xalan-baseline", withExtension: "txt", subdirectory: "Fixtures"),
let text = try? String(contentsOf: url, encoding: .utf8)
else { return [] }
return Set(text.split(separator: "\n").map(String.init).filter { !$0.hasPrefix("#") && !$0.isEmpty })
}
/// Categories that are out of scope: extension mechanisms beyond the
/// XSLT 1.0 core that this package does not ship.
private let outOfScopeCategories: Set<String> = []
@Test("Every conf case transforms to its gold output")
func test_confSuite() throws {
guard let root else {
return // Opt-in: suite not present.
}
let confDirectory = root + "/conf"
let goldDirectory = root + "/conf-gold"
let manager = FileManager.default
let baseline = knownFailures()
var failures: [String: String] = [:]
var passed = 0
var skippedNoGold = 0
let categories = try manager.contentsOfDirectory(atPath: confDirectory)
.filter { !outOfScopeCategories.contains($0) }
.sorted()
for category in categories {
let categoryPath = confDirectory + "/" + category
var isDirectory = ObjCBool(false)
guard manager.fileExists(atPath: categoryPath, isDirectory: &isDirectory), isDirectory.boolValue else { continue }
for sheet in try manager.contentsOfDirectory(atPath: categoryPath).filter({ $0.hasSuffix(".xsl") }).sorted() {
let name = String(sheet.dropLast(4))
let key = category + "/" + name
let goldPath = goldDirectory + "/" + category + "/" + name + ".out"
guard manager.fileExists(atPath: goldPath) else {
skippedNoGold += 1
continue
}
if let problem = runCase(category: category, name: name, root: root, goldPath: goldPath) {
failures[key] = problem
} else {
passed += 1
}
}
}
try? failures.keys.sorted().joined(separator: "\n").write(toFile: "/tmp/xalan-failures.txt", atomically: true, encoding: .utf8)
let newFailures = Set(failures.keys).subtracting(baseline).sorted()
let fixed = baseline.subtracting(failures.keys).sorted()
let sample = newFailures.prefix(3).map { ($0, failures[$0] ?? "") }
let report = "passed \(passed), no-gold \(skippedNoGold), \(newFailures.count) NEW: \(newFailures.prefix(40)) :: \(sample)"
#expect(newFailures.isEmpty, Comment(rawValue: report))
#expect(fixed.isEmpty, "\(fixed.count) baselined cases now pass (remove from xalan-baseline.txt): \(fixed.prefix(40))")
}
/// Reads a file and decodes it through PureXML's own encoding detection (a
/// BOM or the XML declaration), falling back to UTF-8, so a non-UTF-8 source
/// is decoded rather than refused. nil when the file is missing.
private static func decodeFile(_ path: String) -> String? {
guard let data = FileManager.default.contents(atPath: path) else { return nil }
return (try? PureXML.Parsing.ByteDecoder.decode([UInt8](data))) ?? String(data: data, encoding: .utf8)
}
private func runCase(category: String, name: String, root: String, goldPath: String) -> String? {
let directory = root + "/conf/" + category
// Decode inputs the way PureXML decodes any document, by detecting the
// encoding (a BOM or the XML declaration) rather than assuming UTF-8, so
// a UTF-16 or other non-UTF-8 source is fed to the transform, not refused.
guard let stylesheet = Self.decodeFile(directory + "/" + name + ".xsl"),
let source = Self.decodeFile(directory + "/" + name + ".xml")
else {
return "unreadable inputs"
}
guard let goldData = FileManager.default.contents(atPath: goldPath),
let gold = (try? PureXML.Parsing.ByteDecoder.decode([UInt8](goldData))) ?? String(data: goldData, encoding: .utf8)
else {
return "unreadable gold"
}
let loader: (String) -> String? = { uri in Self.decodeFile(directory + "/" + uri) }
let actual: String
do {
actual = try PureXML.XSLT.transform(
stylesheet: stylesheet,
source: source,
documentLoader: loader,
parameters: harnessParameters(directory + "/" + name + ".param"),
)
} catch {
return "transform threw: \(error)"
}
return Self.equivalent(actual: actual, gold: gold) ? nil : "output differs from gold"
}
/// The xalan harness contract: a `<case>.param` file supplies top-level
/// parameters as `name=value` lines (comments and blanks ignored, values
/// cut at whitespace).
private func harnessParameters(_ path: String) -> [String: String] {
guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return [:] }
var parameters: [String: String] = [:]
// CRLF is one Character in Swift: split on any newline grapheme.
for line in text.split(whereSeparator: \.isNewline) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, !trimmed.hasPrefix("#"), let equals = trimmed.firstIndex(of: "=") else { continue }
let name = String(trimmed[..<equals])
let value = trimmed[trimmed.index(after: equals)...].split(whereSeparator: \.isWhitespace).first.map(String.init) ?? ""
parameters[name] = value
}
return parameters
}
/// Gold comparison: canonical XML when both sides parse (indentation is
/// not normative), otherwise collapsed whitespace with whitespace
/// adjacent to tag boundaries dropped (the html method's indentation is
/// layout, not content).
static func equivalent(actual: String, gold: String) -> Bool {
if let actualNode = try? PureXML.parse(actual), let goldNode = try? PureXML.parse(gold) {
if PureXML.Canonical.canonicalize(actualNode) == PureXML.Canonical.canonicalize(goldNode) {
return true
}
}
let collapse = { (text: String) in
var melted = text.split(whereSeparator: \.isWhitespace).joined(separator: " ")
while let range = melted.range(of: "> <") {
melted.replaceSubrange(range, with: "><")
}
return melted
}
return collapse(actual) == collapse(gold)
}
}