use super::output_cap::{SymbolicAccumulator, SymbolicTruncationReason};
use super::primitives::SymbolicData;
fn hostile_dag(annotations: usize) -> String {
hostile_dag_with_leaf(annotations, 2)
}
fn hostile_dag_with_leaf(annotations: usize, leaf_points: usize) -> String {
let mut s = String::from("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n");
let mut id = 1000u32;
let mut tops = Vec::new();
for _ in 0..annotations {
let mut next = 0u32;
for level in 0..24 {
let rm = id;
id += 1;
let r = id;
id += 1;
if level == 0 {
let pl = id;
id += 1;
let p1 = id;
id += 1;
let p2 = id;
id += 1;
s.push_str(&format!("#{r}=IFCSHAPEREPRESENTATION($,$,$,(#{pl}));\n"));
let pts: Vec<u32> = (0..leaf_points.max(2))
.map(|_| {
let q = id;
id += 1;
q
})
.collect();
let refs = pts.iter().map(|q| format!("#{q}")).collect::<Vec<_>>().join(",");
s.push_str(&format!("#{pl}=IFCPOLYLINE(({refs}));\n"));
for (k, q) in pts.iter().enumerate() {
s.push_str(&format!("#{q}=IFCCARTESIANPOINT(({k}.,{k}.));\n"));
}
let _ = (p1, p2);
} else {
let a = id;
id += 1;
let b = id;
id += 1;
s.push_str(&format!("#{r}=IFCSHAPEREPRESENTATION($,$,$,(#{a},#{b}));\n"));
s.push_str(&format!("#{a}=IFCMAPPEDITEM(#{next},$);\n"));
s.push_str(&format!("#{b}=IFCMAPPEDITEM(#{next},$);\n"));
}
s.push_str(&format!("#{rm}=IFCREPRESENTATIONMAP($,#{r});\n"));
next = rm;
}
let top = id;
id += 1;
s.push_str(&format!("#{top}=IFCMAPPEDITEM(#{next},$);\n"));
tops.push(top);
}
let list = tops.iter().map(|t| format!("#{t}")).collect::<Vec<_>>().join(",");
let prod = id;
let shp = id + 1;
let rep = id + 2;
s.push_str(&format!(
"#{prod}=IFCANNOTATION('x',$,$,$,$,$,#{shp});\n\
#{shp}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{rep}));\n\
#{rep}=IFCSHAPEREPRESENTATION($,'Annotation','Annotation',({list}));\n"
));
s.push_str("ENDSEC;\nEND-ISO-10303-21;\n");
s
}
#[test]
fn a_hostile_file_is_bounded_and_says_so() {
let mut out = SymbolicAccumulator::with_limit(500);
super::extract_symbolic_data_into(&hostile_dag(2).into_bytes(), &mut out);
let out = out.into_data();
assert_eq!(
out.len(),
500,
"the cap must bound the TOTAL across every collection, not each one"
);
let truncation = out
.truncated
.as_ref()
.expect("a truncated extraction must say so: silence here is #2938 verbatim");
assert_eq!(truncation.limit, Some(500));
assert_eq!(
truncation.reason,
SymbolicTruncationReason::ElementCount,
"the diagnostic must name WHICH bound fired, not merely that one did"
);
assert_eq!(
truncation.emitted, 500,
"`emitted` must be the count at the moment extraction stopped"
);
}
#[test]
fn a_file_that_fits_is_not_marked_truncated() {
let mut out = SymbolicAccumulator::with_limit(500);
super::extract_symbolic_data_into(&hostile_dag(0).into_bytes(), &mut out);
let out = out.into_data();
assert!(
out.truncated.is_none(),
"an extraction that never reached the cap must not report truncation"
);
assert!(out.truncated.is_none());
}
#[test]
fn truncation_survives_the_wire_and_absence_still_deserializes() {
let mut acc = SymbolicAccumulator::with_limit(1);
super::extract_symbolic_data_into(&hostile_dag(1).into_bytes(), &mut acc);
let truncated = acc.into_data();
assert!(truncated.truncated.is_some(), "fixture must actually truncate");
let json = serde_json::to_string(&truncated).expect("serializes");
let back: SymbolicData = serde_json::from_str(&json).expect("round-trips");
assert_eq!(back.truncated, truncated.truncated);
let clean = SymbolicData::default();
let clean_json = serde_json::to_string(&clean).expect("serializes");
assert!(
!clean_json.contains("truncated"),
"an untruncated result must not gain a field on the wire: {clean_json}"
);
let old_shape: SymbolicData =
serde_json::from_str(r#"{"grid_axes":[],"polylines":[],"circles":[],"texts":[],"fills":[]}"#)
.expect("JSON cached before this field existed must still deserialize");
assert!(old_shape.truncated.is_none());
}
#[test]
fn a_few_enormous_primitives_are_bounded_too() {
let mut acc = SymbolicAccumulator::with_limits(500, 4096);
super::extract_symbolic_data_into(&hostile_dag_with_leaf(2, 400).into_bytes(), &mut acc);
let out = acc.into_data();
assert!(
out.truncated.is_some(),
"a file of few but enormous primitives must still be bounded"
);
assert!(
out.len() < 500,
"the BYTE bound must bite before the count bound: emitted {} of a 500 count cap, \
so this stopped for the wrong reason and the byte charge is not working",
out.len()
);
let emitted_payload: usize = out.polylines.iter().map(|p| p.points.len()).sum();
assert!(
emitted_payload * 8 <= 4096 + 400 * 8,
"total emitted payload must respect the byte budget; got {emitted_payload} coords"
);
}
fn hostile_text_dag(content_len: usize, alignment_len: usize) -> String {
let pad = "A".repeat(alignment_len);
let body = "B".repeat(content_len.max(2));
let mut s = String::from("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n");
let mut id = 1000u32;
let mut next = 0u32;
for level in 0..12 {
let rm = id;
id += 1;
let r = id;
id += 1;
if level == 0 {
let tl = id;
id += 1;
let pt = id;
id += 1;
s.push_str(&format!("#{r}=IFCSHAPEREPRESENTATION($,$,$,(#{tl}));\n"));
s.push_str(&format!(
"#{tl}=IFCTEXTLITERALWITHEXTENT('{body}',#{pt},.RIGHT.,$,'{pad}');\n"
));
s.push_str(&format!("#{pt}=IFCAXIS2PLACEMENT2D(#{},$);\n", pt + 1));
s.push_str(&format!("#{}=IFCCARTESIANPOINT((0.,0.));\n", pt + 1));
id += 2;
} else {
let a = id;
id += 1;
let b = id;
id += 1;
s.push_str(&format!("#{r}=IFCSHAPEREPRESENTATION($,$,$,(#{a},#{b}));\n"));
s.push_str(&format!("#{a}=IFCMAPPEDITEM(#{next},$);\n"));
s.push_str(&format!("#{b}=IFCMAPPEDITEM(#{next},$);\n"));
}
s.push_str(&format!("#{rm}=IFCREPRESENTATIONMAP($,#{r});\n"));
next = rm;
}
let top = id;
id += 1;
s.push_str(&format!("#{top}=IFCMAPPEDITEM(#{next},$);\n"));
let prod = id;
let shp = id + 1;
let rep = id + 2;
s.push_str(&format!(
"#{prod}=IFCANNOTATION('x',$,$,$,$,$,#{shp});\n\
#{shp}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{rep}));\n\
#{rep}=IFCSHAPEREPRESENTATION($,'Annotation','Annotation',(#{top}));\n"
));
s.push_str("ENDSEC;\nEND-ISO-10303-21;\n");
s
}
#[test]
fn every_variable_length_field_is_charged_not_just_the_obvious_one() {
for (content_len, alignment_len, which) in
[(2usize, 4096usize, "alignment"), (4096, 2, "content")]
{
let mut acc = SymbolicAccumulator::with_limits(100_000, 8192);
super::extract_symbolic_data_into(
&hostile_text_dag(content_len, alignment_len).into_bytes(),
&mut acc,
);
let out = acc.into_data();
let truncation = out
.truncated
.as_ref()
.unwrap_or_else(|| panic!("a {which}-heavy fan-out must be bounded and reported"));
assert_eq!(
truncation.reason,
SymbolicTruncationReason::OutputBytes,
"the BYTE bound must fire for a {which}-heavy file; ElementCount here \
means {which} bytes are going uncharged"
);
let charged: usize =
out.texts.iter().map(|t| t.content.len() + t.alignment.len()).sum();
assert!(
charged * 8 <= 8192 + 4096 * 8,
"{which}-heavy payload must respect the byte budget; got {charged}"
);
}
}
#[test]
fn a_per_item_bound_reports_its_own_reason_and_does_not_abandon_the_file() {
let mut acc = SymbolicAccumulator::with_limits(10_000_000, 64 * 1024 * 1024);
super::extract_symbolic_data_into(&hostile_dag(2).into_bytes(), &mut acc);
let out = acc.into_data();
let truncation = out
.truncated
.as_ref()
.expect("a per-item bound drops content and must say so");
assert_eq!(truncation.reason, SymbolicTruncationReason::ItemRevisits);
assert_eq!(
truncation.limit, None,
"a per-item bound has no file-level limit to compare `emitted` against"
);
assert!(
out.len() > 66_675,
"the rest of the file must still be extracted: a per-item bound is not \
exhaustion, and treating it as such abandons every later product. \
Got {} primitives, which is one product's worth or less",
out.len()
);
}
#[test]
fn the_early_exits_stop_the_walk_and_not_only_the_appends() {
let mut acc = SymbolicAccumulator::with_limits(500, 64 * 1024 * 1024);
super::extract_symbolic_data_into(&hostile_dag(2).into_bytes(), &mut acc);
let refusals = acc.refusals();
assert!(acc.is_exhausted(), "fixture must reach the cap");
assert!(
refusals < 5_000,
"the walk must STOP once the accumulator is full, not keep traversing \
and discarding: {refusals} refused appends means the early exits are \
gone and the work is unbounded again"
);
}
fn deep_chain_then(rest: &str, start: u32) -> String {
let mut s = String::new();
let mut id = start;
let mut next = 0u32;
for level in 0..40 {
let rm = id;
id += 1;
let r = id;
id += 1;
if level == 0 {
let pl = id;
id += 1;
s.push_str(&format!("#{r}=IFCSHAPEREPRESENTATION($,$,$,(#{pl}));\n"));
s.push_str(&format!("#{pl}=IFCPOLYLINE((#{},#{}));\n", pl + 1, pl + 2));
s.push_str(&format!("#{}=IFCCARTESIANPOINT((0.,0.));\n", pl + 1));
s.push_str(&format!("#{}=IFCCARTESIANPOINT((1.,1.));\n", pl + 2));
id += 2;
} else {
let a = id;
id += 1;
s.push_str(&format!("#{r}=IFCSHAPEREPRESENTATION($,$,$,(#{a}));\n"));
s.push_str(&format!("#{a}=IFCMAPPEDITEM(#{next},$);\n"));
}
s.push_str(&format!("#{rm}=IFCREPRESENTATIONMAP($,#{r});\n"));
next = rm;
}
let top = id;
id += 1;
s.push_str(&format!("#{top}=IFCMAPPEDITEM(#{next},$);\n"));
let prod = id;
s.push_str(&format!(
"#{prod}=IFCANNOTATION('deep',$,$,$,$,$,#{});\n\
#{}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{}));\n\
#{}=IFCSHAPEREPRESENTATION($,'Annotation','Annotation',(#{top}));\n",
prod + 1, prod + 1, prod + 2, prod + 2
));
format!("{s}{rest}")
}
#[test]
fn an_extraction_bound_outranks_a_per_item_one_whatever_the_scan_order() {
let dag = hostile_dag(2);
let body = dag
.strip_prefix("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n")
.expect("fixture prefix");
let file = format!(
"ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n{}",
deep_chain_then(body, 500_000)
);
let mut acc = SymbolicAccumulator::with_limits(200, 64 * 1024 * 1024);
super::extract_symbolic_data_into(&file.into_bytes(), &mut acc);
let out = acc.into_data();
let truncation = out.truncated.as_ref().expect("must be truncated");
assert_eq!(
truncation.reason,
SymbolicTruncationReason::ElementCount,
"the whole-output cap must outrank a per-item bound that happened to \
fire earlier in scan order; reporting the milder reason understates \
the most severe truncation there is"
);
assert_eq!(
truncation.limit,
Some(200),
"and its numeric limit must survive: a per-item reason carries None, \
so mislabelling also silently drops the number"
);
}
#[test]
fn a_per_item_reason_omits_limit_on_the_wire_rather_than_sending_null() {
let mut acc = SymbolicAccumulator::with_limits(10_000_000, 64 * 1024 * 1024);
super::extract_symbolic_data_into(&hostile_dag(2).into_bytes(), &mut acc);
let out = acc.into_data();
let json = serde_json::to_value(&out).expect("serializes");
let truncated = &json["truncated"];
assert_eq!(truncated["reason"], "item-revisits");
assert!(
!truncated
.as_object()
.expect("truncated is an object")
.contains_key("limit"),
"a per-item reason must OMIT `limit`, not emit null: {truncated}"
);
let mut acc = SymbolicAccumulator::with_limits(200, 64 * 1024 * 1024);
super::extract_symbolic_data_into(&hostile_dag(2).into_bytes(), &mut acc);
let json = serde_json::to_value(acc.into_data()).expect("serializes");
assert_eq!(json["truncated"]["reason"], "element-count");
assert_eq!(json["truncated"]["limit"], 200);
}
#[test]
fn the_wire_spellings_match_serde() {
match SymbolicTruncationReason::ElementCount {
SymbolicTruncationReason::ElementCount
| SymbolicTruncationReason::OutputBytes
| SymbolicTruncationReason::ItemDepth
| SymbolicTruncationReason::ItemRevisits
| SymbolicTruncationReason::ItemCycle => {}
}
for reason in [
SymbolicTruncationReason::ElementCount,
SymbolicTruncationReason::OutputBytes,
SymbolicTruncationReason::ItemDepth,
SymbolicTruncationReason::ItemRevisits,
SymbolicTruncationReason::ItemCycle,
] {
let via_serde = serde_json::to_value(reason).expect("serializes");
assert_eq!(
via_serde,
serde_json::Value::String(reason.as_wire_str().to_string()),
"as_wire_str disagrees with Serialize for {reason:?}"
);
}
}