Files
cadcraft/crates/io/tests/roundtrip_audit.rs
2026-10-11 19:53:33 -04:00

982 lines
25 KiB
Rust

//! What survives DXF open → save → reopen of data CADCraft does not model (#528): unknown
//! entities and objects, other applications' xdata, extension dictionaries, layer states, colour
//! books, hyperlinks, DICTIONARYVAR objects, XRECORDs and persistent reactors. One test per item.
//!
//! The fixture is synthetic, written from the public DXF Reference: a drawing as another program
//! might save it, with an application `ACME` that defines its own entity and object types.
use std::collections::HashMap;
use cadcraft_doc::{Drawing, EntityKind};
use cadcraft_dxf::{Tag, records, sections};
use cadcraft_io::{read_dxf, write_dxf};
/// DXF text from lines of `code value` (the value may be empty).
fn dxf(src: &str) -> String {
let mut out = String::new();
for line in src.lines().map(str::trim).filter(|l| !l.is_empty()) {
let (code, value) = line.split_once(' ').unwrap_or((line, ""));
out.push_str(&format!("{code}\n{value}\n"));
}
out
}
const HEADER: &str = "
0 SECTION
2 HEADER
9 $ACADVER
1 AC1018
9 $HANDSEED
5 100
0 ENDSEC
0 SECTION
2 CLASSES
0 CLASS
1 ACME_WIDGET
2 AcmeWidget
3 AcmeApp
90 1025
280 0
281 1
0 CLASS
1 ACME_OBJECT
2 AcmeObject
3 AcmeApp
90 0
280 0
281 0
0 CLASS
1 DICTIONARYVAR
2 AcDbDictionaryVar
3 ObjectDBX Classes
90 0
280 0
281 0
0 ENDSEC
";
const TABLES: &str = "
0 SECTION
2 TABLES
0 TABLE
2 LAYER
5 2
102 {ACAD_XDICTIONARY
360 60
102 }
330 0
100 AcDbSymbolTable
70 2
0 LAYER
5 10
102 {ACAD_XDICTIONARY
360 63
102 }
330 2
100 AcDbSymbolTableRecord
100 AcDbLayerTableRecord
2 0
70 0
62 7
6 Continuous
0 LAYER
5 11
330 2
100 AcDbSymbolTableRecord
100 AcDbLayerTableRecord
2 WALLS
70 0
62 1
420 13382451
430 RAL$RAL 3020
6 Continuous
370 -3
1001 ACME
1000 layer-data
1070 7
0 ENDTAB
0 TABLE
2 UCS
5 3
330 0
100 AcDbSymbolTable
70 1
0 UCS
5 12
330 3
100 AcDbSymbolTableRecord
100 AcDbUCSTableRecord
2 TILTED
70 0
10 1
20 2
30 3
11 0
21 1
31 0
12 -1
22 0
32 0
79 0
146 5.5
0 ENDTAB
0 TABLE
2 APPID
5 4
330 0
100 AcDbSymbolTable
70 3
0 APPID
5 13
330 4
100 AcDbSymbolTableRecord
100 AcDbRegAppTableRecord
2 ACAD
70 0
0 APPID
5 14
330 4
100 AcDbSymbolTableRecord
100 AcDbRegAppTableRecord
2 ACME
70 0
0 APPID
5 15
330 4
100 AcDbSymbolTableRecord
100 AcDbRegAppTableRecord
2 PE_URL
70 0
0 ENDTAB
0 TABLE
2 BLOCK_RECORD
5 1
330 0
100 AcDbSymbolTable
70 3
0 BLOCK_RECORD
5 1F
330 1
100 AcDbSymbolTableRecord
100 AcDbBlockTableRecord
2 *Model_Space
340 0
0 BLOCK_RECORD
5 1E
330 1
100 AcDbSymbolTableRecord
100 AcDbBlockTableRecord
2 *Paper_Space
340 0
0 BLOCK_RECORD
5 1D
102 {ACAD_XDICTIONARY
360 65
102 }
330 1
100 AcDbSymbolTableRecord
100 AcDbBlockTableRecord
2 DOOR
340 0
70 0
280 1
1001 ACME
1000 block-record-data
0 ENDTAB
0 ENDSEC
";
const BLOCKS: &str = "
0 SECTION
2 BLOCKS
0 BLOCK
5 20
330 1F
100 AcDbEntity
8 0
100 AcDbBlockBegin
2 *Model_Space
70 0
10 0
20 0
30 0
3 *Model_Space
1
0 ENDBLK
5 21
330 1F
100 AcDbEntity
8 0
100 AcDbBlockEnd
0 BLOCK
5 22
330 1E
100 AcDbEntity
67 1
8 0
100 AcDbBlockBegin
2 *Paper_Space
70 0
10 0
20 0
30 0
3 *Paper_Space
1
0 ENDBLK
5 23
330 1E
100 AcDbEntity
67 1
8 0
100 AcDbBlockEnd
0 BLOCK
5 24
330 1D
100 AcDbEntity
8 0
100 AcDbBlockBegin
2 DOOR
70 0
10 0
20 0
30 0
3 DOOR
1
4 A door leaf
1001 ACME
1000 block-entity-data
0 LINE
5 26
330 1D
100 AcDbEntity
8 0
100 AcDbLine
10 0
20 0
30 0
11 1
21 0
31 0
1001 ACME
1000 in-block
0 ACME_WIDGET
5 32
330 1D
100 AcDbEntity
8 0
100 AcDbAcmeWidget
10 0.5
20 0.5
30 0
1 widget-in-block
0 ENDBLK
5 25
330 1D
100 AcDbEntity
8 0
100 AcDbBlockEnd
0 ENDSEC
";
const ENTITIES: &str = "
0 SECTION
2 ENTITIES
0 LINE
5 2A
102 {ACAD_REACTORS
330 42
102 }
102 {ACAD_XDICTIONARY
360 2B
102 }
330 1F
100 AcDbEntity
8 WALLS
62 1
420 16711680
430 RAL$RAL 3000
440 33554559
100 AcDbLine
10 0
20 0
30 0
11 10
21 0
31 0
1001 ACME
1000 hello
1070 42
1005 42
1001 PE_URL
1000 https://example.com/spec
1002 {
1000 Specification
1002 {
1071 0
1002 }
1002 }
0 ACME_WIDGET
5 30
330 1F
100 AcDbEntity
8 WALLS
100 AcDbAcmeWidget
10 1
20 2
30 0
1 widget-in-model
340 42
341 11
0 ACME_WIDGET
5 31
330 1E
100 AcDbEntity
67 1
8 0
100 AcDbAcmeWidget
10 3
20 4
30 0
1 widget-in-paper
0 INSERT
5 33
330 1F
100 AcDbEntity
8 0
100 AcDbBlockReference
2 DOOR
10 5
20 5
30 0
0 IMAGE
5 34
102 {ACAD_REACTORS
330 52
102 }
330 1F
100 AcDbEntity
8 0
100 AcDbRasterImage
90 0
10 5
20 5
30 0
11 0.01
21 0
31 0
12 0
22 0.01
32 0
13 640
23 480
340 51
70 7
280 0
281 50
282 50
283 0
360 52
71 1
91 2
14 -0.5
24 -0.5
14 639.5
24 479.5
0 ENDSEC
";
const OBJECTS: &str = "
0 SECTION
2 OBJECTS
0 DICTIONARY
5 C
330 0
100 AcDbDictionary
281 1
3 ACAD_GROUP
350 D
3 ACAD_IMAGE_DICT
350 50
3 ACME_DATA
350 40
3 ACME_NESTED
350 41
3 AcDbVariableDictionary
350 43
0 DICTIONARY
5 D
102 {ACAD_REACTORS
330 C
102 }
330 C
100 AcDbDictionary
281 1
0 XRECORD
5 40
102 {ACAD_REACTORS
330 C
102 }
330 C
100 AcDbXrecord
280 1
1 top-level-xrecord
70 7
40 2.5
0 DICTIONARY
5 41
330 C
100 AcDbDictionary
281 1
3 INNER
350 42
0 ACME_OBJECT
5 42
102 {ACAD_REACTORS
330 41
330 2A
102 }
330 41
100 AcDbAcmeObject
1 nested-object
340 2A
341 11
0 DICTIONARY
5 43
330 C
100 AcDbDictionary
281 1
3 ACMEVAR
350 44
0 DICTIONARYVAR
5 44
102 {ACAD_REACTORS
330 43
102 }
330 43
100 DictionaryVariables
280 0
1 var-value
0 DICTIONARY
5 2B
330 2A
100 AcDbDictionary
280 1
281 1
3 ACME_XREC
360 2D
0 XRECORD
5 2D
330 2B
100 AcDbXrecord
280 1
1 entity-xdict-data
0 DICTIONARY
5 60
330 2
100 AcDbDictionary
281 1
3 ACAD_LAYERSTATES
360 61
0 DICTIONARY
5 61
330 60
100 AcDbDictionary
281 1
3 Plan
350 62
0 XRECORD
5 62
102 {ACAD_REACTORS
330 61
102 }
330 61
100 AcDbXrecord
280 1
91 0
301 Plan
8 WALLS
90 0
62 1
0 DICTIONARY
5 63
330 10
100 AcDbDictionary
281 1
3 ACME_LAYER
360 64
0 XRECORD
5 64
330 63
100 AcDbXrecord
1 layer-xdict-data
0 DICTIONARY
5 65
330 1D
100 AcDbDictionary
281 1
3 ACME_BLOCK
360 66
0 XRECORD
5 66
330 65
100 AcDbXrecord
1 block-xdict-data
0 DICTIONARY
5 50
330 C
100 AcDbDictionary
281 1
3 logo
350 51
0 IMAGEDEF
5 51
102 {ACAD_REACTORS
330 50
330 52
102 }
330 50
100 AcDbRasterImageDef
90 0
1 images/logo.png
10 640
20 480
11 0.25
21 0.25
280 1
281 5
0 IMAGEDEF_REACTOR
5 52
330 34
100 AcDbRasterImageDefReactor
90 2
330 34
0 ENDSEC
0 EOF
";
fn fixture() -> String {
dxf(&[HEADER, TABLES, BLOCKS, ENTITIES, OBJECTS].concat())
}
/// A written file, record by record: (section, record type, groups after the `0`).
struct Out {
recs: Vec<(String, String, Vec<Tag>)>,
}
impl Out {
fn new(text: &str) -> Out {
let tags = cadcraft_dxf::parse(text.as_bytes()).expect("parse written DXF");
let mut recs = Vec::new();
for s in sections(&tags) {
for (k, t) in records(&s.tags) {
recs.push((s.name.clone(), k, t));
}
}
Out { recs }
}
fn of(&self, kind: &str) -> Vec<&[Tag]> {
self.recs.iter().filter(|(_, k, _)| k == kind).map(|(_, _, t)| t.as_slice()).collect()
}
fn in_section(&self, section: &str, kind: &str) -> Vec<&[Tag]> {
self.recs.iter().filter(|(s, k, _)| s == section && k == kind).map(|(_, _, t)| t.as_slice()).collect()
}
/// The record with handle `h` (group 5, or 105 for DIMSTYLE): (type, groups).
fn handle(&self, h: &str) -> Option<(&str, &[Tag])> {
self.recs
.iter()
.find(|(_, _, t)| t.iter().any(|x| (x.code == 5 || x.code == 105) && x.str().eq_ignore_ascii_case(h)))
.map(|(_, k, t)| (k.as_str(), t.as_slice()))
}
/// The table record or block record of type `kind` named `name`.
fn named(&self, kind: &str, name: &str) -> &[Tag] {
self.of(kind).into_iter().find(|t| s(t, 2).eq_ignore_ascii_case(name)).unwrap_or_else(|| panic!("no {kind} {name}"))
}
fn root(&self) -> &[Tag] {
self.in_section("OBJECTS", "DICTIONARY").into_iter().next().expect("named object dictionary")
}
/// The object a dictionary maps `key` to.
fn entry(&self, dict: &[Tag], key: &str) -> Option<(&str, &[Tag])> {
let h = entries(dict).into_iter().find(|(k, _)| k == key)?.1;
self.handle(&h)
}
/// Every handle reference (330/340/350/360 groups, 102 groups and 1005 xdata) that is not
/// null and names no record of the file.
fn dangling(&self) -> Vec<String> {
let handles: std::collections::HashSet<String> =
self.recs.iter().flat_map(|(_, _, t)| t.iter().filter(|x| x.code == 5 || x.code == 105).map(|x| x.str().to_ascii_uppercase())).collect();
let mut out = Vec::new();
for (_, k, t) in &self.recs {
for x in t.iter().filter(|x| matches!(x.code, 330..=369 | 1005)) {
let v = x.str().to_ascii_uppercase();
if v != "0" && !v.is_empty() && !handles.contains(&v) {
out.push(format!("{k} {} -> {v}", x.code));
}
}
}
out
}
fn counts(&self) -> HashMap<String, usize> {
let mut m = HashMap::new();
for (_, k, _) in &self.recs {
*m.entry(k.clone()).or_insert(0) += 1;
}
m
}
}
fn s(tags: &[Tag], code: i32) -> String {
tags.iter().find(|t| t.code == code).map(Tag::str).unwrap_or_default()
}
fn h(tags: &[Tag]) -> String {
tags.iter().find(|t| t.code == 5 || t.code == 105).map(Tag::str).unwrap_or_default()
}
/// The owner: the first 330 outside `102` groups.
fn owner(tags: &[Tag]) -> String {
let mut depth = 0;
for t in tags {
match (t.code, t.str().as_str()) {
(102, v) if v.starts_with('{') => depth += 1,
(102, "}") => depth -= 1,
(330, v) if depth == 0 => return v.to_string(),
_ => {}
}
}
String::new()
}
/// The handles in a `102 {NAME` group.
fn group(tags: &[Tag], name: &str) -> Vec<String> {
let start = tags.iter().position(|t| t.code == 102 && t.str() == format!("{{{name}"));
let Some(start) = start else { return Vec::new() };
tags[start + 1..].iter().take_while(|t| t.code != 102).map(Tag::str).collect()
}
/// Dictionary entries: (key, handle).
fn entries(tags: &[Tag]) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut key = None;
for t in tags {
match t.code {
3 => key = Some(t.str()),
350 | 360 => {
if let Some(k) = key.take() {
out.push((k, t.str()));
}
}
_ => {}
}
}
out
}
/// The xdata of application `app`: the groups after its `1001` up to the next application.
fn xdata(tags: &[Tag], app: &str) -> Vec<(i32, String)> {
let Some(start) = tags.iter().position(|t| t.code == 1001 && t.str() == app) else { return Vec::new() };
tags[start + 1..].iter().take_while(|t| t.code != 1001).map(|t| (t.code, t.str())).collect()
}
/// Read the fixture, save, reopen and save again: both saves.
fn saves(src: &str) -> (Drawing, Out, Out) {
let d = read_dxf(src.as_bytes()).expect("read fixture");
let first = write_dxf(&d);
let d2 = read_dxf(first.as_bytes()).expect("reopen");
let second = write_dxf(&d2);
let d3 = read_dxf(second.as_bytes()).expect("reopen again");
(d3, Out::new(&first), Out::new(&second))
}
fn entity_by_text<'a>(o: &'a Out, kind: &str, text: &str) -> &'a [Tag] {
o.of(kind).into_iter().find(|t| s(t, 1) == text).unwrap_or_else(|| panic!("no {kind} `{text}`"))
}
#[test]
fn unknown_entities_in_model_paper_and_blocks_are_kept() {
let (_, a, b) = saves(&fixture());
for o in [&a, &b] {
assert_eq!(o.of("ACME_WIDGET").len(), 3);
let model = entity_by_text(o, "ACME_WIDGET", "widget-in-model");
assert_eq!(owner(model), h(o.named("BLOCK_RECORD", "*Model_Space")));
assert_eq!(s(model, 8), "WALLS");
// Its pointers follow the objects they name.
assert_eq!(o.handle(&s(model, 340)).map(|r| r.0), Some("ACME_OBJECT"));
assert_eq!(o.handle(&s(model, 341)).map(|r| s(r.1, 2)), Some("WALLS".into()));
let paper = entity_by_text(o, "ACME_WIDGET", "widget-in-paper");
assert_eq!(s(paper, 67), "1");
assert_eq!(owner(paper), h(o.named("BLOCK_RECORD", "*Paper_Space")));
let in_block = entity_by_text(o, "ACME_WIDGET", "widget-in-block");
assert_eq!(owner(in_block), h(o.named("BLOCK_RECORD", "DOOR")));
// It keeps its place in the drawing order: after the line, before the block reference.
let order: Vec<String> = o.in_section("ENTITIES", "LINE").into_iter().chain(o.in_section("ENTITIES", "ACME_WIDGET")).map(h).collect();
let pos = |x: &str| o.recs.iter().position(|(_, _, t)| h(t) == x);
assert!(order.contains(&"30".to_string()) && pos("2A") < pos("30") && pos("30") < pos("33"), "order");
// Its class is declared.
assert!(o.of("CLASS").iter().any(|c| s(c, 1) == "ACME_WIDGET"), "ACME_WIDGET class");
}
}
#[test]
fn unknown_objects_in_named_and_nested_dictionaries_are_kept() {
let (_, a, b) = saves(&fixture());
for o in [&a, &b] {
let root = o.root();
let (kind, x) = o.entry(root, "ACME_DATA").expect("ACME_DATA entry");
assert_eq!(kind, "XRECORD");
assert_eq!((s(x, 1), s(x, 70), s(x, 40)), ("top-level-xrecord".into(), "7".into(), "2.5".into()));
assert_eq!(owner(x), h(root));
let (kind, nested) = o.entry(root, "ACME_NESTED").expect("ACME_NESTED entry");
assert_eq!(kind, "DICTIONARY");
let (kind, obj) = o.entry(nested, "INNER").expect("INNER entry");
assert_eq!(kind, "ACME_OBJECT");
assert_eq!(s(obj, 1), "nested-object");
assert_eq!(owner(obj), h(nested));
assert!(o.of("CLASS").iter().any(|c| s(c, 1) == "ACME_OBJECT"), "ACME_OBJECT class");
// CADCraft's own dictionaries are not doubled.
let keys: Vec<String> = entries(root).into_iter().map(|e| e.0).collect();
for k in ["ACAD_GROUP", "ACAD_IMAGE_DICT"] {
assert_eq!(keys.iter().filter(|x| *x == k).count(), 1, "{k} in {keys:?}");
}
}
}
#[test]
fn foreign_xdata_on_entities_tables_and_blocks_is_kept() {
let (_, a, b) = saves(&fixture());
for o in [&a, &b] {
let line = o.handle("2A").expect("line").1;
let acme = xdata(line, "ACME");
assert_eq!(acme.get(..2), Some(&[(1000, "hello".to_string()), (1070, "42".to_string())][..]));
let target = acme.get(2).map(|x| x.1.clone()).unwrap_or_default();
assert_eq!(o.handle(&target).map(|r| r.0), Some("ACME_OBJECT"), "1005 handle follows its object");
assert_eq!(xdata(o.named("LAYER", "WALLS"), "ACME"), vec![(1000, "layer-data".into()), (1070, "7".into())]);
assert_eq!(xdata(o.named("BLOCK_RECORD", "DOOR"), "ACME"), vec![(1000, "block-record-data".into())]);
assert_eq!(xdata(o.named("BLOCK", "DOOR"), "ACME"), vec![(1000, "block-entity-data".into())]);
let in_block = o.handle("26").expect("line in block").1;
assert_eq!(xdata(in_block, "ACME"), vec![(1000, "in-block".into())]);
// Every application used has its APPID record.
let apps: Vec<String> = o.of("APPID").iter().map(|r| s(r, 2)).collect();
for app in ["ACME", "PE_URL"] {
assert_eq!(apps.iter().filter(|a| *a == app).count(), 1, "{app} in {apps:?}");
}
}
}
#[test]
fn extension_dictionaries_on_entities_and_table_records_are_kept() {
let (_, a, b) = saves(&fixture());
for o in [&a, &b] {
let check = |rec: &[Tag], key: &str, text: &str| {
let [xd] = group(rec, "ACAD_XDICTIONARY").try_into().unwrap_or_else(|g| panic!("one extension dictionary, got {g:?}"));
let (kind, dict) = o.handle(&xd).expect("extension dictionary");
assert_eq!(kind, "DICTIONARY");
assert_eq!(owner(dict), h(rec), "the extension dictionary's owner");
let (_, x) = o.entry(dict, key).unwrap_or_else(|| panic!("{key}"));
assert_eq!(s(x, 1), text);
};
check(o.handle("2A").expect("line").1, "ACME_XREC", "entity-xdict-data");
check(o.named("LAYER", "0"), "ACME_LAYER", "layer-xdict-data");
check(o.named("BLOCK_RECORD", "DOOR"), "ACME_BLOCK", "block-xdict-data");
}
}
#[test]
fn image_imagedef_and_reactor_are_kept() {
let (d, a, b) = saves(&fixture());
let img = d.model.iter().find_map(|e| match &e.kind {
EntityKind::Image(i) => Some(i.clone()),
_ => None,
});
assert_eq!(img.map(|i| (i.path, i.name)), Some(("images/logo.png".into(), "logo".into())));
for o in [&a, &b] {
assert_eq!(o.of("IMAGEDEF").len(), 1);
assert_eq!(o.of("IMAGEDEF_REACTOR").len(), 1);
let reactor = o.of("IMAGEDEF_REACTOR")[0];
assert_eq!(o.handle(&owner(reactor)).map(|r| r.0), Some("IMAGE"));
}
}
#[test]
fn ucs_layer_states_and_block_descriptions_are_kept() {
let (d, a, b) = saves(&fixture());
let u = d.ucss.iter().find(|u| u.name == "TILTED").expect("UCS TILTED");
assert_eq!((u.origin.x, u.origin.y, u.origin.z), (1.0, 2.0, 3.0));
assert_eq!((u.x_axis.y, u.y_axis.x), (1.0, -1.0));
assert_eq!(d.block("DOOR").map(|b| b.description.as_str()), Some("A door leaf"));
for o in [&a, &b] {
// Layer states live in the LAYER table's extension dictionary.
let table = o.of("TABLE").into_iter().find(|t| s(t, 2) == "LAYER").expect("LAYER table");
let [xd] = group(table, "ACAD_XDICTIONARY").try_into().expect("LAYER table extension dictionary");
let (_, dict) = o.handle(&xd).expect("dictionary");
let (_, states) = o.entry(dict, "ACAD_LAYERSTATES").expect("ACAD_LAYERSTATES");
let (_, plan) = o.entry(states, "Plan").expect("layer state Plan");
assert_eq!((s(plan, 301), s(plan, 8)), ("Plan".into(), "WALLS".into()));
}
}
#[test]
fn true_colours_and_colour_books_are_kept() {
let (_, a, b) = saves(&fixture());
for o in [&a, &b] {
let line = o.handle("2A").expect("line").1;
assert_eq!((s(line, 420), s(line, 430)), ("16711680".into(), "RAL$RAL 3000".into()));
let walls = o.named("LAYER", "WALLS");
assert_eq!((s(walls, 420), s(walls, 430)), ("13382451".into(), "RAL$RAL 3020".into()));
}
}
#[test]
fn transparency_is_kept() {
let (_, a, b) = saves(&fixture());
for o in [&a, &b] {
assert_eq!(s(o.handle("2A").expect("line").1, 440), "33554559");
}
}
#[test]
fn hyperlinks_are_kept() {
let (_, a, b) = saves(&fixture());
for o in [&a, &b] {
let url = xdata(o.handle("2A").expect("line").1, "PE_URL");
let want: Vec<(i32, String)> =
[(1000, "https://example.com/spec"), (1002, "{"), (1000, "Specification"), (1002, "{"), (1071, "0"), (1002, "}"), (1002, "}")]
.iter()
.map(|(c, v)| (*c, v.to_string()))
.collect();
assert_eq!(url, want);
}
}
#[test]
fn dictionary_variables_xrecords_and_persistent_reactors_are_kept() {
let (_, a, b) = saves(&fixture());
for o in [&a, &b] {
let (_, vars) = o.entry(o.root(), "AcDbVariableDictionary").expect("variable dictionary");
let (kind, var) = o.entry(vars, "ACMEVAR").expect("ACMEVAR");
assert_eq!((kind, s(var, 1)), ("DICTIONARYVAR", "var-value".into()));
// The line reacts to the ACME object, and the object to its dictionary and the line.
let line = o.handle("2A").expect("line").1;
let reactors = group(line, "ACAD_REACTORS");
assert!(reactors.iter().any(|r| o.handle(r).is_some_and(|x| x.0 == "ACME_OBJECT")), "line reactors {reactors:?}");
let (_, nested) = o.entry(o.root(), "ACME_NESTED").expect("ACME_NESTED");
let (_, obj) = o.entry(nested, "INNER").expect("INNER");
assert_eq!(group(obj, "ACAD_REACTORS"), vec![h(nested), "2A".to_string()]);
assert_eq!(o.handle(&s(obj, 340)).map(|r| r.0), Some("LINE"));
}
}
#[test]
fn every_reference_names_a_written_object() {
let (_, a, b) = saves(&fixture());
assert_eq!(a.dangling(), Vec::<String>::new());
assert_eq!(b.dangling(), Vec::<String>::new());
}
#[test]
fn saving_twice_writes_the_same_records() {
let (_, a, b) = saves(&fixture());
assert_eq!(a.counts(), b.counts());
}
#[test]
fn class_records_have_instance_counts_for_generated_and_preserved_records() {
let (_, a, b) = saves(&fixture());
for out in [&a, &b] {
let counts = out.counts();
for class in out.in_section("CLASSES", "CLASS") {
let name = s(class, 1);
let instance_count = class.iter().find(|t| t.code == 91).map(Tag::i64);
assert_eq!(instance_count, counts.get(&name).map(|n| *n as i64), "CLASS {name}");
}
assert!(out.in_section("CLASSES", "CLASS").iter().any(|c| s(c, 1) == "ACME_OBJECT"));
assert_eq!(counts.get("ACME_OBJECT"), Some(&1));
}
}
#[test]
fn erased_entities_take_their_extension_data_with_them() {
let mut d = read_dxf(fixture().as_bytes()).expect("read");
let line = cadcraft_doc::Handle::parse_hex("2A").expect("handle");
d.remove_entity(line).expect("erase the line");
let o = Out::new(&write_dxf(&d));
assert!(o.handle("2A").is_none());
// Its extension dictionary is gone; the object that reacted to it no longer names it.
assert!(o.of("XRECORD").iter().all(|x| s(x, 1) != "entity-xdict-data"));
assert_eq!(o.dangling(), Vec::<String>::new());
let (_, nested) = o.entry(o.root(), "ACME_NESTED").expect("ACME_NESTED");
let (_, obj) = o.entry(nested, "INNER").expect("INNER");
assert_eq!(s(obj, 340), "0", "a pointer to an erased entity becomes null");
}
#[test]
fn a_new_colour_drops_the_colour_book_name() {
let mut d = read_dxf(fixture().as_bytes()).expect("read");
let line = cadcraft_doc::Handle::parse_hex("2A").expect("handle");
d.modify_entity(line, |e| e.common.color = cadcraft_color::Color::Index(3)).expect("recolour");
if let Some(l) = d.layer_mut("WALLS") {
l.color = cadcraft_color::Color::Index(5);
}
let o = Out::new(&write_dxf(&d));
assert_eq!(s(o.handle("2A").expect("line").1, 430), "");
assert_eq!(s(o.named("LAYER", "WALLS"), 430), "");
}
/// Hostile variants of the fixture: ownership cycles, references to nothing, oversized and
/// duplicated data. Reading and saving them never panics and writes only valid references.
#[test]
fn hostile_raw_data_never_panics() {
let base = fixture();
let variants: Vec<String> = vec![
// An object that owns itself, and two that own each other.
base.replace("5\n42\n102\n{ACAD_REACTORS\n330\n41\n", "5\n42\n102\n{ACAD_REACTORS\n330\n42\n")
.replace("5\n2D\n330\n2B\n", "5\n2D\n330\n2D\n")
.replace("5\n2B\n330\n2A\n", "5\n2B\n330\n2D\n"),
// References to handles that don't exist.
base.replace("340\n2A\n341\n11\n", "340\nFFFFF\n341\nZZ\n").replace("360\n2B\n", "360\nDEAD\n"),
// Duplicate handles: an object with an entity's handle, an entity with an object's.
base.replace("5\n40\n", "5\n2A\n").replace("5\n30\n", "5\n42\n"),
// Unterminated 102 groups and stray xdata.
base.replace("102\n}\n330\n1F\n", "330\n1F\n").replace("1001\nACME\n1000\nhello\n", "1000\nstray\n1001\n\n"),
// Huge numbers of xdata groups and dictionary entries.
base.replace("1000\nhello\n", &"1000\nhello\n".repeat(20_000)).replace("3\nINNER\n350\n42\n", &"3\nINNER\n350\n42\n".repeat(5_000)),
// A handle seed below every handle, non-hex handles and absurdly large ones.
base.replace("$HANDSEED\n5\n100\n", "$HANDSEED\n5\n1\n").replace("5\n44\n", "5\n-44\n").replace("5\n66\n", "5\nFFFFFFFFFFFFFFFF\n"),
];
for (i, v) in variants.iter().enumerate() {
assert_ne!(v, &base, "variant {i} changes the fixture");
let Ok(d) = read_dxf(v.as_bytes()) else { continue };
let first = write_dxf(&d);
let o = Out::new(&first);
assert_eq!(o.dangling(), Vec::<String>::new(), "variant {i}");
let again = read_dxf(first.as_bytes()).expect("reopen");
let _ = write_dxf(&again);
}
}
/// Through the DWG bridge (acadrust): what the DWG writer and reader keep of the same data.
/// Entities and objects of custom classes, xdata on table records and blocks, entities' colour
/// book names and reactors are not kept there (acadrust gaps); the rest is.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dwg_keeps_extension_dictionaries_xrecords_and_entity_xdata() {
let via_dwg = |d: &Drawing| cadcraft_io::read(&cadcraft_io::write(d, "x.dwg").expect("write DWG"), "x.dwg").expect("read DWG");
let d = via_dwg(&via_dwg(&read_dxf(fixture().as_bytes()).expect("read fixture")));
let o = Out::new(&write_dxf(&d));
assert_eq!(o.dangling(), Vec::<String>::new());
let line = o.handle("2A").expect("line").1;
assert_eq!(xdata(line, "ACME").get(..2), Some(&[(1000, "hello".to_string()), (1070, "42".to_string())][..]));
assert_eq!(xdata(line, "PE_URL").first(), Some(&(1000, "https://example.com/spec".to_string())));
assert_eq!(s(line, 440), "33554559");
let [xd] = group(line, "ACAD_XDICTIONARY").try_into().expect("the line's extension dictionary");
let (_, dict) = o.handle(&xd).expect("dictionary");
assert_eq!(o.entry(dict, "ACME_XREC").map(|x| s(x.1, 1)), Some("entity-xdict-data".into()));
assert_eq!(o.entry(o.root(), "ACME_DATA").map(|x| s(x.1, 1)), Some("top-level-xrecord".into()));
let (_, vars) = o.entry(o.root(), "AcDbVariableDictionary").expect("variable dictionary");
assert_eq!(o.entry(vars, "ACMEVAR").map(|x| (x.0, s(x.1, 1))), Some(("DICTIONARYVAR", "var-value".into())));
let table = o.of("TABLE").into_iter().find(|t| s(t, 2) == "LAYER").expect("LAYER table");
assert_eq!(group(table, "ACAD_XDICTIONARY").len(), 1, "layer states");
assert_eq!(s(o.named("LAYER", "WALLS"), 430), "RAL$RAL 3020");
assert_eq!(d.block("DOOR").map(|b| b.description.as_str()), Some("A door leaf"));
assert!(d.ucss.iter().any(|u| u.name == "TILTED"));
}