use std::io::Write;
use std::process::{Command, Stdio};
fn canboat() -> &'static str {
env!("CARGO_BIN_EXE_canboat")
}
fn run(args: &[&str], input: &[u8]) -> Vec<u8> {
let mut child = Command::new(canboat())
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn canboat");
child
.stdin
.as_mut()
.unwrap()
.write_all(input)
.expect("write stdin");
let out = child.wait_with_output().expect("wait canboat");
assert!(
out.status.success(),
"canboat {args:?} exited {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
out.stdout
}
#[test]
fn two_buses_are_refused_before_any_output() {
let out = Command::new(canboat())
.args([
"convert",
"--to",
"plain",
"--protocol",
"nmea2000",
"--protocol",
"j1939",
])
.stdin(Stdio::null())
.output()
.expect("run canboat");
assert!(!out.status.success());
assert!(out.stdout.is_empty());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("not supported yet"), "{stderr}");
}
const J1939_PLAIN: &[u8] = b"2026-01-01T00:00:00.000Z,6,65253,0,255,8,10,27,00,00,40,42,0f,00\n";
const J1939_BODY: &str = ",6,65253,0,255,8,10,27,00,00,40,42,0f,00\n";
const PLAIN: &[u8] = b"2026-01-01T00:00:00.000Z,3,127251,27,255,8,00,ca,8f,f3,ff,25,02,ff\n";
const BODY: &str = ",3,127251,27,255,8,00,ca,8f,f3,ff,25,02,ff";
#[test]
fn ydwg02_round_trips_to_plain() {
let ydwg = run(&["convert", "--to", "ydwg02"], PLAIN);
assert!(
String::from_utf8_lossy(&ydwg).contains(" R 0DF1131B "),
"ydwg: {ydwg:?}"
);
let back = run(&["convert", "--from", "ydwg02", "--to", "plain"], &ydwg);
let s = String::from_utf8(back).unwrap();
assert!(s.contains(BODY), "round-trip lost the body: {s}");
}
#[test]
fn mxpgn_round_trips_to_mxpgn() {
let input = "$MXPGN,01F805,3824,C69C803AF1E62B00*16\r\n\
$MXPGN,01F805,3824,EB49AA3812000D01*60\r\n\
$MXPGN,01F805,3824,FB95440C00075102*6D\r\n\
$MXPGN,01F805,3824,0033E14000B81503*1A\r\n\
$MXPGN,01F805,3824,09FC130000000004*1D\r\n\
$MXPGN,01F805,3824,FFFFFF008C005A05*1D\r\n\
$MXPGN,01F805,3824,FFFFFFFFFF007F06*60\r\n\
$MXPGN,01F801,2801,C1308AC40C5DE343*19\r\n";
let out = run(
&["convert", "--from", "mxpgn", "--to", "mxpgn"],
input.as_bytes(),
);
assert_eq!(String::from_utf8(out).unwrap(), input);
}
#[test]
fn mxpgn_frames_become_whole_seasmart_messages() {
let input = "$MXPGN,01F805,3824,C69C803AF1E62B00*16\r\n\
$MXPGN,01F805,3824,EB49AA3812000D01*60\r\n\
$MXPGN,01F805,3824,FB95440C00075102*6D\r\n\
$MXPGN,01F805,3824,0033E14000B81503*1A\r\n\
$MXPGN,01F805,3824,09FC130000000004*1D\r\n\
$MXPGN,01F805,3824,FFFFFF008C005A05*1D\r\n\
$MXPGN,01F805,3824,FFFFFFFFFF007F06*60\r\n\
$MXPGN,01F801,2801,C1308AC40C5DE343*19\r\n";
let out = run(
&["convert", "--from", "mxpgn", "--to", "seasmart"],
input.as_bytes(),
);
let pcdin = String::from_utf8(out).unwrap();
assert_eq!(
pcdin,
"$PCDIN,01F805,00000000,24,E6F13A809CC60D001238AA49EB5107000C4495FB15B80040E1\
33000000000013FC095A008C00FFFFFF7F00*2D\r\n\
$PCDIN,01F801,00000000,01,43E35D0CC48A30C1*23\r\n"
);
let back = run(
&["convert", "--from", "seasmart", "--to", "plain"],
pcdin.as_bytes(),
);
let back = String::from_utf8(back).unwrap();
assert!(
back.contains(",0,129025,1,255,8,43,e3,5d,0c,c4,8a,30,c1"),
"{back}"
);
assert!(back.contains(",0,129029,36,255,43,e6,f1,3a,80,"), "{back}");
}
#[test]
fn ignore_checksum_takes_a_wrong_one() {
let wrong = b"$PCDIN,01F119,00000000,0F,2AAF00D1067414FF*21\n";
let out = run(&["convert", "--from", "seasmart", "--to", "plain"], wrong);
assert!(out.is_empty(), "{}", String::from_utf8_lossy(&out));
let out = run(&["convert", "--to", "plain", "--ignore-checksum"], wrong);
let out = String::from_utf8(out).unwrap();
assert!(
out.contains(",0,127257,15,255,8,2a,af,00,d1,06,74,14,ff"),
"{out}"
);
}
#[test]
fn actisense_round_trips_to_plain() {
let acti = run(&["convert", "--to", "actisense"], PLAIN);
assert!(
String::from_utf8_lossy(&acti).starts_with('A'),
"acti: {acti:?}"
);
let back = run(&["convert", "--from", "actisense", "--to", "plain"], &acti);
let s = String::from_utf8(back).unwrap();
assert!(s.contains(BODY), "round-trip lost the body: {s}");
}
#[test]
fn json_leads_with_the_version_banner() {
let out = run(&["convert", "--to", "json"], PLAIN);
let s = String::from_utf8(out).unwrap();
let banner = s.lines().next().expect("a first line");
assert!(banner.starts_with(r#"{"version":"#), "banner: {banner}");
assert!(banner.contains(r#""units":"si""#), "banner: {banner}");
assert!(
banner.contains(r#""showLookupValues":false"#),
"banner: {banner}"
);
assert!(
s.lines()
.nth(1)
.is_some_and(|l| l.contains("\"pgn\":127251"))
);
}
#[test]
fn banner_tracks_the_output_shape() {
let metric = String::from_utf8(run(
&["convert", "--to", "json", "--units", "metric", "--nv"],
PLAIN,
))
.unwrap();
let banner = metric.lines().next().unwrap();
assert!(banner.contains(r#""units":"std""#), "banner: {banner}");
assert!(
banner.contains(r#""showLookupValues":true"#),
"banner: {banner}"
);
}
#[test]
fn banner_names_the_protocol() {
let n2k = String::from_utf8(run(&["convert", "--to", "json"], PLAIN)).unwrap();
let banner = n2k.lines().next().unwrap();
assert!(
banner.contains(r#""protocol":"nmea2000""#),
"banner: {banner}"
);
let j1939 = String::from_utf8(run(
&["convert", "--to", "json", "--protocol", "j1939"],
J1939_PLAIN,
))
.unwrap();
let banner = j1939.lines().next().unwrap();
assert!(banner.contains(r#""protocol":"j1939""#), "banner: {banner}");
}
#[test]
fn a_j1939_json_stream_needs_protocol_j1939() {
let json = run(
&["convert", "--to", "json", "--protocol", "j1939"],
J1939_PLAIN,
);
let plain = String::from_utf8(run(
&[
"convert",
"--from",
"json",
"--to",
"plain",
"--protocol",
"j1939",
],
&json,
))
.unwrap();
assert!(plain.ends_with(J1939_BODY), "round trip: {plain}");
let out = Command::new(canboat())
.args(["convert", "--from", "json", "--to", "plain"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.and_then(|mut child| {
child.stdin.take().unwrap().write_all(&json)?;
child.wait_with_output()
})
.expect("run canboat");
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--protocol j1939"), "{stderr}");
}
#[test]
fn no_banner_suppresses_it_and_text_never_has_one() {
let bare = String::from_utf8(run(&["convert", "--to", "json", "--no-banner"], PLAIN)).unwrap();
assert!(
!bare.contains("\"version\""),
"banner leaked through --no-banner: {bare}"
);
let text = String::from_utf8(run(&["convert", "--to", "text"], PLAIN)).unwrap();
assert!(!text.contains("\"version\""), "text got a banner: {text}");
}
const HEADING: &[u8] = b"2026-01-01T00:00:00.000Z,3,127250,27,255,8,00,ca,8f,ff,7f,ff,7f,fd\n";
#[test]
fn json_round_trips_through_its_own_banner() {
let json = run(&["convert", "--to", "json", "--nv"], HEADING);
assert!(String::from_utf8_lossy(&json).starts_with(r#"{"version":"#));
let back = run(&["convert", "--from", "json", "--to", "plain"], &json);
assert_eq!(
String::from_utf8(back).unwrap().as_bytes(),
HEADING,
"round trip through the banner is not byte-exact"
);
}
#[test]
fn json_input_follows_the_banners_units() {
let metric = run(&["convert", "--to", "json", "--units", "metric"], HEADING);
let s = String::from_utf8(metric.clone()).unwrap();
assert!(s.lines().next().unwrap().contains(r#""units":"std""#));
assert!(s.contains(r#""heading":210.9"#), "metric json: {s}");
let si = String::from_utf8(run(&["convert", "--from", "json"], &metric)).unwrap();
assert!(si.lines().next().unwrap().contains(r#""units":"si""#));
assert!(si.contains(r#""heading":3.68"#), "si json: {si}");
}
#[test]
fn the_banner_outranks_the_units_flag_on_input() {
let si_json = run(&["convert", "--to", "json"], HEADING);
let back = run(
&[
"convert", "--from", "json", "--to", "plain", "--units", "metric",
],
&si_json,
);
assert_eq!(
String::from_utf8(back).unwrap().as_bytes(),
HEADING,
"banner-declared input units were ignored"
);
}
#[test]
fn actisense_ebl_emits_binary_framing() {
let ebl = run(&["convert", "--to", "actisense-ebl"], PLAIN);
assert!(ebl.len() > 16, "ebl too short: {ebl:?}");
assert_eq!(&ebl[..2], &[0x1b, 0x01], "expected ESC SOH opener");
}
fn without_timestamps(json: &[u8]) -> String {
let text = String::from_utf8_lossy(json);
let mut out = String::new();
let mut rest = text.as_ref();
while let Some(i) = rest.find("\"timestamp\":\"") {
out.push_str(&rest[..i]);
let after = &rest[i + "\"timestamp\":\"".len()..];
let end = after
.find("\",")
.expect("timestamp is followed by another field");
rest = &after[end + 2..];
}
out.push_str(rest);
out
}
#[test]
fn bst_d0_decodes_as_pgn_test() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../analyzer/tests");
let plain = std::fs::read(dir.join("pgn-test.in")).expect("read pgn-test.in");
let bst = std::fs::read(dir.join("pgn-test-bst-d0.bin")).expect("read fixture");
let want = without_timestamps(&run(&["convert", "--no-banner"], &plain));
let got = without_timestamps(&run(
&["convert", "--no-banner", "--from", "n2k-actisense"],
&bst,
));
assert_eq!(got.lines().count(), 31);
assert_eq!(got, want);
}
#[test]
fn bst_95_decodes_as_pgn_test() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../analyzer/tests");
let plain = std::fs::read(dir.join("pgn-test.in")).expect("read pgn-test.in");
let bst = std::fs::read(dir.join("pgn-test-bst-95.bin")).expect("read fixture");
let want = without_timestamps(&run(&["convert", "--no-banner"], &plain));
let got = without_timestamps(&run(
&["convert", "--no-banner", "--from", "raw-actisense"],
&bst,
));
assert_eq!(got.lines().count(), 31);
assert_eq!(got, want);
}
#[test]
fn usb_can_a_decodes_as_pgn_test() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../analyzer/tests");
let plain = std::fs::read(dir.join("pgn-test.in")).expect("read pgn-test.in");
let capture = std::fs::read(dir.join("pgn-test-usb-can-a.bin")).expect("read fixture");
assert_eq!(capture.len(), 115 * 20);
let want = without_timestamps(&run(&["convert", "--no-banner"], &plain));
let got = without_timestamps(&run(
&["convert", "--no-banner", "--from", "usb-can-a"],
&capture,
));
assert_eq!(got.lines().count(), 31);
assert_eq!(got, want);
}
fn candump_matches_candump2analyzer(name: &str) -> usize {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../candump2analyzer/tests");
let input = std::fs::read(dir.join(format!("{name}.in"))).expect("read .in");
let want = std::fs::read_to_string(dir.join(format!("{name}.out"))).expect("read .out");
let out = run(&["convert", "--no-banner", "--to", "plain"], &input);
let got: String = String::from_utf8(out)
.expect("utf-8")
.lines()
.map(|l| format!("{}\n", l.split_once(',').map_or(l, |(_, rest)| rest)))
.collect();
assert_eq!(got, want.replace("\r\n", "\n"), "{name}");
got.lines().count()
}
#[test]
fn candump_angstrom_matches_candump2analyzer() {
assert_eq!(candump_matches_candump2analyzer("angstrom"), 51);
}
#[test]
fn candump_bad_lengths_match_candump2analyzer() {
assert_eq!(candump_matches_candump2analyzer("angstrom-bad-length"), 5);
assert_eq!(candump_matches_candump2analyzer("debian-bad-length"), 2);
}
fn bst95(prio: u8, pgn: u32, src: u8, data: &[u8]) -> Vec<u8> {
let mut m = vec![
0x95,
6 + data.len() as u8,
0,
0,
src,
pgn as u8,
(pgn >> 8) as u8,
(prio << 2) | (pgn >> 16) as u8,
];
m.extend_from_slice(data);
let sum = m.iter().fold(0u8, |s, &b| s.wrapping_add(b));
m.push(0u8.wrapping_sub(sum));
let mut out = vec![0x10, 0x02];
for b in m {
out.push(b);
if b == 0x10 {
out.push(0x10);
}
}
out.extend_from_slice(&[0x10, 0x03]);
out
}
#[test]
fn bst_95_follows_the_protocol() {
let frame = bst95(6, 130816, 0x2a, &[0x20, 0x14, 2, 3, 4, 5, 6, 7]);
let j1939 = run(
&[
"convert",
"--from",
"raw-actisense",
"--protocol",
"j1939",
"--to",
"plain",
],
&frame,
);
let j1939 = String::from_utf8(j1939).unwrap();
assert!(
j1939.contains(",6,130816,42,255,8,20,14,02,03,04,05,06,07"),
"{j1939}"
);
let n2k = run(
&["convert", "--from", "raw-actisense", "--to", "plain"],
&frame,
);
assert!(
!String::from_utf8(n2k).unwrap().contains("130816"),
"on NMEA 2000 it is the first frame of a fast-packet"
);
}