rbx_dom_weak/
viewer.rs

1use std::{
2    collections::{BTreeMap, HashMap},
3    fmt::Write,
4};
5
6use crate::{
7    types::{Ref, Variant},
8    WeakDom,
9};
10use serde::{Deserialize, Serialize};
11use ustr::Ustr;
12
13/// Contains state for viewing and redacting nondeterministic portions of
14/// WeakDom objects, making them suitable for usage in snapshot tests.
15///
16/// `DomViewer` can be held onto and used with a DOM multiple times. IDs will
17/// persist when viewing the same instance multiple times, and should stay the
18/// same across multiple runs of a test.
19pub struct DomViewer {
20    referent_to_id: HashMap<Ref, String>,
21    next_id: usize,
22}
23
24impl DomViewer {
25    /// Construct a new `DomViewer` with no interned referents.
26    pub fn new() -> Self {
27        Self {
28            referent_to_id: HashMap::new(),
29            next_id: 0,
30        }
31    }
32
33    /// View the given `WeakDom`, creating a `ViewedInstance` object that can be
34    /// used in a snapshot test.
35    pub fn view(&mut self, dom: &WeakDom) -> ViewedInstance {
36        let root_referent = dom.root_ref();
37        self.populate_referent_map(dom, root_referent);
38        self.view_instance(dom, root_referent)
39    }
40
41    /// View the children of the root instance of the given `WeakDom`, returning
42    /// them as a `Vec<ViewedInstance>`.
43    pub fn view_children(&mut self, dom: &WeakDom) -> Vec<ViewedInstance> {
44        let root_instance = dom.root();
45        let children = root_instance.children();
46
47        for &referent in children {
48            self.populate_referent_map(dom, referent);
49        }
50
51        children
52            .iter()
53            .map(|&referent| self.view_instance(dom, referent))
54            .collect()
55    }
56
57    fn populate_referent_map(&mut self, dom: &WeakDom, referent: Ref) {
58        let next_id = &mut self.next_id;
59        self.referent_to_id.entry(referent).or_insert_with(|| {
60            let name = format!("referent-{next_id}");
61            *next_id += 1;
62            name
63        });
64
65        let instance = dom.get_by_ref(referent).unwrap();
66        for referent in instance.children() {
67            self.populate_referent_map(dom, *referent);
68        }
69    }
70
71    fn view_instance(&self, dom: &WeakDom, referent: Ref) -> ViewedInstance {
72        let instance = dom.get_by_ref(referent).unwrap();
73
74        let children = instance
75            .children()
76            .iter()
77            .copied()
78            .map(|referent| self.view_instance(dom, referent))
79            .collect();
80
81        let properties = instance
82            .properties
83            .iter()
84            .map(|(key, value)| {
85                let new_value = match value {
86                    Variant::Ref(referent) => {
87                        if referent.is_some() {
88                            let referent_str = self
89                                .referent_to_id
90                                .get(referent)
91                                .cloned()
92                                .unwrap_or_else(|| "[unknown ID]".to_owned());
93
94                            ViewedValue::Ref(referent_str)
95                        } else {
96                            ViewedValue::Ref("null".to_owned())
97                        }
98                    }
99                    Variant::SharedString(shared_string) => {
100                        let hash = shared_string.hash();
101                        let mut hash_hex = String::with_capacity(hash.as_bytes().len() * 2);
102
103                        for byte in hash.as_bytes() {
104                            write!(hash_hex, "{byte:02x}").unwrap();
105                        }
106                        ViewedValue::SharedString {
107                            len: shared_string.data().len(),
108                            hash: hash_hex,
109                        }
110                    }
111                    Variant::NetAssetRef(net) => {
112                        let hash = net.hash();
113                        let mut hash_hex = String::with_capacity(hash.as_bytes().len() * 2);
114
115                        for byte in hash.as_bytes() {
116                            write!(hash_hex, "{byte:02x}").unwrap();
117                        }
118                        ViewedValue::NetAssetRef {
119                            len: net.data().len(),
120                            hash: hash_hex,
121                        }
122                    }
123                    other => ViewedValue::Other(other.clone()),
124                };
125
126                (*key, new_value)
127            })
128            .collect();
129
130        ViewedInstance {
131            referent: self.referent_to_id.get(&referent).unwrap().clone(),
132            name: instance.name.clone(),
133            class: instance.class,
134            properties,
135            children,
136        }
137    }
138}
139
140impl Default for DomViewer {
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146/// A transformed view into a `WeakDom` or `Instance` that has been redacted and
147/// transformed to be more readable.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ViewedInstance {
150    referent: String,
151    name: String,
152    class: Ustr,
153    properties: BTreeMap<Ustr, ViewedValue>,
154    children: Vec<ViewedInstance>,
155}
156
157/// Wrapper around Variant with refs replaced to be redacted, stable versions of
158/// their original IDs.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(untagged)]
161enum ViewedValue {
162    Ref(String),
163    SharedString { len: usize, hash: String },
164    NetAssetRef { len: usize, hash: String },
165    Other(Variant),
166}
167
168#[cfg(test)]
169mod test {
170    use super::*;
171
172    use crate::types::SharedString;
173    use crate::InstanceBuilder;
174
175    #[test]
176    fn redact_single() {
177        let dom = WeakDom::new(InstanceBuilder::new("Folder").with_name("Root"));
178
179        insta::assert_yaml_snapshot!(DomViewer::new().view(&dom));
180    }
181
182    #[test]
183    fn redact_multi() {
184        let dom = WeakDom::new(
185            InstanceBuilder::new("Folder")
186                .with_name("Root")
187                .with_children(
188                    (0..4).map(|i| InstanceBuilder::new("Folder").with_name(format!("Child {i}"))),
189                ),
190        );
191
192        insta::assert_yaml_snapshot!(DomViewer::new().view(&dom));
193    }
194
195    #[test]
196    fn redact_values() {
197        let root = InstanceBuilder::new("ObjectValue").with_name("Root");
198        let root_ref = root.referent;
199        let root = root.with_property("Value", root_ref);
200
201        let dom = WeakDom::new(root);
202
203        insta::assert_yaml_snapshot!(DomViewer::new().view(&dom));
204    }
205
206    #[test]
207    fn abbreviate_shared_string() {
208        let shared_string = SharedString::new("foo".into());
209
210        let root = InstanceBuilder::new("UnionOperation")
211            .with_name("Root")
212            .with_property("PhysicalConfigData", shared_string);
213
214        let dom = WeakDom::new(root);
215
216        insta::assert_yaml_snapshot!(DomViewer::new().view(&dom));
217    }
218}