feat(wasm): wire wasm-bindgen-test into CI and expand coverage (#64)

* 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.
This commit is contained in:
kingchenc
2026-05-30 18:21:36 +02:00
committed by GitHub
parent 9db1ff8023
commit 6c2ddf319f
+191
View File
@@ -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<f64> = (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<f64> = (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<f64> = (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 ==============================