use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_powerio")
}
fn repo_file(path: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(path)
}
fn run(args: &[&str]) -> Output {
Command::new(bin()).args(args).output().unwrap()
}
fn run_with_stdin(args: &[&str], stdin: &[u8]) -> Output {
use std::io::Write as _;
use std::process::Stdio;
let mut child = Command::new(bin())
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(stdin).unwrap();
child.wait_with_output().unwrap()
}
fn json_diagnostics(out: &Output) -> Vec<serde_json::Value> {
let stderr = String::from_utf8_lossy(&out.stderr);
let lines: Vec<&str> = stderr.lines().filter(|line| !line.is_empty()).collect();
assert_eq!(lines.len(), 1, "stderr is not one JSON line:\n{stderr}");
serde_json::from_str(lines[0]).unwrap_or_else(|error| panic!("{error}:\n{stderr}"))
}
const IR_DIAGNOSTIC_FIELDS: [&str; 9] = [
"id",
"severity",
"code",
"message",
"target",
"spans",
"related",
"details",
"suggested_action",
];
#[test]
fn convert_reads_a_case_from_stdin_with_a_declared_format() {
let case = std::fs::read(repo_file("tests/data/case9.m")).unwrap();
let out = run_with_stdin(
&[
"convert", "-", "--from", "matpower", "--to", "psse", "-o", "-",
],
&case,
);
assert_success(&out);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("powerio export: case9"), "{stdout}");
assert!(
stdout.contains("/ powerio export") || stdout.contains("BEGIN"),
"{stdout}"
);
let out = run_with_stdin(&["summary", "-", "--from", "matpower"], &case);
assert_success(&out);
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(summary["elements"]["buses"], 9, "{summary}");
let out = run_with_stdin(&["serialize", "-", "--from", "matpower"], &case);
assert_success(&out);
let document: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(document["schema"], powerio::IR_SCHEMA_NAME, "{document}");
assert_eq!(document["version"], powerio::IR_VERSION, "{document}");
assert_eq!(
document["value"]["type"], "powerio.BalancedNetwork",
"{document}"
);
}
#[test]
fn stdin_without_a_declared_format_is_a_request_failure() {
let case = std::fs::read(repo_file("tests/data/case9.m")).unwrap();
let out = run_with_stdin(&["convert", "-", "--to", "psse", "-o", "-"], &case);
assert_failure(&out);
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("REQUEST.CLI.FORMAT_REQUIRED"), "{stderr}");
assert!(stderr.contains("--from"), "{stderr}");
assert!(out.stdout.is_empty(), "nothing is written on failure");
let out = run_with_stdin(
&[
"convert", "-", "--from", "gridfm", "--to", "psse", "-o", "-",
],
&case,
);
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn an_unknown_format_is_a_usage_failure() {
let case = repo_file("tests/data/case9.m");
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
"not-a-format",
"-o",
"-",
]);
assert_eq!(out.status.code(), Some(2));
let out = run(&[
"convert",
case.to_str().unwrap(),
"--from",
"not-a-format",
"--to",
"psse",
"-o",
"-",
]);
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn a_missing_input_exits_with_the_io_status_and_json_records() {
let out = run(&[
"--diagnostics-format",
"json",
"convert",
"does-not-exist.m",
"--to",
"psse",
"-o",
"-",
]);
assert_eq!(out.status.code(), Some(3));
let diagnostics = json_diagnostics(&out);
let record = &diagnostics[0];
assert_eq!(record["code"], "READ.IO.OPEN", "{record}");
assert_eq!(record["severity"], "error", "{record}");
let primary_id = record["id"].as_str().unwrap().to_owned();
assert!(
record["message"]
.as_str()
.unwrap()
.contains("does-not-exist.m"),
"{record}"
);
let notes: Vec<&serde_json::Value> = diagnostics
.iter()
.filter(|d| d["severity"] == "note")
.collect();
assert!(
notes.iter().any(|note| {
note["message"]
.as_str()
.unwrap()
.contains("No such file or directory")
}),
"{diagnostics:?}"
);
for note in ¬es {
assert_eq!(note["code"], "READ.IO.OPEN", "{note}");
assert_eq!(note["related"], serde_json::json!([primary_id]), "{note}");
}
for record in &diagnostics {
for key in record.as_object().unwrap().keys() {
assert!(
IR_DIAGNOSTIC_FIELDS.contains(&key.as_str()),
"unexpected field {key} in {record}"
);
}
}
let out = run(&[
"convert",
"does-not-exist.m",
"--to",
"psse",
"-o",
"-",
"--diagnostics-format",
"json",
]);
assert_eq!(out.status.code(), Some(3));
assert_eq!(json_diagnostics(&out)[0]["code"], "READ.IO.OPEN");
}
#[test]
fn json_format_leaves_only_the_diagnostics_array_on_stderr() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let output = std::env::temp_dir().join(format!("powerio-cli-json-{stamp}.raw"));
let case = repo_file("tests/data/case9.m");
let out = run(&[
"--diagnostics-format",
"json",
"convert",
case.to_str().unwrap(),
"--to",
"psse",
"-o",
output.to_str().unwrap(),
]);
assert_success(&out);
assert!(output.is_file(), "{} was not written", output.display());
let _ = std::fs::remove_file(&output);
let diagnostics = json_diagnostics(&out);
for record in &diagnostics {
assert_ne!(record["severity"], "error", "{record}");
}
let bundle = std::env::temp_dir().join(format!("powerio-cli-json-bundle-{stamp}"));
let out = run(&[
"--diagnostics-format",
"json",
"dcopf",
case.to_str().unwrap(),
"-o",
bundle.to_str().unwrap(),
]);
assert_success(&out);
assert!(bundle.is_dir(), "{} was not written", bundle.display());
let _ = std::fs::remove_dir_all(&bundle);
let diagnostics = json_diagnostics(&out);
for record in &diagnostics {
assert_ne!(record["severity"], "error", "{record}");
}
}
#[test]
fn a_malformed_case_exits_with_the_parse_status() {
let out = run_with_stdin(
&[
"--diagnostics-format",
"json",
"convert",
"-",
"--from",
"matpower",
"--to",
"psse",
"-o",
"-",
],
b"function mpc = broken\nmpc.bus = [\n\t1\t3\n",
);
assert_eq!(out.status.code(), Some(4));
let diagnostics = json_diagnostics(&out);
assert!(!diagnostics.is_empty(), "{diagnostics:?}");
assert!(
diagnostics
.iter()
.any(|d| d["code"].as_str().unwrap().starts_with("PARSE.") && d["severity"] == "error"),
"{diagnostics:?}"
);
let out = run_with_stdin(
&[
"convert", "-", "--from", "matpower", "--to", "psse", "-o", "-",
],
b"function mpc = broken\nmpc.bus = [\n\t1\t3\n",
);
assert_eq!(out.status.code(), Some(4));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.starts_with("Error: "), "{stderr}");
assert!(stderr.contains("PARSE."), "{stderr}");
}
fn assert_success(out: &Output) {
assert!(
out.status.success(),
"expected success\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
fn assert_failure(out: &Output) {
assert!(
!out.status.success(),
"expected failure\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn help_lists_user_facing_commands() {
let out = run(&["--help"]);
assert_success(&out);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Convert a case file"), "{stdout}");
assert!(stdout.contains("summary"), "{stdout}");
assert!(stdout.contains("gridfm"), "{stdout}");
}
#[test]
fn convert_to_stdout_keeps_text_on_stdout() {
let case = repo_file("tests/data/case9.m");
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
"matpower",
"-o",
"-",
]);
assert_success(&out);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("mpc.bus = ["), "{stdout}");
assert!(
!String::from_utf8_lossy(&out.stderr).contains("wrote "),
"stdout target should not report a file write: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn convert_goc3_problem_and_solution_source_emits_official_solution() {
let source = repo_file("tests/data/goc3");
let out = run(&[
"convert",
source.to_str().unwrap(),
"--to",
"goc3-json",
"-o",
"-",
]);
assert_success(&out);
let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let output = &value["time_series_output"];
assert!(
output.is_object(),
"{}",
String::from_utf8_lossy(&out.stdout)
);
assert_eq!(output["bus"].as_array().unwrap().len(), 2);
assert_eq!(
output["simple_dispatchable_device"]
.as_array()
.unwrap()
.len(),
2
);
}
#[test]
fn convert_goc3_solution_without_problem_reports_missing_problem() {
let solution = repo_file("tests/data/goc3/goc3_small_solution.json");
let out = run(&[
"convert",
solution.to_str().unwrap(),
"--to",
"goc3-json",
"-o",
"-",
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("requires the matching problem file"),
"{stderr}"
);
}
#[test]
fn convert_goc3_problem_to_matpower_uses_the_typed_instance() {
let problem = repo_file("tests/data/goc3/goc3_small.json");
let out = run(&[
"convert",
problem.to_str().unwrap(),
"--from",
"goc3",
"--to",
"matpower",
"-o",
"-",
]);
assert_success(&out);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stdout.contains("mpc.bus = ["), "{stdout}");
assert!(stderr.contains("EMIT.CALCULATION.DATA_OMITTED"), "{stderr}");
assert!(stderr.contains("powerio.AcScucInstance"), "{stderr}");
}
#[test]
fn convert_goc3_problem_and_solution_source_emits_cgmes_network() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let output = std::env::temp_dir().join(format!("powerio-cli-goc3-cgmes-{stamp}"));
let source = repo_file("tests/data/goc3");
let out = run(&[
"convert",
source.to_str().unwrap(),
"--to",
"cgmes",
"-o",
output.to_str().unwrap(),
]);
assert_success(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("EMIT.SOLUTION.DATA_OMITTED"), "{stderr}");
assert!(stderr.contains("powerio.AcScucSolution"), "{stderr}");
let names: Vec<_> = std::fs::read_dir(&output)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(names.len(), 4, "{names:?}");
assert!(names.iter().any(|name| name.ends_with("_EQ.xml")));
assert!(names.iter().any(|name| name.ends_with("_TP.xml")));
assert!(names.iter().any(|name| name.ends_with("_SSH.xml")));
assert!(names.iter().any(|name| name.ends_with("_SV.xml")));
std::fs::remove_dir_all(output).unwrap();
}
#[test]
fn convert_goc3_solution_ir_uses_the_same_typed_path() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let ir = std::env::temp_dir().join(format!("powerio-cli-goc3-{stamp}.pio.json"));
let source = repo_file("tests/data/goc3");
let serialized = run(&[
"serialize",
source.to_str().unwrap(),
"-o",
ir.to_str().unwrap(),
]);
assert_success(&serialized);
let converted = run(&[
"convert",
ir.to_str().unwrap(),
"--to",
"matpower",
"-o",
"-",
]);
assert_success(&converted);
let stdout = String::from_utf8_lossy(&converted.stdout);
let stderr = String::from_utf8_lossy(&converted.stderr);
assert!(stdout.contains("mpc.bus = ["), "{stdout}");
assert!(stderr.contains("EMIT.SOLUTION.DATA_OMITTED"), "{stderr}");
assert!(stderr.contains("powerio.AcScucSolution"), "{stderr}");
std::fs::remove_file(ir).unwrap();
}
#[test]
fn convert_refuses_an_existing_text_output() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let out_path = std::env::temp_dir().join(format!("powerio-cli-convert-{stamp}.m"));
std::fs::write(&out_path, "sentinel").unwrap();
let case = repo_file("tests/data/case9.m");
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
"matpower",
"-o",
out_path.to_str().unwrap(),
]);
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("already exists"), "{stderr}");
assert_eq!(std::fs::read_to_string(&out_path).unwrap(), "sentinel");
let fresh = std::env::temp_dir().join(format!("powerio-cli-convert-fresh-{stamp}.m"));
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
"matpower",
"-o",
fresh.to_str().unwrap(),
]);
assert_success(&out);
let text = std::fs::read_to_string(&fresh).unwrap();
assert!(text.contains("mpc.bus = ["), "{text}");
let _ = std::fs::remove_file(&fresh);
let _ = std::fs::remove_file(out_path);
}
#[test]
fn convert_writes_a_cgmes_profile_directory() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let output = std::env::temp_dir().join(format!("powerio-cli-cgmes-{stamp}"));
let case = repo_file("tests/data/case9.m");
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
"cgmes",
"-o",
output.to_str().unwrap(),
]);
assert_success(&out);
let names: Vec<_> = std::fs::read_dir(&output)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(names.len(), 4, "{names:?}");
assert!(names.iter().any(|name| name.ends_with("_EQ.xml")));
assert!(names.iter().any(|name| name.ends_with("_TP.xml")));
assert!(names.iter().any(|name| name.ends_with("_SSH.xml")));
assert!(names.iter().any(|name| name.ends_with("_SV.xml")));
std::fs::remove_dir_all(output).unwrap();
}
#[test]
fn convert_writes_jiidm_and_reads_it_back() {
let case = repo_file("tests/data/case9.m");
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
"jiidm",
"-o",
"-",
]);
assert_success(&out);
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.starts_with("{\n \"version\" : \"1.17\","), "{text}");
let out = run_with_stdin(&["summary", "-", "--from", "jiidm"], text.as_bytes());
assert_success(&out);
let summary = String::from_utf8_lossy(&out.stdout);
assert!(
summary.contains("\"source_format\": \"jiidm\""),
"{summary}"
);
assert!(summary.contains("\"buses\": 9"), "{summary}");
}
#[test]
fn convert_reserves_iidm_and_rawx_for_input() {
let case = repo_file("tests/data/case9.m");
for (input_spelling, canonical) in [("iidm", "xiidm"), ("rawx", "psse-rawx")] {
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
input_spelling,
"-o",
"-",
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("accepted for input only"), "{stderr}");
assert!(stderr.contains(canonical), "{stderr}");
}
}
#[test]
fn summary_outputs_machine_readable_json() {
let case = repo_file("tests/data/case9.m");
let out = run(&["summary", case.to_str().unwrap()]);
assert_success(&out);
let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(value["schema"], "powerio.summary");
assert_eq!(value["domain"], "transmission");
assert_eq!(value["elements"]["buses"], 9);
assert_eq!(value["topology"]["connected_components"], 1);
}
#[test]
fn summary_routes_json_inputs_through_the_classifier() {
let case = repo_file("tests/data/egret/case9.json");
let out = run(&["summary", case.to_str().unwrap()]);
assert_success(&out);
let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(value["domain"], "transmission");
assert_eq!(value["source_format"], "egret-json");
assert_eq!(value["elements"]["buses"], 9);
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dist_json = std::env::temp_dir().join(format!("powerio-cli-summary-{stamp}.json"));
let dss = repo_file("tests/data/dist/micro/xfmr_single_phase.dss");
let out = run(&[
"convert",
dss.to_str().unwrap(),
"--to",
"bmopf-json",
"-o",
dist_json.to_str().unwrap(),
]);
assert_success(&out);
let out = run(&["summary", dist_json.to_str().unwrap()]);
assert_success(&out);
let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(value["domain"], "distribution");
assert_eq!(value["elements"]["buses"], 2);
let _ = std::fs::remove_file(dist_json);
}
#[test]
fn serialize_refuses_an_existing_output_file() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let out_path = std::env::temp_dir().join(format!("powerio-cli-package-{stamp}.pio.json"));
std::fs::write(&out_path, "sentinel").unwrap();
let case = repo_file("tests/data/case9.m");
let out = run(&[
"serialize",
case.to_str().unwrap(),
"-o",
out_path.to_str().unwrap(),
]);
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("already exists"), "{stderr}");
assert_eq!(std::fs::read_to_string(&out_path).unwrap(), "sentinel");
let fresh = std::env::temp_dir().join(format!("powerio-cli-package-fresh-{stamp}.pio.json"));
let out = run(&[
"serialize",
case.to_str().unwrap(),
"-o",
fresh.to_str().unwrap(),
]);
assert_success(&out);
let text = std::fs::read_to_string(&fresh).unwrap();
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(value["schema"], powerio::IR_SCHEMA_NAME);
assert_eq!(value["version"], powerio::IR_VERSION);
assert_eq!(value["producer"]["version"], powerio::VERSION);
let _ = std::fs::remove_file(&fresh);
let _ = std::fs::remove_file(out_path);
}
#[test]
fn batch_writes_requested_matrices_rhs_and_metadata() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let out_dir = std::env::temp_dir().join(format!("powerio-cli-batch-{stamp}"));
let case = repo_file("tests/data/case9.m");
let out = run(&[
"batch",
"-i",
case.to_str().unwrap(),
"-o",
out_dir.to_str().unwrap(),
"--matrices",
"ybus_real,ybus_imag",
"--rhs",
"injection",
"--scheme",
"xb",
]);
assert_success(&out);
for name in [
"case9_ybus_real.mtx",
"case9_ybus_imag.mtx",
"case9_ybus_real_rhs.mtx",
"case9_ybus_imag_rhs.mtx",
"case9_shunt.mtx",
"case9_meta.json",
] {
assert!(out_dir.join(name).is_file(), "missing {name}");
}
let meta: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out_dir.join("case9_meta.json")).unwrap())
.unwrap();
assert_eq!(meta["case_name"], "case9");
assert_eq!(meta["n_buses"], 9);
assert_eq!(meta["n_branches"], 9);
assert_eq!(meta["matrices"].as_array().unwrap().len(), 2);
assert_eq!(meta["matrices"][0]["kind"], "ybus_real");
assert_eq!(meta["matrices"][1]["kind"], "ybus_imag");
assert_eq!(meta["source_sha256"].as_str().unwrap().len(), 64);
let _ = std::fs::remove_dir_all(out_dir);
}
#[test]
fn sensitivities_write_solver_metadata() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let out_dir = std::env::temp_dir().join(format!("powerio-cli-sensitivities-{stamp}"));
let case = repo_file("tests/data/case9.m");
let out = run(&[
"sensitivities",
case.to_str().unwrap(),
"-o",
out_dir.to_str().unwrap(),
"--solver",
"sparse",
"--formula",
"reactance-only",
"--drop-tolerance",
"1e-10",
]);
assert_success(&out);
for name in [
"case9_ptdf.mtx",
"case9_lodf.mtx",
"case9_sensitivity_meta.json",
] {
assert!(out_dir.join(name).is_file(), "missing {name}");
}
let meta: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(out_dir.join("case9_sensitivity_meta.json")).unwrap(),
)
.unwrap();
assert_eq!(meta["case"], "case9");
assert_eq!(meta["branch_susceptance_formula"], "reactance_only");
assert!(meta.get("convention").is_none());
assert_eq!(meta["sensitivity"]["requested_solver"], "sparse");
assert_eq!(meta["sensitivity"]["solver_path"], "sparse_cholesky");
assert_eq!(meta["sensitivity"]["drop_tolerance"], 1e-10);
assert_eq!(meta["sensitivity"]["ptdf"]["rows"], 9);
assert_eq!(meta["sensitivity"]["ptdf"]["cols"], 9);
assert_eq!(meta["sensitivity"]["lodf"]["rows"], 9);
assert_eq!(meta["sensitivity"]["lodf"]["cols"], 9);
let _ = std::fs::remove_dir_all(out_dir);
}
#[test]
fn pypsa_directory_target_requires_output_directory() {
let case = repo_file("tests/data/case9.m");
let out = run(&["convert", case.to_str().unwrap(), "--to", "pypsa-csv"]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("requires `-o <output-dir>`"),
"stderr:\n{stderr}"
);
}
#[test]
fn family_mismatch_exits_before_writing_output() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let out_path = std::env::temp_dir().join(format!("powerio-cli-family-{stamp}.m"));
let case = repo_file("tests/data/dist/micro/xfmr_single_phase.dss");
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
"matpower",
"-o",
out_path.to_str().unwrap(),
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("no conversion path"), "stderr:\n{stderr}");
assert!(
!out_path.exists(),
"failed conversion wrote {}",
out_path.display()
);
}
#[test]
fn batch_scans_directory_recursively_and_skips_unparseable_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let nested = root.join("a").join("b").join("c");
std::fs::create_dir_all(&nested).unwrap();
std::fs::copy(repo_file("tests/data/case9.m"), nested.join("case9.m")).unwrap();
std::fs::copy(
repo_file("tests/data/dist/micro/fourwire_linecode.dss"),
root.join("feeder.dss"),
)
.unwrap();
std::fs::write(root.join("junk.json"), r#"{"not": "a case"}"#).unwrap();
let hidden = root.join(".hidden");
std::fs::create_dir_all(&hidden).unwrap();
std::fs::copy(repo_file("tests/data/case14.m"), hidden.join("case14.m")).unwrap();
let out_dir = root.join("out");
std::fs::create_dir_all(&out_dir).unwrap();
std::fs::copy(repo_file("tests/data/case30.m"), out_dir.join("case30.m")).unwrap();
let out = run(&[
"batch",
"-i",
root.to_str().unwrap(),
"-o",
out_dir.to_str().unwrap(),
]);
assert_success(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out_dir.join("case9_bprime.mtx").is_file(), "{stderr}");
assert!(
out_dir.join("fourwire_bprime.mtx").is_file(),
"lowered dss case missing:\n{stderr}"
);
assert!(stderr.contains("junk.json"), "no skip warning:\n{stderr}");
assert!(
!out_dir.join("case14_bprime.mtx").exists(),
"hidden directory was scanned:\n{stderr}"
);
assert!(
!out_dir.join("case30_bprime.mtx").exists(),
"output directory was scanned:\n{stderr}"
);
}
#[test]
fn batch_scan_discovers_bmopf_json() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let json_path = root.join("sub").join("feeder.json");
std::fs::create_dir_all(json_path.parent().unwrap()).unwrap();
let dss = repo_file("tests/data/dist/micro/fourwire_linecode.dss");
let out = run(&[
"convert",
dss.to_str().unwrap(),
"--to",
"bmopf-json",
"-o",
json_path.to_str().unwrap(),
]);
assert_success(&out);
let out_dir = root.join("out");
let out = run(&[
"batch",
"-i",
root.to_str().unwrap(),
"-o",
out_dir.to_str().unwrap(),
]);
assert_success(&out);
assert!(
out_dir.join("fourwire_bprime.mtx").is_file(),
"bmopf json case missing:\n{}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn batch_scan_skips_unlowerable_dss_but_explicit_input_fails() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let dss = repo_file("tests/data/dist/micro/xfmr_single_phase.dss");
std::fs::copy(&dss, root.join("xfmr.dss")).unwrap();
std::fs::copy(repo_file("tests/data/case9.m"), root.join("case9.m")).unwrap();
let out_dir = root.join("out");
let out = run(&[
"batch",
"-i",
root.to_str().unwrap(),
"-o",
out_dir.to_str().unwrap(),
]);
assert_success(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out_dir.join("case9_bprime.mtx").is_file(), "{stderr}");
assert!(stderr.contains("xfmr.dss"), "no skip warning:\n{stderr}");
let out = run(&[
"batch",
"-i",
dss.to_str().unwrap(),
"-o",
out_dir.to_str().unwrap(),
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("lower") && stderr.contains("balanced"),
"expected a lowering diagnostic:\n{stderr}"
);
}
#[test]
fn batch_scan_with_no_case_files_reports_supported_extensions() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("notes.txt"), "nothing here").unwrap();
let out = run(&[
"batch",
"-i",
root.to_str().unwrap(),
"-o",
root.to_str().unwrap(),
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no case files") && stderr.contains(".epc"),
"expected the extension list:\n{stderr}"
);
}
#[test]
fn batch_scan_where_nothing_loads_fails() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("junk.json"), r#"{"not": "a case"}"#).unwrap();
let out = run(&[
"batch",
"-i",
root.to_str().unwrap(),
"-o",
root.join("out").to_str().unwrap(),
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("loaded as case files"),
"expected the zero-loaded error:\n{stderr}"
);
}
#[test]
fn convert_exits_nonzero_on_a_refused_include() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let case_dir = dir.join("case");
std::fs::create_dir_all(&case_dir).unwrap();
std::fs::write(
dir.join("shared.dss"),
"New Linecode.lc1 nphases=3 r1=0.1 x1=0.2\n",
)
.unwrap();
let master = case_dir.join("master.dss");
std::fs::write(
&master,
"New Circuit.c1\nRedirect ../shared.dss\nNew Line.l1 bus1=a bus2=b linecode=lc1\n",
)
.unwrap();
let out_path = dir.join("out.json");
let out = run(&[
"convert",
master.to_str().unwrap(),
"--to",
"bmopf",
"-o",
out_path.to_str().unwrap(),
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("READ.DSS.INCLUDE_REFUSED"),
"stderr:\n{stderr}"
);
assert!(
stderr.contains("the output is incomplete"),
"stderr:\n{stderr}"
);
assert!(
out_path.is_file(),
"the output must still be written for inspection"
);
}
#[cfg(unix)]
#[test]
fn convert_exits_nonzero_on_an_include_refused_through_a_symbolic_link() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let case_dir = dir.join("case");
std::fs::create_dir_all(&case_dir).unwrap();
std::fs::write(
dir.join("shared.dss"),
"New Linecode.lc1 nphases=3 r1=0.1 x1=0.2\n",
)
.unwrap();
std::os::unix::fs::symlink("../shared.dss", case_dir.join("link.dss")).unwrap();
let master = case_dir.join("master.dss");
std::fs::write(
&master,
"New Circuit.c1\nRedirect link.dss\nNew Line.l1 bus1=a bus2=b linecode=lc1\n",
)
.unwrap();
let out_path = dir.join("out.json");
let out = run(&[
"convert",
master.to_str().unwrap(),
"--to",
"bmopf",
"-o",
out_path.to_str().unwrap(),
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("READ.DSS.INCLUDE_REFUSED"),
"stderr:\n{stderr}"
);
assert!(
stderr.contains("symbolic link"),
"the refusal must name the route:\n{stderr}"
);
assert!(
stderr.contains("references unknown linecode"),
"the linked file must stay unread:\n{stderr}"
);
}
#[test]
fn serialize_exits_nonzero_on_a_refused_include() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let case_dir = dir.join("case");
std::fs::create_dir_all(&case_dir).unwrap();
std::fs::write(
dir.join("shared.dss"),
"New Linecode.lc1 nphases=3 r1=0.1 x1=0.2\n",
)
.unwrap();
let master = case_dir.join("master.dss");
std::fs::write(
&master,
"New Circuit.c1\nRedirect ../shared.dss\nNew Line.l1 bus1=a bus2=b linecode=lc1\n",
)
.unwrap();
let out_path = dir.join("out.pio.json");
let out = run(&[
"serialize",
master.to_str().unwrap(),
"-o",
out_path.to_str().unwrap(),
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("READ.DSS.INCLUDE_REFUSED"),
"stderr:\n{stderr}"
);
assert!(
out_path.is_file(),
"the PowerIO IR must still be serialized for inspection"
);
}
#[test]
fn an_unreadable_include_is_a_warning_not_a_containment_refusal() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let unreadable = dir.join("shared.dss");
std::fs::write(&unreadable, "New Linecode.lc1 nphases=3 r1=0.1 x1=0.2\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap();
}
let master = dir.join("master.dss");
std::fs::write(
&master,
"New Circuit.c1\nRedirect shared.dss\nNew Line.l1 bus1=a bus2=b r1=0.1 x1=0.2\n",
)
.unwrap();
let out_path = dir.join("out.json");
let out = run(&[
"convert",
master.to_str().unwrap(),
"--to",
"bmopf",
"-o",
out_path.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&out.stderr);
#[cfg(unix)]
if !nix_running_as_root() {
assert!(
out.status.success(),
"an unreadable include is not an escape attempt:\n{stderr}"
);
assert!(
!stderr.contains("READ.DSS.INCLUDE_REFUSED"),
"stderr:\n{stderr}"
);
}
}
#[cfg(unix)]
fn nix_running_as_root() -> bool {
std::fs::File::open("/proc/1/mem").is_ok() || std::env::var("USER").is_ok_and(|u| u == "root")
}
#[test]
fn convert_writes_a_deserialized_distribution_module_through_the_writer() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let ir = std::env::temp_dir().join(format!("powerio-cli-dss-{stamp}.pio.json"));
let source = repo_file("tests/data/dist/micro/switch.dss");
let serialized = run(&[
"serialize",
source.to_str().unwrap(),
"-o",
ir.to_str().unwrap(),
]);
assert_success(&serialized);
for target in ["dss", "bmopf-json"] {
let converted = run(&["convert", ir.to_str().unwrap(), "--to", target, "-o", "-"]);
assert_success(&converted);
let stdout = String::from_utf8_lossy(&converted.stdout);
assert!(
!stdout.contains("\"pio-ir\""),
"{target} echoed the IR:\n{stdout}"
);
}
let dss = run(&["convert", ir.to_str().unwrap(), "--to", "dss", "-o", "-"]);
let stdout = String::from_utf8_lossy(&dss.stdout).to_ascii_lowercase();
assert!(stdout.contains("new circuit"), "{stdout}");
std::fs::remove_file(ir).unwrap();
}
#[test]
fn a_typed_library_failure_exits_with_its_category_and_code() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let case = std::env::temp_dir().join(format!("powerio-cli-zero-impedance-{stamp}.m"));
std::fs::write(
&case,
"function mpc = zero\n\
mpc.version = '2';\n\
mpc.baseMVA = 100;\n\
mpc.bus = [\n\
1 3 0 0 0 0 1 1 0 230 1 1.1 0.9;\n\
2 1 10 5 0 0 1 1 0 230 1 1.1 0.9;\n\
];\n\
mpc.gen = [\n\
1 10 5 100 -100 1 100 1 100 0 0 0 0 0 0 0 0 0 0 0 0;\n\
];\n\
mpc.branch = [\n\
1 2 0 0 0 100 100 100 0 0 1 -360 360;\n\
];\n",
)
.unwrap();
let out = run(&[
"--diagnostics-format",
"json",
"verify",
case.to_str().unwrap(),
"--kind",
"bdoubleprime",
]);
assert_eq!(
out.status.code(),
Some(5),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let records = json_diagnostics(&out);
let code = records[0]["code"].as_str().unwrap();
assert_ne!(code, "BIND.CLI.UNCLASSIFIED", "{records:?}");
assert!(code.contains("ZERO_IMPEDANCE"), "{records:?}");
std::fs::remove_file(case).unwrap();
}