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 path::{Path, PathBuf},
10};
11use wowlab_types::{data::TraitTreeFlat, game::SpecId};
12
13use super::{AllSpecGoldens, TalentRecord, TalentSnapshot};
14
15const 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}