1use serde::Serialize;
77
78use super::wire::{write_bytes_field, write_i32_field, write_packed_i32_field, write_string_field};
79use crate::cli_api::UnitSystem;
80use crate::constants::{FPS_TO_MPS, GRAMS_PER_GRAIN};
81use crate::profile::ProfileData;
82use crate::profile_import::wrap_payload;
83
84const SCALE_TWIST: f64 = 100.0; const SCALE_VELOCITY: f64 = 10.0; const SCALE_DIMENSION: f64 = 1000.0; const SCALE_WEIGHT: f64 = 10.0; const SCALE_PRESSURE: f64 = 10.0; const SCALE_COEF: f64 = 10_000.0; const SCALE_DISTANCE: f64 = 100.0; const SCALE_MACH: f64 = 10_000.0; const F_PROFILE_NAME: u32 = 1;
100const F_BULLET_NAME: u32 = 3;
101const F_SIGHT_HEIGHT: u32 = 9;
102const F_TWIST: u32 = 10;
103const F_MUZZLE_VELOCITY: u32 = 11;
104const F_AIR_TEMPERATURE: u32 = 15;
105const F_AIR_PRESSURE: u32 = 16;
106const F_AIR_HUMIDITY: u32 = 17;
107const F_BULLET_DIAMETER: u32 = 20;
108const F_BULLET_WEIGHT: u32 = 21;
109const F_BULLET_LENGTH: u32 = 22;
110const F_TWIST_DIR: u32 = 23;
111const F_BC_TYPE: u32 = 24;
112const F_SWITCHES: u32 = 25;
113const F_DISTANCES: u32 = 26;
114const F_COEF_ROWS: u32 = 27;
115const F_ROW_BC_CD: u32 = 1;
117const F_ROW_MV: u32 = 2;
118const F_SW_C_IDX: u32 = 1;
124const F_SW_ZOOM: u32 = 3;
125const F_SW_DISTANCE: u32 = 4;
126const F_PAYLOAD_PROFILE: u32 = 1;
132
133const MIN_SWITCHES: usize = 4;
146
147const PLACEHOLDER_SWITCHES: [(i32, i32); MIN_SWITCHES] =
159 [(1, 10_000), (2, 20_000), (3, 30_000), (4, 100_000)];
160
161const PLACEHOLDER_SWITCH_C_IDX: i32 = 255;
165
166const BOUND_SIGHT_HEIGHT: (i32, i32) = (-5_000, 5_000); const BOUND_TWIST: (i32, i32) = (0, 10_000); const BOUND_VELOCITY: (i32, i32) = (100, 30_000); const BOUND_TEMPERATURE: (i32, i32) = (-100, 100); const BOUND_PRESSURE: (i32, i32) = (3_000, 15_000); const BOUND_HUMIDITY: (i32, i32) = (0, 100); const BOUND_DIAMETER: (i32, i32) = (1, 50_000); const BOUND_WEIGHT: (i32, i32) = (10, 65_535); const BOUND_LENGTH: (i32, i32) = (10, 200_000); const BOUND_DISTANCE: (i32, i32) = (100, 300_000); const BOUND_NONE: (i32, i32) = (i32::MIN, i32::MAX);
186
187const MM_PER_INCH: f64 = 25.4;
193const METERS_PER_YARD: f64 = 0.9144;
194const HPA_PER_INHG: f64 = 33.8639;
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
198pub struct NotCarried {
199 pub field: &'static str,
203 pub populated: bool,
207 pub value: Option<String>,
209 pub reason: &'static str,
211}
212
213pub const CARRIED_FIELDS: &[&str] = &[
223 "name",
224 "velocity",
225 "bc",
226 "mass",
227 "diameter",
228 "drag_model",
229 "twist_rate",
230 "sight_height",
231 "zero_distance",
232 "units",
233 "temperature",
234 "pressure",
235 "humidity",
236 "bullet_name",
237 "auto_zero",
238 "twist_right",
239 "bullet_length",
240 "bc_segments",
241 "drag_curve",
242];
243
244#[derive(Debug, Clone)]
246pub struct A7pExport {
247 pub bytes: Vec<u8>,
249 pub not_carried: Vec<NotCarried>,
251 pub warnings: Vec<String>,
256}
257
258impl A7pExport {
259 pub fn dropped_fields(&self) -> Vec<&'static str> {
262 self.not_carried
263 .iter()
264 .filter(|n| n.populated)
265 .map(|n| n.field)
266 .collect()
267 }
268}
269
270#[derive(Debug, Clone, PartialEq)]
274pub enum A7pExportError {
275 Units(String),
277 DragModel(String),
281 Field {
285 field: &'static str,
286 message: String,
287 },
288}
289
290impl std::fmt::Display for A7pExportError {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 match self {
293 A7pExportError::Units(u) => write!(f, "{u}"),
294 A7pExportError::DragModel(m) => write!(
295 f,
296 "drag model '{m}' has no .a7p equivalent — the format stores only G1, G7 \
297 or a CUSTOM Mach/Cd curve, and writing '{m}' as one of those would give \
298 the recipient different physics under the same name"
299 ),
300 A7pExportError::Field { field, message } => {
301 write!(f, "{field}: {message}")
302 }
303 }
304 }
305}
306
307impl std::error::Error for A7pExportError {}
308
309struct Lossy {
311 warnings: Vec<String>,
312}
313
314impl Lossy {
315 fn quantize(
320 &mut self,
321 field: &'static str,
322 value: f64,
323 scale: f64,
324 unit: &str,
325 bound: (i32, i32),
326 ) -> Result<i32, A7pExportError> {
327 if !value.is_finite() {
328 return Err(A7pExportError::Field {
329 field,
330 message: format!("{value} is not a finite number"),
331 });
332 }
333 let raw = value * scale;
334 if raw < f64::from(i32::MIN) || raw > f64::from(i32::MAX) {
335 return Err(A7pExportError::Field {
336 field,
337 message: format!(
338 "{value} {unit} is outside the range .a7p can store (the file holds \
339 {unit} x {scale} in a 32-bit integer)"
340 ),
341 });
342 }
343 let rounded = raw.round();
344 let stored = rounded as i32;
345 let (min, max) = bound;
348 if stored < min || stored > max {
349 return Err(A7pExportError::Field {
350 field,
351 message: format!(
352 "{value} {unit} is outside what .a7p accepts: the format stores \
353 {unit} x {scale} and the ecosystem's validator requires that \
354 integer to be between {min} and {max} (this would be {stored})"
355 ),
356 });
357 }
358 let recovered = f64::from(stored) / scale;
359 if (recovered - value).abs() > 1e-9 * value.abs().max(1.0) {
364 let grid = if scale == 1.0 {
365 format!("whole {unit}")
366 } else {
367 format!("1/{scale} {unit}")
368 };
369 self.warnings.push(format!(
370 "{field}: {value} {unit} does not fit .a7p's {grid} grid; \
371 written as {recovered} {unit}"
372 ));
373 }
374 Ok(stored)
375 }
376}
377
378fn require_positive(field: &'static str, value: f64) -> Result<f64, A7pExportError> {
379 if !value.is_finite() || value <= 0.0 {
380 return Err(A7pExportError::Field {
381 field,
382 message: format!("{value} is not a usable measurement (must be finite and > 0)"),
383 });
384 }
385 Ok(value)
386}
387
388fn carried_opt<T: std::fmt::Display>(v: &Option<T>) -> Option<String> {
391 v.as_ref().map(|v| v.to_string())
392}
393
394fn non_empty<T>(v: &Option<Vec<T>>) -> Option<&Vec<T>> {
398 v.as_ref().filter(|v| !v.is_empty())
399}
400
401pub fn export_a7p(profile: &ProfileData) -> Result<A7pExport, A7pExportError> {
407 let units = profile.unit_system().map_err(A7pExportError::Units)?;
408 let mut lossy = Lossy {
409 warnings: Vec::new(),
410 };
411
412 let velocity_mps = match units {
416 UnitSystem::Metric => profile.velocity,
417 UnitSystem::Imperial => profile.velocity * FPS_TO_MPS,
418 };
419 let velocity_mps = require_positive("velocity", velocity_mps)?;
420
421 let weight_grains = match units {
422 UnitSystem::Metric => profile.mass / GRAMS_PER_GRAIN,
423 UnitSystem::Imperial => profile.mass,
424 };
425 let weight_grains = require_positive("mass", weight_grains)?;
426
427 let diameter_in = match units {
428 UnitSystem::Metric => profile.diameter / MM_PER_INCH,
429 UnitSystem::Imperial => profile.diameter,
430 };
431 let diameter_in = require_positive("diameter", diameter_in)?;
432
433 let length_in = match profile.bullet_length {
440 Some(v) => match units {
441 UnitSystem::Metric => v / MM_PER_INCH,
442 UnitSystem::Imperial => v,
443 },
444 None => {
445 return Err(A7pExportError::Field {
446 field: "bullet_length",
447 message: "the .a7p format requires a bullet length and this profile has \
448 none; set bullet_length (a length cannot be invented — it drives \
449 the recipient's stability model)"
450 .to_string(),
451 })
452 }
453 };
454 let twist_in = profile.twist_rate.map(|v| match units {
475 UnitSystem::Metric => v / MM_PER_INCH,
476 UnitSystem::Imperial => v,
477 });
478 if twist_in.is_none() {
479 lossy.warnings.push(
480 "twist_rate: unset, so the file's r_twist stays 0. The format may read that as \
481 \"twist unknown\" or as a zero twist, and which it is decides whether the \
482 recipient gets spin drift at all — set twist_rate if the rifle's twist is \
483 known"
484 .to_string(),
485 );
486 }
487 let sight_height_mm = match profile.sight_height {
488 Some(v) => match units {
489 UnitSystem::Metric => v,
490 UnitSystem::Imperial => v * MM_PER_INCH,
491 },
492 None => {
493 return Err(A7pExportError::Field {
494 field: "sight_height",
495 message: "the .a7p format has no way to say a sight height is unknown, and \
496 the 0 mm it would otherwise carry is a mount no rifle has — the \
497 recipient cannot tell it from a measurement and every solution \
498 they compute moves. Set sight_height"
499 .to_string(),
500 })
501 }
502 };
503 let zero_distance_m = profile.zero_distance.map(|v| match units {
504 UnitSystem::Metric => v,
505 UnitSystem::Imperial => v * METERS_PER_YARD,
506 });
507 let temperature_c = match units {
508 UnitSystem::Metric => profile.temperature,
509 UnitSystem::Imperial => (profile.temperature - 32.0) * 5.0 / 9.0,
510 };
511 let pressure_hpa = match units {
512 UnitSystem::Metric => profile.pressure,
513 UnitSystem::Imperial => profile.pressure * HPA_PER_INHG,
514 };
515
516 let model = profile.drag_model.trim().to_ascii_uppercase();
520 let (bc_type, rows): (i32, Vec<(i32, i32)>) = match model.as_str() {
521 "G1" | "G7" => {
522 let bc_type = if model == "G1" { 0 } else { 1 };
523 let (segments, bc_field): (Vec<(f64, f64)>, &'static str) = match &profile.bc_segments {
527 Some(segments) if !segments.is_empty() => (
530 segments.iter().map(|s| (s.bc, s.velocity_mps)).collect(),
531 "bc_segments.bc",
532 ),
533 _ => (
536 vec![(require_positive("bc", profile.bc)?, velocity_mps)],
537 "bc",
538 ),
539 };
540 if profile.bc_segments.as_ref().is_some_and(|s| !s.is_empty()) {
546 if let Some(fastest) = segments
547 .iter()
548 .max_by(|a, b| a.1.total_cmp(&b.1))
549 .map(|&(bc, _)| bc)
550 {
551 if (profile.bc - fastest).abs() > 1e-9 {
552 lossy.warnings.push(format!(
553 "bc: the profile's scalar BC {} differs from the fastest \
554 bc_segments row {fastest}; .a7p has no separate scalar-BC \
555 field, so the recipient will read {fastest}",
556 profile.bc
557 ));
558 }
559 }
560 }
561 let mut rows = Vec::with_capacity(segments.len());
562 for (bc, mps) in segments {
563 let velocity_field = if bc_field == "bc" {
564 "velocity"
565 } else {
566 "bc_segments.velocity_mps"
567 };
568 rows.push((
569 lossy.quantize(bc_field, bc, SCALE_COEF, "BC", BOUND_NONE)?,
570 lossy.quantize(velocity_field, mps, SCALE_VELOCITY, "m/s", BOUND_NONE)?,
571 ));
572 }
573 (bc_type, rows)
574 }
575 "CUSTOM" => {
576 let curve = profile
577 .drag_curve
578 .as_ref()
579 .filter(|c| !c.is_empty())
580 .ok_or(A7pExportError::Field {
581 field: "drag_curve",
582 message: "drag_model is CUSTOM but the profile carries no drag curve"
583 .to_string(),
584 })?;
585 if profile.bc != 0.0 {
586 lossy.warnings.push(format!(
587 "bc: {} is not carried for a CUSTOM drag model — the file's coef rows ARE \
588 the drag law, and a reader derives no scalar BC from them",
589 profile.bc
590 ));
591 }
592 let mut rows = Vec::with_capacity(curve.len());
593 for point in curve {
594 rows.push((
595 lossy.quantize("drag_curve.cd", point.cd, SCALE_COEF, "Cd", BOUND_NONE)?,
596 lossy.quantize(
597 "drag_curve.mach",
598 point.mach,
599 SCALE_MACH,
600 "Mach",
601 BOUND_NONE,
602 )?,
603 ));
604 }
605 (2, rows)
606 }
607 other => return Err(A7pExportError::DragModel(other.to_string())),
608 };
609 if profile.drag_curve.is_some() && bc_type != 2 {
610 lossy.warnings.push(format!(
611 "drag_curve: dropped — drag_model is {model}, and a .a7p profile stores EITHER \
612 G1/G7 BC rows OR a CUSTOM Mach/Cd curve in the same field, never both"
613 ));
614 }
615 if profile.bc_segments.is_some() && bc_type == 2 {
616 lossy.warnings.push(
617 "bc_segments: dropped — drag_model is CUSTOM, so the file's coef rows carry the \
618 Mach/Cd curve instead"
619 .to_string(),
620 );
621 }
622
623 let zero_distance_m = zero_distance_m.ok_or(A7pExportError::Field {
632 field: "zero_distance",
633 message: "the .a7p format requires at least one range-card distance and this \
634 profile has no zero distance to supply it; set zero_distance"
635 .to_string(),
636 })?;
637 let distances: Vec<i32> = vec![lossy.quantize(
638 "zero_distance",
639 zero_distance_m,
640 SCALE_DISTANCE,
641 "m",
642 BOUND_DISTANCE,
643 )?];
644 if let (Some(auto), Some(zero)) = (profile.auto_zero, profile.zero_distance) {
647 if (auto - zero).abs() > 1e-9 {
648 lossy.warnings.push(format!(
649 "auto_zero: {auto} differs from zero_distance {zero}; .a7p stores one zero \
650 distance, and zero_distance is what was written"
651 ));
652 }
653 }
654
655 lossy.warnings.push(
659 "the file's cartridge_name, short_name_top, short_name_bot, user_note, caliber and \
660 device_uuid are left empty, and its c_zero_temperature, c_zero_p_temperature, \
661 c_t_coeff, c_zero_w_pitch and zero_x/zero_y stay at the format default: a saved \
662 profile has no equivalent for any of them, and filling them in would be fabrication"
663 .to_string(),
664 );
665 lossy.warnings.push(format!(
671 "the file's {MIN_SWITCHES} switch positions (device zoom/range presets at 100, 200, \
672 300 and 1000 m) are PLACEHOLDERS, not this shooter's: a saved profile has no such \
673 concept, and .a7p is refused by the ecosystem with fewer than {MIN_SWITCHES} of \
674 them. They are the upstream tool's own factory values, so nothing here was derived \
675 from the profile or invented as range data"
676 ));
677
678 let mut body = Vec::new();
684 if !profile.name.is_empty() {
685 write_string_field(F_PROFILE_NAME, &profile.name, &mut body);
686 }
687 if let Some(bullet_name) = profile.bullet_name.as_deref().filter(|s| !s.is_empty()) {
688 write_string_field(F_BULLET_NAME, bullet_name, &mut body);
689 }
690 write_i32_field(
697 F_SIGHT_HEIGHT,
698 lossy.quantize(
699 "sight_height",
700 sight_height_mm,
701 1.0,
702 "mm",
703 BOUND_SIGHT_HEIGHT,
704 )?,
705 &mut body,
706 );
707 if let Some(inches) = twist_in {
708 let raw = lossy.quantize("twist_rate", inches, SCALE_TWIST, "in/turn", BOUND_TWIST)?;
709 if raw != 0 {
710 write_i32_field(F_TWIST, raw, &mut body);
711 }
712 }
713 write_i32_field(
714 F_MUZZLE_VELOCITY,
715 lossy.quantize(
716 "velocity",
717 velocity_mps,
718 SCALE_VELOCITY,
719 "m/s",
720 BOUND_VELOCITY,
721 )?,
722 &mut body,
723 );
724 let temperature_raw =
725 lossy.quantize("temperature", temperature_c, 1.0, "C", BOUND_TEMPERATURE)?;
726 if temperature_raw != 0 {
727 write_i32_field(F_AIR_TEMPERATURE, temperature_raw, &mut body);
728 }
729 write_i32_field(
730 F_AIR_PRESSURE,
731 lossy.quantize(
732 "pressure",
733 pressure_hpa,
734 SCALE_PRESSURE,
735 "hPa",
736 BOUND_PRESSURE,
737 )?,
738 &mut body,
739 );
740 let humidity_raw = lossy.quantize("humidity", profile.humidity, 1.0, "%", BOUND_HUMIDITY)?;
741 if humidity_raw != 0 {
742 write_i32_field(F_AIR_HUMIDITY, humidity_raw, &mut body);
743 }
744 write_i32_field(
745 F_BULLET_DIAMETER,
746 lossy.quantize(
747 "diameter",
748 diameter_in,
749 SCALE_DIMENSION,
750 "in",
751 BOUND_DIAMETER,
752 )?,
753 &mut body,
754 );
755 write_i32_field(
756 F_BULLET_WEIGHT,
757 lossy.quantize("mass", weight_grains, SCALE_WEIGHT, "gr", BOUND_WEIGHT)?,
758 &mut body,
759 );
760 write_i32_field(
761 F_BULLET_LENGTH,
762 lossy.quantize(
763 "bullet_length",
764 length_in,
765 SCALE_DIMENSION,
766 "in",
767 BOUND_LENGTH,
768 )?,
769 &mut body,
770 );
771 match profile.twist_right {
776 Some(false) => write_i32_field(F_TWIST_DIR, 1, &mut body),
777 Some(true) => {}
778 None => lossy.warnings.push(
779 "twist_right: unset; .a7p has no \"unknown\" twist direction, so the file says \
780 RIGHT (its own default)"
781 .to_string(),
782 ),
783 }
784 if bc_type != 0 {
785 write_i32_field(F_BC_TYPE, bc_type, &mut body);
786 }
787 for (zoom, distance) in PLACEHOLDER_SWITCHES {
790 let mut switch = Vec::new();
791 write_i32_field(F_SW_C_IDX, PLACEHOLDER_SWITCH_C_IDX, &mut switch);
792 write_i32_field(F_SW_ZOOM, zoom, &mut switch);
796 write_i32_field(F_SW_DISTANCE, distance, &mut switch);
797 write_bytes_field(F_SWITCHES, &switch, &mut body);
798 }
799 if !distances.is_empty() {
800 write_packed_i32_field(F_DISTANCES, &distances, &mut body);
801 }
805 for (bc_cd, mv) in &rows {
806 let mut row = Vec::new();
807 if *bc_cd != 0 {
808 write_i32_field(F_ROW_BC_CD, *bc_cd, &mut row);
809 }
810 if *mv != 0 {
811 write_i32_field(F_ROW_MV, *mv, &mut row);
812 }
813 write_bytes_field(F_COEF_ROWS, &row, &mut body);
814 }
815
816 let mut payload = Vec::new();
817 write_bytes_field(F_PAYLOAD_PROFILE, &body, &mut payload);
818
819 Ok(A7pExport {
820 bytes: wrap_payload(&payload),
821 not_carried: not_carried_for(profile),
822 warnings: lossy.warnings,
823 })
824}
825
826fn not_carried_for(p: &ProfileData) -> Vec<NotCarried> {
830 let mut out = Vec::new();
831 let mut push = |field: &'static str, value: Option<String>, reason: &'static str| {
832 out.push(NotCarried {
833 field,
834 populated: value.is_some(),
835 value,
836 reason,
837 });
838 };
839
840 push(
841 "altitude",
844 (p.altitude != 0.0).then(|| p.altitude.to_string()),
845 "the file records the atmosphere at zeroing as temperature/pressure/humidity only; \
846 it has no altitude field, and folding altitude into the pressure would change a \
847 measured number into a derived one",
848 );
849 push(
850 "density_altitude",
851 carried_opt(&p.density_altitude),
852 "no density-altitude concept in the format — see `altitude`",
853 );
854 push(
855 "pressure_reference",
856 carried_opt(&p.pressure_reference),
857 "the file's pressure field declares no reference, so it is written as-is; a QNH \
858 profile therefore travels as if it were station pressure",
859 );
860 let (temperature_c, pressure_hpa) = match p.unit_system() {
871 Ok(UnitSystem::Imperial) => (
872 (p.temperature - 32.0) * 5.0 / 9.0,
873 p.pressure * HPA_PER_INHG,
874 ),
875 _ => (p.temperature, p.pressure),
876 };
877 let temperature_lost = !p.temperature_supplied
878 && crate::atmosphere::in_legacy_default_temperature_band(temperature_c);
879 let pressure_lost =
880 !p.pressure_supplied && crate::atmosphere::in_legacy_default_pressure_band(pressure_hpa);
881 push(
882 "temperature_supplied",
883 temperature_lost.then(|| "false".to_string()),
884 "the file has no concept of an omitted reading and an import marks its temperature as \
885 typed, so an unmarked stored 15 °C / 59 °F (within the old rule's 0.1 °C band), which \
886 flies the ICAO standard temperature at any altitude above 1 m, the profile's or a \
887 run's --altitude, travels as a reading and flies as typed there; the file cannot carry \
888 the altitude either — see `altitude`",
889 );
890 push(
891 "pressure_supplied",
892 pressure_lost.then(|| "false".to_string()),
893 "the file has no concept of an omitted reading and an import marks its pressure as \
894 typed station pressure, so an unmarked stored 1013.25 hPa / 29.92 inHg (within the old \
895 rule's 0.5 hPa band), which flies ICAO air at any altitude above 1 m, the profile's or \
896 a run's --altitude (a QNH there reduces to about the same), travels as a reading and \
897 flies as typed there — see `altitude` and `pressure_reference`",
898 );
899 push(
900 "bc_reference",
901 carried_opt(&p.bc_reference),
902 "the file names no standard atmosphere for its BCs; a non-ICAO BC travels without \
903 the note that says what it is referenced to",
904 );
905 push(
906 "created",
907 carried_opt(&p.created),
908 "the format has no creation timestamp",
909 );
910 push(
911 "wind_speed",
912 carried_opt(&p.wind_speed),
913 "a .a7p profile describes a rifle and a load, not a shot; it has no wind fields",
914 );
915 push(
916 "wind_direction",
917 carried_opt(&p.wind_direction),
918 "no wind fields — see `wind_speed`",
919 );
920 push(
921 "shooting_angle",
922 carried_opt(&p.shooting_angle),
923 "no look-angle field; a .a7p profile carries no shot conditions",
924 );
925 push(
926 "use_bc_segments",
927 carried_opt(&p.use_bc_segments),
928 "no such switch in the format: a file's coef rows ARE its BC schedule, so there is \
929 nothing to turn on or off",
930 );
931 push(
932 "dsf_points",
933 non_empty(&p.dsf_points).map(|v| format!("{} drop-scale-factor point(s)", v.len())),
934 "truing results have no place in the format; the recipient gets the untrued load",
935 );
936 push(
937 "elevation_cf",
938 carried_opt(&p.elevation_cf),
939 "no scope tracking-correction field; the recipient's solution will not be corrected \
940 for how this scope actually tracks",
941 );
942 push(
943 "windage_cf",
944 carried_opt(&p.windage_cf),
945 "no scope tracking-correction field — see `elevation_cf`",
946 );
947 push(
948 "elevation_click",
949 carried_opt(&p.elevation_click),
950 "the file stores zeroing as raw device click COUNTS and never the click size itself; \
951 a graduation written here would be read against the recipient device's own",
952 );
953 push(
954 "windage_click",
955 carried_opt(&p.windage_click),
956 "no click-size field — see `elevation_click`",
957 );
958 push(
959 "zero_poi_up_m",
960 carried_opt(&p.zero_poi_up_m),
961 "the file's only zero-offset slots are zero_x/zero_y, which are device-scoped click \
962 counts with no click size recorded; converting a linear offset into clicks with THIS \
963 profile's graduation would be misread by any device graduated differently",
964 );
965 push(
966 "zero_poi_right_m",
967 carried_opt(&p.zero_poi_right_m),
968 "device-scoped click counts only — see `zero_poi_up_m`",
969 );
970 push(
971 "sight_offset_lateral_m",
972 carried_opt(&p.sight_offset_lateral_m),
973 "the format models sight height only; there is no lateral sight-to-bore offset",
974 );
975 push(
976 "zero_sets",
977 non_empty(&p.zero_sets).map(|sets| {
978 sets.iter()
979 .map(|z| z.name.as_str())
980 .collect::<Vec<_>>()
981 .join(", ")
982 }),
983 "one profile is one rifle with one zero in this format; alternate zeros and per-load \
984 dial corrections have nowhere to go",
985 );
986 push(
987 "reticle",
988 p.reticle
989 .as_ref()
990 .map(|r| format!("\"{}\" ({} mark(s))", r.name, r.marks.len())),
991 "the format describes the optic by sight height and click counts, not by its reticle",
992 );
993 push(
994 "clicks_per_revolution",
995 carried_opt(&p.clicks_per_revolution),
996 "no turret-mechanics fields in the format",
997 );
998 push(
999 "zero_stop",
1000 carried_opt(&p.zero_stop),
1001 "no turret-mechanics fields — see `clicks_per_revolution`",
1002 );
1003 push(
1004 "elevation_travel_up_mil",
1005 carried_opt(&p.elevation_travel_up_mil),
1006 "no turret travel limits in the format",
1007 );
1008 push(
1009 "elevation_travel_down_mil",
1010 carried_opt(&p.elevation_travel_down_mil),
1011 "no turret travel limits — see `elevation_travel_up_mil`",
1012 );
1013 push(
1014 "windage_travel_left_mil",
1015 carried_opt(&p.windage_travel_left_mil),
1016 "no turret travel limits — see `elevation_travel_up_mil`",
1017 );
1018 push(
1019 "windage_travel_right_mil",
1020 carried_opt(&p.windage_travel_right_mil),
1021 "no turret travel limits — see `elevation_travel_up_mil`",
1022 );
1023 push(
1024 "turret_elevation_dialed_mil",
1025 carried_opt(&p.turret_elevation_dialed_mil),
1026 "no field for what the turrets currently read",
1027 );
1028 push(
1029 "turret_windage_dialed_mil",
1030 carried_opt(&p.turret_windage_dialed_mil),
1031 "no field for what the turrets currently read",
1032 );
1033 push(
1034 "hold_bound_up_mil",
1035 carried_opt(&p.hold_bound_up_mil),
1036 "no reticle hold bounds in the format — see `reticle`",
1037 );
1038 push(
1039 "hold_bound_down_mil",
1040 carried_opt(&p.hold_bound_down_mil),
1041 "no reticle hold bounds — see `reticle`",
1042 );
1043 push(
1044 "hold_bound_left_mil",
1045 carried_opt(&p.hold_bound_left_mil),
1046 "no reticle hold bounds — see `reticle`",
1047 );
1048 push(
1049 "hold_bound_right_mil",
1050 carried_opt(&p.hold_bound_right_mil),
1051 "no reticle hold bounds — see `reticle`",
1052 );
1053
1054 out
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::*;
1060 use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
1061
1062 const FULL_FIXTURE: &str = r#"{
1068 "name": "export-fixture",
1069 "velocity": 792.0,
1070 "bc": 0.381,
1071 "mass": 19.439673,
1072 "diameter": 8.5852,
1073 "drag_model": "G7",
1074 "twist_rate": 254.0,
1075 "sight_height": 90.0,
1076 "zero_distance": 100.0,
1077 "units": "metric",
1078 "temperature": 15.0,
1079 "pressure": 1000.0,
1080 "temperature_supplied": true,
1081 "pressure_supplied": true,
1082 "humidity": 50.0,
1083 "altitude": 300.0,
1084 "bullet_name": "300GR OTM",
1085 "created": "1755400000",
1086 "wind_speed": 4.4704,
1087 "wind_direction": 90.0,
1088 "shooting_angle": -5.0,
1089 "auto_zero": 100.0,
1090 "twist_right": false,
1091 "use_bc_segments": true,
1092 "bullet_length": 45.72,
1093 "elevation_click": "0.1mil",
1094 "windage_click": "0.25moa",
1095 "bc_segments": [
1096 {"bc": 0.381, "velocity_mps": 792.0},
1097 {"bc": 0.360, "velocity_mps": 500.0}
1098 ],
1099 "drag_curve": [
1100 {"mach": 0.5, "cd": 0.23},
1101 {"mach": 3.0, "cd": 0.28}
1102 ],
1103 "dsf_points": [{"mach": 0.9, "dsf": 1.04}],
1104 "bc_reference": "army-standard-metro",
1105 "pressure_reference": "qnh",
1106 "density_altitude": 500.0,
1107 "zero_poi_up_m": 0.05,
1108 "zero_poi_right_m": -0.02,
1109 "sight_offset_lateral_m": 0.01,
1110 "elevation_cf": 0.97,
1111 "windage_cf": 1.02,
1112 "zero_sets": [{"name": "suppressed", "zero_distance": 200.0, "poi_up_mil": -0.3}],
1113 "reticle": {
1114 "name": "mil-grid 0.5/10",
1115 "focal_plane": "ffp",
1116 "reference_magnification": 10.0,
1117 "marks": [{"down_mil": 0.0, "right_mil": 0.0, "kind": "center"}]
1118 },
1119 "clicks_per_revolution": 100,
1120 "zero_stop": true,
1121 "elevation_travel_up_mil": 26.0,
1122 "elevation_travel_down_mil": 4.0,
1123 "windage_travel_left_mil": 12.0,
1124 "windage_travel_right_mil": 12.0,
1125 "turret_elevation_dialed_mil": 5.4,
1126 "turret_windage_dialed_mil": -0.2,
1127 "hold_bound_up_mil": 5.0,
1128 "hold_bound_down_mil": 10.0,
1129 "hold_bound_left_mil": 6.0,
1130 "hold_bound_right_mil": 6.0
1131 }"#;
1132
1133 fn full_profile() -> ProfileData {
1134 serde_json::from_str(FULL_FIXTURE).expect("fixture must load")
1135 }
1136
1137 fn carryable_profile() -> ProfileData {
1140 let mut p = full_profile();
1141 p.altitude = 0.0;
1142 p.created = None;
1143 p.wind_speed = None;
1144 p.wind_direction = None;
1145 p.shooting_angle = None;
1146 p.use_bc_segments = None;
1147 p.elevation_click = None;
1148 p.windage_click = None;
1149 p.drag_curve = None;
1152 p.dsf_points = None;
1153 p.bc_reference = None;
1154 p.pressure_reference = None;
1155 p.density_altitude = None;
1156 p.zero_poi_up_m = None;
1157 p.zero_poi_right_m = None;
1158 p.sight_offset_lateral_m = None;
1159 p.elevation_cf = None;
1160 p.windage_cf = None;
1161 p.zero_sets = None;
1162 p.reticle = None;
1163 p.clicks_per_revolution = None;
1164 p.zero_stop = None;
1165 p.elevation_travel_up_mil = None;
1166 p.elevation_travel_down_mil = None;
1167 p.windage_travel_left_mil = None;
1168 p.windage_travel_right_mil = None;
1169 p.turret_elevation_dialed_mil = None;
1170 p.turret_windage_dialed_mil = None;
1171 p.hold_bound_up_mil = None;
1172 p.hold_bound_down_mil = None;
1173 p.hold_bound_left_mil = None;
1174 p.hold_bound_right_mil = None;
1175 p
1176 }
1177
1178 fn reimport(export: &A7pExport) -> ProfileData {
1179 let doc = parse_a7p(&export.bytes).expect("our own file must parse");
1180 assert!(
1181 matches!(doc.envelope, EnvelopeStatus::Verified),
1182 "the MD5 envelope we wrote must verify"
1183 );
1184 assert!(
1185 doc.unknown_fields.is_empty(),
1186 "the encoder must not emit field numbers the parser does not know: {:?}",
1187 doc.unknown_fields
1188 );
1189 map_a7p_to_profile(&doc, None, None)
1190 .expect("our own file must map")
1191 .profile
1192 }
1193
1194 fn close(a: f64, b: f64, what: &str) {
1195 assert!(
1196 (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0),
1197 "{what}: {a} != {b}"
1198 );
1199 }
1200
1201 #[test]
1206 fn round_trip_through_the_importer_preserves_every_carried_field() {
1207 let source = carryable_profile();
1208 let export = export_a7p(&source).expect("export");
1209 let back = reimport(&export);
1210
1211 assert_eq!(back.name, source.name);
1212 assert_eq!(back.drag_model, source.drag_model);
1213 assert_eq!(back.units, source.units);
1214 assert_eq!(back.bullet_name, source.bullet_name);
1215 assert_eq!(back.twist_right, source.twist_right);
1216 close(back.velocity, source.velocity, "velocity");
1217 close(back.bc, source.bc, "bc");
1218 close(back.mass, source.mass, "mass");
1219 close(back.diameter, source.diameter, "diameter");
1220 close(back.temperature, source.temperature, "temperature");
1221 close(back.pressure, source.pressure, "pressure");
1222 close(back.humidity, source.humidity, "humidity");
1223 close(
1224 back.twist_rate.unwrap(),
1225 source.twist_rate.unwrap(),
1226 "twist",
1227 );
1228 close(
1229 back.sight_height.unwrap(),
1230 source.sight_height.unwrap(),
1231 "sight_height",
1232 );
1233 close(
1234 back.bullet_length.unwrap(),
1235 source.bullet_length.unwrap(),
1236 "bullet_length",
1237 );
1238 close(
1239 back.zero_distance.unwrap(),
1240 source.zero_distance.unwrap(),
1241 "zero_distance",
1242 );
1243 close(
1244 back.auto_zero.unwrap(),
1245 source.auto_zero.unwrap(),
1246 "auto_zero",
1247 );
1248
1249 let (from, to) = (
1250 source.bc_segments.as_ref().unwrap(),
1251 back.bc_segments.as_ref().unwrap(),
1252 );
1253 assert_eq!(to.len(), from.len(), "bc_segments length");
1254 for (a, b) in from.iter().zip(to.iter()) {
1255 close(b.bc, a.bc, "bc_segments.bc");
1256 close(b.velocity_mps, a.velocity_mps, "bc_segments.velocity_mps");
1257 }
1258 assert!(back.drag_curve.is_none());
1259
1260 assert_eq!(
1264 export.warnings.len(),
1265 2,
1266 "unexpected warnings: {:?}",
1267 export.warnings
1268 );
1269 }
1270
1271 #[test]
1279 fn unmarked_in_band_readings_are_reported_as_not_carried() {
1280 let populated = |profile: &ProfileData| {
1281 let export = export_a7p(profile).expect("export");
1282 export
1283 .not_carried
1284 .iter()
1285 .filter(|n| n.field.ends_with("_supplied"))
1286 .map(|n| (n.field, n.populated))
1287 .collect::<Vec<_>>()
1288 };
1289 let lost = |temperature: bool, pressure: bool| {
1290 vec![
1291 ("temperature_supplied", temperature),
1292 ("pressure_supplied", pressure),
1293 ]
1294 };
1295 let marked = full_profile();
1297 assert_eq!(populated(&marked), lost(false, false));
1298 let unmarked = ProfileData {
1299 temperature_supplied: false,
1300 pressure_supplied: false,
1301 ..full_profile()
1302 };
1303 assert_eq!(populated(&unmarked), lost(true, false));
1304 let in_band = ProfileData {
1306 pressure: 1013.25,
1307 pressure_reference: None,
1308 ..unmarked.clone()
1309 };
1310 assert_eq!(populated(&in_band), lost(true, true));
1311 let in_band_qnh = ProfileData {
1312 pressure_reference: Some("qnh".to_string()),
1313 ..in_band.clone()
1314 };
1315 assert_eq!(populated(&in_band_qnh), lost(true, true));
1316 let near = ProfileData {
1318 temperature: 14.95,
1319 pressure: 1012.8,
1320 ..in_band.clone()
1321 };
1322 assert_eq!(populated(&near), lost(true, true));
1323 let out_of_band = ProfileData {
1325 temperature: 15.15,
1326 pressure: 1013.8,
1327 ..in_band.clone()
1328 };
1329 assert_eq!(populated(&out_of_band), lost(false, false));
1330 for altitude in [0.0, 1.0, -1.0, 1.5, 3000.0] {
1332 let at = |profile: &ProfileData| ProfileData {
1333 altitude,
1334 ..profile.clone()
1335 };
1336 assert_eq!(populated(&at(&in_band)), lost(true, true), "{altitude} m");
1337 assert_eq!(
1338 populated(&at(&in_band_qnh)),
1339 lost(true, true),
1340 "{altitude} m"
1341 );
1342 assert_eq!(
1343 populated(&at(&out_of_band)),
1344 lost(false, false),
1345 "{altitude} m"
1346 );
1347 }
1348 let imperial = ProfileData {
1351 units: "imperial".to_string(),
1352 velocity: 2600.0,
1353 mass: 300.0,
1354 diameter: 0.338,
1355 twist_rate: Some(10.0),
1356 sight_height: Some(3.5),
1357 bullet_length: Some(1.8),
1358 temperature: 59.0,
1359 pressure: 29.92,
1360 altitude: 5000.0,
1361 ..in_band.clone()
1362 };
1363 assert_eq!(populated(&imperial), lost(true, true));
1364 let imperial_sea_level = ProfileData {
1365 altitude: 0.0,
1366 ..imperial.clone()
1367 };
1368 assert_eq!(populated(&imperial_sea_level), lost(true, true));
1369 let imperial_out = ProfileData {
1370 temperature: 80.0,
1371 pressure: 24.9,
1372 ..imperial
1373 };
1374 assert_eq!(populated(&imperial_out), lost(false, false));
1375 }
1376
1377 #[test]
1382 fn not_carried_and_carried_cover_every_profile_field() {
1383 let profile = full_profile();
1384 let serialized = serde_json::to_value(&profile).expect("serialize");
1385 let keys: std::collections::BTreeSet<String> = serialized
1386 .as_object()
1387 .expect("a profile is a JSON object")
1388 .keys()
1389 .cloned()
1390 .collect();
1391
1392 let export = export_a7p(&profile).expect("export");
1393 let mut accounted: std::collections::BTreeSet<String> =
1394 CARRIED_FIELDS.iter().map(|f| f.to_string()).collect();
1395 for entry in &export.not_carried {
1396 assert!(
1397 accounted.insert(entry.field.to_string()),
1398 "{} is in BOTH the carried and not-carried lists",
1399 entry.field
1400 );
1401 }
1402
1403 let unaccounted: Vec<&String> = keys.difference(&accounted).collect();
1404 assert!(
1405 unaccounted.is_empty(),
1406 "ProfileData fields in neither list (add them to CARRIED_FIELDS or to \
1407 not_carried_for): {unaccounted:?}"
1408 );
1409 let invented: Vec<&String> = accounted.difference(&keys).collect();
1410 assert!(
1411 invented.is_empty(),
1412 "listed names that are not ProfileData fields: {invented:?}"
1413 );
1414
1415 assert!(
1421 !accounted.contains("switches"),
1422 "switches is not a ProfileData field and must not appear in either list"
1423 );
1424 }
1425
1426 #[test]
1431 fn not_carried_names_real_populated_fields_with_their_values() {
1432 let profile = full_profile();
1433 let serialized = serde_json::to_value(&profile).expect("serialize");
1434 let object = serialized.as_object().expect("object");
1435
1436 let export = export_a7p(&profile).expect("export");
1437 let dropped = export.dropped_fields();
1438 assert!(!dropped.is_empty(), "this profile drops plenty");
1439
1440 for name in &dropped {
1441 assert!(
1442 object.contains_key(*name),
1443 "{name} is not a ProfileData field"
1444 );
1445 }
1446 for expected in [
1450 "altitude",
1451 "dsf_points",
1452 "elevation_cf",
1453 "zero_sets",
1454 "reticle",
1455 "elevation_click",
1456 "zero_poi_up_m",
1457 "bc_reference",
1458 "clicks_per_revolution",
1459 "hold_bound_up_mil",
1460 ] {
1461 assert!(dropped.contains(&expected), "{expected} must be reported");
1462 }
1463
1464 let dsf = export
1465 .not_carried
1466 .iter()
1467 .find(|n| n.field == "dsf_points")
1468 .expect("dsf_points entry");
1469 assert!(dsf.populated);
1470 assert_eq!(dsf.value.as_deref(), Some("1 drop-scale-factor point(s)"));
1471 assert!(!dsf.reason.is_empty());
1472
1473 let mut empty = full_profile();
1476 empty.dsf_points = None;
1477 let empty_export = export_a7p(&empty).expect("export");
1478 let dsf = empty_export
1479 .not_carried
1480 .iter()
1481 .find(|n| n.field == "dsf_points")
1482 .expect("dsf_points is listed even when unset");
1483 assert!(!dsf.populated);
1484 assert_eq!(dsf.value, None);
1485 }
1486
1487 #[test]
1492 fn an_imperial_profile_and_its_metric_twin_encode_identically() {
1493 let metric = carryable_profile();
1494 let mut imperial = metric.clone();
1495 imperial.units = "imperial".to_string();
1496 imperial.velocity = 792.0 / FPS_TO_MPS; imperial.mass = 300.0; imperial.diameter = 0.338; imperial.bullet_length = Some(1.8); imperial.twist_rate = Some(10.0); imperial.sight_height = Some(90.0 / MM_PER_INCH); imperial.zero_distance = Some(100.0 / METERS_PER_YARD); imperial.auto_zero = imperial.zero_distance;
1504 imperial.temperature = 15.0 * 9.0 / 5.0 + 32.0; imperial.pressure = 1000.0 / HPA_PER_INHG; let a = export_a7p(&metric).expect("metric export");
1508 let b = export_a7p(&imperial).expect("imperial export");
1509 assert_eq!(
1510 a.bytes, b.bytes,
1511 "the same rifle must encode identically whatever units it was saved in"
1512 );
1513 }
1514
1515 #[test]
1516 fn a_custom_drag_curve_round_trips_as_mach_cd_rows() {
1517 let mut profile = carryable_profile();
1518 profile.drag_model = "CUSTOM".to_string();
1519 profile.bc = 0.0; profile.bc_segments = None;
1521 profile.drag_curve = Some(vec![
1522 crate::profile::ProfileDragPoint {
1523 mach: 0.5,
1524 cd: 0.23,
1525 },
1526 crate::profile::ProfileDragPoint {
1527 mach: 3.0,
1528 cd: 0.28,
1529 },
1530 ]);
1531
1532 let export = export_a7p(&profile).expect("export");
1533 let back = reimport(&export);
1534 assert_eq!(back.drag_model, "CUSTOM");
1535 assert_eq!(back.bc, 0.0);
1536 let curve = back.drag_curve.as_ref().expect("curve survives");
1537 assert_eq!(curve.len(), 2);
1538 close(curve[0].mach, 0.5, "mach");
1539 close(curve[0].cd, 0.23, "cd");
1540 close(curve[1].mach, 3.0, "mach");
1541 close(curve[1].cd, 0.28, "cd");
1542 }
1543
1544 #[test]
1548 fn a_drag_model_the_format_cannot_name_is_refused() {
1549 let mut profile = carryable_profile();
1550 profile.drag_model = "G5".to_string();
1551 match export_a7p(&profile) {
1552 Err(A7pExportError::DragModel(m)) => assert_eq!(m, "G5"),
1553 other => panic!("expected a DragModel refusal, got {other:?}"),
1554 }
1555 }
1556
1557 #[test]
1561 fn a_value_that_does_not_fit_the_fixed_point_grid_is_warned_about() {
1562 let mut profile = carryable_profile();
1563 profile.sight_height = Some(50.8);
1564 let export = export_a7p(&profile).expect("export");
1565 assert!(
1566 export
1567 .warnings
1568 .iter()
1569 .any(|w| w.starts_with("sight_height:") && w.contains("51")),
1570 "expected a sight_height quantization warning, got {:?}",
1571 export.warnings
1572 );
1573 let back = reimport(&export);
1574 assert_eq!(back.sight_height, Some(51.0));
1575 }
1576
1577 #[test]
1578 fn an_unusable_measurement_is_a_named_field_error() {
1579 let mut profile = carryable_profile();
1580 profile.velocity = 0.0;
1581 match export_a7p(&profile) {
1582 Err(A7pExportError::Field { field, .. }) => assert_eq!(field, "velocity"),
1583 other => panic!("expected a Field error, got {other:?}"),
1584 }
1585
1586 let mut profile = carryable_profile();
1587 profile.units = "furlongs".to_string();
1588 assert!(matches!(
1589 export_a7p(&profile),
1590 Err(A7pExportError::Units(_))
1591 ));
1592 }
1593
1594 #[test]
1598 fn custom_without_a_curve_is_refused() {
1599 let mut profile = carryable_profile();
1600 profile.drag_model = "CUSTOM".to_string();
1601 profile.drag_curve = None;
1602 match export_a7p(&profile) {
1603 Err(A7pExportError::Field { field, .. }) => assert_eq!(field, "drag_curve"),
1604 other => panic!("expected a drag_curve error, got {other:?}"),
1605 }
1606 }
1607
1608 fn switch_entries(bytes: &[u8]) -> Vec<Vec<(u32, u64)>> {
1612 fn varint(b: &[u8], i: &mut usize) -> u64 {
1613 let (mut v, mut shift) = (0u64, 0u32);
1614 loop {
1615 let byte = b[*i];
1616 *i += 1;
1617 v |= u64::from(byte & 0x7f) << shift;
1618 if byte & 0x80 == 0 {
1619 return v;
1620 }
1621 shift += 7;
1622 }
1623 }
1624 fn fields(b: &[u8]) -> Vec<(u32, u64, &[u8])> {
1625 let mut i = 0usize;
1626 let mut out = Vec::new();
1627 while i < b.len() {
1628 let key = varint(b, &mut i);
1629 let (number, wire) = ((key >> 3) as u32, key & 7);
1630 match wire {
1631 0 => {
1632 let v = varint(b, &mut i);
1633 out.push((number, v, &b[0..0]));
1634 }
1635 2 => {
1636 let n = varint(b, &mut i) as usize;
1637 out.push((number, 0, &b[i..i + n]));
1638 i += n;
1639 }
1640 other => panic!("unexpected wire type {other}"),
1641 }
1642 }
1643 out
1644 }
1645 let payload = &bytes[32..];
1646 let profile = fields(payload)
1647 .into_iter()
1648 .find(|(n, _, _)| *n == F_PAYLOAD_PROFILE)
1649 .expect("payload carries a profile")
1650 .2;
1651 fields(profile)
1652 .into_iter()
1653 .filter(|(n, _, _)| *n == F_SWITCHES)
1654 .map(|(_, _, body)| {
1655 fields(body)
1656 .into_iter()
1657 .map(|(n, v, _)| (n, v))
1658 .collect::<Vec<_>>()
1659 })
1660 .collect()
1661 }
1662
1663 #[test]
1671 fn every_export_carries_the_minimum_four_switch_positions() {
1672 for profile in [carryable_profile(), full_profile()] {
1673 let export = export_a7p(&profile).expect("export");
1674 let switches = switch_entries(&export.bytes);
1675 assert!(
1676 switches.len() >= MIN_SWITCHES,
1677 "exported {} switch positions; the ecosystem refuses fewer than \
1678 {MIN_SWITCHES}",
1679 switches.len()
1680 );
1681 for (i, entry) in switches.iter().enumerate() {
1682 let (zoom, distance) = PLACEHOLDER_SWITCHES[i];
1683 assert_eq!(
1684 entry,
1685 &vec![
1686 (F_SW_C_IDX, PLACEHOLDER_SWITCH_C_IDX as u64),
1687 (F_SW_ZOOM, zoom as u64),
1688 (F_SW_DISTANCE, distance as u64),
1689 ],
1690 "switch {i}"
1691 );
1692 }
1693 assert!(
1696 export
1697 .warnings
1698 .iter()
1699 .any(|w| w.contains("PLACEHOLDERS") && w.contains("switch")),
1700 "the placeholder switches must be declared: {:?}",
1701 export.warnings
1702 );
1703 }
1704 }
1705
1706 #[test]
1712 fn fields_the_format_requires_are_refused_when_absent_not_invented() {
1713 for (field, clear) in [
1714 (
1715 "bullet_length",
1716 Box::new(|p: &mut ProfileData| p.bullet_length = None)
1717 as Box<dyn Fn(&mut ProfileData)>,
1718 ),
1719 (
1720 "zero_distance",
1721 Box::new(|p: &mut ProfileData| p.zero_distance = None),
1722 ),
1723 (
1724 "sight_height",
1725 Box::new(|p: &mut ProfileData| p.sight_height = None),
1726 ),
1727 ] {
1728 let mut profile = carryable_profile();
1729 clear(&mut profile);
1730 match export_a7p(&profile) {
1731 Err(A7pExportError::Field { field: got, .. }) => assert_eq!(got, field),
1732 other => panic!("{field}: expected a refusal, got {other:?}"),
1733 }
1734 }
1735 }
1736
1737 #[test]
1749 fn an_absent_twist_rate_warns_rather_than_refusing_and_that_is_deliberate() {
1750 let mut profile = carryable_profile();
1751 profile.twist_rate = None;
1752 let export = export_a7p(&profile).expect("an unknown twist must still export");
1753 assert!(
1754 export
1755 .warnings
1756 .iter()
1757 .any(|w| w.starts_with("twist_rate:") && w.contains("r_twist")),
1758 "an absent twist must be reported: {:?}",
1759 export.warnings
1760 );
1761 let back = reimport(&export);
1763 assert_eq!(back.twist_rate, Some(0.0));
1764 }
1765
1766 #[test]
1770 fn values_outside_what_the_ecosystem_accepts_are_refused_by_name() {
1771 for (field, mutate) in [
1775 (
1776 "velocity",
1777 Box::new(|p: &mut ProfileData| p.velocity = 5.0) as Box<dyn Fn(&mut ProfileData)>,
1778 ),
1779 (
1780 "pressure",
1781 Box::new(|p: &mut ProfileData| p.pressure = 100.0),
1782 ),
1783 (
1784 "temperature",
1785 Box::new(|p: &mut ProfileData| p.temperature = 250.0),
1786 ),
1787 (
1788 "zero_distance",
1789 Box::new(|p: &mut ProfileData| p.zero_distance = Some(5000.0)),
1790 ),
1791 (
1792 "sight_height",
1793 Box::new(|p: &mut ProfileData| p.sight_height = Some(9000.0)),
1794 ),
1795 ] {
1796 let mut profile = carryable_profile();
1797 mutate(&mut profile);
1798 match export_a7p(&profile) {
1799 Err(A7pExportError::Field { field: got, .. }) => assert_eq!(got, field),
1800 other => panic!("{field}: expected a refusal, got {other:?}"),
1801 }
1802 }
1803 }
1804}