Skip to main content

wowlab_engine_wasm_tests/
lib.rs

1//! JS-WASM boundary integration tests; separate crate so the engine's native-only dev-deps don't compile under wasm32.
2
3#![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
392// Malformed on purpose: asserts the boundary surfaces a typed Err, not that a sim runs.
393const 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// #t(fn: rust_nonsend_across_await) wasm32 JavaScript callbacks and resolver futures are intentionally single-threaded
580#[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}