From d595c1ce3ce55ed00c3720299b7f99b2c8640a93 Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 22:50:05 +0800 Subject: [PATCH] sim: add output trace recording --- Cargo.lock | 3 + crates/sim/apsis-output/Cargo.toml | 1 + crates/sim/apsis-output/src/lib.rs | 44 ++++ crates/sim/apsis-output/src/record.rs | 102 +++++++++ crates/sim/apsis-output/src/recorder.rs | 108 +++++++++ crates/sim/apsis-output/src/trace.rs | 226 +++++++++++++++++++ crates/sim/apsis-output/tests/output.rs | 279 ++++++++++++++++++++++++ 7 files changed, 763 insertions(+) create mode 100644 crates/sim/apsis-output/src/record.rs create mode 100644 crates/sim/apsis-output/src/recorder.rs create mode 100644 crates/sim/apsis-output/src/trace.rs create mode 100644 crates/sim/apsis-output/tests/output.rs diff --git a/Cargo.lock b/Cargo.lock index ff92e58..f7fb324 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,6 +198,9 @@ dependencies = [ [[package]] name = "apsis-output" version = "0.1.0" +dependencies = [ + "apsis-core", +] [[package]] name = "apsis-resource" diff --git a/crates/sim/apsis-output/Cargo.toml b/crates/sim/apsis-output/Cargo.toml index 053fff8..1cf888a 100644 --- a/crates/sim/apsis-output/Cargo.toml +++ b/crates/sim/apsis-output/Cargo.toml @@ -11,3 +11,4 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true diff --git a/crates/sim/apsis-output/src/lib.rs b/crates/sim/apsis-output/src/lib.rs index 55de17b..0de4b67 100644 --- a/crates/sim/apsis-output/src/lib.rs +++ b/crates/sim/apsis-output/src/lib.rs @@ -1 +1,45 @@ //! Simulation output recording. +//! +//! A simulation produces more output than a caller can act on at once: event +//! times, the values a model computes as it runs, and the warnings it raises. +//! This crate collects those into one trace, so that a run can be examined +//! after it finishes rather than only as it happens. +//! +//! A record has a time, a kind, a label, and a value. The time is the +//! simulation instant the record was made at, which is what makes a trace +//! orderable and joinable against the event log. The kind separates the three +//! things a caller usually wants to tell apart: an observation the model made, +//! a state change it decided on, and a warning it wants to surface. The label +//! names what the value is about, and the value is a single number, which +//! covers the great majority of what a simulation records without imposing a +//! schema the caller has to design. +//! +//! The trace keeps the records in the order they were made, not in time order. +//! That distinction matters: a handler may record a value for an event it is +//! handling after recording one for a follow-up it just scheduled, and the +//! order the model reasoned in is worth keeping. A caller that wants time +//! order asks for it with [`Trace::in_time_order`]. +//! +//! ``` +//! use apsis_output::{Recorder, RecordKind}; +//! +//! let mut recorder = Recorder::new(); +//! recorder.observe(10.0, "altitude", 550.0); +//! recorder.note(10.0, "eclipse", 1.0); +//! +//! assert_eq!(recorder.len(), 2); +//! assert!(recorder.trace().is_stable()); +//! assert_eq!(recorder.trace().value_of("altitude"), Some(550.0)); +//! let _ = RecordKind::Warning; +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod record; +mod recorder; +mod trace; + +pub use record::{Record, RecordKind}; +pub use recorder::Recorder; +pub use trace::{SeriesSummary, Trace}; diff --git a/crates/sim/apsis-output/src/record.rs b/crates/sim/apsis-output/src/record.rs new file mode 100644 index 0000000..2745f09 --- /dev/null +++ b/crates/sim/apsis-output/src/record.rs @@ -0,0 +1,102 @@ +//! One recorded value. + +use core::fmt; + +/// What a record is about. +/// +/// The three kinds separate the things a caller usually wants to tell apart +/// when it reads a trace back: a value the model measured, a decision it +/// made, and a problem it wants to surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum RecordKind { + /// A value the model measured, such as a range or a rate. + Observation, + /// A change the model decided on, such as entering eclipse. + State, + /// A condition worth surfacing, such as a value outside its bounds. + Warning, +} + +impl RecordKind { + /// Returns a short name for the kind. + pub const fn name(self) -> &'static str { + match self { + Self::Observation => "observation", + Self::State => "state", + Self::Warning => "warning", + } + } + + /// Returns `true` when the kind is a warning. + pub const fn is_warning(self) -> bool { + matches!(self, Self::Warning) + } +} + +impl fmt::Display for RecordKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// One recorded value. +/// +/// A record is small and copied by value, so a trace holds a flat list rather +/// than a tree of references. +#[derive(Debug, Clone, PartialEq)] +pub struct Record { + /// The simulation time the record was made at, in seconds. + pub time: f64, + /// What the record is about. + pub kind: RecordKind, + /// What the value is about. + pub label: String, + /// The value recorded. + pub value: f64, +} + +impl Record { + /// Creates a record. + pub fn new(time: f64, kind: RecordKind, label: impl Into, value: f64) -> Self { + Self { + time, + kind, + label: label.into(), + value, + } + } + + /// Creates an observation record. + pub fn observation(time: f64, label: impl Into, value: f64) -> Self { + Self::new(time, RecordKind::Observation, label, value) + } + + /// Creates a state record. + pub fn state(time: f64, label: impl Into, value: f64) -> Self { + Self::new(time, RecordKind::State, label, value) + } + + /// Creates a warning record. + pub fn warning(time: f64, label: impl Into, value: f64) -> Self { + Self::new(time, RecordKind::Warning, label, value) + } + + /// Returns `true` when the record is usable. + /// + /// A record whose time or value is not finite has no place in a trace, + /// because it cannot be ordered or summarized. + pub fn is_valid(&self) -> bool { + self.time.is_finite() && self.value.is_finite() && !self.label.is_empty() + } +} + +impl fmt::Display for Record { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} {} {} = {}", + self.time, self.kind, self.label, self.value + ) + } +} diff --git a/crates/sim/apsis-output/src/recorder.rs b/crates/sim/apsis-output/src/recorder.rs new file mode 100644 index 0000000..b8ad37e --- /dev/null +++ b/crates/sim/apsis-output/src/recorder.rs @@ -0,0 +1,108 @@ +//! Building a trace as a simulation runs. + +use crate::record::{Record, RecordKind}; +use crate::trace::Trace; + +/// Collects records as a simulation runs. +/// +/// The recorder is the write side of a trace: a handler calls one of its +/// methods for each value it wants to keep, and asks for the trace when the +/// run finishes. The two are separate so that a handler can hold a recorder +/// while the trace is read elsewhere. +/// +/// ``` +/// use apsis_output::Recorder; +/// +/// let mut recorder = Recorder::new(); +/// recorder.observe(0.0, "range", 1_000.0); +/// recorder.observe(10.0, "range", 950.0); +/// +/// let summary = recorder.trace().summarize("range").expect("the series exists"); +/// assert_eq!(summary.count, 2); +/// assert_eq!(summary.change, -50.0); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Recorder { + trace: Trace, + refused: u64, +} + +impl Recorder { + /// Creates an empty recorder. + pub const fn new() -> Self { + Self { + trace: Trace::new(), + refused: 0, + } + } + + /// Records a value of a given kind. + /// + /// Returns `false` when the record is not usable, which a caller can + /// ignore when the value is known to be finite. + pub fn record( + &mut self, + time: f64, + kind: RecordKind, + label: impl Into, + value: f64, + ) -> bool { + let accepted = self.trace.push(Record::new(time, kind, label, value)); + if !accepted { + self.refused = self.refused.wrapping_add(1); + } + accepted + } + + /// Records an observation. + pub fn observe(&mut self, time: f64, label: impl Into, value: f64) -> bool { + self.record(time, RecordKind::Observation, label, value) + } + + /// Records a state change. + pub fn note(&mut self, time: f64, label: impl Into, value: f64) -> bool { + self.record(time, RecordKind::State, label, value) + } + + /// Records a warning. + pub fn warn(&mut self, time: f64, label: impl Into, value: f64) -> bool { + self.record(time, RecordKind::Warning, label, value) + } + + /// Returns the number of records made. + pub fn len(&self) -> usize { + self.trace.len() + } + + /// Returns `true` when no records have been made. + pub fn is_empty(&self) -> bool { + self.trace.is_empty() + } + + /// Returns the number of records refused because they were not usable. + /// + /// A caller that records values from a model checks this to see whether + /// any value was dropped, rather than comparing counts by hand. + pub const fn refused(&self) -> u64 { + self.refused + } + + /// Returns the trace recorded so far. + pub const fn trace(&self) -> &Trace { + &self.trace + } + + /// Returns the trace, leaving an empty one behind. + /// + /// This is what a caller does between runs: the finished trace goes to + /// whoever reads it, and the recorder starts fresh. The refused count is + /// not reset, because it records the whole life of the recorder. + pub fn take_trace(&mut self) -> Trace { + core::mem::take(&mut self.trace) + } + + /// Removes every record without changing the refused count. + pub fn clear(&mut self) -> usize { + self.trace.clear() + } +} diff --git a/crates/sim/apsis-output/src/trace.rs b/crates/sim/apsis-output/src/trace.rs new file mode 100644 index 0000000..47b67d3 --- /dev/null +++ b/crates/sim/apsis-output/src/trace.rs @@ -0,0 +1,226 @@ +//! The trace and its summaries. + +use crate::record::{Record, RecordKind}; +use std::collections::BTreeMap; + +/// A list of records in the order they were made. +/// +/// The trace preserves insertion order rather than sorting by time, because +/// the order a model recorded in is part of what it decided. A caller that +/// needs time order asks for [`Trace::in_time_order`], which leaves the +/// original alone. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Trace { + records: Vec, +} + +impl Trace { + /// Creates an empty trace. + pub const fn new() -> Self { + Self { + records: Vec::new(), + } + } + + /// Creates a trace from a list of records. + pub fn from_records(records: Vec) -> Self { + Self { records } + } + + /// Returns the number of records. + pub fn len(&self) -> usize { + self.records.len() + } + + /// Returns `true` when there are no records. + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } + + /// Returns the records in the order they were made. + pub fn records(&self) -> &[Record] { + &self.records + } + + /// Adds a record, returning `false` when it is not usable. + /// + /// A record whose time or value is not finite is refused rather than + /// stored, so a trace never holds a value that cannot be summarized. + pub fn push(&mut self, record: Record) -> bool { + if !record.is_valid() { + return false; + } + self.records.push(record); + true + } + + /// Returns `true` when no record has a time or value that is not finite. + pub fn is_stable(&self) -> bool { + self.records.iter().all(Record::is_valid) + } + + /// Returns the records in time order, earliest first. + /// + /// Records at the same instant keep their insertion order, so the sort is + /// stable and the result does not depend on the sort algorithm. + pub fn in_time_order(&self) -> Vec { + let mut ordered = self.records.clone(); + ordered.sort_by(|left, right| { + left.time + .partial_cmp(&right.time) + .unwrap_or(core::cmp::Ordering::Equal) + }); + ordered + } + + /// Returns the records of one kind, in order. + pub fn of_kind(&self, kind: RecordKind) -> Vec<&Record> { + self.records + .iter() + .filter(|record| record.kind == kind) + .collect() + } + + /// Returns the records with a label, in order. + pub fn with_label(&self, label: &str) -> Vec<&Record> { + self.records + .iter() + .filter(|record| record.label == label) + .collect() + } + + /// Returns the value of the last record with a label. + /// + /// This is what a caller asks for after a run: the final value a series + /// reached. Returns `None` when no record has the label. + pub fn value_of(&self, label: &str) -> Option { + self.records + .iter() + .rev() + .find(|record| record.label == label) + .map(|record| record.value) + } + + /// Returns the time of the last record with a label. + pub fn last_time_of(&self, label: &str) -> Option { + self.records + .iter() + .rev() + .find(|record| record.label == label) + .map(|record| record.time) + } + + /// Returns the labels present, in sorted order. + /// + /// The order is sorted rather than first seen, so that two traces with + /// the same labels list them the same way. + pub fn labels(&self) -> Vec<&str> { + let mut labels: Vec<&str> = self + .records + .iter() + .map(|record| record.label.as_str()) + .collect(); + labels.sort_unstable(); + labels.dedup(); + labels + } + + /// Returns a summary of the series recorded under a label. + /// + /// Returns `None` when no record has the label. + pub fn summarize(&self, label: &str) -> Option { + let values: Vec = self + .records + .iter() + .filter(|record| record.label == label) + .map(|record| record.value) + .collect(); + if values.is_empty() { + return None; + } + let count = values.len(); + let sum: f64 = values.iter().sum(); + let min = values.iter().copied().fold(f64::INFINITY, f64::min); + let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let first = values[0]; + let last = values[count - 1]; + Some(SeriesSummary { + label: label.to_string(), + count, + min, + max, + mean: sum / count as f64, + first, + last, + change: last - first, + }) + } + + /// Returns the number of warnings in the trace. + pub fn warning_count(&self) -> usize { + self.records + .iter() + .filter(|record| record.kind.is_warning()) + .count() + } + + /// Returns a summary of every label, in sorted label order. + pub fn summaries(&self) -> Vec { + self.labels() + .into_iter() + .filter_map(|label| self.summarize(label)) + .collect() + } + + /// Returns the greatest number of records that share a label. + /// + /// This is the length of the longest series, which a caller uses to size + /// a table of the trace. + pub fn longest_series(&self) -> usize { + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + for record in &self.records { + *counts.entry(record.label.as_str()).or_insert(0) += 1; + } + counts.values().copied().max().unwrap_or(0) + } + + /// Removes every record and returns how many were discarded. + pub fn clear(&mut self) -> usize { + let count = self.records.len(); + self.records.clear(); + count + } +} + +/// A summary of one series of records. +#[derive(Debug, Clone, PartialEq)] +pub struct SeriesSummary { + /// The label the series was recorded under. + pub label: String, + /// The number of records in the series. + pub count: usize, + /// The smallest value recorded. + pub min: f64, + /// The greatest value recorded. + pub max: f64, + /// The mean of the values. + pub mean: f64, + /// The first value recorded. + pub first: f64, + /// The last value recorded. + pub last: f64, + /// The difference between the last and first values. + pub change: f64, +} + +impl SeriesSummary { + /// Returns the spread between the greatest and smallest values. + pub fn range(&self) -> f64 { + self.max - self.min + } + + /// Returns `true` when the series never changed. + pub fn is_constant(&self) -> bool { + self.max == self.min + } +} diff --git a/crates/sim/apsis-output/tests/output.rs b/crates/sim/apsis-output/tests/output.rs new file mode 100644 index 0000000..839a598 --- /dev/null +++ b/crates/sim/apsis-output/tests/output.rs @@ -0,0 +1,279 @@ +//! Tests for the output trace and recorder. + +use apsis_output::{Record, RecordKind, Recorder, Trace}; + +#[test] +fn an_empty_trace_has_nothing() { + let trace = Trace::new(); + assert!(trace.is_empty()); + assert_eq!(trace.len(), 0); + assert!(trace.is_stable()); + assert!(trace.records().is_empty()); + assert_eq!(trace.labels(), Vec::<&str>::new()); + assert_eq!(trace.value_of("anything"), None); + assert_eq!(trace.summarize("anything"), None); + assert_eq!(trace.warning_count(), 0); + assert_eq!(trace.longest_series(), 0); +} + +#[test] +fn a_trace_keeps_insertion_order() { + // The order a model recorded in is part of what it decided, so the trace + // does not sort. + let mut trace = Trace::new(); + trace.push(Record::observation(10.0, "b", 1.0)); + trace.push(Record::observation(5.0, "a", 2.0)); + let labels: Vec<&str> = trace + .records() + .iter() + .map(|record| record.label.as_str()) + .collect(); + assert_eq!(labels, vec!["b", "a"]); +} + +#[test] +fn time_order_is_available_on_request() { + let mut trace = Trace::new(); + trace.push(Record::observation(10.0, "b", 1.0)); + trace.push(Record::observation(5.0, "a", 2.0)); + trace.push(Record::observation(7.0, "c", 3.0)); + let ordered = trace.in_time_order(); + let times: Vec = ordered.iter().map(|record| record.time).collect(); + assert_eq!(times, vec![5.0, 7.0, 10.0]); + // The original is unchanged. + assert_eq!(trace.records()[0].time, 10.0); +} + +#[test] +fn time_order_is_stable_for_equal_times() { + let mut trace = Trace::new(); + trace.push(Record::observation(5.0, "first", 1.0)); + trace.push(Record::observation(5.0, "second", 2.0)); + trace.push(Record::observation(5.0, "third", 3.0)); + let ordered = trace.in_time_order(); + let labels: Vec<&str> = ordered.iter().map(|record| record.label.as_str()).collect(); + assert_eq!(labels, vec!["first", "second", "third"]); +} + +#[test] +fn records_can_be_filtered_by_kind() { + let mut trace = Trace::new(); + trace.push(Record::observation(1.0, "a", 1.0)); + trace.push(Record::warning(2.0, "b", 2.0)); + trace.push(Record::state(3.0, "c", 3.0)); + trace.push(Record::warning(4.0, "d", 4.0)); + + assert_eq!(trace.of_kind(RecordKind::Observation).len(), 1); + assert_eq!(trace.of_kind(RecordKind::Warning).len(), 2); + assert_eq!(trace.of_kind(RecordKind::State).len(), 1); + assert_eq!(trace.warning_count(), 2); +} + +#[test] +fn records_can_be_filtered_by_label() { + let mut trace = Trace::new(); + trace.push(Record::observation(1.0, "range", 1_000.0)); + trace.push(Record::observation(2.0, "range", 900.0)); + trace.push(Record::observation(3.0, "rate", 5.0)); + assert_eq!(trace.with_label("range").len(), 2); + assert_eq!(trace.with_label("rate").len(), 1); + assert_eq!(trace.with_label("absent").len(), 0); +} + +#[test] +fn the_last_value_of_a_label_is_reported() { + let mut trace = Trace::new(); + trace.push(Record::observation(1.0, "range", 1_000.0)); + trace.push(Record::observation(5.0, "range", 900.0)); + trace.push(Record::observation(9.0, "range", 950.0)); + assert_eq!(trace.value_of("range"), Some(950.0)); + assert_eq!(trace.last_time_of("range"), Some(9.0)); + assert_eq!(trace.value_of("absent"), None); +} + +#[test] +fn the_labels_are_unique_and_sorted() { + let mut trace = Trace::new(); + trace.push(Record::observation(1.0, "zeta", 1.0)); + trace.push(Record::observation(2.0, "alpha", 2.0)); + trace.push(Record::observation(3.0, "zeta", 3.0)); + assert_eq!(trace.labels(), vec!["alpha", "zeta"]); +} + +#[test] +fn a_series_summarizes_its_values() { + let mut trace = Trace::new(); + for (time, value) in [(0.0, 10.0), (1.0, 20.0), (2.0, 30.0), (3.0, 5.0)] { + trace.push(Record::observation(time, "x", value)); + } + let summary = trace.summarize("x").expect("the series exists"); + assert_eq!(summary.label, "x"); + assert_eq!(summary.count, 4); + assert_eq!(summary.min, 5.0); + assert_eq!(summary.max, 30.0); + assert_eq!(summary.first, 10.0); + assert_eq!(summary.last, 5.0); + assert_eq!(summary.change, -5.0); + assert_eq!(summary.mean, 65.0 / 4.0); + assert_eq!(summary.range(), 25.0); + assert!(!summary.is_constant()); +} + +#[test] +fn a_constant_series_is_recognized() { + let mut trace = Trace::new(); + for time in 0..3 { + trace.push(Record::observation(f64::from(time), "x", 7.0)); + } + let summary = trace.summarize("x").expect("the series exists"); + assert!(summary.is_constant()); + assert_eq!(summary.range(), 0.0); + assert_eq!(summary.change, 0.0); +} + +#[test] +fn the_summaries_cover_every_label_in_order() { + let mut trace = Trace::new(); + trace.push(Record::observation(1.0, "b", 1.0)); + trace.push(Record::observation(1.0, "a", 2.0)); + trace.push(Record::observation(2.0, "b", 3.0)); + let summaries = trace.summaries(); + assert_eq!(summaries.len(), 2); + assert_eq!(summaries[0].label, "a"); + assert_eq!(summaries[1].label, "b"); + assert_eq!(summaries[1].count, 2); +} + +#[test] +fn the_longest_series_is_reported() { + let mut trace = Trace::new(); + for index in 0..5 { + trace.push(Record::observation(f64::from(index), "x", 1.0)); + } + for index in 0..2 { + trace.push(Record::observation(f64::from(index), "y", 1.0)); + } + assert_eq!(trace.longest_series(), 5); +} + +#[test] +fn a_non_finite_record_is_refused() { + let mut trace = Trace::new(); + assert!(!trace.push(Record::observation(f64::NAN, "x", 1.0))); + assert!(!trace.push(Record::observation(1.0, "x", f64::INFINITY))); + assert!(!trace.push(Record::observation(1.0, "", 1.0))); + assert!(trace.is_empty()); + + // A finite record is accepted. + assert!(trace.push(Record::observation(1.0, "x", 1.0))); + assert_eq!(trace.len(), 1); + assert!(trace.is_stable()); +} + +#[test] +fn clearing_empties_the_trace() { + let mut trace = Trace::new(); + trace.push(Record::observation(1.0, "x", 1.0)); + trace.push(Record::observation(2.0, "x", 2.0)); + assert_eq!(trace.clear(), 2); + assert!(trace.is_empty()); +} + +#[test] +fn the_recorder_collects_records_of_each_kind() { + let mut recorder = Recorder::new(); + assert!(recorder.observe(1.0, "range", 100.0)); + assert!(recorder.note(2.0, "eclipse", 1.0)); + assert!(recorder.warn(3.0, "temperature", 90.0)); + + assert_eq!(recorder.len(), 3); + assert!(!recorder.is_empty()); + let trace = recorder.trace(); + assert_eq!(trace.of_kind(RecordKind::Observation).len(), 1); + assert_eq!(trace.of_kind(RecordKind::State).len(), 1); + assert_eq!(trace.of_kind(RecordKind::Warning).len(), 1); +} + +#[test] +fn the_recorder_counts_what_it_refused() { + let mut recorder = Recorder::new(); + assert!(!recorder.observe(f64::NAN, "x", 1.0)); + assert!(!recorder.observe(1.0, "x", f64::NAN)); + assert!(recorder.observe(1.0, "x", 1.0)); + assert_eq!(recorder.refused(), 2); + assert_eq!(recorder.len(), 1); +} + +#[test] +fn taking_the_trace_leaves_the_recorder_empty() { + let mut recorder = Recorder::new(); + recorder.observe(1.0, "x", 1.0); + recorder.observe(2.0, "x", 2.0); + + let trace = recorder.take_trace(); + assert_eq!(trace.len(), 2); + assert!(recorder.is_empty()); + // The refused count belongs to the recorder and is not reset. + recorder.observe(f64::NAN, "x", 1.0); + assert_eq!(recorder.refused(), 1); +} + +#[test] +fn the_recorder_can_be_cleared_without_taking() { + let mut recorder = Recorder::new(); + recorder.observe(1.0, "x", 1.0); + assert_eq!(recorder.clear(), 1); + assert!(recorder.is_empty()); + assert_eq!(recorder.trace().len(), 0); +} + +#[test] +fn a_trace_can_be_built_from_records() { + let records = vec![ + Record::observation(1.0, "a", 1.0), + Record::observation(2.0, "b", 2.0), + ]; + let trace = Trace::from_records(records); + assert_eq!(trace.len(), 2); + assert_eq!(trace.value_of("b"), Some(2.0)); +} + +#[test] +fn a_record_displays_its_shape() { + let record = Record::warning(3.5, "temperature", 90.0); + let text = record.to_string(); + assert!(text.contains("warning"), "{text}"); + assert!(text.contains("temperature"), "{text}"); + assert!(text.contains("90"), "{text}"); +} + +#[test] +fn the_record_kinds_report_themselves() { + assert_eq!(RecordKind::Observation.name(), "observation"); + assert_eq!(RecordKind::State.name(), "state"); + assert_eq!(RecordKind::Warning.name(), "warning"); + assert!(RecordKind::Warning.is_warning()); + assert!(!RecordKind::State.is_warning()); + assert_eq!(RecordKind::State.to_string(), "state"); +} + +#[test] +fn a_record_reports_whether_it_is_usable() { + assert!(Record::observation(1.0, "x", 1.0).is_valid()); + assert!(!Record::observation(f64::NAN, "x", 1.0).is_valid()); + assert!(!Record::observation(1.0, "x", f64::NAN).is_valid()); + assert!(!Record::observation(1.0, "", 1.0).is_valid()); +} + +#[test] +fn the_trace_default_is_empty() { + let trace = Trace::default(); + assert!(trace.is_empty()); +} + +#[test] +fn the_recorder_default_is_empty() { + let recorder = Recorder::default(); + assert!(recorder.is_empty()); + assert_eq!(recorder.refused(), 0); +}