use rxeval::portability::{check_form, Breaks};
fn form(binds: &str, body: &str) -> String {
format!(
r#"<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml"
xmlns:jr="http://openrosa.org/javarosa">
<h:head><model>
<instance><data id="f">
<a/><b/><cpf/>
<household><resident><idade/></resident></household>
</data></instance>
{binds}
</model></h:head>
<h:body>{body}</h:body>
</h:html>"#
)
}
fn repeat_body() -> &'static str {
r#"<repeat nodeset="/data/household/resident">
<input ref="/data/household/resident/idade"><label>idade</label></input>
</repeat>"#
}
#[test]
fn a_positional_predicate_reads_as_empty_on_a_tablet() {
let xform = form(
r#"<bind nodeset="/data/a" type="string"
calculate="/data/household/resident[2]/idade"/>"#,
repeat_body(),
);
let issues = check_form(&xform).unwrap();
assert_eq!(issues.len(), 1, "{issues:#?}");
assert_eq!(issues[0].breaks, Breaks::Collect);
assert!(issues[0].construct.contains("[2]"), "{:?}", issues[0]);
assert!(
issues[0]
.suggestion
.as_deref()
.unwrap()
.contains("position() = 2"),
"{:?}",
issues[0]
);
let text = issues[0].describe();
assert!(text.contains("/data/a"), "{text}");
assert!(text.contains("calculate"), "{text}");
assert!(text.contains("silently"), "{text}");
}
#[test]
fn functions_only_one_engine_has() {
let xform = form(
r#"<bind nodeset="/data/a" type="string" calculate="floor(1.9) + ceiling(1.1)"/>
<bind nodeset="/data/b" type="string" calculate="substring(/data/cpf, 2, 3)"/>
<bind nodeset="/data/cpf" type="string" constraint="last() > 1"/>"#,
"",
);
let issues = check_form(&xform).unwrap();
let constructs: Vec<&str> = issues.iter().map(|i| i.construct.as_str()).collect();
assert!(constructs.contains(&"floor()"), "{constructs:?}");
assert!(constructs.contains(&"ceiling()"), "{constructs:?}");
assert!(constructs.contains(&"substring()"), "{constructs:?}");
assert!(constructs.contains(&"last()"), "{constructs:?}");
assert!(issues.iter().all(|i| i.breaks == Breaks::Collect));
let substring = issues
.iter()
.find(|i| i.construct == "substring()")
.unwrap();
let hint = substring.suggestion.as_deref().unwrap();
assert!(hint.contains("substr("), "{hint}");
assert!(hint.contains("counts from 0"), "{hint}");
}
#[test]
fn the_other_direction_too() {
let xform = form(
r#"<bind nodeset="/data/a" type="string" calculate="enclosed-area(/data/b)"/>"#,
"",
);
let issues = check_form(&xform).unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].breaks, Breaks::WebForms);
assert!(issues[0].describe().contains("web form"), "{:?}", issues[0]);
}
#[test]
fn an_unanchored_pattern_means_two_things() {
let xform = form(
r#"<bind nodeset="/data/cpf" type="string" constraint="regex(., '[0-9]{11}')"/>"#,
"",
);
let issues = check_form(&xform).unwrap();
assert_eq!(issues.len(), 1, "{issues:#?}");
assert_eq!(issues[0].breaks, Breaks::Differently);
let text = issues[0].describe();
assert!(text.contains("whole value"), "{text}");
assert!(text.contains("anywhere"), "{text}");
assert_eq!(
issues[0].suggestion.as_deref().unwrap(),
"'^(?:[0-9]{11})$'"
);
let anchored = form(
r#"<bind nodeset="/data/cpf" type="string" constraint="regex(., '^[0-9]{11}$')"/>"#,
"",
);
assert!(check_form(&anchored).unwrap().is_empty());
}
#[test]
fn comparing_a_field_that_repeats() {
let xform = form(
r#"<bind nodeset="/data/a" type="string"
relevant="/data/household/resident/idade = 7"/>"#,
repeat_body(),
);
let issues = check_form(&xform).unwrap();
assert_eq!(issues.len(), 1, "{issues:#?}");
assert_eq!(issues[0].breaks, Breaks::Collect);
assert!(issues[0].construct.contains("repeats"), "{:?}", issues[0]);
let plain = form(
r#"<bind nodeset="/data/a" type="string" relevant="/data/b = 7"/>"#,
repeat_body(),
);
assert!(check_form(&plain).unwrap().is_empty());
}
#[test]
fn a_form_that_travels_reports_nothing() {
let xform = form(
r#"<bind nodeset="/data/a" type="string" calculate="concat(/data/b, '-')"/>
<bind nodeset="/data/b" type="int" constraint=". >= 0 and . <= 120"/>
<bind nodeset="/data/cpf" type="string" constraint="regex(., '^[0-9]{11}$')"
relevant="/data/b > 18"/>"#,
repeat_body(),
);
assert!(
check_form(&xform).unwrap().is_empty(),
"{:#?}",
check_form(&xform).unwrap()
);
}
#[test]
fn the_psu_form() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("dados")
.join("psu2026");
let Ok(xform) = std::fs::read_to_string(dir.join("form.xml")) else {
eprintln!("portability: dados/psu2026 not present — skipping");
return;
};
let issues = check_form(&xform).unwrap();
println!("PSU 2026: {} portability issue(s)", issues.len());
for issue in &issues {
println!(" {}", issue.describe());
}
}
#[test]
fn the_suggested_rewrite_survives_alternation() {
let xform = form(
r#"<bind nodeset="/data/a" type="string"
constraint="regex(., '9[0-9]{8}|[2-5][0-9]{7}')"/>"#,
"",
);
let issues = check_form(&xform).unwrap();
assert_eq!(issues.len(), 1, "{issues:#?}");
let fix = issues[0].suggestion.as_deref().unwrap();
assert!(
fix.contains("(?:"),
"the fix must group the alternation, or it changes the meaning: {fix}"
);
assert_eq!(fix, "'^(?:9[0-9]{8}|[2-5][0-9]{7})$'");
}
#[test]
fn anchors_that_only_look_like_anchors() {
let xform = form(
r#"<bind nodeset="/data/a" type="string" constraint="regex(., '^abc|def$')"/>"#,
"",
);
let issues = check_form(&xform).unwrap();
assert_eq!(issues.len(), 1, "{issues:#?}");
assert!(
issues[0].construct.contains("outside the anchors"),
"{:?}",
issues[0]
);
assert_eq!(
issues[0].suggestion.as_deref().unwrap(),
"'^(?:^abc|def$)$'"
);
let grouped = form(
r#"<bind nodeset="/data/a" type="string"
constraint="regex(., '^(9[0-9]{8}|[2-5][0-9]{7})$')"/>"#,
"",
);
assert!(check_form(&grouped).unwrap().is_empty());
let class = form(
r#"<bind nodeset="/data/a" type="string" constraint="regex(., '^[a|b]+$')"/>"#,
"",
);
assert!(check_form(&class).unwrap().is_empty());
}
#[test]
fn a_pattern_that_is_not_written_out() {
let xform = form(
r#"<bind nodeset="/data/a" type="string" constraint="regex(., /data/b)"/>"#,
"",
);
let issues = check_form(&xform).unwrap();
assert_eq!(issues.len(), 1, "{issues:#?}");
assert!(issues[0].suggestion.is_none());
assert!(
issues[0].describe().contains("built at runtime"),
"{:?}",
issues[0]
);
}
#[test]
fn geography_and_pulldata_are_reported() {
let xform = r#"<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml">
<h:head><model>
<instance><data id="p"><t/><a/><b/><c/></data></instance>
<bind nodeset="/data/a" calculate="pulldata('lotes', 'nome', 'codigo', /data/t)"/>
<bind nodeset="/data/b" calculate="distance(/data/t)"/>
<bind nodeset="/data/c" calculate="area(/data/t)"/>
</model></h:head><h:body/></h:html>"#;
let issues = rxeval::check_form(xform).unwrap();
let found: Vec<(&str, &str)> = issues
.iter()
.map(|i| (i.construct.as_str(), i.breaks.describe()))
.collect();
assert!(
found.contains(&("pulldata()", "Enketo web forms")),
"{found:?}"
);
assert!(
found.contains(&("distance()", "both, differently")),
"{found:?}"
);
assert!(
found.contains(&("area()", "both, differently")),
"{found:?}"
);
}
#[test]
fn a_path_that_names_nothing_is_reported() {
let xform = r#"<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml" xmlns:jr="http://openrosa.org/javarosa">
<h:head><model>
<instance>
<data id="p">
<morador jr:template=""><idade/><maior/></morador>
<morador><idade/><maior/></morador>
<total/>
<errado/>
</data>
</instance>
<bind nodeset="/data/morador/maior" calculate="if(../idade >= 18, 1, 0)"/>
<bind nodeset="/data/total" calculate="sum(/p/morador/maior)"/>
<bind nodeset="/data/errado" relevant="/data/nao_existe = 1"/>
</model></h:head><h:body><repeat nodeset="/data/morador"/></h:body></h:html>"#;
let issues = rxeval::check_form(xform).unwrap();
let silent: Vec<&rxeval::Issue> = issues
.iter()
.filter(|i| i.breaks == rxeval::Breaks::Everywhere)
.collect();
assert_eq!(
silent.len(),
2,
"{:?}",
issues.iter().map(|i| i.describe()).collect::<Vec<_>>()
);
let sum = silent
.iter()
.find(|i| i.path == "/data/total")
.expect("the sum");
assert!(
sum.construct.starts_with("/p/morador/maior"),
"{}",
sum.construct
);
assert_eq!(sum.suggestion.as_deref(), Some("/data/morador/maior"));
assert!(
sum.describe().contains("identically on both engines"),
"{}",
sum.describe()
);
let relevant = silent
.iter()
.find(|i| i.path == "/data/errado")
.expect("the relevant");
assert_eq!(relevant.suggestion, None);
}
#[test]
fn legitimate_paths_are_left_alone() {
let xform = r#"<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml" xmlns:jr="http://openrosa.org/javarosa">
<h:head><model>
<instance>
<data id="p">
<ident><ponto/><linha/></ident>
<morador jr:template=""><idade/><adulto/></morador>
<morador><idade/><adulto/></morador>
<lote/><copia/><conta/><attr/>
</data>
</instance>
<instance id="linhas">
<root><item><name/><lote/></item></root>
</instance>
<instance id="externa" src="jr://file-csv/externa.csv"/>
<!-- inside a predicate on a lookup table, `name` is the table's
column and has nothing to do with this form's instance -->
<bind nodeset="/data/lote" calculate="instance('linhas')/root/item[name = /data/ident/linha]/lote"/>
<!-- an external table has no inline shape to check the rest against -->
<bind nodeset="/data/copia" calculate="instance('externa')/root/item[codigo = /data/ident/ponto]/nome"/>
<!-- relative, at the top level, where the context is the bound node -->
<bind nodeset="/data/morador/adulto" calculate="if(../idade >= 18, 1, 0)"/>
<!-- a whole repeat, and a count over it -->
<bind nodeset="/data/conta" calculate="count(/data/morador)"/>
<!-- an attribute of the root -->
<bind nodeset="/data/attr" calculate="/data/@id"/>
</model></h:head><h:body><repeat nodeset="/data/morador"/></h:body></h:html>"#;
let issues: Vec<String> = rxeval::check_form(xform)
.unwrap()
.into_iter()
.filter(|i| i.breaks == rxeval::Breaks::Everywhere)
.map(|i| i.describe())
.collect();
assert!(
issues.is_empty(),
"false alarms:\n {}",
issues.join("\n ")
);
}