Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 43 kB View raw
1use std::path::{Path, PathBuf}; 2use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; 3use std::time::{Duration, Instant}; 4 5use bone_document::{Document, EditOutcome, Sketch, SketchEdit, SketchEntity}; 6use bone_interop::{Cancel, CancelFlag, HeaderDefect, StepError, body_of, read, write}; 7use bone_kernel::{ 8 BrepFace, BrepSolid, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, 9 MergeResult, 10}; 11use bone_types::{ 12 Aabb3, DocumentId, ExtrudeId, FaceLabel, FaceRole, Length, Point2, Point3, PositiveLength, 13 SketchEntityId, SketchId, SketchPlaneBasis, StepEntityKind, StepSchema, Tolerance, UnitVec3, 14 millimeter, 15}; 16use slotmap::KeyData; 17 18const TOL: Tolerance = Tolerance::new(1.0e-9); 19const UPDATE_ENV: &str = "BONE_UPDATE_STEP_GOLDENS"; 20 21fn xy_basis() -> SketchPlaneBasis { 22 let Ok(basis) = SketchPlaneBasis::new( 23 Point3::origin(), 24 UnitVec3::x_axis(), 25 UnitVec3::y_axis(), 26 TOL, 27 ) else { 28 panic!("orthonormal axes"); 29 }; 30 basis 31} 32 33fn ffi_key(n: u64) -> KeyData { 34 KeyData::from_ffi((1u64 << 32) | n) 35} 36 37fn sketch_id(n: u64) -> SketchId { 38 SketchId::from(ffi_key(n)) 39} 40 41fn extrude_id(n: u64) -> ExtrudeId { 42 ExtrudeId::from(ffi_key(n)) 43} 44 45fn add_point(sketch: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { 46 let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( 47 SketchEntity::point(Point2::from_mm(x, y)), 48 )) else { 49 panic!("add point"); 50 }; 51 (next, id) 52} 53 54fn add_line(sketch: Sketch, a: SketchEntityId, b: SketchEntityId) -> Sketch { 55 let Ok((next, _)) = sketch.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) else { 56 panic!("add line"); 57 }; 58 next 59} 60 61fn add_circle(sketch: Sketch, center: SketchEntityId, radius_mm: f64) -> Sketch { 62 let Ok((next, _)) = sketch.apply(SketchEdit::AddEntity(SketchEntity::circle( 63 center, 64 Length::new::<millimeter>(radius_mm), 65 false, 66 ))) else { 67 panic!("add circle"); 68 }; 69 next 70} 71 72fn rectangle_sketch() -> Sketch { 73 let (sketch, p0) = add_point(Sketch::new(xy_basis()), 0.0, 0.0); 74 let (sketch, p1) = add_point(sketch, 4.0, 0.0); 75 let (sketch, p2) = add_point(sketch, 4.0, 2.0); 76 let (sketch, p3) = add_point(sketch, 0.0, 2.0); 77 let sketch = add_line(sketch, p0, p1); 78 let sketch = add_line(sketch, p1, p2); 79 let sketch = add_line(sketch, p2, p3); 80 add_line(sketch, p3, p0) 81} 82 83fn circle_sketch(radius_mm: f64) -> Sketch { 84 let (sketch, center) = add_point(Sketch::new(xy_basis()), 0.0, 0.0); 85 add_circle(sketch, center, radius_mm) 86} 87 88fn donut_sketch() -> Sketch { 89 let (sketch, center) = add_point(Sketch::new(xy_basis()), 0.0, 0.0); 90 let sketch = add_circle(sketch, center, 10.0); 91 add_circle(sketch, center, 4.0) 92} 93 94fn blind(sketch: SketchId, depth_mm: f64) -> ExtrudeFeature { 95 let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(depth_mm)) else { 96 panic!("positive depth"); 97 }; 98 ExtrudeFeature { 99 sketch, 100 direction: ExtrudeDirection::Normal { 101 sense: ExtrudeSense::Forward, 102 }, 103 end_condition: ExtrudeEndCondition::Blind { depth }, 104 draft: None, 105 thin_wall: None, 106 merge_result: MergeResult::Merge, 107 } 108} 109 110fn document(name: &str, sketch: Sketch, depth_mm: f64) -> Document { 111 let mut document = Document::new(DocumentId::default(), name.to_owned()); 112 let sketch_key = sketch_id(1); 113 document.insert_sketch(sketch_key, "Sketch1".to_owned(), sketch); 114 document.insert_extrude(extrude_id(1), blind(sketch_key, depth_mm)); 115 document 116} 117 118fn face_labels(solid: &BrepSolid) -> Vec<FaceLabel> { 119 solid.iter_faces().map(BrepFace::label).collect() 120} 121 122fn is_dumb(solid: &BrepSolid) -> bool { 123 solid 124 .iter_faces() 125 .all(|face| matches!(face.label().role, FaceRole::Imported { .. })) 126} 127 128fn write_to_temp(document: &Document, dir: &Path, name: &str) -> PathBuf { 129 let path = dir.join(name); 130 let Ok(()) = write(document, &path, StepSchema::Ap214, CancelFlag::never()) else { 131 panic!("write step"); 132 }; 133 path 134} 135 136fn check_step_golden(text: &str, golden_rel: &str) { 137 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(golden_rel); 138 if std::env::var(UPDATE_ENV).is_ok() { 139 if let Some(parent) = path.parent() { 140 let Ok(()) = std::fs::create_dir_all(parent) else { 141 panic!("create goldens dir"); 142 }; 143 } 144 let Ok(()) = std::fs::write(&path, text.as_bytes()) else { 145 panic!("write golden {}", path.display()); 146 }; 147 return; 148 } 149 let Ok(golden) = std::fs::read_to_string(&path) else { 150 panic!( 151 "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate", 152 path.display() 153 ); 154 }; 155 assert_eq!(text, golden, "step output drifted from golden {golden_rel}"); 156} 157 158fn matches_golden(name: &str, sketch: Sketch, depth_mm: f64) { 159 let document = document(name, sketch, depth_mm); 160 let Ok(dir) = tempfile::tempdir() else { 161 panic!("temp dir"); 162 }; 163 let path = write_to_temp(&document, dir.path(), &format!("{name}.step")); 164 let Ok(text) = std::fs::read_to_string(&path) else { 165 panic!("read back"); 166 }; 167 check_step_golden(&text, &format!("tests/goldens/{name}.step")); 168} 169 170#[test] 171fn cube_step_matches_golden() { 172 matches_golden("cube", rectangle_sketch(), 4.0); 173} 174 175#[test] 176fn cylinder_step_matches_golden() { 177 matches_golden("cylinder", circle_sketch(5.0), 10.0); 178} 179 180#[test] 181fn donut_step_matches_golden() { 182 matches_golden("donut", donut_sketch(), 6.0); 183} 184 185fn evaluates_within_bounds(seed: &Document, min_mm: [f64; 3], max_mm: [f64; 3], label: &str) { 186 let Ok(solid) = body_of(seed) else { 187 panic!("{label} evaluates to one body"); 188 }; 189 assert_bounds(&solid_bounds(&solid), min_mm, max_mm, label); 190} 191 192#[test] 193fn cylinder_evaluates_within_bounds() { 194 evaluates_within_bounds( 195 &document("cylinder", circle_sketch(5.0), 8.0), 196 [-5.0, -5.0, 0.0], 197 [5.0, 5.0, 8.0], 198 "cylinder", 199 ); 200} 201 202#[test] 203fn donut_evaluates_within_bounds() { 204 evaluates_within_bounds( 205 &document("donut", donut_sketch(), 6.0), 206 [-10.0, -10.0, 0.0], 207 [10.0, 10.0, 6.0], 208 "donut", 209 ); 210} 211 212fn import_round_trips_labels(seed: &Document) { 213 let Ok(expected) = body_of(seed) else { 214 panic!("document evaluates to one body"); 215 }; 216 let Ok(dir) = tempfile::tempdir() else { 217 panic!("temp dir"); 218 }; 219 let path = write_to_temp(seed, dir.path(), "part.step"); 220 let Ok(imported) = read(&path, CancelFlag::never()) else { 221 panic!("read step"); 222 }; 223 let Ok(solid) = body_of(&imported) else { 224 panic!("imported document carries one body"); 225 }; 226 assert_eq!( 227 face_labels(&expected), 228 face_labels(&solid), 229 "matching sidecar restores labels" 230 ); 231 assert!(!is_dumb(&solid), "a matched sidecar yields a labeled body"); 232 assert!(solid.validate(TOL).is_ok()); 233} 234 235#[test] 236fn cube_import_round_trips_labels() { 237 import_round_trips_labels(&document("cube", rectangle_sketch(), 4.0)); 238} 239 240#[test] 241fn cylinder_import_round_trips_labels() { 242 import_round_trips_labels(&document("cylinder", circle_sketch(5.0), 8.0)); 243} 244 245#[test] 246fn donut_import_round_trips_labels() { 247 import_round_trips_labels(&document("donut", donut_sketch(), 6.0)); 248} 249 250fn imported_document(seed: &Document) -> Document { 251 let Ok(dir) = tempfile::tempdir() else { 252 panic!("temp dir"); 253 }; 254 let path = write_to_temp(seed, dir.path(), "part.step"); 255 let Ok(imported) = read(&path, CancelFlag::never()) else { 256 panic!("read step"); 257 }; 258 imported 259} 260 261fn document_round_trips(seed: &Document) { 262 let imported = imported_document(seed); 263 let Ok(dir) = tempfile::tempdir() else { 264 panic!("temp dir"); 265 }; 266 let path = write_to_temp(&imported, dir.path(), "part.step"); 267 let Ok(round) = read(&path, CancelFlag::never()) else { 268 panic!("re-read step"); 269 }; 270 assert_eq!( 271 imported, round, 272 "read(write(d)) preserves an imported-body document" 273 ); 274} 275 276#[test] 277fn cube_document_round_trips() { 278 document_round_trips(&document("cube", rectangle_sketch(), 4.0)); 279} 280 281#[test] 282fn cylinder_document_round_trips() { 283 document_round_trips(&document("cylinder", circle_sketch(5.0), 8.0)); 284} 285 286#[test] 287fn donut_document_round_trips() { 288 document_round_trips(&document("donut", donut_sketch(), 6.0)); 289} 290 291fn corners_mm(bbox: &Aabb3) -> [[f64; 3]; 2] { 292 let (lo_x, lo_y, lo_z) = bbox.min().coords_mm(); 293 let (hi_x, hi_y, hi_z) = bbox.max().coords_mm(); 294 [[lo_x, lo_y, lo_z], [hi_x, hi_y, hi_z]] 295} 296 297#[test] 298fn step_export_then_import_preserves_solid_under_tolerance() { 299 const MATCH_TOL_MM: f64 = 1.0e-6; 300 let seed = document("cube", rectangle_sketch(), 4.0); 301 let Ok(original) = body_of(&seed) else { 302 panic!("the seed evaluates to one body"); 303 }; 304 let imported = imported_document(&seed); 305 let Ok(reimported) = body_of(&imported) else { 306 panic!("export then import yields one body"); 307 }; 308 let before = corners_mm(&solid_bounds(&original)); 309 let after = corners_mm(&solid_bounds(&reimported)); 310 before 311 .iter() 312 .flatten() 313 .zip(after.iter().flatten()) 314 .enumerate() 315 .for_each(|(slot, (b, a))| { 316 assert!( 317 (b - a).abs() <= MATCH_TOL_MM, 318 "re-imported corner slot {slot} drifts past {MATCH_TOL_MM} mm: {b} -> {a}" 319 ); 320 }); 321 assert!( 322 !is_dumb(&reimported), 323 "the matched sidecar restores a labeled solid" 324 ); 325 assert!( 326 reimported.validate(TOL).is_ok(), 327 "the re-imported solid stays valid" 328 ); 329} 330 331#[test] 332fn import_enters_one_importable_body() { 333 let imported = imported_document(&document("cube", rectangle_sketch(), 4.0)); 334 assert_eq!(imported.imported_bodies().count(), 1); 335 assert!(body_of(&imported).is_ok()); 336} 337 338#[test] 339fn header_carries_document_name() { 340 let document = document("bracket", rectangle_sketch(), 4.0); 341 let Ok(dir) = tempfile::tempdir() else { 342 panic!("temp dir"); 343 }; 344 let path = write_to_temp(&document, dir.path(), "export.step"); 345 let Ok(text) = std::fs::read_to_string(&path) else { 346 panic!("read back"); 347 }; 348 assert!( 349 text.contains("FILE_NAME('bracket'"), 350 "header file_name carries the document name, not the step path" 351 ); 352} 353 354#[test] 355fn imported_document_takes_its_name_from_the_file_stem() { 356 let document = document("bracket", rectangle_sketch(), 4.0); 357 let Ok(dir) = tempfile::tempdir() else { 358 panic!("temp dir"); 359 }; 360 let path = write_to_temp(&document, dir.path(), "widget.step"); 361 let Ok(imported) = read(&path, CancelFlag::never()) else { 362 panic!("read step"); 363 }; 364 assert_eq!(imported.name(), "widget"); 365} 366 367#[test] 368fn apostrophe_in_document_name_round_trips() { 369 let document = document("nel's bracket", rectangle_sketch(), 4.0); 370 let Ok(dir) = tempfile::tempdir() else { 371 panic!("temp dir"); 372 }; 373 let path = write_to_temp(&document, dir.path(), "part.step"); 374 let Ok(text) = std::fs::read_to_string(&path) else { 375 panic!("read back"); 376 }; 377 assert!( 378 text.contains("FILE_NAME('nel''s bracket'"), 379 "an apostrophe is doubled per ISO 10303-21" 380 ); 381 let Ok(imported) = read(&path, CancelFlag::never()) else { 382 panic!("read step"); 383 }; 384 let Ok(solid) = body_of(&imported) else { 385 panic!("one body"); 386 }; 387 assert!(!is_dumb(&solid)); 388} 389 390#[test] 391fn step_export_is_byte_deterministic() { 392 let document = document("cube", rectangle_sketch(), 4.0); 393 let Ok(dir) = tempfile::tempdir() else { 394 panic!("temp dir"); 395 }; 396 let path = dir.path().join("part.step"); 397 let Ok(()) = write(&document, &path, StepSchema::Ap214, CancelFlag::never()) else { 398 panic!("write first"); 399 }; 400 let Ok(a) = std::fs::read(&path) else { 401 panic!("read first"); 402 }; 403 let Ok(()) = write(&document, &path, StepSchema::Ap214, CancelFlag::never()) else { 404 panic!("write second"); 405 }; 406 let Ok(b) = std::fs::read(&path) else { 407 panic!("read second"); 408 }; 409 assert_eq!(a, b, "same document and path write byte-identical step"); 410} 411 412#[test] 413fn missing_sidecar_imports_dumb_body() { 414 let document = document("cube", rectangle_sketch(), 4.0); 415 let Ok(expected) = body_of(&document) else { 416 panic!("one body"); 417 }; 418 let Ok(dir) = tempfile::tempdir() else { 419 panic!("temp dir"); 420 }; 421 let path = write_to_temp(&document, dir.path(), "part.step"); 422 let mut labels = path.clone().into_os_string(); 423 labels.push(".labels"); 424 let Ok(()) = std::fs::remove_file(PathBuf::from(labels)) else { 425 panic!("remove sidecar"); 426 }; 427 let Ok(imported) = read(&path, CancelFlag::never()) else { 428 panic!("read step"); 429 }; 430 let Ok(solid) = body_of(&imported) else { 431 panic!("one body"); 432 }; 433 assert!(is_dumb(&solid), "no sidecar yields a dumb body"); 434 assert_eq!( 435 solid.iter_faces().count(), 436 expected.iter_faces().count(), 437 "geometry survives even without labels" 438 ); 439} 440 441#[test] 442fn ap242_header_reports_schema_mismatch() { 443 let document = document("cube", rectangle_sketch(), 4.0); 444 let Ok(dir) = tempfile::tempdir() else { 445 panic!("temp dir"); 446 }; 447 let path = write_to_temp(&document, dir.path(), "cube.step"); 448 let Ok(text) = std::fs::read_to_string(&path) else { 449 panic!("read back"); 450 }; 451 let swapped = text.replace( 452 "AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }", 453 "AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 3 1 4 }", 454 ); 455 let foreign = dir.path().join("cube242.step"); 456 let Ok(()) = std::fs::write(&foreign, swapped.as_bytes()) else { 457 panic!("write swapped"); 458 }; 459 assert!(matches!( 460 read(&foreign, CancelFlag::never()), 461 Err(StepError::SchemaMismatch { 462 found: StepSchema::Ap242E2, 463 .. 464 }) 465 )); 466} 467 468#[test] 469fn ap203_header_imports_best_effort() { 470 let document = document("cube", rectangle_sketch(), 4.0); 471 let Ok(dir) = tempfile::tempdir() else { 472 panic!("temp dir"); 473 }; 474 let path = write_to_temp(&document, dir.path(), "cube.step"); 475 let Ok(text) = std::fs::read_to_string(&path) else { 476 panic!("read back"); 477 }; 478 let swapped = text.replace( 479 "AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }", 480 "CONFIG_CONTROL_DESIGN { 1 0 10303 203 1 1 1 1 }", 481 ); 482 let foreign = dir.path().join("cube203.step"); 483 let Ok(()) = std::fs::write(&foreign, swapped.as_bytes()) else { 484 panic!("write swapped"); 485 }; 486 assert!( 487 read(&foreign, CancelFlag::never()).is_ok(), 488 "an unmodeled schema token still attempts a best-effort import" 489 ); 490} 491 492#[test] 493fn text_without_header_reports_malformed_header() { 494 let Ok(dir) = tempfile::tempdir() else { 495 panic!("temp dir"); 496 }; 497 let path = dir.path().join("bad.step"); 498 let Ok(()) = std::fs::write(&path, b"this is not a step file") else { 499 panic!("write"); 500 }; 501 assert!(matches!( 502 read(&path, CancelFlag::never()), 503 Err(StepError::MalformedHeader { 504 reason: HeaderDefect::NoHeaderSection 505 }) 506 )); 507} 508 509#[test] 510fn header_without_file_schema_reports_malformed_header() { 511 let text = "ISO-10303-21;\nHEADER;\nFILE_NAME('x','1970-01-01T00:00:00',(''),(''),'','','');\nENDSEC;\nDATA;\nENDSEC;\nEND-ISO-10303-21;\n"; 512 let Ok(dir) = tempfile::tempdir() else { 513 panic!("temp dir"); 514 }; 515 let path = dir.path().join("noschema.step"); 516 let Ok(()) = std::fs::write(&path, text.as_bytes()) else { 517 panic!("write"); 518 }; 519 assert!(matches!( 520 read(&path, CancelFlag::never()), 521 Err(StepError::MalformedHeader { 522 reason: HeaderDefect::NoFileSchema 523 }) 524 )); 525} 526 527#[test] 528fn header_with_empty_data_section_is_incomplete() { 529 let text = "ISO-10303-21;\nHEADER;\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\nENDSEC;\nDATA;\nENDSEC;\nEND-ISO-10303-21;\n"; 530 let Ok(dir) = tempfile::tempdir() else { 531 panic!("temp dir"); 532 }; 533 let path = dir.path().join("empty.step"); 534 let Ok(()) = std::fs::write(&path, text.as_bytes()) else { 535 panic!("write"); 536 }; 537 assert!(matches!( 538 read(&path, CancelFlag::never()), 539 Err(StepError::IncompleteFile) 540 )); 541} 542 543#[test] 544fn header_comment_with_an_apostrophe_does_not_break_the_scan() { 545 let document = document("cube", rectangle_sketch(), 4.0); 546 let Ok(dir) = tempfile::tempdir() else { 547 panic!("temp dir"); 548 }; 549 let path = write_to_temp(&document, dir.path(), "part.step"); 550 let Ok(text) = std::fs::read_to_string(&path) else { 551 panic!("read back"); 552 }; 553 let commented = text.replace( 554 "FILE_SCHEMA(", 555 "/* nel's draft, hand-edited */\nFILE_SCHEMA(", 556 ); 557 let Ok(()) = std::fs::write(&path, commented.as_bytes()) else { 558 panic!("rewrite with a header comment"); 559 }; 560 let Ok(imported) = read(&path, CancelFlag::never()) else { 561 panic!("a lone apostrophe inside a header comment must not desync the scan"); 562 }; 563 let Ok(solid) = body_of(&imported) else { 564 panic!("one body"); 565 }; 566 assert!( 567 !is_dumb(&solid), 568 "a header comment does not block sidecar reattach" 569 ); 570} 571 572#[test] 573fn header_comment_carrying_a_foreign_schema_statement_is_ignored() { 574 let document = document("cube", rectangle_sketch(), 4.0); 575 let Ok(dir) = tempfile::tempdir() else { 576 panic!("temp dir"); 577 }; 578 let path = write_to_temp(&document, dir.path(), "part.step"); 579 let Ok(text) = std::fs::read_to_string(&path) else { 580 panic!("read back"); 581 }; 582 let commented = text.replace( 583 "FILE_SCHEMA(", 584 "/* legacy: FILE_SCHEMA(('AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 3 1 4 }')); */\nFILE_SCHEMA(", 585 ); 586 let Ok(()) = std::fs::write(&path, commented.as_bytes()) else { 587 panic!("rewrite with a header comment"); 588 }; 589 let Ok(imported) = read(&path, CancelFlag::never()) else { 590 panic!("a FILE_SCHEMA statement buried in a comment must not classify the file"); 591 }; 592 let Ok(solid) = body_of(&imported) else { 593 panic!("one body"); 594 }; 595 assert!( 596 !is_dumb(&solid), 597 "the real AP214 schema still yields a labeled import" 598 ); 599} 600 601#[test] 602fn ap242_export_is_unsupported() { 603 let document = document("cube", rectangle_sketch(), 4.0); 604 let Ok(dir) = tempfile::tempdir() else { 605 panic!("temp dir"); 606 }; 607 let path = dir.path().join("part.step"); 608 assert!(matches!( 609 write(&document, &path, StepSchema::Ap242E2, CancelFlag::never()), 610 Err(StepError::SchemaUnsupported(StepSchema::Ap242E2)) 611 )); 612} 613 614#[test] 615fn empty_document_has_no_body() { 616 let document = Document::new(DocumentId::default(), "empty".to_owned()); 617 let Ok(dir) = tempfile::tempdir() else { 618 panic!("temp dir"); 619 }; 620 let path = dir.path().join("part.step"); 621 assert!(matches!( 622 write(&document, &path, StepSchema::Ap214, CancelFlag::never()), 623 Err(StepError::BodyCount { count: 0 }) 624 )); 625} 626 627#[test] 628fn multiple_bodies_are_rejected() { 629 let mut document = Document::new(DocumentId::default(), "two".to_owned()); 630 let first = sketch_id(1); 631 let second = sketch_id(2); 632 document.insert_sketch(first, "A".to_owned(), rectangle_sketch()); 633 document.insert_sketch(second, "B".to_owned(), circle_sketch(5.0)); 634 document.insert_extrude(extrude_id(1), blind(first, 4.0)); 635 document.insert_extrude(extrude_id(2), blind(second, 6.0)); 636 let Ok(dir) = tempfile::tempdir() else { 637 panic!("temp dir"); 638 }; 639 let path = dir.path().join("part.step"); 640 assert!(matches!( 641 write(&document, &path, StepSchema::Ap214, CancelFlag::never()), 642 Err(StepError::BodyCount { count: 2 }) 643 )); 644} 645 646#[test] 647fn lone_unresolved_extrude_reports_dangling() { 648 let mut document = Document::new(DocumentId::default(), "pending".to_owned()); 649 document.insert_extrude(extrude_id(1), blind(sketch_id(1), 4.0)); 650 let Ok(dir) = tempfile::tempdir() else { 651 panic!("temp dir"); 652 }; 653 let path = dir.path().join("part.step"); 654 assert!(matches!( 655 write(&document, &path, StepSchema::Ap214, CancelFlag::never()), 656 Err(StepError::DanglingExtrude { .. }) 657 )); 658} 659 660#[test] 661fn unresolved_body_is_not_silently_dropped() { 662 let mut document = Document::new(DocumentId::default(), "mixed".to_owned()); 663 document.insert_sketch(sketch_id(1), "A".to_owned(), rectangle_sketch()); 664 document.insert_extrude(extrude_id(1), blind(sketch_id(1), 4.0)); 665 document.insert_extrude(extrude_id(2), blind(sketch_id(2), 6.0)); 666 let Ok(dir) = tempfile::tempdir() else { 667 panic!("temp dir"); 668 }; 669 let path = dir.path().join("part.step"); 670 assert!(matches!( 671 write(&document, &path, StepSchema::Ap214, CancelFlag::never()), 672 Err(StepError::BodyCount { count: 2 }) 673 )); 674 assert!(!path.exists(), "a rejected export leaves no file behind"); 675} 676 677#[test] 678fn sidecar_failure_leaves_no_orphan_step() { 679 let document = document("cube", rectangle_sketch(), 4.0); 680 let Ok(dir) = tempfile::tempdir() else { 681 panic!("temp dir"); 682 }; 683 let path = dir.path().join("part.step"); 684 let mut blocker = path.clone().into_os_string(); 685 blocker.push(".labels"); 686 let Ok(()) = std::fs::create_dir(PathBuf::from(blocker)) else { 687 panic!("block the sidecar path with a directory"); 688 }; 689 assert!(write(&document, &path, StepSchema::Ap214, CancelFlag::never()).is_err()); 690 assert!( 691 !path.exists(), 692 "no labelless step is written when the sidecar cannot be" 693 ); 694} 695 696fn solid_bounds(solid: &BrepSolid) -> Aabb3 { 697 let Some(bbox) = solid.bounding_box() else { 698 panic!("solid has a bounding box"); 699 }; 700 bbox 701} 702 703fn near_mm(value: Length, mm: f64) -> bool { 704 (value.get::<millimeter>() - mm).abs() < 0.2 705} 706 707const INBOUND_BOUNDS: &[(&str, [f64; 3], [f64; 3])] = 708 &[("wedge.step", [0.0, 0.0, 0.0], [5.0, 5.0, 8.0])]; 709 710fn inbound_step_files() -> Vec<PathBuf> { 711 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens/inbound"); 712 let Ok(entries) = std::fs::read_dir(&dir) else { 713 panic!("inbound goldens directory exists"); 714 }; 715 let mut files: Vec<PathBuf> = entries 716 .filter_map(Result::ok) 717 .map(|entry| entry.path()) 718 .filter(|path| { 719 path.extension() 720 .is_some_and(|ext| ext.eq_ignore_ascii_case("step")) 721 }) 722 .collect(); 723 files.sort(); 724 files 725} 726 727fn assert_bounds(bbox: &Aabb3, min_mm: [f64; 3], max_mm: [f64; 3], name: &str) { 728 let (min, max) = (bbox.min(), bbox.max()); 729 assert!( 730 near_mm(min.x(), min_mm[0]) && near_mm(min.y(), min_mm[1]) && near_mm(min.z(), min_mm[2]), 731 "inbound {name} lower bound {min:?} matches {min_mm:?} mm" 732 ); 733 assert!( 734 near_mm(max.x(), max_mm[0]) && near_mm(max.y(), max_mm[1]) && near_mm(max.z(), max_mm[2]), 735 "inbound {name} upper bound {max:?} matches {max_mm:?} mm" 736 ); 737} 738 739fn is_non_degenerate(bbox: &Aabb3) -> bool { 740 let (min, max) = (bbox.min(), bbox.max()); 741 let span = |hi: Length, lo: Length| (hi - lo).get::<millimeter>() > 0.1; 742 span(max.x(), min.x()) && span(max.y(), min.y()) && span(max.z(), min.z()) 743} 744 745#[test] 746fn inbound_fixtures_import_and_match_bounds() { 747 let files = inbound_step_files(); 748 assert!( 749 !files.is_empty(), 750 "at least one inbound cross-tool fixture is present" 751 ); 752 files.iter().for_each(|path| { 753 let label = path.display(); 754 let document = match read(path, CancelFlag::never()) { 755 Ok(document) => document, 756 Err(error) => panic!("inbound {label} imports: {error}"), 757 }; 758 let Ok(solid) = body_of(&document) else { 759 panic!("inbound {label} carries one body"); 760 }; 761 let bbox = solid_bounds(&solid); 762 let name = path 763 .file_name() 764 .and_then(|n| n.to_str()) 765 .unwrap_or_default(); 766 match INBOUND_BOUNDS.iter().find(|(file, ..)| *file == name) { 767 Some((_, min, max)) => assert_bounds(&bbox, *min, *max, name), 768 None => assert!( 769 is_non_degenerate(&bbox), 770 "inbound {name} imports and tessellates to a non-degenerate box; \ 771 register its design extent in INBOUND_BOUNDS to pin the match" 772 ), 773 } 774 }); 775} 776 777fn wedge_text() -> String { 778 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens/inbound/wedge.step"); 779 let Ok(text) = std::fs::read_to_string(&path) else { 780 panic!("read wedge golden at {}", path.display()); 781 }; 782 text 783} 784 785fn read_step_text(text: &str, name: &str) -> Result<Document, StepError> { 786 let Ok(dir) = tempfile::tempdir() else { 787 panic!("temp dir"); 788 }; 789 let path = dir.path().join(name); 790 let Ok(()) = std::fs::write(&path, text.as_bytes()) else { 791 panic!("write probe step"); 792 }; 793 read(&path, CancelFlag::never()) 794} 795 796#[test] 797fn two_solid_roots_are_rejected_as_assembly() { 798 let text = wedge_text().replace( 799 "#1 = MANIFOLD_SOLID_BREP('wedge', #2);", 800 "#1 = MANIFOLD_SOLID_BREP('wedge', #2);\n#950 = MANIFOLD_SOLID_BREP('clone', #2);", 801 ); 802 assert!( 803 matches!( 804 read_step_text(&text, "assembly.step"), 805 Err(StepError::UnsupportedAssembly { solids: 2 }) 806 ), 807 "two manifold_solid_brep roots are an assembly, not one body" 808 ); 809} 810 811#[test] 812fn a_stray_closed_shell_is_excluded_not_absorbed() { 813 let Ok(clean) = read_step_text(&wedge_text(), "clean_wedge.step") else { 814 panic!("the clean wedge imports"); 815 }; 816 let Ok(clean_solid) = body_of(&clean) else { 817 panic!("clean wedge has one body"); 818 }; 819 let text = wedge_text().replace( 820 "DATA;\n", 821 "DATA;\n#960 = CLOSED_SHELL('stray', (#10, #20, #30, #40, #50));\n", 822 ); 823 let Ok(document) = read_step_text(&text, "stray_closed.step") else { 824 panic!("a stray shell with one solid root still imports"); 825 }; 826 let Ok(solid) = body_of(&document) else { 827 panic!("one solid root yields one body"); 828 }; 829 assert_eq!( 830 solid.iter_faces().count(), 831 clean_solid.iter_faces().count(), 832 "a shell unreachable from the solid root is dropped, not merged in" 833 ); 834} 835 836#[test] 837fn bare_shells_without_a_solid_root_are_rejected() { 838 let text = wedge_text() 839 .replace("#1 = MANIFOLD_SOLID_BREP('wedge', #2);\n", "") 840 .replace( 841 "DATA;\n", 842 "DATA;\n#960 = CLOSED_SHELL('stray', (#10, #20, #30, #40, #50));\n", 843 ); 844 assert!( 845 matches!( 846 read_step_text(&text, "rootless.step"), 847 Err(StepError::UnsupportedAssembly { solids: 2 }) 848 ), 849 "two closed shells with no solid root cannot be one body" 850 ); 851} 852 853#[test] 854fn a_stray_open_shell_is_ignored() { 855 let text = wedge_text().replace("DATA;\n", "DATA;\n#970 = OPEN_SHELL('stray', (#10));\n"); 856 let Ok(document) = read_step_text(&text, "open_shell.step") else { 857 panic!("a construction open shell does not block the import"); 858 }; 859 let Ok(solid) = body_of(&document) else { 860 panic!("one body"); 861 }; 862 assert_bounds( 863 &solid_bounds(&solid), 864 [0.0, 0.0, 0.0], 865 [5.0, 5.0, 8.0], 866 "wedge with a stray open shell", 867 ); 868} 869 870#[test] 871fn spherical_face_reports_unsupported_entity() { 872 let text = wedge_text().replace( 873 "CYLINDRICAL_SURFACE('', #360, 5.0)", 874 "SPHERICAL_SURFACE('', #360, 5.0)", 875 ); 876 assert!(matches!( 877 read_step_text(&text, "sphere.step"), 878 Err(StepError::UnsupportedEntity { 879 kind: StepEntityKind::SphericalSurface 880 }) 881 )); 882} 883 884#[test] 885fn file_schema_catches_an_unsupported_token_past_the_first() { 886 let document = document("cube", rectangle_sketch(), 4.0); 887 let Ok(dir) = tempfile::tempdir() else { 888 panic!("temp dir"); 889 }; 890 let path = write_to_temp(&document, dir.path(), "cube.step"); 891 let Ok(text) = std::fs::read_to_string(&path) else { 892 panic!("read back"); 893 }; 894 let swapped = text.replace( 895 "('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }')", 896 "('GARBAGE_SCHEMA','AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 3 1 4 }')", 897 ); 898 let foreign = dir.path().join("cube_multi.step"); 899 let Ok(()) = std::fs::write(&foreign, swapped.as_bytes()) else { 900 panic!("write swapped"); 901 }; 902 assert!(matches!( 903 read(&foreign, CancelFlag::never()), 904 Err(StepError::SchemaMismatch { 905 found: StepSchema::Ap242E2, 906 .. 907 }) 908 )); 909} 910 911#[test] 912fn corrupt_sidecar_imports_dumb_body() { 913 let document = document("cube", rectangle_sketch(), 4.0); 914 let Ok(dir) = tempfile::tempdir() else { 915 panic!("temp dir"); 916 }; 917 let path = write_to_temp(&document, dir.path(), "part.step"); 918 let mut labels = path.clone().into_os_string(); 919 labels.push(".labels"); 920 let Ok(()) = std::fs::write(PathBuf::from(labels), b"not valid ron @@@ {") else { 921 panic!("overwrite sidecar with garbage"); 922 }; 923 let Ok(imported) = read(&path, CancelFlag::never()) else { 924 panic!("a corrupt sidecar still imports best-effort"); 925 }; 926 let Ok(solid) = body_of(&imported) else { 927 panic!("one body"); 928 }; 929 assert!(is_dumb(&solid), "a corrupt sidecar yields a dumb body"); 930} 931 932#[test] 933fn imported_body_survives_folder_round_trip() { 934 let wedge = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens/inbound/wedge.step"); 935 let Ok(imported) = read(&wedge, CancelFlag::never()) else { 936 panic!("import wedge"); 937 }; 938 let Ok(expected) = body_of(&imported) else { 939 panic!("imported wedge carries one body"); 940 }; 941 let Ok(dir) = tempfile::tempdir() else { 942 panic!("temp dir"); 943 }; 944 let folder = bone_document::DocumentFolder::new(dir.path().join("wedge.bone")); 945 let Ok(()) = bone_document::save(&imported, &folder) else { 946 panic!("save imported document"); 947 }; 948 let Ok(loaded) = bone_document::load(&folder) else { 949 panic!("load imported document"); 950 }; 951 assert_eq!( 952 loaded.imported_bodies().count(), 953 1, 954 "the body survives on disk" 955 ); 956 let Ok(restored) = body_of(&loaded) else { 957 panic!("the reloaded document still resolves its imported body"); 958 }; 959 assert_eq!( 960 face_labels(&expected), 961 face_labels(&restored), 962 "a persisted body keeps its labels across a folder round trip" 963 ); 964 assert!(restored.validate(TOL).is_ok()); 965} 966 967#[test] 968fn cylinder_export_writes_step_and_sidecar() { 969 let document = document("cyl", circle_sketch(5.0), 8.0); 970 let Ok(dir) = tempfile::tempdir() else { 971 panic!("temp dir"); 972 }; 973 let path = write_to_temp(&document, dir.path(), "cyl.step"); 974 assert!(path.exists()); 975 let mut labels = path.clone().into_os_string(); 976 labels.push(".labels"); 977 assert!(PathBuf::from(labels).exists()); 978} 979 980#[test] 981fn step_keywords_in_document_name_round_trip() { 982 ["endsec", "file_schema", "header"] 983 .iter() 984 .for_each(|keyword| { 985 let name = format!("{keyword} bracket"); 986 let document = document(&name, rectangle_sketch(), 4.0); 987 let Ok(dir) = tempfile::tempdir() else { 988 panic!("temp dir"); 989 }; 990 let path = write_to_temp(&document, dir.path(), "part.step"); 991 let Ok(imported) = read(&path, CancelFlag::never()) else { 992 panic!("a name carrying '{keyword}' must not derail header parsing"); 993 }; 994 let Ok(solid) = body_of(&imported) else { 995 panic!("{keyword}: imports one body"); 996 }; 997 assert!( 998 !is_dumb(&solid), 999 "{keyword}: a header keyword inside the name does not block sidecar reattach" 1000 ); 1001 }); 1002} 1003 1004fn role_z_extents(solid: &BrepSolid) -> std::collections::BTreeMap<String, (String, String)> { 1005 use std::collections::BTreeMap; 1006 let loop_edges: BTreeMap<_, Vec<_>> = solid 1007 .iter_loops() 1008 .map(|l| (l.id(), l.edges().to_vec())) 1009 .collect(); 1010 let edge_verts: BTreeMap<_, _> = solid.iter_edges().map(|e| (e.id(), e.vertices())).collect(); 1011 let vert_z: BTreeMap<_, f64> = solid 1012 .iter_vertices() 1013 .map(|v| (v.id(), v.position().coords_mm().2)) 1014 .collect(); 1015 let mm = |z: f64| format!("{z:.3}"); 1016 solid 1017 .iter_faces() 1018 .map(|face| { 1019 let zs: Vec<f64> = face 1020 .loops() 1021 .iter() 1022 .flat_map(|lid| loop_edges.get(lid).into_iter().flatten()) 1023 .flat_map(|eid| edge_verts.get(eid).into_iter().flatten()) 1024 .filter_map(|vid| vert_z.get(vid).copied()) 1025 .collect(); 1026 let lo = zs.iter().copied().fold(f64::INFINITY, f64::min); 1027 let hi = zs.iter().copied().fold(f64::NEG_INFINITY, f64::max); 1028 (format!("{:?}", face.label().role), (mm(lo), mm(hi))) 1029 }) 1030 .collect() 1031} 1032 1033#[test] 1034fn imported_labels_bind_to_original_geometry() { 1035 let seed = document("cube", rectangle_sketch(), 4.0); 1036 let Ok(expected) = body_of(&seed) else { 1037 panic!("one body"); 1038 }; 1039 let Ok(dir) = tempfile::tempdir() else { 1040 panic!("temp dir"); 1041 }; 1042 let path = write_to_temp(&seed, dir.path(), "part.step"); 1043 let Ok(imported) = read(&path, CancelFlag::never()) else { 1044 panic!("read step"); 1045 }; 1046 let Ok(round) = body_of(&imported) else { 1047 panic!("one body"); 1048 }; 1049 let after = role_z_extents(&round); 1050 assert_eq!( 1051 role_z_extents(&expected), 1052 after, 1053 "each label binds to the same geometry before and after import, not merely the same label set" 1054 ); 1055 assert_eq!( 1056 after.get("StartCap").map(|extent| extent.0.as_str()), 1057 Some("0.000"), 1058 "the StartCap label lands on the z=0 face" 1059 ); 1060 assert_eq!( 1061 after.get("EndCap").map(|extent| extent.0.as_str()), 1062 Some("4.000"), 1063 "the EndCap label lands on the z=depth face" 1064 ); 1065} 1066 1067#[test] 1068fn a_set_flag_cancels_export_and_leaves_no_file() { 1069 let document = document("cube", rectangle_sketch(), 4.0); 1070 let Ok(dir) = tempfile::tempdir() else { 1071 panic!("temp dir"); 1072 }; 1073 let path = dir.path().join("part.step"); 1074 let flag = AtomicBool::new(true); 1075 assert!(matches!( 1076 write(&document, &path, StepSchema::Ap214, CancelFlag::new(&flag)), 1077 Err(StepError::Canceled) 1078 )); 1079 assert!(!path.exists(), "a canceled export writes no step file"); 1080 let mut labels = path.into_os_string(); 1081 labels.push(".labels"); 1082 assert!( 1083 !PathBuf::from(labels).exists(), 1084 "a canceled export writes no sidecar either" 1085 ); 1086} 1087 1088#[test] 1089fn a_set_flag_cancels_import() { 1090 let document = document("cube", rectangle_sketch(), 4.0); 1091 let Ok(dir) = tempfile::tempdir() else { 1092 panic!("temp dir"); 1093 }; 1094 let path = write_to_temp(&document, dir.path(), "part.step"); 1095 let flag = AtomicBool::new(true); 1096 assert!(matches!( 1097 read(&path, CancelFlag::new(&flag)), 1098 Err(StepError::Canceled) 1099 )); 1100} 1101 1102#[test] 1103fn a_clear_flag_does_not_block_a_round_trip() { 1104 let document = document("cube", rectangle_sketch(), 4.0); 1105 let Ok(dir) = tempfile::tempdir() else { 1106 panic!("temp dir"); 1107 }; 1108 let path = dir.path().join("part.step"); 1109 let flag = AtomicBool::new(false); 1110 let observed = CancelFlag::new(&flag); 1111 let Ok(()) = write(&document, &path, StepSchema::Ap214, observed) else { 1112 panic!("a clear flag permits export"); 1113 }; 1114 let Ok(imported) = read(&path, observed) else { 1115 panic!("a clear flag permits import"); 1116 }; 1117 let Ok(solid) = body_of(&imported) else { 1118 panic!("one body"); 1119 }; 1120 assert!(!is_dumb(&solid)); 1121} 1122 1123struct CancelAfter { 1124 seen: AtomicUsize, 1125 trip: usize, 1126} 1127 1128impl CancelAfter { 1129 fn new(trip: usize) -> Self { 1130 Self { 1131 seen: AtomicUsize::new(0), 1132 trip, 1133 } 1134 } 1135 1136 fn observations(&self) -> usize { 1137 self.seen.load(Ordering::Relaxed) 1138 } 1139} 1140 1141impl Cancel for CancelAfter { 1142 fn is_canceled(&self) -> bool { 1143 self.seen.fetch_add(1, Ordering::Relaxed) >= self.trip 1144 } 1145} 1146 1147#[test] 1148fn a_cancel_after_evaluation_stops_export_before_writing() { 1149 let document = document("cube", rectangle_sketch(), 4.0); 1150 let Ok(dir) = tempfile::tempdir() else { 1151 panic!("temp dir"); 1152 }; 1153 let path = dir.path().join("part.step"); 1154 let cancel = CancelAfter::new(1); 1155 assert!(matches!( 1156 write( 1157 &document, 1158 &path, 1159 StepSchema::Ap214, 1160 CancelFlag::new(&cancel) 1161 ), 1162 Err(StepError::Canceled) 1163 )); 1164 assert_eq!( 1165 cancel.observations(), 1166 2, 1167 "the export clears the first guard, evaluates, then trips on the second" 1168 ); 1169 assert!( 1170 !path.exists(), 1171 "a cancel seen after evaluation writes no step file" 1172 ); 1173 let mut labels = path.into_os_string(); 1174 labels.push(".labels"); 1175 assert!( 1176 !PathBuf::from(labels).exists(), 1177 "and writes no label sidecar" 1178 ); 1179} 1180 1181#[test] 1182fn a_cancel_after_read_stops_import_before_assembling() { 1183 let document = document("cube", rectangle_sketch(), 4.0); 1184 let Ok(dir) = tempfile::tempdir() else { 1185 panic!("temp dir"); 1186 }; 1187 let path = write_to_temp(&document, dir.path(), "part.step"); 1188 let cancel = CancelAfter::new(1); 1189 assert!(matches!( 1190 read(&path, CancelFlag::new(&cancel)), 1191 Err(StepError::Canceled) 1192 )); 1193 assert_eq!( 1194 cancel.observations(), 1195 2, 1196 "the import clears the first guard, reads the file, then trips on the second" 1197 ); 1198} 1199 1200const WRITE_BUDGET: Duration = Duration::from_millis(100); 1201const BUDGET_SAMPLES: u32 = 8; 1202 1203#[test] 1204fn rectangle_extrude_write_stays_within_budget() { 1205 let document = document("cube", rectangle_sketch(), 4.0); 1206 let Ok(dir) = tempfile::tempdir() else { 1207 panic!("temp dir"); 1208 }; 1209 let never = CancelFlag::never(); 1210 let Ok(()) = write( 1211 &document, 1212 &dir.path().join("warmup.step"), 1213 StepSchema::Ap214, 1214 never, 1215 ) else { 1216 panic!("warmup write"); 1217 }; 1218 let best = (0..BUDGET_SAMPLES).fold(Duration::MAX, |best, sample| { 1219 let path = dir.path().join(format!("bench{sample}.step")); 1220 let start = Instant::now(); 1221 let outcome = write(&document, &path, StepSchema::Ap214, never); 1222 let elapsed = start.elapsed(); 1223 let Ok(()) = outcome else { 1224 panic!("benchmark write"); 1225 }; 1226 best.min(elapsed) 1227 }); 1228 assert!( 1229 cfg!(debug_assertions) || best < WRITE_BUDGET, 1230 "rectangle extrude step write took {best:?} over the {WRITE_BUDGET:?} release budget" 1231 ); 1232} 1233 1234#[test] 1235fn a_cancel_seen_after_parsing_discards_the_import() { 1236 let document = document("cube", rectangle_sketch(), 4.0); 1237 let Ok(dir) = tempfile::tempdir() else { 1238 panic!("temp dir"); 1239 }; 1240 let path = write_to_temp(&document, dir.path(), "part.step"); 1241 let cancel = CancelAfter::new(2); 1242 assert!(matches!( 1243 read(&path, CancelFlag::new(&cancel)), 1244 Err(StepError::Canceled) 1245 )); 1246 assert_eq!( 1247 cancel.observations(), 1248 3, 1249 "the import clears both pre-parse guards, parses, then trips on the post-parse guard" 1250 ); 1251} 1252 1253#[test] 1254fn a_failed_step_write_leaves_no_orphan_sidecar() { 1255 let document = document("cube", rectangle_sketch(), 4.0); 1256 let Ok(dir) = tempfile::tempdir() else { 1257 panic!("temp dir"); 1258 }; 1259 let path = dir.path().join("part.step"); 1260 let Ok(()) = std::fs::create_dir(&path) else { 1261 panic!("occupy the step path with a directory so the body write fails"); 1262 }; 1263 assert!(matches!( 1264 write(&document, &path, StepSchema::Ap214, CancelFlag::never()), 1265 Err(StepError::Io { .. }) 1266 )); 1267 assert_eq!( 1268 read_dir_names(dir.path()), 1269 vec!["part.step".to_owned()], 1270 "a failed commit rolls back the sidecar and leaves no staging file" 1271 ); 1272} 1273 1274fn read_dir_names(dir: &Path) -> Vec<String> { 1275 let Ok(entries) = std::fs::read_dir(dir) else { 1276 panic!("read temp dir"); 1277 }; 1278 entries 1279 .filter_map(Result::ok) 1280 .map(|entry| entry.file_name().to_string_lossy().into_owned()) 1281 .collect::<std::collections::BTreeSet<_>>() 1282 .into_iter() 1283 .collect() 1284} 1285 1286#[test] 1287fn a_clean_export_renames_its_staging_file_into_place() { 1288 let document = document("cube", rectangle_sketch(), 4.0); 1289 let Ok(dir) = tempfile::tempdir() else { 1290 panic!("temp dir"); 1291 }; 1292 let path = dir.path().join("part.step"); 1293 let Ok(()) = write(&document, &path, StepSchema::Ap214, CancelFlag::never()) else { 1294 panic!("export"); 1295 }; 1296 assert_eq!( 1297 read_dir_names(dir.path()), 1298 vec!["part.step".to_owned(), "part.step.labels".to_owned()], 1299 "a clean export leaves the step and its sidecar with no staging temp behind" 1300 ); 1301} 1302 1303#[test] 1304fn a_failed_re_export_keeps_the_prior_step_file_intact() { 1305 let document = document("cube", rectangle_sketch(), 4.0); 1306 let Ok(dir) = tempfile::tempdir() else { 1307 panic!("temp dir"); 1308 }; 1309 let path = dir.path().join("part.step"); 1310 let Ok(()) = std::fs::write(&path, b"PRIOR GOOD STEP") else { 1311 panic!("seed a prior export"); 1312 }; 1313 let mut staging = path.clone().into_os_string(); 1314 staging.push(".staging"); 1315 let Ok(()) = std::fs::create_dir(PathBuf::from(&staging)) else { 1316 panic!("block the staging path so the commit cannot stage the new body"); 1317 }; 1318 assert!(matches!( 1319 write(&document, &path, StepSchema::Ap214, CancelFlag::never()), 1320 Err(StepError::Io { .. }) 1321 )); 1322 let Ok(kept) = std::fs::read(&path) else { 1323 panic!("the prior export vanished"); 1324 }; 1325 assert_eq!( 1326 kept, 1327 b"PRIOR GOOD STEP".to_vec(), 1328 "a failed commit must not truncate or clobber the previous export" 1329 ); 1330}