use crate::base::iso8601;
use crate::error::ParseError;
use crate::rm::common::{LocatableAttrs, impl_locatable};
use crate::rm::data_types::{DataValue, DvCodedText, DvDateTime, DvDuration, Text};
use crate::rm::rm_type_tag;
use crate::terminology;
use serde::{Deserialize, Serialize};
rm_type_tag!(HistoryTag, "HISTORY");
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Element {
#[serde(flatten)]
locatable: LocatableAttrs,
#[serde(skip_serializing_if = "Option::is_none", default)]
value: Option<Box<DataValue>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
null_flavour: Option<DvCodedText>,
#[serde(skip_serializing_if = "Option::is_none", default)]
null_reason: Option<Text>,
}
impl_locatable!(Element, "ELEMENT");
impl Element {
#[must_use]
pub fn new(locatable: LocatableAttrs, value: DataValue) -> Self {
Self {
locatable,
value: Some(Box::new(value)),
null_flavour: None,
null_reason: None,
}
}
pub fn new_null(
locatable: LocatableAttrs,
null_flavour_code: &str,
) -> Result<Self, ParseError> {
let null_flavour = terminology::null_flavour::GROUP
.coded_text(null_flavour_code)
.ok_or_else(|| ParseError::invariant("ELEMENT", "Inv_null_flavour_valid"))?;
Ok(Self {
locatable,
value: None,
null_flavour: Some(null_flavour),
null_reason: None,
})
}
pub fn with_null_reason(mut self, reason: Text) -> Result<Self, ParseError> {
if self.value.is_some() {
return Err(ParseError::invariant("ELEMENT", "Inv_null_reason_valid"));
}
self.null_reason = Some(reason);
Ok(self)
}
#[must_use]
pub fn value(&self) -> Option<&DataValue> {
self.value.as_deref()
}
#[must_use]
pub fn null_flavour(&self) -> Option<&DvCodedText> {
self.null_flavour.as_ref()
}
#[must_use]
pub fn null_reason(&self) -> Option<&Text> {
self.null_reason.as_ref()
}
#[must_use]
pub fn is_null(&self) -> bool {
self.value.is_none()
}
#[must_use]
pub fn null_flavour_code(&self) -> Option<&str> {
self.null_flavour
.as_ref()
.map(|f| f.defining_code().code_string())
}
#[must_use]
pub fn is_masked(&self) -> bool {
self.null_flavour_code() == Some(terminology::null_flavour::MASKED)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Cluster {
#[serde(flatten)]
locatable: LocatableAttrs,
items: Vec<Item>,
}
impl_locatable!(Cluster, "CLUSTER");
impl Cluster {
pub fn new(locatable: LocatableAttrs, items: Vec<Item>) -> Result<Self, ParseError> {
if items.is_empty() {
return Err(ParseError::invariant("CLUSTER", "Items_non_empty"));
}
Ok(Self { locatable, items })
}
#[must_use]
pub fn items(&self) -> &[Item] {
&self.items
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "_type")]
#[allow(clippy::large_enum_variant)]
pub enum Item {
#[serde(rename = "CLUSTER")]
Cluster(Cluster),
#[serde(rename = "ELEMENT")]
Element(Element),
}
impl Item {
#[must_use]
pub fn type_name(&self) -> &'static str {
match self {
Self::Cluster(_) => "CLUSTER",
Self::Element(_) => "ELEMENT",
}
}
#[must_use]
pub fn archetype_node_id(&self) -> &str {
use crate::rm::common::Locatable as _;
match self {
Self::Cluster(c) => c.archetype_node_id(),
Self::Element(e) => e.archetype_node_id(),
}
}
#[must_use]
pub fn name(&self) -> &Text {
use crate::rm::common::Locatable as _;
match self {
Self::Cluster(c) => c.name(),
Self::Element(e) => e.name(),
}
}
pub fn elements(&self) -> Box<dyn Iterator<Item = &Element> + '_> {
match self {
Self::Element(e) => Box::new(core::iter::once(e)),
Self::Cluster(c) => Box::new(c.items().iter().flat_map(Item::elements)),
}
}
}
impl From<Cluster> for Item {
fn from(v: Cluster) -> Self {
Self::Cluster(v)
}
}
impl From<Element> for Item {
fn from(v: Element) -> Self {
Self::Element(v)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ItemSingle {
#[serde(flatten)]
locatable: LocatableAttrs,
item: Element,
}
impl_locatable!(ItemSingle, "ITEM_SINGLE");
impl ItemSingle {
#[must_use]
pub fn new(locatable: LocatableAttrs, item: Element) -> Self {
Self { locatable, item }
}
#[must_use]
pub fn item(&self) -> &Element {
&self.item
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ItemList {
#[serde(flatten)]
locatable: LocatableAttrs,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
items: Vec<Element>,
}
impl_locatable!(ItemList, "ITEM_LIST");
impl ItemList {
#[must_use]
pub fn new(locatable: LocatableAttrs, items: Vec<Element>) -> Self {
Self { locatable, items }
}
#[must_use]
pub fn items(&self) -> &[Element] {
&self.items
}
#[must_use]
pub fn item_count(&self) -> usize {
self.items.len()
}
#[must_use]
pub fn named_item(&self, name: &str) -> Option<&Element> {
self.items.iter().find(|e| {
use crate::rm::common::Locatable as _;
e.name().value() == name
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ItemTable {
#[serde(flatten)]
locatable: LocatableAttrs,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
rows: Vec<Cluster>,
}
impl_locatable!(ItemTable, "ITEM_TABLE");
impl ItemTable {
#[must_use]
pub fn new(locatable: LocatableAttrs, rows: Vec<Cluster>) -> Self {
Self { locatable, rows }
}
#[must_use]
pub fn rows(&self) -> &[Cluster] {
&self.rows
}
#[must_use]
pub fn row_count(&self) -> usize {
self.rows.len()
}
#[must_use]
pub fn column_count(&self) -> usize {
self.rows.first().map_or(0, |r| r.items().len())
}
#[must_use]
pub fn is_regular(&self) -> bool {
let width = self.column_count();
self.rows.iter().all(|r| r.items().len() == width)
}
#[must_use]
pub fn element_at_cell(&self, row: usize, column: usize) -> Option<&Element> {
match self.rows.get(row)?.items().get(column)? {
Item::Element(e) => Some(e),
Item::Cluster(_) => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ItemTree {
#[serde(flatten)]
locatable: LocatableAttrs,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
items: Vec<Item>,
}
impl_locatable!(ItemTree, "ITEM_TREE");
impl ItemTree {
#[must_use]
pub fn new(locatable: LocatableAttrs, items: Vec<Item>) -> Self {
Self { locatable, items }
}
#[must_use]
pub fn items(&self) -> &[Item] {
&self.items
}
pub fn elements(&self) -> impl Iterator<Item = &Element> {
self.items.iter().flat_map(Item::elements)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "_type")]
#[allow(clippy::large_enum_variant)]
pub enum ItemStructure {
#[serde(rename = "ITEM_SINGLE")]
Single(ItemSingle),
#[serde(rename = "ITEM_LIST")]
List(ItemList),
#[serde(rename = "ITEM_TABLE")]
Table(ItemTable),
#[serde(rename = "ITEM_TREE")]
Tree(ItemTree),
}
impl ItemStructure {
#[must_use]
pub fn type_name(&self) -> &'static str {
match self {
Self::Single(_) => "ITEM_SINGLE",
Self::List(_) => "ITEM_LIST",
Self::Table(_) => "ITEM_TABLE",
Self::Tree(_) => "ITEM_TREE",
}
}
#[must_use]
pub fn locatable(&self) -> &LocatableAttrs {
use crate::rm::common::Locatable as _;
match self {
Self::Single(s) => s.locatable(),
Self::List(s) => s.locatable(),
Self::Table(s) => s.locatable(),
Self::Tree(s) => s.locatable(),
}
}
#[must_use]
pub fn elements(&self) -> Box<dyn Iterator<Item = &Element> + '_> {
match self {
Self::Single(s) => Box::new(core::iter::once(s.item())),
Self::List(s) => Box::new(s.items().iter()),
Self::Table(s) => Box::new(
s.rows()
.iter()
.flat_map(|r| r.items().iter().flat_map(Item::elements)),
),
Self::Tree(s) => Box::new(s.elements()),
}
}
}
impl From<ItemSingle> for ItemStructure {
fn from(v: ItemSingle) -> Self {
Self::Single(v)
}
}
impl From<ItemList> for ItemStructure {
fn from(v: ItemList) -> Self {
Self::List(v)
}
}
impl From<ItemTable> for ItemStructure {
fn from(v: ItemTable) -> Self {
Self::Table(v)
}
}
impl From<ItemTree> for ItemStructure {
fn from(v: ItemTree) -> Self {
Self::Tree(v)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PointEvent {
#[serde(flatten)]
locatable: LocatableAttrs,
time: DvDateTime,
#[serde(skip_serializing_if = "Option::is_none", default)]
state: Option<Box<ItemStructure>>,
data: ItemStructure,
}
impl_locatable!(PointEvent, "POINT_EVENT");
impl PointEvent {
#[must_use]
pub fn new(locatable: LocatableAttrs, time: DvDateTime, data: ItemStructure) -> Self {
Self {
locatable,
time,
state: None,
data,
}
}
#[must_use]
pub fn with_state(mut self, state: ItemStructure) -> Self {
self.state = Some(Box::new(state));
self
}
#[must_use]
pub fn time(&self) -> &DvDateTime {
&self.time
}
#[must_use]
pub fn data(&self) -> &ItemStructure {
&self.data
}
#[must_use]
pub fn state(&self) -> Option<&ItemStructure> {
self.state.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntervalEvent {
#[serde(flatten)]
locatable: LocatableAttrs,
time: DvDateTime,
#[serde(skip_serializing_if = "Option::is_none", default)]
state: Option<Box<ItemStructure>>,
data: ItemStructure,
width: DvDuration,
#[serde(skip_serializing_if = "Option::is_none", default)]
sample_count: Option<i64>,
math_function: DvCodedText,
}
impl_locatable!(IntervalEvent, "INTERVAL_EVENT");
impl IntervalEvent {
pub fn new(
locatable: LocatableAttrs,
time: DvDateTime,
data: ItemStructure,
width: DvDuration,
math_function_code: &str,
) -> Result<Self, ParseError> {
if width.value().is_negative() {
return Err(ParseError::invariant(
"INTERVAL_EVENT",
"Width_non_negative",
));
}
let math_function = terminology::event_math_function::GROUP
.coded_text(math_function_code)
.ok_or_else(|| ParseError::invariant("INTERVAL_EVENT", "Math_function_validity"))?;
Ok(Self {
locatable,
time,
state: None,
data,
width,
sample_count: None,
math_function,
})
}
#[must_use]
pub fn with_sample_count(mut self, count: i64) -> Self {
self.sample_count = Some(count);
self
}
#[must_use]
pub fn time(&self) -> &DvDateTime {
&self.time
}
#[must_use]
pub fn width(&self) -> &DvDuration {
&self.width
}
#[must_use]
pub fn math_function(&self) -> &DvCodedText {
&self.math_function
}
#[must_use]
pub fn data(&self) -> &ItemStructure {
&self.data
}
#[must_use]
pub fn state(&self) -> Option<&ItemStructure> {
self.state.as_deref()
}
#[must_use]
pub fn sample_count(&self) -> Option<i64> {
self.sample_count
}
pub fn interval_start_time(&self) -> Result<DvDateTime, crate::Error> {
let width = self.width.value();
if width.years() > 0 || width.months() > 0 {
return Err(crate::Error::Unsupported {
what: "INTERVAL_EVENT.interval_start_time with a calendar-month or -year width",
spec_ref: "spec/04-data-structures.md R4.9",
});
}
let Some(time) = self.time.value().time() else {
return Err(crate::Error::Unsupported {
what: "INTERVAL_EVENT.interval_start_time on a date with no time of day",
spec_ref: "spec/04-data-structures.md R4.9",
});
};
let seconds = width.approx_seconds();
let start = subtract_seconds(self.time.value().date(), time, seconds)?;
DvDateTime::new(&start).map_err(Into::into)
}
}
fn subtract_seconds(
date: &iso8601::Date,
time: &iso8601::Time,
seconds: f64,
) -> Result<String, crate::Error> {
let (Some(month), Some(day)) = (date.month(), date.day()) else {
return Err(crate::Error::Unsupported {
what: "arithmetic on a date without a day",
spec_ref: "spec/04-data-structures.md R4.9",
});
};
let total = i64::from(time.hour()) * 3600
+ i64::from(time.minute().unwrap_or(0)) * 60
+ i64::from(time.second().unwrap_or(0));
#[allow(clippy::cast_possible_truncation)]
let mut remaining = total - seconds.round() as i64;
let mut y = date.year();
let mut m = month;
let mut d = day;
while remaining < 0 {
remaining += 86_400;
d -= 1;
if d == 0 {
m -= 1;
if m == 0 {
m = 12;
y -= 1;
}
d = iso8601::days_in_month(y, m);
}
}
let offset = time.offset().map(|o| o.to_string()).unwrap_or_default();
Ok(format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}{offset}",
remaining / 3600,
(remaining % 3600) / 60,
remaining % 60
))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "_type")]
#[allow(clippy::large_enum_variant)]
pub enum Event {
#[serde(rename = "POINT_EVENT")]
Point(PointEvent),
#[serde(rename = "INTERVAL_EVENT")]
Interval(IntervalEvent),
}
impl Event {
#[must_use]
pub fn type_name(&self) -> &'static str {
match self {
Self::Point(_) => "POINT_EVENT",
Self::Interval(_) => "INTERVAL_EVENT",
}
}
#[must_use]
pub fn time(&self) -> &DvDateTime {
match self {
Self::Point(e) => e.time(),
Self::Interval(e) => e.time(),
}
}
#[must_use]
pub fn data(&self) -> &ItemStructure {
match self {
Self::Point(e) => e.data(),
Self::Interval(e) => e.data(),
}
}
#[must_use]
pub fn state(&self) -> Option<&ItemStructure> {
match self {
Self::Point(e) => e.state(),
Self::Interval(e) => e.state(),
}
}
#[must_use]
pub fn locatable(&self) -> &LocatableAttrs {
use crate::rm::common::Locatable as _;
match self {
Self::Point(e) => e.locatable(),
Self::Interval(e) => e.locatable(),
}
}
}
impl From<PointEvent> for Event {
fn from(v: PointEvent) -> Self {
Self::Point(v)
}
}
impl From<IntervalEvent> for Event {
fn from(v: IntervalEvent) -> Self {
Self::Interval(v)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct History {
#[serde(rename = "_type", default)]
rm_type: HistoryTag,
#[serde(flatten)]
locatable: LocatableAttrs,
origin: DvDateTime,
#[serde(skip_serializing_if = "Option::is_none", default)]
period: Option<DvDuration>,
#[serde(skip_serializing_if = "Option::is_none", default)]
duration: Option<DvDuration>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
events: Vec<Event>,
#[serde(skip_serializing_if = "Option::is_none", default)]
summary: Option<Box<ItemStructure>>,
}
impl_locatable!(History, "HISTORY");
impl History {
pub fn new(
locatable: LocatableAttrs,
origin: DvDateTime,
events: Vec<Event>,
summary: Option<ItemStructure>,
) -> Result<Self, ParseError> {
if events.is_empty() && summary.is_none() {
return Err(ParseError::invariant("HISTORY", "Events_valid"));
}
Ok(Self {
rm_type: HistoryTag,
locatable,
origin,
period: None,
duration: None,
events,
summary: summary.map(Box::new),
})
}
pub fn with_period(mut self, period: DvDuration) -> Result<Self, ParseError> {
if period.value().is_negative() || period.value().is_zero() {
return Err(ParseError::invariant("HISTORY", "Periodic_validity"));
}
self.period = Some(period);
Ok(self)
}
#[must_use]
pub fn with_duration(mut self, duration: DvDuration) -> Self {
self.duration = Some(duration);
self
}
#[must_use]
pub fn origin(&self) -> &DvDateTime {
&self.origin
}
#[must_use]
pub fn events(&self) -> &[Event] {
&self.events
}
#[must_use]
pub fn summary(&self) -> Option<&ItemStructure> {
self.summary.as_deref()
}
#[must_use]
pub fn period(&self) -> Option<&DvDuration> {
self.period.as_ref()
}
#[must_use]
pub fn duration(&self) -> Option<&DvDuration> {
self.duration.as_ref()
}
#[must_use]
pub fn offset_seconds(&self, event: &Event) -> Option<i64> {
event.time().value().diff_seconds(self.origin.value())
}
#[must_use]
pub fn is_period_consistent(&self) -> Option<bool> {
let period = self.period.as_ref()?.value().approx_seconds();
let p = self.period.as_ref()?.value();
if p.years() > 0 || p.months() > 0 {
return None;
}
#[allow(clippy::cast_possible_truncation)]
let period = period.round() as i64;
if period == 0 {
return None;
}
let mut all = true;
for event in &self.events {
let offset = self.offset_seconds(event)?;
if offset % period != 0 {
all = false;
}
}
Some(all)
}
#[must_use]
pub fn is_periodic(&self) -> bool {
self.period.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rm::data_types::{DvCount, DvQuantity};
fn attrs(name: &str, node: &str) -> LocatableAttrs {
LocatableAttrs::named(name, node).unwrap()
}
fn count_element(name: &str, n: i64) -> Element {
Element::new(attrs(name, "at0001"), DataValue::Count(DvCount::new(n)))
}
#[test]
fn an_element_is_valued_or_null_and_never_both() {
let valued = count_element("x", 1);
assert!(!valued.is_null());
assert!(valued.null_flavour().is_none());
let null =
Element::new_null(attrs("x", "at0001"), terminology::null_flavour::UNKNOWN).unwrap();
assert!(null.is_null());
assert!(null.value().is_none());
assert!(
valued
.with_null_reason(Text::plain("because").unwrap())
.is_err()
);
}
#[test]
fn the_four_null_flavours_stay_four() {
let masked =
Element::new_null(attrs("x", "at0001"), terminology::null_flavour::MASKED).unwrap();
let unknown =
Element::new_null(attrs("x", "at0001"), terminology::null_flavour::UNKNOWN).unwrap();
assert!(masked.is_masked());
assert!(!unknown.is_masked());
assert_ne!(masked.null_flavour_code(), unknown.null_flavour_code());
assert!(Element::new_null(attrs("x", "at0001"), "999").is_err());
}
#[test]
fn interval_start_time_is_derived_where_it_can_be_and_refused_where_it_cannot() {
let data = ItemSingle::new(attrs("d", "at0002"), count_element("v", 1)).into();
let event = IntervalEvent::new(
attrs("8 hour output", "at0006"),
DvDateTime::new("2026-07-31T08:00:00Z").unwrap(),
data,
DvDuration::new("PT8H").unwrap(),
terminology::event_math_function::TOTAL,
)
.unwrap();
assert_eq!(
event.interval_start_time().unwrap().as_str(),
"2026-07-31T00:00:00Z"
);
let data2 = ItemSingle::new(attrs("d", "at0002"), count_element("v", 1)).into();
let calendar = IntervalEvent::new(
attrs("monthly total", "at0006"),
DvDateTime::new("2026-03-31T08:00:00Z").unwrap(),
data2,
DvDuration::new("P1M").unwrap(),
terminology::event_math_function::TOTAL,
)
.unwrap();
assert!(calendar.interval_start_time().is_err());
}
#[test]
fn interval_start_time_crosses_a_day_boundary_correctly() {
let data = ItemSingle::new(attrs("d", "at0002"), count_element("v", 1)).into();
let event = IntervalEvent::new(
attrs("overnight", "at0006"),
DvDateTime::new("2026-03-01T06:00:00Z").unwrap(),
data,
DvDuration::new("PT12H").unwrap(),
terminology::event_math_function::MEAN,
)
.unwrap();
assert_eq!(
event.interval_start_time().unwrap().as_str(),
"2026-02-28T18:00:00Z"
);
}
#[test]
fn a_history_needs_events_or_a_summary() {
assert!(
History::new(
attrs("h", "at0001"),
DvDateTime::new("2026-07-31T09:00:00Z").unwrap(),
Vec::new(),
None,
)
.is_err()
);
}
#[test]
fn element_traversal_reaches_every_leaf_of_every_structure() {
let tree = ItemTree::new(
attrs("t", "at0001"),
vec![
count_element("a", 1).into(),
Cluster::new(attrs("c", "at0002"), vec![count_element("b", 2).into()])
.unwrap()
.into(),
],
);
assert_eq!(ItemStructure::Tree(tree).elements().count(), 2);
let table = ItemTable::new(
attrs("t", "at0001"),
vec![
Cluster::new(attrs("r", "at0002"), vec![count_element("a", 1).into()]).unwrap(),
Cluster::new(attrs("r", "at0002"), vec![count_element("b", 2).into()]).unwrap(),
],
);
assert_eq!(ItemStructure::Table(table).elements().count(), 2);
}
#[test]
fn structures_round_trip_with_their_type_tags() {
let s = ItemStructure::Single(ItemSingle::new(
attrs("s", "at0001"),
Element::new(
attrs("q", "at0002"),
DataValue::Quantity(DvQuantity::new(1.0, "mg").unwrap()),
),
));
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains(r#""_type":"ITEM_SINGLE""#), "{json}");
let back: ItemStructure = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
#[test]
fn an_interval_event_reports_when_its_window_opened() {
let event = |time: &str, width: &str| {
IntervalEvent::new(
attrs("summary", "at0100"),
DvDateTime::new(time).unwrap(),
ItemTree::new(attrs("data", "at0101"), Vec::new()).into(),
crate::rm::data_types::DvDuration::new(width).unwrap(),
terminology::event_math_function::MEAN,
)
.unwrap()
};
let start = |time: &str, width: &str| {
event(time, width)
.interval_start_time()
.unwrap_or_else(|e| panic!("{time} - {width}: {e}"))
.as_str()
.to_owned()
};
assert_eq!(start("2026-08-03T12:34:56Z", "PT1H"), "2026-08-03T11:34:56Z");
assert_eq!(start("2026-08-03T12:34:56Z", "PT30S"), "2026-08-03T12:34:26Z");
assert_eq!(start("2026-08-03T00:00:30Z", "PT1M"), "2026-08-02T23:59:30Z");
assert_eq!(start("2026-08-03T12:00:00Z", "PT1H"), "2026-08-03T11:00:00Z");
assert_eq!(start("2026-08-03T12:00:00Z", "PT30M"), "2026-08-03T11:30:00Z");
assert_eq!(start("2026-08-03T12:00:00Z", "PT45S"), "2026-08-03T11:59:15Z");
assert_eq!(start("2026-08-03T12:00:00Z", "PT0S"), "2026-08-03T12:00:00Z");
assert_eq!(start("2026-08-03T00:30:00Z", "PT1H"), "2026-08-02T23:30:00Z");
assert_eq!(start("2026-08-03T12:00:00Z", "P1D"), "2026-08-02T12:00:00Z");
assert_eq!(start("2026-08-03T12:00:00Z", "P5D"), "2026-07-29T12:00:00Z");
assert_eq!(start("2026-08-01T12:00:00Z", "P1D"), "2026-07-31T12:00:00Z");
assert_eq!(start("2026-03-01T12:00:00Z", "P1D"), "2026-02-28T12:00:00Z");
assert_eq!(start("2024-03-01T12:00:00Z", "P1D"), "2024-02-29T12:00:00Z");
assert_eq!(start("1900-03-01T12:00:00Z", "P1D"), "1900-02-28T12:00:00Z");
assert_eq!(start("2000-03-01T12:00:00Z", "P1D"), "2000-02-29T12:00:00Z");
assert_eq!(start("2026-01-01T00:30:00Z", "PT1H"), "2025-12-31T23:30:00Z");
assert_eq!(
start("2026-08-03T00:30:00+02:00", "PT1H"),
"2026-08-02T23:30:00+02:00"
);
assert!(event("2026-03-31T12:00:00Z", "P1M").interval_start_time().is_err());
assert!(event("2026-03-31T12:00:00Z", "P1Y").interval_start_time().is_err());
let dateless = IntervalEvent::new(
attrs("summary", "at0100"),
DvDateTime::new("2026-08-03").unwrap(),
ItemTree::new(attrs("data", "at0101"), Vec::new()).into(),
crate::rm::data_types::DvDuration::new("PT1H").unwrap(),
terminology::event_math_function::MEAN,
)
.unwrap();
assert!(dateless.interval_start_time().is_err());
assert!(
IntervalEvent::new(
attrs("summary", "at0100"),
DvDateTime::new("2026-08-03T12:00:00Z").unwrap(),
ItemTree::new(attrs("data", "at0101"), Vec::new()).into(),
crate::rm::data_types::DvDuration::new("-PT1H").unwrap(),
terminology::event_math_function::MEAN,
)
.is_err()
);
let plain = event("2026-08-03T12:00:00Z", "PT1H");
assert_eq!(plain.sample_count(), None);
assert_eq!(plain.state(), None);
let summarised = event("2026-08-03T12:00:00Z", "PT1H").with_sample_count(12);
assert_eq!(summarised.sample_count(), Some(12));
let json = serde_json::to_value(&summarised).expect("serialize");
let mut with_state = json.as_object().expect("an object").clone();
with_state.insert(
"state".to_owned(),
serde_json::to_value(ItemStructure::from(ItemTree::new(
attrs("state", "at0102"),
Vec::new(),
)))
.expect("serialize"),
);
let revived: IntervalEvent =
serde_json::from_value(serde_json::Value::Object(with_state)).expect("deserialize");
assert!(revived.state().is_some(), "a recorded state was dropped");
assert_eq!(revived.sample_count(), Some(12));
}
#[test]
fn the_accessors_on_an_item_and_a_list_report_what_was_built() {
use crate::rm::common::Locatable as _;
let absent = Element::new_null(attrs("bp", "at0004"), "253").unwrap();
assert!(absent.is_null());
assert_eq!(absent.null_reason(), None);
let excused = Element::new_null(attrs("bp", "at0004"), "253")
.unwrap()
.with_null_reason(Text::Plain(
crate::rm::data_types::DvText::new("cuff too small").unwrap(),
))
.unwrap();
assert_eq!(
excused.null_reason().map(Text::value),
Some("cuff too small")
);
assert!(
count_element("x", 1)
.with_null_reason(Text::Plain(
crate::rm::data_types::DvText::new("why").unwrap()
))
.is_err()
);
let element_item = Item::Element(count_element("systolic", 120));
let cluster_item = Item::Cluster(
Cluster::new(attrs("row", "at0500"), vec![Item::Element(count_element("cell", 1))])
.unwrap(),
);
assert_eq!(element_item.type_name(), "ELEMENT");
assert_eq!(cluster_item.type_name(), "CLUSTER");
assert_ne!(element_item.type_name(), cluster_item.type_name());
assert_eq!(element_item.archetype_node_id(), "at0001");
assert_eq!(cluster_item.archetype_node_id(), "at0500");
let list = ItemList::new(
attrs("readings", "at0600"),
vec![
count_element("systolic", 120),
count_element("diastolic", 80),
count_element("pulse", 72),
],
);
assert_eq!(list.item_count(), 3, "the list reported the wrong size");
assert_eq!(list.items().len(), list.item_count());
assert_eq!(ItemList::new(attrs("empty", "at0601"), Vec::new()).item_count(), 0);
let found = list.named_item("diastolic").expect("diastolic is in the list");
assert_eq!(found.name().value(), "diastolic");
assert_eq!(list.named_item("pulse").map(|e| e.name().value()), Some("pulse"));
assert!(list.named_item("nonesuch").is_none());
}
#[test]
fn a_history_reports_its_duration_and_whether_its_period_holds() {
let event = |offset_minutes: u32| -> Event {
PointEvent::new(
attrs("sample", "at0700"),
DvDateTime::new(&format!("2026-08-03T09:{offset_minutes:02}:00Z")).unwrap(),
ItemTree::new(attrs("data", "at0701"), Vec::new()).into(),
)
.into()
};
let history = |events: Vec<Event>| {
History::new(
attrs("series", "at0702"),
DvDateTime::new("2026-08-03T09:00:00Z").unwrap(),
events,
None,
)
.unwrap()
};
let plain = history(vec![event(0)]);
assert_eq!(plain.duration(), None);
let timed = history(vec![event(0)])
.with_duration(crate::rm::data_types::DvDuration::new("PT30M").unwrap());
assert_eq!(timed.duration().map(|d| d.value().as_str()), Some("PT30M"));
assert!(!plain.is_periodic());
assert_eq!(plain.is_period_consistent(), None);
let consistent = history(vec![event(0), event(5), event(10)])
.with_period(crate::rm::data_types::DvDuration::new("PT5M").unwrap())
.unwrap();
assert!(consistent.is_periodic());
assert_eq!(consistent.is_period_consistent(), Some(true));
let inconsistent = history(vec![event(0), event(5), event(7)])
.with_period(crate::rm::data_types::DvDuration::new("PT5M").unwrap())
.unwrap();
assert_eq!(
inconsistent.is_period_consistent(),
Some(false),
"a series off its declared period was reported consistent"
);
for calendar in ["P1M", "P1Y"] {
let h = history(vec![event(0), event(5)])
.with_period(crate::rm::data_types::DvDuration::new(calendar).unwrap())
.unwrap();
assert!(h.is_periodic());
assert_eq!(
h.is_period_consistent(),
None,
"{calendar} was treated as a fixed number of seconds"
);
}
}
}