Skip to main content

azul_core/
camera.rs

1//! POD types for the camera-capture surface
2//! (SUPER_PLAN_2 §4 Priority 6 + research/01).
3//!
4//! Camera frames are GPU textures, not scalar samples, so the stateful side
5//! is heavier than the sensors': `azul_layout::managers::camera` owns a
6//! `CameraStream` per capture, each holding a shared `ImageRef` texture the
7//! capture thread writes into (zero-copy - clones see new bytes via the
8//! `ImageRef` `Arc`). A `CameraPreview` node renders that texture and, by
9//! appearing in the DOM, declares "I need the camera" to the permission
10//! layer (research/01 §"permission-as-DOM").
11//!
12//! Defined here in `azul-core` so the config / id / status types cross the
13//! FFI without `azul-layout` (or AVFoundation / Camera2) as a dependency -
14//! these are what an app passes to `start_camera` and reads back from a
15//! stream. The `Nv12` zero-copy output format is a `RawImageFormat` addition
16//! deferred to the backend tick; configs default to `BGRA8`.
17
18use crate::resources::RawImageFormat;
19
20/// Identifies one camera capture stream - assigned by `start_camera`, used
21/// to read the stream back (`get_camera_frame`) and to stop / pause / flip it.
22#[repr(C)]
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub struct CaptureStreamId {
25    pub id: u64,
26}
27
28/// Which physical camera to open.
29#[repr(C)]
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub enum CameraFacing {
32    /// User-facing (selfie) camera.
33    Front,
34    /// World-facing (rear) camera.
35    Back,
36    /// An external / USB camera (desktop webcams report here).
37    External,
38}
39
40/// Lifecycle of a capture stream.
41#[repr(C)]
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
43pub enum StreamState {
44    /// Opening the device / negotiating the format.
45    Starting,
46    /// Delivering frames.
47    Running,
48    /// Temporarily suspended (app backgrounded, `pause_camera`).
49    Paused,
50    /// Stopped by the app (`stop_camera`) or torn down.
51    Stopped,
52    /// Failed - see the stream's [`CaptureErrorCode`].
53    Error,
54}
55
56/// Rotation / mirroring the capture needs relative to the display (the
57/// sensor's native orientation rarely matches the UI's).
58#[repr(C)]
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
60pub enum CaptureOrientation {
61    /// Upright (0°).
62    Up,
63    /// Upside down (180°).
64    Down,
65    /// Rotated 90° counter-clockwise.
66    Left,
67    /// Rotated 90° clockwise.
68    Right,
69    /// Horizontally mirrored (typical for the front camera).
70    Mirror,
71}
72
73/// Why a capture stream failed ([`StreamState::Error`]).
74#[repr(C)]
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
76pub enum CaptureErrorCode {
77    /// The user denied (or hasn't granted) camera permission.
78    PermissionDenied,
79    /// No camera matched the requested [`CameraFacing`].
80    DeviceUnavailable,
81    /// The device disappeared mid-capture (unplugged / claimed).
82    DeviceLost,
83    /// The requested format / resolution isn't supported.
84    Unsupported,
85    /// A platform error not covered above.
86    Internal,
87}
88
89/// Requested capture configuration - the input to `start_camera`. Zero
90/// `width`/`height`/`fps` mean "let the backend pick its default".
91#[repr(C)]
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct CameraConfig {
94    /// Which camera to open.
95    pub facing: CameraFacing,
96    /// Preferred frame width in px (0 = backend default).
97    pub width: u32,
98    /// Preferred frame height in px (0 = backend default).
99    pub height: u32,
100    /// Preferred frame rate (0 = backend default).
101    pub fps: u32,
102    /// Texture format the backend should deliver. `BGRA8` is the portable
103    /// default; `Nv12` (a later `RawImageFormat` addition) is the zero-copy
104    /// path on platforms that produce it natively.
105    pub output_format: RawImageFormat,
106}
107
108impl Default for CameraConfig {
109    fn default() -> Self {
110        Self {
111            facing: CameraFacing::Back,
112            width: 0,
113            height: 0,
114            fps: 0,
115            output_format: RawImageFormat::BGRA8,
116        }
117    }
118}
119
120impl CameraConfig {
121    /// A default config for the given `facing` (backend-chosen size/fps,
122    /// `BGRA8`).
123    #[must_use] pub fn new(facing: CameraFacing) -> Self {
124        Self {
125            facing,
126            ..Self::default()
127        }
128    }
129}
130
131/// Runtime stats for a capture stream - surfaced for HUD / debugging.
132#[repr(C)]
133#[derive(Debug, Clone, Copy, PartialEq)]
134pub struct CaptureStats {
135    /// Measured delivery rate (frames/s), smoothed by the backend.
136    pub measured_fps: f32,
137    /// Frames delivered to the texture since the stream started.
138    pub frames_delivered: u64,
139    /// Frames the backend dropped (couldn't keep up / late).
140    pub frames_dropped: u64,
141}
142
143impl Default for CaptureStats {
144    fn default() -> Self {
145        Self {
146            measured_fps: 0.0,
147            frames_delivered: 0,
148            frames_dropped: 0,
149        }
150    }
151}
152
153#[cfg(test)]
154mod autotest_generated {
155    use super::*;
156
157    const ALL_FACINGS: [CameraFacing; 3] =
158        [CameraFacing::Front, CameraFacing::Back, CameraFacing::External];
159
160    // ---- CameraConfig::new — constructor: no_panic + invariants_hold ----
161
162    #[test]
163    fn new_does_not_panic_for_any_facing() {
164        // Constructor over every representative argument must not panic.
165        for &facing in &ALL_FACINGS {
166            let _ = CameraConfig::new(facing);
167        }
168    }
169
170    #[test]
171    fn new_sets_facing_and_leaves_everything_else_at_default() {
172        // Documented contract: default config for the given `facing`
173        // (backend-chosen size/fps, BGRA8). So new(f) must differ from
174        // Default only in `facing`.
175        let def = CameraConfig::default();
176        for &facing in &ALL_FACINGS {
177            let cfg = CameraConfig::new(facing);
178            assert_eq!(cfg.facing, facing, "facing must equal the argument");
179            assert_eq!(cfg.width, def.width);
180            assert_eq!(cfg.height, def.height);
181            assert_eq!(cfg.fps, def.fps);
182            assert_eq!(cfg.output_format, def.output_format);
183            // Absolute invariants (not just "== default"):
184            assert_eq!(cfg.width, 0, "0 => backend default width");
185            assert_eq!(cfg.height, 0, "0 => backend default height");
186            assert_eq!(cfg.fps, 0, "0 => backend default fps");
187            assert_eq!(cfg.output_format, RawImageFormat::BGRA8);
188        }
189    }
190
191    #[test]
192    fn new_equals_struct_update_syntax() {
193        // new(f) must be observably identical to `{ facing: f, ..default() }`.
194        for &facing in &ALL_FACINGS {
195            let via_new = CameraConfig::new(facing);
196            let via_update = CameraConfig {
197                facing,
198                ..CameraConfig::default()
199            };
200            assert_eq!(via_new, via_update);
201        }
202    }
203
204    #[test]
205    fn new_back_equals_full_default() {
206        // Default facing is Back, so new(Back) must equal Default entirely.
207        assert_eq!(
208            CameraConfig::new(CameraFacing::Back),
209            CameraConfig::default()
210        );
211    }
212
213    #[test]
214    fn new_result_is_copy_and_independent() {
215        // CameraConfig is Copy; mutating a copy must not touch the original.
216        let a = CameraConfig::new(CameraFacing::Front);
217        let mut b = a;
218        b.width = 1920;
219        b.facing = CameraFacing::Back;
220        assert_eq!(a.width, 0, "original must be untouched by copy mutation");
221        assert_eq!(a.facing, CameraFacing::Front);
222        assert_eq!(b.width, 1920);
223        assert_eq!(b.facing, CameraFacing::Back);
224    }
225
226    #[test]
227    fn new_differs_only_by_facing_between_variants() {
228        // Two configs built from different facings must be unequal, and equal
229        // once their facings are aligned (proves facing is the only variance).
230        let front = CameraConfig::new(CameraFacing::Front);
231        let mut back = CameraConfig::new(CameraFacing::Back);
232        assert_ne!(front, back);
233        back.facing = CameraFacing::Front;
234        assert_eq!(front, back);
235    }
236
237    // ---- CameraConfig field storage: no clamping / no overflow on extremes ----
238
239    #[test]
240    fn camera_config_stores_extreme_dimensions_verbatim() {
241        // These are POD "requested" values; the type must not clamp/saturate.
242        let cfg = CameraConfig {
243            width: u32::MAX,
244            height: u32::MAX,
245            fps: u32::MAX,
246            ..CameraConfig::new(CameraFacing::External)
247        };
248        assert_eq!(cfg.width, u32::MAX);
249        assert_eq!(cfg.height, u32::MAX);
250        assert_eq!(cfg.fps, u32::MAX);
251        assert_eq!(cfg.facing, CameraFacing::External);
252    }
253
254    #[test]
255    fn camera_config_output_format_can_hold_non_default() {
256        // new() forces BGRA8, but the struct must faithfully hold any format.
257        let cfg = CameraConfig {
258            output_format: RawImageFormat::RGBA8,
259            ..CameraConfig::new(CameraFacing::Front)
260        };
261        assert_eq!(cfg.output_format, RawImageFormat::RGBA8);
262        assert_ne!(cfg.output_format, RawImageFormat::BGRA8);
263    }
264
265    // ---- Default invariants ----
266
267    #[test]
268    fn camera_config_default_is_back_bgra8_zeroed() {
269        let d = CameraConfig::default();
270        assert_eq!(d.facing, CameraFacing::Back);
271        assert_eq!(d.width, 0);
272        assert_eq!(d.height, 0);
273        assert_eq!(d.fps, 0);
274        assert_eq!(d.output_format, RawImageFormat::BGRA8);
275    }
276
277    #[test]
278    fn camera_config_default_is_idempotent() {
279        assert_eq!(CameraConfig::default(), CameraConfig::default());
280    }
281
282    #[test]
283    fn capture_stats_default_is_fully_zeroed() {
284        let s = CaptureStats::default();
285        assert_eq!(s.measured_fps, 0.0);
286        assert_eq!(s.frames_delivered, 0);
287        assert_eq!(s.frames_dropped, 0);
288    }
289
290    // ---- CaptureStats numeric / NaN adversarial (PartialEq over f32, NOT Eq) ----
291
292    #[test]
293    fn capture_stats_nan_fps_breaks_eq_reflexivity() {
294        // measured_fps is f32; NaN != NaN, so a stats value carrying NaN must
295        // not compare equal to itself. This confirms CaptureStats is (correctly)
296        // PartialEq and not Eq.
297        let s = CaptureStats {
298            measured_fps: f32::NAN,
299            ..CaptureStats::default()
300        };
301        assert_ne!(s, s, "NaN measured_fps must break PartialEq reflexivity");
302        assert!(s.measured_fps.is_nan());
303    }
304
305    #[test]
306    fn capture_stats_holds_infinities_and_saturated_counters() {
307        let s = CaptureStats {
308            measured_fps: f32::INFINITY,
309            frames_delivered: u64::MAX,
310            frames_dropped: u64::MAX,
311        };
312        assert!(s.measured_fps.is_infinite() && s.measured_fps > 0.0);
313        assert_eq!(s.frames_delivered, u64::MAX);
314        assert_eq!(s.frames_dropped, u64::MAX);
315
316        let neg = CaptureStats {
317            measured_fps: f32::NEG_INFINITY,
318            ..CaptureStats::default()
319        };
320        assert!(neg.measured_fps.is_infinite() && neg.measured_fps < 0.0);
321    }
322
323    #[test]
324    fn capture_stats_negative_zero_fps_equals_zero() {
325        // IEEE: -0.0 == 0.0 for PartialEq; assert the type doesn't surprise us.
326        let s = CaptureStats {
327            measured_fps: -0.0,
328            ..CaptureStats::default()
329        };
330        assert_eq!(s, CaptureStats::default());
331    }
332
333    // ---- CaptureStreamId round-trips its id over boundary values ----
334
335    #[test]
336    fn capture_stream_id_roundtrips_boundary_ids() {
337        for id in [0u64, 1, 2, u64::MAX / 2, u64::MAX - 1, u64::MAX] {
338            let s = CaptureStreamId { id };
339            assert_eq!(s.id, id);
340            assert_eq!(s, CaptureStreamId { id }, "Eq must hold for equal ids");
341        }
342    }
343
344    #[test]
345    fn capture_stream_id_distinct_ids_are_unequal_and_ordered() {
346        let lo = CaptureStreamId { id: 0 };
347        let hi = CaptureStreamId { id: u64::MAX };
348        assert_ne!(lo, hi);
349        assert!(lo < hi, "Ord must follow the inner u64");
350    }
351
352    // ---- Enum ordering / total-order / hash-eq contract (derived, but assert it) ----
353
354    #[test]
355    fn enum_ord_matches_declaration_order() {
356        // CameraFacing: Front < Back < External.
357        assert!(CameraFacing::Front < CameraFacing::Back);
358        assert!(CameraFacing::Back < CameraFacing::External);
359        // StreamState: Starting < Running < Paused < Stopped < Error.
360        assert!(StreamState::Starting < StreamState::Running);
361        assert!(StreamState::Running < StreamState::Paused);
362        assert!(StreamState::Paused < StreamState::Stopped);
363        assert!(StreamState::Stopped < StreamState::Error);
364        // CaptureOrientation: Up < Down < Left < Right < Mirror.
365        assert!(CaptureOrientation::Up < CaptureOrientation::Down);
366        assert!(CaptureOrientation::Down < CaptureOrientation::Left);
367        assert!(CaptureOrientation::Left < CaptureOrientation::Right);
368        assert!(CaptureOrientation::Right < CaptureOrientation::Mirror);
369        // CaptureErrorCode: PermissionDenied < DeviceUnavailable < DeviceLost
370        //                   < Unsupported < Internal.
371        assert!(CaptureErrorCode::PermissionDenied < CaptureErrorCode::DeviceUnavailable);
372        assert!(CaptureErrorCode::DeviceUnavailable < CaptureErrorCode::DeviceLost);
373        assert!(CaptureErrorCode::DeviceLost < CaptureErrorCode::Unsupported);
374        assert!(CaptureErrorCode::Unsupported < CaptureErrorCode::Internal);
375    }
376
377    #[test]
378    fn camera_facing_ord_is_total_and_antisymmetric() {
379        use core::cmp::Ordering;
380        for &a in &ALL_FACINGS {
381            assert_eq!(a.cmp(&a), Ordering::Equal, "reflexive equality");
382            for &b in &ALL_FACINGS {
383                // Exactly one of <, ==, > holds (trichotomy / total order).
384                let lt = (a < b) as u8;
385                let eq = (a == b) as u8;
386                let gt = (a > b) as u8;
387                assert_eq!(lt + eq + gt, 1, "trichotomy must hold for ({a:?},{b:?})");
388                // Antisymmetry: a < b implies !(b < a).
389                if a < b {
390                    assert!(!(b < a), "antisymmetry violated for ({a:?},{b:?})");
391                }
392            }
393        }
394    }
395
396    #[test]
397    fn enum_hash_eq_consistency() {
398        use std::collections::HashSet;
399        let mut set = HashSet::new();
400        assert!(set.insert(CameraFacing::Front));
401        assert!(set.insert(CameraFacing::Back));
402        assert!(set.insert(CameraFacing::External));
403        // Re-inserting an equal value must be rejected (Hash agrees with Eq).
404        assert!(!set.insert(CameraFacing::Front));
405        assert_eq!(set.len(), 3);
406        assert!(set.contains(&CameraFacing::Back));
407    }
408
409    // =====================================================================
410    // Appended adversarial tests.
411    //
412    // NOTE: the module docs mention an `Nv12` output format, but it is an
413    // explicitly deferred `RawImageFormat` addition and does not exist yet -
414    // it is therefore un-constructible and deliberately NOT tested here.
415    // =====================================================================
416
417    const ALL_STATES: [StreamState; 5] = [
418        StreamState::Starting,
419        StreamState::Running,
420        StreamState::Paused,
421        StreamState::Stopped,
422        StreamState::Error,
423    ];
424
425    const ALL_ORIENTATIONS: [CaptureOrientation; 5] = [
426        CaptureOrientation::Up,
427        CaptureOrientation::Down,
428        CaptureOrientation::Left,
429        CaptureOrientation::Right,
430        CaptureOrientation::Mirror,
431    ];
432
433    const ALL_ERRORS: [CaptureErrorCode; 5] = [
434        CaptureErrorCode::PermissionDenied,
435        CaptureErrorCode::DeviceUnavailable,
436        CaptureErrorCode::DeviceLost,
437        CaptureErrorCode::Unsupported,
438        CaptureErrorCode::Internal,
439    ];
440
441    /// Every `RawImageFormat` a `CameraConfig` could carry. `Nv12` is absent
442    /// on purpose (see the note above).
443    const ALL_FORMATS: [RawImageFormat; 12] = [
444        RawImageFormat::R8,
445        RawImageFormat::RG8,
446        RawImageFormat::RGB8,
447        RawImageFormat::RGBA8,
448        RawImageFormat::R16,
449        RawImageFormat::RG16,
450        RawImageFormat::RGB16,
451        RawImageFormat::RGBA16,
452        RawImageFormat::BGR8,
453        RawImageFormat::BGRA8,
454        RawImageFormat::RGBF32,
455        RawImageFormat::RGBAF32,
456    ];
457
458    // ---- Round-trip: enum -> FFI discriminant -> enum (encode == decode) ----
459    //
460    // These are `#[repr(C)]` fieldless enums that cross the FFI boundary, so
461    // their discriminants ARE the wire format. A reordered / inserted variant
462    // silently re-numbers the C ABI; these round-trips pin it down. The
463    // `match` arms are exhaustive, so adding a variant fails to compile rather
464    // than silently shipping an untested value.
465
466    fn facing_from_discriminant(i: i32) -> Option<CameraFacing> {
467        match i {
468            0 => Some(CameraFacing::Front),
469            1 => Some(CameraFacing::Back),
470            2 => Some(CameraFacing::External),
471            _ => None,
472        }
473    }
474
475    fn state_from_discriminant(i: i32) -> Option<StreamState> {
476        match i {
477            0 => Some(StreamState::Starting),
478            1 => Some(StreamState::Running),
479            2 => Some(StreamState::Paused),
480            3 => Some(StreamState::Stopped),
481            4 => Some(StreamState::Error),
482            _ => None,
483        }
484    }
485
486    fn orientation_from_discriminant(i: i32) -> Option<CaptureOrientation> {
487        match i {
488            0 => Some(CaptureOrientation::Up),
489            1 => Some(CaptureOrientation::Down),
490            2 => Some(CaptureOrientation::Left),
491            3 => Some(CaptureOrientation::Right),
492            4 => Some(CaptureOrientation::Mirror),
493            _ => None,
494        }
495    }
496
497    fn error_from_discriminant(i: i32) -> Option<CaptureErrorCode> {
498        match i {
499            0 => Some(CaptureErrorCode::PermissionDenied),
500            1 => Some(CaptureErrorCode::DeviceUnavailable),
501            2 => Some(CaptureErrorCode::DeviceLost),
502            3 => Some(CaptureErrorCode::Unsupported),
503            4 => Some(CaptureErrorCode::Internal),
504            _ => None,
505        }
506    }
507
508    #[test]
509    fn enum_discriminants_are_the_pinned_ffi_wire_values() {
510        // If this fails, the C ABI changed: every C/Python/Kotlin caller that
511        // passed an integer facing/state/orientation/error is now wrong.
512        assert_eq!(CameraFacing::Front as i32, 0);
513        assert_eq!(CameraFacing::Back as i32, 1);
514        assert_eq!(CameraFacing::External as i32, 2);
515
516        assert_eq!(StreamState::Starting as i32, 0);
517        assert_eq!(StreamState::Running as i32, 1);
518        assert_eq!(StreamState::Paused as i32, 2);
519        assert_eq!(StreamState::Stopped as i32, 3);
520        assert_eq!(StreamState::Error as i32, 4);
521
522        assert_eq!(CaptureOrientation::Up as i32, 0);
523        assert_eq!(CaptureOrientation::Down as i32, 1);
524        assert_eq!(CaptureOrientation::Left as i32, 2);
525        assert_eq!(CaptureOrientation::Right as i32, 3);
526        assert_eq!(CaptureOrientation::Mirror as i32, 4);
527
528        assert_eq!(CaptureErrorCode::PermissionDenied as i32, 0);
529        assert_eq!(CaptureErrorCode::DeviceUnavailable as i32, 1);
530        assert_eq!(CaptureErrorCode::DeviceLost as i32, 2);
531        assert_eq!(CaptureErrorCode::Unsupported as i32, 3);
532        assert_eq!(CaptureErrorCode::Internal as i32, 4);
533    }
534
535    #[test]
536    fn enum_discriminant_roundtrip_is_identity() {
537        for &f in &ALL_FACINGS {
538            assert_eq!(facing_from_discriminant(f as i32), Some(f));
539        }
540        for &s in &ALL_STATES {
541            assert_eq!(state_from_discriminant(s as i32), Some(s));
542        }
543        for &o in &ALL_ORIENTATIONS {
544            assert_eq!(orientation_from_discriminant(o as i32), Some(o));
545        }
546        for &e in &ALL_ERRORS {
547            assert_eq!(error_from_discriminant(e as i32), Some(e));
548        }
549    }
550
551    #[test]
552    fn enum_decode_rejects_out_of_range_discriminants() {
553        // A hostile / buggy FFI caller can hand over ANY i32. Decoding must
554        // yield None, never a bogus variant (and never wrap around).
555        for bad in [-1i32, 3, 5, 99, i32::MIN, i32::MAX] {
556            assert_eq!(facing_from_discriminant(bad), None, "facing {bad}");
557        }
558        for bad in [-1i32, 5, 6, 255, i32::MIN, i32::MAX] {
559            assert_eq!(state_from_discriminant(bad), None, "state {bad}");
560            assert_eq!(orientation_from_discriminant(bad), None, "orient {bad}");
561            assert_eq!(error_from_discriminant(bad), None, "error {bad}");
562        }
563        // Boundary: the last valid value decodes, one past it does not.
564        assert!(facing_from_discriminant(2).is_some());
565        assert!(facing_from_discriminant(3).is_none());
566        assert!(state_from_discriminant(4).is_some());
567        assert!(state_from_discriminant(5).is_none());
568    }
569
570    #[test]
571    fn enum_discriminant_order_agrees_with_ord() {
572        // Ord is derived from declaration order, which is also the FFI
573        // numbering; the two must not drift apart.
574        for &a in &ALL_STATES {
575            for &b in &ALL_STATES {
576                assert_eq!(
577                    a < b,
578                    (a as i32) < (b as i32),
579                    "Ord and discriminant disagree for ({a:?}, {b:?})"
580                );
581            }
582        }
583    }
584
585    // ---- Round-trip: struct -> fields -> struct (decompose == recompose) ----
586
587    #[test]
588    fn camera_config_field_roundtrip_is_identity_over_full_cross_product() {
589        // Every facing x every format, with adversarial extents: taking a
590        // config apart and rebuilding it must reproduce it exactly.
591        for &facing in &ALL_FACINGS {
592            for &output_format in &ALL_FORMATS {
593                for &(width, height, fps) in &[
594                    (0u32, 0u32, 0u32),
595                    (1, 1, 1),
596                    (1920, 1080, 60),
597                    (u32::MAX, u32::MAX, u32::MAX),
598                    (u32::MAX, 0, 1),
599                ] {
600                    let cfg = CameraConfig {
601                        facing,
602                        width,
603                        height,
604                        fps,
605                        output_format,
606                    };
607                    let rebuilt = CameraConfig {
608                        facing: cfg.facing,
609                        width: cfg.width,
610                        height: cfg.height,
611                        fps: cfg.fps,
612                        output_format: cfg.output_format,
613                    };
614                    assert_eq!(cfg, rebuilt);
615                    // ...and the fields survived verbatim - no clamping.
616                    assert_eq!(rebuilt.width, width);
617                    assert_eq!(rebuilt.height, height);
618                    assert_eq!(rebuilt.fps, fps);
619                    assert_eq!(rebuilt.facing, facing);
620                    assert_eq!(rebuilt.output_format, output_format);
621                }
622            }
623        }
624    }
625
626    #[test]
627    fn camera_config_does_not_confuse_width_and_height() {
628        // Guards against a transposed-field bug in any future manual PartialEq
629        // or FFI struct layout: a config is NOT equal to its transpose.
630        let a = CameraConfig {
631            width: 1920,
632            height: 1080,
633            ..CameraConfig::default()
634        };
635        let transposed = CameraConfig {
636            width: 1080,
637            height: 1920,
638            ..CameraConfig::default()
639        };
640        assert_ne!(a, transposed, "width/height must be distinguishable");
641        // A square config IS its own transpose - sanity check on the above.
642        let square = CameraConfig {
643            width: 512,
644            height: 512,
645            ..CameraConfig::default()
646        };
647        let square2 = CameraConfig {
648            width: 512,
649            height: 512,
650            ..CameraConfig::default()
651        };
652        assert_eq!(square, square2);
653    }
654
655    #[test]
656    fn camera_config_eq_is_sensitive_to_every_field() {
657        // Flipping any single field must break equality - i.e. no field is
658        // accidentally excluded from the derived PartialEq.
659        let base = CameraConfig::default();
660        assert_ne!(
661            base,
662            CameraConfig {
663                facing: CameraFacing::Front,
664                ..base
665            }
666        );
667        assert_ne!(base, CameraConfig { width: 1, ..base });
668        assert_ne!(base, CameraConfig { height: 1, ..base });
669        assert_ne!(base, CameraConfig { fps: 1, ..base });
670        assert_ne!(
671            base,
672            CameraConfig {
673                output_format: RawImageFormat::RGBA8,
674                ..base
675            }
676        );
677    }
678
679    #[test]
680    fn capture_stats_eq_is_sensitive_to_every_field() {
681        let base = CaptureStats::default();
682        assert_ne!(
683            base,
684            CaptureStats {
685                measured_fps: 1.0,
686                ..base
687            }
688        );
689        assert_ne!(
690            base,
691            CaptureStats {
692                frames_delivered: 1,
693                ..base
694            }
695        );
696        assert_ne!(
697            base,
698            CaptureStats {
699                frames_dropped: 1,
700                ..base
701            }
702        );
703    }
704
705    #[test]
706    // These types are Copy; calling .clone() explicitly is the point of the
707    // test (Clone must agree with Copy), so the lint is deliberately waived.
708    #[allow(clippy::clone_on_copy)]
709    fn clone_is_identity_for_all_pod_types_at_extremes() {
710        let cfg = CameraConfig {
711            facing: CameraFacing::External,
712            width: u32::MAX,
713            height: u32::MAX,
714            fps: u32::MAX,
715            output_format: RawImageFormat::RGBAF32,
716        };
717        assert_eq!(cfg.clone(), cfg);
718
719        let id = CaptureStreamId { id: u64::MAX };
720        assert_eq!(id.clone(), id);
721
722        // NaN is excluded here on purpose: clone of NaN cannot compare equal
723        // (see capture_stats_nan_fps_breaks_eq_reflexivity); assert bit
724        // equality instead, which IS reflexive.
725        let nan_stats = CaptureStats {
726            measured_fps: f32::NAN,
727            frames_delivered: u64::MAX,
728            frames_dropped: u64::MAX,
729        };
730        let cloned = nan_stats.clone();
731        assert_eq!(
732            cloned.measured_fps.to_bits(),
733            nan_stats.measured_fps.to_bits(),
734            "clone must preserve the exact NaN bit pattern"
735        );
736        assert_eq!(cloned.frames_delivered, u64::MAX);
737        assert_eq!(cloned.frames_dropped, u64::MAX);
738    }
739
740    // ---- Numeric: NaN ordering, saturation, overflow, precision limits ----
741
742    #[test]
743    fn capture_stats_nan_fps_makes_partial_cmp_none() {
744        // CaptureStats is only PartialEq (no Ord) precisely because of the f32.
745        // A NaN fps must yield an *incomparable* float, not a silent ordering.
746        let nan = f32::NAN;
747        assert!(nan.partial_cmp(&0.0).is_none());
748        assert!(nan.partial_cmp(&nan).is_none());
749
750        let s = CaptureStats {
751            measured_fps: nan,
752            ..CaptureStats::default()
753        };
754        // A NaN-carrying stats value can never be *found* by equality search,
755        // so consumers must not rely on `contains` / dedup for it.
756        let haystack = vec![s, CaptureStats::default()];
757        assert!(!haystack.contains(&s), "NaN stats is unfindable by Eq");
758        assert!(haystack.contains(&CaptureStats::default()));
759    }
760
761    #[test]
762    fn measured_fps_to_integer_cast_saturates_instead_of_wrapping() {
763        // HUD code casting measured_fps to an integer must not hit UB or wrap.
764        // Rust `as` casts are saturating: NaN -> 0, +inf -> MAX, negative -> 0.
765        let cases: [(f32, u32); 6] = [
766            (f32::NAN, 0),
767            (f32::INFINITY, u32::MAX),
768            (f32::NEG_INFINITY, 0),
769            (-1.0, 0),
770            (-0.0, 0),
771            (f32::MAX, u32::MAX),
772        ];
773        for (fps, expected) in cases {
774            let s = CaptureStats {
775                measured_fps: fps,
776                ..CaptureStats::default()
777            };
778            assert_eq!(
779                s.measured_fps as u32, expected,
780                "cast of {fps} must saturate to {expected}"
781            );
782        }
783        // Ordinary value is unchanged (truncates toward zero).
784        let ok = CaptureStats {
785            measured_fps: 59.94,
786            ..CaptureStats::default()
787        };
788        assert_eq!(ok.measured_fps as u32, 59);
789    }
790
791    #[test]
792    fn frame_counters_overflow_only_via_checked_arithmetic() {
793        // `frames_delivered + frames_dropped` is the obvious "total frames"
794        // consumers will compute - at the saturated boundary it overflows.
795        let s = CaptureStats {
796            measured_fps: 30.0,
797            frames_delivered: u64::MAX,
798            frames_dropped: 1,
799        };
800        assert_eq!(
801            s.frames_delivered.checked_add(s.frames_dropped),
802            None,
803            "total-frames overflows u64; consumers must saturate/check"
804        );
805        assert_eq!(
806            s.frames_delivered.saturating_add(s.frames_dropped),
807            u64::MAX,
808            "saturating_add is the safe path"
809        );
810        // One below the boundary is fine.
811        let ok = CaptureStats {
812            frames_delivered: u64::MAX - 1,
813            frames_dropped: 1,
814            ..CaptureStats::default()
815        };
816        assert_eq!(
817            ok.frames_delivered.checked_add(ok.frames_dropped),
818            Some(u64::MAX)
819        );
820    }
821
822    #[test]
823    fn drop_rate_over_zeroed_stats_must_be_guarded() {
824        // Default stats have delivered == dropped == 0, so the natural drop
825        // rate `dropped / (delivered + dropped)` divides by zero: integer
826        // division would PANIC, float division yields NaN. Assert both, so the
827        // hazard is pinned rather than discovered in a HUD at runtime.
828        let s = CaptureStats::default();
829        let total = s.frames_delivered + s.frames_dropped;
830        assert_eq!(total, 0);
831        assert_eq!(
832            s.frames_dropped.checked_div(total),
833            None,
834            "integer drop-rate on zeroed stats must be checked, not raw `/`"
835        );
836        let rate = s.frames_dropped as f64 / total as f64;
837        assert!(rate.is_nan(), "float drop-rate on zeroed stats is NaN");
838
839        // With traffic, the rate is well-defined.
840        let live = CaptureStats {
841            measured_fps: 30.0,
842            frames_delivered: 75,
843            frames_dropped: 25,
844        };
845        let total = live.frames_delivered + live.frames_dropped;
846        assert_eq!(live.frames_dropped.checked_div(total), Some(0));
847        assert!(((live.frames_dropped as f64 / total as f64) - 0.25).abs() < 1e-12);
848    }
849
850    #[test]
851    fn config_pixel_count_overflows_u32_at_extreme_dimensions() {
852        // width * height is the natural buffer-size computation. At the
853        // documented-legal extremes it overflows u32 and must be widened.
854        let cfg = CameraConfig {
855            width: u32::MAX,
856            height: u32::MAX,
857            ..CameraConfig::default()
858        };
859        assert_eq!(
860            cfg.width.checked_mul(cfg.height),
861            None,
862            "u32 pixel count overflows; widen to u64 before multiplying"
863        );
864        assert_eq!(
865            u64::from(cfg.width) * u64::from(cfg.height),
866            (u64::from(u32::MAX)) * (u64::from(u32::MAX)),
867            "u64 widening is the safe path"
868        );
869        // A realistic size does not overflow.
870        let hd = CameraConfig {
871            width: 1920,
872            height: 1080,
873            ..CameraConfig::default()
874        };
875        assert_eq!(hd.width.checked_mul(hd.height), Some(2_073_600));
876    }
877
878    #[test]
879    fn fps_u32_to_f32_roundtrip_is_lossy_above_2_pow_24() {
880        // The config's requested `fps` is u32 while the reported `measured_fps`
881        // is f32. Comparing them via a cast is exact only below 2^24.
882        let exact: u32 = 1 << 24; // 16_777_216
883        assert_eq!(exact as f32 as u32, exact, "2^24 round-trips exactly");
884
885        let lossy: u32 = (1 << 24) + 1; // 16_777_217
886        assert_ne!(
887            lossy as f32 as u32,
888            lossy,
889            "2^24+1 must NOT round-trip through f32 - precision is lost"
890        );
891        assert_eq!(lossy as f32 as u32, exact, "it rounds down to 2^24");
892
893        // Realistic fps values are far below the boundary and are exact.
894        for fps in [0u32, 1, 24, 30, 60, 120, 240] {
895            let cfg = CameraConfig {
896                fps,
897                ..CameraConfig::default()
898            };
899            assert_eq!(cfg.fps as f32 as u32, fps, "fps {fps} must be exact");
900        }
901    }
902
903    #[test]
904    fn frames_delivered_u64_to_f32_roundtrip_is_lossy_at_the_boundary() {
905        // Same hazard on the counter side: a HUD computing fps as
906        // frames_delivered as f32 / secs loses counts past 2^24.
907        let s = CaptureStats {
908            frames_delivered: (1 << 24) + 1,
909            ..CaptureStats::default()
910        };
911        assert_ne!(s.frames_delivered as f32 as u64, s.frames_delivered);
912        // u64::MAX -> f32 rounds UP past u64::MAX, and the cast back saturates.
913        let sat = CaptureStats {
914            frames_delivered: u64::MAX,
915            ..CaptureStats::default()
916        };
917        assert_eq!(
918            sat.frames_delivered as f32 as u64,
919            u64::MAX,
920            "saturating cast clamps rather than wrapping to 0"
921        );
922    }
923
924    // ---- Predicates / invariants over the whole variant space ----
925
926    #[test]
927    fn all_enum_variants_are_pairwise_distinct_and_hash_consistently() {
928        use std::collections::HashSet;
929        assert_eq!(
930            ALL_STATES.iter().collect::<HashSet<_>>().len(),
931            ALL_STATES.len(),
932            "StreamState variants must be pairwise distinct"
933        );
934        assert_eq!(
935            ALL_ORIENTATIONS.iter().collect::<HashSet<_>>().len(),
936            ALL_ORIENTATIONS.len(),
937            "CaptureOrientation variants must be pairwise distinct"
938        );
939        assert_eq!(
940            ALL_ERRORS.iter().collect::<HashSet<_>>().len(),
941            ALL_ERRORS.len(),
942            "CaptureErrorCode variants must be pairwise distinct"
943        );
944        assert_eq!(
945            ALL_FORMATS.iter().collect::<HashSet<_>>().len(),
946            ALL_FORMATS.len(),
947            "RawImageFormat variants must be pairwise distinct"
948        );
949    }
950
951    #[test]
952    fn ord_is_a_total_order_for_every_camera_enum() {
953        use core::cmp::Ordering;
954        // Trichotomy + antisymmetry + transitivity, exhaustively over the
955        // (small) variant spaces. CameraFacing is already covered above.
956        macro_rules! assert_total_order {
957            ($set:expr) => {{
958                for &a in $set {
959                    assert_eq!(a.cmp(&a), Ordering::Equal);
960                    for &b in $set {
961                        let lt = (a < b) as u8;
962                        let eq = (a == b) as u8;
963                        let gt = (a > b) as u8;
964                        assert_eq!(lt + eq + gt, 1, "trichotomy ({a:?}, {b:?})");
965                        if a < b {
966                            assert!(!(b < a), "antisymmetry ({a:?}, {b:?})");
967                        }
968                        for &c in $set {
969                            if a < b && b < c {
970                                assert!(a < c, "transitivity ({a:?}, {b:?}, {c:?})");
971                            }
972                        }
973                    }
974                }
975            }};
976        }
977        assert_total_order!(&ALL_STATES);
978        assert_total_order!(&ALL_ORIENTATIONS);
979        assert_total_order!(&ALL_ERRORS);
980    }
981
982    #[test]
983    fn capture_stream_id_hash_eq_and_copy_independence() {
984        use std::collections::HashSet;
985        let mut set = HashSet::new();
986        for id in [0u64, 1, u64::MAX / 2, u64::MAX] {
987            assert!(set.insert(CaptureStreamId { id }), "id {id} is new");
988        }
989        assert_eq!(set.len(), 4);
990        // Re-inserting an equal id must be rejected (Hash agrees with Eq).
991        assert!(!set.insert(CaptureStreamId { id: u64::MAX }));
992        assert!(set.contains(&CaptureStreamId { id: 0 }));
993
994        // Copy: mutating the copy must not disturb the original.
995        let a = CaptureStreamId { id: 7 };
996        let mut b = a;
997        b.id = 9;
998        assert_eq!(a.id, 7);
999        assert_eq!(b.id, 9);
1000    }
1001
1002    #[test]
1003    fn capture_stats_copy_is_independent() {
1004        let a = CaptureStats {
1005            measured_fps: 30.0,
1006            frames_delivered: 100,
1007            frames_dropped: 2,
1008        };
1009        let mut b = a;
1010        b.measured_fps = 0.0;
1011        b.frames_delivered = 0;
1012        b.frames_dropped = 0;
1013        assert_eq!(a.measured_fps, 30.0, "original must survive copy mutation");
1014        assert_eq!(a.frames_delivered, 100);
1015        assert_eq!(a.frames_dropped, 2);
1016        assert_eq!(b, CaptureStats::default());
1017    }
1018
1019    #[test]
1020    fn debug_formatting_never_panics_on_extreme_or_nan_values() {
1021        // Debug is what a crash report / HUD log prints; it must survive the
1022        // adversarial values above.
1023        let stats = CaptureStats {
1024            measured_fps: f32::NAN,
1025            frames_delivered: u64::MAX,
1026            frames_dropped: u64::MAX,
1027        };
1028        assert!(!format!("{stats:?}").is_empty());
1029        assert!(!format!("{:?}", CaptureStats {
1030            measured_fps: f32::NEG_INFINITY,
1031            ..CaptureStats::default()
1032        })
1033        .is_empty());
1034
1035        let cfg = CameraConfig {
1036            facing: CameraFacing::External,
1037            width: u32::MAX,
1038            height: u32::MAX,
1039            fps: u32::MAX,
1040            output_format: RawImageFormat::RGBAF32,
1041        };
1042        let s = format!("{cfg:?}");
1043        assert!(s.contains("External"), "Debug must name the facing: {s}");
1044        assert!(s.contains("4294967295"), "Debug must show raw extents: {s}");
1045
1046        assert!(!format!("{:?}", CaptureStreamId { id: u64::MAX }).is_empty());
1047        for &st in &ALL_STATES {
1048            assert!(!format!("{st:?}").is_empty());
1049        }
1050        for &o in &ALL_ORIENTATIONS {
1051            assert!(!format!("{o:?}").is_empty());
1052        }
1053        for &e in &ALL_ERRORS {
1054            assert!(!format!("{e:?}").is_empty());
1055        }
1056    }
1057
1058    #[test]
1059    fn repr_c_layout_invariants_hold_for_the_ffi_pod_types() {
1060        use core::mem::{align_of, size_of};
1061        // Single-field #[repr(C)] newtype: must be exactly its payload, so it
1062        // can be passed across the FFI as a bare u64.
1063        assert_eq!(size_of::<CaptureStreamId>(), size_of::<u64>());
1064        assert_eq!(align_of::<CaptureStreamId>(), align_of::<u64>());
1065
1066        // No exact byte sizes asserted for the aggregates: u64 alignment (and
1067        // therefore trailing padding) differs on 32-bit targets. Assert the
1068        // portable invariants instead - big enough for the payload, and a
1069        // whole number of alignment units (a valid array element).
1070        assert!(size_of::<CaptureStats>() >= size_of::<f32>() + 2 * size_of::<u64>());
1071        assert_eq!(size_of::<CaptureStats>() % align_of::<CaptureStats>(), 0);
1072
1073        assert!(size_of::<CameraConfig>() >= 3 * size_of::<u32>());
1074        assert_eq!(size_of::<CameraConfig>() % align_of::<CameraConfig>(), 0);
1075
1076        // Fieldless repr(C) enums are C ints - never zero-sized, never huge.
1077        assert_eq!(size_of::<CameraFacing>(), size_of::<StreamState>());
1078        assert_eq!(size_of::<StreamState>(), size_of::<CaptureOrientation>());
1079        assert_eq!(size_of::<CaptureOrientation>(), size_of::<CaptureErrorCode>());
1080        assert!(size_of::<CameraFacing>() > 0);
1081    }
1082}