use super::*;
use std::collections::BTreeMap;
fn header_line(body: &str, label: &str) -> String {
format!("{body:<60}{label}")
}
fn obs_text(headers: &[String], body: &str) -> String {
let mut lines = vec![
header_line(
" 3.05 OBSERVATION DATA M (MIXED)",
"RINEX VERSION / TYPE",
),
header_line(
"test test 20200101 000000 UTC",
"PGM / RUN BY / DATE",
),
header_line("QC01", "MARKER NAME"),
header_line("observer agency", "OBSERVER / AGENCY"),
header_line(
"receiver type version",
"REC # / TYPE / VERS",
),
header_line("antenna type", "ANT # / TYPE"),
header_line(
" 0.0000 0.0000 0.0000",
"APPROX POSITION XYZ",
),
header_line(
" 0.0000 0.0000 0.0000",
"ANTENNA: DELTA H/E/N",
),
header_line("G 1 C1C", "SYS / # / OBS TYPES"),
header_line(
" 2020 01 01 00 00 0.0000000 GPS",
"TIME OF FIRST OBS",
),
];
lines.extend(headers.iter().cloned());
lines.push(header_line("", "END OF HEADER"));
lines.extend(body.lines().map(str::to_string));
lines.join("\n")
}
fn gps_epoch(minute: u8, second: f64, value: &str) -> String {
format!("> 2020 01 01 00 {minute:02}{second:11.7} 0 1\nG01{value}")
}
fn finding_code_counts(report: &LintReport) -> BTreeMap<&'static str, usize> {
let mut counts = BTreeMap::new();
for finding in &report.findings {
*counts.entry(finding.code()).or_default() += 1;
}
counts
}
fn expected_counts(expected: &[(&'static str, usize)]) -> BTreeMap<&'static str, usize> {
expected.iter().copied().collect()
}
fn assert_finding_counts(report: &LintReport, expected: &[(&'static str, usize)]) {
assert_eq!(
finding_code_counts(report),
expected_counts(expected),
"{:?}",
report.findings
);
}
fn nav_fixture() -> String {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/nav/ESBC00DNK_R_20201770000_01D_MN.rnx"
);
std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read NAV fixture {path}: {e}"))
}
#[test]
fn committed_obs_fixtures_have_pinned_lint_findings() {
let fixtures = [
("ESBC00DNK_R_20201770000_01D_30S_MO_120epoch.rnx", &[][..]),
(
"ESBC00DNK_R_20201770000_01D_30S_MO_trim.crx",
&[("OBS-H08", 1)][..],
),
(
"ESBC00DNK_R_20201770000_01D_30S_MO_trim.rnx",
&[("OBS-H08", 1)][..],
),
(
"PASA00ESP_R_20261201000_02H_30S_MO.rnx",
&[("OBS-H90", 1)][..],
),
(
"SCOA00FRA_R_20261201000_02H_30S_MO.rnx",
&[("OBS-H90", 1)][..],
),
(
"WTZR00DEU_R_20201770000_01D_30S_MO_120epoch.rnx",
&[("OBS-H10", 1), ("OBS-H90", 1)][..],
),
(
"WTZZ00DEU_R_20201770000_01D_30S_MO_120epoch.rnx",
&[("OBS-H90", 1)][..],
),
(
"ZIM200CHE_R_20261330000_01H_30S_MO_120epoch.rnx",
&[("OBS-H08", 1), ("OBS-H90", 1)][..],
),
(
"algo0010_2015001_v1_trim.crx",
&[("OBS-H01", 1)][..],
),
(
"algo0010_2015001_v1_trim.rnx",
&[("OBS-H01", 1)][..],
),
];
for (fixture, expected) in fixtures {
let path = format!(
"{}/tests/fixtures/obs/{fixture}",
env!("CARGO_MANIFEST_DIR")
);
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
let report = lint_obs_text(&text);
assert_finding_counts(&report, expected);
}
}
#[test]
fn obs_code_tables_accept_fixture_deltas_and_reject_bad_bands() {
let accepted = [
(GnssSystem::Glonass, "C3Q"),
(GnssSystem::Glonass, "D3Q"),
(GnssSystem::Glonass, "L3Q"),
(GnssSystem::Glonass, "S3Q"),
(GnssSystem::Qzss, "C5Q"),
(GnssSystem::Qzss, "D5Q"),
(GnssSystem::Qzss, "L5Q"),
(GnssSystem::Qzss, "S5Q"),
(GnssSystem::Gps, "C1N"),
(GnssSystem::Sbas, "C5Q"),
(GnssSystem::BeiDou, "C7Z"),
];
for (system, code) in accepted {
assert!(is_valid_obs_code(system, code, 3.05), "{system:?} {code}");
}
assert!(!is_valid_obs_code(GnssSystem::Gps, "C9C", 3.05));
}
#[test]
fn obs_lint_reports_time_interval_order_and_repair_fixes_them() {
let headers = [
header_line(
" 2020 01 01 00 00 1.0000000 GPS",
"TIME OF FIRST OBS",
),
header_line(" 60.000", "INTERVAL"),
];
let body = [
gps_epoch(1, 0.0, " 20000000.000 "),
gps_epoch(0, 0.0, " 21000000.000 "),
gps_epoch(0, 30.0, " 20000000.000 "),
gps_epoch(0, 0.0, " 22000000.000 "),
]
.join("\n");
let obs = RinexObs::parse(&obs_text(&headers, &body)).expect("parse OBS");
let report = lint_obs(&obs);
assert_finding_counts(
&report,
&[
("OBS-H07", 1),
("OBS-H09", 1),
("OBS-B01", 2),
("OBS-B02", 1),
],
);
assert!(!report.is_clean());
let repair = repair_obs(
&obs,
&RepairOptions {
set_interval: true,
..RepairOptions::default()
},
);
assert_eq!(
repair
.actions
.iter()
.map(|action| action.id)
.collect::<Vec<_>>(),
vec!["A3", "A4", "A6"]
);
assert_finding_counts(&repair.remaining, &[]);
let second = repair_obs(&repair.repaired, &RepairOptions::default());
assert!(second.actions.is_empty(), "{:?}", second.actions);
}
#[test]
fn obs_lint_reports_time_scale_mismatch_and_repair_fixes_it() {
let headers = [
header_line(
" 2020 01 01 00 00 0.0000000 UTC",
"TIME OF FIRST OBS",
),
header_line(
" 2020 01 01 00 00 30.0000000 GPS",
"TIME OF LAST OBS",
),
];
let body = [
gps_epoch(0, 0.0, " 20000000.000 "),
gps_epoch(0, 30.0, " 20000001.000 "),
]
.join("\n");
let obs = RinexObs::parse(&obs_text(&headers, &body)).expect("parse OBS");
let report = lint_obs(&obs);
assert_finding_counts(&report, &[("OBS-H08", 1)]);
let repair = repair_obs(&obs, &RepairOptions::default());
assert_finding_counts(&repair.remaining, &[]);
assert_eq!(
repair
.repaired
.header
.time_of_first_obs
.map(|(_, scale)| scale),
Some(TimeScale::Utc)
);
assert_eq!(
repair
.repaired
.header
.time_of_last_obs
.map(|(_, scale)| scale),
Some(TimeScale::Utc)
);
}
#[test]
fn obs_drop_empty_satellite_record_is_opt_in() {
let headers = [header_line(
" 2020 01 01 00 00 0.0000000 GPS",
"TIME OF FIRST OBS",
)];
let body = "> 2020 01 01 00 00 0.0000000 0 1\nG01";
let obs = RinexObs::parse(&obs_text(&headers, body)).expect("parse OBS");
let report = lint_obs(&obs);
assert_finding_counts(&report, &[("OBS-B08", 1)]);
let repair = repair_obs(
&obs,
&RepairOptions {
drop_empty_records: true,
..RepairOptions::default()
},
);
assert_eq!(
repair
.actions
.iter()
.map(|action| action.id)
.collect::<Vec<_>>(),
vec!["A7"]
);
assert_finding_counts(&repair.remaining, &[]);
}
#[test]
fn obs_repair_duplicate_epoch_keeps_first_satellite_row() {
let headers = [header_line(
" 2020 01 01 00 00 0.0000000 GPS",
"TIME OF FIRST OBS",
)];
let body = [
gps_epoch(0, 0.0, " 11111111.000 "),
gps_epoch(0, 0.0, " 22222222.000 "),
]
.join("\n");
let obs = RinexObs::parse(&obs_text(&headers, &body)).expect("parse OBS");
let repair = repair_obs(&obs, &RepairOptions::default());
assert_eq!(repair.repaired.epochs.len(), 1);
let g01 = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("G01");
assert_eq!(
repair.repaired.epochs[0].sats[&g01][0].value,
Some(11_111_111.0)
);
assert!(repair.actions[0].message.contains("G01"));
}
#[test]
fn glonass_slot_findings_are_per_satellite_and_check_channel_range() {
let headers = [
header_line(
" 2020 01 01 00 00 0.0000000 GPS",
"TIME OF FIRST OBS",
),
header_line("R 1 C1C", "SYS / # / OBS TYPES"),
];
let body = [
"> 2020 01 01 00 00 0.0000000 0 1\nR01 20000000.000 ",
"> 2020 01 01 00 00 30.0000000 0 1\nR01 20000001.000 ",
]
.join("\n");
let mut obs = RinexObs::parse(&obs_text(&headers, &body)).expect("parse OBS");
let report = lint_obs(&obs);
assert_finding_counts(&report, &[("OBS-H12", 1)]);
obs.header.glonass_slots.insert(1, 99);
let report = lint_obs(&obs);
assert_finding_counts(&report, &[("OBS-H12", 1)]);
assert!(matches!(
report.findings.as_slice(),
[Finding::ObsGlonassSlotIssue {
issue: "invalid channel",
..
}]
));
}
#[test]
fn obs_text_repair_guards_event_special_records() {
let headers = [header_line(
" 2020 01 01 00 00 0.0000000 GPS",
"TIME OF FIRST OBS",
)];
let body = "> 2020 01 01 00 00 0.0000000 2 1\nCOMMENT";
let text = obs_text(&headers, body);
assert!(repair_obs_text(&text, &RepairOptions::default()).is_err());
let repair = repair_obs_text(
&text,
&RepairOptions {
drop_unsupported: true,
..RepairOptions::default()
},
)
.expect("drop unsupported event records");
assert_eq!(
repair
.actions
.iter()
.map(|action| action.id)
.collect::<Vec<_>>(),
vec!["OBS-B11"]
);
}
#[test]
fn obs_text_repair_guards_unretained_header_records() {
let headers = [header_line("payload", "UNSUPPORTED LABEL")];
let text = obs_text(&headers, &gps_epoch(0, 0.0, " 20000000.000 "));
let report = lint_obs_text(&text);
assert_finding_counts(&report, &[("OBS-H90", 1)]);
assert!(repair_obs_text(&text, &RepairOptions::default()).is_err());
let repair = repair_obs_text(
&text,
&RepairOptions {
drop_unsupported: true,
..RepairOptions::default()
},
)
.expect("drop unsupported header");
assert_eq!(
repair
.actions
.iter()
.map(|action| action.id)
.collect::<Vec<_>>(),
vec!["OBS-H90"]
);
assert_finding_counts(&repair.remaining, &[]);
}
#[test]
fn obs_writer_skips_unretained_header_labels() {
let headers = [header_line("payload", "UNSUPPORTED LABEL")];
let text = obs_text(&headers, &gps_epoch(0, 0.0, " 20000000.000 "));
let obs = RinexObs::parse(&text).expect("parse OBS");
assert_eq!(
obs.header.unretained_header_labels,
vec!["UNSUPPORTED LABEL".to_string()]
);
let serialized = obs.to_rinex_string();
assert!(!serialized.contains("UNSUPPORTED LABEL"));
let reparsed = RinexObs::parse(&serialized).expect("parse serialized OBS");
assert!(reparsed.header.unretained_header_labels.is_empty());
}
#[test]
fn rinex4_epoch_extension_and_clock_offset_round_trip() {
let text = [
header_line(
" 4.02 OBSERVATION DATA M (MIXED)",
"RINEX VERSION / TYPE",
),
header_line("G 1 C1C", "SYS / # / OBS TYPES"),
header_line("", "END OF HEADER"),
"> 2026 01 02 03 04 5.0000000 12345 0 1 0.123456789012".to_string(),
"G01 20000000.000 ".to_string(),
]
.join("\n");
let obs = RinexObs::parse(&text).expect("parse RINEX 4 OBS");
assert_eq!(obs.epochs[0].epoch_picoseconds, Some(12345));
assert_eq!(obs.epochs[0].rcv_clock_offset_s, Some(0.123456789012));
let reparsed = RinexObs::parse(&obs.to_rinex_string()).expect("reparse RINEX 4 OBS");
assert_eq!(reparsed, obs);
}
#[test]
fn obs_text_lint_decodes_crinex_before_linting() {
let rnx_path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/obs/ESBC00DNK_R_20201770000_01D_30S_MO_trim.rnx"
);
let crx_path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/obs/ESBC00DNK_R_20201770000_01D_30S_MO_trim.crx"
);
let rnx = std::fs::read_to_string(rnx_path).expect("read RINEX fixture");
let crx = std::fs::read_to_string(crx_path).expect("read CRINEX fixture");
let rnx_report = lint_obs_text(&rnx);
let crx_report = lint_obs_text(&crx);
assert!(!rnx_report.decoded_from_crinex);
assert!(crx_report.decoded_from_crinex);
assert_finding_counts(&crx_report, &[("OBS-H08", 1)]);
assert_finding_counts(&rnx_report, &[("OBS-H08", 1)]);
}
#[test]
fn nav_lint_and_repair_identical_duplicates_and_order() {
let records = parse_nav(&nav_fixture()).expect("parse NAV fixture");
assert!(records.len() >= 2);
let damaged = vec![records[1], records[0], records[0]];
let report = LintReport {
findings: nav_findings(&damaged),
decoded_from_crinex: false,
};
assert_finding_counts(&report, &[("NAV-B02", 1), ("NAV-B03", 1)]);
let repair = repair_nav(&damaged, &RepairOptions::default());
assert_eq!(
repair
.actions
.iter()
.map(|action| action.id)
.collect::<Vec<_>>(),
vec!["A11", "A12"]
);
assert_eq!(repair.records.len(), 2);
assert_finding_counts(&repair.remaining, &[]);
}
#[test]
fn nav_text_lint_reports_header_findings_without_file_io() {
let text = crate::rinex_nav::encode_nav(&[]);
let report = lint_nav_text(&text);
assert_finding_counts(&report, &[("NAV-H02", 1)]);
}
#[test]
fn nav_text_lint_total_parse_failure_does_not_emit_block_drop() {
let report = lint_nav_text("not a RINEX NAV file\n");
assert_finding_counts(&report, &[("NAV-H01", 1), ("NAV-H02", 1)]);
}
#[test]
fn nav_text_lint_reports_lenient_supported_block_drop_only_for_that_block() {
let records = parse_nav(&nav_fixture()).expect("parse NAV fixture");
let text = crate::rinex_nav::encode_nav(&records[..2]);
let mut lines = text.lines().map(str::to_string).collect::<Vec<_>>();
let damaged = lines
.iter_mut()
.find(|line| line.starts_with("C05 2020 06 24 22 00 00"))
.expect("first C05 record");
damaged.replace_range(23..42, " not-a-number ");
let text = lines.join("\n");
let report = lint_nav_text(&text);
assert_finding_counts(&report, &[("NAV-B01", 1), ("NAV-H02", 1)]);
assert!(matches!(
report.findings.as_slice(),
[
Finding::NavDroppedBlock { satellite, .. },
Finding::NavLeapSecondsAbsent { .. },
] if satellite == "C05"
));
}
#[test]
fn teqc_algo0010_oracle_fixture_pins_reported_metrics() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/qc/teqc_algo0010_2015001_v1_trim.json"
);
let raw = std::fs::read_to_string(path).expect("read TEQC oracle");
let oracle: serde_json::Value = serde_json::from_str(&raw).expect("parse TEQC oracle");
assert_eq!(oracle["provenance"]["tool_version"], "teqc 2019Feb25");
assert_eq!(
oracle["source_fixture"]["sha256"],
"f2eae58b37fa267b6f64549de8eb1504473057b46fba1d039b9d2f063b536f22"
);
assert_eq!(oracle["summary"]["possible_observation_epochs"], 2);
assert_eq!(oracle["summary"]["epochs_with_observations"], 2);
assert_eq!(oracle["summary"]["complete_observations"], 39);
assert_eq!(oracle["summary"]["gaps"].as_array().unwrap().len(), 0);
assert_eq!(oracle["summary"]["moving_average_mp12_m"], 0.208985);
assert_eq!(oracle["summary"]["moving_average_mp21_m"], 0.138536);
assert_eq!(
oracle["satellite_completeness"]
.as_array()
.expect("satellite completeness")
.len(),
20
);
}
#[test]
fn nav_text_repair_guards_out_of_scope_records() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/nav/ESBC00DNK_R_20201770000_01D_RN.rnx"
);
let text = std::fs::read_to_string(path).expect("read GLONASS NAV fixture");
assert!(repair_nav_text(&text, &RepairOptions::default()).is_err());
let repair = repair_nav_text(
&text,
&RepairOptions {
drop_unsupported: true,
..RepairOptions::default()
},
)
.expect("drop unsupported NAV records");
assert_eq!(
repair
.actions
.iter()
.map(|action| action.id)
.collect::<Vec<_>>(),
vec!["NAV-B06"]
);
}
#[test]
fn repair_is_idempotent_and_byte_stable_on_committed_obs_fixtures() {
let fixtures = [
"ESBC00DNK_R_20201770000_01D_30S_MO_120epoch.rnx",
"ESBC00DNK_R_20201770000_01D_30S_MO_trim.crx",
"ESBC00DNK_R_20201770000_01D_30S_MO_trim.rnx",
"PASA00ESP_R_20261201000_02H_30S_MO.rnx",
"SCOA00FRA_R_20261201000_02H_30S_MO.rnx",
"WTZR00DEU_R_20201770000_01D_30S_MO_120epoch.rnx",
"WTZZ00DEU_R_20201770000_01D_30S_MO_120epoch.rnx",
"ZIM200CHE_R_20261330000_01H_30S_MO_120epoch.rnx",
];
let options = repair_oracle_options();
for fixture in fixtures {
let input = obs_fixture_text(fixture);
let first = repair_obs_fixture_output(&input, &options);
let repeated = repair_obs_fixture_output(&input, &options);
assert_eq!(first, repeated, "{fixture} repair output changed");
let second = repair_obs_fixture_output(&first, &options);
assert_eq!(first, second, "{fixture} repair was not idempotent");
}
}
#[test]
fn repair_is_idempotent_and_byte_stable_on_committed_nav_fixtures() {
let fixtures = [
"BRDC00GOP_R_20210010000_01D_MN.rnx",
"ESBC00DNK_R_20201770000_01D_MN.rnx",
"ESBC00DNK_R_20201770000_01D_RN.rnx",
"KMS300DNK_R_20221591000_01H_MN.rnx",
];
let options = repair_oracle_options();
for fixture in fixtures {
let input = nav_fixture_text(fixture);
let first = repair_nav_fixture_output(&input, &options);
let repeated = repair_nav_fixture_output(&input, &options);
assert_eq!(first, repeated, "{fixture} repair output changed");
let second = repair_nav_fixture_output(&first, &options);
assert_eq!(first, second, "{fixture} repair was not idempotent");
}
}
#[test]
fn obs_repair_header_only_changes_are_spp_bit_equivalent() {
let input = obs_fixture_text("ESBC00DNK_R_20201770000_01D_30S_MO_trim.rnx");
let mut damaged = RinexObs::parse(&input).expect("parse ESBC OBS");
damaged.header.time_of_last_obs = Some((
ObsEpochTime {
year: 2020,
month: 6,
day: 25,
hour: 23,
minute: 59,
second: 30.0,
},
TimeScale::Gpst,
));
damaged.header.n_satellites = Some(999);
let options = repair_oracle_options();
let repair = repair_obs(&damaged, &options);
assert_eq!(damaged.epochs, repair.repaired.epochs);
assert_finding_counts(&repair.remaining, &[]);
let broadcast = crate::rinex_nav::BroadcastStore::from_nav(&nav_fixture_text(
"ESBC00DNK_R_20201770000_01D_MN.rnx",
))
.expect("parse ESBC NAV");
let before = solve_esbc_first_epoch(&broadcast, &damaged);
let after = solve_esbc_first_epoch(&broadcast, &repair.repaired);
assert_solution_bits_eq(&before, &after);
}
fn repair_oracle_options() -> RepairOptions {
RepairOptions {
set_interval: true,
set_time_of_last_obs: true,
set_obs_counts: true,
drop_empty_records: true,
drop_unsupported: true,
..RepairOptions::default()
}
}
fn obs_fixture_text(fixture: &str) -> String {
let path = format!(
"{}/tests/fixtures/obs/{fixture}",
env!("CARGO_MANIFEST_DIR")
);
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
}
fn nav_fixture_text(fixture: &str) -> String {
let path = format!(
"{}/tests/fixtures/nav/{fixture}",
env!("CARGO_MANIFEST_DIR")
);
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
}
fn repair_obs_fixture_output(input: &str, options: &RepairOptions) -> String {
let repair = repair_obs_text(input, options).expect("repair OBS fixture");
if repair.decoded_from_crinex {
repair_obs_to_crinex_string(&repair).expect("encode repaired CRINEX")
} else {
repair.repaired.to_rinex_string()
}
}
fn repair_nav_fixture_output(input: &str, options: &RepairOptions) -> String {
let repair = repair_nav_text(input, options).expect("repair NAV fixture");
crate::rinex_nav::encode_nav(&repair.records)
}
fn solve_esbc_first_epoch(
broadcast: &crate::rinex_nav::BroadcastStore,
obs: &RinexObs,
) -> crate::spp::ReceiverSolution {
let policy = crate::rinex_obs::SignalPolicy {
codes: [(GnssSystem::Gps, vec!["C1C".to_string()])]
.into_iter()
.collect(),
};
let observations = crate::rinex_obs::pseudoranges(obs, &obs.epochs()[0], &policy)
.expect("extract pseudoranges")
.into_iter()
.map(|(satellite_id, pseudorange_m)| crate::spp::Observation {
satellite_id,
pseudorange_m,
})
.collect();
let approx = obs
.header()
.approx_position_m
.expect("ESBC OBS approximate position");
let inputs = crate::spp::SolveInputs {
observations,
t_rx_j2000_s: 646_315_200.0,
t_rx_second_of_day_s: 0.0,
day_of_year: 177.0,
initial_guess: [approx[0], approx[1], approx[2], 0.0],
corrections: crate::spp::Corrections {
ionosphere: false,
troposphere: true,
},
klobuchar: crate::spp::KlobucharCoeffs {
alpha: [0.0; 4],
beta: [0.0; 4],
},
beidou_klobuchar: None,
galileo_nequick: None,
sbas_iono: None,
glonass_channels: BTreeMap::new(),
met: crate::spp::SurfaceMet {
pressure_hpa: 1013.25,
temperature_k: 288.15,
relative_humidity: 0.5,
},
robust: None,
};
crate::spp::solve(broadcast, &inputs, false).expect("solve SPP")
}
fn assert_solution_bits_eq(
left: &crate::spp::ReceiverSolution,
right: &crate::spp::ReceiverSolution,
) {
assert_eq!(left.position.x_m.to_bits(), right.position.x_m.to_bits());
assert_eq!(left.position.y_m.to_bits(), right.position.y_m.to_bits());
assert_eq!(left.position.z_m.to_bits(), right.position.z_m.to_bits());
assert_eq!(left.geodetic, right.geodetic);
assert_eq!(left.rx_clock_s.to_bits(), right.rx_clock_s.to_bits());
assert_eq!(left.system_clocks_s.len(), right.system_clocks_s.len());
for ((left_system, left_clock), (right_system, right_clock)) in left
.system_clocks_s
.iter()
.zip(right.system_clocks_s.iter())
{
assert_eq!(left_system, right_system);
assert_eq!(left_clock.to_bits(), right_clock.to_bits());
}
assert_eq!(left.dop, right.dop);
assert_eq!(
left.residuals_m
.iter()
.map(|value| value.to_bits())
.collect::<Vec<_>>(),
right
.residuals_m
.iter()
.map(|value| value.to_bits())
.collect::<Vec<_>>()
);
assert_eq!(left.used_sats, right.used_sats);
assert_eq!(left.rejected_sats, right.rejected_sats);
assert_eq!(left.metadata, right.metadata);
}