Skip to main content

wowlab_common/sim/
intent.rs

1// #t(file: rust_inline_test_module_size) tests cover private validation and JSON-to-TOML conversion boundaries
2
3//! Sim config TOML: build from portal input, parse back for the engine.
4
5use std::collections::BTreeMap;
6
7use serde::{Deserialize, Serialize};
8#[cfg(test)]
9use wowlab_types::game::GearSlot;
10use wowlab_types::{
11    constants::{CURRENT_EXPANSION_ID, MAX_PLAYER_LEVEL},
12    game::{GearEntry, SpecId},
13    sim::{EncounterDefinition, EncounterValidationError},
14};
15
16/// Intent schema version stamped into every [`SimConfigIntent`] and checked by the engine validator.
17pub const INTENT_VERSION: &str = "v2";
18const OBSOLETE_ENCOUNTER_SETTINGS: [&str; 6] = [
19    "duration_s",
20    "enemy_count",
21    "target_count",
22    "fight_style",
23    "level",
24    "expansion_id",
25];
26
27/// Sim configuration as supplied by the portal.
28#[derive(Debug, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct IntentInput {
31    pub spec_id: u32,
32    pub rotation_id: String,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub loadout: Option<String>,
35    /// Named expansion progression systems and their selected trait entry slugs.
36    #[serde(default)]
37    pub expansion_talents: BTreeMap<String, Vec<String>>,
38    #[serde(default)]
39    // #t(rust_pub_api_foreign_types) JSON values are the portal wire-input contract
40    pub settings: BTreeMap<String, serde_json::Value>,
41    #[serde(default)]
42    pub equipment: Vec<GearEntry>,
43    pub player_level: u16,
44    pub player_expansion_id: u32,
45    pub encounter: EncounterDefinition,
46}
47
48/// Versioned TOML form of the sim intent shipped to the engine.
49#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
50#[serde(deny_unknown_fields)]
51pub struct SimConfigIntent {
52    pub intent_version: String,
53    pub spec: u32,
54    pub rotation_id: String,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub loadout: Option<String>,
57    /// Named expansion progression systems and their selected trait entry slugs.
58    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
59    pub expansion_talents: BTreeMap<String, Vec<String>>,
60    pub player_level: u16,
61    pub player_expansion_id: u32,
62    pub encounter: EncounterDefinition,
63    // #t(rust_pub_api_foreign_types) TOML values are the persisted sim-config wire contract
64    pub settings: BTreeMap<String, toml::Value>,
65    pub gear: Vec<GearEntry>,
66}
67
68/// Operation being performed when an intent configuration failed.
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70#[non_exhaustive]
71pub enum IntentConfigOperation {
72    Build,
73    Parse,
74    Serialize,
75}
76
77wowlab_engine_macros::define_error! {
78/// Failure to build, parse, validate, or serialize a simulation intent.
79#[derive(Debug)]
80#[non_exhaustive]
81pub struct IntentConfigError {
82    #[source]
83    kind: IntentConfigErrorKind,
84}
85
86#[derive(Debug, thiserror::Error)]
87enum IntentConfigErrorKind {
88    #[error("TOML parse error: {source}")]
89    TomlDeserialize {
90        operation: IntentConfigOperation,
91        #[source]
92        source: toml::de::Error,
93    },
94    #[error("TOML serialization error: {source}")]
95    TomlSerialize {
96        operation: IntentConfigOperation,
97        #[source]
98        source: toml::ser::Error,
99    },
100    #[error("unknown wow spec id: {spec_id}")]
101    UnknownSpec { spec_id: u32 },
102    #[error("unsupported number: {number}")]
103    UnsupportedSettingNumber {
104        setting: String,
105        number: serde_json::Number,
106    },
107    #[error("null values are not supported in TOML")]
108    NullSetting { setting: String },
109    #[error("unsupported intent version {found:?}; only {supported:?} is accepted")]
110    UnsupportedIntentVersion {
111        operation: IntentConfigOperation,
112        found: String,
113        supported: &'static str,
114    },
115    #[error("obsolete aggregate encounter setting {key:?}; author the typed encounter instead")]
116    ObsoleteEncounterSetting {
117        operation: IntentConfigOperation,
118        key: &'static str,
119    },
120    #[error("invalid player_level {player_level}: must be non-zero")]
121    InvalidPlayerLevel {
122        operation: IntentConfigOperation,
123        player_level: u16,
124    },
125    #[error("invalid player_expansion_id {player_expansion_id}: must be non-zero")]
126    InvalidPlayerExpansionId {
127        operation: IntentConfigOperation,
128        player_expansion_id: u32,
129    },
130    #[error("invalid encounter: {source}")]
131    InvalidEncounter {
132        operation: IntentConfigOperation,
133        #[source]
134        source: EncounterValidationError,
135    },
136}
137}
138
139impl IntentConfigError {
140    fn toml_deserialize(operation: IntentConfigOperation, source: toml::de::Error) -> Self {
141        Self {
142            kind: IntentConfigErrorKind::TomlDeserialize { operation, source },
143        }
144    }
145
146    fn toml_serialize(operation: IntentConfigOperation, source: toml::ser::Error) -> Self {
147        Self {
148            kind: IntentConfigErrorKind::TomlSerialize { operation, source },
149        }
150    }
151
152    const fn unknown_spec(spec_id: u32) -> Self {
153        Self {
154            kind: IntentConfigErrorKind::UnknownSpec { spec_id },
155        }
156    }
157
158    fn unsupported_setting_number(setting: String, number: serde_json::Number) -> Self {
159        Self {
160            kind: IntentConfigErrorKind::UnsupportedSettingNumber { setting, number },
161        }
162    }
163
164    fn null_setting(setting: String) -> Self {
165        Self {
166            kind: IntentConfigErrorKind::NullSetting { setting },
167        }
168    }
169
170    fn unsupported_version(operation: IntentConfigOperation, found: String) -> Self {
171        Self {
172            kind: IntentConfigErrorKind::UnsupportedIntentVersion {
173                operation,
174                found,
175                supported: INTENT_VERSION,
176            },
177        }
178    }
179
180    const fn obsolete_encounter_setting(
181        operation: IntentConfigOperation,
182        key: &'static str,
183    ) -> Self {
184        Self {
185            kind: IntentConfigErrorKind::ObsoleteEncounterSetting { operation, key },
186        }
187    }
188
189    const fn invalid_player_level(operation: IntentConfigOperation, player_level: u16) -> Self {
190        Self {
191            kind: IntentConfigErrorKind::InvalidPlayerLevel {
192                operation,
193                player_level,
194            },
195        }
196    }
197
198    const fn invalid_player_expansion_id(
199        operation: IntentConfigOperation,
200        player_expansion_id: u32,
201    ) -> Self {
202        Self {
203            kind: IntentConfigErrorKind::InvalidPlayerExpansionId {
204                operation,
205                player_expansion_id,
206            },
207        }
208    }
209
210    fn invalid_encounter(
211        operation: IntentConfigOperation,
212        source: EncounterValidationError,
213    ) -> Self {
214        Self {
215            kind: IntentConfigErrorKind::InvalidEncounter { operation, source },
216        }
217    }
218
219    /// Return the operation that failed.
220    #[must_use]
221    pub const fn operation(&self) -> IntentConfigOperation {
222        match self.kind {
223            IntentConfigErrorKind::TomlDeserialize { operation, .. }
224            | IntentConfigErrorKind::TomlSerialize { operation, .. }
225            | IntentConfigErrorKind::UnsupportedIntentVersion { operation, .. }
226            | IntentConfigErrorKind::ObsoleteEncounterSetting { operation, .. }
227            | IntentConfigErrorKind::InvalidPlayerLevel { operation, .. }
228            | IntentConfigErrorKind::InvalidPlayerExpansionId { operation, .. }
229            | IntentConfigErrorKind::InvalidEncounter { operation, .. } => operation,
230            IntentConfigErrorKind::UnknownSpec { .. }
231            | IntentConfigErrorKind::UnsupportedSettingNumber { .. }
232            | IntentConfigErrorKind::NullSetting { .. } => IntentConfigOperation::Build,
233        }
234    }
235
236    /// Return the unknown `WoW` specialization ID, when applicable.
237    #[must_use]
238    pub const fn unknown_spec_id(&self) -> Option<u32> {
239        match self.kind {
240            IntentConfigErrorKind::UnknownSpec { spec_id } => Some(spec_id),
241            _ => None,
242        }
243    }
244
245    /// Return the top-level setting key whose JSON value cannot be represented in TOML.
246    #[must_use]
247    pub fn setting_key(&self) -> Option<&str> {
248        match &self.kind {
249            IntentConfigErrorKind::UnsupportedSettingNumber { setting, .. }
250            | IntentConfigErrorKind::NullSetting { setting } => Some(setting),
251            _ => None,
252        }
253    }
254
255    /// Return the unsupported version string, when applicable.
256    #[must_use]
257    pub fn unsupported_version_value(&self) -> Option<&str> {
258        match &self.kind {
259            IntentConfigErrorKind::UnsupportedIntentVersion { found, .. } => Some(found),
260            _ => None,
261        }
262    }
263
264    /// Return the invalid player level, when applicable.
265    #[must_use]
266    pub const fn invalid_player_level_value(&self) -> Option<u16> {
267        match self.kind {
268            IntentConfigErrorKind::InvalidPlayerLevel { player_level, .. } => Some(player_level),
269            _ => None,
270        }
271    }
272
273    /// Return the invalid expansion identifier, when applicable.
274    #[must_use]
275    pub const fn invalid_player_expansion_id_value(&self) -> Option<u32> {
276        match self.kind {
277            IntentConfigErrorKind::InvalidPlayerExpansionId {
278                player_expansion_id,
279                ..
280            } => Some(player_expansion_id),
281            _ => None,
282        }
283    }
284}
285
286impl SimConfigIntent {
287    /// Build the canonical single-target (Patchwerk) intent.
288    ///
289    /// # Panics
290    ///
291    /// Panics if the compile-time default encounter context is invalid.
292    #[must_use]
293    pub fn patchwerk(spec: SpecId, duration_s: f64, rotation_id: impl Into<String>) -> Self {
294        Self {
295            intent_version: INTENT_VERSION.to_string(),
296            spec: spec.wow_spec_id(),
297            rotation_id: rotation_id.into(),
298            loadout: None,
299            expansion_talents: BTreeMap::new(),
300            player_level: MAX_PLAYER_LEVEL,
301            player_expansion_id: CURRENT_EXPANSION_ID,
302            encounter: EncounterDefinition::patchwerk(
303                duration_s,
304                MAX_PLAYER_LEVEL,
305                CURRENT_EXPANSION_ID,
306            )
307            .expect("default Patchwerk context is valid"),
308            settings: BTreeMap::new(),
309            gear: Vec::new(),
310        }
311    }
312}
313
314/// Build a TOML sim config from an [`IntentInput`] (`spec_id` is the `WoW` API spec ID).
315///
316/// # Errors
317///
318/// Returns an error when the spec, settings, encounter, or TOML serialization is invalid.
319pub fn build_sim_config(input: IntentInput) -> Result<String, IntentConfigError> {
320    let spec = SpecId::from_wow_spec_id(input.spec_id)
321        .ok_or_else(|| IntentConfigError::unknown_spec(input.spec_id))?;
322    let spec_id = spec.wow_spec_id();
323
324    let mut settings = BTreeMap::new();
325
326    for (k, v) in input.settings {
327        let tv = json_to_toml(v, &k)?;
328
329        settings.insert(k, tv);
330    }
331
332    let intent = SimConfigIntent {
333        intent_version: INTENT_VERSION.to_string(),
334        spec: spec_id,
335        rotation_id: input.rotation_id,
336        loadout: input.loadout,
337        expansion_talents: input.expansion_talents,
338        player_level: input.player_level,
339        player_expansion_id: input.player_expansion_id,
340        encounter: input.encounter,
341        settings,
342        gear: input.equipment,
343    };
344
345    validate_intent(&intent, IntentConfigOperation::Build)?;
346
347    toml::to_string_pretty(&intent)
348        .map_err(|source| IntentConfigError::toml_serialize(IntentConfigOperation::Build, source))
349}
350
351/// Parse and validate the sole current sim-intent contract.
352///
353/// # Errors
354///
355/// Returns [`IntentConfigError`] when TOML decoding, version checks, or intent validation fails.
356pub fn parse_sim_config(toml_str: &str) -> Result<SimConfigIntent, IntentConfigError> {
357    let value: toml::Value = toml::from_str(toml_str).map_err(|source| {
358        IntentConfigError::toml_deserialize(IntentConfigOperation::Parse, source)
359    })?;
360    let found = value
361        .get("intent_version")
362        .and_then(toml::Value::as_str)
363        .unwrap_or_default();
364
365    if found != INTENT_VERSION {
366        return Err(IntentConfigError::unsupported_version(
367            IntentConfigOperation::Parse,
368            found.to_string(),
369        ));
370    }
371
372    let intent: SimConfigIntent = toml::from_str(toml_str).map_err(|source| {
373        IntentConfigError::toml_deserialize(IntentConfigOperation::Parse, source)
374    })?;
375
376    validate_intent(&intent, IntentConfigOperation::Parse)?;
377
378    Ok(intent)
379}
380
381fn validate_intent(
382    intent: &SimConfigIntent,
383    operation: IntentConfigOperation,
384) -> Result<(), IntentConfigError> {
385    if intent.intent_version != INTENT_VERSION {
386        return Err(IntentConfigError::unsupported_version(
387            operation,
388            intent.intent_version.clone(),
389        ));
390    }
391
392    if intent.player_level == 0 {
393        return Err(IntentConfigError::invalid_player_level(
394            operation,
395            intent.player_level,
396        ));
397    }
398
399    if intent.player_expansion_id == 0 {
400        return Err(IntentConfigError::invalid_player_expansion_id(
401            operation,
402            intent.player_expansion_id,
403        ));
404    }
405
406    for key in OBSOLETE_ENCOUNTER_SETTINGS {
407        if intent.settings.contains_key(key) {
408            return Err(IntentConfigError::obsolete_encounter_setting(
409                operation, key,
410            ));
411        }
412    }
413
414    intent
415        .encounter
416        .validate()
417        .map_err(|source| IntentConfigError::invalid_encounter(operation, source))?;
418
419    Ok(())
420}
421
422/// Serialize a [`SimConfigIntent`] back to canonical pretty-printed TOML.
423///
424/// # Errors
425///
426/// Returns an error when the intent is invalid or TOML serialization fails.
427pub fn serialize_sim_config(intent: &SimConfigIntent) -> Result<String, IntentConfigError> {
428    validate_intent(intent, IntentConfigOperation::Serialize)?;
429
430    toml::to_string_pretty(intent).map_err(|source| {
431        IntentConfigError::toml_serialize(IntentConfigOperation::Serialize, source)
432    })
433}
434
435// #t(fn: rust_recursive_fn) bounded tree mapping over JSON value - depth limited by input
436fn json_to_toml(v: serde_json::Value, setting: &str) -> Result<toml::Value, IntentConfigError> {
437    match v {
438        serde_json::Value::Bool(b) => Ok(toml::Value::Boolean(b)),
439        serde_json::Value::Number(n) => {
440            if let Some(i) = n.as_i64() {
441                Ok(toml::Value::Integer(i))
442            } else if let Some(f) = n.as_f64() {
443                Ok(toml::Value::Float(f))
444            } else {
445                Err(IntentConfigError::unsupported_setting_number(
446                    setting.to_string(),
447                    n,
448                ))
449            }
450        }
451        serde_json::Value::String(s) => Ok(toml::Value::String(s)),
452        serde_json::Value::Array(arr) => {
453            let values = arr
454                .into_iter()
455                .map(|value| json_to_toml(value, setting))
456                .collect::<Result<Vec<_>, _>>()?;
457
458            Ok(toml::Value::Array(values))
459        }
460        serde_json::Value::Object(map) => {
461            let mut table = toml::map::Map::new();
462            let mut sorted = BTreeMap::new();
463
464            for (k, val) in map {
465                sorted.insert(k, val);
466            }
467
468            for (k, val) in sorted {
469                table.insert(k, json_to_toml(val, setting)?);
470            }
471
472            Ok(toml::Value::Table(table))
473        }
474        serde_json::Value::Null => Err(IntentConfigError::null_setting(setting.to_string())),
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use std::error::Error as _;
481
482    use googletest::prelude::*;
483    use rstest::rstest;
484
485    use super::*;
486
487    fn patchwerk_input(spec_id: u32, rotation_id: &str) -> IntentInput {
488        IntentInput {
489            spec_id,
490            rotation_id: rotation_id.to_string(),
491            loadout: None,
492            expansion_talents: BTreeMap::new(),
493            settings: BTreeMap::new(),
494            equipment: Vec::new(),
495            player_level: MAX_PLAYER_LEVEL,
496            player_expansion_id: CURRENT_EXPANSION_ID,
497            encounter: EncounterDefinition::patchwerk(
498                300.0,
499                MAX_PLAYER_LEVEL,
500                CURRENT_EXPANSION_ID,
501            )
502            .expect("current Patchwerk input is valid"),
503        }
504    }
505
506    #[gtest]
507    fn test_build_sim_config_beast_mastery() -> Result<()> {
508        let input = IntentInput {
509            equipment: vec![GearEntry {
510                slot: GearSlot::Head,
511                id: 12345,
512                bonus_ids: Some(vec![1, 2, 3]),
513                enchant_id: None,
514                gem_ids: None,
515                crafted_stats: None,
516                crafting_quality: None,
517                drop_level: None,
518                ilevel: None,
519            }],
520            ..patchwerk_input(253, "rot-123")
521        };
522
523        let toml_str = build_sim_config(input).or_fail()?;
524
525        verify_that!(toml_str, contains_substring("intent_version = \"v2\""))?;
526        verify_that!(toml_str, contains_substring("spec = 253"))?;
527        verify_that!(toml_str, contains_substring("rotation_id = \"rot-123\""))?;
528        verify_that!(toml_str, contains_substring("id = 12345"))?;
529        verify_that!(toml_str, contains_substring("fixed_duration_s = 300.0"))?;
530        verify_that!(
531            toml_str,
532            contains_substring("display_name = \"Training Dummy\"")
533        )?;
534
535        Ok(())
536    }
537
538    #[gtest]
539    fn test_build_sim_config_unknown_spec() -> Result<()> {
540        let input = patchwerk_input(9999, "rot-123");
541
542        let error = build_sim_config(input).err().or_fail()?;
543
544        verify_that!(error.to_string(), eq("unknown wow spec id: 9999"))?;
545        verify_that!(error.operation(), eq(IntentConfigOperation::Build))?;
546        verify_that!(error.unknown_spec_id(), some(eq(9999)))?;
547
548        Ok(())
549    }
550
551    #[gtest]
552    fn test_build_sim_config_accepts_all_valid_specs() -> Result<()> {
553        let input = patchwerk_input(65, "rot-123");
554
555        let toml_str = build_sim_config(input).or_fail()?;
556
557        verify_that!(toml_str, contains_substring("spec = 65"))?;
558
559        Ok(())
560    }
561
562    #[gtest]
563    fn test_build_sim_config_uses_explicit_current_encounter() -> Result<()> {
564        let mut settings = BTreeMap::new();
565
566        settings.insert("bloodlust".into(), serde_json::Value::Bool(true));
567
568        let input = IntentInput {
569            settings,
570            player_level: 70,
571            player_expansion_id: 10,
572            encounter: EncounterDefinition::patchwerk(120.0, 70, 10).or_fail()?,
573            ..patchwerk_input(254, "rot-456")
574        };
575
576        let toml_str = build_sim_config(input).or_fail()?;
577
578        verify_that!(toml_str, contains_substring("spec = 254"))?;
579        verify_that!(toml_str, contains_substring("bloodlust = true"))?;
580        let parsed = parse_sim_config(&toml_str).or_fail()?;
581
582        verify_that!(parsed.encounter.fixed_duration_s, some(near(120.0, 1e-9)))?;
583        verify_that!(parsed.encounter.enemies[0].level, eq(70))?;
584        verify_that!(parsed.player_level, eq(70))?;
585        verify_that!(parsed.player_expansion_id, eq(10))?;
586        verify_that!(parsed.settings.len(), eq(1))?;
587
588        Ok(())
589    }
590
591    #[gtest]
592    fn test_parse_sim_config_roundtrip() -> Result<()> {
593        let input = patchwerk_input(253, "rot-1");
594        let toml_str = build_sim_config(input).or_fail()?;
595        let parsed = parse_sim_config(&toml_str).or_fail()?;
596
597        verify_that!(
598            parsed,
599            matches_pattern!(SimConfigIntent {
600                intent_version: "v2",
601                spec: eq(&253),
602                rotation_id: "rot-1",
603                ..
604            })
605        )?;
606
607        Ok(())
608    }
609
610    #[gtest]
611    fn test_build_sim_config_passes_loadout() -> Result<()> {
612        let input = IntentInput {
613            loadout: Some("ABCDEF".into()),
614            ..patchwerk_input(260, "rot-1")
615        };
616        let toml_str = build_sim_config(input).or_fail()?;
617
618        verify_that!(toml_str, contains_substring("loadout = \"ABCDEF\""))?;
619        let parsed = parse_sim_config(&toml_str).or_fail()?;
620
621        verify_that!(parsed.loadout.as_deref(), some(eq("ABCDEF")))?;
622
623        Ok(())
624    }
625
626    #[gtest]
627    fn test_json_to_toml_null_rejected() -> Result<()> {
628        let error = json_to_toml(serde_json::Value::Null, "optional_feature")
629            .err()
630            .or_fail()?;
631
632        verify_that!(
633            error.to_string(),
634            eq("null values are not supported in TOML")
635        )?;
636        verify_that!(error.operation(), eq(IntentConfigOperation::Build))?;
637        verify_that!(error.setting_key(), some(eq("optional_feature")))?;
638
639        Ok(())
640    }
641
642    // The `"unsupported number"` Err arm is dead-in-practice: any JSON number that
643    // fails `as_i64` still yields `as_f64 = Some(_)` (even `u64::MAX`), so the Float
644    // branch always wins. No test targets it because it is unreachable.
645    #[gtest]
646    #[rstest]
647    #[case::boolean(serde_json::Value::Bool(true), toml::Value::Boolean(true))]
648    #[case::integer(serde_json::json!(42), toml::Value::Integer(42))]
649    #[case::string(serde_json::Value::String("x".to_string()), toml::Value::String("x".to_string()))]
650    #[case::array(
651        serde_json::json!([1, 2]),
652        toml::Value::Array(vec![toml::Value::Integer(1), toml::Value::Integer(2)])
653    )]
654    fn json_to_toml_arms(
655        #[case] input: serde_json::Value,
656        #[case] expected: toml::Value,
657    ) -> Result<()> {
658        verify_that!(json_to_toml(input, "fixture"), ok(eq(&expected)))
659    }
660
661    #[gtest]
662    fn json_to_toml_float() -> Result<()> {
663        let value = json_to_toml(serde_json::json!(1.5), "fixture").or_fail()?;
664
665        verify_that!(value.as_float(), some(near(1.5, 1e-9)))?;
666
667        Ok(())
668    }
669
670    #[gtest]
671    fn json_to_toml_object_sorts_keys() -> Result<()> {
672        let value = json_to_toml(serde_json::json!({ "b": 1, "a": 2 }), "fixture").or_fail()?;
673        let keys: Vec<&str> = value
674            .as_table()
675            .or_fail()?
676            .keys()
677            .map(String::as_str)
678            .collect();
679
680        verify_that!(keys, container_eq(["a", "b"]))?;
681
682        Ok(())
683    }
684
685    #[gtest]
686    fn patchwerk_known_answer() -> Result<()> {
687        let intent = SimConfigIntent::patchwerk(SpecId::BeastMastery, 240.0, "rot-x");
688
689        verify_that!(
690            intent,
691            matches_pattern!(SimConfigIntent {
692                intent_version: "v2",
693                rotation_id: "rot-x",
694                spec: eq(&SpecId::BeastMastery.wow_spec_id()),
695                loadout: none(),
696                gear: is_empty(),
697                settings: is_empty(),
698                player_level: eq(&MAX_PLAYER_LEVEL),
699                player_expansion_id: eq(&CURRENT_EXPANSION_ID),
700                ..
701            })
702        )?;
703        verify_that!(intent.encounter.fixed_duration_s, some(near(240.0, 1e-9)))?;
704
705        Ok(())
706    }
707
708    #[gtest]
709    fn serialize_sim_config_roundtrip() -> Result<()> {
710        let intent = SimConfigIntent::patchwerk(SpecId::BeastMastery, 300.0, "rot-y");
711        let serialized = serialize_sim_config(&intent);
712
713        verify_that!(&serialized, ok(anything()))?;
714
715        let serialized = serialized.or_fail()?;
716        let parsed = parse_sim_config(&serialized).or_fail()?;
717
718        verify_that!(
719            parsed,
720            matches_pattern!(SimConfigIntent {
721                intent_version: "v2",
722                rotation_id: "rot-y",
723                spec: eq(&SpecId::BeastMastery.wow_spec_id()),
724                ..
725            })
726        )?;
727
728        Ok(())
729    }
730
731    #[gtest]
732    fn obsolete_v1_intent_is_rejected_without_migration() -> Result<()> {
733        let source = r#"
734intent_version = "v1"
735spec = 253
736rotation_id = "obsolete"
737gear = []
738"#;
739        let error = parse_sim_config(source).err().or_fail()?;
740
741        verify_that!(error.unsupported_version_value(), some(eq("v1")))
742    }
743
744    #[gtest]
745    fn malformed_toml_preserves_parse_operation_and_source() -> Result<()> {
746        let error = parse_sim_config("[").err().or_fail()?;
747
748        verify_that!(error.operation(), eq(IntentConfigOperation::Parse))?;
749        verify_that!(error.to_string(), starts_with("TOML parse error:"))?;
750
751        verify_true!(
752            error
753                .source()
754                .and_then(std::error::Error::source)
755                .is_some_and(<dyn std::error::Error>::is::<toml::de::Error>)
756        )
757    }
758
759    #[gtest]
760    fn empty_toml_is_an_unsupported_version_without_a_source() -> Result<()> {
761        let error = parse_sim_config("").err().or_fail()?;
762
763        verify_that!(error.operation(), eq(IntentConfigOperation::Parse))?;
764        verify_that!(error.unsupported_version_value(), some(eq("")))?;
765
766        let kind = error.source().or_fail()?;
767
768        verify_that!(kind.source(), none())
769    }
770
771    #[gtest]
772    fn unicode_values_roundtrip_unchanged() -> Result<()> {
773        let mut intent = SimConfigIntent::patchwerk(SpecId::BeastMastery, 300.0, "旋转-🦊");
774
775        intent.settings.insert(
776            "greeting".to_string(),
777            toml::Value::String("Grüße 世界".to_string()),
778        );
779
780        let serialized = serialize_sim_config(&intent).or_fail()?;
781        let parsed = parse_sim_config(&serialized).or_fail()?;
782
783        verify_that!(parsed.rotation_id, eq("旋转-🦊"))?;
784
785        verify_that!(
786            parsed
787                .settings
788                .get("greeting")
789                .and_then(toml::Value::as_str),
790            some(eq("Grüße 世界"))
791        )
792    }
793
794    #[gtest]
795    fn unknown_intent_version_is_typed_error() -> Result<()> {
796        let source = r#"intent_version = "v9""#;
797        let error = parse_sim_config(source).err().or_fail()?;
798
799        verify_that!(error.unsupported_version_value(), some(eq("v9")))
800    }
801
802    #[gtest]
803    fn current_intent_rejects_zero_player_game_data_context() -> Result<()> {
804        let mut intent = SimConfigIntent::patchwerk(SpecId::BeastMastery, 300.0, "rotation");
805
806        intent.player_level = 0;
807        let serialized = toml::to_string_pretty(&intent).or_fail()?;
808        let error = parse_sim_config(&serialized).err().or_fail()?;
809
810        verify_that!(error.invalid_player_level_value(), some(eq(0)))?;
811
812        intent.player_level = 80;
813        intent.player_expansion_id = 0;
814        let serialized = toml::to_string_pretty(&intent).or_fail()?;
815        let error = parse_sim_config(&serialized).err().or_fail()?;
816
817        verify_that!(error.invalid_player_expansion_id_value(), some(eq(0)))
818    }
819
820    #[gtest]
821    fn current_intent_rejects_obsolete_aggregate_settings() -> Result<()> {
822        let mut intent = SimConfigIntent::patchwerk(SpecId::BeastMastery, 300.0, "rotation");
823
824        intent.player_level = 90;
825        intent.player_expansion_id = 11;
826        intent
827            .settings
828            .insert("level".to_string(), toml::Value::Integer(70));
829        intent
830            .settings
831            .insert("expansion_id".to_string(), toml::Value::Integer(10));
832
833        verify_that!(
834            serialize_sim_config(&intent),
835            err(displays_as(contains_substring(
836                "obsolete aggregate encounter setting \"level\""
837            )))
838        )
839    }
840
841    #[gtest]
842    fn build_sim_config_nested_settings() -> Result<()> {
843        let mut settings = BTreeMap::new();
844
845        settings.insert("tags".to_string(), serde_json::json!(["a", "b"]));
846        settings.insert("nested".to_string(), serde_json::json!({ "x": 1, "y": 2 }));
847
848        let input = IntentInput {
849            settings,
850            ..patchwerk_input(253, "rot-nested")
851        };
852
853        let toml_str = build_sim_config(input).or_fail()?;
854
855        verify_that!(
856            toml_str,
857            contains_substring("tags = [\n    \"a\",\n    \"b\",\n]")
858        )?;
859        verify_that!(toml_str, contains_substring("[settings.nested]"))?;
860        verify_that!(toml_str, contains_substring("x = 1"))?;
861        verify_that!(toml_str, contains_substring("y = 2"))?;
862
863        Ok(())
864    }
865
866    #[gtest]
867    fn test_all_specs_have_snake_case() -> Result<()> {
868        use strum::IntoEnumIterator;
869
870        for spec in SpecId::iter() {
871            let snake = spec.toml_key().to_string();
872
873            verify_that!(snake.as_bytes(), not(is_empty()))?;
874            verify_that!(
875                snake.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
876                eq(true)
877            )?;
878        }
879
880        Ok(())
881    }
882
883    #[gtest]
884    fn test_settings_serialization_is_deterministic() -> Result<()> {
885        let encounter = serde_json::to_value(
886            EncounterDefinition::patchwerk(300.0, MAX_PLAYER_LEVEL, CURRENT_EXPANSION_ID)
887                .or_fail()?,
888        )
889        .or_fail()?;
890        let input_a: IntentInput = serde_json::from_value(serde_json::json!({
891            "spec_id": 253,
892            "rotation_id": "rot-1",
893            "player_level": MAX_PLAYER_LEVEL,
894            "player_expansion_id": CURRENT_EXPANSION_ID,
895            "encounter": encounter,
896            "settings": {
897                "zeta": true,
898                "alpha": 1
899            },
900            "equipment": []
901        }))
902        .or_fail()?;
903
904        let input_b: IntentInput = serde_json::from_value(serde_json::json!({
905            "spec_id": 253,
906            "rotation_id": "rot-1",
907            "player_level": MAX_PLAYER_LEVEL,
908            "player_expansion_id": CURRENT_EXPANSION_ID,
909            "encounter": serde_json::to_value(
910                EncounterDefinition::patchwerk(
911                    300.0,
912                    MAX_PLAYER_LEVEL,
913                    CURRENT_EXPANSION_ID,
914                )
915                .or_fail()?,
916            )
917            .or_fail()?,
918            "settings": {
919                "alpha": 1,
920                "zeta": true
921            },
922            "equipment": []
923        }))
924        .or_fail()?;
925
926        let a = build_sim_config(input_a).or_fail()?;
927        let b = build_sim_config(input_b).or_fail()?;
928
929        verify_that!(a, eq(&b))?;
930
931        Ok(())
932    }
933}