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"));
}
}
-90
View File
@@ -115,93 +115,3 @@ pub(crate) fn format_optional_count(value: Option<u64>) -> String {
fn absent() -> String {
ABSENT.to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nanosecond_scalars_select_units_at_the_defined_boundaries() {
assert_eq!(format_ns_f64(0.0), "0ns");
assert_eq!(format_ns_f64(999.0), "999ns");
assert_eq!(format_ns_f64(1_000.0), "1.0µs");
assert_eq!(format_ns_f64(999_999.0), "1000.0µs");
assert_eq!(format_ns_f64(1_000_000.0), "1.0ms");
assert_eq!(format_ns_f64(999_999_999.0), "1000.0ms");
assert_eq!(format_ns_f64(1_000_000_000.0), "1.000s");
assert_eq!(format_ns_f64(1_500_000_000.0), "1.500s");
}
#[test]
fn negative_nanosecond_scalars_keep_their_sign() {
assert_eq!(format_ns_f64(-500.0), "-500ns");
assert_eq!(format_ns_f64(-1_500.0), "-1.5µs");
assert_eq!(format_ns_f64(-2_500_000.0), "-2.5ms");
assert_eq!(format_ns_f64(-1_000_000_000.0), "-1.000s");
assert_eq!(format_ns_i128(-1_500), "-1.5µs");
}
#[test]
fn exact_and_floating_nanosecond_scalars_agree() {
for value in [0_i128, 1, 999, 1_000, 1_234_567, -1_234_567, 5_000_000_000] {
assert_eq!(format_ns_i128(value), format_ns_f64(value as f64));
}
}
#[test]
fn microseconds_use_the_micro_sign() {
assert!(format_ns_f64(1_500.0).ends_with("µs"));
assert!(!format_ns_f64(1_500.0).contains("us"));
}
#[test]
fn durations_render_as_nanosecond_scalars() {
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
assert_eq!(format_duration(Duration::from_secs(1)), "1.000s");
}
#[test]
#[cfg(feature = "client")]
fn signed_durations_render_as_nanosecond_scalars() {
assert_eq!(
format_signed_duration(SignedDuration::from_nanos(-1_500)),
"-1.5µs"
);
}
#[test]
fn absent_and_non_finite_scalars_render_as_a_placeholder() {
assert_eq!(format_optional_ns_i128(None), ABSENT);
assert_eq!(format_ns_f64(f64::NAN), ABSENT);
assert_eq!(format_ns_f64(f64::INFINITY), ABSENT);
assert_eq!(format_percent(f64::NAN), ABSENT);
}
#[test]
#[cfg(feature = "client")]
fn absent_optional_ns_f64_renders_as_a_placeholder() {
assert_eq!(format_optional_ns_f64(None), ABSENT);
}
#[test]
#[cfg(feature = "tui")]
fn absent_counts_and_ratios_render_as_a_placeholder() {
assert_eq!(format_optional_count(None), ABSENT);
assert_eq!(format_percent_ratio(1, 0), ABSENT);
}
#[test]
fn percentages_have_one_spelling() {
assert_eq!(format_percent(0.0), "0.00%");
assert_eq!(format_percent(12.345), "12.35%");
}
#[test]
#[cfg(feature = "tui")]
fn percent_ratios_and_counts_have_one_spelling() {
assert_eq!(format_percent_ratio(1, 4), "25.00%");
assert_eq!(format_percent_ratio(5, 4), "100.00%");
assert_eq!(format_count(42), "42");
assert_eq!(format_optional_count(Some(0)), "0");
}
}
-391
View File
@@ -257,394 +257,3 @@ fn parse_duration_units(input: &str) -> Result<Duration, String> {
)),
}
}
#[cfg(test)]
mod tests {
use std::net::{Ipv4Addr, Ipv6Addr};
use super::*;
use crate::cmd::server::env_lock::with_env_lock;
fn parse(args: &[&str]) -> Result<ServerArgs, clap::Error> {
with_env_lock(|| {
let mut argv = vec!["irtt-server"];
argv.extend_from_slice(args);
ServerArgs::try_parse_from(argv)
})
}
fn bound(args: &[&str]) -> ServerArgs {
let mut argv = vec!["--bind", "127.0.0.1:2112"];
argv.extend_from_slice(args);
parse(&argv).unwrap()
}
#[test]
fn bind_is_optional_and_parses_both_families() {
let ipv4 = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
assert_eq!(ipv4.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))]);
let ipv6 = parse(&["--bind", "[::1]:2112"]).unwrap();
assert_eq!(ipv6.bind, [SocketAddr::from((Ipv6Addr::LOCALHOST, 2112))]);
assert!(parse(&["--bind", "127.0.0.1"]).is_err());
assert!(parse(&["--bind", "localhost:2112"]).is_err());
// Still one address per option: comma-separated syntax is not a thing
// here, and accepting it silently would be worse than rejecting it.
assert!(parse(&["--bind", "127.0.0.1:2112,[::1]:2112"]).is_err());
}
#[test]
fn no_explicit_bind_resolves_to_the_wildcard_default_pair() {
let args = parse(&[]).unwrap();
assert!(
args.bind.is_empty(),
"the empty list is the parsed representation of \"no explicit bind\""
);
assert_eq!(
args.resolve_binds(),
[
SocketAddr::from((Ipv6Addr::UNSPECIFIED, 2112)),
SocketAddr::from((Ipv4Addr::UNSPECIFIED, 2112)),
],
"zero explicit binds means the ordinary port on both wildcard families, IPv6 first"
);
}
#[test]
fn an_explicit_bind_replaces_the_defaults_rather_than_augmenting_them() {
let args = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
assert_eq!(
args.resolve_binds(),
[SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))],
"one explicit bind means exactly that one listener, not that listener plus defaults"
);
}
#[test]
fn repeated_explicit_binds_are_unaffected_by_the_default_resolution() {
let args = parse(&["--bind", "127.0.0.1:2112", "--bind", "[::1]:2112"]).unwrap();
assert_eq!(args.resolve_binds(), args.bind);
}
#[test]
fn bind_is_repeatable_and_keeps_the_requested_order() {
let args = parse(&[
"--bind",
"[::1]:2112",
"--bind",
"127.0.0.1:2112",
"--bind",
"127.0.0.1:2113",
])
.unwrap();
assert_eq!(
args.bind,
[
SocketAddr::from((Ipv6Addr::LOCALHOST, 2112)),
SocketAddr::from((Ipv4Addr::LOCALHOST, 2112)),
SocketAddr::from((Ipv4Addr::LOCALHOST, 2113)),
],
"listeners are served in the order they were requested"
);
}
#[test]
fn the_configuration_is_one_policy_however_many_listeners_there_are() {
// Addresses never enter the server configuration: every listener is
// built from a clone of this one value.
let one = parse(&["--bind", "127.0.0.1:2112", "--max-sessions", "4"])
.unwrap()
.server_config();
let several = parse(&[
"--bind",
"127.0.0.1:2112",
"--bind",
"[::1]:2112",
"--max-sessions",
"4",
])
.unwrap()
.server_config();
assert_eq!(one, several);
assert_eq!(several.max_sessions(), 4, "and it is a per-listener bound");
}
#[test]
fn omitted_policy_options_keep_the_library_defaults() {
assert_eq!(bound(&[]).server_config(), ServerConfig::default());
}
#[test]
fn policy_options_map_onto_the_server_config() {
let config = bound(&[
"--hmac",
"secret",
"--max-sessions",
"512",
"--max-packet-length",
"1472",
"--min-interval",
"20ms",
"--burst",
"3",
"--idle-timeout",
"30s",
"--max-duration",
"2m",
])
.server_config();
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
assert_eq!(config.max_sessions(), 512);
assert_eq!(config.max_packet_length(), 1472);
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
assert_eq!(config.burst_allowance(), 3);
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
}
#[test]
fn the_capability_restrictions_map_onto_the_server_config() {
let config = bound(&["--timestamp-allowance", "single", "--no-dscp"]).server_config();
assert_eq!(
config.timestamp_allowance(),
TimestampAllowance::Single,
"a request for both timestamps is negotiated to midpoint"
);
assert!(!config.dscp_allowed());
}
#[test]
fn every_timestamp_allowance_mode_parses() {
for (argument, expected) in [
("dual", TimestampAllowance::Dual),
("single", TimestampAllowance::Single),
("none", TimestampAllowance::None),
] {
assert_eq!(
bound(&["--timestamp-allowance", argument])
.server_config()
.timestamp_allowance(),
expected,
"--timestamp-allowance {argument}"
);
}
assert!(parse(&[
"--bind",
"127.0.0.1:2112",
"--timestamp-allowance",
"midpoint"
])
.is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--timestamp-allowance", "both"]).is_err());
}
#[test]
fn the_capability_restrictions_are_off_when_their_flags_are_absent() {
// Explicitly, and not only through the whole-config comparison above:
// these are the two values that would silently change every existing
// deployment's negotiation if the CLI restated them.
let config = bound(&["--idle-timeout", "30s"]).server_config();
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Dual);
assert!(config.dscp_allowed());
}
#[test]
fn explicit_zero_policy_values_are_preserved() {
let config = bound(&[
"--min-interval",
"0",
"--idle-timeout",
"0ms",
"--burst",
"0",
"--max-sessions",
"0",
"--max-packet-length",
"0",
])
.server_config();
assert_eq!(config.min_send_interval(), Duration::ZERO);
assert_eq!(config.idle_timeout(), Duration::ZERO);
assert_eq!(config.burst_allowance(), 0);
assert_eq!(config.max_sessions(), 0);
assert_eq!(config.max_packet_length(), 0);
}
#[test]
fn durations_require_a_supported_unit() {
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "30"]).is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--min-interval", "20us"]).is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "2h"]).is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "ms"]).is_err());
}
#[test]
fn an_empty_hmac_key_is_rejected() {
let err = parse(&["--bind", "127.0.0.1:2112", "--hmac", ""]).unwrap_err();
assert!(err.to_string().contains("HMAC key must not be empty"));
}
#[test]
fn a_whitespace_only_hmac_key_is_accepted_literally() {
let config = bound(&["--hmac", " "]).server_config();
assert_eq!(config.hmac_key(), Some(b" ".as_slice()));
}
#[test]
fn a_zero_maximum_duration_is_not_a_spelling_of_no_maximum() {
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0"]).is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0s"]).is_err());
assert_eq!(bound(&[]).server_config().max_test_duration(), None);
}
/// Runs `body` with the given environment variables set, restoring
/// whatever was there before (present or absent) once `body` returns.
/// Held under `with_env_lock` for the whole span, including `body`, so a
/// `parse` call elsewhere never observes a partially-applied environment.
fn with_env<T>(vars: &[(&str, &str)], body: impl FnOnce() -> T) -> T {
with_env_lock(|| {
let previous: Vec<(&str, Option<String>)> = vars
.iter()
.map(|(k, _)| (*k, std::env::var(k).ok()))
.collect();
for (k, v) in vars {
std::env::set_var(k, v);
}
let result = body();
for (k, v) in previous {
match v {
Some(v) => std::env::set_var(k, v),
None => std::env::remove_var(k),
}
}
result
})
}
#[test]
fn environment_variables_supply_policy_when_no_flag_is_given() {
with_env(
&[
("IRTT_SERVER_MAX_SESSIONS", "512"),
("IRTT_SERVER_MAX_PACKET_LENGTH", "1472"),
("IRTT_SERVER_MIN_INTERVAL", "20ms"),
("IRTT_SERVER_BURST", "3"),
("IRTT_SERVER_IDLE_TIMEOUT", "30s"),
("IRTT_SERVER_MAX_DURATION", "2m"),
("IRTT_SERVER_TIMESTAMP_ALLOWANCE", "single"),
("IRTT_SERVER_HMAC", "secret"),
("IRTT_SERVER_NO_DSCP", "true"),
],
|| {
let config = parse(&["--bind", "127.0.0.1:2112"])
.unwrap()
.server_config();
assert_eq!(config.max_sessions(), 512);
assert_eq!(config.max_packet_length(), 1472);
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
assert_eq!(config.burst_allowance(), 3);
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
assert!(!config.dscp_allowed());
},
);
}
/// `IRTT_SERVER_NO_DSCP` is the one policy variable whose value is a
/// boolean rather than a number, a duration or a string, and the
/// container/orchestrator deployments this whole environment surface
/// exists for spell booleans `1`/`0`. clap's default parser for a flag
/// accepts only the literals `true`/`false` and turns everything else
/// into a *parse failure*, so `IRTT_SERVER_NO_DSCP=1` used to stop the
/// server from starting at all rather than enabling the flag. Falsey
/// semantics are what keeps that from being a startup failure.
#[test]
fn the_no_dscp_environment_variable_accepts_ordinary_boolean_spellings() {
for value in ["1", "true", "TRUE", "yes", "on", "enabled"] {
with_env(&[("IRTT_SERVER_NO_DSCP", value)], || {
let config = parse(&["--bind", "127.0.0.1:2112"])
.expect("a truthy value must not fail argument parsing")
.server_config();
assert!(!config.dscp_allowed(), "IRTT_SERVER_NO_DSCP={value}");
});
}
for value in ["0", "false", "no", "off", ""] {
with_env(&[("IRTT_SERVER_NO_DSCP", value)], || {
let config = parse(&["--bind", "127.0.0.1:2112"])
.expect("a falsey value must not fail argument parsing")
.server_config();
assert!(config.dscp_allowed(), "IRTT_SERVER_NO_DSCP={value}");
});
}
}
/// The command-line flag stays a flag: it takes no value, whatever the
/// environment parser accepts.
#[test]
fn the_no_dscp_flag_still_takes_no_command_line_value() {
assert!(parse(&["--bind", "127.0.0.1:2112", "--no-dscp=false"]).is_err());
assert!(!bound(&["--no-dscp"]).server_config().dscp_allowed());
}
#[test]
fn an_explicit_flag_overrides_the_environment() {
with_env(&[("IRTT_SERVER_MAX_SESSIONS", "512")], || {
let config = parse(&["--bind", "127.0.0.1:2112", "--max-sessions", "4"])
.unwrap()
.server_config();
assert_eq!(
config.max_sessions(),
4,
"the command-line value wins over the environment"
);
});
}
#[test]
fn the_environment_wins_over_the_built_in_default() {
with_env(&[("IRTT_SERVER_BURST", "9")], || {
let config = bound(&[]).server_config();
assert_eq!(
config.burst_allowance(),
9,
"an unset flag still falls back to the environment before the library default"
);
});
}
#[test]
fn irtt_server_bind_supplies_a_single_address_from_the_environment() {
with_env(&[("IRTT_SERVER_BIND", "127.0.0.1:2113")], || {
let args = parse(&[]).unwrap();
assert_eq!(args.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2113))]);
});
}
#[test]
fn an_explicit_bind_flag_overrides_irtt_server_bind() {
with_env(&[("IRTT_SERVER_BIND", "127.0.0.1:2113")], || {
let args = parse(&["--bind", "[::1]:2112"]).unwrap();
assert_eq!(args.bind, [SocketAddr::from((Ipv6Addr::LOCALHOST, 2112))]);
});
}
#[test]
fn leaving_irtt_server_bind_unset_keeps_the_wildcard_default() {
let args = parse(&[]).unwrap();
assert!(
args.bind.is_empty(),
"with no --bind and no IRTT_SERVER_BIND, resolve_binds still falls back to the wildcard pair"
);
}
}
@@ -1,30 +0,0 @@
//! Serializes every test in this module tree that touches process
//! environment variables.
//!
//! `ServerArgs` parsing reads `IRTT_SERVER_*` via `clap`'s `env` support, and
//! `std::env::set_var`/`remove_var` mutate genuinely process-wide state.
//! Every test that parses `ServerArgs` — whether or not it sets an env var
//! itself — must go through [`with_env_lock`], or a parse in one test thread
//! can observe a value another thread's test set only for the duration of
//! its own assertions. Reentrant per-thread, since a test that sets env vars
//! typically calls `parse` again from inside its own locked section.
use std::{cell::Cell, sync::Mutex};
static ENV_LOCK: Mutex<()> = Mutex::new(());
thread_local! {
static ENV_LOCK_HELD: Cell<bool> = const { Cell::new(false) };
}
pub(crate) fn with_env_lock<T>(f: impl FnOnce() -> T) -> T {
if ENV_LOCK_HELD.with(Cell::get) {
return f();
}
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
ENV_LOCK_HELD.with(|held| held.set(true));
let result = f();
ENV_LOCK_HELD.with(|held| held.set(false));
result
}
-2
View File
@@ -1,6 +1,4 @@
pub mod args;
#[cfg(test)]
pub(crate) mod env_lock;
pub mod run;
pub use args::ServerArgs;
-102
View File
@@ -105,105 +105,3 @@ async fn shutdown_signal(shutdown_requested: &AtomicBool) {
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
}
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
use crate::cmd::server::env_lock::with_env_lock;
/// The ordinary invocation, and the one that keeps the single-listener case
/// on the same orchestration path as every other.
#[test]
fn an_already_requested_shutdown_binds_one_listener_and_returns() {
let args = with_env_lock(|| {
ServerArgs::try_parse_from(["irtt-server", "--bind", "127.0.0.1:0"]).unwrap()
});
let shutdown_requested = AtomicBool::new(true);
run_server(args, &shutdown_requested).unwrap();
}
#[test]
fn an_already_requested_shutdown_binds_every_listener_and_returns() {
let args = with_env_lock(|| {
ServerArgs::try_parse_from([
"irtt-server",
"--bind",
"127.0.0.1:0",
"--bind",
"127.0.0.1:0",
])
.unwrap()
});
let shutdown_requested = AtomicBool::new(true);
run_server(args, &shutdown_requested).unwrap();
}
fn default_binds() -> Vec<SocketAddr> {
with_env_lock(|| {
ServerArgs::try_parse_from(["irtt-server"])
.unwrap()
.resolve_binds()
})
}
#[test]
fn implicit_default_losing_an_unsupported_family_falls_back_to_the_other() {
let binds = default_binds();
let failed = binds[0];
let remaining = implicit_default_fallback(true, false, &binds, failed).unwrap();
assert_eq!(remaining, [binds[1]]);
}
#[test]
fn a_supported_familys_failure_never_falls_back() {
// The family itself works fine here; whatever made this specific
// bind fail (port in use, permission, ...) is a real problem this
// invocation should surface, not paper over.
let binds = default_binds();
assert_eq!(
implicit_default_fallback(true, true, &binds, binds[0]),
None,
"family_available: true means this is not a family-support problem"
);
}
#[test]
fn explicit_binds_never_fall_back() {
let binds = default_binds();
assert_eq!(
implicit_default_fallback(false, false, &binds, binds[0]),
None
);
}
#[test]
fn a_failure_outside_the_requested_set_does_not_fall_back() {
let binds = default_binds();
let unrelated = SocketAddr::from(([127, 0, 0, 1], 2113));
assert_eq!(
implicit_default_fallback(true, false, &binds, unrelated),
None,
"the failing address must actually be one this invocation asked for"
);
}
#[test]
fn losing_the_only_remaining_default_address_does_not_fall_back() {
let single = [SocketAddr::from(([0, 0, 0, 0], 2112))];
assert_eq!(
implicit_default_fallback(true, false, &single, single[0]),
None,
"no address would be left to retry with"
);
}
}
-168
View File
@@ -73,171 +73,3 @@ impl std::ops::Deref for TuiArgs {
&self.common
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
#[derive(Parser)]
struct SharedOnlyArgs {
#[command(flatten)]
common: CommonClientArgs,
}
fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> {
let mut argv = vec!["irtt-tui"];
argv.extend_from_slice(args);
TuiArgs::try_parse_from(argv)
}
fn parse_shared(args: &[&str]) -> Result<SharedOnlyArgs, clap::Error> {
let mut argv = vec!["shared-only"];
argv.extend_from_slice(args);
SharedOnlyArgs::try_parse_from(argv)
}
#[test]
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
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_TUI_DURATION);
assert!(args.is_continuous());
assert_eq!(args.prepare().unwrap().client.duration, None);
let finite = parse(&["--duration", "30s", "127.0.0.1:2112"]).unwrap();
assert_eq!(finite.duration, Duration::from_secs(30));
assert_eq!(
finite.prepare().unwrap().client.duration,
Some(Duration::from_secs(30))
);
assert!(parse(&["--output", "human", "127.0.0.1:2112"]).is_err());
let help = TuiArgs::command().render_help().to_string();
assert!(!help.contains("--output"));
}
#[test]
fn multiple_positional_targets_parse() {
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 at_least_one_target_is_required() {
// The TUI has no target-free mode, so this is rejected at parse time
// rather than during preparation.
assert!(parse(&[]).is_err());
assert!(parse(&["ams=ams.example.com:2112"]).is_ok());
}
#[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 shared_client_options_match_tui_config_mapping() {
let shared = [
"--interval",
"250ms",
"--length",
"128",
"--hmac",
"secret",
"--clock",
"monotonic",
"--tstamp",
"receive",
"--stats",
"count",
"--sfill",
"abc",
"--dscp",
"46",
"--ttl",
"64",
"--loose",
];
let tui_args = [
"--duration",
"30s",
"--interval",
"250ms",
"--length",
"128",
"--hmac",
"secret",
"--clock",
"monotonic",
"--tstamp",
"receive",
"--stats",
"count",
"--sfill",
"abc",
"--dscp",
"46",
"--ttl",
"64",
"--loose",
"127.0.0.1:2112",
];
let tui = parse(&tui_args).unwrap().prepare().unwrap().client;
let common = parse_shared(&shared).unwrap().common;
let shared = common.to_client_config(Duration::from_secs(30));
assert_eq!(shared.duration, tui.duration);
assert_eq!(shared.interval, tui.interval);
assert_eq!(shared.length, tui.length);
assert_eq!(shared.received_stats, tui.received_stats);
assert_eq!(shared.stamp_at, tui.stamp_at);
assert_eq!(shared.clock, tui.clock);
assert_eq!(shared.dscp, tui.dscp);
assert_eq!(shared.hmac_key, tui.hmac_key);
assert_eq!(shared.server_fill, tui.server_fill);
assert_eq!(shared.negotiation_policy, tui.negotiation_policy);
assert_eq!(shared.socket_config.ttl, tui.socket_config.ttl);
}
#[test]
fn tui_help_lists_multi_target_options() {
let help = TuiArgs::command().render_help().to_string();
assert!(!help.contains("--target "));
assert!(help.contains("--pacing <PACING>"));
}
}
+55 -2
View File
@@ -87,12 +87,18 @@ pub fn run_tui(
) {
drop(handle.stop());
}
let previous_dropped = dropped_events;
let drain_state = drain_tui_events(
&mut events,
&mut state,
&mut terminal_targets,
&mut dropped_events,
);
if dropped_events != previous_dropped {
// Data loss changes the meaning of displayed statistics, even while
// ordinary measurement refreshes are paused.
render_if_due(&mut terminal, &state, &mut next_render, true)?;
}
match drain_state {
ManagedDrainState::Empty => {
thread::sleep(managed_tui_wait_duration(&next_render, state.paused));
@@ -178,11 +184,16 @@ fn drain_tui_events(
terminal_targets: &mut HashSet<TargetInstance>,
dropped_events: &mut u64,
) -> ManagedDrainState {
drain_managed_events(events, dropped_events, |event| {
let previous_dropped = *dropped_events;
let drained = drain_managed_events(events, dropped_events, |event| {
process_tui_event(event, state, terminal_targets);
Ok::<(), std::convert::Infallible>(())
})
.expect("processing TUI managed events is infallible")
.expect("processing TUI managed events is infallible");
if *dropped_events != previous_dropped {
state.mark_dropped_managed_events(*dropped_events);
}
drained
}
fn drain_final_tui_events(
@@ -301,3 +312,45 @@ fn managed_tui_wait_duration(next_render: &Instant, paused: bool) -> Duration {
};
render_wait.min(TUI_WAIT_SLICE)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{backend::TestBackend, Terminal};
#[test]
fn subscription_lag_keeps_incomplete_statistics_visible_while_paused() {
for paused in [false, true] {
// The production subscription is a broadcast receiver. Overflow the
// real channel rather than synthesizing a lag result or UI state.
let (sender, mut events) = tokio::sync::broadcast::channel(1);
sender.send(ManagedEvent::Started).unwrap();
sender.send(ManagedEvent::Started).unwrap();
let mut state = TuiState::default();
if paused {
state.toggle_pause();
}
let mut dropped = 0;
let mut terminal_targets = HashSet::new();
drain_tui_events(&mut events, &mut state, &mut terminal_targets, &mut dropped);
assert_eq!(dropped, 1);
let mut terminal = Terminal::new(TestBackend::new(200, 40)).unwrap();
for _ in 0..2 {
// Empty drains must not clear the warning on later redraws.
drain_tui_events(&mut events, &mut state, &mut terminal_targets, &mut dropped);
terminal
.draw(|frame| super::super::ui::draw_dashboard(frame, &state))
.unwrap();
let text = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(text.contains("incomplete:dropped=1"));
assert_eq!(text.contains("display paused"), paused);
}
}
}
}
File diff suppressed because it is too large. Load diff