1use std::sync::Arc;
2
3use wowlab_types::data::{
4 ChallengeModeHealthFlat, ContentTuningFlat, ContentTuningXDifficultyFlat,
5 ContentTuningXExpectedFlat, CreatureDifficultyFlat, CreatureFlat, ExpansionTraitTreeFlat,
6 ExpectedStatFlat, ExpectedStatModFlat, ItemDamageScalingFlat, ItemDataFlat, ItemScalingData,
7 PermanentEnchantFlat, PowerTypeFlat, SpecDataFlat, SpellDataFlat, SpellEffect, TraitTreeFlat,
8 TraitTreeWithSelections,
9};
10
11use crate::types::SpellId;
12
13#[must_use]
15pub fn tokenize_name(name: &str) -> String {
16 let mut out = String::with_capacity(name.len());
17
18 for c in name.trim().chars() {
19 if c.is_ascii_alphanumeric() {
20 out.extend(c.to_lowercase());
21 } else if (c.is_whitespace() || c == '-' || c == '_') && !out.ends_with('_') {
22 out.push('_');
23 }
24 }
25
26 out.trim_end_matches('_').to_string()
27}
28
29#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
31pub struct SpellSearchResult {
32 #[serde(alias = "id")]
33 pub spell_id: SpellId,
34 pub name: String,
35}
36
37#[derive(Clone, Copy, Debug, Default, serde::Deserialize, serde::Serialize)]
39pub struct EnchantmentEffect {
40 pub effect_type: i32,
41 pub effect_arg: i32,
42 #[serde(default)]
43 pub points_min: Option<f64>,
44 #[serde(default)]
45 pub scaling_points: Option<f64>,
46}
47
48pub(crate) const ENCHANTMENT_EFFECTS: usize = 3;
50
51#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)]
53pub struct EnchantmentRow {
54 pub id: i32,
55 pub name: String,
56 pub duration_ms: u32,
57 pub effects: [EnchantmentEffect; ENCHANTMENT_EFFECTS],
58 pub scaling_class: i32,
59 pub item_level_min: i32,
60 pub item_level_max: i32,
61 pub max_level: i32,
63}
64
65#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct PermanentEnchantQuery {
68 pub tokenized_name: String,
69 pub rank: i32,
70 pub item_class: i32,
71 pub inventory_type: i32,
72 pub item_subclass: i32,
73}
74
75fn permanent_enchant_mask_allows(mask: i32, value: i32) -> bool {
76 value <= 0
77 || mask <= 0
78 || u32::try_from(value)
79 .ok()
80 .and_then(|shift| 1_i32.checked_shl(shift))
81 .is_some_and(|bit| mask & bit != 0)
82}
83
84#[must_use]
86pub fn select_permanent_enchant(
87 entries: &[PermanentEnchantFlat],
88 query: &PermanentEnchantQuery,
89) -> Option<PermanentEnchantFlat> {
90 let canonical_name = entries
91 .iter()
92 .find(|entry| {
93 entry.tokenized_name.starts_with("enchant_")
94 && entry
95 .tokenized_name
96 .split_once("__")
97 .is_some_and(|(_, alias)| alias == query.tokenized_name)
98 })
99 .map_or(query.tokenized_name.as_str(), |entry| {
100 entry.tokenized_name.as_str()
101 });
102
103 entries
104 .iter()
105 .find(|entry| {
106 entry.tokenized_name == canonical_name
107 && entry.rank == query.rank
108 && entry.item_class == query.item_class
109 && permanent_enchant_mask_allows(entry.inventory_type_mask, query.inventory_type)
110 && permanent_enchant_mask_allows(entry.subclass_mask, query.item_subclass)
111 })
112 .cloned()
113}
114
115type BoxedCause = Box<dyn std::error::Error + Send + Sync + 'static>;
116
117wowlab_engine_macros::define_error! {
118#[derive(Debug)]
120pub struct ResolverError {
121 #[source]
122 kind: ResolverErrorKind,
123}
124
125#[derive(Debug, thiserror::Error)]
126enum ResolverErrorKind {
127 #[error("spell {spell_id} not found")]
128 SpellNotFound { spell_id: SpellId },
129
130 #[error("spell effect not found: spell_id={spell_id}, effect_index={effect_index}")]
131 SpellEffectNotFound { spell_id: SpellId, effect_index: u8 },
132
133 #[error("item {item_id} not found")]
134 ItemNotFound { item_id: i32 },
135
136 #[error("trait tree for spec {spec_id} not found")]
137 TraitTreeNotFound { spec_id: i32 },
138
139 #[error("expansion trait tree {system:?} for expansion {expansion_id} not found")]
140 ExpansionTraitTreeNotFound { expansion_id: i32, system: String },
141
142 #[error("spec {spec_id} not found")]
143 SpecNotFound { spec_id: i32 },
144
145 #[error("rotation script {rotation_id} not found")]
146 RotationScriptNotFound { rotation_id: String },
147
148 #[error("expected stats not found for expansion {expansion_id}, lvl {lvl}")]
149 ExpectedStatsNotFound { expansion_id: i32, lvl: i32 },
150
151 #[error("creature {creature_id} not found")]
152 CreatureNotFound { creature_id: i32 },
153
154 #[error("content tuning {content_tuning_id} not found")]
155 ContentTuningNotFound { content_tuning_id: i32 },
156
157 #[error("expected stat mod {expected_stat_mod_id} not found")]
158 ExpectedStatModNotFound { expected_stat_mod_id: i32 },
159
160 #[error("challenge mode health not found for keystone level {keystone_level}")]
161 ChallengeModeHealthNotFound { keystone_level: i32 },
162
163 #[error("item_damage_scaling not found for weapon_type={weapon_type}, item_level={item_level}")]
164 ItemDamageScalingNotFound {
165 weapon_type: String,
166 item_level: i32,
167 },
168
169 #[error("enchantment {enchantment_id} not found")]
170 EnchantmentNotFound { enchantment_id: i32 },
171
172 #[error("scaling data not configured for this resolver")]
173 NoScalingData,
174
175 #[error("resolver does not support {method}")]
176 Unsupported { method: &'static str },
177
178 #[error("trait decode error: {0}")]
179 TraitDecode(#[source] wowlab_loadout::TraitError),
180
181 #[error("filesystem error: {source}")]
182 Filesystem {
183 #[source]
184 source: wowlab_fs::error::Error,
185 },
186
187 #[error("decode error in {op}")]
188 Decode {
189 op: &'static str,
190 #[source]
191 source: BoxedCause,
192 },
193
194 #[error("js bridge error in {method}: {message}")]
195 JsBridge { method: String, message: String },
196
197 #[error("backend error in {op}")]
198 Backend {
199 op: &'static str,
200 #[source]
201 source: BoxedCause,
202 },
203
204 #[error("environment variable error: {0}")]
205 EnvVar(String),
206}
207}
208
209impl ResolverError {
210 #[must_use]
211 pub fn spell_not_found(spell_id: SpellId) -> Self {
212 Self {
213 kind: ResolverErrorKind::SpellNotFound { spell_id },
214 }
215 }
216
217 #[must_use]
218 pub fn spell_effect_not_found(spell_id: SpellId, effect_index: u8) -> Self {
219 Self {
220 kind: ResolverErrorKind::SpellEffectNotFound {
221 spell_id,
222 effect_index,
223 },
224 }
225 }
226
227 #[must_use]
228 pub fn item_not_found(item_id: i32) -> Self {
229 Self {
230 kind: ResolverErrorKind::ItemNotFound { item_id },
231 }
232 }
233
234 #[must_use]
235 pub fn trait_tree_not_found(spec_id: i32) -> Self {
236 Self {
237 kind: ResolverErrorKind::TraitTreeNotFound { spec_id },
238 }
239 }
240
241 pub fn expansion_trait_tree_not_found(expansion_id: i32, system: impl Into<String>) -> Self {
242 Self {
243 kind: ResolverErrorKind::ExpansionTraitTreeNotFound {
244 expansion_id,
245 system: system.into(),
246 },
247 }
248 }
249
250 #[must_use]
251 pub fn spec_not_found(spec_id: i32) -> Self {
252 Self {
253 kind: ResolverErrorKind::SpecNotFound { spec_id },
254 }
255 }
256
257 pub fn rotation_script_not_found(rotation_id: impl Into<String>) -> Self {
258 Self {
259 kind: ResolverErrorKind::RotationScriptNotFound {
260 rotation_id: rotation_id.into(),
261 },
262 }
263 }
264
265 #[must_use]
266 pub fn expected_stats_not_found(expansion_id: i32, lvl: i32) -> Self {
267 Self {
268 kind: ResolverErrorKind::ExpectedStatsNotFound { expansion_id, lvl },
269 }
270 }
271
272 #[must_use]
273 pub fn creature_not_found(creature_id: i32) -> Self {
274 Self {
275 kind: ResolverErrorKind::CreatureNotFound { creature_id },
276 }
277 }
278
279 #[must_use]
280 pub fn content_tuning_not_found(content_tuning_id: i32) -> Self {
281 Self {
282 kind: ResolverErrorKind::ContentTuningNotFound { content_tuning_id },
283 }
284 }
285
286 #[must_use]
287 pub fn expected_stat_mod_not_found(expected_stat_mod_id: i32) -> Self {
288 Self {
289 kind: ResolverErrorKind::ExpectedStatModNotFound {
290 expected_stat_mod_id,
291 },
292 }
293 }
294
295 #[must_use]
296 pub fn challenge_mode_health_not_found(keystone_level: i32) -> Self {
297 Self {
298 kind: ResolverErrorKind::ChallengeModeHealthNotFound { keystone_level },
299 }
300 }
301
302 pub fn item_damage_scaling_not_found(weapon_type: impl Into<String>, item_level: i32) -> Self {
303 Self {
304 kind: ResolverErrorKind::ItemDamageScalingNotFound {
305 weapon_type: weapon_type.into(),
306 item_level,
307 },
308 }
309 }
310
311 #[must_use]
312 pub fn enchantment_not_found(enchantment_id: i32) -> Self {
313 Self {
314 kind: ResolverErrorKind::EnchantmentNotFound { enchantment_id },
315 }
316 }
317
318 #[must_use]
319 pub fn no_scaling_data() -> Self {
320 Self {
321 kind: ResolverErrorKind::NoScalingData,
322 }
323 }
324
325 #[must_use]
326 pub fn unsupported(method: &'static str) -> Self {
327 Self {
328 kind: ResolverErrorKind::Unsupported { method },
329 }
330 }
331
332 #[must_use]
333 pub fn filesystem(source: wowlab_fs::error::Error) -> Self {
334 Self {
335 kind: ResolverErrorKind::Filesystem { source },
336 }
337 }
338
339 pub fn decode(
340 op: &'static str,
341 source: impl Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
342 ) -> Self {
343 Self {
344 kind: ResolverErrorKind::Decode {
345 op,
346 source: source.into(),
347 },
348 }
349 }
350
351 pub fn js_bridge(method: impl Into<String>, message: impl Into<String>) -> Self {
352 Self {
353 kind: ResolverErrorKind::JsBridge {
354 method: method.into(),
355 message: message.into(),
356 },
357 }
358 }
359
360 pub fn backend(
361 op: &'static str,
362 source: impl Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
363 ) -> Self {
364 Self {
365 kind: ResolverErrorKind::Backend {
366 op,
367 source: source.into(),
368 },
369 }
370 }
371
372 pub fn env_var(message: impl Into<String>) -> Self {
373 Self {
374 kind: ResolverErrorKind::EnvVar(message.into()),
375 }
376 }
377
378 #[must_use]
379 pub fn is_spell_not_found(&self) -> bool {
380 matches!(self.kind, ResolverErrorKind::SpellNotFound { .. })
381 }
382
383 #[must_use]
384 pub fn is_item_not_found(&self) -> bool {
385 matches!(self.kind, ResolverErrorKind::ItemNotFound { .. })
386 }
387
388 #[must_use]
389 pub fn is_trait_tree_not_found(&self) -> bool {
390 matches!(self.kind, ResolverErrorKind::TraitTreeNotFound { .. })
391 }
392
393 #[must_use]
394 pub fn is_spec_not_found(&self) -> bool {
395 matches!(self.kind, ResolverErrorKind::SpecNotFound { .. })
396 }
397
398 #[must_use]
399 pub fn is_rotation_script_not_found(&self) -> bool {
400 matches!(self.kind, ResolverErrorKind::RotationScriptNotFound { .. })
401 }
402
403 #[must_use]
404 pub fn is_expected_stats_not_found(&self) -> bool {
405 matches!(self.kind, ResolverErrorKind::ExpectedStatsNotFound { .. })
406 }
407
408 #[must_use]
409 pub fn is_creature_not_found(&self) -> bool {
410 matches!(self.kind, ResolverErrorKind::CreatureNotFound { .. })
411 }
412
413 #[must_use]
414 pub fn is_content_tuning_not_found(&self) -> bool {
415 matches!(self.kind, ResolverErrorKind::ContentTuningNotFound { .. })
416 }
417
418 #[must_use]
419 pub fn is_expected_stat_mod_not_found(&self) -> bool {
420 matches!(self.kind, ResolverErrorKind::ExpectedStatModNotFound { .. })
421 }
422
423 #[must_use]
424 pub fn is_challenge_mode_health_not_found(&self) -> bool {
425 matches!(
426 self.kind,
427 ResolverErrorKind::ChallengeModeHealthNotFound { .. }
428 )
429 }
430
431 #[must_use]
432 pub fn is_no_scaling_data(&self) -> bool {
433 matches!(self.kind, ResolverErrorKind::NoScalingData)
434 }
435
436 #[must_use]
437 pub fn spell_not_found_id(&self) -> Option<SpellId> {
438 match self.kind {
439 ResolverErrorKind::SpellNotFound { spell_id } => Some(spell_id),
440 _ => None,
441 }
442 }
443
444 #[must_use]
445 pub fn spell_effect_not_found_coordinate(&self) -> Option<(SpellId, u8)> {
446 match self.kind {
447 ResolverErrorKind::SpellEffectNotFound {
448 spell_id,
449 effect_index,
450 } => Some((spell_id, effect_index)),
451 _ => None,
452 }
453 }
454}
455
456impl From<wowlab_loadout::TraitError> for ResolverError {
457 fn from(source: wowlab_loadout::TraitError) -> Self {
458 Self {
459 kind: ResolverErrorKind::TraitDecode(source),
460 }
461 }
462}
463
464impl From<wowlab_fs::error::Error> for ResolverError {
465 fn from(source: wowlab_fs::error::Error) -> Self {
466 Self::filesystem(source)
467 }
468}
469
470macro_rules! unsupported_default {
471 ($method:ident; $($argument:expr),* $(,)?) => {{
472 let _ = ($($argument),*);
473
474 Err(ResolverError::unsupported(stringify!($method)))
475 }};
476}
477
478#[cfg(not(target_arch = "wasm32"))]
480pub trait ResolverSyncBound: Sync {}
481#[cfg(not(target_arch = "wasm32"))]
482impl<T> ResolverSyncBound for T where T: Sync + ?Sized {}
483#[cfg(target_arch = "wasm32")]
485pub trait ResolverSyncBound {}
486#[cfg(target_arch = "wasm32")]
487impl<T> ResolverSyncBound for T where T: ?Sized {}
488
489#[cfg_attr(target_arch = "wasm32", allow(async_fn_in_trait))]
493#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
494#[allow(
496 unreachable_pub,
497 reason = "the dynosaur macro exports DataResolver through the generated facade"
498)]
499#[dynosaur::dynosaur(pub DynDataResolver = dyn(box) DataResolver, bridge(dyn))]
500pub trait DataResolver: ResolverSyncBound {
501 async fn get_spell(&self, spell_id: SpellId) -> Result<SpellDataFlat, ResolverError>;
503
504 fn get_spells(
505 &self,
506 spell_ids: &[SpellId],
507 ) -> impl Future<Output = Result<Vec<SpellDataFlat>, ResolverError>> {
508 async move {
509 let mut result = Vec::with_capacity(spell_ids.len());
510
511 for &id in spell_ids {
512 result.push(self.get_spell(id).await?);
513 }
514
515 Ok(result)
516 }
517 }
518 async fn get_spell_effect(
520 &self,
521 spell_id: SpellId,
522 effect_index: u8,
523 ) -> Result<SpellEffect, ResolverError>;
524 async fn get_spell_effects(&self, spell_id: SpellId)
525 -> Result<Vec<SpellEffect>, ResolverError>;
526 async fn get_item(&self, item_id: i32) -> Result<ItemDataFlat, ResolverError>;
527 async fn get_scaling_data(&self) -> Result<Arc<ItemScalingData>, ResolverError>;
528
529 async fn get_power_types(&self) -> Result<Vec<PowerTypeFlat>, ResolverError>;
530
531 async fn get_spec(&self, spec_id: i32) -> Result<SpecDataFlat, ResolverError>;
532 async fn get_trait_tree(&self, spec_id: i32) -> Result<TraitTreeFlat, ResolverError>;
533 fn get_expansion_trait_tree(
535 &self,
536 expansion_id: i32,
537 system: &str,
538 ) -> impl Future<Output = Result<ExpansionTraitTreeFlat, ResolverError>> {
539 async move { unsupported_default!(get_expansion_trait_tree; expansion_id, system) }
540 }
541 async fn get_rotation_script(&self, rotation_id: &str) -> Result<String, ResolverError>;
542
543 async fn get_spell_overrides(
545 &self,
546 spec_id: i32,
547 ) -> Result<Vec<(SpellId, SpellId)>, ResolverError>;
548
549 async fn get_specialization_spells(&self, spec_id: i32) -> Result<Vec<SpellId>, ResolverError>;
551
552 async fn get_racial_spells(
556 &self,
557 race_id: i32,
558 class_id: i32,
559 ) -> Result<Vec<SpellId>, ResolverError>;
560
561 async fn get_expected_stats(
563 &self,
564 expansion_id: i32,
565 lvl: i32,
566 ) -> Result<ExpectedStatFlat, ResolverError>;
567
568 async fn get_item_damage_scaling(
570 &self,
571 item_level: i32,
572 weapon_type: &str,
573 ) -> Result<ItemDamageScalingFlat, ResolverError>;
574
575 fn search_spells(
577 &self,
578 query: &str,
579 limit: u32,
580 ) -> impl Future<Output = Result<Vec<SpellSearchResult>, ResolverError>> {
581 async move { unsupported_default!(search_spells; query, limit) }
582 }
583
584 fn find_consumable_items(
586 &self,
587 name_token: &str,
588 subclass: i32,
589 ) -> impl Future<Output = Result<Vec<ItemDataFlat>, ResolverError>> {
590 async move { unsupported_default!(find_consumable_items; name_token, subclass) }
591 }
592
593 async fn get_enchantment(&self, enchantment_id: i32) -> Result<EnchantmentRow, ResolverError>;
595
596 fn find_permanent_enchant(
598 &self,
599 query: &PermanentEnchantQuery,
600 ) -> impl Future<Output = Result<Option<PermanentEnchantFlat>, ResolverError>> {
601 async move { unsupported_default!(find_permanent_enchant; query) }
602 }
603
604 fn get_creature(
606 &self,
607 creature_id: i32,
608 ) -> impl Future<Output = Result<CreatureFlat, ResolverError>> {
609 async move { unsupported_default!(get_creature; creature_id) }
610 }
611
612 fn get_creature_difficulties(
614 &self,
615 creature_id: i32,
616 ) -> impl Future<Output = Result<Vec<CreatureDifficultyFlat>, ResolverError>> {
617 async move { unsupported_default!(get_creature_difficulties; creature_id) }
618 }
619
620 fn get_content_tuning(
622 &self,
623 content_tuning_id: i32,
624 ) -> impl Future<Output = Result<ContentTuningFlat, ResolverError>> {
625 async move { unsupported_default!(get_content_tuning; content_tuning_id) }
626 }
627
628 fn get_content_tuning_x_difficulty(
630 &self,
631 content_tuning_id: i32,
632 ) -> impl Future<Output = Result<Vec<ContentTuningXDifficultyFlat>, ResolverError>> {
633 async move { unsupported_default!(get_content_tuning_x_difficulty; content_tuning_id) }
634 }
635
636 fn get_content_tuning_x_expected(
638 &self,
639 content_tuning_id: i32,
640 ) -> impl Future<Output = Result<Vec<ContentTuningXExpectedFlat>, ResolverError>> {
641 async move { unsupported_default!(get_content_tuning_x_expected; content_tuning_id) }
642 }
643
644 fn get_expected_stat_mod(
646 &self,
647 expected_stat_mod_id: i32,
648 ) -> impl Future<Output = Result<ExpectedStatModFlat, ResolverError>> {
649 async move { unsupported_default!(get_expected_stat_mod; expected_stat_mod_id) }
650 }
651
652 fn get_challenge_mode_health(
654 &self,
655 keystone_level: i32,
656 ) -> impl Future<Output = Result<ChallengeModeHealthFlat, ResolverError>> {
657 async move { unsupported_default!(get_challenge_mode_health; keystone_level) }
658 }
659
660 fn decode_traits(
662 &self,
663 spec_id: i32,
664 trait_string: &str,
665 ) -> impl Future<Output = Result<TraitTreeWithSelections, ResolverError>> {
666 async move {
667 let tree = self.get_trait_tree(spec_id).await?;
668 let decoded = wowlab_loadout::decode_trait_loadout(trait_string)?;
669
670 Ok(wowlab_loadout::apply_decoded_traits(tree, &decoded))
671 }
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use std::error::Error as _;
678
679 use googletest::prelude::*;
680 use rstest::rstest;
681
682 use super::*;
683
684 #[cfg(not(target_arch = "wasm32"))]
685 fn assert_send_sync<T>()
686 where
687 T: Send + Sync + ?Sized,
688 {
689 }
690
691 #[cfg(not(target_arch = "wasm32"))]
692 #[gtest]
693 fn native_dynamic_resolver_preserves_send_sync_contract() {
694 assert_send_sync::<DynDataResolver<'static>>();
695 }
696
697 #[gtest]
698 #[rstest]
699 #[case::trim_and_lower(" Arcane Blast ", "arcane_blast")]
700 #[case::collapse_separators("arcane-- blast___rank 2", "arcane_blast_rank_2")]
701 #[case::discard_punctuation("Nature's Swiftness!", "natures_swiftness")]
702 #[case::empty(" --- ", "")]
703 fn tokenize_name_normalizes_profile_lookup_keys(
704 #[case] input: &str,
705 #[case] expected: &str,
706 ) -> Result<()> {
707 verify_that!(tokenize_name(input), eq(expected))
708 }
709
710 #[gtest]
711 fn permanent_enchant_lookup_applies_alias_rank_and_masks_exactly() -> Result<()> {
712 let entries = vec![
713 PermanentEnchantFlat {
714 enchant_id: 7_966,
715 rank: 1,
716 item_class: 4,
717 inventory_type_mask: 1 << 11,
718 subclass_mask: 0x1f,
719 tokenized_name: "enchant_ring__eyes_of_the_eagle".to_string(),
720 },
721 PermanentEnchantFlat {
722 enchant_id: 7_967,
723 rank: 2,
724 item_class: 4,
725 inventory_type_mask: 1 << 11,
726 subclass_mask: 0x1f,
727 tokenized_name: "enchant_ring__eyes_of_the_eagle".to_string(),
728 },
729 ];
730 let query = PermanentEnchantQuery {
731 tokenized_name: "eyes_of_the_eagle".to_string(),
732 rank: 2,
733 item_class: 4,
734 inventory_type: 11,
735 item_subclass: 0,
736 };
737
738 verify_that!(
739 select_permanent_enchant(&entries, &query),
740 some(matches_pattern!(PermanentEnchantFlat {
741 enchant_id: eq(&7_967),
742 ..
743 }))
744 )?;
745
746 let wrong_rank = PermanentEnchantQuery {
747 rank: 3,
748 ..query.clone()
749 };
750
751 verify_that!(select_permanent_enchant(&entries, &wrong_rank), none())?;
752
753 let wrong_inventory = PermanentEnchantQuery {
754 inventory_type: 7,
755 ..query.clone()
756 };
757
758 verify_that!(select_permanent_enchant(&entries, &wrong_inventory), none())?;
759
760 let bit_31_entries = vec![PermanentEnchantFlat {
761 inventory_type_mask: i32::MIN,
762 ..entries[1].clone()
763 }];
764
765 let bit_31_inventory = PermanentEnchantQuery {
766 inventory_type: 31,
767 ..query
768 };
769
770 verify_that!(
771 select_permanent_enchant(&bit_31_entries, &bit_31_inventory),
772 some(matches_pattern!(PermanentEnchantFlat {
773 enchant_id: eq(&7_967),
774 ..
775 }))
776 )
777 }
778
779 #[gtest]
780 fn backend_error_preserves_context_and_source() -> Result<()> {
781 let error = ResolverError::backend(
782 "fetch spell",
783 std::io::Error::other("upstream request failed"),
784 );
785
786 verify_that!(error.to_string(), eq("backend error in fetch spell"))?;
787
788 verify_that!(
789 error
790 .source()
791 .and_then(std::error::Error::source)
792 .map(ToString::to_string)
793 .as_deref(),
794 some(eq("upstream request failed"))
795 )
796 }
797
798 #[gtest]
799 fn trait_decode_error_preserves_typed_source() -> Result<()> {
800 let error = wowlab_loadout::decode_trait_loadout("")
801 .err()
802 .or_fail()?
803 .into();
804 let error: ResolverError = error;
805
806 verify_that!(
807 error.to_string(),
808 eq("trait decode error: Trait string too short")
809 )?;
810 let source = error
811 .source()
812 .and_then(std::error::Error::source)
813 .and_then(|source| source.downcast_ref::<wowlab_loadout::TraitError>())
814 .or_fail()?;
815
816 verify_true!(source.is_too_short())
817 }
818
819 #[gtest]
820 #[rstest]
821 #[case::first_arm(0, Some(10.0))]
822 #[case::mid_arm(3, Some(40.0))]
823 #[case::last_valid_arm(6, Some(70.0))]
824 #[case::above_range(7, None)]
825 #[case::negative(-1, None)]
826 fn item_damage_scaling_quality_arms(
827 #[case] quality: i32,
828 #[case] expected: Option<f64>,
829 ) -> Result<()> {
830 let row = ItemDamageScalingFlat {
831 quality_0: 10.0,
832 quality_1: 20.0,
833 quality_2: 30.0,
834 quality_3: 40.0,
835 quality_4: 50.0,
836 quality_5: 60.0,
837 quality_6: 70.0,
838 ..ItemDamageScalingFlat::default()
839 };
840
841 if let Some(v) = expected {
842 verify_that!(row.quality(quality), some(near(v, 1e-9)))
843 } else {
844 verify_that!(row.quality(quality), none())
845 }
846 }
847}