diff --git a/examples/build_fixtures.rs b/examples/build_fixtures.rs index cc510ea2..40cd99de 100644 --- a/examples/build_fixtures.rs +++ b/examples/build_fixtures.rs @@ -32,10 +32,24 @@ //! //! These fixtures exercise **our writer against our reader**. They are //! NOT proof that AutoCAD, BricsCAD, or LibreCAD accept the bytes — see -//! the acceptance statement in `src/file_writer.rs`. They also cover only -//! the four entity types that have [`ElementEncoder`] implementations -//! (LINE, CIRCLE, ARC, POINT); the corpus should grow as more encoders -//! land. +//! the acceptance statement in `src/file_writer.rs`. They cover the four +//! entity types that have [`ElementEncoder`] implementations (LINE, +//! CIRCLE, ARC, POINT) and three block definitions, each a `BLOCK_HEADER` +//! table entry plus the `BLOCK` sentinel entity it names (#78). Those two +//! kinds have no [`ElementEncoder`]; this example writes them directly, +//! inverting `tables::block_record` and `entities::block` field for field +//! (see [`block_header_body`] and [`block_body`]). +//! +//! # The block-name gate (#70, #78) +//! +//! A block's name is stored twice: on its `BLOCK_HEADER`, which holds only +//! a stem for AutoCAD's generated names, and on its `BLOCK` sentinel, which +//! holds the full name. [`BLOCKS`] pairs a plain name with two anonymous +//! blocks whose headers both store `*D` and whose sentinels read `*D1` and +//! `*D2`, so `dwg::graph::resolve_block_names` has a join to do on every +//! band the writer emits. `tests/block_names.rs` runs that gate on these +//! files under a plain `cargo test`; it used to run only where the public +//! corpus was present. //! //! Two container families are emitted. The R2004 family goes through //! [`assemble_dwg_bytes`] (page map + section info + LZ77). R2000 goes @@ -68,10 +82,28 @@ use std::process::ExitCode; /// Object type codes (spec §19.4 table). Only the four types with an /// [`ElementEncoder`] implementation are emitted. +const TYPE_BLOCK: u16 = 0x04; const TYPE_ARC: u16 = 0x11; const TYPE_CIRCLE: u16 = 0x12; const TYPE_LINE: u16 = 0x13; const TYPE_POINT: u16 = 0x1B; +const TYPE_BLOCK_HEADER: u16 = 0x31; + +/// The BLOCK_CONTROL handle every `BLOCK_HEADER` names as its owner. The +/// fixtures carry no block control object, so the soft pointer dangles, +/// which a soft pointer may. +const BLOCK_CONTROL_HANDLE: u64 = 0x01; + +/// The block definitions the fixtures carry, as +/// `(BLOCK_HEADER handle, name the header stores, BLOCK handle, name the +/// BLOCK sentinel stores)`. Two anonymous blocks store the same stem `*D` +/// and are told apart only by their sentinels, as eight records of +/// `sample_AC1032.dwg` are. +const BLOCKS: [(u64, &str, u64, &str); 3] = [ + (0x30, "FixtureBlock", 0x31, "FixtureBlock"), + (0x32, "*D", 0x33, "*D1"), + (0x34, "*D", 0x35, "*D2"), +]; /// R2018 `AcDb:AcDbObjects` streams open with an undocumented RL that /// the walker skips (spec note: "starts with a RL value of 0x0dca"). @@ -137,9 +169,35 @@ fn write_common_entity_preamble(w: &mut BitWriter, version: Version) { w.write_rc(0); } +/// What follows a record's data fields: the strings of its R2007+ string +/// stream and the references of its handle stream. Before R2007 the +/// strings are inline in the data fields and `strings` stays empty. +struct Tail<'a> { + strings: &'a [&'a str], + handles: &'a [(u8, u64)], +} + +/// No string stream, no handle stream: the entities' tail. +const NO_TAIL: Tail<'static> = Tail { + strings: &[], + handles: &[], +}; + +/// Write one `TU` of an R2007+ string stream: a `BS` unit count and the +/// UTF-16LE units, with no terminator (the 12-unit `*Paper_Space` of a real +/// BLOCK_HEADER takes exactly 10 + 192 bits). +fn write_string_stream_tv(w: &mut BitWriter, s: &str) { + let units: Vec = s.encode_utf16().collect(); + w.write_bs_u(units.len() as u16); + for unit in units { + w.write_rc((unit & 0xFF) as u8); + w.write_rc((unit >> 8) as u8); + } +} + /// Build one `AcDb:AcDbObjects` record: `MS size` + payload + 2-byte CRC, /// where the payload is the object header (R2010+ handle-stream MC, -/// object type, handle), the common entity preamble, and the entity body. +/// object type, handle), what `body` writes, and the `tail`. /// /// Mirrors `dwg::object::ObjectWalker::read_one_at_pos` field-for-field. /// The CRC is little-endian, seeded 0xC0C1, over the `MS` bytes and the @@ -152,18 +210,25 @@ fn write_common_entity_preamble(w: &mut BitWriter, version: Version) { /// /// * the record's `MS` **excludes its own leading `MC`** handle-stream- /// size field, so the emitted `MS` is `payload.len() - mc_width`; -/// * §19.1 ends the data area with a `B` *strings present* flag, and the -/// handle stream starts on the next bit. These fixtures carry no -/// handle references, so the byte padding after that flag stands in -/// for the handle stream and the `MC` records its width — the same -/// convention `string_stream::tests::build_payload` uses. Declaring a -/// zero-bit handle stream instead put the data boundary past the end -/// of the record, which suppressed every decoder's boundary check. -fn build_object_record( +/// * §19.1 ends the data area with the string stream, if any, then a `B` +/// *strings present* flag, and the handle stream starts on the next bit. +/// The `MC` records the handle stream's width in bits, and the byte +/// padding at the end of the record counts as part of that stream, so +/// `payload bits - MC` lands on the trailer. A record with no handle +/// references has only the padding, which is how the entities were +/// written before the blocks. Declaring a zero-bit handle stream +/// instead put the data boundary past the end of the record, which +/// suppressed every decoder's boundary check. +/// +/// Before R2010 the `RL` object-data size in bits (R2000-R2007, §19.1) +/// marks where the handle stream starts, and the handle stream runs to the +/// end of the record. +fn build_record( type_code: u16, handle: u64, version: Version, - entity: &E, + body: &dyn Fn(&mut BitWriter), + tail: &Tail, ) -> Vec { // The R2000-R2007 `RL` object-data-size-in-bits can only be known // once the body is written, and it is a fixed-width field, so the @@ -172,11 +237,6 @@ fn build_object_record( // number of bits. let emit = |obj_size_bits: u32| -> BitWriter { let mut w = BitWriter::new(); - // R2010+: MC handle-stream-size-in-bits. We emit no handle stream, - // so the value is 0 — a single 0x00 byte with no continuation bit. - if version.is_r2010_plus() { - w.write_rc(0x00); - } // Object type: R2010+ uses a 2-bit dispatch tag (00 = one raw byte // follows); earlier R2004-family releases use a plain BS. if version.is_r2010_plus() { @@ -192,28 +252,50 @@ fn build_object_record( } // Object handle: 4-bit code + 4-bit counter + counter bytes. w.write_handle(0, handle); - write_common_entity_preamble(&mut w, version); - entity - .encode(&mut w, version) - .expect("ElementEncoder impls for LINE/CIRCLE/ARC/POINT are infallible"); + body(&mut w); w }; let obj_size_bits = emit(0).position_bits() as u32; let mut w = emit(obj_size_bits); - let mc_width = if version.is_r2010_plus() { - // §19.1 "strings present" flag, clear: these fixtures write no - // string stream. + if version.is_r2010_plus() { + if tail.strings.is_empty() { + // §19.1 "strings present" flag, clear. + w.write_b(false); + } else { + let mut strings = BitWriter::new(); + for s in tail.strings { + write_string_stream_tv(&mut strings, s); + } + let string_bits = strings.position_bits(); + for s in tail.strings { + write_string_stream_tv(&mut w, s); + } + // The string-stream size, then the flag, set. + w.write_rs(string_bits as i16); + w.write_b(true); + } + } + let handles_at = w.position_bits(); + for (code, value) in tail.handles { + w.write_handle(*code, *value); + } + let handle_bits = w.position_bits() - handles_at; + let pad = (8 - w.position_bits() % 8) % 8; + for _ in 0..pad { w.write_b(false); - 1 + } + let rest = w.into_bytes(); + let (payload, mc_width) = if version.is_r2010_plus() { + let mut mc = BitWriter::new(); + mc.write_mc((handle_bits + pad) as i64) + .expect("an MC of a handle stream's width encodes"); + let mut bytes = mc.into_bytes(); + let width = bytes.len(); + bytes.extend_from_slice(&rest); + (bytes, width) } else { - 0 + (rest, 0) }; - let pad = (8 - w.position_bits() % 8) % 8; - let mut payload = w.into_bytes(); - if version.is_r2010_plus() { - // The handle stream is the byte padding that follows the flag. - payload[0] = pad as u8; - } let mut header = BitWriter::new(); header.write_ms((payload.len() - mc_width) as u64); @@ -225,6 +307,164 @@ fn build_object_record( record } +/// Build the record of one entity that has an [`ElementEncoder`]: the +/// common entity preamble and the entity body, no string or handle stream. +fn build_object_record( + type_code: u16, + handle: u64, + version: Version, + entity: &E, +) -> Vec { + build_record( + type_code, + handle, + version, + &|w| { + write_common_entity_preamble(w, version); + entity + .encode(w, version) + .expect("ElementEncoder impls for LINE/CIRCLE/ARC/POINT are infallible"); + }, + &NO_TAIL, + ) +} + +/// Write one pre-R2007 `TV`: a `BS` byte count that includes the NUL AutoCAD +/// terminates names with, the bytes, the NUL. An empty string is `BS` 0. +fn write_tv_inline(w: &mut BitWriter, s: &str) { + if s.is_empty() { + w.write_bs_u(0); + return; + } + w.write_bs_u(s.len() as u16 + 1); + for b in s.bytes() { + w.write_rc(b); + } + w.write_rc(0); +} + +/// Write the common non-entity object data (§20.1) of an object with no +/// extended data, no reactors and no extension dictionary — the inverse of +/// `dwg::common_entity::read_common_object_data_full` for R2000 and later. +fn write_common_object_data(w: &mut BitWriter, version: Version) { + // Extended entity data: a zero-length record terminates the loop. + w.write_bs_u(0); + // num_reactors = 0. + w.write_bl(0); + // R2004+: no xdictionary handle. + if version.is_r2004_plus() { + w.write_b(true); + } + // R2013+: no DS binary data chain. + if matches!(version, Version::R2013 | Version::R2018) { + w.write_b(false); + } +} + +/// The data fields of a `BLOCK_HEADER` (§20.4.52), the inverse of +/// `tables::block_record::decode` (R2000, R2004) and +/// `decode_modern_split_stream` (R2007+). The three `TV`s — name, xref path +/// and description — are inline before R2007 and in the string stream from +/// it, where [`block_header_tail`] writes them. +fn block_header_body(w: &mut BitWriter, version: Version, name: &str, anonymous: bool) { + write_common_object_data(w, version); + if version.is_r2007_plus() { + // B 64-flag (set on every entry of the corpus), B Xdep. + w.write_b(true); + w.write_b(false); + } else { + write_tv_inline(w, name); + // B 64-flag, BS xrefindex+1, B Xdep. + w.write_b(true); + w.write_bs(0); + w.write_b(false); + } + w.write_b(anonymous); + w.write_b(false); // has attribute definitions + w.write_b(false); // is an xref + w.write_b(false); // xref overlay + w.write_b(false); // loaded xref + if version.is_r2004_plus() { + w.write_bl(0); // owned objects: the block holds none + } + for _ in 0..3 { + w.write_bd(0.0); // base point + } + if !version.is_r2007_plus() { + write_tv_inline(w, ""); // xref path + } + w.write_rc(0); // insert count run, terminated + if !version.is_r2007_plus() { + write_tv_inline(w, ""); // description + } + w.write_bl_u(0); // preview data: none + if version.is_r2007_plus() { + w.write_bs(0); // insert units + w.write_b(true); // explodable + w.write_rc(1); // block scaling + } +} + +/// The handle stream of a `BLOCK_HEADER` (§20.4.52): the block control +/// object (soft owner), a NULL hard pointer, then the `BLOCK` entity. The +/// NULL is the anchor `tables::block_record::block_sentinel_handle` scans +/// for. The record carries no reactors and no extension dictionary, and +/// the references after the `BLOCK` (its entities, `ENDBLK`, layout) are +/// left out: the fixtures hold neither. +fn block_header_handles(block: u64) -> [(u8, u64); 3] { + [(4, BLOCK_CONTROL_HANDLE), (5, 0), (3, block)] +} + +/// The data fields of a `BLOCK` sentinel (§20.4.6): the common entity +/// preamble and, before R2007, the one inline `TV`; from R2007 the name is +/// in the string stream and the data fields end after the preamble. +fn block_body(w: &mut BitWriter, version: Version, name: &str) { + write_common_entity_preamble(w, version); + if !version.is_r2007_plus() { + write_tv_inline(w, name); + } +} + +/// The string stream of a `BLOCK_HEADER` or `BLOCK` on R2007+, empty before. +fn block_strings<'a>(version: Version, all: &'a [&'a str]) -> &'a [&'a str] { + if version.is_r2007_plus() { all } else { &[] } +} + +/// The `BLOCK_HEADER` and `BLOCK` records of [`BLOCKS`]. +fn build_block_records(version: Version) -> Vec<(u64, Vec)> { + let mut out = Vec::with_capacity(BLOCKS.len() * 2); + for (header, stem, block, name) in BLOCKS { + let handles = block_header_handles(block); + out.push(( + header, + build_record( + TYPE_BLOCK_HEADER, + header, + version, + &|w| block_header_body(w, version, stem, stem.starts_with('*')), + &Tail { + strings: block_strings(version, &[stem, "", ""]), + handles: &handles, + }, + ), + )); + out.push(( + block, + build_record( + TYPE_BLOCK, + block, + version, + &|w| block_body(w, version, name), + &Tail { + strings: block_strings(version, &[name]), + handles: &[], + }, + ), + )); + } + out +} + /// The fixture's entity set — one of each encodable type, with values /// chosen so a mis-aligned decode produces visibly wrong numbers rather /// than plausible zeros. @@ -301,7 +541,7 @@ fn fixture_entities() -> (Line, Line, Circle, Arc, Point) { /// The `(handle, record)` list both container assemblers share. fn build_object_records(version: Version) -> Vec<(u64, Vec)> { let (line_2d, line_3d, circle, arc, point) = fixture_entities(); - vec![ + let mut records = vec![ ( 0x20, build_object_record(TYPE_LINE, 0x20, version, &line_2d), @@ -316,7 +556,9 @@ fn build_object_records(version: Version) -> Vec<(u64, Vec)> { ), (0x23, build_object_record(TYPE_ARC, 0x23, version, &arc)), (0x24, build_object_record(TYPE_POINT, 0x24, version, &point)), - ] + ]; + records.extend(build_block_records(version)); + records } /// Build the R2004-family `AcDb:AcDbObjects` section: the `0x0dca` diff --git a/tests/block_names.rs b/tests/block_names.rs index c37a6078..c98686f2 100644 --- a/tests/block_names.rs +++ b/tests/block_names.rs @@ -16,7 +16,8 @@ //! `*Paper_Space`, so the sentinel is the authority. //! //! [`dwg::graph::resolve_block_names`] performs that join; these tests -//! pin it. They skip when the sample corpus is absent. +//! pin it. The corpus tests skip when the sample corpus is absent; the +//! test on the committed canonical fixtures never does. use dwg::entities::DecodedEntity; use dwg::{DwgFile, ObjectType}; @@ -38,11 +39,112 @@ fn open_if_present(name: &str) -> Option { Some(DwgFile::open(&p).unwrap_or_else(|e| panic!("{name} open failed: {e}"))) } -/// Every R2007+ file that carries blocks: the resolved name of each +/// The block-name gate on one drawing: the resolved name of each /// BLOCK_HEADER must equal the name its `BLOCK` sentinel carries, the -/// resolved names must be unique (a symbol table cannot hold two -/// entries under one name), and the header's own stored string must be -/// the resolved name minus a decimal suffix. +/// resolved names must be unique (a symbol table cannot hold two entries +/// under one name), and the header's own stored string must be the +/// resolved name minus a decimal suffix. Returns the resolved names, in +/// handle order. +fn check_block_names(name: &str, file: &DwgFile) -> Vec { + let version = file.version(); + let objects = file + .all_objects() + .unwrap_or_else(|| panic!("{name}: no handle-map-driven walk")) + .unwrap_or_else(|e| panic!("{name}: walk failed: {e}")); + + let mut sentinels: BTreeMap = BTreeMap::new(); + for object in &objects { + if object.kind != ObjectType::Block { + continue; + } + match dwg::entities::decode_from_raw(object, version) { + DecodedEntity::Block(b) => { + sentinels.insert(object.handle.value, b.name); + } + other => panic!("{name}: BLOCK {} decoded as {other:?}", object.handle.value), + } + } + + let headers: Vec<_> = objects + .iter() + .filter(|o| o.kind == ObjectType::BlockHeader) + .collect(); + assert!( + !headers.is_empty(), + "{name}: expected at least one BLOCK_HEADER" + ); + assert_eq!( + headers.len(), + sentinels.len(), + "{name}: one BLOCK sentinel per BLOCK_HEADER" + ); + + let resolved = dwg::graph::resolve_block_names(&objects, version); + assert_eq!( + resolved.len(), + headers.len(), + "{name}: resolve_block_names must cover every BLOCK_HEADER" + ); + + let mut seen: BTreeMap<&str, u64> = BTreeMap::new(); + for header in &headers { + let DecodedEntity::BlockRecord(record) = dwg::entities::decode_from_raw(header, version) + else { + panic!( + "{name}: BLOCK_HEADER {} did not decode", + header.handle.value + ); + }; + let sentinel = dwg::tables::block_record::block_sentinel_handle_of(header, version) + .unwrap_or_else(|| { + panic!( + "{name}: BLOCK_HEADER {} names no BLOCK sentinel", + header.handle.value + ) + }); + let sentinel_name = sentinels.get(&sentinel).unwrap_or_else(|| { + panic!( + "{name}: BLOCK_HEADER {} points at {sentinel}, which is not a BLOCK", + header.handle.value + ) + }); + + // The join must reproduce the sentinel's name exactly. + let full = &resolved[&header.handle.value]; + assert_eq!( + full, sentinel_name, + "{name}: BLOCK_HEADER {} resolved to {full:?}, sentinel says {sentinel_name:?}", + header.handle.value + ); + + // The record's own string is that name minus a decimal + // suffix — never a different string. + let stem = &record.header.name; + let suffix = full.strip_prefix(stem.as_str()).unwrap_or_else(|| { + panic!( + "{name}: BLOCK_HEADER {} stores {stem:?}, which is not a prefix of {full:?}", + header.handle.value + ) + }); + assert!( + suffix.chars().all(|c| c.is_ascii_digit()), + "{name}: BLOCK_HEADER {} stem {stem:?} differs from {full:?} by {suffix:?}, \ + which is not a decimal suffix", + header.handle.value + ); + + if let Some(other) = seen.insert(full.as_str(), header.handle.value) { + panic!( + "{name}: block name {full:?} resolved for both {other} and {}", + header.handle.value + ); + } + } + resolved.into_values().collect() +} + +/// [`check_block_names`] on every corpus drawing that carries blocks +/// (R2000 to R2018). Skips a drawing the sample corpus does not have. #[test] fn resolved_block_names_agree_with_their_block_sentinels() { for name in [ @@ -64,101 +166,7 @@ fn resolved_block_names_agree_with_their_block_sentinels() { let Some(file) = open_if_present(name) else { continue; }; - let version = file.version(); - let objects = file - .all_objects() - .unwrap_or_else(|| panic!("{name}: no handle-map-driven walk")) - .unwrap_or_else(|e| panic!("{name}: walk failed: {e}")); - - let mut sentinels: BTreeMap = BTreeMap::new(); - for object in &objects { - if object.kind != ObjectType::Block { - continue; - } - match dwg::entities::decode_from_raw(object, version) { - DecodedEntity::Block(b) => { - sentinels.insert(object.handle.value, b.name); - } - other => panic!("{name}: BLOCK {} decoded as {other:?}", object.handle.value), - } - } - - let headers: Vec<_> = objects - .iter() - .filter(|o| o.kind == ObjectType::BlockHeader) - .collect(); - assert!( - !headers.is_empty(), - "{name}: expected at least one BLOCK_HEADER" - ); - assert_eq!( - headers.len(), - sentinels.len(), - "{name}: one BLOCK sentinel per BLOCK_HEADER" - ); - - let resolved = dwg::graph::resolve_block_names(&objects, version); - assert_eq!( - resolved.len(), - headers.len(), - "{name}: resolve_block_names must cover every BLOCK_HEADER" - ); - - let mut seen: BTreeMap<&str, u64> = BTreeMap::new(); - for header in &headers { - let DecodedEntity::BlockRecord(record) = - dwg::entities::decode_from_raw(header, version) - else { - panic!( - "{name}: BLOCK_HEADER {} did not decode", - header.handle.value - ); - }; - let sentinel = dwg::tables::block_record::block_sentinel_handle_of(header, version) - .unwrap_or_else(|| { - panic!( - "{name}: BLOCK_HEADER {} names no BLOCK sentinel", - header.handle.value - ) - }); - let sentinel_name = sentinels.get(&sentinel).unwrap_or_else(|| { - panic!( - "{name}: BLOCK_HEADER {} points at {sentinel}, which is not a BLOCK", - header.handle.value - ) - }); - - // The join must reproduce the sentinel's name exactly. - let full = &resolved[&header.handle.value]; - assert_eq!( - full, sentinel_name, - "{name}: BLOCK_HEADER {} resolved to {full:?}, sentinel says {sentinel_name:?}", - header.handle.value - ); - - // The record's own string is that name minus a decimal - // suffix — never a different string. - let stem = &record.header.name; - let suffix = full.strip_prefix(stem.as_str()).unwrap_or_else(|| { - panic!( - "{name}: BLOCK_HEADER {} stores {stem:?}, which is not a prefix of {full:?}", - header.handle.value - ) - }); - assert!( - suffix.chars().all(|c| c.is_ascii_digit()), - "{name}: BLOCK_HEADER {} stem {stem:?} differs from {full:?} by {suffix:?}, \ - which is not a decimal suffix", - header.handle.value - ); - - if let Some(other) = seen.insert(full.as_str(), header.handle.value) { - panic!( - "{name}: block name {full:?} resolved for both {other} and {}", - header.handle.value - ); - } - } + check_block_names(name, &file); } } @@ -265,12 +273,15 @@ fn r14_block_names_stay_unresolved_for_want_of_a_handle_stream() { assert_eq!(names, ["*MODEL_SPACE", "*PAPER_SPACE", "*PAPER_SPACE"]); } -/// The committed canonical corpus carries no block definitions, so the -/// gate above cannot run there. Pin that fact: the moment -/// `examples/build_fixtures.rs` grows a BLOCK_HEADER, this test fails -/// and the fixture path must be added to the gate. +/// The same gate on the committed canonical fixtures, which +/// `examples/build_fixtures.rs` writes with three block definitions each +/// (#78): `FixtureBlock`, whose header and sentinel agree, and two +/// anonymous blocks whose headers both store the stem `*D` and whose +/// sentinels read `*D1` and `*D2`. Unlike the corpus tests above, this one +/// never skips: the fixtures are in the repository, so the join runs under +/// every `cargo test` on every platform, on every band the writer emits. #[test] -fn canonical_fixtures_carry_no_block_definitions() { +fn canonical_fixtures_block_names_agree_with_their_sentinels() { let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/canonical"); for name in [ "synthetic_2000.dwg", @@ -280,15 +291,11 @@ fn canonical_fixtures_carry_no_block_definitions() { "synthetic_2018.dwg", ] { let file = DwgFile::open(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")); - let objects = file.all_objects().expect("walk").expect("walk"); - let blocks = objects - .iter() - .filter(|o| matches!(o.kind, ObjectType::BlockHeader | ObjectType::Block)) - .count(); + let resolved = check_block_names(name, &file); assert_eq!( - blocks, 0, - "{name} now carries block records — add it to \ - resolved_block_names_agree_with_their_block_sentinels" + resolved, + ["FixtureBlock", "*D1", "*D2"], + "{name}: resolved block names" ); } } diff --git a/tests/canonical_corpus.rs b/tests/canonical_corpus.rs index 7c3e8d97..4474a44c 100644 --- a/tests/canonical_corpus.rs +++ b/tests/canonical_corpus.rs @@ -31,6 +31,14 @@ const FIXTURES: &[(&str, Version)] = &[ /// LINE (2D), LINE (3D), CIRCLE, ARC, POINT. const ENTITIES_PER_FIXTURE: usize = 5; +/// Records of the three block definitions it writes after them: a +/// BLOCK_HEADER and a BLOCK sentinel each (#78). `tests/block_names.rs` +/// runs the name-join gate on them. +const BLOCK_RECORDS_PER_FIXTURE: usize = 6; + +/// Every record in a fixture's object stream, and so its handle map. +const RECORDS_PER_FIXTURE: usize = ENTITIES_PER_FIXTURE + BLOCK_RECORDS_PER_FIXTURE; + fn corpus_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/canonical") } @@ -56,8 +64,8 @@ fn canonical_corpus_is_not_empty() { } /// Each fixture opens, reports the version it was generated for, and -/// yields exactly `ENTITIES_PER_FIXTURE` cleanly decoded entities with -/// zero unhandled and zero errored records. +/// yields exactly `RECORDS_PER_FIXTURE` cleanly decoded records (its five +/// entities, then the block records) with zero unhandled and zero errored. #[test] fn every_fixture_decodes_all_entities() { for (name, version) in FIXTURES { @@ -70,8 +78,8 @@ fn every_fixture_decodes_all_entities() { .unwrap_or_else(|| panic!("{name}: decoded_entities returned None (no handle map)")) .unwrap_or_else(|e| panic!("{name}: decoded_entities failed: {e}")); - assert_eq!(entities.len(), ENTITIES_PER_FIXTURE, "{name}: entity count"); - assert_eq!(summary.decoded, ENTITIES_PER_FIXTURE, "{name}: decoded"); + assert_eq!(entities.len(), RECORDS_PER_FIXTURE, "{name}: record count"); + assert_eq!(summary.decoded, RECORDS_PER_FIXTURE, "{name}: decoded"); assert_eq!(summary.unhandled, 0, "{name}: unhandled"); assert_eq!(summary.errored, 0, "{name}: errored — {:?}", summary.errors); assert_eq!(summary.decoded_ratio(), 1.0, "{name}: decoded_ratio"); @@ -162,7 +170,7 @@ fn fixture_sections_are_readable() { .handle_map() .expect("AcDb:Handles present") .expect("handle map parses"); - assert_eq!(map.len(), ENTITIES_PER_FIXTURE, "{name}: handle map size"); + assert_eq!(map.len(), RECORDS_PER_FIXTURE, "{name}: handle map size"); } } diff --git a/tests/fixtures/canonical/README.md b/tests/fixtures/canonical/README.md index 0ca52d9a..fc18d362 100644 --- a/tests/fixtures/canonical/README.md +++ b/tests/fixtures/canonical/README.md @@ -4,17 +4,26 @@ A minimal, self-generated corpus of DWG files that CI runs `coverage_report` against (task #94). Five files, one per DWG version the write path can assemble. -| File | Version | Container | Entities | decoded / skip / err | ratio | +| File | Version | Container | Records | decoded / skip / err | ratio | |-----------------------|----------------|-------------|----------|----------------------|-------| -| `synthetic_2000.dwg` | AC1015 (R2000) | flat §3.2.6 | 5 | 5 / 0 / 0 | 100 % | -| `synthetic_2004.dwg` | AC1018 (R2004) | page map §4 | 5 | 5 / 0 / 0 | 100 % | -| `synthetic_2010.dwg` | AC1024 (R2010) | page map §4 | 5 | 5 / 0 / 0 | 100 % | -| `synthetic_2013.dwg` | AC1027 (R2013) | page map §4 | 5 | 5 / 0 / 0 | 100 % | -| `synthetic_2018.dwg` | AC1032 (R2018) | page map §4 | 5 | 5 / 0 / 0 | 100 % | -| **TOTAL** | | | 25 | 25 / 0 / 0 | 100 % | - -Each file carries the same five entities: a 2D LINE, a 3D LINE with -non-default thickness and extrusion, a CIRCLE, an ARC, and a POINT. +| `synthetic_2000.dwg` | AC1015 (R2000) | flat §3.2.6 | 11 | 11 / 0 / 0 | 100 % | +| `synthetic_2004.dwg` | AC1018 (R2004) | page map §4 | 11 | 11 / 0 / 0 | 100 % | +| `synthetic_2010.dwg` | AC1024 (R2010) | page map §4 | 11 | 11 / 0 / 0 | 100 % | +| `synthetic_2013.dwg` | AC1027 (R2013) | page map §4 | 11 | 11 / 0 / 0 | 100 % | +| `synthetic_2018.dwg` | AC1032 (R2018) | page map §4 | 11 | 11 / 0 / 0 | 100 % | +| **TOTAL** | | | 55 | 55 / 0 / 0 | 100 % | + +Each file carries the same eleven records: five entities (a 2D LINE, a 3D +LINE with non-default thickness and extrusion, a CIRCLE, an ARC, and a +POINT) and three block definitions, a BLOCK_HEADER table entry plus the +BLOCK sentinel it names, six records in all (#78). The blocks are +`FixtureBlock`, whose header and sentinel store the same name, and two +anonymous blocks whose headers both store the stem `*D` and whose +sentinels read `*D1` and `*D2`, so the name join of +`dwg::graph::resolve_block_names` (#70) has work to do on every release the +writer emits. Each header's handle stream holds the block control object +(soft pointer, not in the file), the NULL anchor and its BLOCK; the +references after the BLOCK (entities, ENDBLK, layout) are left out. The R2004-family files carry three sections — `AcDb:Header` (64 zero bytes, placeholder), `AcDb:Handles`, `AcDb:AcDbObjects` — assembled by @@ -87,9 +96,11 @@ the acceptance statement in `src/file_writer.rs`. CI does not run any Autodesk product. Coverage is limited to the four entity types that currently have an -`ElementEncoder` implementation (LINE, CIRCLE, ARC, POINT). Add -fixtures as more encoders land — every new encoder is a chance to widen -this gate. +`ElementEncoder` implementation (LINE, CIRCLE, ARC, POINT) and the +block records, which the generator writes by hand, inverting +`tables::block_record` and `entities::block` (no `ElementEncoder` exists +for them). Add fixtures as more encoders land — every new encoder is a +chance to widen this gate. No R14 or R2007 fixture exists. R14 entity records need the §20.4.1 R13/R14 preamble and the §20.4.21 `3BD` LINE body, which the @@ -112,11 +123,20 @@ corpus)**: threshold stated in the workflow. The tight companion gate is [`tests/canonical_corpus.rs`](../../canonical_corpus.rs), -which runs under `cargo test` and pins the exact per-file entity counts +which runs under `cargo test` and pins the exact per-file record counts *and* decoded field values — so a bit-alignment regression that still "decodes" into garbage coordinates fails there even when the ratio would stay at 100 %. +[`tests/block_names.rs`](../../block_names.rs) runs the block-name gate +(#70) on these five files, in `cargo test` on every platform (`ci.yml`, job +**Test**, step `cargo test --release`, as `canonical_fixtures_block_names_agree_with_their_sentinels`): +each BLOCK_HEADER's +resolved name must equal the name its BLOCK sentinel carries, the resolved +names must be unique, and the header's stored string must be that name +minus a decimal suffix. Until #78 the gate ran only where the public +corpus was present. + A regression that breaks the entity walker, the common-entity preamble, LINE / CIRCLE / ARC / POINT decoding, the section map, or the handle map trips one or both guards. diff --git a/tests/fixtures/canonical/synthetic_2000.dwg b/tests/fixtures/canonical/synthetic_2000.dwg index 2e030f34..dd62181c 100644 Binary files a/tests/fixtures/canonical/synthetic_2000.dwg and b/tests/fixtures/canonical/synthetic_2000.dwg differ diff --git a/tests/fixtures/canonical/synthetic_2004.dwg b/tests/fixtures/canonical/synthetic_2004.dwg index cae5f233..f4d6b778 100644 Binary files a/tests/fixtures/canonical/synthetic_2004.dwg and b/tests/fixtures/canonical/synthetic_2004.dwg differ diff --git a/tests/fixtures/canonical/synthetic_2010.dwg b/tests/fixtures/canonical/synthetic_2010.dwg index 286fb114..de6e03dc 100644 Binary files a/tests/fixtures/canonical/synthetic_2010.dwg and b/tests/fixtures/canonical/synthetic_2010.dwg differ diff --git a/tests/fixtures/canonical/synthetic_2013.dwg b/tests/fixtures/canonical/synthetic_2013.dwg index 52c9e660..e6066e2b 100644 Binary files a/tests/fixtures/canonical/synthetic_2013.dwg and b/tests/fixtures/canonical/synthetic_2013.dwg differ diff --git a/tests/fixtures/canonical/synthetic_2018.dwg b/tests/fixtures/canonical/synthetic_2018.dwg index f542246b..01d20820 100644 Binary files a/tests/fixtures/canonical/synthetic_2018.dwg and b/tests/fixtures/canonical/synthetic_2018.dwg differ diff --git a/tools/streaming/verify_walk.py b/tools/streaming/verify_walk.py index 1b6e081a..7835104c 100644 --- a/tools/streaming/verify_walk.py +++ b/tools/streaming/verify_walk.py @@ -225,7 +225,7 @@ def run(data, *args): result = subprocess.run([walk, str(path), *map(str, args)], capture_output=True, timeout=600) return result.returncode, result.stdout.decode(), result.stderr.decode() - corpus = [('synthetic_2000.dwg', Path('tests/fixtures/canonical/synthetic_2000.dwg').read_bytes(), 5)] + corpus = [('synthetic_2000.dwg', Path('tests/fixtures/canonical/synthetic_2000.dwg').read_bytes(), 11)] for manifest_name in ('walk-corpus.json', 'walk-acadsharp-corpus.json'): manifest = json.loads(Path('tools/streaming', manifest_name).read_text()) for item in manifest['files']: