Skip to main content

pjson_rs_domain/entities/
stream.rs

1//! Stream entity representing a prioritized data stream
2
3use crate::{
4    DomainError, DomainResult,
5    entities::{Frame, frame::FramePatch},
6    value_objects::{JsonData, JsonPath, Priority, SessionId, StreamId},
7};
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Custom serde for SessionId within entities
13mod serde_session_id {
14    use crate::value_objects::SessionId;
15    use serde::{Deserialize, Deserializer, Serialize, Serializer};
16
17    pub fn serialize<S>(id: &SessionId, serializer: S) -> Result<S::Ok, S::Error>
18    where
19        S: Serializer,
20    {
21        id.as_uuid().serialize(serializer)
22    }
23
24    pub fn deserialize<'de, D>(deserializer: D) -> Result<SessionId, D::Error>
25    where
26        D: Deserializer<'de>,
27    {
28        let uuid = uuid::Uuid::deserialize(deserializer)?;
29        Ok(SessionId::from_uuid(uuid))
30    }
31}
32
33/// Custom serde for StreamId within entities
34mod serde_stream_id {
35    use crate::value_objects::StreamId;
36    use serde::{Deserialize, Deserializer, Serialize, Serializer};
37
38    pub fn serialize<S>(id: &StreamId, serializer: S) -> Result<S::Ok, S::Error>
39    where
40        S: Serializer,
41    {
42        id.as_uuid().serialize(serializer)
43    }
44
45    pub fn deserialize<'de, D>(deserializer: D) -> Result<StreamId, D::Error>
46    where
47        D: Deserializer<'de>,
48    {
49        let uuid = uuid::Uuid::deserialize(deserializer)?;
50        Ok(StreamId::from_uuid(uuid))
51    }
52}
53
54/// Custom serde for HashMap<String, Priority>
55mod serde_priority_map {
56    use crate::value_objects::Priority;
57    use serde::{Deserialize, Deserializer, Serialize, Serializer};
58    use std::collections::HashMap;
59
60    pub fn serialize<S>(map: &HashMap<String, Priority>, serializer: S) -> Result<S::Ok, S::Error>
61    where
62        S: Serializer,
63    {
64        let u8_map: HashMap<String, u8> = map.iter().map(|(k, v)| (k.clone(), v.value())).collect();
65        u8_map.serialize(serializer)
66    }
67
68    pub fn deserialize<'de, D>(deserializer: D) -> Result<HashMap<String, Priority>, D::Error>
69    where
70        D: Deserializer<'de>,
71    {
72        let u8_map: HashMap<String, u8> = HashMap::deserialize(deserializer)?;
73        u8_map
74            .into_iter()
75            .map(|(k, v)| {
76                Priority::new(v)
77                    .map(|p| (k, p))
78                    .map_err(serde::de::Error::custom)
79            })
80            .collect()
81    }
82}
83
84/// Stream state in its lifecycle
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[non_exhaustive]
87pub enum StreamState {
88    /// Stream is being prepared
89    Preparing,
90    /// Stream is actively sending data
91    Streaming,
92    /// Stream completed successfully
93    Completed,
94    /// Stream failed with error
95    Failed,
96    /// Stream was cancelled
97    Cancelled,
98}
99
100/// Stream configuration and metadata
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct StreamConfig {
103    /// Maximum frame size in bytes
104    pub max_frame_size: usize,
105    /// Maximum frames per batch
106    pub max_frames_per_batch: usize,
107    /// Compression settings
108    pub enable_compression: bool,
109    /// Custom priority rules
110    #[serde(with = "serde_priority_map")]
111    pub priority_rules: HashMap<String, Priority>,
112}
113
114impl Default for StreamConfig {
115    fn default() -> Self {
116        Self {
117            max_frame_size: 64 * 1024, // 64KB
118            max_frames_per_batch: 10,
119            enable_compression: true,
120            priority_rules: HashMap::new(),
121        }
122    }
123}
124
125/// Stream statistics for monitoring
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127pub struct StreamStats {
128    /// Total number of frames generated
129    pub total_frames: u64,
130    /// Number of skeleton frames sent
131    pub skeleton_frames: u64,
132    /// Number of patch frames sent
133    pub patch_frames: u64,
134    /// Number of completion frames sent
135    pub complete_frames: u64,
136    /// Number of error frames sent
137    pub error_frames: u64,
138    /// Total bytes transmitted across all frames
139    pub total_bytes: u64,
140    /// Bytes transmitted in critical priority frames
141    pub critical_bytes: u64,
142    /// Bytes transmitted in high priority frames
143    pub high_priority_bytes: u64,
144    /// Average size of frames in bytes
145    pub average_frame_size: f64,
146}
147
148/// Priority data stream entity
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct Stream {
151    #[serde(with = "serde_stream_id")]
152    id: StreamId,
153    #[serde(with = "serde_session_id")]
154    session_id: SessionId,
155    state: StreamState,
156    config: StreamConfig,
157    stats: StreamStats,
158    created_at: DateTime<Utc>,
159    updated_at: DateTime<Utc>,
160    completed_at: Option<DateTime<Utc>>,
161    next_sequence: u64,
162    source_data: Option<JsonData>,
163    metadata: HashMap<String, String>,
164}
165
166impl Stream {
167    /// Create new stream
168    pub fn new(session_id: SessionId, source_data: JsonData, config: StreamConfig) -> Self {
169        let now = Utc::now();
170
171        Self {
172            id: StreamId::new(),
173            session_id,
174            state: StreamState::Preparing,
175            config,
176            stats: StreamStats::default(),
177            created_at: now,
178            updated_at: now,
179            completed_at: None,
180            next_sequence: 1,
181            source_data: Some(source_data),
182            metadata: HashMap::new(),
183        }
184    }
185
186    /// Get stream ID
187    pub fn id(&self) -> StreamId {
188        self.id
189    }
190
191    /// Get session ID
192    pub fn session_id(&self) -> SessionId {
193        self.session_id
194    }
195
196    /// Get current state
197    pub fn state(&self) -> &StreamState {
198        &self.state
199    }
200
201    /// Get configuration
202    pub fn config(&self) -> &StreamConfig {
203        &self.config
204    }
205
206    /// Get statistics
207    pub fn stats(&self) -> &StreamStats {
208        &self.stats
209    }
210
211    /// Get creation timestamp
212    pub fn created_at(&self) -> DateTime<Utc> {
213        self.created_at
214    }
215
216    /// Get last update timestamp
217    pub fn updated_at(&self) -> DateTime<Utc> {
218        self.updated_at
219    }
220
221    /// Get completion timestamp
222    pub fn completed_at(&self) -> Option<DateTime<Utc>> {
223        self.completed_at
224    }
225
226    /// Get source data
227    pub fn source_data(&self) -> Option<&JsonData> {
228        self.source_data.as_ref()
229    }
230
231    /// Get metadata
232    pub fn metadata(&self) -> &HashMap<String, String> {
233        &self.metadata
234    }
235
236    /// Add metadata
237    pub fn add_metadata(&mut self, key: String, value: String) {
238        self.metadata.insert(key, value);
239        self.update_timestamp();
240    }
241
242    /// Start streaming (transition to Streaming state)
243    pub fn start_streaming(&mut self) -> DomainResult<()> {
244        match self.state {
245            StreamState::Preparing => {
246                self.state = StreamState::Streaming;
247                self.update_timestamp();
248                Ok(())
249            }
250            _ => Err(DomainError::InvalidStateTransition(format!(
251                "Cannot start streaming from state: {:?}",
252                self.state
253            ))),
254        }
255    }
256
257    /// Complete stream successfully
258    pub fn complete(&mut self) -> DomainResult<()> {
259        match self.state {
260            StreamState::Streaming => {
261                self.state = StreamState::Completed;
262                self.completed_at = Some(Utc::now());
263                self.update_timestamp();
264                Ok(())
265            }
266            _ => Err(DomainError::InvalidStateTransition(format!(
267                "Cannot complete stream from state: {:?}",
268                self.state
269            ))),
270        }
271    }
272
273    /// Fail stream with error
274    pub fn fail(&mut self, error: String) -> DomainResult<()> {
275        match self.state {
276            StreamState::Preparing | StreamState::Streaming => {
277                self.state = StreamState::Failed;
278                self.completed_at = Some(Utc::now());
279                self.add_metadata("error".to_string(), error);
280                Ok(())
281            }
282            _ => Err(DomainError::InvalidStateTransition(format!(
283                "Cannot fail stream from state: {:?}",
284                self.state
285            ))),
286        }
287    }
288
289    /// Cancel stream
290    pub fn cancel(&mut self) -> DomainResult<()> {
291        match self.state {
292            StreamState::Preparing | StreamState::Streaming => {
293                self.state = StreamState::Cancelled;
294                self.completed_at = Some(Utc::now());
295                self.update_timestamp();
296                Ok(())
297            }
298            _ => Err(DomainError::InvalidStateTransition(format!(
299                "Cannot cancel stream from state: {:?}",
300                self.state
301            ))),
302        }
303    }
304
305    /// Generate skeleton frame for the stream
306    pub fn create_skeleton_frame(&mut self) -> DomainResult<Frame> {
307        if !matches!(self.state, StreamState::Streaming) {
308            return Err(DomainError::InvalidStreamState(
309                "Stream must be in streaming state to create frames".to_string(),
310            ));
311        }
312
313        let skeleton_data = self.source_data.as_ref().ok_or_else(|| {
314            DomainError::InvalidStreamState("No source data available for skeleton".to_string())
315        })?;
316
317        let skeleton = self.generate_skeleton(skeleton_data)?;
318        let frame = Frame::skeleton(self.id, self.next_sequence, skeleton);
319
320        self.record_frame_created(&frame);
321
322        Ok(frame)
323    }
324
325    /// Create batch of patch frames based on priority
326    pub fn create_patch_frames(
327        &mut self,
328        priority_threshold: Priority,
329        max_frames: usize,
330    ) -> DomainResult<Vec<Frame>> {
331        let patches = self.extract_prioritized_patches(priority_threshold)?;
332        self.commit_patch_frames(patches, max_frames)
333    }
334
335    /// Compute prioritized patches for this stream's current `source_data`,
336    /// without mutating any state — the expensive half of
337    /// [`Self::create_patch_frames`] (a full traversal of `source_data`,
338    /// computing a priority for every leaf value).
339    ///
340    /// Split out from [`Self::commit_patch_frames`] so a caller that needs
341    /// its own concurrency control around the *mutating* half (e.g. a
342    /// repository holding a per-session lock for the read-modify-write) can
343    /// run this traversal lock-free and only take the lock for the commit
344    /// step — which is itself `O(patches.len())`, not cheap or
345    /// `max_frames`-bounded; see [`Self::commit_patch_frames`]'s docs. Safe
346    /// to call without holding any lock: `source_data` is set once at
347    /// stream creation and never mutated afterward.
348    pub fn extract_prioritized_patches(
349        &self,
350        priority_threshold: Priority,
351    ) -> DomainResult<Vec<(FramePatch, Priority)>> {
352        if !matches!(self.state, StreamState::Streaming) {
353            return Err(DomainError::InvalidStreamState(
354                "Stream must be in streaming state to create frames".to_string(),
355            ));
356        }
357
358        let source_data = self.source_data.as_ref().ok_or_else(|| {
359            DomainError::InvalidStreamState("No source data available for patches".to_string())
360        })?;
361
362        self.extract_patches(source_data, priority_threshold)
363    }
364
365    /// Turn already-extracted prioritized patches (from
366    /// [`Self::extract_prioritized_patches`]) into frames and commit their
367    /// bookkeeping (`next_sequence`, `stats`) — the other half of
368    /// [`Self::create_patch_frames`].
369    ///
370    /// `max_frames` only bounds the number of frames returned, not the work
371    /// done to produce them: every patch in `patches` is still cloned into
372    /// some frame, so this call's cost is `O(patches.len())`, proportional to
373    /// the total number of patches extracted, not to `max_frames`.
374    ///
375    /// Re-checks the streaming-state precondition itself: if the stream
376    /// transitioned out of `Streaming` between the caller's earlier
377    /// [`Self::extract_prioritized_patches`] call and this one, this fails
378    /// cleanly instead of committing frames for a stream that can no longer
379    /// accept them.
380    ///
381    /// Not strictly all-or-nothing: chunks are finalized one at a time via
382    /// [`Self::finalize_patch_frame`], so a construction failure partway
383    /// through would leave earlier chunks already committed. Currently
384    /// unobservable — [`Frame::patch`] only errors on an empty patch vector,
385    /// and chunking never produces an empty chunk here.
386    pub fn commit_patch_frames(
387        &mut self,
388        patches: Vec<(FramePatch, Priority)>,
389        max_frames: usize,
390    ) -> DomainResult<Vec<Frame>> {
391        if !matches!(self.state, StreamState::Streaming) {
392            return Err(DomainError::InvalidStreamState(
393                "Stream must be in streaming state to create frames".to_string(),
394            ));
395        }
396
397        Self::chunk_patches_for_commit(patches, max_frames)
398            .into_iter()
399            .map(|(priority, frame_patches)| self.finalize_patch_frame(priority, frame_patches))
400            .collect()
401    }
402
403    /// Build and commit one patch frame from an already-chunked group of
404    /// patches (see [`Self::chunk_patches_for_commit`]).
405    ///
406    /// This is the only place patch-frame `next_sequence`/`stats` bookkeeping
407    /// happens: assigns the next sequence number, constructs the [`Frame`],
408    /// and records it. Callers that decide
409    /// *which* chunked candidates survive before committing (e.g. a
410    /// cross-stream priority truncation across multiple streams) should call
411    /// this only for the surviving subset, so discarded candidates never
412    /// consume a sequence number or inflate stats.
413    ///
414    /// # Examples
415    ///
416    /// ```
417    /// use pjson_rs_domain::entities::Stream;
418    /// use pjson_rs_domain::entities::frame::FramePatch;
419    /// use pjson_rs_domain::value_objects::{JsonData, JsonPath, Priority, SessionId};
420    ///
421    /// let mut stream = Stream::new(
422    ///     SessionId::new(),
423    ///     JsonData::Object(Default::default()),
424    ///     Default::default(),
425    /// );
426    /// stream.start_streaming().unwrap();
427    ///
428    /// let patch = FramePatch::set(JsonPath::root(), JsonData::Bool(true));
429    /// let frame = stream
430    ///     .finalize_patch_frame(Priority::HIGH, vec![patch])
431    ///     .unwrap();
432    ///
433    /// assert_eq!(frame.sequence(), 1);
434    /// assert_eq!(stream.stats().total_frames, 1);
435    /// ```
436    pub fn finalize_patch_frame(
437        &mut self,
438        priority: Priority,
439        frame_patches: Vec<FramePatch>,
440    ) -> DomainResult<Frame> {
441        if !matches!(self.state, StreamState::Streaming) {
442            return Err(DomainError::InvalidStreamState(
443                "Stream must be in streaming state to create frames".to_string(),
444            ));
445        }
446
447        let frame = Frame::patch(self.id, self.next_sequence, priority, frame_patches)?;
448        self.record_frame_created(&frame);
449        Ok(frame)
450    }
451
452    /// Create completion frame
453    pub fn create_completion_frame(&mut self, checksum: Option<String>) -> DomainResult<Frame> {
454        if !matches!(self.state, StreamState::Streaming) {
455            return Err(DomainError::InvalidStreamState(
456                "Stream must be in streaming state to create frames".to_string(),
457            ));
458        }
459
460        let frame = Frame::complete(self.id, self.next_sequence, checksum);
461        self.record_frame_created(&frame);
462
463        Ok(frame)
464    }
465
466    /// Check if stream is active
467    pub fn is_active(&self) -> bool {
468        matches!(self.state, StreamState::Preparing | StreamState::Streaming)
469    }
470
471    /// Check if stream is finished
472    pub fn is_finished(&self) -> bool {
473        matches!(
474            self.state,
475            StreamState::Completed | StreamState::Failed | StreamState::Cancelled
476        )
477    }
478
479    /// Get stream duration
480    pub fn duration(&self) -> Option<chrono::Duration> {
481        self.completed_at.map(|end| end - self.created_at)
482    }
483
484    /// Calculate stream progress (0.0 to 1.0)
485    pub fn progress(&self) -> f64 {
486        match self.state {
487            StreamState::Preparing => 0.0,
488            StreamState::Streaming => {
489                // Estimate based on frames sent vs expected
490                if self.stats.total_frames == 0 {
491                    0.1 // Just started
492                } else {
493                    // Simple heuristic: more frames = more progress
494                    (self.stats.total_frames as f64 / 100.0).min(0.9)
495                }
496            }
497            StreamState::Completed => 1.0,
498            StreamState::Failed | StreamState::Cancelled => {
499                // Partial progress before failure/cancellation
500                (self.stats.total_frames as f64 / 100.0).min(0.99)
501            }
502        }
503    }
504
505    /// Update configuration
506    pub fn update_config(&mut self, config: StreamConfig) -> DomainResult<()> {
507        if !self.is_active() {
508            return Err(DomainError::InvalidStreamState(
509                "Cannot update config of inactive stream".to_string(),
510            ));
511        }
512
513        self.config = config;
514        self.update_timestamp();
515        Ok(())
516    }
517
518    /// Private helper: Update timestamp
519    fn update_timestamp(&mut self) {
520        self.updated_at = Utc::now();
521    }
522
523    /// Private helper: Record frame creation for stats
524    fn record_frame_created(&mut self, frame: &Frame) {
525        self.next_sequence += 1;
526        self.stats.total_frames += 1;
527
528        let frame_size = frame.estimated_size() as u64;
529        self.stats.total_bytes += frame_size;
530
531        match frame.frame_type() {
532            crate::entities::frame::FrameType::Skeleton => {
533                self.stats.skeleton_frames += 1;
534                self.stats.critical_bytes += frame_size;
535            }
536            crate::entities::frame::FrameType::Patch => {
537                self.stats.patch_frames += 1;
538                if frame.is_critical() {
539                    self.stats.critical_bytes += frame_size;
540                } else if frame.is_high_priority() {
541                    self.stats.high_priority_bytes += frame_size;
542                }
543            }
544            crate::entities::frame::FrameType::Complete => {
545                self.stats.complete_frames += 1;
546                self.stats.critical_bytes += frame_size;
547            }
548            crate::entities::frame::FrameType::Error => {
549                self.stats.error_frames += 1;
550                self.stats.critical_bytes += frame_size;
551            }
552        }
553
554        // Update average frame size
555        self.stats.average_frame_size =
556            self.stats.total_bytes as f64 / self.stats.total_frames as f64;
557
558        self.update_timestamp();
559    }
560
561    /// Private helper: Generate skeleton from source data
562    fn generate_skeleton(&self, data: &JsonData) -> DomainResult<JsonData> {
563        // Simplified skeleton generation - create empty structure
564        match data {
565            JsonData::Object(obj) => {
566                let mut skeleton = HashMap::new();
567                for (key, value) in obj.iter() {
568                    skeleton.insert(
569                        key.clone(),
570                        match value {
571                            JsonData::Array(_) => JsonData::Array(Vec::new()),
572                            JsonData::Object(_) => self.generate_skeleton(value)?,
573                            JsonData::Integer(_) => JsonData::Integer(0),
574                            JsonData::Float(_) => JsonData::Float(0.0),
575                            JsonData::String(_) => JsonData::Null,
576                            JsonData::Bool(_) => JsonData::Bool(false),
577                            JsonData::Null => JsonData::Null,
578                        },
579                    );
580                }
581                Ok(JsonData::Object(skeleton))
582            }
583            JsonData::Array(_) => Ok(JsonData::Array(Vec::new())),
584            _ => Ok(JsonData::Null),
585        }
586    }
587
588    /// Private helper: Extract patches with priority filtering.
589    ///
590    /// Walks `data` recursively, emitting one `Set` patch per leaf-level
591    /// value (primitives and arrays — objects are traversed without emitting
592    /// a patch, since their structure is already conveyed by the skeleton
593    /// frame). Each patch is paired with a computed priority so that
594    /// [`Self::chunk_patches_for_commit`] can group chunks by maximum
595    /// priority. Patches whose priority falls below `threshold` are dropped.
596    fn extract_patches(
597        &self,
598        data: &JsonData,
599        threshold: Priority,
600    ) -> DomainResult<Vec<(crate::entities::frame::FramePatch, Priority)>> {
601        let mut patches = Vec::new();
602        self.collect_patches(data, &JsonPath::root(), threshold, &mut patches)?;
603        // Sort by priority descending so high-priority patches land in earlier
604        // frames within the chunk-based batch layout.
605        patches.sort_by_key(|p| core::cmp::Reverse(p.1));
606        Ok(patches)
607    }
608
609    /// Recursive walker that emits prioritized patches into `out`.
610    fn collect_patches(
611        &self,
612        data: &JsonData,
613        path: &JsonPath,
614        threshold: Priority,
615        out: &mut Vec<(crate::entities::frame::FramePatch, Priority)>,
616    ) -> DomainResult<()> {
617        if let JsonData::Object(map) = data {
618            for (key, value) in map.iter() {
619                // Keys with characters JsonPath cannot encode (`.`, `[`, `]`)
620                // are skipped: a domain-internal walker must not refuse the
621                // entire document because of one weird key.
622                let Ok(child_path) = path.append_key(key) else {
623                    continue;
624                };
625                self.collect_patches(value, &child_path, threshold, out)?;
626            }
627            return Ok(());
628        }
629
630        let priority = self.compute_priority(path, data);
631        if priority >= threshold {
632            let patch = crate::entities::frame::FramePatch::set(path.clone(), data.clone());
633            out.push((patch, priority));
634        }
635        Ok(())
636    }
637
638    /// Compute a priority for a patch by delegating to
639    /// [`crate::services::compute_priority`].
640    ///
641    /// The per-stream `priority_rules` map is mapped onto
642    /// [`PriorityHeuristicConfig::overrides`] so that user-provided rules keep
643    /// winning over the shared heuristic. This is the single entry point used
644    /// by both the HTTP transport (via `extract_patches`) and the WebAssembly
645    /// bindings; see #242 for the divergence this resolves.
646    fn compute_priority(&self, path: &JsonPath, value: &JsonData) -> Priority {
647        let mut cfg = crate::services::PriorityHeuristicConfig::default();
648        if !self.config.priority_rules.is_empty() {
649            cfg.overrides = self.config.priority_rules.clone();
650        }
651        crate::services::compute_priority(&cfg, path, value)
652    }
653
654    /// Group prioritized patches into per-frame chunks without constructing
655    /// any [`Frame`] or mutating stream state.
656    ///
657    /// Each returned chunk's priority is the maximum priority of the patches
658    /// it contains, so per-frame ordering downstream reflects the most
659    /// important content the frame will carry. Pure and side-effect-free so a
660    /// caller (e.g. a cross-stream priority batch) can chunk candidates from
661    /// several streams, decide which survive truncation, and only then
662    /// commit the survivors via [`Self::finalize_patch_frame`] — see that
663    /// method's docs.
664    ///
665    /// # Examples
666    ///
667    /// ```
668    /// use pjson_rs_domain::entities::Stream;
669    /// use pjson_rs_domain::entities::frame::FramePatch;
670    /// use pjson_rs_domain::value_objects::{JsonData, JsonPath, Priority, SessionId};
671    ///
672    /// let mut stream = Stream::new(
673    ///     SessionId::new(),
674    ///     JsonData::Object(Default::default()),
675    ///     Default::default(),
676    /// );
677    /// stream.start_streaming().unwrap();
678    ///
679    /// let patches = vec![
680    ///     (
681    ///         FramePatch::set(JsonPath::root(), JsonData::Bool(true)),
682    ///         Priority::LOW,
683    ///     ),
684    ///     (
685    ///         FramePatch::set(JsonPath::root(), JsonData::Bool(false)),
686    ///         Priority::HIGH,
687    ///     ),
688    /// ];
689    ///
690    /// // Chunk without mutating the stream — a caller can inspect priorities
691    /// // and pick survivors before any sequence number is spent.
692    /// let mut chunks = Stream::chunk_patches_for_commit(patches, 2);
693    /// assert_eq!(chunks.len(), 2);
694    /// chunks.sort_by_key(|(priority, _)| std::cmp::Reverse(*priority));
695    ///
696    /// // Only commit the highest-priority chunk.
697    /// let (priority, frame_patches) = chunks.remove(0);
698    /// let frame = stream
699    ///     .finalize_patch_frame(priority, frame_patches)
700    ///     .unwrap();
701    /// assert_eq!(frame.priority(), Priority::HIGH);
702    /// assert_eq!(stream.stats().total_frames, 1);
703    /// ```
704    pub fn chunk_patches_for_commit(
705        patches: Vec<(FramePatch, Priority)>,
706        max_frames: usize,
707    ) -> Vec<(Priority, Vec<FramePatch>)> {
708        if patches.is_empty() || max_frames == 0 {
709            return Vec::new();
710        }
711
712        let chunk_size = patches.len().div_ceil(max_frames).max(1);
713
714        patches
715            .chunks(chunk_size)
716            .map(|chunk| {
717                let priority = chunk
718                    .iter()
719                    .map(|(_, p)| *p)
720                    .max()
721                    .unwrap_or(Priority::MEDIUM);
722
723                let frame_patches: Vec<FramePatch> =
724                    chunk.iter().map(|(patch, _)| patch.clone()).collect();
725
726                (priority, frame_patches)
727            })
728            .collect()
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    #[test]
737    fn test_stream_creation() {
738        let session_id = SessionId::new();
739        let source_data = serde_json::json!({
740            "users": [
741                {"id": 1, "name": "John"},
742                {"id": 2, "name": "Jane"}
743            ],
744            "total": 2
745        });
746
747        let stream = Stream::new(
748            session_id,
749            source_data.clone().into(),
750            StreamConfig::default(),
751        );
752
753        assert_eq!(stream.session_id(), session_id);
754        assert_eq!(stream.state(), &StreamState::Preparing);
755        assert!(stream.is_active());
756        assert!(!stream.is_finished());
757        assert_eq!(stream.progress(), 0.0);
758    }
759
760    #[test]
761    fn test_stream_state_transitions() {
762        let session_id = SessionId::new();
763        let source_data = serde_json::json!({});
764        let mut stream = Stream::new(session_id, source_data.into(), StreamConfig::default());
765
766        // Start streaming
767        assert!(stream.start_streaming().is_ok());
768        assert_eq!(stream.state(), &StreamState::Streaming);
769
770        // Complete stream
771        assert!(stream.complete().is_ok());
772        assert_eq!(stream.state(), &StreamState::Completed);
773        assert!(stream.is_finished());
774        assert_eq!(stream.progress(), 1.0);
775    }
776
777    #[test]
778    fn test_invalid_state_transitions() {
779        let session_id = SessionId::new();
780        let source_data = serde_json::json!({});
781        let mut stream = Stream::new(session_id, source_data.into(), StreamConfig::default());
782
783        // Cannot complete from preparing state
784        assert!(stream.complete().is_err());
785
786        // Start and complete
787        assert!(stream.start_streaming().is_ok());
788        assert!(stream.complete().is_ok());
789
790        // Cannot start again from completed state
791        assert!(stream.start_streaming().is_err());
792    }
793
794    #[test]
795    fn test_frame_creation() {
796        let session_id = SessionId::new();
797        let source_data = serde_json::json!({
798            "test": "data"
799        });
800        let mut stream = Stream::new(session_id, source_data.into(), StreamConfig::default());
801
802        // Cannot create frames before streaming
803        assert!(stream.create_skeleton_frame().is_err());
804
805        // Start streaming and create skeleton
806        assert!(stream.start_streaming().is_ok());
807        let skeleton = stream
808            .create_skeleton_frame()
809            .expect("Failed to create skeleton frame in test");
810
811        assert_eq!(
812            skeleton.frame_type(),
813            &crate::entities::frame::FrameType::Skeleton
814        );
815        assert_eq!(skeleton.sequence(), 1);
816        assert_eq!(stream.stats().skeleton_frames, 1);
817    }
818
819    #[test]
820    fn test_stream_metadata() {
821        let session_id = SessionId::new();
822        let source_data = serde_json::json!({});
823        let mut stream = Stream::new(session_id, source_data.into(), StreamConfig::default());
824
825        stream.add_metadata("source".to_string(), "api".to_string());
826        stream.add_metadata("version".to_string(), "1.0".to_string());
827
828        assert_eq!(stream.metadata().len(), 2);
829        assert_eq!(stream.metadata().get("source"), Some(&"api".to_string()));
830    }
831
832    #[test]
833    fn test_create_patch_frames_emits_frames_for_typical_payload() {
834        let session_id = SessionId::new();
835        let source_data = serde_json::json!({
836            "id": "abc-123",
837            "name": "Alice",
838            "items": [1, 2, 3]
839        });
840        let mut stream = Stream::new(session_id, source_data.into(), StreamConfig::default());
841
842        stream
843            .start_streaming()
844            .expect("stream must enter streaming state");
845
846        let frames = stream
847            .create_patch_frames(Priority::BACKGROUND, 16)
848            .expect("frame generation must succeed");
849
850        assert!(
851            !frames.is_empty(),
852            "extract_patches must produce at least one patch for non-empty source data"
853        );
854
855        let id_frame_priority_max = frames
856            .iter()
857            .map(|f| f.priority())
858            .max()
859            .expect("non-empty frames must have a max priority");
860        assert!(
861            id_frame_priority_max >= Priority::CRITICAL,
862            "frames carrying the `id` field must surface at critical priority"
863        );
864    }
865
866    #[test]
867    fn test_create_patch_frames_filters_below_threshold() {
868        let session_id = SessionId::new();
869        // `analytics` is forced to BACKGROUND priority by the heuristic, so
870        // a CRITICAL threshold filters everything out.
871        let source_data = serde_json::json!({
872            "analytics": {"clicks": 1, "views": 2}
873        });
874        let mut stream = Stream::new(session_id, source_data.into(), StreamConfig::default());
875
876        stream.start_streaming().expect("stream starts");
877        let frames = stream
878            .create_patch_frames(Priority::CRITICAL, 8)
879            .expect("frame generation must succeed");
880
881        assert!(
882            frames.is_empty(),
883            "patches below the priority threshold must be dropped"
884        );
885    }
886
887    #[test]
888    fn test_create_patch_frames_uses_max_priority_per_chunk() {
889        let session_id = SessionId::new();
890        // Mix of CRITICAL (`id`), HIGH (`name`), and BACKGROUND (`logs`).
891        let source_data = serde_json::json!({
892            "id": "x",
893            "name": "y",
894            "logs": "z"
895        });
896        let mut stream = Stream::new(session_id, source_data.into(), StreamConfig::default());
897
898        stream.start_streaming().expect("stream starts");
899        // Force a single chunk so we can assert max-priority aggregation.
900        let frames = stream
901            .create_patch_frames(Priority::BACKGROUND, 1)
902            .expect("frame generation must succeed");
903
904        assert_eq!(frames.len(), 1, "max_frames=1 must yield a single frame");
905        assert_eq!(
906            frames[0].priority(),
907            Priority::CRITICAL,
908            "frame priority must reflect the highest-priority patch in the chunk"
909        );
910    }
911}