Skip to main content

spatialrust_viewer/
live_publish.rs

1//! Portable state for the source-bound ROS 2 live-publish bridge.
2//!
3//! The bridge state distinguishes transport readiness from calibrated mapping
4//! admission. A bounded point-cloud stream may be published for inspection
5//! through an explicit adapter, while source/frame identity failures withhold
6//! packets and calibration remains a separate mapping gate.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use crate::{ReplayArtifact, StudioSource, ViewerError, ViewerResult};
11
12/// Current serialized live-publish state schema version.
13pub const LIVE_PUBLISH_STATE_VERSION: u32 = 1;
14
15/// One source topic mapped to one explicit ROS 2 publish topic.
16#[derive(Clone, Debug, PartialEq, Eq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
19pub struct LivePublishTopic {
20    /// Topic read from the source episode.
21    pub source_topic: String,
22    /// Topic exposed by the publish adapter.
23    pub publish_topic: String,
24    /// Fully-qualified ROS 2 message type.
25    pub message_type: String,
26    /// Number of messages present in the source bag for this topic.
27    pub source_message_count: u64,
28    /// Number of bounded records retained from this topic.
29    pub retained_record_count: u64,
30    /// Number of points retained from this topic.
31    pub retained_point_count: u64,
32    /// Number of messages published for this topic.
33    pub published_message_count: u64,
34    /// Number of points published for this topic.
35    pub published_point_count: u64,
36    /// Frame IDs observed in the published messages.
37    pub frame_ids: Vec<String>,
38}
39
40impl LivePublishTopic {
41    /// Creates and validates one source-to-publish topic mapping.
42    #[allow(clippy::too_many_arguments)]
43    pub fn try_new(
44        source_topic: impl Into<String>,
45        publish_topic: impl Into<String>,
46        message_type: impl Into<String>,
47        source_message_count: u64,
48        retained_record_count: u64,
49        retained_point_count: u64,
50        published_message_count: u64,
51        published_point_count: u64,
52        frame_ids: Vec<String>,
53    ) -> ViewerResult<Self> {
54        let topic = Self {
55            source_topic: source_topic.into(),
56            publish_topic: publish_topic.into(),
57            message_type: message_type.into(),
58            source_message_count,
59            retained_record_count,
60            retained_point_count,
61            published_message_count,
62            published_point_count,
63            frame_ids,
64        };
65        topic.validate()?;
66        Ok(topic)
67    }
68
69    /// Validates names, counters, and frame identity uniqueness.
70    pub fn validate(&self) -> ViewerResult<()> {
71        if self.source_topic.trim().is_empty()
72            || self.publish_topic.trim().is_empty()
73            || self.message_type.trim().is_empty()
74            || (self.retained_record_count > 0 && self.retained_point_count == 0)
75            || (self.published_message_count > 0 && self.published_point_count == 0)
76        {
77            return Err(ViewerError::InvalidState(
78                "live-publish topics require names, a message type, and consistent counters".into(),
79            ));
80        }
81        let mut frames = BTreeSet::new();
82        for frame_id in &self.frame_ids {
83            if frame_id.trim().is_empty() || !frames.insert(frame_id) {
84                return Err(ViewerError::InvalidState(
85                    "live-publish topic frame IDs must be non-empty and unique".into(),
86                ));
87            }
88        }
89        Ok(())
90    }
91}
92
93/// One encoded ROS 2 packet and its explicit loopback round-trip receipt.
94#[derive(Clone, Debug, PartialEq, Eq)]
95#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
96#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
97pub struct LivePublishPacket {
98    /// Zero-based deterministic publish sequence.
99    pub sequence: u64,
100    /// Source episode topic.
101    pub source_topic: String,
102    /// Published ROS 2 topic.
103    pub publish_topic: String,
104    /// PointCloud2 frame ID.
105    pub frame_id: String,
106    /// PointCloud2 header stamp in nanoseconds.
107    pub stamp_nanos: u64,
108    /// Number of points represented by the packet.
109    pub point_count: u64,
110    /// Encoded CDR payload bytes handed to the adapter.
111    pub payload_bytes: u64,
112    /// Decoded loopback payload bytes returned by the adapter.
113    pub roundtrip_payload_bytes: u64,
114    /// Whether the decoded message exactly matched the encoded message.
115    pub roundtrip_verified: bool,
116}
117
118impl LivePublishPacket {
119    /// Creates and validates one publish packet receipt.
120    #[allow(clippy::too_many_arguments)]
121    pub fn try_new(
122        sequence: u64,
123        source_topic: impl Into<String>,
124        publish_topic: impl Into<String>,
125        frame_id: impl Into<String>,
126        stamp_nanos: u64,
127        point_count: u64,
128        payload_bytes: u64,
129        roundtrip_payload_bytes: u64,
130        roundtrip_verified: bool,
131    ) -> ViewerResult<Self> {
132        let packet = Self {
133            sequence,
134            source_topic: source_topic.into(),
135            publish_topic: publish_topic.into(),
136            frame_id: frame_id.into(),
137            stamp_nanos,
138            point_count,
139            payload_bytes,
140            roundtrip_payload_bytes,
141            roundtrip_verified,
142        };
143        packet.validate()?;
144        Ok(packet)
145    }
146
147    /// Validates packet identity and round-trip counters.
148    pub fn validate(&self) -> ViewerResult<()> {
149        if self.source_topic.trim().is_empty()
150            || self.publish_topic.trim().is_empty()
151            || self.frame_id.trim().is_empty()
152            || self.point_count == 0
153            || self.payload_bytes == 0
154            || (self.roundtrip_verified && self.roundtrip_payload_bytes != self.payload_bytes)
155        {
156            return Err(ViewerError::InvalidState(
157                "live-publish packets require non-empty identity, points, and payload receipts"
158                    .into(),
159            ));
160        }
161        if self.roundtrip_verified && self.roundtrip_payload_bytes == 0 {
162            return Err(ViewerError::InvalidState(
163                "verified live-publish packets require a non-zero round-trip payload".into(),
164            ));
165        }
166        Ok(())
167    }
168}
169
170/// Explicit CPU/adapter transport counters for a publish run.
171#[derive(Clone, Debug, PartialEq, Eq)]
172#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
173#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
174pub struct LivePublishTransport {
175    /// Adapter name, for example `in-process-loopback`.
176    pub adapter: String,
177    /// Fully-qualified ROS 2 message type.
178    pub message_type: String,
179    /// Queue policy used by the adapter.
180    pub queue_policy: String,
181    /// Maximum number of samples retained by the adapter queue.
182    pub queue_capacity: u64,
183    /// Number of packets handed to the adapter.
184    pub published_message_count: u64,
185    /// Number of packets received back from the adapter.
186    pub received_message_count: u64,
187    /// Number of explicit queue/backpressure events.
188    pub backpressure_event_count: u64,
189    /// Bytes encoded on the host before publish.
190    pub host_encode_bytes: u64,
191    /// Bytes decoded on the host after receive.
192    pub host_decode_bytes: u64,
193    /// Explicit host-to-device bytes; zero for the CPU loopback adapter.
194    pub device_upload_bytes: u64,
195    /// Explicit device-to-host bytes; zero for the CPU loopback adapter.
196    pub device_readback_bytes: u64,
197}
198
199impl LivePublishTransport {
200    /// Creates and validates a transport receipt.
201    #[allow(clippy::too_many_arguments)]
202    pub fn try_new(
203        adapter: impl Into<String>,
204        message_type: impl Into<String>,
205        queue_policy: impl Into<String>,
206        queue_capacity: u64,
207        published_message_count: u64,
208        received_message_count: u64,
209        backpressure_event_count: u64,
210        host_encode_bytes: u64,
211        host_decode_bytes: u64,
212        device_upload_bytes: u64,
213        device_readback_bytes: u64,
214    ) -> ViewerResult<Self> {
215        let transport = Self {
216            adapter: adapter.into(),
217            message_type: message_type.into(),
218            queue_policy: queue_policy.into(),
219            queue_capacity,
220            published_message_count,
221            received_message_count,
222            backpressure_event_count,
223            host_encode_bytes,
224            host_decode_bytes,
225            device_upload_bytes,
226            device_readback_bytes,
227        };
228        transport.validate()?;
229        Ok(transport)
230    }
231
232    /// Validates queue and message counters.
233    pub fn validate(&self) -> ViewerResult<()> {
234        if self.adapter.trim().is_empty()
235            || self.message_type.trim().is_empty()
236            || self.queue_policy.trim().is_empty()
237            || self.queue_capacity == 0
238            || self.received_message_count > self.published_message_count
239        {
240            return Err(ViewerError::InvalidState(
241                "live-publish transport has invalid adapter, queue, or message counters".into(),
242            ));
243        }
244        Ok(())
245    }
246}
247
248/// Aggregate counts and admission inputs for one live-publish run.
249#[derive(Clone, Debug, PartialEq, Eq)]
250#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
251#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
252pub struct LivePublishSummary {
253    /// Sum of source messages for the selected source topics.
254    pub source_message_count: u64,
255    /// Number of bounded records admitted before publish.
256    pub selected_record_count: u64,
257    /// Number of points in the bounded source episode.
258    pub selected_point_count: u64,
259    /// Conservative allocated bytes retained by the bounded source episode.
260    pub selected_bytes: u64,
261    /// Largest source allocation observed while reading the bag.
262    pub peak_source_bytes: u64,
263    /// Number of packets handed to the adapter.
264    pub published_message_count: u64,
265    /// Number of packets received back from the adapter.
266    pub received_message_count: u64,
267    /// Number of points represented by published packets.
268    pub published_point_count: u64,
269    /// Whether the deterministic episode order was verified.
270    pub deterministic_order_verified: bool,
271    /// Whether all emitted records matched the expected frame.
272    pub frame_identity_match: bool,
273    /// Whether the source-bound readiness receipt registered calibration.
274    pub calibration_registered: bool,
275    /// Whether a clock/frame transform was actually applied to packets.
276    pub calibration_applied: bool,
277    /// Timestamp domain exposed by the published messages.
278    pub time_basis: String,
279}
280
281impl LivePublishSummary {
282    /// Creates and validates publish counters and gates.
283    #[allow(clippy::too_many_arguments)]
284    pub fn try_new(
285        source_message_count: u64,
286        selected_record_count: u64,
287        selected_point_count: u64,
288        selected_bytes: u64,
289        peak_source_bytes: u64,
290        published_message_count: u64,
291        received_message_count: u64,
292        published_point_count: u64,
293        deterministic_order_verified: bool,
294        frame_identity_match: bool,
295        calibration_registered: bool,
296        calibration_applied: bool,
297        time_basis: impl Into<String>,
298    ) -> ViewerResult<Self> {
299        let summary = Self {
300            source_message_count,
301            selected_record_count,
302            selected_point_count,
303            selected_bytes,
304            peak_source_bytes,
305            published_message_count,
306            received_message_count,
307            published_point_count,
308            deterministic_order_verified,
309            frame_identity_match,
310            calibration_registered,
311            calibration_applied,
312            time_basis: time_basis.into(),
313        };
314        summary.validate()?;
315        Ok(summary)
316    }
317
318    /// Validates monotonic counters and calibration ordering.
319    pub fn validate(&self) -> ViewerResult<()> {
320        if self.time_basis.trim().is_empty()
321            || self.published_message_count > self.selected_record_count
322            || self.received_message_count > self.published_message_count
323            || (self.selected_record_count > 0 && self.selected_point_count == 0)
324            || (self.published_message_count > 0 && self.published_point_count == 0)
325            || (self.calibration_applied && !self.calibration_registered)
326        {
327            return Err(ViewerError::InvalidState(
328                "live-publish summary has invalid counters or calibration ordering".into(),
329            ));
330        }
331        Ok(())
332    }
333}
334
335/// Portable source-bound state emitted by the ROS 2 live-publish bridge.
336#[derive(Clone, Debug, PartialEq, Eq)]
337#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
338#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
339pub struct LivePublishState {
340    /// Serialized state schema version.
341    pub version: u32,
342    /// User-facing dashboard title.
343    pub title: String,
344    /// Exact input identity.
345    pub source: StudioSource,
346    /// Source-topic to expected frame identity map.
347    pub expected_frame_ids: BTreeMap<String, String>,
348    /// Time basis exposed by the bridge.
349    pub time_basis: String,
350    /// Source-to-publish topic inventory.
351    pub topics: Vec<LivePublishTopic>,
352    /// Explicit adapter and transfer counters.
353    pub transport: LivePublishTransport,
354    /// Ordered packet receipts.
355    pub packets: Vec<LivePublishPacket>,
356    /// Aggregate publish metrics and calibration gates.
357    pub summary: LivePublishSummary,
358    /// Checksummed state/dashboard/input artifacts.
359    pub artifacts: Vec<ReplayArtifact>,
360    /// Whether source-bound packet publish and round-trip checks passed.
361    pub publish_ready: bool,
362    /// Whether calibrated-world mapping is admitted.
363    pub mapping_admitted: bool,
364    /// Human-readable fail-closed reasons.
365    pub blockers: Vec<String>,
366}
367
368impl LivePublishState {
369    /// Creates state and derives publish/mapping admission from its receipts.
370    #[allow(clippy::too_many_arguments)]
371    pub fn try_new(
372        title: impl Into<String>,
373        source: StudioSource,
374        expected_frame_ids: BTreeMap<String, String>,
375        time_basis: impl Into<String>,
376        topics: Vec<LivePublishTopic>,
377        transport: LivePublishTransport,
378        packets: Vec<LivePublishPacket>,
379        summary: LivePublishSummary,
380        artifacts: Vec<ReplayArtifact>,
381        blockers: Vec<String>,
382    ) -> ViewerResult<Self> {
383        let all_roundtrips_verified = !packets.is_empty()
384            && packets.iter().all(|packet| {
385                packet.roundtrip_verified && packet.roundtrip_payload_bytes == packet.payload_bytes
386            });
387        let packet_count = u64::try_from(packets.len()).map_err(|_| {
388            ViewerError::InvalidState("live-publish packet count does not fit in u64".into())
389        })?;
390        let publish_ready = source.identity_matches
391            && summary.frame_identity_match
392            && summary.deterministic_order_verified
393            && summary.selected_record_count > 0
394            && packet_count == summary.selected_record_count
395            && summary.published_message_count == summary.selected_record_count
396            && summary.received_message_count == summary.published_message_count
397            && all_roundtrips_verified;
398        let mapping_admitted = publish_ready && summary.calibration_applied;
399        let state = Self {
400            version: LIVE_PUBLISH_STATE_VERSION,
401            title: title.into(),
402            source,
403            expected_frame_ids,
404            time_basis: time_basis.into(),
405            topics,
406            transport,
407            packets,
408            summary,
409            artifacts,
410            publish_ready,
411            mapping_admitted,
412            blockers,
413        };
414        state.validate()?;
415        Ok(state)
416    }
417
418    /// Validates packet, transport, artifact, and admission invariants.
419    pub fn validate(&self) -> ViewerResult<()> {
420        if self.version != LIVE_PUBLISH_STATE_VERSION {
421            return Err(ViewerError::InvalidState(format!(
422                "unsupported live-publish state version {}",
423                self.version
424            )));
425        }
426        if self.title.trim().is_empty()
427            || self.time_basis.trim().is_empty()
428            || self.expected_frame_ids.is_empty()
429        {
430            return Err(ViewerError::InvalidState(
431                "live-publish state title, expected frame map, and time basis are required".into(),
432            ));
433        }
434        self.source.validate()?;
435        self.summary.validate()?;
436        self.transport.validate()?;
437
438        let mut source_topics = BTreeSet::new();
439        let mut publish_topics = BTreeSet::new();
440        for topic in &self.topics {
441            topic.validate()?;
442            if !source_topics.insert(&topic.source_topic)
443                || !publish_topics.insert(&topic.publish_topic)
444            {
445                return Err(ViewerError::InvalidState(
446                    "live-publish topics must have unique source and publish names".into(),
447                ));
448            }
449            if topic.message_type != self.transport.message_type {
450                return Err(ViewerError::InvalidState(
451                    "live-publish topic message type disagrees with transport".into(),
452                ));
453            }
454            let Some(frame_id) = self.expected_frame_ids.get(&topic.source_topic) else {
455                return Err(ViewerError::InvalidState(
456                    "live-publish topic is missing an expected frame identity".into(),
457                ));
458            };
459            if frame_id.trim().is_empty() {
460                return Err(ViewerError::InvalidState(
461                    "live-publish expected frame identities must be non-empty".into(),
462                ));
463            }
464        }
465
466        let mut packet_points = 0_u64;
467        let mut packet_payload_bytes = 0_u64;
468        let mut roundtrip_payload_bytes = 0_u64;
469        for (expected_sequence, packet) in self.packets.iter().enumerate() {
470            packet.validate()?;
471            if packet.sequence != u64::try_from(expected_sequence).unwrap_or(u64::MAX)
472                || !source_topics.contains(&packet.source_topic)
473                || !publish_topics.contains(&packet.publish_topic)
474                || self.expected_frame_ids.get(&packet.source_topic) != Some(&packet.frame_id)
475            {
476                return Err(ViewerError::InvalidState(
477                    "live-publish packets have invalid sequence, topic, or frame identity".into(),
478                ));
479            }
480            packet_points = packet_points.checked_add(packet.point_count).ok_or_else(|| {
481                ViewerError::InvalidState("live-publish packet point count overflow".into())
482            })?;
483            packet_payload_bytes =
484                packet_payload_bytes.checked_add(packet.payload_bytes).ok_or_else(|| {
485                    ViewerError::InvalidState("live-publish payload count overflow".into())
486                })?;
487            roundtrip_payload_bytes =
488                roundtrip_payload_bytes.checked_add(packet.roundtrip_payload_bytes).ok_or_else(
489                    || ViewerError::InvalidState("live-publish round-trip count overflow".into()),
490                )?;
491        }
492        if !self.source.identity_matches && !self.packets.is_empty() {
493            return Err(ViewerError::InvalidState(
494                "source-mismatched live-publish state cannot contain packets".into(),
495            ));
496        }
497        if packet_points != self.summary.published_point_count
498            || packet_payload_bytes != self.transport.host_encode_bytes
499            || roundtrip_payload_bytes != self.transport.host_decode_bytes
500        {
501            return Err(ViewerError::InvalidState(
502                "live-publish packet totals disagree with transport summary".into(),
503            ));
504        }
505        if self.transport.published_message_count != self.summary.published_message_count
506            || self.transport.received_message_count != self.summary.received_message_count
507            || u64::try_from(self.packets.len()).unwrap_or(u64::MAX)
508                != self.transport.published_message_count
509        {
510            return Err(ViewerError::InvalidState(
511                "live-publish message totals disagree with transport summary".into(),
512            ));
513        }
514        let calculated_publish_ready = self.source.identity_matches
515            && self.summary.frame_identity_match
516            && self.summary.deterministic_order_verified
517            && self.summary.selected_record_count > 0
518            && u64::try_from(self.packets.len()).unwrap_or(u64::MAX)
519                == self.summary.selected_record_count
520            && self.summary.published_message_count == self.summary.selected_record_count
521            && self.summary.received_message_count == self.summary.published_message_count
522            && self.packets.iter().all(|packet| {
523                packet.roundtrip_verified && packet.roundtrip_payload_bytes == packet.payload_bytes
524            });
525        if self.publish_ready != calculated_publish_ready {
526            return Err(ViewerError::InvalidState(
527                "publish_ready disagrees with source, frame, packet, or round-trip gates".into(),
528            ));
529        }
530        let calculated_mapping = self.publish_ready && self.summary.calibration_applied;
531        if self.mapping_admitted != calculated_mapping {
532            return Err(ViewerError::InvalidState(
533                "mapping_admitted disagrees with publish and calibration gates".into(),
534            ));
535        }
536        if self.mapping_admitted && !self.blockers.is_empty() {
537            return Err(ViewerError::InvalidState(
538                "admitted live-publish mapping cannot contain blockers".into(),
539            ));
540        }
541        if !self.mapping_admitted && self.blockers.is_empty() {
542            return Err(ViewerError::InvalidState(
543                "blocked live-publish mapping must expose at least one blocker".into(),
544            ));
545        }
546        if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
547            return Err(ViewerError::InvalidState(
548                "live-publish blockers must not contain empty messages".into(),
549            ));
550        }
551        let mut artifact_roles = BTreeSet::new();
552        let mut artifact_paths = BTreeSet::new();
553        for artifact in &self.artifacts {
554            artifact.validate()?;
555            if !artifact_roles.insert(&artifact.role) || !artifact_paths.insert(&artifact.path) {
556                return Err(ViewerError::InvalidState(
557                    "live-publish artifacts must have unique roles and paths".into(),
558                ));
559            }
560        }
561        Ok(())
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
570
571    fn source(identity_matches: bool) -> StudioSource {
572        StudioSource::try_new(
573            "canonical bag",
574            "/media/input.db3",
575            SHA,
576            if identity_matches {
577                SHA
578            } else {
579                "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
580            },
581            identity_matches,
582        )
583        .unwrap()
584    }
585
586    fn topic() -> LivePublishTopic {
587        LivePublishTopic::try_new(
588            "/lidar_front/points_raw",
589            "/spatialrust/lidar_front/points_raw",
590            "sensor_msgs/msg/PointCloud2",
591            1,
592            1,
593            2,
594            1,
595            2,
596            vec!["lidar_front".into()],
597        )
598        .unwrap()
599    }
600
601    fn packet() -> LivePublishPacket {
602        LivePublishPacket::try_new(
603            0,
604            "/lidar_front/points_raw",
605            "/spatialrust/lidar_front/points_raw",
606            "lidar_front",
607            10,
608            2,
609            24,
610            24,
611            true,
612        )
613        .unwrap()
614    }
615
616    fn transport() -> LivePublishTransport {
617        LivePublishTransport::try_new(
618            "in-process-loopback",
619            "sensor_msgs/msg/PointCloud2",
620            "replace-latest-per-topic",
621            1,
622            1,
623            1,
624            0,
625            24,
626            24,
627            0,
628            0,
629        )
630        .unwrap()
631    }
632
633    fn summary(frame_identity_match: bool) -> LivePublishSummary {
634        LivePublishSummary::try_new(
635            1,
636            1,
637            2,
638            24,
639            24,
640            1,
641            1,
642            2,
643            true,
644            frame_identity_match,
645            false,
646            false,
647            "PointCloud2 header stamp; no clock calibration applied",
648        )
649        .unwrap()
650    }
651
652    #[test]
653    fn healthy_publish_is_ready_while_mapping_stays_blocked() {
654        let expected_frames =
655            BTreeMap::from([("/lidar_front/points_raw".to_owned(), "lidar_front".to_owned())]);
656        let state = LivePublishState::try_new(
657            "Live Publish",
658            source(true),
659            expected_frames,
660            "PointCloud2 header stamp",
661            vec![topic()],
662            transport(),
663            vec![packet()],
664            summary(true),
665            Vec::new(),
666            vec!["clock/frame calibration was not applied".into()],
667        )
668        .unwrap();
669        assert!(state.publish_ready);
670        assert!(!state.mapping_admitted);
671        state.validate().unwrap();
672    }
673
674    #[test]
675    fn source_mismatch_withholds_packets() {
676        let expected_frames =
677            BTreeMap::from([("/lidar_front/points_raw".to_owned(), "lidar_front".to_owned())]);
678        let state = LivePublishState::try_new(
679            "Live Publish",
680            source(false),
681            expected_frames,
682            "PointCloud2 header stamp",
683            vec![LivePublishTopic::try_new(
684                "/lidar_front/points_raw",
685                "/spatialrust/lidar_front/points_raw",
686                "sensor_msgs/msg/PointCloud2",
687                1,
688                0,
689                0,
690                0,
691                0,
692                Vec::new(),
693            )
694            .unwrap()],
695            LivePublishTransport::try_new(
696                "in-process-loopback",
697                "sensor_msgs/msg/PointCloud2",
698                "replace-latest-per-topic",
699                1,
700                0,
701                0,
702                0,
703                0,
704                0,
705                0,
706                0,
707            )
708            .unwrap(),
709            Vec::new(),
710            LivePublishSummary::try_new(
711                1,
712                0,
713                0,
714                0,
715                0,
716                0,
717                0,
718                0,
719                false,
720                false,
721                false,
722                false,
723                "PointCloud2 header stamp",
724            )
725            .unwrap(),
726            Vec::new(),
727            vec!["source SHA-256 mismatch".into()],
728        )
729        .unwrap();
730        assert!(!state.publish_ready);
731        assert!(!state.mapping_admitted);
732        assert!(state.packets.is_empty());
733    }
734}