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