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
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:
101 files changed
+3636
-15159
No files matched your search
@@ -180,148 +180,3 @@ fn applet_argv(applet_name: &str, args: &[OsString]) -> Vec<OsString> {
|
||||
argv.extend(args.iter().cloned());
|
||||
argv
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn applet_detection_maps_binary_names() {
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0("/usr/bin/irtt-client"),
|
||||
Some(RequestedApplet::Client)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0("irtt-tui"),
|
||||
Some(RequestedApplet::Tui)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0("irtt-server"),
|
||||
Some(RequestedApplet::Server)
|
||||
);
|
||||
assert_eq!(detect_applet_from_argv0("irttd"), None);
|
||||
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
|
||||
assert_eq!(detect_applet_from_argv0("custom-name"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_irtt_cli_binary_name_is_no_longer_a_recognized_applet() {
|
||||
assert_eq!(detect_applet_from_argv0("irtt-cli"), None);
|
||||
assert_eq!(detect_applet_from_argv0("/usr/bin/irtt-cli"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applet_detection_ignores_the_platform_executable_suffix() {
|
||||
let exe = std::env::consts::EXE_SUFFIX;
|
||||
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0(&format!("/opt/irtt/irtt-server{exe}")),
|
||||
Some(RequestedApplet::Server)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0(&format!("irtt-client{exe}")),
|
||||
Some(RequestedApplet::Client)
|
||||
);
|
||||
|
||||
// The dispatcher's own name must not be taken for an unknown applet.
|
||||
let dispatcher = format!("irtt-rs{exe}");
|
||||
assert_eq!(detect_applet_from_argv0(&dispatcher), None);
|
||||
assert!(dispatch_from_argv(vec![dispatcher.into(), "--help".into()]).is_ok());
|
||||
|
||||
// A name that merely ends in the suffix letters keeps them.
|
||||
assert!(detect_applet_from_argv0("irtt-serverexe").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_executable_suffix_handling_is_assertable_from_any_host() {
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-server.exe", ".exe"),
|
||||
"irtt-server"
|
||||
);
|
||||
assert_eq!(strip_suffix_ignoring_case("IRTT-RS.EXE", ".exe"), "IRTT-RS");
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-server", ".exe"),
|
||||
"irtt-server"
|
||||
);
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-serverexe", ".exe"),
|
||||
"irtt-serverexe"
|
||||
);
|
||||
assert_eq!(strip_suffix_ignoring_case(".exe", ".exe"), ".exe");
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-server.exe", ""),
|
||||
"irtt-server.exe"
|
||||
);
|
||||
// A multi-byte tail must not split mid-character.
|
||||
assert_eq!(strip_suffix_ignoring_case("irtt-så", ".exe"), "irtt-så");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_help_renders_dispatcher_help() {
|
||||
let dispatch = dispatch_from_argv(vec!["irtt-rs".into(), "--help".into()]).unwrap();
|
||||
let AppletDispatch::Help(help) = dispatch else {
|
||||
panic!("expected dispatcher help");
|
||||
};
|
||||
assert!(help.contains("IRTT-compatible multi-applet dispatcher"));
|
||||
assert!(help.contains("client"));
|
||||
if cfg!(feature = "tui") {
|
||||
assert!(help.contains("Run the terminal UI applet"));
|
||||
} else {
|
||||
assert!(help.contains("Terminal UI applet is not available in this build"));
|
||||
}
|
||||
assert!(help.contains("server"));
|
||||
if cfg!(feature = "server") {
|
||||
assert!(help.contains("Run the UDP server applet"));
|
||||
} else {
|
||||
assert!(help.contains("UDP server applet is not available in this build"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_without_applet_is_an_error() {
|
||||
let err = dispatch_from_argv(vec!["irtt-rs".into()]).unwrap_err();
|
||||
assert!(err.contains("choose an applet"));
|
||||
assert!(err.contains("client"));
|
||||
if cfg!(feature = "tui") {
|
||||
assert!(err.contains("tui"));
|
||||
}
|
||||
assert!(err.contains("server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_subcommands_dispatch_to_applet_argv() {
|
||||
assert_eq!(
|
||||
dispatch_from_argv(vec!["irtt-rs".into(), "client".into(), "host:2112".into()])
|
||||
.unwrap(),
|
||||
AppletDispatch::Run {
|
||||
applet: RequestedApplet::Client,
|
||||
argv: vec!["irtt-client".into(), "host:2112".into()],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
dispatch_from_argv(vec!["irtt-rs".into(), "tui".into(), "host:2112".into()]).unwrap(),
|
||||
AppletDispatch::Run {
|
||||
applet: RequestedApplet::Tui,
|
||||
argv: vec!["irtt-tui".into(), "host:2112".into()],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
dispatch_from_argv(vec!["irtt-rs".into(), "server".into()]).unwrap(),
|
||||
AppletDispatch::Run {
|
||||
applet: RequestedApplet::Server,
|
||||
argv: vec!["irtt-server".into()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_irtt_binary_name_is_an_error() {
|
||||
let err = dispatch_from_argv(vec!["/usr/bin/irtt-typo".into()]).unwrap_err();
|
||||
assert!(err.contains("unknown applet name 'irtt-typo'"));
|
||||
assert!(err.contains("irtt-client"));
|
||||
if cfg!(feature = "tui") {
|
||||
assert!(err.contains("irtt-tui"));
|
||||
}
|
||||
assert!(err.contains("irtt-server"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}}"));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
pub mod args;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod env_lock;
|
||||
pub mod run;
|
||||
|
||||
pub use args::ServerArgs;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -81,61 +81,3 @@ pub fn request_managed_stop_once(stop_requested: &mut bool) -> bool {
|
||||
*stop_requested = true;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn peer_close_run_error_is_run_mode_and_interruption_aware() {
|
||||
assert_eq!(peer_close_run_error(false, false, 1), None);
|
||||
assert_eq!(peer_close_run_error(true, false, 0), None);
|
||||
assert_eq!(peer_close_run_error(true, true, 1), None);
|
||||
assert_eq!(
|
||||
peer_close_run_error(true, false, 1).as_deref(),
|
||||
Some("continuous run ended because of peer closure (1 target session)")
|
||||
);
|
||||
assert_eq!(
|
||||
peer_close_run_error(true, false, 3).as_deref(),
|
||||
Some("continuous run ended because of peer closure (3 target sessions)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_peer_close_stop_policy_is_run_mode_and_one_shot() {
|
||||
let mut stop_requested = false;
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(!stop_requested);
|
||||
|
||||
assert!(request_managed_stop_for_peer_close(
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(stop_requested);
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -322,170 +322,3 @@ pub fn parse_stdin_target_set(
|
||||
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
||||
prepare_managed_targets(target_specs_with_empty(&args, true)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn target(input: &str) -> TargetArg {
|
||||
TargetArg::new(input)
|
||||
}
|
||||
|
||||
fn specs(inputs: &[&str]) -> Result<Vec<TargetSpec>, String> {
|
||||
target_specs(&inputs.iter().map(|input| target(input)).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_target_syntax_and_ipv6_remain_supported() {
|
||||
let parsed = specs(&[
|
||||
"host.example",
|
||||
"host.example:2112",
|
||||
"[::1]:2112",
|
||||
"eu=host.example",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(parsed[0].label, "host.example");
|
||||
assert_eq!(parsed[1].label, "host.example:2112");
|
||||
assert_eq!(parsed[2].label, "[::1]:2112");
|
||||
assert_eq!(parsed[3].label, "eu");
|
||||
assert!(parsed
|
||||
.iter()
|
||||
.all(|target| matches!(target.auth, TargetAuth::Inherit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_hmac_supports_inherit_override_disable_and_base64_padding() {
|
||||
let parsed = specs(&[
|
||||
"default=default.example",
|
||||
"private=private.example@hmac=YWJjZA==",
|
||||
"public=public.example@hmac=",
|
||||
])
|
||||
.unwrap();
|
||||
assert!(matches!(parsed[0].auth, TargetAuth::Inherit));
|
||||
assert_eq!(parsed[1].auth, TargetAuth::Override(b"YWJjZA==".to_vec()));
|
||||
assert!(matches!(parsed[2].auth, TargetAuth::Disable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_delimiters_are_escaped_contextually() {
|
||||
let parsed = specs(&["eu\\=west=host\\@hmac=literal@hmac=a\\@b\\,c\\=d\\\\e"]).unwrap();
|
||||
assert_eq!(parsed[0].label, "eu=west");
|
||||
assert_eq!(parsed[0].addr, "host@hmac=literal");
|
||||
assert_eq!(parsed[0].auth, TargetAuth::Override(b"a@b,c=d\\e".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_commas_are_not_reserved() {
|
||||
let parsed = specs(&["edge=host,region@hmac=key,part"]).unwrap();
|
||||
assert_eq!(parsed[0].addr, "host,region");
|
||||
assert_eq!(parsed[0].auth, TargetAuth::Override(b"key,part".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_diagnostics_and_debug_do_not_reveal_hmac_values() {
|
||||
let secret = "very-secret-value";
|
||||
let error = specs(&[&format!("target=host@hmac={secret}\\q")]).unwrap_err();
|
||||
assert!(!error.contains(secret));
|
||||
assert!(!format!("{:?}", target(&format!("target=host@hmac={secret}"))).contains(secret));
|
||||
assert!(
|
||||
!format!("{:?}", TargetAuth::Override(secret.as_bytes().to_vec())).contains(secret)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
|
||||
let parsed = specs(&["host-a:2112", "host-a:2112"]).unwrap();
|
||||
assert_eq!(parsed[0].label, "host-a:2112");
|
||||
assert_eq!(parsed[1].label, "host-a:2112#2");
|
||||
assert_eq!(
|
||||
specs(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap_err(),
|
||||
"duplicate target label"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_preserve_argument_order_and_unlabeled_counts() {
|
||||
let parsed = specs(&["local", "eu=eu.example", "foo", "us=us.example", "foo"]).unwrap();
|
||||
let labels: Vec<_> = parsed.iter().map(|target| target.label.as_str()).collect();
|
||||
assert_eq!(labels, ["local", "eu", "foo", "us", "foo#2"]);
|
||||
let addresses: Vec<_> = parsed.iter().map(|target| target.addr.as_str()).collect();
|
||||
assert_eq!(
|
||||
addresses,
|
||||
["local", "eu.example", "foo", "us.example", "foo"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_reject_generated_suffix_collisions() {
|
||||
assert_eq!(
|
||||
specs(&[
|
||||
"host.example",
|
||||
"host.example",
|
||||
"host.example#2=other.example"
|
||||
])
|
||||
.unwrap_err(),
|
||||
"duplicate target label"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_target_syntax_is_rejected_without_echoing_input() {
|
||||
for input in ["=host", "label=", "host@hmac=value\\q", "host\\x"] {
|
||||
assert!(specs(&[input]).is_err(), "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_preparation_maps_auth_to_managed_config() {
|
||||
let prepared =
|
||||
prepare_managed_targets(specs(&["a=one@hmac=key", "b=two@hmac="]).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
prepared[0]
|
||||
.managed
|
||||
.auth
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hmac_key
|
||||
.as_deref(),
|
||||
Some(b"key".as_slice())
|
||||
);
|
||||
assert_eq!(prepared[1].managed.auth.as_ref().unwrap().hmac_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_preparation_preserves_duplicate_endpoints() {
|
||||
let prepared =
|
||||
prepare_managed_targets(specs(&["a=example.test", "b=example.test"]).unwrap()).unwrap();
|
||||
assert_eq!(prepared[0].managed.server_addr, "example.test");
|
||||
assert_eq!(prepared[1].managed.server_addr, "example.test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_target_sets_frame_commas_without_trimming_payload() {
|
||||
let parsed = parse_stdin_target_set("a=one@hmac=x\\,y,b=two", 128).unwrap();
|
||||
assert_eq!(
|
||||
parsed[0].managed.auth.as_ref().unwrap().hmac_key.as_deref(),
|
||||
Some(b"x,y".as_slice())
|
||||
);
|
||||
assert_eq!(parsed[1].label, "b");
|
||||
assert_eq!(
|
||||
parse_stdin_target_set(" []", 128).unwrap()[0]
|
||||
.managed
|
||||
.server_addr,
|
||||
" []"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_stdin_target_set("[] ", 128).unwrap()[0]
|
||||
.managed
|
||||
.server_addr,
|
||||
"[] "
|
||||
);
|
||||
assert!(parse_stdin_target_set("", 128).is_err());
|
||||
assert!(parse_stdin_target_set("a=one,b=two", 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_empty_set_is_exactly_bracket_pair() {
|
||||
assert!(parse_stdin_target_set("[]", 128).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user