use serde::de::{self, SeqAccess, Visitor};
use serde::{Deserialize, Deserializer};
use std::collections::BTreeMap;
use std::fmt;
use crate::reticle::{
FocalPlane, MarkKind, ReticleDescription, ReticleError, ReticleMark, MAX_RETICLE_MARKS,
};
pub const MOA_TO_MIL: f64 = 0.2908882;
pub const DEFAULT_TEXT_BIND_MIL: f64 = 1.0;
pub const ARC_TAG: &str = "arc";
const ARC_SYMMETRY_EPSILON_DEGREES: f64 = 1e-9;
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct VentumArcPoint {
pub right_mil: f64,
pub down_mil: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct VentumArc {
pub center: VentumArcPoint,
pub radius_mil: f64,
pub start_degrees: f64,
pub end_degrees: f64,
pub sweep_degrees: f64,
pub start_tip: VentumArcPoint,
pub apex: VentumArcPoint,
pub end_tip: VentumArcPoint,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct VentumImportReport {
pub dropped_elements: usize,
pub dropped_element_types: Vec<(String, usize)>,
pub arcs: Vec<VentumArc>,
pub arcs_unresolved: usize,
pub circle_repeats_unreadable: usize,
}
impl VentumImportReport {
pub fn is_empty(&self) -> bool {
self.dropped_elements == 0
}
pub fn tally(&self) -> String {
self.dropped_element_types
.iter()
.map(|(tag, count)| format!("{tag} x{count}"))
.collect::<Vec<_>>()
.join(", ")
}
}
pub fn import_ventum_reticle(json: &str) -> Result<ReticleDescription, ReticleError> {
import_ventum_reticle_with_report(json).map(|(description, _)| description)
}
pub fn import_ventum_reticle_with_report(
json: &str,
) -> Result<(ReticleDescription, VentumImportReport), ReticleError> {
let reticle: VentumReticle =
serde_json::from_str(json).map_err(|e| ReticleError::InvalidSpec(e.to_string()))?;
let scale = match reticle.unit {
Unit::Mil => 1.0,
Unit::Moa => MOA_TO_MIL,
};
let mut dropped: BTreeMap<&'static str, usize> = BTreeMap::new();
let mut circle_repeats_unreadable = 0usize;
let instances = expand_elements(
&reticle.spec.0,
scale,
&mut dropped,
&mut circle_repeats_unreadable,
)?;
let mut marks: Vec<ReticleMark> = Vec::new();
let mut texts: Vec<TextLabel> = Vec::new();
let mut arcs: Vec<VentumArc> = Vec::new();
let mut arcs_unresolved = 0usize;
for instance in instances {
match instance.role {
Role::Dot => marks.push(ReticleMark::new(instance.down, instance.right, MarkKind::Dot)),
Role::Tick => {
marks.push(ReticleMark::new(instance.down, instance.right, MarkKind::Hash))
}
Role::Text(label) => texts.push(TextLabel {
down: instance.down,
right: instance.right,
label,
}),
Role::Circle(circle) => {
let tag = if circle.is_arc() { ARC_TAG } else { "circle" };
*dropped.entry(tag).or_insert(0) += 1;
if tag == ARC_TAG {
match circle.resolve(instance.down, instance.right) {
Some(arc) => arcs.push(arc),
None => arcs_unresolved += 1,
}
}
}
}
}
let unbound = bind_text_labels(&mut marks, &texts);
if unbound > 0 {
*dropped.entry("text").or_insert(0) += unbound;
}
let reference_magnification = match reticle.plane {
FocalPlane::Second => reticle.ref_magnification,
FocalPlane::First => 1.0,
};
let report = VentumImportReport {
dropped_elements: dropped.values().sum(),
dropped_element_types: dropped
.into_iter()
.map(|(tag, count)| (tag.to_string(), count))
.collect(),
arcs,
arcs_unresolved,
circle_repeats_unreadable,
};
Ok((
ReticleDescription {
name: reticle.name,
focal_plane: reticle.plane,
reference_magnification,
marks,
},
report,
))
}
struct TextLabel {
down: f64,
right: f64,
label: String,
}
fn bind_text_labels(marks: &mut [ReticleMark], texts: &[TextLabel]) -> usize {
let mut unbound = 0usize;
for text in texts {
let mut nearest: Option<usize> = None;
let mut nearest_distance = f64::INFINITY;
for (index, mark) in marks.iter().enumerate() {
let distance = ((text.right - mark.right_mil).powi(2)
+ (text.down - mark.down_mil).powi(2))
.sqrt();
if distance < nearest_distance {
nearest_distance = distance;
nearest = Some(index);
}
}
match nearest {
Some(index) if nearest_distance <= DEFAULT_TEXT_BIND_MIL => {
marks[index].label = Some(text.label.clone());
}
_ => unbound += 1,
}
}
unbound
}
#[derive(Debug, Clone, PartialEq)]
enum Role {
Dot,
Tick,
Text(String),
Circle(CircleShape),
}
impl Role {
fn mirrored(&self, axis: Axis) -> Role {
match self {
Role::Circle(circle) => Role::Circle(circle.mirrored(axis)),
other => other.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct CircleShape {
radius_mil: Option<f64>,
angles: Option<(f64, f64)>,
sweep_unreadable: bool,
center_unreadable: bool,
}
fn normalize_degrees(degrees: f64) -> f64 {
if !degrees.is_finite() {
return degrees;
}
let reduced = degrees.rem_euclid(360.0);
if reduced >= 360.0 {
0.0
} else {
reduced
}
}
impl CircleShape {
fn is_arc(&self) -> bool {
self.sweep_degrees().is_some() || self.sweep_unreadable
}
fn sweep_degrees(&self) -> Option<f64> {
let (start, end) = self.angles?;
if !start.is_finite() || !end.is_finite() {
return None;
}
let sweep = (end - start).rem_euclid(360.0);
(sweep > 0.0).then_some(sweep)
}
fn is_reflection_of_itself(&self, axis: Axis) -> bool {
if self.sweep_unreadable || self.center_unreadable {
return false;
}
let Some((start, end)) = self.angles.filter(|_| self.sweep_degrees().is_some()) else {
return true;
};
let target = match axis {
Axis::X => 180.0,
Axis::Y => 0.0,
};
let offset = (start + end - target).rem_euclid(360.0);
offset.min(360.0 - offset) <= ARC_SYMMETRY_EPSILON_DEGREES
}
fn mirrored(&self, axis: Axis) -> CircleShape {
let angles = self.angles.map(|(start, end)| match axis {
Axis::X => (
normalize_degrees(180.0 - end),
normalize_degrees(180.0 - start),
),
Axis::Y => (normalize_degrees(-end), normalize_degrees(-start)),
});
CircleShape {
radius_mil: self.radius_mil,
angles,
sweep_unreadable: self.sweep_unreadable,
center_unreadable: self.center_unreadable,
}
}
fn resolve(&self, down: f64, right: f64) -> Option<VentumArc> {
if self.sweep_unreadable || self.center_unreadable {
return None;
}
let sweep = self.sweep_degrees()?;
let radius = self.radius_mil?;
if !radius.is_finite() || radius <= 0.0 || !down.is_finite() || !right.is_finite() {
return None;
}
let (start, end) = self.angles?;
let point = |degrees: f64| {
let radians = degrees.to_radians();
VentumArcPoint {
right_mil: right + radius * radians.cos(),
down_mil: down + radius * radians.sin(),
}
};
Some(VentumArc {
center: VentumArcPoint {
right_mil: right,
down_mil: down,
},
radius_mil: radius,
start_degrees: start,
end_degrees: end,
sweep_degrees: sweep,
start_tip: point(start),
apex: point(start + sweep / 2.0),
end_tip: point(end),
})
}
}
#[derive(Debug, Clone, PartialEq)]
struct ExpandedInstance {
down: f64,
right: f64,
role: Role,
}
enum PointRole<'a> {
Dot,
Tick,
Text(&'a str),
Circle(CircleShape),
}
impl PointRole<'_> {
fn role_with_label(&self, ladder_label: Option<String>) -> Role {
match self {
PointRole::Dot => Role::Dot,
PointRole::Tick => Role::Tick,
PointRole::Text(base) => Role::Text(ladder_label.unwrap_or_else(|| (*base).to_string())),
PointRole::Circle(shape) => Role::Circle(*shape),
}
}
fn ladder_label(&self, repeat: &Repeat, i: u32) -> Option<String> {
match self {
PointRole::Text(_) if repeat.label => {
Some(format_label_number(repeat.label_start + f64::from(i) * repeat.label_step))
}
_ => None,
}
}
}
fn expand_elements(
elements: &[Element],
scale: f64,
dropped: &mut BTreeMap<&'static str, usize>,
circle_repeats_unreadable: &mut usize,
) -> Result<Vec<ExpandedInstance>, ReticleError> {
let mut out: Vec<ExpandedInstance> = Vec::new();
for element in elements {
let (x, y, repeat, base) = match element {
Element::Dot { x, y, repeat } => (*x, *y, repeat.as_ref(), PointRole::Dot),
Element::Tick { x, y, repeat } => (*x, *y, repeat.as_ref(), PointRole::Tick),
Element::Text {
x, y, text, repeat,
} => (*x, *y, repeat.as_ref(), PointRole::Text(text)),
Element::Circle(CircleFields {
x,
y,
r,
start,
end,
repeat,
}) => {
if repeat.is_unreadable() {
*circle_repeats_unreadable += 1;
}
(
x.value().unwrap_or(0.0),
y.value().unwrap_or(0.0),
repeat.as_repeat(),
PointRole::Circle(CircleShape {
radius_mil: r.value().map(|r| r * scale),
angles: start.value().zip(end.value()),
sweep_unreadable: start.is_declared()
&& end.is_declared()
&& (start.is_unreadable() || end.is_unreadable()),
center_unreadable: x.is_unreadable() || y.is_unreadable(),
}),
)
}
Element::Line {} => {
*dropped.entry("line").or_insert(0) += 1;
continue;
}
Element::Rect {} => {
*dropped.entry("rect").or_insert(0) += 1;
continue;
}
Element::Grid {} => {
*dropped.entry("grid").or_insert(0) += 1;
continue;
}
Element::Unknown => {
*dropped.entry("unknown").or_insert(0) += 1;
continue;
}
};
expand_point(x, y, repeat, scale, base, &mut out)?;
}
Ok(out)
}
fn expand_point(
x: f64,
y: f64,
repeat: Option<&Repeat>,
scale: f64,
base: PointRole<'_>,
out: &mut Vec<ExpandedInstance>,
) -> Result<(), ReticleError> {
let Some(repeat) = repeat else {
let role = base.role_with_label(None);
return push_capped(y * scale, x * scale, role, out);
};
for i in 0..repeat.n {
let offset = f64::from(i) * repeat.step;
let (cx, cy) = match repeat.axis {
Axis::X => (x + offset, y),
Axis::Y => (x, y + offset),
};
let stepped = match repeat.axis {
Axis::X => cx,
Axis::Y => cy,
};
let down = cy * scale;
let right = cx * scale;
let role = base.role_with_label(base.ladder_label(repeat, i));
let mirror_is_distinct = stepped != 0.0
|| match &role {
Role::Circle(circle) => !circle.is_reflection_of_itself(repeat.axis),
Role::Dot | Role::Tick | Role::Text(_) => false,
};
if repeat.mirror && mirror_is_distinct {
let (mirror_down, mirror_right) = match repeat.axis {
Axis::X => (down, -right),
Axis::Y => (-down, right),
};
let mirror_role = role.mirrored(repeat.axis);
push_capped(down, right, role, out)?;
push_capped(mirror_down, mirror_right, mirror_role, out)?;
} else {
push_capped(down, right, role, out)?;
}
}
Ok(())
}
fn push_capped(
down: f64,
right: f64,
role: Role,
out: &mut Vec<ExpandedInstance>,
) -> Result<(), ReticleError> {
if out.len() >= MAX_RETICLE_MARKS {
return Err(ReticleError::TooManyMarks {
count: out.len() + 1,
max: MAX_RETICLE_MARKS,
});
}
out.push(ExpandedInstance { down, right, role });
Ok(())
}
fn format_label_number(value: f64) -> String {
if value.is_finite() && value == value.round() {
(value as i64).to_string()
} else {
value.to_string()
}
}
#[derive(Debug, Deserialize)]
struct VentumReticle {
#[serde(default)]
name: String,
#[serde(default)]
plane: FocalPlane,
#[serde(default = "default_ref_magnification")]
ref_magnification: f64,
#[serde(default)]
unit: Unit,
#[serde(default)]
spec: Spec,
}
fn default_ref_magnification() -> f64 {
1.0
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Unit {
#[default]
Mil,
Moa,
}
#[derive(Debug, Default)]
struct Spec(Vec<Element>);
impl<'de> Deserialize<'de> for Spec {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct SpecVisitor;
impl<'de> Visitor<'de> for SpecVisitor {
type Value = Spec;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a JSON array of reticle elements, or a string containing that array")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let elements: Vec<Element> =
serde_json::from_str(v).map_err(de::Error::custom)?;
Ok(Spec(elements))
}
fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let elements =
Vec::<Element>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
Ok(Spec(elements))
}
}
deserializer.deserialize_any(SpecVisitor)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
enum Cosmetic {
#[default]
Absent,
Unreadable,
Value(f64),
}
impl Cosmetic {
fn value(self) -> Option<f64> {
match self {
Cosmetic::Value(number) => Some(number),
Cosmetic::Absent | Cosmetic::Unreadable => None,
}
}
fn is_declared(self) -> bool {
!matches!(self, Cosmetic::Absent)
}
fn is_unreadable(self) -> bool {
matches!(self, Cosmetic::Unreadable)
}
fn from_json(value: serde_json::Value) -> Cosmetic {
if value.is_null() {
return Cosmetic::Absent;
}
match value.as_f64().filter(|v| v.is_finite()) {
Some(number) => Cosmetic::Value(number),
None => Cosmetic::Unreadable,
}
}
}
#[derive(Debug, Default)]
enum CosmeticRepeat {
#[default]
Absent,
Unreadable,
Present(Repeat),
}
impl CosmeticRepeat {
fn as_repeat(&self) -> Option<&Repeat> {
match self {
CosmeticRepeat::Present(repeat) => Some(repeat),
CosmeticRepeat::Absent | CosmeticRepeat::Unreadable => None,
}
}
fn is_unreadable(&self) -> bool {
matches!(self, CosmeticRepeat::Unreadable)
}
fn from_json(value: serde_json::Value) -> CosmeticRepeat {
if value.is_null() {
return CosmeticRepeat::Absent;
}
match serde_json::from_value::<Repeat>(value) {
Ok(repeat) => CosmeticRepeat::Present(repeat),
Err(_) => CosmeticRepeat::Unreadable,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Element {
Dot {
x: f64,
y: f64,
#[serde(default)]
repeat: Option<Repeat>,
},
Tick {
x: f64,
y: f64,
#[serde(default)]
repeat: Option<Repeat>,
},
Text {
x: f64,
y: f64,
text: String,
#[serde(default)]
repeat: Option<Repeat>,
},
Line {},
Circle(CircleFields),
Rect {},
Grid {},
#[serde(other)]
Unknown,
}
#[derive(Debug, Default)]
struct CircleFields {
x: Cosmetic,
y: Cosmetic,
r: Cosmetic,
start: Cosmetic,
end: Cosmetic,
repeat: CosmeticRepeat,
}
impl<'de> Deserialize<'de> for CircleFields {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct CircleFieldsVisitor;
impl<'de> Visitor<'de> for CircleFieldsVisitor {
type Value = CircleFields;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a circle element")
}
fn visit_map<A>(self, mut map: A) -> Result<CircleFields, A::Error>
where
A: de::MapAccess<'de>,
{
let mut fields = CircleFields::default();
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"x" | "cx" => fields.x = Cosmetic::from_json(map.next_value()?),
"y" | "cy" => fields.y = Cosmetic::from_json(map.next_value()?),
"r" => fields.r = Cosmetic::from_json(map.next_value()?),
"start" => fields.start = Cosmetic::from_json(map.next_value()?),
"end" => fields.end = Cosmetic::from_json(map.next_value()?),
"repeat" => fields.repeat = CosmeticRepeat::from_json(map.next_value()?),
_ => {
map.next_value::<de::IgnoredAny>()?;
}
}
}
Ok(fields)
}
}
deserializer.deserialize_map(CircleFieldsVisitor)
}
}
#[derive(Debug, Deserialize)]
struct Repeat {
axis: Axis,
step: f64,
n: u32,
#[serde(default)]
mirror: bool,
#[serde(default)]
label: bool,
#[serde(rename = "labelStart", default)]
label_start: f64,
#[serde(rename = "labelStep", default = "default_label_step")]
label_step: f64,
}
fn default_label_step() -> f64 {
1.0
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Axis {
X,
Y,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::reticle::hold_point_in_reticle;
const MBR_SPEC: &str = r#"[{"type":"dot","y":4,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":9,"mirror":true}},{"type":"dot","y":8,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":13,"mirror":true}},{"type":"dot","y":12,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":17,"mirror":true}}]"#;
fn row_rights(desc: &ReticleDescription, down: f64) -> Vec<f64> {
let mut rights: Vec<f64> = desc
.marks
.iter()
.filter(|m| m.down_mil == down)
.map(|m| m.right_mil)
.collect();
rights.sort_by(|a, b| a.partial_cmp(b).unwrap());
rights
}
fn symmetric_spread(max: i32) -> Vec<f64> {
let mut v: Vec<f64> = Vec::new();
for k in 1..=max {
v.push(-f64::from(k));
}
for k in 1..=max {
v.push(f64::from(k));
}
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
v
}
fn assert_mbr(desc: &ReticleDescription) {
assert_eq!(desc.marks.len(), 78, "MBR expands to 78 holdable marks");
assert_eq!(row_rights(desc, 4.0), symmetric_spread(9), "row down=4 -> ±1..±9");
assert_eq!(row_rights(desc, 8.0), symmetric_spread(13), "row down=8 -> ±1..±13");
assert_eq!(row_rights(desc, 12.0), symmetric_spread(17), "row down=12 -> ±1..±17");
assert_eq!(row_rights(desc, 4.0).len(), 18);
assert_eq!(row_rights(desc, 8.0).len(), 26);
assert_eq!(row_rights(desc, 12.0).len(), 34);
assert!(desc.marks.iter().all(|m| m.kind == MarkKind::Dot), "all MBR marks are dots");
assert!(
desc.marks.iter().all(|m| m.right_mil != 0.0),
"no MBR mark lands on the center line"
);
assert!(
row_rights(desc, 4.0).iter().all(|&r| r.abs() <= 9.0),
"row down=4 has nothing beyond ±9"
);
}
#[test]
fn mbr_array_form_expands_to_the_full_dot_tree() {
let json = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
let desc = import_ventum_reticle(&json).unwrap();
assert_eq!(desc.name, "MBR");
assert_eq!(desc.focal_plane, FocalPlane::First);
assert_mbr(&desc);
}
#[test]
fn mbr_string_form_parses_identically_to_the_array_form() {
let string_form = serde_json::json!({
"name": "MBR", "plane": "ffp", "unit": "mil", "spec": MBR_SPEC
})
.to_string();
let array_form = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
let from_string = import_ventum_reticle(&string_form).unwrap();
let from_array = import_ventum_reticle(&array_form).unwrap();
assert_mbr(&from_string);
assert_eq!(
from_string, from_array,
"the string-wrapped spec must import identically to the array spec"
);
}
#[test]
fn sfp_carries_reference_magnification_but_ffp_normalizes_it() {
let sfp = import_ventum_reticle(
r#"{"name":"SE","plane":"sfp","ref_magnification":6,"unit":"mil",
"spec":[{"type":"dot","x":0,"y":1}]}"#,
)
.unwrap();
assert_eq!(sfp.focal_plane, FocalPlane::Second);
assert_eq!(sfp.reference_magnification, 6.0);
let ffp = import_ventum_reticle(
r#"{"name":"F","plane":"ffp","ref_magnification":6,"unit":"mil",
"spec":[{"type":"dot","x":0,"y":1}]}"#,
)
.unwrap();
assert_eq!(ffp.focal_plane, FocalPlane::First);
assert_eq!(ffp.reference_magnification, 1.0);
}
#[test]
fn moa_coordinates_convert_to_milliradians() {
let desc = import_ventum_reticle(
r#"{"name":"M","unit":"moa","spec":[{"type":"dot","x":0,"y":2}]}"#,
)
.unwrap();
assert_eq!(desc.marks.len(), 1);
assert!(
(desc.marks[0].down_mil - 2.0 * MOA_TO_MIL).abs() < 1e-6,
"2 MOA -> {} mil (~0.581776)",
desc.marks[0].down_mil
);
}
#[test]
fn text_binds_to_the_nearest_mark_and_far_text_is_dropped() {
let desc = import_ventum_reticle(
r#"{"name":"T","unit":"mil","spec":[
{"type":"tick","x":0,"y":5},
{"type":"text","x":0.5,"y":5,"text":"5"},
{"type":"text","x":10,"y":10,"text":"far"}
]}"#,
)
.unwrap();
assert_eq!(desc.marks.len(), 1);
assert_eq!(desc.marks[0].down_mil, 5.0);
assert_eq!(desc.marks[0].kind, MarkKind::Hash);
assert_eq!(desc.marks[0].label.as_deref(), Some("5"));
assert!(
desc.marks.iter().all(|m| m.label.as_deref() != Some("far")),
"distant text must not bind"
);
}
#[test]
fn mirror_never_duplicates_a_center_mark() {
let center = import_ventum_reticle(
r#"{"name":"C","unit":"mil","spec":[
{"type":"tick","x":0,"y":1,"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
]}"#,
)
.unwrap();
assert_eq!(center.marks.len(), 1, "no duplicate at the center");
assert_eq!(center.marks[0].right_mil, 0.0);
let off = import_ventum_reticle(
r#"{"name":"O","unit":"mil","spec":[
{"type":"tick","x":1,"y":1,"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
]}"#,
)
.unwrap();
assert_eq!(off.marks.len(), 2, "mirror of an off-center mark yields ±1");
let mut rights: Vec<f64> = off.marks.iter().map(|m| m.right_mil).collect();
rights.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert_eq!(rights, vec![-1.0, 1.0]);
}
#[test]
fn text_ladder_auto_numbers_each_copy() {
let elements = vec![Element::Text {
x: 0.0,
y: 1.0,
text: "base".to_string(),
repeat: Some(Repeat {
axis: Axis::Y,
step: 1.0,
n: 3,
mirror: false,
label: true,
label_start: 0.0,
label_step: 1.0,
}),
}];
let mut dropped = BTreeMap::new();
let mut circle_repeats_unreadable = 0usize;
let instances =
expand_elements(&elements, 1.0, &mut dropped, &mut circle_repeats_unreadable).unwrap();
assert!(dropped.is_empty(), "a text ladder drops nothing");
assert_eq!(circle_repeats_unreadable, 0);
assert_eq!(
instances,
vec![
ExpandedInstance { down: 1.0, right: 0.0, role: Role::Text("0".to_string()) },
ExpandedInstance { down: 2.0, right: 0.0, role: Role::Text("1".to_string()) },
ExpandedInstance { down: 3.0, right: 0.0, role: Role::Text("2".to_string()) },
]
);
}
#[test]
fn label_numbers_print_integers_without_a_decimal() {
assert_eq!(format_label_number(5.0), "5");
assert_eq!(format_label_number(0.0), "0");
assert_eq!(format_label_number(-3.0), "-3");
assert_eq!(format_label_number(5.5), "5.5");
}
#[test]
fn a_runaway_repeat_is_capped_promptly() {
let json = r#"{"name":"X","unit":"mil","spec":[
{"type":"dot","x":0,"y":1,"repeat":{"axis":"x","step":1,"n":100000}}
]}"#;
assert!(matches!(
import_ventum_reticle(json),
Err(ReticleError::TooManyMarks { .. })
));
}
#[test]
fn malformed_json_reports_invalid_spec() {
assert!(matches!(
import_ventum_reticle("{not json"),
Err(ReticleError::InvalidSpec(_))
));
assert!(matches!(
import_ventum_reticle(r#"{"name":"X","spec":"not-an-array"}"#),
Err(ReticleError::InvalidSpec(_))
));
}
#[test]
fn decoration_and_unknown_elements_are_dropped() {
let desc = import_ventum_reticle(
r#"{"name":"D","unit":"mil","spec":[
{"type":"line","x1":-5,"y1":0,"x2":5,"y2":0,"width":0.05},
{"type":"circle","x":0,"y":0,"r":1.0},
{"type":"rect","x":-1,"y":-1,"w":2,"h":2},
{"type":"grid","x0":-5,"y0":-5,"x1":5,"y1":5,"step":1},
{"type":"future_shape","x":0,"y":0},
{"type":"dot","x":0,"y":2,"color":"red","width":0.1}
]}"#,
)
.unwrap();
assert_eq!(desc.marks.len(), 1);
assert_eq!(desc.marks[0].down_mil, 2.0);
assert_eq!(desc.marks[0].kind, MarkKind::Dot);
}
#[test]
fn a_horseshoe_apex_resolves_to_the_top_of_the_reticle() {
let (_, report) = import_ventum_reticle_with_report(
r#"{"name":"H","unit":"mil","spec":[
{"type":"dot","x":0,"y":1},
{"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340}
]}"#,
)
.unwrap();
assert_eq!(report.arcs.len(), 1, "the horseshoe is reported");
let arc = report.arcs[0];
assert!((arc.sweep_degrees - 140.0).abs() < 1e-9, "200 -> 340 clockwise is 140 deg");
assert!(arc.apex.right_mil.abs() < 1e-9, "apex is on the vertical axis");
assert!(
(arc.apex.down_mil + 2.0).abs() < 1e-9,
"apex must be 2 mil ABOVE center (down_mil -2), not below: {:?}",
arc.apex
);
assert!(arc.start_tip.right_mil < 0.0, "the 200 deg tip is on the left");
assert!(arc.end_tip.right_mil > 0.0, "the 340 deg tip is on the right");
for tip in [arc.start_tip, arc.end_tip] {
assert!(
tip.down_mil > arc.apex.down_mil,
"a tip of a downward-opening horseshoe is below its apex: {tip:?}"
);
}
}
#[test]
fn an_arc_is_reported_but_never_becomes_a_mark() {
let spec = |arc: &str| {
format!(
r#"{{"name":"A","unit":"mil","spec":[{{"type":"tick","x":0,"y":5}}{arc}]}}"#
)
};
let without = import_ventum_reticle(&spec("")).unwrap();
let with = import_ventum_reticle(&spec(
r#",{"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340}"#,
))
.unwrap();
assert_eq!(
without, with,
"adding an arc must not change a single imported mark"
);
}
#[test]
fn a_ring_is_counted_as_a_circle_and_an_arc_as_an_arc() {
let (_, report) = import_ventum_reticle_with_report(
r#"{"name":"R","unit":"mil","spec":[
{"type":"dot","x":0,"y":1},
{"type":"circle","x":0,"y":0,"r":2},
{"type":"circle","x":0,"y":0,"r":3,"start":0,"end":360},
{"type":"circle","x":0,"y":0,"r":4,"start":200,"end":340}
]}"#,
)
.unwrap();
assert_eq!(
report.dropped_element_types,
vec![("arc".to_string(), 1), ("circle".to_string(), 2)],
"a whole-revolution sweep is a ring, not an arc"
);
assert_eq!(report.arcs.len(), 1);
assert_eq!(report.dropped_elements, 3);
}
#[test]
fn the_report_tallies_every_dropped_type_including_unbound_text() {
let (desc, report) = import_ventum_reticle_with_report(
r#"{"name":"D","unit":"mil","spec":[
{"type":"dot","x":0,"y":2},
{"type":"text","x":0.2,"y":2,"text":"near"},
{"type":"text","x":9,"y":9,"text":"far"},
{"type":"line","x1":-5,"y1":0,"x2":5,"y2":0},
{"type":"line","x1":0,"y1":-5,"x2":0,"y2":5},
{"type":"rect","x":-1,"y":-1,"w":2,"h":2},
{"type":"grid","x0":-5,"y0":-5,"x1":5,"y1":5,"step":1},
{"type":"future_shape","x":0,"y":0}
]}"#,
)
.unwrap();
assert_eq!(desc.marks.len(), 1);
assert_eq!(desc.marks[0].label.as_deref(), Some("near"));
assert_eq!(
report.dropped_element_types,
vec![
("grid".to_string(), 1),
("line".to_string(), 2),
("rect".to_string(), 1),
("text".to_string(), 1),
("unknown".to_string(), 1),
]
);
assert_eq!(report.dropped_elements, 6);
assert_eq!(report.tally(), "grid x1, line x2, rect x1, text x1, unknown x1");
assert!(!report.is_empty());
}
#[test]
fn a_fully_representable_reticle_reports_nothing_dropped() {
let json = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
let (_, report) = import_ventum_reticle_with_report(&json).unwrap();
assert!(report.is_empty(), "MBR is all dots: {report:?}");
assert_eq!(report, VentumImportReport::default());
assert_eq!(report.tally(), "");
}
#[test]
fn a_mirrored_arc_reflects_its_angles_not_just_its_center() {
let (_, report) = import_ventum_reticle_with_report(
r#"{"name":"M","unit":"mil","spec":[
{"type":"circle","x":3,"y":0,"r":1,"start":290,"end":70,
"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
]}"#,
)
.unwrap();
assert_eq!(report.arcs.len(), 2, "mirror emits both horseshoes");
let right = report.arcs.iter().find(|a| a.center.right_mil > 0.0).unwrap();
let left = report.arcs.iter().find(|a| a.center.right_mil < 0.0).unwrap();
assert!((right.apex.right_mil - 4.0).abs() < 1e-9, "{:?}", right.apex);
assert!(
(left.apex.right_mil + 4.0).abs() < 1e-9,
"mirrored apex must reflect too (got {:?}, an unreflected sweep gives -2)",
left.apex
);
assert!((left.sweep_degrees - right.sweep_degrees).abs() < 1e-9);
assert!((left.apex.down_mil - right.apex.down_mil).abs() < 1e-9);
}
#[test]
fn an_arc_without_a_radius_is_counted_as_unresolved() {
let (_, report) = import_ventum_reticle_with_report(
r#"{"name":"U","unit":"mil","spec":[
{"type":"circle","x":0,"y":0,"start":200,"end":340},
{"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340}
]}"#,
)
.unwrap();
assert_eq!(report.dropped_element_types, vec![("arc".to_string(), 2)]);
assert_eq!(report.arcs.len(), 1);
assert_eq!(report.arcs_unresolved, 1);
assert_eq!(
report.arcs.len() + report.arcs_unresolved,
report.dropped_elements,
"every counted arc is either resolved or explicitly unresolved"
);
}
#[test]
fn moa_scales_an_arcs_lengths_but_never_its_angles() {
let (_, report) = import_ventum_reticle_with_report(
r#"{"name":"M","unit":"moa","spec":[
{"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340}
]}"#,
)
.unwrap();
let arc = report.arcs[0];
assert!((arc.radius_mil - 2.0 * MOA_TO_MIL).abs() < 1e-9);
assert_eq!(arc.start_degrees, 200.0);
assert_eq!(arc.end_degrees, 340.0);
assert!((arc.sweep_degrees - 140.0).abs() < 1e-9);
assert!((arc.apex.down_mil + 2.0 * MOA_TO_MIL).abs() < 1e-9);
}
#[test]
fn a_circle_accepts_cx_cy_and_defaults_to_the_center() {
let (_, report) = import_ventum_reticle_with_report(
r#"{"name":"C","unit":"mil","spec":[
{"type":"circle","cx":1,"cy":2,"r":1,"start":0,"end":180},
{"type":"circle","r":1,"start":0,"end":180}
]}"#,
)
.unwrap();
assert_eq!(report.arcs.len(), 2);
assert_eq!(report.arcs[0].center.right_mil, 1.0);
assert_eq!(report.arcs[0].center.down_mil, 2.0);
assert_eq!(report.arcs[1].center, VentumArcPoint::default());
}
#[test]
fn a_non_numeric_radius_is_ignored_rather_than_rejected() {
for radius in [r#""2mil""#, r#"{"v":2,"unit":"mil"}"#, "[2]", "true", "null"] {
let json = format!(
r#"{{"name":"L","unit":"mil","spec":[
{{"type":"dot","x":0,"y":1}},
{{"type":"circle","x":0,"y":0,"r":{radius}}}
]}}"#
);
let (desc, report) = import_ventum_reticle_with_report(&json)
.unwrap_or_else(|e| panic!("r: {radius} must not fail the import, got {e:?}"));
assert_eq!(desc.marks.len(), 1, "r: {radius} — the dot must survive");
assert_eq!(desc.marks[0].down_mil, 1.0);
assert_eq!(
report.dropped_element_types,
vec![("circle".to_string(), 1)],
"r: {radius} — the ring is counted under its own tag"
);
assert_eq!(report.dropped_elements, 1);
}
}
#[test]
fn a_declared_sweep_that_cannot_be_read_is_reported_not_quietly_a_ring() {
for (start, end) in [
(r#""200deg""#, r#"{"deg":340}"#),
("200", r#""340deg""#),
(r#""200deg""#, "340"),
] {
let json = format!(
r#"{{"name":"A","unit":"mil","spec":[
{{"type":"dot","x":0,"y":1}},
{{"type":"circle","x":0,"y":0,"r":2,"start":{start},"end":{end}}}
]}}"#
);
let (desc, report) = import_ventum_reticle_with_report(&json)
.unwrap_or_else(|e| panic!("{start}/{end} must not fail the import, got {e:?}"));
assert_eq!(desc.marks.len(), 1, "{start}/{end} — the dot must survive");
assert_eq!(
report.dropped_element_types,
vec![("arc".to_string(), 1)],
"{start}/{end} — a declared sweep keeps the arc tag even when unreadable"
);
assert!(report.arcs.is_empty(), "{start}/{end} — nothing to resolve");
assert_eq!(
report.arcs_unresolved, 1,
"{start}/{end} — an unreadable sweep is reported exactly like an unreadable \
radius, not swallowed"
);
assert_eq!(
report.arcs.len() + report.arcs_unresolved,
report.dropped_elements,
"{start}/{end} — the stated arcs-vs-tally invariant still holds"
);
}
}
#[test]
fn a_lone_angle_is_a_ring_whether_or_not_it_can_be_read() {
for spec in [
r#"{"type":"circle","x":0,"y":0,"r":2,"start":200}"#,
r#"{"type":"circle","x":0,"y":0,"r":2,"start":"200deg"}"#,
r#"{"type":"circle","x":0,"y":0,"r":2,"end":340}"#,
] {
let json = format!(r#"{{"name":"R","unit":"mil","spec":[{spec}]}}"#);
let (_, report) = import_ventum_reticle_with_report(&json).unwrap();
assert_eq!(
report.dropped_element_types,
vec![("circle".to_string(), 1)],
"{spec} — one angle is no sweep"
);
assert_eq!(report.arcs_unresolved, 0, "{spec}");
}
}
#[test]
fn an_arc_with_an_unreadable_radius_is_counted_as_unresolved() {
let (_, report) = import_ventum_reticle_with_report(
r#"{"name":"U","unit":"mil","spec":[
{"type":"circle","x":0,"y":0,"r":"2mil","start":200,"end":340}
]}"#,
)
.unwrap();
assert_eq!(report.dropped_element_types, vec![("arc".to_string(), 1)]);
assert_eq!(report.arcs_unresolved, 1);
assert!(report.arcs.is_empty());
assert_eq!(report.arcs.len() + report.arcs_unresolved, report.dropped_elements);
}
#[test]
fn an_absent_or_null_center_is_the_reticle_center() {
let (_, report) = import_ventum_reticle_with_report(
r#"{"name":"C","unit":"mil","spec":[
{"type":"circle","r":2,"start":200,"end":340},
{"type":"circle","x":null,"y":null,"r":2,"start":200,"end":340}
]}"#,
)
.unwrap();
assert_eq!(report.arcs.len(), 2);
assert_eq!(report.arcs_unresolved, 0);
assert_eq!(report.arcs[0].center, VentumArcPoint::default());
assert_eq!(report.arcs[1].center, VentumArcPoint::default());
}
#[test]
fn a_center_that_cannot_be_read_leaves_the_arc_unresolved() {
for (x, y) in [
(r#""left""#, "0"),
("0", r#""up""#),
(r#"{"v":1}"#, r#"{"v":1}"#),
] {
let json = format!(
r#"{{"name":"C","unit":"mil","spec":[
{{"type":"circle","x":{x},"y":{y},"r":2,"start":200,"end":340}}
]}}"#
);
let (_, report) = import_ventum_reticle_with_report(&json)
.unwrap_or_else(|e| panic!("x:{x} y:{y} must not fail the import, got {e:?}"));
assert_eq!(
report.dropped_element_types,
vec![("arc".to_string(), 1)],
"x:{x} y:{y} — still an arc"
);
assert!(
report.arcs.is_empty(),
"x:{x} y:{y} — no points may be reported from a center nobody wrote"
);
assert_eq!(report.arcs_unresolved, 1, "x:{x} y:{y}");
}
}
#[test]
fn an_unreadable_center_on_a_plain_ring_is_just_a_counted_ring() {
let (_, report) = import_ventum_reticle_with_report(
r#"{"name":"C","unit":"mil","spec":[{"type":"circle","x":"left","r":2}]}"#,
)
.unwrap();
assert_eq!(report.dropped_element_types, vec![("circle".to_string(), 1)]);
assert_eq!(report.arcs_unresolved, 0);
assert!(report.arcs.is_empty());
let (_, mirrored) = import_ventum_reticle_with_report(
r#"{"name":"C","unit":"mil","spec":[
{"type":"circle","x":"left","y":0,"r":2,
"repeat":{"axis":"x","step":0,"n":1,"mirror":true}}
]}"#,
)
.unwrap();
assert_eq!(
mirrored.dropped_element_types,
vec![("circle".to_string(), 2)],
"an unprovable dedupe keeps the twin, ring or not"
);
let (_, readable) = import_ventum_reticle_with_report(
r#"{"name":"C","unit":"mil","spec":[
{"type":"circle","x":0,"y":0,"r":2,
"repeat":{"axis":"x","step":0,"n":1,"mirror":true}}
]}"#,
)
.unwrap();
assert_eq!(readable.dropped_element_types, vec![("circle".to_string(), 1)]);
}
#[test]
fn a_numeric_arc_still_resolves_beside_a_cosmetic_one() {
let (desc, report) = import_ventum_reticle_with_report(
r#"{"name":"H","unit":"mil","spec":[
{"type":"dot","x":0,"y":1},
{"type":"circle","x":0,"y":0,"r":"2mil"},
{"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340}
]}"#,
)
.unwrap();
assert_eq!(desc.marks.len(), 1, "the dot is unaffected by either circle");
assert_eq!(
report.dropped_element_types,
vec![("arc".to_string(), 1), ("circle".to_string(), 1)],
"the cosmetic ring and the real horseshoe are counted apart"
);
assert_eq!(report.dropped_elements, 2);
assert_eq!(report.arcs.len(), 1);
assert_eq!(report.arcs_unresolved, 0);
let arc = report.arcs[0];
assert_eq!(arc.radius_mil, 2.0);
assert!((arc.sweep_degrees - 140.0).abs() < 1e-9);
assert!(
(arc.apex.down_mil + 2.0).abs() < 1e-9 && arc.apex.right_mil.abs() < 1e-9,
"apex at 270 deg is 2 mil straight above center, got {:?}",
arc.apex
);
}
#[test]
fn an_unusable_repeat_on_a_circle_is_ignored_rather_than_rejected() {
for repeat in [
r#"{"axis":"x","step":"1mil","n":1}"#,
r#"{"axis":"x","step":1,"n":"two"}"#,
r#"{"axis":"diagonal","step":1,"n":1}"#,
r#"{"step":1,"n":1}"#,
r#"{"axis":"x","step":1,"n":1,"mirror":"yes"}"#,
r#""every 1 mil""#,
"[1]",
"7",
] {
let json = format!(
r#"{{"name":"L","unit":"mil","spec":[
{{"type":"dot","x":0,"y":1}},
{{"type":"circle","x":0,"y":0,"r":2,"repeat":{repeat}}}
]}}"#
);
let (desc, report) = import_ventum_reticle_with_report(&json).unwrap_or_else(|e| {
panic!("repeat {repeat} must not fail the import, got {e:?}")
});
assert_eq!(desc.marks.len(), 1, "repeat {repeat} — the dot must survive");
assert_eq!(desc.marks[0].down_mil, 1.0);
assert_eq!(
report.dropped_element_types,
vec![("circle".to_string(), 1)],
"repeat {repeat} — expanded as though it had no repeat"
);
assert_eq!(
report.circle_repeats_unreadable, 1,
"repeat {repeat} — an unreadable repeat must be declared, not swallowed"
);
}
}
#[test]
fn an_unusable_repeat_on_a_hold_bearing_mark_still_fails_loudly() {
for kind in [
r#"{"type":"dot","x":0,"y":1"#,
r#"{"type":"tick","x":0,"y":1"#,
r#"{"type":"text","x":0,"y":1,"text":"5""#,
] {
let json = format!(
r#"{{"name":"S","unit":"mil","spec":[
{kind},"repeat":{{"axis":"x","step":"1mil","n":3}}}}
]}}"#
);
let error = import_ventum_reticle(&json)
.expect_err("a mark's malformed repeat must not be swallowed");
assert!(
matches!(error, ReticleError::InvalidSpec(_)),
"{kind} — expected InvalidSpec, got {error:?}"
);
}
}
#[test]
fn a_readable_repeat_on_a_circle_still_stamps_its_copies() {
let (_, report) = import_ventum_reticle_with_report(
r#"{"name":"L","unit":"mil","spec":[
{"type":"circle","x":1,"y":0,"r":0.5,"repeat":{"axis":"x","step":1,"n":4}}
]}"#,
)
.unwrap();
assert_eq!(report.dropped_element_types, vec![("circle".to_string(), 4)]);
assert_eq!(report.circle_repeats_unreadable, 0);
}
#[test]
fn an_unreadable_centered_arc_keeps_its_mirrored_twin() {
for spec in [
r#""start":"200deg","end":340"#,
r#""start":200,"end":"340deg""#,
] {
let json = format!(
r#"{{"name":"U","unit":"mil","spec":[
{{"type":"circle","x":0,"y":0,"r":2,{spec},
"repeat":{{"axis":"x","step":1,"n":1,"mirror":true}}}}
]}}"#
);
let (_, report) = import_ventum_reticle_with_report(&json).unwrap();
assert_eq!(
report.dropped_element_types,
vec![("arc".to_string(), 2)],
"{spec} — an unprovable dedupe keeps the twin"
);
assert_eq!(report.arcs_unresolved, 2, "{spec}");
assert!(report.arcs.is_empty(), "{spec}");
}
let (_, report) = import_ventum_reticle_with_report(
r#"{"name":"U","unit":"mil","spec":[
{"type":"circle","x":"left","y":0,"r":2,"start":200,"end":340,
"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
]}"#,
)
.unwrap();
assert_eq!(report.dropped_element_types, vec![("arc".to_string(), 2)]);
assert_eq!(report.arcs_unresolved, 2);
}
#[test]
fn a_circle_counts_against_the_mark_cap_unlike_0_32_0() {
let at_cap = format!(
r#"{{"name":"C","unit":"mil","spec":[
{{"type":"dot","x":1,"y":1,"repeat":{{"axis":"x","step":0.001,"n":{}}}}},
{{"type":"circle","x":0,"y":0,"r":1}}
]}}"#,
MAX_RETICLE_MARKS - 1
);
let (desc, report) = import_ventum_reticle_with_report(&at_cap).unwrap();
assert_eq!(desc.marks.len(), MAX_RETICLE_MARKS - 1);
assert_eq!(report.dropped_element_types, vec![("circle".to_string(), 1)]);
let over_cap = format!(
r#"{{"name":"C","unit":"mil","spec":[
{{"type":"dot","x":1,"y":1,"repeat":{{"axis":"x","step":0.001,"n":{MAX_RETICLE_MARKS}}}}},
{{"type":"circle","x":0,"y":0,"r":1}}
]}}"#
);
assert!(
matches!(
import_ventum_reticle(&over_cap),
Err(ReticleError::TooManyMarks { .. })
),
"a circle past the cap is refused — the documented 0.32.0 divergence"
);
}
#[test]
fn a_centered_asymmetric_arc_still_gets_its_mirrored_twin() {
let (_, report) = import_ventum_reticle_with_report(
r#"{"name":"A","unit":"mil","spec":[
{"type":"circle","x":0,"y":0,"r":2,"start":290,"end":70,
"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
]}"#,
)
.unwrap();
assert_eq!(report.dropped_element_types, vec![("arc".to_string(), 2)]);
assert_eq!(report.arcs.len(), 2, "the centered twin is a second shape");
let (first, second) = (report.arcs[0], report.arcs[1]);
assert!((first.apex.right_mil - 2.0).abs() < 1e-9, "{:?}", first.apex);
assert!((second.apex.right_mil + 2.0).abs() < 1e-9, "{:?}", second.apex);
assert!((first.sweep_degrees - second.sweep_degrees).abs() < 1e-9);
assert_eq!(first.center, second.center);
}
#[test]
fn a_centered_symmetric_arc_is_still_deduped() {
let (_, horseshoe) = import_ventum_reticle_with_report(
r#"{"name":"S","unit":"mil","spec":[
{"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340,
"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
]}"#,
)
.unwrap();
assert_eq!(horseshoe.dropped_element_types, vec![("arc".to_string(), 1)]);
assert_eq!(horseshoe.arcs.len(), 1);
let (_, ring) = import_ventum_reticle_with_report(
r#"{"name":"R","unit":"mil","spec":[
{"type":"circle","x":0,"y":0,"r":2,
"repeat":{"axis":"y","step":1,"n":1,"mirror":true}}
]}"#,
)
.unwrap();
assert_eq!(ring.dropped_element_types, vec![("circle".to_string(), 1)]);
let (_, flipped) = import_ventum_reticle_with_report(
r#"{"name":"F","unit":"mil","spec":[
{"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340,
"repeat":{"axis":"y","step":1,"n":1,"mirror":true}}
]}"#,
)
.unwrap();
assert_eq!(flipped.dropped_element_types, vec![("arc".to_string(), 2)]);
assert!((flipped.arcs[0].apex.down_mil + 2.0).abs() < 1e-9, "apex up");
assert!((flipped.arcs[1].apex.down_mil - 2.0).abs() < 1e-9, "twin apex down");
}
#[test]
fn the_centered_mirror_dedupe_table() {
let table = [
(200, 340, "x", 1),
(200, 340, "y", 2),
(290, 70, "x", 2),
(290, 70, "y", 1),
];
for (start, end, axis, expected) in table {
let (_, report) = import_ventum_reticle_with_report(&format!(
r#"{{"name":"T","unit":"mil","spec":[
{{"type":"circle","x":0,"y":0,"r":2,"start":{start},"end":{end},
"repeat":{{"axis":"{axis}","step":1,"n":1,"mirror":true}}}}
]}}"#
))
.unwrap();
assert_eq!(
report.arcs.len(),
expected,
"start {start}, end {end}, mirrored across {axis}"
);
assert_eq!(
report.dropped_element_types,
vec![("arc".to_string(), expected)],
"the tally must agree with the arcs for start {start}, end {end} across {axis}"
);
}
}
#[test]
fn a_mirrored_arcs_reported_angles_are_clockwise_bearings() {
let (_, across_x) = import_ventum_reticle_with_report(
r#"{"name":"N","unit":"mil","spec":[
{"type":"circle","x":3,"y":0,"r":2,"start":290,"end":70,
"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
]}"#,
)
.unwrap();
let twin = across_x
.arcs
.iter()
.find(|a| a.center.right_mil < 0.0)
.expect("the mirrored twin");
assert!((twin.start_degrees - 110.0).abs() < 1e-9, "{twin:?}");
assert!((twin.end_degrees - 250.0).abs() < 1e-9, "{twin:?}");
assert!((twin.sweep_degrees - 140.0).abs() < 1e-9, "{twin:?}");
assert!((twin.apex.right_mil + 5.0).abs() < 1e-9, "{:?}", twin.apex);
let (_, across_y) = import_ventum_reticle_with_report(
r#"{"name":"N","unit":"mil","spec":[
{"type":"circle","x":0,"y":3,"r":2,"start":200,"end":340,
"repeat":{"axis":"y","step":1,"n":1,"mirror":true}}
]}"#,
)
.unwrap();
let twin = across_y
.arcs
.iter()
.find(|a| a.center.down_mil < 0.0)
.expect("the mirrored twin");
assert!((twin.start_degrees - 20.0).abs() < 1e-9, "{twin:?}");
assert!((twin.end_degrees - 160.0).abs() < 1e-9, "{twin:?}");
assert!((twin.sweep_degrees - 140.0).abs() < 1e-9, "{twin:?}");
for arc in across_x.arcs.iter().chain(across_y.arcs.iter()) {
for angle in [arc.start_degrees, arc.end_degrees] {
assert!(
(0.0..360.0).contains(&angle),
"{angle} is not a clockwise bearing ({arc:?})"
);
}
}
}
#[test]
fn an_unmirrored_arcs_angles_are_the_documents_own() {
let (_, report) = import_ventum_reticle_with_report(
r#"{"name":"D","unit":"mil","spec":[
{"type":"circle","x":0,"y":0,"r":2,"start":-110,"end":110}
]}"#,
)
.unwrap();
let arc = report.arcs[0];
assert_eq!(arc.start_degrees, -110.0);
assert_eq!(arc.end_degrees, 110.0);
assert!((arc.sweep_degrees - 220.0).abs() < 1e-9);
}
#[test]
fn normalize_degrees_lands_in_range() {
let cases = [
0.0,
-0.0,
360.0,
-360.0,
110.0,
-110.0,
-1e-16,
-1e-300,
f64::MIN_POSITIVE,
1e12,
-1e12,
];
for input in cases {
let out = normalize_degrees(input);
assert!(
(0.0..360.0).contains(&out),
"normalize_degrees({input}) = {out}, outside [0, 360)"
);
}
assert_eq!(normalize_degrees(-110.0), 250.0);
assert_eq!(normalize_degrees(360.0), 0.0);
assert_eq!((-1e-16f64).rem_euclid(360.0), 360.0);
assert_eq!(normalize_degrees(-1e-16), 0.0);
assert!(normalize_degrees(f64::NAN).is_nan());
assert_eq!(normalize_degrees(f64::INFINITY), f64::INFINITY);
}
const WRONG_TYPED: [&str; 4] = [r#""a string""#, r#"{"nested":1}"#, "[1]", "true"];
fn wrong_typed_for(key: &str) -> Vec<&'static str> {
WRONG_TYPED
.into_iter()
.filter(|value| !(key == "text" && value.starts_with('"')))
.collect()
}
const EVERY_ELEMENT_KEY: [&str; 11] = [
"x",
"y",
"cx",
"cy",
"r",
"start",
"end",
"text",
"repeat",
"color",
"max_extent",
];
fn every_element_type() -> Vec<&'static str> {
let samples = [
Element::Dot {
x: 0.0,
y: 0.0,
repeat: None,
},
Element::Tick {
x: 0.0,
y: 0.0,
repeat: None,
},
Element::Text {
x: 0.0,
y: 0.0,
text: String::new(),
repeat: None,
},
Element::Line {},
Element::Circle(CircleFields::default()),
Element::Rect {},
Element::Grid {},
Element::Unknown,
];
samples
.iter()
.map(|element| match element {
Element::Dot { .. } => "dot",
Element::Tick { .. } => "tick",
Element::Text { .. } => "text",
Element::Line {} => "line",
Element::Circle(_) => "circle",
Element::Rect {} => "rect",
Element::Grid {} => "grid",
Element::Unknown => "a_type_this_module_has_never_heard_of",
})
.collect()
}
fn valid_base(element_type: &str) -> &'static [(&'static str, &'static str)] {
match element_type {
"dot" | "tick" => &[("x", "0"), ("y", "0")],
"text" => &[("x", "0"), ("y", "0"), ("text", "\"t\"")],
"circle" => &[("r", "1")],
_ => &[],
}
}
fn is_strict(element_type: &str, key: &str) -> bool {
matches!(
(element_type, key),
("dot" | "tick", "x" | "y" | "repeat") | ("text", "x" | "y" | "text" | "repeat")
)
}
fn one_element_document(element_type: &str, key: &str, value: &str) -> String {
let mut keys: Vec<String> = vec![format!(r#""type":"{element_type}""#)];
for (base_key, base_value) in valid_base(element_type) {
if *base_key != key {
keys.push(format!(r#""{base_key}":{base_value}"#));
}
}
keys.push(format!(r#""{key}":{value}"#));
format!(
r#"{{"name":"S","unit":"mil","spec":[{{{}}}]}}"#,
keys.join(",")
)
}
#[test]
fn strictness_is_exactly_what_a_hold_is_built_from() {
let mut refusals = 0usize;
let mut acceptances = 0usize;
for element_type in every_element_type() {
for key in EVERY_ELEMENT_KEY {
for value in wrong_typed_for(key) {
let json = one_element_document(element_type, key, value);
let refused = import_ventum_reticle(&json).is_err();
let expected = is_strict(element_type, key);
assert_eq!(
refused, expected,
"{element_type}.{key} = {value}: the importer refused={refused}, the \
documented boundary says strict={expected}\n {json}"
);
if expected {
refusals += 1;
} else {
acceptances += 1;
}
}
}
}
assert_eq!(refusals, 12 + 12 + 15, "the strict side must be covered");
assert!(acceptances > 100, "the lenient side must be covered too");
for element_type in every_element_type() {
for key in ["x", "y", "cx", "cy", "r", "start", "end", "text", "repeat", "label"] {
assert!(
!is_strict(element_type, key) || EVERY_ELEMENT_KEY.contains(&key),
"{element_type}.{key} is claimed strict but is not in the swept key list"
);
}
}
for document in [
r#"{"name":"N","unit":"mil","spec":[{"x":0,"y":1}]}"#,
r#"{"name":"N","unit":"mil","spec":[{"type":null,"x":0,"y":1}]}"#,
r#"{"name":"N","unit":"mil","spec":[{"type":7,"x":0,"y":1}]}"#,
] {
assert!(
import_ventum_reticle(document).is_err(),
"an element whose `type` is missing or is not a STRING must refuse the \
document — leniency is per-variant, and there is no variant yet. A type \
that IS a string but names no variant is a different case: `#[serde(other)]` \
reads it as `Element::Unknown` and it is dropped as decoration, which \
`every_element_type` drives deliberately.\n {document}"
);
}
}
#[test]
fn no_circle_key_refuses_a_document_however_it_is_spelled_or_repeated() {
const CIRCLE_KEYS: [&str; 8] = ["x", "y", "cx", "cy", "r", "start", "end", "repeat"];
let values: Vec<&str> = WRONG_TYPED
.iter()
.copied()
.chain(["null", "0", "-1", "1e308"])
.collect();
for key in CIRCLE_KEYS {
for value in &values {
let mut circles = vec![
format!(r#"{{"type":"circle","{key}":{value}}}"#),
format!(r#"{{"type":"circle","{key}":{value},"{key}":{value}}}"#),
];
if let Some(other) = match key {
"x" => Some("cx"),
"cx" => Some("x"),
"y" => Some("cy"),
"cy" => Some("y"),
_ => None,
} {
circles.push(format!(r#"{{"type":"circle","{other}":1,"{key}":{value}}}"#));
circles.push(format!(r#"{{"type":"circle","{key}":{value},"{other}":1}}"#));
}
for circle in circles {
let json = format!(
r#"{{"name":"L","unit":"mil","spec":[
{{"type":"dot","x":0,"y":1}},{circle}
]}}"#
);
let (desc, report) = import_ventum_reticle_with_report(&json)
.unwrap_or_else(|e| panic!("{circle} must not fail the import: {e:?}"));
assert_eq!(desc.marks.len(), 1, "{circle} — the dot must survive");
assert_eq!(
report.dropped_elements, 1,
"{circle} — the ring must still be counted, not silently absorbed"
);
}
}
}
}
#[test]
fn a_repeated_circle_key_takes_its_last_value() {
for (spec, expected_right) in [
(r#""x":5,"cx":1"#, 1.0),
(r#""cx":1,"x":5"#, 5.0),
(r#""x":5,"x":1"#, 1.0),
] {
let json = format!(
r#"{{"name":"O","unit":"mil","spec":[
{{"type":"circle",{spec},"y":0,"r":2,"start":200,"end":340}}
]}}"#
);
let (_, report) = import_ventum_reticle_with_report(&json).unwrap();
assert_eq!(report.arcs.len(), 1, "{spec}");
assert!(
(report.arcs[0].center.right_mil - expected_right).abs() < 1e-12,
"{spec}: center {} should be {expected_right}",
report.arcs[0].center.right_mil
);
}
let (_, report) = import_ventum_reticle_with_report(
r#"{"name":"O","unit":"mil","spec":[
{"type":"circle","r":2,"repeat":{"axis":"x","step":1,"n":3},"repeat":null}
]}"#,
)
.unwrap();
assert_eq!(report.dropped_element_types, vec![("circle".to_string(), 1)]);
assert_eq!(report.circle_repeats_unreadable, 0, "`null` is absence, not garbage");
}
#[test]
fn an_arc_resolves_only_from_a_center_a_positive_radius_and_a_sweep() {
let (_, resolved) = import_ventum_reticle_with_report(
r#"{"name":"A","unit":"mil","spec":[
{"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340}
]}"#,
)
.unwrap();
assert_eq!(resolved.arcs.len(), 1);
assert_eq!(resolved.arcs_unresolved, 0);
for (what, circle) in [
("no radius", r#""x":0,"y":0,"start":200,"end":340"#),
("unreadable radius", r#""x":0,"y":0,"r":"2mil","start":200,"end":340"#),
("zero radius", r#""x":0,"y":0,"r":0,"start":200,"end":340"#),
("negative radius", r#""x":0,"y":0,"r":-2,"start":200,"end":340"#),
("unreadable start", r#""x":0,"y":0,"r":2,"start":"200deg","end":340"#),
("unreadable end", r#""x":0,"y":0,"r":2,"start":200,"end":"340deg""#),
("unreadable center x", r#""x":"left","y":0,"r":2,"start":200,"end":340"#),
("unreadable center y", r#""x":0,"y":"up","r":2,"start":200,"end":340"#),
] {
let json =
format!(r#"{{"name":"A","unit":"mil","spec":[{{"type":"circle",{circle}}}]}}"#);
let (_, report) = import_ventum_reticle_with_report(&json)
.unwrap_or_else(|e| panic!("{what} must not fail the import: {e:?}"));
assert!(report.arcs.is_empty(), "{what}: no points may be reported");
assert_eq!(report.arcs_unresolved, 1, "{what}: must be declared unresolved");
assert_eq!(
report.dropped_element_types,
vec![(ARC_TAG.to_string(), 1)],
"{what}: keeps its arc tag — the loss is reported as an arc's"
);
}
}
#[test]
fn null_is_read_as_absence_not_as_a_bad_value() {
for (what, circle) in [
("start null, end unreadable", r#""r":2,"start":null,"end":"340deg""#),
("start unreadable, end null", r#""r":2,"start":"200deg","end":null"#),
("both null", r#""r":2,"start":null,"end":null"#),
("every key null", r#""x":null,"y":null,"r":null,"start":null,"end":null"#),
] {
let json =
format!(r#"{{"name":"N","unit":"mil","spec":[{{"type":"circle",{circle}}}]}}"#);
let (_, report) = import_ventum_reticle_with_report(&json).unwrap();
assert_eq!(
report.dropped_element_types,
vec![("circle".to_string(), 1)],
"{what}: a declared-but-null angle is no angle, so this is a ring"
);
assert_eq!(report.arcs_unresolved, 0, "{what}: a ring loses no sweep");
}
let desc = import_ventum_reticle(
r#"{"name":"N","unit":"mil","spec":[{"type":"dot","x":0,"y":1,"repeat":null}]}"#,
)
.unwrap();
assert_eq!(desc.marks.len(), 1);
for metadata in [
r#""name":null"#,
r#""plane":null"#,
r#""unit":null"#,
r#""ref_magnification":null"#,
r#""spec":null"#,
] {
let json = format!(r#"{{{metadata}}}"#);
assert!(
import_ventum_reticle(&json).is_err(),
"{metadata}: a present null is not an absent key at reticle level\n {json}"
);
}
assert!(
import_ventum_reticle(
r#"{"unit":"mil","spec":[{"type":"dot","x":0,"y":1,
"repeat":{"axis":"x","step":1,"n":2,"mirror":null}}]}"#
)
.is_err(),
"a null inside a mark's repeat is not an absent key either"
);
}
#[test]
fn a_marks_own_position_refuses_a_null_like_any_other_wrong_value() {
for (what, document) in [
("dot x", r#"{"name":"N","unit":"mil","spec":[{"type":"dot","x":null,"y":4}]}"#),
("dot y", r#"{"name":"N","unit":"mil","spec":[{"type":"dot","x":4,"y":null}]}"#),
("tick x", r#"{"name":"N","unit":"mil","spec":[{"type":"tick","x":null,"y":4}]}"#),
("tick y", r#"{"name":"N","unit":"mil","spec":[{"type":"tick","x":4,"y":null}]}"#),
(
"text x",
r#"{"name":"N","unit":"mil","spec":[{"type":"text","x":null,"y":4,"text":"2"}]}"#,
),
(
"text y",
r#"{"name":"N","unit":"mil","spec":[{"type":"text","x":4,"y":null,"text":"2"}]}"#,
),
(
"text string",
r#"{"name":"N","unit":"mil","spec":[{"type":"text","x":4,"y":4,"text":null}]}"#,
),
] {
assert!(
import_ventum_reticle(document).is_err(),
"{what}: a null where a hold's own coordinate belongs must refuse the \
document. Reading it as a default would place a mark at a coordinate the \
document never wrote.\n {document}"
);
}
}
#[test]
fn a_repeat_counts_per_instance_for_text_and_once_for_a_dropped_shape() {
let (_, text_report) = import_ventum_reticle_with_report(
r#"{"name":"R","unit":"mil","spec":[
{"type":"text","x":20,"y":20,"text":"t","repeat":{"axis":"x","step":1,"n":3}}
]}"#,
)
.unwrap();
assert_eq!(
text_report.dropped_element_types,
vec![("text".to_string(), 3)],
"three copies that bound to nothing are three lost labels"
);
let (_, line_report) = import_ventum_reticle_with_report(
r#"{"name":"R","unit":"mil","spec":[
{"type":"line","x1":0,"y1":0,"x2":1,"y2":1,
"repeat":{"axis":"x","step":1,"n":3}}
]}"#,
)
.unwrap();
assert_eq!(
line_report.dropped_element_types,
vec![("line".to_string(), 1)],
"a dropped shape's repeat is never expanded, so the element counts once"
);
}
#[test]
fn a_refusal_that_is_not_about_a_key_falls_on_every_element_type_alike() {
let deep = format!("{}1{}", "[".repeat(200), "]".repeat(200));
for value in [String::from("1e400"), deep] {
for element_type in every_element_type() {
let json = one_element_document(element_type, "r", &value);
assert!(
import_ventum_reticle(&json).is_err(),
"{element_type} with r={value} must be refused by the parser\n {json}"
);
}
}
let mut refusals = 0usize;
for element_type in every_element_type() {
let json = format!(
r#"{{"name":"S","unit":"mil","spec":[
{{"type":"{element_type}","type":"{element_type}","x":0,"y":1}}
]}}"#
);
assert!(
import_ventum_reticle(&json).is_err(),
"{element_type} written with two `type` keys must be refused\n {json}"
);
refusals += 1;
}
assert_eq!(refusals, every_element_type().len());
}
#[test]
fn imported_mbr_reticle_hold_solves() {
let json = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
let desc = import_ventum_reticle(&json).unwrap();
let hold = hold_point_in_reticle(4.0, 2.0, 1.0, &desc).unwrap();
assert!(hold.nearest_mark.is_some());
assert!(
hold.nearest_mark_distance_mil < 0.5,
"hold should sit on/near a real mark (distance {})",
hold.nearest_mark_distance_mil
);
assert!(!hold.off_reticle, "a mid-tree hold is on the reticle");
}
}