1use std::collections::HashMap;
14
15use anyhow::Result;
16
17use crate::filter::FeatureFilter;
18use crate::osm::{FeatureSource, OsmData, OsmPoiNode};
19use crate::overture::OvertureParams;
20
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum PoiSourceMode {
29 OsmOnly,
31 OvertureOnly,
33 Both,
36 #[default]
39 OverturePreferred,
40}
41
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum OvertureFailureMode {
46 #[default]
48 FallbackToOsm,
49 Fail,
51}
52
53#[derive(Debug, Clone)]
55pub struct SourceOptions {
56 pub filter: FeatureFilter,
58 pub overpass_url: Option<String>,
60 pub use_overpass_cache: bool,
63 pub overture: OvertureParams,
65 pub poi_source_mode: PoiSourceMode,
67 pub overture_failure_mode: OvertureFailureMode,
69}
70
71impl Default for SourceOptions {
72 fn default() -> Self {
73 Self {
74 filter: FeatureFilter::default(),
75 overpass_url: None,
76 use_overpass_cache: true,
77 overture: OvertureParams::default(),
78 poi_source_mode: PoiSourceMode::OverturePreferred,
79 overture_failure_mode: OvertureFailureMode::FallbackToOsm,
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum SourceStatus {
88 OsmOnly,
90 OvertureOnly,
92 Both,
94 OverturePreferred,
96 OvertureFallbackToOsm,
98}
99
100pub struct SourceFetchResult {
102 pub data: OsmData,
104 pub status: SourceStatus,
106 pub warnings: Vec<String>,
108}
109
110fn normalized_name(tags: &HashMap<String, String>) -> Option<String> {
111 tags.get("name")
112 .map(|name| name.trim().to_lowercase())
113 .filter(|name| !name.is_empty())
114}
115
116fn poi_category(tags: &HashMap<String, String>) -> String {
117 for key in [
118 "amenity", "shop", "tourism", "leisure", "historic", "man_made",
119 ] {
120 if let Some(value) = tags.get(key) {
121 return format!("{key}:{value}");
122 }
123 }
124 "unknown".to_string()
125}
126
127fn metres_between(a: &OsmPoiNode, b: &OsmPoiNode) -> f64 {
128 let mean_lat = ((a.lat + b.lat) * 0.5).to_radians();
129 let metres_per_degree_lat = 111_320.0;
130 let metres_per_degree_lon = 111_320.0 * mean_lat.cos().abs().max(0.01);
131 let dx = (a.lon - b.lon) * metres_per_degree_lon;
132 let dz = (a.lat - b.lat) * metres_per_degree_lat;
133 (dx * dx + dz * dz).sqrt()
134}
135
136fn poi_duplicates(a: &OsmPoiNode, b: &OsmPoiNode) -> bool {
137 let same_category = poi_category(&a.tags) == poi_category(&b.tags);
138 if !same_category {
139 return false;
140 }
141 match (normalized_name(&a.tags), normalized_name(&b.tags)) {
142 (Some(a_name), Some(b_name)) if a_name == b_name => metres_between(a, b) <= 25.0,
143 (None, None) => metres_between(a, b) <= 10.0,
144 _ => false,
145 }
146}
147
148fn dedupe_pois_with_overture_preference(mut pois: Vec<OsmPoiNode>) -> Vec<OsmPoiNode> {
149 pois.sort_by_key(|poi| match poi.source {
150 FeatureSource::Overture => 0,
151 FeatureSource::Osm => 1,
152 FeatureSource::Synthetic => 2,
153 });
154
155 let mut kept: Vec<OsmPoiNode> = Vec::new();
156 'next_poi: for poi in pois {
157 for existing in &kept {
158 if poi_duplicates(existing, &poi) {
159 continue 'next_poi;
160 }
161 }
162 kept.push(poi);
163 }
164 kept
165}
166
167pub fn merge_source_data(
173 mut osm_data: OsmData,
174 overture_data: Option<OsmData>,
175 poi_source_mode: PoiSourceMode,
176) -> SourceFetchResult {
177 let original_osm_pois = osm_data.poi_nodes.clone();
178 let mut warnings = Vec::new();
179
180 match (poi_source_mode, overture_data) {
181 (PoiSourceMode::OsmOnly, Some(mut overture)) => {
182 overture.poi_nodes.clear();
183 osm_data.merge(overture);
184 osm_data.poi_nodes = original_osm_pois;
185 SourceFetchResult {
186 data: osm_data,
187 status: SourceStatus::OsmOnly,
188 warnings,
189 }
190 }
191 (PoiSourceMode::OsmOnly, None) => SourceFetchResult {
192 data: osm_data,
193 status: SourceStatus::OsmOnly,
194 warnings,
195 },
196 (PoiSourceMode::OvertureOnly, Some(mut overture)) => {
197 let overture_pois = overture.poi_nodes.clone();
198 osm_data.poi_nodes = overture_pois;
199 overture.poi_nodes.clear();
200 osm_data.merge(overture);
201 SourceFetchResult {
202 data: osm_data,
203 status: SourceStatus::OvertureOnly,
204 warnings,
205 }
206 }
207 (PoiSourceMode::OvertureOnly, None) => {
208 osm_data.poi_nodes.clear();
209 warnings.push("Overture POIs unavailable for overture-only mode".to_string());
210 SourceFetchResult {
211 data: osm_data,
212 status: SourceStatus::OvertureOnly,
213 warnings,
214 }
215 }
216 (PoiSourceMode::Both, Some(mut overture)) => {
217 let mut all_pois = original_osm_pois;
218 all_pois.extend(overture.poi_nodes.clone());
219 overture.poi_nodes.clear();
220 osm_data.merge(overture);
221 osm_data.poi_nodes = dedupe_pois_with_overture_preference(all_pois);
222 SourceFetchResult {
223 data: osm_data,
224 status: SourceStatus::Both,
225 warnings,
226 }
227 }
228 (PoiSourceMode::Both, None) => {
229 warnings.push("Overture POIs unavailable; using OSM POIs only".to_string());
230 SourceFetchResult {
231 data: osm_data,
232 status: SourceStatus::OvertureFallbackToOsm,
233 warnings,
234 }
235 }
236 (PoiSourceMode::OverturePreferred, Some(mut overture))
237 if !overture.poi_nodes.is_empty() =>
238 {
239 let mut all_pois = original_osm_pois;
240 all_pois.extend(overture.poi_nodes.clone());
241 overture.poi_nodes.clear();
242 osm_data.merge(overture);
243 osm_data.poi_nodes = dedupe_pois_with_overture_preference(all_pois);
244 SourceFetchResult {
245 data: osm_data,
246 status: SourceStatus::OverturePreferred,
247 warnings,
248 }
249 }
250 (PoiSourceMode::OverturePreferred, Some(mut overture)) => {
251 warnings.push("Overture returned no POIs; using OSM POIs only".to_string());
252 overture.poi_nodes.clear();
253 osm_data.merge(overture);
254 osm_data.poi_nodes = original_osm_pois;
255 SourceFetchResult {
256 data: osm_data,
257 status: SourceStatus::OvertureFallbackToOsm,
258 warnings,
259 }
260 }
261 (PoiSourceMode::OverturePreferred, None) => {
262 warnings.push("Overture POIs unavailable; using OSM POIs only".to_string());
263 SourceFetchResult {
264 data: osm_data,
265 status: SourceStatus::OvertureFallbackToOsm,
266 warnings,
267 }
268 }
269 }
270}
271
272fn emit_progress(
273 progress_cb: &mut dyn FnMut(f32, &str),
274 last_progress: &mut f32,
275 pct: f32,
276 message: &str,
277) {
278 let pct = if pct.is_finite() {
279 pct.clamp(0.0, 1.0)
280 } else {
281 *last_progress
282 };
283 if pct >= *last_progress {
284 *last_progress = pct;
285 progress_cb(pct, message);
286 }
287}
288
289pub(crate) fn fetch_map_data_with_fetchers<FetchOsm, FetchOverture>(
290 bbox: (f64, f64, f64, f64),
291 options: &SourceOptions,
292 progress_cb: &mut dyn FnMut(f32, &str),
293 mut fetch_osm: FetchOsm,
294 mut fetch_overture: FetchOverture,
295) -> Result<SourceFetchResult>
296where
297 FetchOsm: FnMut((f64, f64, f64, f64), &FeatureFilter, bool, &str) -> Result<OsmData>,
298 FetchOverture:
299 FnMut((f64, f64, f64, f64), &OvertureParams, &mut dyn FnMut(f32, &str)) -> Result<OsmData>,
300{
301 const OSM_DONE_PROGRESS: f32 = 0.45;
302 const OVERTURE_DONE_PROGRESS: f32 = 0.90;
303 const MERGE_PROGRESS: f32 = 0.95;
304
305 let mut last_progress = 0.0;
306 emit_progress(progress_cb, &mut last_progress, 0.0, "Fetching OSM data…");
307 let overpass_url = match options.overpass_url.as_deref() {
308 Some(url) => url,
309 None => crate::overpass::default_overpass_url(),
310 };
311 let osm_data = fetch_osm(
312 bbox,
313 &options.filter,
314 options.use_overpass_cache,
315 overpass_url,
316 )?;
317
318 let overture_data = if options.overture.enabled {
319 emit_progress(
320 progress_cb,
321 &mut last_progress,
322 OSM_DONE_PROGRESS,
323 "OSM data ready; fetching Overture data…",
324 );
325 let overture_params = options.overture.clone();
326 let mut overture_progress = |pct: f32, message: &str| {
327 let pct = if pct.is_finite() {
328 pct.clamp(0.0, 1.0)
329 } else {
330 0.0
331 };
332 let mapped = OSM_DONE_PROGRESS + pct * (OVERTURE_DONE_PROGRESS - OSM_DONE_PROGRESS);
333 emit_progress(progress_cb, &mut last_progress, mapped, message);
334 };
335 match fetch_overture(bbox, &overture_params, &mut overture_progress) {
336 Ok(data) => Some(data),
337 Err(err) if options.overture_failure_mode == OvertureFailureMode::FallbackToOsm => {
338 let warning = format!("Overture fetch failed: {err:#}");
339 log::warn!(
340 "{warning}; continuing with configured POI source mode {:?}",
341 options.poi_source_mode
342 );
343 let mut result = merge_source_data(osm_data, None, options.poi_source_mode);
344 result.warnings.push(warning);
345 emit_progress(
346 progress_cb,
347 &mut last_progress,
348 MERGE_PROGRESS,
349 "Merging map data…",
350 );
351 result.data.clip_to_bbox(bbox);
352 emit_progress(progress_cb, &mut last_progress, 1.0, "Map data ready");
353 return Ok(result);
354 }
355 Err(err) => return Err(err),
356 }
357 } else {
358 emit_progress(
359 progress_cb,
360 &mut last_progress,
361 OVERTURE_DONE_PROGRESS,
362 "OSM data ready",
363 );
364 None
365 };
366
367 emit_progress(
368 progress_cb,
369 &mut last_progress,
370 MERGE_PROGRESS,
371 "Merging map data…",
372 );
373 let mut result = merge_source_data(osm_data, overture_data, options.poi_source_mode);
374 result.data.clip_to_bbox(bbox);
375 emit_progress(progress_cb, &mut last_progress, 1.0, "Map data ready");
376 Ok(result)
377}
378
379pub fn fetch_map_data(
390 bbox: (f64, f64, f64, f64),
391 options: &SourceOptions,
392 progress_cb: &mut dyn FnMut(f32, &str),
393) -> Result<SourceFetchResult> {
394 fetch_map_data_with_fetchers(
395 bbox,
396 options,
397 progress_cb,
398 crate::overpass::fetch_osm_data,
399 crate::overture::fetch_overture_data,
400 )
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn source_options_default_uses_overture_preferred_with_fallback() {
409 let options = SourceOptions::default();
410
411 assert_eq!(options.poi_source_mode, PoiSourceMode::OverturePreferred);
412 assert_eq!(
413 options.overture_failure_mode,
414 OvertureFailureMode::FallbackToOsm
415 );
416 assert!(options.use_overpass_cache);
417 }
418
419 fn empty_data() -> OsmData {
420 OsmData {
421 nodes: HashMap::new(),
422 ways: Vec::new(),
423 ways_by_id: HashMap::new(),
424 relations: Vec::new(),
425 bounds: Some((0.0, 0.0, 1.0, 1.0)),
426 poi_nodes: Vec::new(),
427 addr_nodes: Vec::new(),
428 tree_nodes: Vec::new(),
429 }
430 }
431
432 fn poi(
433 lat: f64,
434 lon: f64,
435 key: &str,
436 value: &str,
437 name: &str,
438 source: FeatureSource,
439 ) -> OsmPoiNode {
440 let mut tags = HashMap::from([(key.to_string(), value.to_string())]);
441 if !name.is_empty() {
442 tags.insert("name".to_string(), name.to_string());
443 }
444 OsmPoiNode {
445 lat,
446 lon,
447 tags,
448 source,
449 }
450 }
451
452 fn test_bbox() -> (f64, f64, f64, f64) {
453 (0.0, 0.0, 1.0, 1.0)
454 }
455
456 #[test]
457 fn fetch_map_data_default_options_do_not_invoke_overture_fetcher() {
458 let options = SourceOptions::default();
459 let mut overture_called = false;
460 let mut progress = Vec::new();
461
462 let result = fetch_map_data_with_fetchers(
463 test_bbox(),
464 &options,
465 &mut |pct, message| progress.push((pct, message.to_string())),
466 |_, _, _, _| {
467 let mut osm = empty_data();
468 osm.poi_nodes.push(poi(
469 0.5,
470 0.5,
471 "shop",
472 "bakery",
473 "Bakery",
474 FeatureSource::Osm,
475 ));
476 Ok(osm)
477 },
478 |_, _, _| {
479 overture_called = true;
480 panic!("Overture fetcher should not be called when disabled");
481 },
482 )
483 .expect("fetch succeeds");
484
485 assert!(!overture_called);
486 assert_eq!(result.status, SourceStatus::OvertureFallbackToOsm);
487 assert_eq!(result.data.poi_nodes.len(), 1);
488 assert_eq!(result.data.poi_nodes[0].source, FeatureSource::Osm);
489 assert_eq!(progress.last().map(|(pct, _)| *pct), Some(1.0));
490 }
491
492 #[test]
493 fn fetch_map_data_enabled_overture_invokes_fetcher_and_dedupes_preferred_pois() {
494 let mut options = SourceOptions::default();
495 options.overture.enabled = true;
496 options.poi_source_mode = PoiSourceMode::OverturePreferred;
497 let mut overture_called = false;
498
499 let result = fetch_map_data_with_fetchers(
500 test_bbox(),
501 &options,
502 &mut |_, _| {},
503 |_, _, _, _| {
504 let mut osm = empty_data();
505 osm.poi_nodes.push(poi(
506 0.50000,
507 0.50000,
508 "amenity",
509 "restaurant",
510 "Diner",
511 FeatureSource::Osm,
512 ));
513 Ok(osm)
514 },
515 |_, params, progress| {
516 overture_called = true;
517 assert!(params.enabled);
518 progress(0.0, "Overture starting");
519 progress(1.0, "Overture done");
520 let mut overture = empty_data();
521 overture.poi_nodes.push(poi(
522 0.50005,
523 0.50005,
524 "amenity",
525 "restaurant",
526 "Diner",
527 FeatureSource::Overture,
528 ));
529 Ok(overture)
530 },
531 )
532 .expect("fetch succeeds");
533
534 assert!(overture_called);
535 assert_eq!(result.status, SourceStatus::OverturePreferred);
536 assert_eq!(result.data.poi_nodes.len(), 1);
537 assert_eq!(result.data.poi_nodes[0].source, FeatureSource::Overture);
538 }
539
540 #[test]
541 fn fetch_map_data_fallback_captures_overture_error_warning_and_keeps_osm_result() {
542 let mut options = SourceOptions::default();
543 options.overture.enabled = true;
544 options.poi_source_mode = PoiSourceMode::OverturePreferred;
545 options.overture_failure_mode = OvertureFailureMode::FallbackToOsm;
546
547 let result = fetch_map_data_with_fetchers(
548 test_bbox(),
549 &options,
550 &mut |_, _| {},
551 |_, _, _, _| {
552 let mut osm = empty_data();
553 osm.poi_nodes.push(poi(
554 0.5,
555 0.5,
556 "shop",
557 "bakery",
558 "Bakery",
559 FeatureSource::Osm,
560 ));
561 Ok(osm)
562 },
563 |_, _, _| anyhow::bail!("synthetic overture failure"),
564 )
565 .expect("fallback succeeds");
566
567 assert_eq!(result.status, SourceStatus::OvertureFallbackToOsm);
568 assert_eq!(result.data.poi_nodes.len(), 1);
569 assert_eq!(result.data.poi_nodes[0].source, FeatureSource::Osm);
570 assert!(
571 result
572 .warnings
573 .iter()
574 .any(|warning| warning.contains("synthetic overture failure"))
575 );
576 }
577
578 #[test]
579 fn fetch_map_data_strict_overture_failure_returns_error() {
580 let mut options = SourceOptions::default();
581 options.overture.enabled = true;
582 options.overture_failure_mode = OvertureFailureMode::Fail;
583
584 let err = match fetch_map_data_with_fetchers(
585 test_bbox(),
586 &options,
587 &mut |_, _| {},
588 |_, _, _, _| Ok(empty_data()),
589 |_, _, _| anyhow::bail!("strict overture failure"),
590 ) {
591 Ok(_) => panic!("strict mode should return Overture error"),
592 Err(err) => err,
593 };
594
595 assert!(err.to_string().contains("strict overture failure"));
596 }
597
598 #[test]
599 fn fetch_map_data_progress_is_monotonic_and_finishes_at_one() {
600 let mut options = SourceOptions::default();
601 options.overture.enabled = true;
602 let mut progress_values = Vec::new();
603
604 fetch_map_data_with_fetchers(
605 test_bbox(),
606 &options,
607 &mut |pct, _| progress_values.push(pct),
608 |_, _, _, _| Ok(empty_data()),
609 |_, _, progress| {
610 progress(0.0, "Overture reset to zero");
611 progress(0.5, "Overture halfway");
612 progress(1.0, "Overture complete");
613 Ok(empty_data())
614 },
615 )
616 .expect("fetch succeeds");
617
618 assert!(!progress_values.is_empty());
619 for window in progress_values.windows(2) {
620 assert!(
621 window[0] <= window[1],
622 "progress moved backwards: {progress_values:?}"
623 );
624 }
625 assert!(
626 progress_values[..progress_values.len() - 1]
627 .iter()
628 .all(|pct| *pct < 1.0)
629 );
630 assert_eq!(progress_values.last().copied(), Some(1.0));
631 }
632
633 #[test]
634 fn osm_only_keeps_osm_pois_and_reports_osm_only_status() {
635 let mut osm = empty_data();
636 osm.poi_nodes.push(poi(
637 0.0,
638 0.0,
639 "amenity",
640 "restaurant",
641 "Diner",
642 FeatureSource::Osm,
643 ));
644 let mut overture = empty_data();
645 overture.poi_nodes.push(poi(
646 0.0,
647 0.0,
648 "amenity",
649 "restaurant",
650 "Diner",
651 FeatureSource::Overture,
652 ));
653
654 let merged = merge_source_data(osm, Some(overture), PoiSourceMode::OsmOnly);
655
656 assert_eq!(merged.status, SourceStatus::OsmOnly);
657 assert_eq!(merged.data.poi_nodes.len(), 1);
658 assert_eq!(merged.data.poi_nodes[0].source, FeatureSource::Osm);
659 }
660
661 #[test]
662 fn overture_only_keeps_overture_pois() {
663 let mut osm = empty_data();
664 osm.poi_nodes.push(poi(
665 0.0,
666 0.0,
667 "amenity",
668 "restaurant",
669 "Diner",
670 FeatureSource::Osm,
671 ));
672 let mut overture = empty_data();
673 overture.poi_nodes.push(poi(
674 0.0,
675 0.0,
676 "amenity",
677 "restaurant",
678 "Diner",
679 FeatureSource::Overture,
680 ));
681
682 let merged = merge_source_data(osm, Some(overture), PoiSourceMode::OvertureOnly);
683
684 assert_eq!(merged.data.poi_nodes.len(), 1);
685 assert_eq!(merged.data.poi_nodes[0].source, FeatureSource::Overture);
686 }
687
688 #[test]
689 fn overture_only_without_overture_clears_osm_pois_and_warns() {
690 let mut osm = empty_data();
691 osm.poi_nodes.push(poi(
692 0.0,
693 0.0,
694 "shop",
695 "bakery",
696 "Bakery",
697 FeatureSource::Osm,
698 ));
699
700 let merged = merge_source_data(osm, None, PoiSourceMode::OvertureOnly);
701
702 assert_eq!(merged.status, SourceStatus::OvertureOnly);
703 assert!(merged.data.poi_nodes.is_empty());
704 assert_eq!(
705 merged.warnings,
706 vec!["Overture POIs unavailable for overture-only mode".to_string()]
707 );
708 }
709
710 #[test]
711 fn both_dedupes_duplicate_pois_with_overture_winning_and_reports_both_status() {
712 let mut osm = empty_data();
713 osm.poi_nodes.push(poi(
714 51.50000,
715 -0.10000,
716 "amenity",
717 "restaurant",
718 "Diner",
719 FeatureSource::Osm,
720 ));
721 let mut overture = empty_data();
722 overture.poi_nodes.push(poi(
723 51.50005,
724 -0.10005,
725 "amenity",
726 "restaurant",
727 "Diner",
728 FeatureSource::Overture,
729 ));
730
731 let merged = merge_source_data(osm, Some(overture), PoiSourceMode::Both);
732
733 assert_eq!(merged.status, SourceStatus::Both);
734 assert_eq!(merged.data.poi_nodes.len(), 1);
735 assert_eq!(merged.data.poi_nodes[0].source, FeatureSource::Overture);
736 }
737
738 #[test]
739 fn same_name_with_category_mismatch_keeps_both_pois() {
740 let mut osm = empty_data();
741 osm.poi_nodes.push(poi(
742 51.50000,
743 -0.10000,
744 "amenity",
745 "restaurant",
746 "Corner",
747 FeatureSource::Osm,
748 ));
749 let mut overture = empty_data();
750 overture.poi_nodes.push(poi(
751 51.50005,
752 -0.10005,
753 "shop",
754 "bakery",
755 "Corner",
756 FeatureSource::Overture,
757 ));
758
759 let merged = merge_source_data(osm, Some(overture), PoiSourceMode::Both);
760
761 assert_eq!(merged.data.poi_nodes.len(), 2);
762 assert!(
763 merged
764 .data
765 .poi_nodes
766 .iter()
767 .any(|poi| poi.source == FeatureSource::Osm)
768 );
769 assert!(
770 merged
771 .data
772 .poi_nodes
773 .iter()
774 .any(|poi| poi.source == FeatureSource::Overture)
775 );
776 }
777
778 #[test]
779 fn overture_preferred_dedupes_named_pois_with_overture_winning_and_reports_success() {
780 let mut osm = empty_data();
781 osm.poi_nodes.push(poi(
782 51.50000,
783 -0.10000,
784 "amenity",
785 "restaurant",
786 "Diner",
787 FeatureSource::Osm,
788 ));
789 let mut overture = empty_data();
790 overture.poi_nodes.push(poi(
791 51.50005,
792 -0.10005,
793 "amenity",
794 "restaurant",
795 "Diner",
796 FeatureSource::Overture,
797 ));
798
799 let merged = merge_source_data(osm, Some(overture), PoiSourceMode::OverturePreferred);
800
801 assert_eq!(merged.status, SourceStatus::OverturePreferred);
802 assert_eq!(merged.data.poi_nodes.len(), 1);
803 assert_eq!(merged.data.poi_nodes[0].source, FeatureSource::Overture);
804 }
805
806 #[test]
807 fn overture_preferred_falls_back_when_overture_missing() {
808 let mut osm = empty_data();
809 osm.poi_nodes.push(poi(
810 0.0,
811 0.0,
812 "shop",
813 "bakery",
814 "Bakery",
815 FeatureSource::Osm,
816 ));
817
818 let merged = merge_source_data(osm, None, PoiSourceMode::OverturePreferred);
819
820 assert_eq!(merged.status, SourceStatus::OvertureFallbackToOsm);
821 assert_eq!(merged.data.poi_nodes.len(), 1);
822 assert_eq!(merged.data.poi_nodes[0].source, FeatureSource::Osm);
823 assert!(
824 merged
825 .warnings
826 .iter()
827 .any(|warning| warning.contains("Overture POIs unavailable"))
828 );
829 }
830
831 #[test]
832 fn overture_preferred_falls_back_precisely_when_overture_returns_zero_pois() {
833 let mut osm = empty_data();
834 osm.poi_nodes.push(poi(
835 0.0,
836 0.0,
837 "shop",
838 "bakery",
839 "Bakery",
840 FeatureSource::Osm,
841 ));
842 let overture = empty_data();
843
844 let merged = merge_source_data(osm, Some(overture), PoiSourceMode::OverturePreferred);
845
846 assert_eq!(merged.status, SourceStatus::OvertureFallbackToOsm);
847 assert_eq!(merged.data.poi_nodes.len(), 1);
848 assert_eq!(merged.data.poi_nodes[0].source, FeatureSource::Osm);
849 assert_eq!(
850 merged.warnings,
851 vec!["Overture returned no POIs; using OSM POIs only".to_string()]
852 );
853 }
854
855 #[test]
856 fn non_poi_overture_tree_nodes_are_preserved_when_pois_are_filtered() {
857 let mut osm = empty_data();
858 osm.poi_nodes.push(poi(
859 0.0,
860 0.0,
861 "shop",
862 "bakery",
863 "Bakery",
864 FeatureSource::Osm,
865 ));
866 let mut overture = empty_data();
867 overture.tree_nodes.push(crate::osm::OsmNode {
868 lat: 51.5,
869 lon: -0.1,
870 });
871
872 let merged = merge_source_data(osm, Some(overture), PoiSourceMode::OverturePreferred);
873
874 assert_eq!(merged.status, SourceStatus::OvertureFallbackToOsm);
875 assert_eq!(merged.data.poi_nodes.len(), 1);
876 assert_eq!(merged.data.tree_nodes.len(), 1);
877 assert_eq!(merged.data.tree_nodes[0].lat, 51.5);
878 assert_eq!(merged.data.tree_nodes[0].lon, -0.1);
879 }
880}