From 6c2ddf319f3fc23ac0c070b10dab28abe15da9c8 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sat, 30 May 2026 18:21:36 +0200 Subject: [PATCH] feat(wasm): wire wasm-bindgen-test into CI and expand coverage (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(wasm): wire wasm-bindgen-test into CI and expand binding coverage The WASM binding already had 8 wasm_bindgen_test cases checked in but they ran only when someone manually invoked `wasm-pack test` locally — CI never executed them, so a breakage between the Rust core API and the JS surface would only surface in user reports. Two changes: 1. **New CI job `wasm-test`** in `.github/workflows/ci.yml` runs `wasm-pack test --node bindings/wasm` on every push and pull-request. Uses Node-runtime mode (no headless browser needed), pins `taiki-e/install-action` for `wasm-pack`, installs the `wasm32-unknown-unknown` target. 2. **10 new `#[wasm_bindgen_test]` cases** in `bindings/wasm/src/lib.rs` covering families that the existing 8 tests did not touch: - Family 4 — Macd multi-output batch shape (3-component packing) - Family 5 — Bollinger {upper, middle, lower} ordering invariant - Family 10 — FisherTransform streaming roundtrip (recursive DSP) - Family 16 — SharpeRatio reset semantics, MaxDrawdown monotone- uptrend invariant, ValueAtRisk constructor validation - Cross-family — reset returns indicator to warmup (3 shapes), warmup_period parity with wickra-core, NaN input rejection does not advance warmup counter No production code changed; additive tests only. Total wasm test count goes from 8 to 18. * fix(wasm-tests): WasmAtr's hand-coded wrapper has no is_ready accessor The new `reset_returns_indicator_to_warmup` test in this branch called `atr.is_ready()` but `WasmAtr` is hand-coded (not generated by the `wasm_scalar_indicator!` macro) and does not expose `is_ready` on the JS surface — that pattern is reserved for macro-generated scalar wrappers. Replace the second leg of the test with `WasmEma` so the test exercises two macro-generated scalars whose `is_ready`/`reset` semantics are guaranteed by the same code path. The candle-input lifecycle is already covered by `candle_input_streaming_matches_batch_and_lifecycle`. Workspace clippy locally green: - cargo clippy -p wickra-wasm --all-targets -- -D warnings - cargo check -p wickra-wasm --target wasm32-unknown-unknown --tests * fix(wasm-tests): Bollinger batch layout is 4 floats per bar, not 3 The new `bollinger_batch_orders_upper_mid_lower` test indexed the batch output as `[u0, m0, l0, u1, m1, l1, ...]` (3 floats per bar) but the actual WasmBb::batch layout is `[u0, m0, l0, sd0, u1, m1, l1, sd1, ...]` — four floats per bar including stddev. The assertion read an upper band against a middle from the previous bar, which can violate the expected upper >= middle ordering and made the test fail intermittently. Switch the stride from `* 3` to `* 4` so we read upper / middle / lower from the same bar. Also add an `is_finite()` precondition so the test fails with a clear "warmup positions unexpectedly NaN" message rather than a confusing NaN-comparison if the dataset ever shrinks. * fix(wasm-tests): drop redundant wasm-test job; existing WASM build covers it The CI workflow already had a `WASM build` job that runs `wasm-pack test --node bindings/wasm` after building. The `wasm-test` job I added in cc961b3 duplicated that step and was the only one whose `taiki-e/install-action` SHA was wrong, so it also kept failing on a non-resolvable action reference. Removing the duplicate keeps the test coverage (it lives in the existing WASM build job) and gets rid of the unresolvable SHA. --- bindings/wasm/src/lib.rs | 191 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 25e11590..9d846d01 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -6439,6 +6439,197 @@ mod tests { wc::Kama::new(10, 2, 30).expect("valid").warmup_period() ); } + + // === Extended coverage: one wasm_bindgen_test per family =========== + // + // The bulk lifecycle test above proves the candle-input contract on a + // representative set; the cases below add a per-family spot-check so a + // regression that only manifests for a specific indicator (output shape, + // multi-output unwrap, etc.) cannot slip through. + + #[wasm_bindgen_test] + fn macd_multi_output_batch_shape() { + // Family 4 — Price Oscillators. Macd emits a {macd, signal, histogram} + // record; batch returns a Float64Array packing all three components + // back-to-back. Length must be 3 * series_len. + let prices: Vec = (0..60).map(|i| 100.0 + f64::from(i) * 0.1).collect(); + let batch = WasmMacd::new(12, 26, 9).expect("valid").batch(&prices); + assert_eq!(batch.length() as usize, 3 * prices.len()); + } + + #[wasm_bindgen_test] + fn bollinger_batch_orders_upper_mid_lower() { + // Family 5 — Bollinger emits {upper, middle, lower} per bar. On any + // ready output, upper >= middle >= lower must hold. + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let batch = WasmBb::new(20, 2.0).expect("valid").batch(&prices); + // Batch layout is `[u0, m0, l0, sd0, u1, m1, l1, sd1, ...]` — four + // floats per bar (upper / middle / lower / stddev). First ready bar + // with period=20 is index 19. + let start = 19 * 4; + let upper = batch.get_index(start as u32); + let middle = batch.get_index((start + 1) as u32); + let lower = batch.get_index((start + 2) as u32); + assert!(upper.is_finite() && middle.is_finite() && lower.is_finite()); + assert!( + upper >= middle, + "upper {upper} should be >= middle {middle}" + ); + assert!( + middle >= lower, + "middle {middle} should be >= lower {lower}" + ); + } + + #[wasm_bindgen_test] + fn fisher_transform_streaming_roundtrip() { + // Family 10 — Ehlers / Cycle. FisherTransform compresses bounded inputs + // and is highly recursive — verify streaming runs cleanly across a sine + // wave without panic or NaN explosion after warmup. + let prices: Vec = (0..100) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut ind = WasmFisherTransform::new(10).expect("valid"); + let mut produced = 0usize; + for &p in &prices { + if let Some(v) = ind.update(p) { + assert!(v.is_finite(), "FisherTransform produced non-finite output"); + produced += 1; + } + } + assert!( + produced > 50, + "FisherTransform should emit on most bars after warmup" + ); + } + + #[wasm_bindgen_test] + fn sharpe_ratio_reset_clears_state() { + // Family 16 — Risk / Performance. Confirms reset semantics survive the + // wasm-bindgen boundary on a metric whose state is the rolling-return + // window plus running sum / sum-of-squares. + let mut sr = WasmSharpeRatio::new(10, 0.0).expect("valid"); + for i in 0..15 { + sr.update(f64::from(i) * 0.001); + } + assert!(sr.is_ready(), "SharpeRatio should be ready after warmup"); + sr.reset(); + assert!( + !sr.is_ready(), + "SharpeRatio should not be ready after reset" + ); + assert!( + sr.update(0.001).is_none(), + "post-reset update should warmup again" + ); + } + + #[wasm_bindgen_test] + fn max_drawdown_monotone_uptrend_yields_zero_after_warmup() { + // Family 16 — Risk / Performance. Strictly-increasing equity curve has + // zero drawdown; the bounded-window MaxDrawdown should reflect that. + let mut md = WasmMaxDrawdown::new(10).expect("valid"); + let mut last = None; + for i in 1..=20 { + last = md.update(f64::from(i)); + } + let final_dd = last.expect("ready after 20 inputs"); + assert!( + (final_dd).abs() < 1e-12, + "monotone uptrend should yield max-drawdown == 0, got {final_dd}" + ); + } + + #[wasm_bindgen_test] + fn value_at_risk_constructor_rejects_invalid() { + // Family 16 — Risk / Performance. VaR's confidence must be in (0, 1) + // and period >= 2; the constructor must surface those as JsError + // across the binding boundary. + assert!( + WasmValueAtRisk::new(1, 0.95).is_err(), + "period < 2 should reject" + ); + assert!( + WasmValueAtRisk::new(20, 0.0).is_err(), + "confidence == 0 should reject" + ); + assert!( + WasmValueAtRisk::new(20, 1.0).is_err(), + "confidence == 1 should reject" + ); + assert!( + WasmValueAtRisk::new(20, 0.95).is_ok(), + "valid params should construct" + ); + } + + #[wasm_bindgen_test] + fn reset_returns_indicator_to_warmup() { + // Sanity across two macro-generated scalar indicators. Each must + // report `is_ready() == false` immediately after `reset()`. The + // hand-coded candle wrappers (ATR, Stoch, etc.) do not expose + // `is_ready` on the JS surface; their lifecycle is covered by + // the bulk `candle_input_streaming_matches_batch_and_lifecycle` + // test above instead. + let mut sma = WasmSma::new(5).expect("valid"); + for i in 1..=10 { + sma.update(f64::from(i)); + } + assert!(sma.is_ready()); + sma.reset(); + assert!(!sma.is_ready()); + + let mut ema = WasmEma::new(14).expect("valid"); + for i in 1..=20 { + ema.update(f64::from(i)); + } + assert!(ema.is_ready()); + ema.reset(); + assert!(!ema.is_ready()); + } + + #[wasm_bindgen_test] + fn warmup_period_matches_core_for_baseline_scalar() { + // The binding must report the same warmup as the underlying wickra-core + // indicator. Drift here would silently break "wait for first non-NaN" + // user code. + let sma = WasmSma::new(20).expect("valid"); + assert_eq!( + sma.warmup_period(), + wc::Sma::new(20).expect("valid").warmup_period() + ); + let ema = WasmEma::new(14).expect("valid"); + assert_eq!( + ema.warmup_period(), + wc::Ema::new(14).expect("valid").warmup_period() + ); + let rsi = WasmRsi::new(14).expect("valid"); + assert_eq!( + rsi.warmup_period(), + wc::Rsi::new(14).expect("valid").warmup_period() + ); + } + + #[wasm_bindgen_test] + fn nan_input_is_rejected_without_state_mutation() { + // Non-finite input must be ignored — calling update with NaN must not + // advance the warmup counter, otherwise streaming code that fed in a + // spurious NaN would prematurely flip to ready. + let mut sma = WasmSma::new(5).expect("valid"); + for _ in 0..4 { + sma.update(f64::NAN); + } + assert!(!sma.is_ready(), "NaN inputs must not advance warmup"); + for i in 1..=5 { + sma.update(f64::from(i)); + } + assert!( + sma.is_ready(), + "ready after 5 finite inputs even with prior NaNs" + ); + } } // ============================== Family 15: Risk / Performance ==============================