use std::path::{Path, PathBuf};
use crate::party::{OutcomeRecord, OutcomeStatus, Results, Statement};
use crate::pipeline::{Error, load_clean_root, load_ixit, load_statement, to_json_document};
use crate::run::RunReport;
use crate::transcript::{Recording, RunTranscript, TRANSCRIPT_FILE};
#[derive(Debug)]
pub struct RunRequest<'a> {
pub root: &'a Path,
pub ixit: &'a Path,
pub out_dir: &'a Path,
pub sut_name: &'a str,
pub sut_version: &'a str,
pub filter: Option<&'a str>,
pub statement: Option<&'a Path>,
pub recording: Recording,
}
#[derive(Debug, Clone, Copy)]
pub enum RunWarning<'a> {
StatementBlindSelection {
excused: &'a std::collections::BTreeMap<crate::run::UnestablishedFact, usize>,
},
OptionFamilySelection {
gaps: &'a [crate::verdict::OptionFamilyGap],
},
CarriedMeasurements {
count: usize,
measured_at: &'a str,
running_at: &'a str,
},
}
impl RunWarning<'_> {
#[must_use]
pub fn lines(&self) -> Vec<String> {
match *self {
RunWarning::CarriedMeasurements {
count,
measured_at,
running_at,
} => vec![format!(
"carrying {count} measurement record(s) taken at SUT version {measured_at} into a run at {running_at} — re-measure or attest the surface unchanged"
)],
RunWarning::StatementBlindSelection { excused } => {
let mut lines = vec![String::from(
"no --statement was supplied, so ISO/IEC 9646 test selection ran blind and this record covers the whole catalogue rather than one party's claim",
)];
lines.extend(crate::run::UnestablishedFact::ALL.iter().map(|fact| {
let count = excused.get(fact).copied().unwrap_or_default();
let effect = if fact.excuses_case() {
format!("{count} case(s) recorded not-applicable instead of driven")
} else {
String::from("not applied to selection")
};
format!(
" {}: unestablished ({}) — {effect}",
fact.token(),
fact.decides()
)
}));
lines.push(String::from(
"judge this record with `veredictum verdicts --statement <file>`, which re-applies the ICS filters",
));
lines
}
RunWarning::OptionFamilySelection { gaps } => {
let mut lines = vec![format!(
"the supplied statement does not answer {} option family/families the claim reaches with exactly one arm, so every row of each is recorded not-applicable",
gaps.len()
)];
lines.extend(gaps.iter().map(|gap| format!(" {}", gap.message())));
lines
}
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Selection<'a> {
selected_under: Option<(&'a Statement, &'a str)>,
excused: &'a std::collections::BTreeMap<crate::run::UnestablishedFact, usize>,
}
impl<'a> Selection<'a> {
#[must_use]
pub fn of(
selected_under: Option<(&'a Statement, &'a str)>,
excused: &'a std::collections::BTreeMap<crate::run::UnestablishedFact, usize>,
) -> Self {
Self {
selected_under,
excused,
}
}
#[must_use]
pub fn basis(self) -> crate::party::SelectionBasis {
match self.selected_under {
Some(_) => crate::party::SelectionBasis::Statement,
None => crate::party::SelectionBasis::StatementBlind,
}
}
#[must_use]
pub fn statement(self) -> Option<&'a Statement> {
self.selected_under.map(|(declared, _)| declared)
}
#[must_use]
pub fn digest(self) -> Option<String> {
self.selected_under.map(|(_, text)| statement_digest(text))
}
#[must_use]
pub fn advisory(self) -> Option<RunWarning<'a>> {
match self.basis() {
crate::party::SelectionBasis::Statement => None,
crate::party::SelectionBasis::StatementBlind => {
Some(RunWarning::StatementBlindSelection {
excused: self.excused,
})
}
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct OutcomeCounts {
pub passed: usize,
pub failed: usize,
pub errored: usize,
pub not_applicable: usize,
}
#[derive(Debug)]
pub struct RunOutcome {
pub results: Results,
pub report: RunReport,
pub counts: OutcomeCounts,
pub results_path: PathBuf,
pub exceptions_path: PathBuf,
pub transcript_path: Option<PathBuf>,
}
impl RunOutcome {
#[must_use]
pub fn is_clean(&self) -> bool {
self.counts.failed == 0 && self.counts.errored == 0
}
pub fn results_document(&self) -> Result<String, Error> {
to_json_document(&self.results, "serialize")
}
pub fn exceptions_document(&self) -> Result<String, Error> {
let entries: Vec<serde_json::Value> = self
.report
.exceptions
.iter()
.map(|(case, e)| serde_json::json!({ "case": case.to_string(), "exception": e }))
.collect();
to_json_document(&entries, "serialize")
}
pub fn transcript_document(&self) -> Result<Option<String>, Error> {
if self.report.transcripts.is_empty() {
return Ok(None);
}
let mut transcript = RunTranscript {
sut: self.results.sut.clone(),
schedule_release: self.results.schedule_release.clone(),
cases: self.report.transcripts.clone(),
};
transcript.canonicalize();
to_json_document(&transcript, "serialize").map(Some)
}
}
const DIGEST_BYTES: usize = 8;
fn leading_digest(text: &str) -> String {
use std::fmt::Write as _;
use sha2::{Digest as _, Sha256};
Sha256::digest(text.as_bytes())
.iter()
.take(DIGEST_BYTES)
.fold(
String::with_capacity(DIGEST_BYTES.saturating_mul(2)),
|mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
},
)
}
#[must_use]
pub fn ixit_digest(ixit_text: &str) -> String {
leading_digest(ixit_text)
}
#[must_use]
pub fn statement_digest(statement_text: &str) -> String {
leading_digest(statement_text)
}
#[derive(Debug)]
pub struct RecordedCampaign<'a> {
pub sut: crate::party::Sut,
pub schedule_release: String,
pub selection: Selection<'a>,
pub register: Option<&'a crate::model::register::AmbiguityRegister>,
pub ixit_text: &'a str,
pub restapi_specs_version: Option<String>,
pub outcomes: Vec<OutcomeRecord>,
pub measurements: Vec<crate::perf::Measurement>,
}
impl RecordedCampaign<'_> {
#[must_use]
pub fn into_results(self) -> Results {
let statement = self.selection.statement();
Results {
sut: self.sut,
runner: crate::party::Runner {
name: "veredictum".to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
verification_pack_status: crate::party::VerificationPackStatus::Passed,
},
schedule_release: self.schedule_release,
tech_profile: tech_profile(statement),
ixit_digest: ixit_digest(self.ixit_text),
statement_digest: self.selection.digest(),
selection_basis: Some(self.selection.basis()),
restapi_specs_version: self.restapi_specs_version,
outcomes: self.outcomes,
measurements: self.measurements,
ambiguity_dispositions: ambiguity_dispositions(statement, self.register),
}
}
}
pub(crate) fn ambiguity_dispositions(
statement: Option<&Statement>,
register: Option<&crate::model::register::AmbiguityRegister>,
) -> Vec<crate::party::AmbiguityDisposition> {
let (Some(statement), Some(register)) = (statement, register) else {
return Vec::new();
};
let mut applied = Vec::new();
for (id, entry) in register.entries() {
if entry.disposition != crate::vocab::Disposition::OptionSelect {
continue;
}
for arm in entry.options.tags() {
if statement.options.contains(arm) {
applied.push(crate::party::AmbiguityDisposition {
ambiguity: id.clone(),
option: Some(arm.clone()),
});
}
}
}
applied
}
pub fn execute_run(
request: &RunRequest<'_>,
warn: &dyn Fn(RunWarning<'_>),
progress: &mut dyn FnMut(crate::run::Progress<'_>),
) -> Result<RunOutcome, Error> {
let loaded = load_clean_root(request.root)?;
let (ixit, ixit_text) = load_ixit(request.ixit)?;
let mut set = loaded.set;
if let Some(needle) = request.filter {
set.cases.retain(|(_, c)| c.id.as_str().contains(needle));
}
let selected_under: Option<(Statement, String)> = match request.statement {
None => None,
Some(path) => Some(load_statement(path)?),
};
let statement = selected_under.as_ref().map(|(declared, _)| declared);
if let (Some(declared), Some((_, register))) = (statement, &set.register) {
let gaps = crate::verdict::option_family_gaps(
declared,
set.cases.iter().map(|(_, case)| case),
register,
);
if !gaps.is_empty() {
warn(RunWarning::OptionFamilySelection { gaps: &gaps });
}
}
let report = crate::run::execute(&set, &ixit, statement, request.recording, progress)
.map_err(|e| Error::Instrument(format!("execution defect: {e}")))?;
let selection = Selection::of(
selected_under
.as_ref()
.map(|(declared, text)| (declared, text.as_str())),
&report.unestablished,
);
if let Some(advisory) = selection.advisory() {
warn(advisory);
}
let outcomes: Vec<OutcomeRecord> = report.records.iter().map(OutcomeRecord::from).collect();
let counts = tally(&outcomes);
let carried = carried_measurements(request, warn)?;
let results = RecordedCampaign {
sut: crate::party::Sut {
name: request.sut_name.to_owned(),
version: request.sut_version.to_owned(),
},
schedule_release: crate::party::SCHEDULE_RELEASE.to_owned(),
selection,
register: set.register.as_ref().map(|(_, register)| register),
ixit_text: &ixit_text,
restapi_specs_version: report.restapi_specs_version.clone(),
outcomes,
measurements: carried,
}
.into_results();
results
.check_invariants()
.map_err(Error::RecordedInvariants)?;
let transcript_path =
(!report.transcripts.is_empty()).then(|| request.out_dir.join(TRANSCRIPT_FILE));
Ok(RunOutcome {
results,
report,
counts,
results_path: request.out_dir.join("results.json"),
exceptions_path: request.out_dir.join("run-exceptions.json"),
transcript_path,
})
}
fn tally(outcomes: &[OutcomeRecord]) -> OutcomeCounts {
let mut counts = OutcomeCounts::default();
for outcome in outcomes {
match outcome.status {
OutcomeStatus::Passed => counts.passed += 1,
OutcomeStatus::Failed => counts.failed += 1,
OutcomeStatus::Errored => counts.errored += 1,
_ => counts.not_applicable += 1,
}
}
counts
}
pub(crate) fn tech_profile(statement: Option<&Statement>) -> crate::party::RecordedTechProfile {
let declared = statement.and_then(|s| {
s.tech_profiles
.iter()
.find(|p| p.its == crate::vocab::ItsName::ItsRest)
});
crate::party::RecordedTechProfile {
its: crate::vocab::ItsName::ItsRest,
formats: declared.map_or_else(
|| crate::vocab::FormatName::ALL.to_vec(),
|p| p.formats.clone(),
),
source: Some(match declared {
Some(_) => crate::party::TechProfileSource::Declared,
None => crate::party::TechProfileSource::Defaulted,
}),
}
}
fn carried_measurements(
request: &RunRequest<'_>,
warn: &dyn Fn(RunWarning<'_>),
) -> Result<Vec<crate::perf::Measurement>, Error> {
let prior_path = request.out_dir.join("results.json");
let prior = match std::fs::read_to_string(&prior_path) {
Ok(text) => match serde_json::from_str::<Results>(&text) {
Ok(prior) => Some(prior),
Err(e) => {
return Err(Error::Instrument(format!(
"runner defect: {} exists but does not parse as results.json ({e}) — \
its measurement records cannot be carried forward",
prior_path.display()
)));
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(Error::Instrument(format!(
"runner defect: {} is unreadable ({e})",
prior_path.display()
)));
}
};
let Some(prior) = prior.filter(|prior| prior.sut.name == request.sut_name) else {
return Ok(Vec::new());
};
if prior.sut.version != request.sut_version && !prior.measurements.is_empty() {
warn(RunWarning::CarriedMeasurements {
count: prior.measurements.len(),
measured_at: &prior.sut.version,
running_at: request.sut_version,
});
}
Ok(prior.measurements)
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use super::*;
fn example_results() -> Results {
let text = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/examples/results.example.json"
))
.expect("the committed example results document");
serde_json::from_str(&text).expect("the example document parses as results")
}
fn request<'a>(out_dir: &'a Path, sut_name: &'a str, sut_version: &'a str) -> RunRequest<'a> {
RunRequest {
root: Path::new("artifacts"),
ixit: Path::new("ixit.json"),
out_dir,
sut_name,
sut_version,
filter: None,
statement: None,
recording: Recording::Off,
}
}
fn outcome(case: &str, status: OutcomeStatus) -> OutcomeRecord {
OutcomeRecord {
case: crate::ids::CaseId::parse(case).expect("a well-formed case id"),
format: None,
status,
rows_driven: 1,
rows_total: 1,
failing_step: None,
reason: None,
citation: Some("citation".to_owned()),
failed_rows: Vec::new(),
}
}
fn write_prior(dir: &Path, results: &Results) -> PathBuf {
let path = dir.join("results.json");
let text = serde_json::to_string(results).expect("the record serializes");
std::fs::write(&path, text).expect("writing the prior record");
path
}
fn sink() -> RefCell<Vec<String>> {
RefCell::new(Vec::new())
}
fn any_statement() -> Statement {
serde_json::from_str(
r#"{
"product": {
"name": "selection-gate", "version": "0",
"vendor": "selection-gate", "identifier": "urn:selection-gate"
},
"schedule_release": "cnf-2.0-w2",
"claims": {}
}"#,
)
.expect("the minimal statement shape parses")
}
#[test]
fn the_stamped_basis_and_the_advisory_never_disagree() {
let excused = std::collections::BTreeMap::new();
let statement = any_statement();
for (case, selection) in [
("blind", Selection::of(None, &excused)),
(
"selected",
Selection::of(Some((&statement, "{}")), &excused),
),
] {
let blind_stamp = selection.basis() == crate::party::SelectionBasis::StatementBlind;
let announced = selection.advisory().is_some();
assert_eq!(
blind_stamp,
announced,
"{case}: the document stamps {} and the run {} an advisory",
selection.basis().token(),
if announced { "raises" } else { "raises no" }
);
}
}
#[test]
fn both_bases_are_reachable_from_a_statements_presence() {
let excused = std::collections::BTreeMap::new();
let statement = any_statement();
let reached = [
Selection::of(None, &excused).basis(),
Selection::of(Some((&statement, "{}")), &excused).basis(),
];
for basis in crate::party::SelectionBasis::ALL {
assert!(
reached.contains(basis),
"{} is never derived, so the agreement test does not cover it",
basis.token()
);
}
}
#[test]
fn the_blind_advisory_names_the_flag_and_every_unestablished_fact() {
let excused = std::collections::BTreeMap::new();
let advisory = Selection::of(None, &excused)
.advisory()
.expect("a blind campaign announces itself");
let lines = advisory.lines();
assert_eq!(
lines.len(),
crate::run::UnestablishedFact::ALL.len() + 2,
"{lines:?}"
);
let first = lines.first().expect("the advisory opens with its sentence");
assert!(first.contains("--statement"), "{first}");
for fact in crate::run::UnestablishedFact::ALL {
assert!(
lines.iter().any(|line| line.contains(fact.token())),
"{} is not named: {lines:?}",
fact.token()
);
}
}
#[test]
fn skipped_and_not_applicable_tally_into_one_selection_bucket() {
let outcomes = vec![
outcome("I_EHR_SERVICE.create_ehr-a", OutcomeStatus::Passed),
outcome("I_EHR_SERVICE.create_ehr-b", OutcomeStatus::Passed),
outcome("I_EHR_SERVICE.create_ehr-c", OutcomeStatus::Failed),
outcome("I_EHR_SERVICE.create_ehr-d", OutcomeStatus::Errored),
outcome("I_EHR_SERVICE.create_ehr-e", OutcomeStatus::Skipped),
outcome("I_EHR_SERVICE.create_ehr-f", OutcomeStatus::NotApplicable),
];
let counts = tally(&outcomes);
assert_eq!(counts.passed, 2);
assert_eq!(counts.failed, 1);
assert_eq!(counts.errored, 1);
assert_eq!(counts.not_applicable, 2);
}
#[test]
fn a_campaign_is_clean_only_when_nothing_failed_or_errored() {
let clean = OutcomeCounts {
passed: 3,
failed: 0,
errored: 0,
not_applicable: 4,
};
let outcome_of = |counts: OutcomeCounts| RunOutcome {
results: example_results(),
report: RunReport::default(),
counts,
results_path: PathBuf::from("results.json"),
exceptions_path: PathBuf::from("run-exceptions.json"),
transcript_path: None,
};
assert!(outcome_of(clean).is_clean());
assert!(
!outcome_of(OutcomeCounts { failed: 1, ..clean }).is_clean(),
"a failed row is never clean"
);
assert!(
!outcome_of(OutcomeCounts {
errored: 1,
..clean
})
.is_clean(),
"an inconclusive row is never clean either"
);
}
#[test]
fn an_absent_statement_records_every_format() {
let profile = tech_profile(None);
assert_eq!(profile.its, crate::vocab::ItsName::ItsRest);
assert_eq!(profile.formats, crate::vocab::FormatName::ALL.to_vec());
assert_eq!(
profile.source,
Some(crate::party::TechProfileSource::Defaulted),
"the record says the list is the fallback rather than a claim"
);
}
#[test]
fn a_statement_records_its_own_declared_its_rest_formats() {
let statement = declaring(&serde_json::json!([
{ "its": "its-rest", "formats": ["canonical-json"] }
]));
let profile = tech_profile(Some(&statement));
assert_eq!(
profile.formats,
vec![crate::vocab::FormatName::CanonicalJson]
);
assert_ne!(
profile.formats,
crate::vocab::FormatName::ALL.to_vec(),
"a declared profile narrows the recorded formats"
);
assert_eq!(
profile.source,
Some(crate::party::TechProfileSource::Declared),
"the record says the list is the party's own claim"
);
}
#[test]
fn a_statement_declaring_no_profile_records_the_fallback_as_defaulted() {
let statement = declaring(&serde_json::json!([]));
let profile = tech_profile(Some(&statement));
assert_eq!(profile.formats, crate::vocab::FormatName::ALL.to_vec());
assert_eq!(
profile.source,
Some(crate::party::TechProfileSource::Defaulted)
);
}
fn declaring(tech_profiles: &serde_json::Value) -> Statement {
serde_json::from_value(serde_json::json!({
"product": {
"vendor": "v", "name": "n", "version": "1", "identifier": "urn:test:n"
},
"schedule_release": "cnf-2.0-w2",
"claims": { "profiles": [], "capabilities": [] },
"tech_profiles": tech_profiles
}))
.expect("a minimal statement parses")
}
fn register() -> crate::model::register::AmbiguityRegister {
serde_json::from_value(serde_json::json!({
"AMB-39": {
"ambiguity": "the deprecated types may be served or refused",
"source": "ITS-REST §Requirements",
"handling": "sibling cases carry option tags; the ICS options declaration selects",
"disposition": "option_select",
"options": {
"deprecated-types": [
"sf-deprecated-types-supported",
"sf-deprecated-types-unsupported"
],
"deprecated-media": [
"sf-deprecated-media-supported",
"sf-deprecated-media-unsupported"
]
}
},
"AMB-5": {
"ambiguity": "an editorial divergence",
"source": "ITS-REST §Requirements",
"handling": "carried and reported",
"disposition": "editorial",
"upstream_issue": 1
}
}))
.expect("the register fixture parses")
}
fn declaring_options(options: &[&str]) -> Statement {
let mut statement = declaring(&serde_json::json!([]));
statement.options = options
.iter()
.map(|tag| crate::ids::OptionTag::parse(tag).expect("a well-formed option tag"))
.collect();
statement
}
#[test]
fn the_declared_option_arms_are_recorded_as_dispositions() {
let register = register();
let statement = declaring_options(&[
"sf-deprecated-types-unsupported",
"sf-deprecated-media-supported",
]);
let applied = ambiguity_dispositions(Some(&statement), Some(®ister));
let recorded: Vec<(String, Option<String>)> = applied
.iter()
.map(|d| {
(
d.ambiguity.to_string(),
d.option.as_ref().map(ToString::to_string),
)
})
.collect();
assert_eq!(
recorded,
vec![
(
String::from("AMB-39"),
Some(String::from("sf-deprecated-types-unsupported"))
),
(
String::from("AMB-39"),
Some(String::from("sf-deprecated-media-supported"))
),
],
"one record per answered family, in the register's authored order"
);
}
#[test]
fn a_blind_campaign_applies_no_disposition() {
assert!(ambiguity_dispositions(None, Some(®ister())).is_empty());
let statement = declaring_options(&["sf-deprecated-types-supported"]);
assert!(
ambiguity_dispositions(Some(&statement), None).is_empty(),
"a catalogue with no register defines no option arm to apply"
);
}
#[test]
fn an_undeclared_arm_records_no_disposition() {
let statement = declaring_options(&["nobody-declares-this"]);
assert!(ambiguity_dispositions(Some(&statement), Some(®ister())).is_empty());
}
#[test]
fn the_ixit_digest_is_a_function_of_the_topology_bytes() {
let text = r#"{"instances":{}}"#;
assert_eq!(ixit_digest(text), ixit_digest(text));
assert_ne!(ixit_digest(text), ixit_digest(r#"{"instances":{ }}"#));
assert_eq!(ixit_digest(text).len(), 16, "16 lowercase hex characters");
assert!(ixit_digest(text).chars().all(|c| c.is_ascii_hexdigit()));
}
const FIXTURE_IXIT: &str = r#"{
"instances": {
"sut": {
"base_url": "http://127.0.0.1:8080/rest/openehr/v1",
"auth": { "mode": "basic", "user_env": "SUT_USER", "password_env": "SUT_PASS" }
},
"unauthenticated": {
"base_url": "http://127.0.0.1:8080/rest/openehr/v1",
"auth": { "mode": "none" }
}
}
}
"#;
#[test]
fn the_ixit_digest_is_the_leading_sha256_bytes_of_the_declaration() {
serde_json::from_str::<crate::ixit::Ixit>(FIXTURE_IXIT)
.expect("the pinned fixture is a real declaration, not just bytes");
assert_eq!(ixit_digest(r#"{"instances":{}}"#), "b6d92d2643a85d0c");
assert_eq!(ixit_digest(FIXTURE_IXIT), "bfbf6ece2dea6ef0");
}
const FIXTURE_STATEMENT: &str = r#"{
"product": {
"name": "example-cdr",
"version": "0.0.0-example",
"vendor": "nobody",
"identifier": "urn:example:cdr"
},
"schedule_release": "cnf-2.0-w2",
"spec_versions": { "its_rest": "1.1.0" },
"claims": { "capabilities": ["EhrOperations"], "profiles": ["CORE"] },
"tech_profiles": [ { "its": "its-rest", "formats": ["canonical-json"] } ]
}
"#;
#[test]
fn the_statement_digest_is_the_leading_sha256_bytes_of_the_declaration() {
serde_json::from_str::<Statement>(FIXTURE_STATEMENT)
.expect("the pinned fixture is a real declaration, not just bytes");
assert_eq!(statement_digest(FIXTURE_STATEMENT), "21307080d1024bff");
assert_eq!(statement_digest(r#"{"instances":{}}"#), "b6d92d2643a85d0c");
}
#[test]
fn the_statement_digest_is_a_function_of_the_declaration_bytes() {
let altered = FIXTURE_STATEMENT.replace("\"nobody\"", "\"nobody \"");
assert_ne!(
statement_digest(FIXTURE_STATEMENT),
statement_digest(&altered)
);
assert_eq!(statement_digest(FIXTURE_STATEMENT).len(), 16);
assert!(
statement_digest(FIXTURE_STATEMENT)
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
}
#[test]
fn no_prior_record_carries_nothing_and_warns_about_nothing() {
let dir = assert_fs::TempDir::new().expect("temp dir");
let seen = sink();
let carried = carried_measurements(
&request(dir.path(), "example-cdr", "0.0.0-example"),
&|warning| seen.borrow_mut().push(format!("{warning:?}")),
)
.expect("an absent prior record is absence, never a defect");
assert!(carried.is_empty());
assert!(seen.borrow().is_empty());
}
#[test]
fn a_prior_record_of_another_sut_never_carries_forward() {
let dir = assert_fs::TempDir::new().expect("temp dir");
let prior = example_results();
assert!(!prior.measurements.is_empty(), "the example is measured");
write_prior(dir.path(), &prior);
let seen = sink();
let carried = carried_measurements(
&request(dir.path(), "another-cdr", "0.0.0-example"),
&|warning| seen.borrow_mut().push(format!("{warning:?}")),
)
.expect("a foreign prior record is not a defect");
assert!(
carried.is_empty(),
"measurements never travel between systems under test"
);
assert!(seen.borrow().is_empty());
}
#[test]
fn measurements_carry_forward_silently_at_the_same_version() {
let dir = assert_fs::TempDir::new().expect("temp dir");
let prior = example_results();
write_prior(dir.path(), &prior);
let seen = sink();
let carried = carried_measurements(
&request(dir.path(), &prior.sut.name, &prior.sut.version),
&|warning| seen.borrow_mut().push(format!("{warning:?}")),
)
.expect("the same SUT at the same version");
assert_eq!(carried.len(), prior.measurements.len());
assert!(
seen.borrow().is_empty(),
"an unchanged version needs no attestation"
);
}
#[test]
fn a_version_change_carries_the_records_and_warns() {
let dir = assert_fs::TempDir::new().expect("temp dir");
let prior = example_results();
write_prior(dir.path(), &prior);
let seen = sink();
let carried = carried_measurements(
&request(dir.path(), &prior.sut.name, "9.9.9-next"),
&|warning| match warning {
RunWarning::CarriedMeasurements {
count,
measured_at,
running_at,
} => seen
.borrow_mut()
.push(format!("{count} {measured_at} {running_at}")),
RunWarning::StatementBlindSelection { .. }
| RunWarning::OptionFamilySelection { .. } => {
panic!("carry-forward reports no selection warning")
}
},
)
.expect("carry-forward across versions is a warning, not a refusal");
assert_eq!(carried.len(), prior.measurements.len());
assert_eq!(
*seen.borrow(),
vec![format!(
"{} {} 9.9.9-next",
prior.measurements.len(),
prior.sut.version
)]
);
}
#[test]
fn an_unparsable_prior_record_is_a_defect_not_an_empty_carry() {
let dir = assert_fs::TempDir::new().expect("temp dir");
std::fs::write(dir.path().join("results.json"), "{ not json")
.expect("writing the broken record");
let error = carried_measurements(&request(dir.path(), "example-cdr", "0.0.0"), &|_| {})
.expect_err("a broken prior record must stop the run");
let message = error.to_string();
assert!(
message.contains("does not parse as results.json"),
"{message}"
);
assert!(message.contains("cannot be carried forward"), "{message}");
}
#[test]
fn the_documents_render_the_record_and_one_entry_per_exception() {
let results = example_results();
let case = crate::ids::CaseId::parse("I_EHR_SERVICE.create_ehr-main").expect("case id");
let outcome = RunOutcome {
results,
report: RunReport {
exceptions: vec![(
case.clone(),
crate::run::Exception::Unrealized("no wire on this ITS".to_owned()),
)],
..RunReport::default()
},
counts: OutcomeCounts::default(),
results_path: PathBuf::from("results.json"),
exceptions_path: PathBuf::from("run-exceptions.json"),
transcript_path: None,
};
let document = outcome
.results_document()
.expect("the record serializes as a document");
assert!(document.ends_with('\n'), "documents end with a newline");
let parsed: Results =
serde_json::from_str(&document).expect("the rendered document parses back");
assert_eq!(parsed.sut.name, outcome.results.sut.name);
let exceptions = outcome
.exceptions_document()
.expect("the exceptions serialize");
let entries: Vec<serde_json::Value> =
serde_json::from_str(&exceptions).expect("the exception document parses");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["case"], case.to_string());
assert_eq!(entries[0]["exception"]["kind"], "unrealized");
}
}