use crate::error::{ValidationReport, Violation};
use crate::rm::common::{Attestation, Locatable as _, Participation, PartyProxy};
use crate::rm::data_structures::{Cluster, Element, Event, History, Item, ItemStructure};
use crate::rm::data_types::{DataValue, DvCodedText, DvOrdered, DvQuantity, OrderedAttrs, Text};
use crate::rm::ehr::{Composition, ContentItem, EhrStatus, Entry, EventContext, Section};
#[derive(Debug, Default)]
pub struct Context {
path: Vec<String>,
report: ValidationReport,
}
impl Context {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn violation(
&mut self,
class: &'static str,
invariant: &'static str,
detail: &'static str,
) {
self.report.push(Violation {
path: self.path.join(""),
class,
invariant,
detail,
});
}
fn nested<F: FnOnce(&mut Self)>(&mut self, segment: String, f: F) {
self.path.push(segment);
f(self);
self.path.pop();
}
#[must_use]
pub fn finish(self) -> ValidationReport {
self.report
}
}
pub trait Validate {
fn visit(&self, ctx: &mut Context);
fn validate(&self) -> ValidationReport {
let mut ctx = Context::new();
self.visit(&mut ctx);
ctx.finish()
}
fn validate_ok(&self) -> Result<(), ValidationReport> {
self.validate().into_result()
}
}
fn check_locatable<L: crate::rm::common::Locatable>(node: &L, ctx: &mut Context) {
if node.archetype_node_id().is_empty() {
ctx.violation(
"LOCATABLE",
"Archetype_node_id_valid",
"archetype_node_id is empty, so the node cannot be reached by any path",
);
}
if node.name().value().is_empty() {
ctx.nested("/name".to_owned(), |c| {
c.violation("DV_TEXT", "Valid_value", "value is empty");
});
}
if let Some(details) = node.archetype_details() {
let entity = details.archetype_id().rm_entity();
if entity != node.rm_type_name() {
ctx.violation(
"ARCHETYPED",
"Archetype_id_rm_entity_matches",
"archetype id constrains a different RM class than the node it annotates",
);
}
if details.rm_version().is_empty() {
ctx.violation("ARCHETYPED", "Rm_version_valid", "rm_version is empty");
}
}
}
fn check_coded_text(coded: &DvCodedText, ctx: &mut Context) {
if coded.check_openehr_rubric() == Some(false) {
ctx.violation(
"DV_CODED_TEXT",
"Value_is_rubric",
"value does not match the openEHR rubric for defining_code",
);
}
}
fn check_ordered(attrs: &OrderedAttrs, is_abnormal: Option<bool>, ctx: &mut Context) {
let Some(status) = attrs.normal_status() else {
return;
};
if !crate::terminology::normal_status::GROUP.contains(status.code_string()) {
ctx.violation(
"DV_ORDERED",
"Normal_status_validity",
"normal_status is not from the openEHR normal-statuses code set",
);
}
if let Some(abnormal) = is_abnormal {
let says_normal = status.code_string() == crate::terminology::normal_status::NORMAL;
if says_normal == abnormal {
ctx.violation(
"DV_ORDERED",
"Normal_range_and_status_consistency",
"normal_status and the value's position in its normal_range disagree",
);
}
}
}
fn check_optional_group(
text: &Text,
group: crate::terminology::Group,
class: &'static str,
invariant: &'static str,
ctx: &mut Context,
) {
let Text::Coded(coded) = text else {
return;
};
check_coded_text(coded, ctx);
if coded.defining_code().is_openehr() && !group.contains(coded.defining_code().code_string()) {
ctx.violation(
class,
invariant,
"coded value is not from its openEHR group",
);
}
}
fn check_participation(participation: &Participation, ctx: &mut Context) {
check_optional_group(
participation.function(),
crate::terminology::participation_function::GROUP,
"PARTICIPATION",
"Function_valid",
ctx,
);
if let Some(mode) = participation.mode() {
check_coded_text(mode, ctx);
if mode.defining_code().is_openehr()
&& !crate::terminology::participation_mode::GROUP
.contains(mode.defining_code().code_string())
{
ctx.violation(
"PARTICIPATION",
"Mode_valid",
"mode is not from the openEHR participation-mode group",
);
}
}
check_party(
participation.performer(),
"PARTICIPATION",
"Performer_valid",
ctx,
);
}
pub fn check_attestation(attestation: &Attestation, ctx: &mut Context) {
check_optional_group(
attestation.reason(),
crate::terminology::attestation_reason::GROUP,
"ATTESTATION",
"Reason_valid",
ctx,
);
check_party(
attestation.audit().committer(),
"AUDIT_DETAILS",
"Committer_valid",
ctx,
);
}
impl Validate for DataValue {
fn visit(&self, ctx: &mut Context) {
match self {
Self::CodedText(t) => check_coded_text(t, ctx),
Self::Ordinal(o) => {
check_coded_text(o.symbol(), ctx);
check_ordered(o.ordered_attrs(), o.is_abnormal(), ctx);
}
Self::Scale(s) => {
check_coded_text(s.symbol(), ctx);
check_ordered(s.ordered_attrs(), s.is_abnormal(), ctx);
}
Self::Count(c) => check_ordered(c.ordered_attrs(), c.is_abnormal(), ctx),
Self::Quantity(q) => {
if !q.magnitude().is_finite() {
ctx.violation(
"DV_QUANTITY",
"Magnitude_finite",
"magnitude is not a finite number",
);
}
if q.units().is_empty() {
ctx.violation("DV_QUANTITY", "Units_valid", "units is empty");
}
if q.precision()
.is_some_and(|p| p < DvQuantity::UNLIMITED_PRECISION)
{
ctx.violation(
"DV_QUANTITY",
"Precision_valid",
"precision is below -1, the unlimited-precision sentinel",
);
}
check_ordered(q.ordered_attrs(), q.is_abnormal(), ctx);
}
Self::Proportion(p) => {
if p.denominator() == 0.0 {
ctx.violation("DV_PROPORTION", "Valid_denominator", "denominator is zero");
}
if p.kind().requires_integral() && !p.is_integral() {
ctx.violation(
"DV_PROPORTION",
"Fraction_validity",
"a fractional kind has a non-integral numerator or denominator",
);
}
if p.precision() == Some(0) && !p.is_integral() {
ctx.violation(
"DV_PROPORTION",
"Precision_validity",
"precision is 0 but a part has a fractional component",
);
}
check_ordered(p.ordered_attrs(), p.is_abnormal(), ctx);
}
Self::Multimedia(m) => {
if !m.has_content() {
ctx.violation(
"DV_MULTIMEDIA",
"Not_empty",
"neither inline data nor a uri is present",
);
}
if m.verify_integrity() == crate::rm::data_types::IntegrityCheck::Failed {
ctx.violation(
"DV_MULTIMEDIA",
"Integrity_check_validity",
"the recorded digest does not match the inline data",
);
}
}
Self::Text(t) if t.value().is_empty() => {
ctx.violation("DV_TEXT", "Valid_value", "value is empty");
}
_ => {}
}
}
}
impl Validate for Element {
fn visit(&self, ctx: &mut Context) {
check_locatable(self, ctx);
match (self.value().is_some(), self.null_flavour().is_some()) {
(true, true) => ctx.violation(
"ELEMENT",
"Inv_null_flavour_indicated",
"an element has both a value and a null_flavour",
),
(false, false) => ctx.violation(
"ELEMENT",
"Inv_null_flavour_indicated",
"an element has neither a value nor a null_flavour",
),
_ => {}
}
if self.null_reason().is_some() && self.value().is_some() {
ctx.violation(
"ELEMENT",
"Inv_null_reason_valid",
"a reason for absence is recorded on an element that has a value",
);
}
if let Some(flavour) = self.null_flavour() {
if !flavour.defining_code().is_openehr()
|| !crate::terminology::null_flavour::GROUP
.contains(flavour.defining_code().code_string())
{
ctx.violation(
"ELEMENT",
"Inv_null_flavour_valid",
"null_flavour is not one of the four openEHR null flavours",
);
}
check_coded_text(flavour, ctx);
}
if let Some(value) = self.value() {
ctx.nested("/value".to_owned(), |c| value.visit(c));
}
}
}
impl Validate for Cluster {
fn visit(&self, ctx: &mut Context) {
check_locatable(self, ctx);
if self.items().is_empty() {
ctx.violation("CLUSTER", "Items_non_empty", "a cluster has no items");
}
for (i, item) in self.items().iter().enumerate() {
ctx.nested(format!("/items[{i}]"), |c| item.visit(c));
}
}
}
impl Validate for Item {
fn visit(&self, ctx: &mut Context) {
match self {
Self::Cluster(v) => v.visit(ctx),
Self::Element(v) => v.visit(ctx),
}
}
}
impl Validate for ItemStructure {
fn visit(&self, ctx: &mut Context) {
match self {
Self::Single(s) => {
check_locatable(s, ctx);
ctx.nested("/item".to_owned(), |c| s.item().visit(c));
}
Self::List(s) => {
check_locatable(s, ctx);
for (i, e) in s.items().iter().enumerate() {
ctx.nested(format!("/items[{i}]"), |c| e.visit(c));
}
}
Self::Table(s) => {
check_locatable(s, ctx);
if !s.is_regular() {
ctx.violation(
"ITEM_TABLE",
"Rows_regular",
"rows do not all have the same number of columns",
);
}
for (i, r) in s.rows().iter().enumerate() {
ctx.nested(format!("/rows[{i}]"), |c| r.visit(c));
}
}
Self::Tree(s) => {
check_locatable(s, ctx);
for (i, item) in s.items().iter().enumerate() {
ctx.nested(format!("/items[{i}]"), |c| item.visit(c));
}
}
}
}
}
impl Validate for Event {
fn visit(&self, ctx: &mut Context) {
match self {
Self::Point(e) => {
check_locatable(e, ctx);
ctx.nested("/data".to_owned(), |c| e.data().visit(c));
if let Some(state) = e.state() {
ctx.nested("/state".to_owned(), |c| state.visit(c));
}
}
Self::Interval(e) => {
check_locatable(e, ctx);
if e.width().value().is_negative() {
ctx.violation(
"INTERVAL_EVENT",
"Width_non_negative",
"width is negative, so the interval ends before it starts",
);
}
check_coded_text(e.math_function(), ctx);
ctx.nested("/data".to_owned(), |c| e.data().visit(c));
if let Some(state) = e.state() {
ctx.nested("/state".to_owned(), |c| state.visit(c));
}
}
}
}
}
impl Validate for History {
fn visit(&self, ctx: &mut Context) {
check_locatable(self, ctx);
if self.events().is_empty() && self.summary().is_none() {
ctx.violation(
"HISTORY",
"Events_valid",
"a history has neither events nor a summary",
);
}
if self
.period()
.is_some_and(|p| p.value().is_zero() || p.value().is_negative())
{
ctx.violation(
"HISTORY",
"Periodic_validity",
"a periodic history declares a zero or negative period",
);
}
if self.is_period_consistent() == Some(false) {
ctx.violation(
"HISTORY",
"Period_consistency",
"a periodic history has an event whose offset is not a multiple of the period",
);
}
for (i, event) in self.events().iter().enumerate() {
if matches!(
event.time().partial_cmp(self.origin()),
Some(core::cmp::Ordering::Less)
) {
ctx.nested(format!("/events[{i}]"), |c| {
c.violation(
"EVENT",
"Time_after_origin",
"event time is before the history origin",
);
});
}
ctx.nested(format!("/events[{i}]"), |c| event.visit(c));
}
if let Some(summary) = self.summary() {
ctx.nested("/summary".to_owned(), |c| summary.visit(c));
}
}
}
impl Validate for EventContext {
fn visit(&self, ctx: &mut Context) {
check_coded_text(self.setting(), ctx);
if !self.setting().defining_code().is_openehr()
|| !crate::terminology::setting::GROUP
.contains(self.setting().defining_code().code_string())
{
ctx.violation(
"EVENT_CONTEXT",
"Setting_valid",
"setting is not from the openEHR setting group",
);
}
if let Some(end) = self.end_time()
&& matches!(
end.partial_cmp(self.start_time()),
Some(core::cmp::Ordering::Less)
)
{
ctx.violation(
"EVENT_CONTEXT",
"End_time_valid",
"end_time is before start_time",
);
}
if self.location().is_some_and(str::is_empty) {
ctx.violation(
"EVENT_CONTEXT",
"location_valid",
"location is present and empty",
);
}
for (i, participation) in self.participations().iter().enumerate() {
ctx.nested(format!("/participations[{i}]"), |c| {
check_participation(participation, c);
});
}
if let Some(other) = self.other_context() {
ctx.nested("/other_context".to_owned(), |c| other.visit(c));
}
}
}
impl Validate for Entry {
fn visit(&self, ctx: &mut Context) {
check_locatable_entry(self, ctx);
check_party(self.subject(), "ENTRY", "Subject_valid", ctx);
if let Some(provider) = self.entry_attrs().provider() {
check_party(provider, "ENTRY", "Provider_valid", ctx);
}
for (i, participation) in self.entry_attrs().other_participations().iter().enumerate() {
ctx.nested(format!("/other_participations[{i}]"), |c| {
check_participation(participation, c);
});
}
match self {
Self::Observation(o) => {
ctx.nested("/data".to_owned(), |c| o.data().visit(c));
if let Some(state) = o.state() {
ctx.nested("/state".to_owned(), |c| state.visit(c));
}
if let Some(protocol) = o.care_entry().protocol() {
ctx.nested("/protocol".to_owned(), |c| protocol.visit(c));
}
}
Self::Evaluation(e) => ctx.nested("/data".to_owned(), |c| e.data().visit(c)),
Self::AdminEntry(a) => ctx.nested("/data".to_owned(), |c| a.data().visit(c)),
Self::Instruction(i) => {
if i.activities().is_empty() {
ctx.violation(
"INSTRUCTION",
"Activities_valid",
"an instruction has no activities",
);
}
if i.narrative().value().is_empty() {
ctx.violation(
"INSTRUCTION",
"Narrative_valid",
"an instruction has an empty narrative",
);
}
for (n, activity) in i.activities().iter().enumerate() {
ctx.nested(format!("/activities[{n}]/description"), |c| {
activity.description().visit(c);
});
}
}
Self::Action(a) => {
check_coded_text(a.ism_transition().current_state(), ctx);
if !crate::terminology::instruction_state::GROUP.contains(
a.ism_transition()
.current_state()
.defining_code()
.code_string(),
) {
ctx.violation(
"ISM_TRANSITION",
"Current_state_valid",
"current_state is not an Instruction State Machine state",
);
}
ctx.nested("/description".to_owned(), |c| a.description().visit(c));
}
}
}
}
fn check_locatable_entry(entry: &Entry, ctx: &mut Context) {
match entry {
Entry::Observation(e) => check_locatable(e, ctx),
Entry::Evaluation(e) => check_locatable(e, ctx),
Entry::Instruction(e) => check_locatable(e, ctx),
Entry::Action(e) => check_locatable(e, ctx),
Entry::AdminEntry(e) => check_locatable(e, ctx),
}
}
fn check_party(
party: &PartyProxy,
class: &'static str,
invariant: &'static str,
ctx: &mut Context,
) {
let identified = match party {
PartyProxy::SelfParty(_) => return,
PartyProxy::Identified(p) => p,
PartyProxy::Related(p) => {
check_coded_text(p.relationship(), ctx);
let code = p.relationship().defining_code();
if code.is_openehr()
&& !crate::terminology::subject_relationship::GROUP.contains(code.code_string())
{
ctx.violation(
"PARTY_RELATED",
"Relationship_valid",
"relationship is not from the openEHR subject-relationship group",
);
}
p.as_identified()
}
};
if identified.name().is_none()
&& identified.identifiers().is_empty()
&& identified.external_ref().is_none()
{
ctx.violation(
class,
invariant,
"an identified party has no name, no identifiers, and no external reference",
);
}
}
impl Validate for Section {
fn visit(&self, ctx: &mut Context) {
check_locatable(self, ctx);
for (i, item) in self.items().iter().enumerate() {
ctx.nested(format!("/items[{i}]"), |c| item.visit(c));
}
}
}
impl Validate for ContentItem {
fn visit(&self, ctx: &mut Context) {
match self {
Self::Section(s) => s.visit(ctx),
Self::Entry(e) => e.visit(ctx),
}
}
}
impl Validate for EhrStatus {
fn visit(&self, ctx: &mut Context) {
check_locatable(self, ctx);
if !self.is_archetype_root() {
ctx.violation(
"EHR_STATUS",
"Is_archetype_root",
"an EHR_STATUS has no archetype_details, so it is not an archetype root",
);
}
if let Some(other) = self.other_details() {
ctx.nested("/other_details".to_owned(), |c| other.visit(c));
}
}
}
impl Validate for Composition {
fn visit(&self, ctx: &mut Context) {
check_locatable(self, ctx);
check_coded_text(self.category(), ctx);
if !crate::terminology::composition_category::GROUP.contains(self.category_code()) {
ctx.violation(
"COMPOSITION",
"Category_validity",
"category is not from the openEHR composition_category group",
);
}
check_party(self.composer(), "COMPOSITION", "Composer_valid", ctx);
if !self.is_archetype_root() {
ctx.violation(
"COMPOSITION",
"Is_archetype_root",
"a composition has no archetype_details, so it is not an archetype root",
);
}
if self.is_persistent() && self.context().is_some() {
ctx.violation(
"COMPOSITION",
"Is_persistent_validity",
"a persistent composition carries an event context",
);
}
if let Some(context) = self.context() {
ctx.nested("/context".to_owned(), |c| context.visit(c));
}
for (i, item) in self.content().iter().enumerate() {
ctx.nested(format!("/content[{i}]"), |c| item.visit(c));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::base::Interval;
use crate::rm::common::{Archetyped, LocatableAttrs, PartyIdentified};
use crate::rm::data_structures::{ItemSingle, PointEvent};
use crate::rm::data_types::{CodePhrase, DvCount, DvDateTime, DvQuantity, DvText};
use crate::rm::ehr::{EntryAttrs, Evaluation, Observation};
use crate::terminology;
fn attrs(name: &str, node: &str) -> LocatableAttrs {
LocatableAttrs::named(name, node).unwrap()
}
fn structure() -> ItemStructure {
ItemSingle::new(
attrs("d", "at0001"),
Element::new(attrs("v", "at0002"), DataValue::Count(DvCount::new(1))),
)
.into()
}
fn entry_attrs() -> EntryAttrs {
EntryAttrs::about_subject(
CodePhrase::new("ISO_639-1", "en").unwrap(),
CodePhrase::new("IANA_character-sets", "UTF-8").unwrap(),
)
}
fn composition() -> Composition {
Composition::new(
attrs("Encounter", "openEHR-EHR-COMPOSITION.encounter.v1").with_archetype_details(
Archetyped::new("openEHR-EHR-COMPOSITION.encounter.v1", "1.1.0").unwrap(),
),
terminology::composition_category::EVENT,
PartyIdentified::named("Dr A Nurse").unwrap().into(),
CodePhrase::new("ISO_639-1", "en").unwrap(),
CodePhrase::new("ISO_3166-1", "GB").unwrap(),
)
.unwrap()
}
#[test]
fn a_well_formed_composition_has_no_violations() {
let c = composition().with_content(
Evaluation::new(attrs("Problem", "at0000"), entry_attrs(), structure()).into(),
);
let report = c.validate();
assert!(report.is_empty(), "{report}");
}
#[test]
fn deserialization_bypasses_constructors_and_validation_catches_it() {
let json = r#"{
"name": {"value": "Systolic"},
"archetype_node_id": "at0004",
"value": {"_type": "DV_COUNT", "magnitude": 1},
"null_flavour": {"value": "unknown", "defining_code":
{"terminology_id": {"value": "openehr"}, "code_string": "253"}}
}"#;
let element: Element = serde_json::from_str(json).unwrap();
let report = element.validate();
assert_eq!(report.len(), 1);
assert_eq!(report.violations()[0].invariant, "Inv_null_flavour_indicated");
}
#[test]
fn an_element_with_nothing_at_all_is_also_a_violation() {
let json = r#"{"name": {"value": "Systolic"}, "archetype_node_id": "at0004"}"#;
let element: Element = serde_json::from_str(json).unwrap();
assert_eq!(element.validate().len(), 1);
}
#[test]
fn an_archetype_id_on_the_wrong_class_is_reported() {
let wrong = Evaluation::new(
attrs("Problem", "openEHR-EHR-OBSERVATION.blood_pressure.v2").with_archetype_details(
Archetyped::new("openEHR-EHR-OBSERVATION.blood_pressure.v2", "1.1.0").unwrap(),
),
entry_attrs(),
structure(),
);
let report = Entry::from(wrong).validate();
assert_eq!(report.len(), 1);
assert_eq!(
report.violations()[0].invariant,
"Archetype_id_rm_entity_matches"
);
}
#[test]
fn a_coded_text_that_contradicts_its_own_code_is_reported() {
let json = r#"{
"name": {"value": "Category"},
"archetype_node_id": "at0001",
"value": {"_type": "DV_CODED_TEXT", "value": "deletion",
"defining_code": {"terminology_id": {"value": "openehr"}, "code_string": "249"}}
}"#;
let element: Element = serde_json::from_str(json).unwrap();
let report = element.validate();
assert_eq!(report.len(), 1);
assert_eq!(report.violations()[0].invariant, "Value_is_rubric");
assert_eq!(report.violations()[0].path, "/value");
}
#[test]
fn violation_paths_locate_the_node_and_never_the_value() {
let marker = "ZZ-DISTINCTIVE-9999";
let bad_element: Element = serde_json::from_str(&format!(
r#"{{"name": {{"value": "{marker}"}}, "archetype_node_id": "at0004"}}"#
))
.unwrap();
let structure: ItemStructure = ItemSingle::new(attrs("d", "at0001"), bad_element).into();
let report = structure.validate();
assert_eq!(report.violations()[0].path, "/item");
assert!(!report.to_string().contains(marker), "{report}");
}
#[test]
fn an_event_before_its_history_origin_is_reported() {
let event = PointEvent::new(
attrs("early", "at0006"),
DvDateTime::new("2026-07-31T08:00:00Z").unwrap(),
structure(),
);
let history = History::new(
attrs("Event Series", "at0001"),
DvDateTime::new("2026-07-31T09:00:00Z").unwrap(),
vec![event.into()],
None,
)
.unwrap();
let observation = Observation::new(attrs("Obs", "at0000"), entry_attrs(), history);
let report = Entry::from(observation).validate();
assert_eq!(report.len(), 1);
assert_eq!(report.violations()[0].invariant, "Time_after_origin");
assert_eq!(report.violations()[0].path, "/data/events[0]");
}
#[test]
fn every_violation_is_reported_not_just_the_first() {
let json = r#"{
"name": {"value": ""},
"archetype_node_id": "",
"value": {"_type": "DV_QUANTITY", "magnitude": 1.0, "units": "", "precision": -3}
}"#;
let element: Element = serde_json::from_str(json).unwrap();
let report = element.validate();
assert_eq!(report.len(), 4, "{report}");
}
#[test]
fn a_normal_flag_beside_an_abnormal_number_is_reported() {
let range = Interval::closed(
DataValue::Quantity(DvQuantity::new(3.5, "mmol/l").unwrap()),
DataValue::Quantity(DvQuantity::new(5.5, "mmol/l").unwrap()),
)
.unwrap();
let lying = DvQuantity::new(9.9, "mmol/l")
.unwrap()
.with_normal_range(range.clone())
.with_normal_status(CodePhrase::openehr(terminology::normal_status::NORMAL).unwrap());
let report = Element::new(attrs("K", "at0004"), DataValue::Quantity(lying)).validate();
assert_eq!(report.len(), 1, "{report}");
assert_eq!(
report.violations()[0].invariant,
"Normal_range_and_status_consistency"
);
let truthful = DvQuantity::new(9.9, "mmol/l")
.unwrap()
.with_normal_range(range.clone())
.with_normal_status(
CodePhrase::openehr(terminology::normal_status::VERY_HIGH).unwrap(),
);
assert!(
Element::new(attrs("K", "at0004"), DataValue::Quantity(truthful))
.validate()
.is_empty()
);
let in_range = DvQuantity::new(4.2, "mmol/l")
.unwrap()
.with_normal_range(range)
.with_normal_status(CodePhrase::openehr(terminology::normal_status::NORMAL).unwrap());
assert!(
Element::new(attrs("K", "at0004"), DataValue::Quantity(in_range))
.validate()
.is_empty()
);
}
#[test]
fn a_normal_status_outside_the_code_set_is_reported() {
let odd = DvQuantity::new(4.2, "mmol/l")
.unwrap()
.with_normal_status(CodePhrase::openehr("VERY-HIGH-INDEED").unwrap());
let report = Element::new(attrs("K", "at0004"), DataValue::Quantity(odd)).validate();
assert_eq!(report.len(), 1, "{report}");
assert_eq!(report.violations()[0].invariant, "Normal_status_validity");
}
#[test]
fn the_unlimited_precision_sentinel_validates() {
let unlimited = DvQuantity::new(1.5, "mg")
.unwrap()
.with_precision(-1)
.unwrap();
assert!(
Element::new(attrs("q", "at0004"), DataValue::Quantity(unlimited))
.validate()
.is_empty()
);
}
#[test]
fn a_valid_quantity_with_a_normal_range_still_validates() {
let q = DvQuantity::new(4.2, "mmol/l").unwrap();
let element = Element::new(attrs("Glucose", "at0004"), DataValue::Quantity(q));
assert!(element.validate().is_empty());
let _ = DvText::new("unused");
}
}