tests: rework the actually useful tests to live in the integration crate
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Rust CI / cargo doc (push) Successful in 23s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m11s

This commit is contained in:
Nils Andreas Svee committed 2026-10-03 13:59:38 +07:00
1 parent 74e7532965
commit 48a50d7f3a
101 files changed
+3636 -15159

No files matched your search

-192
View File
@@ -151,195 +151,3 @@ pub enum HeaderMode {
/// Never print headers.
Never,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
use irtt_client::NegotiationPolicy;
use irtt_proto::{Clock, ReceivedStats, StampAt};
fn parse(args: &[&str]) -> Result<ClientArgs, clap::Error> {
let mut argv = vec!["irtt-client"];
argv.extend_from_slice(args);
ClientArgs::try_parse_from(argv)
}
#[test]
fn client_parser_keeps_finite_default_duration() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.targets.len(), 1);
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
assert_eq!(args.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
assert_eq!(args.interval, Duration::from_secs(1));
assert_eq!(args.format, OutputFormat::Table);
assert_eq!(args.columns, None);
assert_eq!(args.header, HeaderMode::Auto);
assert!(!args.targets_stdin);
assert!(!args.is_continuous());
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
let config = args.prepare().unwrap().client;
assert_eq!(config.duration, Some(DEFAULT_CLIENT_DURATION));
assert_eq!(config.received_stats, ReceivedStats::Both);
assert_eq!(config.stamp_at, StampAt::Both);
assert_eq!(config.clock, Clock::Both);
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
}
#[test]
fn client_parser_accepts_stream_formats_and_rejects_tui_output() {
for format in ["table", "csv", "tsv", "jsonl"] {
assert!(parse(&["--format", format, "127.0.0.1:2112"]).is_ok());
}
assert!(parse(&["--format", "tui", "127.0.0.1:2112"]).is_err());
}
#[test]
fn list_columns_does_not_require_server() {
let args = parse(&["--list-columns"]).unwrap();
assert!(args.targets.is_empty());
assert!(args.list_columns);
// The parser accepts no target; preparation is where a target becomes
// mandatory, and the stream command returns before preparing when
// listing columns.
let err = args.prepare().unwrap_err();
assert!(err.contains("--list-columns"));
}
#[test]
fn multiple_positional_targets_parse_as_targets() {
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
let specs = args.prepare().unwrap().targets;
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[0].managed.server_addr, "host-a:2112");
assert_eq!(specs[1].label, "host-b:2112");
assert_eq!(specs[1].managed.server_addr, "host-b:2112");
}
#[test]
fn repeated_labeled_targets_parse() {
let args = parse(&["ams=ams.example.com:2112", "sg=sg.example.com:2112"]).unwrap();
let specs = args.prepare().unwrap().targets;
assert_eq!(specs[0].label, "ams");
assert_eq!(specs[0].managed.server_addr, "ams.example.com:2112");
assert_eq!(specs[1].label, "sg");
assert_eq!(specs[1].managed.server_addr, "sg.example.com:2112");
}
#[test]
fn mixed_labeled_and_unlabeled_targets_preserve_argument_order() {
let args = parse(&["local.example", "eu=a.example", "backup.example"]).unwrap();
let specs = args.prepare().unwrap().targets;
let labels: Vec<_> = specs.iter().map(|target| target.label.as_str()).collect();
assert_eq!(labels, ["local.example", "eu", "backup.example"]);
}
#[test]
fn duplicate_labels_are_rejected() {
let args = parse(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap();
let err = args.prepare().unwrap_err();
assert!(err.contains("duplicate target label"));
}
#[test]
fn duplicate_positional_target_strings_get_stable_suffixes() {
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
let specs = args.prepare().unwrap().targets;
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[1].label, "host-a:2112#2");
}
#[test]
fn duplicate_target_endpoints_are_allowed() {
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
assert_eq!(args.prepare().unwrap().target_count(), 2);
}
#[test]
fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
assert!(parse(&["label="]).unwrap().prepare().is_err());
}
#[test]
fn old_target_option_is_rejected() {
assert!(parse(&["--target", "eu=host.example"]).is_err());
}
#[test]
fn pacing_option_accepts_supported_values() {
assert_eq!(
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
.unwrap()
.pacing,
GroupPacingArg::Staggered
);
assert_eq!(
parse(&["--pacing", "burst", "127.0.0.1:2112"])
.unwrap()
.pacing,
GroupPacingArg::Burst
);
}
#[test]
fn client_help_lists_shared_protocol_options() {
let help = ClientArgs::command().render_help().to_string();
assert!(help.contains("--format <FORMAT>"));
assert!(!help.contains("--target "));
assert!(help.contains("--pacing <PACING>"));
assert!(help.contains("--columns <COLUMNS>"));
assert!(help.contains("--list-columns"));
assert!(help.contains("--header <HEADER>"));
assert!(help.contains("--tstamp <MODE>"));
assert!(help.contains("--stats <STATS>"));
assert!(help.contains("--sfill <STRING>"));
assert!(help.contains("--dscp <0..=63>"));
assert!(help.contains("--ttl <1..=255>"));
assert!(help.contains("--loose"));
}
#[test]
fn an_empty_hmac_key_is_rejected() {
let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err();
assert!(err.to_string().contains("HMAC key must not be empty"));
}
#[test]
fn a_non_empty_hmac_key_is_accepted() {
let args = parse(&["--hmac", "secret", "127.0.0.1:2112"]).unwrap();
let config = args.prepare().unwrap().client;
assert_eq!(config.hmac_key, Some(b"secret".to_vec()));
}
#[test]
fn targets_stdin_requires_continuous_mode_and_allows_empty_initial_set() {
assert!(parse(&["--targets-stdin"]).unwrap().prepare().is_err());
let args = parse(&["--duration", "0", "--targets-stdin"]).unwrap();
let setup = args.prepare().unwrap();
assert!(setup.targets.is_empty());
assert!(setup.stdin_controlled);
let config = setup.managed_config();
assert_eq!(
config.completion,
irtt_client::managed::ManagedCompletionPolicy::ExplicitStop
);
assert_eq!(config.max_live_target_generations, 256);
}
#[test]
fn output_format_summary_policy_is_table_only() {
assert!(OutputFormat::Table.prints_summary());
assert!(!OutputFormat::Csv.prints_summary());
assert!(!OutputFormat::Tsv.prints_summary());
assert!(!OutputFormat::Jsonl.prints_summary());
}
}
-180
View File
@@ -1205,183 +1205,3 @@ fn write_json_string_content(out: &mut String, value: &str) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use irtt_client::ClientTimestamp;
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::Instant,
};
fn output_config(format: OutputFormat, columns: Option<&str>) -> OutputConfig {
OutputConfig::new(format, columns, HeaderMode::Auto, false).unwrap()
}
fn remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
fn reply() -> ClientEvent {
let sent_at = ClientTimestamp {
wall: UNIX_EPOCH + Duration::from_secs(1),
mono: Instant::now(),
};
let received_at = ClientTimestamp {
wall: sent_at.wall + Duration::from_micros(1200),
mono: sent_at.mono + Duration::from_micros(1200),
};
ClientEvent::EchoReply {
seq: 7,
remote: remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1200),
adjusted: None,
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
#[test]
fn list_columns_includes_target() {
assert!(OutputConfig::list_columns().contains("target"));
}
#[test]
fn default_table_columns_always_include_target_first() {
let header = output_config(OutputFormat::Table, None)
.render_header()
.unwrap();
assert_eq!(header.split_whitespace().next(), Some("target"));
}
#[test]
fn columns_default_keyword_matches_omitted_columns() {
for format in [
OutputFormat::Table,
OutputFormat::Csv,
OutputFormat::Tsv,
OutputFormat::Jsonl,
] {
let omitted = output_config(format, None).render_header();
let explicit = output_config(format, Some("default")).render_header();
assert_eq!(omitted, explicit);
}
}
#[test]
fn custom_columns_do_not_insert_target() {
let config = output_config(OutputFormat::Csv, Some("seq,effective_rtt_us"));
let header = config.render_header().unwrap();
assert!(!header.split(',').any(|column| column == "target"));
}
#[test]
fn custom_target_column_renders_label() {
let config = output_config(OutputFormat::Csv, Some("target,seq,effective_rtt_us"));
let line = config
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
.unwrap();
assert_eq!(
config.render_header().as_deref(),
Some("target,seq,effective_rtt_us")
);
assert_eq!(line, "ams,7,1200");
}
#[test]
fn default_csv_renders_target_label_in_row() {
let config = output_config(OutputFormat::Csv, None);
let line = config
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
.unwrap();
assert_eq!(line.split(',').next(), Some("ams"));
}
#[test]
fn default_jsonl_renders_target_key_first() {
let config = output_config(OutputFormat::Jsonl, None);
let line = config
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
.unwrap();
assert!(line.starts_with("{\"target\":\"ams\""));
}
#[test]
fn table_row_with_long_target_keeps_next_column_aligned() {
let config = output_config(OutputFormat::Table, None);
let header = config.render_header().unwrap();
let event_offset = header.find("event").unwrap();
for label in ["ams".to_owned(), "a".repeat(40), "é".repeat(40)] {
let line = config
.render_event(&reply(), Some(&label), Some(&EventRenderStats::default()))
.unwrap();
let prefix = line.split("echo_reply").next().unwrap();
assert_eq!(prefix.chars().count(), event_offset, "{line}");
if label.chars().count() > event_offset {
assert!(prefix.trim_end().ends_with("..."), "{line}");
} else {
assert!(prefix.starts_with(&label), "{line}");
}
}
}
#[test]
fn csv_preserves_full_long_target_label() {
let config = output_config(OutputFormat::Csv, Some("target,seq"));
let long_target = "a".repeat(40);
let line = config
.render_event(
&reply(),
Some(&long_target),
Some(&EventRenderStats::default()),
)
.unwrap();
assert_eq!(line, format!("{long_target},7"));
}
#[test]
fn tsv_preserves_full_long_target_label() {
let config = output_config(OutputFormat::Tsv, Some("target,seq"));
let long_target = "b".repeat(40);
let line = config
.render_event(
&reply(),
Some(&long_target),
Some(&EventRenderStats::default()),
)
.unwrap();
assert_eq!(line, format!("{long_target}\t7"));
}
#[test]
fn jsonl_preserves_full_long_target_label() {
let config = output_config(OutputFormat::Jsonl, Some("target,seq"));
let long_target = "c".repeat(40);
let line = config
.render_event(
&reply(),
Some(&long_target),
Some(&EventRenderStats::default()),
)
.unwrap();
assert_eq!(line, format!("{{\"target\":\"{long_target}\",\"seq\":7}}"));
}
}
-286
View File
@@ -938,289 +938,3 @@ fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option
};
Some(format!("irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"))
}
#[cfg(test)]
mod tests {
use super::*;
use irtt_client::managed::{
ManagedLifecycle, ManagedTargetFailure, ManagedTargetFailureKind,
ManagedTargetFailurePhase, ManagedTargetLifecycle, ManagedTargetStatus, TargetId,
};
use std::{io::BufReader, io::Cursor, sync::Arc};
#[test]
fn stdin_record_reader_removes_only_line_framing() {
let mut reader = BufReader::with_capacity(2, Cursor::new(b" first \r\n\nthird\r"));
let mut buffer = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
assert_eq!(
read_stdin_record(&mut reader, &mut buffer).unwrap(),
Some(" first ".to_owned())
);
assert_eq!(
read_stdin_record(&mut reader, &mut buffer).unwrap(),
Some(String::new())
);
assert_eq!(
read_stdin_record(&mut reader, &mut buffer).unwrap(),
Some("third\r".to_owned())
);
assert_eq!(read_stdin_record(&mut reader, &mut buffer).unwrap(), None);
}
#[test]
fn stdin_record_reader_rejects_over_limit_unterminated_record() {
let input = vec![b'x'; MAX_STDIN_RECORD_BYTES + 1];
let mut reader = BufReader::with_capacity(17, Cursor::new(input));
let mut buffer = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
let error = read_stdin_record(&mut reader, &mut buffer).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(error.to_string().contains("maximum size"));
assert!(buffer.len() <= MAX_STDIN_RECORD_BYTES + 1);
}
#[test]
fn newer_mailbox_value_survives_an_earlier_submitted_acknowledgement() {
let mailbox = StdinMailbox::default();
mailbox.publish(vec![ManagedTargetConfig::new("a", "127.0.0.1:2112")]);
let submitted = mailbox.latest().unwrap();
mailbox.publish(vec![ManagedTargetConfig::new("b", "127.0.0.1:2113")]);
let pending = mailbox.latest().unwrap();
mailbox.clear_if_current(submitted.revision);
assert_eq!(mailbox.latest().unwrap().revision, pending.revision);
}
#[test]
fn rejected_update_retries_after_status_publishes_during_acknowledgement() {
let mailbox = StdinMailbox::default();
mailbox.publish(Vec::new());
let target_set = mailbox.latest().unwrap();
let saturated = Arc::new(status_with_targets(&[TargetInstance {
id: TargetId::from("old"),
generation: 1,
}]));
let capacity_freed = Arc::new(status_with_targets(&[]));
// Capacity becomes available after the transaction rejected but before
// its acknowledgement reaches the controller. The rejection path must
// retain the pre-submission status, not this later snapshot.
let mut retry_after =
retry_after_capacity_rejection(&mailbox, &target_set, Arc::clone(&saturated));
assert!(retry_waits_for_status_change(
&mut retry_after,
&target_set,
&saturated
));
assert!(!retry_waits_for_status_change(
&mut retry_after,
&target_set,
&capacity_freed
));
}
#[test]
fn later_desired_set_supersedes_a_capacity_rejected_candidate_before_retry() {
let mailbox = StdinMailbox::default();
mailbox.publish(vec![ManagedTargetConfig::new("a", "127.0.0.1:2112")]);
let applied = mailbox.latest().unwrap();
mailbox.clear_if_current(applied.revision);
mailbox.publish(vec![ManagedTargetConfig::new("b", "127.0.0.1:2113")]);
let rejected = mailbox.latest().unwrap();
let saturated = Arc::new(status_with_targets(&[TargetInstance {
id: TargetId::from("old"),
generation: 1,
}]));
let mut retry_after = retry_after_capacity_rejection(&mailbox, &rejected, saturated);
mailbox.publish(vec![ManagedTargetConfig::new("c", "127.0.0.1:2114")]);
let replacement = mailbox.latest().unwrap();
assert!(!retry_waits_for_status_change(
&mut retry_after,
&replacement,
&Arc::new(status_with_targets(&[TargetInstance {
id: TargetId::from("old"),
generation: 1,
}]))
));
assert!(retry_after.is_none());
assert_eq!(replacement.targets[0].id.as_str(), "c");
mailbox.clear_if_current(replacement.revision);
assert!(mailbox.latest().is_none());
}
fn status_with_targets(targets: &[TargetInstance]) -> ManagedStatus {
ManagedStatus {
lifecycle: ManagedLifecycle::Running,
stop_requested: false,
applied_command_sequence: 0,
desired_target_count: targets.len(),
connecting_target_count: 0,
opening_target_count: 0,
active_target_count: 0,
draining_target_count: 0,
closing_target_count: 0,
terminal_target_count: 0,
total_target_outcomes: 0,
successful_target_outcomes: 0,
failed_target_outcomes: 0,
peer_closed_target_outcomes: 0,
discarded_target_outcomes: 0,
targets: Arc::from(
targets
.iter()
.cloned()
.map(|target| ManagedTargetStatus {
target,
desired: true,
lifecycle: ManagedTargetLifecycle::Active,
server_addr: Arc::from("127.0.0.1:2112"),
remote: None,
})
.collect::<Vec<_>>()
.into_boxed_slice(),
),
recent_target_outcomes: Arc::from([]),
final_outcome: None,
}
}
#[test]
fn dynamic_accounting_prunes_retired_generations_and_retains_only_shutdown_snapshot() {
let retired = TargetInstance {
id: TargetId::from("edge"),
generation: 1,
};
let replacement = TargetInstance {
id: TargetId::from("edge"),
generation: 2,
};
let live_status = status_with_targets(std::slice::from_ref(&replacement));
let empty_status = status_with_targets(&[]);
let mut stats = BTreeMap::from([
(retired.clone(), StatsCollector::new(stats_config(true))),
(replacement.clone(), StatsCollector::new(stats_config(true))),
]);
reconcile_stdin_stats(&mut stats, &live_status, None);
assert_eq!(stats.keys().cloned().collect::<Vec<_>>(), [replacement]);
let summary_targets = snapshot_stdin_summary_targets(&mut stats, &live_status);
stats.insert(retired, StatsCollector::new(stats_config(true)));
reconcile_stdin_stats(&mut stats, &empty_status, Some(&summary_targets));
assert_eq!(
stats.keys().cloned().collect::<Vec<_>>(),
[TargetInstance {
id: TargetId::from("edge"),
generation: 2,
}]
);
}
#[test]
fn terminal_deduplication_is_bounded_under_target_churn() {
let mut terminal_targets = BoundedTargetSet::new(2);
let target = |generation| TargetInstance {
id: TargetId::from("edge"),
generation,
};
assert!(terminal_targets.insert(target(1)));
assert!(terminal_targets.insert(target(2)));
assert!(terminal_targets.insert(target(3)));
assert!(terminal_targets.insert(target(1)));
assert!(!terminal_targets.insert(target(3)));
}
fn long_finite_args() -> ClientArgs {
use clap::Parser;
ClientArgs::try_parse_from([
"irtt-client",
"--duration",
"4200s",
"--interval",
"100ms",
"127.0.0.1:2112",
])
.unwrap()
}
/// Smallest target count whose aggregate estimate reaches the warning
/// threshold, derived from the stats crate's own estimate rather than from
/// any bytes-per-probe assumption held here.
fn first_warning_target_count(args: &ClientArgs) -> usize {
let probes = expected_probe_count(args.duration, args.interval);
(1..=1024)
.find(|targets| {
let total = probes.saturating_mul(*targets as u64);
stats_config(false).estimated_retained_bytes(total)
>= FINITE_STATS_MEMORY_WARNING_BYTES
})
.expect("a long finite run should cross the warning threshold within 1024 targets")
}
#[test]
fn finite_stats_memory_warning_accounts_for_target_count() {
let args = long_finite_args();
let crossing = first_warning_target_count(&args);
assert!(
crossing > 1,
"this run should need more than one target to cross the threshold, \
otherwise the test proves nothing about target-count accounting"
);
assert!(
finite_stats_memory_warning(&args, crossing - 1).is_none(),
"an aggregate estimate below the threshold should not warn"
);
assert!(
finite_stats_memory_warning(&args, crossing).is_some(),
"an aggregate estimate at the threshold should warn"
);
}
#[test]
fn finite_stats_memory_warning_saturates_target_count_multiplication() {
assert!(finite_stats_memory_warning(&long_finite_args(), usize::MAX).is_some());
}
#[test]
fn target_failure_diagnostics_include_primary_and_cleanup_failures() {
let target = ManagedTargetOutcome {
target: TargetInstance {
id: TargetId::from("edge"),
generation: 1,
},
server_addr: Arc::from("127.0.0.1:2112"),
remote: None,
end_reason: ManagedTargetEndReason::Failed(ManagedTargetFailure {
phase: ManagedTargetFailurePhase::Receiving,
kind: ManagedTargetFailureKind::Protocol,
message: "primary failure".into(),
}),
packets_sent: 0,
replies_received: 0,
duplicates: 0,
late: 0,
warning_events: 0,
cleanup_failure: Some(ManagedTargetFailure {
phase: ManagedTargetFailurePhase::Closing,
kind: ManagedTargetFailureKind::Socket,
message: "cleanup failure".into(),
}),
};
assert_eq!(
target_failure_messages(&target),
[
"irtt-rs: target edge failed (receiving protocol): primary failure",
"irtt-rs: target edge cleanup failed (closing socket): cleanup failure",
]
);
}
}
-254
View File
@@ -121,257 +121,3 @@ fn write_time_row(out: &mut String, label: &str, value: &TimeStats) {
)
.unwrap();
}
#[cfg(test)]
mod tests {
use super::*;
use irtt_client::{
ClientEvent, ClientTimestamp, PacketMeta, RttSample, ServerTiming, SignedDuration,
};
use irtt_stats::{StatsCollector, StatsConfig};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::{Duration, Instant, UNIX_EPOCH},
};
fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
#[test]
fn empty_summary_omits_optional_metric_sections() {
let summary = StatsCollector::new(StatsConfig::finite()).snapshot();
let output = format_summary(&summary);
assert!(output.contains("Metric"));
assert!(output.contains("Min"));
assert!(output.contains("Mean"));
assert!(output.contains("Median"));
assert!(output.contains("Max"));
assert!(output.contains("Stddev"));
assert!(output.contains("packets: sent=0 received=0 unique=0 lost=0"));
assert!(!output.contains("RTT 0"));
assert!(!output.contains("server processing"));
}
#[test]
fn summary_formats_counts_and_available_metrics() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let sent_at = test_timestamp(Duration::from_secs(1));
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
collector.process(&ClientEvent::EchoSent {
seq: 1,
remote: test_remote(),
scheduled_at: Some(sent_at.mono),
sent_at,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Some(Duration::from_micros(2)),
});
collector.process(&ClientEvent::EchoReply {
seq: 1,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: Some(ServerTiming {
receive_wall_ns: None,
receive_mono_ns: None,
send_wall_ns: None,
send_mono_ns: None,
midpoint_wall_ns: None,
midpoint_mono_ns: None,
processing: Some(Duration::from_micros(300)),
}),
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
let output = format_summary(&collector.snapshot());
assert!(output.contains("packets: sent=1 received=1 unique=1 lost=0 loss=0.00%"));
assert!(output.contains("bytes: sent=64 received=64"));
assert!(output.contains("RTT"));
assert!(output.contains("1.2ms"));
assert!(!output.contains("raw RTT"));
assert!(!output.contains("adjusted RTT"));
assert!(output.contains("server processing"));
assert!(output.contains("300.0µs"));
assert!(output.contains("send call"));
assert!(output.contains("10.0µs"));
assert!(output.contains("timer error"));
assert!(output.contains("2.0µs"));
assert!(!output.contains("medians unavailable"));
}
/// Returns the Median cell of the row with this label.
fn median_cell(output: &str, label: &str) -> String {
let row = output
.lines()
.find(|line| line.trim_start().starts_with(label))
.unwrap_or_else(|| panic!("summary should contain a {label:?} row:\n{output}"));
// Metric, Count, Min, Mean, Median, ...
// Counting from the right keeps multi-word labels out of the way.
row.split_whitespace()
.rev()
.nth(2)
.unwrap_or_else(|| panic!("row {row:?} should have a Median cell"))
.to_owned()
}
/// Collects probes whose send-call, timer-error, and server-processing
/// values each have a median distinct from their min, mean, and max, so
/// the rendered cell can only come from a real median calculation.
fn spread_finite_summary(config: StatsConfig) -> Snapshot {
let mut collector = StatsCollector::new(config);
for (seq, (send_call_us, timer_error_us, processing_us)) in
[(10_u64, 2_u64, 100_u64), (40, 12, 700), (20, 4, 300)]
.into_iter()
.enumerate()
{
let seq = seq as u32;
let offset = Duration::from_secs(1) + Duration::from_millis(u64::from(seq));
let sent_at = test_timestamp(offset);
let received_at = test_timestamp(offset + Duration::from_micros(1500));
collector.process(&ClientEvent::EchoSent {
seq,
remote: test_remote(),
scheduled_at: Some(sent_at.mono),
sent_at,
bytes: 64,
send_call: Duration::from_micros(send_call_us),
timer_error: Some(Duration::from_micros(timer_error_us)),
});
collector.process(&ClientEvent::EchoReply {
seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: None,
effective: SignedDuration::from_nanos(1_500_000),
},
server_timing: Some(ServerTiming {
receive_wall_ns: None,
receive_mono_ns: None,
send_wall_ns: None,
send_mono_ns: None,
midpoint_wall_ns: None,
midpoint_mono_ns: None,
processing: Some(Duration::from_micros(processing_us)),
}),
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
}
collector.snapshot()
}
#[test]
fn finite_summary_shows_medians_for_send_call_timer_error_and_processing() {
let output = format_summary(&spread_finite_summary(StatsConfig::finite()));
// Medians of 10/20/40, 2/4/12, and 100/300/700 µs. None of these
// equals the row's min, mean, or max.
assert_eq!(median_cell(&output, "send call"), "20.0µs");
assert_eq!(median_cell(&output, "timer error"), "4.0µs");
assert_eq!(median_cell(&output, "server processing"), "300.0µs");
}
#[test]
fn continuous_summary_still_shows_no_medians_for_those_rows() {
let output = format_summary(&spread_finite_summary(StatsConfig::continuous()));
for label in ["send call", "timer error", "server processing"] {
assert_eq!(
median_cell(&output, label),
"-",
"continuous mode retains no exact samples for {label}"
);
}
}
#[test]
fn verbose_summary_includes_raw_and_adjusted_rtt_rows() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
collector.process(&ClientEvent::EchoReply {
seq: 1,
remote: test_remote(),
sent_at: test_timestamp(Duration::from_secs(1)),
received_at,
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
let output = format_summary_with_options(
&collector.snapshot(),
SummaryFormatOptions {
verbose: true,
..SummaryFormatOptions::default()
},
);
assert!(output.contains("raw RTT"));
assert!(output.contains("adjusted RTT"));
}
#[test]
fn running_only_note_is_format_option() {
let summary = StatsCollector::new(StatsConfig::continuous()).snapshot();
let output = format_summary_with_options(
&summary,
SummaryFormatOptions {
show_running_only_note: true,
..SummaryFormatOptions::default()
},
);
assert!(output.contains("irtt-rs summary"));
assert!(output.contains("medians unavailable"));
assert!(output.contains("continuous mode"));
}
#[test]
fn signed_stats_can_format_negative_values() {
let stats = TimeStats {
count: 1,
total_ns: -500,
min_ns: Some(-500),
max_ns: Some(-500),
mean_ns: -500.0,
median_ns: Some(-500.0),
variance_ns2: 0.0,
};
let mut out = String::new();
write_time_row(&mut out, "RTT", &stats);
assert!(out.contains("-500ns"));
}
}