1use thiserror::Error;
11
12#[derive(Debug, Error)]
16pub enum Error {
17 #[error("{0}")]
19 General(String),
20
21 #[error("GJK did not converge after {iterations} iterations (dist={dist:.6})")]
23 GjkNoConverge {
24 iterations: usize,
26 dist: f64,
28 },
29
30 #[error("EPA failed: {reason}")]
32 EpaFailure {
33 reason: String,
35 },
36
37 #[error("CCD did not converge: toi={toi:.6} after {iters} iters")]
39 CcdNoConverge {
40 toi: f64,
42 iters: usize,
44 },
45
46 #[error("degenerate shape: {detail}")]
48 DegenerateShape {
49 detail: String,
51 },
52
53 #[error("index {index} out of range (len={len})")]
55 IndexOutOfRange {
56 index: usize,
58 len: usize,
60 },
61
62 #[error("broadphase capacity exceeded: {capacity} slots are full")]
64 BroadphaseCapacity {
65 capacity: usize,
67 },
68
69 #[error("non-finite value encountered in {context}")]
71 NonFinite {
72 context: String,
74 },
75
76 #[error("unsupported shape pair: {shape_a} vs {shape_b}")]
78 UnsupportedShapePair {
79 shape_a: String,
81 shape_b: String,
83 },
84}
85
86pub type Result<T> = std::result::Result<T, Error>;
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
93pub enum ErrorKind {
94 Convergence,
96 Numerical,
98 Bounds,
100 Capacity,
102 Unsupported,
104 Other,
106}
107
108impl Error {
109 pub fn kind(&self) -> ErrorKind {
111 match self {
112 Error::GjkNoConverge { .. } | Error::CcdNoConverge { .. } => ErrorKind::Convergence,
113 Error::EpaFailure { .. } | Error::DegenerateShape { .. } | Error::NonFinite { .. } => {
114 ErrorKind::Numerical
115 }
116 Error::IndexOutOfRange { .. } => ErrorKind::Bounds,
117 Error::BroadphaseCapacity { .. } => ErrorKind::Capacity,
118 Error::UnsupportedShapePair { .. } => ErrorKind::Unsupported,
119 Error::General(_) => ErrorKind::Other,
120 }
121 }
122
123 pub fn is_convergence(&self) -> bool {
125 self.kind() == ErrorKind::Convergence
126 }
127
128 pub fn is_numerical(&self) -> bool {
130 self.kind() == ErrorKind::Numerical
131 }
132
133 pub fn is_bounds(&self) -> bool {
135 self.kind() == ErrorKind::Bounds
136 }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
143pub enum Severity {
144 Info = 0,
146 Warning = 1,
148 Error = 2,
150 Fatal = 3,
152}
153
154impl Error {
155 pub fn severity(&self) -> Severity {
157 match self {
158 Error::GjkNoConverge { .. } | Error::CcdNoConverge { .. } => Severity::Warning,
159 Error::EpaFailure { .. } => Severity::Warning,
160 Error::DegenerateShape { .. } => Severity::Warning,
161 Error::NonFinite { .. } => Severity::Fatal,
162 Error::IndexOutOfRange { .. } => Severity::Error,
163 Error::BroadphaseCapacity { .. } => Severity::Error,
164 Error::UnsupportedShapePair { .. } => Severity::Warning,
165 Error::General(_) => Severity::Info,
166 }
167 }
168
169 pub fn is_severe(&self) -> bool {
171 self.severity() >= Severity::Error
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum RecoveryHint {
180 SkipPair,
182 TreatAsSeparated,
184 UseFallback,
186 Abort,
188}
189
190impl Error {
191 pub fn recovery_hint(&self) -> RecoveryHint {
193 match self {
194 Error::GjkNoConverge { .. }
195 | Error::EpaFailure { .. }
196 | Error::CcdNoConverge { .. } => RecoveryHint::TreatAsSeparated,
197 Error::DegenerateShape { .. } | Error::UnsupportedShapePair { .. } => {
198 RecoveryHint::SkipPair
199 }
200 Error::NonFinite { .. } => RecoveryHint::Abort,
201 Error::IndexOutOfRange { .. } | Error::BroadphaseCapacity { .. } => {
202 RecoveryHint::UseFallback
203 }
204 Error::General(_) => RecoveryHint::SkipPair,
205 }
206 }
207}
208
209#[derive(Debug, Default)]
214pub struct ErrorCollector {
215 errors: Vec<Error>,
216}
217
218impl ErrorCollector {
219 pub fn new() -> Self {
221 Self { errors: Vec::new() }
222 }
223
224 pub fn push(&mut self, err: Error) -> Result<()> {
229 if err.severity() == Severity::Fatal {
230 return Err(err);
231 }
232 self.errors.push(err);
233 Ok(())
234 }
235
236 pub fn has_errors(&self) -> bool {
238 !self.errors.is_empty()
239 }
240
241 pub fn len(&self) -> usize {
243 self.errors.len()
244 }
245
246 pub fn is_empty(&self) -> bool {
248 self.errors.is_empty()
249 }
250
251 pub fn take_errors(&mut self) -> Vec<Error> {
253 std::mem::take(&mut self.errors)
254 }
255
256 pub fn count_by_kind(&self, kind: ErrorKind) -> usize {
258 self.errors.iter().filter(|e| e.kind() == kind).count()
259 }
260
261 pub fn errors_above(&self, min: Severity) -> Vec<&Error> {
263 self.errors.iter().filter(|e| e.severity() >= min).collect()
264 }
265}
266
267#[derive(Debug, Clone)]
276pub struct ErrorBudget {
277 convergence: u32,
278 numerical: u32,
279 bounds: u32,
280 capacity: u32,
281 unsupported: u32,
282 other: u32,
283}
284
285impl Default for ErrorBudget {
286 fn default() -> Self {
287 Self {
288 convergence: 16,
289 numerical: 8,
290 bounds: 4,
291 capacity: 2,
292 unsupported: 32,
293 other: 64,
294 }
295 }
296}
297
298impl ErrorBudget {
299 pub fn new() -> Self {
301 Self::default()
302 }
303
304 pub fn uniform(threshold: u32) -> Self {
306 Self {
307 convergence: threshold,
308 numerical: threshold,
309 bounds: threshold,
310 capacity: threshold,
311 unsupported: threshold,
312 other: threshold,
313 }
314 }
315
316 pub fn check(&mut self, kind: ErrorKind) -> bool {
321 let slot = match kind {
322 ErrorKind::Convergence => &mut self.convergence,
323 ErrorKind::Numerical => &mut self.numerical,
324 ErrorKind::Bounds => &mut self.bounds,
325 ErrorKind::Capacity => &mut self.capacity,
326 ErrorKind::Unsupported => &mut self.unsupported,
327 ErrorKind::Other => &mut self.other,
328 };
329 if *slot == 0 {
330 return true;
331 }
332 *slot -= 1;
333 *slot == 0
334 }
335
336 pub fn reset(&mut self) {
338 *self = Self::default();
339 }
340
341 pub fn is_exhausted(&self, kind: ErrorKind) -> bool {
343 match kind {
344 ErrorKind::Convergence => self.convergence == 0,
345 ErrorKind::Numerical => self.numerical == 0,
346 ErrorKind::Bounds => self.bounds == 0,
347 ErrorKind::Capacity => self.capacity == 0,
348 ErrorKind::Unsupported => self.unsupported == 0,
349 ErrorKind::Other => self.other == 0,
350 }
351 }
352}
353
354#[derive(Debug, Default, Clone)]
362pub struct ErrorStats {
363 pub by_kind: [u64; 6],
365 pub by_severity: [u64; 4],
367 pub total: u64,
369}
370
371impl ErrorStats {
372 pub fn new() -> Self {
374 Self::default()
375 }
376
377 pub fn record(&mut self, err: &Error) {
379 self.total += 1;
380 let ki = match err.kind() {
381 ErrorKind::Convergence => 0,
382 ErrorKind::Numerical => 1,
383 ErrorKind::Bounds => 2,
384 ErrorKind::Capacity => 3,
385 ErrorKind::Unsupported => 4,
386 ErrorKind::Other => 5,
387 };
388 self.by_kind[ki] += 1;
389 let si = match err.severity() {
390 Severity::Info => 0,
391 Severity::Warning => 1,
392 Severity::Error => 2,
393 Severity::Fatal => 3,
394 };
395 self.by_severity[si] += 1;
396 }
397
398 pub fn absorb(&mut self, collector: &mut ErrorCollector) {
400 for err in collector.take_errors() {
401 self.record(&err);
402 }
403 }
404
405 pub fn count_kind(&self, kind: ErrorKind) -> u64 {
407 let ki = match kind {
408 ErrorKind::Convergence => 0,
409 ErrorKind::Numerical => 1,
410 ErrorKind::Bounds => 2,
411 ErrorKind::Capacity => 3,
412 ErrorKind::Unsupported => 4,
413 ErrorKind::Other => 5,
414 };
415 self.by_kind[ki]
416 }
417
418 pub fn count_severity(&self, sev: Severity) -> u64 {
420 let si = match sev {
421 Severity::Info => 0,
422 Severity::Warning => 1,
423 Severity::Error => 2,
424 Severity::Fatal => 3,
425 };
426 self.by_severity[si]
427 }
428
429 pub fn reset(&mut self) {
431 *self = Self::default();
432 }
433
434 pub fn has_fatals(&self) -> bool {
436 self.by_severity[3] > 0
437 }
438
439 pub fn merge(&mut self, other: &ErrorStats) {
441 self.total += other.total;
442 for i in 0..6 {
443 self.by_kind[i] += other.by_kind[i];
444 }
445 for i in 0..4 {
446 self.by_severity[i] += other.by_severity[i];
447 }
448 }
449}
450
451#[derive(Debug)]
458pub struct ContextualError {
459 pub error: Error,
461 pub body_a: usize,
463 pub body_b: usize,
465 pub tag: Option<&'static str>,
467}
468
469impl ContextualError {
470 pub fn new(error: Error, body_a: usize, body_b: usize) -> Self {
472 Self {
473 error,
474 body_a,
475 body_b,
476 tag: None,
477 }
478 }
479
480 pub fn with_tag(mut self, tag: &'static str) -> Self {
482 self.tag = Some(tag);
483 self
484 }
485
486 pub fn kind(&self) -> ErrorKind {
488 self.error.kind()
489 }
490
491 pub fn severity(&self) -> Severity {
493 self.error.severity()
494 }
495
496 pub fn recovery_hint(&self) -> RecoveryHint {
498 self.error.recovery_hint()
499 }
500}
501
502impl std::fmt::Display for ContextualError {
503 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504 match self.tag {
505 Some(tag) => write!(
506 f,
507 "[{}] bodies ({},{}) — {}",
508 tag, self.body_a, self.body_b, self.error
509 ),
510 None => write!(
511 f,
512 "bodies ({},{}) — {}",
513 self.body_a, self.body_b, self.error
514 ),
515 }
516 }
517}
518
519#[derive(Debug, Default)]
523pub struct ContextualCollector {
524 errors: Vec<ContextualError>,
525}
526
527impl ContextualCollector {
528 pub fn new() -> Self {
530 Self::default()
531 }
532
533 pub fn push(&mut self, err: ContextualError) -> std::result::Result<(), ContextualError> {
538 if err.severity() == Severity::Fatal {
539 return Err(err);
540 }
541 self.errors.push(err);
542 Ok(())
543 }
544
545 pub fn len(&self) -> usize {
547 self.errors.len()
548 }
549
550 pub fn is_empty(&self) -> bool {
552 self.errors.is_empty()
553 }
554
555 pub fn take_errors(&mut self) -> Vec<ContextualError> {
557 std::mem::take(&mut self.errors)
558 }
559
560 pub fn count_for_body(&self, body: usize) -> usize {
562 self.errors
563 .iter()
564 .filter(|e| e.body_a == body || e.body_b == body)
565 .count()
566 }
567
568 pub fn error_pairs(&self) -> Vec<(usize, usize)> {
570 self.errors.iter().map(|e| (e.body_a, e.body_b)).collect()
571 }
572
573 pub fn drain_into_stats(&mut self, stats: &mut ErrorStats) {
575 for ce in self.take_errors() {
576 stats.record(&ce.error);
577 }
578 }
579}
580
581#[derive(Debug, Clone, Copy, PartialEq, Eq)]
585pub struct RetryPolicy {
586 pub max_convergence_retries: u32,
588 pub max_numerical_retries: u32,
590}
591
592impl Default for RetryPolicy {
593 fn default() -> Self {
594 Self {
595 max_convergence_retries: 2,
596 max_numerical_retries: 1,
597 }
598 }
599}
600
601impl RetryPolicy {
602 pub fn retries_for(&self, kind: ErrorKind) -> u32 {
604 match kind {
605 ErrorKind::Convergence => self.max_convergence_retries,
606 ErrorKind::Numerical => self.max_numerical_retries,
607 _ => 0,
608 }
609 }
610
611 pub fn should_retry(&self, kind: ErrorKind) -> bool {
613 self.retries_for(kind) > 0
614 }
615}
616
617#[cfg(test)]
620mod tests {
621 use super::*;
622
623 #[test]
624 fn general_error_kind_is_other() {
625 let e = Error::General("oops".into());
626 assert_eq!(e.kind(), ErrorKind::Other);
627 }
628
629 #[test]
630 fn gjk_no_converge_kind() {
631 let e = Error::GjkNoConverge {
632 iterations: 64,
633 dist: 0.001,
634 };
635 assert_eq!(e.kind(), ErrorKind::Convergence);
636 assert!(e.is_convergence());
637 }
638
639 #[test]
640 fn ccd_no_converge_kind() {
641 let e = Error::CcdNoConverge {
642 toi: 0.5,
643 iters: 32,
644 };
645 assert_eq!(e.kind(), ErrorKind::Convergence);
646 assert!(e.is_convergence());
647 }
648
649 #[test]
650 fn non_finite_is_fatal_and_severe() {
651 let e = Error::NonFinite {
652 context: "EPA".into(),
653 };
654 assert_eq!(e.severity(), Severity::Fatal);
655 assert!(e.is_severe());
656 assert_eq!(e.recovery_hint(), RecoveryHint::Abort);
657 }
658
659 #[test]
660 fn degenerate_shape_warning() {
661 let e = Error::DegenerateShape {
662 detail: "radius=0".into(),
663 };
664 assert_eq!(e.severity(), Severity::Warning);
665 assert!(!e.is_severe());
666 assert!(e.is_numerical());
667 assert_eq!(e.recovery_hint(), RecoveryHint::SkipPair);
668 }
669
670 #[test]
671 fn index_out_of_range_is_bounds() {
672 let e = Error::IndexOutOfRange { index: 99, len: 10 };
673 assert_eq!(e.kind(), ErrorKind::Bounds);
674 assert!(e.is_bounds());
675 assert!(e.is_severe());
676 }
677
678 #[test]
679 fn collector_push_non_fatal_ok() {
680 let mut col = ErrorCollector::new();
681 col.push(Error::General("warn".into())).unwrap();
682 assert_eq!(col.len(), 1);
683 assert!(col.has_errors());
684 }
685
686 #[test]
687 fn collector_push_fatal_returns_err() {
688 let mut col = ErrorCollector::new();
689 let result = col.push(Error::NonFinite {
690 context: "test".into(),
691 });
692 assert!(result.is_err());
693 assert!(col.is_empty());
695 }
696
697 #[test]
698 fn collector_count_by_kind() {
699 let mut col = ErrorCollector::new();
700 col.push(Error::GjkNoConverge {
701 iterations: 10,
702 dist: 0.01,
703 })
704 .unwrap();
705 col.push(Error::CcdNoConverge { toi: 0.3, iters: 5 })
706 .unwrap();
707 col.push(Error::General("misc".into())).unwrap();
708 assert_eq!(col.count_by_kind(ErrorKind::Convergence), 2);
709 assert_eq!(col.count_by_kind(ErrorKind::Other), 1);
710 }
711
712 #[test]
713 fn collector_errors_above_error_severity() {
714 let mut col = ErrorCollector::new();
715 col.push(Error::GjkNoConverge {
716 iterations: 8,
717 dist: 0.0,
718 })
719 .unwrap();
720 col.push(Error::BroadphaseCapacity { capacity: 1024 })
721 .unwrap();
722 let severe = col.errors_above(Severity::Error);
723 assert_eq!(severe.len(), 1); }
725
726 #[test]
727 fn collector_take_errors_clears() {
728 let mut col = ErrorCollector::new();
729 col.push(Error::General("a".into())).unwrap();
730 col.push(Error::General("b".into())).unwrap();
731 let taken = col.take_errors();
732 assert_eq!(taken.len(), 2);
733 assert!(col.is_empty());
734 }
735
736 #[test]
737 fn severity_ordering() {
738 assert!(Severity::Fatal > Severity::Error);
739 assert!(Severity::Error > Severity::Warning);
740 assert!(Severity::Warning > Severity::Info);
741 }
742
743 #[test]
744 fn unsupported_shape_pair() {
745 let e = Error::UnsupportedShapePair {
746 shape_a: "Torus".into(),
747 shape_b: "Cone".into(),
748 };
749 assert_eq!(e.kind(), ErrorKind::Unsupported);
750 assert_eq!(e.recovery_hint(), RecoveryHint::SkipPair);
751 assert!(!e.is_severe());
752 }
753
754 #[test]
755 fn broadphase_capacity_error_level() {
756 let e = Error::BroadphaseCapacity { capacity: 4096 };
757 assert_eq!(e.kind(), ErrorKind::Capacity);
758 assert_eq!(e.severity(), Severity::Error);
759 assert_eq!(e.recovery_hint(), RecoveryHint::UseFallback);
760 }
761
762 #[test]
763 fn epa_failure_convergence_and_warn() {
764 let e = Error::EpaFailure {
765 reason: "degenerate polytope".into(),
766 };
767 assert_eq!(e.kind(), ErrorKind::Numerical);
768 assert_eq!(e.severity(), Severity::Warning);
769 assert_eq!(e.recovery_hint(), RecoveryHint::TreatAsSeparated);
770 }
771
772 #[test]
773 fn general_error_display() {
774 let e = Error::General("something went wrong".into());
775 assert_eq!(format!("{e}"), "something went wrong");
776 }
777
778 #[test]
779 fn gjk_no_converge_display_contains_iterations() {
780 let e = Error::GjkNoConverge {
781 iterations: 64,
782 dist: 0.001_234,
783 };
784 let s = format!("{e}");
785 assert!(
786 s.contains("64"),
787 "display should contain iteration count: {s}"
788 );
789 }
790
791 #[test]
794 fn budget_uniform_starts_full() {
795 let b = ErrorBudget::uniform(5);
796 assert!(!b.is_exhausted(ErrorKind::Convergence));
797 assert!(!b.is_exhausted(ErrorKind::Numerical));
798 assert!(!b.is_exhausted(ErrorKind::Bounds));
799 }
800
801 #[test]
802 fn budget_check_decrements_to_zero() {
803 let mut b = ErrorBudget::uniform(2);
804 let first = b.check(ErrorKind::Convergence);
805 assert!(!first, "first check should not signal exhaustion yet");
806 let second = b.check(ErrorKind::Convergence);
807 assert!(second, "second check should signal exhaustion (reached 0)");
808 assert!(b.is_exhausted(ErrorKind::Convergence));
809 }
810
811 #[test]
812 fn budget_check_already_zero_returns_true() {
813 let mut b = ErrorBudget::uniform(0);
814 assert!(b.check(ErrorKind::Numerical));
816 assert!(b.check(ErrorKind::Numerical));
817 }
818
819 #[test]
820 fn budget_reset_restores_defaults() {
821 let mut b = ErrorBudget::uniform(1);
822 b.check(ErrorKind::Convergence);
823 assert!(b.is_exhausted(ErrorKind::Convergence));
824 b.reset();
825 assert!(!b.is_exhausted(ErrorKind::Convergence));
827 }
828
829 #[test]
830 fn budget_each_kind_independent() {
831 let mut b = ErrorBudget::uniform(1);
832 b.check(ErrorKind::Convergence);
833 assert!(b.is_exhausted(ErrorKind::Convergence));
834 assert!(!b.is_exhausted(ErrorKind::Numerical));
835 assert!(!b.is_exhausted(ErrorKind::Bounds));
836 }
837
838 #[test]
841 fn stats_starts_zeroed() {
842 let s = ErrorStats::new();
843 assert_eq!(s.total, 0);
844 assert_eq!(s.count_kind(ErrorKind::Convergence), 0);
845 assert_eq!(s.count_severity(Severity::Fatal), 0);
846 }
847
848 #[test]
849 fn stats_record_convergence() {
850 let mut s = ErrorStats::new();
851 let e = Error::GjkNoConverge {
852 iterations: 10,
853 dist: 0.0,
854 };
855 s.record(&e);
856 assert_eq!(s.total, 1);
857 assert_eq!(s.count_kind(ErrorKind::Convergence), 1);
858 assert_eq!(s.count_severity(Severity::Warning), 1);
859 assert!(!s.has_fatals());
860 }
861
862 #[test]
863 fn stats_record_fatal() {
864 let mut s = ErrorStats::new();
865 let e = Error::NonFinite {
866 context: "test".into(),
867 };
868 s.record(&e);
869 assert_eq!(s.total, 1);
870 assert!(s.has_fatals());
871 assert_eq!(s.count_severity(Severity::Fatal), 1);
872 }
873
874 #[test]
875 fn stats_merge() {
876 let mut a = ErrorStats::new();
877 a.record(&Error::General("x".into()));
878 let mut b = ErrorStats::new();
879 b.record(&Error::General("y".into()));
880 b.record(&Error::GjkNoConverge {
881 iterations: 1,
882 dist: 0.0,
883 });
884 a.merge(&b);
885 assert_eq!(a.total, 3);
886 assert_eq!(a.count_kind(ErrorKind::Other), 2);
887 assert_eq!(a.count_kind(ErrorKind::Convergence), 1);
888 }
889
890 #[test]
891 fn stats_absorb_from_collector() {
892 let mut col = ErrorCollector::new();
893 col.push(Error::GjkNoConverge {
894 iterations: 5,
895 dist: 0.1,
896 })
897 .unwrap();
898 col.push(Error::CcdNoConverge { toi: 0.5, iters: 3 })
899 .unwrap();
900 let mut stats = ErrorStats::new();
901 stats.absorb(&mut col);
902 assert_eq!(stats.total, 2);
903 assert_eq!(stats.count_kind(ErrorKind::Convergence), 2);
904 assert!(col.is_empty());
906 }
907
908 #[test]
909 fn stats_reset_clears_all() {
910 let mut s = ErrorStats::new();
911 s.record(&Error::General("a".into()));
912 s.record(&Error::GjkNoConverge {
913 iterations: 1,
914 dist: 0.0,
915 });
916 s.reset();
917 assert_eq!(s.total, 0);
918 assert_eq!(s.count_kind(ErrorKind::Other), 0);
919 assert_eq!(s.count_kind(ErrorKind::Convergence), 0);
920 }
921
922 #[test]
923 fn stats_all_kinds_counted() {
924 let mut s = ErrorStats::new();
925 s.record(&Error::GjkNoConverge {
926 iterations: 1,
927 dist: 0.0,
928 }); s.record(&Error::EpaFailure { reason: "x".into() }); s.record(&Error::IndexOutOfRange { index: 1, len: 0 }); s.record(&Error::BroadphaseCapacity { capacity: 10 }); s.record(&Error::UnsupportedShapePair {
933 shape_a: "A".into(),
934 shape_b: "B".into(),
935 }); s.record(&Error::General("misc".into())); assert_eq!(s.total, 6);
938 assert_eq!(s.count_kind(ErrorKind::Convergence), 1);
939 assert_eq!(s.count_kind(ErrorKind::Numerical), 1);
940 assert_eq!(s.count_kind(ErrorKind::Bounds), 1);
941 assert_eq!(s.count_kind(ErrorKind::Capacity), 1);
942 assert_eq!(s.count_kind(ErrorKind::Unsupported), 1);
943 assert_eq!(s.count_kind(ErrorKind::Other), 1);
944 }
945
946 #[test]
949 fn contextual_error_display_includes_bodies() {
950 let ce = ContextualError::new(
951 Error::GjkNoConverge {
952 iterations: 5,
953 dist: 0.01,
954 },
955 3,
956 7,
957 );
958 let s = format!("{ce}");
959 assert!(s.contains('3'), "should contain body_a: {s}");
960 assert!(s.contains('7'), "should contain body_b: {s}");
961 }
962
963 #[test]
964 fn contextual_error_with_tag_display() {
965 let ce = ContextualError::new(Error::General("fail".into()), 0, 1).with_tag("GJK");
966 let s = format!("{ce}");
967 assert!(s.contains("GJK"), "display should include tag: {s}");
968 }
969
970 #[test]
971 fn contextual_error_delegates_kind_and_severity() {
972 let ce = ContextualError::new(
973 Error::NonFinite {
974 context: "test".into(),
975 },
976 0,
977 1,
978 );
979 assert_eq!(ce.kind(), ErrorKind::Numerical);
980 assert_eq!(ce.severity(), Severity::Fatal);
981 assert_eq!(ce.recovery_hint(), RecoveryHint::Abort);
982 }
983
984 #[test]
987 fn contextual_collector_push_and_len() {
988 let mut col = ContextualCollector::new();
989 col.push(ContextualError::new(Error::General("x".into()), 0, 1))
990 .unwrap();
991 col.push(ContextualError::new(
992 Error::GjkNoConverge {
993 iterations: 1,
994 dist: 0.0,
995 },
996 2,
997 3,
998 ))
999 .unwrap();
1000 assert_eq!(col.len(), 2);
1001 assert!(!col.is_empty());
1002 }
1003
1004 #[test]
1005 fn contextual_collector_fatal_not_stored() {
1006 let mut col = ContextualCollector::new();
1007 let result = col.push(ContextualError::new(
1008 Error::NonFinite {
1009 context: "epa".into(),
1010 },
1011 0,
1012 1,
1013 ));
1014 assert!(result.is_err(), "fatal should not be stored");
1015 assert!(col.is_empty());
1016 }
1017
1018 #[test]
1019 fn contextual_collector_count_for_body() {
1020 let mut col = ContextualCollector::new();
1021 col.push(ContextualError::new(Error::General("a".into()), 5, 3))
1022 .unwrap();
1023 col.push(ContextualError::new(Error::General("b".into()), 5, 7))
1024 .unwrap();
1025 col.push(ContextualError::new(Error::General("c".into()), 1, 2))
1026 .unwrap();
1027 assert_eq!(col.count_for_body(5), 2);
1028 assert_eq!(col.count_for_body(3), 1);
1029 assert_eq!(col.count_for_body(99), 0);
1030 }
1031
1032 #[test]
1033 fn contextual_collector_error_pairs() {
1034 let mut col = ContextualCollector::new();
1035 col.push(ContextualError::new(Error::General("a".into()), 1, 2))
1036 .unwrap();
1037 col.push(ContextualError::new(Error::General("b".into()), 3, 4))
1038 .unwrap();
1039 let pairs = col.error_pairs();
1040 assert_eq!(pairs.len(), 2);
1041 assert!(pairs.contains(&(1, 2)));
1042 assert!(pairs.contains(&(3, 4)));
1043 }
1044
1045 #[test]
1046 fn contextual_collector_drain_into_stats() {
1047 let mut col = ContextualCollector::new();
1048 col.push(ContextualError::new(
1049 Error::GjkNoConverge {
1050 iterations: 3,
1051 dist: 0.01,
1052 },
1053 0,
1054 1,
1055 ))
1056 .unwrap();
1057 col.push(ContextualError::new(Error::General("misc".into()), 2, 3))
1058 .unwrap();
1059 let mut stats = ErrorStats::new();
1060 col.drain_into_stats(&mut stats);
1061 assert_eq!(stats.total, 2);
1062 assert!(col.is_empty());
1063 }
1064
1065 #[test]
1066 fn contextual_collector_take_clears() {
1067 let mut col = ContextualCollector::new();
1068 col.push(ContextualError::new(Error::General("x".into()), 0, 0))
1069 .unwrap();
1070 let taken = col.take_errors();
1071 assert_eq!(taken.len(), 1);
1072 assert!(col.is_empty());
1073 }
1074
1075 #[test]
1078 fn retry_policy_convergence_defaults() {
1079 let p = RetryPolicy::default();
1080 assert!(p.should_retry(ErrorKind::Convergence));
1081 assert!(p.should_retry(ErrorKind::Numerical));
1082 assert!(!p.should_retry(ErrorKind::Bounds));
1083 assert!(!p.should_retry(ErrorKind::Capacity));
1084 assert!(!p.should_retry(ErrorKind::Unsupported));
1085 }
1086
1087 #[test]
1088 fn retry_policy_retries_for_kind() {
1089 let p = RetryPolicy::default();
1090 assert_eq!(p.retries_for(ErrorKind::Convergence), 2);
1091 assert_eq!(p.retries_for(ErrorKind::Numerical), 1);
1092 assert_eq!(p.retries_for(ErrorKind::Other), 0);
1093 }
1094
1095 #[test]
1096 fn retry_policy_custom() {
1097 let p = RetryPolicy {
1098 max_convergence_retries: 5,
1099 max_numerical_retries: 3,
1100 };
1101 assert_eq!(p.retries_for(ErrorKind::Convergence), 5);
1102 assert_eq!(p.retries_for(ErrorKind::Numerical), 3);
1103 assert!(p.should_retry(ErrorKind::Convergence));
1104 assert!(p.should_retry(ErrorKind::Numerical));
1105 }
1106
1107 #[test]
1110 fn collector_is_empty_initially() {
1111 let col = ErrorCollector::new();
1112 assert!(col.is_empty());
1113 assert_eq!(col.len(), 0);
1114 }
1115
1116 #[test]
1117 fn error_kind_all_variants_have_recovery_hints() {
1118 let errors: Vec<Error> = vec![
1120 Error::General("g".into()),
1121 Error::GjkNoConverge {
1122 iterations: 1,
1123 dist: 0.0,
1124 },
1125 Error::EpaFailure { reason: "r".into() },
1126 Error::CcdNoConverge { toi: 0.0, iters: 0 },
1127 Error::DegenerateShape { detail: "d".into() },
1128 Error::IndexOutOfRange { index: 0, len: 0 },
1129 Error::BroadphaseCapacity { capacity: 0 },
1130 Error::NonFinite {
1131 context: "c".into(),
1132 },
1133 Error::UnsupportedShapePair {
1134 shape_a: "A".into(),
1135 shape_b: "B".into(),
1136 },
1137 ];
1138 for e in &errors {
1139 let _ = e.recovery_hint(); let _ = e.kind();
1141 let _ = e.severity();
1142 }
1143 }
1144
1145 #[test]
1146 fn collector_errors_above_info_includes_all() {
1147 let mut col = ErrorCollector::new();
1148 col.push(Error::General("a".into())).unwrap();
1149 col.push(Error::GjkNoConverge {
1150 iterations: 1,
1151 dist: 0.0,
1152 })
1153 .unwrap();
1154 col.push(Error::BroadphaseCapacity { capacity: 10 })
1155 .unwrap();
1156 let above_info = col.errors_above(Severity::Info);
1158 assert_eq!(above_info.len(), 3);
1159 }
1160
1161 #[test]
1162 fn stats_count_severity_info() {
1163 let mut s = ErrorStats::new();
1164 s.record(&Error::General("x".into())); assert_eq!(s.count_severity(Severity::Info), 1);
1166 assert_eq!(s.count_severity(Severity::Warning), 0);
1167 }
1168}