#[cfg(test)]
#[cfg(target_arch = "wasm32")]
mod tests {
use crate::wasm::*;
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn test_wasm_ballistics_creation() {
let wasm = WasmBallistics::new();
assert!(wasm.run_command("help").is_ok());
}
#[wasm_bindgen_test]
fn test_help_command() {
let wasm = WasmBallistics::new();
let result = wasm.run_command("help").unwrap();
assert!(result.contains("Ballistics Engine"));
assert!(result.contains("trajectory"));
assert!(result.contains("zero"));
assert!(result.contains("monte-carlo"));
}
#[wasm_bindgen_test]
fn test_basic_trajectory_command() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308")
.unwrap();
assert!(result.contains("Trajectory Calculation Results"));
assert!(result.contains("Range"));
assert!(result.contains("Drop"));
assert!(result.contains("Velocity"));
}
#[wasm_bindgen_test]
fn test_trajectory_with_auto_zero() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --auto-zero 200")
.unwrap();
assert!(result.contains("Rifle zeroed at 200 yards"));
assert!(result.contains("MOA"));
assert!(result.contains("mrad"));
}
#[wasm_bindgen_test]
fn test_metric_units() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("--units metric trajectory -v 823 -b 0.475 -m 10.9 -d 7.82")
.unwrap();
assert!(result.contains("m/s"));
assert!(result.contains("meters"));
assert!(!result.contains("yards"));
assert!(!result.contains("fps"));
}
#[wasm_bindgen_test]
fn test_json_output_format() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 -o json")
.unwrap();
assert!(result.contains("\"trajectory\""));
assert!(result.contains("\"summary\""));
assert!(result.contains("range_yards"));
assert!(result.contains("drop_inches"));
assert!(result.contains("\"legend\""));
assert!(result.contains("\"axes\""));
assert!(result.contains("\"drift\""));
}
#[wasm_bindgen_test]
fn json_legend_drift_axis_matches_observed_crosswind_sign() {
let wasm = WasmBallistics::new();
let out = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 -o json")
.unwrap();
let doc: serde_json::Value = serde_json::from_str(&out).unwrap();
let drift_text = doc["legend"]["axes"]["drift"]
.as_str()
.expect("legend.axes.drift string");
assert!(
drift_text.contains("shooter's") && drift_text.contains("right"),
"legend.axes.drift wording changed; update this test's sign assumptions: {drift_text}"
);
assert!(drift_text.contains("positive"));
fn last_drift_inches(json: &str) -> f64 {
let doc: serde_json::Value = serde_json::from_str(json).unwrap();
doc["trajectory"]
.as_array()
.expect("trajectory array")
.last()
.expect("at least one trajectory point")["drift_inches"]
.as_f64()
.expect("drift_inches is a number")
}
let baseline = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --full -o json")
.unwrap();
let from_left = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --wind-speed 10 \
--wind-direction 270 --full -o json",
)
.unwrap();
let from_right = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --wind-speed 10 \
--wind-direction 90 --full -o json",
)
.unwrap();
assert_eq!(
last_drift_inches(&baseline),
0.0,
"a no-wind run must have zero lateral drift"
);
assert!(
last_drift_inches(&from_left) > 0.0,
"wind FROM the left (--wind-direction 270) must drift right (positive) per legend.axes.drift"
);
assert!(
last_drift_inches(&from_right) < 0.0,
"wind FROM the right (--wind-direction 90) must drift left (negative) per legend.axes.drift"
);
}
#[wasm_bindgen_test]
fn test_csv_output_format() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 -o csv")
.unwrap();
assert!(result.contains("Range(yards),Drop(inches)"));
assert!(result.contains(","));
assert!(!result.contains("Trajectory Calculation Results")); }
#[wasm_bindgen_test]
fn test_zero_command() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("zero -v 2700 -b 0.475 -m 168 -d 0.308 --target-distance 300")
.unwrap();
assert!(result.contains("Zero Calculation Results"));
assert!(result.contains("Target Distance"));
assert!(result.contains("MOA Adjustment"));
assert!(result.contains("Mrad Adjustment"));
}
#[wasm_bindgen_test]
fn test_monte_carlo_command() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 -n 100")
.unwrap();
assert!(result.contains("Monte Carlo Simulation Results"));
assert!(result.contains("Simulations Run: 100"));
assert!(result.contains("Range Statistics"));
assert!(result.contains("Impact Velocity Statistics"));
}
#[wasm_bindgen_test]
fn test_estimate_bc_command() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("estimate-bc -v 2700 -m 168 -d 0.308 --data \"100,2.5;200,10.2;300,23.5\"")
.unwrap();
assert!(result.contains("BC Estimation Results"));
assert!(result.contains("Estimated BC"));
assert!(result.contains("G1"));
assert!(result.contains("G7"));
assert!(result.contains("drop (3 pts)"));
}
#[wasm_bindgen_test]
fn test_estimate_bc_velocity_and_drag_model() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command(
"estimate-bc -v 2650 -m 77 -d 0.224 \
--velocity-data \"200,2270;400,1930;600,1610\" --drag-model g7",
)
.unwrap();
assert!(result.contains("BC Estimation Results"));
assert!(result.contains("G7"));
assert!(result.contains("velocity (3 pts)"));
assert!(!result.contains("G1"));
}
#[wasm_bindgen_test]
fn test_advanced_physics_flags() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 \
--enable-magnus --enable-coriolis --enable-spin-drift \
--twist-rate 10 --latitude 45",
)
.unwrap();
assert!(result.contains("Trajectory Calculation Results"));
}
#[wasm_bindgen_test]
fn omitted_twist_matches_miller_default_and_explicit_override_wins() {
let wasm = WasmBallistics::new();
let final_drift = |output: &str, key: &str| {
let json: serde_json::Value = serde_json::from_str(output).unwrap();
json["trajectory"].as_array().unwrap().last().unwrap()[key]
.as_f64()
.unwrap()
};
let command = "trajectory -v 2750 -b 0.219 -m 77 -d 0.224 --drag-model G7 \
--enable-spin-drift --max-range 500 -o json";
let default_twist = crate::stability::default_twist_inches(
0.224 * 0.0254,
77.0 * crate::constants::GRAINS_TO_KG,
2750.0 * 0.3048,
);
let omitted = wasm.run_command(command).unwrap();
let explicit_default = wasm
.run_command(&format!("{command} --twist-rate {default_twist:.17}"))
.unwrap();
let explicit_twelve = wasm
.run_command(&format!("{command} --twist-rate 12"))
.unwrap();
let omitted_drift = final_drift(&omitted, "drift_inches");
let default_drift = final_drift(&explicit_default, "drift_inches");
let twelve_drift = final_drift(&explicit_twelve, "drift_inches");
assert!((omitted_drift - default_drift).abs() < 1e-9);
assert!((omitted_drift - twelve_drift).abs() > 0.1);
let metric_command = "trajectory --units metric -v 838.2 -b 0.219 -m 4.98951607 \
-d 5.6896 --drag-model G7 --enable-spin-drift \
--max-range 457.2 -o json";
let metric_default_twist =
crate::stability::default_twist_inches(5.6896 * 0.001, 4.98951607 * 0.001, 838.2);
let omitted_metric = wasm.run_command(metric_command).unwrap();
let explicit_default_metric = wasm
.run_command(&format!(
"{metric_command} --twist-rate {:.17}",
metric_default_twist * 25.4
))
.unwrap();
assert!(
(final_drift(&omitted_metric, "drift_cm")
- final_drift(&explicit_default_metric, "drift_cm"))
.abs()
< 1e-9
);
}
#[wasm_bindgen_test]
fn test_environmental_conditions() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 \
--temperature 32 --pressure 25.0 --humidity 80 --altitude 5000 \
--wind-speed 10 --wind-direction 90",
)
.unwrap();
assert!(result.contains("Trajectory Calculation Results"));
}
#[wasm_bindgen_test]
fn test_full_trajectory_output() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --full --max-range 500")
.unwrap();
let lines: Vec<&str> = result.lines().collect();
let data_lines = lines.iter().filter(|l| l.contains("yd")).count();
assert!(data_lines > 5); }
#[wasm_bindgen_test]
fn test_powder_temperature_sensitivity() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 \
--use-powder-sensitivity --powder-temp 90 --powder-temp-sensitivity 1.5",
)
.unwrap();
assert!(result.contains("Trajectory Calculation Results"));
}
#[wasm_bindgen_test]
fn explicit_zero_velocity_ignores_linear_powder_adjustment() {
let wasm = WasmBallistics::new();
let command = "trajectory -v 2700 -b 0.475 -m 168 -d 0.308 \
--auto-zero 300 --zero-velocity 2400 --zero-temperature 20 \
--max-range 100";
let zero_line = |output: &str| output.lines().next().unwrap().to_string();
let without_linear = zero_line(&wasm.run_command(command).unwrap());
let with_linear = zero_line(
&wasm
.run_command(&format!(
"{command} --use-powder-sensitivity --powder-temp-sensitivity 5"
))
.unwrap(),
);
assert!(without_linear.starts_with("Rifle zeroed at 300 yards"));
assert_eq!(with_linear, without_linear);
}
#[wasm_bindgen_test]
fn auto_zero_inherits_explicit_shot_day_powder_temperature() {
let wasm = WasmBallistics::new();
let command = "trajectory -v 2650 -b 0.19 -m 77 -d 0.224 --drag-model g7 \
--temperature 32 --powder-temp 68 \
--powder-temp-curve 32:2650,77:2720 \
--auto-zero 100 --max-range 100";
let adjustment = |output: &str| {
output
.lines()
.next()
.and_then(|line| line.split_once("(Adjustment: "))
.map(|(_, value)| value.to_string())
.expect("successful auto-zero adjustment")
};
let inherited = adjustment(&wasm.run_command(command).unwrap());
let explicit = adjustment(
&wasm
.run_command(&format!("{command} --zero-powder-temp 68"))
.unwrap(),
);
assert_eq!(inherited, explicit);
let zero_air = adjustment(
&wasm
.run_command(&format!("{command} --zero-temperature 77"))
.unwrap(),
);
let explicit_zero_air = adjustment(
&wasm
.run_command(&format!(
"{command} --zero-temperature 77 --zero-powder-temp 77"
))
.unwrap(),
);
assert_eq!(zero_air, explicit_zero_air);
}
#[wasm_bindgen_test]
fn metric_default_powder_temperature_represents_70_fahrenheit() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command(
"trajectory --units metric -v 800 -b 0.3 -m 10 -d 7.62 \
--temperature 30 --use-powder-sensitivity \
--powder-temp-sensitivity 1 --max-range 1 -o json",
)
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let actual_velocity = json["trajectory"][0]["velocity_mps"].as_f64().unwrap();
let reference_temp_c = (70.0 - 32.0) * 5.0 / 9.0;
let expected_velocity = 800.0 + (30.0 - reference_temp_c);
assert!((actual_velocity - expected_velocity).abs() < 1e-9);
}
#[wasm_bindgen_test]
fn test_shooting_angle() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --shooting-angle 15")
.unwrap();
assert!(result.contains("Trajectory Calculation Results"));
}
#[wasm_bindgen_test]
fn test_drag_models() {
let wasm = WasmBallistics::new();
let g1_result = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --drag-model G1")
.unwrap();
assert!(g1_result.contains("Trajectory Calculation Results"));
let g7_result = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --drag-model G7")
.unwrap();
assert!(g7_result.contains("Trajectory Calculation Results"));
}
#[wasm_bindgen_test]
fn test_invalid_command() {
let wasm = WasmBallistics::new();
let result = wasm.run_command("invalid-command").unwrap();
assert!(result.contains("Error: Unknown command"));
assert!(result.contains("help"));
}
#[wasm_bindgen_test]
fn test_invalid_parameters() {
let wasm = WasmBallistics::new();
let result = wasm.run_command("trajectory -v not-a-number -b 0.475 -m 168 -d 0.308");
assert!(result.is_err());
}
#[wasm_bindgen_test]
fn test_metric_monte_carlo() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("--units metric monte-carlo -v 823 -b 0.475 -m 10.9 -d 7.82 -n 50")
.unwrap();
assert!(result.contains("m/s"));
assert!(result.contains("meters"));
assert!(!result.contains("yards"));
assert!(!result.contains("fps"));
}
#[wasm_bindgen_test]
fn test_metric_zero_calculation() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command(
"--units metric zero -v 823 -b 0.475 -m 10.9 -d 7.82 --target-distance 200",
)
.unwrap();
assert!(result.contains("200 meters"));
assert!(!result.contains("yards"));
}
#[wasm_bindgen_test]
fn test_bc_segments() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --use-bc-segments")
.unwrap();
assert!(result.contains("Trajectory Calculation Results"));
}
#[wasm_bindgen_test]
fn test_trajectory_sampling() {
let wasm = WasmBallistics::new();
let result = wasm.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --sample-trajectory --sample-interval 25"
).unwrap();
assert!(result.contains("Trajectory Calculation Results"));
}
#[wasm_bindgen_test]
fn test_integration_methods() {
let wasm = WasmBallistics::new();
let rk4_result = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308")
.unwrap();
assert!(rk4_result.contains("Trajectory Calculation Results"));
let euler_result = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --use-euler")
.unwrap();
assert!(euler_result.contains("Trajectory Calculation Results"));
}
#[wasm_bindgen_test]
fn test_estimate_bc_no_data() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("estimate-bc -v 2700 -m 168 -d 0.308")
.unwrap();
assert!(result.contains("Error: No data provided"));
assert!(result.contains("Example"));
}
#[wasm_bindgen_test]
fn test_zero_with_sight_height() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command(
"zero -v 2700 -b 0.475 -m 168 -d 0.308 --target-distance 200 --sight-height 2.5",
)
.unwrap();
assert!(result.contains("Sight Height: 2.5 inches"));
}
#[wasm_bindgen_test]
fn test_monte_carlo_with_variations() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command(
"monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 -n 50 \
--velocity-std 15 --angle-std 0.2 --bc-std 0.02 \
--wind-speed-std 3 --wind-dir-std 10",
)
.unwrap();
assert!(result.contains("Monte Carlo Simulation Results"));
assert!(result.contains("Std Dev"));
}
#[wasm_bindgen_test]
fn test_command_parsing() {
let wasm = WasmBallistics::new();
let result1 = wasm
.run_command("ballistics trajectory -v 2700 -b 0.475 -m 168 -d 0.308")
.unwrap();
assert!(result1.contains("Trajectory Calculation Results"));
let result2 = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308")
.unwrap();
assert!(result2.contains("Trajectory Calculation Results"));
let result3 = wasm
.run_command("./ballistics trajectory -v 2700 -b 0.475 -m 168 -d 0.308")
.unwrap();
assert!(result3.contains("Trajectory Calculation Results"));
}
#[wasm_bindgen_test]
fn test_empty_command() {
let wasm = WasmBallistics::new();
let result = wasm.run_command("").unwrap();
assert!(result.contains("Ballistics Engine"));
}
#[wasm_bindgen_test]
fn test_help_variants() {
let wasm = WasmBallistics::new();
let help1 = wasm.run_command("help").unwrap();
let help2 = wasm.run_command("--help").unwrap();
let help3 = wasm.run_command("-h").unwrap();
assert!(help1.contains("Ballistics Engine"));
assert!(help2.contains("Ballistics Engine"));
assert!(help3.contains("Ballistics Engine"));
}
#[wasm_bindgen_test]
fn ignore_ground_impact_reaches_full_range_where_default_truncates() {
fn parse_max_range_yards(output: &str) -> f64 {
output
.lines()
.find_map(|l| l.strip_prefix("Max Range: "))
.and_then(|rest| rest.split_whitespace().next())
.and_then(|n| n.parse::<f64>().ok())
.expect("Max Range line present in trajectory output")
}
let wasm = WasmBallistics::new();
let default_result = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 1000")
.unwrap();
let default_max_range = parse_max_range_yards(&default_result);
assert!(
default_max_range < 900.0,
"expected the default run to truncate well short of 1000 yd, got {default_max_range}"
);
let ignore_result = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 1000 \
--ignore-ground-impact",
)
.unwrap();
let ignore_max_range = parse_max_range_yards(&ignore_result);
assert!(
ignore_max_range >= 999.0,
"expected --ignore-ground-impact to reach ~1000 yd, got {ignore_max_range}"
);
}
#[wasm_bindgen_test]
fn muzzle_height_warning_appears_only_above_the_1000m_threshold() {
let wasm = WasmBallistics::new();
let warned = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --muzzle-height 1000000")
.unwrap();
assert!(warned.starts_with("WARNING"));
assert!(warned.contains("--altitude"));
assert!(warned.contains("--ignore-ground-impact"));
let unwarned = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --muzzle-height 2400")
.unwrap();
assert!(!unwarned.contains("WARNING"));
}
#[wasm_bindgen_test]
fn lead_output_json_parses_with_sane_intercept_range() {
let wasm = WasmBallistics::new();
let output = wasm
.run_command("lead --target-speed 10 --target-angle 0 --range 500 -o json")
.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
let range = json["range"].as_f64().unwrap();
let intercept_range = json["intercept_range"].as_f64().unwrap();
assert!(intercept_range >= range);
assert_eq!(json["distance_unit"], "yd");
assert_eq!(json["adjustment_unit"], "mil");
}
#[wasm_bindgen_test]
fn test_all_physics_flags_combined() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 \
--enable-magnus --enable-coriolis --enable-spin-drift \
--enable-wind-shear --enable-pitch-damping --enable-precession \
--sample-trajectory --use-bc-segments --use-powder-sensitivity \
--twist-rate 10 --latitude 45",
)
.unwrap();
assert!(result.contains("Trajectory Calculation Results"));
}
#[wasm_bindgen_test]
fn canted_90_vertical_and_lateral_misses_are_symmetric() {
let wasm = WasmBallistics::new();
let out = wasm
.run_command(
"trajectory -b 0.19 -m 77 -d 0.224 --drag-model g7 --max-range 200 \
--sight-height 2.48 --ignore-ground-impact -v 2650 --auto-zero 100 \
--cant 90 --full -o csv",
)
.unwrap();
let mut muzzle_drop: Option<f64> = None;
let mut drop100: Option<f64> = None;
let mut drift100: Option<f64> = None;
for line in out.lines() {
let cols: Vec<&str> = line.split(',').collect();
if cols.len() < 3 {
continue;
}
if let (Ok(dist), Ok(drop), Ok(drift)) = (
cols[0].trim().parse::<f64>(),
cols[1].trim().parse::<f64>(),
cols[2].trim().parse::<f64>(),
) {
if dist.abs() < 1.0 && muzzle_drop.is_none() {
muzzle_drop = Some(drop);
}
if (dist - 100.0).abs() < 6.0 && drop100.is_none() {
drop100 = Some(drop);
drift100 = Some(drift);
}
}
}
let muzzle_drop = muzzle_drop.expect("muzzle row");
let (d, w) = (drop100.expect("100yd drop"), drift100.expect("100yd drift"));
assert!(
muzzle_drop.abs() < 0.3,
"muzzle drop must be ~0 at 90 deg cant (bore beside scope), got {muzzle_drop}"
);
let ratio = d.abs() / w.abs();
assert!(
(0.95..=1.05).contains(&ratio),
"90-deg cant symmetry broken: drop {d} vs drift {w} (ratio {ratio})"
);
}
#[wasm_bindgen_test]
fn target_speed_adds_ring_column_to_trajectory_table() {
let wasm = WasmBallistics::new();
let without = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --full --max-range 500")
.unwrap();
assert!(!without.contains("Ring"), "Ring column must not appear without --target-speed");
let with_ring = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --full --max-range 500 \
--target-speed 5",
)
.unwrap();
assert!(with_ring.contains("Ring"), "table should carry a Ring column:\n{with_ring}");
assert!(with_ring.contains("mil"), "ring values should be labeled mil");
}
#[wasm_bindgen_test]
fn target_speed_adds_mover_ring_m_to_trajectory_json() {
let wasm = WasmBallistics::new();
let without = wasm
.run_command(
"trajectory --units metric -v 823 -b 0.475 -m 10.9 -d 7.82 --full \
--max-range 400 -o json",
)
.unwrap();
assert!(
!without.contains("mover_ring"),
"mover_ring fields must not appear without --target-speed"
);
let result = wasm
.run_command(
"trajectory --units metric -v 823 -b 0.475 -m 10.9 -d 7.82 --full \
--max-range 400 --target-speed 3 -o json",
)
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let points = json["trajectory"].as_array().expect("trajectory array");
assert!(points.len() > 5);
let mut checked = 0;
for p in points {
let time = p["time_seconds"].as_f64().expect("time_seconds");
let ring_m = p["mover_ring_m"].as_f64().expect("mover_ring_m present");
let expected = 3.0 * time;
assert!(
(ring_m - expected).abs() < 1e-9,
"ring_m {ring_m} != target_speed_mps*time {expected} at t={time}"
);
checked += 1;
}
assert!(checked > 5);
let muzzle = &points[0];
assert_eq!(muzzle["range_meters"].as_f64().unwrap(), 0.0);
assert_eq!(muzzle["mover_ring_m"].as_f64().unwrap(), 0.0);
assert!(muzzle.get("mover_ring_mil").is_none());
}
#[wasm_bindgen_test]
fn lead_powder_curve_matches_manually_resolved_velocity() {
let wasm = WasmBallistics::new();
let resolved_json = wasm
.run_command(
"trajectory -v 2700 -b 0.19 -m 77 -d 0.224 --drag-model g7 --max-range 5 \
--sight-height 2.48 --powder-temp-curve 40:2620,70:2700,100:2760 \
--powder-temp 55 --full -o json",
)
.unwrap();
let resolved_json: serde_json::Value = serde_json::from_str(&resolved_json).unwrap();
let resolved_velocity = resolved_json["trajectory"][0]["velocity_fps"].as_f64().unwrap();
assert!(
(resolved_velocity - 2660.0).abs() < 0.5,
"sanity: 55F should interpolate to ~2660 fps, got {resolved_velocity}"
);
let with_curve = wasm
.run_command(
"lead -v 2700 -b 0.19 -m 77 -d 0.224 --drag-model g7 --sight-height 2.48 \
--powder-temp-curve 40:2620,70:2700,100:2760 --powder-temp 55 \
--target-speed 5 --range 300 -o json",
)
.unwrap();
let manual = wasm
.run_command(&format!(
"lead -v {resolved_velocity} -b 0.19 -m 77 -d 0.224 --drag-model g7 \
--sight-height 2.48 --target-speed 5 --range 300 -o json"
))
.unwrap();
let with_curve: serde_json::Value = serde_json::from_str(&with_curve).unwrap();
let manual: serde_json::Value = serde_json::from_str(&manual).unwrap();
for field in ["lead_mil", "lead_moa", "lead", "tof_s", "intercept_range"] {
let a = with_curve[field].as_f64().unwrap_or_else(|| panic!("{field} missing"));
let b = manual[field].as_f64().unwrap_or_else(|| panic!("{field} missing"));
assert!(
(a - b).abs() < 1e-5,
"field '{field}' differs between curve-corrected and manually-resolved lead runs: {a} vs {b}"
);
}
assert_eq!(
with_curve["iterations"], manual["iterations"],
"iterations should match exactly (integer count)"
);
}
#[wasm_bindgen_test]
fn lead_no_env_flags_output_is_byte_identical_to_pre_env_build() {
const GOLDEN_IMPERIAL_JSON: &str = r#"{
"adjustment_unit": "mil",
"distance_unit": "yd",
"intercept_range": 399.9987936,
"iterations": 0,
"lead": 1.267051911310233,
"lead_mil": 3.1676393318758076,
"lead_moa": 10.890344022989026,
"range": 400.0,
"target_angle_deg": 90.0,
"target_speed": 5.0,
"target_speed_unit": "mph",
"tof_s": 0.5183409815796777
}"#;
const GOLDEN_METRIC_JSON: &str = r#"{
"adjustment_unit": "mil",
"distance_unit": "m",
"intercept_range": 350.0,
"iterations": 0,
"lead": 1.4774105669002737,
"lead_mil": 4.221173048286496,
"lead_moa": 14.512392940008974,
"range": 350.0,
"target_angle_deg": 90.0,
"target_speed": 3.0,
"target_speed_unit": "m/s",
"tof_s": 0.49247018896675787
}"#;
const GOLDEN_IMPERIAL_TABLE: &str = "Moving-Target Lead\n\
===================\n\
Target: 5.0 mph at 90\u{b0} (0=away, 90=left-to-right, 180=toward, 270=right-to-left;\n\
positive lead = hold in direction of travel)\n\
\n\
Range: 400 yd\n\
Time of Flight: 0.518 s\n\
Lead: 1.27 yd (3.17 MIL / 10.89 MOA)\n\
Intercept Range: 400.0 yd\n\
Iterations: 0\n";
let wasm = WasmBallistics::new();
let g1 = wasm
.run_command(
"lead -v 2700 -b 0.475 -m 168 -d 0.308 --target-speed 5 --range 400 -o json",
)
.unwrap();
assert_eq!(g1, GOLDEN_IMPERIAL_JSON, "imperial JSON drifted from pre-env-flags build");
let g2 = wasm
.run_command(
"--units metric lead -v 823 -b 0.475 -m 10.9 -d 7.82 --target-speed 3 \
--range 350 -o json",
)
.unwrap();
assert_eq!(g2, GOLDEN_METRIC_JSON, "metric JSON drifted from pre-env-flags build");
let g3 = wasm
.run_command("lead -v 2700 -b 0.475 -m 168 -d 0.308 --target-speed 5 --range 400")
.unwrap();
assert_eq!(g3, GOLDEN_IMPERIAL_TABLE, "imperial table drifted from pre-env-flags build");
}
#[wasm_bindgen_test]
fn lead_temperature_is_live_hot_air_shortens_lead() {
let wasm = WasmBallistics::new();
let base = "lead -v 2700 -b 0.475 -m 168 -d 0.308 --target-speed 5 --range 400 -o json";
let std_out = wasm.run_command(base).unwrap();
let hot_out = wasm
.run_command(&format!("{base} --temperature 100"))
.unwrap();
let std_json: serde_json::Value = serde_json::from_str(&std_out).unwrap();
let hot_json: serde_json::Value = serde_json::from_str(&hot_out).unwrap();
let std_tof = std_json["tof_s"].as_f64().unwrap();
let hot_tof = hot_json["tof_s"].as_f64().unwrap();
let std_mil = std_json["lead_mil"].as_f64().unwrap();
let hot_mil = hot_json["lead_mil"].as_f64().unwrap();
assert!(
hot_tof < std_tof,
"hot air must shorten TOF: hot {hot_tof} vs standard {std_tof}"
);
assert!(
hot_mil < std_mil && (std_mil - hot_mil) / std_mil > 1e-4,
"hot air must shrink the hold by more than float noise: hot {hot_mil} vs standard {std_mil}"
);
}
#[wasm_bindgen_test]
fn lead_headwind_is_live_and_lengthens_tof() {
let wasm = WasmBallistics::new();
let base = "lead -v 2700 -b 0.475 -m 168 -d 0.308 --target-speed 5 --range 400 -o json";
let calm_out = wasm.run_command(base).unwrap();
let wind_out = wasm
.run_command(&format!("{base} --wind-speed 20 --wind-direction 0"))
.unwrap();
let calm_json: serde_json::Value = serde_json::from_str(&calm_out).unwrap();
let wind_json: serde_json::Value = serde_json::from_str(&wind_out).unwrap();
let calm_tof = calm_json["tof_s"].as_f64().unwrap();
let wind_tof = wind_json["tof_s"].as_f64().unwrap();
assert!(
wind_tof > calm_tof && (wind_tof - calm_tof) / calm_tof > 1e-4,
"a 20 mph headwind must lengthen TOF beyond float noise: wind {wind_tof} vs calm {calm_tof}"
);
}
#[wasm_bindgen_test]
fn lead_without_powder_flags_uses_velocity_verbatim() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command(
"trajectory -v 2700 -b 0.19 -m 77 -d 0.224 --drag-model g7 --max-range 5 \
--sight-height 2.48 --temperature 100 --full -o json",
)
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let velocity = json["trajectory"][0]["velocity_fps"].as_f64().unwrap();
assert!(
(velocity - 2700.0).abs() < 1e-3,
"no powder flags should keep -v 2700 verbatim regardless of --temperature, got {velocity}"
);
}
#[wasm_bindgen_test]
fn target_speed_out_of_range_is_rejected_on_both_commands() {
let wasm = WasmBallistics::new();
let err = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --target-speed 301")
.unwrap_err();
assert!(
err.as_string().unwrap_or_default().contains("--target-speed must be between 0 and 300"),
"trajectory should reject 301: {err:?}"
);
let err = wasm
.run_command(
"lead -v 2700 -b 0.475 -m 168 -d 0.308 --target-speed 1000000000 --range 400",
)
.unwrap_err();
assert!(
err.as_string().unwrap_or_default().contains("--target-speed must be between 0 and 300"),
"lead should reject 1e9 mph: {err:?}"
);
assert!(wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --target-speed 300 --max-range 200")
.is_ok());
}
#[wasm_bindgen_test]
fn powder_temp_curve_duplicate_temperatures_rejected() {
let wasm = WasmBallistics::new();
for cmd in [
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 5 \
--powder-temp-curve 40:2620,40:2700,100:2760",
"lead -v 2700 -b 0.475 -m 168 -d 0.308 --target-speed 5 --range 300 \
--powder-temp-curve 40:2620,40:2700,100:2760",
] {
let err = wasm.run_command(cmd).unwrap_err();
assert!(
err.as_string()
.unwrap_or_default()
.contains("--powder-temp-curve has duplicate temperatures"),
"duplicate curve temps must be rejected for `{cmd}`: {err:?}"
);
}
}
#[wasm_bindgen_test]
fn ring_table_honors_moa_adjustment_unit() {
let wasm = WasmBallistics::new();
let base = "trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 400 \
--target-speed 300 --full";
let json_out = wasm.run_command(&format!("{base} -o json")).unwrap();
let json: serde_json::Value = serde_json::from_str(&json_out).unwrap();
let mil = json["trajectory"]
.as_array()
.expect("points")
.last()
.expect("last point")["mover_ring_mil"]
.as_f64()
.expect("mover_ring_mil");
assert!(mil > 160.0, "sensitivity precondition: ring_mil > 160, got {mil}");
let last_ring_cell = |table: &str, unit: &str| -> String {
let suffix = format!(" {unit}");
table
.lines()
.filter_map(|line| {
let cell = line.rsplit('|').next()?.trim();
cell.strip_suffix(suffix.as_str()).map(str::to_string)
})
.last()
.unwrap_or_else(|| panic!("no ring cell ending in '{suffix}' found:\n{table}"))
};
let moa_table = wasm
.run_command(&format!("{base} --adjustment-unit moa"))
.unwrap();
assert!(moa_table.contains(" moa"), "moa run must label ring cells moa");
assert!(!moa_table.contains(" mil"), "moa run must not label ring cells mil");
assert_eq!(
last_ring_cell(&moa_table, "moa"),
format!("{:.2}", mil * 3.438),
"Ring moa cell must be exactly mil x 3.438, 2dp-rounded"
);
let mil_table = wasm.run_command(base).unwrap();
assert_eq!(
last_ring_cell(&mil_table, "mil"),
format!("{:.2}", mil),
"default (mil) Ring cell must match JSON mover_ring_mil, 2dp-rounded"
);
}
#[wasm_bindgen_test]
fn test_units_smoa_and_clicks_accepted() {
let base = "trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 300 --target-speed 3";
let smoa = WasmBallistics::new()
.run_command(&format!("{base} --adjustment-unit smoa"))
.unwrap();
assert!(smoa.contains("Ring(smoa)"), "{smoa}");
let iphy = WasmBallistics::new()
.run_command(&format!("{base} --adjustment-unit iphy"))
.unwrap();
assert!(iphy.contains("Ring(iphy)"), "{iphy}");
let smoa_json = WasmBallistics::new()
.run_command(&format!("{base} --adjustment-unit smoa -o json"))
.unwrap();
let iphy_json = WasmBallistics::new()
.run_command(&format!("{base} --adjustment-unit iphy -o json"))
.unwrap();
assert_eq!(smoa_json, iphy_json, "smoa/iphy JSON is unit-in-name-only (mil-based fields)");
let clicks = WasmBallistics::new()
.run_command(&format!("{base} --adjustment-unit clicks --elevation-click-value 0.25moa"))
.unwrap();
assert!(clicks.contains("Ring(clicks)"), "{clicks}");
let last_ring_cell = clicks
.lines()
.filter(|l| l.contains('|') && !l.starts_with("Range") && !l.starts_with("---"))
.last()
.map(|l| l.rsplit('|').next().unwrap().trim().to_string())
.unwrap_or_default();
assert!(
last_ring_cell.parse::<i64>().is_ok(),
"clicks Ring cell must be a bare whole integer, got {last_ring_cell:?} in:\n{clicks}"
);
let missing = WasmBallistics::new()
.run_command(&format!("{base} --adjustment-unit clicks"))
.unwrap_err();
let msg = missing.as_string().unwrap_or_default();
assert!(
msg.contains("--adjustment-unit clicks requires a turret elevation graduation"),
"{msg}"
);
assert!(msg.contains("--elevation-click-value"), "{msg}");
}
#[wasm_bindgen_test]
fn clicks_rejects_malformed_elevation_click_value() {
let err = WasmBallistics::new()
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 300 --target-speed 3 \
--adjustment-unit clicks --elevation-click-value notaclick",
)
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert!(msg.contains("needs a unit suffix"), "{msg}");
}
#[wasm_bindgen_test]
fn clicks_validates_windage_click_value_even_though_unused_by_ring() {
let err = WasmBallistics::new()
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 300 --target-speed 3 \
--adjustment-unit clicks --elevation-click-value 0.25moa \
--windage-click-value bogus",
)
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert!(msg.contains("needs a unit suffix"), "{msg}");
let ok = WasmBallistics::new()
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 300 --target-speed 3 \
--adjustment-unit clicks --elevation-click-value 0.25moa \
--windage-click-value 0.1mil",
)
.unwrap();
assert!(ok.contains("Ring(clicks)"), "{ok}");
}
#[wasm_bindgen_test]
fn invalid_adjustment_unit_lists_all_five_choices() {
let err = WasmBallistics::new()
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 300 --target-speed 3 \
--adjustment-unit bogus",
)
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert!(msg.contains("mil, moa, smoa, iphy, or clicks"), "{msg}");
}
#[wasm_bindgen_test]
fn ring_csv_and_json_fields_stay_mil_only_under_clicks() {
let base = "trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 300 --target-speed 3 \
--adjustment-unit clicks --elevation-click-value 0.25moa";
let csv = WasmBallistics::new().run_command(&format!("{base} -o csv")).unwrap();
assert!(csv.contains("Ring(mil)"), "{csv}");
let json = WasmBallistics::new().run_command(&format!("{base} -o json")).unwrap();
let json: serde_json::Value = serde_json::from_str(&json).unwrap();
let points = json["trajectory"].as_array().expect("trajectory array");
assert!(points.iter().any(|p| p.get("mover_ring_mil").is_some()));
}
#[wasm_bindgen_test]
fn lead_accepts_smoa_and_iphy_with_real_values() {
let smoa_json = WasmBallistics::new()
.run_command(
"lead -v 2700 -b 0.475 -m 168 -d 0.308 --target-speed 5 --range 300 \
--adjustment-unit smoa -o json",
)
.unwrap();
let json: serde_json::Value = serde_json::from_str(&smoa_json).unwrap();
let lead_mil = json["lead_mil"].as_f64().unwrap();
let lead_smoa = json["lead_smoa"].as_f64().unwrap();
assert!(
(lead_smoa - lead_mil * 3.6).abs() < 1e-6,
"lead_smoa must be lead_mil * 3.6 exactly: {lead_smoa} vs {}",
lead_mil * 3.6
);
assert_eq!(json["adjustment_unit"], "smoa");
let smoa_table = WasmBallistics::new()
.run_command(
"lead -v 2700 -b 0.475 -m 168 -d 0.308 --target-speed 5 --range 300 \
--adjustment-unit smoa",
)
.unwrap();
assert!(smoa_table.contains("SMOA"), "{smoa_table}");
let iphy_table = WasmBallistics::new()
.run_command(
"lead -v 2700 -b 0.475 -m 168 -d 0.308 --target-speed 5 --range 300 \
--adjustment-unit iphy",
)
.unwrap();
assert!(iphy_table.contains("IPHY"), "{iphy_table}");
}
#[wasm_bindgen_test]
fn lead_rejects_clicks_out_of_scope() {
let err = WasmBallistics::new()
.run_command(
"lead -v 2700 -b 0.475 -m 168 -d 0.308 --target-speed 5 --range 300 \
--adjustment-unit clicks",
)
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert_eq!(
msg,
"error: --adjustment-unit clicks is currently supported for trajectory and come-ups only (MBA-1355)"
);
}
const FLAT_CD_CSV: &str = "mach,cd\n0.0,0.5\n0.5,0.5\n1.0,0.5\n1.5,0.5\n2.0,0.5\n3.0,0.5\n";
#[wasm_bindgen_test]
fn drag_table_not_loaded_by_default() {
let wasm = WasmBallistics::new();
assert!(!wasm.has_drag_table());
}
#[wasm_bindgen_test]
fn load_drag_table_reports_point_count_and_mach_range() {
let wasm = WasmBallistics::new();
let summary = wasm.load_drag_table(FLAT_CD_CSV.as_bytes()).unwrap();
assert!(wasm.has_drag_table());
assert!(summary.contains("6 points"), "got: {summary}");
assert!(summary.contains("0.000-3.000"), "got: {summary}");
}
#[wasm_bindgen_test]
fn load_drag_table_replaces_previous_table() {
let wasm = WasmBallistics::new();
wasm.load_drag_table(FLAT_CD_CSV.as_bytes()).unwrap();
assert!(wasm.has_drag_table());
let summary = wasm
.load_drag_table(b"mach,cd\n0.5,0.30\n2.5,0.25\n")
.unwrap();
assert!(wasm.has_drag_table());
assert!(summary.contains("2 points"), "got: {summary}");
}
#[wasm_bindgen_test]
fn drag_table_live_change_alters_trajectory_output() {
let command = "trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 100 -o json";
let unloaded = WasmBallistics::new();
let baseline = unloaded.run_command(command).unwrap();
let loaded = WasmBallistics::new();
loaded.load_drag_table(FLAT_CD_CSV.as_bytes()).unwrap();
let with_table = loaded.run_command(command).unwrap();
assert_ne!(
baseline, with_table,
"loading a drag table must change trajectory output for identical args"
);
}
#[wasm_bindgen_test]
fn drag_table_live_change_alters_zero_lead_and_monte_carlo() {
let zero_cmd = "zero -v 2700 -b 0.475 -m 168 -d 0.308 --target-distance 300";
let lead_cmd =
"lead -v 2700 -b 0.475 -m 168 -d 0.308 --target-speed 10 --range 300 -o json";
let mc_cmd = "monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 -n 50";
for command in [zero_cmd, lead_cmd, mc_cmd] {
let unloaded = WasmBallistics::new();
let baseline = unloaded.run_command(command).unwrap();
let loaded = WasmBallistics::new();
loaded.load_drag_table(FLAT_CD_CSV.as_bytes()).unwrap();
let with_table = loaded.run_command(command).unwrap();
assert_ne!(
baseline, with_table,
"loading a drag table must change `{command}` output"
);
}
}
#[wasm_bindgen_test]
fn clear_drag_table_reverts_trajectory_output_to_g7_baseline() {
let command = "trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 100 -o json";
let baseline = WasmBallistics::new().run_command(command).unwrap();
let engine = WasmBallistics::new();
engine.load_drag_table(FLAT_CD_CSV.as_bytes()).unwrap();
let with_table = engine.run_command(command).unwrap();
assert_ne!(
baseline, with_table,
"sanity: the loaded CDM run must differ from the G7 baseline"
);
assert!(
engine.clear_drag_table(),
"clear must report it unloaded a table"
);
assert!(!engine.has_drag_table());
let after_clear = engine.run_command(command).unwrap();
assert_eq!(
baseline, after_clear,
"after clearDragTable the solve must revert byte-for-byte to the G7-BC baseline"
);
}
#[wasm_bindgen_test]
fn clear_drag_table_reports_prior_state_and_is_idempotent() {
let engine = WasmBallistics::new();
assert!(
!engine.clear_drag_table(),
"no table loaded → clear returns false"
);
engine.load_drag_table(FLAT_CD_CSV.as_bytes()).unwrap();
assert!(engine.has_drag_table());
assert!(
engine.clear_drag_table(),
"a loaded table → clear returns true"
);
assert!(
!engine.has_drag_table(),
"after clear, no table remains loaded"
);
assert!(
!engine.clear_drag_table(),
"a second clear is an idempotent false no-op"
);
}
#[wasm_bindgen_test]
fn drag_table_unloaded_output_is_byte_identical_to_pre_change_build() {
const GOLDEN: &str = "Trajectory Calculation Results\n\
==============================\n\
\n\
Range | Drop | Drift | Velocity | Energy | Time\n\
------|------|-------|----------|--------|------\n\
0 yd | 2.0 in | 0.0 in | 2700 fps | 2719 ft-lb | 0.000 s\n\
55 yd | 2.7 in | 0.0 in | 2595 fps | 2512 ft-lb | 0.062 s\n\
100 yd | 4.5 in | 0.0 in | 2510 fps | 2350 ft-lb | 0.115 s\n\
\n\
Max Range: 100 yards\n\
Max Height: 60.0 inches\n\
Time of Flight: 0.12 seconds\n\
Impact Velocity: 2510 fps\n";
let wasm = WasmBallistics::new();
assert!(!wasm.has_drag_table());
let out = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 100")
.unwrap();
assert_eq!(
out, GOLDEN,
"unloaded trajectory output drifted from the pre-MBA-1328 build"
);
}
#[wasm_bindgen_test]
fn load_drag_table_rejects_malformed_csv_with_parser_message() {
let wasm = WasmBallistics::new();
let err = wasm
.load_drag_table(b"0.5,0.23\n1.0,notanumber\n")
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert!(
msg.contains("line 2"),
"expected the DragTable::from_csv_str parser's line-numbered message, got: {msg}"
);
assert!(
!wasm.has_drag_table(),
"a failed load must not leave a table installed"
);
}
#[wasm_bindgen_test]
fn load_drag_table_rejects_invalid_utf8_cleanly() {
let wasm = WasmBallistics::new();
let err = wasm
.load_drag_table(&[0xFF, 0xFE, 0x00, 0x00])
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert!(
msg.contains("UTF-8"),
"expected a clean UTF-8 decode error, got: {msg}"
);
assert!(!wasm.has_drag_table());
}
#[wasm_bindgen_test]
fn drag_table_solve_succeeds_without_explicit_bc_flag() {
let wasm = WasmBallistics::new();
wasm.load_drag_table(FLAT_CD_CSV.as_bytes()).unwrap();
let trajectory = wasm
.run_command("trajectory -v 2700 -m 168 -d 0.308 --max-range 100")
.unwrap();
assert!(trajectory.contains("Trajectory Calculation Results"));
let zero = wasm
.run_command("zero -v 2700 -m 168 -d 0.308 --target-distance 300")
.unwrap();
assert!(zero.contains("Zero Calculation Results"));
let lead = wasm
.run_command("lead -v 2700 -m 168 -d 0.308 --target-speed 10 --range 300")
.unwrap();
assert!(lead.contains("Moving-Target Lead"));
let monte_carlo = wasm
.run_command("monte-carlo -v 2700 -m 168 -d 0.308 -n 50")
.unwrap();
assert!(monte_carlo.contains("Monte Carlo Simulation Results"));
}
#[wasm_bindgen_test]
fn cd_scale_without_a_loaded_drag_table_errors_on_every_command() {
for command in [
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 100 --cd-scale 1.1",
"zero -v 2700 -b 0.475 -m 168 -d 0.308 --target-distance 300 --cd-scale 1.1",
"monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 -n 50 --cd-scale 1.1",
] {
let err = WasmBallistics::new().run_command(command).unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert_eq!(
msg,
"--cd-scale requires --drag-table",
"command: {command}"
);
}
}
#[wasm_bindgen_test]
fn cd_scale_without_a_loaded_drag_table_errors_on_wez() {
let err = WasmBallistics::new()
.run_command(
"monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 --wez --target-size 18x30 \
-n 20 --wez-start 200 --wez-end 300 --wez-step 100 --cd-scale 1.1",
)
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert_eq!(msg, "--cd-scale requires --drag-table");
}
#[wasm_bindgen_test]
fn cd_scale_accepted_with_a_loaded_table_and_shifts_trajectory_output() {
let wasm = WasmBallistics::new();
wasm.load_drag_table(FLAT_CD_CSV.as_bytes()).unwrap();
let neutral = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 100 --cd-scale 1.0 -o json",
)
.unwrap();
let scaled = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 100 --cd-scale 1.1 -o json",
)
.unwrap();
assert_ne!(
neutral, scaled,
"--cd-scale 1.1 must change trajectory output relative to the neutral 1.0"
);
let omitted = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 100 -o json")
.unwrap();
assert_eq!(
omitted, neutral,
"omitting --cd-scale must be byte-identical to an explicit --cd-scale 1.0"
);
}
#[wasm_bindgen_test]
fn cd_scale_shifts_zero_and_monte_carlo_and_wez_output() {
let zero_cmd_neutral =
"zero -v 2700 -b 0.475 -m 168 -d 0.308 --target-distance 300 --cd-scale 1.0";
let zero_cmd_scaled =
"zero -v 2700 -b 0.475 -m 168 -d 0.308 --target-distance 300 --cd-scale 1.1";
let mc_cmd_neutral = "monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 -n 50 --cd-scale 1.0";
let mc_cmd_scaled = "monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 -n 50 --cd-scale 1.1";
let wez_cmd_neutral = "monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 --wez \
--target-size 18x30 -n 20 --wez-start 200 --wez-end 300 --wez-step 100 \
--cd-scale 1.0";
let wez_cmd_scaled = "monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 --wez \
--target-size 18x30 -n 20 --wez-start 200 --wez-end 300 --wez-step 100 \
--cd-scale 1.1";
for (neutral_cmd, scaled_cmd) in [
(zero_cmd_neutral, zero_cmd_scaled),
(mc_cmd_neutral, mc_cmd_scaled),
(wez_cmd_neutral, wez_cmd_scaled),
] {
let wasm = WasmBallistics::new();
wasm.load_drag_table(FLAT_CD_CSV.as_bytes()).unwrap();
let neutral = wasm.run_command(neutral_cmd).unwrap();
let scaled = wasm.run_command(scaled_cmd).unwrap();
assert_ne!(
neutral, scaled,
"`{scaled_cmd}` must differ from `{neutral_cmd}`"
);
}
}
#[wasm_bindgen_test]
fn cd_scale_out_of_range_warns_table_only() {
let wasm = WasmBallistics::new();
wasm.load_drag_table(FLAT_CD_CSV.as_bytes()).unwrap();
let table_output = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 100 --cd-scale 3.0")
.unwrap();
assert!(
table_output.contains("--cd-scale 3 is far outside the typical truing range (0.90-1.10)"),
"got: {table_output}"
);
let json_output = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 100 --cd-scale 3.0 -o json",
)
.unwrap();
assert!(
!json_output.contains("far outside the typical truing range"),
"the range warning must never contaminate JSON output, got: {json_output}"
);
}
const DRG_TEXT_SAME_POINTS_AS_FLAT_CD_CSV: &str =
"SYNTH TEST DECK, invented values (MBA-1409 WASM test)\n\
0.00\t0.5\n\
0.50\t0.5\n\
1.00\t0.5\n\
1.50\t0.5\n\
2.00\t0.5\n\
3.00\t0.5\n";
#[wasm_bindgen_test]
fn test_load_drag_table_accepts_drg_text() {
let wasm = WasmBallistics::new();
let summary = wasm
.load_drag_table(DRG_TEXT_SAME_POINTS_AS_FLAT_CD_CSV.as_bytes())
.expect(".drg-shaped text should be accepted as a fallback");
assert!(wasm.has_drag_table());
assert!(summary.contains("6 points"), "got: {summary}");
assert!(summary.contains("0.000-3.000"), "got: {summary}");
let command = "trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 100 -o json";
let via_drg = wasm.run_command(command).unwrap();
let via_csv = WasmBallistics::new();
via_csv.load_drag_table(FLAT_CD_CSV.as_bytes()).unwrap();
let via_csv_out = via_csv.run_command(command).unwrap();
assert_eq!(
via_drg, via_csv_out,
".drg-loaded and CSV-loaded identical-point tables must solve identically"
);
}
#[wasm_bindgen_test]
fn test_load_drag_table_junk_error_names_both_formats() {
let wasm = WasmBallistics::new();
let err = wasm
.load_drag_table(b"this is not a drag table at all\nit is just some prose\n")
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert!(
msg.to_lowercase().contains("csv"),
"combined error should name the csv format, got: {msg}"
);
assert!(
msg.to_lowercase().contains("drg"),
"combined error should name the .drg format, got: {msg}"
);
assert!(!wasm.has_drag_table());
}
#[wasm_bindgen_test]
fn test_json_output_pure_with_auto_zero() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --auto-zero 200 -o json")
.unwrap();
assert!(
!result.contains("Rifle zeroed at"),
"auto-zero banner leaked into JSON output: {result}"
);
let _: serde_json::Value =
serde_json::from_str(&result).expect("auto-zero JSON output must be pure JSON");
}
#[wasm_bindgen_test]
fn test_csv_output_pure_with_auto_zero() {
let wasm = WasmBallistics::new();
let result = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --auto-zero 200 -o csv")
.unwrap();
assert!(
!result.contains("Rifle zeroed at"),
"auto-zero banner leaked into CSV output: {result}"
);
}
#[wasm_bindgen_test]
fn test_print_bc_segments_table_only() {
let wasm = WasmBallistics::new();
let table = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --print-bc-segments")
.unwrap();
assert!(
table.contains("BC segments") || table.contains("BC Segments"),
"bc-segments report missing from table view: {table}"
);
let json = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --print-bc-segments -o json")
.unwrap();
assert!(
!json.contains("BC Segments (active)") && !json.contains("No BC segments active"),
"bc-segments report leaked into JSON output: {json}"
);
let _: serde_json::Value =
serde_json::from_str(&json).expect("--print-bc-segments must not break JSON");
}
#[wasm_bindgen_test]
fn test_lead_invalid_output_rejected() {
let wasm = WasmBallistics::new();
assert!(
wasm.run_command("lead -v 2700 -m 168 -d 0.308 --target-speed 10 --range 300 -o json")
.is_ok(),
"lead -o json should be accepted"
);
let err = wasm
.run_command("lead -v 2700 -m 168 -d 0.308 --target-speed 10 --range 300 -o bogus")
.expect_err("lead must reject an invalid -o value");
assert!(
format!("{err:?}").contains("Invalid --output"),
"unexpected lead -o error: {err:?}"
);
}
#[wasm_bindgen_test]
fn test_bore_height_aliases_muzzle_height() {
let wasm = WasmBallistics::new();
let via_muzzle = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --muzzle-height 40 --max-range 300",
)
.unwrap();
let via_bore = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --bore-height 40 --max-range 300",
)
.unwrap();
assert_eq!(
via_muzzle, via_bore,
"--bore-height must alias --muzzle-height in the WASM CLI"
);
}
#[wasm_bindgen_test]
fn test_auto_zero_converges_on_inclined_shot() {
let cmd = |ang: u32| {
WasmBallistics::new()
.run_command(&format!(
"trajectory -v 2650 -b 0.19 -m 77 -d 0.224 --drag-model g7 \
--sight-height 2.48 --auto-zero 100 --shooting-angle {ang} --max-range 300"
))
.unwrap()
};
let flat = cmd(0);
let incline = cmd(5);
assert!(!incline.contains("Error calculating zero"), "{}", incline);
let zero_line =
|s: &str| s.lines().find(|l| l.contains("Rifle zeroed")).map(str::to_string);
assert_eq!(
zero_line(&flat),
zero_line(&incline),
"same rifle zero regardless of shot incline"
);
}
#[wasm_bindgen_test]
fn test_auto_zero_ignores_shot_coriolis() {
let cmd = |coriolis: &str| {
WasmBallistics::new()
.run_command(&format!(
"trajectory -v 2650 -b 0.19 -m 77 -d 0.224 --drag-model g7 \
--sight-height 2.48 --auto-zero 100 --max-range 1000 {coriolis}"
))
.unwrap()
};
let plain = cmd("");
let coriolis = cmd("--enable-coriolis --latitude 45 --shot-direction 90");
let zero_line =
|s: &str| s.lines().find(|l| l.contains("Rifle zeroed")).map(str::to_string);
assert_eq!(
zero_line(&plain),
zero_line(&coriolis),
"rifle zero must not change with shot-day Coriolis conditions"
);
}
#[wasm_bindgen_test]
fn test_trajectory_plot_appends_charts() {
let out = WasmBallistics::new()
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 300 --plot")
.unwrap();
assert!(out.contains("Drop vs Range:"), "{}", out);
assert!(out.contains("Lateral Drift vs Range:"), "{}", out);
let ascii = WasmBallistics::new()
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 300 --plot ascii",
)
.unwrap();
assert!(ascii.contains("Drop vs Range:"), "{}", ascii);
let json = WasmBallistics::new()
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 300 --plot -o json",
)
.unwrap();
assert!(!json.contains("Drop vs Range:"), "{}", json);
}
#[wasm_bindgen_test]
fn test_powder_linear_model_mba1296_pin() {
let out = WasmBallistics::new()
.run_command("powder -v 2800 --temperature 40")
.unwrap();
assert!(out.contains("2770.0 fps"), "{}", out);
assert!(out.contains("(-30.0)"), "{}", out);
}
#[wasm_bindgen_test]
fn test_powder_curve_interpolates_at_powder_temp() {
let out = WasmBallistics::new()
.run_command("powder --powder-temp-curve 40:2620,70:2700,100:2760 --powder-temp 55")
.unwrap();
assert!(out.contains("2660.0 fps"), "{}", out);
assert!(out.contains("measured curve, 3 points"), "{}", out);
}
#[wasm_bindgen_test]
fn test_powder_sweep_csv_includes_end_row() {
let out = WasmBallistics::new()
.run_command("powder -v 2700 --sweep 20:110:30 -o csv")
.unwrap();
assert!(out.contains("2650.0,-50.0"), "{}", out);
assert!(out.contains("2740.0,40.0"), "{}", out);
}
#[wasm_bindgen_test]
fn test_powder_json_resolved_object() {
let out = WasmBallistics::new()
.run_command("powder -v 2800 --temperature 40 -o json")
.unwrap();
assert!(out.contains("\"velocity\": 2770.0"), "{}", out);
assert!(out.contains("\"shift\": -30.0"), "{}", out);
}
#[wasm_bindgen_test]
fn test_powder_metric_resolves_in_celsius() {
let out = WasmBallistics::new()
.run_command("--units metric powder -v 823 --temperature 5 --powder-temp 21")
.unwrap();
assert!(out.contains("814.2 m/s"), "{}", out);
}
#[wasm_bindgen_test]
fn test_powder_missing_inputs_error() {
assert!(WasmBallistics::new()
.run_command("powder --temperature 40")
.is_err());
assert!(WasmBallistics::new().run_command("powder -v 2800").is_err());
}
#[wasm_bindgen_test]
fn test_powder_energy_with_mass() {
let out = WasmBallistics::new()
.run_command("powder -v 2700 --temperature 70 -m 168")
.unwrap();
assert!(out.contains("2719 ft\u{b7}lb"), "{}", out);
}
#[wasm_bindgen_test]
fn true_velocity_single_observation_recovers_a_velocity() {
let out = WasmBallistics::new()
.run_command(
"true-velocity --range 300 --measured-drop 1.8 --bc 0.475 -m 168 -d 0.308 \
--zero-distance 100 --chrono-velocity 2700",
)
.unwrap();
assert!(out.contains("VELOCITY TRUING RESULTS"), "{}", out);
assert!(out.contains("Effective Muzzle Velocity:"), "{}", out);
assert!(out.contains("2355.5"), "{}", out);
assert!(out.contains("Adjustment from Chrono:"), "{}", out);
}
#[wasm_bindgen_test]
fn true_velocity_multi_observation_matches_native_joint_fit() {
let out = WasmBallistics::new()
.run_command(
"true-velocity --range 300 --measured-drop 1.8 --observed 600:5.1 \
--observed 900:10.9 --bc 0.475 -m 168 -d 0.308 --zero-distance 100 \
--chrono-velocity 2700",
)
.unwrap();
assert!(out.contains("Joint MV+BC fit"), "{}", out);
assert!(out.contains("2514.5"), "{}", out);
}
#[wasm_bindgen_test]
fn true_velocity_multi_observation_mv_window_line_matches_native() {
let out = WasmBallistics::new()
.run_command(
"true-velocity --range 300 --measured-drop 1.8 --observed 600:5.1 \
--observed 900:10.9 --bc 0.475 -m 168 -d 0.308 --zero-distance 100 \
--chrono-velocity 2700",
)
.unwrap();
assert!(
out.contains("MV-calibration window: 656.7-729.7 yd (90-100% of the Mach 1.2 distance)"),
"{out}"
);
assert!(
out.contains("for optimal observation ranges run: ballistics plan-truing"),
"{out}"
);
}
#[wasm_bindgen_test]
fn true_velocity_multi_observation_no_window_note_matches_native() {
let out = WasmBallistics::new()
.run_command(
"true-velocity --range 300 --measured-drop 0.3 --observed 600:0.65 \
--bc 2.0 -m 750 -d 0.510 --zero-distance 300 --chrono-velocity 3300",
)
.unwrap();
assert!(
out.contains(
"note: no MV window: trajectory is supersonic through 3109.4 yd; MV is identifiable at any range"
),
"{out}"
);
assert!(!out.contains("MV-calibration window:"), "{out}");
}
#[wasm_bindgen_test]
fn true_velocity_multi_observation_never_supersonic_note_matches_native() {
let out = WasmBallistics::new()
.run_command(
"true-velocity --range 100.0 --measured-drop 1.834371 --observed \
400.0:17.880184 --bc 0.3 -m 220 -d 0.308 --zero-distance 25 \
--chrono-velocity 1050",
)
.unwrap();
assert!(
out.contains(
"note: no MV window: trajectory never reaches Mach 1.2; calibrate muzzle \
velocity with a chronograph, then collect DSF points"
),
"{out}"
);
assert!(!out.contains("MV-calibration window:"), "{out}");
assert!(!out.contains("supersonic through"), "{out}");
}
#[wasm_bindgen_test]
fn true_velocity_multi_observation_never_supersonic_json_window_fields_null() {
let out = WasmBallistics::new()
.run_command(
"true-velocity --range 100.0 --measured-drop 1.834371 --observed \
400.0:17.880184 --bc 0.3 -m 220 -d 0.308 --zero-distance 25 \
--chrono-velocity 1050 -o json",
)
.unwrap();
assert!(!out.to_lowercase().contains("calibration window"), "{out}");
assert!(!out.to_lowercase().contains("mach 1.2"), "{out}");
let v: serde_json::Value = serde_json::from_str(&out).expect("json");
assert!(v["mv_window_start_m"].is_null(), "{v}");
assert!(v["mv_window_end_m"].is_null(), "{v}");
}
#[wasm_bindgen_test]
fn true_velocity_multi_observation_json_has_additive_window_fields_only() {
let out = WasmBallistics::new()
.run_command(
"true-velocity --range 300 --measured-drop 1.8 --observed 600:5.1 \
--observed 900:10.9 --bc 0.475 -m 168 -d 0.308 --zero-distance 100 \
--chrono-velocity 2700 -o json",
)
.unwrap();
assert!(!out.to_lowercase().contains("calibration window"), "{out}");
let v: serde_json::Value = serde_json::from_str(&out).expect("json");
let lo = v["mv_window_start_m"].as_f64().expect("mv_window_start_m present");
let hi = v["mv_window_end_m"].as_f64().expect("mv_window_end_m present");
assert!((lo - 656.7 * 0.9144).abs() < 0.5, "lo={lo}");
assert!((hi - 729.7 * 0.9144).abs() < 0.5, "hi={hi}");
}
#[wasm_bindgen_test]
fn true_velocity_multi_observation_json_window_fields_null_when_absent() {
let out = WasmBallistics::new()
.run_command(
"true-velocity --range 300 --measured-drop 0.3 --observed 600:0.65 \
--bc 2.0 -m 750 -d 0.510 --zero-distance 300 --chrono-velocity 3300 -o json",
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&out).expect("json");
assert!(v["mv_window_start_m"].is_null(), "{v}");
assert!(v["mv_window_end_m"].is_null(), "{v}");
}
#[wasm_bindgen_test]
fn wez_summary_reports_sims_per_step_and_p_hit_rows() {
let out = WasmBallistics::new()
.run_command(
"monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 --wez --target-size 18x30 \
-n 300 --wez-start 200 --wez-end 500 --wez-step 100",
)
.unwrap();
assert!(out.contains("WEZ sweep: 300 sims/step"), "{}", out);
assert!(
out.contains("│ 200.0 │ 49.0% │ other │ 0.0% │ 0.0% │ 100.0% │"),
"{}",
out
);
}
#[wasm_bindgen_test]
fn wez_without_target_size_errors_like_native() {
let err = WasmBallistics::new()
.run_command("monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 --wez -n 10")
.unwrap_err();
assert!(
err.as_string()
.unwrap_or_default()
.contains("--target-size is required with --wez (e.g. --target-size 18x30)"),
"{err:?}"
);
}
#[wasm_bindgen_test]
fn wez_drag_model_reaches_the_sweep() {
let base = "monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 --wez --target-size 18x30 \
-n 100 --wez-start 200 --wez-end 400 --wez-step 100";
let default_out = WasmBallistics::new().run_command(base).unwrap();
let g1_out = WasmBallistics::new()
.run_command(&format!("{base} --drag-model g1"))
.unwrap();
let g7_out = WasmBallistics::new()
.run_command(&format!("{base} --drag-model g7"))
.unwrap();
assert_eq!(default_out, g1_out, "explicit g1 must equal the default");
assert_ne!(
g1_out, g7_out,
"a G7 sweep must not silently degrade to G1:\n{g7_out}"
);
}
#[wasm_bindgen_test]
fn true_velocity_rejects_bare_tokens_and_dangling_flags() {
let wasm = WasmBallistics::new();
let err = wasm
.run_command(
"true-velocity --range 300 --measured-drop 1.8 --observed 600:4.8 700:5.9 \
--bc 0.475 -m 168 -d 0.308",
)
.unwrap_err();
assert!(
err.as_string()
.unwrap_or_default()
.contains("unexpected argument '700:5.9'"),
"{err:?}"
);
let err = wasm
.run_command("true-velocity --range 300 --measured-drop 1.8 --bc 0.475 --observed")
.unwrap_err();
assert!(
err.as_string()
.unwrap_or_default()
.contains("a value is required for '--observed'"),
"{err:?}"
);
let err = wasm
.run_command("monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 300")
.unwrap_err();
assert!(
err.as_string()
.unwrap_or_default()
.contains("unexpected argument '300'"),
"{err:?}"
);
let err = wasm
.run_command("monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 --velocity-std")
.unwrap_err();
assert!(
err.as_string()
.unwrap_or_default()
.contains("a value is required for '--velocity-std'"),
"{err:?}"
);
}
#[wasm_bindgen_test]
fn true_velocity_range_validates_bullet_parameters() {
let wasm = WasmBallistics::new();
for (args, needle) in [
("-b 5 -m 168 -d 0.308", "for '--bc'"),
("-b 0.475 -m 0.05 -d 0.308", "for '--mass'"),
("-b 0.475 -m 168 -d 99", "for '--diameter'"),
] {
let err = wasm
.run_command(&format!(
"true-velocity --range 300 --measured-drop 1.8 {args}"
))
.unwrap_err();
assert!(
err.as_string().unwrap_or_default().contains(needle),
"{args}: {err:?}"
);
}
}
#[wasm_bindgen_test]
fn test_g5_solves_with_its_own_real_table() {
let g5 = WasmBallistics::new()
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --drag-model g5 --max-range 300")
.unwrap();
assert!(!g5.contains("using the G1 curve"), "{}", g5);
let g1 = WasmBallistics::new()
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --drag-model g1 --max-range 300")
.unwrap();
assert!(!g1.contains("using the G1 curve"), "{}", g1);
assert_ne!(g5, g1, "g5 must apply its own drag table, not silently alias g1");
}
#[wasm_bindgen_test]
fn test_ra4_drag_model_accepted_and_solves() {
let out = WasmBallistics::new()
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --drag-model ra4 --max-range 300")
.unwrap();
assert!(!out.contains("using the G1 curve"), "{}", out);
assert!(out.contains("Range"), "expected a trajectory table, got: {}", out);
}
fn synthetic_bc5d_bytes(correction: f32) -> Vec<u8> {
let weight_bins = [168.0f32];
let bc_bins = [0.4f32];
let muzzle_vel_bins = [2500.0f32];
let current_vel_bins = [2000.0f32];
let data = [correction];
let mut out = Vec::new();
out.extend_from_slice(b"BC5D");
out.extend_from_slice(&2u32.to_le_bytes()); out.extend_from_slice(&0.308f32.to_le_bytes()); out.extend_from_slice(&0u32.to_le_bytes()); out.extend_from_slice(&0u32.to_le_bytes()); out.extend_from_slice(&(weight_bins.len() as u32).to_le_bytes());
out.extend_from_slice(&(bc_bins.len() as u32).to_le_bytes());
out.extend_from_slice(&(muzzle_vel_bins.len() as u32).to_le_bytes());
out.extend_from_slice(&(current_vel_bins.len() as u32).to_le_bytes());
out.extend_from_slice(&1u32.to_le_bytes()); out.extend_from_slice(&0u64.to_le_bytes());
let mut checksum_data = Vec::new();
for v in weight_bins
.iter()
.chain(&bc_bins)
.chain(&muzzle_vel_bins)
.chain(¤t_vel_bins)
.chain(&data)
{
checksum_data.extend_from_slice(&v.to_le_bytes());
}
out.extend_from_slice(&crate::bc_table_5d::crc32_ieee(&checksum_data).to_le_bytes());
let mut api = [0u8; 16];
api[..4].copy_from_slice(b"test");
out.extend_from_slice(&api);
out.extend_from_slice(&[0u8; 12]);
for v in weight_bins
.iter()
.chain(&bc_bins)
.chain(&muzzle_vel_bins)
.chain(¤t_vel_bins)
.chain(&data)
{
out.extend_from_slice(&v.to_le_bytes());
}
out
}
#[wasm_bindgen_test]
fn bc5d_coercion_warning_survives_a_successful_solve() {
let wasm = WasmBallistics::new();
wasm.load_bc5d_table(&synthetic_bc5d_bytes(0.9)).unwrap();
let table = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --drag-model g5 --use-bc-segments \
--max-range 300",
)
.unwrap();
assert!(
table.contains("BC5D correction tables support G1/G7 only"),
"{table}"
);
assert!(table.contains("treating drag model 'G5' as G1"), "{table}");
let json = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --drag-model g5 --use-bc-segments \
--max-range 300 -o json",
)
.unwrap();
assert!(!json.contains("BC5D correction tables"), "{json}");
}
#[wasm_bindgen_test]
fn bc5d_coercion_warning_survives_a_failed_solve() {
let wasm = WasmBallistics::new();
wasm.load_bc5d_table(&synthetic_bc5d_bytes(0.9)).unwrap();
let table = wasm
.run_command(
"trajectory -v 2700 -b -1 -m 168 -d 0.308 --drag-model g5 --use-bc-segments \
--max-range 300",
)
.unwrap();
assert!(
table.contains("BC5D correction tables support G1/G7 only"),
"{table}"
);
assert!(table.contains("Error:"), "{table}");
let json = wasm
.run_command(
"trajectory -v 2700 -b -1 -m 168 -d 0.308 --drag-model g5 --use-bc-segments \
--max-range 300 -o json",
)
.unwrap();
assert!(!json.contains("BC5D correction tables"), "{json}");
assert!(json.contains("Error:"), "{json}");
}
#[wasm_bindgen_test]
fn dsf_point_bad_token_errors() {
let err = WasmBallistics::new()
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --dsf-point abc")
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert!(
msg.contains("--dsf-point expects MACH:DSF") && msg.contains("'abc'"),
"{msg}"
);
}
#[wasm_bindgen_test]
fn dsf_point_non_numeric_fields_error() {
let wasm = WasmBallistics::new();
let err = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --dsf-point x:1.0")
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert!(
msg.contains("--dsf-point: invalid MACH in 'x:1.0'"),
"{msg}"
);
let err = wasm
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --dsf-point 0.5:x")
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert!(
msg.contains("--dsf-point: invalid DSF in '0.5:x'"),
"{msg}"
);
}
#[wasm_bindgen_test]
fn dsf_point_out_of_range_mach_uses_engine_error_text() {
let err = WasmBallistics::new()
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --dsf-point 1.5:1.1")
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert!(
msg.contains("DSF point Mach 1.5 is out of range") && msg.contains("0 < mach < 1.2"),
"{msg}"
);
}
#[wasm_bindgen_test]
fn dsf_point_out_of_range_dsf_uses_engine_error_text() {
let err = WasmBallistics::new()
.run_command("trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --dsf-point 0.5:3.0")
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert!(
msg.contains("DSF value 3 is out of range") && msg.contains("0.5 < dsf < 2"),
"{msg}"
);
}
#[wasm_bindgen_test]
fn dsf_point_more_than_six_uses_engine_cap_error() {
let err = WasmBallistics::new()
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 \
--dsf-point 0.1:1.0 --dsf-point 0.2:1.0 --dsf-point 0.3:1.0 \
--dsf-point 0.4:1.0 --dsf-point 0.5:1.0 --dsf-point 0.6:1.0 \
--dsf-point 0.7:1.0",
)
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert!(
msg.contains("DSF table supports at most 6 points") && msg.contains("got 7"),
"{msg}"
);
}
#[wasm_bindgen_test]
fn dsf_point_applies_drop_only_correction() {
let wasm = WasmBallistics::new();
let base_cmd = "trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 1500 \
--ignore-ground-impact -o json";
let dsf_cmd = "trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 1500 \
--ignore-ground-impact --dsf-point 0.9:1.3 -o json";
let baseline = wasm.run_command(base_cmd).unwrap();
let corrected = wasm.run_command(dsf_cmd).unwrap();
assert_ne!(
baseline, corrected,
"an active DSF table must change trajectory JSON output"
);
let base_json: serde_json::Value = serde_json::from_str(&baseline).unwrap();
let corr_json: serde_json::Value = serde_json::from_str(&corrected).unwrap();
assert_eq!(
base_json["summary"], corr_json["summary"],
"summary must stay identical"
);
assert_eq!(
base_json["legend"], corr_json["legend"],
"legend must stay identical"
);
let base_points = base_json["trajectory"].as_array().unwrap();
let corr_points = corr_json["trajectory"].as_array().unwrap();
assert!(!base_points.is_empty(), "expected a non-empty trajectory");
assert_eq!(base_points.len(), corr_points.len());
let mut any_drop_changed = false;
for (bp, cp) in base_points.iter().zip(corr_points.iter()) {
assert_eq!(
bp["range_yards"], cp["range_yards"],
"range must stay identical"
);
assert_eq!(
bp["drift_inches"], cp["drift_inches"],
"drift must stay identical"
);
assert_eq!(
bp["velocity_fps"], cp["velocity_fps"],
"velocity must stay identical"
);
assert_eq!(
bp["energy_ftlb"], cp["energy_ftlb"],
"energy must stay identical"
);
assert_eq!(
bp["time_seconds"], cp["time_seconds"],
"time must stay identical"
);
let bp_keys: std::collections::BTreeSet<_> = bp.as_object().unwrap().keys().collect();
let cp_keys: std::collections::BTreeSet<_> = cp.as_object().unwrap().keys().collect();
assert_eq!(bp_keys, cp_keys, "point key set must stay identical");
let bd = bp["drop_inches"].as_f64().unwrap();
let cd = cp["drop_inches"].as_f64().unwrap();
if (bd - cd).abs() > 1e-9 {
any_drop_changed = true;
}
}
assert!(
any_drop_changed,
"at least one point's drop must change under an active DSF table"
);
}
#[wasm_bindgen_test]
fn dsf_point_note_is_table_output_only() {
let wasm = WasmBallistics::new();
let table = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 1500 \
--ignore-ground-impact --dsf-point 0.9:1.3",
)
.unwrap();
assert!(
table.contains("DSF table active (1 points, Mach 0.90-0.90)"),
"{table}"
);
let json_without = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 1500 \
--ignore-ground-impact -o json",
)
.unwrap();
let json_with = wasm
.run_command(
"trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --max-range 1500 \
--ignore-ground-impact --dsf-point 0.9:1.3 -o json",
)
.unwrap();
assert!(
!json_with.contains("DSF table active"),
"note text must never leak into JSON: {json_with}"
);
let without_json: serde_json::Value = serde_json::from_str(&json_without).unwrap();
let with_json: serde_json::Value = serde_json::from_str(&json_with).unwrap();
let without_keys: std::collections::BTreeSet<_> =
without_json.as_object().unwrap().keys().collect();
let with_keys: std::collections::BTreeSet<_> =
with_json.as_object().unwrap().keys().collect();
assert_eq!(without_keys, with_keys, "top-level JSON key set must stay identical");
}
#[wasm_bindgen_test]
fn lead_cd_scale_without_a_loaded_drag_table_errors() {
let err = WasmBallistics::new()
.run_command(
"lead -v 2700 -m 168 -d 0.308 --target-speed 10 --range 300 --cd-scale 1.1",
)
.unwrap_err();
let msg = err.as_string().unwrap_or_default();
assert_eq!(msg, "--cd-scale requires --drag-table");
}
#[wasm_bindgen_test]
fn lead_cd_scale_accepted_with_a_loaded_table_and_shifts_output() {
let wasm = WasmBallistics::new();
wasm.load_drag_table(FLAT_CD_CSV.as_bytes()).unwrap();
let neutral = wasm
.run_command(
"lead -v 2700 -m 168 -d 0.308 --target-speed 10 --range 300 --cd-scale 1.0 \
-o json",
)
.unwrap();
let scaled = wasm
.run_command(
"lead -v 2700 -m 168 -d 0.308 --target-speed 10 --range 300 --cd-scale 1.1 \
-o json",
)
.unwrap();
assert_ne!(
neutral, scaled,
"--cd-scale 1.1 must change lead output relative to the neutral 1.0"
);
let omitted = wasm
.run_command("lead -v 2700 -m 168 -d 0.308 --target-speed 10 --range 300 -o json")
.unwrap();
assert_eq!(
omitted, neutral,
"omitting --cd-scale must be byte-identical to an explicit --cd-scale 1.0"
);
}
#[wasm_bindgen_test]
fn lead_cd_scale_out_of_range_warns_table_only() {
let wasm = WasmBallistics::new();
wasm.load_drag_table(FLAT_CD_CSV.as_bytes()).unwrap();
let table_output = wasm
.run_command(
"lead -v 2700 -m 168 -d 0.308 --target-speed 10 --range 300 --cd-scale 3.0",
)
.unwrap();
assert!(
table_output.contains("--cd-scale 3 is far outside the typical truing range (0.90-1.10)"),
"{table_output}"
);
let json_output = wasm
.run_command(
"lead -v 2700 -m 168 -d 0.308 --target-speed 10 --range 300 --cd-scale 3.0 \
-o json",
)
.unwrap();
assert!(
!json_output.contains("far outside the typical truing range"),
"the range warning must never contaminate JSON output: {json_output}"
);
}
}