use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::adjustment::{click_size_mil, quantize_angle, ClickBase, ClickValue};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OpticProfile {
pub elevation_click: ClickValue,
pub windage_click: ClickValue,
pub clicks_per_revolution: Option<u32>,
pub zero_stop: bool,
pub elevation_travel: Option<TravelLimits>,
pub windage_travel: Option<TravelLimits>,
pub turret_state: Option<TurretState>,
pub reticle_hold_bounds: Option<HoldBounds>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TravelLimits {
pub down_mil: f64,
pub up_mil: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TurretState {
pub elevation_mil: f64,
pub windage_mil: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HoldBounds {
pub up_mil: f64,
pub down_mil: f64,
pub left_mil: f64,
pub right_mil: f64,
}
#[derive(Debug, Clone, PartialEq, Error)]
pub enum OpticError {
#[error("{field} must be finite")]
NonFinite { field: &'static str },
#[error("{field} must not be negative (got {value})")]
NegativeLimit { field: &'static str, value: f64 },
#[error("{field} must be a positive click size (got {size})")]
NonPositiveClickSize { field: &'static str, size: f64 },
#[error("clicks_per_revolution must be at least 1 when present, got 0")]
ZeroClicksPerRevolution,
#[error(
"{axis} turret state at {dialed_mil} mil from zero is outside its travel of \
-{down_mil}..={up_mil} mil"
)]
StateOutsideTravel {
axis: &'static str,
dialed_mil: f64,
down_mil: f64,
up_mil: f64,
},
#[error("{field} must be a positive, finite tracking factor (got {value})")]
NonPositiveTrackingFactor { field: &'static str, value: f64 },
}
impl OpticProfile {
pub fn validate(&self) -> Result<(), OpticError> {
require_finite("elevation_click.size", self.elevation_click.size)?;
require_positive_click_size("elevation_click.size", self.elevation_click.size)?;
require_finite("windage_click.size", self.windage_click.size)?;
require_positive_click_size("windage_click.size", self.windage_click.size)?;
if self.clicks_per_revolution == Some(0) {
return Err(OpticError::ZeroClicksPerRevolution);
}
if let Some(travel) = &self.elevation_travel {
validate_travel("elevation_travel.down_mil", "elevation_travel.up_mil", travel)?;
}
if let Some(travel) = &self.windage_travel {
validate_travel("windage_travel.down_mil", "windage_travel.up_mil", travel)?;
}
if let Some(state) = &self.turret_state {
require_finite("turret_state.elevation_mil", state.elevation_mil)?;
require_finite("turret_state.windage_mil", state.windage_mil)?;
}
if let Some(bounds) = &self.reticle_hold_bounds {
for (field, value) in [
("reticle_hold_bounds.up_mil", bounds.up_mil),
("reticle_hold_bounds.down_mil", bounds.down_mil),
("reticle_hold_bounds.left_mil", bounds.left_mil),
("reticle_hold_bounds.right_mil", bounds.right_mil),
] {
require_finite(field, value)?;
require_non_negative(field, value)?;
}
}
if let (Some(state), Some(travel)) = (&self.turret_state, &self.elevation_travel) {
check_state_within_travel("elevation", state.elevation_mil, travel)?;
}
if let (Some(state), Some(travel)) = (&self.turret_state, &self.windage_travel) {
check_state_within_travel("windage", state.windage_mil, travel)?;
}
Ok(())
}
}
fn require_finite(field: &'static str, value: f64) -> Result<(), OpticError> {
if value.is_finite() {
Ok(())
} else {
Err(OpticError::NonFinite { field })
}
}
fn require_non_negative(field: &'static str, value: f64) -> Result<(), OpticError> {
if value >= 0.0 {
Ok(())
} else {
Err(OpticError::NegativeLimit { field, value })
}
}
fn require_positive_click_size(field: &'static str, size: f64) -> Result<(), OpticError> {
if size > 0.0 {
Ok(())
} else {
Err(OpticError::NonPositiveClickSize { field, size })
}
}
fn require_positive_tracking_factor(field: &'static str, value: f64) -> Result<(), OpticError> {
if value.is_finite() && value > 0.0 {
Ok(())
} else {
Err(OpticError::NonPositiveTrackingFactor { field, value })
}
}
fn validate_travel(
down_field: &'static str,
up_field: &'static str,
travel: &TravelLimits,
) -> Result<(), OpticError> {
require_finite(down_field, travel.down_mil)?;
require_finite(up_field, travel.up_mil)?;
require_non_negative(down_field, travel.down_mil)?;
require_non_negative(up_field, travel.up_mil)?;
Ok(())
}
fn check_state_within_travel(
axis: &'static str,
dialed_mil: f64,
travel: &TravelLimits,
) -> Result<(), OpticError> {
if dialed_mil > travel.up_mil || dialed_mil < -travel.down_mil {
Err(OpticError::StateOutsideTravel {
axis,
dialed_mil,
down_mil: travel.down_mil,
up_mil: travel.up_mil,
})
} else {
Ok(())
}
}
pub fn revolution_annotation(
clicks_from_zero: i64,
clicks_per_revolution: u32,
) -> Option<(u32, u32)> {
if clicks_from_zero < 0 || clicks_per_revolution == 0 {
return None;
}
let cpr = i64::from(clicks_per_revolution);
let revolutions = clicks_from_zero / cpr;
let clicks_in_revolution = clicks_from_zero % cpr;
Some((revolutions as u32, clicks_in_revolution as u32))
}
pub const DIAL_PLAN_SCHEMA_VERSION_V1: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct AngularCorrection {
pub elevation_mil: f64,
pub windage_mil: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
pub struct Preferences {
pub prefer_hold: bool,
pub max_hold_mil: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Axis {
Elevation,
Windage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Strategy {
DialAll,
HoldAll,
Hybrid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LimitKind {
TravelExceeded,
HoldBoundExceeded,
NoTravelData,
NoHoldBoundData,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct LimitViolation {
pub axis: Axis,
pub kind: LimitKind,
pub needed_mil: f64,
pub available_mil: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Direction {
Up,
Down,
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct AxisInstruction {
pub axis: Axis,
pub direction: Direction,
pub delta_clicks: i64,
pub target_clicks_from_zero: i64,
pub end_revolution: Option<(u32, u32)>,
pub dial_mil_true: f64,
pub hold_mil: f64,
pub residual_mil: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DialPlan {
pub strategy: Strategy,
pub instructions: [AxisInstruction; 2],
pub residual_linear_at_range_m: f64,
pub feasible: bool,
pub limits_hit: Vec<LimitViolation>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DialPlanReportV1 {
pub schema_version: u32,
pub method: String,
pub assumptions: Vec<String>,
pub range_m: f64,
pub plans: Vec<DialPlan>,
}
fn max_clicks_within(boundary_mil: f64, click_mil: f64) -> i64 {
let raw = boundary_mil / click_mil;
let nearest = raw.round();
if (nearest * click_mil - boundary_mil).abs() <= click_mil * 1e-9 {
nearest as i64
} else {
raw.floor() as i64
}
}
fn quantize_and_clamp(
axis: Axis,
corr_true: f64,
click_mil: f64,
cf: f64,
travel: Option<&TravelLimits>,
) -> (i64, f64, Option<LimitViolation>) {
let corr_dial = corr_true / cf;
let synthetic = ClickValue { size: click_mil, base: ClickBase::Mil };
let target = quantize_angle(corr_dial, &synthetic).clicks;
let violation = match travel {
None if target == 0 => None,
None => Some(LimitViolation {
axis,
kind: LimitKind::NoTravelData,
needed_mil: corr_dial,
available_mil: None,
}),
Some(t) => {
let max_up = max_clicks_within(t.up_mil, click_mil);
let max_down = max_clicks_within(t.down_mil, click_mil);
if target > max_up {
return (
max_up,
corr_dial,
Some(LimitViolation {
axis,
kind: LimitKind::TravelExceeded,
needed_mil: corr_dial,
available_mil: Some(t.up_mil),
}),
);
} else if target < -max_down {
return (
-max_down,
corr_dial,
Some(LimitViolation {
axis,
kind: LimitKind::TravelExceeded,
needed_mil: corr_dial,
available_mil: Some(t.down_mil),
}),
);
}
None
}
};
(target, corr_dial, violation)
}
fn check_hold_bounds(
axis: Axis,
hold_true_mil: f64,
bound_positive: Option<f64>,
bound_negative: Option<f64>,
max_hold_mil: Option<f64>,
) -> Option<LimitViolation> {
if hold_true_mil == 0.0 {
return None;
}
let directional_bound = if hold_true_mil > 0.0 { bound_positive } else { bound_negative };
let effective_bound = match (directional_bound, max_hold_mil) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
};
match effective_bound {
None => Some(LimitViolation {
axis,
kind: LimitKind::NoHoldBoundData,
needed_mil: hold_true_mil,
available_mil: None,
}),
Some(bound) if hold_true_mil.abs() > bound => Some(LimitViolation {
axis,
kind: LimitKind::HoldBoundExceeded,
needed_mil: hold_true_mil,
available_mil: Some(bound),
}),
Some(_) => None,
}
}
fn end_revolution_for(target_clicks: i64, cpr: Option<u32>) -> Option<(u32, u32)> {
cpr.and_then(|c| revolution_annotation(target_clicks, c))
}
fn direction_for(axis: Axis, delta_clicks: i64) -> Direction {
match (axis, delta_clicks < 0) {
(Axis::Elevation, true) => Direction::Down,
(Axis::Elevation, false) => Direction::Up,
(Axis::Windage, true) => Direction::Left,
(Axis::Windage, false) => Direction::Right,
}
}
struct AxisComputation {
axis: Axis,
corr_true: f64,
click_mil: f64,
cf: f64,
target_clicks: i64,
travel_violation: Option<LimitViolation>,
state_clicks: i64,
cpr: Option<u32>,
bound_for_positive_correction: Option<f64>,
bound_for_negative_correction: Option<f64>,
}
#[allow(clippy::too_many_arguments)]
fn compute_axis(
axis: Axis,
corr_true: f64,
click: &ClickValue,
cf: f64,
travel: Option<&TravelLimits>,
state_mil: Option<f64>,
bound_for_positive_correction: Option<f64>,
bound_for_negative_correction: Option<f64>,
cpr: Option<u32>,
) -> AxisComputation {
let click_mil = click_size_mil(click);
let (target_clicks, _corr_dial, travel_violation) =
quantize_and_clamp(axis, corr_true, click_mil, cf, travel);
let state_clicks = state_mil.map_or(0, |mil| {
quantize_angle(mil, &ClickValue { size: click_mil, base: ClickBase::Mil }).clicks
});
AxisComputation {
axis,
corr_true,
click_mil,
cf,
target_clicks,
travel_violation,
state_clicks,
cpr,
bound_for_positive_correction,
bound_for_negative_correction,
}
}
fn build_axis_instruction(
strategy: Strategy,
ac: &AxisComputation,
prefs: &Preferences,
) -> (AxisInstruction, Vec<LimitViolation>, bool) {
match strategy {
Strategy::DialAll => {
let clicks = ac.target_clicks;
let dial_true = clicks as f64 * ac.click_mil * ac.cf;
let residual = ac.corr_true - dial_true;
let delta_clicks = clicks - ac.state_clicks;
let feasible = ac.travel_violation.is_none();
let violations = ac.travel_violation.into_iter().collect();
let instr = AxisInstruction {
axis: ac.axis,
direction: direction_for(ac.axis, delta_clicks),
delta_clicks,
target_clicks_from_zero: clicks,
end_revolution: end_revolution_for(clicks, ac.cpr),
dial_mil_true: dial_true,
hold_mil: 0.0,
residual_mil: residual,
};
(instr, violations, feasible)
}
Strategy::HoldAll => {
let hold = ac.corr_true;
let hold_violation = check_hold_bounds(
ac.axis,
hold,
ac.bound_for_positive_correction,
ac.bound_for_negative_correction,
prefs.max_hold_mil,
);
let feasible = hold_violation.is_none();
let target_clicks = 0_i64;
let delta_clicks = target_clicks - ac.state_clicks;
let violations = hold_violation.into_iter().collect();
let instr = AxisInstruction {
axis: ac.axis,
direction: direction_for(ac.axis, delta_clicks),
delta_clicks,
target_clicks_from_zero: target_clicks,
end_revolution: end_revolution_for(target_clicks, ac.cpr),
dial_mil_true: 0.0,
hold_mil: hold,
residual_mil: 0.0,
};
(instr, violations, feasible)
}
Strategy::Hybrid => {
let clicks = ac.target_clicks;
let dial_true = clicks as f64 * ac.click_mil * ac.cf;
let hold = ac.corr_true - dial_true;
let residual = ac.corr_true - dial_true - hold;
let hold_violation = check_hold_bounds(
ac.axis,
hold,
ac.bound_for_positive_correction,
ac.bound_for_negative_correction,
prefs.max_hold_mil,
);
let feasible = hold_violation.is_none();
let delta_clicks = clicks - ac.state_clicks;
let mut violations: Vec<LimitViolation> = ac.travel_violation.into_iter().collect();
violations.extend(hold_violation);
let instr = AxisInstruction {
axis: ac.axis,
direction: direction_for(ac.axis, delta_clicks),
delta_clicks,
target_clicks_from_zero: clicks,
end_revolution: end_revolution_for(clicks, ac.cpr),
dial_mil_true: dial_true,
hold_mil: hold,
residual_mil: residual,
};
(instr, violations, feasible)
}
}
}
fn residual_linear_at_range(e_res_mil: f64, w_res_mil: f64, range_m: f64) -> f64 {
let e = e_res_mil / 1000.0 * range_m;
let w = w_res_mil / 1000.0 * range_m;
(e * e + w * w).sqrt()
}
fn build_plan(
strategy: Strategy,
elevation: &AxisComputation,
windage: &AxisComputation,
prefs: &Preferences,
range_m: f64,
) -> DialPlan {
let (e_instr, e_viol, e_feasible) = build_axis_instruction(strategy, elevation, prefs);
let (w_instr, w_viol, w_feasible) = build_axis_instruction(strategy, windage, prefs);
let mut limits_hit = e_viol;
limits_hit.extend(w_viol);
DialPlan {
strategy,
residual_linear_at_range_m: residual_linear_at_range(
e_instr.residual_mil,
w_instr.residual_mil,
range_m,
),
instructions: [e_instr, w_instr],
feasible: e_feasible && w_feasible,
limits_hit,
}
}
fn preference_rank(strategy: Strategy, prefer_hold: bool) -> u8 {
let dial_first = match strategy {
Strategy::DialAll => 0,
Strategy::Hybrid => 1,
Strategy::HoldAll => 2,
};
if prefer_hold { 2 - dial_first } else { dial_first }
}
fn declaration_rank(strategy: Strategy) -> u8 {
match strategy {
Strategy::DialAll => 0,
Strategy::HoldAll => 1,
Strategy::Hybrid => 2,
}
}
pub fn plan_corrections(
correction: AngularCorrection,
optic: &OpticProfile,
range_m: f64,
elevation_cf: f64,
windage_cf: f64,
prefs: &Preferences,
) -> Result<DialPlanReportV1, OpticError> {
optic.validate()?;
require_finite("correction.elevation_mil", correction.elevation_mil)?;
require_finite("correction.windage_mil", correction.windage_mil)?;
require_finite("range_m", range_m)?;
require_non_negative("range_m", range_m)?;
require_positive_tracking_factor("elevation_cf", elevation_cf)?;
require_positive_tracking_factor("windage_cf", windage_cf)?;
if let Some(max_hold) = prefs.max_hold_mil {
require_finite("max_hold_mil", max_hold)?;
require_non_negative("max_hold_mil", max_hold)?;
}
let (hold_up, hold_down, hold_left, hold_right) = match &optic.reticle_hold_bounds {
Some(b) => (Some(b.up_mil), Some(b.down_mil), Some(b.left_mil), Some(b.right_mil)),
None => (None, None, None, None),
};
let elevation = compute_axis(
Axis::Elevation,
correction.elevation_mil,
&optic.elevation_click,
elevation_cf,
optic.elevation_travel.as_ref(),
optic.turret_state.as_ref().map(|s| s.elevation_mil),
hold_down,
hold_up,
optic.clicks_per_revolution,
);
let windage = compute_axis(
Axis::Windage,
correction.windage_mil,
&optic.windage_click,
windage_cf,
optic.windage_travel.as_ref(),
optic.turret_state.as_ref().map(|s| s.windage_mil),
hold_left,
hold_right,
optic.clicks_per_revolution,
);
let mut plans = vec![
build_plan(Strategy::DialAll, &elevation, &windage, prefs, range_m),
build_plan(Strategy::HoldAll, &elevation, &windage, prefs, range_m),
build_plan(Strategy::Hybrid, &elevation, &windage, prefs, range_m),
];
plans.sort_by(|a, b| {
(!a.feasible)
.cmp(&!b.feasible)
.then_with(|| a.residual_linear_at_range_m.total_cmp(&b.residual_linear_at_range_m))
.then_with(|| {
preference_rank(a.strategy, prefs.prefer_hold)
.cmp(&preference_rank(b.strategy, prefs.prefer_hold))
})
.then_with(|| declaration_rank(a.strategy).cmp(&declaration_rank(b.strategy)))
});
Ok(DialPlanReportV1 {
schema_version: DIAL_PLAN_SCHEMA_VERSION_V1,
method: "dial_space_quantization_v1".to_string(),
assumptions: vec![
"Linear miss at range uses the small-angle approximation (mil / 1000 * range); \
it is not exact at extreme angles."
.to_string(),
"Elevation and windage are planned independently; no cant-induced coupling \
between axes is modeled."
.to_string(),
"Reticle holds are assumed continuous and unquantized, unlike turret clicks."
.to_string(),
"Travel limits and turret state are trusted exactly as declared in the optic \
profile, not sensed or independently verified."
.to_string(),
"MOA-graduated clicks convert to milliradians using the locked printed-table \
constant 3438, not the exact geometric 3437.7467."
.to_string(),
],
range_m,
plans,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adjustment::ClickBase;
fn baseline_profile() -> OpticProfile {
OpticProfile {
elevation_click: ClickValue { size: 0.1, base: ClickBase::Mil },
windage_click: ClickValue { size: 0.1, base: ClickBase::Mil },
clicks_per_revolution: Some(10),
zero_stop: true,
elevation_travel: Some(TravelLimits { down_mil: 0.4, up_mil: 28.0 }),
windage_travel: Some(TravelLimits { down_mil: 6.0, up_mil: 6.0 }),
turret_state: Some(TurretState { elevation_mil: 0.0, windage_mil: 0.0 }),
reticle_hold_bounds: Some(HoldBounds {
up_mil: 5.0,
down_mil: 10.0,
left_mil: 6.0,
right_mil: 6.0,
}),
}
}
#[test]
fn validate_accepts_a_full_realistic_profile() {
assert_eq!(baseline_profile().validate(), Ok(()));
}
#[test]
fn validate_rejects_negative_travel() {
let mut down_negative = baseline_profile();
down_negative.elevation_travel = Some(TravelLimits { down_mil: -0.4, up_mil: 28.0 });
assert!(
matches!(
down_negative.validate(),
Err(OpticError::NegativeLimit { field: "elevation_travel.down_mil", value })
if value == -0.4
),
"{:?}",
down_negative.validate()
);
let mut up_negative = baseline_profile();
up_negative.windage_travel = Some(TravelLimits { down_mil: 6.0, up_mil: -6.0 });
assert!(
matches!(
up_negative.validate(),
Err(OpticError::NegativeLimit { field: "windage_travel.up_mil", value })
if value == -6.0
),
"{:?}",
up_negative.validate()
);
let mut hold_negative = baseline_profile();
hold_negative.reticle_hold_bounds = Some(HoldBounds {
up_mil: -5.0,
down_mil: 10.0,
left_mil: 6.0,
right_mil: 6.0,
});
assert!(
matches!(
hold_negative.validate(),
Err(OpticError::NegativeLimit { field: "reticle_hold_bounds.up_mil", .. })
),
"{:?}",
hold_negative.validate()
);
}
#[test]
fn validate_rejects_zero_clicks_per_revolution() {
let mut zero_cpr = baseline_profile();
zero_cpr.clicks_per_revolution = Some(0);
assert!(matches!(
zero_cpr.validate(),
Err(OpticError::ZeroClicksPerRevolution)
));
let mut one_cpr = baseline_profile();
one_cpr.clicks_per_revolution = Some(1);
assert_eq!(one_cpr.validate(), Ok(()));
let mut no_cpr = baseline_profile();
no_cpr.clicks_per_revolution = None;
assert_eq!(no_cpr.validate(), Ok(()));
}
#[test]
fn validate_rejects_non_positive_click_size() {
for bad_size in [0.0, -0.1] {
let mut elevation_bad = baseline_profile();
elevation_bad.elevation_click.size = bad_size;
assert!(
matches!(
elevation_bad.validate(),
Err(OpticError::NonPositiveClickSize { field: "elevation_click.size", size })
if size == bad_size
),
"size {bad_size}: {:?}",
elevation_bad.validate()
);
let mut windage_bad = baseline_profile();
windage_bad.windage_click.size = bad_size;
assert!(
matches!(
windage_bad.validate(),
Err(OpticError::NonPositiveClickSize { field: "windage_click.size", size })
if size == bad_size
),
"size {bad_size}: {:?}",
windage_bad.validate()
);
}
let mut tiny = baseline_profile();
tiny.elevation_click.size = f64::MIN_POSITIVE;
assert_eq!(tiny.validate(), Ok(()));
}
#[test]
fn validate_rejects_non_finite_fields() {
type Mutator = fn(&mut OpticProfile);
let cases: &[(&str, Mutator)] = &[
("elevation_click.size", |p| p.elevation_click.size = f64::NAN),
("windage_click.size", |p| p.windage_click.size = f64::INFINITY),
("elevation_travel.down_mil", |p| {
p.elevation_travel.as_mut().unwrap().down_mil = f64::NAN
}),
("elevation_travel.up_mil", |p| {
p.elevation_travel.as_mut().unwrap().up_mil = f64::INFINITY
}),
("windage_travel.down_mil", |p| {
p.windage_travel.as_mut().unwrap().down_mil = f64::NEG_INFINITY
}),
("windage_travel.up_mil", |p| {
p.windage_travel.as_mut().unwrap().up_mil = f64::NAN
}),
("turret_state.elevation_mil", |p| {
p.turret_state.as_mut().unwrap().elevation_mil = f64::NAN
}),
("turret_state.windage_mil", |p| {
p.turret_state.as_mut().unwrap().windage_mil = f64::INFINITY
}),
("reticle_hold_bounds.up_mil", |p| {
p.reticle_hold_bounds.as_mut().unwrap().up_mil = f64::NAN
}),
("reticle_hold_bounds.down_mil", |p| {
p.reticle_hold_bounds.as_mut().unwrap().down_mil = f64::NAN
}),
("reticle_hold_bounds.left_mil", |p| {
p.reticle_hold_bounds.as_mut().unwrap().left_mil = f64::NAN
}),
("reticle_hold_bounds.right_mil", |p| {
p.reticle_hold_bounds.as_mut().unwrap().right_mil = f64::NAN
}),
];
for (field, mutate) in cases {
let mut profile = baseline_profile();
mutate(&mut profile);
let result = profile.validate();
assert!(
matches!(&result, Err(OpticError::NonFinite { field: f }) if f == field),
"field {field}: expected Err(NonFinite {{ field: {field:?} }}), got {result:?}"
);
}
}
#[test]
fn validate_rejects_turret_state_outside_travel() {
let mut above_up = baseline_profile();
above_up.turret_state = Some(TurretState { elevation_mil: 28.1, windage_mil: 0.0 });
assert!(
matches!(
above_up.validate(),
Err(OpticError::StateOutsideTravel {
axis: "elevation",
dialed_mil,
down_mil,
up_mil
}) if dialed_mil == 28.1 && down_mil == 0.4 && up_mil == 28.0
),
"{:?}",
above_up.validate()
);
let mut below_down = baseline_profile();
below_down.turret_state = Some(TurretState { elevation_mil: -0.5, windage_mil: 0.0 });
assert!(matches!(
below_down.validate(),
Err(OpticError::StateOutsideTravel { axis: "elevation", .. })
));
let mut windage_out = baseline_profile();
windage_out.turret_state = Some(TurretState { elevation_mil: 0.0, windage_mil: 6.1 });
assert!(matches!(
windage_out.validate(),
Err(OpticError::StateOutsideTravel { axis: "windage", .. })
));
let mut at_boundary = baseline_profile();
at_boundary.turret_state = Some(TurretState { elevation_mil: 28.0, windage_mil: -6.0 });
assert_eq!(at_boundary.validate(), Ok(()));
let mut untethered = baseline_profile();
untethered.elevation_travel = None;
untethered.turret_state = Some(TurretState { elevation_mil: 999.0, windage_mil: 0.0 });
assert_eq!(untethered.validate(), Ok(()));
}
#[test]
fn revolution_annotation_matches_known_points_at_cpr_ten() {
assert_eq!(revolution_annotation(0, 10), Some((0, 0)));
assert_eq!(revolution_annotation(9, 10), Some((0, 9)));
assert_eq!(revolution_annotation(10, 10), Some((1, 0)));
assert_eq!(revolution_annotation(27, 10), Some((2, 7)));
}
#[test]
fn revolution_annotation_reconstructs_the_input_over_a_range() {
for cpr in [1_u32, 3, 10, 60] {
for input in 0_i64..100 {
let (revolutions, clicks_in_rev) = revolution_annotation(input, cpr)
.unwrap_or_else(|| panic!("expected Some for input {input}, cpr {cpr}"));
assert!(
clicks_in_rev < cpr,
"clicks_in_rev {clicks_in_rev} must be < cpr {cpr}"
);
assert_eq!(
i64::from(revolutions) * i64::from(cpr) + i64::from(clicks_in_rev),
input,
"reconstruction broke at input {input}, cpr {cpr}"
);
}
}
}
#[test]
fn revolution_annotation_negative_input_is_none() {
for clicks in [-1_i64, -7, -10, -27, i64::MIN] {
assert_eq!(
revolution_annotation(clicks, 10),
None,
"clicks_from_zero {clicks} must be None, not a misleading revolution count"
);
}
}
#[test]
fn revolution_annotation_zero_clicks_per_revolution_is_none_not_a_panic() {
assert_eq!(revolution_annotation(0, 0), None);
assert_eq!(revolution_annotation(5, 0), None);
assert_eq!(revolution_annotation(-5, 0), None);
}
#[test]
fn optic_profile_round_trips_through_json() {
let profile = baseline_profile();
let json = serde_json::to_string(&profile).unwrap();
let parsed: OpticProfile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, profile);
}
#[test]
fn optic_profile_json_pins_click_fields_to_the_suffixed_string() {
let profile = baseline_profile();
let json = serde_json::to_value(&profile).unwrap();
assert_eq!(json["elevation_click"], serde_json::json!("0.1mil"));
assert_eq!(json["windage_click"], serde_json::json!("0.1mil"));
}
#[test]
fn optic_profile_json_pins_option_field_presence_and_absence() {
let present = serde_json::to_value(baseline_profile()).unwrap();
for field in [
"clicks_per_revolution",
"elevation_travel",
"windage_travel",
"turret_state",
"reticle_hold_bounds",
] {
let value = &present[field];
assert!(
!value.is_null(),
"{field} should be present as a real value in the baseline profile, got {value:?}"
);
}
let mut profile = baseline_profile();
profile.clicks_per_revolution = None;
profile.elevation_travel = None;
profile.windage_travel = None;
profile.turret_state = None;
profile.reticle_hold_bounds = None;
let absent = serde_json::to_value(&profile).unwrap();
for field in [
"clicks_per_revolution",
"elevation_travel",
"windage_travel",
"turret_state",
"reticle_hold_bounds",
] {
assert_eq!(
absent.get(field),
Some(&serde_json::Value::Null),
"{field} should be a present-but-null key, not an omitted one"
);
}
let json = serde_json::to_string(&profile).unwrap();
let parsed: OpticProfile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, profile);
}
}
#[cfg(test)]
mod plan_corrections_tests {
use super::*;
use crate::adjustment::ClickBase;
fn baseline_profile() -> OpticProfile {
OpticProfile {
elevation_click: ClickValue { size: 0.1, base: ClickBase::Mil },
windage_click: ClickValue { size: 0.1, base: ClickBase::Mil },
clicks_per_revolution: Some(10),
zero_stop: true,
elevation_travel: Some(TravelLimits { down_mil: 0.4, up_mil: 28.0 }),
windage_travel: Some(TravelLimits { down_mil: 6.0, up_mil: 6.0 }),
turret_state: Some(TurretState { elevation_mil: 0.0, windage_mil: 0.0 }),
reticle_hold_bounds: Some(HoldBounds {
up_mil: 5.0,
down_mil: 10.0,
left_mil: 6.0,
right_mil: 6.0,
}),
}
}
fn plan_for(strategy: Strategy, report: &DialPlanReportV1) -> DialPlan {
report
.plans
.iter()
.find(|p| p.strategy == strategy)
.unwrap_or_else(|| panic!("no {strategy:?} plan in report"))
.clone()
}
#[test]
fn exact_click_dope_has_residual_exactly_zero() {
let corr = AngularCorrection { elevation_mil: 23.0 * 0.1, windage_mil: 0.0 };
let optic = baseline_profile();
let report =
plan_corrections(corr, &optic, 600.0, 1.0, 1.0, &Preferences::default()).unwrap();
let dial_all = plan_for(Strategy::DialAll, &report);
assert_eq!(dial_all.instructions[0].target_clicks_from_zero, 23);
assert_eq!(dial_all.instructions[0].residual_mil, 0.0, "must be bit-exact zero");
assert_eq!(dial_all.instructions[0].residual_mil.to_bits(), 0.0_f64.to_bits());
assert!(dial_all.feasible, "{dial_all:?}");
assert!(dial_all.limits_hit.is_empty(), "{dial_all:?}");
}
#[test]
fn fractional_click_reports_rounding_and_linear_error() {
let corr = AngularCorrection { elevation_mil: 2.34, windage_mil: 0.0 };
let optic = baseline_profile();
let report =
plan_corrections(corr, &optic, 600.0, 1.0, 1.0, &Preferences::default()).unwrap();
let dial_all = plan_for(Strategy::DialAll, &report);
assert_eq!(dial_all.instructions[0].target_clicks_from_zero, 23);
let expected_residual = 2.34 - 2.3;
assert!(
(dial_all.instructions[0].residual_mil - expected_residual).abs() < 1e-12,
"residual {} vs hand-derived {}",
dial_all.instructions[0].residual_mil,
expected_residual
);
assert!(
(dial_all.residual_linear_at_range_m - 0.024).abs() < 1e-9,
"residual_linear_at_range_m = {}",
dial_all.residual_linear_at_range_m
);
let hybrid = plan_for(Strategy::Hybrid, &report);
assert_eq!(
hybrid.instructions[0].residual_mil, 0.0,
"Hybrid's residual is an identity by construction, always bit-exact zero"
);
assert!(
(hybrid.instructions[0].hold_mil - 0.04).abs() < 1e-9,
"hold_mil = {}",
hybrid.instructions[0].hold_mil
);
}
#[test]
fn mil_and_moa_optics_are_physically_equivalent() {
let corr = AngularCorrection { elevation_mil: 2.34, windage_mil: 0.0 };
let prefs = Preferences::default();
let mil_optic = baseline_profile();
let mil_report = plan_corrections(corr, &mil_optic, 100.0, 1.0, 1.0, &prefs).unwrap();
let mut moa_optic = baseline_profile();
moa_optic.elevation_click = ClickValue { size: 0.25, base: ClickBase::Moa };
let moa_report = plan_corrections(corr, &moa_optic, 100.0, 1.0, 1.0, &prefs).unwrap();
for (label, report, click) in [
("mil", &mil_report, &mil_optic.elevation_click),
("moa", &moa_report, &moa_optic.elevation_click),
] {
let hybrid = plan_for(Strategy::Hybrid, report);
let e = &hybrid.instructions[0];
assert!(
(e.dial_mil_true + e.hold_mil - corr.elevation_mil).abs() < 1e-12,
"{label}: dial_true {} + hold {} should reconstruct corr {} to 1e-12",
e.dial_mil_true,
e.hold_mil,
corr.elevation_mil
);
assert_eq!(e.residual_mil, 0.0, "{label}: Hybrid residual must be bit-exact zero");
let dial_all = plan_for(Strategy::DialAll, report);
let click_mil = click_size_mil(click);
assert!(
dial_all.instructions[0].residual_mil.abs() <= click_mil / 2.0,
"{label}: DialAll residual {} must be within half a click ({})",
dial_all.instructions[0].residual_mil,
click_mil / 2.0
);
}
}
#[test]
fn cf_dial_space_worked_example() {
let corr = AngularCorrection { elevation_mil: 5.0, windage_mil: 0.0 };
let optic = baseline_profile();
let report =
plan_corrections(corr, &optic, 100.0, 0.98, 1.0, &Preferences::default()).unwrap();
let dial_all = plan_for(Strategy::DialAll, &report);
let e = &dial_all.instructions[0];
assert_eq!(e.target_clicks_from_zero, 51, "51 clicks: 5.0/0.98 = 5.10204... -> round");
assert!(
(e.dial_mil_true - 4.998).abs() < 1e-12,
"dial_mil_true = {} (expected 51*0.1*0.98 = 4.998)",
e.dial_mil_true
);
let hybrid = plan_for(Strategy::Hybrid, &report);
let eh = &hybrid.instructions[0];
assert_eq!(eh.target_clicks_from_zero, 51);
assert!(
(eh.hold_mil - 0.002).abs() < 1e-12,
"hybrid hold_mil = {} (expected 5.0 - 4.998 = 0.002)",
eh.hold_mil
);
assert_eq!(eh.residual_mil, 0.0, "Hybrid residual must be bit-exact zero");
assert!(hybrid.feasible, "{hybrid:?}");
}
#[test]
fn revolutions_and_zero_stop() {
let optic = baseline_profile(); let prefs = Preferences::default();
let up_corr = AngularCorrection { elevation_mil: 27.0 * 0.1, windage_mil: 0.0 };
let up_report = plan_corrections(up_corr, &optic, 100.0, 1.0, 1.0, &prefs).unwrap();
let dial_all = plan_for(Strategy::DialAll, &up_report);
assert_eq!(dial_all.instructions[0].target_clicks_from_zero, 27);
assert_eq!(dial_all.instructions[0].end_revolution, Some((2, 7)));
assert!(dial_all.feasible, "{dial_all:?}");
let down_corr = AngularCorrection { elevation_mil: -1.0, windage_mil: 0.0 };
let down_report = plan_corrections(down_corr, &optic, 100.0, 1.0, 1.0, &prefs).unwrap();
let dial_all = plan_for(Strategy::DialAll, &down_report);
let e = &dial_all.instructions[0];
assert_eq!(e.target_clicks_from_zero, -4, "clamped to the 0.4 mil / 0.1 mil = 4 clicks available");
assert!(!dial_all.feasible, "DialAll must be infeasible when travel-clamped");
assert_eq!(dial_all.limits_hit.len(), 1);
assert!(matches!(
dial_all.limits_hit[0],
LimitViolation {
axis: Axis::Elevation,
kind: LimitKind::TravelExceeded,
needed_mil,
available_mil: Some(available_mil),
} if (needed_mil - (-1.0)).abs() < 1e-12 && available_mil == 0.4
), "{:?}", dial_all.limits_hit[0]);
let hybrid = plan_for(Strategy::Hybrid, &down_report);
let eh = &hybrid.instructions[0];
assert_eq!(eh.target_clicks_from_zero, -4, "Hybrid dials the same clamped -4 clicks");
assert!(
eh.hold_mil.is_sign_negative() && (eh.hold_mil - (-0.6)).abs() < 1e-9,
"Hybrid holds the rest: hold_mil = {} (expected -0.6)",
eh.hold_mil
);
assert_eq!(eh.residual_mil, 0.0, "Hybrid residual must be bit-exact zero even when clamped");
assert!(
hybrid.limits_hit.iter().any(|v| matches!(v.kind, LimitKind::TravelExceeded)),
"{:?}",
hybrid.limits_hit
);
assert!(
hybrid.feasible,
"Hybrid must remain feasible: the hold (-0.6) fits within the 5.0 mil up_mil \
bound, even though its dial component was travel-clamped: {hybrid:?}"
);
}
#[test]
fn infeasible_is_reported_never_silently_clamped() {
let optic = baseline_profile(); let corr = AngularCorrection { elevation_mil: -1.0, windage_mil: 0.0 };
let report =
plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &Preferences::default()).unwrap();
let dial_all = plan_for(Strategy::DialAll, &report);
assert!(!dial_all.feasible);
let e = &dial_all.instructions[0];
let clamped_dial_true = e.target_clicks_from_zero as f64 * 0.1 * 1.0;
assert_eq!(clamped_dial_true, -0.4);
let honest_residual = corr.elevation_mil - clamped_dial_true;
assert_eq!(
e.residual_mil, honest_residual,
"residual_mil must equal corr_true - clamped_dial_true exactly, not a smaller, \
optimistic number"
);
assert!(
e.residual_mil.abs() > 0.5,
"a silently-optimistic implementation might under-report this; the real miss \
is large (-0.6 mil): residual_mil = {}",
e.residual_mil
);
for plan in &report.plans {
match plan.strategy {
Strategy::DialAll | Strategy::HoldAll => {
assert_eq!(
plan.feasible,
plan.limits_hit.is_empty(),
"{:?}: DialAll/HoldAll feasibility must exactly track limits_hit",
plan.strategy
);
}
Strategy::Hybrid => {} }
}
}
#[test]
fn ranking_is_deterministic_and_preference_respected() {
let optic = baseline_profile();
let corr = AngularCorrection { elevation_mil: 23.0 * 0.1, windage_mil: 0.0 };
let prefer_dial = Preferences { prefer_hold: false, max_hold_mil: None };
let report_dial =
plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &prefer_dial).unwrap();
let strategies: Vec<Strategy> = report_dial.plans.iter().map(|p| p.strategy).collect();
assert_eq!(
strategies,
vec![Strategy::DialAll, Strategy::Hybrid, Strategy::HoldAll],
"prefer_hold=false must rank DialAll < Hybrid < HoldAll when fully tied"
);
let prefer_hold = Preferences { prefer_hold: true, max_hold_mil: None };
let report_hold =
plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &prefer_hold).unwrap();
let strategies: Vec<Strategy> = report_hold.plans.iter().map(|p| p.strategy).collect();
assert_eq!(
strategies,
vec![Strategy::HoldAll, Strategy::Hybrid, Strategy::DialAll],
"prefer_hold=true must reverse the order to HoldAll < Hybrid < DialAll"
);
let frac_corr = AngularCorrection { elevation_mil: 2.34, windage_mil: 0.0 };
for prefs in [prefer_dial, prefer_hold] {
let report =
plan_corrections(frac_corr, &optic, 100.0, 1.0, 1.0, &prefs).unwrap();
assert_eq!(
report.plans.last().unwrap().strategy,
Strategy::DialAll,
"DialAll's nonzero residual must always rank it last, prefer_hold={}",
prefs.prefer_hold
);
for pair in report.plans.windows(2) {
assert!(
pair[0].residual_linear_at_range_m <= pair[1].residual_linear_at_range_m,
"{:?}",
report.plans
);
}
}
}
#[test]
fn report_carries_method_and_all_five_assumptions() {
let optic = baseline_profile();
let corr = AngularCorrection { elevation_mil: 2.3, windage_mil: 0.0 };
let report =
plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &Preferences::default()).unwrap();
assert_eq!(report.schema_version, DIAL_PLAN_SCHEMA_VERSION_V1);
assert_eq!(report.method, "dial_space_quantization_v1");
assert_eq!(report.assumptions.len(), 5, "{:?}", report.assumptions);
assert_eq!(
report.assumptions[0],
"Linear miss at range uses the small-angle approximation (mil / 1000 * range); it is not exact at extreme angles."
);
assert_eq!(
report.assumptions[1],
"Elevation and windage are planned independently; no cant-induced coupling between axes is modeled."
);
assert_eq!(
report.assumptions[2],
"Reticle holds are assumed continuous and unquantized, unlike turret clicks."
);
assert_eq!(
report.assumptions[3],
"Travel limits and turret state are trusted exactly as declared in the optic profile, not sensed or independently verified."
);
assert_eq!(
report.assumptions[4],
"MOA-graduated clicks convert to milliradians using the locked printed-table constant 3438, not the exact geometric 3437.7467."
);
}
#[test]
fn turret_state_shifts_delta_but_not_target() {
let corr = AngularCorrection { elevation_mil: 23.0 * 0.1, windage_mil: 0.0 };
let prefs = Preferences::default();
let zeroed = baseline_profile(); let report_zeroed = plan_corrections(corr, &zeroed, 100.0, 1.0, 1.0, &prefs).unwrap();
let dial_zeroed = plan_for(Strategy::DialAll, &report_zeroed);
let mut dialed = baseline_profile();
dialed.turret_state = Some(TurretState { elevation_mil: 1.0, windage_mil: 0.0 });
let report_dialed = plan_corrections(corr, &dialed, 100.0, 1.0, 1.0, &prefs).unwrap();
let dial_dialed = plan_for(Strategy::DialAll, &report_dialed);
assert_eq!(dial_zeroed.instructions[0].target_clicks_from_zero, 23);
assert_eq!(dial_dialed.instructions[0].target_clicks_from_zero, 23,
"target_clicks_from_zero must NOT move just because turret_state changed");
assert_eq!(dial_zeroed.instructions[0].delta_clicks, 23);
assert_eq!(
dial_dialed.instructions[0].delta_clicks, 13,
"1.0 mil dialed = 10 clicks of state; delta_clicks must drop by exactly 10 (23 -> 13)"
);
assert_eq!(dial_zeroed.instructions[0].end_revolution, Some((2, 3)));
assert_eq!(
dial_dialed.instructions[0].end_revolution,
Some((2, 3)),
"end_revolution must stay (2, 3) with turret_state dialed, not shift to \
delta_clicks=13's (1, 3)"
);
}
#[test]
fn missing_travel_data_is_disclosed_and_gates_dial_all_but_not_hybrid() {
let mut optic = baseline_profile();
optic.elevation_travel = None; let corr = AngularCorrection { elevation_mil: 2.34, windage_mil: 0.0 };
let report =
plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &Preferences::default()).unwrap();
let dial_all = plan_for(Strategy::DialAll, &report);
assert!(
dial_all.limits_hit.iter().any(|v| matches!(
v,
LimitViolation { axis: Axis::Elevation, kind: LimitKind::NoTravelData, available_mil: None, .. }
)),
"{:?}",
dial_all.limits_hit
);
assert!(!dial_all.feasible, "DialAll cannot affirm feasibility without travel data");
let hybrid = plan_for(Strategy::Hybrid, &report);
assert!(
hybrid.limits_hit.iter().any(|v| matches!(v.kind, LimitKind::NoTravelData)),
"Hybrid still discloses the missing data: {:?}",
hybrid.limits_hit
);
assert!(
hybrid.feasible,
"Hybrid's feasibility does not depend on travel data at all, only its hold \
fitting bounds (it does, here): {hybrid:?}"
);
let zero_corr = AngularCorrection { elevation_mil: 0.0, windage_mil: 0.0 };
let zero_report =
plan_corrections(zero_corr, &optic, 100.0, 1.0, 1.0, &Preferences::default())
.unwrap();
let zero_dial_all = plan_for(Strategy::DialAll, &zero_report);
assert!(zero_dial_all.limits_hit.is_empty(), "{:?}", zero_dial_all.limits_hit);
assert!(zero_dial_all.feasible);
}
#[test]
fn missing_hold_bound_data_is_disclosed_and_gates_hold_all_and_hybrid() {
let mut optic = baseline_profile();
optic.reticle_hold_bounds = None; let corr = AngularCorrection { elevation_mil: 2.34, windage_mil: 0.0 };
let prefs = Preferences::default(); let report = plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &prefs).unwrap();
let hold_all = plan_for(Strategy::HoldAll, &report);
assert!(
hold_all.limits_hit.iter().any(|v| matches!(
v,
LimitViolation { axis: Axis::Elevation, kind: LimitKind::NoHoldBoundData, available_mil: None, .. }
)),
"{:?}",
hold_all.limits_hit
);
assert!(!hold_all.feasible);
let hybrid = plan_for(Strategy::Hybrid, &report);
assert!(
hybrid.limits_hit.iter().any(|v| matches!(v.kind, LimitKind::NoHoldBoundData)),
"{:?}",
hybrid.limits_hit
);
assert!(!hybrid.feasible, "Hybrid's hold ALSO cannot be verified here: {hybrid:?}");
let capped = Preferences { prefer_hold: false, max_hold_mil: Some(3.0) };
let capped_report = plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &capped).unwrap();
let capped_hold_all = plan_for(Strategy::HoldAll, &capped_report);
assert!(
capped_hold_all.limits_hit.is_empty(),
"2.34 fits within the 3.0 cap, so nothing should be recorded at all: {:?}",
capped_hold_all.limits_hit
);
assert!(capped_hold_all.feasible, "{:?}", capped_hold_all);
}
#[test]
fn max_clicks_within_handles_exact_and_fractional_boundaries() {
assert_eq!(max_clicks_within(0.4, 0.1), 4);
assert_eq!(max_clicks_within(28.0, 0.1), 280);
assert_eq!(max_clicks_within(6.0, 0.1), 60);
assert_eq!(max_clicks_within(0.45, 0.1), 4);
assert_eq!(max_clicks_within(0.05, 0.1), 0);
}
#[test]
fn preferences_default_prefers_dial_with_no_hold_cap() {
let p = Preferences::default();
assert!(!p.prefer_hold);
assert_eq!(p.max_hold_mil, None);
}
#[test]
fn plan_corrections_rejects_an_invalid_profile() {
let mut optic = baseline_profile();
optic.elevation_click.size = 0.0; let corr = AngularCorrection { elevation_mil: 1.0, windage_mil: 0.0 };
let result = plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &Preferences::default());
assert!(matches!(result, Err(OpticError::NonPositiveClickSize { .. })), "{result:?}");
}
#[test]
fn hold_bound_mapping_matches_reticle_space_not_correction_space() {
let optic = baseline_profile(); let prefs = Preferences::default();
let up_report = plan_corrections(
AngularCorrection { elevation_mil: 7.0, windage_mil: 0.0 },
&optic,
100.0,
1.0,
1.0,
&prefs,
)
.unwrap();
let up_hold_all = plan_for(Strategy::HoldAll, &up_report);
assert!(
up_hold_all.feasible,
"+7.0 mil (up) consumes down_mil=10.0 (available) and must fit: {up_hold_all:?}"
);
assert!(up_hold_all.limits_hit.is_empty(), "{:?}", up_hold_all.limits_hit);
let down_report = plan_corrections(
AngularCorrection { elevation_mil: -7.0, windage_mil: 0.0 },
&optic,
100.0,
1.0,
1.0,
&prefs,
)
.unwrap();
let down_hold_all = plan_for(Strategy::HoldAll, &down_report);
assert!(
!down_hold_all.feasible,
"-7.0 mil (down) consumes up_mil=5.0 (available) and must NOT fit: {down_hold_all:?}"
);
let down_violation = down_hold_all
.limits_hit
.iter()
.find(|v| v.axis == Axis::Elevation && matches!(v.kind, LimitKind::HoldBoundExceeded))
.unwrap_or_else(|| panic!("{:?}", down_hold_all.limits_hit));
assert_eq!(down_violation.available_mil, Some(5.0));
let mut windage_optic = baseline_profile();
{
let bounds = windage_optic.reticle_hold_bounds.as_mut().unwrap();
bounds.left_mil = 8.0;
bounds.right_mil = 3.0;
}
let right_report = plan_corrections(
AngularCorrection { elevation_mil: 0.0, windage_mil: 7.0 },
&windage_optic,
100.0,
1.0,
1.0,
&prefs,
)
.unwrap();
let right_hold_all = plan_for(Strategy::HoldAll, &right_report);
assert!(
right_hold_all.feasible,
"+7.0 mil (right) consumes left_mil=8.0 (available) and must fit: {right_hold_all:?}"
);
let left_report = plan_corrections(
AngularCorrection { elevation_mil: 0.0, windage_mil: -7.0 },
&windage_optic,
100.0,
1.0,
1.0,
&prefs,
)
.unwrap();
let left_hold_all = plan_for(Strategy::HoldAll, &left_report);
assert!(
!left_hold_all.feasible,
"-7.0 mil (left) consumes right_mil=3.0 (available) and must NOT fit: {left_hold_all:?}"
);
let left_violation = left_hold_all
.limits_hit
.iter()
.find(|v| v.axis == Axis::Windage && matches!(v.kind, LimitKind::HoldBoundExceeded))
.unwrap_or_else(|| panic!("{:?}", left_hold_all.limits_hit));
assert_eq!(left_violation.available_mil, Some(3.0));
}
#[test]
fn travel_clamped_hybrid_with_an_unsupportable_hold_is_infeasible() {
let optic = baseline_profile(); let corr = AngularCorrection { elevation_mil: -7.4, windage_mil: 0.0 };
let report =
plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &Preferences::default()).unwrap();
let hybrid = plan_for(Strategy::Hybrid, &report);
let e = &hybrid.instructions[0];
assert_eq!(e.target_clicks_from_zero, -4);
assert_eq!(e.hold_mil, -7.0, "hold_mil = {}", e.hold_mil);
assert!(
!hybrid.feasible,
"a -7.0 mil hold exceeds the correct up_mil=5.0 bound and must be infeasible, \
even though it would fit the WRONG down_mil=10.0 bound an inverted mapping \
would have checked it against: {hybrid:?}"
);
}
#[test]
fn windage_arm_uses_its_own_click_cf_and_contributes_to_the_rss() {
let mut optic = baseline_profile();
optic.windage_click = ClickValue { size: 0.25, base: ClickBase::Moa };
let corr = AngularCorrection { elevation_mil: 2.34, windage_mil: -1.3 };
let report = plan_corrections(corr, &optic, 400.0, 0.98, 1.05, &Preferences::default())
.unwrap();
let dial_all = plan_for(Strategy::DialAll, &report);
let e = &dial_all.instructions[0];
assert_eq!(e.axis, Axis::Elevation);
assert_eq!(e.target_clicks_from_zero, 24);
assert_eq!(e.direction, Direction::Up);
let w = &dial_all.instructions[1];
assert_eq!(w.axis, Axis::Windage);
assert_eq!(w.target_clicks_from_zero, -17);
assert!(
(w.dial_mil_true - (-1.2979930191972078)).abs() < 1e-12,
"windage dial_mil_true = {}",
w.dial_mil_true
);
assert_eq!(w.hold_mil, 0.0, "DialAll never holds");
assert_eq!(w.direction, Direction::Left, "-17 clicks must read as Left, not Up/Down");
assert!(
(dial_all.residual_linear_at_range_m - 0.004866669858419206).abs() < 1e-9,
"residual_linear_at_range_m = {} -- both axes' residuals are nonzero here, so \
this must be a real two-term RSS, not a single-axis pass-through",
dial_all.residual_linear_at_range_m
);
let hybrid = plan_for(Strategy::Hybrid, &report);
let wh = &hybrid.instructions[1];
assert!(
(wh.hold_mil - (-0.0020069808027922686)).abs() < 1e-9,
"hybrid windage hold_mil = {}",
wh.hold_mil
);
assert_eq!(wh.residual_mil, 0.0);
}
#[test]
fn hold_all_hold_is_never_cf_scaled() {
let optic = baseline_profile();
let corr = AngularCorrection { elevation_mil: 5.0, windage_mil: 0.0 };
let report = plan_corrections(corr, &optic, 100.0, 0.98, 1.0, &Preferences::default())
.unwrap();
let hold_all = plan_for(Strategy::HoldAll, &report);
assert_eq!(
hold_all.instructions[0].hold_mil, 5.0,
"must equal corr_true exactly (bit-exact copy, no arithmetic at all) -- NOT \
corr_true / cf = {}",
5.0_f64 / 0.98
);
}
#[test]
fn infeasible_plans_never_outrank_a_feasible_one() {
let optic = baseline_profile();
let corr = AngularCorrection { elevation_mil: 2.34, windage_mil: 0.0 };
let prefs = Preferences { prefer_hold: false, max_hold_mil: Some(0.0) };
let report = plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &prefs).unwrap();
let dial_all = plan_for(Strategy::DialAll, &report);
let hold_all = plan_for(Strategy::HoldAll, &report);
let hybrid = plan_for(Strategy::Hybrid, &report);
assert!(dial_all.feasible, "{dial_all:?}");
assert!(!hold_all.feasible, "{hold_all:?}");
assert!(!hybrid.feasible, "{hybrid:?}");
assert_eq!(hold_all.residual_linear_at_range_m, 0.0);
assert_eq!(hybrid.residual_linear_at_range_m, 0.0);
assert!(dial_all.residual_linear_at_range_m > 0.0);
assert_eq!(
report.plans[0].strategy,
Strategy::DialAll,
"the only feasible plan must rank first, never an infeasible zero-residual one \
(a residual-only ranking would put Hybrid or HoldAll here instead): {:?}",
report.plans
);
assert!(report.plans[0].feasible);
}
#[test]
fn non_positive_or_non_finite_tracking_factor_is_rejected() {
let optic = baseline_profile();
let corr = AngularCorrection { elevation_mil: 1.0, windage_mil: 0.0 };
for bad_cf in [0.0, -1.0, -0.5, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let elevation_result =
plan_corrections(corr, &optic, 100.0, bad_cf, 1.0, &Preferences::default());
assert!(
matches!(
elevation_result,
Err(OpticError::NonPositiveTrackingFactor { field: "elevation_cf", .. })
),
"cf={bad_cf}: {elevation_result:?}"
);
let windage_result =
plan_corrections(corr, &optic, 100.0, 1.0, bad_cf, &Preferences::default());
assert!(
matches!(
windage_result,
Err(OpticError::NonPositiveTrackingFactor { field: "windage_cf", .. })
),
"cf={bad_cf}: {windage_result:?}"
);
}
let lenient = plan_corrections(corr, &optic, 100.0, 0.001, 1.0, &Preferences::default());
assert!(lenient.is_ok(), "{lenient:?}");
}
#[test]
fn travel_violation_needed_mil_is_dial_space_not_true_space_at_nonunit_cf() {
let mut optic = baseline_profile();
optic.elevation_travel = Some(TravelLimits { down_mil: 0.1, up_mil: 0.1 });
let corr = AngularCorrection { elevation_mil: 1.0, windage_mil: 0.0 }; let cf = 0.5;
let report =
plan_corrections(corr, &optic, 100.0, cf, 1.0, &Preferences::default()).unwrap();
let dial_all = plan_for(Strategy::DialAll, &report);
assert!(!dial_all.feasible, "{dial_all:?}");
let violation = dial_all
.limits_hit
.iter()
.find(|v| matches!(v.kind, LimitKind::TravelExceeded))
.unwrap_or_else(|| panic!("{:?}", dial_all.limits_hit));
let corr_dial = corr.elevation_mil / cf; assert_eq!(
violation.needed_mil, corr_dial,
"needed_mil must be DIAL-space (corr_true/cf = {corr_dial}), not TRUE-space \
({})",
corr.elevation_mil
);
}
#[test]
fn direction_wire_form_is_unchanged_and_the_full_report_round_trips() {
for (direction, expected_json) in [
(Direction::Up, "\"up\""),
(Direction::Down, "\"down\""),
(Direction::Left, "\"left\""),
(Direction::Right, "\"right\""),
] {
assert_eq!(serde_json::to_string(&direction).unwrap(), expected_json);
}
let optic = baseline_profile();
let corr = AngularCorrection { elevation_mil: 2.34, windage_mil: -1.3 };
let report = plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &Preferences::default())
.unwrap();
let json = serde_json::to_string(&report).unwrap();
let parsed: DialPlanReportV1 = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, report);
}
}