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}