1use crate::error::{Error, Result};
6use crate::types::{BoundingBox, TimeRange};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum SummaryScheme {
14 H3,
16 Quadbin,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SummaryAggregation {
24 Count,
25 Sum,
26 Mean,
27 Min,
28 Max,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SummaryColumn {
34 pub name: String,
35 pub agg: SummaryAggregation,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SummaryTier {
41 pub scheme: SummaryScheme,
42 pub min_zoom: u8,
43 pub max_zoom: u8,
44 pub cell_resolution_per_zoom: Vec<u8>,
45 pub columns: Vec<SummaryColumn>,
46 #[serde(default = "default_summary_layer_name")]
47 pub layer_name: String,
48 #[serde(default = "default_sub_buckets")]
55 pub sub_buckets: u32,
56}
57
58fn default_sub_buckets() -> u32 {
59 1
60}
61
62fn default_summary_layer_name() -> String {
63 "summary".to_string()
64}
65
66impl SummaryTier {
67 pub fn resolution_for_zoom(&self, zoom: u8) -> u8 {
70 if self.cell_resolution_per_zoom.is_empty() {
71 return zoom;
72 }
73 if zoom <= self.min_zoom {
74 return self.cell_resolution_per_zoom[0];
75 }
76 if zoom >= self.max_zoom {
77 return *self
78 .cell_resolution_per_zoom
79 .last()
80 .expect("non-empty per check");
81 }
82 let idx = (zoom - self.min_zoom) as usize;
83 self.cell_resolution_per_zoom
84 .get(idx)
85 .copied()
86 .unwrap_or_else(|| {
87 *self.cell_resolution_per_zoom.last().unwrap()
88 })
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101pub struct HeatmapClassDomain {
102 pub id: String,
105 pub min: f64,
107 pub max: f64,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub property: Option<String>,
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct HeatmapDomain {
122 pub classes: Vec<HeatmapClassDomain>,
123}
124
125#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
134pub struct PropertyStyleHint {
135 pub name: String,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub min: Option<f64>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub p50: Option<f64>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub p90: Option<f64>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub p95: Option<f64>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub p97: Option<f64>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub p99: Option<f64>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub max: Option<f64>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub suggested_domain: Option<[f64; 2]>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub cardinality: Option<u32>,
168}
169
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179pub struct StyleHints {
180 pub version: u32,
182 pub properties: Vec<PropertyStyleHint>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub suggested_playback_seconds: Option<u32>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub layer_hint: Option<String>,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207pub struct TemporalLodLevel {
208 pub bucket_ms: u64,
210 pub max_zoom_level: u8,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct Metadata {
220 pub name: String,
222 pub description: String,
224 pub attribution: String,
226 pub bounds: BoundingBox,
228 pub time_range: TimeRange,
230 pub min_zoom: u8,
232 pub max_zoom: u8,
234 pub tile_count: u64,
238 pub feature_count: u64,
243 pub layers: Vec<String>,
245 pub properties: HashMap<String, String>,
247 pub temporal_bucket_ms: u64,
250 #[serde(default)]
253 pub summary_tier: Option<SummaryTier>,
254
255 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub temporal_lod: Option<Vec<TemporalLodLevel>>,
261
262 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub heatmap_domain: Option<HeatmapDomain>,
269
270 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub style_hints: Option<StyleHints>,
275}
276
277impl Default for Metadata {
278 fn default() -> Self {
279 Self {
280 name: String::new(),
281 description: String::new(),
282 attribution: String::new(),
283 bounds: BoundingBox {
284 min_lon: -180.0,
285 min_lat: -85.0511,
286 max_lon: 180.0,
287 max_lat: 85.0511,
288 },
289 time_range: TimeRange::new(0, 0),
290 min_zoom: 0,
291 max_zoom: 14,
292 tile_count: 0,
293 feature_count: 0,
294 layers: vec!["default".to_string()],
295 properties: HashMap::new(),
296 temporal_bucket_ms: 3600 * 1000, summary_tier: None,
298 temporal_lod: None,
299 heatmap_domain: None,
300 style_hints: None,
301 }
302 }
303}
304
305impl Metadata {
306 pub fn new(name: impl Into<String>) -> Self {
308 Self {
309 name: name.into(),
310 ..Default::default()
311 }
312 }
313
314 pub fn with_description(mut self, description: impl Into<String>) -> Self {
316 self.description = description.into();
317 self
318 }
319
320 pub fn with_attribution(mut self, attribution: impl Into<String>) -> Self {
322 self.attribution = attribution.into();
323 self
324 }
325
326 pub fn with_bounds(mut self, bounds: BoundingBox) -> Self {
328 self.bounds = bounds;
329 self
330 }
331
332 pub fn with_time_range(mut self, time_range: TimeRange) -> Self {
334 self.time_range = time_range;
335 self
336 }
337
338 pub fn with_zoom_levels(mut self, min_zoom: u8, max_zoom: u8) -> Self {
340 self.min_zoom = min_zoom;
341 self.max_zoom = max_zoom;
342 self
343 }
344
345 pub fn with_temporal_bucket_ms(mut self, temporal_bucket_ms: u64) -> Self {
347 self.temporal_bucket_ms = temporal_bucket_ms;
348 self
349 }
350
351 pub fn with_temporal_lod(mut self, levels: Vec<TemporalLodLevel>) -> Result<Self> {
359 validate_temporal_lod(self.temporal_bucket_ms, &levels)?;
360 self.temporal_lod = if levels.is_empty() { None } else { Some(levels) };
361 Ok(self)
362 }
363
364 pub fn temporal_lod_for_zoom(&self, zoom: u8) -> Option<&TemporalLodLevel> {
368 let levels = self.temporal_lod.as_ref()?;
369 levels
370 .iter()
371 .filter(|l| zoom <= l.max_zoom_level)
372 .max_by_key(|l| l.bucket_ms)
373 }
374
375 pub fn with_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
377 self.properties.insert(key.into(), value.into());
378 self
379 }
380
381 pub fn with_summary_tier(mut self, tier: SummaryTier) -> Self {
383 self.summary_tier = Some(tier);
384 self
385 }
386
387 pub fn with_heatmap_domain(mut self, domain: HeatmapDomain) -> Self {
389 self.heatmap_domain = Some(domain);
390 self
391 }
392
393 pub fn with_style_hints(mut self, hints: StyleHints) -> Self {
395 self.style_hints = Some(hints);
396 self
397 }
398
399 pub fn to_json_bytes(&self) -> Result<Vec<u8>> {
401 serde_json::to_vec(self)
402 .map_err(|e| Error::Other(format!("metadata JSON encode failed: {e}")))
403 }
404
405 pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
407 serde_json::from_slice(bytes)
408 .map_err(|e| Error::InvalidArchive(format!("metadata JSON decode failed: {e}")))
409 }
410
411 pub fn to_tilejson(&self, tile_url_template: Option<&str>) -> serde_json::Value {
422 use serde_json::json;
423 let tiles = tile_url_template.unwrap_or("{z}/{x}/{y}/{t}");
424 let center_lon = (self.bounds.min_lon + self.bounds.max_lon) / 2.0;
425 let center_lat = (self.bounds.min_lat + self.bounds.max_lat) / 2.0;
426
427 let to_iso = |ms: u64| -> serde_json::Value {
428 if ms > i64::MAX as u64 {
431 return serde_json::Value::Null;
432 }
433 chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms as i64)
434 .map(|dt| json!(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)))
435 .unwrap_or(serde_json::Value::Null)
436 };
437
438 let vector_layers: Vec<serde_json::Value> = self
439 .layers
440 .iter()
441 .map(|name| {
442 json!({
443 "id": name,
444 "fields": {},
445 "minzoom": self.min_zoom,
446 "maxzoom": self.max_zoom,
447 })
448 })
449 .collect();
450
451 let mut temporal = json!({
455 "interval": [[to_iso(self.time_range.start), to_iso(self.time_range.end)]],
456 "bucket_ms": self.temporal_bucket_ms,
457 });
458 if let Some(step) = iso8601_duration(self.temporal_bucket_ms) {
459 temporal["step"] = json!(step);
460 }
461 if let Some(levels) = &self.temporal_lod {
462 temporal["lod"] = json!(levels
463 .iter()
464 .map(|l| json!({ "bucket_ms": l.bucket_ms, "max_zoom": l.max_zoom_level }))
465 .collect::<Vec<_>>());
466 }
467
468 json!({
469 "tilejson": "3.0.0",
470 "tiles": [tiles],
471 "name": self.name,
472 "description": self.description,
473 "attribution": self.attribution,
474 "scheme": "xyz",
475 "version": "1.0.0",
476 "minzoom": self.min_zoom,
477 "maxzoom": self.max_zoom,
478 "bounds": [
479 self.bounds.min_lon,
480 self.bounds.min_lat,
481 self.bounds.max_lon,
482 self.bounds.max_lat
483 ],
484 "center": [center_lon, center_lat, self.min_zoom],
485 "vector_layers": vector_layers,
486 "temporal": temporal,
487 })
488 }
489}
490
491fn validate_temporal_lod(base_bucket_ms: u64, levels: &[TemporalLodLevel]) -> Result<()> {
494 if base_bucket_ms == 0 {
495 return Err(Error::Other(
496 "temporal_bucket_ms must be non-zero when declaring a LOD pyramid".into(),
497 ));
498 }
499 let mut prev: Option<u64> = None;
500 for (i, level) in levels.iter().enumerate() {
501 if level.bucket_ms == 0 {
502 return Err(Error::Other(format!(
503 "temporal_lod[{i}].bucket_ms must be non-zero"
504 )));
505 }
506 if level.bucket_ms <= base_bucket_ms {
507 return Err(Error::Other(format!(
508 "temporal_lod[{i}].bucket_ms ({}) must be > base bucket ({})",
509 level.bucket_ms, base_bucket_ms
510 )));
511 }
512 if level.bucket_ms % base_bucket_ms != 0 {
513 return Err(Error::Other(format!(
514 "temporal_lod[{i}].bucket_ms ({}) must be a multiple of base bucket ({})",
515 level.bucket_ms, base_bucket_ms
516 )));
517 }
518 if let Some(p) = prev {
519 if level.bucket_ms <= p {
520 return Err(Error::Other(format!(
521 "temporal_lod must be sorted by ascending bucket_ms; got {} after {}",
522 level.bucket_ms, p
523 )));
524 }
525 }
526 prev = Some(level.bucket_ms);
527 }
528 Ok(())
529}
530
531fn iso8601_duration(ms: u64) -> Option<String> {
535 if ms == 0 {
536 return None;
537 }
538 Some(if ms % 86_400_000 == 0 {
539 format!("P{}D", ms / 86_400_000)
540 } else if ms % 3_600_000 == 0 {
541 format!("PT{}H", ms / 3_600_000)
542 } else if ms % 60_000 == 0 {
543 format!("PT{}M", ms / 60_000)
544 } else if ms % 1000 == 0 {
545 format!("PT{}S", ms / 1000)
546 } else {
547 format!("PT{:.3}S", ms as f64 / 1000.0)
548 })
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 #[test]
556 fn test_metadata_json_roundtrip() {
557 let metadata = Metadata::new("json-test")
558 .with_description("desc")
559 .with_zoom_levels(2, 12)
560 .with_temporal_bucket_ms(3_600_000)
561 .with_property("source", "unit-test");
562 let bytes = metadata.to_json_bytes().unwrap();
563 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
564 assert_eq!(decoded.name, "json-test");
565 assert_eq!(decoded.min_zoom, 2);
566 assert_eq!(decoded.max_zoom, 12);
567 assert_eq!(decoded.temporal_bucket_ms, 3_600_000);
568 assert_eq!(decoded.properties.get("source").map(String::as_str), Some("unit-test"));
569 }
570
571 #[test]
572 fn test_metadata_summary_tier_roundtrip() {
573 let tier = SummaryTier {
574 scheme: SummaryScheme::H3,
575 min_zoom: 0,
576 max_zoom: 4,
577 cell_resolution_per_zoom: vec![0, 1, 2, 3, 4],
578 columns: vec![
579 SummaryColumn {
580 name: "magnitude".to_string(),
581 agg: SummaryAggregation::Mean,
582 },
583 SummaryColumn {
584 name: "magnitude".to_string(),
585 agg: SummaryAggregation::Max,
586 },
587 ],
588 layer_name: "summary".to_string(),
589 sub_buckets: 1,
590 };
591 let metadata = Metadata::new("summary-test").with_summary_tier(tier.clone());
592 let bytes = metadata.to_json_bytes().unwrap();
593 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
594 let dt = decoded.summary_tier.unwrap();
595 assert_eq!(dt.scheme, SummaryScheme::H3);
596 assert_eq!(dt.min_zoom, 0);
597 assert_eq!(dt.max_zoom, 4);
598 assert_eq!(dt.cell_resolution_per_zoom.len(), 5);
599 assert_eq!(dt.columns.len(), 2);
600 assert_eq!(dt.resolution_for_zoom(2), 2);
601 assert_eq!(dt.resolution_for_zoom(10), 4);
603 }
604
605 #[test]
606 fn test_metadata_ignores_unknown_fields() {
607 let metadata = Metadata::new("fwd");
611 let mut v: serde_json::Value =
612 serde_json::from_slice(&metadata.to_json_bytes().unwrap()).unwrap();
613 v["raster_tier"] = serde_json::json!({ "min_zoom": 0, "max_zoom": 5 });
614 let decoded = Metadata::from_json_bytes(&serde_json::to_vec(&v).unwrap()).unwrap();
615 assert_eq!(decoded.name, "fwd");
616 }
617
618 #[test]
619 fn test_heatmap_domain_roundtrip() {
620 let domain = HeatmapDomain {
621 classes: vec![
622 HeatmapClassDomain {
623 id: "pickup".to_string(),
624 min: 0.0,
625 max: 7.5,
626 property: Some("intensity".to_string()),
627 },
628 HeatmapClassDomain {
629 id: "dropoff".to_string(),
630 min: 0.0,
631 max: 9.0,
632 property: None,
633 },
634 ],
635 };
636 let metadata = Metadata::new("heat-test").with_heatmap_domain(domain.clone());
637 let bytes = metadata.to_json_bytes().unwrap();
638 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
639 let d = decoded.heatmap_domain.unwrap();
640 assert_eq!(d.classes.len(), 2);
641 assert_eq!(d.classes[0].id, "pickup");
642 assert_eq!(d.classes[0].max, 7.5);
643 assert_eq!(d.classes[0].property.as_deref(), Some("intensity"));
644 assert_eq!(d.classes[1].id, "dropoff");
645 assert_eq!(d.classes[1].property, None);
646 }
647
648 #[test]
649 fn test_heatmap_domain_field_omitted_when_unset() {
650 let metadata = Metadata::new("no-heat");
651 let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
652 assert!(!s.contains("heatmap_domain"), "got: {s}");
653 }
654
655 #[test]
656 fn test_style_hints_roundtrip() {
657 let hints = StyleHints {
658 version: 1,
659 properties: vec![
660 PropertyStyleHint {
661 name: "magnitude".to_string(),
662 min: Some(0.1),
663 p50: Some(2.0),
664 p90: Some(4.1),
665 p95: Some(4.9),
666 p97: Some(5.3),
667 p99: Some(6.2),
668 max: Some(9.1),
669 suggested_domain: Some([0.1, 5.3]),
670 cardinality: None,
671 },
672 PropertyStyleHint {
674 name: "category".to_string(),
675 cardinality: Some(7),
676 ..Default::default()
677 },
678 ],
679 suggested_playback_seconds: Some(45),
680 layer_hint: Some("points".to_string()),
681 };
682 let metadata = Metadata::new("hints-test").with_style_hints(hints.clone());
683 let bytes = metadata.to_json_bytes().unwrap();
684
685 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
688 let cat = &v["style_hints"]["properties"][1];
689 assert_eq!(cat["name"], "category");
690 assert_eq!(cat["cardinality"], 7);
691 let cat_keys: Vec<&String> = cat.as_object().unwrap().keys().collect();
692 assert_eq!(cat_keys.len(), 2, "categorical carries only name+cardinality: {cat_keys:?}");
693 assert!(v["style_hints"]["properties"][0].get("cardinality").is_none());
695 assert_eq!(v["style_hints"]["suggested_playback_seconds"], 45);
696 assert_eq!(v["style_hints"]["layer_hint"], "points");
697
698 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
699 assert_eq!(decoded.style_hints, Some(hints));
700 }
701
702 #[test]
703 fn test_style_hints_field_omitted_when_unset() {
704 let metadata = Metadata::new("no-hints");
710 let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
711 assert!(!s.contains("style_hints"), "got: {s}");
712 }
713
714 #[test]
715 fn test_metadata_without_summary_tier_decodes() {
716 let json = br#"{
719 "name": "old",
720 "description": "",
721 "attribution": "",
722 "bounds": {"min_lon":-180.0,"min_lat":-85.0,"max_lon":180.0,"max_lat":85.0},
723 "time_range": {"start":0,"end":1000},
724 "min_zoom": 0,
725 "max_zoom": 8,
726 "tile_count": 0,
727 "feature_count": 0,
728 "layers": ["default"],
729 "properties": {},
730 "temporal_bucket_ms": 3600000
731 }"#;
732 let m = Metadata::from_json_bytes(json).unwrap();
733 assert!(m.summary_tier.is_none());
734 }
735
736 #[test]
737 fn test_metadata_builder() {
738 let metadata = Metadata::new("test")
739 .with_description("Test archive")
740 .with_attribution("Test data")
741 .with_zoom_levels(0, 14)
742 .with_property("key", "value");
743
744 assert_eq!(metadata.name, "test");
745 assert_eq!(metadata.description, "Test archive");
746 assert_eq!(metadata.min_zoom, 0);
747 assert_eq!(metadata.max_zoom, 14);
748 assert_eq!(metadata.properties.get("key"), Some(&"value".to_string()));
749 }
750
751 fn hour() -> u64 {
756 3_600_000
757 }
758 fn day() -> u64 {
759 24 * hour()
760 }
761 fn thirty_days() -> u64 {
762 30 * day()
763 }
764
765 #[test]
766 fn temporal_lod_roundtrips_through_json() {
767 let levels = vec![
768 TemporalLodLevel {
769 bucket_ms: day(),
770 max_zoom_level: 8,
771 },
772 TemporalLodLevel {
773 bucket_ms: thirty_days(),
774 max_zoom_level: 4,
775 },
776 ];
777 let metadata = Metadata::new("lod")
778 .with_temporal_bucket_ms(hour())
779 .with_temporal_lod(levels.clone())
780 .unwrap();
781 let bytes = metadata.to_json_bytes().unwrap();
782 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
783 assert_eq!(decoded.temporal_lod.as_deref(), Some(levels.as_slice()));
784 }
785
786 #[test]
787 fn temporal_lod_field_omitted_when_unset() {
788 let metadata = Metadata::new("no-lod").with_temporal_bucket_ms(hour());
791 let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
792 assert!(!s.contains("temporal_lod"), "got: {s}");
793 }
794
795 #[test]
796 fn temporal_lod_missing_field_decodes_back_compat() {
797 let legacy = r#"{
800 "name": "legacy",
801 "description": "",
802 "attribution": "",
803 "bounds": {"min_lon": -180, "min_lat": -85, "max_lon": 180, "max_lat": 85},
804 "time_range": {"start": 0, "end": 1},
805 "min_zoom": 0,
806 "max_zoom": 14,
807 "tile_count": 0,
808 "feature_count": 0,
809 "layers": ["default"],
810 "properties": {},
811 "temporal_bucket_ms": 3600000
812 }"#;
813 let m = Metadata::from_json_bytes(legacy.as_bytes()).unwrap();
814 assert!(m.temporal_lod.is_none());
815 }
816
817 #[test]
818 fn temporal_lod_rejects_non_multiple_bucket() {
819 let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
820 TemporalLodLevel { bucket_ms: hour() + 7, max_zoom_level: 5 },
821 ]);
822 assert!(res.is_err());
823 }
824
825 #[test]
826 fn temporal_lod_rejects_bucket_smaller_than_or_equal_to_base() {
827 let res = Metadata::new("bad")
828 .with_temporal_bucket_ms(day())
829 .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
830 assert!(res.is_err());
831
832 let res = Metadata::new("bad")
833 .with_temporal_bucket_ms(hour())
834 .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
835 assert!(res.is_err());
836 }
837
838 #[test]
839 fn temporal_lod_rejects_unsorted_levels() {
840 let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
841 TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
842 TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
843 ]);
844 assert!(res.is_err());
845 }
846
847 #[test]
848 fn temporal_lod_for_zoom_picks_coarsest_applicable() {
849 let m = Metadata::new("lod")
850 .with_temporal_bucket_ms(hour())
851 .with_temporal_lod(vec![
852 TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
853 TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
854 ])
855 .unwrap();
856 assert_eq!(
858 m.temporal_lod_for_zoom(0).map(|l| l.bucket_ms),
859 Some(thirty_days())
860 );
861 assert_eq!(m.temporal_lod_for_zoom(6).map(|l| l.bucket_ms), Some(day()));
863 assert!(m.temporal_lod_for_zoom(12).is_none());
865 }
866
867 #[test]
868 fn temporal_lod_for_zoom_is_none_when_unset() {
869 let m = Metadata::new("plain").with_temporal_bucket_ms(hour());
870 assert!(m.temporal_lod_for_zoom(0).is_none());
871 }
872
873 #[test]
874 fn temporal_lod_empty_vec_clears_to_none() {
875 let m = Metadata::new("empty")
878 .with_temporal_bucket_ms(hour())
879 .with_temporal_lod(vec![])
880 .unwrap();
881 assert!(m.temporal_lod.is_none());
882 }
883
884 #[test]
885 fn tilejson_descriptor_has_core_fields_and_temporal_extension() {
886 let m = Metadata::new("quakes")
887 .with_description("USGS earthquakes")
888 .with_attribution("USGS")
889 .with_zoom_levels(0, 10)
890 .with_temporal_bucket_ms(hour())
891 .with_time_range(TimeRange::new(1_700_000_000_000, 1_700_086_400_000))
892 .with_temporal_lod(vec![TemporalLodLevel {
893 bucket_ms: day(),
894 max_zoom_level: 6,
895 }])
896 .unwrap();
897 let tj = m.to_tilejson(Some("https://cdn/{z}/{x}/{y}/{t}.stt"));
898
899 assert_eq!(tj["tilejson"], "3.0.0");
901 assert_eq!(tj["tiles"][0], "https://cdn/{z}/{x}/{y}/{t}.stt");
902 assert_eq!(tj["minzoom"], 0);
903 assert_eq!(tj["maxzoom"], 10);
904 assert_eq!(tj["scheme"], "xyz");
905 assert_eq!(tj["vector_layers"][0]["id"], "default");
906 assert_eq!(tj["bounds"].as_array().unwrap().len(), 4);
907
908 assert_eq!(tj["temporal"]["step"], "PT1H");
910 assert_eq!(tj["temporal"]["bucket_ms"], 3_600_000u64);
911 let interval = &tj["temporal"]["interval"][0];
912 assert!(interval[0].as_str().unwrap().starts_with("2023-11-"));
913 assert!(interval[1].is_string());
914 assert_eq!(tj["temporal"]["lod"][0]["bucket_ms"], day());
915 }
916
917 #[test]
918 fn iso8601_duration_formats_common_buckets() {
919 assert_eq!(iso8601_duration(hour()).as_deref(), Some("PT1H"));
920 assert_eq!(iso8601_duration(day()).as_deref(), Some("P1D"));
921 assert_eq!(iso8601_duration(60_000).as_deref(), Some("PT1M"));
922 assert_eq!(iso8601_duration(0), None);
923 }
924
925 #[test]
926 fn tilejson_time_beyond_i64_is_null_not_garbage() {
927 let m = Metadata::new("x").with_time_range(TimeRange::new(u64::MAX, u64::MAX));
930 let tj = m.to_tilejson(None);
931 assert!(tj["temporal"]["interval"][0][0].is_null());
932 assert!(tj["temporal"]["interval"][0][1].is_null());
933 }
934}