Skip to main content

forma_ir/
slot.rs

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