use super::output_cap::{SymbolicAccumulator, SymbolicTruncationReason};
use super::primitives::SymbolicData;
impl SymbolicAccumulator {
pub(super) fn with_limits(limit: usize, byte_limit: usize) -> Self {
let mut acc = Self::new();
acc.limit = limit;
acc.byte_limit = byte_limit;
acc
}
pub(super) fn with_limit(limit: usize) -> Self {
let mut acc = Self::new();
acc.limit = limit;
acc
}
pub(super) fn with_revisit_budget(revisit_budget: u32) -> Self {
let mut acc = Self::new();
acc.revisit_budget = revisit_budget;
acc
}
pub(super) fn refusals(&self) -> usize {
self.refusals
}
}
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);
}
fn shared_map_multi_placement(placements: usize, leaves: usize) -> String {
let mut s = String::from("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n");
let mut id = 1000u32;
let set = id;
id += 1;
let mut elems = Vec::new();
for _ in 0..leaves {
let pl = id;
id += 1;
let p1 = id;
id += 1;
let p2 = id;
id += 1;
s.push_str(&format!("#{pl}=IFCPOLYLINE((#{p1},#{p2}));\n"));
s.push_str(&format!("#{p1}=IFCCARTESIANPOINT((0.,0.));\n"));
s.push_str(&format!("#{p2}=IFCCARTESIANPOINT((1.,1.));\n"));
elems.push(pl);
}
let refs = elems.iter().map(|q| format!("#{q}")).collect::<Vec<_>>().join(",");
s.push_str(&format!("#{set}=IFCGEOMETRICCURVESET(({refs}));\n"));
let list = (0..placements).map(|_| format!("#{set}")).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
}
fn flat_multi_item_dag(top_items: usize, leaves: 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..top_items {
let set = id;
id += 1;
let mut elems = Vec::new();
for _ in 0..leaves {
let pl = id;
id += 1;
let p1 = id;
id += 1;
let p2 = id;
id += 1;
s.push_str(&format!("#{pl}=IFCPOLYLINE((#{p1},#{p2}));\n"));
s.push_str(&format!("#{p1}=IFCCARTESIANPOINT((0.,0.));\n"));
s.push_str(&format!("#{p2}=IFCCARTESIANPOINT((1.,1.));\n"));
elems.push(pl);
}
let refs = elems.iter().map(|q| format!("#{q}")).collect::<Vec<_>>().join(",");
s.push_str(&format!("#{set}=IFCGEOMETRICCURVESET(({refs}));\n"));
tops.push(set);
}
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 the_revisit_budget_is_shared_once_across_the_whole_extraction_not_reset_per_item() {
const BUDGET: u32 = 50;
let mut one = SymbolicAccumulator::with_revisit_budget(BUDGET);
super::extract_symbolic_data_into(&hostile_dag(1).into_bytes(), &mut one);
let one_data = one.into_data();
let one_item_total = one_data.len();
assert_eq!(
one_data.truncated.as_ref().map(|t| t.reason),
Some(SymbolicTruncationReason::ItemRevisits),
"a budget of {BUDGET} must be nowhere near enough for a single 24-level \
fan-out DAG, so this item alone must already be truncated: {:?}",
one_data.truncated
);
let mut two = SymbolicAccumulator::with_revisit_budget(BUDGET);
super::extract_symbolic_data_into(&hostile_dag(2).into_bytes(), &mut two);
let two_data = two.into_data();
let two_item_total = two_data.len();
assert_eq!(
two_data.truncated.as_ref().map(|t| t.reason),
Some(SymbolicTruncationReason::ItemRevisits),
"the second item must also end up truncated once the shared pool is \
spent: {:?}",
two_data.truncated
);
assert!(
one_item_total > 0,
"one item emitted nothing, so the comparison that follows would hold \
for any implementation"
);
assert!(
two_item_total < 2 * one_item_total,
"a SECOND top-level item sharing the same tiny revisit budget as the \
first must contribute almost nothing once the first item has spent \
the pool -- got {one_item_total} primitives for one item and \
{two_item_total} for two. Approaching twice the one-item total is \
what a per-item {BUDGET}-revisit budget looks like, which is the \
regression this test exists to catch"
);
}
#[test]
fn re_placing_one_library_block_is_not_charged_as_a_revisit() {
const BUDGET: u32 = 5;
const PLACEMENTS: usize = 4;
const LEAVES: usize = 50;
let mut acc = SymbolicAccumulator::with_revisit_budget(BUDGET);
super::extract_symbolic_data_into(
&shared_map_multi_placement(PLACEMENTS, LEAVES).into_bytes(),
&mut acc,
);
let out = acc.into_data();
assert_eq!(
out.polylines.len(),
PLACEMENTS * LEAVES,
"each placement of a shared block is a separate top-level item and \
must emit its own geometry in full; a node first reached under THIS \
item is a first visit even if an earlier item also reached it"
);
assert!(
out.truncated.is_none(),
"re-placing one block is not a revisit fan-out and must not be \
reported as truncated: {:?}",
out.truncated
);
}
#[test]
fn the_shared_revisit_budget_does_not_charge_legitimate_first_visits_across_items() {
const BUDGET: u32 = 5;
const LEAVES: usize = 200;
let mut acc = SymbolicAccumulator::with_revisit_budget(BUDGET);
super::extract_symbolic_data_into(
&flat_multi_item_dag(2, LEAVES).into_bytes(),
&mut acc,
);
let out = acc.into_data();
assert_eq!(
out.polylines.len(),
2 * LEAVES,
"every element of two well-formed, non-cyclic flat sets must be \
emitted regardless of how small the shared revisit budget is -- \
first visits are never charged"
);
assert!(
out.truncated.is_none(),
"a file that never needed a single revisit must not be reported as \
truncated just because the shared pool is tiny: {:?}",
out.truncated
);
}
#[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:?}"
);
}
}