Skip to main content

sup_xml_core/xpath/exslt/
math.rs

1//! EXSLT math family — https://exslt.org/math/
2//!
3//! All functions live in the `http://exslt.org/math` namespace.
4//! The dispatcher returns `None` for names it doesn't recognise so
5//! the XPath engine can fall through to other tables.
6//!
7//! Coverage:
8//!   - `min`, `max`, `highest`, `lowest`   — nodeset reductions
9//!   - `abs`, `sqrt`, `exp`, `log`, `power` — scalar
10//!   - `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2` — scalar
11//!   - `constant`              — π / e / etc. (lookup by name)
12//!   - `random`                — uniform [0, 1) per call
13//!
14//! Scalar functions take/return `Value::Number`.  Nodeset reductions
15//! coerce each node's string-value to a number; non-numeric values
16//! propagate NaN through the result (matching libexslt).
17
18use crate::error::{ErrorDomain, ErrorLevel, XmlError};
19use crate::xpath::eval::{Numeric, Value, value_to_number};
20use crate::xpath::index::DocIndexLike;
21
22use super::Result;
23
24fn err(msg: impl Into<String>) -> XmlError {
25    XmlError::new(ErrorDomain::XPath, ErrorLevel::Error, msg)
26}
27
28pub fn dispatch<I: DocIndexLike>(
29    name: &str, args: Vec<Value>, idx: &I,
30) -> Option<Result<Value>> {
31    let r = match name {
32        // Nodeset reductions — return a number.
33        "min"     => reduce(&args, idx, f64::INFINITY,      |acc, x| if x < acc { x } else { acc }),
34        "max"     => reduce(&args, idx, f64::NEG_INFINITY,  |acc, x| if x > acc { x } else { acc }),
35        // `highest` / `lowest` differ from max/min: they return the
36        // *nodes* with the extreme value, not the number.  Multiple
37        // ties → all tied nodes in document order.
38        "highest" => extremum(&args, idx, |a, b| a > b),
39        "lowest"  => extremum(&args, idx, |a, b| a < b),
40
41        // Scalar functions (1 arg → number).
42        "abs"      => one_num(&args, idx, |n| n.abs()),
43        "sqrt"     => one_num(&args, idx, |n| n.sqrt()),
44        "exp"      => one_num(&args, idx, |n| n.exp()),
45        "log"      => one_num(&args, idx, |n| n.ln()),
46        "sin"      => one_num(&args, idx, |n| n.sin()),
47        "cos"      => one_num(&args, idx, |n| n.cos()),
48        "tan"      => one_num(&args, idx, |n| n.tan()),
49        "asin"     => one_num(&args, idx, |n| n.asin()),
50        "acos"     => one_num(&args, idx, |n| n.acos()),
51        "atan"     => one_num(&args, idx, |n| n.atan()),
52
53        // Two-arg scalar functions.
54        "power"    => two_num(&args, idx, |a, b| a.powf(b)),
55        "atan2"    => two_num(&args, idx, |a, b| a.atan2(b)),
56
57        // `constant(name, precision)` — fixed table.  Precision
58        // truncates the result to the given number of significant
59        // decimal digits (libexslt treats it as a hint, not a hard
60        // requirement).
61        "constant" => constant(&args, idx),
62
63        // `random()` — uniform [0, 1).
64        "random"   => random_value(&args),
65
66        _ => return None,
67    };
68    Some(r)
69}
70
71// ── helpers ───────────────────────────────────────────────────────
72
73fn one_num<I: DocIndexLike>(
74    args: &[Value], idx: &I, f: impl Fn(f64) -> f64,
75) -> Result<Value> {
76    if args.len() != 1 {
77        return Err(err("math: function requires 1 argument"));
78    }
79    Ok(Value::Number(Numeric::Double(f(value_to_number(&args[0], idx)))))
80}
81
82fn two_num<I: DocIndexLike>(
83    args: &[Value], idx: &I, f: impl Fn(f64, f64) -> f64,
84) -> Result<Value> {
85    if args.len() != 2 {
86        return Err(err("math: function requires 2 arguments"));
87    }
88    Ok(Value::Number(Numeric::Double(f(
89        value_to_number(&args[0], idx),
90        value_to_number(&args[1], idx),
91    ))))
92}
93
94/// Common path for `min` / `max` — fold over the nodeset's
95/// numeric coercions.  Empty nodeset returns NaN (libexslt
96/// behaviour; the spec says "indeterminate", which we render as
97/// NaN for consistency with XPath's `number(())` semantics).
98fn reduce<I: DocIndexLike>(
99    args: &[Value], idx: &I, init: f64, op: impl Fn(f64, f64) -> f64,
100) -> Result<Value> {
101    if args.len() != 1 {
102        return Err(err("math:min/max takes a single nodeset argument"));
103    }
104    let ns = match &args[0] {
105        Value::NodeSet(ns) => ns,
106        _ => return Err(err("math:min/max requires a nodeset argument")),
107    };
108    if ns.is_empty() {
109        return Ok(Value::Number(Numeric::Double(f64::NAN)));
110    }
111    let mut acc = init;
112    for &id in ns {
113        let n: f64 = idx.string_value(id).trim().parse().unwrap_or(f64::NAN);
114        if n.is_nan() {
115            return Ok(Value::Number(Numeric::Double(f64::NAN)));
116        }
117        acc = op(acc, n);
118    }
119    Ok(Value::Number(Numeric::Double(acc)))
120}
121
122/// `highest` / `lowest`: return the subset of nodes whose numeric
123/// string-value is the extremum.  Multiple winners → all in
124/// document order (the nodeset is already document-sorted by the
125/// engine before we receive it).
126fn extremum<I: DocIndexLike>(
127    args: &[Value], idx: &I, better: impl Fn(f64, f64) -> bool,
128) -> Result<Value> {
129    if args.len() != 1 {
130        return Err(err("math:highest/lowest takes a single nodeset argument"));
131    }
132    let ns = match &args[0] {
133        Value::NodeSet(ns) => ns.clone(),
134        _ => return Err(err("math:highest/lowest requires a nodeset argument")),
135    };
136    if ns.is_empty() {
137        return Ok(Value::NodeSet(Vec::new()));
138    }
139    let nums: Vec<f64> = ns.iter()
140        .map(|&id| idx.string_value(id).trim().parse().unwrap_or(f64::NAN))
141        .collect();
142    if nums.iter().any(|n| n.is_nan()) {
143        // libexslt: any NaN → empty nodeset, matching its IEEE
144        // ordering bail-out.
145        return Ok(Value::NodeSet(Vec::new()));
146    }
147    let mut best = nums[0];
148    for &n in &nums[1..] {
149        if better(n, best) { best = n; }
150    }
151    let out: Vec<_> = ns.iter().zip(nums.iter())
152        .filter(|(_, n)| **n == best)
153        .map(|(id, _)| *id)
154        .collect();
155    Ok(Value::NodeSet(out))
156}
157
158fn constant<I: DocIndexLike>(args: &[Value], idx: &I) -> Result<Value> {
159    if args.is_empty() || args.len() > 2 {
160        return Err(err("math:constant requires 1 or 2 arguments"));
161    }
162    let name = match &args[0] {
163        Value::String(s) => s.clone(),
164        v => crate::xpath::eval::value_to_string(v, idx),
165    };
166    // Named constants from libexslt.
167    //
168    // `SQRRT2` (with the double R) is a typo in the EXSLT reference
169    // implementation that was adopted verbatim by every shipping
170    // engine — libexslt (libxslt/libexslt/math.c, the EXSLT reference
171    // XSL at exslt.github.io, Saxon, IBM DataPower, etc.).
172    // Stylesheets in the wild call `math:constant('SQRRT2', n)`
173    // because that's the key every consumer recognises.  We match the
174    // typo and do NOT accept the obvious `SQRT2` spelling — a
175    // stylesheet that depends on `SQRT2` working here would silently
176    // break on every other XSLT engine, which is worse than the small
177    // surprise of having to use the misspelled name.
178    let raw = match name.as_str() {
179        "PI"      => std::f64::consts::PI,
180        "E"       => std::f64::consts::E,
181        "SQRRT2"  => std::f64::consts::SQRT_2,
182        "LN2"     => std::f64::consts::LN_2,
183        "LN10"    => std::f64::consts::LN_10,
184        "LOG2E"   => std::f64::consts::LOG2_E,
185        "SQRT1_2" => std::f64::consts::FRAC_1_SQRT_2,
186        _         => return Ok(Value::Number(Numeric::Double(f64::NAN))),
187    };
188    if args.len() == 1 {
189        return Ok(Value::Number(Numeric::Double(raw)));
190    }
191    // Optional precision: round to `precision - 1` significant
192    // figures.  Matches libexslt's behaviour — the second arg there
193    // controls printf's `%g` precision modulo a `-1` offset (test
194    // case: `math:constant('PI', 4)` → "3.14", i.e. 3 sig figs).
195    let prec = value_to_number(&args[1], idx);
196    if !prec.is_finite() || prec < 2.0 {
197        // prec < 2 → 0 or fewer sig figs requested; return raw and
198        // let XPath number-to-string take over.
199        return Ok(Value::Number(Numeric::Double(raw)));
200    }
201    let sig_figs = (prec as i32) - 1;
202    let scale = 10f64.powi(sig_figs - 1 - (raw.abs().log10().floor() as i32));
203    Ok(Value::Number(Numeric::Double((raw * scale).round() / scale)))
204}
205
206fn random_value(args: &[Value]) -> Result<Value> {
207    if !args.is_empty() {
208        return Err(err("math:random takes no arguments"));
209    }
210    // Simple LCG-on-thread-locals — deterministic-per-thread is
211    // fine for EXSLT; nothing security-sensitive here.  Seeded from
212    // a constant XOR'd with the address of a thread-local so
213    // different threads get different streams.
214    use std::cell::Cell;
215    std::thread_local! {
216        static STATE: Cell<u64> = const { Cell::new(0x9E37_79B9_7F4A_7C15) };
217    }
218    let v = STATE.with(|s| {
219        let mut x = s.get();
220        if x == 0 {
221            // Address of the per-thread Cell — distinct across
222            // threads, so seeds diverge.  `STATE` itself is the
223            // LocalKey accessor (a thread-local *accessor*, not
224            // storage); only the &Cell handed into this closure
225            // has a real address.
226            x = (s as *const _ as u64) ^ 0xDEAD_BEEF_DEAD_BEEF;
227        }
228        x ^= x << 13;
229        x ^= x >> 7;
230        x ^= x << 17;
231        s.set(x);
232        x
233    });
234    // Top 53 bits → uniform [0, 1).
235    Ok(Value::Number(Numeric::Double((v >> 11) as f64 / (1u64 << 53) as f64)))
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::xpath::XPathContext;
242    use crate::{parse_str, ParseOptions};
243
244    fn assert_num(r: Result<Value>, expected: f64) {
245        match r.unwrap() {
246            Value::Number(n) if (n.as_f64() - expected).abs() < 1e-12 => {}
247            other => panic!("expected number ~{expected}, got {other:?}"),
248        }
249    }
250
251    fn tiny_doc() -> sup_xml_tree::dom::Document {
252        parse_str("<r/>", &ParseOptions::default()).unwrap()
253    }
254
255    #[test]
256    fn scalar_sqrt() {
257        let doc = tiny_doc();
258        let ctx = XPathContext::new(&doc);
259        assert_num(
260            dispatch("sqrt", vec![Value::Number(Numeric::Double(16.0))], &ctx.index).unwrap(),
261            4.0,
262        );
263    }
264
265    #[test]
266    fn scalar_power() {
267        let doc = tiny_doc();
268        let ctx = XPathContext::new(&doc);
269        assert_num(
270            dispatch("power",
271                vec![Value::Number(Numeric::Double(2.0)), Value::Number(Numeric::Double(10.0))], &ctx.index).unwrap(),
272            1024.0,
273        );
274    }
275
276    #[test]
277    fn scalar_abs() {
278        let doc = tiny_doc();
279        let ctx = XPathContext::new(&doc);
280        assert_num(
281            dispatch("abs", vec![Value::Number(Numeric::Double(-7.5))], &ctx.index).unwrap(),
282            7.5,
283        );
284    }
285
286    #[test]
287    fn constant_pi_with_precision_truncates() {
288        let doc = tiny_doc();
289        let ctx = XPathContext::new(&doc);
290        let r = dispatch("constant",
291            vec![Value::String("PI".into()), Value::Number(Numeric::Double(5.0))],
292            &ctx.index).unwrap().unwrap();
293        match r {
294            Value::Number(n) => assert!((n.as_f64() - 3.1416).abs() < 1e-3),
295            _ => panic!("expected number"),
296        }
297    }
298
299    #[test]
300    fn unknown_returns_none() {
301        let doc = tiny_doc();
302        let ctx = XPathContext::new(&doc);
303        assert!(dispatch("does-not-exist", vec![], &ctx.index).is_none());
304    }
305
306    #[test]
307    fn min_max_over_nodeset() {
308        let doc = parse_str(
309            "<r><i>3</i><i>1</i><i>5</i><i>2</i></r>",
310            &ParseOptions::default(),
311        ).unwrap();
312        let ctx = XPathContext::new(&doc);
313        let ns = ctx.eval("/r/i").unwrap();
314        assert_num(dispatch("max", vec![ns.clone()], &ctx.index).unwrap(), 5.0);
315        assert_num(dispatch("min", vec![ns],         &ctx.index).unwrap(), 1.0);
316    }
317
318    #[test]
319    fn highest_returns_nodes_with_max_value() {
320        let doc = parse_str(
321            "<r><i>3</i><i>9</i><i>5</i><i>9</i></r>",
322            &ParseOptions::default(),
323        ).unwrap();
324        let ctx = XPathContext::new(&doc);
325        let ns = ctx.eval("/r/i").unwrap();
326        match dispatch("highest", vec![ns], &ctx.index).unwrap().unwrap() {
327            // Two nodes tied at 9 — both returned in document order.
328            Value::NodeSet(ids) => assert_eq!(ids.len(), 2),
329            other => panic!("expected nodeset, got {other:?}"),
330        }
331    }
332
333    #[test]
334    fn empty_nodeset_min_is_nan() {
335        let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
336        let ctx = XPathContext::new(&doc);
337        let r = dispatch("min", vec![Value::NodeSet(vec![])],
338            &ctx.index).unwrap().unwrap();
339        match r {
340            Value::Number(n) => assert!(n.as_f64().is_nan()),
341            _ => panic!("expected NaN"),
342        }
343    }
344
345    // ── scalar trig / log / exp ─────────────────────────────────────────
346
347    #[test]
348    fn scalar_trig_functions() {
349        let doc = tiny_doc();
350        let ctx = XPathContext::new(&doc);
351        let pi = std::f64::consts::PI;
352        assert_num(dispatch("sin", vec![Value::Number(Numeric::Double(0.0))], &ctx.index).unwrap(), 0.0);
353        assert_num(dispatch("cos", vec![Value::Number(Numeric::Double(0.0))], &ctx.index).unwrap(), 1.0);
354        assert_num(dispatch("tan", vec![Value::Number(Numeric::Double(0.0))], &ctx.index).unwrap(), 0.0);
355        assert_num(dispatch("asin", vec![Value::Number(Numeric::Double(0.0))], &ctx.index).unwrap(), 0.0);
356        assert_num(dispatch("acos", vec![Value::Number(Numeric::Double(1.0))], &ctx.index).unwrap(), 0.0);
357        assert_num(dispatch("atan", vec![Value::Number(Numeric::Double(0.0))], &ctx.index).unwrap(), 0.0);
358        assert_num(dispatch("atan2",
359            vec![Value::Number(Numeric::Double(0.0)), Value::Number(Numeric::Double(1.0))], &ctx.index).unwrap(), 0.0);
360        // sin(π/2) ≈ 1
361        assert_num(dispatch("sin", vec![Value::Number(Numeric::Double(pi / 2.0))], &ctx.index).unwrap(), 1.0);
362    }
363
364    #[test]
365    fn scalar_exp_log() {
366        let doc = tiny_doc();
367        let ctx = XPathContext::new(&doc);
368        assert_num(dispatch("exp", vec![Value::Number(Numeric::Double(0.0))], &ctx.index).unwrap(), 1.0);
369        assert_num(dispatch("log", vec![Value::Number(Numeric::Double(std::f64::consts::E))],
370            &ctx.index).unwrap(), 1.0);
371    }
372
373    // ── argc / type errors ──────────────────────────────────────────────
374
375    #[test]
376    fn one_num_wrong_argc_errors() {
377        let doc = tiny_doc();
378        let ctx = XPathContext::new(&doc);
379        let r = dispatch("sqrt", vec![], &ctx.index).unwrap();
380        assert!(r.is_err());
381        let r = dispatch("sqrt",
382            vec![Value::Number(Numeric::Double(1.0)), Value::Number(Numeric::Double(2.0))], &ctx.index).unwrap();
383        assert!(r.is_err());
384    }
385
386    #[test]
387    fn two_num_wrong_argc_errors() {
388        let doc = tiny_doc();
389        let ctx = XPathContext::new(&doc);
390        let r = dispatch("power", vec![Value::Number(Numeric::Double(1.0))], &ctx.index).unwrap();
391        assert!(r.is_err());
392    }
393
394    #[test]
395    fn reduce_wrong_argc_errors() {
396        let doc = tiny_doc();
397        let ctx = XPathContext::new(&doc);
398        assert!(dispatch("min", vec![], &ctx.index).unwrap().is_err());
399        assert!(dispatch("min",
400            vec![Value::NodeSet(vec![]), Value::NodeSet(vec![])],
401            &ctx.index).unwrap().is_err());
402    }
403
404    #[test]
405    fn reduce_wrong_type_errors() {
406        let doc = tiny_doc();
407        let ctx = XPathContext::new(&doc);
408        let r = dispatch("min", vec![Value::Number(Numeric::Double(3.0))], &ctx.index).unwrap();
409        assert!(r.is_err());
410    }
411
412    #[test]
413    fn reduce_nan_in_nodeset_propagates() {
414        let doc = parse_str(
415            "<r><i>3</i><i>not-a-number</i><i>1</i></r>",
416            &ParseOptions::default(),
417        ).unwrap();
418        let ctx = XPathContext::new(&doc);
419        let ns = ctx.eval("/r/i").unwrap();
420        let r = dispatch("max", vec![ns], &ctx.index).unwrap().unwrap();
421        match r {
422            Value::Number(n) => assert!(n.as_f64().is_nan()),
423            _ => panic!("expected NaN"),
424        }
425    }
426
427    #[test]
428    fn extremum_wrong_argc_errors() {
429        let doc = tiny_doc();
430        let ctx = XPathContext::new(&doc);
431        assert!(dispatch("highest", vec![], &ctx.index).unwrap().is_err());
432    }
433
434    #[test]
435    fn extremum_wrong_type_errors() {
436        let doc = tiny_doc();
437        let ctx = XPathContext::new(&doc);
438        let r = dispatch("lowest", vec![Value::Number(Numeric::Double(3.0))], &ctx.index).unwrap();
439        assert!(r.is_err());
440    }
441
442    #[test]
443    fn extremum_empty_nodeset_returns_empty_nodeset() {
444        let doc = tiny_doc();
445        let ctx = XPathContext::new(&doc);
446        let r = dispatch("highest", vec![Value::NodeSet(vec![])],
447            &ctx.index).unwrap().unwrap();
448        match r {
449            Value::NodeSet(ids) => assert!(ids.is_empty()),
450            _ => panic!("expected nodeset"),
451        }
452    }
453
454    #[test]
455    fn extremum_nan_in_nodeset_returns_empty() {
456        let doc = parse_str(
457            "<r><i>3</i><i>bogus</i><i>5</i></r>",
458            &ParseOptions::default(),
459        ).unwrap();
460        let ctx = XPathContext::new(&doc);
461        let ns = ctx.eval("/r/i").unwrap();
462        let r = dispatch("highest", vec![ns], &ctx.index).unwrap().unwrap();
463        match r {
464            Value::NodeSet(ids) => assert!(ids.is_empty()),
465            _ => panic!("expected empty nodeset"),
466        }
467    }
468
469    #[test]
470    fn lowest_returns_nodes_with_min_value() {
471        let doc = parse_str(
472            "<r><i>3</i><i>1</i><i>5</i><i>1</i></r>",
473            &ParseOptions::default(),
474        ).unwrap();
475        let ctx = XPathContext::new(&doc);
476        let ns = ctx.eval("/r/i").unwrap();
477        match dispatch("lowest", vec![ns], &ctx.index).unwrap().unwrap() {
478            Value::NodeSet(ids) => assert_eq!(ids.len(), 2),
479            other => panic!("expected nodeset, got {other:?}"),
480        }
481    }
482
483    // ── constant() ───────────────────────────────────────────────────────
484
485    #[test]
486    fn constant_all_named_values() {
487        let doc = tiny_doc();
488        let ctx = XPathContext::new(&doc);
489        let single = |name: &str| -> f64 {
490            match dispatch("constant",
491                vec![Value::String(name.into())], &ctx.index).unwrap().unwrap() {
492                Value::Number(n) => n.as_f64(),
493                _ => panic!(),
494            }
495        };
496        assert!((single("PI")      - std::f64::consts::PI).abs()         < 1e-12);
497        assert!((single("E")       - std::f64::consts::E).abs()          < 1e-12);
498        // SQRRT2 is the canonical EXSLT key — a typo baked into the
499        // reference impl and copied by every shipping engine.
500        assert!((single("SQRRT2")  - std::f64::consts::SQRT_2).abs()     < 1e-12);
501        assert!((single("LN2")     - std::f64::consts::LN_2).abs()       < 1e-12);
502        assert!((single("LN10")    - std::f64::consts::LN_10).abs()      < 1e-12);
503        assert!((single("LOG2E")   - std::f64::consts::LOG2_E).abs()     < 1e-12);
504        assert!((single("SQRT1_2") - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-12);
505    }
506
507    #[test]
508    fn constant_unknown_name_is_nan() {
509        let doc = tiny_doc();
510        let ctx = XPathContext::new(&doc);
511        let r = dispatch("constant",
512            vec![Value::String("BOGUS".into())], &ctx.index).unwrap().unwrap();
513        match r {
514            Value::Number(n) => assert!(n.as_f64().is_nan()),
515            _ => panic!(),
516        }
517    }
518
519    #[test]
520    fn constant_wrong_argc_errors() {
521        let doc = tiny_doc();
522        let ctx = XPathContext::new(&doc);
523        assert!(dispatch("constant", vec![], &ctx.index).unwrap().is_err());
524        assert!(dispatch("constant",
525            vec![Value::String("PI".into()),
526                 Value::Number(Numeric::Double(1.0)),
527                 Value::Number(Numeric::Double(2.0))],
528            &ctx.index).unwrap().is_err());
529    }
530
531    #[test]
532    fn constant_non_string_first_arg_coerced() {
533        // `v => value_to_string(...)` branch in constant().  Number 1.0
534        // stringifies to "1", which isn't a known constant → NaN.
535        let doc = tiny_doc();
536        let ctx = XPathContext::new(&doc);
537        let r = dispatch("constant",
538            vec![Value::Number(Numeric::Double(1.0))], &ctx.index).unwrap().unwrap();
539        match r {
540            Value::Number(n) => assert!(n.as_f64().is_nan()),
541            _ => panic!(),
542        }
543    }
544
545    #[test]
546    fn constant_non_finite_precision_returns_raw() {
547        // precision = NaN / inf / <1 → return raw value (line 183).
548        let doc = tiny_doc();
549        let ctx = XPathContext::new(&doc);
550        let r = dispatch("constant",
551            vec![Value::String("PI".into()), Value::Number(Numeric::Double(f64::NAN))],
552            &ctx.index).unwrap().unwrap();
553        match r {
554            Value::Number(n) => assert!((n.as_f64() - std::f64::consts::PI).abs() < 1e-12),
555            _ => panic!(),
556        }
557        let r = dispatch("constant",
558            vec![Value::String("PI".into()), Value::Number(Numeric::Double(0.0))],
559            &ctx.index).unwrap().unwrap();
560        match r {
561            Value::Number(n) => assert!((n.as_f64() - std::f64::consts::PI).abs() < 1e-12),
562            _ => panic!(),
563        }
564    }
565
566    // ── random() ─────────────────────────────────────────────────────────
567
568    #[test]
569    fn random_value_in_unit_interval() {
570        // Note: random() uses a thread-local LCG; each call advances the
571        // state.  We just check the value is in [0, 1).  This also
572        // exercises the seed-init branch on the first call.
573        let r = dispatch("random", vec![], &MockIdx).unwrap().unwrap();
574        match r {
575            Value::Number(n) => assert!((0.0..1.0).contains(&n.as_f64()), "got {}", n.as_f64()),
576            _ => panic!(),
577        }
578        // Second call advances the state (exercises the post-seed path).
579        let r = dispatch("random", vec![], &MockIdx).unwrap().unwrap();
580        assert!(matches!(r, Value::Number(n) if (0.0..1.0).contains(&n.as_f64())));
581    }
582
583    #[test]
584    fn random_rejects_args() {
585        let r = dispatch("random",
586            vec![Value::Number(Numeric::Double(1.0))], &MockIdx).unwrap();
587        assert!(r.is_err());
588    }
589
590    // Minimal DocIndexLike for tests that don't need a real document.
591    struct MockIdx;
592    impl DocIndexLike for MockIdx {
593        fn children(&self, _: NodeId) -> &[NodeId] { &[] }
594        fn parent(&self, _: NodeId) -> Option<NodeId> { None }
595        fn attr_range(&self, _: NodeId) -> std::ops::Range<NodeId> { 0..0 }
596        fn kind(&self, _: NodeId) -> crate::xpath::index::XPathNodeKind {
597            crate::xpath::index::XPathNodeKind::Document
598        }
599        fn pi_target(&self, _: NodeId) -> &str { "" }
600        fn string_value(&self, _: NodeId) -> String { String::new() }
601        fn node_name(&self, _: NodeId) -> &str { "" }
602        fn local_name(&self, _: NodeId) -> &str { "" }
603        fn namespace_uri(&self, _: NodeId) -> &str { "" }
604    }
605    use crate::xpath::index::NodeId;
606}