forge/talent_conformance/
golden.rs1use 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
16const 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}