Skip to main content

voronoi_go/clipping/
validate.rs

1//! The structural check that turns a malformed segment list into a test
2//! failure instead of a hang.
3//!
4//! Several algorithms walk a shape's list until they reach a particular
5//! terminator. If the list is broken they never reach it, and the symptom is a
6//! process that stops responding rather than one that reports an error. The
7//! bounded walks catch that at the point of use; this catches it at the point of
8//! damage, which is where it can still be diagnosed.
9//!
10//! [`ClippingGraph::validate`] runs after **every** mutating operation under
11//! `cfg(debug_assertions)`, and the alive zone's own validation delegates to it.
12//! The one exception is the inside of a compound operation, where the check
13//! would be quadratic and says nothing the check at the end of it does not —
14//! [`ClippingGraph::defer_validation`] is where that is written down.
15
16use std::collections::BTreeSet;
17
18use thiserror::Error;
19
20use super::graph::ClippingGraph;
21use super::segment::SegId;
22use super::shape::{Closure, ShapeId};
23
24/// Something the clipping structure guarantees, found not to hold.
25///
26/// Every variant is a bug in whatever last mutated the structure. None of them
27/// is reachable from user input: a move that cannot be played is rejected long
28/// before it reaches this layer.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
30pub enum StructureError {
31    /// A list links to a segment that is not in the arena.
32    #[error("shape {shape:?} links to segment {segment:?}, which is not in the arena")]
33    DanglingSegment {
34        /// The shape whose list holds the broken link.
35        shape: ShapeId,
36        /// The segment that is not there.
37        segment: SegId,
38    },
39
40    /// A segment appears in the list of a shape that does not own it.
41    #[error("segment {segment:?} is in shape {shape:?}'s list but belongs to {owner:?}")]
42    WrongParent {
43        /// The shape whose list it turned up in.
44        shape: ShapeId,
45        /// The segment.
46        segment: SegId,
47        /// The shape it says it belongs to.
48        owner: ShapeId,
49    },
50
51    /// The same segment is reachable from two shapes' heads.
52    #[error("segment {segment:?} is in more than one shape's list")]
53    SegmentInTwoLists {
54        /// The segment.
55        segment: SegId,
56    },
57
58    /// `a.next == b` without `b.prev == a`, or the mirror of that.
59    #[error("segments {from:?} and {to:?} do not agree about the link between them")]
60    AsymmetricLink {
61        /// The segment whose `next` points at `to`.
62        from: SegId,
63        /// The segment whose `prev` should point back at `from`.
64        to: SegId,
65    },
66
67    /// Walking from a shape's head did not return to it within the shape's
68    /// recorded segment count — the list has a cycle that misses the head, or
69    /// the count is wrong.
70    #[error("walking shape {shape:?}'s list did not return to its head within {bound} segments")]
71    WalkOverran {
72        /// The shape.
73        shape: ShapeId,
74        /// The count the walk was bounded by.
75        bound: usize,
76    },
77
78    /// A shape's recorded segment count is not the length of its list.
79    #[error("shape {shape:?} records {recorded} segments but its list holds {walked}")]
80    CountMismatch {
81        /// The shape.
82        shape: ShapeId,
83        /// What it says.
84        recorded: usize,
85        /// What the walk found.
86        walked: usize,
87    },
88
89    /// Offsets do not ascend round the list, allowing for one wraparound — and
90    /// for an open shape, allowing for it only at the wrap itself.
91    #[error("shape {shape:?}'s segment offsets are not in order")]
92    OffsetsOutOfOrder {
93        /// The shape.
94        shape: ShapeId,
95    },
96
97    /// A segment's recorded offset is not the offset of the point it starts at.
98    #[error("segment {segment:?}'s offset is not the offset of its start point")]
99    OffsetDisagreesWithPoint {
100        /// The segment.
101        segment: SegId,
102    },
103
104    /// A live segment is missing from the shared-start index.
105    #[error("segment {segment:?} is not indexed under the point it starts at")]
106    MissingFromIndex {
107        /// The segment.
108        segment: SegId,
109    },
110
111    /// The shared-start index refers to a segment that is not in the arena.
112    #[error("the shared-start index refers to segment {segment:?}, which is not in the arena")]
113    DanglingIndexEntry {
114        /// The segment that is not there.
115        segment: SegId,
116    },
117
118    /// The shared-start index files a segment under a point it does not start
119    /// at.
120    #[error("segment {segment:?} is indexed under a point it does not start at")]
121    MisfiledIndexEntry {
122        /// The segment.
123        segment: SegId,
124    },
125
126    /// The shared-start index lists the same segment twice under one point.
127    #[error("the shared-start index lists segment {segment:?} twice under one point")]
128    DuplicateIndexEntry {
129        /// The segment.
130        segment: SegId,
131    },
132
133    /// A point is in the shared-start index with nothing starting there.
134    #[error("the shared-start index holds a point with no segments")]
135    EmptyIndexEntry,
136
137    /// A segment is alive in the arena but no shape's list reaches it.
138    #[error("segment {segment:?} is alive but no shape's list reaches it")]
139    UnreachableSegment {
140        /// The segment.
141        segment: SegId,
142    },
143
144    /// The index holds a different number of segments from the arena.
145    #[error("the shared-start index holds {indexed} segments but {live} are alive")]
146    IndexSizeMismatch {
147        /// How many the index holds.
148        indexed: usize,
149        /// How many are in the arena.
150        live: usize,
151    },
152
153    /// A segment is marked as covered by a shape that no longer exists, which
154    /// would keep it invisible for ever.
155    #[error("segment {segment:?} is covered by shape {shape:?}, which no longer exists")]
156    CoveredByMissingShape {
157        /// The segment.
158        segment: SegId,
159        /// The shape that is gone.
160        shape: ShapeId,
161    },
162
163    /// A shape is marked as covering part of its own outline.
164    #[error("shape {shape:?} covers part of its own outline")]
165    ShapeCoversItself {
166        /// The shape.
167        shape: ShapeId,
168    },
169}
170
171impl ClippingGraph {
172    /// Checks every invariant the structure is supposed to hold.
173    ///
174    /// Specifically: that each shape's list is circular and `prev`/`next`
175    /// symmetric, that walking it from the head returns to the head in exactly
176    /// the recorded number of steps, that offsets ascend round it allowing for
177    /// one wraparound, that every segment belongs to the shape whose list it is
178    /// in and agrees with the point it starts at, that nothing is marked as
179    /// covered by a shape that has gone, and that the segment arena and the
180    /// shared-start index describe the same set of segments **in both
181    /// directions**.
182    ///
183    /// # Errors
184    ///
185    /// Returns the first invariant found not to hold. Any of them means an
186    /// earlier mutation left the structure corrupt.
187    pub fn validate(&self) -> Result<(), StructureError> {
188        let mut reachable: BTreeSet<SegId> = BTreeSet::new();
189        for shape in self.shapes.keys() {
190            self.validate_shape(*shape, &mut reachable)?;
191        }
192
193        // The other direction: nothing alive may be stranded outside every
194        // list. Together with the walks above, the segment arena and the set of
195        // reachable segments are then the same set.
196        for (id, _) in self.arena.iter() {
197            if !reachable.contains(&id) {
198                return Err(StructureError::UnreachableSegment { segment: id });
199            }
200        }
201
202        self.validate_index()
203    }
204
205    /// Walks one shape's list, checking everything that is true of a segment
206    /// and of its place in the list. Records every segment it reaches.
207    fn validate_shape(
208        &self,
209        shape_id: ShapeId,
210        reachable: &mut BTreeSet<SegId>,
211    ) -> Result<(), StructureError> {
212        let Some(shape) = self.shapes.get(&shape_id) else {
213            return Ok(());
214        };
215        let Some(head) = shape.head else {
216            if shape.count != 0 {
217                return Err(StructureError::CountMismatch {
218                    shape: shape_id,
219                    recorded: shape.count,
220                    walked: 0,
221                });
222            }
223            return Ok(());
224        };
225
226        let mut current = head;
227        let mut walked = 0_usize;
228        let mut decreases = 0_usize;
229        let mut decrease_is_the_wrap = false;
230
231        loop {
232            let segment = self
233                .arena
234                .get(current)
235                .ok_or(StructureError::DanglingSegment {
236                    shape: shape_id,
237                    segment: current,
238                })?;
239
240            if segment.parent != shape_id {
241                return Err(StructureError::WrongParent {
242                    shape: shape_id,
243                    segment: current,
244                    owner: segment.parent,
245                });
246            }
247            if !reachable.insert(current) {
248                return Err(StructureError::SegmentInTwoLists { segment: current });
249            }
250            self.validate_links(shape_id, current)?;
251
252            // Compared by bits, because the offset is a pure function of the
253            // point and recomputing it must land on the very same value.
254            // Anything else means the point was changed underneath the offset,
255            // or the segment was filed on the wrong shape.
256            if segment.start.to_bits() != shape.kind.point_to_offset(segment.point).to_bits() {
257                return Err(StructureError::OffsetDisagreesWithPoint { segment: current });
258            }
259            if !self
260                .shared_starts
261                .segments(segment.point)
262                .contains(&current)
263            {
264                return Err(StructureError::MissingFromIndex { segment: current });
265            }
266
267            for covering in &segment.overlapping {
268                if *covering == shape_id {
269                    return Err(StructureError::ShapeCoversItself { shape: shape_id });
270                }
271                if !self.shapes.contains_key(covering) {
272                    return Err(StructureError::CoveredByMissingShape {
273                        segment: current,
274                        shape: *covering,
275                    });
276                }
277            }
278
279            walked += 1;
280            if walked > shape.count {
281                return Err(StructureError::WalkOverran {
282                    shape: shape_id,
283                    bound: shape.count,
284                });
285            }
286
287            let next_start = self
288                .arena
289                .get(segment.next)
290                .map_or(segment.start, |next| next.start);
291            let wraps = segment.next == head;
292            if segment.start > next_start {
293                decreases += 1;
294                decrease_is_the_wrap = wraps;
295            }
296
297            current = segment.next;
298            if wraps {
299                break;
300            }
301        }
302
303        if walked != shape.count {
304            return Err(StructureError::CountMismatch {
305                shape: shape_id,
306                recorded: shape.count,
307                walked,
308            });
309        }
310        // Offsets ascend all the way round, dropping back only once — at the
311        // wrap. An open shape's list does not wrap in the parameter, so the one
312        // drop has to be the link from its tail back to its head, which is what
313        // keeps the head holding the lowest offset.
314        if decreases > 1 {
315            return Err(StructureError::OffsetsOutOfOrder { shape: shape_id });
316        }
317        if matches!(shape.closure, Closure::Open) && decreases == 1 && !decrease_is_the_wrap {
318            return Err(StructureError::OffsetsOutOfOrder { shape: shape_id });
319        }
320
321        Ok(())
322    }
323
324    /// Checks that the segment's neighbours point back at it.
325    fn validate_links(&self, shape_id: ShapeId, current: SegId) -> Result<(), StructureError> {
326        let segment = self
327            .arena
328            .get(current)
329            .ok_or(StructureError::DanglingSegment {
330                shape: shape_id,
331                segment: current,
332            })?;
333
334        let next = self
335            .arena
336            .get(segment.next)
337            .ok_or(StructureError::DanglingSegment {
338                shape: shape_id,
339                segment: segment.next,
340            })?;
341        if next.prev != current {
342            return Err(StructureError::AsymmetricLink {
343                from: current,
344                to: segment.next,
345            });
346        }
347
348        let previous = self
349            .arena
350            .get(segment.prev)
351            .ok_or(StructureError::DanglingSegment {
352                shape: shape_id,
353                segment: segment.prev,
354            })?;
355        if previous.next != current {
356            return Err(StructureError::AsymmetricLink {
357                from: segment.prev,
358                to: current,
359            });
360        }
361
362        Ok(())
363    }
364
365    /// Checks the shared-start index against the arena: every entry names a
366    /// live segment, filed under the point that segment starts at, exactly
367    /// once, and the two hold the same number of segments.
368    fn validate_index(&self) -> Result<(), StructureError> {
369        for (key, segments) in self.shared_starts.iter() {
370            let [_, ..] = segments else {
371                return Err(StructureError::EmptyIndexEntry);
372            };
373            let mut distinct = BTreeSet::new();
374            for id in segments {
375                if !distinct.insert(*id) {
376                    return Err(StructureError::DuplicateIndexEntry { segment: *id });
377                }
378                let segment = self
379                    .arena
380                    .get(*id)
381                    .ok_or(StructureError::DanglingIndexEntry { segment: *id })?;
382                if segment.point.key() != key {
383                    return Err(StructureError::MisfiledIndexEntry { segment: *id });
384                }
385            }
386        }
387
388        let indexed = self.shared_starts.len();
389        if indexed != self.arena.len() {
390            return Err(StructureError::IndexSizeMismatch {
391                indexed,
392                live: self.arena.len(),
393            });
394        }
395
396        Ok(())
397    }
398
399    /// Panics if the structure is corrupt, in a debug build.
400    ///
401    /// Called at the end of every mutating operation. In a release build the
402    /// check compiles away, and while a compound mutation is in progress it is
403    /// deferred — see [`ClippingGraph::defer_validation`].
404    pub(super) fn debug_validate(&self) {
405        if cfg!(debug_assertions) && self.deferred == 0 {
406            if let Err(error) = self.validate() {
407                panic!("the clipping structure is corrupt: {error}");
408            }
409        }
410    }
411
412    /// Suspends the per-mutation check until the matching
413    /// [`ClippingGraph::resume_validation`].
414    ///
415    /// One carve is a shape, a few segment insertions per shape already on the
416    /// board, and a covered run marked on each of them — every one of which is a
417    /// mutating operation that checks the *whole* structure. That makes a carve
418    /// quadratic in the size of the board for no added coverage: the caller
419    /// validates in full the moment the compound operation is complete, and
420    /// until then the structure is mid-edit rather than settled.
421    ///
422    /// What is given up is the resolution of the report — damage is attributed
423    /// to the carve rather than to the insertion within it. Nothing goes
424    /// unchecked, and the deferral cannot outlive one operation:
425    /// [`AliveZone::compound`](crate::AliveZone) resumes from a guard's `Drop`.
426    pub(crate) fn defer_validation(&mut self) {
427        if cfg!(debug_assertions) {
428            self.deferred += 1;
429        }
430    }
431
432    /// Ends one [`ClippingGraph::defer_validation`]. Deliberately does not
433    /// validate: the caller does, once, with everything it owns restored too.
434    pub(crate) fn resume_validation(&mut self) {
435        if cfg!(debug_assertions) {
436            self.deferred = self.deferred.saturating_sub(1);
437        }
438    }
439
440    /// Whether the per-mutation check is deferred right now.
441    ///
442    /// Only the tests ask, and what they ask about is the guard: every deferral
443    /// is resumed from a `Drop`, so this is false everywhere else.
444    #[cfg(test)]
445    pub(crate) const fn validation_is_deferred(&self) -> bool {
446        self.deferred > 0
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    #![allow(clippy::unwrap_used, clippy::expect_used)]
453
454    use super::StructureError;
455    use crate::Point;
456    use crate::clipping::{ClippingGraph, SegId, ShapeId, ShapeKind};
457
458    const BOARD: f64 = 20.0;
459
460    fn p(x: f64, y: f64) -> Point {
461        Point::new(x, y)
462    }
463
464    /// A graph with a stone's dead zone clipped against the left edge, so that
465    /// there is a real structure to break.
466    fn populated() -> (ClippingGraph, ShapeId, ShapeId) {
467        let mut graph = ClippingGraph::new(BOARD);
468        let edge = graph.shape_ids().next().unwrap();
469        let center = p(2.0, 10.0);
470        let zone = graph.add_dead_zone(center);
471        let circle = ShapeKind::dead_zone(center).circle().unwrap();
472
473        let crossing = graph.intersect(edge, circle).unwrap();
474        let edge_covered = graph.insert_or_get_existing(edge, crossing.entry).unwrap();
475        let zone_uncovered = graph.insert_or_get_existing(zone, crossing.entry).unwrap();
476        let edge_uncovered = graph.insert_or_get_existing(edge, crossing.exit).unwrap();
477        let zone_covered = graph.insert_or_get_existing(zone, crossing.exit).unwrap();
478        graph.add_overlapping(edge_covered, edge_uncovered, zone);
479        graph.add_overlapping(zone_covered, zone_uncovered, edge);
480
481        assert!(graph.validate().is_ok());
482        (graph, edge, zone)
483    }
484
485    fn head_of(graph: &ClippingGraph, shape: ShapeId) -> SegId {
486        graph.shape(shape).unwrap().head().unwrap()
487    }
488
489    #[test]
490    fn a_healthy_structure_validates() {
491        let (graph, _, _) = populated();
492        assert_eq!(graph.validate(), Ok(()));
493    }
494
495    #[test]
496    fn an_asymmetric_link_is_caught() {
497        let (mut graph, edge, _) = populated();
498        let head = head_of(&graph, edge);
499        let second = graph.segment(head).unwrap().next();
500        let third = graph.segment(second).unwrap().next();
501
502        // `head.next` now skips a segment, but nothing's `prev` was updated.
503        graph.arena.get_mut(head).unwrap().next = third;
504
505        assert!(matches!(
506            graph.validate(),
507            Err(StructureError::AsymmetricLink { .. })
508        ));
509    }
510
511    #[test]
512    fn a_self_loop_part_way_round_the_list_is_caught() {
513        let (mut graph, edge, _) = populated();
514        let head = head_of(&graph, edge);
515        let second = graph.segment(head).unwrap().next();
516
517        // The walk would spin here for ever. Symmetry is what makes a circular
518        // list come back round at all, so this is caught as the broken link it
519        // is, before the bound is ever reached.
520        graph.arena.get_mut(second).unwrap().next = second;
521        graph.arena.get_mut(second).unwrap().prev = second;
522
523        assert!(matches!(
524            graph.validate(),
525            Err(StructureError::AsymmetricLink { .. })
526        ));
527    }
528
529    #[test]
530    fn a_count_larger_than_the_list_is_caught() {
531        let (mut graph, edge, _) = populated();
532        graph.shapes.get_mut(&edge).unwrap().count += 1;
533
534        assert!(matches!(
535            graph.validate(),
536            Err(StructureError::CountMismatch { .. })
537        ));
538    }
539
540    #[test]
541    fn a_count_smaller_than_the_list_is_caught() {
542        let (mut graph, edge, _) = populated();
543        graph.shapes.get_mut(&edge).unwrap().count -= 1;
544
545        // The count is what every walk is bounded by, so a count that is too
546        // small is the same failure as a list that never terminates.
547        assert!(matches!(
548            graph.validate(),
549            Err(StructureError::WalkOverran { .. })
550        ));
551    }
552
553    #[test]
554    fn offsets_out_of_order_are_caught() {
555        let (mut graph, edge, _) = populated();
556        let head = head_of(&graph, edge);
557        let second = graph.segment(head).unwrap().next();
558        let third = graph.segment(second).unwrap().next();
559        let fourth = graph.segment(third).unwrap().next();
560
561        // Swap two neighbours' places in the list without moving their offsets,
562        // which is exactly what a mis-aimed insertion would do. Every link
563        // stays symmetric; only the ordering breaks.
564        graph.arena.get_mut(head).unwrap().next = third;
565        graph.arena.get_mut(third).unwrap().prev = head;
566        graph.arena.get_mut(third).unwrap().next = second;
567        graph.arena.get_mut(second).unwrap().prev = third;
568        graph.arena.get_mut(second).unwrap().next = fourth;
569        graph.arena.get_mut(fourth).unwrap().prev = second;
570
571        assert!(matches!(
572            graph.validate(),
573            Err(StructureError::OffsetsOutOfOrder { .. })
574        ));
575    }
576
577    #[test]
578    fn an_open_shape_whose_head_is_not_its_lowest_offset_is_caught() {
579        let (mut graph, edge, _) = populated();
580        let head = head_of(&graph, edge);
581        let second = graph.segment(head).unwrap().next();
582
583        // Rotate the head forwards. The list is still perfectly circular; it is
584        // only the edge's openness that this breaks, because the wrap link now
585        // falls in the middle of the run of offsets.
586        graph.shapes.get_mut(&edge).unwrap().head = Some(second);
587
588        assert!(matches!(
589            graph.validate(),
590            Err(StructureError::OffsetsOutOfOrder { .. })
591        ));
592    }
593
594    #[test]
595    fn a_segment_missing_from_the_index_is_caught() {
596        let (mut graph, edge, _) = populated();
597        let head = head_of(&graph, edge);
598        let point = graph.segment(head).unwrap().point();
599
600        graph.shared_starts.remove(point, head);
601
602        assert!(matches!(
603            graph.validate(),
604            Err(StructureError::MissingFromIndex { .. })
605        ));
606    }
607
608    #[test]
609    fn an_index_entry_pointing_at_nothing_is_caught() {
610        let (mut graph, _, _) = populated();
611        graph.shared_starts.add(p(3.0, 3.0), SegId::new(4_000));
612
613        assert!(matches!(
614            graph.validate(),
615            Err(StructureError::DanglingIndexEntry { .. })
616        ));
617    }
618
619    #[test]
620    fn an_index_entry_filed_under_the_wrong_point_is_caught() {
621        let (mut graph, edge, _) = populated();
622        let head = head_of(&graph, edge);
623        graph.shared_starts.add(p(3.0, 3.0), head);
624
625        assert!(matches!(
626            graph.validate(),
627            Err(StructureError::MisfiledIndexEntry { .. })
628        ));
629    }
630
631    #[test]
632    fn a_segment_no_shape_can_reach_is_caught() {
633        let (mut graph, edge, _) = populated();
634        let head = head_of(&graph, edge);
635        let second = graph.segment(head).unwrap().next();
636        let third = graph.segment(second).unwrap().next();
637
638        // Unlink one segment without deleting it.
639        graph.arena.get_mut(head).unwrap().next = third;
640        graph.arena.get_mut(third).unwrap().prev = head;
641        graph.shapes.get_mut(&edge).unwrap().count -= 1;
642
643        assert!(matches!(
644            graph.validate(),
645            Err(StructureError::UnreachableSegment { .. })
646        ));
647    }
648
649    #[test]
650    fn a_segment_in_the_wrong_shapes_list_is_caught() {
651        let (mut graph, edge, zone) = populated();
652        let head = head_of(&graph, edge);
653        graph.arena.get_mut(head).unwrap().parent = zone;
654
655        assert!(matches!(
656            graph.validate(),
657            Err(StructureError::WrongParent { .. })
658        ));
659    }
660
661    #[test]
662    fn an_offset_that_does_not_match_its_point_is_caught() {
663        let (mut graph, edge, _) = populated();
664        let head = head_of(&graph, edge);
665        graph.arena.get_mut(head).unwrap().start += 1.0;
666
667        assert!(matches!(
668            graph.validate(),
669            Err(StructureError::OffsetDisagreesWithPoint { .. })
670        ));
671    }
672
673    #[test]
674    fn a_cover_by_a_shape_that_has_gone_is_caught() {
675        let (mut graph, _, zone) = populated();
676        // Take the dead zone away without letting the edge forget it first.
677        graph.shapes.remove(&zone);
678
679        assert!(matches!(
680            graph.validate(),
681            Err(StructureError::CoveredByMissingShape { .. })
682        ));
683    }
684
685    #[test]
686    fn a_shape_covering_itself_is_caught() {
687        let (mut graph, edge, _) = populated();
688        let head = head_of(&graph, edge);
689        graph.arena.get_mut(head).unwrap().overlapping.insert(edge);
690
691        assert!(matches!(
692            graph.validate(),
693            Err(StructureError::ShapeCoversItself { .. })
694        ));
695    }
696
697    #[test]
698    #[cfg(debug_assertions)]
699    #[should_panic(expected = "the clipping structure is corrupt")]
700    fn a_mutation_on_a_corrupt_structure_panics() {
701        let (mut graph, edge, _) = populated();
702        let head = head_of(&graph, edge);
703        graph.shapes.get_mut(&edge).unwrap().count += 1;
704
705        // The next mutating operation validates, and finds the damage.
706        graph.delete_segment(head);
707    }
708
709    #[test]
710    #[cfg(debug_assertions)]
711    fn a_deferred_mutation_does_not_check_and_the_damage_is_still_there() {
712        let (mut graph, edge, _) = populated();
713        let head = head_of(&graph, edge);
714        graph.shapes.get_mut(&edge).unwrap().count += 1;
715
716        // The same mutation the test above panics on, inside a deferral. It has
717        // to return: the check is what the deferral suspends.
718        graph.defer_validation();
719        graph.delete_segment(head);
720        graph.resume_validation();
721
722        // Nothing was swallowed — the deferral moves the check, and an explicit
723        // one still finds what the per-mutation check would have.
724        assert!(graph.validate().is_err());
725    }
726
727    #[test]
728    #[cfg(debug_assertions)]
729    fn deferrals_nest_so_an_inner_resume_cannot_turn_the_check_back_on() {
730        let (mut graph, edge, _) = populated();
731        let head = head_of(&graph, edge);
732        graph.shapes.get_mut(&edge).unwrap().count += 1;
733
734        graph.defer_validation();
735        graph.defer_validation();
736        graph.resume_validation();
737
738        // The outer deferral is still open, so the mutation that panics with
739        // the check on returns instead.
740        graph.delete_segment(head);
741
742        graph.resume_validation();
743        assert_eq!(graph.deferred, 0, "the deferrals balanced out");
744    }
745}