Another project
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}