use assert_cmd::Command;
use predicates::prelude::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::PathBuf;
const OUT_MAGIC: i32 = 516114521;
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("workspace root")
.join("tests/fixtures")
.join(name)
}
fn hydra() -> Command {
Command::cargo_bin("hydra").expect("hydra binary builds")
}
#[test]
fn valid_inp_writes_report_to_stdout_with_exit_0() {
hydra()
.arg(fixture_path("four_node_loop.inp"))
.assert()
.success()
.stdout(predicate::str::contains("H Y D R A"))
.stdout(predicate::str::contains("Number of Junctions"));
}
#[test]
fn valid_inp_writes_rpt_and_out_files() {
let dir = tempfile::tempdir().expect("tempdir");
let rpt = dir.path().join("net.rpt");
let out = dir.path().join("net.out");
hydra()
.arg(fixture_path("four_node_loop.inp"))
.arg(&rpt)
.arg(&out)
.assert()
.success();
let rpt_text = std::fs::read_to_string(&rpt).expect("report file written");
assert!(
rpt_text.contains("H Y D R A"),
"report missing banner:\n{rpt_text}"
);
let out_bytes = std::fs::read(&out).expect("output file written");
assert!(out_bytes.len() > 8, "output file too small");
let prolog = i32::from_le_bytes(out_bytes[0..4].try_into().expect("4 bytes"));
let epilog = i32::from_le_bytes(
out_bytes[out_bytes.len() - 4..]
.try_into()
.expect("4 bytes"),
);
assert_eq!(prolog, OUT_MAGIC, "prolog magic mismatch");
assert_eq!(epilog, OUT_MAGIC, "epilog magic mismatch");
}
#[test]
fn json_report_is_valid_json() {
let dir = tempfile::tempdir().expect("tempdir");
let json_path = dir.path().join("net.json");
hydra()
.arg("--input")
.arg(fixture_path("four_node_loop.inp"))
.arg("--report")
.arg(&json_path)
.assert()
.success();
let text = std::fs::read_to_string(&json_path).expect("json report written");
let value: serde_json::Value = serde_json::from_str(&text).expect("report is valid JSON");
assert!(value.is_object(), "JSON report root must be an object");
}
#[test]
fn positional_json_report_is_valid_json() {
let dir = tempfile::tempdir().expect("tempdir");
let json_path = dir.path().join("net.json");
hydra()
.arg(fixture_path("four_node_loop.inp"))
.arg(&json_path)
.assert()
.success();
let text = std::fs::read_to_string(&json_path).expect("json report written");
let value: serde_json::Value = serde_json::from_str(&text).expect("report is valid JSON");
assert!(value.is_object(), "JSON report root must be an object");
}
#[test]
fn quiet_flag_is_accepted() {
hydra()
.arg("-q")
.arg(fixture_path("four_node_loop.inp"))
.assert()
.success()
.stdout(predicate::str::contains("H Y D R A"));
}
#[test]
fn help_exits_0() {
hydra()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("Usage:"));
}
#[test]
fn version_exits_0() {
hydra()
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains("Hydra version"))
.stdout(predicate::str::contains("CLI version"));
}
#[test]
fn short_upper_v_prints_version_and_exits_0() {
hydra()
.arg("-V")
.assert()
.success()
.stdout(predicate::str::contains("Hydra version"))
.stdout(predicate::str::contains("CLI version"));
}
#[test]
fn short_lower_v_is_an_error_with_hint() {
hydra()
.arg("-v")
.assert()
.code(1)
.stdout(predicate::str::contains("Hydra version").not())
.stderr(predicate::str::contains("-V"))
.stderr(predicate::str::contains("--quiet"));
}
#[test]
fn missing_input_file_exits_1() {
hydra()
.arg("definitely/not/a/real/file.inp")
.assert()
.code(1)
.stderr(predicate::str::contains("io/fetch"));
}
#[test]
fn unknown_flag_exits_1() {
hydra()
.arg("--no-such-flag")
.assert()
.code(1)
.stderr(predicate::str::contains("--no-such-flag"));
}
#[test]
fn no_input_specified_exits_1() {
hydra()
.assert()
.code(1)
.stderr(predicate::str::contains("no input file specified"));
}
#[test]
fn too_many_positional_args_exits_1() {
hydra()
.args(["a.inp", "b.rpt", "c.out", "d.extra"])
.assert()
.code(1)
.stderr(predicate::str::contains("at most 3 positional arguments"));
}
#[test]
fn validation_failing_inp_exits_1() {
let dir = tempfile::tempdir().expect("tempdir");
let bad = dir.path().join("junctions_only.inp");
std::fs::write(
&bad,
b"[JUNCTIONS]\nJ1 0 10\nJ2 0 5\n\n\
[PIPES]\nP1 J1 J2 1000 12 100 0 Open\n\n\
[OPTIONS]\nUnits GPM\nHeadloss H-W\n",
)
.expect("write INP");
hydra()
.arg(&bad)
.assert()
.code(1)
.stderr(predicate::str::contains("\"level\":\"error\""))
.stderr(predicate::str::contains("validation/network"))
.stderr(predicate::str::contains("network has no reservoir"));
}
#[test]
fn duplicate_id_inp_exits_1_and_names_the_id() {
let dir = tempfile::tempdir().expect("tempdir");
let bad = dir.path().join("dup_id.inp");
std::fs::write(
&bad,
b"[JUNCTIONS]\nJ1 0 10\nJ1 0 5\n\n\
[RESERVOIRS]\nR1 100\n\n\
[PIPES]\nP1 R1 J1 1000 12 100 0 Open\n\n\
[OPTIONS]\nUnits GPM\nHeadloss H-W\n",
)
.expect("write INP");
hydra()
.arg(&bad)
.assert()
.code(1)
.stderr(predicate::str::contains("input/parse"))
.stderr(predicate::str::contains("duplicate node ID 'J1'"));
}
#[test]
fn unparseable_inp_exits_1() {
let dir = tempfile::tempdir().expect("tempdir");
let bad = dir.path().join("garbage.inp");
std::fs::write(&bad, b"\x00\x01\x02 this is not an INP file").expect("write garbage");
hydra()
.arg(&bad)
.assert()
.code(1)
.stderr(predicate::str::contains("\"level\":\"error\""));
}
#[test]
fn report_to_missing_directory_exits_3() {
let dir = tempfile::tempdir().expect("tempdir");
let rpt = dir.path().join("no/such/dir/net.rpt");
hydra()
.arg(fixture_path("four_node_loop.inp"))
.arg(&rpt)
.assert()
.code(3)
.stderr(predicate::str::contains("io/report"));
}
#[test]
fn output_to_missing_directory_exits_3() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("no/such/dir/net.out");
hydra()
.arg("--input")
.arg(fixture_path("four_node_loop.inp"))
.arg("--output")
.arg(&out)
.assert()
.code(3)
.stderr(predicate::str::contains("io/output"));
}
fn one_shot_http_server(
status: &'static str,
body: Vec<u8>,
) -> (String, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
let addr = listener.local_addr().expect("local addr");
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
loop {
let n = stream.read(&mut chunk).expect("read request");
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let response = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream
.write_all(response.as_bytes())
.expect("write headers");
stream.write_all(&body).expect("write body");
stream.flush().expect("flush");
});
(format!("http://{addr}/model.inp"), handle)
}
#[test]
fn http_input_from_localhost_server_succeeds() {
let inp = std::fs::read(fixture_path("four_node_loop.inp")).expect("read fixture");
let (url, server) = one_shot_http_server("200 OK", inp);
hydra()
.arg(&url)
.assert()
.success()
.stdout(predicate::str::contains("H Y D R A"));
server.join().expect("server thread");
}
#[test]
fn http_404_exits_1() {
let (url, server) = one_shot_http_server("404 Not Found", Vec::new());
hydra()
.arg(&url)
.assert()
.code(1)
.stderr(predicate::str::contains("HTTP 404"));
server.join().expect("server thread");
}
#[test]
fn http_500_exits_3() {
let (url, server) = one_shot_http_server("500 Internal Server Error", Vec::new());
hydra()
.arg(&url)
.assert()
.code(3)
.stderr(predicate::str::contains("HTTP 500"));
server.join().expect("server thread");
}