1use crate::error::{Error, Result};
6use crate::types::{BoundingBox, TimeRange};
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
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: BTreeMap<String, String>,
249 pub temporal_bucket_ms: u64,
252 #[serde(default)]
255 pub summary_tier: Option<SummaryTier>,
256
257 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub temporal_lod: Option<Vec<TemporalLodLevel>>,
263
264 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub heatmap_domain: Option<HeatmapDomain>,
271
272 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub style_hints: Option<StyleHints>,
277}
278
279impl Default for Metadata {
280 fn default() -> Self {
281 Self {
282 name: String::new(),
283 description: String::new(),
284 attribution: String::new(),
285 bounds: BoundingBox {
286 min_lon: -180.0,
287 min_lat: -85.0511,
288 max_lon: 180.0,
289 max_lat: 85.0511,
290 },
291 time_range: TimeRange::new(0, 0),
292 min_zoom: 0,
293 max_zoom: 14,
294 tile_count: 0,
295 feature_count: 0,
296 layers: vec!["default".to_string()],
297 properties: BTreeMap::new(),
298 temporal_bucket_ms: 3600 * 1000, summary_tier: None,
300 temporal_lod: None,
301 heatmap_domain: None,
302 style_hints: None,
303 }
304 }
305}
306
307impl Metadata {
308 pub fn new(name: impl Into<String>) -> Self {
310 Self {
311 name: name.into(),
312 ..Default::default()
313 }
314 }
315
316 pub fn with_description(mut self, description: impl Into<String>) -> Self {
318 self.description = description.into();
319 self
320 }
321
322 pub fn with_attribution(mut self, attribution: impl Into<String>) -> Self {
324 self.attribution = attribution.into();
325 self
326 }
327
328 pub fn with_bounds(mut self, bounds: BoundingBox) -> Self {
330 self.bounds = bounds;
331 self
332 }
333
334 pub fn with_time_range(mut self, time_range: TimeRange) -> Self {
336 self.time_range = time_range;
337 self
338 }
339
340 pub fn with_zoom_levels(mut self, min_zoom: u8, max_zoom: u8) -> Self {
342 self.min_zoom = min_zoom;
343 self.max_zoom = max_zoom;
344 self
345 }
346
347 pub fn with_temporal_bucket_ms(mut self, temporal_bucket_ms: u64) -> Self {
349 self.temporal_bucket_ms = temporal_bucket_ms;
350 self
351 }
352
353 pub fn with_temporal_lod(mut self, levels: Vec<TemporalLodLevel>) -> Result<Self> {
361 validate_temporal_lod(self.temporal_bucket_ms, &levels)?;
362 self.temporal_lod = if levels.is_empty() { None } else { Some(levels) };
363 Ok(self)
364 }
365
366 pub fn temporal_lod_for_zoom(&self, zoom: u8) -> Option<&TemporalLodLevel> {
370 let levels = self.temporal_lod.as_ref()?;
371 levels
372 .iter()
373 .filter(|l| zoom <= l.max_zoom_level)
374 .max_by_key(|l| l.bucket_ms)
375 }
376
377 pub fn with_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
379 self.properties.insert(key.into(), value.into());
380 self
381 }
382
383 pub fn with_summary_tier(mut self, tier: SummaryTier) -> Self {
385 self.summary_tier = Some(tier);
386 self
387 }
388
389 pub fn with_heatmap_domain(mut self, domain: HeatmapDomain) -> Self {
391 self.heatmap_domain = Some(domain);
392 self
393 }
394
395 pub fn with_style_hints(mut self, hints: StyleHints) -> Self {
397 self.style_hints = Some(hints);
398 self
399 }
400
401 pub fn to_json_bytes(&self) -> Result<Vec<u8>> {
403 serde_json::to_vec(self)
404 .map_err(|e| Error::Other(format!("metadata JSON encode failed: {e}")))
405 }
406
407 pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
409 serde_json::from_slice(bytes)
410 .map_err(|e| Error::InvalidArchive(format!("metadata JSON decode failed: {e}")))
411 }
412
413 pub fn to_tilejson(&self, tile_url_template: Option<&str>) -> serde_json::Value {
424 use serde_json::json;
425 let tiles = tile_url_template.unwrap_or("{z}/{x}/{y}/{t}");
426 let center_lon = (self.bounds.min_lon + self.bounds.max_lon) / 2.0;
427 let center_lat = (self.bounds.min_lat + self.bounds.max_lat) / 2.0;
428
429 let to_iso = |ms: u64| -> serde_json::Value {
430 if ms > i64::MAX as u64 {
433 return serde_json::Value::Null;
434 }
435 chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms as i64)
436 .map(|dt| json!(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)))
437 .unwrap_or(serde_json::Value::Null)
438 };
439
440 let vector_layers: Vec<serde_json::Value> = self
441 .layers
442 .iter()
443 .map(|name| {
444 json!({
445 "id": name,
446 "fields": {},
447 "minzoom": self.min_zoom,
448 "maxzoom": self.max_zoom,
449 })
450 })
451 .collect();
452
453 let mut temporal = json!({
457 "interval": [[to_iso(self.time_range.start), to_iso(self.time_range.end)]],
458 "bucket_ms": self.temporal_bucket_ms,
459 });
460 if let Some(step) = iso8601_duration(self.temporal_bucket_ms) {
461 temporal["step"] = json!(step);
462 }
463 if let Some(levels) = &self.temporal_lod {
464 temporal["lod"] = json!(levels
465 .iter()
466 .map(|l| json!({ "bucket_ms": l.bucket_ms, "max_zoom": l.max_zoom_level }))
467 .collect::<Vec<_>>());
468 }
469
470 json!({
471 "tilejson": "3.0.0",
472 "tiles": [tiles],
473 "name": self.name,
474 "description": self.description,
475 "attribution": self.attribution,
476 "scheme": "xyz",
477 "version": "1.0.0",
478 "minzoom": self.min_zoom,
479 "maxzoom": self.max_zoom,
480 "bounds": [
481 self.bounds.min_lon,
482 self.bounds.min_lat,
483 self.bounds.max_lon,
484 self.bounds.max_lat
485 ],
486 "center": [center_lon, center_lat, self.min_zoom],
487 "vector_layers": vector_layers,
488 "temporal": temporal,
489 })
490 }
491}
492
493fn validate_temporal_lod(base_bucket_ms: u64, levels: &[TemporalLodLevel]) -> Result<()> {
496 if base_bucket_ms == 0 {
497 return Err(Error::Other(
498 "temporal_bucket_ms must be non-zero when declaring a LOD pyramid".into(),
499 ));
500 }
501 let mut prev: Option<u64> = None;
502 for (i, level) in levels.iter().enumerate() {
503 if level.bucket_ms == 0 {
504 return Err(Error::Other(format!(
505 "temporal_lod[{i}].bucket_ms must be non-zero"
506 )));
507 }
508 if level.bucket_ms <= base_bucket_ms {
509 return Err(Error::Other(format!(
510 "temporal_lod[{i}].bucket_ms ({}) must be > base bucket ({})",
511 level.bucket_ms, base_bucket_ms
512 )));
513 }
514 if level.bucket_ms % base_bucket_ms != 0 {
515 return Err(Error::Other(format!(
516 "temporal_lod[{i}].bucket_ms ({}) must be a multiple of base bucket ({})",
517 level.bucket_ms, base_bucket_ms
518 )));
519 }
520 if let Some(p) = prev {
521 if level.bucket_ms <= p {
522 return Err(Error::Other(format!(
523 "temporal_lod must be sorted by ascending bucket_ms; got {} after {}",
524 level.bucket_ms, p
525 )));
526 }
527 }
528 prev = Some(level.bucket_ms);
529 }
530 Ok(())
531}
532
533fn iso8601_duration(ms: u64) -> Option<String> {
537 if ms == 0 {
538 return None;
539 }
540 Some(if ms % 86_400_000 == 0 {
541 format!("P{}D", ms / 86_400_000)
542 } else if ms % 3_600_000 == 0 {
543 format!("PT{}H", ms / 3_600_000)
544 } else if ms % 60_000 == 0 {
545 format!("PT{}M", ms / 60_000)
546 } else if ms % 1000 == 0 {
547 format!("PT{}S", ms / 1000)
548 } else {
549 format!("PT{:.3}S", ms as f64 / 1000.0)
550 })
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 #[test]
558 fn test_metadata_json_roundtrip() {
559 let metadata = Metadata::new("json-test")
560 .with_description("desc")
561 .with_zoom_levels(2, 12)
562 .with_temporal_bucket_ms(3_600_000)
563 .with_property("source", "unit-test");
564 let bytes = metadata.to_json_bytes().unwrap();
565 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
566 assert_eq!(decoded.name, "json-test");
567 assert_eq!(decoded.min_zoom, 2);
568 assert_eq!(decoded.max_zoom, 12);
569 assert_eq!(decoded.temporal_bucket_ms, 3_600_000);
570 assert_eq!(decoded.properties.get("source").map(String::as_str), Some("unit-test"));
571 }
572
573 #[test]
574 fn test_metadata_summary_tier_roundtrip() {
575 let tier = SummaryTier {
576 scheme: SummaryScheme::H3,
577 min_zoom: 0,
578 max_zoom: 4,
579 cell_resolution_per_zoom: vec![0, 1, 2, 3, 4],
580 columns: vec![
581 SummaryColumn {
582 name: "magnitude".to_string(),
583 agg: SummaryAggregation::Mean,
584 },
585 SummaryColumn {
586 name: "magnitude".to_string(),
587 agg: SummaryAggregation::Max,
588 },
589 ],
590 layer_name: "summary".to_string(),
591 sub_buckets: 1,
592 };
593 let metadata = Metadata::new("summary-test").with_summary_tier(tier.clone());
594 let bytes = metadata.to_json_bytes().unwrap();
595 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
596 let dt = decoded.summary_tier.unwrap();
597 assert_eq!(dt.scheme, SummaryScheme::H3);
598 assert_eq!(dt.min_zoom, 0);
599 assert_eq!(dt.max_zoom, 4);
600 assert_eq!(dt.cell_resolution_per_zoom.len(), 5);
601 assert_eq!(dt.columns.len(), 2);
602 assert_eq!(dt.resolution_for_zoom(2), 2);
603 assert_eq!(dt.resolution_for_zoom(10), 4);
605 }
606
607 #[test]
608 fn test_metadata_summary_tier_quadbin_and_sub_buckets_roundtrip() {
609 let tier = SummaryTier {
614 scheme: SummaryScheme::Quadbin,
615 min_zoom: 0,
616 max_zoom: 6,
617 cell_resolution_per_zoom: vec![0, 1, 2, 3, 4, 5, 6],
618 columns: vec![SummaryColumn {
619 name: "magnitude".to_string(),
620 agg: SummaryAggregation::Mean,
621 }],
622 layer_name: "summary".to_string(),
623 sub_buckets: 24,
624 };
625 let bytes = Metadata::new("q")
626 .with_summary_tier(tier)
627 .to_json_bytes()
628 .expect("serialize metadata");
629 let decoded = Metadata::from_json_bytes(&bytes).expect("deserialize metadata");
630 let dt = decoded.summary_tier.expect("summary tier round-trips");
631 assert_eq!(dt.scheme, SummaryScheme::Quadbin, "quadbin scheme survives");
632 assert_eq!(dt.sub_buckets, 24, "sub_buckets survives the round-trip");
633 assert_eq!(dt.max_zoom, 6);
634
635 let json = String::from_utf8(bytes).unwrap();
636 assert!(json.contains("quadbin"), "scheme serializes lowercase: {json}");
637 assert!(json.contains("sub_buckets"), "sub_buckets is a manifest field: {json}");
638 }
639
640 #[test]
641 fn test_metadata_summary_tier_defaults_when_subfields_absent() {
642 let json = br#"{
646 "name": "old-summary",
647 "description": "",
648 "attribution": "",
649 "bounds": {"min_lon":-180.0,"min_lat":-85.0,"max_lon":180.0,"max_lat":85.0},
650 "time_range": {"start":0,"end":1000},
651 "min_zoom": 0,
652 "max_zoom": 8,
653 "tile_count": 0,
654 "feature_count": 0,
655 "layers": ["default"],
656 "properties": {},
657 "temporal_bucket_ms": 3600000,
658 "summary_tier": {
659 "scheme": "h3",
660 "min_zoom": 0,
661 "max_zoom": 4,
662 "cell_resolution_per_zoom": [0,1,2,3,4],
663 "columns": [{"name":"magnitude","agg":"mean"}]
664 }
665 }"#;
666 let m = Metadata::from_json_bytes(json).expect("legacy summary tier decodes");
667 let dt = m.summary_tier.expect("summary tier present");
668 assert_eq!(dt.scheme, SummaryScheme::H3);
669 assert_eq!(dt.sub_buckets, 1, "absent sub_buckets defaults to 1");
670 assert_eq!(dt.layer_name, "summary", "absent layer_name defaults to 'summary'");
671 }
672
673 #[test]
674 fn test_properties_serialize_in_sorted_key_order() {
675 let metadata = Metadata::new("ord")
679 .with_property("zebra", "1")
680 .with_property("alpha", "2")
681 .with_property("mid", "3");
682 let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
683 let a = s.find("\"alpha\"").unwrap();
684 let m = s.find("\"mid\"").unwrap();
685 let z = s.find("\"zebra\"").unwrap();
686 assert!(a < m && m < z, "properties must serialize sorted: {s}");
687 }
688
689 #[test]
690 fn test_metadata_ignores_unknown_fields() {
691 let metadata = Metadata::new("fwd");
695 let mut v: serde_json::Value =
696 serde_json::from_slice(&metadata.to_json_bytes().unwrap()).unwrap();
697 v["raster_tier"] = serde_json::json!({ "min_zoom": 0, "max_zoom": 5 });
698 let decoded = Metadata::from_json_bytes(&serde_json::to_vec(&v).unwrap()).unwrap();
699 assert_eq!(decoded.name, "fwd");
700 }
701
702 #[test]
703 fn test_heatmap_domain_roundtrip() {
704 let domain = HeatmapDomain {
705 classes: vec![
706 HeatmapClassDomain {
707 id: "pickup".to_string(),
708 min: 0.0,
709 max: 7.5,
710 property: Some("intensity".to_string()),
711 },
712 HeatmapClassDomain {
713 id: "dropoff".to_string(),
714 min: 0.0,
715 max: 9.0,
716 property: None,
717 },
718 ],
719 };
720 let metadata = Metadata::new("heat-test").with_heatmap_domain(domain.clone());
721 let bytes = metadata.to_json_bytes().unwrap();
722 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
723 let d = decoded.heatmap_domain.unwrap();
724 assert_eq!(d.classes.len(), 2);
725 assert_eq!(d.classes[0].id, "pickup");
726 assert_eq!(d.classes[0].max, 7.5);
727 assert_eq!(d.classes[0].property.as_deref(), Some("intensity"));
728 assert_eq!(d.classes[1].id, "dropoff");
729 assert_eq!(d.classes[1].property, None);
730 }
731
732 #[test]
733 fn test_heatmap_domain_field_omitted_when_unset() {
734 let metadata = Metadata::new("no-heat");
735 let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
736 assert!(!s.contains("heatmap_domain"), "got: {s}");
737 }
738
739 #[test]
740 fn test_style_hints_roundtrip() {
741 let hints = StyleHints {
742 version: 1,
743 properties: vec![
744 PropertyStyleHint {
745 name: "magnitude".to_string(),
746 min: Some(0.1),
747 p50: Some(2.0),
748 p90: Some(4.1),
749 p95: Some(4.9),
750 p97: Some(5.3),
751 p99: Some(6.2),
752 max: Some(9.1),
753 suggested_domain: Some([0.1, 5.3]),
754 cardinality: None,
755 },
756 PropertyStyleHint {
758 name: "category".to_string(),
759 cardinality: Some(7),
760 ..Default::default()
761 },
762 ],
763 suggested_playback_seconds: Some(45),
764 layer_hint: Some("points".to_string()),
765 };
766 let metadata = Metadata::new("hints-test").with_style_hints(hints.clone());
767 let bytes = metadata.to_json_bytes().unwrap();
768
769 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
772 let cat = &v["style_hints"]["properties"][1];
773 assert_eq!(cat["name"], "category");
774 assert_eq!(cat["cardinality"], 7);
775 let cat_keys: Vec<&String> = cat.as_object().unwrap().keys().collect();
776 assert_eq!(cat_keys.len(), 2, "categorical carries only name+cardinality: {cat_keys:?}");
777 assert!(v["style_hints"]["properties"][0].get("cardinality").is_none());
779 assert_eq!(v["style_hints"]["suggested_playback_seconds"], 45);
780 assert_eq!(v["style_hints"]["layer_hint"], "points");
781
782 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
783 assert_eq!(decoded.style_hints, Some(hints));
784 }
785
786 #[test]
787 fn test_style_hints_field_omitted_when_unset() {
788 let metadata = Metadata::new("no-hints");
794 let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
795 assert!(!s.contains("style_hints"), "got: {s}");
796 }
797
798 #[test]
799 fn test_metadata_without_summary_tier_decodes() {
800 let json = br#"{
803 "name": "old",
804 "description": "",
805 "attribution": "",
806 "bounds": {"min_lon":-180.0,"min_lat":-85.0,"max_lon":180.0,"max_lat":85.0},
807 "time_range": {"start":0,"end":1000},
808 "min_zoom": 0,
809 "max_zoom": 8,
810 "tile_count": 0,
811 "feature_count": 0,
812 "layers": ["default"],
813 "properties": {},
814 "temporal_bucket_ms": 3600000
815 }"#;
816 let m = Metadata::from_json_bytes(json).unwrap();
817 assert!(m.summary_tier.is_none());
818 }
819
820 #[test]
821 fn test_metadata_builder() {
822 let metadata = Metadata::new("test")
823 .with_description("Test archive")
824 .with_attribution("Test data")
825 .with_zoom_levels(0, 14)
826 .with_property("key", "value");
827
828 assert_eq!(metadata.name, "test");
829 assert_eq!(metadata.description, "Test archive");
830 assert_eq!(metadata.min_zoom, 0);
831 assert_eq!(metadata.max_zoom, 14);
832 assert_eq!(metadata.properties.get("key"), Some(&"value".to_string()));
833 }
834
835 fn hour() -> u64 {
840 3_600_000
841 }
842 fn day() -> u64 {
843 24 * hour()
844 }
845 fn thirty_days() -> u64 {
846 30 * day()
847 }
848
849 #[test]
850 fn temporal_lod_roundtrips_through_json() {
851 let levels = vec![
852 TemporalLodLevel {
853 bucket_ms: day(),
854 max_zoom_level: 8,
855 },
856 TemporalLodLevel {
857 bucket_ms: thirty_days(),
858 max_zoom_level: 4,
859 },
860 ];
861 let metadata = Metadata::new("lod")
862 .with_temporal_bucket_ms(hour())
863 .with_temporal_lod(levels.clone())
864 .unwrap();
865 let bytes = metadata.to_json_bytes().unwrap();
866 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
867 assert_eq!(decoded.temporal_lod.as_deref(), Some(levels.as_slice()));
868 }
869
870 #[test]
871 fn temporal_lod_field_omitted_when_unset() {
872 let metadata = Metadata::new("no-lod").with_temporal_bucket_ms(hour());
875 let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
876 assert!(!s.contains("temporal_lod"), "got: {s}");
877 }
878
879 #[test]
880 fn temporal_lod_missing_field_decodes_back_compat() {
881 let legacy = r#"{
884 "name": "legacy",
885 "description": "",
886 "attribution": "",
887 "bounds": {"min_lon": -180, "min_lat": -85, "max_lon": 180, "max_lat": 85},
888 "time_range": {"start": 0, "end": 1},
889 "min_zoom": 0,
890 "max_zoom": 14,
891 "tile_count": 0,
892 "feature_count": 0,
893 "layers": ["default"],
894 "properties": {},
895 "temporal_bucket_ms": 3600000
896 }"#;
897 let m = Metadata::from_json_bytes(legacy.as_bytes()).unwrap();
898 assert!(m.temporal_lod.is_none());
899 }
900
901 #[test]
902 fn temporal_lod_rejects_non_multiple_bucket() {
903 let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
904 TemporalLodLevel { bucket_ms: hour() + 7, max_zoom_level: 5 },
905 ]);
906 assert!(res.is_err());
907 }
908
909 #[test]
910 fn temporal_lod_rejects_bucket_smaller_than_or_equal_to_base() {
911 let res = Metadata::new("bad")
912 .with_temporal_bucket_ms(day())
913 .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
914 assert!(res.is_err());
915
916 let res = Metadata::new("bad")
917 .with_temporal_bucket_ms(hour())
918 .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
919 assert!(res.is_err());
920 }
921
922 #[test]
923 fn temporal_lod_rejects_unsorted_levels() {
924 let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
925 TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
926 TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
927 ]);
928 assert!(res.is_err());
929 }
930
931 #[test]
932 fn temporal_lod_for_zoom_picks_coarsest_applicable() {
933 let m = Metadata::new("lod")
934 .with_temporal_bucket_ms(hour())
935 .with_temporal_lod(vec![
936 TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
937 TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
938 ])
939 .unwrap();
940 assert_eq!(
942 m.temporal_lod_for_zoom(0).map(|l| l.bucket_ms),
943 Some(thirty_days())
944 );
945 assert_eq!(m.temporal_lod_for_zoom(6).map(|l| l.bucket_ms), Some(day()));
947 assert!(m.temporal_lod_for_zoom(12).is_none());
949 }
950
951 #[test]
952 fn temporal_lod_for_zoom_is_none_when_unset() {
953 let m = Metadata::new("plain").with_temporal_bucket_ms(hour());
954 assert!(m.temporal_lod_for_zoom(0).is_none());
955 }
956
957 #[test]
958 fn temporal_lod_empty_vec_clears_to_none() {
959 let m = Metadata::new("empty")
962 .with_temporal_bucket_ms(hour())
963 .with_temporal_lod(vec![])
964 .unwrap();
965 assert!(m.temporal_lod.is_none());
966 }
967
968 #[test]
969 fn tilejson_descriptor_has_core_fields_and_temporal_extension() {
970 let m = Metadata::new("quakes")
971 .with_description("USGS earthquakes")
972 .with_attribution("USGS")
973 .with_zoom_levels(0, 10)
974 .with_temporal_bucket_ms(hour())
975 .with_time_range(TimeRange::new(1_700_000_000_000, 1_700_086_400_000))
976 .with_temporal_lod(vec![TemporalLodLevel {
977 bucket_ms: day(),
978 max_zoom_level: 6,
979 }])
980 .unwrap();
981 let tj = m.to_tilejson(Some("https://cdn/{z}/{x}/{y}/{t}.stt"));
982
983 assert_eq!(tj["tilejson"], "3.0.0");
985 assert_eq!(tj["tiles"][0], "https://cdn/{z}/{x}/{y}/{t}.stt");
986 assert_eq!(tj["minzoom"], 0);
987 assert_eq!(tj["maxzoom"], 10);
988 assert_eq!(tj["scheme"], "xyz");
989 assert_eq!(tj["vector_layers"][0]["id"], "default");
990 assert_eq!(tj["bounds"].as_array().unwrap().len(), 4);
991
992 assert_eq!(tj["temporal"]["step"], "PT1H");
994 assert_eq!(tj["temporal"]["bucket_ms"], 3_600_000u64);
995 let interval = &tj["temporal"]["interval"][0];
996 assert!(interval[0].as_str().unwrap().starts_with("2023-11-"));
997 assert!(interval[1].is_string());
998 assert_eq!(tj["temporal"]["lod"][0]["bucket_ms"], day());
999 }
1000
1001 #[test]
1002 fn iso8601_duration_formats_common_buckets() {
1003 assert_eq!(iso8601_duration(hour()).as_deref(), Some("PT1H"));
1004 assert_eq!(iso8601_duration(day()).as_deref(), Some("P1D"));
1005 assert_eq!(iso8601_duration(60_000).as_deref(), Some("PT1M"));
1006 assert_eq!(iso8601_duration(0), None);
1007 }
1008
1009 #[test]
1010 fn tilejson_time_beyond_i64_is_null_not_garbage() {
1011 let m = Metadata::new("x").with_time_range(TimeRange::new(u64::MAX, u64::MAX));
1014 let tj = m.to_tilejson(None);
1015 assert!(tj["temporal"]["interval"][0][0].is_null());
1016 assert!(tj["temporal"]["interval"][0][1].is_null());
1017 }
1018}