1use std::path::{Path, PathBuf};
2use std::str::FromStr;
3
4use image::{DynamicImage, Rgb, RgbImage};
5use itertools::Itertools;
6
7use crate::block::Block;
8use crate::everything::Everything;
9#[cfg(test)]
10use crate::fileset::FileStage;
11use crate::fileset::{FileEntry, FileHandler};
12use crate::helpers::{TigerHashMap, TigerHashSet};
13use crate::item::Item;
14use crate::parse::ParserMemory;
15use crate::parse::csv::{parse_csv, read_csv};
16use crate::pdxfile::PdxFile;
17use crate::report::{ErrorKey, Severity, err, fatal, report, untidy, warn};
18use crate::token::{Loc, Token};
19
20pub type ProvId = u32;
21
22#[derive(Clone, Debug, Default)]
23pub struct ImperatorProvinces {
24 colors: TigerHashSet<Rgb<u8>>,
26
27 provinces_png: Option<RgbImage>,
29
30 provinces: TigerHashMap<ProvId, Province>,
34
35 definition_csv: Option<FileEntry>,
37
38 adjacencies: Vec<Adjacency>,
39
40 impassable: TigerHashSet<ProvId>,
41
42 sea_or_river: TigerHashSet<ProvId>,
43
44 default_map: Option<Block>,
45
46 pending_map_files: Vec<FileEntry>,
47}
48
49fn map_filename(token: Option<&Token>) -> Option<String> {
50 token
51 .map(|value| value.as_str().trim_matches('"').to_string())
52 .filter(|value| !value.is_empty())
53}
54
55fn matches_entry(entry: &FileEntry, expected: Option<&str>) -> bool {
56 let Some(expected) = expected else { return false };
57 let expected = expected.trim();
58 if expected.is_empty() {
59 return false;
60 }
61
62 let expected_path = Path::new(expected);
63 if expected_path == entry.path() {
64 return true;
65 }
66
67 expected_path.file_name().is_some_and(|filename| filename == entry.filename())
68}
69
70fn is_map_key(key: &Token) -> bool {
71 key.lowercase_is("definitions")
72 || key.lowercase_is("provinces")
73 || key.lowercase_is("positions")
74 || key.lowercase_is("rivers")
75 || key.lowercase_is("topology")
76 || key.lowercase_is("adjacencies")
77 || key.lowercase_is("areas")
78 || key.lowercase_is("regions")
79 || key.lowercase_is("ports")
80 || key.lowercase_is("climate")
81}
82
83impl ImperatorProvinces {
84 fn expected_map_filename(&self, key: &str) -> Option<String> {
85 if let Some(block) = &self.default_map {
86 if let Some(value) = map_filename(block.get_field_value(key)) {
87 return Some(value);
88 }
89 }
90
91 match key {
92 "definitions" => Some("definition.csv".to_string()),
93 "provinces" => Some("provinces.png".to_string()),
94 "positions" => Some("positions.txt".to_string()),
95 "rivers" => Some("rivers.png".to_string()),
96 "topology" => Some("heightmap.heightmap".to_string()),
97 "adjacencies" => Some("adjacencies.csv".to_string()),
98 "areas" => Some("areas.txt".to_string()),
99 "regions" => Some("regions.txt".to_string()),
100 "ports" => Some("ports.csv".to_string()),
101 "climate" => Some("climate.txt".to_string()),
102 _ => None,
103 }
104 }
105 fn province_color(&self, provid: ProvId) -> Option<Rgb<u8>> {
106 self.provinces.get(&provid).map(|p| p.color)
107 }
108
109 fn provinces_png_pixel(&self, coords: Coords) -> Option<Rgb<u8>> {
110 let img = self.provinces_png.as_ref()?;
111
112 let x = u32::try_from(coords.x).ok()?;
113 let y = u32::try_from(coords.y).ok()?;
114
115 if x >= img.width() || y >= img.height() {
117 return None;
118 }
119
120 Some(*img.get_pixel(x, y))
121 }
122
123 fn parse_definition(&mut self, csv: &[Token]) {
124 if let Some(province) = Province::parse(csv) {
125 if self.provinces.contains_key(&province.id) {
126 err(ErrorKey::DuplicateItem)
127 .msg("duplicate entry for this province id")
128 .loc(&province.comment)
129 .push();
130 }
131 self.provinces.insert(province.id, province);
132 }
133 }
134
135 pub fn load_impassable(&mut self, block: &Block) {
136 enum Expecting<'a> {
137 Range(&'a Token),
138 List(&'a Token),
139 Nothing,
140 }
141
142 let mut expecting = Expecting::Nothing;
143 for item in block.iter_items() {
144 match expecting {
145 Expecting::Nothing => {
146 if let Some((key, token)) = item.expect_assignment() {
147 if key.lowercase_is("sea_zones")
148 || key.lowercase_is("river_provinces")
149 || key.lowercase_is("impassable_terrain")
150 || key.lowercase_is("uninhabitable")
151 || key.lowercase_is("wasteland")
152 || key.lowercase_is("lakes")
153 {
154 if token.is("LIST") {
155 expecting = Expecting::List(key);
156 } else if token.is("RANGE") {
157 expecting = Expecting::Range(key);
158 } else {
159 expecting = Expecting::Nothing;
160 }
161 } else if !is_map_key(key) {
162 let msg = format!("unexpected key `{key}`");
163 warn(ErrorKey::UnknownField).msg(msg).loc(key).push();
164 }
165 }
166 }
167 Expecting::Range(key) => {
168 if let Some(block) = item.expect_block() {
169 let vec: Vec<&Token> = block.iter_values().collect();
170 if vec.len() != 2 {
171 err(ErrorKey::Validation).msg("invalid RANGE").loc(block).push();
172 expecting = Expecting::Nothing;
173 continue;
174 }
175 let from = vec[0].as_str().parse::<ProvId>();
176 let to = vec[1].as_str().parse::<ProvId>();
177 if from.is_err() || to.is_err() {
178 err(ErrorKey::Validation).msg("invalid RANGE").loc(block).push();
179 expecting = Expecting::Nothing;
180 continue;
181 }
182 for provid in from.unwrap()..=to.unwrap() {
183 self.impassable.insert(provid);
184 if key.is("sea_zones") || key.is("river_provinces") {
185 self.sea_or_river.insert(provid);
186 }
187 }
188 }
189 expecting = Expecting::Nothing;
190 }
191 Expecting::List(key) => {
192 if let Some(block) = item.expect_block() {
193 for token in block.iter_values() {
194 let provid = token.as_str().parse::<ProvId>();
195 if let Ok(provid) = provid {
196 self.impassable.insert(provid);
197 if key.is("sea_zones") || key.is("river_provinces") {
198 self.sea_or_river.insert(provid);
199 }
200 } else {
201 err(ErrorKey::Validation)
202 .msg("invalid LIST item")
203 .loc(token)
204 .push();
205 break;
206 }
207 }
208 }
209 expecting = Expecting::Nothing;
210 }
211 }
212 }
213 }
214
215 pub fn verify_exists_implied(&self, key: &str, item: &Token, max_sev: Severity) {
216 if let Ok(provid) = key.parse::<ProvId>() {
217 if !self.provinces.contains_key(&provid) {
218 let msg = format!("province {provid} not defined in map_data/definition.csv");
219 report(ErrorKey::MissingItem, Item::Province.severity()).msg(msg).loc(item).push();
220 }
221 } else {
222 let msg = "province id should be numeric";
223 let sev = Item::Province.severity().at_most(max_sev);
224 report(ErrorKey::Validation, sev).msg(msg).loc(item).push();
225 }
226 }
227
228 pub fn exists(&self, key: &str) -> bool {
229 if let Ok(provid) = key.parse::<ProvId>() {
230 self.provinces.contains_key(&provid)
231 } else {
232 false
233 }
234 }
235
236 pub fn iter_keys(&self) -> impl Iterator<Item = &Token> {
237 self.provinces.values().map(|item| &item.key)
238 }
239
240 pub fn validate(&self, _data: &Everything) {
241 for item in &self.adjacencies {
242 item.validate(self);
243 }
244 }
245
246 fn handle_adjacencies_content(&mut self, entry: &FileEntry, content: &str) {
247 let mut seen_terminator = false;
248 for csv in parse_csv(entry, 1, content) {
249 if csv[0].is("-1") {
250 seen_terminator = true;
251 } else if seen_terminator {
252 let msg = "the line with all `-1;` should be the last line in the file";
253 warn(ErrorKey::ParseError).msg(msg).loc(&csv[0]).push();
254 break;
255 } else {
256 self.adjacencies.extend(Adjacency::parse(&csv));
257 }
258 }
259 if !seen_terminator {
260 let msg = "Imperator needs a line with all `-1;` at the end of this file";
261 err(ErrorKey::ParseError).msg(msg).loc(entry).push();
262 }
263 }
264
265 fn handle_definitions_content(&mut self, entry: &FileEntry, content: &str) {
266 self.definition_csv = Some(entry.clone());
267 for csv in parse_csv(entry, 0, content) {
268 self.parse_definition(&csv);
269 }
270 }
271
272 fn handle_provinces_image(&mut self, img: DynamicImage, entry: &FileEntry) {
273 match img {
274 DynamicImage::ImageRgb8(img) => {
275 for pixel in img.pixels().dedup() {
276 self.colors.insert(*pixel);
277 }
278
279 self.provinces_png = Some(img);
281 }
282 other => {
283 let msg = format!(
284 "`{}` has wrong color format `{:?}`, should be Rgb8",
285 entry.path().display(),
286 other.color()
287 );
288 err(ErrorKey::ImageFormat).msg(msg).loc(entry).push();
289 }
290 }
291 }
292
293 fn process_map_entry(&mut self, entry: &FileEntry) {
294 let adjacencies = self.expected_map_filename("adjacencies");
295 if matches_entry(entry, adjacencies.as_deref()) {
296 let content = match read_csv(entry.fullpath()) {
297 Ok(content) => content,
298 Err(e) => {
299 err(ErrorKey::ReadError)
300 .msg(format!("could not read file: {e:#}"))
301 .loc(entry)
302 .push();
303 return;
304 }
305 };
306 self.handle_adjacencies_content(entry, &content);
307 return;
308 }
309
310 let definitions = self.expected_map_filename("definitions");
311 if matches_entry(entry, definitions.as_deref()) {
312 let content = match read_csv(entry.fullpath()) {
313 Ok(content) => content,
314 Err(e) => {
315 let msg = format!("could not read `{}`: {:#}", entry.path().display(), e);
316 err(ErrorKey::ReadError).msg(msg).loc(entry).push();
317 return;
318 }
319 };
320 self.handle_definitions_content(entry, &content);
321 return;
322 }
323
324 let provinces = self.expected_map_filename("provinces");
325 if matches_entry(entry, provinces.as_deref()) {
326 let img = match image::open(entry.fullpath()) {
327 Ok(img) => img,
328 Err(e) => {
329 let msg = format!("could not read `{}`: {e:#}", entry.path().display());
330 err(ErrorKey::ReadError).msg(msg).loc(entry).push();
331 return;
332 }
333 };
334 self.handle_provinces_image(img, entry);
335 }
336 }
337
338 fn process_pending_map_entries(&mut self) {
339 let pending = std::mem::take(&mut self.pending_map_files);
340 for entry in pending {
341 self.process_map_entry(&entry);
342 }
343 }
344}
345
346#[derive(Debug)]
347pub enum FileContent {
348 DefaultMap(Block),
349 Deferred,
350}
351
352impl FileHandler<FileContent> for ImperatorProvinces {
353 fn subpath(&self) -> PathBuf {
354 PathBuf::from("map_data")
355 }
356
357 fn load_file(&self, entry: &FileEntry, parser: &ParserMemory) -> Option<FileContent> {
358 if entry.path().components().count() == 2 {
359 if &*entry.filename().to_string_lossy() == "default.map" {
360 return PdxFile::read_optional_bom(entry, parser).map(FileContent::DefaultMap);
361 }
362 return Some(FileContent::Deferred);
363 }
364 None
365 }
366
367 fn handle_file(&mut self, entry: &FileEntry, content: FileContent) {
368 match content {
369 FileContent::DefaultMap(block) => {
370 self.default_map = Some(block.clone());
371 self.load_impassable(&block);
372 }
373 FileContent::Deferred => {
374 if self.default_map.is_some() {
375 self.process_map_entry(entry);
376 } else {
377 self.pending_map_files.push(entry.clone());
378 }
379 }
380 }
381 }
382
383 fn finalize(&mut self) {
384 self.process_pending_map_entries();
385
386 if self.definition_csv.is_none() {
387 eprintln!("map_data/definition.csv is missing?!?");
389 return;
390 }
391 let definition_csv = self.definition_csv.as_ref().unwrap();
392
393 let mut seen_colors = TigerHashMap::default();
394 #[allow(clippy::cast_possible_truncation)]
395 for i in 1..self.provinces.len() as u32 {
396 if let Some(province) = self.provinces.get(&i) {
397 if let Some(k) = seen_colors.get(&province.color) {
398 let msg = format!("color was already used for id {k}");
399 warn(ErrorKey::Colors).msg(msg).loc(&province.comment).push();
400 } else {
401 seen_colors.insert(province.color, i);
402 }
403 } else {
404 let msg = format!("province ids must be sequential, but {i} is missing");
405 err(ErrorKey::Validation).msg(msg).loc(definition_csv).push();
406 return;
407 }
408 }
409 for color in &self.colors {
410 if !seen_colors.contains_key(color) {
411 let Rgb(rgb) = color;
412 let msg = format!(
413 "definitions.csv lacks entry for color ({}, {}, {})",
414 rgb[0], rgb[1], rgb[2]
415 );
416 untidy(ErrorKey::Colors).msg(msg).loc(definition_csv).push();
417 }
418 }
419 }
420}
421
422#[derive(Copy, Clone, Debug, Default)]
423pub struct Coords {
424 x: i32,
425 y: i32,
426}
427
428impl Coords {
429 fn is_sentinel(self) -> bool {
430 self.x == -1 && self.y == -1
431 }
432}
433
434#[allow(dead_code)] #[derive(Clone, Debug)]
436pub struct Adjacency {
437 line: Loc,
438 from: ProvId,
439 to: ProvId,
440 kind: Token,
442 through: ProvId,
443 start: Coords,
446 stop: Coords,
447 comment: Token,
448}
449
450fn verify<T: FromStr>(v: &Token, msg: &str) -> Option<T> {
451 let r = v.as_str().parse().ok();
452 if r.is_none() {
453 err(ErrorKey::ParseError).msg(msg).loc(v).push();
454 }
455 r
456}
457
458impl Adjacency {
459 pub fn parse(csv: &[Token]) -> Option<Self> {
460 if csv.is_empty() {
461 return None;
462 }
463
464 let line = csv[0].loc;
465
466 if csv.len() != 9 {
467 let msg = "wrong number of fields for this line, expected 9";
468 err(ErrorKey::ParseError).msg(msg).loc(&csv[0]).push();
469 return None;
470 }
471
472 let from = verify(&csv[0], "expected province id");
473 let to = verify(&csv[1], "expected province id");
474 let through = verify(&csv[3], "expected province id");
475 let start_x = verify(&csv[4], "expected x coordinate");
476 let start_y = verify(&csv[5], "expected y coordinate");
477 let stop_x = verify(&csv[6], "expected x coordinate");
478 let stop_y = verify(&csv[7], "expected y coordinate");
479
480 Some(Adjacency {
481 line,
482 from: from?,
483 to: to?,
484 kind: csv[2].clone(),
485 through: through?,
486 start: Coords { x: start_x?, y: start_y? },
487 stop: Coords { x: stop_x?, y: stop_y? },
488 comment: csv[8].clone(),
489 })
490 }
491
492 fn validate(&self, provinces: &ImperatorProvinces) {
493 for prov in &[self.from, self.to, self.through] {
494 if !provinces.provinces.contains_key(prov) {
495 let msg = format!("province id {prov} not defined in definitions.csv");
496 fatal(ErrorKey::Crash).msg(msg).loc(self.line).push();
497 }
498 }
499
500 if !self.kind.lowercase_is("sea") && !self.kind.lowercase_is("river_large") {
501 let msg = format!(
502 "adjacency type `{}` is invalid; expected `sea` or `river_large`",
503 self.kind.as_str()
504 );
505 err(ErrorKey::Validation).msg(msg).loc(&self.kind).push();
506 }
507
508 if self.start.is_sentinel() && self.stop.is_sentinel() {
509 return;
510 }
511
512 let Some(img) = provinces.provinces_png.as_ref() else {
513 return;
515 };
516
517 let (w, h) = (img.width(), img.height());
518 for (label, coords) in [("start", &self.start), ("stop", &self.stop)] {
519 if coords.is_sentinel() {
520 continue;
521 }
522
523 let x = u32::try_from(coords.x);
524 let y = u32::try_from(coords.y);
525 if x.is_err() || y.is_err() {
526 let msg = format!(
527 "{label} coordinate ({}, {}) is out of bounds (image size {}x{})",
528 coords.x, coords.y, w, h
529 );
530 err(ErrorKey::Validation).msg(msg).loc(&self.comment).push();
531 continue;
532 }
533 let (x, y) = (x.unwrap(), y.unwrap());
534 if x >= w || y >= h {
535 let msg = format!(
536 "{label} coordinate ({}, {}) is out of bounds (image size {}x{})",
537 coords.x, coords.y, w, h
538 );
539 err(ErrorKey::Validation).msg(msg).loc(&self.comment).push();
540 }
541 }
542
543 if !self.start.is_sentinel() {
544 let Some(expected_start) = provinces.province_color(self.from) else {
545 return;
546 };
547 if let Some(actual) = provinces.provinces_png_pixel(self.start) {
548 if actual != expected_start {
549 let Rgb([er, eg, eb]) = expected_start;
550 let Rgb([ar, ag, ab]) = actual;
551 let msg = format!(
552 "start coordinate is in the wrong province color: expected ({er}, {eg}, {eb}), got ({ar}, {ag}, {ab})"
553 );
554 err(ErrorKey::Validation).msg(msg).loc(&self.comment).push();
555 }
556 }
557 }
558
559 if !self.stop.is_sentinel() {
560 let Some(expected_stop) = provinces.province_color(self.to) else {
561 return;
562 };
563 if let Some(actual) = provinces.provinces_png_pixel(self.stop) {
564 if actual != expected_stop {
565 let Rgb([er, eg, eb]) = expected_stop;
566 let Rgb([ar, ag, ab]) = actual;
567 let msg = format!(
568 "stop coordinate is in the wrong province color: expected ({er}, {eg}, {eb}), got ({ar}, {ag}, {ab})"
569 );
570 err(ErrorKey::Validation).msg(msg).loc(&self.comment).push();
571 }
572 }
573 }
574 }
575}
576
577#[derive(Clone, Debug)]
578pub struct Province {
579 key: Token,
580 id: ProvId,
581 color: Rgb<u8>,
582 comment: Token,
583}
584
585impl Province {
586 fn parse(csv: &[Token]) -> Option<Self> {
587 if csv.is_empty() {
588 return None;
589 }
590
591 if csv.len() < 5 {
592 let msg = "too few fields for this line, expected 5";
593 err(ErrorKey::ParseError).msg(msg).loc(&csv[0]).push();
594 return None;
595 }
596
597 let id = verify(&csv[0], "expected province id")?;
598 let r = verify(&csv[1], "expected red value")?;
599 let g = verify(&csv[2], "expected green value")?;
600 let b = verify(&csv[3], "expected blue value")?;
601 let color = Rgb::from([r, g, b]);
602 Some(Province { key: csv[0].clone(), id, color, comment: csv[4].clone() })
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 use std::path::PathBuf;
611 use std::sync::{LazyLock, Mutex};
612
613 use crate::fileset::FileKind;
614 use crate::report::take_reports;
615
616 static TEST_MUTEX: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
617
618 fn with_test_lock<T>(f: impl FnOnce() -> T) -> T {
619 let _guard = TEST_MUTEX.lock().unwrap();
620 let _ = take_reports();
621 f()
622 }
623
624 fn loc(line: u32, column: u32) -> Loc {
625 let mut loc = Loc::for_file(
626 PathBuf::from("map_data/adjacencies.csv"),
627 FileStage::NoStage,
628 FileKind::Mod,
629 PathBuf::from("C:/test/map_data/adjacencies.csv"),
630 );
631 loc.line = line;
632 loc.column = column;
633 loc
634 }
635
636 fn tok(s: &str, line: u32, column: u32) -> Token {
637 Token::new(s, loc(line, column))
638 }
639
640 fn base_provinces(img: RgbImage, from_color: Rgb<u8>, to_color: Rgb<u8>) -> ImperatorProvinces {
641 let _ = take_reports();
642
643 let mut provinces = ImperatorProvinces::default();
644 provinces.provinces_png = Some(img);
645 provinces.provinces.insert(
646 1,
647 Province { key: tok("1", 1, 1), id: 1, color: from_color, comment: tok("c", 1, 1) },
648 );
649 provinces.provinces.insert(
650 2,
651 Province { key: tok("2", 1, 1), id: 2, color: to_color, comment: tok("c", 1, 1) },
652 );
653 provinces
654 }
655
656 fn adjacency(start: Coords, stop: Coords) -> Adjacency {
657 Adjacency {
658 line: loc(1, 1),
659 from: 1,
660 to: 2,
661 kind: tok("sea", 1, 5),
662 through: 1,
663 start,
664 stop,
665 comment: tok("comment", 1, 10),
666 }
667 }
668
669 fn adjacency_with_kind(kind: &str, start: Coords, stop: Coords) -> Adjacency {
670 Adjacency { kind: tok(kind, 1, 5), ..adjacency(start, stop) }
671 }
672
673 fn take_msgs() -> Vec<String> {
674 take_reports().into_iter().map(|(meta, _)| meta.msg).collect()
675 }
676
677 #[test]
678 fn adjacency_start_out_of_bounds_errors() {
679 with_test_lock(|| {
680 let img = RgbImage::from_pixel(2, 2, Rgb([1, 2, 3]));
681 let provinces = base_provinces(img, Rgb([1, 2, 3]), Rgb([9, 9, 9]));
682
683 let adj = adjacency(Coords { x: 5, y: 0 }, Coords { x: -1, y: -1 });
684 adj.validate(&provinces);
685
686 let msgs = take_msgs();
687 assert!(
688 msgs.iter().any(|m| m.contains("start coordinate (5, 0) is out of bounds")),
689 "reports were: {msgs:?}"
690 );
691 });
692 }
693
694 #[test]
695 fn adjacency_start_wrong_color_errors() {
696 with_test_lock(|| {
697 let mut img = RgbImage::from_pixel(2, 2, Rgb([0, 0, 0]));
698 img.put_pixel(0, 0, Rgb([9, 9, 9]));
699 let provinces = base_provinces(img, Rgb([1, 2, 3]), Rgb([7, 8, 9]));
700
701 let adj = adjacency(Coords { x: 0, y: 0 }, Coords { x: -1, y: -1 });
702 adj.validate(&provinces);
703
704 let msgs = take_msgs();
705 assert!(
706 msgs.iter().any(|m| m.contains("start coordinate is in the wrong province color")),
707 "reports were: {msgs:?}"
708 );
709 });
710 }
711
712 #[test]
713 fn adjacency_stop_wrong_color_errors_when_start_sentinel() {
714 with_test_lock(|| {
715 let mut img = RgbImage::from_pixel(2, 2, Rgb([0, 0, 0]));
716 img.put_pixel(1, 1, Rgb([9, 9, 9]));
717 let provinces = base_provinces(img, Rgb([1, 2, 3]), Rgb([7, 8, 9]));
718
719 let adj = adjacency(Coords { x: -1, y: -1 }, Coords { x: 1, y: 1 });
720 adj.validate(&provinces);
721
722 let msgs = take_msgs();
723 assert!(
724 msgs.iter().any(|m| m.contains("stop coordinate is in the wrong province color")),
725 "reports were: {msgs:?}"
726 );
727 });
728 }
729
730 #[test]
731 fn adjacency_one_endpoint_sentinel_can_still_pass() {
732 with_test_lock(|| {
733 let mut img = RgbImage::from_pixel(2, 2, Rgb([0, 0, 0]));
734 img.put_pixel(1, 0, Rgb([7, 8, 9]));
735 let provinces = base_provinces(img, Rgb([1, 2, 3]), Rgb([7, 8, 9]));
736
737 let adj = adjacency(Coords { x: -1, y: -1 }, Coords { x: 1, y: 0 });
738 adj.validate(&provinces);
739
740 let msgs = take_msgs();
741 assert!(msgs.is_empty(), "reports were: {msgs:?}");
742 });
743 }
744
745 #[test]
746 fn adjacency_kind_invalid_errors_even_if_coords_sentinel() {
747 with_test_lock(|| {
748 let img = RgbImage::from_pixel(2, 2, Rgb([1, 2, 3]));
749 let provinces = base_provinces(img, Rgb([1, 2, 3]), Rgb([9, 9, 9]));
750
751 let adj = adjacency_with_kind("land", Coords { x: -1, y: -1 }, Coords { x: -1, y: -1 });
752 adj.validate(&provinces);
753
754 let msgs = take_msgs();
755 assert!(
756 msgs.iter().any(|m| m.contains("adjacency type `land` is invalid")),
757 "reports were: {msgs:?}"
758 );
759 });
760 }
761
762 #[test]
763 fn adjacency_kind_sea_and_river_large_are_allowed() {
764 with_test_lock(|| {
765 let mut img = RgbImage::from_pixel(2, 2, Rgb([0, 0, 0]));
766 img.put_pixel(0, 0, Rgb([1, 2, 3]));
767 img.put_pixel(1, 0, Rgb([7, 8, 9]));
768 let provinces = base_provinces(img, Rgb([1, 2, 3]), Rgb([7, 8, 9]));
769
770 for kind in ["sea", "river_large"] {
771 let adj = adjacency_with_kind(kind, Coords { x: 0, y: 0 }, Coords { x: 1, y: 0 });
772 adj.validate(&provinces);
773
774 let msgs = take_msgs();
775 assert!(msgs.is_empty(), "kind {kind} reports were: {msgs:?}");
776 }
777 });
778 }
779}