Skip to main content

mapky_app_specs/models/
route.rs

1use crate::{
2    common::sanitize_url,
3    constants::{
4        MAX_ROUTE_CONTROL_POINTS, MAX_ROUTE_COSTING_LENGTH, MAX_ROUTE_DESCRIPTION_LENGTH,
5        MAX_ROUTE_ENGINE_LENGTH, MAX_ROUTE_INSTRUCTION_LENGTH, MAX_ROUTE_NAME_LENGTH,
6        MAX_ROUTE_POLYLINE_LENGTH, MAX_ROUTE_WAYPOINTS, MAX_WAYPOINT_NAME_LENGTH, MIN_WAYPOINTS,
7    },
8    traits::{HasIdPath, TimestampId, Validatable},
9    validation::{validate_coordinates, validate_osm_way_url},
10    MAPKY_PATH, PUBLIC_PATH,
11};
12use serde::{Deserialize, Serialize};
13
14#[cfg(target_arch = "wasm32")]
15use crate::traits::Json;
16#[cfg(target_arch = "wasm32")]
17use wasm_bindgen::prelude::*;
18
19#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
20#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
21#[serde(rename_all = "snake_case")]
22#[derive(Default)]
23pub enum RouteActivityType {
24    #[default]
25    Hiking,
26    Cycling,
27    Running,
28    Walking,
29    Driving,
30    Skiing,
31    Other,
32}
33
34/// A geographic waypoint along a route
35#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
36#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
37pub struct Waypoint {
38    pub lat: f64,
39    pub lon: f64,
40    pub ele: Option<f64>,
41    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
42    pub name: Option<String>,
43}
44
45#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
46impl Waypoint {
47    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(constructor))]
48    pub fn new(lat: f64, lon: f64, ele: Option<f64>) -> Self {
49        Self {
50            lat,
51            lon,
52            ele,
53            name: None,
54        }
55    }
56}
57
58/// A step in a route with a navigation instruction
59#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
60#[derive(Serialize, Deserialize, Debug, Clone)]
61pub struct RouteStep {
62    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
63    pub instruction: String,
64    pub distance_m: f64,
65    pub waypoint_index: usize,
66}
67
68/// Snapped geometry for a route, computed by a routing engine or imported from GPX.
69/// Stored alongside the route so that viewers don't need to re-snap on every render.
70#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
71#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
72pub struct RouteGeometry {
73    /// Encoded polyline (Google polyline algorithm, precision 6 for Valhalla output).
74    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
75    pub polyline: String,
76    /// Source engine: "valhalla" | "manual" | "gpx" | other future values.
77    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
78    pub engine: String,
79    /// Engine-specific costing/profile, e.g. "pedestrian", "bicycle", "auto".
80    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
81    pub costing: Option<String>,
82    /// Unix milliseconds when this geometry was computed.
83    pub computed_at: i64,
84}
85
86/// User-created route (hiking, cycling, etc.)
87/// URI: /pub/mapky.app/routes/:route_id
88#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
89#[derive(Serialize, Deserialize, Default, Clone, Debug)]
90pub struct MapkyAppRoute {
91    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
92    pub name: String,
93    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
94    pub description: Option<String>,
95    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
96    pub activity: RouteActivityType,
97    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
98    pub waypoints: Vec<Waypoint>,
99    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
100    pub osm_ways: Option<Vec<String>>,
101    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
102    pub control_points: Option<Vec<Waypoint>>,
103    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
104    pub steps: Option<Vec<RouteStep>>,
105    pub distance_m: Option<f64>,
106    pub elevation_gain_m: Option<f64>,
107    pub elevation_loss_m: Option<f64>,
108    pub estimated_duration_s: Option<i64>,
109    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
110    pub image_uri: Option<String>,
111    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
112    pub geometry: Option<RouteGeometry>,
113}
114
115#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
116impl MapkyAppRoute {
117    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(constructor))]
118    pub fn new(name: String, activity: RouteActivityType, waypoints: Vec<Waypoint>) -> Self {
119        let route = MapkyAppRoute {
120            name,
121            description: None,
122            activity,
123            waypoints,
124            osm_ways: None,
125            control_points: None,
126            steps: None,
127            distance_m: None,
128            elevation_gain_m: None,
129            elevation_loss_m: None,
130            estimated_duration_s: None,
131            image_uri: None,
132            geometry: None,
133        };
134        route.sanitize()
135    }
136}
137
138#[cfg(target_arch = "wasm32")]
139#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
140impl MapkyAppRoute {
141    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = fromJson))]
142    pub fn from_json(js_value: &JsValue) -> Result<Self, String> {
143        Self::import_json(js_value)
144    }
145
146    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = toJson))]
147    pub fn to_json(&self) -> Result<JsValue, String> {
148        self.export_json()
149    }
150
151    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
152    pub fn name(&self) -> String {
153        self.name.clone()
154    }
155
156    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
157    pub fn description(&self) -> Option<String> {
158        self.description.clone()
159    }
160}
161
162#[cfg(target_arch = "wasm32")]
163impl Json for MapkyAppRoute {}
164
165impl TimestampId for MapkyAppRoute {}
166
167impl HasIdPath for MapkyAppRoute {
168    const PATH_SEGMENT: &'static str = "routes/";
169
170    fn create_path(id: &str) -> String {
171        [PUBLIC_PATH, MAPKY_PATH, Self::PATH_SEGMENT, id].concat()
172    }
173}
174
175impl Validatable for MapkyAppRoute {
176    fn sanitize(self) -> Self {
177        let name = self.name.trim().to_string();
178        let description = self.description.map(|d| d.trim().to_string());
179        let image_uri = self.image_uri.map(|u| sanitize_url(&u));
180        let osm_ways = self
181            .osm_ways
182            .map(|ways| ways.into_iter().map(|u| sanitize_url(&u)).collect());
183
184        MapkyAppRoute {
185            name,
186            description,
187            image_uri,
188            osm_ways,
189            ..self
190        }
191    }
192
193    fn validate(&self, id: Option<&str>) -> Result<(), String> {
194        if let Some(id) = id {
195            self.validate_id(id)?;
196        }
197
198        // Validate name
199        if self.name.trim().is_empty() {
200            return Err("Validation Error: Route name cannot be empty".into());
201        }
202        if self.name.chars().count() > MAX_ROUTE_NAME_LENGTH {
203            return Err(format!(
204                "Validation Error: Route name exceeds maximum length of {} characters",
205                MAX_ROUTE_NAME_LENGTH
206            ));
207        }
208
209        // Validate description
210        if let Some(ref desc) = self.description {
211            if desc.chars().count() > MAX_ROUTE_DESCRIPTION_LENGTH {
212                return Err(format!(
213                    "Validation Error: Description exceeds maximum length of {} characters",
214                    MAX_ROUTE_DESCRIPTION_LENGTH
215                ));
216            }
217        }
218
219        // Validate waypoints
220        if self.waypoints.len() < MIN_WAYPOINTS {
221            return Err(format!(
222                "Validation Error: Route must have at least {} waypoints",
223                MIN_WAYPOINTS
224            ));
225        }
226        if self.waypoints.len() > MAX_ROUTE_WAYPOINTS {
227            return Err(format!(
228                "Validation Error: Route exceeds maximum of {} waypoints",
229                MAX_ROUTE_WAYPOINTS
230            ));
231        }
232
233        for (i, wp) in self.waypoints.iter().enumerate() {
234            validate_coordinates(wp.lat, wp.lon)
235                .map_err(|e| format!("Validation Error: Waypoint {}: {}", i, e))?;
236            if let Some(ref name) = wp.name {
237                if name.chars().count() > MAX_WAYPOINT_NAME_LENGTH {
238                    return Err(format!(
239                        "Validation Error: Waypoint {} name exceeds maximum length of {} characters",
240                        i, MAX_WAYPOINT_NAME_LENGTH
241                    ));
242                }
243            }
244        }
245
246        // Validate control_points
247        if let Some(ref cps) = self.control_points {
248            if cps.len() > MAX_ROUTE_CONTROL_POINTS {
249                return Err(format!(
250                    "Validation Error: Route exceeds maximum of {} control points",
251                    MAX_ROUTE_CONTROL_POINTS
252                ));
253            }
254            if cps.len() < MIN_WAYPOINTS {
255                return Err(format!(
256                    "Validation Error: control_points must have at least {} points",
257                    MIN_WAYPOINTS
258                ));
259            }
260            for (i, cp) in cps.iter().enumerate() {
261                validate_coordinates(cp.lat, cp.lon)
262                    .map_err(|e| format!("Validation Error: control_points[{}]: {}", i, e))?;
263            }
264        }
265
266        // Validate steps
267        if let Some(ref steps) = self.steps {
268            for (i, step) in steps.iter().enumerate() {
269                if step.instruction.chars().count() > MAX_ROUTE_INSTRUCTION_LENGTH {
270                    return Err(format!(
271                        "Validation Error: steps[{}] instruction exceeds maximum length of {} characters",
272                        i, MAX_ROUTE_INSTRUCTION_LENGTH
273                    ));
274                }
275                if step.distance_m < 0.0 {
276                    return Err(format!(
277                        "Validation Error: steps[{}] distance_m cannot be negative",
278                        i
279                    ));
280                }
281                if step.waypoint_index >= self.waypoints.len() {
282                    return Err(format!(
283                        "Validation Error: steps[{}] waypoint_index {} out of bounds (route has {} waypoints)",
284                        i, step.waypoint_index, self.waypoints.len()
285                    ));
286                }
287            }
288        }
289
290        // Validate osm_ways — all must be Way URLs
291        if let Some(ref ways) = self.osm_ways {
292            for (i, way) in ways.iter().enumerate() {
293                validate_osm_way_url(way)
294                    .map_err(|e| format!("Validation Error: osm_ways[{}]: {}", i, e))?;
295            }
296        }
297
298        // Validate image URI
299        if let Some(ref uri) = self.image_uri {
300            url::Url::parse(uri)
301                .map_err(|_| format!("Validation Error: Invalid image URI: {}", uri))?;
302        }
303
304        // Validate non-negative measurements
305        if let Some(d) = self.distance_m {
306            if d < 0.0 {
307                return Err("Validation Error: distance_m cannot be negative".into());
308            }
309        }
310        if let Some(g) = self.elevation_gain_m {
311            if g < 0.0 {
312                return Err("Validation Error: elevation_gain_m cannot be negative".into());
313            }
314        }
315        if let Some(l) = self.elevation_loss_m {
316            if l < 0.0 {
317                return Err("Validation Error: elevation_loss_m cannot be negative".into());
318            }
319        }
320        if let Some(d) = self.estimated_duration_s {
321            if d < 0 {
322                return Err("Validation Error: estimated_duration_s cannot be negative".into());
323            }
324        }
325
326        // Validate geometry
327        if let Some(ref g) = self.geometry {
328            if g.polyline.is_empty() {
329                return Err("Validation Error: geometry.polyline cannot be empty".into());
330            }
331            if g.polyline.len() > MAX_ROUTE_POLYLINE_LENGTH {
332                return Err(format!(
333                    "Validation Error: geometry.polyline exceeds maximum length of {} bytes",
334                    MAX_ROUTE_POLYLINE_LENGTH
335                ));
336            }
337            if g.engine.trim().is_empty() {
338                return Err("Validation Error: geometry.engine cannot be empty".into());
339            }
340            if g.engine.chars().count() > MAX_ROUTE_ENGINE_LENGTH {
341                return Err(format!(
342                    "Validation Error: geometry.engine exceeds maximum length of {} characters",
343                    MAX_ROUTE_ENGINE_LENGTH
344                ));
345            }
346            if let Some(ref costing) = g.costing {
347                if costing.chars().count() > MAX_ROUTE_COSTING_LENGTH {
348                    return Err(format!(
349                        "Validation Error: geometry.costing exceeds maximum length of {} characters",
350                        MAX_ROUTE_COSTING_LENGTH
351                    ));
352                }
353            }
354            if g.computed_at < 0 {
355                return Err("Validation Error: geometry.computed_at cannot be negative".into());
356            }
357        }
358
359        Ok(())
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    fn test_waypoints() -> Vec<Waypoint> {
368        vec![
369            Waypoint::new(47.3769, 8.5417, Some(400.0)),
370            Waypoint::new(47.3800, 8.5450, Some(420.0)),
371            Waypoint::new(47.3850, 8.5500, Some(450.0)),
372        ]
373    }
374
375    #[test]
376    fn test_create_id() {
377        let route = MapkyAppRoute::new(
378            "Lake Loop".into(),
379            RouteActivityType::Hiking,
380            test_waypoints(),
381        );
382        let id = route.create_id();
383        assert_eq!(id.len(), 13);
384    }
385
386    #[test]
387    fn test_create_path() {
388        let route = MapkyAppRoute::new(
389            "Lake Loop".into(),
390            RouteActivityType::Cycling,
391            test_waypoints(),
392        );
393        let id = route.create_id();
394        let path = MapkyAppRoute::create_path(&id);
395        assert!(path.starts_with("/pub/mapky.app/routes/"));
396    }
397
398    #[test]
399    fn test_validate_happy() {
400        let route = MapkyAppRoute::new(
401            "Lake Loop".into(),
402            RouteActivityType::Hiking,
403            test_waypoints(),
404        );
405        let id = route.create_id();
406        assert!(route.validate(Some(&id)).is_ok());
407    }
408
409    #[test]
410    fn test_validate_empty_name() {
411        let route = MapkyAppRoute::new("".into(), RouteActivityType::Hiking, test_waypoints());
412        let id = route.create_id();
413        assert!(route.validate(Some(&id)).is_err());
414    }
415
416    #[test]
417    fn test_validate_too_few_waypoints() {
418        let route = MapkyAppRoute::new(
419            "Short".into(),
420            RouteActivityType::Walking,
421            vec![Waypoint::new(0.0, 0.0, None)],
422        );
423        let id = route.create_id();
424        assert!(route.validate(Some(&id)).is_err());
425    }
426
427    #[test]
428    fn test_validate_invalid_waypoint_coords() {
429        let route = MapkyAppRoute::new(
430            "Bad Route".into(),
431            RouteActivityType::Hiking,
432            vec![
433                Waypoint::new(0.0, 0.0, None),
434                Waypoint::new(91.0, 0.0, None),
435            ],
436        );
437        let id = route.create_id();
438        assert!(route.validate(Some(&id)).is_err());
439    }
440
441    #[test]
442    fn test_validate_osm_ways_must_be_way() {
443        let mut route = MapkyAppRoute::new(
444            "Linked Route".into(),
445            RouteActivityType::Hiking,
446            test_waypoints(),
447        );
448        route.osm_ways = Some(vec!["https://www.openstreetmap.org/node/123".into()]);
449        let id = route.create_id();
450        let result = route.validate(Some(&id));
451        assert!(result.is_err());
452        assert!(result.unwrap_err().contains("Way URL"));
453    }
454
455    #[test]
456    fn test_validate_osm_ways_valid() {
457        let mut route = MapkyAppRoute::new(
458            "Linked Route".into(),
459            RouteActivityType::Cycling,
460            test_waypoints(),
461        );
462        route.osm_ways = Some(vec![
463            "https://www.openstreetmap.org/way/123".into(),
464            "https://www.openstreetmap.org/way/456".into(),
465        ]);
466        let id = route.create_id();
467        assert!(route.validate(Some(&id)).is_ok());
468    }
469
470    #[test]
471    fn test_validate_negative_distance() {
472        let mut route =
473            MapkyAppRoute::new("Route".into(), RouteActivityType::Hiking, test_waypoints());
474        route.distance_m = Some(-1.0);
475        let id = route.create_id();
476        assert!(route.validate(Some(&id)).is_err());
477    }
478
479    #[test]
480    fn test_waypoint_with_name() {
481        let mut wp = Waypoint::new(47.0, 8.0, None);
482        wp.name = Some("Summit".into());
483        let route = MapkyAppRoute::new(
484            "Named WP Route".into(),
485            RouteActivityType::Hiking,
486            vec![wp, Waypoint::new(47.1, 8.1, None)],
487        );
488        let id = route.create_id();
489        assert!(route.validate(Some(&id)).is_ok());
490    }
491
492    #[test]
493    fn test_waypoint_name_too_long() {
494        let mut wp = Waypoint::new(47.0, 8.0, None);
495        wp.name = Some("a".repeat(101));
496        let route = MapkyAppRoute::new(
497            "Route".into(),
498            RouteActivityType::Hiking,
499            vec![wp, Waypoint::new(47.1, 8.1, None)],
500        );
501        let id = route.create_id();
502        assert!(route.validate(Some(&id)).is_err());
503    }
504
505    #[test]
506    fn test_validate_control_points() {
507        let mut route =
508            MapkyAppRoute::new("Route".into(), RouteActivityType::Hiking, test_waypoints());
509        route.control_points = Some(vec![
510            Waypoint::new(47.0, 8.0, None),
511            Waypoint::new(47.1, 8.1, None),
512        ]);
513        let id = route.create_id();
514        assert!(route.validate(Some(&id)).is_ok());
515    }
516
517    #[test]
518    fn test_validate_control_points_too_few() {
519        let mut route =
520            MapkyAppRoute::new("Route".into(), RouteActivityType::Hiking, test_waypoints());
521        route.control_points = Some(vec![Waypoint::new(47.0, 8.0, None)]);
522        let id = route.create_id();
523        assert!(route.validate(Some(&id)).is_err());
524    }
525
526    #[test]
527    fn test_validate_steps() {
528        let mut route =
529            MapkyAppRoute::new("Route".into(), RouteActivityType::Hiking, test_waypoints());
530        route.steps = Some(vec![
531            RouteStep {
532                instruction: "Head north".into(),
533                distance_m: 100.0,
534                waypoint_index: 0,
535            },
536            RouteStep {
537                instruction: "Turn right".into(),
538                distance_m: 200.0,
539                waypoint_index: 1,
540            },
541        ]);
542        let id = route.create_id();
543        assert!(route.validate(Some(&id)).is_ok());
544    }
545
546    #[test]
547    fn test_validate_step_waypoint_index_out_of_bounds() {
548        let mut route =
549            MapkyAppRoute::new("Route".into(), RouteActivityType::Hiking, test_waypoints());
550        route.steps = Some(vec![RouteStep {
551            instruction: "Go".into(),
552            distance_m: 10.0,
553            waypoint_index: 99,
554        }]);
555        let id = route.create_id();
556        let result = route.validate(Some(&id));
557        assert!(result.is_err());
558        assert!(result.unwrap_err().contains("out of bounds"));
559    }
560
561    #[test]
562    fn test_validate_step_negative_distance() {
563        let mut route =
564            MapkyAppRoute::new("Route".into(), RouteActivityType::Hiking, test_waypoints());
565        route.steps = Some(vec![RouteStep {
566            instruction: "Go".into(),
567            distance_m: -5.0,
568            waypoint_index: 0,
569        }]);
570        let id = route.create_id();
571        assert!(route.validate(Some(&id)).is_err());
572    }
573
574    #[test]
575    fn test_all_activity_types() {
576        let types = vec![
577            RouteActivityType::Hiking,
578            RouteActivityType::Cycling,
579            RouteActivityType::Running,
580            RouteActivityType::Walking,
581            RouteActivityType::Driving,
582            RouteActivityType::Skiing,
583            RouteActivityType::Other,
584        ];
585        for activity in types {
586            let route = MapkyAppRoute::new("Test".into(), activity, test_waypoints());
587            let id = route.create_id();
588            assert!(route.validate(Some(&id)).is_ok());
589        }
590    }
591
592    #[test]
593    fn test_serde_roundtrip() {
594        let mut route = MapkyAppRoute::new(
595            "Mountain Trail".into(),
596            RouteActivityType::Hiking,
597            test_waypoints(),
598        );
599        route.distance_m = Some(12500.0);
600
601        let json = serde_json::to_string(&route).unwrap();
602        assert!(json.contains("\"hiking\""));
603        assert!(json.contains("\"distance_m\":12500"));
604        let parsed: MapkyAppRoute = serde_json::from_str(&json).unwrap();
605        assert_eq!(parsed.name, "Mountain Trail");
606        assert_eq!(parsed.distance_m, Some(12500.0));
607    }
608
609    /// Existing routes saved with `difficulty` should still deserialize
610    /// cleanly — serde drops unknown fields by default. We don't read the
611    /// value anywhere; users should re-tag with universal tags instead.
612    #[test]
613    fn test_legacy_difficulty_field_ignored() {
614        let json = r#"{
615            "name": "Legacy",
616            "activity": "hiking",
617            "difficulty": "expert",
618            "waypoints": [
619                {"lat": 47.3, "lon": 8.5},
620                {"lat": 47.4, "lon": 8.6}
621            ]
622        }"#;
623        let parsed: MapkyAppRoute = serde_json::from_str(json).unwrap();
624        assert_eq!(parsed.name, "Legacy");
625    }
626
627    #[test]
628    fn test_geometry_roundtrip() {
629        let mut route = MapkyAppRoute::new(
630            "With Geometry".into(),
631            RouteActivityType::Cycling,
632            test_waypoints(),
633        );
634        route.geometry = Some(RouteGeometry {
635            polyline: "kpkfFcueeBgC@".into(),
636            engine: "valhalla".into(),
637            costing: Some("bicycle".into()),
638            computed_at: 1_730_000_000_000,
639        });
640        let id = route.create_id();
641        assert!(route.validate(Some(&id)).is_ok());
642
643        let json = serde_json::to_string(&route).unwrap();
644        assert!(json.contains("\"valhalla\""));
645        assert!(json.contains("\"computed_at\":1730000000000"));
646        let parsed: MapkyAppRoute = serde_json::from_str(&json).unwrap();
647        assert_eq!(parsed.geometry.as_ref().unwrap().engine, "valhalla");
648        assert_eq!(
649            parsed.geometry.as_ref().unwrap().costing.as_deref(),
650            Some("bicycle")
651        );
652    }
653
654    #[test]
655    fn test_geometry_validation_empty_polyline() {
656        let mut route = MapkyAppRoute::new(
657            "Bad Geo".into(),
658            RouteActivityType::Hiking,
659            test_waypoints(),
660        );
661        route.geometry = Some(RouteGeometry {
662            polyline: String::new(),
663            engine: "valhalla".into(),
664            costing: None,
665            computed_at: 0,
666        });
667        let id = route.create_id();
668        assert!(route.validate(Some(&id)).is_err());
669    }
670
671    #[test]
672    fn test_geometry_validation_oversize_polyline() {
673        let mut route = MapkyAppRoute::new(
674            "Big Geo".into(),
675            RouteActivityType::Hiking,
676            test_waypoints(),
677        );
678        route.geometry = Some(RouteGeometry {
679            polyline: "a".repeat(MAX_ROUTE_POLYLINE_LENGTH + 1),
680            engine: "valhalla".into(),
681            costing: None,
682            computed_at: 0,
683        });
684        let id = route.create_id();
685        let err = route.validate(Some(&id)).unwrap_err();
686        assert!(err.contains("polyline"));
687    }
688
689    #[test]
690    fn test_geometry_validation_empty_engine() {
691        let mut route = MapkyAppRoute::new(
692            "No Engine".into(),
693            RouteActivityType::Hiking,
694            test_waypoints(),
695        );
696        route.geometry = Some(RouteGeometry {
697            polyline: "kpkfFcueeB".into(),
698            engine: "   ".into(),
699            costing: None,
700            computed_at: 0,
701        });
702        let id = route.create_id();
703        let err = route.validate(Some(&id)).unwrap_err();
704        assert!(err.contains("engine"));
705    }
706
707    #[test]
708    fn test_geometry_validation_negative_computed_at() {
709        let mut route = MapkyAppRoute::new(
710            "Negative Time".into(),
711            RouteActivityType::Hiking,
712            test_waypoints(),
713        );
714        route.geometry = Some(RouteGeometry {
715            polyline: "kpkfFcueeB".into(),
716            engine: "valhalla".into(),
717            costing: None,
718            computed_at: -1,
719        });
720        let id = route.create_id();
721        let err = route.validate(Some(&id)).unwrap_err();
722        assert!(err.contains("computed_at"));
723    }
724
725    #[test]
726    fn test_try_from_valid() {
727        let json = r#"{
728            "name": "Lake Loop",
729            "description": "A nice walk around the lake",
730            "activity": "hiking",
731            "waypoints": [
732                {"lat": 47.3769, "lon": 8.5417, "ele": 400.0},
733                {"lat": 47.3800, "lon": 8.5450, "ele": 420.0}
734            ],
735            "osm_ways": null,
736            "distance_m": 5000.0,
737            "elevation_gain_m": 100.0,
738            "elevation_loss_m": 100.0,
739            "estimated_duration_s": 3600,
740            "image_uri": null
741        }"#;
742        let route = MapkyAppRoute::new(
743            "Lake Loop".into(),
744            RouteActivityType::Hiking,
745            vec![
746                Waypoint::new(47.3769, 8.5417, Some(400.0)),
747                Waypoint::new(47.3800, 8.5450, Some(420.0)),
748            ],
749        );
750        let id = route.create_id();
751        let result = <MapkyAppRoute as Validatable>::try_from(json.as_bytes(), &id);
752        assert!(result.is_ok());
753    }
754}