PureXML/Tests/XSLTNumberSingleTests.swift
Mihaela Mihaljevic 8f992a8897
perf(xslt): cache xsl:number level single/multiple sibling ranks (#310)
siblingPosition ranked a numbered node among its parent's matching children by
scanning the child list up to the node, counting matches. Run for every node of
a wide list, numbering was O(n^2): the default xsl:number over 8000 list items
took ~3.5s.

The count test is the same for every candidate of one xsl:number call, so cache
per parent and count pattern the one-based rank of each matching child, built
once. Each node's rank is then an O(1) lookup. The transformer holds the cache
across the whole transform; the count test itself is already O(1) through the
match cache, so building a parent's ranks is linear in its children. The numbers
are identical (a node's rank among matching siblings is 1 + the matching
siblings before it).

The 8000-item numbering drops ~3.5s to ~0.04s (~90x), quadratic to linear. The
full suite, the W3C XSLT and Apache Xalan corpora, and the WASM build stay
green; XSLTNumberSingleTests pins single, partial-match, and multiple-level.
2026-06-26 01:13:07 +02:00

50 lines
2.6 KiB
Swift

import Testing
@testable import PureXML
/// `xsl:number` level single and multiple rank a node among its parent's
/// matching children. That rank now comes from a per-parent-and-pattern cache
/// (built once) instead of rescanning the child list per numbered node. These
/// pin that the numbers are unchanged: the position among matching siblings for
/// the default and an explicit count, a partial-match count, and the
/// multiple-level path that numbers each matching ancestor.
@Suite("xsl:number level single and multiple")
struct XSLTNumberSingleTests {
private func transform(_ source: String, template: String) throws -> String {
let style = """
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" indent="no"/>
<xsl:template match="/"><out>\(template)</out></xsl:template>
</xsl:stylesheet>
"""
return try PureXML.XSLT.transform(stylesheet: style, source: source)
}
@Test("default and explicit count rank a node among its same-name siblings")
func test_single() throws {
let source = "<r><item/><item/><item/></r>"
let template = #"<xsl:for-each select="//item"><n><xsl:number/></n></xsl:for-each>"#
#expect(try transform(source, template: template).hasSuffix("<n>1</n><n>2</n><n>3</n></out>"))
let explicit = #"<xsl:for-each select="//item"><n><xsl:number count="item"/></n></xsl:for-each>"#
#expect(try transform(source, template: explicit).hasSuffix("<n>1</n><n>2</n><n>3</n></out>"))
}
@Test("a partial-match count numbers only the matching siblings")
func test_partialMatch() throws {
// The <other/> elements are interleaved but not counted, so the items
// keep ranks 1, 2, 3 among the item siblings.
let source = "<r><other/><item/><other/><item/><item/><other/></r>"
let template = #"<xsl:for-each select="//item"><n><xsl:number count="item"/></n></xsl:for-each>"#
#expect(try transform(source, template: template).hasSuffix("<n>1</n><n>2</n><n>3</n></out>"))
}
@Test("multiple level numbers each matching ancestor")
func test_multiple() throws {
let source = "<doc><sec><sec/><sec/></sec><sec/></doc>"
// The innermost <sec> elements: the two inside the first sec are 1.1 and
// 1.2; the last top-level sec is 2.
let template = #"<xsl:for-each select="//sec[not(sec)]"><n><xsl:number level="multiple" count="sec" format="1.1"/></n></xsl:for-each>"#
let out = try transform(source, template: template)
#expect(out.hasSuffix("<n>1.1</n><n>1.2</n><n>2</n></out>"))
}
}