use std::collections::BTreeMap;
use rxeval::{parse, Context, Fixed, Instance, Value};
fn deliberate_divergences() -> BTreeMap<&'static str, &'static str> {
BTreeMap::new()
}
#[derive(Debug, PartialEq)]
enum Answer {
Boolean(bool),
Number(String),
String(String),
NodeSet(Vec<String>),
Refused,
}
fn read_json(text: &str) -> BTreeMap<String, serde_json::Value> {
serde_json::from_str(text).expect("the recorded expectations are JSON")
}
fn reference_answer(entry: &serde_json::Value) -> Answer {
match entry["type"].as_str().unwrap_or_default() {
"boolean" => Answer::Boolean(entry["value"].as_bool().unwrap_or_default()),
"number" => Answer::Number(match &entry["value"] {
serde_json::Value::String(s) => s.clone(),
other => number_text(other.as_f64().unwrap_or(f64::NAN)),
}),
"string" => Answer::String(entry["value"].as_str().unwrap_or_default().to_string()),
"nodeset" => Answer::NodeSet(
entry["value"]
.as_array()
.map(|values| {
values
.iter()
.map(|v| v.as_str().unwrap_or_default().to_string())
.collect()
})
.unwrap_or_default(),
),
_ => Answer::Refused,
}
}
fn number_text(n: f64) -> String {
if n.is_nan() {
return "NaN".into();
}
if n.is_infinite() {
return if n > 0.0 { "Infinity" } else { "-Infinity" }.into();
}
if n == 0.0 {
return "0".to_string();
}
let rounded = format!("{:.10}", n);
let trimmed = rounded.trim_end_matches('0').trim_end_matches('.');
if trimmed.is_empty() || trimmed == "-" {
"0".to_string()
} else {
trimmed.to_string()
}
}
fn ours(expression: &str, instance: &Instance, env: &Fixed) -> Answer {
let Ok(expr) = parse(expression) else {
return Answer::Refused;
};
let Some(root) = instance.root() else {
return Answer::Refused;
};
match rxeval::evaluate(&expr, instance, Context::at(root), env) {
Err(_) => Answer::Refused,
Ok(Value::Boolean(b)) => Answer::Boolean(b),
Ok(Value::Number(n)) => Answer::Number(number_text(n)),
Ok(Value::String(s)) => Answer::String(s),
Ok(Value::NodeSet(nodes)) => Answer::NodeSet(
nodes
.iter()
.map(|n| instance.string_value(*n))
.collect::<Vec<_>>(),
),
}
}
fn agree(a: &Answer, b: &Answer, instance: &Instance) -> bool {
let flatten = |answer: &Answer| -> Option<String> {
match answer {
Answer::String(s) => Some(s.clone()),
Answer::NodeSet(values) => Some(values.first().cloned().unwrap_or_default()),
_ => None,
}
};
let _ = instance;
match (a, b) {
(Answer::NodeSet(x), Answer::NodeSet(y)) => x == y,
(Answer::String(_) | Answer::NodeSet(_), Answer::String(_) | Answer::NodeSet(_)) => {
flatten(a) == flatten(b)
}
_ => a == b,
}
}
fn reference_disagreements() -> BTreeMap<&'static str, (&'static str, &'static str)> {
BTreeMap::from([
(
"area(\"-1 -1 0 0;-1 1 0 0;1 1 0 0;1 -1 0 0;-1 -1 0 0\")",
(
"neither",
"JavaRosa refuses a literal; Enketo answers with a spherical formula, which \
over two degrees differs from the planar one by 0.01%",
),
),
(
"distance(/data/trecho)",
(
"javarosa",
"Enketo rounds to two decimals; JavaRosa does not",
),
),
(
"distance(/data/area_quadra)",
(
"javarosa",
"Enketo rounds to two decimals; JavaRosa does not",
),
),
(
"distance(\"-23.5505 -46.6333 0 0;-23.5605 -46.6333 0 0\")",
(
"javarosa",
"Enketo rounds to two decimals; JavaRosa does not",
),
),
(
"area(/data/area_quadra)",
(
"javarosa",
"Enketo rounds, and its formula is spherical rather than planar",
),
),
(
"area(/data/trecho)",
(
"javarosa",
"Enketo rounds, and its formula is spherical rather than planar",
),
),
(
"/data/household/resident[2]/nome",
(
"enketo",
"JavaRosa returns nothing for a bare [n]; use [position() = n]",
),
),
(
"/data/household/resident[last()]/nome",
("enketo", "JavaRosa has no last()"),
),
("//nome", ("enketo", "JavaRosa has no // axis")),
("count(//idade)", ("enketo", "JavaRosa has no // axis")),
(
"substring(\"hello\", 2)",
("enketo", "JavaRosa has no substring(); it has substr()"),
),
(
"substring(\"hello\", 2, 3)",
("enketo", "JavaRosa has no substring(); it has substr()"),
),
(
"substring(\"hello\", 1.5, 2.6)",
("enketo", "JavaRosa has no substring(); it has substr()"),
),
(
"substring(\"hello\", 0, 3)",
(
"xpath",
"JavaRosa has no substring(); Enketo reads start <= 0 from the end",
),
),
(
"concat(/data/household/resident/nome, \"!\")",
(
"xpath",
"JavaRosa refuses a node-set here; Enketo joins every value",
),
),
(
".",
(
"xpath",
"Enketo includes the layout whitespace; JavaRosa gives nothing",
),
),
(
"once(/data/age)",
(
"xpath",
"Enketo includes the layout whitespace; JavaRosa gives nothing",
),
),
(
"/data/household/resident/idade = 7",
("enketo", "JavaRosa refuses a multi-node comparison"),
),
(
"/data/household/resident/idade != 7",
("enketo", "JavaRosa refuses a multi-node comparison"),
),
(
"/data/missing != 0",
("enketo", "JavaRosa reads a missing node as an empty string"),
),
(
"boolean(/data/blank)",
(
"enketo",
"JavaRosa judges the value, not whether the node exists",
),
),
(
"boolean-from-string(\"TRUE\")",
(
"javarosa",
"case-insensitive; Enketo accepts only lowercase",
),
),
("floor(1.9)", ("enketo", "JavaRosa has no floor()")),
("ceiling(1.1)", ("enketo", "JavaRosa has no ceiling()")),
(
"round(-1.5)",
(
"enketo",
"JavaRosa rounds half up; XPath rounds half away from zero",
),
),
(
"regex(\"a12345678901b\", \"[0-9]{11}\")",
(
"javarosa",
"anchored, against the spec and Enketo — getodk/javarosa#531",
),
),
(
"regex(\"abc123\", \"[0-9]+\")",
(
"javarosa",
"anchored, against the spec and Enketo — getodk/javarosa#531",
),
),
])
}
#[test]
fn rxeval_means_what_the_ecosystem_means() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("oracle");
let instance = Instance::from_xml(
&std::fs::read_to_string(dir.join("instance.xml")).expect("the shared instance"),
)
.expect("the shared instance parses");
let enketo =
read_json(&std::fs::read_to_string(dir.join("expected.json")).expect("Enketo's answers"));
let javarosa = read_json(
&std::fs::read_to_string(dir.join("javarosa-expected.json")).expect("JavaRosa's answers"),
);
let env = Fixed {
today: "2026-08-14".into(),
now: "2026-08-14T09:30:00.000-03:00".into(),
};
let ours_diverges = deliberate_divergences();
let they_disagree = reference_disagreements();
let mut all_three = 0;
let mut split = Vec::new();
let mut surprises = Vec::new();
let mut compared = 0;
for (expression, enketo_entry) in &enketo {
if expression == "$meta" {
continue;
}
compared += 1;
let theirs = reference_answer(enketo_entry);
let jr = javarosa.get(expression).map(reference_answer);
let ours = ours(expression, &instance, &env);
let matches_enketo = agree(&ours, &theirs, &instance);
let matches_javarosa = jr.as_ref().is_some_and(|a| agree(&ours, a, &instance));
let references_agree = jr.as_ref().is_some_and(|a| agree(a, &theirs, &instance));
if references_agree {
if matches_enketo {
all_three += 1;
if ours_diverges.contains_key(expression.as_str()) {
surprises.push(format!(
"{expression}\n listed as a deliberate divergence, but all three now \
agree — remove the entry"
));
}
} else {
match ours_diverges.get(expression.as_str()) {
Some(why) => split.push(format!("{expression} (both references agree; we differ: {why})")),
None => surprises.push(format!(
"{expression}\n both references say: {theirs:?}\n rxeval: {ours:?}"
)),
}
}
continue;
}
match they_disagree.get(expression.as_str()) {
Some((side, why)) => {
let followed = match *side {
"javarosa" => matches_javarosa,
"enketo" => matches_enketo,
_ => !matches_enketo && !matches_javarosa,
};
if followed {
split.push(format!("{expression} (follows {side}: {why})"));
} else {
surprises.push(format!(
"{expression}\n recorded as following {side} ({why}), but does not\n \
enketo: {theirs:?}\n javarosa: {jr:?}\n rxeval: {ours:?}"
));
}
}
None => surprises.push(format!(
"{expression}\n the two references disagree and nothing says which we follow\n \
enketo: {theirs:?}\n javarosa: {jr:?}\n rxeval: {ours:?}"
)),
}
}
println!(
"ecosystem oracle: {compared} expressions | {all_three} where both references agree and \
so do we | {} where they part ways",
split.len()
);
for note in &split {
println!(" {note}");
}
assert!(
surprises.is_empty(),
"{} expression(s) undecided or wrong:\n\n {}\n",
surprises.len(),
surprises.join("\n\n ")
);
assert!(
all_three > 80,
"only {all_three} expressions had both references agreeing; the fixtures may be stale"
);
}