/home/runner/work/tenet/tenet/tenet-aws/src/models/graph_layout.rs
Line | Count | Source |
1 | | //! Graph layout override model for zoom-level specific rendering state. |
2 | | //! |
3 | | //! Stores per-view layout metadata (nodes, edges, viewport) independently from |
4 | | //! canonical architecture entities and raw connections. |
5 | | |
6 | | use std::collections::HashMap; |
7 | | |
8 | | use aws_sdk_dynamodb::types::AttributeValue; |
9 | | use chrono::{DateTime, Utc}; |
10 | | use serde::{Deserialize, Serialize}; |
11 | | use serde_dynamo::{from_item, to_item}; |
12 | | |
13 | | use crate::{ |
14 | | ApiError, |
15 | | db::insert_entity_type, |
16 | | models::{ |
17 | | connection::{ConnectionAnchorSide, ConnectionControlPoint, ConnectionRoutingMode}, |
18 | | organisation::OrganisationId, |
19 | | project::ProjectId, |
20 | | }, |
21 | | }; |
22 | | |
23 | | pub const MAX_LAYOUT_NODE_OVERRIDES: usize = 5000; |
24 | | pub const MAX_LAYOUT_EDGE_OVERRIDES: usize = 10_000; |
25 | | const MAX_LAYOUT_KEY_LENGTH: usize = 256; |
26 | | const MAX_LAYOUT_CONTROL_POINTS: usize = 16; |
27 | | |
28 | | pub use tenet_domain::GraphViewLevel; |
29 | | |
30 | | #[derive(Debug, Clone, Copy, Serialize, Deserialize)] |
31 | | pub struct GraphLayoutNodeOverride { |
32 | | pub x: f64, |
33 | | pub y: f64, |
34 | | } |
35 | | |
36 | | impl GraphLayoutNodeOverride { |
37 | 5 | pub fn validate(&self) -> Result<(), ApiError> { |
38 | 5 | if !self.x.is_finite() || !self.y.is_finite()4 { |
39 | 1 | return Err(ApiError::InvalidRequest( |
40 | 1 | "Graph layout node coordinates must be finite".to_string(), |
41 | 1 | )); |
42 | 4 | } |
43 | 4 | Ok(()) |
44 | 5 | } |
45 | | } |
46 | | |
47 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
48 | | pub struct GraphLayoutEdgeOverride { |
49 | | #[serde(skip_serializing_if = "Option::is_none")] |
50 | | pub source_side: Option<ConnectionAnchorSide>, |
51 | | #[serde(skip_serializing_if = "Option::is_none")] |
52 | | pub target_side: Option<ConnectionAnchorSide>, |
53 | | #[serde(skip_serializing_if = "Option::is_none")] |
54 | | pub routing_mode: Option<ConnectionRoutingMode>, |
55 | | #[serde(skip_serializing_if = "Option::is_none")] |
56 | | pub control_points: Option<Vec<ConnectionControlPoint>>, |
57 | | } |
58 | | |
59 | | impl GraphLayoutEdgeOverride { |
60 | 7 | pub fn validate(&self) -> Result<(), ApiError> { |
61 | 7 | if let Some(points5 ) = &self.control_points { |
62 | 5 | if points.len() > MAX_LAYOUT_CONTROL_POINTS { |
63 | 1 | return Err(ApiError::InvalidRequest(format!( |
64 | 1 | "Graph layout edge control points must not exceed {}", |
65 | 1 | MAX_LAYOUT_CONTROL_POINTS |
66 | 1 | ))); |
67 | 4 | } |
68 | 4 | let has_non_finite = points |
69 | 4 | .iter() |
70 | 5 | .any4 (|point| !point.x.is_finite() || !point.y.is_finite()4 ); |
71 | 4 | if has_non_finite { |
72 | 1 | return Err(ApiError::InvalidRequest( |
73 | 1 | "Graph layout control points must have finite coordinates".to_string(), |
74 | 1 | )); |
75 | 3 | } |
76 | 2 | } |
77 | 5 | Ok(()) |
78 | 7 | } |
79 | | } |
80 | | |
81 | | #[derive(Debug, Clone, Copy, Serialize, Deserialize)] |
82 | | pub struct GraphLayoutViewport { |
83 | | pub x: f64, |
84 | | pub y: f64, |
85 | | pub zoom: f64, |
86 | | } |
87 | | |
88 | | impl GraphLayoutViewport { |
89 | 4 | pub fn validate(&self) -> Result<(), ApiError> { |
90 | 4 | if !self.x.is_finite() || !self.y.is_finite() || !self.zoom.is_finite() { |
91 | 0 | return Err(ApiError::InvalidRequest( |
92 | 0 | "Graph layout viewport values must be finite".to_string(), |
93 | 0 | )); |
94 | 4 | } |
95 | 4 | if self.zoom <= 0.0 { |
96 | 0 | return Err(ApiError::InvalidRequest( |
97 | 0 | "Graph layout viewport zoom must be greater than 0".to_string(), |
98 | 0 | )); |
99 | 4 | } |
100 | 4 | Ok(()) |
101 | 4 | } |
102 | | } |
103 | | |
104 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
105 | | pub struct GraphLayoutView { |
106 | | pub organisation_id: OrganisationId, |
107 | | pub project_id: ProjectId, |
108 | | pub view_level: GraphViewLevel, |
109 | | #[serde(default)] |
110 | | pub version: u64, |
111 | | #[serde(default)] |
112 | | pub nodes: HashMap<String, GraphLayoutNodeOverride>, |
113 | | #[serde(default)] |
114 | | pub edges: HashMap<String, GraphLayoutEdgeOverride>, |
115 | | #[serde(skip_serializing_if = "Option::is_none")] |
116 | | pub viewport: Option<GraphLayoutViewport>, |
117 | | pub created_at: DateTime<Utc>, |
118 | | pub updated_at: DateTime<Utc>, |
119 | | } |
120 | | |
121 | | impl GraphLayoutView { |
122 | 4 | pub fn new( |
123 | 4 | organisation_id: OrganisationId, |
124 | 4 | project_id: ProjectId, |
125 | 4 | view_level: GraphViewLevel, |
126 | 4 | ) -> Self { |
127 | 4 | let now = Utc::now(); |
128 | 4 | Self { |
129 | 4 | organisation_id, |
130 | 4 | project_id, |
131 | 4 | view_level, |
132 | 4 | version: 0, |
133 | 4 | nodes: HashMap::new(), |
134 | 4 | edges: HashMap::new(), |
135 | 4 | viewport: None, |
136 | 4 | created_at: now, |
137 | 4 | updated_at: now, |
138 | 4 | } |
139 | 4 | } |
140 | | |
141 | 2 | pub fn validate(&self) -> Result<(), ApiError> { |
142 | 2 | validate_override_keys(&self.nodes)?1 ; |
143 | 1 | validate_override_keys(&self.edges)?0 ; |
144 | | |
145 | 1 | if self.nodes.len() > MAX_LAYOUT_NODE_OVERRIDES { |
146 | 0 | return Err(ApiError::InvalidRequest(format!( |
147 | 0 | "Graph layout node overrides must not exceed {}", |
148 | 0 | MAX_LAYOUT_NODE_OVERRIDES |
149 | 0 | ))); |
150 | 1 | } |
151 | | |
152 | 1 | if self.edges.len() > MAX_LAYOUT_EDGE_OVERRIDES { |
153 | 0 | return Err(ApiError::InvalidRequest(format!( |
154 | 0 | "Graph layout edge overrides must not exceed {}", |
155 | 0 | MAX_LAYOUT_EDGE_OVERRIDES |
156 | 0 | ))); |
157 | 1 | } |
158 | | |
159 | 1 | for node_override in self.nodes.values() { |
160 | 1 | node_override.validate()?0 ; |
161 | | } |
162 | 1 | for edge_override in self.edges.values() { |
163 | 1 | edge_override.validate()?0 ; |
164 | | } |
165 | 1 | if let Some(viewport) = self.viewport { |
166 | 1 | viewport.validate()?0 ; |
167 | 0 | } |
168 | 1 | Ok(()) |
169 | 2 | } |
170 | | } |
171 | | |
172 | | #[derive(Debug, Clone, Serialize, Deserialize, Default)] |
173 | | pub struct PutGraphLayoutRequest { |
174 | | #[serde(skip_serializing_if = "Option::is_none")] |
175 | | pub expected_version: Option<u64>, |
176 | | #[serde(default)] |
177 | | pub nodes: HashMap<String, GraphLayoutNodeOverride>, |
178 | | #[serde(default)] |
179 | | pub edges: HashMap<String, GraphLayoutEdgeOverride>, |
180 | | #[serde(skip_serializing_if = "Option::is_none")] |
181 | | pub viewport: Option<GraphLayoutViewport>, |
182 | | } |
183 | | |
184 | | impl PutGraphLayoutRequest { |
185 | 1 | pub fn validate(&self) -> Result<(), ApiError> { |
186 | 1 | validate_override_keys(&self.nodes)?0 ; |
187 | 1 | validate_override_keys(&self.edges)?0 ; |
188 | | |
189 | 1 | if self.nodes.len() > MAX_LAYOUT_NODE_OVERRIDES { |
190 | 0 | return Err(ApiError::InvalidRequest(format!( |
191 | 0 | "Graph layout node overrides must not exceed {}", |
192 | 0 | MAX_LAYOUT_NODE_OVERRIDES |
193 | 0 | ))); |
194 | 1 | } |
195 | 1 | if self.edges.len() > MAX_LAYOUT_EDGE_OVERRIDES { |
196 | 0 | return Err(ApiError::InvalidRequest(format!( |
197 | 0 | "Graph layout edge overrides must not exceed {}", |
198 | 0 | MAX_LAYOUT_EDGE_OVERRIDES |
199 | 0 | ))); |
200 | 1 | } |
201 | | |
202 | 1 | for node in self.nodes.values() { |
203 | 1 | node.validate()?0 ; |
204 | | } |
205 | 1 | for edge in self.edges.values() { |
206 | 1 | edge.validate()?0 ; |
207 | | } |
208 | 1 | if let Some(viewport) = self.viewport { |
209 | 1 | viewport.validate()?0 ; |
210 | 0 | } |
211 | 1 | Ok(()) |
212 | 1 | } |
213 | | } |
214 | | |
215 | | #[derive(Debug, Clone, Serialize, Deserialize, Default)] |
216 | | pub struct PatchGraphLayoutRequest { |
217 | | #[serde(skip_serializing_if = "Option::is_none")] |
218 | | pub expected_version: Option<u64>, |
219 | | #[serde(skip_serializing_if = "Option::is_none")] |
220 | | pub nodes_upsert: Option<HashMap<String, GraphLayoutNodeOverride>>, |
221 | | #[serde(skip_serializing_if = "Option::is_none")] |
222 | | pub nodes_remove: Option<Vec<String>>, |
223 | | #[serde(skip_serializing_if = "Option::is_none")] |
224 | | pub edges_upsert: Option<HashMap<String, GraphLayoutEdgeOverride>>, |
225 | | #[serde(skip_serializing_if = "Option::is_none")] |
226 | | pub edges_remove: Option<Vec<String>>, |
227 | | #[serde(skip_serializing_if = "Option::is_none")] |
228 | | pub viewport: Option<GraphLayoutViewport>, |
229 | | #[serde(skip_serializing_if = "Option::is_none")] |
230 | | pub clear_viewport: Option<bool>, |
231 | | } |
232 | | |
233 | | impl PatchGraphLayoutRequest { |
234 | 3 | pub fn validate(&self) -> Result<(), ApiError> { |
235 | 3 | if let Some(nodes_upsert) = &self.nodes_upsert { |
236 | 3 | validate_override_keys(nodes_upsert)?0 ; |
237 | 3 | if nodes_upsert.len() > MAX_LAYOUT_NODE_OVERRIDES { |
238 | 0 | return Err(ApiError::InvalidRequest(format!( |
239 | 0 | "Graph layout node overrides in patch must not exceed {}", |
240 | 0 | MAX_LAYOUT_NODE_OVERRIDES |
241 | 0 | ))); |
242 | 3 | } |
243 | 3 | for node_override in nodes_upsert.values() { |
244 | 3 | node_override.validate()?1 ; |
245 | | } |
246 | 0 | } |
247 | | |
248 | 2 | if let Some(nodes_remove) = &self.nodes_remove { |
249 | 2 | for key in nodes_remove { |
250 | 2 | validate_override_key(key)?0 ; |
251 | | } |
252 | 0 | } |
253 | | |
254 | 2 | if let Some(edges_upsert) = &self.edges_upsert { |
255 | 2 | validate_override_keys(edges_upsert)?0 ; |
256 | 2 | if edges_upsert.len() > MAX_LAYOUT_EDGE_OVERRIDES { |
257 | 0 | return Err(ApiError::InvalidRequest(format!( |
258 | 0 | "Graph layout edge overrides in patch must not exceed {}", |
259 | 0 | MAX_LAYOUT_EDGE_OVERRIDES |
260 | 0 | ))); |
261 | 2 | } |
262 | 2 | for edge_override in edges_upsert.values() { |
263 | 2 | edge_override.validate()?0 ; |
264 | | } |
265 | 0 | } |
266 | | |
267 | 2 | if let Some(edges_remove) = &self.edges_remove { |
268 | 2 | for key in edges_remove { |
269 | 2 | validate_override_key(key)?0 ; |
270 | | } |
271 | 0 | } |
272 | | |
273 | 2 | if let Some(viewport) = self.viewport { |
274 | 2 | viewport.validate()?0 ; |
275 | 0 | } |
276 | | |
277 | 2 | if self.clear_viewport.unwrap_or(false) && self.viewport1 .is_some1 () { |
278 | 1 | return Err(ApiError::InvalidRequest( |
279 | 1 | "Graph layout patch cannot set viewport and clear_viewport in same request" |
280 | 1 | .to_string(), |
281 | 1 | )); |
282 | 1 | } |
283 | | |
284 | 1 | Ok(()) |
285 | 3 | } |
286 | | } |
287 | | |
288 | 10 | fn validate_override_keys<T>(overrides: &HashMap<String, T>) -> Result<(), ApiError> { |
289 | 10 | for key in overrides.keys() { |
290 | 10 | validate_override_key(key)?1 ; |
291 | | } |
292 | 9 | Ok(()) |
293 | 10 | } |
294 | | |
295 | 14 | fn validate_override_key(key: &str) -> Result<(), ApiError> { |
296 | 14 | if key.trim().is_empty() { |
297 | 1 | return Err(ApiError::InvalidRequest( |
298 | 1 | "Graph layout override keys must not be empty".to_string(), |
299 | 1 | )); |
300 | 13 | } |
301 | 13 | if key.len() > MAX_LAYOUT_KEY_LENGTH { |
302 | 0 | return Err(ApiError::InvalidRequest(format!( |
303 | 0 | "Graph layout override key length must not exceed {}", |
304 | 0 | MAX_LAYOUT_KEY_LENGTH |
305 | 0 | ))); |
306 | 13 | } |
307 | 13 | Ok(()) |
308 | 14 | } |
309 | | |
310 | | impl TryFrom<&GraphLayoutView> for HashMap<String, AttributeValue> { |
311 | | type Error = ApiError; |
312 | | |
313 | 1 | fn try_from(value: &GraphLayoutView) -> Result<Self, Self::Error> { |
314 | 1 | let item = to_item(value).map_err(|error| ApiError::InvalidRequest(error0 .to_string0 ()))?0 ; |
315 | 1 | Ok(insert_entity_type(item, "GraphLayoutView")) |
316 | 1 | } |
317 | | } |
318 | | |
319 | | impl TryFrom<&HashMap<String, AttributeValue>> for GraphLayoutView { |
320 | | type Error = ApiError; |
321 | | |
322 | 1 | fn try_from(value: &HashMap<String, AttributeValue>) -> Result<Self, Self::Error> { |
323 | 1 | from_item(value.clone()).map_err(|error| ApiError::InvalidRequest(error0 .to_string0 ())) |
324 | 1 | } |
325 | | } |
326 | | |
327 | | impl TryFrom<HashMap<String, AttributeValue>> for GraphLayoutView { |
328 | | type Error = ApiError; |
329 | | |
330 | 0 | fn try_from(value: HashMap<String, AttributeValue>) -> Result<Self, Self::Error> { |
331 | 0 | GraphLayoutView::try_from(&value) |
332 | 0 | } |
333 | | } |
334 | | |
335 | | #[cfg(test)] |
336 | | mod tests { |
337 | | use std::str::FromStr; |
338 | | |
339 | | use super::*; |
340 | | use crate::test_support::must_ok; |
341 | | |
342 | | #[allure_rs::allure_parent_suite("tenet-aws")] |
343 | | #[allure_rs::allure_test] |
344 | | #[test] |
345 | | fn test_graph_view_level_roundtrip() { |
346 | | let level: GraphViewLevel = must_ok(serde_json::from_str("\"context\"")); |
347 | | assert_eq!(level, GraphViewLevel::Context); |
348 | | assert_eq!(level.to_string(), "context"); |
349 | | assert!(GraphViewLevel::from_str("invalid").is_err()); |
350 | | } |
351 | | |
352 | | #[allure_rs::allure_parent_suite("tenet-aws")] |
353 | | #[allure_rs::allure_test] |
354 | | #[test] |
355 | | fn test_graph_layout_edge_override_validate() { |
356 | | let valid = GraphLayoutEdgeOverride { |
357 | | source_side: Some(ConnectionAnchorSide::Right), |
358 | | target_side: Some(ConnectionAnchorSide::Left), |
359 | | routing_mode: Some(ConnectionRoutingMode::Manual), |
360 | | control_points: Some(vec![ |
361 | | ConnectionControlPoint { x: 10.0, y: 20.0 }, |
362 | | ConnectionControlPoint { x: 30.0, y: 40.0 }, |
363 | | ]), |
364 | | }; |
365 | | assert!(valid.validate().is_ok()); |
366 | | |
367 | | let too_many = GraphLayoutEdgeOverride { |
368 | | source_side: None, |
369 | | target_side: None, |
370 | | routing_mode: None, |
371 | | control_points: Some( |
372 | | (0..=MAX_LAYOUT_CONTROL_POINTS) |
373 | | .map(|index| ConnectionControlPoint { |
374 | 17 | x: index as f64, |
375 | 17 | y: index as f64, |
376 | 17 | }) |
377 | | .collect(), |
378 | | ), |
379 | | }; |
380 | | assert!(too_many.validate().is_err()); |
381 | | |
382 | | let non_finite = GraphLayoutEdgeOverride { |
383 | | source_side: None, |
384 | | target_side: None, |
385 | | routing_mode: None, |
386 | | control_points: Some(vec![ConnectionControlPoint { |
387 | | x: f64::INFINITY, |
388 | | y: 0.0, |
389 | | }]), |
390 | | }; |
391 | | assert!(non_finite.validate().is_err()); |
392 | | } |
393 | | |
394 | | #[allure_rs::allure_parent_suite("tenet-aws")] |
395 | | #[allure_rs::allure_test] |
396 | | #[test] |
397 | | fn test_graph_layout_view_validate_keys_counts_and_values() { |
398 | | let mut layout = GraphLayoutView::new( |
399 | | must_ok( |
400 | | "550e8400-e29b-41d4-a716-446655440000" |
401 | | .to_string() |
402 | | .try_into(), |
403 | | ), |
404 | | must_ok(ProjectId::try_from("11111111-1111-4111-8111-111111111111")), |
405 | | GraphViewLevel::Container, |
406 | | ); |
407 | | layout.nodes.insert( |
408 | | "container:frontend".to_string(), |
409 | | GraphLayoutNodeOverride { x: 10.0, y: 20.0 }, |
410 | | ); |
411 | | layout.edges.insert( |
412 | | "inferred:container:frontend->api".to_string(), |
413 | | GraphLayoutEdgeOverride { |
414 | | source_side: Some(ConnectionAnchorSide::Bottom), |
415 | | target_side: Some(ConnectionAnchorSide::Top), |
416 | | routing_mode: Some(ConnectionRoutingMode::Auto), |
417 | | control_points: None, |
418 | | }, |
419 | | ); |
420 | | layout.viewport = Some(GraphLayoutViewport { |
421 | | x: 0.0, |
422 | | y: 0.0, |
423 | | zoom: 1.2, |
424 | | }); |
425 | | assert!(layout.validate().is_ok()); |
426 | | |
427 | | layout |
428 | | .nodes |
429 | | .insert("".to_string(), GraphLayoutNodeOverride { x: 0.0, y: 0.0 }); |
430 | | assert!(layout.validate().is_err()); |
431 | | } |
432 | | |
433 | | #[allure_rs::allure_parent_suite("tenet-aws")] |
434 | | #[allure_rs::allure_test] |
435 | | #[test] |
436 | | fn test_patch_graph_layout_request_validate_conflict_and_keys() { |
437 | | let mut patch = PatchGraphLayoutRequest { |
438 | | expected_version: Some(2), |
439 | | nodes_upsert: Some(HashMap::from([( |
440 | | "node:abc".to_string(), |
441 | | GraphLayoutNodeOverride { x: 1.0, y: 2.0 }, |
442 | | )])), |
443 | | nodes_remove: Some(vec!["node:def".to_string()]), |
444 | | edges_upsert: Some(HashMap::from([( |
445 | | "inferred:context:project->store".to_string(), |
446 | | GraphLayoutEdgeOverride { |
447 | | source_side: Some(ConnectionAnchorSide::Right), |
448 | | target_side: Some(ConnectionAnchorSide::Left), |
449 | | routing_mode: Some(ConnectionRoutingMode::Manual), |
450 | | control_points: Some(vec![ConnectionControlPoint { x: 3.0, y: 4.0 }]), |
451 | | }, |
452 | | )])), |
453 | | edges_remove: Some(vec!["connection:123".to_string()]), |
454 | | viewport: Some(GraphLayoutViewport { |
455 | | x: 0.0, |
456 | | y: 0.0, |
457 | | zoom: 1.0, |
458 | | }), |
459 | | clear_viewport: Some(false), |
460 | | }; |
461 | | assert!(patch.validate().is_ok()); |
462 | | |
463 | | patch.clear_viewport = Some(true); |
464 | | assert!(patch.validate().is_err()); |
465 | | } |
466 | | |
467 | | #[allure_rs::allure_parent_suite("tenet-aws")] |
468 | | #[allure_rs::allure_test] |
469 | | #[test] |
470 | | fn test_patch_graph_layout_request_validate_rejects_non_finite() { |
471 | | let patch = PatchGraphLayoutRequest { |
472 | | expected_version: None, |
473 | | nodes_upsert: Some(HashMap::from([( |
474 | | "node:abc".to_string(), |
475 | | GraphLayoutNodeOverride { |
476 | | x: f64::NAN, |
477 | | y: 0.0, |
478 | | }, |
479 | | )])), |
480 | | nodes_remove: None, |
481 | | edges_upsert: None, |
482 | | edges_remove: None, |
483 | | viewport: None, |
484 | | clear_viewport: None, |
485 | | }; |
486 | | assert!(patch.validate().is_err()); |
487 | | } |
488 | | |
489 | | #[allure_rs::allure_parent_suite("tenet-aws")] |
490 | | #[allure_rs::allure_test] |
491 | | #[test] |
492 | | fn test_graph_layout_roundtrip_item_conversion() { |
493 | | let mut layout = GraphLayoutView::new( |
494 | | must_ok( |
495 | | "550e8400-e29b-41d4-a716-446655440000" |
496 | | .to_string() |
497 | | .try_into(), |
498 | | ), |
499 | | must_ok(ProjectId::try_from("11111111-1111-4111-8111-111111111111")), |
500 | | GraphViewLevel::Context, |
501 | | ); |
502 | | layout.version = 3; |
503 | | layout.nodes.insert( |
504 | | "project".to_string(), |
505 | | GraphLayoutNodeOverride { x: 10.0, y: 20.0 }, |
506 | | ); |
507 | | layout.edges.insert( |
508 | | "inferred:context:project->store".to_string(), |
509 | | GraphLayoutEdgeOverride { |
510 | | source_side: Some(ConnectionAnchorSide::Bottom), |
511 | | target_side: Some(ConnectionAnchorSide::Top), |
512 | | routing_mode: Some(ConnectionRoutingMode::Auto), |
513 | | control_points: None, |
514 | | }, |
515 | | ); |
516 | | layout.viewport = Some(GraphLayoutViewport { |
517 | | x: 1.0, |
518 | | y: 2.0, |
519 | | zoom: 1.4, |
520 | | }); |
521 | | |
522 | | let item = must_ok(HashMap::<String, AttributeValue>::try_from(&layout)); |
523 | | let decoded = must_ok(GraphLayoutView::try_from(&item)); |
524 | | |
525 | | assert_eq!(decoded.view_level, GraphViewLevel::Context); |
526 | | assert_eq!(decoded.version, 3); |
527 | | assert_eq!(decoded.nodes.len(), 1); |
528 | | assert_eq!(decoded.edges.len(), 1); |
529 | | assert!(decoded.viewport.is_some()); |
530 | | } |
531 | | |
532 | | #[allure_rs::allure_parent_suite("tenet-aws")] |
533 | | #[allure_rs::allure_test] |
534 | | #[test] |
535 | | fn test_put_graph_layout_request_validate() { |
536 | | let put = PutGraphLayoutRequest { |
537 | | expected_version: Some(1), |
538 | | nodes: HashMap::from([( |
539 | | "node:abc".to_string(), |
540 | | GraphLayoutNodeOverride { x: 1.0, y: 2.0 }, |
541 | | )]), |
542 | | edges: HashMap::from([( |
543 | | "edge:abc".to_string(), |
544 | | GraphLayoutEdgeOverride { |
545 | | source_side: None, |
546 | | target_side: None, |
547 | | routing_mode: Some(ConnectionRoutingMode::Auto), |
548 | | control_points: None, |
549 | | }, |
550 | | )]), |
551 | | viewport: Some(GraphLayoutViewport { |
552 | | x: 0.0, |
553 | | y: 0.0, |
554 | | zoom: 1.0, |
555 | | }), |
556 | | }; |
557 | | assert!(put.validate().is_ok()); |
558 | | } |
559 | | } |