Skip to main content

wowlab_engine_domain/rotation/validate/
schema.rs

1//! Variable-path schema generated from the descriptor table.
2
3use serde::{Deserialize, Serialize};
4#[cfg(feature = "wasm")]
5use tsify::Tsify;
6use wowlab_types::sim::FastMap;
7
8use super::super::{
9    buffer::{DescriptorTable, SlotKind},
10    condition::{KeyCategory, domain},
11    expr::FieldType,
12};
13
14/// A grouping of variable paths within one descriptor domain.
15#[derive(Clone, Debug, Deserialize, Serialize)]
16#[serde(rename_all = "camelCase")]
17#[cfg_attr(feature = "wasm", derive(Tsify))]
18#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
19pub struct VarPathCategory {
20    pub name: String,
21    pub description: String,
22    pub paths: Vec<VarPathInfo>,
23}
24
25/// Metadata about a single variable path.
26#[derive(Clone, Debug, Deserialize, Serialize)]
27#[serde(rename_all = "camelCase")]
28#[cfg_attr(feature = "wasm", derive(Tsify))]
29#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
30pub struct VarPathInfo {
31    pub name: String,
32    pub description: String,
33    pub value_type: String,
34    pub has_arg: bool,
35    pub arg_name: Option<String>,
36    pub example: String,
37}
38
39/// Variable path schema generated from the descriptor table.
40// #t(fn: rust_alloc_in_loop, rust_clone_in_loop, rust_unchecked_indexing) building schema output; allocations needed for owned strings, idx bounds guaranteed by category_map
41#[must_use]
42pub fn get_var_path_schema() -> Vec<VarPathCategory> {
43    let table = DescriptorTable::build();
44    let mut categories: Vec<VarPathCategory> = Vec::with_capacity(table.len());
45    let mut category_map: FastMap<String, usize> = FastMap::default();
46
47    for (_, desc) in &table {
48        let domain = desc.domain.to_string();
49        let idx = if let Some(&idx) = category_map.get(&domain) {
50            idx
51        } else {
52            let idx = categories.len();
53
54            category_map.insert(domain.clone(), idx);
55            categories.push(VarPathCategory {
56                name: capitalize(&domain),
57                description: format!("{} fields", capitalize(&domain)),
58                paths: Vec::new(),
59            });
60
61            idx
62        };
63
64        let has_arg = matches!(desc.slot_kind, SlotKind::Keyed);
65        let arg_name = has_arg.then(|| domain_arg_name(&domain));
66        let value_type = match desc
67            .eval_kind
68            .result_field_type()
69            .unwrap_or(desc.field_type)
70        {
71            FieldType::Bool => "bool",
72            FieldType::Int => "int",
73            _ => "float",
74        };
75
76        let example = if has_arg {
77            format!("{}.{}.{}", domain, "<key>", desc.name)
78        } else {
79            format!("{}.{}", domain, desc.name)
80        };
81
82        categories[idx].paths.push(VarPathInfo {
83            name: desc.name.to_string(),
84            description: desc.description.to_string(),
85            value_type: value_type.to_string(),
86            has_arg,
87            arg_name,
88            example,
89        });
90    }
91
92    categories
93}
94
95fn capitalize(s: &str) -> String {
96    let mut chars = s.chars();
97
98    match chars.next() {
99        None => String::new(),
100        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
101    }
102}
103
104fn domain_arg_name(dom: &str) -> String {
105    match KeyCategory::for_domain(dom) {
106        Some(KeyCategory::Spell) => domain::SPELL.to_string(),
107        Some(KeyCategory::Aura) => domain::AURA.to_string(),
108        Some(KeyCategory::Resource) => domain::RESOURCE.to_string(),
109        Some(KeyCategory::Named) if dom == domain::TALENT || dom == domain::HERO_TREE => {
110            "name".to_string()
111        }
112        _ => "key".to_string(),
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use googletest::prelude::*;
119
120    use super::*;
121
122    #[gtest]
123    fn schema_is_non_empty() -> Result<()> {
124        verify_that!(get_var_path_schema().is_empty(), eq(false))?;
125
126        Ok(())
127    }
128
129    #[gtest]
130    fn every_category_is_capitalized_and_described() -> Result<()> {
131        for category in get_var_path_schema() {
132            let first = category.name.chars().next();
133
134            verify_that!(first.is_some(), eq(true))?;
135            verify_that!(first.is_some_and(char::is_uppercase), eq(true))?;
136            verify_that!(
137                category.description,
138                eq(&format!("{} fields", category.name))
139            )?;
140        }
141
142        Ok(())
143    }
144
145    #[gtest]
146    fn every_path_has_consistent_arg_example_and_value_type() -> Result<()> {
147        for category in get_var_path_schema() {
148            let domain_lower = category.name.to_lowercase();
149
150            for path in &category.paths {
151                verify_that!(path.has_arg, eq(path.arg_name.is_some()))?;
152
153                if path.has_arg {
154                    verify_that!(path.example.contains(".<key>."), eq(true))?;
155                } else {
156                    verify_that!(path.example, eq(&format!("{domain_lower}.{}", path.name)))?;
157                }
158
159                verify_that!(
160                    ["bool", "int", "float"].contains(&path.value_type.as_str()),
161                    eq(true)
162                )?;
163            }
164        }
165
166        Ok(())
167    }
168
169    #[gtest]
170    fn talent_domain_uses_name_arg() -> Result<()> {
171        let uses_name_arg = get_var_path_schema()
172            .iter()
173            .filter(|c| {
174                c.name.eq_ignore_ascii_case(domain::TALENT)
175                    || c.name.eq_ignore_ascii_case(domain::HERO_TREE)
176            })
177            .flat_map(|c| &c.paths)
178            .any(|p| p.arg_name.as_deref() == Some("name"));
179
180        verify_that!(uses_name_arg, eq(true))?;
181
182        Ok(())
183    }
184
185    #[gtest]
186    fn spell_keyed_domain_uses_spell_arg() -> Result<()> {
187        let uses_spell_arg = get_var_path_schema()
188            .iter()
189            .flat_map(|c| &c.paths)
190            .any(|p| p.arg_name.as_deref() == Some(domain::SPELL));
191
192        verify_that!(uses_spell_arg, eq(true))?;
193
194        Ok(())
195    }
196
197    #[gtest]
198    fn cooldown_category_schema_snapshot() {
199        let mut cooldown = get_var_path_schema()
200            .into_iter()
201            .find(|c| c.name.eq_ignore_ascii_case(domain::COOLDOWN))
202            .expect("cooldown category must be present in the var-path schema");
203
204        cooldown.paths.sort_by(|a, b| a.name.cmp(&b.name));
205        insta::assert_debug_snapshot!(cooldown);
206    }
207}