Skip to main content

plushie_core/protocol/
props.rs

1//! Typed prop storage for the widget tree.
2//!
3//! [`Props`] wraps a [`PropMap`], a small ordered vector of
4//! `(String, PropValue)` pairs. Both direct-mode SDK builders and
5//! wire-mode JSON input produce the same underlying shape: wire
6//! deserialisation walks the `serde_json::Value` once and converts
7//! each entry into a [`PropValue`].
8//!
9//! Earlier versions of this module had two variants (`Typed` and
10//! `Wire`) so wire props could wrap `serde_json::Value` without
11//! converting. That traded a one-time deserialize cost for a
12//! per-access branch on every accessor, and several accessors were
13//! silently variant-asymmetric (e.g. `get` returned `None` for the
14//! typed variant). The render path dominates the cost; unifying on
15//! `PropMap` removes the footgun without a measurable hit.
16//!
17//! # Null-valued entries are wire-canonical "absent"
18//!
19//! The wire protocol encodes prop removal by sending `null` in an
20//! `update_props` op. There is no way to transmit "set this key to
21//! an explicit null value." As a result, equality on [`Props`] and
22//! [`PropMap`] treats null-valued entries as equivalent to absent
23//! entries, so round-tripping a tree through diff + apply is
24//! lossless. See the `PartialEq` impl on [`PropMap`] below.
25
26use serde_json::Value;
27
28// ---------------------------------------------------------------------------
29// PropValue
30// ---------------------------------------------------------------------------
31
32/// A typed prop value. Covers all value types the widget system uses.
33///
34/// Mirrors JSON's type system but without serde allocation overhead.
35/// Primitive values are stored inline (no boxing).
36///
37/// # Wire-canonical equality across numeric variants
38///
39/// JSON does not distinguish integer from float; the wire collapses
40/// `42` and `42.0` to the same on-wire shape. To keep diff/apply
41/// self-consistent across the wire, equality on `PropValue` treats
42/// `I64(42)`, `U64(42)`, and `F64(42.0)` as equal. Without this,
43/// `tree_diff` produces a spurious `update_props` op whenever a value
44/// passes through a numeric round-trip (animations interpolate to
45/// `F64` while authored props start as `I64`, etc.).
46#[derive(Debug, Clone)]
47pub enum PropValue {
48    /// Null.
49    Null,
50    /// Bool.
51    Bool(bool),
52    /// F64.
53    F64(f64),
54    /// I64.
55    I64(i64),
56    /// U64.
57    U64(u64),
58    /// Str.
59    Str(String),
60    /// Array.
61    Array(Vec<PropValue>),
62    /// Object.
63    Object(PropMap),
64}
65
66const I64_MIN_AS_F64: f64 = -9_223_372_036_854_775_808.0;
67const I64_MAX_PLUS_ONE_AS_F64: f64 = 9_223_372_036_854_775_808.0;
68const U64_MAX_PLUS_ONE_AS_F64: f64 = 18_446_744_073_709_551_616.0;
69
70fn exact_i64_to_f64(value: i64) -> Option<f64> {
71    let float = value as f64;
72
73    ((I64_MIN_AS_F64..I64_MAX_PLUS_ONE_AS_F64).contains(&float) && float as i64 == value)
74        .then_some(float)
75}
76
77fn exact_u64_to_f64(value: u64) -> Option<f64> {
78    let float = value as f64;
79
80    ((0.0..U64_MAX_PLUS_ONE_AS_F64).contains(&float) && float as u64 == value).then_some(float)
81}
82
83fn exact_f64_to_i64(value: f64) -> Option<i64> {
84    (value.is_finite()
85        && value.fract() == 0.0
86        && (I64_MIN_AS_F64..I64_MAX_PLUS_ONE_AS_F64).contains(&value))
87    .then_some(value as i64)
88}
89
90fn exact_f64_to_u64(value: f64) -> Option<u64> {
91    (value.is_finite() && value.fract() == 0.0 && (0.0..U64_MAX_PLUS_ONE_AS_F64).contains(&value))
92        .then_some(value as u64)
93}
94
95fn finite_f64_to_f32(value: f64) -> Option<f32> {
96    let narrowed = value as f32;
97
98    (value.is_finite() && narrowed.is_finite()).then_some(narrowed)
99}
100
101impl PartialEq for PropValue {
102    fn eq(&self, other: &Self) -> bool {
103        match (self, other) {
104            (Self::Null, Self::Null) => true,
105            (Self::Bool(a), Self::Bool(b)) => a == b,
106            (Self::Str(a), Self::Str(b)) => a == b,
107            (Self::Array(a), Self::Array(b)) => a == b,
108            (Self::Object(a), Self::Object(b)) => a == b,
109            // Cross-variant numeric equality: integer-equal values
110            // compare equal regardless of which variant they sit in.
111            (Self::I64(a), Self::I64(b)) => a == b,
112            (Self::U64(a), Self::U64(b)) => a == b,
113            (Self::F64(a), Self::F64(b)) => a == b,
114            (Self::I64(i), Self::U64(u)) | (Self::U64(u), Self::I64(i)) => {
115                u64::try_from(*i).is_ok_and(|iu| iu == *u)
116            }
117            (Self::I64(i), Self::F64(f)) | (Self::F64(f), Self::I64(i)) => {
118                exact_f64_to_i64(*f).is_some_and(|fi| fi == *i)
119            }
120            (Self::U64(u), Self::F64(f)) | (Self::F64(f), Self::U64(u)) => {
121                exact_f64_to_u64(*f).is_some_and(|fu| fu == *u)
122            }
123            _ => false,
124        }
125    }
126}
127
128impl PropValue {
129    /// Borrow the value as a str.
130    pub fn as_str(&self) -> Option<&str> {
131        match self {
132            Self::Str(s) => Some(s),
133            _ => None,
134        }
135    }
136
137    /// Return the value as an f64 when conversion is finite and exact.
138    pub fn as_f64(&self) -> Option<f64> {
139        match self {
140            Self::F64(v) => v.is_finite().then_some(*v),
141            Self::I64(v) => exact_i64_to_f64(*v),
142            Self::U64(v) => exact_u64_to_f64(*v),
143            _ => None,
144        }
145    }
146
147    /// Borrow the value as a bool.
148    pub fn as_bool(&self) -> Option<bool> {
149        match self {
150            Self::Bool(v) => Some(*v),
151            _ => None,
152        }
153    }
154
155    /// Return the value as an i64 when conversion is integral and in range.
156    pub fn as_i64(&self) -> Option<i64> {
157        match self {
158            Self::I64(v) => Some(*v),
159            Self::U64(v) => i64::try_from(*v).ok(),
160            Self::F64(v) => exact_f64_to_i64(*v),
161            _ => None,
162        }
163    }
164
165    /// Return the value as a u64 when conversion is integral and in range.
166    pub fn as_u64(&self) -> Option<u64> {
167        match self {
168            Self::U64(v) => Some(*v),
169            Self::I64(v) => u64::try_from(*v).ok(),
170            Self::F64(v) => exact_f64_to_u64(*v),
171            _ => None,
172        }
173    }
174
175    /// Borrow the value as an array.
176    pub fn as_array(&self) -> Option<&[PropValue]> {
177        match self {
178            Self::Array(a) => Some(a),
179            _ => None,
180        }
181    }
182
183    /// Borrow the value as an object.
184    pub fn as_object(&self) -> Option<&PropMap> {
185        match self {
186            Self::Object(m) => Some(m),
187            _ => None,
188        }
189    }
190
191    /// Returns true when the null condition holds.
192    pub fn is_null(&self) -> bool {
193        matches!(self, Self::Null)
194    }
195}
196
197// From impls for ergonomic construction.
198impl From<bool> for PropValue {
199    fn from(v: bool) -> Self {
200        Self::Bool(v)
201    }
202}
203impl From<f32> for PropValue {
204    fn from(v: f32) -> Self {
205        Self::F64(v as f64)
206    }
207}
208impl From<f64> for PropValue {
209    fn from(v: f64) -> Self {
210        Self::F64(v)
211    }
212}
213impl From<i32> for PropValue {
214    fn from(v: i32) -> Self {
215        Self::I64(v as i64)
216    }
217}
218impl From<i64> for PropValue {
219    fn from(v: i64) -> Self {
220        Self::I64(v)
221    }
222}
223impl From<u32> for PropValue {
224    fn from(v: u32) -> Self {
225        Self::U64(v as u64)
226    }
227}
228impl From<u64> for PropValue {
229    fn from(v: u64) -> Self {
230        Self::U64(v)
231    }
232}
233impl From<&str> for PropValue {
234    fn from(v: &str) -> Self {
235        Self::Str(v.to_string())
236    }
237}
238impl From<String> for PropValue {
239    fn from(v: String) -> Self {
240        Self::Str(v)
241    }
242}
243
244// ---------------------------------------------------------------------------
245// PropValue <-> serde_json::Value conversion
246// ---------------------------------------------------------------------------
247
248impl From<Value> for PropValue {
249    fn from(v: Value) -> Self {
250        match v {
251            Value::Null => Self::Null,
252            Value::Bool(b) => Self::Bool(b),
253            Value::Number(n) => {
254                if let Some(i) = n.as_i64() {
255                    Self::I64(i)
256                } else if let Some(u) = n.as_u64() {
257                    Self::U64(u)
258                } else if let Some(f) = n.as_f64() {
259                    Self::F64(f)
260                } else {
261                    Self::Null
262                }
263            }
264            Value::String(s) => Self::Str(s),
265            Value::Array(arr) => Self::Array(arr.into_iter().map(PropValue::from).collect()),
266            Value::Object(map) => Self::Object(PropMap::from_json_map(map)),
267        }
268    }
269}
270
271impl From<PropValue> for Value {
272    fn from(v: PropValue) -> Self {
273        match v {
274            PropValue::Null => Value::Null,
275            PropValue::Bool(b) => Value::Bool(b),
276            PropValue::F64(f) => {
277                if !f.is_finite() {
278                    log::warn!(
279                        "non-finite f64 ({f}) in PropValue silently encoded as JSON null; \
280                         caller passed an invalid value through `From<f32>`/`From<f64>`"
281                    );
282                }
283                serde_json::json!(f)
284            }
285            PropValue::I64(i) => Value::Number(i.into()),
286            PropValue::U64(u) => Value::Number(u.into()),
287            PropValue::Str(s) => Value::String(s),
288            PropValue::Array(arr) => Value::Array(arr.into_iter().map(Value::from).collect()),
289            PropValue::Object(map) => Value::Object(map.into_json_map()),
290        }
291    }
292}
293
294// ---------------------------------------------------------------------------
295// PropMap
296// ---------------------------------------------------------------------------
297
298/// Ordered map of prop key-value pairs.
299///
300/// Uses a `Vec` for storage since widget props are typically small
301/// (5-15 entries). Linear scan is faster than hashing for this size.
302///
303/// # Wire serialisation key order
304///
305/// Props are serialised to JSON via
306/// [`into_json_map`](PropMap::into_json_map) which collects into a
307/// `serde_json::Map`. The workspace compiles `serde_json` **without**
308/// the `preserve_order` feature, so `Map` is an alphabetical-key
309/// `BTreeMap` equivalent. That keeps direct JSON serialisation stable
310/// for protocol-facing code and prop regression tests in this crate.
311/// Enabling `preserve_order` would make JSON serialisation
312/// insertion-ordered and change the emitted wire shape.
313#[derive(Debug, Clone, Default)]
314pub struct PropMap(Vec<(String, PropValue)>);
315
316impl PartialEq for PropMap {
317    /// Wire-canonical equality: null-valued entries are equivalent to
318    /// absent entries. The wire protocol encodes key removal by sending
319    /// `null`, so `{}` and `{"a": null}` are indistinguishable downstream
320    /// and must compare equal here. Without this, `tree_diff` +
321    /// `apply_patch` could not round-trip trees whose only difference
322    /// is a null-valued prop, because there is no protocol op that adds
323    /// an explicit null-valued key.
324    fn eq(&self, other: &Self) -> bool {
325        let non_null =
326            |pairs: &[(String, PropValue)]| pairs.iter().filter(|(_, v)| !v.is_null()).count();
327        if non_null(&self.0) != non_null(&other.0) {
328            return false;
329        }
330        self.0
331            .iter()
332            .filter(|(_, v)| !v.is_null())
333            .all(|(k, v)| match other.get(k) {
334                Some(ov) if !ov.is_null() => ov == v,
335                _ => false,
336            })
337    }
338}
339
340impl Eq for PropMap {}
341
342impl PropMap {
343    /// Construct a new value.
344    pub fn new() -> Self {
345        Self(Vec::new())
346    }
347
348    /// Return a new value with the capacity set.
349    pub fn with_capacity(cap: usize) -> Self {
350        Self(Vec::with_capacity(cap))
351    }
352
353    /// Get a prop value by key.
354    pub fn get(&self, key: &str) -> Option<&PropValue> {
355        self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v)
356    }
357
358    /// Get a mutable reference to a prop value by key.
359    pub fn get_mut(&mut self, key: &str) -> Option<&mut PropValue> {
360        self.0.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v)
361    }
362
363    /// Insert or replace a prop value.
364    pub fn insert(&mut self, key: impl Into<String>, value: impl Into<PropValue>) {
365        let key = key.into();
366        let value = value.into();
367        if let Some(entry) = self.0.iter_mut().find(|(k, _)| *k == key) {
368            entry.1 = value;
369        } else {
370            self.0.push((key, value));
371        }
372    }
373
374    /// Remove a prop by key, returning the old value if present.
375    pub fn remove(&mut self, key: &str) -> Option<PropValue> {
376        let idx = self.0.iter().position(|(k, _)| k == key)?;
377        Some(self.0.remove(idx).1)
378    }
379
380    /// Set or construct `contains_key`.
381    pub fn contains_key(&self, key: &str) -> bool {
382        self.0.iter().any(|(k, _)| k == key)
383    }
384
385    /// Returns true when the empty condition holds.
386    pub fn is_empty(&self) -> bool {
387        self.0.is_empty()
388    }
389    /// Set or construct `len`.
390    pub fn len(&self) -> usize {
391        self.0.len()
392    }
393
394    /// Iterate over (key, value) pairs.
395    pub fn iter(&self) -> impl Iterator<Item = (&str, &PropValue)> {
396        self.0.iter().map(|(k, v)| (k.as_str(), v))
397    }
398
399    /// Iterate over keys.
400    pub fn keys(&self) -> impl Iterator<Item = &str> {
401        self.0.iter().map(|(k, _)| k.as_str())
402    }
403
404    /// Convert from a serde_json Map.
405    pub fn from_json_map(map: serde_json::Map<String, Value>) -> Self {
406        Self(
407            map.into_iter()
408                .map(|(k, v)| (k, PropValue::from(v)))
409                .collect(),
410        )
411    }
412
413    /// Convert to a serde_json Map.
414    pub fn into_json_map(self) -> serde_json::Map<String, Value> {
415        self.0
416            .into_iter()
417            .map(|(k, v)| (k, Value::from(v)))
418            .collect()
419    }
420}
421
422// ---------------------------------------------------------------------------
423// Props
424// ---------------------------------------------------------------------------
425
426/// Prop storage for [`TreeNode`](super::TreeNode).
427///
428/// Wraps a [`PropMap`]. Both direct-mode SDK builders and wire-mode
429/// JSON deserialisation land in the same representation; accessors
430/// are plain delegations with no per-variant branching.
431#[derive(Debug, Clone, Default, PartialEq, Eq)]
432pub struct Props(PropMap);
433
434impl Props {
435    /// Construct from a `serde_json::Value`. Non-object values (a stray
436    /// string, number, etc.) become an empty [`PropMap`] rather than a
437    /// panic, so malformed wire input degrades gracefully.
438    pub fn from_json(value: Value) -> Self {
439        match value {
440            Value::Object(map) => Self(PropMap::from_json_map(map)),
441            _ => Self(PropMap::new()),
442        }
443    }
444
445    /// Get a string prop.
446    pub fn get_str(&self, key: &str) -> Option<&str> {
447        self.0.get(key)?.as_str()
448    }
449
450    /// Get a numeric prop as f64 when conversion is finite and exact.
451    pub fn get_f64(&self, key: &str) -> Option<f64> {
452        self.0.get(key)?.as_f64()
453    }
454
455    /// Get a numeric prop as f32 when conversion remains finite.
456    pub fn get_f32(&self, key: &str) -> Option<f32> {
457        finite_f64_to_f32(self.get_f64(key)?)
458    }
459
460    /// Get a boolean prop.
461    pub fn get_bool(&self, key: &str) -> Option<bool> {
462        self.0.get(key)?.as_bool()
463    }
464
465    /// Get an integer prop as i64 when conversion is integral and in range.
466    pub fn get_i64(&self, key: &str) -> Option<i64> {
467        self.0.get(key)?.as_i64()
468    }
469
470    /// Get an unsigned integer prop as u64 when conversion is integral and in range.
471    pub fn get_u64(&self, key: &str) -> Option<u64> {
472        self.0.get(key)?.as_u64()
473    }
474
475    /// Check if a key exists.
476    pub fn contains_key(&self, key: &str) -> bool {
477        self.0.contains_key(key)
478    }
479
480    /// Convert to a JSON Value for consumption by prop_helpers.
481    ///
482    /// Always allocates (converts PropMap to JSON Map). Callers that
483    /// only need field-by-field access should use the typed accessors
484    /// directly instead.
485    pub fn as_value_cow(&self) -> std::borrow::Cow<'_, Value> {
486        std::borrow::Cow::Owned(Value::Object(self.0.clone().into_json_map()))
487    }
488
489    /// Borrow the underlying [`PropMap`].
490    pub fn as_prop_map(&self) -> &PropMap {
491        &self.0
492    }
493
494    /// Mutably borrow the underlying [`PropMap`].
495    pub fn as_prop_map_mut(&mut self) -> &mut PropMap {
496        &mut self.0
497    }
498
499    /// Get a prop by key as `&PropValue`.
500    pub fn get(&self, key: &str) -> Option<&PropValue> {
501        self.0.get(key)
502    }
503
504    /// Get a prop value as an owned `Value`. Allocates. Use sparingly;
505    /// prefer the typed accessors (`get_str`, `get_f64`, etc.) when
506    /// possible.
507    pub fn get_value(&self, key: &str) -> Option<Value> {
508        self.0.get(key).map(|pv| Value::from(pv.clone()))
509    }
510
511    /// Convert to a `serde_json::Value` (for wire serialization).
512    pub fn to_value(&self) -> Value {
513        Value::Object(self.0.clone().into_json_map())
514    }
515
516    /// True if the props contain an object/map structure. Always
517    /// true for the unified representation.
518    pub fn is_object(&self) -> bool {
519        true
520    }
521}
522
523impl From<PropMap> for Props {
524    fn from(map: PropMap) -> Self {
525        Self(map)
526    }
527}
528
529// ---------------------------------------------------------------------------
530// Serde: Props serializes as a JSON object and deserializes from any Value
531// (non-object inputs collapse to an empty PropMap).
532// ---------------------------------------------------------------------------
533
534impl serde::Serialize for Props {
535    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
536        self.to_value().serialize(serializer)
537    }
538}
539
540impl<'de> serde::Deserialize<'de> for Props {
541    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
542        let value = Value::deserialize(deserializer)?;
543        Ok(Self::from_json(value))
544    }
545}
546
547// ---------------------------------------------------------------------------
548// Tests
549// ---------------------------------------------------------------------------
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use serde_json::json;
555
556    #[test]
557    fn prop_map_insert_and_get() {
558        let mut map = PropMap::new();
559        map.insert("label", "Save");
560        map.insert("size", 18.0f64);
561        map.insert("disabled", false);
562
563        assert_eq!(map.get("label").unwrap().as_str(), Some("Save"));
564        assert_eq!(map.get("size").unwrap().as_f64(), Some(18.0));
565        assert_eq!(map.get("disabled").unwrap().as_bool(), Some(false));
566        assert!(map.get("missing").is_none());
567    }
568
569    #[test]
570    fn prop_map_insert_replaces() {
571        let mut map = PropMap::new();
572        map.insert("value", 1.0f64);
573        map.insert("value", 2.0f64);
574        assert_eq!(map.len(), 1);
575        assert_eq!(map.get("value").unwrap().as_f64(), Some(2.0));
576    }
577
578    #[test]
579    fn prop_map_remove() {
580        let mut map = PropMap::new();
581        map.insert("a", "hello");
582        map.insert("b", "world");
583        assert_eq!(map.remove("a").unwrap().as_str(), Some("hello"));
584        assert_eq!(map.len(), 1);
585        assert!(map.get("a").is_none());
586    }
587
588    #[test]
589    fn props_typed_accessors() {
590        let mut map = PropMap::new();
591        map.insert("title", "Hello");
592        map.insert("size", 24.0f64);
593        map.insert("visible", true);
594        let props = Props::from(map);
595
596        assert_eq!(props.get_str("title"), Some("Hello"));
597        assert_eq!(props.get_f64("size"), Some(24.0));
598        assert_eq!(props.get_f32("size"), Some(24.0));
599        assert_eq!(props.get_bool("visible"), Some(true));
600        assert!(props.get_str("missing").is_none());
601    }
602
603    #[test]
604    fn props_wire_accessors() {
605        let props = Props::from_json(json!({"title": "Hello", "size": 24.0, "visible": true}));
606
607        assert_eq!(props.get_str("title"), Some("Hello"));
608        assert_eq!(props.get_f64("size"), Some(24.0));
609        assert_eq!(props.get_bool("visible"), Some(true));
610    }
611
612    #[test]
613    fn props_deserialize_round_trip_accessors() {
614        let json_str = r#"{"a": 1, "b": "x", "c": true}"#;
615        let props: Props = serde_json::from_str(json_str).unwrap();
616        assert_eq!(props.get_i64("a"), Some(1));
617        assert_eq!(props.get_str("b"), Some("x"));
618        assert_eq!(props.get_bool("c"), Some(true));
619    }
620
621    #[test]
622    fn props_from_non_object_json_is_empty() {
623        let props = Props::from_json(json!("stray string"));
624        assert!(props.as_prop_map().is_empty());
625        assert!(props.is_object());
626        assert_eq!(props.get_str("anything"), None);
627    }
628
629    #[test]
630    fn props_null_entries_are_absent_for_eq() {
631        let mut with_null = PropMap::new();
632        with_null.insert("content", "hello");
633        with_null.insert("size", PropValue::Null);
634        let empty_size = PropMap::new();
635        let mut plain = empty_size.clone();
636        plain.insert("content", "hello");
637
638        assert_eq!(Props::from(with_null), Props::from(plain));
639    }
640
641    #[test]
642    fn props_typed_eq_wire() {
643        let mut map = PropMap::new();
644        map.insert("content", "hello");
645        map.insert("size", 18.0f64);
646        let typed = Props::from(map);
647
648        let wire = Props::from_json(json!({"content": "hello", "size": 18.0}));
649
650        assert_eq!(typed, wire);
651    }
652
653    #[test]
654    fn prop_value_round_trip_through_json() {
655        let mut map = PropMap::new();
656        map.insert("text", "hello");
657        map.insert("num", 42.0f64);
658        map.insert("flag", true);
659        map.insert(
660            "items",
661            PropValue::Array(vec![PropValue::from(1.0f64), PropValue::from(2.0f64)]),
662        );
663
664        let json_map = map.clone().into_json_map();
665        let round_tripped = PropMap::from_json_map(json_map);
666        assert_eq!(map, round_tripped);
667    }
668
669    #[test]
670    fn props_serializes_as_json_object() {
671        let mut map = PropMap::new();
672        map.insert("label", "Save");
673        let props = Props::from(map);
674
675        let json_str = serde_json::to_string(&props).unwrap();
676        assert!(json_str.contains("\"label\":\"Save\""));
677    }
678
679    #[test]
680    fn props_deserializes_to_prop_map() {
681        let json_str = r#"{"label":"Save","size":18}"#;
682        let props: Props = serde_json::from_str(json_str).unwrap();
683        assert_eq!(props.get_str("label"), Some("Save"));
684        assert_eq!(props.get_i64("size"), Some(18));
685    }
686
687    #[test]
688    fn props_default_is_empty() {
689        let props = Props::default();
690        assert!(props.as_prop_map().is_empty());
691    }
692
693    #[test]
694    fn prop_value_numeric_coercion() {
695        assert_eq!(PropValue::I64(42).as_f64(), Some(42.0));
696        assert_eq!(PropValue::U64(42).as_f64(), Some(42.0));
697        assert_eq!(
698            PropValue::I64(9_007_199_254_740_994).as_f64(),
699            Some(9_007_199_254_740_994.0)
700        );
701        assert_eq!(
702            PropValue::U64(9_007_199_254_740_994).as_f64(),
703            Some(9_007_199_254_740_994.0)
704        );
705        assert_eq!(PropValue::F64(42.0).as_i64(), Some(42));
706        assert_eq!(PropValue::F64(42.0).as_u64(), Some(42));
707        assert_eq!(PropValue::I64(42).as_u64(), Some(42));
708    }
709
710    #[test]
711    fn prop_value_rejects_fractional_float_integer_access() {
712        let value = PropValue::F64(42.9);
713
714        assert_eq!(value.as_i64(), None);
715        assert_eq!(value.as_u64(), None);
716
717        let mut map = PropMap::new();
718        map.insert("value", value);
719        let props = Props::from(map);
720
721        assert_eq!(props.get_i64("value"), None);
722        assert_eq!(props.get_u64("value"), None);
723    }
724
725    #[test]
726    fn prop_value_rejects_non_finite_float_access() {
727        for value in [
728            PropValue::F64(f64::NAN),
729            PropValue::F64(f64::INFINITY),
730            PropValue::F64(f64::NEG_INFINITY),
731        ] {
732            assert_eq!(value.as_f64(), None);
733            assert_eq!(value.as_i64(), None);
734            assert_eq!(value.as_u64(), None);
735        }
736
737        let mut map = PropMap::new();
738        map.insert("value", PropValue::F64(f64::INFINITY));
739        let props = Props::from(map);
740
741        assert_eq!(props.get_f64("value"), None);
742        assert_eq!(props.get_f32("value"), None);
743    }
744
745    #[test]
746    fn prop_value_rejects_lossy_integer_float_access() {
747        assert_eq!(PropValue::I64(9_007_199_254_740_993).as_f64(), None);
748        assert_eq!(PropValue::I64(i64::MAX).as_f64(), None);
749        assert_eq!(PropValue::U64(9_007_199_254_740_993).as_f64(), None);
750        assert_eq!(PropValue::U64(u64::MAX).as_f64(), None);
751    }
752
753    #[test]
754    fn props_get_f32_accepts_finite_narrowing_and_rejects_overflow() {
755        let mut map = PropMap::new();
756        map.insert("exact_float", 1.5f64);
757        map.insert("from_f32", 1.1f32);
758        map.insert("lossy_float", 1.1f64);
759        map.insert("lossy_integer", 16_777_217_u64);
760        map.insert("too_large", f64::from(f32::MAX) * 2.0);
761        let props = Props::from(map);
762
763        assert_eq!(props.get_f32("exact_float"), Some(1.5));
764        assert_eq!(props.get_f32("from_f32"), Some(1.1f32));
765        assert_eq!(props.get_f32("lossy_float"), Some(1.1f32));
766        assert_eq!(props.get_f32("lossy_integer"), Some(16_777_216.0));
767        assert_eq!(props.get_f32("too_large"), None);
768    }
769
770    // ---------------------------------------------------------------------------
771    // Alphabetical key ordering invariant
772    //
773    // Direct prop serialisation depends on `serde_json::to_string`
774    // producing alphabetical-key output. That holds only when
775    // `serde_json`'s `preserve_order` feature is NOT enabled. This test
776    // inserts keys in non-alphabetical order and asserts the serialised
777    // string is alphabetical.
778    //
779    // If this test ever fails, a transitive dependency has enabled
780    // `preserve_order`; direct prop serialisation will change.
781    // ---------------------------------------------------------------------------
782
783    #[test]
784    fn props_serialise_keys_alphabetically() {
785        let mut map = PropMap::new();
786        // Insert in reverse-alphabetical order.
787        map.insert("zebra", "z");
788        map.insert("mango", "m");
789        map.insert("apple", "a");
790        let props = Props::from(map);
791
792        let json_str = serde_json::to_string(&props).unwrap();
793        // Alphabetical: apple, mango, zebra.
794        let expected = r#"{"apple":"a","mango":"m","zebra":"z"}"#;
795        assert_eq!(
796            json_str, expected,
797            "serde_json Props serialisation must be alphabetical; \
798             if this fails, serde_json's preserve_order feature may have \
799             leaked in via a transitive dependency"
800        );
801    }
802
803    #[test]
804    fn nested_props_serialise_keys_alphabetically() {
805        // Nested objects must also be alphabetical.
806        let mut inner = PropMap::new();
807        inner.insert("width", 100.0f64);
808        inner.insert("height", 50.0f64);
809        let mut outer = PropMap::new();
810        outer.insert("z_field", PropValue::Object(inner));
811        outer.insert("a_field", "a");
812        let props = Props::from(outer);
813
814        let json_str = serde_json::to_string(&props).unwrap();
815        assert_eq!(
816            json_str,
817            r#"{"a_field":"a","z_field":{"height":50.0,"width":100.0}}"#
818        );
819    }
820}