1use crate::model::{DistSourceFormat, MulticonductorNetwork};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7pub(crate) struct TextSidecar {
8 pub(crate) path: String,
10 pub(crate) text: String,
12}
13
14#[derive(Debug, Clone)]
16pub(crate) struct TextEmission {
17 pub(crate) text: String,
18 pub(crate) sidecars: Vec<TextSidecar>,
19 pub(crate) diagnostics: Vec<crate::diagnostics::Diagnostic>,
20 pub(crate) fidelity: powerio_core::Fidelity,
21}
22
23impl TextEmission {
24 pub(crate) fn new(
25 text: String,
26 sidecars: Vec<TextSidecar>,
27 diagnostics: crate::diagnostics::Diagnostics,
28 ) -> Self {
29 Self {
30 text,
31 sidecars,
32 diagnostics: diagnostics.into_records(),
33 fidelity: powerio_core::Fidelity::Canonical,
34 }
35 }
36
37 pub(crate) fn faithful(text: String) -> Self {
39 let mut emission = Self::new(text, Vec::new(), crate::diagnostics::Diagnostics::new());
40 emission.fidelity = powerio_core::Fidelity::ExactSameFormat;
41 emission
42 }
43
44 pub(crate) fn push(
46 &mut self,
47 info: &'static crate::diagnostics::DiagnosticInfo,
48 message: impl Into<String>,
49 ) {
50 self.diagnostics
51 .push(crate::diagnostics::Diagnostic::of(info, message));
52 }
53
54 #[cfg(test)]
55 pub(crate) fn render_diagnostics(&self) -> Vec<String> {
56 crate::diagnostics::render_diagnostics(&self.diagnostics)
57 }
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62#[non_exhaustive]
63pub enum DistTargetFormat {
64 Dss,
65 BmopfJson,
66 PmdJson,
67}
68
69#[derive(Clone, Debug, Default, PartialEq)]
71#[non_exhaustive]
72pub struct EmitOptions {
73 pub dss: crate::dss::DssEmitOptions,
74 pub bmopf: crate::bmopf::BmopfEmitOptions,
75}
76
77impl EmitOptions {
78 fn is_default_for(&self, format: DistTargetFormat) -> bool {
79 match format {
80 DistTargetFormat::Dss => self.dss == crate::dss::DssEmitOptions::default(),
81 DistTargetFormat::BmopfJson => self.bmopf == crate::bmopf::BmopfEmitOptions::default(),
82 DistTargetFormat::PmdJson => true,
83 }
84 }
85}
86
87pub fn parse_dist_target_format(name: &str) -> Option<DistTargetFormat> {
89 let key = canonical_key(name);
90 match key.as_str() {
91 "dss" | "opendss" => Some(DistTargetFormat::Dss),
92 "pmd" | "pmdjson" | "engineering" => Some(DistTargetFormat::PmdJson),
93 "bmopf" | "bmopfjson" => Some(DistTargetFormat::BmopfJson),
94 _ => None,
95 }
96}
97
98impl std::str::FromStr for DistTargetFormat {
99 type Err = crate::Error;
100
101 fn from_str(s: &str) -> crate::Result<Self> {
104 parse_dist_target_format(s).ok_or_else(|| crate::Error::UnknownFormat(s.to_string()))
105 }
106}
107
108impl DistTargetFormat {
109 pub fn name(self) -> &'static str {
112 match self {
113 DistTargetFormat::Dss => "dss",
114 DistTargetFormat::PmdJson => "pmd-json",
115 DistTargetFormat::BmopfJson => "bmopf-json",
116 }
117 }
118}
119
120fn canonical_key(name: &str) -> String {
121 name.to_ascii_lowercase()
122 .chars()
123 .filter(|c| *c != '-' && *c != '_')
124 .collect()
125}
126
127const DISTRIBUTION_ELEMENT_TABLES: &[&str] = &[
140 "capacitor",
141 "control_profile",
146 "generator",
147 "ibr",
148 "line",
149 "linecode",
150 "load",
151 "meta",
152 "shunt",
153 "switch",
154 "terminal_conventions",
155 "transformer",
156 "voltage_source",
157];
158
159const NOT_BMOPF_KEYS: &[&str] = &[
164 "baseMVA",
165 "branch",
166 "dcline",
167 "gen",
168 "per_unit",
169 "source_type",
170 "source_version",
171 "storage",
172];
173
174const PMD_MARKER: &str = "data_model";
178
179#[allow(clippy::struct_excessive_bools)]
191#[derive(Default)]
192struct TopLevel {
193 is_object: bool,
195 pmd_marker: bool,
196 bus: bool,
197 dist_table: bool,
199 not_bmopf: bool,
201}
202
203impl<'de> serde::Deserialize<'de> for TopLevel {
204 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
205 struct Probe;
206
207 impl<'de> serde::de::Visitor<'de> for Probe {
208 type Value = TopLevel;
209
210 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 f.write_str("a JSON document")
212 }
213
214 fn visit_map<A: serde::de::MapAccess<'de>>(
215 self,
216 mut map: A,
217 ) -> Result<TopLevel, A::Error> {
218 let mut out = TopLevel {
219 is_object: true,
220 ..TopLevel::default()
221 };
222 while let Some(key) = map.next_key::<std::borrow::Cow<'_, str>>()? {
227 let key = key.as_ref();
228 out.pmd_marker |= key == PMD_MARKER;
229 out.bus |= key == "bus";
230 out.dist_table |= DISTRIBUTION_ELEMENT_TABLES.contains(&key);
231 out.not_bmopf |= NOT_BMOPF_KEYS.contains(&key);
232 map.next_value::<serde::de::IgnoredAny>()?;
233 }
234 Ok(out)
235 }
236
237 fn visit_bool<E>(self, _: bool) -> Result<TopLevel, E> {
242 Ok(TopLevel::default())
243 }
244 fn visit_i64<E>(self, _: i64) -> Result<TopLevel, E> {
245 Ok(TopLevel::default())
246 }
247 fn visit_u64<E>(self, _: u64) -> Result<TopLevel, E> {
248 Ok(TopLevel::default())
249 }
250 fn visit_f64<E>(self, _: f64) -> Result<TopLevel, E> {
251 Ok(TopLevel::default())
252 }
253 fn visit_str<E>(self, _: &str) -> Result<TopLevel, E> {
254 Ok(TopLevel::default())
255 }
256 fn visit_unit<E>(self) -> Result<TopLevel, E> {
257 Ok(TopLevel::default())
258 }
259 fn visit_none<E>(self) -> Result<TopLevel, E> {
260 Ok(TopLevel::default())
261 }
262 fn visit_seq<A: serde::de::SeqAccess<'de>>(
263 self,
264 mut seq: A,
265 ) -> Result<TopLevel, A::Error> {
266 while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
267 Ok(TopLevel::default())
268 }
269 }
270
271 deserializer.deserialize_any(Probe)
272 }
273}
274
275pub fn classify_distribution_json(text: &str) -> crate::Result<DistTargetFormat> {
293 let text = text.trim_start_matches('\u{feff}');
296 let unrecognized = |detail: &str| crate::Error::Json {
297 format: "distribution",
298 message: format!(
299 "not a recognized distribution document: {detail}. PMD ENGINEERING JSON \
300 carries `data_model`; BMOPF JSON carries a `bus` table beside one of \
301 {DISTRIBUTION_ELEMENT_TABLES:?}. Pass the format explicitly to override."
302 ),
303 };
304
305 let Ok(top) = serde_json::from_str::<TopLevel>(text) else {
306 return Ok(DistTargetFormat::BmopfJson);
307 };
308 if !top.is_object {
309 return Err(unrecognized("the top level is not an object"));
310 }
311
312 if top.pmd_marker {
318 return Ok(DistTargetFormat::PmdJson);
319 }
320 if top.bus && top.dist_table && !top.not_bmopf {
321 return Ok(DistTargetFormat::BmopfJson);
322 }
323 Err(if top.bus && top.not_bmopf {
324 unrecognized(
325 "it carries a `bus` table with PowerModels keys beside it, so it is a \
326 transmission document; read it through the transmission hub",
327 )
328 } else if top.bus {
329 unrecognized("its `bus` table has no distribution element table beside it")
330 } else {
331 unrecognized("it carries no marker of either format")
332 })
333}
334
335pub fn parse(
349 source: powerio_core::Source,
350) -> std::result::Result<powerio_core::PioModule<MulticonductorNetwork>, powerio_core::Error> {
351 let mut warnings = crate::collect::Diagnostics::new();
352 match parse_to_network(&source, &mut warnings) {
353 Ok(network) => {
354 let format = *network.source_format();
355 let source =
358 match format.and_then(|format| powerio_core::FormatId::new(format.name()).ok()) {
359 Some(format) => source.with_format(format),
360 None => source,
361 };
362 powerio_core::PioModule::parsed(network, source, warnings.into_records())
363 }
364 Err(error) => {
365 let core = powerio_core::Error::new(error.code(), error.to_string());
366 Err(core
367 .with_diagnostics(warnings.into_records())
368 .with_cause(error)
369 .with_source(source))
370 }
371 }
372}
373
374fn parse_to_network(
376 source: &powerio_core::Source,
377 warnings: &mut crate::collect::Diagnostics,
378) -> crate::Result<MulticonductorNetwork> {
379 if source.is_directory() {
380 return Err(crate::Error::UnknownFormat(format!(
381 "{} is a directory, and no distribution case format is a directory; pass a case file",
382 source.name()
383 )));
384 }
385 let declared = source
386 .format()
387 .map(powerio_core::FormatId::as_str)
388 .map(|name| {
389 parse_dist_target_format(name)
390 .ok_or_else(|| crate::Error::UnknownFormat(name.to_string()))
391 })
392 .transpose()?;
393 let format = if let Some(format) = declared {
394 format
395 } else {
396 let ext = std::path::Path::new(source.name())
397 .extension()
398 .and_then(|e| e.to_str())
399 .unwrap_or_default()
400 .to_ascii_lowercase();
401 match ext.as_str() {
402 "dss" => DistTargetFormat::Dss,
403 "json" => {
404 let buffer = primary(source)?;
405 classify_distribution_json(source_text(&buffer)?)?
406 }
407 other => return Err(crate::Error::UnknownFormat(other.to_string())),
408 }
409 };
410 match format {
411 DistTargetFormat::Dss => crate::dss::read::parse_dss_collecting(source, warnings),
412 DistTargetFormat::BmopfJson => {
413 let buffer = primary(source)?;
414 crate::bmopf::read::parse_bmopf_collecting(source_text(&buffer)?, warnings)
415 }
416 DistTargetFormat::PmdJson => {
417 let buffer = primary(source)?;
418 crate::pmd::read::parse_pmd_collecting(source_text(&buffer)?, warnings)
419 }
420 }
421}
422
423fn primary(source: &powerio_core::Source) -> crate::Result<powerio_core::SourceBuffer> {
425 source
426 .primary_buffer()
427 .map_err(|error| crate::Error::FormatRead {
428 format: "case text",
429 message: error.to_string(),
430 })
431}
432
433fn source_text(buffer: &powerio_core::SourceBuffer) -> crate::Result<&str> {
437 std::str::from_utf8(buffer.content_bytes()).map_err(|e| crate::Error::FormatRead {
438 format: "case text",
439 message: format!("not valid UTF-8: {e}"),
440 })
441}
442
443impl DistTargetFormat {
444 fn matches(self, source: Option<DistSourceFormat>) -> bool {
445 matches!(
446 (self, source),
447 (DistTargetFormat::Dss, Some(DistSourceFormat::Dss))
448 | (
449 DistTargetFormat::BmopfJson,
450 Some(DistSourceFormat::BmopfJson)
451 )
452 | (DistTargetFormat::PmdJson, Some(DistSourceFormat::PmdJson))
453 )
454 }
455}
456
457pub(crate) fn emit_text_with_options(
459 module: &powerio_core::PioModule<MulticonductorNetwork>,
460 format: DistTargetFormat,
461 options: &EmitOptions,
462) -> Result<TextEmission, powerio_core::Error> {
463 let output = if options.is_default_for(format)
464 && let Some(text) = echo_text(module, format)
465 {
466 TextEmission::faithful(text)
467 } else {
468 crate::readiness::require_resolved_geometry(module.value())?;
469 emit_value_text_with_options(module.value(), format, options)
470 };
471 Ok(output)
472}
473
474fn echo_text(
478 module: &powerio_core::PioModule<MulticonductorNetwork>,
479 target: DistTargetFormat,
480) -> Option<String> {
481 let source = module.source()?;
482 let buffer = source.primary_buffer().ok()?;
483 let declared = source
487 .format()
488 .and_then(|format| parse_dist_target_format(format.as_str()))?;
489 if declared != target || !target.matches(*module.value().source_format()) {
490 return None;
491 }
492 let text = std::str::from_utf8(buffer.bytes()).ok()?;
493 Some(text.to_owned())
494}
495
496pub(crate) fn emit_value_text_with_options(
498 net: &MulticonductorNetwork,
499 format: DistTargetFormat,
500 options: &EmitOptions,
501) -> TextEmission {
502 let mut conv = match format {
503 DistTargetFormat::Dss => crate::dss::emit_dss_text_with_options(net, &options.dss),
504 DistTargetFormat::BmopfJson => {
505 crate::bmopf::emit_bmopf_json_text_with_options(net, options.bmopf)
506 }
507 DistTargetFormat::PmdJson => crate::pmd::emit_pmd_json_text(net),
508 };
509 let routed = net
512 .lines()
513 .iter()
514 .filter(|line| line.route.is_some())
515 .count();
516 if routed > 0 {
517 conv.push(
518 &crate::diagnostics::codes::EMIT_MULTICONDUCTOR_ROUTE_DROPPED,
519 format!(
520 "{routed} line route(s) dropped: {} has no polyline field",
521 format.name()
522 ),
523 );
524 }
525 conv
526}
527
528#[cfg(test)]
529pub(crate) fn emit_value_text(
530 net: &MulticonductorNetwork,
531 format: DistTargetFormat,
532) -> TextEmission {
533 emit_value_text_with_options(net, format, &EmitOptions::default())
534}
535
536pub fn emit(
552 module: &powerio_core::PioModule<MulticonductorNetwork>,
553 format: DistTargetFormat,
554 destination: powerio_core::Destination,
555) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
556 emit_with_options(module, format, &EmitOptions::default(), destination)
557}
558
559pub fn emit_with_options(
568 module: &powerio_core::PioModule<MulticonductorNetwork>,
569 format: DistTargetFormat,
570 options: &EmitOptions,
571 destination: powerio_core::Destination,
572) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
573 let conv = emit_text_with_options(module, format, options)?;
574 match format {
575 DistTargetFormat::Dss => {
576 let mut artifacts = vec![powerio_core::MemoryArtifact::new(
577 powerio_core::ArtifactPath::new("case.dss")
578 .expect("static name is a valid artifact path"),
579 conv.text.into_bytes(),
580 )];
581 for sidecar in conv.sidecars {
582 artifacts.push(powerio_core::MemoryArtifact::new(
583 powerio_core::ArtifactPath::new(sidecar.path)?,
584 sidecar.text.into_bytes(),
585 ));
586 }
587 destination.__commit_artifacts(true, conv.fidelity, artifacts, conv.diagnostics)
588 }
589 DistTargetFormat::BmopfJson | DistTargetFormat::PmdJson => {
590 let artifact = powerio_core::MemoryArtifact::new(
591 powerio_core::ArtifactPath::new("case")
592 .expect("static name is a valid artifact path"),
593 conv.text.into_bytes(),
594 );
595 destination.__commit_artifacts(false, conv.fidelity, vec![artifact], conv.diagnostics)
596 }
597 }
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 #[test]
605 fn distribution_json_classifier_preserves_pmd_marker_and_bmopf_fallback() {
606 for doc in [
607 r#"{"data_model": "ENGINEERING"}"#,
608 r#"{"data_model": "MATHEMATICAL"}"#,
609 r#"{"data_model": 7}"#,
612 r#"{"data_model": null}"#,
613 ] {
614 assert_eq!(
615 classify_distribution_json(doc).unwrap(),
616 DistTargetFormat::PmdJson,
617 "{doc}"
618 );
619 }
620 for doc in [
621 r#"{"bus": {}, "voltage_source": {}}"#,
622 r#"{"bus": {}, "line": {}, "linecode": {}}"#,
625 r#"{"bus": {}, "transformer": {}}"#,
626 r#"{"bus": {}, "capacitor": {}}"#,
627 r#"{"bus": {}, "generator": {}}"#,
628 r#"{"bus": {}, "ibr": {}}"#,
631 r#"{"bus": {}, "control_profile": {}}"#,
632 ] {
633 assert_eq!(
634 classify_distribution_json(doc).unwrap(),
635 DistTargetFormat::BmopfJson,
636 "{doc}"
637 );
638 }
639 assert_eq!(
642 classify_distribution_json("{not json").unwrap(),
643 DistTargetFormat::BmopfJson
644 );
645 assert_eq!(
648 classify_distribution_json("\u{feff}{\"data_model\": \"ENGINEERING\"}").unwrap(),
649 DistTargetFormat::PmdJson
650 );
651 }
652
653 #[test]
658 fn a_powermodels_document_never_classifies_as_bmopf() {
659 let powermodels = r#"{"baseMVA": 100.0, "branch": {}, "bus": {}, "dcline": {},
661 "gen": {}, "load": {}, "name": "case14", "per_unit": true, "shunt": {},
662 "source_type": "matpower", "source_version": "2", "storage": {},
663 "switch": {}}"#;
664 assert!(classify_distribution_json(powermodels).is_err());
665
666 for marker in NOT_BMOPF_KEYS {
670 let doc = format!("{{\"bus\": {{}}, \"linecode\": {{}}, \"{marker}\": 1}}");
671 assert!(
672 classify_distribution_json(&doc).is_err(),
673 "`{marker}` must refuse the BMOPF reading: {doc}"
674 );
675 }
676 }
677
678 #[test]
683 fn shared_table_names_classify_as_bmopf_and_the_veto_still_refuses_powermodels() {
684 for doc in [
687 r#"{"bus": {}, "load": {}}"#,
688 r#"{"bus": {}, "shunt": {}}"#,
689 r#"{"bus": {}, "switch": {}}"#,
690 r#"{"bus": {}, "meta": {"frequency": 60}}"#,
691 ] {
692 assert_eq!(
693 classify_distribution_json(doc).unwrap(),
694 DistTargetFormat::BmopfJson,
695 "{doc}"
696 );
697 }
698 for doc in [
700 r#"{"bus": {}, "load": {}, "baseMVA": 100.0}"#,
701 r#"{"bus": {}, "shunt": {}, "branch": {}}"#,
702 r#"{"bus": {}, "switch": {}, "per_unit": true}"#,
703 ] {
704 assert!(classify_distribution_json(doc).is_err(), "{doc}");
705 }
706 }
707
708 #[test]
712 fn the_pmd_marker_wins_over_shared_element_tables() {
713 let both = r#"{"data_model": "ENGINEERING", "bus": {}, "line": {}, "linecode": {}}"#;
714 assert_eq!(
715 classify_distribution_json(both).unwrap(),
716 DistTargetFormat::PmdJson
717 );
718 }
719
720 #[test]
721 fn unclassifiable_documents_are_refused_with_a_reason() {
722 for (doc, needle) in [
723 (
724 r#"{"bus": {"data_model": {}}}"#,
725 "no distribution element table",
726 ),
727 (r#"{"name": "data_model"}"#, "no marker of either format"),
728 ("{}", "no marker of either format"),
729 ("[]", "not an object"),
730 ("null", "not an object"),
731 ("3", "not an object"),
732 (r#""a string""#, "not an object"),
733 ("true", "not an object"),
734 ] {
735 let err = classify_distribution_json(doc).unwrap_err().to_string();
736 assert!(err.contains(needle), "{doc}: got {err}");
737 }
738 }
739
740 #[test]
745 fn the_probe_is_bounded_on_adversarial_shapes() {
746 let big = format!(
748 r#"{{"bus": {{}}, "linecode": {{}}, "junk": [{}]}}"#,
749 "0,".repeat(200_000) + "0"
750 );
751 assert_eq!(
752 classify_distribution_json(&big).unwrap(),
753 DistTargetFormat::BmopfJson
754 );
755
756 let mut keys = String::new();
758 for i in 0..50_000 {
759 use std::fmt::Write as _;
760 let _ = write!(keys, "\"k{i}\":0,");
761 }
762 let many = format!(r#"{{{keys}"bus":{{}},"linecode":{{}}}}"#);
763 assert_eq!(
764 classify_distribution_json(&many).unwrap(),
765 DistTargetFormat::BmopfJson
766 );
767
768 let deep = format!(
774 r#"{{"bus":{{}},"linecode":{{}},"junk":{}{}}}"#,
775 "[".repeat(20_000),
776 "]".repeat(20_000)
777 );
778 assert_eq!(
779 classify_distribution_json(&deep).unwrap(),
780 DistTargetFormat::BmopfJson
781 );
782
783 assert_eq!(
785 classify_distribution_json(
786 r#"{"data_model":"ENGINEERING","data_model":"ENGINEERING"}"#
787 )
788 .unwrap(),
789 DistTargetFormat::PmdJson
790 );
791 }
792
793 #[test]
798 fn a_document_the_probe_accepts_is_refused_by_the_reader_not_a_crash() {
799 for depth in [200usize, 20_000, 500_000] {
800 let doc = format!(
801 "{{\"bus\":{{}},\"linecode\":{{}},\"junk\":{}{}}}",
802 "[".repeat(depth),
803 "]".repeat(depth)
804 );
805 let format = classify_distribution_json(&doc).expect("markers are present");
806 assert_eq!(format, DistTargetFormat::BmopfJson);
807 let err = crate::testkit::parse_str(&doc, format.name())
808 .expect_err("the reader refuses past its recursion limit");
809 assert!(
810 err.to_string().contains("recursion limit"),
811 "depth {depth}: {err}"
812 );
813 }
814 }
815
816 #[test]
820 fn marker_matching_is_case_sensitive() {
821 for doc in [
822 r#"{"Data_Model": "ENGINEERING"}"#,
823 r#"{"DATA_MODEL": "ENGINEERING"}"#,
824 r#"{"Bus": {}, "Linecode": {}}"#,
825 ] {
826 assert!(classify_distribution_json(doc).is_err(), "{doc}");
827 }
828 }
829
830 #[test]
831 fn byte_order_mark_is_retained_and_echoed() {
832 let dss = "\u{feff}clear\nnew circuit.c basekv=12.47 bus1=src\n";
833 let net = crate::testkit::parse_dss_str(dss);
834 assert_eq!(net.name().as_deref(), Some("c"));
835 assert!(
836 !net.warnings.iter().any(|w| w.contains("byte order mark")),
837 "retaining the mark is not a loss: {:?}",
838 net.warnings
839 );
840 assert_eq!(net.emit(DistTargetFormat::Dss).text, dss);
843 assert!(
844 net.source
845 .as_ref()
846 .is_some_and(|s| !s.starts_with('\u{feff}'))
847 );
848 }
849
850 #[test]
851 fn memory_and_file_sources_parse_the_same_fixture_alike() {
852 let path = concat!(
853 env!("CARGO_MANIFEST_DIR"),
854 "/../tests/data/dist/micro/onephase_zip_load.dss"
855 );
856 let bytes = std::fs::read(path).unwrap();
857 let from_bytes =
858 crate::testkit::parse_str(std::str::from_utf8(&bytes).unwrap(), "dss").unwrap();
859 let from_file = crate::testkit::parse_file(path, None).unwrap();
860 assert_eq!(from_bytes.buses().len(), from_file.buses().len());
861 assert_eq!(from_bytes.loads().len(), from_file.loads().len());
862 assert_eq!(from_bytes.source, from_file.source);
863 }
864
865 #[test]
866 fn non_utf8_bytes_are_refused_and_name_the_encoding() {
867 let bytes: &[u8] = b"clear\nnew circuit.caf\xE9 basekv=12.47 bus1=src\n";
869 let source = powerio_core::Source::from_memory("<memory>", bytes.to_vec())
870 .unwrap()
871 .with_format(powerio_core::FormatId::new("dss").unwrap());
872 let err = parse(source).unwrap_err();
873 assert!(err.to_string().contains("UTF-8"), "{err}");
874 }
875
876 #[test]
877 fn parse_rejects_unclassifiable_json() {
878 let dir = tempfile::tempdir().unwrap();
881 let path = dir.path().join("case.json");
882 std::fs::write(
883 &path,
884 r#"{"bus": {}, "branch": {}, "gen": {}, "baseMVA": 100.0}"#,
885 )
886 .unwrap();
887 let err = crate::testkit::parse_file(&path, None).unwrap_err();
888 assert!(
889 err.to_string()
890 .contains("not a recognized distribution document"),
891 "{err}"
892 );
893 assert!(crate::testkit::parse_file(&path, Some("bmopf-json")).is_ok());
895 }
896
897 #[test]
898 fn unknown_format_names_fail_before_any_work() {
899 assert!(matches!(
900 crate::testkit::parse_str("", "matpower"),
901 Err(crate::Error::UnknownFormat(_))
902 ));
903 assert!(matches!(
904 "matpower".parse::<DistTargetFormat>(),
905 Err(crate::Error::UnknownFormat(_))
906 ));
907 assert!(matches!(
908 crate::testkit::parse_file("missing.dss", Some("matpower")),
909 Err(crate::Error::UnknownFormat(_))
910 ));
911 }
912
913 #[test]
914 fn parse_diagnostics_remain_on_the_module_when_it_is_emitted() {
915 let dss = "clear\nnew circuit.w basekv=12.47 bus1=src\n\
916 new line.l1 bus1=src bus2=b2 length=1 units=furlong\n";
917 let source = powerio_core::Source::from_memory("<memory>", dss.as_bytes().to_vec())
918 .unwrap()
919 .with_format(powerio_core::FormatId::new("dss").unwrap());
920 let module = parse(source).unwrap();
921 let lines = crate::diagnostics::render_diagnostics(module.diagnostics());
922 assert!(
923 lines.iter().any(|w| w.contains("furlong")),
924 "parse diagnostics stay on PioModule: {lines:?}"
925 );
926 emit(
927 &module,
928 DistTargetFormat::BmopfJson,
929 powerio_core::Destination::memory("case.json").unwrap(),
930 )
931 .unwrap();
932 }
933
934 #[test]
935 fn canonical_format_bypasses_same_format_dss_echo() {
936 let src = "Clear\n\
937 New Circuit.c basekv=12.47 bus1=sourcebus\n\
938 New Load.l1 bus1=sourcebus.1 phases=1 conn=wye kv=7.2 kw=10 kvar=2\n";
939 let net = crate::testkit::parse_dss_str(src);
940 assert_eq!(net.emit(DistTargetFormat::Dss).text, src);
941
942 let canonical = net.emit_value(DistTargetFormat::Dss);
943 assert_ne!(canonical.text, src);
944 assert!(
945 canonical
946 .text
947 .lines()
948 .any(|l| l.contains("Load.l1") && l.contains("vminpu=0")),
949 "{}",
950 canonical.text
951 );
952 }
953}