1use std::collections::{BTreeMap, BTreeSet};
10
11use crate::{ReplayArtifact, StudioSource, ViewerError, ViewerResult};
12
13pub const MISSION_COCKPIT_STATE_VERSION: u32 = 1;
15
16pub const MISSION_COCKPIT_MAX_SAMPLED_POINTS: usize = 4_096;
18
19#[derive(Clone, Copy, Debug, PartialEq)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
23pub struct MissionCockpitPoint {
24 pub x: f32,
26 pub y: f32,
28 pub z: f32,
30}
31
32impl MissionCockpitPoint {
33 pub fn try_new(x: f32, y: f32, z: f32) -> ViewerResult<Self> {
35 if !x.is_finite() || !y.is_finite() || !z.is_finite() {
36 return Err(ViewerError::InvalidState(
37 "mission cockpit points must contain finite XYZ values".into(),
38 ));
39 }
40 Ok(Self { x, y, z })
41 }
42
43 fn validate(&self) -> ViewerResult<()> {
44 Self::try_new(self.x, self.y, self.z).map(|_| ())
45 }
46}
47
48#[derive(Clone, Debug, PartialEq)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
52pub struct MissionCockpitFrame {
53 pub sequence: u64,
55 pub source_topic: String,
57 pub publish_topic: String,
59 pub frame_id: String,
61 pub stamp_nanos: u64,
63 pub point_count: u64,
65 pub sampled_source_indices: Vec<u64>,
67 pub sampled_points: Vec<MissionCockpitPoint>,
69}
70
71impl MissionCockpitFrame {
72 #[allow(clippy::too_many_arguments)]
74 pub fn try_new(
75 sequence: u64,
76 source_topic: impl Into<String>,
77 publish_topic: impl Into<String>,
78 frame_id: impl Into<String>,
79 stamp_nanos: u64,
80 point_count: u64,
81 sampled_source_indices: Vec<u64>,
82 sampled_points: Vec<MissionCockpitPoint>,
83 ) -> ViewerResult<Self> {
84 let frame = Self {
85 sequence,
86 source_topic: source_topic.into(),
87 publish_topic: publish_topic.into(),
88 frame_id: frame_id.into(),
89 stamp_nanos,
90 point_count,
91 sampled_source_indices,
92 sampled_points,
93 };
94 frame.validate()?;
95 Ok(frame)
96 }
97
98 pub fn validate(&self) -> ViewerResult<()> {
100 if self.source_topic.trim().is_empty()
101 || self.publish_topic.trim().is_empty()
102 || self.frame_id.trim().is_empty()
103 || self.point_count == 0
104 || self.sampled_points.is_empty()
105 || self.sampled_points.len() != self.sampled_source_indices.len()
106 || self.sampled_points.len() > MISSION_COCKPIT_MAX_SAMPLED_POINTS
107 {
108 return Err(ViewerError::InvalidState(
109 "mission cockpit frames require bounded identity and point samples".into(),
110 ));
111 }
112 let mut previous = None;
113 for (source_index, point) in self.sampled_source_indices.iter().zip(&self.sampled_points) {
114 if *source_index >= self.point_count
115 || previous.is_some_and(|previous| *source_index <= previous)
116 {
117 return Err(ViewerError::InvalidState(
118 "mission cockpit source indices must be strictly increasing and in range"
119 .into(),
120 ));
121 }
122 point.validate()?;
123 previous = Some(*source_index);
124 }
125 Ok(())
126 }
127}
128
129#[derive(Clone, Debug, PartialEq, Eq)]
131#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
132#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
133pub struct MissionCockpitLayer {
134 pub id: String,
136 pub label: String,
138 pub kind: String,
140 pub visible: bool,
142 pub frame_ids: Vec<String>,
144 pub color_rgb: [u8; 3],
146}
147
148impl MissionCockpitLayer {
149 pub fn try_new(
151 id: impl Into<String>,
152 label: impl Into<String>,
153 kind: impl Into<String>,
154 visible: bool,
155 frame_ids: Vec<String>,
156 color_rgb: [u8; 3],
157 ) -> ViewerResult<Self> {
158 let layer = Self {
159 id: id.into(),
160 label: label.into(),
161 kind: kind.into(),
162 visible,
163 frame_ids,
164 color_rgb,
165 };
166 layer.validate()?;
167 Ok(layer)
168 }
169
170 pub fn validate(&self) -> ViewerResult<()> {
172 if self.id.trim().is_empty() || self.label.trim().is_empty() || self.kind.trim().is_empty()
173 {
174 return Err(ViewerError::InvalidState(
175 "mission cockpit layers require identity, label, and kind".into(),
176 ));
177 }
178 let mut ids = BTreeSet::new();
179 if self.frame_ids.iter().any(|id| id.trim().is_empty() || !ids.insert(id)) {
180 return Err(ViewerError::InvalidState(
181 "mission cockpit layer topic identities must be non-empty and unique".into(),
182 ));
183 }
184 Ok(())
185 }
186}
187
188#[derive(Clone, Debug, PartialEq)]
190#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
191#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
192pub struct MissionCockpitNode {
193 pub id: String,
195 pub partition_id: String,
197 pub placement: String,
199 pub display_x: f32,
201 pub display_y: f32,
203}
204
205impl MissionCockpitNode {
206 pub fn try_new(
208 id: impl Into<String>,
209 partition_id: impl Into<String>,
210 placement: impl Into<String>,
211 display_x: f32,
212 display_y: f32,
213 ) -> ViewerResult<Self> {
214 let node = Self {
215 id: id.into(),
216 partition_id: partition_id.into(),
217 placement: placement.into(),
218 display_x,
219 display_y,
220 };
221 node.validate()?;
222 Ok(node)
223 }
224
225 pub fn validate(&self) -> ViewerResult<()> {
227 if self.id.trim().is_empty()
228 || self.partition_id.trim().is_empty()
229 || self.placement.trim().is_empty()
230 || !self.display_x.is_finite()
231 || !self.display_y.is_finite()
232 || !(0.0..=1.0).contains(&self.display_x)
233 || !(0.0..=1.0).contains(&self.display_y)
234 {
235 return Err(ViewerError::InvalidState(
236 "mission cockpit nodes require bounded identity and display coordinates".into(),
237 ));
238 }
239 Ok(())
240 }
241}
242
243#[derive(Clone, Debug, PartialEq, Eq)]
245#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
246#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
247pub struct MissionCockpitLink {
248 pub from_node: String,
250 pub to_node: String,
252 pub transfer_count: u64,
254 pub completed_transfer_count: u64,
256 pub payload_bytes: u64,
258 pub counted_copy_bytes: u64,
260}
261
262impl MissionCockpitLink {
263 pub fn try_new(
265 from_node: impl Into<String>,
266 to_node: impl Into<String>,
267 transfer_count: u64,
268 completed_transfer_count: u64,
269 payload_bytes: u64,
270 counted_copy_bytes: u64,
271 ) -> ViewerResult<Self> {
272 let link = Self {
273 from_node: from_node.into(),
274 to_node: to_node.into(),
275 transfer_count,
276 completed_transfer_count,
277 payload_bytes,
278 counted_copy_bytes,
279 };
280 link.validate()?;
281 Ok(link)
282 }
283
284 pub fn validate(&self) -> ViewerResult<()> {
286 if self.from_node.trim().is_empty()
287 || self.to_node.trim().is_empty()
288 || self.from_node == self.to_node
289 || self.completed_transfer_count > self.transfer_count
290 || (self.transfer_count > 0 && self.payload_bytes == 0)
291 || self.counted_copy_bytes > self.payload_bytes
292 {
293 return Err(ViewerError::InvalidState(
294 "mission cockpit links require distinct nodes and consistent counters".into(),
295 ));
296 }
297 Ok(())
298 }
299}
300
301#[derive(Clone, Debug, PartialEq, Eq)]
303#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
304#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
305pub struct MissionCockpitTimeline {
306 pub time_basis: String,
308 pub start_nanos: u64,
310 pub end_nanos: u64,
312 pub cursor_nanos: u64,
314 pub frame_count: u64,
316}
317
318impl MissionCockpitTimeline {
319 pub fn try_new(
321 time_basis: impl Into<String>,
322 start_nanos: u64,
323 end_nanos: u64,
324 cursor_nanos: u64,
325 frame_count: u64,
326 ) -> ViewerResult<Self> {
327 let timeline = Self {
328 time_basis: time_basis.into(),
329 start_nanos,
330 end_nanos,
331 cursor_nanos,
332 frame_count,
333 };
334 timeline.validate()?;
335 Ok(timeline)
336 }
337
338 pub fn validate(&self) -> ViewerResult<()> {
340 if self.time_basis.trim().is_empty() {
341 return Err(ViewerError::InvalidState(
342 "mission cockpit timeline requires a time basis".into(),
343 ));
344 }
345 if self.frame_count == 0 {
346 if self.start_nanos != 0 || self.end_nanos != 0 || self.cursor_nanos != 0 {
347 return Err(ViewerError::InvalidState(
348 "empty mission cockpit timelines must have zero bounds".into(),
349 ));
350 }
351 } else if self.start_nanos > self.end_nanos
352 || self.cursor_nanos < self.start_nanos
353 || self.cursor_nanos > self.end_nanos
354 {
355 return Err(ViewerError::InvalidState(
356 "mission cockpit timeline bounds or cursor are invalid".into(),
357 ));
358 }
359 Ok(())
360 }
361}
362
363#[derive(Clone, Debug, PartialEq, Eq)]
365#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
366#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
367pub struct MissionCockpitSummary {
368 pub source_packet_count: u64,
370 pub frame_count: u64,
372 pub total_point_count: u64,
374 pub sampled_point_count: u64,
376 pub transfer_count: u64,
378 pub completed_transfer_count: u64,
380 pub payload_bytes: u64,
382 pub counted_copy_bytes: u64,
384 pub upstream_publish_ready: bool,
386 pub upstream_partition_ready: bool,
388 pub calibration_registered: bool,
390 pub calibration_applied: bool,
392 pub time_basis: String,
394}
395
396impl MissionCockpitSummary {
397 #[allow(clippy::too_many_arguments)]
399 pub fn try_new(
400 source_packet_count: u64,
401 frame_count: u64,
402 total_point_count: u64,
403 sampled_point_count: u64,
404 transfer_count: u64,
405 completed_transfer_count: u64,
406 payload_bytes: u64,
407 counted_copy_bytes: u64,
408 upstream_publish_ready: bool,
409 upstream_partition_ready: bool,
410 calibration_registered: bool,
411 calibration_applied: bool,
412 time_basis: impl Into<String>,
413 ) -> ViewerResult<Self> {
414 let summary = Self {
415 source_packet_count,
416 frame_count,
417 total_point_count,
418 sampled_point_count,
419 transfer_count,
420 completed_transfer_count,
421 payload_bytes,
422 counted_copy_bytes,
423 upstream_publish_ready,
424 upstream_partition_ready,
425 calibration_registered,
426 calibration_applied,
427 time_basis: time_basis.into(),
428 };
429 summary.validate()?;
430 Ok(summary)
431 }
432
433 pub fn validate(&self) -> ViewerResult<()> {
435 if self.time_basis.trim().is_empty()
436 || self.frame_count > self.source_packet_count
437 || self.completed_transfer_count > self.transfer_count
438 || (self.frame_count > 0 && self.total_point_count == 0)
439 || (self.sampled_point_count > self.total_point_count)
440 || (self.transfer_count > 0 && self.payload_bytes == 0)
441 || self.counted_copy_bytes > self.payload_bytes
442 || (self.calibration_applied && !self.calibration_registered)
443 {
444 return Err(ViewerError::InvalidState(
445 "mission cockpit summary has invalid counters or calibration ordering".into(),
446 ));
447 }
448 Ok(())
449 }
450}
451
452#[derive(Clone, Debug, PartialEq)]
454#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
455#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
456pub struct MissionCockpitState {
457 pub version: u32,
459 pub title: String,
461 pub source: StudioSource,
463 pub expected_frame_ids: BTreeMap<String, String>,
465 pub timeline: MissionCockpitTimeline,
467 pub frames: Vec<MissionCockpitFrame>,
469 pub layers: Vec<MissionCockpitLayer>,
471 pub nodes: Vec<MissionCockpitNode>,
473 pub links: Vec<MissionCockpitLink>,
475 pub summary: MissionCockpitSummary,
477 pub artifacts: Vec<ReplayArtifact>,
479 pub publish_ready: bool,
481 pub partition_ready: bool,
483 pub mapping_admitted: bool,
485 pub blockers: Vec<String>,
487}
488
489impl MissionCockpitState {
490 #[allow(clippy::too_many_arguments)]
492 pub fn try_new(
493 title: impl Into<String>,
494 source: StudioSource,
495 expected_frame_ids: BTreeMap<String, String>,
496 timeline: MissionCockpitTimeline,
497 frames: Vec<MissionCockpitFrame>,
498 layers: Vec<MissionCockpitLayer>,
499 nodes: Vec<MissionCockpitNode>,
500 links: Vec<MissionCockpitLink>,
501 summary: MissionCockpitSummary,
502 artifacts: Vec<ReplayArtifact>,
503 blockers: Vec<String>,
504 ) -> ViewerResult<Self> {
505 let publish_ready = source.identity_matches
506 && summary.upstream_publish_ready
507 && summary.source_packet_count > 0
508 && summary.frame_count == summary.source_packet_count
509 && summary.frame_count == u64::try_from(frames.len()).unwrap_or(u64::MAX);
510 let partition_ready = publish_ready
511 && summary.upstream_partition_ready
512 && summary.transfer_count == summary.source_packet_count
513 && summary.completed_transfer_count == summary.transfer_count;
514 let mapping_admitted = partition_ready && summary.calibration_applied;
515 let state = Self {
516 version: MISSION_COCKPIT_STATE_VERSION,
517 title: title.into(),
518 source,
519 expected_frame_ids,
520 timeline,
521 frames,
522 layers,
523 nodes,
524 links,
525 summary,
526 artifacts,
527 publish_ready,
528 partition_ready,
529 mapping_admitted,
530 blockers,
531 };
532 state.validate()?;
533 Ok(state)
534 }
535
536 pub fn validate(&self) -> ViewerResult<()> {
538 if self.version != MISSION_COCKPIT_STATE_VERSION {
539 return Err(ViewerError::InvalidState(format!(
540 "unsupported mission cockpit state version {}",
541 self.version
542 )));
543 }
544 if self.title.trim().is_empty() || self.expected_frame_ids.is_empty() {
545 return Err(ViewerError::InvalidState(
546 "mission cockpit state requires title and expected frame identities".into(),
547 ));
548 }
549 self.source.validate()?;
550 self.timeline.validate()?;
551 self.summary.validate()?;
552
553 let mut expected_frames = BTreeSet::new();
554 for (topic, frame_id) in &self.expected_frame_ids {
555 if topic.trim().is_empty()
556 || frame_id.trim().is_empty()
557 || !expected_frames.insert(topic)
558 {
559 return Err(ViewerError::InvalidState(
560 "mission cockpit expected frame identities must be non-empty and unique".into(),
561 ));
562 }
563 }
564
565 let mut frame_topics = BTreeSet::new();
566 let mut total_points = 0_u64;
567 let mut sampled_points = 0_u64;
568 for (expected_sequence, frame) in self.frames.iter().enumerate() {
569 frame.validate()?;
570 if frame.sequence != u64::try_from(expected_sequence).unwrap_or(u64::MAX)
571 || !expected_frames.contains(&frame.source_topic)
572 || self.expected_frame_ids.get(&frame.source_topic) != Some(&frame.frame_id)
573 {
574 return Err(ViewerError::InvalidState(
575 "mission cockpit frames have invalid sequence, topic, or frame identity".into(),
576 ));
577 }
578 frame_topics.insert(frame.source_topic.clone());
579 total_points = total_points.checked_add(frame.point_count).ok_or_else(|| {
580 ViewerError::InvalidState("mission cockpit point count overflow".into())
581 })?;
582 sampled_points = sampled_points
583 .checked_add(u64::try_from(frame.sampled_points.len()).unwrap_or(u64::MAX))
584 .ok_or_else(|| {
585 ViewerError::InvalidState("mission cockpit sample count overflow".into())
586 })?;
587 }
588
589 let mut layer_ids = BTreeSet::new();
590 for layer in &self.layers {
591 layer.validate()?;
592 if !layer_ids.insert(&layer.id) {
593 return Err(ViewerError::InvalidState(
594 "mission cockpit layer IDs must be unique".into(),
595 ));
596 }
597 }
598
599 let mut node_ids = BTreeSet::new();
600 for node in &self.nodes {
601 node.validate()?;
602 if !node_ids.insert(&node.id) {
603 return Err(ViewerError::InvalidState(
604 "mission cockpit node IDs must be unique".into(),
605 ));
606 }
607 }
608 let mut link_transfer_count = 0_u64;
609 let mut link_completed_count = 0_u64;
610 let mut link_payload_bytes = 0_u64;
611 let mut link_copy_bytes = 0_u64;
612 for link in &self.links {
613 link.validate()?;
614 if !node_ids.contains(&link.from_node) || !node_ids.contains(&link.to_node) {
615 return Err(ViewerError::InvalidState(
616 "mission cockpit links must reference known graph nodes".into(),
617 ));
618 }
619 link_transfer_count =
620 link_transfer_count.checked_add(link.transfer_count).ok_or_else(|| {
621 ViewerError::InvalidState("mission cockpit transfer count overflow".into())
622 })?;
623 link_completed_count = link_completed_count
624 .checked_add(link.completed_transfer_count)
625 .ok_or_else(|| {
626 ViewerError::InvalidState("mission cockpit completion count overflow".into())
627 })?;
628 link_payload_bytes =
629 link_payload_bytes.checked_add(link.payload_bytes).ok_or_else(|| {
630 ViewerError::InvalidState("mission cockpit payload byte overflow".into())
631 })?;
632 link_copy_bytes =
633 link_copy_bytes.checked_add(link.counted_copy_bytes).ok_or_else(|| {
634 ViewerError::InvalidState("mission cockpit copy byte overflow".into())
635 })?;
636 }
637 if total_points != self.summary.total_point_count
638 || sampled_points != self.summary.sampled_point_count
639 || link_transfer_count != self.summary.transfer_count
640 || link_completed_count != self.summary.completed_transfer_count
641 || link_payload_bytes != self.summary.payload_bytes
642 || link_copy_bytes != self.summary.counted_copy_bytes
643 || self.timeline.frame_count != u64::try_from(self.frames.len()).unwrap_or(u64::MAX)
644 || self.summary.frame_count != u64::try_from(self.frames.len()).unwrap_or(u64::MAX)
645 {
646 return Err(ViewerError::InvalidState(
647 "mission cockpit frame, link, or timeline totals disagree with summary".into(),
648 ));
649 }
650 if self.publish_ready && frame_topics.is_empty() {
651 return Err(ViewerError::InvalidState(
652 "admitted mission cockpit state must contain frames".into(),
653 ));
654 }
655 let calculated_publish = self.source.identity_matches
656 && self.summary.upstream_publish_ready
657 && self.summary.source_packet_count > 0
658 && self.summary.frame_count == self.summary.source_packet_count
659 && self.summary.frame_count == u64::try_from(self.frames.len()).unwrap_or(u64::MAX);
660 if self.publish_ready != calculated_publish {
661 return Err(ViewerError::InvalidState(
662 "publish_ready disagrees with source, upstream, or frame gates".into(),
663 ));
664 }
665 let calculated_partition = calculated_publish
666 && self.summary.upstream_partition_ready
667 && self.summary.transfer_count == self.summary.source_packet_count
668 && self.summary.completed_transfer_count == self.summary.transfer_count;
669 if self.partition_ready != calculated_partition {
670 return Err(ViewerError::InvalidState(
671 "partition_ready disagrees with source, upstream, or transfer gates".into(),
672 ));
673 }
674 let calculated_mapping = self.partition_ready && self.summary.calibration_applied;
675 if self.mapping_admitted != calculated_mapping {
676 return Err(ViewerError::InvalidState(
677 "mapping_admitted disagrees with partition and calibration gates".into(),
678 ));
679 }
680 if self.mapping_admitted && !self.blockers.is_empty() {
681 return Err(ViewerError::InvalidState(
682 "admitted mission cockpit mapping cannot contain blockers".into(),
683 ));
684 }
685 if !self.mapping_admitted && self.blockers.is_empty() {
686 return Err(ViewerError::InvalidState(
687 "blocked mission cockpit mapping must expose at least one blocker".into(),
688 ));
689 }
690 if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
691 return Err(ViewerError::InvalidState(
692 "mission cockpit blockers must not contain empty messages".into(),
693 ));
694 }
695
696 let mut artifact_roles = BTreeSet::new();
697 let mut artifact_paths = BTreeSet::new();
698 for artifact in &self.artifacts {
699 artifact.validate()?;
700 if !artifact_roles.insert(&artifact.role) || !artifact_paths.insert(&artifact.path) {
701 return Err(ViewerError::InvalidState(
702 "mission cockpit artifacts must have unique roles and paths".into(),
703 ));
704 }
705 }
706 Ok(())
707 }
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713
714 const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
715
716 fn source(matches: bool) -> StudioSource {
717 StudioSource::try_new(
718 "canonical bag",
719 "/media/input.db3",
720 SHA,
721 if matches {
722 SHA
723 } else {
724 "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
725 },
726 matches,
727 )
728 .unwrap()
729 }
730
731 fn frame(sequence: u64) -> MissionCockpitFrame {
732 MissionCockpitFrame::try_new(
733 sequence,
734 "/lidar_front/points_raw",
735 "/spatialrust/lidar_front/points_raw",
736 "lidar_front",
737 10 + sequence,
738 2,
739 vec![0, 1],
740 vec![
741 MissionCockpitPoint::try_new(sequence as f32, 0.0, 0.0).unwrap(),
742 MissionCockpitPoint::try_new(sequence as f32, 1.0, 0.0).unwrap(),
743 ],
744 )
745 .unwrap()
746 }
747
748 fn layers() -> Vec<MissionCockpitLayer> {
749 vec![
750 MissionCockpitLayer::try_new(
751 "front",
752 "Front lidar",
753 "point-cloud",
754 true,
755 vec!["/lidar_front/points_raw".into()],
756 [91, 220, 255],
757 )
758 .unwrap(),
759 MissionCockpitLayer::try_new(
760 "graph",
761 "Execution graph",
762 "transfer-graph",
763 true,
764 Vec::new(),
765 [99, 231, 165],
766 )
767 .unwrap(),
768 ]
769 }
770
771 fn state(matches: bool) -> MissionCockpitState {
772 let frames = if matches { vec![frame(0)] } else { Vec::new() };
773 let nodes = vec![
774 MissionCockpitNode::try_new("edge", "edge", "edge", 0.2, 0.5).unwrap(),
775 MissionCockpitNode::try_new("host", "host", "host", 0.8, 0.5).unwrap(),
776 ];
777 let links = if matches {
778 vec![MissionCockpitLink::try_new("edge", "host", 1, 1, 128, 128).unwrap()]
779 } else {
780 Vec::new()
781 };
782 let summary = MissionCockpitSummary::try_new(
783 if matches { 1 } else { 0 },
784 u64::try_from(frames.len()).unwrap(),
785 if matches { 2 } else { 0 },
786 if matches { 2 } else { 0 },
787 if matches { 1 } else { 0 },
788 if matches { 1 } else { 0 },
789 if matches { 128 } else { 0 },
790 if matches { 128 } else { 0 },
791 matches,
792 matches,
793 false,
794 false,
795 "header stamp",
796 )
797 .unwrap();
798 MissionCockpitState::try_new(
799 "Mission Cockpit",
800 source(matches),
801 BTreeMap::from([("/lidar_front/points_raw".into(), "lidar_front".into())]),
802 MissionCockpitTimeline::try_new(
803 "header stamp",
804 if matches { 10 } else { 0 },
805 if matches { 10 } else { 0 },
806 if matches { 10 } else { 0 },
807 u64::try_from(frames.len()).unwrap(),
808 )
809 .unwrap(),
810 frames,
811 layers(),
812 nodes,
813 links,
814 summary,
815 Vec::new(),
816 if matches {
817 vec!["calibration not applied".into()]
818 } else {
819 vec!["source SHA mismatch".into()]
820 },
821 )
822 .unwrap()
823 }
824
825 #[test]
826 fn healthy_packet_and_partition_are_admitted_but_mapping_stays_blocked() {
827 let cockpit = state(true);
828 assert!(cockpit.publish_ready);
829 assert!(cockpit.partition_ready);
830 assert!(!cockpit.mapping_admitted);
831 cockpit.validate().unwrap();
832 }
833
834 #[test]
835 fn source_mismatch_withholds_frames_and_execution() {
836 let cockpit = state(false);
837 assert!(!cockpit.publish_ready);
838 assert!(!cockpit.partition_ready);
839 assert!(!cockpit.mapping_admitted);
840 assert!(cockpit.frames.is_empty());
841 }
842
843 #[test]
844 fn rejects_non_finite_sample() {
845 assert!(MissionCockpitPoint::try_new(f32::NAN, 0.0, 0.0).is_err());
846 }
847
848 #[test]
849 fn rejects_unsorted_source_indices() {
850 let result = MissionCockpitFrame::try_new(
851 0,
852 "topic",
853 "publish",
854 "frame",
855 1,
856 3,
857 vec![1, 0],
858 vec![
859 MissionCockpitPoint::try_new(0.0, 0.0, 0.0).unwrap(),
860 MissionCockpitPoint::try_new(1.0, 0.0, 0.0).unwrap(),
861 ],
862 );
863 assert!(result.is_err());
864 }
865}