1use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
54use serde::{Deserialize, Serialize};
55
56use crate::delay::DelayList;
57use crate::eng::{EngEntry, EngFile};
58use crate::error::MotorError;
59use crate::rse::{RseEngine, RseFile, RsePoint};
60use crate::text::WarningKind;
61
62pub const UNSPECIFIED_TYPE: &str = "unspecified";
64
65pub const RSE_PLUGGED: &str = "1000";
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct Converted<T> {
71 pub value: T,
73 pub warnings: Vec<ConvertWarning>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct ConvertWarning {
80 pub motor: String,
82 pub kind: WarningKind,
85 pub message: String,
87}
88
89impl ConvertWarning {
90 fn new(motor: &str, kind: WarningKind, message: impl Into<String>) -> Self {
91 Self {
92 motor: motor.to_owned(),
93 kind,
94 message: message.into(),
95 }
96 }
97}
98
99pub fn eng_to_rse(file: &EngFile) -> Result<Converted<RseFile>, MotorError> {
108 let mut warnings = Vec::new();
109 let engines = file
110 .entries
111 .iter()
112 .map(|entry| rse_engine(entry, &mut warnings))
113 .collect::<Result<Vec<_>, _>>()?;
114 if let Some(last) = file.entries.last()
115 && !file.trailing_comments.is_empty()
116 {
117 warnings.push(ConvertWarning::new(
118 &last.name,
119 WarningKind::Dropped,
120 format!(
121 "{} comment line(s) after the last motor: a .rse file has no place for them",
122 file.trailing_comments.len()
123 ),
124 ));
125 }
126 Ok(Converted {
127 value: RseFile { engines },
128 warnings,
129 })
130}
131
132fn rse_engine(
133 entry: &EngEntry,
134 warnings: &mut Vec<ConvertWarning>,
135) -> Result<RseEngine, MotorError> {
136 let curve = entry.thrust_curve()?;
137 let total_impulse_ns = curve.total_impulse_ns();
138 let burn_time_s = curve.end_time_s();
139 let initial_mass_g = scaled(&entry.name, entry.total_mass_kg, Unit::Kg, warnings)?;
140 let propellant_mass_g = scaled(&entry.name, entry.propellant_mass_kg, Unit::Kg, warnings)?;
141 let cg_mm = entry.length_mm / 2.0;
142 let mut points = entry.points.clone();
143 if points.first().is_some_and(|&(time_s, _)| time_s > 0.0) {
145 points.insert(0, (0.0, 0.0));
146 }
147 let mut impulse_ns = 0.0;
150 let mut previous: Option<(f64, f64)> = None;
151 let points = points
152 .into_iter()
153 .map(|(time_s, thrust_n)| {
154 if let Some((t0, f0)) = previous {
155 impulse_ns += 0.5 * (f0 + thrust_n) * (time_s - t0);
156 }
157 previous = Some((time_s, thrust_n));
158 RsePoint {
159 time_s,
160 thrust_n,
161 mass_g: Some(propellant_mass_g * (1.0 - impulse_ns / total_impulse_ns)),
162 cg_mm: Some(cg_mm),
163 }
164 })
165 .collect();
166 let positive = |value: f64| (value > 0.0).then_some(value);
167 let delays = if DelayList::parse(&entry.delays).delays.is_empty() {
168 warnings.push(ConvertWarning::new(
169 &entry.name,
170 WarningKind::Dropped,
171 format!(
172 "dropped the delays {:?}, which name no delay; a .rse file may leave them out",
173 entry.delays
174 ),
175 ));
176 None
177 } else {
178 Some(delays_to_rse(&entry.delays))
179 };
180 let engine = RseEngine {
181 manufacturer: entry.manufacturer.clone(),
182 code: entry.name.clone(),
183 motor_type: Some(UNSPECIFIED_TYPE.to_owned()),
184 diameter_mm: entry.diameter_mm,
185 length_mm: entry.length_mm,
186 initial_mass_g,
187 propellant_mass_g,
188 delays,
189 auto_calc_mass: Some(true),
190 auto_calc_cg: Some(true),
191 average_thrust_n: Some(total_impulse_ns / burn_time_s),
193 peak_thrust_n: Some(curve.peak_thrust_n()),
194 throat_diameter_mm: None,
195 exit_diameter_mm: None,
196 total_impulse_ns: Some(total_impulse_ns),
197 burn_time_s: Some(burn_time_s),
198 mass_fraction_pct: positive(initial_mass_g)
201 .map(|initial| 100.0 * propellant_mass_g / initial),
202 isp_s: positive(entry.propellant_mass_kg)
203 .map(|propellant| total_impulse_ns / (propellant * STANDARD_GRAVITY_MPS2)),
204 comments: (!entry.comments.is_empty()).then(|| entry.comments.join("\n")),
205 points,
206 };
207 for (what, value) in [
209 ("average thrust (N)", engine.average_thrust_n),
210 ("mass fraction (%)", engine.mass_fraction_pct),
211 ("specific impulse (s)", engine.isp_s),
212 ] {
213 if let Some(value) = value
214 && !value.is_finite()
215 {
216 return Err(MotorError::Domain { what, value });
217 }
218 }
219 Ok(engine)
220}
221
222pub fn rse_to_eng(file: &RseFile) -> Result<Converted<EngFile>, MotorError> {
231 let mut warnings = Vec::new();
232 let entries = file
233 .engines
234 .iter()
235 .map(|engine| eng_entry(engine, &mut warnings))
236 .collect::<Result<Vec<_>, _>>()?;
237 Ok(Converted {
238 value: EngFile {
239 entries,
240 trailing_comments: Vec::new(),
241 },
242 warnings,
243 })
244}
245
246fn eng_entry(
247 engine: &RseEngine,
248 warnings: &mut Vec<ConvertWarning>,
249) -> Result<EngEntry, MotorError> {
250 let code = &engine.code;
251 if engine
252 .motor_type
253 .as_deref()
254 .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("hybrid"))
255 {
256 return Err(MotorError::Inconsistent(format!(
257 "{code} is a hybrid, which a .eng file can't say; hpr models solid motors only"
258 )));
259 }
260 let delays = engine
261 .delays
262 .as_deref()
263 .filter(|delays| !DelayList::parse(delays).delays.is_empty())
264 .ok_or_else(|| {
265 MotorError::Inconsistent(format!(
266 "the .rse engine {code:?} gives no delays, and a .eng header must"
267 ))
268 })?;
269 let name = code.split_whitespace().collect::<Vec<_>>().join("_");
272 if name != *code {
273 warnings.push(ConvertWarning::new(
274 code,
275 WarningKind::Unusual,
276 format!("a .eng name is one word, so {code:?} is written {name:?}"),
277 ));
278 }
279 let manufacturer = engine
280 .manufacturer
281 .split_whitespace()
282 .collect::<Vec<_>>()
283 .join("_");
284 if manufacturer != engine.manufacturer {
285 warnings.push(ConvertWarning::new(
286 code,
287 WarningKind::Unusual,
288 format!(
289 "a .eng maker is one word, so {:?} is written {manufacturer:?}",
290 engine.manufacturer
291 ),
292 ));
293 }
294 let comments = match &engine.comments {
295 Some(text) => {
296 let lines: Vec<String> = text
297 .lines()
298 .map(str::trim_end)
299 .filter(|line| !line.is_empty())
300 .map(str::to_owned)
301 .collect();
302 if lines.join("\n") != *text {
303 warnings.push(ConvertWarning::new(
304 code,
305 WarningKind::Dropped,
306 "dropped the comments' blank lines and the spaces that end their lines: a \
307 .eng comment is one line of text",
308 ));
309 }
310 lines
311 }
312 None => Vec::new(),
313 };
314 let mut points: Vec<(f64, f64)> = engine
315 .points
316 .iter()
317 .map(|point| (point.time_s, point.thrust_n))
318 .collect();
319 if let [(t0, f0), (t1, _), ..] = points[..]
322 && t0.to_bits() == 0
323 && f0.to_bits() == 0
324 && t1 > 0.0
325 {
326 points.remove(0);
327 }
328 let dropped = dropped(engine);
329 if !dropped.is_empty() {
330 warnings.push(ConvertWarning::new(
331 code,
332 WarningKind::Dropped,
333 format!(
334 "dropped {}: a .eng file has no place for them. hpr doesn't use them for a solid \
335 motor: it works the mass and center of gravity out from the curve and the masses",
336 dropped.join(", ")
337 ),
338 ));
339 }
340 Ok(EngEntry {
341 comments,
342 name,
343 diameter_mm: engine.diameter_mm,
344 length_mm: engine.length_mm,
345 delays: delays_to_eng(delays),
346 propellant_mass_kg: scaled(code, engine.propellant_mass_g, Unit::G, warnings)?,
347 total_mass_kg: scaled(code, engine.initial_mass_g, Unit::G, warnings)?,
348 manufacturer,
349 points,
350 })
351}
352
353fn dropped(engine: &RseEngine) -> Vec<&'static str> {
355 let flags = [
356 ("Type", engine.motor_type.is_some()),
357 ("auto-calc-mass", engine.auto_calc_mass.is_some()),
358 ("auto-calc-cg", engine.auto_calc_cg.is_some()),
359 ("avgThrust", engine.average_thrust_n.is_some()),
360 ("peakThrust", engine.peak_thrust_n.is_some()),
361 ("throatDia", engine.throat_diameter_mm.is_some()),
362 ("exitDia", engine.exit_diameter_mm.is_some()),
363 ("Itot", engine.total_impulse_ns.is_some()),
364 ("burn-time", engine.burn_time_s.is_some()),
365 ("massFrac", engine.mass_fraction_pct.is_some()),
366 ("Isp", engine.isp_s.is_some()),
367 (
368 "m",
369 engine.points.iter().any(|point| point.mass_g.is_some()),
370 ),
371 (
372 "cg",
373 engine.points.iter().any(|point| point.cg_mm.is_some()),
374 ),
375 ];
376 flags
377 .into_iter()
378 .filter_map(|(name, present)| present.then_some(name))
379 .collect()
380}
381
382fn delays_to_rse(delays: &str) -> String {
384 delays
385 .split(['-', ','])
386 .filter(|piece| !piece.is_empty())
387 .map(|piece| {
388 if piece.eq_ignore_ascii_case("P") {
389 RSE_PLUGGED
390 } else {
391 piece
392 }
393 })
394 .collect::<Vec<_>>()
395 .join(",")
396}
397
398fn delays_to_eng(delays: &str) -> String {
400 delays
401 .split([',', '-'])
402 .map(str::trim)
403 .filter(|piece| !piece.is_empty())
404 .map(|piece| if piece == RSE_PLUGGED { "P" } else { piece })
405 .collect::<Vec<_>>()
406 .join("-")
407}
408
409#[derive(Clone, Copy)]
411enum Unit {
412 Kg,
413 G,
414}
415
416fn scaled(
425 motor: &str,
426 mass: f64,
427 unit: Unit,
428 warnings: &mut Vec<ConvertWarning>,
429) -> Result<f64, MotorError> {
430 let (there, back, from, to): (f64, fn(f64) -> f64, _, _) = match unit {
431 Unit::Kg => (kg_to_g(mass), g_to_kg, "kg", "g"),
432 Unit::G => (g_to_kg(mass), kg_to_g, "g", "kg"),
433 };
434 if !there.is_finite() || (there == 0.0 && mass != 0.0) {
435 return Err(MotorError::Domain {
436 what: match unit {
437 Unit::Kg => "mass (kg), which has no value in g",
438 Unit::G => "mass (g), which has no value in kg",
439 },
440 value: mass,
441 });
442 }
443 let returned = back(there);
444 if returned.to_bits() != mass.to_bits() {
445 warnings.push(ConvertWarning::new(
446 motor,
447 WarningKind::Unusual,
448 format!(
449 "{mass} {from} has more digits than a value in {to} keeps: it is written \
450 {there} {to}, which reads back as {returned} {from}"
451 ),
452 ));
453 }
454 Ok(there)
455}
456
457pub fn kg_to_g(kg: f64) -> f64 {
460 shift(kg, 3)
461}
462
463pub fn g_to_kg(g: f64) -> f64 {
465 shift(g, -3)
466}
467
468fn shift(value: f64, places: i32) -> f64 {
477 let text = format!("{value:e}");
478 text.split_once('e')
479 .and_then(|(digits, exponent)| {
480 let exponent: i32 = exponent.parse().ok()?;
481 format!("{digits}e{}", exponent + places).parse().ok()
482 })
483 .unwrap_or(value * 10f64.powi(places))
486}
487
488#[cfg(test)]
489mod tests {
490 use proptest::prelude::*;
491
492 use super::*;
493 use crate::{DelayList, eng, rse};
494
495 #[test]
496 fn a_decimal_shift_reads_as_the_file_writes() {
497 assert_eq!(0.0041_f64 * 1000.0, 4.100_000_000_000_000_5);
498 assert_eq!(kg_to_g(0.0041), 4.1);
499 assert_eq!(g_to_kg(4.1), 0.0041);
500 assert_eq!(kg_to_g(0.0773), 77.3);
501 assert_eq!(g_to_kg(77.3), 0.0773);
502 assert_eq!(kg_to_g(0.0).to_bits(), 0);
503 assert_eq!(kg_to_g(1.5e-5), 0.015);
504 assert_eq!(g_to_kg(182.5), 0.1825);
505 assert!(kg_to_g(f64::NAN).is_nan());
506 assert_eq!(kg_to_g(f64::INFINITY), f64::INFINITY);
507 }
508
509 #[test]
510 fn delays_trade_dashes_and_p_for_commas_and_1000() {
511 assert_eq!(delays_to_rse("4-5-7-8-9"), "4,5,7,8,9");
512 assert_eq!(delays_to_rse("P"), "1000");
513 assert_eq!(delays_to_rse("6-10-P"), "6,10,1000");
514 assert_eq!(delays_to_rse("5,8,11"), "5,8,11");
515 assert_eq!(delays_to_eng("10,14,1000"), "10-14-P");
516 assert_eq!(delays_to_eng("15, 12,10"), "15-12-10");
517 assert_eq!(delays_to_eng("0"), "0");
518 for eng in ["4-5-7-8-9", "P", "6-10-P", "0", "5,8,11", "1000", "100"] {
519 let there_and_back = delays_to_eng(&delays_to_rse(eng));
520 assert_eq!(
521 DelayList::parse(&there_and_back),
522 DelayList::parse(eng),
523 "{eng}"
524 );
525 }
526 }
527
528 const ENG: &str = "; a comment\n; another\nH54 38 152 6-10-P 0.0773 0.172 Test_Maker\n 0.05 80.25\n 0.4 60\n 1.2 0\n;\n";
529
530 #[test]
531 fn eng_to_rse_fills_what_rocksim_files_give() {
532 let eng = eng::parse(ENG).unwrap().value;
533 let rse = eng_to_rse(&eng).unwrap();
534 assert!(rse.warnings.is_empty(), "{:?}", rse.warnings);
535 let [engine] = &rse.value.engines[..] else {
536 panic!("one engine");
537 };
538 assert_eq!(engine.code, "H54");
539 assert_eq!(engine.manufacturer, "Test_Maker");
540 assert_eq!(engine.motor_type.as_deref(), Some("unspecified"));
541 assert_eq!(engine.initial_mass_g, 172.0);
542 assert_eq!(engine.propellant_mass_g, 77.3);
543 assert_eq!(engine.delays.as_deref(), Some("6,10,1000"));
544 assert_eq!(engine.comments.as_deref(), Some(" a comment\n another"));
545 let times: Vec<f64> = engine.points.iter().map(|p| p.time_s).collect();
546 assert_eq!(times, [0.0, 0.05, 0.4, 1.2]);
547 let itot = 2.006_25 + 24.543_75 + 24.0;
549 assert!((engine.total_impulse_ns.unwrap() - itot).abs() < 1e-12);
550 assert_eq!(engine.peak_thrust_n, Some(80.25));
551 assert_eq!(engine.burn_time_s, Some(1.2));
552 assert!((engine.average_thrust_n.unwrap() - itot / 1.2).abs() < 1e-12);
553 let masses: Vec<f64> = engine.points.iter().map(|p| p.mass_g.unwrap()).collect();
554 assert_eq!(masses[0], 77.3);
555 assert!((masses[1] - 77.3 * (1.0 - 2.006_25 / itot)).abs() < 1e-12);
556 assert_eq!(masses[3], 0.0);
557 assert!(engine.points.iter().all(|p| p.cg_mm == Some(76.0)));
558 assert!((engine.mass_fraction_pct.unwrap() - 100.0 * 77.3 / 172.0).abs() < 1e-12);
559 assert!((engine.isp_s.unwrap() - itot / (0.0773 * 9.806_65)).abs() < 1e-9);
560 let text = rse::write(&rse.value).unwrap();
562 let read = rse::parse(&text).unwrap();
563 assert!(read.warnings.is_empty(), "{:?}", read.warnings);
564 assert_eq!(read.value, rse.value);
565 }
566
567 #[test]
568 fn a_round_trip_through_rse_gives_the_eng_back() {
569 let eng = eng::parse(ENG).unwrap().value;
570 let rse = eng_to_rse(&eng).unwrap().value;
571 let back = rse_to_eng(&rse).unwrap();
572 assert_eq!(back.value, eng);
573 assert_eq!(back.warnings.len(), 1);
574 assert_eq!(back.warnings[0].kind, WarningKind::Dropped);
575 assert!(
576 back.warnings[0].message.starts_with(
577 "dropped Type, auto-calc-mass, auto-calc-cg, avgThrust, peakThrust, Itot, burn-time, \
578 massFrac, Isp, m, cg:"
579 ),
580 "{}",
581 back.warnings[0].message
582 );
583 }
584
585 const RSE: &str = r#"<engine-database>
586 <engine-list>
587 <engine mfg=" Some Maker" code="Micro Maxx II" Type="reloadable" dia="38." len="191." initWt="330." propWt="182.5" delays="2,4,6" auto-calc-mass="1" auto-calc-cg="1" avgThrust="148.737" peakThrust="205.821" throatDia="0." exitDia="0." Itot="318." burn-time="2.14" massFrac="55.3" Isp="177.68">
588 <comments>
589 Line one
590 Line two </comments>
591 <data>
592 <eng-data t="0." f="0." m="182.5" cg="95.5"/>
593 <eng-data t="0.1" f="205.821" m="170." cg="95.5"/>
594 <eng-data t="2.14" f="0." m="0." cg="95.5"/>
595 </data>
596 </engine>
597 </engine-list>
598</engine-database>
599"#;
600
601 #[test]
602 fn rse_to_eng_names_what_it_drops_and_changes() {
603 let rse = rse::parse(RSE).unwrap().value;
604 let eng = rse_to_eng(&rse).unwrap();
605 let [entry] = &eng.value.entries[..] else {
606 panic!("one entry");
607 };
608 assert_eq!(entry.name, "Micro_Maxx_II");
609 assert_eq!(entry.manufacturer, "Some_Maker");
610 assert_eq!(entry.delays, "2-4-6");
611 assert_eq!(entry.total_mass_kg, 0.33);
612 assert_eq!(entry.propellant_mass_kg, 0.1825);
613 assert_eq!(entry.comments, [" Line one", " Line two"]);
614 assert_eq!(entry.points, [(0.1, 205.821), (2.14, 0.0)]);
615 let kinds: Vec<(WarningKind, &str)> = eng
616 .warnings
617 .iter()
618 .map(|w| (w.kind, &w.message[..12]))
619 .collect();
620 assert_eq!(
621 kinds,
622 [
623 (WarningKind::Unusual, "a .eng name "),
624 (WarningKind::Unusual, "a .eng maker"),
625 (WarningKind::Dropped, "dropped the "),
626 (WarningKind::Dropped, "dropped Type"),
627 ]
628 );
629 assert!(eng.warnings[3].message.starts_with(
630 "dropped Type, auto-calc-mass, auto-calc-cg, avgThrust, peakThrust, throatDia, exitDia, Itot, \
631 burn-time, massFrac, Isp, m, cg:"
632 ));
633 assert_eq!(
635 entry.thrust_curve().unwrap(),
636 rse.engines[0].thrust_curve().unwrap()
637 );
638 let text = eng::write(&eng.value).unwrap();
640 assert_eq!(eng::parse(&text).unwrap().value, eng.value);
641 }
642
643 #[test]
644 fn a_round_trip_through_eng_keeps_what_both_formats_carry() {
645 let rse = rse::parse(RSE).unwrap().value;
646 let there = rse_to_eng(&rse).unwrap().value;
647 let back = eng_to_rse(&there).unwrap().value;
648 let (a, b) = (&rse.engines[0], &back.engines[0]);
649 assert_eq!(
650 (
651 a.diameter_mm,
652 a.length_mm,
653 a.initial_mass_g,
654 a.propellant_mass_g
655 ),
656 (
657 b.diameter_mm,
658 b.length_mm,
659 b.initial_mass_g,
660 b.propellant_mass_g
661 )
662 );
663 assert_eq!(a.delays, b.delays);
664 let curve = |e: &RseEngine| -> Vec<(f64, f64)> {
665 e.points.iter().map(|p| (p.time_s, p.thrust_n)).collect()
666 };
667 assert_eq!(curve(a), curve(b));
668 assert_eq!(rse_to_eng(&back).unwrap().value, there);
670 }
671
672 #[test]
673 fn a_hybrid_or_an_engine_without_delays_is_refused() {
674 let mut rse = rse::parse(RSE).unwrap().value;
675 rse.engines[0].motor_type = Some("Hybrid".to_owned());
676 let error = rse_to_eng(&rse).unwrap_err().to_string();
677 assert!(error.contains("is a hybrid"), "{error}");
678 rse.engines[0].motor_type = None;
679 rse.engines[0].delays = None;
680 let error = rse_to_eng(&rse).unwrap_err().to_string();
681 assert!(error.contains("gives no delays"), "{error}");
682 }
683
684 #[test]
685 fn a_written_origin_and_trailing_comments_are_said() {
686 let text = "X1 29 100 P 0.01 0.05 M\n 0 0\n 0.5 10\n 1 0\n; after\n";
687 let eng = eng::parse(text).unwrap().value;
688 assert_eq!(eng.trailing_comments, [" after"]);
689 let rse = eng_to_rse(&eng).unwrap();
690 assert_eq!(rse.value.engines[0].points.len(), 3, "no second origin");
691 assert_eq!(rse.warnings.len(), 1);
692 assert!(
693 rse.warnings[0]
694 .message
695 .starts_with("1 comment line(s) after")
696 );
697 let back = rse_to_eng(&rse.value).unwrap().value;
699 assert_eq!(back.entries[0].points, [(0.5, 10.0), (1.0, 0.0)]);
700 assert_eq!(
701 back.entries[0].thrust_curve().unwrap(),
702 eng.entries[0].thrust_curve().unwrap()
703 );
704 let mut rse = rse.value;
706 rse.engines[0].points[0].thrust_n = -0.0;
707 let back = rse_to_eng(&rse).unwrap().value;
708 assert_eq!(back.entries[0].points.len(), 3);
709 }
710
711 #[test]
714 fn every_bundled_curve_round_trips() {
715 let (mut engs, mut rses) = (0, 0);
716 let mut respelled = Vec::new();
717 let mut said = Vec::new();
718 let mut makers = Vec::new();
719 let mut moved = Vec::new();
720 for (name, text) in crate::bundled::CURVE_FILES {
721 if name.ends_with(".eng") {
722 engs += 1;
723 let eng = eng::parse(text).unwrap().value;
724 let rse = eng_to_rse(&eng).unwrap();
725 said.extend(
726 rse.warnings
727 .iter()
728 .map(|w| format!("{name}: {}", w.message)),
729 );
730 let written = rse::parse(&rse::write(&rse.value).unwrap()).unwrap();
733 for warning in &written.warnings {
734 assert!(warning.message.contains("delay"), "{name}: {warning:?}");
735 }
736 assert_eq!(written.value, rse.value, "{name}");
737 let mut back = rse_to_eng(&written.value).unwrap().value;
738 for (back, eng) in back.entries.iter_mut().zip(&eng.entries) {
739 if rse
742 .warnings
743 .iter()
744 .any(|w| w.message.contains("more digits"))
745 {
746 moved.push(*name);
747 back.propellant_mass_kg = eng.propellant_mass_kg;
748 back.total_mass_kg = eng.total_mass_kg;
749 }
750 if back.delays != eng.delays {
751 respelled.push(format!("{name}: {} as {}", eng.delays, back.delays));
752 assert_eq!(back.delays(), eng.delays(), "{name}");
753 back.delays.clone_from(&eng.delays);
754 }
755 }
756 assert_eq!(back, eng, "{name}");
757 } else {
758 rses += 1;
759 let rse = rse::parse(text).unwrap().value;
760 let eng = rse_to_eng(&rse).unwrap();
761 let written = eng::parse(&eng::write(&eng.value).unwrap()).unwrap();
764 assert_eq!(written.value, eng.value, "{name}");
765 let back = eng_to_rse(&written.value).unwrap().value;
766 for (a, b) in rse.engines.iter().zip(&back.engines) {
767 let mut b = b.clone();
768 if b.manufacturer != a.manufacturer {
769 makers.push(format!("{name}: {} as {}", a.manufacturer, b.manufacturer));
770 b.manufacturer.clone_from(&a.manufacturer);
771 }
772 let carried = |e: &RseEngine| {
773 let points: Vec<(u64, u64)> = e
774 .points
775 .iter()
776 .map(|p| (p.time_s.to_bits(), p.thrust_n.to_bits()))
777 .collect();
778 (
779 e.code.clone(),
780 e.manufacturer.clone(),
781 [
782 e.diameter_mm,
783 e.length_mm,
784 e.initial_mass_g,
785 e.propellant_mass_g,
786 ]
787 .map(f64::to_bits),
788 e.delays.clone(),
789 points,
790 )
791 };
792 assert_eq!(carried(a), carried(&b), "{name}");
793 }
794 assert_eq!(rse_to_eng(&back).unwrap().value, written.value, "{name}");
795 }
796 }
797 assert_eq!((engs, rses), (29, 3));
798 assert_eq!(
800 respelled,
801 [
802 "curves/5f4294d20002e90000000875.eng: p as P",
803 "curves/5f4294d20002e90000000876.eng: 1000 as P"
804 ]
805 );
806 assert_eq!(
808 makers,
809 [
810 "curves/5f923edb1bca5800041716ab.rse: Estes Industries, Inc. as Estes_Industries,_Inc."
811 ]
812 );
813 assert_eq!(
814 moved,
815 [
816 "curves/5f4294d20002e90000000884.eng",
817 "curves/5f4294d20002e9000000088e.eng"
818 ]
819 );
820 assert_eq!(
822 said,
823 [
824 "curves/5f4294d20002e90000000884.eng: 7.5120000000000005 kg has more digits than a \
825 value in g keeps: it is written 7512.000000000001 g, which reads back as \
826 7.512000000000001 kg",
827 "curves/5f4294d20002e9000000088e.eng: 0.0036000000000000003 kg has more digits \
828 than a value in g keeps: it is written 3.6 g, which reads back as 0.0036 kg"
829 ]
830 );
831 }
832
833 #[test]
835 fn delays_that_name_none_are_left_out_or_refused() {
836 let mut eng = eng::parse(ENG).unwrap().value;
837 eng.entries[0].delays = "-".to_owned();
838 let rse = eng_to_rse(&eng).unwrap();
839 assert_eq!(rse.value.engines[0].delays, None);
840 assert!(
841 rse.warnings[0]
842 .message
843 .starts_with("dropped the delays \"-\"")
844 );
845 let mut rse = rse::parse(RSE).unwrap().value;
846 for delays in ["", " ", "abc"] {
847 rse.engines[0].delays = Some(delays.to_owned());
848 let error = rse_to_eng(&rse).unwrap_err().to_string();
849 assert!(error.contains("gives no delays"), "{delays:?}: {error}");
850 }
851 }
852
853 #[test]
856 fn a_mass_at_a_doubles_limits_is_refused() {
857 let mut rse = rse::parse(RSE).unwrap().value;
858 rse.engines[0].propellant_mass_g = 5e-324;
859 let error = rse_to_eng(&rse).unwrap_err();
860 assert!(
861 matches!(
862 error,
863 MotorError::Domain {
864 what: "mass (g), which has no value in kg",
865 ..
866 }
867 ),
868 "{error:?}"
869 );
870 let mut eng = eng::parse(ENG).unwrap().value;
871 eng.entries[0].total_mass_kg = 1e306;
872 let error = eng_to_rse(&eng).unwrap_err();
873 assert!(
874 matches!(
875 error,
876 MotorError::Domain {
877 what: "mass (kg), which has no value in g",
878 ..
879 }
880 ),
881 "{error:?}"
882 );
883 let mut eng = eng::parse(ENG).unwrap().value;
884 eng.entries[0].propellant_mass_kg = 1e-310;
885 let error = eng_to_rse(&eng).unwrap_err();
886 assert!(
887 matches!(
888 error,
889 MotorError::Domain {
890 what: "specific impulse (s)",
891 ..
892 }
893 ),
894 "{error:?}"
895 );
896 }
897
898 fn file_mass() -> impl Strategy<Value = f64> {
900 (1u64..1_000_000_000_000_000, -12i32..4)
901 .prop_map(|(digits, exponent)| format!("{digits}e{exponent}").parse().unwrap())
902 }
903
904 proptest! {
905 #[test]
906 fn a_mass_with_15_digits_survives_kg_to_g_and_back(mass in file_mass()) {
907 prop_assert_eq!(g_to_kg(kg_to_g(mass)).to_bits(), mass.to_bits());
908 prop_assert_eq!(kg_to_g(g_to_kg(mass)).to_bits(), mass.to_bits());
909 }
910 }
911}