Skip to main content

forma_ir/
slot.rs

1use crate::format::{IrError, SlotType};
2use crate::parser::{IrModule, SlotTable};
3use std::borrow::Cow;
4use std::sync::Arc;
5
6/// Runtime values populated by page handlers before IR walking.
7///
8/// `Array` and `Object` payloads are `Arc`-backed so that handing one item of a
9/// list to the walker is a refcount bump rather than a deep copy of the whole
10/// tree. `Arc` rather than `Rc` because `SlotData` is borrowed into page
11/// configs inside async handlers and must stay `Send + Sync`.
12#[derive(Debug, Clone)]
13pub enum SlotValue {
14    /// No value. Renders as empty text and is falsy, like JS `null`.
15    Null,
16    /// A string, emitted HTML-escaped.
17    Text(String),
18    /// A boolean. As a DYN_ATTR value, `true` emits presence on an HTML
19    /// boolean attribute and the word "true" on any other attribute;
20    /// `false` omits the attribute entirely.
21    Bool(bool),
22    /// A double, rendered exactly as ECMAScript's `Number::toString` would.
23    Number(f64),
24    /// An array. The only value a LIST can iterate; `Arc` so binding one item
25    /// per row is a refcount bump, not a deep copy.
26    Array(Arc<[SlotValue]>),
27    /// A key/value object. The only value PROP can read a property from.
28    Object(Arc<[(String, SlotValue)]>),
29}
30
31/// A static Null reference returned for out-of-bounds slot lookups.
32static NULL_SLOT: SlotValue = SlotValue::Null;
33
34/// The exclusive integer fast-path boundary for JSON transport (2^53).
35const JS_SAFE_INTEGER_LIMIT: f64 = 9007199254740992.0;
36
37/// Format an `f64` exactly the way ECMAScript's `Number::toString` does.
38///
39/// Shortest-round-trip alone is insufficient: Rust's `LowerExp` chooses a
40/// different decimal for exact ties such as -270129723325078.625. Ryū-js
41/// implements ECMAScript's tie choice and exponent boundaries, including
42/// negative zero and non-finite values, for both text and attributes.
43fn format_js_number(n: f64) -> String {
44    ryu_js::Buffer::new().format(n).to_owned()
45}
46
47impl SlotValue {
48    /// The same text as `to_text`, borrowing scalars or a caller's number buffer.
49    /// Arrays retain the existing join conversion so normalization still applies
50    /// once to the complete value, including CRLF across nested array elements.
51    pub(crate) fn text_ref<'a>(&'a self, number: &'a mut ryu_js::Buffer) -> Cow<'a, str> {
52        match self {
53            Self::Null => Cow::Borrowed(""),
54            Self::Text(text) => Cow::Borrowed(text),
55            Self::Bool(true) => Cow::Borrowed("true"),
56            Self::Bool(false) => Cow::Borrowed("false"),
57            Self::Number(value) => Cow::Borrowed(number.format(*value)),
58            Self::Object(_) => Cow::Borrowed("[object Object]"),
59            Self::Array(_) => Cow::Owned(self.to_text()),
60        }
61    }
62
63    /// Render to string for DYN_TEXT output.
64    ///
65    /// Numbers follow ECMAScript `Number::toString` so the SSR text node
66    /// matches the client renderer exactly. Note this also drives SWITCH case
67    /// comparison, whose case strings are authored on the JS side.
68    pub fn to_text(&self) -> String {
69        match self {
70            SlotValue::Null => String::new(),
71            SlotValue::Text(s) => s.clone(),
72            SlotValue::Bool(true) => "true".to_string(),
73            SlotValue::Bool(false) => "false".to_string(),
74            SlotValue::Number(n) => format_js_number(*n),
75            // `String(["a","b"])` is "a,b", not "[Array]" — the client
76            // renderer produces the join, so anything else is a visible
77            // SSR→hydration flip on the text node. Nesting flattens because
78            // `Array.prototype.join` calls `toString` on each element, which
79            // for an array is `join` again; `null`/`undefined` become "".
80            SlotValue::Array(items) => {
81                let mut out = String::new();
82                for (i, item) in items.iter().enumerate() {
83                    if i > 0 {
84                        out.push(',');
85                    }
86                    out.push_str(&item.to_text());
87                }
88                out
89            }
90            // `String({})` — every plain object, whatever its keys.
91            SlotValue::Object(_) => "[object Object]".to_string(),
92        }
93    }
94
95    /// Build an `Array` value from a `Vec`.
96    ///
97    /// Convenience constructor for the `Arc`-backed payload — prefer it over
98    /// naming the variant so call sites do not have to spell the conversion.
99    pub fn array(items: Vec<SlotValue>) -> Self {
100        SlotValue::Array(items.into())
101    }
102
103    /// Build an `Object` value from a `Vec` of key/value pairs.
104    pub fn object(pairs: Vec<(String, SlotValue)>) -> Self {
105        SlotValue::Object(pairs.into())
106    }
107
108    /// Truthiness for SHOW_IF evaluation, following ECMAScript `ToBoolean`.
109    ///
110    /// Every object is truthy in JS, empty arrays included (`!![]` is `true`).
111    /// The server used to call an empty array falsy, so a SHOW_IF bound
112    /// straight to a list slot hid its block during SSR and then un-hid it the
113    /// moment the client evaluated the same condition — a guaranteed visible
114    /// flip on the empty-list case, which is exactly the state a fresh page
115    /// starts in.
116    ///
117    /// NaN is falsy (`n != 0.0` is true for NaN), matching `ToBoolean`.
118    ///
119    /// Verified by: crates/forma-ir/src/slot.rs > "as_bool_matches_javascript_tobool"
120    pub fn as_bool(&self) -> bool {
121        match self {
122            SlotValue::Null => false,
123            SlotValue::Bool(b) => *b,
124            SlotValue::Text(s) => !s.is_empty(),
125            SlotValue::Number(n) => *n != 0.0 && !n.is_nan(),
126            SlotValue::Array(_) | SlotValue::Object(_) => true,
127        }
128    }
129
130    /// Extract a named property from an Object. Returns Null if not Object or key not found.
131    pub fn get_property(&self, name: &str) -> SlotValue {
132        match self {
133            SlotValue::Object(pairs) => {
134                for (k, v) in pairs.iter() {
135                    if k == name {
136                        return v.clone();
137                    }
138                }
139                SlotValue::Null
140            }
141            _ => SlotValue::Null,
142        }
143    }
144
145    /// For LIST iteration — returns the inner slice if Array, None otherwise.
146    pub fn as_array(&self) -> Option<&[SlotValue]> {
147        match self {
148            SlotValue::Array(a) => Some(a),
149            _ => None,
150        }
151    }
152
153    /// Borrow text without cloning. Returns the inner &str for Text variant,
154    /// "" for all others (Number requires allocation via to_text()).
155    pub fn as_text_ref(&self) -> &str {
156        match self {
157            SlotValue::Text(s) => s.as_str(),
158            _ => "",
159        }
160    }
161
162    /// Convert to a serde_json::Value for props serialization.
163    ///
164    /// Non-finite numbers become `null`, which is not a defect but exact
165    /// parity: `JSON.stringify(Infinity)` is `null` in JS too, so a props
166    /// payload matches what a formajs SSR render would produce even though
167    /// [`SlotValue::to_text`] says `"Infinity"` for the same value.
168    ///
169    /// The serialized *text* is not byte-identical to `JSON.stringify` (serde
170    /// emits `1e16` where JS emits `10000000000000000`); the parsed values are.
171    /// Props are always consumed via `JSON.parse`, never string-compared.
172    pub fn to_json(&self) -> serde_json::Value {
173        match self {
174            SlotValue::Null => serde_json::Value::Null,
175            SlotValue::Text(s) => serde_json::Value::String(s.clone()),
176            SlotValue::Bool(b) => serde_json::Value::Bool(*b),
177            SlotValue::Number(n) => {
178                // Only take the integer shortcut inside the JS safe range —
179                // `*n as i64` saturates above 2^63 and would silently corrupt
180                // the value.
181                if n.fract() == 0.0 && n.abs() < JS_SAFE_INTEGER_LIMIT {
182                    serde_json::Value::Number(serde_json::Number::from(*n as i64))
183                } else {
184                    serde_json::Number::from_f64(*n)
185                        .map(serde_json::Value::Number)
186                        .unwrap_or(serde_json::Value::Null)
187                }
188            }
189            SlotValue::Array(items) => {
190                serde_json::Value::Array(items.iter().map(|v| v.to_json()).collect())
191            }
192            SlotValue::Object(pairs) => {
193                let map: serde_json::Map<String, serde_json::Value> = pairs
194                    .iter()
195                    .map(|(k, v)| (k.clone(), v.to_json()))
196                    .collect();
197                serde_json::Value::Object(map)
198            }
199        }
200    }
201}
202
203/// Dense Vec indexed by slot_id — the bridge between Rust page handlers and the IR walker.
204///
205/// Handlers create a SlotData, populate it with values (title, user name, nav items, etc.),
206/// then the walker reads slot values when it encounters DYN_TEXT, DYN_ATTR, SHOW_IF, LIST opcodes.
207#[derive(Debug, Clone)]
208pub struct SlotData {
209    slots: Vec<SlotValue>,
210}
211
212impl SlotData {
213    /// Create a new SlotData with `capacity` Null-initialized slots.
214    ///
215    /// This is the only constructor that can be undersized relative to a
216    /// module's slot table. Prefer [`SlotData::new_from_defaults`] or
217    /// [`SlotData::from_json`], which size themselves from the module; `new`
218    /// is for tests and for callers that know their own slot count.
219    pub fn new(capacity: usize) -> Self {
220        let mut slots = Vec::with_capacity(capacity);
221        slots.resize_with(capacity, || SlotValue::Null);
222        Self { slots }
223    }
224
225    /// Set a slot value, growing the backing storage if `slot_id` is past the
226    /// end (new entries are `Null`).
227    ///
228    /// This used to be a silent no-op when out of bounds, which made a LIST
229    /// whose `item_slot_id` exceeded the SlotData length render every row
230    /// identically with no error at all. Growing is cheap, and a write outside
231    /// the module's declared slot table cannot reach here from a parsed module
232    /// — `IrModule::validate` rejects an opcode naming an undeclared slot at
233    /// parse time — so the branch only catches a caller that sized its own
234    /// `SlotData` too small, and warns under the `tracing` feature.
235    ///
236    /// Verified by: crates/forma-ir/src/slot.rs > "set_grows_instead_of_silently_dropping"
237    /// Verified by: crates/forma-ir/src/parser.rs > "validate_rejects_list_item_slot_not_in_table"
238    pub fn set(&mut self, slot_id: u16, value: SlotValue) {
239        self.replace(slot_id, value);
240    }
241
242    /// Set a slot value and return the previous one.
243    ///
244    /// This is the primitive the walker's LIST loop is built on: it binds the
245    /// row's item (and any PROP the body extracts from it) by swapping values
246    /// in, then swaps the saved originals back when the row ends. That keeps
247    /// per-row scoping without the deep `SlotData` clone the loop used to take,
248    /// which made a list cost O(rows x slots) instead of O(rows).
249    ///
250    /// Verified by: crates/forma-ir/tests/list_scaling.rs > "list_walk_allocations_are_linear_in_row_count"
251    pub fn replace(&mut self, slot_id: u16, value: SlotValue) -> SlotValue {
252        let idx = slot_id as usize;
253        if idx >= self.slots.len() {
254            #[cfg(feature = "tracing")]
255            tracing::warn!(
256                slot_id = slot_id,
257                len = self.slots.len(),
258                "Slot write past the end of SlotData — growing (SlotData sized smaller than the module's slot table)"
259            );
260            self.slots.resize_with(idx + 1, || SlotValue::Null);
261        }
262        std::mem::replace(&mut self.slots[idx], value)
263    }
264
265    /// Get a reference to a slot value. Returns &Null if out of bounds.
266    pub fn get(&self, slot_id: u16) -> &SlotValue {
267        let idx = slot_id as usize;
268        self.slots.get(idx).unwrap_or(&NULL_SLOT)
269    }
270
271    /// Get the text content of a slot. Returns Some(&str) if Text, None otherwise.
272    pub fn get_text(&self, slot_id: u16) -> Option<&str> {
273        match self.get(slot_id) {
274            SlotValue::Text(s) => Some(s.as_str()),
275            _ => None,
276        }
277    }
278
279    /// Create SlotData from a named-key JSON string, resolving names to slot IDs
280    /// via the module's string table and slot table.
281    ///
282    /// The JSON must be an object (e.g., `{"title": "Hello", "count": 42}`).
283    /// Each key is resolved against the slot table: the slot entry's `name_str_idx`
284    /// is looked up in the string table to find the slot name. If it matches a JSON
285    /// key, the JSON value is converted to a `SlotValue` and stored at that slot_id.
286    ///
287    /// Unknown JSON keys (not in the slot table) are silently ignored.
288    /// Starts from slot table defaults so missing keys retain their defaults.
289    /// JSON numbers become IEEE-754 `f64` values. Decimal spellings use
290    /// correctly rounded parsing, so finite `f64` values serialized to JSON
291    /// preserve their bits, including negative zero. This does not make all
292    /// integers above 2^53 exact. Island state serialization subsequently
293    /// canonicalizes negative zero to zero under the Serialize9 contract.
294    pub fn from_json(json_str: &str, module: &IrModule) -> Result<Self, IrError> {
295        let parsed: serde_json::Value =
296            serde_json::from_str(json_str).map_err(|e| IrError::JsonParseError(e.to_string()))?;
297
298        let obj = match parsed {
299            serde_json::Value::Object(map) => map,
300            _ => return Err(IrError::JsonParseError("expected JSON object".to_string())),
301        };
302
303        // Build name → slot_id map from the slot table + string table.
304        //
305        // Slot names are not unique (see `IrModule::slot_id_by_name`), and the
306        // first entry in table order wins — `insert` alone would have made the
307        // LAST one win here while `slot_id_by_name` returned the first, so a
308        // handler setting a value by name filled a different slot than a
309        // lookup by the same name resolved to.
310        let mut name_to_slot: std::collections::HashMap<&str, u16> =
311            std::collections::HashMap::new();
312        for entry in module.slots.entries() {
313            if let Ok(name) = module.strings.get(entry.name_str_idx) {
314                name_to_slot.entry(name).or_insert(entry.slot_id);
315            }
316        }
317
318        // Start from defaults
319        let mut data = Self::new_from_defaults(&module.slots);
320
321        // Override with JSON values
322        for (key, value) in &obj {
323            if let Some(&slot_id) = name_to_slot.get(key.as_str()) {
324                data.set(slot_id, json_to_slot_value(value));
325            }
326            // Unknown keys silently ignored
327        }
328
329        Ok(data)
330    }
331
332    pub(crate) fn apply_module_constants(&mut self, module: &IrModule) {
333        if let Some(contracts) = &module.contracts {
334            for (&id, value) in &contracts.constants {
335                self.replace(id, value.clone());
336            }
337        }
338    }
339
340    /// Create SlotData pre-populated from the IR slot table defaults.
341    /// Server-sourced slots with no default -> Null.
342    /// Client-sourced slots with defaults -> parsed from default_bytes.
343    pub fn new_from_defaults(table: &SlotTable) -> Self {
344        let entries = table.entries();
345        let capacity = entries
346            .iter()
347            .map(|e| e.slot_id as usize + 1)
348            .max()
349            .unwrap_or(0);
350        let mut slots = Vec::with_capacity(capacity);
351        slots.resize_with(capacity, || SlotValue::Null);
352
353        for entry in entries {
354            let idx = entry.slot_id as usize;
355            if idx >= slots.len() {
356                continue;
357            }
358            if entry.default_bytes.is_empty() {
359                continue;
360            }
361
362            let default_str = std::str::from_utf8(&entry.default_bytes).unwrap_or("");
363            let value = match entry.type_hint {
364                SlotType::Bool => match default_str {
365                    "true" => SlotValue::Bool(true),
366                    "false" => SlotValue::Bool(false),
367                    _ => SlotValue::Null,
368                },
369                SlotType::Text => SlotValue::Text(default_str.to_string()),
370                SlotType::Number => default_str
371                    .parse::<f64>()
372                    .map(SlotValue::Number)
373                    .unwrap_or(SlotValue::Null),
374                SlotType::Array => {
375                    if default_str == "[]" {
376                        SlotValue::array(vec![])
377                    } else {
378                        SlotValue::Null
379                    }
380                }
381                SlotType::Object => SlotValue::Null,
382            };
383            slots[idx] = value;
384        }
385
386        Self { slots }
387    }
388}
389
390/// Convert a `serde_json::Value` to a `SlotValue` recursively.
391///
392/// Type mapping:
393/// - null → Null
394/// - string → Text
395/// - bool → Bool
396/// - number → Number (as f64)
397/// - array → Array (each element converted recursively)
398/// - object → Object (each key-value pair converted recursively)
399pub fn json_to_slot_value(value: &serde_json::Value) -> SlotValue {
400    match value {
401        serde_json::Value::Null => SlotValue::Null,
402        serde_json::Value::String(s) => SlotValue::Text(s.clone()),
403        serde_json::Value::Bool(b) => SlotValue::Bool(*b),
404        serde_json::Value::Number(n) => SlotValue::Number(n.as_f64().unwrap_or(0.0)),
405        serde_json::Value::Array(arr) => {
406            SlotValue::Array(arr.iter().map(json_to_slot_value).collect())
407        }
408        serde_json::Value::Object(map) => SlotValue::Object(
409            map.iter()
410                .map(|(k, v)| (k.clone(), json_to_slot_value(v)))
411                .collect(),
412        ),
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::parser::{IrModule, SlotTable};
420
421    /// Build valid v2 slot table binary data from a slice of
422    /// (slot_id, name_str_idx, type_hint_byte, source_byte, default_bytes).
423    fn build_slot_table_bytes(entries: &[(u16, u32, u8, u8, &[u8])]) -> Vec<u8> {
424        let mut buf = Vec::new();
425        buf.extend_from_slice(&(entries.len() as u16).to_le_bytes());
426        for &(slot_id, name_str_idx, type_hint, source, default_bytes) in entries {
427            buf.extend_from_slice(&slot_id.to_le_bytes());
428            buf.extend_from_slice(&name_str_idx.to_le_bytes());
429            buf.push(type_hint);
430            buf.push(source);
431            buf.extend_from_slice(&(default_bytes.len() as u16).to_le_bytes());
432            buf.extend_from_slice(default_bytes);
433        }
434        buf
435    }
436
437    #[test]
438    fn slot_value_text_to_text() {
439        assert_eq!(SlotValue::Text("hello".to_string()).to_text(), "hello");
440    }
441
442    #[test]
443    // Spell the exact binary value whose shortest decimal is an ECMAScript tie.
444    #[allow(clippy::excessive_precision)]
445    fn borrowed_scalar_text_preserves_ecmascript_conversion() {
446        let values = vec![
447            SlotValue::Null,
448            SlotValue::Text("\0\r\n<&>\"é🦀".into()),
449            SlotValue::Bool(true),
450            SlotValue::Bool(false),
451            SlotValue::Number(-0.0),
452            SlotValue::Number(f64::NAN),
453            SlotValue::Number(f64::INFINITY),
454            SlotValue::Number(f64::NEG_INFINITY),
455            SlotValue::Number(1e-7),
456            SlotValue::Number(1e21),
457            SlotValue::Number(-270129723325078.625),
458            SlotValue::object(vec![]),
459            SlotValue::array(vec![
460                SlotValue::Text("\r".into()),
461                SlotValue::array(vec![SlotValue::Null, SlotValue::Text("\n".into())]),
462                SlotValue::Number(42.0),
463            ]),
464        ];
465        for value in values {
466            let mut number = ryu_js::Buffer::new();
467            let borrowed = value.text_ref(&mut number);
468            assert_eq!(borrowed, value.to_text(), "{value:?}");
469            assert_eq!(
470                matches!(borrowed, Cow::Owned(_)),
471                matches!(value, SlotValue::Array(_))
472            );
473        }
474    }
475
476    #[test]
477    fn slot_value_bool_to_text() {
478        assert_eq!(SlotValue::Bool(true).to_text(), "true");
479        assert_eq!(SlotValue::Bool(false).to_text(), "false");
480    }
481
482    #[test]
483    fn slot_value_number_integer() {
484        assert_eq!(SlotValue::Number(42.0).to_text(), "42");
485    }
486
487    #[test]
488    fn slot_value_number_float() {
489        assert_eq!(SlotValue::Number(3.15).to_text(), "3.15");
490    }
491
492    #[test]
493    fn slot_value_null_to_text() {
494        assert_eq!(SlotValue::Null.to_text(), "");
495    }
496
497    #[test]
498    fn as_bool_matches_javascript_tobool() {
499        // Each row is (value, exactly what `Boolean(value)` gives in JS). The
500        // SSR condition has to agree with the client's, or the block flips
501        // visible/hidden the moment hydration runs.
502        assert!(SlotValue::Text("x".to_string()).as_bool());
503        assert!(SlotValue::Number(1.0).as_bool());
504        assert!(SlotValue::Number(-1.0).as_bool());
505        assert!(SlotValue::Bool(true).as_bool());
506        assert!(SlotValue::array(vec![SlotValue::Null]).as_bool());
507        assert!(SlotValue::object(vec![]).as_bool());
508        // `!![]` is true. This is the row the old `!a.is_empty()` got wrong,
509        // and an empty list is the state a fresh page starts in.
510        assert!(SlotValue::array(vec![]).as_bool());
511
512        assert!(!SlotValue::Null.as_bool());
513        assert!(!SlotValue::Text("".to_string()).as_bool());
514        assert!(!SlotValue::Number(0.0).as_bool());
515        assert!(!SlotValue::Number(-0.0).as_bool());
516        assert!(!SlotValue::Number(f64::NAN).as_bool());
517        assert!(!SlotValue::Bool(false).as_bool());
518    }
519
520    #[test]
521    fn to_text_matches_javascript_string_for_containers() {
522        // `String(value)` in JS, for the container variants.
523        assert_eq!(SlotValue::array(vec![]).to_text(), "");
524        assert_eq!(
525            SlotValue::array(vec![
526                SlotValue::Text("a".into()),
527                SlotValue::Text("b".into())
528            ])
529            .to_text(),
530            "a,b"
531        );
532        // null and undefined join as empty strings, not as "null".
533        assert_eq!(
534            SlotValue::array(vec![
535                SlotValue::Null,
536                SlotValue::Number(1.0),
537                SlotValue::Null
538            ])
539            .to_text(),
540            ",1,"
541        );
542        // Nested arrays flatten, because join calls toString on each element.
543        assert_eq!(
544            SlotValue::array(vec![
545                SlotValue::array(vec![SlotValue::Number(1.0), SlotValue::Number(2.0)]),
546                SlotValue::array(vec![SlotValue::Number(3.0)]),
547            ])
548            .to_text(),
549            "1,2,3"
550        );
551        assert_eq!(SlotValue::object(vec![]).to_text(), "[object Object]");
552        assert_eq!(
553            SlotValue::array(vec![SlotValue::object(vec![])]).to_text(),
554            "[object Object]"
555        );
556    }
557
558    #[test]
559    fn slot_value_as_array() {
560        let arr = SlotValue::array(vec![SlotValue::Text("a".to_string())]);
561        assert!(arr.as_array().is_some());
562        assert_eq!(arr.as_array().unwrap().len(), 1);
563
564        assert!(SlotValue::Null.as_array().is_none());
565        assert!(SlotValue::Text("x".to_string()).as_array().is_none());
566        assert!(SlotValue::Number(1.0).as_array().is_none());
567        assert!(SlotValue::Bool(true).as_array().is_none());
568        assert!(SlotValue::object(vec![]).as_array().is_none());
569    }
570
571    #[test]
572    fn slot_data_set_and_get() {
573        let mut data = SlotData::new(4);
574        data.set(0, SlotValue::Text("title".to_string()));
575        data.set(2, SlotValue::Number(99.0));
576
577        assert_eq!(data.get(0).to_text(), "title");
578        assert_eq!(data.get(2).to_text(), "99");
579        // Unset slots remain Null
580        assert_eq!(data.get(1).to_text(), "");
581    }
582
583    #[test]
584    fn slot_data_out_of_bounds() {
585        let data = SlotData::new(2);
586        // Beyond capacity returns Null
587        assert_eq!(data.get(5).to_text(), "");
588        assert!(!data.get(100).as_bool());
589    }
590
591    #[test]
592    fn slot_data_get_text() {
593        let mut data = SlotData::new(3);
594        data.set(0, SlotValue::Text("hello".to_string()));
595        data.set(1, SlotValue::Number(42.0));
596
597        assert_eq!(data.get_text(0), Some("hello"));
598        assert_eq!(data.get_text(1), None); // Number, not Text
599        assert_eq!(data.get_text(2), None); // Null, not Text
600    }
601
602    // -- new_from_defaults tests --------------------------------------------
603
604    #[test]
605    fn slot_data_new_from_defaults_basic() {
606        // Build a v2 slot table with 3 slots:
607        // Slot 0: Array, Server, default "[]"
608        // Slot 1: Bool, Client, default "false"
609        // Slot 2: Text, Client, default "hello"
610        let bytes = build_slot_table_bytes(&[
611            (0, 0, 0x04, 0x00, b"[]"),    // Array, Server, default "[]"
612            (1, 1, 0x02, 0x01, b"false"), // Bool, Client, default "false"
613            (2, 2, 0x01, 0x01, b"hello"), // Text, Client, default "hello"
614        ]);
615        let table = SlotTable::parse(&bytes).unwrap();
616        let data = SlotData::new_from_defaults(&table);
617
618        // Slot 0: Array with default "[]" -> empty array
619        assert!(matches!(data.get(0), SlotValue::Array(v) if v.is_empty()));
620        // Slot 1: Bool with default "false" -> Bool(false)
621        assert!(matches!(data.get(1), SlotValue::Bool(false)));
622        // Slot 2: Text with default "hello" -> Text("hello")
623        assert_eq!(data.get_text(2), Some("hello"));
624    }
625
626    #[test]
627    fn slot_data_new_from_defaults_empty_table() {
628        // Empty slot table -> empty SlotData
629        let bytes = build_slot_table_bytes(&[]);
630        let table = SlotTable::parse(&bytes).unwrap();
631        let data = SlotData::new_from_defaults(&table);
632
633        // Out of bounds returns Null
634        assert!(matches!(data.get(0), SlotValue::Null));
635    }
636
637    #[test]
638    fn slot_data_new_from_defaults_no_default_bytes() {
639        // Slot with empty default_bytes -> Null
640        let bytes = build_slot_table_bytes(&[
641            (0, 0, 0x01, 0x00, b""), // Text, Server, no default
642            (1, 1, 0x03, 0x01, b""), // Number, Client, no default
643        ]);
644        let table = SlotTable::parse(&bytes).unwrap();
645        let data = SlotData::new_from_defaults(&table);
646
647        assert!(matches!(data.get(0), SlotValue::Null));
648        assert!(matches!(data.get(1), SlotValue::Null));
649    }
650
651    #[test]
652    fn slot_data_new_from_defaults_number() {
653        // Verify Number parsing
654        let bytes = build_slot_table_bytes(&[
655            (0, 0, 0x03, 0x00, b"42"),   // Number, Server, default "42"
656            (1, 1, 0x03, 0x01, b"3.15"), // Number, Client, default "3.15"
657        ]);
658        let table = SlotTable::parse(&bytes).unwrap();
659        let data = SlotData::new_from_defaults(&table);
660
661        assert!(matches!(data.get(0), SlotValue::Number(n) if (*n - 42.0).abs() < f64::EPSILON));
662        assert!(matches!(data.get(1), SlotValue::Number(n) if (*n - 3.15).abs() < f64::EPSILON));
663        let bad = build_slot_table_bytes(&[(2, 2, 0x03, 0, b"nope")]);
664        assert_eq!(
665            SlotTable::parse(&bad).unwrap_err(),
666            IrError::InvalidDefault(2)
667        );
668    }
669
670    #[test]
671    fn slot_data_new_from_defaults_bool_true() {
672        let bytes = build_slot_table_bytes(&[
673            (0, 0, 0x02, 0x00, b"true"), // Bool, Server, default "true"
674        ]);
675        let table = SlotTable::parse(&bytes).unwrap();
676        let data = SlotData::new_from_defaults(&table);
677
678        assert!(matches!(data.get(0), SlotValue::Bool(true)));
679    }
680
681    #[test]
682    fn slot_data_nonempty_object_default_is_refused_at_load() {
683        let bytes = build_slot_table_bytes(&[(0, 0, 0x05, 0, b"{}")]);
684        assert_eq!(
685            SlotTable::parse(&bytes).unwrap_err(),
686            IrError::InvalidDefault(0)
687        );
688    }
689
690    // -- from_json tests ---------------------------------------------------
691
692    use crate::parser::test_helpers::{build_minimal_ir, encode_text};
693
694    /// Helper to build an IrModule with slots whose names are in the string table.
695    fn build_module_for_json(
696        strings: &[&str],
697        slot_decls: &[(u16, u32, u8, u8, &[u8])],
698    ) -> IrModule {
699        // Minimal opcodes: just a TEXT referencing string 0
700        let opcodes = encode_text(0);
701        let data = build_minimal_ir(strings, slot_decls, &opcodes, &[]);
702        IrModule::parse(&data).unwrap()
703    }
704
705    #[test]
706    fn from_json_basic() {
707        // strings: 0="title", 1="count"
708        // slot 0: name_str_idx=0 (title), type=Text
709        // slot 1: name_str_idx=1 (count), type=Number
710        let module = build_module_for_json(
711            &["title", "count"],
712            &[
713                (0, 0, 0x01, 0x00, &[]), // slot_id=0, name="title", Text, Server
714                (1, 1, 0x03, 0x00, &[]), // slot_id=1, name="count", Number, Server
715            ],
716        );
717
718        let data = SlotData::from_json(r#"{"title": "Hello", "count": 42}"#, &module).unwrap();
719        assert_eq!(data.get(0).to_text(), "Hello");
720        assert_eq!(data.get(1).to_text(), "42");
721    }
722
723    #[test]
724    fn from_json_missing_key_uses_default() {
725        // slot 0: name="greeting", Text, default "hi"
726        let module = build_module_for_json(
727            &["greeting"],
728            &[
729                (0, 0, 0x01, 0x00, b"hi"), // slot_id=0, name="greeting", Text, Server, default "hi"
730            ],
731        );
732
733        // JSON does not contain "greeting" key
734        let data = SlotData::from_json(r#"{}"#, &module).unwrap();
735        assert_eq!(data.get(0).to_text(), "hi");
736    }
737
738    #[test]
739    fn from_json_null_value() {
740        let module = build_module_for_json(&["name"], &[(0, 0, 0x01, 0x00, &[])]);
741
742        let data = SlotData::from_json(r#"{"name": null}"#, &module).unwrap();
743        assert!(matches!(data.get(0), SlotValue::Null));
744    }
745
746    #[test]
747    fn from_json_bool_values() {
748        let module = build_module_for_json(
749            &["active", "hidden"],
750            &[
751                (0, 0, 0x02, 0x00, &[]), // Bool
752                (1, 1, 0x02, 0x00, &[]), // Bool
753            ],
754        );
755
756        let data = SlotData::from_json(r#"{"active": true, "hidden": false}"#, &module).unwrap();
757        assert!(matches!(data.get(0), SlotValue::Bool(true)));
758        assert!(matches!(data.get(1), SlotValue::Bool(false)));
759    }
760
761    #[test]
762    fn from_json_array() {
763        let module = build_module_for_json(
764            &["items"],
765            &[(0, 0, 0x04, 0x00, &[])], // Array
766        );
767
768        let data = SlotData::from_json(r#"{"items": ["a", "b", 3]}"#, &module).unwrap();
769        if let SlotValue::Array(arr) = data.get(0) {
770            assert_eq!(arr.len(), 3);
771            assert_eq!(arr[0].to_text(), "a");
772            assert_eq!(arr[1].to_text(), "b");
773            assert_eq!(arr[2].to_text(), "3");
774        } else {
775            panic!("expected Array, got {:?}", data.get(0));
776        }
777    }
778
779    #[test]
780    fn from_json_nested_object() {
781        let module = build_module_for_json(
782            &["config"],
783            &[(0, 0, 0x05, 0x00, &[])], // Object
784        );
785
786        let data = SlotData::from_json(r#"{"config": {"key": "value", "n": 7}}"#, &module).unwrap();
787        if let SlotValue::Object(pairs) = data.get(0) {
788            assert_eq!(pairs.len(), 2);
789            assert_eq!(pairs[0].0, "key");
790            assert_eq!(pairs[0].1.to_text(), "value");
791            assert_eq!(pairs[1].0, "n");
792            assert_eq!(pairs[1].1.to_text(), "7");
793        } else {
794            panic!("expected Object, got {:?}", data.get(0));
795        }
796    }
797
798    #[test]
799    fn from_json_unknown_key_ignored() {
800        let module = build_module_for_json(&["title"], &[(0, 0, 0x01, 0x00, &[])]);
801
802        // "extra_key" is not in the slot table — should be silently ignored
803        let data =
804            SlotData::from_json(r#"{"title": "Hi", "extra_key": "ignored"}"#, &module).unwrap();
805        assert_eq!(data.get(0).to_text(), "Hi");
806    }
807
808    #[test]
809    fn from_json_invalid_json() {
810        let module = build_module_for_json(&["x"], &[(0, 0, 0x01, 0x00, &[])]);
811
812        let result = SlotData::from_json(r#"not valid json"#, &module);
813        assert!(result.is_err());
814        match result.unwrap_err() {
815            crate::format::IrError::JsonParseError(_) => {} // expected
816            other => panic!("expected JsonParseError, got {other:?}"),
817        }
818    }
819
820    // -- ECMAScript number parity (F-05) ------------------------------------
821
822    #[test]
823    fn number_to_text_matches_javascript_string() {
824        // Each row is (value, exactly what `String(value)` produces in JS).
825        // The SSR text node must agree with the client renderer byte for byte.
826        let cases: &[(f64, &str)] = &[
827            (0.0, "0"),
828            // -0.0 stays on the integer fast path: String(-0) is "0", but
829            // Rust's Display for -0.0 is "-0", so routing it through the
830            // general path would silently regress it.
831            (-0.0, "0"),
832            (1.0, "1"),
833            (-42.0, "-42"),
834            (1.5, "1.5"),
835            (123.456, "123.456"),
836            (0.1, "0.1"),
837            // Above 2^53 the fast path does not apply, but the general path
838            // must still expand: this is the row that catches a naive
839            // "above 2^53 means exponential" implementation.
840            (1e16, "10000000000000000"),
841            (1e20, "100000000000000000000"),
842            // ...and 1e21 is where ECMAScript switches to exponent notation,
843            // with an explicit '+' that Rust's {:e} does not write.
844            (1e21, "1e+21"),
845            (1e300, "1e+300"),
846            (9007199254740992.0, "9007199254740992"), // 2^53
847            (9007199254740994.0, "9007199254740994"), // beyond MAX_SAFE_INTEGER
848            (1e-6, "0.000001"),
849            (1e-7, "1e-7"),
850            (1.5e-7, "1.5e-7"),
851            (-1.5e-7, "-1.5e-7"),
852            (f64::MAX, "1.7976931348623157e+308"),
853            // Only ±Infinity change here; Rust already prints "NaN".
854            (f64::INFINITY, "Infinity"),
855            (f64::NEG_INFINITY, "-Infinity"),
856            (f64::NAN, "NaN"),
857        ];
858        for (value, expected) in cases {
859            assert_eq!(
860                SlotValue::Number(*value).to_text(),
861                *expected,
862                "String({value:?})"
863            );
864        }
865    }
866
867    #[test]
868    fn number_to_text_never_saturates_above_i64() {
869        // `*n as i64` saturates at 2^63, which silently corrupted every value
870        // above it into 9223372036854775807.
871        let huge = 1e19_f64; // > i64::MAX, integral
872        assert_eq!(SlotValue::Number(huge).to_text(), "10000000000000000000");
873        assert!(!SlotValue::Number(huge)
874            .to_text()
875            .contains("9223372036854775807"));
876    }
877
878    #[test]
879    fn number_to_json_round_trips_by_value_not_by_text() {
880        // serde emits `1e16` where JS emits `10000000000000000` — different
881        // bytes, identical parsed f64. Props are consumed with JSON.parse and
882        // never string-compared, so assert on the parsed value.
883        for value in [0.0_f64, -0.0, 1.5, 1e16, 1e21, 1e300, -1e19] {
884            let json = SlotValue::Number(value).to_json();
885            let parsed: f64 = json.as_f64().unwrap_or_else(|| panic!("{value} -> {json}"));
886            assert_eq!(parsed, value, "value {value} did not round-trip");
887        }
888    }
889
890    #[test]
891    fn number_to_json_non_finite_is_null_matching_js() {
892        // NOT a residual defect: JSON.stringify(Infinity) is null in JS too,
893        // so `null` here while to_text says "Infinity" is exactly ES-faithful.
894        for value in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
895            assert_eq!(SlotValue::Number(value).to_json(), serde_json::Value::Null);
896        }
897    }
898
899    // -- Slot writes past the end (F-16) ------------------------------------
900
901    #[test]
902    fn set_grows_instead_of_silently_dropping() {
903        // A LIST whose item_slot_id exceeded the SlotData length used to make
904        // every row render identically, with no error anywhere.
905        let mut data = SlotData::new(1);
906        data.set(5, SlotValue::Text("late".into()));
907        assert_eq!(data.get(5).to_text(), "late");
908        // Intervening slots are Null, not garbage.
909        assert!(matches!(data.get(3), SlotValue::Null));
910    }
911}