#![allow(
clippy::unwrap_used,
reason = "panicking is the failure mode a test wants"
)]
use std::io::Write;
use std::process::{Command, Output, Stdio};
const BIN: &str = env!("CARGO_BIN_EXE_hl7probe");
fn run(args: &[&str]) -> Output {
Command::new(BIN)
.args(args)
.env("NO_COLOR", "1")
.output()
.expect("binary should run")
}
fn pipe(args: &[&str], stdin: &str) -> Output {
let mut child = Command::new(BIN)
.args(args)
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("binary should run");
child
.stdin
.as_mut()
.unwrap()
.write_all(stdin.as_bytes())
.unwrap();
child.wait_with_output().unwrap()
}
fn stdout(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).to_string()
}
fn code(out: &Output) -> i32 {
out.status.code().unwrap_or(-1)
}
#[test]
fn reports_a_clean_message_and_exits_zero() {
let out = run(&["examples/adt_a01.hl7"]);
let text = stdout(&out);
assert_eq!(code(&out), 0, "{text}");
assert!(text.contains("HL7 v2.5.1"));
assert!(text.contains("ADT^A01"));
assert!(text.contains("Admit / Visit Notification"));
assert!(text.contains("Patient Name"));
assert!(text.contains("John A Smith"));
assert!(text.contains("message passes all checks"));
}
#[test]
fn invalid_message_lists_findings_and_exits_one() {
let out = run(&["examples/invalid.hl7"]);
let text = stdout(&out);
assert_eq!(code(&out), 1, "{text}");
for expected in [
"required segment is missing",
"PID-7",
"not a valid date/time",
"PV1-3",
"invalid location",
"OBX-5",
] {
assert!(text.contains(expected), "missing {expected:?} in:\n{text}");
}
}
#[test]
fn strict_mode_fails_on_warnings_only() {
let warning_only = "MSH|^~\\&|HIS|MERCY|LIS|LAB|20240115143200||ADT^A01|MSG1|P|2.5.1\r\
EVN|A01|20240115143200||||20240115143000\r\
PID|1||123456^^^MERCY^MR||Smith^John||19850312|M\r\
PV1|1|O|CLINIC^1\r";
assert_eq!(code(&pipe(&["-q"], warning_only)), 0);
assert_eq!(code(&pipe(&["-q", "--strict"], warning_only)), 1);
}
#[test]
fn unreadable_input_exits_two() {
let out = run(&["no/such/file.hl7"]);
assert_eq!(code(&out), 2);
assert!(String::from_utf8_lossy(&out.stderr).contains("no/such/file.hl7"));
let out = pipe(&[], "this is not an HL7 message\n");
assert_eq!(code(&out), 2);
assert!(String::from_utf8_lossy(&out.stderr).contains("no MSH segment"));
}
#[test]
fn quiet_mode_prints_one_line_per_message() {
let out = run(&["-q", "examples/adt_a01.hl7", "examples/batch.hl7"]);
let text = stdout(&out);
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 3, "{text}");
assert!(lines[0].starts_with("adt_a01.hl7"));
assert!(lines[1].starts_with("batch.hl7#1"));
assert!(lines[2].starts_with("batch.hl7#2"));
}
#[test]
fn segment_filter_limits_the_field_tables() {
let text = stdout(&run(&["-s", "PID", "examples/adt_a01.hl7"]));
assert!(text.contains("Patient Identifier List"));
assert!(
!text.contains("Assigned Patient Location"),
"PV1 table should be hidden"
);
assert!(
text.contains("PV1"),
"the segment overview still lists every segment"
);
}
#[test]
fn field_query_prints_raw_values() {
assert_eq!(
stdout(&run(&["-f", "PID-5.1", "examples/adt_a01.hl7"])).trim(),
"Smith"
);
assert_eq!(
stdout(&run(&["-f", "MSH-10", "examples/adt_a01.hl7"])).trim(),
"MSG00001"
);
assert_eq!(
stdout(&run(&["-f", "OBX[2]-5", "examples/oru_r01.hl7"])).trim(),
"39.1"
);
assert_eq!(
stdout(&run(&["-f", "PV1-3.2", "examples/adt_a01.hl7"])).trim(),
"101"
);
let ids = stdout(&run(&["-f", "PID-3.1", "examples/adt_a01.hl7"]));
assert_eq!(ids.lines().collect::<Vec<_>>(), vec!["123456", "987654321"]);
assert_eq!(code(&run(&["-f", "ZZZ-1", "examples/adt_a01.hl7"])), 1);
assert_eq!(code(&run(&["-f", "nonsense", "examples/adt_a01.hl7"])), 2);
}
#[test]
fn json_output_is_machine_readable() {
let out = run(&["--json", "examples/invalid.hl7"]);
assert_eq!(code(&out), 1);
let text = stdout(&out);
let value: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
assert_eq!(value["status"], "error");
let message = &value["files"][0]["messages"][0];
assert_eq!(message["type"], "ADT^A01");
assert_eq!(message["version"], "2.5.1");
assert_eq!(message["control_id"], "MSG00002");
assert!(message["summary"]["errors"].as_u64().unwrap() >= 4);
let findings = message["findings"].as_array().unwrap();
assert!(findings
.iter()
.any(|f| f["location"] == "PV1-3" && f["severity"] == "error"));
let segments = message["segments"].as_array().unwrap();
assert_eq!(segments[0]["name"], "MSH");
assert!(segments[0]["fields"]
.as_array()
.unwrap()
.iter()
.any(|f| f["name"] == "Version ID"));
}
#[test]
fn reads_a_message_from_a_pipe() {
let out = pipe(
&[],
"MSH|^~\\&|A|B|C|D|20240115143200||ACK|1|P|2.5.1\rMSA|AA|MSG00001\r",
);
assert_eq!(code(&out), 0, "{}", stdout(&out));
assert!(stdout(&out).contains("General Acknowledgment"));
}
#[test]
fn selects_a_single_message_from_a_batch() {
let text = stdout(&run(&["-m", "2", "--summary", "examples/batch.hl7"]));
assert!(text.contains("ADT^A03"));
assert!(!text.contains("ADT^A01"));
assert_eq!(code(&run(&["-m", "9", "examples/batch.hl7"])), 2);
}
#[test]
fn escape_sequences_are_resolved_for_display() {
let out = pipe(
&[],
"MSH|^~\\&|A|B|C|D|20240115143200||ADT^A08|1|P|2.5.1\r\
EVN|A08|20240115143200||||20240115143000\r\
PID|1||1^^^A^MR||O\\T\\Brien^Se\\S\\an||19850312|M|||1 St^^X^IL^1\r\
PV1|1|O|CL^1\r",
);
let text = stdout(&out);
assert!(text.contains("O&Brien^Se^an"), "{}", text);
}
#[test]
fn help_and_version_are_available() {
let help = stdout(&run(&["--help"]));
assert!(help.contains("Reads HL7 v2.x messages"));
assert!(help.contains("Exit status: 0 clean, 1 validation errors, 2 unreadable input"));
assert!(help.contains("--tui"));
assert!(help.contains("EXAMPLES"));
assert!(stdout(&run(&["--version"])).contains(env!("CARGO_PKG_VERSION")));
}
fn batch_with_a_broken_message() -> String {
let good = std::fs::read_to_string("examples/adt_a01.hl7")
.unwrap()
.replace('\n', "\r");
format!("{good}MSHzzzz\rPID|1||X\r{good}")
}
#[test]
fn parse_errors_print_before_the_messages_they_sit_among() {
let out = pipe(&[], &batch_with_a_broken_message());
let text = stdout(&out);
assert_eq!(code(&out), 2, "{text}");
let error_at = text
.find("parse error")
.expect("the bad message is reported");
let first_message_at = text
.find("message 1 of")
.expect("the good ones still render");
assert!(
error_at < first_message_at,
"the error belongs above the messages:\n{text}"
);
}
#[test]
fn message_numbering_counts_only_the_readable_messages() {
let text = stdout(&pipe(&[], &batch_with_a_broken_message()));
assert!(text.contains("message 1 of 2"), "{text}");
assert!(text.contains("message 2 of 2"), "{text}");
assert!(!text.contains("message 3 of 2"), "{text}");
assert!(!text.contains("of 3"), "{text}");
let quiet = stdout(&pipe(&["-q"], &batch_with_a_broken_message()));
assert!(quiet.contains("#1"), "{quiet}");
assert!(quiet.contains("#2"), "{quiet}");
assert!(!quiet.contains("#3"), "{quiet}");
}
#[test]
fn a_closed_pipe_is_not_an_error() {
let batch = batch_with_a_broken_message().repeat(200);
let child = Command::new(BIN)
.args(["-"])
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut child = child;
let _ = child.stdin.as_mut().unwrap().write_all(batch.as_bytes());
drop(child.stdin.take());
let out = child.wait_with_output().unwrap();
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(!err.contains("stdout:"), "{err}");
}
#[test]
fn json_reports_a_batch_with_an_unreadable_message() {
let out = pipe(&["--json"], &batch_with_a_broken_message());
let text = stdout(&out);
assert_eq!(code(&out), 2, "{text}");
let value: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
assert_eq!(value["status"], "error");
let file = &value["files"][0];
assert_eq!(file["parse_errors"].as_array().unwrap().len(), 1);
let messages = file["messages"].as_array().unwrap();
assert_eq!(messages.len(), 2, "only the readable ones are described");
for message in messages {
assert_eq!(message["type"], "ADT^A01");
}
}
#[test]
fn json_status_carries_the_worst_severity_in_the_batch() {
let clean = std::fs::read_to_string("examples/adt_a01.hl7")
.unwrap()
.replace('\n', "\r");
let broken = std::fs::read_to_string("examples/invalid.hl7")
.unwrap()
.replace('\n', "\r");
let value: serde_json::Value =
serde_json::from_str(&stdout(&pipe(&["--json"], &clean))).unwrap();
assert_eq!(value["status"], "ok");
let out = pipe(&["--json"], &format!("{clean}{broken}"));
let value: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
assert_eq!(value["status"], "error");
assert_eq!(code(&out), 1);
}
#[test]
fn a_field_query_survives_a_closed_pipe() {
let batch = std::fs::read_to_string("examples/adt_a01.hl7")
.unwrap()
.replace('\n', "\r")
.repeat(400);
let mut child = Command::new(BIN)
.args(["-f", "PID-5.1", "-"])
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let _ = child.stdin.as_mut().unwrap().write_all(batch.as_bytes());
drop(child.stdin.take());
let out = child.wait_with_output().unwrap();
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(!err.contains("panicked"), "{err}");
}
#[test]
fn coded_fields_are_checked_against_their_hl7_table() {
let with_reason = |code: &str| {
format!(
"MSH|^~\\&|A|B|C|D|20240115143200||ADT^A01|1|P|2.5.1\r\
EVN|A01|20240115143200||{code}\r\
PID|1||1^^^A^MR||Smith^John||19850312|M\r\
PV1|1|I|E^1^A\r"
)
};
let good = stdout(&pipe(&["-v", "--width", "160"], &with_reason("02")));
assert!(
!good.contains("EVN-4"),
"a listed code is accepted:\n{good}"
);
let bad = stdout(&pipe(&["-v", "--width", "160"], &with_reason("ZZ")));
assert!(
bad.contains("EVN-4"),
"an unlisted code is reported:\n{bad}"
);
assert!(bad.contains("0062"), "the finding names the table:\n{bad}");
}
#[test]
fn site_defined_tables_accept_whatever_the_site_uses() {
let msg = "MSH|^~\\&|A|B|C|D|20240115143200||ADT^A01|1|P|2.5.1\r\
EVN|A01|20240115143200\r\
PID|1||1^^^A^MR||Smith^John||19850312|M\r\
PV1|1|I|E^1^A||||||CARDIOLOGY_WARD_7\r";
let out = stdout(&pipe(&["-v"], msg));
assert!(
!out.contains("PV1-10"),
"site codes pass unremarked:\n{out}"
);
}
#[test]
fn a_version_the_dictionary_was_not_built_from_is_declared() {
let message = |version: &str| {
format!(
"MSH|^~\\&|A|B|C|D|20240115143200||ADT^A01|1|P|{version}\r\
EVN|A01|20240115143200\r\
PID|1||1^^^A^MR||Smith^John||19850312|M\r\
PV1|1|I|E^1^A\r"
)
};
let report = |v: &str| stdout(&pipe(&["-v", "--width", "160"], &message(v)));
for version in ["2.3", "2.3.1", "2.4", "2.5", "2.5.1", "2.6"] {
let out = report(version);
assert!(
!out.contains("checked against HL7"),
"{version} is carried, so nothing to say:\n{out}"
);
}
for version in ["2.2", "2.7"] {
let out = report(version);
assert!(out.contains("MSH-12"), "{version} is flagged:\n{out}");
assert!(
out.contains(version),
"the declared version is named:\n{out}"
);
assert!(out.contains("2.5.1"), "and the one used instead:\n{out}");
}
}
#[test]
fn a_universal_id_without_its_type_is_reported() {
let with_facility = |facility: &str| {
format!(
"MSH|^~\\&|HIS|{facility}|LIS|LAB|20240115143200||ADT^A01|1|P|2.5.1\r\
EVN|A01|20240115143200\r\
PID|1||1^^^A^MR||Smith^John||19850312|M\r\
PV1|1|I|E^1^A\r"
)
};
let report =
|facility: &str| stdout(&pipe(&["-v", "--width", "160"], &with_facility(facility)));
assert!(
!report("MERCY").contains("MSH-4"),
"namespace alone is fine"
);
assert!(
!report("MERCY^1.2.3.4^ISO").contains("universal ID"),
"all three components are fine"
);
assert!(
!report("^1.2.3.4^ISO").contains("universal ID without"),
"namespace may be omitted"
);
let no_type = report("MERCY^1.2.3.4");
assert!(no_type.contains("MSH-4"), "{no_type}");
assert!(
no_type.contains("universal ID without an ID type"),
"{no_type}"
);
let no_id = report("MERCY^^ISO");
assert!(no_id.contains("ID type without a universal ID"), "{no_id}");
}
#[test]
fn colour_is_emitted_when_asked_for_and_never_otherwise() {
let coloured = Command::new(BIN)
.args(["--color", "always", "examples/adt_a01.hl7"])
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&coloured.stdout).contains('\u{1b}'),
"--color always paints the output"
);
let plain = run(&["--color", "never", "examples/adt_a01.hl7"]);
assert!(!stdout(&plain).contains('\u{1b}'));
}
#[test]
fn the_viewer_refuses_a_file_it_cannot_parse() {
let out = pipe(&["--tui"], "MSHzzzz\rPID|1\r");
assert_eq!(code(&out), 2);
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("no parsable messages"), "{err}");
}
#[test]
fn several_files_are_reported_under_their_own_headings() {
let out = run(&["examples/adt_a01.hl7", "examples/oru_r01.hl7"]);
let text = stdout(&out);
assert!(text.contains("adt_a01.hl7"), "{text}");
assert!(text.contains("oru_r01.hl7"), "{text}");
}
#[test]
fn batch_wrappers_are_skipped_with_a_note() {
let msg = "FHS|^~\\&|A|B\rBHS|^~\\&|A|B\r\
MSH|^~\\&|A|B|C|D|20240115143200||ADT^A01|1|P|2.5.1\r\
EVN|A01|20240115143200\r\
PID|1||1^^^A^MR||Smith^John||19850312|M\r\
PV1|1|I|E^1^A\r\
BTS|1\rFTS|1\r";
let text = stdout(&pipe(&["-v"], msg));
assert!(text.contains("batch wrapper"), "{text}");
assert!(
text.contains("ADT^A01"),
"the message is still read:\n{text}"
);
}
#[test]
fn each_message_in_a_batch_is_headed_by_its_position() {
let text = stdout(&run(&["examples/batch.hl7"]));
assert!(text.contains("message 1 of 2"), "{text}");
assert!(text.contains("message 2 of 2"), "{text}");
}
#[test]
fn a_field_query_reaches_repetitions_and_subcomponents() {
let msg = "MSH|^~\\&|A|B|C|D|20240115143200||ADT^A01|1|P|2.5.1\r\
EVN|A01|20240115143200\r\
PID|1||111^^^A^MR~222^^^B^PI||Smith^John||19850312|M\r\
PV1|1|I|E^1^A\r";
let all = stdout(&pipe(&["-f", "PID-3.1"], msg));
assert!(all.contains("111") && all.contains("222"), "{all}");
let second = stdout(&pipe(&["-f", "PID-3[2].1"], msg));
assert_eq!(second.trim(), "222");
let sub = stdout(&pipe(&["-f", "PID-5.1.1"], msg));
assert_eq!(sub.trim(), "Smith");
let missing = pipe(&["-f", "ZZZ-1"], msg);
assert_eq!(code(&missing), 1);
assert!(stdout(&missing).trim().is_empty());
}
#[test]
fn json_status_says_warning_when_warnings_are_all_there_is() {
let msg = "MSH|^~\\&|A|B|C|D|20240115143200||ADT^A01|1|P|2.5.1\r\
EVN|A01|20240115143200\r\
PID|1||1^^^A^MR||Smith^John||19850312|M\r\
PV1|1|I|E^1^A\r";
let out = pipe(&["--json"], msg);
let value: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
assert_eq!(value["status"], "warning", "{}", stdout(&out));
assert_eq!(code(&out), 0, "warnings alone do not fail without --strict");
}
#[test]
fn the_same_message_is_read_against_the_version_it_declares() {
let with_version = |version: &str| {
let mut pid = vec![String::new(); 39];
pid[0] = "1".into();
pid[2] = "1^^^A^MR".into();
pid[4] = "Smith^John".into();
pid[6] = "19850312".into();
pid[7] = "M".into();
pid[38] = "X^Tribe^L".into();
format!(
"MSH|^~\\&|A|B|C|D|20240115143200||ADT^A01|1|P|{version}\r\
EVN|A01|20240115143200\r\
PID|{}\r\
PV1|1|I|E^1^A\r",
pid.join("|")
)
};
let older = stdout(&pipe(&["-v", "--width", "160"], &with_version("2.4")));
assert!(
older.contains("beyond the last defined field"),
"2.4 stops at PID-38:\n{older}"
);
let newer = stdout(&pipe(&["-v", "--width", "160"], &with_version("2.5")));
assert!(
!newer.contains("beyond the last defined field"),
"2.5 defines PID-39:\n{newer}"
);
}
#[test]
fn a_field_is_labelled_the_way_its_version_names_it() {
let message = |version: &str| {
format!(
"MSH|^~\\&|A|B|C|D|20240115143200||ADT^A01|1|P|{version}\r\
EVN|A01|20240115143200\r\
PID|1||1^^^A^MR||Smith^John||19850312|M\r"
)
};
let older = stdout(&pipe(&["--width", "160"], &message("2.3")));
assert!(
older.contains("Patient ID (Internal ID)") && !older.contains("Patient Identifier List"),
"2.3 names PID-3 for the single ID it held:\n{older}"
);
let newer = stdout(&pipe(&["--width", "160"], &message("2.5.1")));
assert!(
newer.contains("Patient Identifier List") && !newer.contains("Patient ID (Internal ID)"),
"2.5.1 names PID-3 for the list it holds:\n{newer}"
);
}
#[test]
fn a_version_is_told_about_a_field_it_does_not_have() {
let msh = |version: &str| {
format!(
"MSH|^~\\&|A|B|C|D|20240115143200||ADT^A01|1|P|{version}|||||||||PROFILE^X\r\
EVN|A01|20240115143200\r"
)
};
let older = stdout(&pipe(&["-v", "--width", "160"], &msh("2.3")));
assert!(
older.contains("beyond the last defined field"),
"2.3 stops before MSH-21:\n{older}"
);
let newer = stdout(&pipe(&["-v", "--width", "160"], &msh("2.4")));
assert!(
!newer.contains("beyond the last defined field"),
"2.4 defines MSH-21:\n{newer}"
);
}