#![expect(
clippy::disallowed_types,
reason = "canonical JSON / Simplified Formats operate on the wire value by definition \
(ITS-JSON; ITS-REST Simplified Formats) — serde_json::Value IS the subject matter \
(#1694)"
)]
mod leaf;
mod subtype;
use indexmap::IndexMap;
use openehr_rm::paths::PathSegment;
use serde_json::{Map, Value};
use crate::rm_instance::{ValidationKind, ValidationMessage};
use crate::flat::path;
use crate::flat::rmpath;
use crate::flat::sim::{SimDocument, SimNode, is_present};
use crate::flat::webtemplate::{WebTemplate, WebTemplateArchetypeSlot, WebTemplateNode};
#[must_use]
pub fn validate_archetype_conformance(
composition: &Value,
wt: &WebTemplate,
) -> Vec<ValidationMessage> {
let mut v = Validator::default();
v.walk(composition, &wt.tree);
v.out
}
#[must_use]
pub fn validate_archetype_conformance_incomplete(
composition: &Value,
wt: &WebTemplate,
) -> Vec<ValidationMessage> {
let mut v = Validator {
relax_lower_bounds: true,
..Validator::default()
};
v.walk(composition, &wt.tree);
v.out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConstraintBindingCheck {
pub path: String,
pub ac_code: String,
pub binding_terminology: String,
pub query_uri: String,
pub instance_terminology: String,
pub instance_code: String,
}
#[must_use]
pub fn collect_constraint_binding_checks(
composition: &Value,
wt: &WebTemplate,
) -> Vec<ConstraintBindingCheck> {
let mut v = Validator {
collect_bindings: true,
..Validator::default()
};
v.walk(composition, &wt.tree);
v.bindings
}
#[must_use]
pub fn validate_flat_other(doc: &Map<String, Value>, wt: &WebTemplate) -> Vec<ValidationMessage> {
const EXCLUSIVE: &[&str] = &["code", "value", "terminology", "preferred_term"];
let mut out = Vec::new();
let mut per_leaf: IndexMap<String, LeafGroup> = IndexMap::new();
for key in doc.keys() {
let Ok(fk) = path::FlatKey::parse(key) else {
continue; };
if fk.is_ctx() {
continue;
}
let path_str = key.split('|').next().unwrap_or(key).to_owned();
let suffix = fk.suffixes.first().map(|s| s.name.clone());
let entry = per_leaf.entry(path_str).or_insert_with(|| LeafGroup {
segments: fk.segments,
suffixes: Vec::new(),
});
if let Some(s) = suffix {
entry.suffixes.push(s);
}
}
for (path_str, group) in &per_leaf {
if !group.suffixes.iter().any(|s| s == "other") {
continue;
}
if let Some(conflict) = group
.suffixes
.iter()
.find(|s| EXCLUSIVE.contains(&s.as_str()))
{
out.push(ValidationMessage {
path: path_str.clone(),
message: format!(
"`|other` is mutually exclusive with `|{conflict}` on the same leaf \
(master04 §Open Value-Sets)"
),
kind: ValidationKind::CodedValue,
});
}
if let Some(node) = find_node_by_segments(wt, &group.segments)
&& node.inputs.iter().any(|i| i.list_open == Some(false))
{
out.push(ValidationMessage {
path: path_str.clone(),
message: "`|other` is not allowed on a closed value-set (listOpen: false) \
(master04 §Open Value-Sets)"
.to_owned(),
kind: ValidationKind::CodedValue,
});
}
}
out
}
struct LeafGroup {
segments: Vec<path::Segment>,
suffixes: Vec<String>,
}
#[must_use]
pub fn validate_context(doc: &SimDocument) -> Vec<ValidationMessage> {
let mut out = Vec::new();
let ctx = doc.child("ctx");
for field in ["language", "territory"] {
let in_ctx = ctx
.and_then(|c| c.child(field))
.and_then(SimNode::bare)
.is_some_and(is_present);
let in_root = doc.children.iter().any(|(name, child)| {
name.as_str() != "ctx"
&& child
.occurrences
.iter()
.any(|occ| occ.child(field).is_some())
});
if !in_ctx && !in_root {
out.push(ValidationMessage {
path: format!("ctx/{field}"),
message: format!(
"mandatory context field '{field}' is absent (language and territory \
are required)"
),
kind: ValidationKind::Required,
});
}
}
out
}
fn find_node_by_segments<'a>(
wt: &'a WebTemplate,
segments: &[path::Segment],
) -> Option<&'a WebTemplateNode> {
let mut iter = segments.iter();
let first = iter.next()?;
if first.name != wt.tree.id {
return None;
}
let mut node = &wt.tree;
for seg in iter {
if seg.is_rm_attribute() {
return None; }
node = node
.children
.iter()
.find(|c| c.id == seg.name || c.alt_json_id.as_deref() == Some(seg.name.as_str()))?;
}
Some(node)
}
pub(crate) type SiblingNameIndex = std::collections::HashMap<(String, String), Vec<String>>;
#[derive(Debug, Clone)]
pub(crate) struct NodeWalk {
pub(crate) child_groups: Vec<ChildGroup>,
pub(crate) child_names: SiblingNameIndex,
pub(crate) existence: Vec<Option<Vec<PathSegment>>>,
pub(crate) cardinalities: Vec<Option<Vec<PathSegment>>>,
pub(crate) closed: Vec<Option<Vec<PathSegment>>>,
pub(crate) slot_groups: Vec<SlotGroup>,
pub(crate) slot_names: SiblingNameIndex,
}
#[derive(Debug, Clone)]
pub(crate) struct ChildGroup {
pub(crate) members: Vec<usize>,
pub(crate) segments: Vec<PathSegment>,
}
#[derive(Debug, Clone)]
pub(crate) struct SlotGroup {
pub(crate) allowed: Vec<String>,
pub(crate) path: String,
pub(crate) segments: Vec<PathSegment>,
}
impl NodeWalk {
#[expect(
clippy::indexing_slicing,
reason = "`gi` is either an index already recorded in the `child_idx`/`slot_idx` map or `next == len` immediately after the matching `push`, so both `child_groups[gi]` and `slot_groups[gi]` are in bounds by construction"
)]
pub(crate) fn build(node: &WebTemplateNode) -> Self {
let aql = node.aql_path.as_str();
let mut child_groups: Vec<ChildGroup> = Vec::new();
let mut child_idx: std::collections::HashMap<&str, usize> =
std::collections::HashMap::new();
for (i, child) in node.children.iter().enumerate() {
let key = child.aql_path.as_str();
let next = child_groups.len();
let gi = *child_idx.entry(key).or_insert(next);
if gi == next {
let segments = key.strip_prefix(aql).map(rmpath::parse).unwrap_or_default();
child_groups.push(ChildGroup {
members: Vec::new(),
segments,
});
}
child_groups[gi].members.push(i);
}
let child_names =
sibling_index_from_segments(child_groups.iter().map(|g| g.segments.as_slice()));
let mut slot_groups: Vec<SlotGroup> = Vec::new();
let mut slot_idx: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for slot in &node.slots {
let key = slot.path.as_str();
let next = slot_groups.len();
let gi = *slot_idx.entry(key).or_insert(next);
if gi == next {
let segments = key.strip_prefix(aql).map(rmpath::parse).unwrap_or_default();
slot_groups.push(SlotGroup {
allowed: Vec::new(),
path: slot.path.clone(),
segments,
});
}
slot_groups[gi].allowed.push(slot.rm_type.clone());
}
let slot_names =
sibling_index_from_segments(slot_groups.iter().map(|g| g.segments.as_slice()));
let rel = |p: &str| p.strip_prefix(aql).map(rmpath::parse);
Self {
child_groups,
child_names,
existence: node.existence.iter().map(|e| rel(&e.path)).collect(),
cardinalities: node.card_all.iter().map(|c| rel(&c.path)).collect(),
closed: node
.closed_attributes
.iter()
.map(|c| rel(&c.path))
.collect(),
slot_groups,
slot_names,
}
}
}
fn sibling_index_from_segments<'a>(
groups: impl Iterator<Item = &'a [PathSegment]>,
) -> SiblingNameIndex {
let mut acc: std::collections::HashMap<(String, String), (u32, Vec<String>)> =
std::collections::HashMap::new();
for segs in groups {
let Some(id_seg) = segs.iter().rfind(|s| !s.predicate.is_empty()) else {
continue;
};
let Some(id) = &id_seg.predicate.archetype_node_id else {
continue;
};
let entry = acc
.entry((id_seg.attribute.clone(), id.clone()))
.or_default();
entry.0 += 1;
if let Some(name) = &id_seg.predicate.name_value
&& !entry.1.contains(name)
{
entry.1.push(name.clone());
}
}
acc.into_iter()
.filter(|(_, (count, _))| *count > 1)
.map(|(k, (_, names))| (k, names))
.collect()
}
pub(crate) fn prepare_walk(node: &mut WebTemplateNode) {
let plan = NodeWalk::build(node);
node.walk = Some(Box::new(plan));
for child in &mut node.children {
prepare_walk(child);
}
}
#[derive(Default)]
struct Validator {
out: Vec<ValidationMessage>,
relax_lower_bounds: bool,
collect_bindings: bool,
bindings: Vec<ConstraintBindingCheck>,
}
fn rm_declares_attribute(attr: &str) -> bool {
static DECLARED: std::sync::LazyLock<std::collections::BTreeSet<&'static str>> =
std::sync::LazyLock::new(|| {
openehr_rm::model::classes()
.flat_map(|c| c.attributes.iter().map(|a| a.name))
.collect()
});
DECLARED.contains(attr)
}
impl Validator {
fn push(&mut self, path: impl Into<String>, message: impl Into<String>, kind: ValidationKind) {
self.out.push(ValidationMessage {
path: path.into(),
message: message.into(),
kind,
});
}
fn collect_node_bindings(&mut self, instance: &Value, wt: &WebTemplateNode) {
for binding in &wt.constraint_bindings {
let code_phrase = if binding.attr.is_empty() {
Some(instance)
} else {
instance.get(&binding.attr)
};
let Some(cp) = code_phrase else { continue };
let Some(code) = cp.get("code_string").and_then(Value::as_str) else {
continue;
};
if code.is_empty() {
continue;
}
let instance_terminology = cp
.get("terminology_id")
.and_then(|t| t.get("value"))
.and_then(Value::as_str)
.unwrap_or_default();
self.bindings.push(ConstraintBindingCheck {
path: wt.aql_path.clone(),
ac_code: binding.ac_code.clone(),
binding_terminology: binding.terminology.clone(),
query_uri: binding.query_uri.clone(),
instance_terminology: instance_terminology.to_owned(),
instance_code: code.to_owned(),
});
}
}
fn walk(&mut self, instance: &Value, wt: &WebTemplateNode) {
if let Some(inst_type) = instance.get("_type").and_then(Value::as_str)
&& !subtype::conforms(inst_type, &wt.rm_type)
{
self.push(
&wt.aql_path,
format!(
"expected RM type conforming to {} but found {inst_type}",
wt.rm_type
),
ValidationKind::WrongType,
);
return;
}
if !wt.inputs.is_empty() {
leaf::check_inputs(self, instance, wt);
}
if self.collect_bindings && !wt.constraint_bindings.is_empty() {
self.collect_node_bindings(instance, wt);
}
let built;
let plan: &NodeWalk = if let Some(p) = &wt.walk {
p
} else {
built = NodeWalk::build(wt);
&built
};
for group in &plan.child_groups {
self.check_group(instance, wt, group, &plan.child_names);
}
self.check_cardinalities(instance, wt, plan);
self.check_existence(instance, wt, plan);
self.check_slots(instance, plan);
self.check_closure(instance, wt, plan);
}
#[expect(
clippy::indexing_slicing,
reason = "`slot_counts` is sized `ca.slots.len()` and every index into it is a `position`/`enumerate` index over those same slots, so all are in bounds by construction"
)]
fn check_closure(&mut self, instance: &Value, wt: &WebTemplateNode, plan: &NodeWalk) {
for (ca, segs) in wt.closed_attributes.iter().zip(&plan.closed) {
let Some(segments) = segs else {
continue;
};
let Some((last, intermediate)) = segments.split_last() else {
continue;
};
for container in &rmpath::navigate(&[instance], intermediate) {
let mut slot_counts = vec![0usize; ca.slots.len()];
for child in children_under_attr(container, &last.attribute) {
let Some(nid) = child
.get("archetype_node_id")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
else {
continue;
};
if ca.allowed_ids.iter().any(|a| a == nid) {
continue; }
if ca.slots.is_empty() && openehr_rm::paths::is_archetype_root_node_id(nid) {
continue;
}
let ct = child.get("_type").and_then(Value::as_str).unwrap_or("");
match ca.slots.iter().position(|s| slot_admits(s, ct, nid)) {
Some(i) => slot_counts[i] += 1,
None => self.push(
&ca.path,
format!(
"unexpected node '{nid}' under '{}': no matching archetype \
constraint or slot",
last.attribute
),
ValidationKind::Unexpected,
),
}
}
for (i, slot) in ca.slots.iter().enumerate() {
let count = i32::try_from(slot_counts[i]).unwrap_or(i32::MAX);
if slot_counts[i] == 0 {
if slot.min >= 1 {
self.push(
&ca.path,
format!(
"mandatory archetype slot (occurrences {}..) under '{}' \
has no filler",
slot.min, last.attribute
),
ValidationKind::Required,
);
}
} else if slot.max != -1 && count > slot.max {
self.push(
&ca.path,
format!(
"too many slot fillers under '{}': found {}, expected at most {}",
last.attribute, slot_counts[i], slot.max
),
ValidationKind::Occurrences,
);
}
}
}
}
}
fn check_slots(&mut self, instance: &Value, plan: &NodeWalk) {
for sg in &plan.slot_groups {
let Some((last, intermediate)) = sg.segments.split_last() else {
continue;
};
let containers = rmpath::navigate(&[instance], intermediate);
for container in &containers {
for node in select_group_children(container, last, &plan.slot_names) {
let Some(it) = node.get("_type").and_then(Value::as_str) else {
continue;
};
if !sg.allowed.iter().any(|a| subtype::conforms(it, a)) {
self.push(
&sg.path,
format!(
"class {it} not allowed: the slot is constrained to [{}]",
sg.allowed.join(", ")
),
ValidationKind::WrongType,
);
}
}
}
}
}
fn check_existence(&mut self, instance: &Value, wt: &WebTemplateNode, plan: &NodeWalk) {
if self.relax_lower_bounds {
return;
}
for (ex, segs) in wt.existence.iter().zip(&plan.existence) {
if ex.min < 1 {
continue;
}
let Some(segments) = segs else {
continue;
};
let Some((last, intermediate)) = segments.split_last() else {
continue;
};
if !rm_declares_attribute(&last.attribute) {
continue;
}
let containers = rmpath::navigate(&[instance], intermediate);
for container in &containers {
if attr_absent(container, &last.attribute) {
self.push(
ex.path.clone(),
format!(
"mandatory attribute '{}' is missing (existence {}..)",
last.attribute, ex.min
),
ValidationKind::Required,
);
}
}
}
}
#[expect(
clippy::indexing_slicing,
reason = "`group.members` holds indices into `wt_parent.children` recorded by `NodeWalk::build` over that same child vector, `members` is non-empty for any recorded group (so `members[0]` exists), and `identity_idx` is an `rposition`/`len() - 1` over `segments` (returned early when empty), so every index and range here is in bounds by construction"
)]
fn check_group(
&mut self,
parent: &Value,
wt_parent: &WebTemplateNode,
group: &ChildGroup,
names: &SiblingNameIndex,
) {
let segments = &group.segments;
if segments.is_empty() {
return;
}
let members: Vec<&WebTemplateNode> = group
.members
.iter()
.map(|&i| &wt_parent.children[i])
.collect();
let first = members[0];
let identity_idx = segments
.iter()
.rposition(|s| !s.predicate.is_empty())
.unwrap_or(segments.len() - 1);
let raw_id_seg = &segments[identity_idx];
let trailing = &segments[identity_idx + 1..];
let identity_is_locatable = !trailing.is_empty() || is_locatable(&first.rm_type);
let structural_match = !identity_is_locatable
&& !raw_id_seg.predicate.is_empty()
&& raw_id_seg.attribute != "ism_transition";
let structural_id_seg;
let id_seg: &PathSegment = if structural_match {
let mut stripped = raw_id_seg.clone();
stripped.predicate = openehr_rm::paths::Predicate::default();
structural_id_seg = stripped;
&structural_id_seg
} else {
raw_id_seg
};
let containers = rmpath::navigate(&[parent], &segments[..identity_idx]);
let occ_applies = identity_is_locatable
&& id_seg.predicate.archetype_node_id.is_some()
&& id_seg.attribute != "ism_transition"
&& !members.iter().any(|c| c.in_context == Some(true));
let group_min = members
.iter()
.filter_map(|c| c.min)
.min()
.unwrap_or(0)
.max(0);
let group_max = if members.iter().any(|c| c.max == -1) {
-1
} else {
members.iter().map(|c| c.max).max().unwrap_or(-1)
};
for container in &containers {
let matched = select_group_children(container, id_seg, names);
if occ_applies {
self.emit_occurrences(&first.aql_path, group_min, group_max, matched.len());
}
for node in matched {
for target in rmpath::navigate(&[node], trailing) {
if members.len() == 1 {
self.walk(target, first);
} else {
self.visit_choice(target, &members);
}
}
}
}
}
#[expect(
clippy::indexing_slicing,
reason = "`visit_choice` is only reached from `check_group` with `members.len() > 1`, so `group[0]` exists"
)]
fn visit_choice(&mut self, target: &Value, group: &[&WebTemplateNode]) {
match target.get("_type").and_then(Value::as_str) {
Some(it) => {
if let Some(alt) = group.iter().find(|c| subtype::conforms(it, &c.rm_type)) {
self.walk(target, alt);
} else {
let expected: Vec<&str> = group.iter().map(|c| c.rm_type.as_str()).collect();
self.push(
&group[0].aql_path,
format!(
"value type {it} conforms to none of the permitted types [{}]",
expected.join(", ")
),
ValidationKind::WrongType,
);
}
}
None => self.walk(target, group[0]),
}
}
fn emit_occurrences(&mut self, aql_path: &str, min: i32, max: i32, count: usize) {
let count_i = i32::try_from(count).unwrap_or(i32::MAX);
if !self.relax_lower_bounds {
if count == 0 {
if min >= 1 {
self.push(
aql_path,
format!("mandatory node is missing (occurrences {min}..)"),
ValidationKind::Required,
);
}
} else if count_i < min {
self.push(
aql_path,
format!("too few occurrences: found {count}, expected at least {min}"),
ValidationKind::Occurrences,
);
}
}
if max != -1 && count_i > max {
self.push(
aql_path,
format!("too many occurrences: found {count}, expected at most {max}"),
ValidationKind::Occurrences,
);
}
}
fn check_cardinalities(&mut self, instance: &Value, wt: &WebTemplateNode, plan: &NodeWalk) {
for (card, segs) in wt.card_all.iter().zip(&plan.cardinalities) {
let Some(segments) = segs else {
continue;
};
let Some((last, intermediate)) = segments.split_last() else {
continue;
};
let containers = rmpath::navigate(&[instance], intermediate);
for container in &containers {
if matches!(container.get(&last.attribute), None | Some(Value::Null)) {
continue;
}
let count =
i32::try_from(openehr_rm::paths::select_children(container, last).len())
.unwrap_or(i32::MAX);
let min = card.min.unwrap_or(0).max(0);
if count < min && !self.relax_lower_bounds {
self.push(
card.path.clone(),
format!("cardinality violated: {count} children, expected at least {min}"),
ValidationKind::Cardinality,
);
}
if card.max != -1 && count > card.max {
self.push(
card.path.clone(),
format!(
"cardinality violated: {count} children, expected at most {}",
card.max
),
ValidationKind::Cardinality,
);
}
}
}
}
}
fn select_group_children<'a>(
container: &'a Value,
id_seg: &PathSegment,
names: &SiblingNameIndex,
) -> Vec<&'a Value> {
let claimed = id_seg
.predicate
.archetype_node_id
.as_ref()
.and_then(|id| names.get(&(id_seg.attribute.clone(), id.clone())));
match (id_seg.predicate.name_value.as_ref(), claimed) {
(Some(_), Some(_)) => rmpath::select_children_matched(container, id_seg, false),
(None, Some(claimed)) => {
rmpath::select_children_excluding_names(container, id_seg, claimed)
}
(_, None) => rmpath::select_children_matched(container, id_seg, true),
}
}
fn is_locatable(rm_type: &str) -> bool {
let base = rm_type.split('<').next().unwrap_or(rm_type).trim();
openehr_rm::model::class(base).is_none() || openehr_rm::model::is_a(base, "LOCATABLE")
}
fn children_under_attr<'a>(node: &'a Value, attr: &str) -> Vec<&'a Value> {
match node.get(attr) {
Some(Value::Array(a)) => a.iter().filter(|v| v.is_object()).collect(),
Some(v @ Value::Object(_)) => vec![v],
_ => Vec::new(),
}
}
fn slot_admits(slot: &WebTemplateArchetypeSlot, child_type: &str, archetype_id: &str) -> bool {
if !slot.rm_type.is_empty() && !subtype::conforms(child_type, &slot.rm_type) {
return false;
}
let include_ok = slot.includes.is_empty()
|| slot
.includes
.iter()
.any(|p| leaf::matches_pattern(p, archetype_id));
if !include_ok {
return false;
}
for ex in &slot.excludes {
if !slot.includes.is_empty() && matches!(ex.trim(), ".*" | ".+") {
continue; }
if leaf::matches_pattern(ex, archetype_id) {
return false;
}
}
true
}
fn attr_absent(node: &Value, attr: &str) -> bool {
match node.get(attr) {
None | Some(Value::Null) => true,
Some(Value::Array(a)) => a.is_empty(),
Some(_) => false,
}
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use super::*;
use crate::flat::webtemplate::{
WebTemplateCardinality, WebTemplateClosedAttribute, WebTemplateCodeList,
WebTemplateCodedValue, WebTemplateExistence, WebTemplateInput, WebTemplateInputType,
WebTemplateRange, WebTemplateSlot, WebTemplateValidation,
};
fn node(rm: &str, path: &str) -> WebTemplateNode {
WebTemplateNode::new(rm.to_owned(), path.to_owned())
}
fn walk_only(instance: &Value, root: &WebTemplateNode) -> Vec<ValidationMessage> {
let mut v = Validator::default();
v.walk(instance, root);
v.out
}
fn kinds(msgs: &[ValidationMessage]) -> Vec<ValidationKind> {
msgs.iter().map(|m| m.kind).collect()
}
#[test]
fn occurrences_too_few() {
let mut root = node("COMPOSITION", "");
let mut sec = node("SECTION", "/content[at0001]");
sec.min = Some(2);
sec.max = 5;
root.children = vec![sec];
let inst = json!({
"_type": "COMPOSITION", "archetype_node_id": "x",
"content": [{"_type": "SECTION", "archetype_node_id": "at0001",
"name": {"_type": "DV_TEXT", "value": "s"}}]
});
let msgs = walk_only(&inst, &root);
assert!(
kinds(&msgs).contains(&ValidationKind::Occurrences),
"expected Occurrences (too few), got {msgs:?}"
);
}
#[test]
fn occurrences_too_many() {
let mut root = node("COMPOSITION", "");
let mut sec = node("SECTION", "/content[at0001]");
sec.min = Some(0);
sec.max = 1;
root.children = vec![sec];
let one = json!({"_type": "SECTION", "archetype_node_id": "at0001",
"name": {"_type": "DV_TEXT", "value": "s"}});
let inst = json!({
"_type": "COMPOSITION", "archetype_node_id": "x",
"content": [one.clone(), one.clone(), one]
});
let msgs = walk_only(&inst, &root);
assert!(
msgs.iter()
.any(|m| m.kind == ValidationKind::Occurrences && m.message.contains("too many")),
"expected Occurrences (too many), got {msgs:?}"
);
}
#[test]
fn occurrences_required_missing() {
let mut root = node("COMPOSITION", "");
let mut sec = node("SECTION", "/content[at0001]");
sec.min = Some(1);
sec.max = 1;
root.children = vec![sec];
let inst = json!({"_type": "COMPOSITION", "archetype_node_id": "x", "content": []});
let msgs = walk_only(&inst, &root);
assert!(
kinds(&msgs).contains(&ValidationKind::Required),
"expected Required, got {msgs:?}"
);
}
#[test]
fn cardinality_violation() {
let mut root = node("COMPOSITION", "");
root.card_all = vec![WebTemplateCardinality {
min: Some(1),
max: 2,
ids: None,
path: "/content".to_owned(),
}];
let entry = json!({"_type": "OBSERVATION", "archetype_node_id": "a"});
let inst = json!({
"_type": "COMPOSITION", "archetype_node_id": "x",
"content": [entry.clone(), entry.clone(), entry]
});
let msgs = walk_only(&inst, &root);
assert!(
kinds(&msgs).contains(&ValidationKind::Cardinality),
"expected Cardinality (>max), got {msgs:?}"
);
}
fn walk_incomplete(instance: &Value, root: &WebTemplateNode) -> Vec<ValidationMessage> {
let mut v = Validator {
relax_lower_bounds: true,
..Validator::default()
};
v.walk(instance, root);
v.out
}
#[test]
fn incomplete_suppresses_required_missing_occurrences() {
let mut root = node("COMPOSITION", "");
let mut sec = node("SECTION", "/content[at0001]");
sec.min = Some(1);
sec.max = 1;
root.children = vec![sec];
let inst = json!({"_type": "COMPOSITION", "archetype_node_id": "x", "content": []});
assert!(kinds(&walk_only(&inst, &root)).contains(&ValidationKind::Required));
assert!(
walk_incomplete(&inst, &root).is_empty(),
"incomplete commit must tolerate a missing mandatory node"
);
}
#[test]
fn incomplete_suppresses_too_few_but_keeps_too_many() {
let mut root = node("COMPOSITION", "");
let mut sec = node("SECTION", "/content[at0001]");
sec.min = Some(2);
sec.max = 3;
root.children = vec![sec];
let one = json!({"_type": "SECTION", "archetype_node_id": "at0001",
"name": {"_type": "DV_TEXT", "value": "s"}});
let too_few = json!({
"_type": "COMPOSITION", "archetype_node_id": "x", "content": [one.clone()]
});
assert!(
walk_incomplete(&too_few, &root).is_empty(),
"incomplete commit must tolerate too-few occurrences"
);
let too_many = json!({
"_type": "COMPOSITION", "archetype_node_id": "x",
"content": [one.clone(), one.clone(), one.clone(), one]
});
assert!(
walk_incomplete(&too_many, &root)
.iter()
.any(|m| m.kind == ValidationKind::Occurrences && m.message.contains("too many")),
"incomplete commit must still reject too-many occurrences"
);
}
#[test]
fn incomplete_suppresses_cardinality_lower_but_keeps_upper() {
let mut low = node("COMPOSITION", "");
low.card_all = vec![WebTemplateCardinality {
min: Some(2),
max: -1,
ids: None,
path: "/content".to_owned(),
}];
let entry = json!({"_type": "OBSERVATION", "archetype_node_id": "a"});
let one_child = json!({
"_type": "COMPOSITION", "archetype_node_id": "x", "content": [entry.clone()]
});
assert!(
walk_incomplete(&one_child, &low).is_empty(),
"incomplete commit must tolerate a below-minimum container"
);
let mut high = node("COMPOSITION", "");
high.card_all = vec![WebTemplateCardinality {
min: Some(1),
max: 2,
ids: None,
path: "/content".to_owned(),
}];
let three = json!({
"_type": "COMPOSITION", "archetype_node_id": "x",
"content": [entry.clone(), entry.clone(), entry]
});
assert!(
kinds(&walk_incomplete(&three, &high)).contains(&ValidationKind::Cardinality),
"incomplete commit must still reject an over-maximum container"
);
}
#[test]
fn incomplete_suppresses_existence() {
let mut root = node("COMPOSITION", "");
root.existence = vec![WebTemplateExistence {
min: 1,
max: 1,
path: "/context".to_owned(),
}];
let inst = json!({"_type": "COMPOSITION", "archetype_node_id": "x"});
assert!(kinds(&walk_only(&inst, &root)).contains(&ValidationKind::Required));
assert!(
walk_incomplete(&inst, &root).is_empty(),
"incomplete commit must tolerate a missing mandatory attribute (existence)"
);
}
fn count_node_range(min: i64, max: i64) -> WebTemplateNode {
let mut n = node("DV_COUNT", "/count");
let mut input = WebTemplateInput::new(WebTemplateInputType::Integer, None);
input.validation = Some(WebTemplateValidation {
range: Some(WebTemplateRange {
min_op: Some(">=".to_owned()),
min: Some(Value::from(min)),
max_op: Some("<=".to_owned()),
max: Some(Value::from(max)),
}),
..Default::default()
});
n.inputs = vec![input];
n
}
#[test]
fn range_error_out_of_bounds() {
let n = count_node_range(0, 10);
let inst = json!({"_type": "DV_COUNT", "magnitude": 42});
let msgs = walk_only(&inst, &n);
assert_eq!(kinds(&msgs), vec![ValidationKind::RangeError], "{msgs:?}");
}
#[test]
fn range_ok_in_bounds() {
let n = count_node_range(0, 10);
let inst = json!({"_type": "DV_COUNT", "magnitude": 5});
assert!(walk_only(&inst, &n).is_empty());
}
#[test]
fn multimedia_size_out_of_range_reported() {
let mut n = node("DV_MULTIMEDIA", "/media");
n.inputs = vec![WebTemplateInput::new(WebTemplateInputType::Text, None)];
n.numeric_ranges = vec![(
"size".to_owned(),
WebTemplateRange {
min_op: Some(">=".to_owned()),
min: Some(Value::from(200)),
max_op: Some("<=".to_owned()),
max: Some(Value::from(1000)),
},
)];
let bad = json!({"_type": "DV_MULTIMEDIA", "uri": "http://x", "size": 123});
assert_eq!(
kinds(&walk_only(&bad, &n)),
vec![ValidationKind::RangeError]
);
let good = json!({"_type": "DV_MULTIMEDIA", "uri": "http://x", "size": 500});
assert!(walk_only(&good, &n).is_empty());
}
#[test]
fn multimedia_size_not_in_list_reported() {
let mut n = node("DV_MULTIMEDIA", "/media");
n.inputs = vec![WebTemplateInput::new(WebTemplateInputType::Text, None)];
n.numeric_lists = vec![("size".to_owned(), vec![10.0, 100.0, 1000.0])];
let bad = json!({"_type": "DV_MULTIMEDIA", "uri": "http://x", "size": 123});
assert_eq!(
kinds(&walk_only(&bad, &n)),
vec![ValidationKind::CodedValue]
);
let good = json!({"_type": "DV_MULTIMEDIA", "uri": "http://x", "size": 100});
assert!(walk_only(&good, &n).is_empty());
}
#[test]
fn dv_identifier_mandatory_issuer_enforced() {
let mut n = node("DV_IDENTIFIER", "/value");
let mut issuer = WebTemplateInput::new(WebTemplateInputType::Text, Some("issuer"));
issuer.list = vec![WebTemplateCodedValue::new("XYZ", Some("XYZ".to_owned()))];
issuer.list_open = Some(false);
n.inputs = vec![
WebTemplateInput::new(WebTemplateInputType::Text, Some("id")),
issuer,
];
n.existence = vec![WebTemplateExistence {
min: 1,
max: 1,
path: "/value/issuer".to_owned(),
}];
let absent = json!({"_type": "DV_IDENTIFIER", "id": "x"});
assert!(kinds(&walk_only(&absent, &n)).contains(&ValidationKind::Required));
let wrong = json!({"_type": "DV_IDENTIFIER", "id": "x", "issuer": "ABC"});
assert!(kinds(&walk_only(&wrong, &n)).contains(&ValidationKind::CodedValue));
let ok = json!({"_type": "DV_IDENTIFIER", "id": "x", "issuer": "XYZ"});
assert!(walk_only(&ok, &n).is_empty());
}
#[test]
fn dv_scale_generic_symbol_membership_enforced() {
let mut n = node("DV_SCALE", "/value");
let mut input = WebTemplateInput::new(WebTemplateInputType::CodedText, None);
input.list = vec![
WebTemplateCodedValue::new("at0005", Some("mild".to_owned())),
WebTemplateCodedValue::new("at0006", Some("severe".to_owned())),
];
n.inputs = vec![input];
n.numeric_lists = vec![("value".to_owned(), vec![1.5, 2.4])];
let bad = json!({
"_type": "DV_SCALE", "value": 1.5,
"symbol": {"_type": "DV_CODED_TEXT", "value": "?",
"defining_code": {"_type": "CODE_PHRASE",
"terminology_id": {"_type": "TERMINOLOGY_ID", "value": "local"},
"code_string": "at0666"}}
});
assert!(kinds(&walk_only(&bad, &n)).contains(&ValidationKind::CodedValue));
let ok = json!({
"_type": "DV_SCALE", "value": 1.5,
"symbol": {"_type": "DV_CODED_TEXT", "value": "mild",
"defining_code": {"_type": "CODE_PHRASE",
"terminology_id": {"_type": "TERMINOLOGY_ID", "value": "local"},
"code_string": "at0005"}}
});
assert!(walk_only(&ok, &n).is_empty(), "{:?}", walk_only(&ok, &n));
}
fn time_node(pattern: &str) -> WebTemplateNode {
let mut n = node("DV_TIME", "/time");
let mut input = WebTemplateInput::new(WebTemplateInputType::Time, None);
input.validation = Some(WebTemplateValidation {
pattern: Some(pattern.to_owned()),
..Default::default()
});
n.inputs = vec![input];
n
}
#[test]
fn dv_time_hour_only_accepts_optional_and_prohibited_patterns() {
for pattern in ["HH:??:??", "HH:XX:XX"] {
let n = time_node(pattern);
let inst = json!({"_type": "DV_TIME", "value": "10"});
assert!(
walk_only(&inst, &n).is_empty(),
"hour-only \"10\" must satisfy pattern {pattern}: {:?}",
walk_only(&inst, &n)
);
}
}
#[test]
fn pattern_error() {
let mut n = node("DV_TEXT", "/text");
let mut input = WebTemplateInput::new(WebTemplateInputType::Text, None);
input.validation = Some(WebTemplateValidation {
pattern: Some("[A-Z]+".to_owned()),
..Default::default()
});
n.inputs = vec![input];
let bad = json!({"_type": "DV_TEXT", "value": "abc"});
assert_eq!(
kinds(&walk_only(&bad, &n)),
vec![ValidationKind::PatternError]
);
let good = json!({"_type": "DV_TEXT", "value": "ABC"});
assert!(walk_only(&good, &n).is_empty());
}
fn coded_node(codes: &[&str]) -> WebTemplateNode {
let mut n = node("DV_CODED_TEXT", "/coded");
let mut input = WebTemplateInput::new(WebTemplateInputType::CodedText, Some("code"));
input.list = codes
.iter()
.map(|c| WebTemplateCodedValue::new(*c, Some((*c).to_owned())))
.collect();
n.inputs = vec![input];
n
}
#[test]
fn coded_value_not_in_list() {
let n = coded_node(&["at0001", "at0002"]);
let bad = json!({
"_type": "DV_CODED_TEXT", "value": "x",
"defining_code": {"_type": "CODE_PHRASE",
"terminology_id": {"_type": "TERMINOLOGY_ID", "value": "local"},
"code_string": "at0099"}
});
assert_eq!(
kinds(&walk_only(&bad, &n)),
vec![ValidationKind::CodedValue]
);
let good = json!({
"_type": "DV_CODED_TEXT", "value": "x",
"defining_code": {"_type": "CODE_PHRASE",
"terminology_id": {"_type": "TERMINOLOGY_ID", "value": "local"},
"code_string": "at0001"}
});
assert!(walk_only(&good, &n).is_empty());
}
#[test]
fn coded_value_external_terminology_is_skipped() {
let n = coded_node(&["at0001"]);
let external = json!({
"_type": "DV_CODED_TEXT", "value": "x",
"defining_code": {"_type": "CODE_PHRASE",
"terminology_id": {"_type": "TERMINOLOGY_ID", "value": "SNOMED-CT"},
"code_string": "999"}
});
assert!(walk_only(&external, &n).is_empty());
}
#[test]
fn wrong_type_reported() {
let n = node("DV_QUANTITY", "/q");
let inst = json!({"_type": "DV_TEXT", "value": "x"});
assert_eq!(
kinds(&walk_only(&inst, &n)),
vec![ValidationKind::WrongType]
);
}
#[test]
fn coded_text_in_text_slot_conforms() {
let n = node("DV_TEXT", "/t");
let inst = json!({
"_type": "DV_CODED_TEXT", "value": "x",
"defining_code": {"_type": "CODE_PHRASE",
"terminology_id": {"_type": "TERMINOLOGY_ID", "value": "local"},
"code_string": "at0001"}
});
assert!(
!kinds(&walk_only(&inst, &n)).contains(&ValidationKind::WrongType),
"DV_CODED_TEXT should conform to a DV_TEXT slot"
);
}
#[test]
fn existence_mandatory_attribute_missing_reported() {
let mut n = node("ELEMENT", "/items[at0001]");
n.existence = vec![WebTemplateExistence {
min: 1,
max: 1,
path: "/items[at0001]/value".to_owned(),
}];
let inst = json!({"_type": "ELEMENT", "archetype_node_id": "at0001",
"name": {"_type": "DV_TEXT", "value": "e"}});
let msgs = walk_only(&inst, &n);
assert!(
msgs.iter()
.any(|m| m.kind == ValidationKind::Required && m.path.ends_with("/value")),
"expected a Required existence violation for the missing value, got {msgs:?}"
);
}
#[test]
fn existence_present_attribute_is_clean() {
let mut n = node("ELEMENT", "/items[at0001]");
n.existence = vec![WebTemplateExistence {
min: 1,
max: 1,
path: "/items[at0001]/value".to_owned(),
}];
let inst = json!({"_type": "ELEMENT", "archetype_node_id": "at0001",
"name": {"_type": "DV_TEXT", "value": "e"},
"value": {"_type": "DV_TEXT", "value": "v"}});
assert!(
walk_only(&inst, &n).is_empty(),
"a present mandatory value should be clean"
);
}
#[test]
fn existence_empty_array_counts_as_absent() {
let mut n = node("COMPOSITION", "");
n.existence = vec![WebTemplateExistence {
min: 1,
max: -1,
path: "/content".to_owned(),
}];
let inst = json!({"_type": "COMPOSITION", "archetype_node_id": "x", "content": []});
let msgs = walk_only(&inst, &n);
assert!(
msgs.iter().any(|m| m.kind == ValidationKind::Required),
"an empty mandatory container attribute is absent, got {msgs:?}"
);
}
#[test]
fn segment_parsing_respects_brackets() {
let segs = rmpath::parse("/content[openEHR-EHR-SECTION.x.v1]/items[at0004,'Sys']/value");
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].attribute, "content");
assert_eq!(
segs[0].predicate.archetype_node_id.as_deref(),
Some("openEHR-EHR-SECTION.x.v1")
);
assert_eq!(segs[0].predicate.name_value, None);
assert_eq!(
segs[1].predicate.archetype_node_id.as_deref(),
Some("at0004")
);
assert_eq!(segs[1].predicate.name_value.as_deref(), Some("Sys"));
assert!(segs[2].predicate.is_empty());
}
#[test]
fn cardinality_one_plus_empty_rejected() {
let mut root = node("COMPOSITION", "");
root.card_all = vec![WebTemplateCardinality {
min: Some(1),
max: -1,
ids: None,
path: "/content".to_owned(),
}];
let inst = json!({"_type": "COMPOSITION", "archetype_node_id": "x", "content": []});
let msgs = walk_only(&inst, &root);
assert!(
kinds(&msgs).contains(&ValidationKind::Cardinality),
"expected Cardinality for 1..* with 0 members, got {msgs:?}"
);
}
#[test]
fn bare_mandatory_attribute_absent_rejected() {
let mut root = node("COMPOSITION", "");
root.existence = vec![WebTemplateExistence {
min: 1,
max: 1,
path: "/context".to_owned(),
}];
let inst = json!({"_type": "COMPOSITION", "archetype_node_id": "x"});
let msgs = walk_only(&inst, &root);
assert!(
kinds(&msgs).contains(&ValidationKind::Required),
"expected Required for absent mandatory context, got {msgs:?}"
);
}
#[test]
fn slot_narrowing() {
let mut eval = node("EVALUATION", "/content[at0001]");
eval.slots = vec![WebTemplateSlot {
path: "/content[at0001]/data[at0002]".to_owned(),
rm_type: "ITEM_LIST".to_owned(),
}];
let wrong = json!({"_type": "EVALUATION", "archetype_node_id": "at0001",
"data": {"_type": "ITEM_TREE", "archetype_node_id": "at0002", "items": []}});
let msgs = walk_only(&wrong, &eval);
assert!(
msgs.iter()
.any(|m| m.kind == ValidationKind::WrongType && m.message.contains("not allowed")),
"expected WrongType for ITEM_TREE in an ITEM_LIST slot, got {msgs:?}"
);
let right = json!({"_type": "EVALUATION", "archetype_node_id": "at0001",
"data": {"_type": "ITEM_LIST", "archetype_node_id": "at0002", "items": []}});
assert!(
walk_only(&right, &eval).is_empty(),
"narrowed subtype accepted"
);
eval.slots[0].rm_type = "ITEM_STRUCTURE".to_owned();
let any = json!({"_type": "EVALUATION", "archetype_node_id": "at0001",
"data": {"_type": "ITEM_TABLE", "archetype_node_id": "at0002", "rows": []}});
assert!(
walk_only(&any, &eval).is_empty(),
"abstract ITEM_STRUCTURE slot admits any subtype"
);
}
#[test]
fn numeric_list_membership() {
let mut count = node("DV_COUNT", "/value");
count.inputs = vec![WebTemplateInput::new(WebTemplateInputType::Integer, None)];
count.numeric_lists = vec![("magnitude".to_owned(), vec![3.0])];
let bad = json!({"_type": "DV_COUNT", "magnitude": 7});
let msgs = walk_only(&bad, &count);
assert!(
kinds(&msgs).contains(&ValidationKind::CodedValue),
"expected CodedValue for magnitude off the list, got {msgs:?}"
);
let good = json!({"_type": "DV_COUNT", "magnitude": 3});
assert!(walk_only(&good, &count).is_empty());
}
#[test]
fn proportion_kind_membership() {
let mut prop = node("DV_PROPORTION", "/value");
prop.inputs = vec![WebTemplateInput::new(
WebTemplateInputType::Decimal,
Some("numerator"),
)];
prop.proportion_types = vec!["percent".to_owned()];
let bad =
json!({"_type": "DV_PROPORTION", "numerator": 1.0, "denominator": 2.0, "type": 0});
let msgs = walk_only(&bad, &prop);
assert!(
kinds(&msgs).contains(&ValidationKind::CodedValue),
"expected CodedValue for ratio where percent required, got {msgs:?}"
);
let good =
json!({"_type": "DV_PROPORTION", "numerator": 42.0, "denominator": 100.0, "type": 2});
assert!(walk_only(&good, &prop).is_empty());
}
#[test]
fn temporal_pattern_and_range() {
let mut date = node("DV_DATE", "/value");
let mut input = WebTemplateInput::new(WebTemplateInputType::Date, None);
input.validation = Some(WebTemplateValidation {
pattern: Some("yyyy-mm-dd".to_owned()),
range: Some(WebTemplateRange {
min_op: Some(">=".to_owned()),
min: Some(json!("2021-01-01")),
max_op: Some("<=".to_owned()),
max: Some(json!("2021-12-31")),
}),
precision: None,
});
date.inputs = vec![input];
let partial = json!({"_type": "DV_DATE", "value": "2021"});
assert!(
kinds(&walk_only(&partial, &date)).contains(&ValidationKind::PatternError),
"partial date violates yyyy-mm-dd"
);
let out = json!({"_type": "DV_DATE", "value": "2025-06-01"});
assert!(
kinds(&walk_only(&out, &date)).contains(&ValidationKind::RangeError),
"date outside the range"
);
let ok = json!({"_type": "DV_DATE", "value": "2021-10-18"});
assert!(walk_only(&ok, &date).is_empty());
}
#[test]
fn time_pattern_partial_rejected() {
let mut time = node("DV_TIME", "/value");
let mut input = WebTemplateInput::new(WebTemplateInputType::Time, None);
input.validation = Some(WebTemplateValidation {
pattern: Some("HH:MM:SS".to_owned()),
range: None,
precision: None,
});
time.inputs = vec![input];
assert!(
kinds(&walk_only(
&json!({"_type": "DV_TIME", "value": "22"}),
&time
))
.contains(&ValidationKind::PatternError)
);
assert!(walk_only(&json!({"_type": "DV_TIME", "value": "22:18:16"}), &time).is_empty());
}
#[test]
fn duration_fields_and_range() {
let mut dur = node("DV_DURATION", "/value");
dur.inputs = vec![
WebTemplateInput::new(WebTemplateInputType::Integer, Some("hour")),
WebTemplateInput::new(WebTemplateInputType::Integer, Some("minute")),
];
dur.duration_range = Some(WebTemplateRange {
min_op: Some(">=".to_owned()),
min: Some(json!("PT0S")),
max_op: Some("<=".to_owned()),
max: Some(json!("PT1H")),
});
assert!(
kinds(&walk_only(
&json!({"_type": "DV_DURATION", "value": "P1Y"}),
&dur
))
.contains(&ValidationKind::PatternError),
"year field forbidden by the pattern"
);
assert!(
kinds(&walk_only(
&json!({"_type": "DV_DURATION", "value": "PT5H"}),
&dur
))
.contains(&ValidationKind::RangeError),
"PT5H outside [PT0S,PT1H]"
);
assert!(walk_only(&json!({"_type": "DV_DURATION", "value": "PT30M"}), &dur).is_empty());
}
#[test]
fn external_code_list_membership() {
let mut coded = node("DV_CODED_TEXT", "/value");
let mut input = WebTemplateInput::new(WebTemplateInputType::CodedText, Some("code"));
input.terminology = Some("SNOMED-CT".to_owned());
input.list = vec![WebTemplateCodedValue::new("73211009", None)];
coded.inputs = vec![input];
let bad = json!({"_type": "DV_CODED_TEXT", "value": "x", "defining_code": {
"terminology_id": {"value": "SNOMED-CT"}, "code_string": "99999999"}});
assert!(
kinds(&walk_only(&bad, &coded)).contains(&ValidationKind::CodedValue),
"external code off the enumerated list"
);
let good = json!({"_type": "DV_CODED_TEXT", "value": "x", "defining_code": {
"terminology_id": {"value": "SNOMED-CT"}, "code_string": "73211009"}});
assert!(walk_only(&good, &coded).is_empty());
let other = json!({"_type": "DV_CODED_TEXT", "value": "x", "defining_code": {
"terminology_id": {"value": "ICD10"}, "code_string": "A00"}});
assert!(walk_only(&other, &coded).is_empty());
}
#[test]
fn media_type_code_list() {
let mut mm = node("DV_MULTIMEDIA", "/value");
mm.inputs = vec![WebTemplateInput::new(WebTemplateInputType::Text, None)];
mm.code_lists = vec![WebTemplateCodeList {
attr: "media_type".to_owned(),
terminology: Some("IANA_media-types".to_owned()),
codes: vec!["image/png".to_owned()],
}];
let bad = json!({"_type": "DV_MULTIMEDIA", "size": 1, "media_type": {
"terminology_id": {"value": "IANA_media-types"}, "code_string": "image/gif"}});
assert!(
kinds(&walk_only(&bad, &mm)).contains(&ValidationKind::CodedValue),
"media_type off the enumerated list"
);
let good = json!({"_type": "DV_MULTIMEDIA", "size": 1, "media_type": {
"terminology_id": {"value": "IANA_media-types"}, "code_string": "image/png"}});
assert!(walk_only(&good, &mm).is_empty());
}
#[test]
fn closed_world_rejects_foreign_content() {
let mut root = node("COMPOSITION", "");
root.closed_attributes = vec![WebTemplateClosedAttribute {
path: "/content".to_owned(),
allowed_ids: vec!["openEHR-EHR-SECTION.x.v1".to_owned()],
slots: vec![],
}];
let inst = json!({"_type": "COMPOSITION", "archetype_node_id": "x", "content": [
{"_type": "SECTION", "archetype_node_id": "openEHR-EHR-SECTION.x.v1",
"name": {"_type": "DV_TEXT", "value": "s"}},
{"_type": "OBSERVATION", "archetype_node_id": "openEHR-EHR-OBSERVATION.foreign.v1",
"name": {"_type": "DV_TEXT", "value": "o"}}
]});
let msgs = walk_only(&inst, &root);
assert!(
msgs.is_empty(),
"foreign archetype-rooted content is tolerated (the closed-world admission rule), got {msgs:?}"
);
let at_foreign = json!({"_type": "COMPOSITION", "archetype_node_id": "x", "content": [
{"_type": "SECTION", "archetype_node_id": "at0099",
"name": {"_type": "DV_TEXT", "value": "s"}}
]});
let msgs = walk_only(&at_foreign, &root);
assert!(
msgs.iter()
.any(|m| m.kind == ValidationKind::Unexpected && m.message.contains("at0099")),
"expected an Unexpected violation for the foreign at-coded child, got {msgs:?}"
);
let ok = json!({"_type": "COMPOSITION", "archetype_node_id": "x", "content": [
{"_type": "SECTION", "archetype_node_id": "openEHR-EHR-SECTION.x.v1",
"name": {"_type": "DV_TEXT", "value": "s"}}]});
assert!(
walk_only(&ok, &root).is_empty(),
"the defined section is admitted"
);
}
#[test]
fn closed_world_ignores_metadata_values() {
let mut root = node("ELEMENT", "/items[at0001]");
root.closed_attributes = vec![WebTemplateClosedAttribute {
path: "/items[at0001]/value".to_owned(),
allowed_ids: vec!["at9999".to_owned()],
slots: vec![],
}];
let inst = json!({"_type": "ELEMENT", "archetype_node_id": "at0001",
"name": {"_type": "DV_TEXT", "value": "e"},
"value": {"_type": "DV_QUANTITY", "magnitude": 1.0, "units": "kg"}});
assert!(
walk_only(&inst, &root).is_empty(),
"a DATA_VALUE with no archetype_node_id must not be flagged by closure"
);
}
fn obs_slot(includes: &[&str], excludes: &[&str], min: i32, max: i32) -> WebTemplateNode {
let mut root = node("COMPOSITION", "");
root.closed_attributes = vec![WebTemplateClosedAttribute {
path: "/content".to_owned(),
allowed_ids: vec![],
slots: vec![WebTemplateArchetypeSlot {
rm_type: "OBSERVATION".to_owned(),
min,
max,
includes: includes.iter().map(|s| (*s).to_owned()).collect(),
excludes: excludes.iter().map(|s| (*s).to_owned()).collect(),
}],
}];
root
}
fn content_obs(archetype_id: &str, rm_type: &str) -> Value {
json!({"_type": "COMPOSITION", "archetype_node_id": "x", "content": [
{"_type": rm_type, "archetype_node_id": archetype_id,
"name": {"_type": "DV_TEXT", "value": "o"}}]})
}
#[test]
fn slot_admits_include_rejects_others() {
let root = obs_slot(&[r"openEHR-EHR-OBSERVATION\..*"], &[], 0, -1);
assert!(
walk_only(
&content_obs("openEHR-EHR-OBSERVATION.bp.v1", "OBSERVATION"),
&root
)
.is_empty(),
"an include-matching filler is admitted"
);
let msgs = walk_only(
&content_obs("openEHR-EHR-EVALUATION.x.v1", "EVALUATION"),
&root,
);
assert!(
msgs.iter().any(|m| m.kind == ValidationKind::Unexpected),
"a wrong-rm-type filler is rejected, got {msgs:?}"
);
}
#[test]
fn slot_exclude_rejects_matching_filler() {
let root = obs_slot(
&[r"openEHR-EHR-OBSERVATION\..*"],
&[r"openEHR-EHR-OBSERVATION\.secret\..*"],
0,
-1,
);
let msgs = walk_only(
&content_obs("openEHR-EHR-OBSERVATION.secret.v1", "OBSERVATION"),
&root,
);
assert!(
msgs.iter().any(|m| m.kind == ValidationKind::Unexpected),
"an exclude-matching filler is rejected, got {msgs:?}"
);
}
#[test]
fn slot_blanket_exclude_ignored_when_includes_present() {
let root = obs_slot(&[r"openEHR-EHR-OBSERVATION\.bp\.v1"], &[".*"], 0, -1);
assert!(
walk_only(
&content_obs("openEHR-EHR-OBSERVATION.bp.v1", "OBSERVATION"),
&root
)
.is_empty(),
"a specific include overrides a blanket `.*` exclude"
);
}
#[test]
fn slot_occurrences_min_and_max() {
let root = obs_slot(&[r"openEHR-EHR-OBSERVATION\..*"], &[], 1, 1);
let empty = json!({"_type": "COMPOSITION", "archetype_node_id": "x", "content": []});
assert!(
walk_only(&empty, &root)
.iter()
.any(|m| m.kind == ValidationKind::Required),
"an unfilled mandatory slot is Required"
);
let two = json!({"_type": "COMPOSITION", "archetype_node_id": "x", "content": [
{"_type": "OBSERVATION", "archetype_node_id": "openEHR-EHR-OBSERVATION.a.v1",
"name": {"_type": "DV_TEXT", "value": "a"}},
{"_type": "OBSERVATION", "archetype_node_id": "openEHR-EHR-OBSERVATION.b.v1",
"name": {"_type": "DV_TEXT", "value": "b"}}]});
assert!(
walk_only(&two, &root)
.iter()
.any(|m| m.kind == ValidationKind::Occurrences),
"too many slot fillers is Occurrences"
);
}
fn ordinal_node(rm: &str, scale: bool) -> WebTemplateNode {
let mut n = node(rm, "/value");
let mut input = WebTemplateInput::new(WebTemplateInputType::CodedText, None);
let mk = |code: &str, v: i32| {
let mut cv = WebTemplateCodedValue::new(code, None);
if scale {
cv.scale = Some(f64::from(v));
} else {
cv.ordinal = Some(v);
}
cv
};
input.list = vec![mk("at0014", 0), mk("at0015", 1)];
n.inputs = vec![input];
n
}
fn ordinal_value(rm: &str, v: &Value, code: &str) -> Value {
json!({"_type": rm, "value": v, "symbol": {"_type": "DV_CODED_TEXT",
"value": "s", "defining_code": {"_type": "CODE_PHRASE",
"terminology_id": {"_type": "TERMINOLOGY_ID", "value": "local"}, "code_string": code}}})
}
#[test]
fn ordinal_pair_must_match() {
let n = ordinal_node("DV_ORDINAL", false);
assert!(walk_only(&ordinal_value("DV_ORDINAL", &json!(0), "at0014"), &n).is_empty());
assert!(
kinds(&walk_only(
&ordinal_value("DV_ORDINAL", &json!(1), "at0014"),
&n
))
.contains(&ValidationKind::CodedValue),
"value 1 does not pair with symbol at0014"
);
assert!(
kinds(&walk_only(
&ordinal_value("DV_ORDINAL", &json!(0), "at0666"),
&n
))
.contains(&ValidationKind::CodedValue),
"symbol at0666 is off the list"
);
}
#[test]
fn scale_pair_must_match() {
let n = ordinal_node("DV_SCALE", true);
assert!(walk_only(&ordinal_value("DV_SCALE", &json!(0.0), "at0014"), &n).is_empty());
assert!(
kinds(&walk_only(
&ordinal_value("DV_SCALE", &json!(1.0), "at0014"),
&n
))
.contains(&ValidationKind::CodedValue),
"scale 1.0 does not pair with symbol at0014"
);
}
#[test]
fn c_string_backreference_is_enforced() {
let mut n = node("DV_TEXT", "/text");
let mut input = WebTemplateInput::new(WebTemplateInputType::Text, None);
input.validation = Some(WebTemplateValidation {
pattern: Some(r"(a)\1".to_owned()),
..Default::default()
});
n.inputs = vec![input];
assert!(
kinds(&walk_only(&json!({"_type": "DV_TEXT", "value": "ab"}), &n))
.contains(&ValidationKind::PatternError),
"`ab` must fail the backreference pattern rather than silently pass"
);
assert!(walk_only(&json!({"_type": "DV_TEXT", "value": "aa"}), &n).is_empty());
}
#[test]
fn timezone_validity_mandatory_and_disallowed() {
let mut n = node("DV_TIME", "/value");
n.inputs = vec![WebTemplateInput::new(WebTemplateInputType::Time, None)];
n.tz_validity = Some(1001);
assert!(
kinds(&walk_only(
&json!({"_type": "DV_TIME", "value": "10:30:00"}),
&n
))
.contains(&ValidationKind::PatternError),
"a missing mandatory timezone is rejected"
);
assert!(walk_only(&json!({"_type": "DV_TIME", "value": "10:30:00Z"}), &n).is_empty());
n.tz_validity = Some(1003);
assert!(
kinds(&walk_only(
&json!({"_type": "DV_TIME", "value": "10:30:00+01:00"}),
&n
))
.contains(&ValidationKind::PatternError),
"a present disallowed timezone is rejected"
);
assert!(walk_only(&json!({"_type": "DV_TIME", "value": "10:30:00"}), &n).is_empty());
}
fn name_diff_parent() -> WebTemplateNode {
let mut root = node("CLUSTER", "");
let sib_a = {
let mut n = node("CLUSTER", "/items[openEHR-EHR-CLUSTER.c.v1]");
n.name = Some("A".to_owned());
n.min = Some(0);
n.max = 1;
n.closed_attributes = vec![WebTemplateClosedAttribute {
path: "/items[openEHR-EHR-CLUSTER.c.v1]/items".to_owned(),
allowed_ids: vec!["at0004".to_owned()],
slots: vec![],
}];
n
};
let sib_b = {
let mut n = node("CLUSTER", "/items[openEHR-EHR-CLUSTER.c.v1,'B']");
n.name = Some("B".to_owned());
n.min = Some(0);
n.max = 1;
n.closed_attributes = vec![WebTemplateClosedAttribute {
path: "/items[openEHR-EHR-CLUSTER.c.v1,'B']/items".to_owned(),
allowed_ids: vec!["at0013".to_owned()],
slots: vec![],
}];
n
};
root.children = vec![sib_a, sib_b];
root
}
fn c_instance(name: &str, child_id: &str) -> Value {
json!({
"_type": "CLUSTER", "archetype_node_id": "openEHR-EHR-CLUSTER.c.v1",
"name": {"_type": "DV_TEXT", "value": name},
"items": [{"_type": "ELEMENT", "archetype_node_id": child_id,
"name": {"_type": "DV_TEXT", "value": "leaf"},
"value": {"_type": "DV_TEXT", "value": "v"}}]
})
}
fn unexpected_of(msgs: &[ValidationMessage]) -> Vec<&ValidationMessage> {
msgs.iter()
.filter(|m| m.kind == ValidationKind::Unexpected)
.collect()
}
#[test]
fn name_diff_siblings_route_each_instance_to_its_own_overlay() {
let root = name_diff_parent();
let inst = json!({
"_type": "CLUSTER", "archetype_node_id": "x",
"name": {"_type": "DV_TEXT", "value": "root"},
"items": [c_instance("A", "at0004"), c_instance("B", "at0013")]
});
let msgs = walk_only(&inst, &root);
assert!(
unexpected_of(&msgs).is_empty(),
"both name-differentiated siblings should validate against their own \
overlay, got {msgs:?}"
);
}
#[test]
fn name_qualified_siblings_child_in_unqualified_instance_is_unexpected() {
let root = name_diff_parent();
let inst = json!({
"_type": "CLUSTER", "archetype_node_id": "x",
"name": {"_type": "DV_TEXT", "value": "root"},
"items": [c_instance("A", "at0013")]
});
let msgs = walk_only(&inst, &root);
assert!(
unexpected_of(&msgs)
.iter()
.any(|m| m.message.contains("at0013")),
"at0013 in the unqualified sibling's instance must be Unexpected, got {msgs:?}"
);
}
#[test]
fn unqualified_sibling_admits_a_runtime_named_residual_instance() {
let root = name_diff_parent();
let ok = json!({
"_type": "CLUSTER", "archetype_node_id": "x",
"name": {"_type": "DV_TEXT", "value": "root"},
"items": [c_instance("other", "at0004")]
});
assert!(
unexpected_of(&walk_only(&ok, &root)).is_empty(),
"a residual-named instance must validate against the unqualified overlay"
);
let bad = json!({
"_type": "CLUSTER", "archetype_node_id": "x",
"name": {"_type": "DV_TEXT", "value": "root"},
"items": [c_instance("other", "at0013")]
});
assert!(
unexpected_of(&walk_only(&bad, &root))
.iter()
.any(|m| m.message.contains("at0013")),
"the residual instance is still closed-world checked against the \
unqualified overlay"
);
}
}