use std::collections::BTreeMap;
use crate::reticle::{
FocalPlane, MarkKind, ReticleDescription, ReticleError, ReticleMark, MAX_RETICLE_MARKS,
};
use crate::reticle_import::MOA_TO_MIL;
pub const MAX_DOCUMENT_DEPTH: usize = 64;
const TAG_RETICLE: &str = "reticle";
const TAG_ELEMENTS: &str = "elements";
const TAG_BDC: &str = "bdc";
const ATTR_NAME: &str = "name";
const ATTR_SIZE_X: &str = "size-x";
const ATTR_SIZE_Y: &str = "size-y";
const ATTR_ZERO_X: &str = "zero-x";
const ATTR_ZERO_Y: &str = "zero-y";
const ATTR_POSITION_X: &str = "position-x";
const ATTR_POSITION_Y: &str = "position-y";
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ReticleImportReport {
pub drawing_elements_dropped: usize,
pub dropped_element_tags: Vec<(String, usize)>,
pub canvas_mil: Option<(f64, f64)>,
pub zero_in_canvas_mil: Option<(f64, f64)>,
}
pub fn import_reticle_document(xml: &str) -> Result<ReticleDescription, ReticleError> {
import_reticle_document_with_report(xml).map(|(description, _)| description)
}
pub fn import_reticle_document_with_report(
xml: &str,
) -> Result<(ReticleDescription, ReticleImportReport), ReticleError> {
let mut scanner = XmlScanner::new(xml);
let mut open: Vec<&str> = Vec::new();
let mut root_seen = false;
let mut name = String::new();
let (mut size_x, mut size_y) = (None, None);
let (mut zero_x, mut zero_y) = (None, None);
let mut marks: Vec<ReticleMark> = Vec::new();
let mut dropped: BTreeMap<&str, usize> = BTreeMap::new();
while let Some(event) = scanner.next_event()? {
match event {
XmlEvent::Start {
tag,
attributes,
self_closing,
} => {
if open.is_empty() {
if root_seen {
return Err(invalid(
"a .reticle document must have exactly one root element".to_string(),
));
}
if tag != TAG_RETICLE {
return Err(invalid(format!(
"expected a <{TAG_RETICLE}> root element, found <{tag}>"
)));
}
root_seen = true;
if let Some(raw) = attribute(&attributes, ATTR_NAME) {
name = decode_entities(raw);
}
size_x = parse_canvas_extent(&attributes, ATTR_SIZE_X)?;
size_y = parse_canvas_extent(&attributes, ATTR_SIZE_Y)?;
zero_x = parse_optional_angle_mil(&attributes, ATTR_ZERO_X)?;
zero_y = parse_optional_angle_mil(&attributes, ATTR_ZERO_Y)?;
} else {
match tag {
TAG_ELEMENTS => {}
TAG_BDC => {
if let Some(mark) = bdc_mark(&attributes)? {
push_capped(&mut marks, mark)?;
}
}
drawing => *dropped.entry(drawing).or_insert(0) += 1,
}
}
if !self_closing {
if open.len() >= MAX_DOCUMENT_DEPTH {
return Err(invalid(format!(
"element nesting deeper than {MAX_DOCUMENT_DEPTH} at <{tag}>"
)));
}
open.push(tag);
}
}
XmlEvent::End { tag } => match open.pop() {
Some(expected) if expected == tag => {}
Some(expected) => {
return Err(invalid(format!(
"</{tag}> closes <{expected}>, which is still open"
)))
}
None => return Err(invalid(format!("</{tag}> has no matching open element"))),
},
}
}
if let Some(unclosed) = open.last() {
return Err(invalid(format!("<{unclosed}> is never closed")));
}
if !root_seen {
return Err(invalid(format!(
"the document has no <{TAG_RETICLE}> root element"
)));
}
let report = ReticleImportReport {
drawing_elements_dropped: dropped.values().sum(),
dropped_element_tags: dropped
.into_iter()
.map(|(tag, count)| (tag.to_string(), count))
.collect(),
canvas_mil: size_x.zip(size_y),
zero_in_canvas_mil: zero_x.zip(zero_y),
};
let description = ReticleDescription {
name,
focal_plane: FocalPlane::First,
reference_magnification: 1.0,
marks,
};
Ok((description, report))
}
fn bdc_mark(attributes: &[(&str, &str)]) -> Result<Option<ReticleMark>, ReticleError> {
let raw_x = attribute(attributes, ATTR_POSITION_X);
let raw_y = attribute(attributes, ATTR_POSITION_Y);
if raw_x.is_none() && raw_y.is_none() {
return Ok(None);
}
let right_mil = match raw_x {
Some(raw) => parse_angle_mil(raw, ATTR_POSITION_X)?,
None => 0.0,
};
let up_mil = match raw_y {
Some(raw) => parse_angle_mil(raw, ATTR_POSITION_Y)?,
None => 0.0,
};
Ok(Some(ReticleMark::new(
-up_mil,
right_mil,
MarkKind::Hash,
)))
}
fn push_capped(marks: &mut Vec<ReticleMark>, mark: ReticleMark) -> Result<(), ReticleError> {
if marks.len() >= MAX_RETICLE_MARKS {
return Err(ReticleError::TooManyMarks {
count: marks.len() + 1,
max: MAX_RETICLE_MARKS,
});
}
marks.push(mark);
Ok(())
}
fn attribute<'a>(attributes: &[(&'a str, &'a str)], wanted: &str) -> Option<&'a str> {
attributes
.iter()
.find(|(key, _)| *key == wanted)
.map(|(_, value)| *value)
}
fn parse_optional_angle_mil(
attributes: &[(&str, &str)],
wanted: &'static str,
) -> Result<Option<f64>, ReticleError> {
match attribute(attributes, wanted) {
Some(raw) => parse_angle_mil(raw, wanted).map(Some),
None => Ok(None),
}
}
fn parse_canvas_extent(
attributes: &[(&str, &str)],
wanted: &'static str,
) -> Result<Option<f64>, ReticleError> {
let Some(value) = parse_optional_angle_mil(attributes, wanted)? else {
return Ok(None);
};
if value <= 0.0 {
return Err(invalid(format!(
"{wanted} must be greater than zero (got {value} mil)"
)));
}
Ok(Some(value))
}
fn parse_angle_mil(raw: &str, attribute: &str) -> Result<f64, ReticleError> {
let text = raw.trim();
let suffix_len = text
.chars()
.rev()
.take_while(|c| c.is_ascii_alphabetic())
.count();
let (number, unit) = text.split_at(text.len() - suffix_len);
let scale = if unit.eq_ignore_ascii_case("moa") {
MOA_TO_MIL
} else if unit.eq_ignore_ascii_case("mil")
|| unit.eq_ignore_ascii_case("mils")
|| unit.eq_ignore_ascii_case("mrad")
{
1.0
} else if unit.is_empty() {
return Err(invalid(format!(
"{attribute}=\"{raw}\" has no unit suffix; every .reticle value names its own \
unit (e.g. \"1.5moa\")"
)));
} else {
return Err(invalid(format!(
"{attribute}=\"{raw}\" uses the unrecognized unit \"{unit}\"; this importer \
reads moa, mil and mrad"
)));
};
let value: f64 = number.trim().parse().map_err(|_| {
invalid(format!(
"{attribute}=\"{raw}\" is not a number followed by a unit"
))
})?;
if !value.is_finite() {
return Err(invalid(format!("{attribute}=\"{raw}\" is not finite")));
}
Ok(value * scale)
}
fn invalid(reason: String) -> ReticleError {
ReticleError::InvalidSpec(reason)
}
fn decode_entities(raw: &str) -> String {
if !raw.contains('&') {
return raw.to_string();
}
let mut out = String::with_capacity(raw.len());
let mut rest = raw;
while let Some(start) = rest.find('&') {
out.push_str(&rest[..start]);
let tail = &rest[start..];
let Some(end) = tail.find(';') else {
out.push_str(tail);
return out;
};
let entity = &tail[1..end];
match entity {
"amp" => out.push('&'),
"lt" => out.push('<'),
"gt" => out.push('>'),
"quot" => out.push('"'),
"apos" => out.push('\''),
_ => {
let decoded = entity.strip_prefix('#').and_then(|digits| {
let code = match digits.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
None => digits.parse::<u32>().ok()?,
};
char::from_u32(code)
});
match decoded {
Some(c) => out.push(c),
None => out.push_str(&tail[..=end]),
}
}
}
rest = &tail[end + 1..];
}
out.push_str(rest);
out
}
#[derive(Debug)]
enum XmlEvent<'a> {
Start {
tag: &'a str,
attributes: Vec<(&'a str, &'a str)>,
self_closing: bool,
},
End {
tag: &'a str,
},
}
struct XmlScanner<'a> {
src: &'a str,
pos: usize,
}
impl<'a> XmlScanner<'a> {
fn new(src: &'a str) -> Self {
Self { src, pos: 0 }
}
fn bytes(&self) -> &'a [u8] {
self.src.as_bytes()
}
fn rest(&self) -> &'a str {
&self.src[self.pos..]
}
fn skip_delimited(&mut self, open: &str, close: &str, what: &str) -> Result<(), ReticleError> {
let after_open = self.pos + open.len();
match self.src[after_open..].find(close) {
Some(offset) => {
self.pos = after_open + offset + close.len();
Ok(())
}
None => Err(invalid(format!(
"unterminated {what} at byte {} (expected {close:?})",
self.pos
))),
}
}
fn next_event(&mut self) -> Result<Option<XmlEvent<'a>>, ReticleError> {
loop {
match self.rest().find('<') {
Some(offset) => self.pos += offset,
None => {
self.pos = self.src.len();
return Ok(None);
}
}
let rest = self.rest();
if rest.starts_with("<!--") {
self.skip_delimited("<!--", "-->", "comment")?;
} else if rest.starts_with("<![CDATA[") {
self.skip_delimited("<![CDATA[", "]]>", "CDATA section")?;
} else if rest.starts_with("<?") {
self.skip_delimited("<?", "?>", "processing instruction")?;
} else if rest.starts_with("<!") {
self.skip_delimited("<!", ">", "declaration")?;
} else if rest.starts_with("</") {
return self.read_end_tag().map(Some);
} else {
return self.read_start_tag().map(Some);
}
}
}
fn read_end_tag(&mut self) -> Result<XmlEvent<'a>, ReticleError> {
self.pos += 2; let start = self.pos;
let Some(tag) = self.read_name() else {
return Err(invalid(format!(
"an end tag has an empty name at byte {start}"
)));
};
self.skip_whitespace();
if self.bytes().get(self.pos) != Some(&b'>') {
return Err(invalid(format!(
"</{tag}> is malformed at byte {}",
self.pos
)));
}
self.pos += 1;
Ok(XmlEvent::End { tag })
}
fn read_start_tag(&mut self) -> Result<XmlEvent<'a>, ReticleError> {
self.pos += 1; let start = self.pos;
let Some(tag) = self.read_name() else {
return Err(invalid(format!(
"a start tag has an empty name at byte {start}"
)));
};
let mut attributes: Vec<(&'a str, &'a str)> = Vec::new();
loop {
self.skip_whitespace();
let rest = self.rest();
if rest.starts_with("/>") {
self.pos += 2;
return Ok(XmlEvent::Start {
tag,
attributes,
self_closing: true,
});
}
if rest.starts_with('>') {
self.pos += 1;
return Ok(XmlEvent::Start {
tag,
attributes,
self_closing: false,
});
}
if rest.is_empty() {
return Err(invalid(format!("<{tag}> is never terminated")));
}
let Some(key) = self.read_name() else {
return Err(invalid(format!(
"<{tag}> has a malformed attribute at byte {}",
self.pos
)));
};
self.skip_whitespace();
if self.bytes().get(self.pos) != Some(&b'=') {
return Err(invalid(format!(
"attribute {key:?} of <{tag}> has no value"
)));
}
self.pos += 1;
self.skip_whitespace();
let quote = match self.bytes().get(self.pos) {
Some(&q @ (b'"' | b'\'')) => q,
_ => {
return Err(invalid(format!(
"attribute {key:?} of <{tag}> must be quoted"
)))
}
};
self.pos += 1;
let start = self.pos;
let Some(offset) = self.rest().find(quote as char) else {
return Err(invalid(format!(
"attribute {key:?} of <{tag}> has an unterminated value"
)));
};
self.pos = start + offset;
attributes.push((key, &self.src[start..self.pos]));
self.pos += 1; }
}
fn read_name(&mut self) -> Option<&'a str> {
let start = self.pos;
let bytes = self.bytes();
while let Some(&byte) = bytes.get(self.pos) {
if byte.is_ascii_whitespace() || matches!(byte, b'/' | b'>' | b'=') {
break;
}
self.pos += 1;
}
if self.pos == start {
return None;
}
Some(&self.src[start..self.pos])
}
fn skip_whitespace(&mut self) {
let bytes = self.bytes();
while matches!(bytes.get(self.pos), Some(byte) if byte.is_ascii_whitespace()) {
self.pos += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::reticle::hold_point_in_reticle;
const LADDER_MOA: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<reticle name="Test Ladder MOA" size-x="40moa" size-y="40moa" zero-x="20moa" zero-y="20moa">
<elements>
<reticle-rectangle position-x="-20moa" position-y="20moa" size-x="40moa" size-y="40moa" fill="true" color="black" />
<reticle-circle center-x="0moa" center-y="0moa" radius="19moa" fill="true" line-width="0moa" color="white" />
<!-- crosshair arms -->
<reticle-line start-x="0moa" start-y="0moa" end-x="0moa" end-y="6moa" line-width="0.2moa" line-color="black" />
<reticle-line start-x="0moa" start-y="0moa" end-x="0moa" end-y="-9moa" line-width="0.2moa" line-color="black" />
<reticle-line start-x="-3moa" start-y="-0.5moa" end-x="-3moa" end-y="0.5moa" line-width="0.2moa" line-color="black" />
<!-- the holdover ticks the bdc section names again -->
<reticle-line start-x="-0.5moa" start-y="-2moa" end-x="0.5moa" end-y="-2moa" line-width="0.2moa" line-color="black" />
<reticle-line start-x="-0.5moa" start-y="-5moa" end-x="0.5moa" end-y="-5moa" line-width="0.2moa" line-color="black" />
<reticle-line start-x="-0.5moa" start-y="-8moa" end-x="0.5moa" end-y="-8moa" line-width="0.2moa" line-color="black" />
<reticle-path fill="true" color="black">
<elements>
<reticle-path-move-to position-x="-0.3moa" position-y="19moa" />
<reticle-path-line-to position-x="0.3moa" position-y="19moa" />
<reticle-path-line-to position-x="0.3moa" position-y="6moa" />
<reticle-path-line-to position-x="-0.3moa" position-y="6moa" />
</elements>
</reticle-path>
</elements>
<bdc>
<bdc position-x="0moa" position-y="-2moa" text-offset="1moa" text-height="0.8moa" />
<bdc position-x="0moa" position-y="-5moa" text-offset="1moa" text-height="0.8moa" />
<bdc position-x="0moa" position-y="-8moa" text-offset="1moa" text-height="0.8moa" />
</bdc>
</reticle>"#;
fn downs(description: &ReticleDescription) -> Vec<f64> {
let mut downs: Vec<f64> = description.marks.iter().map(|m| m.down_mil).collect();
downs.sort_by(|a, b| a.partial_cmp(b).unwrap());
downs
}
#[test]
fn bdc_entries_are_the_only_holds_and_the_drawing_is_dropped() {
let (description, report) = import_reticle_document_with_report(LADDER_MOA).unwrap();
assert_eq!(description.name, "Test Ladder MOA");
assert_eq!(
description.marks.len(),
3,
"only the three <bdc> entries carry holds; the drawing draws six lines, a \
circle, a rectangle and a path and none of them is a mark"
);
assert!(
description.marks.iter().all(|m| m.kind == MarkKind::Hash),
"a bdc point has no declared shape; every imported mark is a hash"
);
assert!(
description.marks.iter().all(|m| m.label.is_none()),
"a bdc entry carries no range text, so no mark is labeled"
);
assert_eq!(
report
.dropped_element_tags
.iter()
.find(|(tag, _)| tag == "reticle-line")
.map(|(_, count)| *count),
Some(6),
"the three drawn holdover ticks are dropped as drawing, not imported twice"
);
assert_eq!(
report.dropped_element_tags,
vec![
("reticle-circle".to_string(), 1),
("reticle-line".to_string(), 6),
("reticle-path".to_string(), 1),
("reticle-path-line-to".to_string(), 3),
("reticle-path-move-to".to_string(), 1),
("reticle-rectangle".to_string(), 1),
],
"the report tallies every drawing tag it walked past, sorted by tag"
);
assert_eq!(report.drawing_elements_dropped, 13);
}
#[test]
fn positive_y_in_the_document_is_down_negated_in_the_engine() {
let description = import_reticle_document(LADDER_MOA).unwrap();
let expected: Vec<f64> = vec![2.0, 5.0, 8.0]
.into_iter()
.map(|moa| moa * MOA_TO_MIL)
.collect();
for (got, want) in downs(&description).iter().zip(expected.iter()) {
assert!(
(got - want).abs() < 1e-12,
"holdover {got} mil should be {want} mil below the zero"
);
}
assert!(
description.marks.iter().all(|m| m.down_mil > 0.0),
"a holdover is BELOW the zero after the sign flip"
);
}
#[test]
fn a_mark_above_the_zero_imports_as_negative_down() {
let description = import_reticle_document(
r#"<reticle name="Up" size-x="10mil" size-y="10mil" zero-x="5mil" zero-y="5mil">
<bdc><bdc position-x="0mil" position-y="3mil" /></bdc>
</reticle>"#,
)
.unwrap();
assert_eq!(description.marks.len(), 1);
assert_eq!(description.marks[0].down_mil, -3.0);
}
#[test]
fn each_value_converts_on_its_own_unit_suffix() {
let description = import_reticle_document(
r#"<reticle name="Mixed" size-x="40moa" size-y="10mrad" zero-x="20moa" zero-y="5mrad">
<bdc>
<bdc position-x="0mil" position-y="-1.5mil" />
<bdc position-x="0mrad" position-y="-3mrad" />
<bdc position-x="0moa" position-y="-2MOA" />
</bdc>
</reticle>"#,
)
.unwrap();
let got: Vec<f64> = description.marks.iter().map(|m| m.down_mil).collect();
assert_eq!(got[0], 1.5, "mil passes through");
assert_eq!(got[1], 3.0, "mrad passes through");
assert!(
(got[2] - 2.0 * MOA_TO_MIL).abs() < 1e-12,
"MOA is case-insensitive and scales by MOA_TO_MIL (got {})",
got[2]
);
}
#[test]
fn a_value_this_module_cannot_scale_is_refused_rather_than_guessed() {
for (xml, why) in [
(
r#"<reticle><bdc><bdc position-y="-2" /></bdc></reticle>"#,
"no unit suffix",
),
(
r#"<reticle><bdc><bdc position-y="-2cm100m" /></bdc></reticle>"#,
"unrecognized unit",
),
(
r#"<reticle><bdc><bdc position-y="twomoa" /></bdc></reticle>"#,
"not a number",
),
(
r#"<reticle><bdc><bdc position-y="1e400moa" /></bdc></reticle>"#,
"overflows to infinity",
),
] {
assert!(
matches!(
import_reticle_document(xml),
Err(ReticleError::InvalidSpec(_))
),
"{why} must be an InvalidSpec, not a guessed scale"
);
}
}
#[test]
fn a_non_positive_canvas_is_refused() {
assert!(matches!(
import_reticle_document(r#"<reticle size-x="0moa" size-y="10moa"></reticle>"#),
Err(ReticleError::InvalidSpec(_))
));
assert!(matches!(
import_reticle_document(r#"<reticle size-x="-4moa" size-y="10moa"></reticle>"#),
Err(ReticleError::InvalidSpec(_))
));
}
#[test]
fn the_zero_attributes_place_the_canvas_and_never_move_a_mark() {
let centered = import_reticle_document(
r#"<reticle name="Z" size-x="40moa" size-y="40moa" zero-x="20moa" zero-y="20moa">
<bdc><bdc position-x="1moa" position-y="-5moa" /></bdc>
</reticle>"#,
)
.unwrap();
let offset = import_reticle_document(
r#"<reticle name="Z" size-x="40moa" size-y="40moa" zero-x="12moa" zero-y="31moa">
<bdc><bdc position-x="1moa" position-y="-5moa" /></bdc>
</reticle>"#,
)
.unwrap();
assert_eq!(
centered, offset,
"zero-x/zero-y are canvas placement; they must not offset the marks"
);
}
#[test]
fn the_canvas_and_zero_are_reported_in_milliradians() {
let (_, report) = import_reticle_document_with_report(LADDER_MOA).unwrap();
let (size_x, size_y) = report.canvas_mil.expect("canvas declared");
let (zero_x, zero_y) = report.zero_in_canvas_mil.expect("zero declared");
assert!((size_x - 40.0 * MOA_TO_MIL).abs() < 1e-12);
assert!((size_y - 40.0 * MOA_TO_MIL).abs() < 1e-12);
assert!((zero_x - 20.0 * MOA_TO_MIL).abs() < 1e-12);
assert!((zero_y - 20.0 * MOA_TO_MIL).abs() < 1e-12);
let (_, bare) = import_reticle_document_with_report(
r#"<reticle name="Bare"><bdc><bdc position-y="-1mil" /></bdc></reticle>"#,
)
.unwrap();
assert_eq!(bare.canvas_mil, None);
assert_eq!(bare.zero_in_canvas_mil, None);
}
#[test]
fn a_bdc_wrapper_is_not_itself_a_mark() {
let description = import_reticle_document(
r#"<reticle name="W" size-x="10mil" size-y="10mil">
<bdc>
<bdc position-y="-1mil" />
<bdc position-x="2mil" />
</bdc>
</reticle>"#,
)
.unwrap();
assert_eq!(description.marks.len(), 2, "the wrapper is not a mark");
assert_eq!(description.marks[0].down_mil, 1.0);
assert_eq!(description.marks[0].right_mil, 0.0);
assert_eq!(description.marks[1].down_mil, 0.0);
assert_eq!(description.marks[1].right_mil, 2.0);
let empty = import_reticle_document(r#"<reticle name="E"><bdc /></reticle>"#).unwrap();
assert!(empty.marks.is_empty());
}
#[test]
fn an_all_decoration_document_imports_with_no_marks() {
let description = import_reticle_document(
r#"<reticle name="Picture" size-x="20moa" size-y="20moa">
<elements>
<reticle-line start-x="-8moa" start-y="0moa" end-x="8moa" end-y="0moa" line-width="0.2moa" />
<reticle-circle center-x="0moa" center-y="0moa" radius="2moa" />
<future-shape whatever="7pizzas" />
</elements>
</reticle>"#,
)
.unwrap();
assert!(description.marks.is_empty());
assert_eq!(description.validate(), Err(ReticleError::NoMarks));
assert!(
matches!(
hold_point_in_reticle(1.0, 0.0, 1.0, &description),
Err(ReticleError::NoMarks)
),
"an all-decoration reticle has nothing to hold on"
);
}
#[test]
fn dropped_geometry_is_never_read_as_an_angle() {
let (description, report) = import_reticle_document_with_report(
r#"<reticle name="Odd" size-x="20moa" size-y="20moa">
<elements>
<reticle-line start-x="nonsense" end-y="12furlongs" />
</elements>
<bdc><bdc position-y="-1moa" /></bdc>
</reticle>"#,
)
.unwrap();
assert_eq!(description.marks.len(), 1);
assert_eq!(report.drawing_elements_dropped, 1);
}
#[test]
fn the_import_is_always_ffp_identity_because_the_format_says_nothing() {
let description = import_reticle_document(
r#"<reticle name="Scope 4-12x40, at 12x" size-x="20moa" size-y="20moa">
<bdc><bdc position-y="-2moa" /></bdc>
</reticle>"#,
)
.unwrap();
assert_eq!(description.focal_plane, FocalPlane::First);
assert_eq!(description.reference_magnification, 1.0);
assert_eq!(
description.name, "Scope 4-12x40, at 12x",
"the magnification hint stays prose in the name"
);
}
#[test]
fn malformed_documents_report_invalid_spec() {
for (xml, why) in [
("<reticle>", "unclosed root"),
("<reticle><bdc></reticle>", "mismatched close tag"),
("</reticle>", "close with nothing open"),
("", "no root at all"),
(" \n ", "whitespace only"),
("<scope name=\"x\" />", "root is not <reticle>"),
("<reticle /><reticle />", "two roots"),
(r#"<reticle name=x />"#, "unquoted attribute value"),
(r#"<reticle name />"#, "attribute with no value"),
(r#"<reticle name="unterminated />"#, "unterminated value"),
("<reticle", "unterminated start tag"),
("<!-- never ends", "unterminated comment"),
("<>", "empty tag name"),
] {
assert!(
matches!(
import_reticle_document(xml),
Err(ReticleError::InvalidSpec(_))
),
"{why} must be reported as an InvalidSpec, not accepted"
);
}
}
#[test]
fn prologue_comments_and_cdata_are_skipped() {
let description = import_reticle_document(
r#"<?xml version="1.0"?>
<!DOCTYPE reticle>
<!-- a comment mentioning <reticle-line /> and </reticle> -->
<reticle name="Skip" size-x="10mil" size-y="10mil">
<![CDATA[ <bdc position-y="-99mil" /> ]]>
<bdc><bdc position-y="-1mil" /></bdc>
</reticle>"#,
)
.unwrap();
assert_eq!(
description.marks.len(),
1,
"markup inside a comment or CDATA is text, not an element"
);
assert_eq!(description.marks[0].down_mil, 1.0);
}
#[test]
fn too_many_bdc_entries_are_capped_during_collection() {
let mut xml = String::from(r#"<reticle name="Flood"><bdc>"#);
for _ in 0..(MAX_RETICLE_MARKS + 10) {
xml.push_str(r#"<bdc position-y="-1mil" />"#);
}
xml.push_str("</bdc></reticle>");
assert!(matches!(
import_reticle_document(&xml),
Err(ReticleError::TooManyMarks { max, .. }) if max == MAX_RETICLE_MARKS
));
}
#[test]
fn nesting_deeper_than_the_cap_is_refused_without_recursing() {
let mut xml = String::from("<reticle>");
for _ in 0..(MAX_DOCUMENT_DEPTH + 5) {
xml.push_str("<elements>");
}
assert!(matches!(
import_reticle_document(&xml),
Err(ReticleError::InvalidSpec(_))
));
}
#[test]
fn entities_in_the_name_are_decoded() {
let description = import_reticle_document(
r#"<reticle name="Duplex & BDC "Pro" — 3x" />"#,
)
.unwrap();
assert_eq!(description.name, r#"Duplex & BDC "Pro" — 3x"#);
let odd = import_reticle_document(r#"<reticle name="A B &broken C" />"#).unwrap();
assert_eq!(odd.name, "A B &broken C");
}
#[test]
fn an_imported_ladder_hold_solves() {
let description = import_reticle_document(LADDER_MOA).unwrap();
let hold = hold_point_in_reticle(5.0 * MOA_TO_MIL, 0.0, 1.0, &description).unwrap();
assert!(hold.nearest_mark.is_some());
assert!(
hold.nearest_mark_distance_mil < 1e-9,
"the hold sits on the mark (distance {})",
hold.nearest_mark_distance_mil
);
assert!(!hold.off_reticle);
let windy = hold_point_in_reticle(5.0 * MOA_TO_MIL, 0.5, 1.0, &description).unwrap();
assert!(
windy.off_reticle,
"with elevation-only marks, any wind hold is off-reticle by construction"
);
}
}