Skip to main content

rs_teststand_serde/
value.rs

1//! A serializable mirror of a `PropertyObject` tree.
2
3use std::collections::BTreeMap;
4
5use rs_teststand::{Error, PropValType, PropertyObject, PropertyRepresentation};
6
7/// Create-or-update: `PropOption_InsertIfMissing`.
8const INSERT_IF_MISSING: i32 = 1;
9
10/// Default options: `PropOption_NoOptions`.
11const NO_OPTIONS: i32 = 0;
12
13/// The prefix the engine emits for each base, paired with that base.
14///
15/// `0c` for octal is the engine's own choice, not C's bare leading zero, which
16/// is why a generic parser would misread it.
17const RADIX_PREFIXES: [(&str, u32); 3] = [("0x", 16), ("0b", 2), ("0c", 8)];
18
19/// `printf` conversion characters that select a base other than ten.
20const RADIX_CONVERSIONS: [char; 4] = ['x', 'X', 'o', 'b'];
21
22/// One value from a `PropertyObject` tree, in a form serde can handle.
23///
24/// The representation is untagged, so the serialized form is ordinary data, a
25/// container becomes an object, an array becomes a list, a scalar becomes a
26/// scalar, rather than something carrying wrapper keys.
27///
28/// The engine distinguishes three numeric storages and matches them strictly,
29/// so they are separate variants here: collapsing them to one would lose the
30/// exactness that [`Integer`](Self::Integer) and [`Unsigned`](Self::Unsigned)
31/// exist to provide.
32///
33/// Variant order matters for deserialization: serde tries untagged variants top
34/// to bottom, so an integral JSON number is read as [`Integer`](Self::Integer)
35/// and only a value too large for `i64` falls through to
36/// [`Unsigned`](Self::Unsigned), with fractional values reaching
37/// [`Number`](Self::Number).
38///
39/// # Representation is not round-trip stable through plain JSON
40///
41/// JSON has one number type, so a value that fits both signed and unsigned, /// `0`, or anything up to `i64::MAX`, comes back as [`Integer`](Self::Integer)
42/// even if it left as [`Unsigned`](Self::Unsigned). The *number* is preserved
43/// exactly; only the engine's choice of storage is not.
44///
45/// This is a property of the wire format, not a defect here, and it is the
46/// price of emitting ordinary JSON instead of tagged objects. It matters only
47/// when rebuilding a property whose representation must be unsigned: read the
48/// representation from the live
49/// [`PropertyObjectType`](rs_teststand::PropertyObjectType) rather than inferring it
50/// from deserialized JSON. Values above `i64::MAX` are unambiguous and do
51/// survive.
52#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
53#[serde(untagged)]
54pub enum PropertyValue {
55    /// No value.
56    ///
57    /// Produced for any non-finite number. The engine names three of these:
58    /// `NAN` (not a number), `IND` (indeterminate, a special quiet NaN, from
59    /// operations such as `Sqrt(-1)`, which the engine treats as equivalent to
60    /// `NAN` in comparisons), and `INF`.
61    ///
62    /// JSON cannot write any of them, and inventing an encoding would force
63    /// every consumer to learn it, so they all serialize as `null`, which any
64    /// language already understands. An empty object reference is *not* null
65    /// here: it round-trips as the string `"Nothing"`, so a reference stays
66    /// distinguishable from a missing number.
67    Null,
68    /// A boolean.
69    Bool(bool),
70    /// A number stored as a signed 64-bit integer.
71    Integer(i64),
72    /// A number stored as an unsigned 64-bit integer.
73    Unsigned(u64),
74    /// A number stored as a double.
75    Number(f64),
76    /// A string.
77    Text(String),
78    /// An array. TestStand arrays are homogeneous.
79    Array(Vec<Self>),
80    /// A container, keyed by sub-property name.
81    ///
82    /// Ordered rather than hashed so a serialized tree is stable between runs
83    /// and diffable.
84    Container(BTreeMap<String, Self>),
85}
86
87impl PropertyValue {
88    /// The `PropValType` to create a sub-property with for this value.
89    ///
90    /// Numeric variants all map to `Number`: the engine decides representation
91    /// when the value is written, so the distinction is carried by the setter
92    /// rather than by the creation type.
93    fn creation_type(&self) -> PropValType {
94        match self {
95            Self::Bool(_) => PropValType::Boolean,
96            // A null carries no type of its own; Number is the only kind that
97            // can hold one (as NAN), so that is what it recreates.
98            Self::Null | Self::Integer(_) | Self::Unsigned(_) | Self::Number(_) => {
99                PropValType::Number
100            }
101            Self::Text(_) => PropValType::String,
102            Self::Container(_) => PropValType::Container,
103            // An empty array has no element type to inspect; a container is the
104            // most permissive choice and matches how the tree is rebuilt.
105            Self::Array(items) => items
106                .first()
107                .map_or(PropValType::Container, Self::creation_type),
108        }
109    }
110}
111
112/// The flat offset of an element, given the array's shape and the indices.
113///
114/// Column-major: the first index varies fastest, so
115/// `offset = i0 + d0*(i1 + d1*(i2 + ...))`.
116fn column_major_offset(lengths: &[i32], indices: &[i32]) -> i32 {
117    let mut offset = 0;
118    let mut stride = 1;
119    for (length, index) in lengths.iter().zip(indices.iter()) {
120        offset += index * stride;
121        stride *= *length;
122    }
123    offset
124}
125
126/// Parses a radix-prefixed string back into a number.
127///
128/// Recognizes what the engine emits: `0x` for hexadecimal, `0b` for binary and
129/// the engine's own `0c` for octal. Anything else is a genuine string and is
130/// left alone, so a value that merely begins with `0` is not mangled.
131fn parse_radix(text: &str) -> Option<f64> {
132    let trimmed = text.trim();
133    let (negative, digits) = trimmed
134        .strip_prefix('-')
135        .map_or((false, trimmed), |rest| (true, rest));
136
137    let lowered = digits.to_ascii_lowercase();
138    let (radix, body) = RADIX_PREFIXES
139        .into_iter()
140        .find_map(|(prefix, radix)| lowered.strip_prefix(prefix).map(|rest| (radix, rest)))?;
141    let magnitude = i64::from_str_radix(body, radix).ok()?;
142    // i64 -> f64 is exact up to 2^53; beyond that the value was never a
143    // faithful `Number` in the first place.
144    #[allow(clippy::cast_precision_loss, reason = "engine numbers are f64 already")]
145    let value = magnitude as f64;
146    Some(if negative { -value } else { value })
147}
148
149/// Whether a numeric format selects a base other than ten.
150///
151/// Only these carry information a bare number cannot: a value shown as `0xa`
152/// was authored in hex, and rendering it as `10` loses that intent. Width and
153/// precision formats (`%.3f`, `%+.13e`) are presentation of the same decimal
154/// value, so they stay numbers.
155fn is_radix_format(format: &str) -> bool {
156    let mut characters = format.chars();
157    while let Some(character) = characters.next() {
158        if character != '%' {
159            continue;
160        }
161        // Skip flags, width and precision to reach the conversion character.
162        for following in characters.by_ref() {
163            if following.is_ascii_alphabetic() {
164                return RADIX_CONVERSIONS.contains(&following);
165            }
166        }
167    }
168    false
169}
170
171/// Reading a `PropertyObject` tree out as data, and writing data back into one.
172///
173/// An extension trait rather than inherent methods, because this is an addition
174/// to the COM API rather than part of it: `PropertyObject` is defined in
175/// [`rs_teststand`], and only its own crate may add inherent methods to it. The
176/// split is deliberate, the binding mirrors TestStand™ and nothing else, so a
177/// consumer that never serializes anything carries no serde dependency.
178///
179/// ```no_run
180/// use rs_teststand::Engine;
181/// use rs_teststand_serde::PropertyObjectValue;
182///
183/// let engine = Engine::new()?;
184/// let json = serde_json::to_string_pretty(&engine.globals()?.to_value()?)?;
185/// # Ok::<(), Box<dyn std::error::Error>>(())
186/// ```
187pub trait PropertyObjectValue {
188    /// Walks this property into a [`PropertyValue`].
189    ///
190    /// # Errors
191    /// [`Error`] if any COM call fails.
192    fn to_value(&self) -> Result<PropertyValue, Error>;
193
194    /// Walks this property, refusing to descend past `max_depth` levels.
195    ///
196    /// Use this when the object might contain a cycle and you would rather
197    /// choose the limit yourself. [`to_value`](Self::to_value) is this with
198    /// [`DEFAULT_MAX_DEPTH`].
199    ///
200    /// # Errors
201    /// [`Error::RecursionLimit`] if the tree is deeper than `max_depth`, or
202    /// [`Error`] if any COM call fails.
203    fn to_value_with_depth(&self, max_depth: usize) -> Result<PropertyValue, Error>;
204
205    /// Rebuilds this container's contents from a [`PropertyValue`].
206    ///
207    /// # Errors
208    /// [`Error`] if `value` is not a container, or if any COM call fails.
209    fn apply_value(&self, value: &PropertyValue) -> Result<(), Error>;
210}
211
212impl PropertyObjectValue for PropertyObject {
213    /// Walks this property into a [`PropertyValue`].
214    ///
215    /// Arrays are read element by element, containers are recursed into, and a
216    /// scalar is read through the accessor its representation requires, an
217    /// `Int64` property is read as an integer rather than being forced through
218    /// the floating-point accessor, which the engine would refuse.
219    ///
220    /// # Errors
221    /// [`Error`] if any COM call fails.
222    fn to_value(&self) -> Result<PropertyValue, Error> {
223        self.to_value_with_depth(DEFAULT_MAX_DEPTH)
224    }
225
226    fn to_value_with_depth(&self, max_depth: usize) -> Result<PropertyValue, Error> {
227        walk(self, max_depth, "")
228    }
229
230    /// Rebuilds this container's contents from a [`PropertyValue`].
231    ///
232    /// Existing sub-properties of the same name are updated in place; the
233    /// container is not cleared first.
234    ///
235    /// # Errors
236    /// [`Error`] if `value` is not a container, or if any COM call fails.
237    fn apply_value(&self, value: &PropertyValue) -> Result<(), Error> {
238        let PropertyValue::Container(members) = value else {
239            return Err(Error::UnexpectedType {
240                expected: "Container",
241                actual: "scalar or array",
242            });
243        };
244        for (name, member) in members {
245            set_member(self, name, member)?;
246        }
247        Ok(())
248    }
249}
250
251/// Builds a value for an array, nesting one level per dimension.
252///
253/// Elements are stored **column-major**: the first index varies fastest, so
254/// a 10x2 array puts `[0][1]` at flat offset 10, not 1. Emitting them in
255/// storage order would transpose the result, so the offset is computed from
256/// the indices rather than walked linearly.
257fn array_to_value(object: &PropertyObject, lengths: &[i32]) -> Result<PropertyValue, Error> {
258    match lengths {
259        // Not an array after all, or a shape the engine did not report.
260        [] => Ok(PropertyValue::Array(Vec::new())),
261        [single] => {
262            let mut items = Vec::with_capacity((*single).max(0).try_into().unwrap_or(0));
263            for offset in 0..*single {
264                items.push(
265                    object
266                        .get_property_object_by_offset(offset, NO_OPTIONS)?
267                        .to_value()?,
268                );
269            }
270            Ok(PropertyValue::Array(items))
271        }
272        _ => {
273            let mut indices = vec![0_i32; lengths.len()];
274            nest(object, lengths, 0, &mut indices)
275        }
276    }
277}
278
279/// Recursively nests one dimension, outermost first.
280fn nest(
281    object: &PropertyObject,
282    lengths: &[i32],
283    depth: usize,
284    indices: &mut Vec<i32>,
285) -> Result<PropertyValue, Error> {
286    let Some(&length) = lengths.get(depth) else {
287        return Ok(PropertyValue::Array(Vec::new()));
288    };
289    let mut items = Vec::with_capacity(length.max(0).try_into().unwrap_or(0));
290    for index in 0..length {
291        if let Some(slot) = indices.get_mut(depth) {
292            *slot = index;
293        }
294        if depth + 1 == lengths.len() {
295            let offset = column_major_offset(lengths, indices);
296            items.push(
297                object
298                    .get_property_object_by_offset(offset, NO_OPTIONS)?
299                    .to_value()?,
300            );
301        } else {
302            items.push(nest(object, lengths, depth + 1, indices)?);
303        }
304    }
305    Ok(PropertyValue::Array(items))
306}
307
308/// Reads a numeric scalar, honouring its representation and display format.
309///
310/// Three cases the plain accessor cannot express:
311///
312/// * `NAN`, `INF` and `-INF` become [`PropertyValue::Null`], JSON cannot
313///   write them.
314/// * A value whose format selects a radix (`%x`, `%o`, `%b`) becomes the
315///   formatted string, so `10` under `%#x` serializes as `"0xa"` and the
316///   author's chosen base survives the round trip.
317/// * `Int64` and `UInt64` use their own accessors, which the engine
318///   requires.
319fn number_to_value(
320    object: &PropertyObject,
321    property_type: &rs_teststand::PropertyObjectType,
322) -> Result<PropertyValue, Error> {
323    match property_type.representation()? {
324        Ok(PropertyRepresentation::Int64) => {
325            return Ok(PropertyValue::Integer(
326                object.get_val_integer64("", NO_OPTIONS)?,
327            ));
328        }
329        Ok(PropertyRepresentation::UInt64) => {
330            return Ok(PropertyValue::Unsigned(
331                object.get_val_unsigned_integer64("", NO_OPTIONS)?,
332            ));
333        }
334        _ => {}
335    }
336
337    let number = object.get_val_number("", NO_OPTIONS)?;
338    // Covers NAN, IND and both infinities in one test: IND is a quiet NaN,
339    // so the engine's three special constants are all non-finite.
340    if !number.is_finite() {
341        return Ok(PropertyValue::Null);
342    }
343    if is_radix_format(&object.numeric_format()?) {
344        return Ok(PropertyValue::Text(
345            object.get_formatted_value("", NO_OPTIONS, "", true, "")?,
346        ));
347    }
348    Ok(PropertyValue::Number(number))
349}
350
351/// Creates or updates one sub-property from a value.
352fn set_member(object: &PropertyObject, name: &str, value: &PropertyValue) -> Result<(), Error> {
353    match value {
354        // A null restores the engine's own "not a number": that is what it
355        // came from, and what a consumer means by null in this position.
356        PropertyValue::Null => object.set_val_number(name, INSERT_IF_MISSING, f64::NAN),
357        PropertyValue::Bool(flag) => object.set_val_boolean(name, INSERT_IF_MISSING, *flag),
358        PropertyValue::Number(number) => object.set_val_number(name, INSERT_IF_MISSING, *number),
359        PropertyValue::Integer(number) => {
360            object.set_val_integer64(name, INSERT_IF_MISSING, *number)
361        }
362        PropertyValue::Unsigned(number) => {
363            object.set_val_unsigned_integer64(name, INSERT_IF_MISSING, *number)
364        }
365        // A radix string such as "0xa" came from a number, not a string, so
366        // it is parsed back rather than stored as text.
367        PropertyValue::Text(text) => parse_radix(text).map_or_else(
368            || object.set_val_string(name, INSERT_IF_MISSING, text),
369            |number| object.set_val_number(name, INSERT_IF_MISSING, number),
370        ),
371        PropertyValue::Container(_) => {
372            if !object.exists(name, NO_OPTIONS)? {
373                object.new_sub_property(
374                    name,
375                    PropValType::Container,
376                    false,
377                    "",
378                    INSERT_IF_MISSING,
379                )?;
380            }
381            object
382                .get_property_object(name, NO_OPTIONS)?
383                .apply_value(value)
384        }
385        PropertyValue::Array(items) => set_array_member(object, name, items),
386    }
387}
388
389/// Creates or updates an array sub-property and fills its elements.
390fn set_array_member(
391    object: &PropertyObject,
392    name: &str,
393    items: &[PropertyValue],
394) -> Result<(), Error> {
395    let element_type = items
396        .first()
397        .map_or(PropValType::Container, PropertyValue::creation_type);
398    if !object.exists(name, NO_OPTIONS)? {
399        object.new_sub_property(name, element_type, true, "", INSERT_IF_MISSING)?;
400    }
401    let array = object.get_property_object(name, NO_OPTIONS)?;
402    let count = i32::try_from(items.len()).map_err(|_| Error::UnexpectedType {
403        expected: "an array length within i32",
404        actual: "a longer array",
405    })?;
406    array.set_num_elements(count, NO_OPTIONS)?;
407
408    for (offset, item) in items.iter().enumerate() {
409        let offset = i32::try_from(offset).unwrap_or(i32::MAX);
410        let element = array.get_property_object_by_offset(offset, NO_OPTIONS)?;
411        match item {
412            PropertyValue::Container(_) => element.apply_value(item)?,
413            PropertyValue::Bool(flag) => element.set_val_boolean("", NO_OPTIONS, *flag)?,
414            PropertyValue::Number(number) => element.set_val_number("", NO_OPTIONS, *number)?,
415            PropertyValue::Integer(number) => {
416                element.set_val_integer64("", NO_OPTIONS, *number)?;
417            }
418            PropertyValue::Unsigned(number) => {
419                element.set_val_unsigned_integer64("", NO_OPTIONS, *number)?;
420            }
421            PropertyValue::Null => element.set_val_number("", NO_OPTIONS, f64::NAN)?,
422            PropertyValue::Text(text) => match parse_radix(text) {
423                Some(number) => element.set_val_number("", NO_OPTIONS, number)?,
424                None => element.set_val_string("", NO_OPTIONS, text)?,
425            },
426            PropertyValue::Array(_) => {
427                return Err(Error::UnexpectedType {
428                    expected: "a scalar or container array element",
429                    actual: "a nested array",
430                });
431            }
432        }
433    }
434    Ok(())
435}
436
437/// How deep [`PropertyObjectValue::to_value`] descends before giving up.
438///
439/// Generous for real data and far short of the stack. Nothing legitimate in a
440/// sequence file nests anywhere near this, so reaching it means a cycle.
441pub const DEFAULT_MAX_DEPTH: usize = 64;
442
443/// Walks one property, carrying the remaining budget and the path reached.
444///
445/// The budget is not defensive programming for its own sake. A live
446/// `SequenceContext` reports `ThisContext` among its own sub-properties, so it
447/// contains itself, and posting one to a user interface is an ordinary thing
448/// for a sequence to do. With
449/// no limit, walking one recursed until the stack was exhausted and the process
450/// died with nothing to catch. Now it returns [`Error::RecursionLimit`] naming
451/// the path, and a caller walks a named subtree instead.
452fn walk(object: &PropertyObject, remaining: usize, path: &str) -> Result<PropertyValue, Error> {
453    // Spend a level before doing anything, so the check runs once per node and
454    // reports the node that exhausted the budget.
455    let Some(remaining) = remaining.checked_sub(1) else {
456        return Err(Error::RecursionLimit {
457            path: path.to_owned(),
458            limit: DEFAULT_MAX_DEPTH,
459        });
460    };
461    let property_type = object.property_type()?;
462    let value_type = property_type.value_type()?;
463
464    // Arrays first: an array of numbers still reports Number as its type.
465    if matches!(value_type, Ok(PropValType::Array)) {
466        let lengths = property_type.array_dimensions()?.lengths()?;
467        return array_to_value(object, &lengths);
468    }
469
470    // A container, or anything that behaves like one (a named type instance
471    // reports its own type but is still a bag of fields).
472    let sub_properties = object.get_num_sub_properties("")?;
473    if matches!(value_type, Ok(PropValType::Container)) || sub_properties > 0 {
474        let mut members = BTreeMap::new();
475        for index in 0..sub_properties {
476            let name = object.get_nth_sub_property_name("", index, NO_OPTIONS)?;
477            let child = object.get_property_object(&name, NO_OPTIONS)?;
478            let child_path = if path.is_empty() {
479                name.clone()
480            } else {
481                format!("{path}.{name}")
482            };
483            let walked = walk(&child, remaining, &child_path)?;
484            members.insert(name, walked);
485        }
486        return Ok(PropertyValue::Container(members));
487    }
488
489    match value_type {
490        Ok(PropValType::Boolean) => {
491            Ok(PropertyValue::Bool(object.get_val_boolean("", NO_OPTIONS)?))
492        }
493        Ok(PropValType::Number) => number_to_value(object, &property_type),
494        // Strings read directly. Anything else that is still a leaf, // an object reference, an enumeration, is not a string and
495        // `GetValString` refuses it, so fall back to the formatted value,
496        // which the engine guarantees to produce for any object ("Nothing"
497        // for an empty reference).
498        Ok(PropValType::String) => Ok(PropertyValue::Text(object.get_val_string("", NO_OPTIONS)?)),
499        _ => Ok(PropertyValue::Text(
500            object
501                .get_val_string("", NO_OPTIONS)
502                .or_else(|_| object.get_formatted_value("", 0, "", true, ", "))?,
503        )),
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use std::collections::BTreeMap;
510
511    use super::PropertyValue;
512
513    type JsonResult<T> = Result<T, serde_json::Error>;
514
515    fn json(value: &PropertyValue) -> JsonResult<String> {
516        serde_json::to_string(value)
517    }
518
519    fn parse(text: &str) -> JsonResult<PropertyValue> {
520        serde_json::from_str(text)
521    }
522
523    #[test]
524    fn scalars_serialize_as_plain_json() -> JsonResult<()> {
525        // Untagged: no wrapper keys, so the output is ordinary data.
526        assert_eq!(json(&PropertyValue::Bool(true))?, "true");
527        assert_eq!(
528            json(&PropertyValue::Text("SN-001".to_owned()))?,
529            "\"SN-001\""
530        );
531        assert_eq!(json(&PropertyValue::Number(1.5))?, "1.5");
532        assert_eq!(json(&PropertyValue::Integer(-7))?, "-7");
533        Ok(())
534    }
535
536    #[test]
537    fn an_integral_number_parses_as_integer_not_float() -> JsonResult<()> {
538        // Variant order decides this. Reading 42 as a float would lose the
539        // exactness the integer representations exist to provide.
540        assert_eq!(parse("42")?, PropertyValue::Integer(42));
541        assert_eq!(parse("-42")?, PropertyValue::Integer(-42));
542        Ok(())
543    }
544
545    #[test]
546    fn a_value_beyond_i64_falls_through_to_unsigned() -> JsonResult<()> {
547        // u64::MAX does not fit in i64, so Integer must fail and Unsigned catch
548        // it, exactly the case a double would corrupt.
549        assert_eq!(
550            parse("18446744073709551615")?,
551            PropertyValue::Unsigned(u64::MAX)
552        );
553        Ok(())
554    }
555
556    #[test]
557    fn a_fractional_number_reaches_the_float_variant() -> JsonResult<()> {
558        assert_eq!(parse("1.5")?, PropertyValue::Number(1.5));
559        Ok(())
560    }
561
562    #[test]
563    fn the_64bit_extremes_survive_a_json_round_trip() -> JsonResult<()> {
564        for value in [
565            PropertyValue::Integer(i64::MIN),
566            PropertyValue::Integer(i64::MAX),
567            PropertyValue::Unsigned(u64::MAX),
568        ] {
569            assert_eq!(parse(&json(&value)?)?, value, "lost {value:?}");
570        }
571        Ok(())
572    }
573
574    #[test]
575    fn a_container_round_trips_with_stable_key_order() -> JsonResult<()> {
576        let mut members = BTreeMap::new();
577        members.insert("Zebra".to_owned(), PropertyValue::Integer(1));
578        members.insert("Alpha".to_owned(), PropertyValue::Bool(false));
579        let value = PropertyValue::Container(members);
580
581        // BTreeMap keeps keys sorted, so serialized output is diffable.
582        assert_eq!(json(&value)?, r#"{"Alpha":false,"Zebra":1}"#);
583        assert_eq!(parse(&json(&value)?)?, value);
584        Ok(())
585    }
586
587    #[test]
588    fn nested_arrays_and_containers_round_trip() -> JsonResult<()> {
589        let mut inner = BTreeMap::new();
590        inner.insert("Mode".to_owned(), PropertyValue::Text("Voltage".to_owned()));
591        let mut outer = BTreeMap::new();
592        outer.insert(
593            "Readings".to_owned(),
594            PropertyValue::Array(vec![PropertyValue::Number(1.5), PropertyValue::Number(2.5)]),
595        );
596        outer.insert("Instrument".to_owned(), PropertyValue::Container(inner));
597        let value = PropertyValue::Container(outer);
598        assert_eq!(parse(&json(&value)?)?, value);
599        Ok(())
600    }
601}
602
603#[cfg(test)]
604mod representation_tests {
605    use super::PropertyValue;
606
607    /// Pins the documented limitation so it stays a known trade-off rather than
608    /// becoming a surprise.
609    #[test]
610    fn json_collapses_unsigned_into_signed_where_the_value_fits() -> Result<(), serde_json::Error> {
611        let unsigned = PropertyValue::Unsigned(0);
612        let text = serde_json::to_string(&unsigned)?;
613        let parsed: PropertyValue = serde_json::from_str(&text)?;
614        // The number survives; the storage choice does not.
615        assert_eq!(parsed, PropertyValue::Integer(0));
616        assert_ne!(parsed, unsigned);
617        Ok(())
618    }
619
620    #[test]
621    fn a_value_above_i64_max_keeps_its_unsigned_identity() -> Result<(), serde_json::Error> {
622        // Unambiguous: no signed variant can hold it, so nothing is lost.
623        let unsigned = PropertyValue::Unsigned(u64::MAX);
624        let parsed: PropertyValue = serde_json::from_str(&serde_json::to_string(&unsigned)?)?;
625        assert_eq!(parsed, unsigned);
626        Ok(())
627    }
628}