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#[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#[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#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
71#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
72pub struct RouteGeometry {
73 #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
75 pub polyline: String,
76 #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
78 pub engine: String,
79 #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
81 pub costing: Option<String>,
82 pub computed_at: i64,
84}
85
86#[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 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 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 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 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 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 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 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 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 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 #[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}