1use std::{collections::BTreeMap, fmt::Write as _};
8
9use serde::{Deserialize, Serialize};
10use ts_rs::TS;
11
12use crate::rules::CALIBRATION_LIMITS;
13
14const ULTRA_MS: (f64, f64) = CALIBRATION_LIMITS.ultra_ms;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
18#[serde(rename_all = "lowercase")]
19#[ts(export)]
20pub enum Mode {
21 Zero,
22 Mixed,
23 Linear,
24 Ultra,
25}
26
27#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
29#[serde(rename_all = "snake_case")]
30#[ts(export)]
31pub enum RcHiBasis {
32 #[default]
33 Strict,
34 Lenient,
35 Agreement,
36}
37
38impl RcHiBasis {
39 pub fn as_str(self) -> &'static str {
40 match self {
41 Self::Strict => "strict",
42 Self::Lenient => "lenient",
43 Self::Agreement => "agreement",
44 }
45 }
46}
47
48fn default_rc_hi_transition() -> u8 {
49 1
50}
51
52#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
54#[serde(rename_all = "snake_case")]
55#[ts(export)]
56pub enum SampleRatePolicy {
57 #[default]
59 Reject,
60 Resample,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
65#[ts(export)]
66pub struct SubEntry {
67 pub mode: String,
68 pub bundle: String,
69 pub gain_db: f64,
70 pub delay_ms: f64,
71 pub polarity: i8,
72 pub allpass: Vec<(f64, f64)>,
73 pub mimo_support: bool,
74 pub hp_hz: f64,
75 pub lp_hz: f64,
76 pub peq: Vec<(f64, f64, f64)>,
77 pub hp_type: String,
78 pub lp_type: String,
79 pub dba_row: String,
80}
81
82impl Default for SubEntry {
83 fn default() -> Self {
84 Self {
85 mode: "auto".into(),
86 bundle: "A".into(),
87 gain_db: 0.0,
88 delay_ms: 0.0,
89 polarity: 1,
90 allpass: Vec::new(),
91 mimo_support: true,
92 hp_hz: 0.0,
93 lp_hz: 0.0,
94 peq: Vec::new(),
95 hp_type: "LR24".into(),
96 lp_type: "LR24".into(),
97 dba_row: "front".into(),
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
103#[ts(export)]
104pub struct BundleLayoutEntry {
105 pub kind: String,
106 pub rear_trim_ms: f64,
107}
108
109impl Default for BundleLayoutEntry {
110 fn default() -> Self {
111 Self {
112 kind: "values".into(),
113 rear_trim_ms: 0.0,
114 }
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
119#[ts(export)]
120pub struct CalibrationSettings {
121 pub mode: Mode,
122 #[serde(default)]
123 pub sample_rate_policy: SampleRatePolicy,
124 #[serde(default)]
125 pub low_protect: bool,
126 pub rc_lo: f64,
127 pub rc_hi: Option<f64>,
128 #[serde(default)]
129 pub rc_hi_basis: RcHiBasis,
130 #[serde(default = "default_rc_hi_transition")]
131 pub rc_hi_transition: u8,
132 pub room_volume_m3: Option<f64>,
133 pub room_dims_m: Option<[f64; 3]>,
134 pub mimo_on: bool,
135 pub fsiso: f64,
136 pub mimo_low: Option<f64>,
137 #[ts(type = "number")]
138 pub mimo_max_supports: i64,
139 pub mimo_fir: bool,
140 #[serde(default)]
142 pub peq_analytic_derivatives: bool,
143 #[serde(default)]
145 pub mimo_iir_analytic_derivatives: bool,
146 #[serde(with = "ordered_f64_map")]
147 #[ts(as = "BTreeMap<String, f64>")]
148 pub xo: Vec<(String, f64)>,
149 #[serde(with = "ordered_f64_map")]
150 #[ts(as = "BTreeMap<String, f64>")]
151 pub xo_rec: Vec<(String, f64)>,
152 pub ultra_ms: f64,
153 #[serde(with = "ordered_sub_map")]
154 #[ts(as = "BTreeMap<String, SubEntry>")]
155 pub subs_manual: Vec<(String, SubEntry)>,
156 pub sub_main_delay_ms: f64,
157 #[serde(with = "ordered_layout_map")]
158 #[ts(as = "BTreeMap<String, BundleLayoutEntry>")]
159 pub bundle_layouts: Vec<(String, BundleLayoutEntry)>,
160 pub infra_boost_max_db: f64,
161 pub xo_peq_on: bool,
162 pub boost_max_db: f64,
163 pub xo_peq_lo: f64,
164 pub xo_peq_hi: f64,
165}
166
167impl Default for CalibrationSettings {
168 fn default() -> Self {
169 Self {
170 mode: Mode::Mixed,
171 sample_rate_policy: SampleRatePolicy::Reject,
172 low_protect: false,
173 rc_lo: 20.0,
174 rc_hi: None,
175 rc_hi_basis: RcHiBasis::Strict,
176 rc_hi_transition: 1,
177 room_volume_m3: None,
178 room_dims_m: None,
179 mimo_on: false,
180 fsiso: 150.0,
181 mimo_low: None,
182 mimo_max_supports: 0,
183 mimo_fir: true,
185 peq_analytic_derivatives: false,
186 mimo_iir_analytic_derivatives: false,
187 xo: Vec::new(),
188 xo_rec: Vec::new(),
189 ultra_ms: 200.0,
190 subs_manual: Vec::new(),
191 sub_main_delay_ms: 0.0,
192 bundle_layouts: Vec::new(),
193 infra_boost_max_db: 24.0,
194 xo_peq_on: true,
195 boost_max_db: 10.0,
196 xo_peq_lo: 0.0,
197 xo_peq_hi: 0.0,
198 }
199 }
200}
201
202impl CalibrationSettings {
203 pub fn label(&self) -> String {
205 mode_label(self.mode, Some(self.ultra_ms))
206 }
207
208 pub fn engine_key(&self) -> String {
210 let mut out = String::new();
211 write!(&mut out, "{:?}|{}|", self.mode, number(self.rc_lo)).unwrap();
212 optional_number(&mut out, self.rc_hi);
213 out.push('|');
214 optional_number(&mut out, self.room_volume_m3);
215 out.push('|');
216 sorted_f64_map(&mut out, &self.xo);
217 out.push('|');
218 if self.mode == Mode::Ultra {
219 out.push_str(&number(self.ultra_ms));
220 } else {
221 out.push('-');
222 }
223 out.push('|');
224 out.push_str(&self.manual_key());
225 write!(
226 &mut out,
227 "|{}|{}|{}|{}|{}",
228 number(self.infra_boost_max_db),
229 self.xo_peq_on,
230 number(self.xo_peq_lo),
231 number(self.xo_peq_hi),
232 number(self.boost_max_db)
233 )
234 .unwrap();
235 if self.rc_hi_basis != RcHiBasis::Strict {
237 write!(&mut out, "|rc_hi_basis={}", self.rc_hi_basis.as_str()).unwrap();
238 }
239 if self.rc_hi_transition != 1 {
240 write!(&mut out, "|rc_hi_transition={}", self.rc_hi_transition).unwrap();
241 }
242 if self.sample_rate_policy == SampleRatePolicy::Resample {
244 out.push_str("|sample_rate_policy=resample");
245 }
246 if self.low_protect {
247 out.push_str("|low_protect");
248 }
249 if self.peq_analytic_derivatives {
250 out.push_str("|peq_analytic_derivatives");
251 }
252 out
253 }
254
255 pub fn manual_key(&self) -> String {
257 let mut entries: Vec<_> = self.subs_manual.iter().collect();
258 entries.sort_by(|a, b| a.0.cmp(&b.0));
259 let mut out = String::new();
260 for (role, entry) in entries {
261 let filters = filter_key(entry);
262 if entry.mode == "auto" {
263 if entry.hp_hz != 0.0 || entry.lp_hz != 0.0 || !entry.peq.is_empty() {
264 write!(&mut out, "{role}:auto:{filters};").unwrap();
265 }
266 continue;
267 }
268 let layout = if entry.mode == "bundle" {
269 self.bundle_layouts
270 .iter()
271 .find(|(name, _)| name == &entry.bundle)
272 .map_or("values", |(_, layout)| layout.kind.as_str())
273 } else {
274 "values"
275 };
276 if layout != "values" {
277 let row = if layout == "dba" {
278 entry.dba_row.as_str()
279 } else {
280 ""
281 };
282 let trim = if layout == "dba" {
283 self.bundle_layouts
284 .iter()
285 .find(|(name, _)| name == &entry.bundle)
286 .map_or(0.0, |(_, value)| value.rear_trim_ms)
287 } else {
288 0.0
289 };
290 write!(
291 &mut out,
292 "{role}:bundle:{}:{layout}:{row}:{}:{filters};",
293 entry.bundle,
294 number(trim)
295 )
296 .unwrap();
297 } else {
298 let bundle = if entry.mode == "bundle" {
299 entry.bundle.as_str()
300 } else {
301 ""
302 };
303 write!(
304 &mut out,
305 "{role}:{}:{bundle}:{}:{}:{}:",
306 entry.mode,
307 number(entry.gain_db),
308 number(entry.delay_ms),
309 entry.polarity
310 )
311 .unwrap();
312 for &(frequency, q) in &entry.allpass {
313 write!(&mut out, "{},{};", number(frequency), number(q)).unwrap();
314 }
315 write!(&mut out, ":{filters};").unwrap();
316 }
317 }
318 let fixed = self.subs_manual.iter().any(|(_, e)| e.mode == "fixed");
319 if fixed {
320 write!(&mut out, "|{}", number(self.sub_main_delay_ms)).unwrap();
321 } else {
322 out.push_str("|-");
323 }
324 out
325 }
326
327 pub fn mimo_key(&self) -> String {
329 let mut excluded: Vec<_> = self
330 .subs_manual
331 .iter()
332 .filter(|(_, entry)| !entry.mimo_support)
333 .map(|(role, _)| role.as_str())
334 .collect();
335 excluded.sort_unstable();
336 let mut out = format!("{}|", number(self.fsiso));
337 optional_number(&mut out, self.mimo_low);
338 write!(
339 &mut out,
340 "|{}|{}|{}",
341 excluded.join(","),
342 self.mimo_max_supports,
343 self.mimo_fir
344 )
345 .unwrap();
346 if self.mimo_iir_analytic_derivatives {
347 out.push_str("|mimo_iir_analytic_derivatives");
348 }
349 out
350 }
351}
352
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
354#[ts(export)]
355pub struct ModeParams {
356 pub taps: usize,
357 pub lift_ms: f64,
358 pub peq_bands: Option<usize>,
359 pub phase_frac: f64,
360 pub max_latency_ms: f64,
361}
362
363pub fn mode_tag(mode: Mode) -> &'static str {
365 match mode {
366 Mode::Zero => "zero",
367 Mode::Mixed => "mixed",
368 Mode::Linear => "linear",
369 Mode::Ultra => "ultra",
370 }
371}
372
373pub fn mode_label(mode: Mode, ultra_ms: Option<f64>) -> String {
375 let name = match mode {
376 Mode::Zero => "최소 지연 모드",
377 Mode::Mixed => "저지연 모드",
378 Mode::Linear => "표준 모드",
379 Mode::Ultra => "초고지연 모드",
380 };
381 if mode == Mode::Ultra
382 && let Some(ms) = ultra_ms
383 {
384 let (lo, hi) = ULTRA_MS;
385 format!("{name} {} ms", python_g(ms.clamp(lo, hi)))
386 } else {
387 name.into()
388 }
389}
390
391fn python_g(value: f64) -> String {
392 if value == 0.0 {
393 return if value.is_sign_negative() { "-0" } else { "0" }.into();
394 }
395 let mut exponent = value.abs().log10().floor() as i32;
396 let places = 5 - exponent;
397 let scale = 10_f64.powi(places);
398 let rounded = if places >= 0 {
399 (value * scale).round_ties_even() / scale
400 } else {
401 (value / scale.recip()).round_ties_even() * scale.recip()
402 };
403 if rounded != 0.0 {
404 exponent = rounded.abs().log10().floor() as i32;
405 }
406 if !(-4..6).contains(&exponent) {
407 let mut text = format!("{rounded:.5e}");
408 let e = text.find('e').expect("scientific format");
409 while text[..e].ends_with('0') {
410 text.remove(e - 1);
411 }
412 if text[..text.find('e').unwrap()].ends_with('.') {
413 text.remove(text.find('e').unwrap() - 1);
414 }
415 let e = text.find('e').unwrap();
416 let power = text[e + 1..].parse::<i32>().expect("scientific exponent");
417 format!(
418 "{}e{}{:02}",
419 &text[..e],
420 if power >= 0 { '+' } else { '-' },
421 power.abs()
422 )
423 } else {
424 let places = (5 - exponent).max(0) as usize;
425 let mut text = format!("{rounded:.places$}");
426 while text.contains('.') && text.ends_with('0') {
427 text.pop();
428 }
429 if text.ends_with('.') {
430 text.pop();
431 }
432 text
433 }
434}
435
436pub fn mode_params(mode: Mode, ultra_ms: f64) -> ModeParams {
437 match mode {
438 Mode::Zero => ModeParams {
439 taps: 0,
440 lift_ms: 0.0,
441 peq_bands: Some(32),
442 phase_frac: 6.0,
443 max_latency_ms: 0.0,
444 },
445 Mode::Mixed => ModeParams {
446 taps: 2048,
447 lift_ms: 8.0,
448 peq_bands: None,
449 phase_frac: 6.0,
450 max_latency_ms: 10.0,
451 },
452 Mode::Linear => ModeParams {
453 taps: 2048,
454 lift_ms: 28.0,
455 peq_bands: None,
456 phase_frac: 24.0,
457 max_latency_ms: 30.0,
458 },
459 Mode::Ultra => {
460 let ms = ultra_ms.clamp(ULTRA_MS.0, ULTRA_MS.1);
461 let lift_ms = ms - 2.0;
462 let mut taps = 2048;
463 while taps < 16384 && 0.7 * taps as f64 / 48_000.0 * 1000.0 < lift_ms {
464 taps *= 2;
465 }
466 ModeParams {
467 taps,
468 lift_ms,
469 peq_bands: None,
470 phase_frac: 24.0,
471 max_latency_ms: ms,
472 }
473 }
474 }
475}
476
477fn number(value: f64) -> String {
478 if value == 0.0 {
479 "0".into()
480 } else {
481 format!("{:016x}", value.to_bits())
482 }
483}
484
485fn optional_number(out: &mut String, value: Option<f64>) {
486 if let Some(value) = value {
487 out.push_str(&number(value));
488 } else {
489 out.push('-');
490 }
491}
492
493fn sorted_f64_map(out: &mut String, values: &[(String, f64)]) {
494 let mut values: Vec<_> = values.iter().collect();
495 values.sort_by(|a, b| a.0.cmp(&b.0));
496 for (key, value) in values {
497 write!(out, "{key}={};", number(*value)).unwrap();
498 }
499}
500
501fn filter_key(entry: &SubEntry) -> String {
502 let mut out = format!(
503 "{}:{}:{}:{}:",
504 number(entry.hp_hz),
505 if entry.hp_hz != 0.0 {
506 &entry.hp_type
507 } else {
508 ""
509 },
510 number(entry.lp_hz),
511 if entry.lp_hz != 0.0 {
512 &entry.lp_type
513 } else {
514 ""
515 }
516 );
517 for &(frequency, q, gain) in &entry.peq {
518 write!(
519 &mut out,
520 "{},{},{};",
521 number(frequency),
522 number(q),
523 number(gain)
524 )
525 .unwrap();
526 }
527 out
528}
529
530macro_rules! ordered_map {
531 ($module:ident, $value:ty) => {
532 mod $module {
533 use serde::{Deserialize, Deserializer, Serializer};
534 use std::collections::BTreeMap;
535
536 pub fn serialize<S>(
537 values: &Vec<(String, $value)>,
538 serializer: S,
539 ) -> Result<S::Ok, S::Error>
540 where
541 S: Serializer,
542 {
543 use serde::ser::SerializeMap as _;
544 let mut map = serializer.serialize_map(Some(values.len()))?;
545 for (key, value) in values {
546 map.serialize_entry(key, value)?;
547 }
548 map.end()
549 }
550
551 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<(String, $value)>, D::Error>
552 where
553 D: Deserializer<'de>,
554 {
555 Ok(BTreeMap::<String, $value>::deserialize(deserializer)?
558 .into_iter()
559 .collect())
560 }
561 }
562 };
563}
564
565ordered_map!(ordered_f64_map, f64);
566ordered_map!(ordered_sub_map, super::SubEntry);
567ordered_map!(ordered_layout_map, super::BundleLayoutEntry);
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 #[test]
574 fn rc_upper_defaults_and_each_setting_change_engine_key() {
575 let baseline = CalibrationSettings::default();
576 let mut json = serde_json::to_value(&baseline).unwrap();
577 json.as_object_mut().unwrap().remove("rc_hi_basis");
578 json.as_object_mut().unwrap().remove("rc_hi_transition");
579 let legacy: CalibrationSettings = serde_json::from_value(json).unwrap();
580 assert_eq!(legacy, baseline);
581 for basis in [RcHiBasis::Lenient, RcHiBasis::Agreement] {
582 let settings = CalibrationSettings {
583 rc_hi_basis: basis,
584 ..baseline.clone()
585 };
586 assert_ne!(settings.engine_key(), baseline.engine_key());
587 assert_eq!(settings.mimo_key(), baseline.mimo_key());
588 assert_eq!(
589 serde_json::to_value(&settings).unwrap()["rc_hi_basis"],
590 basis.as_str()
591 );
592 }
593 for factor in [2, 4] {
594 let settings = CalibrationSettings {
595 rc_hi_transition: factor,
596 ..baseline.clone()
597 };
598 assert_ne!(settings.engine_key(), baseline.engine_key());
599 assert_eq!(settings.mimo_key(), baseline.mimo_key());
600 }
601 }
602
603 #[test]
604 fn sample_rate_policy_defaults_for_legacy_settings_and_changes_engine_key() {
605 let baseline = CalibrationSettings::default();
606 let mut json = serde_json::to_value(&baseline).unwrap();
607 assert_eq!(json["sample_rate_policy"], "reject");
608 json.as_object_mut().unwrap().remove("sample_rate_policy");
609 let legacy: CalibrationSettings = serde_json::from_value(json).unwrap();
610 assert_eq!(legacy, baseline);
611 let mut resampled = legacy.clone();
612 resampled.sample_rate_policy = SampleRatePolicy::Resample;
613 assert_ne!(resampled.engine_key(), legacy.engine_key());
614 assert_eq!(
615 serde_json::to_value(&resampled).unwrap()["sample_rate_policy"],
616 "resample"
617 );
618 resampled.sample_rate_policy = SampleRatePolicy::Reject;
619 assert_eq!(resampled.engine_key(), legacy.engine_key());
620 }
621
622 #[test]
623 fn low_protect_defaults_off_and_preserves_legacy_engine_key() {
624 let baseline = CalibrationSettings::default();
625 assert!(!baseline.low_protect);
626 let key = baseline.engine_key();
627 assert!(!key.contains("low_protect"));
628 let mut json = serde_json::to_value(&baseline).unwrap();
629 json.as_object_mut().unwrap().remove("low_protect");
630 let mut legacy: CalibrationSettings = serde_json::from_value(json).unwrap();
631 assert_eq!(legacy, baseline);
632 legacy.low_protect = true;
633 assert_eq!(legacy.engine_key(), format!("{key}|low_protect"));
634 legacy.low_protect = false;
635 assert_eq!(legacy.engine_key(), key);
636 let edit: crate::settings_edit::SettingEdit =
637 serde_json::from_value(serde_json::json!({"kind":"low_protect","on":true})).unwrap();
638 assert_eq!(
639 edit,
640 crate::settings_edit::SettingEdit::LowProtect { on: true }
641 );
642 }
643
644 #[test]
645 fn analytic_derivative_settings_default_off_and_change_only_their_keys() {
646 let baseline = CalibrationSettings::default();
647 let engine_key = baseline.engine_key();
648 let mimo_key = baseline.mimo_key();
649 let mut json = serde_json::to_value(&baseline).unwrap();
650 let object = json.as_object_mut().unwrap();
651 object.remove("peq_analytic_derivatives");
652 object.remove("mimo_iir_analytic_derivatives");
653 let legacy: CalibrationSettings = serde_json::from_value(json).unwrap();
654 assert!(!legacy.peq_analytic_derivatives);
655 assert!(!legacy.mimo_iir_analytic_derivatives);
656 assert_eq!(legacy.engine_key(), engine_key);
657 assert_eq!(legacy.mimo_key(), mimo_key);
658
659 let mut peq = legacy.clone();
660 peq.peq_analytic_derivatives = true;
661 assert_eq!(
662 peq.engine_key(),
663 format!("{engine_key}|peq_analytic_derivatives")
664 );
665 assert_eq!(peq.mimo_key(), mimo_key);
666
667 let mut mimo = legacy;
668 mimo.mimo_iir_analytic_derivatives = true;
669 assert_eq!(mimo.engine_key(), engine_key);
670 assert_eq!(
671 mimo.mimo_key(),
672 format!("{mimo_key}|mimo_iir_analytic_derivatives")
673 );
674 }
675
676 #[test]
677 fn mode_labels_match_upstream() {
678 assert_eq!(mode_label(Mode::Zero, None), "최소 지연 모드");
679 assert_eq!(mode_label(Mode::Mixed, Some(120.0)), "저지연 모드");
680 assert_eq!(mode_label(Mode::Linear, None), "표준 모드");
681 assert_eq!(mode_label(Mode::Ultra, None), "초고지연 모드");
682 assert_eq!(mode_label(Mode::Ultra, Some(20.0)), "초고지연 모드 30 ms");
683 assert_eq!(
684 mode_label(Mode::Ultra, Some(123.456_789)),
685 "초고지연 모드 123.457 ms"
686 );
687 assert_eq!(mode_label(Mode::Ultra, Some(250.0)), "초고지연 모드 200 ms");
688
689 let settings = CalibrationSettings {
690 mode: Mode::Ultra,
691 ultra_ms: 30.123_456_7,
692 ..CalibrationSettings::default()
693 };
694 assert_eq!(settings.label(), "초고지연 모드 30.1235 ms");
695 }
696
697 #[test]
698 fn mode_params_match_upstream() {
699 let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!(
700 env!("CARGO_MANIFEST_DIR"),
701 "/../../oracle/goldens/calibration/mode_params.json"
702 )))
703 .unwrap();
704 for row in fixture["outputs"]["rows"].as_array().unwrap() {
705 let mode = match row["mode"].as_str().unwrap() {
706 "zero" => Mode::Zero,
707 "mixed" => Mode::Mixed,
708 "linear" => Mode::Linear,
709 "ultra" => Mode::Ultra,
710 _ => unreachable!(),
711 };
712 let actual = mode_params(mode, row["ultra_ms"].as_f64().unwrap());
713 let expected = &row["params"];
714 assert_eq!(actual.taps as u64, expected["taps"].as_u64().unwrap());
715 assert_eq!(actual.lift_ms, expected["lift_ms"].as_f64().unwrap());
716 assert_eq!(
717 actual.peq_bands.map(|value| value as u64),
718 expected
719 .get("peq_bands")
720 .and_then(serde_json::Value::as_u64)
721 );
722 assert_eq!(
723 actual.phase_frac,
724 expected
725 .get("phase_frac")
726 .and_then(serde_json::Value::as_f64)
727 .unwrap_or(6.0)
728 );
729 assert_eq!(
730 actual.max_latency_ms,
731 expected["max_latency_ms"].as_f64().unwrap()
732 );
733 }
734 }
735
736 #[test]
737 fn keys_have_upstream_exclusions() {
738 let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!(
739 env!("CARGO_MANIFEST_DIR"),
740 "/../../oracle/goldens/calibration/keys.json"
741 )))
742 .unwrap();
743 let settings: Vec<_> = fixture["inputs"]["settings"]
744 .as_object()
745 .unwrap()
746 .iter()
747 .map(|(name, value)| {
748 (
749 name.as_str(),
750 serde_json::from_value::<CalibrationSettings>(value.clone()).unwrap(),
751 )
752 })
753 .collect();
754
755 for (kind, key) in [
756 (
757 "engine",
758 CalibrationSettings::engine_key as fn(&CalibrationSettings) -> String,
759 ),
760 ("manual", CalibrationSettings::manual_key),
761 ("mimo", CalibrationSettings::mimo_key),
762 ] {
763 let expected = fixture["outputs"][kind].as_object().unwrap();
764 for (left_name, left) in &settings {
765 for (right_name, right) in &settings {
766 assert_eq!(
767 key(left) == key(right),
768 expected[*left_name] == expected[*right_name],
769 "{kind} key equality differs for {left_name} and {right_name}"
770 );
771 }
772 }
773 }
774 }
775}