Skip to main content

forge/talent_conformance/
golden.rs

1use std::collections::BTreeSet;
2
3use anyhow::{Context, Result, bail};
4#[cfg(test)]
5use googletest::{Result as GtestResult, prelude::*};
6use wowlab_common::output;
7use wowlab_fs::{
8    artifact::GeneratedTextFile,
9    file,
10    path::{Path, PathBuf},
11};
12use wowlab_types::{data::TraitTreeFlat, game::SpecId};
13
14use super::{AllSpecGoldens, TalentRecord, TalentSnapshot};
15
16/// Indentation the committed goldens are formatted with; keeps a refresh diff reviewable.
17const GOLDEN_INDENT: &[u8] = b"    ";
18
19pub(super) fn normalize_snapshot(snapshot: &mut TalentSnapshot) {
20    for record in &mut snapshot.records {
21        record.tree.make_ascii_lowercase();
22    }
23
24    for record in &mut snapshot.effective_records {
25        record.tree.make_ascii_lowercase();
26    }
27
28    snapshot.records.sort();
29    snapshot.active_subtree_ids.sort_unstable();
30    snapshot.active_subtree_ids.dedup();
31    snapshot.effective_records.sort();
32}
33
34pub(super) fn compare_snapshots(
35    spec: SpecId,
36    actual: &TalentSnapshot,
37    expected: &TalentSnapshot,
38) -> Result<()> {
39    if actual.version != expected.version
40        || actual.spec_id != expected.spec_id
41        || actual.node_count != expected.node_count
42        || actual.node_ids != expected.node_ids
43        || actual.records != expected.records
44    {
45        let raw_missing: Vec<_> = expected
46            .records
47            .iter()
48            .filter(|record| !actual.records.contains(record))
49            .collect();
50        let raw_extra: Vec<_> = actual
51            .records
52            .iter()
53            .filter(|record| !expected.records.contains(record))
54            .collect();
55        let effective_missing: Vec<_> = expected
56            .effective_records
57            .iter()
58            .filter(|record| !actual.effective_records.contains(record))
59            .collect();
60        let effective_extra: Vec<_> = actual
61            .effective_records
62            .iter()
63            .filter(|record| !expected.effective_records.contains(record))
64            .collect();
65
66        bail!(
67            "talent conformance mismatch for {}\nheaders: SimC v{}/{}/{} WoW Lab v{}/{}/{}\nfirst node-id mismatch: {:?}\nactive subtrees: SimC {:?} WoW Lab {:?}\nraw missing: {}\nraw extra: {}\neffective missing: {}\neffective extra: {}",
68            spec.slug(),
69            expected.version,
70            expected.spec_id,
71            expected.node_count,
72            actual.version,
73            actual.spec_id,
74            actual.node_count,
75            expected
76                .node_ids
77                .iter()
78                .zip(&actual.node_ids)
79                .enumerate()
80                .find(|(_, (simc, wowlab))| simc != wowlab),
81            expected.active_subtree_ids,
82            actual.active_subtree_ids,
83            serde_json::to_string_pretty(&raw_missing)?,
84            serde_json::to_string_pretty(&raw_extra)?,
85            serde_json::to_string_pretty(&effective_missing)?,
86            serde_json::to_string_pretty(&effective_extra)?,
87        );
88    }
89
90    Ok(())
91}
92
93pub(super) fn compact_tree(mut tree: TraitTreeFlat, records: &[TalentRecord]) -> TraitTreeFlat {
94    let selected: BTreeSet<i32> = records.iter().map(|record| record.node_id).collect();
95
96    tree.nodes.retain(|node| selected.contains(&node.id));
97    tree.edges.clear();
98
99    for node in &mut tree.nodes {
100        node.pos_x = 0;
101        node.pos_y = 0;
102        node.order_index = 0;
103
104        for entry in &mut node.entries {
105            entry.definition_id = 0;
106            entry.description.clear();
107            entry.icon_file_name.clear();
108            entry.effect_points.clear();
109        }
110    }
111
112    for subtree in &mut tree.sub_trees {
113        subtree.description.clear();
114        subtree.icon_file_name.clear();
115    }
116
117    tree
118}
119
120fn golden_path() -> PathBuf {
121    Path::new(env!("CARGO_MANIFEST_DIR")).join("talent_goldens.json")
122}
123
124pub(super) fn write_goldens(mut updates: AllSpecGoldens) -> Result<()> {
125    let path = golden_path();
126    let source =
127        file::read_text(&path).with_context(|| format!("failed to read {}", path.display()))?;
128    let mut goldens: AllSpecGoldens = serde_json::from_str(&source)
129        .with_context(|| format!("failed to parse {}", path.display()))?;
130
131    for update in updates.specs.drain(..) {
132        let slot = goldens
133            .specs
134            .iter_mut()
135            .find(|golden| golden.slug == update.slug)
136            .context("MID2 refresh produced an unknown spec")?;
137
138        *slot = update;
139    }
140
141    goldens.version = updates.version;
142
143    let mut buffer = Vec::new();
144    let mut serializer = serde_json::Serializer::with_formatter(
145        &mut buffer,
146        serde_json::ser::PrettyFormatter::with_indent(GOLDEN_INDENT),
147    );
148
149    serde::Serialize::serialize(&goldens, &mut serializer)
150        .context("failed to serialize talent goldens")?;
151
152    let json = String::from_utf8(buffer).context("talent goldens serialized to invalid UTF-8")?;
153    let contents = format!("{json}\n");
154
155    GeneratedTextFile::new(&path, &contents)
156        .persist()
157        .with_context(|| format!("failed to write {}", path.display()))?;
158    output::success("Talent goldens refreshed");
159    output::kv_fmt("Specs", goldens.specs.len());
160
161    Ok(())
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::talent_conformance::{GOLDEN_SPECS, GOLDEN_VERSION, snapshot::wowlab_snapshot};
168
169    const COMMITTED_GOLDENS: &str = include_str!("../../talent_goldens.json");
170
171    #[gtest]
172    fn committed_goldens_are_canonical_and_cover_every_engine_spec() -> GtestResult<()> {
173        let goldens: AllSpecGoldens = serde_json::from_str(COMMITTED_GOLDENS).or_fail()?;
174
175        verify_that!(goldens.version, eq(GOLDEN_VERSION))?;
176        verify_that!(goldens.specs.len(), eq(GOLDEN_SPECS.len()))?;
177        let committed_value: serde_json::Value =
178            serde_json::from_str(COMMITTED_GOLDENS).or_fail()?;
179
180        verify_that!(
181            serde_json::to_value(&goldens).or_fail()?,
182            eq(&committed_value)
183        )?;
184
185        for (golden, spec) in goldens.specs.iter().zip(GOLDEN_SPECS) {
186            verify_that!(golden.slug, eq(spec.slug()))?;
187            let actual = wowlab_snapshot(&golden.loadout, &golden.tree).or_fail()?;
188
189            compare_snapshots(*spec, &actual, &golden.expected).or_fail()?;
190        }
191
192        Ok(())
193    }
194}