voronoi_go/alive_zone/
validate.rs1use std::collections::BTreeSet;
11
12use thiserror::Error;
13
14use crate::clipping::{ShapeId, ShapeKind, StructureError};
15use crate::{Point, StoneId};
16
17use super::AliveZone;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
25pub enum ZoneError {
26 #[error("the clipping structure is corrupt: {0}")]
28 Structure(#[from] StructureError),
29
30 #[error("stone {stone}'s dead zone names shape {shape:?}, which is not in the graph")]
32 MissingDeadZone {
33 stone: StoneId,
35 shape: ShapeId,
37 },
38
39 #[error("stone {stone}'s dead zone names shape {shape:?}, which is a board edge")]
41 NotADeadZone {
42 stone: StoneId,
44 shape: ShapeId,
46 },
47
48 #[error("shape {shape:?} is named as the dead zone of more than one stone")]
51 SharedDeadZone {
52 shape: ShapeId,
54 },
55
56 #[error("shape {shape:?} is a dead zone that no stone names")]
64 UnnamedDeadZone {
65 shape: ShapeId,
67 },
68
69 #[error("the temporary circle names shape {shape:?}, which is not a dead zone in the graph")]
72 StrandedTempCircle {
73 shape: ShapeId,
75 },
76
77 #[error("the forced eye at {point:?} is filed under another point's key")]
79 MisfiledForcedEye {
80 point: Point,
82 },
83}
84
85impl AliveZone {
86 pub fn validate(&self) -> Result<(), ZoneError> {
103 self.graph.validate()?;
104
105 let mut named: BTreeSet<ShapeId> = BTreeSet::new();
106 for shape in &self.temp_circles {
107 let shape = *shape;
108 let is_dead_zone = self
109 .graph
110 .shape(shape)
111 .is_some_and(|entry| matches!(entry.kind(), ShapeKind::DeadZone(_)));
112 if !is_dead_zone {
113 return Err(ZoneError::StrandedTempCircle { shape });
114 }
115 named.insert(shape);
116 }
117
118 for (stone, shape) in &self.dead_zones {
119 let (stone, shape) = (*stone, *shape);
120 let Some(entry) = self.graph.shape(shape) else {
121 return Err(ZoneError::MissingDeadZone { stone, shape });
122 };
123 if !matches!(entry.kind(), ShapeKind::DeadZone(_)) {
124 return Err(ZoneError::NotADeadZone { stone, shape });
125 }
126 if !named.insert(shape) {
127 return Err(ZoneError::SharedDeadZone { shape });
128 }
129 }
130
131 for (id, shape) in self.graph.shapes() {
134 if matches!(shape.kind(), ShapeKind::DeadZone(_)) && !named.contains(&id) {
135 return Err(ZoneError::UnnamedDeadZone { shape: id });
136 }
137 }
138
139 for (key, point) in self.forced_eyes.entries() {
140 if point.key() != key {
141 return Err(ZoneError::MisfiledForcedEye { point });
142 }
143 }
144
145 Ok(())
146 }
147
148 pub(super) fn debug_validate(&self) {
153 if cfg!(debug_assertions) {
154 if let Err(error) = self.validate() {
155 panic!("the alive zone is corrupt: {error}");
156 }
157 }
158 }
159
160 #[cfg(test)]
179 pub(crate) fn fingerprint(&self) -> Vec<(crate::PointKey, Vec<(u64, bool)>)> {
180 let mut shapes: Vec<(crate::PointKey, Vec<(u64, bool)>)> = self
181 .graph
182 .shape_ids()
183 .filter_map(|shape| {
184 let kind = self.graph.shape(shape)?.kind();
185 let mut segments: Vec<(u64, bool)> = self
186 .graph
187 .node_ids(shape)
188 .into_iter()
189 .filter_map(|id| {
190 let start = self.graph.segment(id)?.start().to_bits();
191 Some((start, self.graph.is_active(id)))
192 })
193 .collect();
194 segments.sort_unstable();
195 Some((kind.offset_to_point(0.0).key(), segments))
196 })
197 .collect();
198 shapes.sort_unstable();
199 shapes
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 #![allow(clippy::unwrap_used, clippy::expect_used)]
206
207 use super::ZoneError;
208 use crate::alive_zone::AliveZone;
209 use crate::clipping::{ShapeId, StructureError};
210 use crate::{Point, PointKey, StoneId};
211
212 const BOARD: f64 = 20.0;
213
214 fn p(x: f64, y: f64) -> Point {
215 Point::new(x, y)
216 }
217
218 fn dangling_shape(zone: &mut AliveZone) -> ShapeId {
222 let ghost = zone.graph.add_dead_zone(p(15.0, 15.0));
223 let _ = zone.graph.remove_shape(ghost);
224 ghost
225 }
226
227 fn populated() -> AliveZone {
228 let mut zone = AliveZone::new(BOARD);
229 zone.remove_circle(StoneId::new(0), p(2.0, 10.0)).unwrap();
230 zone.remove_circle(StoneId::new(1), p(3.0, 11.5)).unwrap();
231 zone.add_forced_eye(p(9.0, 9.0));
232 assert_eq!(zone.validate(), Ok(()));
233 zone
234 }
235
236 #[test]
237 fn a_healthy_zone_validates() {
238 assert_eq!(populated().validate(), Ok(()));
239 }
240
241 #[test]
242 fn corruption_underneath_surfaces_through_the_zone() {
243 let error = ZoneError::from(StructureError::EmptyIndexEntry);
246 assert!(matches!(error, ZoneError::Structure(_)));
247 assert!(error.to_string().starts_with("the clipping structure is"));
248 }
249
250 #[test]
251 fn a_dead_zone_that_has_gone_is_caught() {
252 let mut zone = populated();
253 let ghost = dangling_shape(&mut zone);
254 zone.dead_zones.insert(StoneId::new(9), ghost);
255
256 assert!(matches!(
257 zone.validate(),
258 Err(ZoneError::MissingDeadZone { .. })
259 ));
260 }
261
262 #[test]
263 fn a_stone_naming_a_board_edge_is_caught() {
264 let mut zone = populated();
265 let edge = zone.graph.shape_ids().next().unwrap();
266 zone.dead_zones.insert(StoneId::new(9), edge);
267
268 assert!(matches!(
269 zone.validate(),
270 Err(ZoneError::NotADeadZone { .. })
271 ));
272 }
273
274 #[test]
275 fn two_stones_naming_one_dead_zone_is_caught() {
276 let mut zone = populated();
277 let shared = *zone.dead_zones.get(&StoneId::new(0)).unwrap();
278 zone.dead_zones.insert(StoneId::new(9), shared);
279
280 assert!(matches!(
281 zone.validate(),
282 Err(ZoneError::SharedDeadZone { .. })
283 ));
284 }
285
286 #[test]
287 fn a_dead_zone_no_stone_names_is_caught() {
288 let mut zone = populated();
289 zone.dead_zones.remove(&StoneId::new(0));
291
292 assert!(matches!(
293 zone.validate(),
294 Err(ZoneError::UnnamedDeadZone { .. })
295 ));
296 }
297
298 #[test]
299 fn a_live_temporary_circle_is_named_by_the_zone_itself() {
300 let mut zone = populated();
303 zone.with_temp_circle(p(9.0, 4.0), |zone| {
304 assert_eq!(zone.validate(), Ok(()));
305 assert_eq!(zone.temp_circles.len(), 1);
306 });
307 assert!(zone.temp_circles.is_empty());
308 assert_eq!(zone.validate(), Ok(()));
309 }
310
311 #[test]
312 fn a_temporary_circle_naming_nothing_is_caught() {
313 let mut zone = populated();
314 let ghost = dangling_shape(&mut zone);
315 zone.temp_circles.push(ghost);
316
317 assert!(matches!(
318 zone.validate(),
319 Err(ZoneError::StrandedTempCircle { .. })
320 ));
321 }
322
323 #[test]
324 fn a_forced_eye_under_the_wrong_key_is_caught() {
325 let mut zone = populated();
326 zone.forced_eyes
327 .by_point
328 .insert(PointKey::new(1.0, 1.0), p(4.0, 4.0));
329
330 assert!(matches!(
331 zone.validate(),
332 Err(ZoneError::MisfiledForcedEye { .. })
333 ));
334 }
335
336 #[test]
337 fn a_panic_out_of_a_compound_operation_puts_the_structure_check_back() {
338 let mut zone = populated();
342 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
343 zone.compound(|_| panic!("something went wrong mid-carve"));
344 }));
345
346 assert!(unwound.is_err());
347 assert!(!zone.graph.validation_is_deferred());
348 }
349
350 #[test]
351 #[cfg(debug_assertions)]
352 #[should_panic(expected = "the alive zone is corrupt")]
353 fn a_mutation_on_a_corrupt_zone_panics() {
354 let mut zone = populated();
355 let ghost = dangling_shape(&mut zone);
356 zone.dead_zones.insert(StoneId::new(9), ghost);
357
358 zone.add_forced_eye(p(1.5, 1.5));
360 }
361}