1#![expect(
4 clippy::multiple_crate_versions,
5 reason = "the wasm integration harness combines native test tooling with the engine's browser dependency graph"
6)]
7#![cfg(target_arch = "wasm32")]
8
9use std::{cell::RefCell, error::Error as _, rc::Rc, sync::Once};
10
11use prost::Message as _;
12use wasm_bindgen::{JsValue, closure::Closure};
13use wasm_bindgen_test::wasm_bindgen_test;
14use wowlab_engine::wasm::{
15 WasmEngineError,
16 init::init_wasm_runtime,
17 metadata::{
18 get_engine_git_hash, get_engine_version, get_implemented_specs, get_spec_introspection,
19 get_spec_rotation_field_schema,
20 },
21 rotation::{
22 get_field_descriptors, get_var_path_schema_wasm, parse_rotation_json,
23 validate_rotation_json,
24 },
25 simulation::{run_simulation, run_simulation_with_progress},
26};
27
28static INIT: Once = Once::new();
29
30fn ensure_init() {
31 INIT.call_once(|| {
32 init_wasm_runtime();
33 });
34}
35
36fn js_get_f64(obj: &JsValue, key: &str) -> f64 {
37 js_sys::Reflect::get(obj, &JsValue::from_str(key))
38 .unwrap_or_else(|_| panic!("missing field: {key}"))
39 .as_f64()
40 .unwrap_or_else(|| panic!("{key} is not a number"))
41}
42
43fn js_get_string(obj: &JsValue, key: &str) -> String {
44 js_sys::Reflect::get(obj, &JsValue::from_str(key))
45 .unwrap_or_else(|_| panic!("missing field: {key}"))
46 .as_string()
47 .unwrap_or_else(|| panic!("{key} is not a string"))
48}
49
50fn js_get_bool(obj: &JsValue, key: &str) -> bool {
51 js_sys::Reflect::get(obj, &JsValue::from_str(key))
52 .unwrap_or_else(|_| panic!("missing field: {key}"))
53 .as_bool()
54 .unwrap_or_else(|| panic!("{key} is not a boolean"))
55}
56
57fn js_error_name(error: impl Into<JsValue>) -> String {
58 js_get_string(&error.into(), "name")
59}
60
61fn assert_js_error_presentation(
62 error: WasmEngineError,
63 expected_name: &str,
64 expected_message: &str,
65) {
66 let value = JsValue::from(error);
67
68 assert_eq!(
69 js_get_string(&value, "name"),
70 expected_name,
71 "the JavaScript error name remains stable"
72 );
73 assert_eq!(
74 js_get_string(&value, "message"),
75 expected_message,
76 "the JavaScript error message remains stable"
77 );
78}
79
80#[wasm_bindgen_test]
81fn application_error_retains_typed_source_and_javascript_presentation() {
82 use wowlab_engine_application::{ApplicationError, ApplicationErrorCategory, ApplicationStage};
83
84 let source = ApplicationError::from_engine(
85 ApplicationStage::Simulation,
86 wowlab_engine_ports::EngineError::simulation_runtime("event queue failed"),
87 );
88 let error = WasmEngineError::from(source);
89 let application = error
90 .application_error()
91 .expect("the typed Application error remains accessible");
92
93 assert_eq!(application.stage(), ApplicationStage::Simulation);
94 assert_eq!(application.category(), ApplicationErrorCategory::Engine);
95 assert!(
96 error
97 .source()
98 .and_then(std::error::Error::source)
99 .is_some_and(<dyn std::error::Error>::is::<ApplicationError>),
100 "Error::source exposes the typed Application error"
101 );
102 assert_js_error_presentation(
103 error,
104 "SimulationError",
105 "simulation runtime error: event queue failed",
106 );
107}
108
109#[wasm_bindgen_test]
110fn engine_error_retains_typed_source_and_javascript_presentation() {
111 use wowlab_engine_ports::EngineError;
112
113 let error = WasmEngineError::from(EngineError::simulation_runtime("event queue failed"));
114 let engine = error
115 .engine_error()
116 .expect("the typed Engine error remains accessible");
117
118 assert_eq!(engine.message(), Some("event queue failed"));
119 assert!(
120 error
121 .source()
122 .and_then(std::error::Error::source)
123 .is_some_and(<dyn std::error::Error>::is::<EngineError>),
124 "Error::source exposes the typed Engine error"
125 );
126 assert_js_error_presentation(
127 error,
128 "SimulationError",
129 "simulation runtime error: event queue failed",
130 );
131}
132
133#[wasm_bindgen_test]
134fn content_catalog_error_retains_typed_source_and_javascript_presentation() {
135 use wowlab_engine_ports::{ContentCatalog, ContentCatalogError};
136
137 let catalog = ContentCatalog::new(&[], &[], &[]);
138 let source = catalog
139 .validate()
140 .expect_err("an empty catalog must fail validation");
141 let error = WasmEngineError::from(source);
142
143 assert!(
144 error.content_catalog_error().is_some(),
145 "the typed content-catalog error remains accessible"
146 );
147 assert!(
148 error
149 .source()
150 .and_then(std::error::Error::source)
151 .is_some_and(<dyn std::error::Error>::is::<ContentCatalogError>),
152 "Error::source exposes the typed content-catalog error"
153 );
154 assert_js_error_presentation(
155 error,
156 "SimulationError",
157 "content catalog error: engine-content catalog contains no spec descriptors",
158 );
159}
160
161const VALID_ROTATION_JSON: &str = r#"{"version": 1, "name": "test", "variables": {}, "actions": [{"type": "wait", "seconds": 0.1}], "lists": {}}"#;
162
163#[wasm_bindgen_test]
164fn engine_version_is_nonempty() {
165 let version = get_engine_version();
166 assert!(!version.is_empty(), "engine version should not be empty");
167}
168
169#[wasm_bindgen_test]
170fn engine_git_hash_is_nonempty() {
171 let hash = get_engine_git_hash();
172 assert!(!hash.is_empty(), "git hash should not be empty");
173}
174
175#[wasm_bindgen_test]
176fn content_catalog_is_ready_after_runtime_initialization() {
177 ensure_init();
178 wowlab_engine::wasm::init::assert_content_catalog_ready()
179 .expect("assertRegistryReady should observe the linked content catalog");
180 let first = get_implemented_specs().expect("first catalog-backed export should succeed");
181
182 wowlab_engine::wasm::init::assert_content_catalog_ready()
183 .expect("repeated readiness checks should reuse the initialized composition");
184 let second = get_implemented_specs().expect("repeated catalog-backed export should succeed");
185 let first = js_sys::Array::from(&first);
186 let second = js_sys::Array::from(&second);
187
188 assert_eq!(
189 first.length(),
190 second.length(),
191 "repeated exports observe one stable composition"
192 );
193 assert!(
194 first.length() > 0,
195 "the cached composition retains linked spec content"
196 );
197}
198
199#[wasm_bindgen_test]
200fn engine_wasm_export_names_are_pinned_at_binding_declarations() {
201 let sources = [
202 include_str!("../../engine/src/wasm/init.rs"),
203 include_str!("../../engine/src/wasm/metadata.rs"),
204 include_str!("../../engine/src/wasm/paperdoll.rs"),
205 include_str!("../../engine/src/wasm/rotation.rs"),
206 include_str!("../../engine/src/wasm/simulation.rs"),
207 ]
208 .join("\n");
209
210 assert!(
211 sources.contains("#[wasm_bindgen(start)]"),
212 "missing wasm-bindgen start binding"
213 );
214 for export in [
215 "assertRegistryReady",
216 "getEngineVersion",
217 "getEngineGitHash",
218 "getImplementedSpecs",
219 "getSpecIntrospection",
220 "getSpecIntrospectionResolved",
221 "getSpecRotationFieldSchema",
222 "resolvePaperdoll",
223 "validateRotation",
224 "parseRotation",
225 "getVarPathSchema",
226 "getFieldDescriptors",
227 "validateRotationForSpec",
228 "runSimulation",
229 "runSimulationWithProgress",
230 "runIterationTrace",
231 ] {
232 assert!(
233 sources.contains(&format!("js_name = {export}"))
234 || sources.contains(&format!("js_name = \"{export}\"")),
235 "missing wasm-bindgen JavaScript export name {export}"
236 );
237 }
238}
239
240#[wasm_bindgen_test]
241fn implemented_specs_returns_populated_array() {
242 ensure_init();
243 let specs = get_implemented_specs().expect("getImplementedSpecs should succeed");
244 let arr = js_sys::Array::from(&specs);
245 let actual_ids: Vec<_> = arr
246 .iter()
247 .map(|spec| js_get_f64(&spec, "spec_id") as u32)
248 .collect();
249 let expected_ids = [
250 wowlab_types::game::SpecId::FrostDK,
251 wowlab_types::game::SpecId::Unholy,
252 wowlab_types::game::SpecId::Devourer,
253 wowlab_types::game::SpecId::Havoc,
254 wowlab_types::game::SpecId::Vengeance,
255 wowlab_types::game::SpecId::Balance,
256 wowlab_types::game::SpecId::Feral,
257 wowlab_types::game::SpecId::Devastation,
258 wowlab_types::game::SpecId::BeastMastery,
259 wowlab_types::game::SpecId::Marksmanship,
260 wowlab_types::game::SpecId::Survival,
261 wowlab_types::game::SpecId::Arcane,
262 wowlab_types::game::SpecId::Fire,
263 wowlab_types::game::SpecId::FrostMage,
264 wowlab_types::game::SpecId::Windwalker,
265 wowlab_types::game::SpecId::Retribution,
266 wowlab_types::game::SpecId::Shadow,
267 wowlab_types::game::SpecId::Assassination,
268 wowlab_types::game::SpecId::Outlaw,
269 wowlab_types::game::SpecId::Subtlety,
270 wowlab_types::game::SpecId::Elemental,
271 wowlab_types::game::SpecId::Enhancement,
272 wowlab_types::game::SpecId::Affliction,
273 wowlab_types::game::SpecId::Demonology,
274 wowlab_types::game::SpecId::Destruction,
275 wowlab_types::game::SpecId::Arms,
276 wowlab_types::game::SpecId::Fury,
277 ]
278 .map(wowlab_types::game::SpecId::wow_spec_id);
279 assert_eq!(
280 actual_ids, expected_ids,
281 "implemented spec IDs must preserve canonical content-catalog order"
282 );
283
284 let first = arr.get(0);
285 let _spec_id = js_get_f64(&first, "spec_id");
286 let _class_id = js_get_f64(&first, "class_id");
287 let _class_name = js_get_string(&first, "class_name");
288 let _spec_name = js_get_string(&first, "spec_name");
289 let _display_name = js_get_string(&first, "display_name");
290 let _slug = js_get_string(&first, "slug");
291 let _spell_count = js_get_f64(&first, "spell_count");
292 let _aura_count = js_get_f64(&first, "aura_count");
293 let _talent_count = js_get_f64(&first, "talent_count");
294}
295
296#[wasm_bindgen_test]
297fn spec_introspection_succeeds_for_valid_spec() {
298 ensure_init();
299 let specs = get_implemented_specs().expect("should succeed");
300 let arr = js_sys::Array::from(&specs);
301 let first = arr.get(0);
302 let spec_id = js_get_f64(&first, "spec_id") as u32;
303
304 let result = get_spec_introspection(spec_id);
305 assert!(
306 result.is_ok(),
307 "introspection for valid spec should succeed: {:?}",
308 result.err()
309 );
310}
311
312#[wasm_bindgen_test]
313fn spec_introspection_fails_for_invalid_spec() {
314 ensure_init();
315 let result = get_spec_introspection(999_999);
316 assert!(result.is_err(), "invalid spec_id should return error");
317}
318
319#[wasm_bindgen_test]
320fn rotation_field_schema_succeeds() {
321 ensure_init();
322 let specs = get_implemented_specs().expect("should succeed");
323 let arr = js_sys::Array::from(&specs);
324 let first = arr.get(0);
325 let spec_id = js_get_f64(&first, "spec_id") as u32;
326
327 let result = get_spec_rotation_field_schema(spec_id);
328 assert!(
329 result.is_ok(),
330 "rotation field schema should succeed: {:?}",
331 result.err()
332 );
333}
334
335#[wasm_bindgen_test]
336fn validate_rotation_accepts_valid_json() {
337 let result = validate_rotation_json(VALID_ROTATION_JSON);
338 assert!(
339 result.is_ok(),
340 "valid rotation JSON should validate: {:?}",
341 result.err()
342 );
343}
344
345#[wasm_bindgen_test]
346fn validate_rotation_rejects_invalid_json() {
347 let result = validate_rotation_json("not valid json {{{");
348 assert!(result.is_err(), "invalid JSON should return error");
349}
350
351#[wasm_bindgen_test]
352fn parse_rotation_returns_ast() {
353 let result = parse_rotation_json(VALID_ROTATION_JSON);
354 assert!(
355 result.is_ok(),
356 "valid rotation JSON should parse: {:?}",
357 result.err()
358 );
359}
360
361#[wasm_bindgen_test]
362fn var_path_schema_returns_data() {
363 let result = get_var_path_schema_wasm();
364 assert!(
365 result.is_ok(),
366 "var path schema should succeed: {:?}",
367 result.err()
368 );
369}
370
371#[wasm_bindgen_test]
372fn field_descriptors_returns_populated_array() {
373 ensure_init();
374 let result = get_field_descriptors(0).expect("getFieldDescriptors should succeed");
375 let arr = js_sys::Array::from(&result);
376 assert!(
377 arr.length() > 0,
378 "expected at least one field descriptor, got {}",
379 arr.length()
380 );
381
382 let first = arr.get(0);
383 let _id = js_get_f64(&first, "id");
384 let _domain = js_get_string(&first, "domain");
385 let _name = js_get_string(&first, "name");
386 let _field_type = js_get_string(&first, "field_type");
387 let _eval_kind = js_get_string(&first, "eval_kind");
388 let _slot_kind = js_get_string(&first, "slot_kind");
389 let _description = js_get_string(&first, "description");
390}
391
392const INVALID_SIM_CONFIG_TOML: &str = "this is not a valid sim config";
394
395type ResolverMethod = Closure<dyn Fn(JsValue, JsValue) -> js_sys::Promise>;
396
397struct FakeResolver {
398 object: js_sys::Object,
399 calls: Rc<RefCell<Vec<&'static str>>>,
400 methods: Vec<ResolverMethod>,
401}
402
403impl FakeResolver {
404 fn new() -> Self {
405 let mut resolver = Self {
406 object: js_sys::Object::new(),
407 calls: Rc::new(RefCell::new(Vec::new())),
408 methods: Vec::new(),
409 };
410
411 resolver.install("getSpell", |id, _| {
412 let id = id.as_f64().expect("spell id is numeric") as i32;
413 let mut spell = wowlab_types::data::SpellDataFlat::default();
414 spell.id = id;
415 spell.name = format!("Spell {id}").into();
416 spell.cast_time = 1_500;
417 spell.duration = 10_000;
418 spell.max_duration = 10_000;
419 spell.range_max_0 = 40.0;
420 spell.effects = (1..=16)
421 .map(|index| wowlab_types::data::SpellEffect {
422 index,
423 ..Default::default()
424 })
425 .collect();
426 serde_wasm_bindgen::to_value(&spell).expect("spell fixture serializes")
427 });
428 resolver.install("getPowerTypes", |_, _| {
429 let rows: Vec<wowlab_types::data::PowerTypeFlat> = (0..=20)
430 .map(|power_type_enum| wowlab_types::data::PowerTypeFlat {
431 id: power_type_enum + 1,
432 name_global_string_tag: format!("POWER_{power_type_enum}"),
433 cost_global_string_tag: format!("COST_{power_type_enum}"),
434 power_type_enum,
435 max_base_power: 100,
436 default_power: 100,
437 display_modifier: 1.0,
438 regen_combat: 10.0,
439 ..Default::default()
440 })
441 .collect();
442 serde_wasm_bindgen::to_value(&rows).expect("power fixtures serialize")
443 });
444 resolver.install("getSpec", |id, _| {
445 let id = id.as_f64().expect("spec id is numeric") as i32;
446 let spec = wowlab_types::data::SpecDataFlat {
447 id,
448 name: "Survival".into(),
449 class_id: 3,
450 class_name: "Hunter".into(),
451 primary_stat_priority: 2,
452 ..Default::default()
453 };
454 serde_wasm_bindgen::to_value(&spec).expect("spec fixture serializes")
455 });
456 resolver.install("getTraitTree", |id, _| {
457 let tree = wowlab_types::data::TraitTreeFlat {
458 spec_id: id.as_f64().expect("spec id is numeric") as i32,
459 ..Default::default()
460 };
461 serde_wasm_bindgen::to_value(&tree).expect("trait fixture serializes")
462 });
463 resolver.install("getExpansionTraitTree", |id, system| {
464 let tree = wowlab_types::data::ExpansionTraitTreeFlat {
465 expansion_id: id.as_f64().expect("expansion id is numeric") as i32,
466 system: system.as_string().expect("system is a string"),
467 ..Default::default()
468 };
469 serde_wasm_bindgen::to_value(&tree).expect("expansion trait fixture serializes")
470 });
471 resolver.install("getExpectedStats", |expansion, level| {
472 let stats = wowlab_types::data::ExpectedStatFlat {
473 id: 1,
474 expansion_id: expansion.as_f64().expect("expansion id is numeric") as i32,
475 lvl: level.as_f64().expect("level is numeric") as i32,
476 creature_health: 10_000.0,
477 player_health: 10_000.0,
478 creature_auto_attack_dps: 100.0,
479 creature_armor: 1_000.0,
480 player_mana: 100_000.0,
481 player_primary_stat: 1_000.0,
482 player_secondary_stat: 500.0,
483 armor_constant: 1_000.0,
484 creature_spell_damage: 100.0,
485 content_set_id: 1,
486 };
487 serde_wasm_bindgen::to_value(&stats).expect("expected stats fixture serializes")
488 });
489 resolver.install("getScalingData", |_, _| {
490 serde_wasm_bindgen::to_value(&wowlab_types::data::ItemScalingData::default())
491 .expect("scaling fixture serializes")
492 });
493 resolver.install("getRotationScript", |_, _| {
494 JsValue::from_str(VALID_ROTATION_JSON)
495 });
496
497 for method in [
498 "getItem",
499 "getItemDamageScaling",
500 "getCreature",
501 "getContentTuning",
502 "getExpectedStatMod",
503 "getChallengeModeHealth",
504 "getEnchantment",
505 ] {
506 resolver.install(method, |_, _| JsValue::NULL);
507 }
508 for method in [
509 "getCreatureDifficulties",
510 "getContentTuningXDifficulty",
511 "getContentTuningXExpected",
512 ] {
513 resolver.install(method, |_, _| js_sys::Array::new().into());
514 }
515
516 resolver
517 }
518
519 fn install(
520 &mut self,
521 name: &'static str,
522 response: impl Fn(JsValue, JsValue) -> JsValue + 'static,
523 ) {
524 let calls = Rc::clone(&self.calls);
525 let method = Closure::new(move |first: JsValue, second: JsValue| {
526 calls.borrow_mut().push(name);
527 js_sys::Promise::resolve(&response(first, second))
528 });
529 js_sys::Reflect::set(&self.object, &JsValue::from_str(name), method.as_ref())
530 .expect("resolver method installs");
531 self.methods.push(method);
532 }
533
534 fn value(&self) -> JsValue {
535 self.object.clone().into()
536 }
537}
538
539#[wasm_bindgen_test]
540async fn run_simulation_surfaces_typed_error_on_invalid_config() {
541 ensure_init();
542 let resolver = JsValue::from(js_sys::Object::new());
543 let result = run_simulation(INVALID_SIM_CONFIG_TOML, 1, 42, 0, resolver).await;
544 let error = result.expect_err("invalid sim config must surface a typed error");
545 assert!(
546 error.application_error().is_some(),
547 "runSimulation retains the typed Application error until JsValue conversion"
548 );
549 assert!(
550 error
551 .to_string()
552 .starts_with("intent validation error: failed to parse sim config TOML:"),
553 "runSimulation preserves the established Application error presentation"
554 );
555 assert_eq!(js_error_name(error), "SimulationError");
556}
557
558#[wasm_bindgen_test]
559async fn run_simulation_with_progress_surfaces_typed_error_on_invalid_config() {
560 ensure_init();
561 let resolver = JsValue::from(js_sys::Object::new());
562 let cb = js_sys::Function::new_no_args("");
563 let result =
564 run_simulation_with_progress(INVALID_SIM_CONFIG_TOML, 1, 42, 0, resolver, cb.into()).await;
565 let error = result.expect_err("invalid sim config must surface a typed error");
566 assert!(
567 error.application_error().is_some(),
568 "runSimulationWithProgress retains the typed Application error until JsValue conversion"
569 );
570 assert!(
571 error
572 .to_string()
573 .starts_with("intent validation error: failed to parse sim config TOML:"),
574 "runSimulationWithProgress preserves the established Application error presentation"
575 );
576 assert_eq!(js_error_name(error), "SimulationError");
577}
578
579#[wasm_bindgen_test]
581async fn successful_simulation_invokes_js_resolver_and_progress_callback() {
582 ensure_init();
583 let resolver = FakeResolver::new();
584 let mut intent = wowlab_common::sim::intent::SimConfigIntent::patchwerk(
585 wowlab_types::game::SpecId::Survival,
586 1.0,
587 "boundary_fixture",
588 );
589 intent.settings.insert("flask".to_string(), false.into());
590 intent
591 .settings
592 .insert("augment_rune".to_string(), false.into());
593 let sim_config = wowlab_common::sim::intent::serialize_sim_config(&intent)
594 .expect("fixture sim config serializes");
595 let updates = Rc::new(RefCell::new(Vec::new()));
596 let updates_for_callback = Rc::clone(&updates);
597 let callback = Closure::<dyn Fn(JsValue)>::new(move |update| {
598 updates_for_callback.borrow_mut().push(update);
599 });
600
601 let result = run_simulation_with_progress(
602 &sim_config,
603 1,
604 42,
605 7,
606 resolver.value(),
607 callback.as_ref().clone(),
608 )
609 .await
610 .expect("fixture simulation succeeds");
611
612 assert_eq!(result.chunk_index, 7, "requested chunk index is preserved");
613 assert!(
614 !result.bytes.is_empty(),
615 "successful simulation emits telemetry"
616 );
617 let telemetry = wowlab_types::proto::ChunkTelemetry::decode(result.bytes.as_slice())
618 .expect("successful simulation returns valid ChunkTelemetry protobuf bytes");
619 assert_eq!(
620 telemetry.chunk_index, 7,
621 "protobuf preserves the requested chunk index"
622 );
623 assert_eq!(
624 telemetry.iterations, 1,
625 "protobuf records the completed iteration"
626 );
627 let calls = resolver.calls.borrow();
628 assert!(
629 calls.contains(&"getRotationScript"),
630 "simulation resolves the rotation through JavaScript"
631 );
632 assert!(
633 calls.contains(&"getSpell"),
634 "simulation resolves spell data through JavaScript"
635 );
636 let updates = updates.borrow();
637 assert!(!updates.is_empty(), "progress callback is invoked");
638 let final_update = updates.last().expect("progress emits a final update");
639 assert_eq!(
640 js_get_f64(final_update, "completed"),
641 1.0,
642 "final progress records completed iterations"
643 );
644 assert_eq!(
645 js_get_f64(final_update, "total"),
646 1.0,
647 "final progress records total iterations"
648 );
649 assert!(
650 js_get_bool(final_update, "done"),
651 "final progress marks completion"
652 );
653 let keys: Vec<String> = js_sys::Object::keys(&js_sys::Object::from(final_update.clone()))
654 .iter()
655 .filter_map(|key| key.as_string())
656 .collect();
657 assert_eq!(
658 keys,
659 ["completed", "total", "done"],
660 "final progress retains its exact JavaScript field casing"
661 );
662}