Skip to main content

lumen_script/
node_query.rs

1//! Host-neutral read side of the dynamic DOM API: selector queries,
2//! `get_by_id`, and traversal over the per-tick [`DomIndex`] snapshot.
3//!
4//! Script hosts and the C-ABI all funnel
5//! through these functions. Handles cross the boundary as a packed `u64`
6//! ([`NodeHandle::pack`]); candela, whose value type is `i32`, interns the
7//! handle into the process-global side-table instead (see
8//! `lumen_core::node::intern_node`).
9//!
10//! Selector matching reuses the cascade matcher in `lumen-ir`; there is
11//! no second selector engine. A query walks each snapshot record's
12//! ancestor chain (root-first) and applies
13//! [`lumen_ir::css::selector_matches`]. Sibling combinators (`+`, `~`)
14//! inherit the matcher's conservative-fail behavior.
15
16use lumen_core::node::{DomIndex, DomRecord, NodeHandle, dom_index_snapshot};
17use lumen_ir::css::AncestorInfo;
18
19use bevy_ecs::entity::Entity;
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex, OnceLock, RwLock};
22
23use crate::ScriptCommand;
24
25/// Host-neutral packed node handle. `0` is reserved for "no node".
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct NodeRef(pub u64);
28
29impl NodeRef {
30    /// The null handle (`0`), meaning "no node".
31    pub const NONE: NodeRef = NodeRef(0);
32
33    /// Wrap an entity as a packed handle.
34    pub fn from_entity(entity: Entity) -> Self {
35        Self(NodeHandle::new(entity).pack())
36    }
37
38    /// Decode to an entity, or `None` for the null / invalid handle or a
39    /// reserved spawn token (which has no entity until command-drain).
40    pub fn entity(self) -> Option<Entity> {
41        if self.0 == 0 || lumen_core::node::is_reserved_token(self.0) {
42            return None;
43        }
44        NodeHandle::unpack(self.0).map(|h| h.entity)
45    }
46}
47
48impl From<Entity> for NodeRef {
49    fn from(e: Entity) -> Self {
50        Self::from_entity(e)
51    }
52}
53
54/// Materialized query result: packed handles in document order. Mirrors
55/// the Bevy-flavored `NodeQuery` consumers the host types wrap.
56#[derive(Debug, Clone, Default)]
57pub struct NodeQueryResult {
58    /// Matched handles, document order.
59    pub nodes: Vec<u64>,
60}
61
62impl NodeQueryResult {
63    /// Number of matches.
64    pub fn len(&self) -> usize {
65        self.nodes.len()
66    }
67
68    /// Whether the result set is empty.
69    pub fn is_empty(&self) -> bool {
70        self.nodes.is_empty()
71    }
72
73    /// First match in document order (`first()`).
74    pub fn first(&self) -> Option<u64> {
75        self.nodes.first().copied()
76    }
77
78    /// Match at `index` (`nth(i)`).
79    pub fn nth(&self, index: usize) -> Option<u64> {
80        self.nodes.get(index).copied()
81    }
82
83    /// Exactly-one match (Bevy `single()`): the sole handle, else an error
84    /// naming the count.
85    pub fn single(&self) -> Result<u64, String> {
86        match self.nodes.len() {
87            1 => Ok(self.nodes[0]),
88            n => Err(format!(
89                "query.single(): expected exactly 1 match, found {n}"
90            )),
91        }
92    }
93
94    /// Fallible one-or-none form (`get_single()`): the sole handle, or
95    /// `None` for zero or many.
96    pub fn get_single(&self) -> Option<u64> {
97        if self.nodes.len() == 1 {
98            Some(self.nodes[0])
99        } else {
100            None
101        }
102    }
103
104    /// All handles as a slice (`iter()` / `collect()`).
105    pub fn collect(&self) -> Vec<u64> {
106        self.nodes.clone()
107    }
108}
109
110/// Build the matcher's subject identity for a record, threading the real
111/// sibling position so `:first-child` / `:nth-child` resolve.
112fn subject_info(rec: &DomRecord) -> AncestorInfo {
113    AncestorInfo::new(rec.tag.clone(), rec.classes.clone(), rec.id.clone())
114        .with_position(record_position(rec))
115}
116
117/// The sibling position the DOM index recorded for `rec`.
118pub(crate) fn record_position(rec: &DomRecord) -> lumen_ir::css::SiblingPosition {
119    lumen_ir::css::SiblingPosition::new(rec.child_index, rec.sibling_count)
120}
121
122/// Root-first ancestor identities for `entity` (excluding itself).
123pub(crate) fn ancestor_infos(index: &DomIndex, entity: Entity) -> Vec<AncestorInfo> {
124    let mut chain: Vec<AncestorInfo> = index
125        .ancestors(entity)
126        .into_iter()
127        .filter_map(|e| index.record(e).map(subject_info))
128        .collect();
129    chain.reverse();
130    chain
131}
132
133/// Does `entity` match any selector in `selectors`?
134fn matches_any(index: &DomIndex, entity: Entity, selectors: &[lumen_ir::css::SelectorBuf]) -> bool {
135    let Some(rec) = index.record(entity) else {
136        return false;
137    };
138    let subject = subject_info(rec);
139    let ancestors = ancestor_infos(index, entity);
140    selectors
141        .iter()
142        .any(|sel| lumen_ir::css::selector_matches(sel, &subject, &ancestors))
143}
144
145/// Run a selector query against `index`, returning matched entities in
146/// document order. Errors carry the selector parse message.
147pub fn query_entities(index: &DomIndex, selector: &str) -> Result<Vec<Entity>, String> {
148    let selectors = lumen_ir::css::parse_selector_list(selector)?;
149    let mut hits: Vec<(u32, Entity)> = Vec::new();
150    for rec in index.records() {
151        if matches_any(index, rec.entity, &selectors) {
152            hits.push((rec.doc_order, rec.entity));
153        }
154    }
155    hits.sort_by_key(|(order, _)| *order);
156    Ok(hits.into_iter().map(|(_, e)| e).collect())
157}
158
159/// Walk from `start` up the ancestor chain (including `start`) and return
160/// the nearest element matching `selector` (`node.closest(sel)`).
161pub fn closest_entity(
162    index: &DomIndex,
163    start: Entity,
164    selector: &str,
165) -> Result<Option<Entity>, String> {
166    let selectors = lumen_ir::css::parse_selector_list(selector)?;
167    let mut cur = Some(start);
168    while let Some(e) = cur {
169        if matches_any(index, e, &selectors) {
170            return Ok(Some(e));
171        }
172        cur = index.parent(e);
173    }
174    Ok(None)
175}
176
177// ---------------------------------------------------------------------------
178// Snapshot-backed entry points (what the hosts and the C-ABI call). Each
179// reads the current per-tick snapshot and returns packed handles.
180// ---------------------------------------------------------------------------
181
182/// `query(sel)`: run against the current snapshot, packed handles.
183pub fn run_query(selector: &str) -> Result<NodeQueryResult, String> {
184    let index = dom_index_snapshot();
185    let nodes = query_entities(&index, selector)?
186        .into_iter()
187        .map(|e| NodeHandle::new(e).pack())
188        .collect();
189    Ok(NodeQueryResult { nodes })
190}
191
192/// `get_by_id(id)`: fast id path, packed handle or `None`.
193pub fn run_get_by_id(id: &str) -> Option<u64> {
194    dom_index_snapshot()
195        .get_by_id(id)
196        .map(|e| NodeHandle::new(e).pack())
197}
198
199/// `document()`: the root element as a packed handle.
200pub fn run_document() -> Option<u64> {
201    dom_index_snapshot()
202        .document()
203        .map(|e| NodeHandle::new(e).pack())
204}
205
206fn map_relation<F>(handle: u64, f: F) -> Option<u64>
207where
208    F: FnOnce(&DomIndex, Entity) -> Option<Entity>,
209{
210    let entity = NodeRef(handle).entity()?;
211    let index = dom_index_snapshot();
212    f(&index, entity).map(|e| NodeHandle::new(e).pack())
213}
214
215/// `node.parent()`.
216pub fn node_parent(handle: u64) -> Option<u64> {
217    map_relation(handle, |i, e| i.parent(e))
218}
219
220/// `node.first_child()`.
221pub fn node_first_child(handle: u64) -> Option<u64> {
222    map_relation(handle, |i, e| i.first_child(e))
223}
224
225/// `node.last_child()`.
226pub fn node_last_child(handle: u64) -> Option<u64> {
227    map_relation(handle, |i, e| i.last_child(e))
228}
229
230/// `node.next()`.
231pub fn node_next(handle: u64) -> Option<u64> {
232    map_relation(handle, |i, e| i.next_sibling(e))
233}
234
235/// `node.prev()`.
236pub fn node_prev(handle: u64) -> Option<u64> {
237    map_relation(handle, |i, e| i.prev_sibling(e))
238}
239
240/// `node.children()`: packed handles in document order.
241pub fn node_children(handle: u64) -> Vec<u64> {
242    let Some(entity) = NodeRef(handle).entity() else {
243        return Vec::new();
244    };
245    dom_index_snapshot()
246        .children(entity)
247        .into_iter()
248        .map(|e| NodeHandle::new(e).pack())
249        .collect()
250}
251
252/// `node.closest(sel)`: nearest matching ancestor-or-self, packed handle.
253pub fn node_closest(handle: u64, selector: &str) -> Result<Option<u64>, String> {
254    let Some(entity) = NodeRef(handle).entity() else {
255        return Ok(None);
256    };
257    let index = dom_index_snapshot();
258    Ok(closest_entity(&index, entity, selector)?.map(|e| NodeHandle::new(e).pack()))
259}
260
261/// `node.exists()` / `node_valid(h)`: whether the handle is present in the
262/// current snapshot. The snapshot rebuilds each tick, so a despawned node
263/// drops out; this is the read-side liveness check without a live world.
264pub fn node_valid(handle: u64) -> bool {
265    match NodeRef(handle).entity() {
266        Some(entity) => dom_index_snapshot().record(entity).is_some(),
267        None => false,
268    }
269}
270
271// ---------------------------------------------------------------------------
272// Per-node detail snapshot + style context (the read-back side of the
273// mutation API: text / attrs / inline / computed style). DomIndex carries
274// tag / id / classes / hierarchy; text, generic attributes, and inline
275// style live here, published each tick alongside the stylesheet + media so
276// `computed_style` can re-run the cascade host-side without a live world.
277// ---------------------------------------------------------------------------
278
279/// Per-node detail carried in the snapshot, keyed by packed handle.
280#[derive(Debug, Clone, Default)]
281pub struct NodeDetail {
282    /// The node's text content, if any.
283    pub text: Option<String>,
284    /// Generic attribute map (attrs with no typed component).
285    pub attributes: Vec<(String, String)>,
286    /// Inline-style overrides (`element.style`), ordered.
287    pub inline_style: Vec<(String, String)>,
288    /// Styling attributes from the node's markup, ordered. They rank
289    /// above every stylesheet rule and below `inline_style`.
290    pub markup_style: Vec<(String, String)>,
291}
292
293/// Snapshot of per-node details plus the cascade inputs `computed_style`
294/// needs. Rebuilt and published by the runtime each tick.
295#[derive(Default)]
296pub struct DomDetails {
297    nodes: HashMap<u64, NodeDetail>,
298    sheet: Option<Arc<lumen_ir::css::Stylesheet>>,
299    media: lumen_ir::css::MediaContext,
300}
301
302impl DomDetails {
303    /// Build from a packed-handle-keyed detail map plus the live stylesheet
304    /// and media context.
305    pub fn new(
306        nodes: HashMap<u64, NodeDetail>,
307        sheet: Option<Arc<lumen_ir::css::Stylesheet>>,
308        media: lumen_ir::css::MediaContext,
309    ) -> Self {
310        Self {
311            nodes,
312            sheet,
313            media,
314        }
315    }
316
317    fn detail(&self, handle: u64) -> Option<&NodeDetail> {
318        self.nodes.get(&handle)
319    }
320
321    /// The live stylesheet the cascade re-resolver reads, if published.
322    pub fn sheet(&self) -> Option<&Arc<lumen_ir::css::Stylesheet>> {
323        self.sheet.as_ref()
324    }
325
326    /// The media context the cascade resolves `@media` against.
327    pub fn media(&self) -> lumen_ir::css::MediaContext {
328        self.media
329    }
330
331    /// The inline-style overrides published for `handle`, if any.
332    pub fn inline_style_of(&self, handle: u64) -> Vec<(String, String)> {
333        self.detail(handle)
334            .map(|d| d.inline_style.clone())
335            .unwrap_or_default()
336    }
337
338    /// The generic attribute map published for `handle`, if any.
339    pub fn attributes_of(&self, handle: u64) -> Vec<(String, String)> {
340        self.detail(handle)
341            .map(|d| d.attributes.clone())
342            .unwrap_or_default()
343    }
344}
345
346static DOM_DETAILS: OnceLock<RwLock<Arc<DomDetails>>> = OnceLock::new();
347
348fn dom_details_cell() -> &'static RwLock<Arc<DomDetails>> {
349    DOM_DETAILS.get_or_init(|| RwLock::new(Arc::new(DomDetails::default())))
350}
351
352/// Publish a freshly-built detail snapshot for cross-thread readers. The
353/// runtime calls this each tick from the same system that publishes the
354/// [`DomIndex`].
355pub fn publish_dom_details(details: DomDetails) {
356    if let Ok(mut g) = dom_details_cell().write() {
357        *g = Arc::new(details);
358    }
359}
360
361/// Read the current detail snapshot (cheap `Arc` clone).
362pub fn dom_details_snapshot() -> Arc<DomDetails> {
363    dom_details_cell()
364        .read()
365        .map(|g| g.clone())
366        .unwrap_or_else(|_| Arc::new(DomDetails::default()))
367}
368
369/// `node.text()`: the node's current text content, or `None`.
370pub fn node_text(handle: u64) -> Option<String> {
371    dom_details_snapshot()
372        .detail(handle)
373        .and_then(|d| d.text.clone())
374}
375
376/// `node.get_attr(name)`. KNOWN attrs resolve from the index (`id`,
377/// `class`, `text`); everything else from the generic attribute map.
378pub fn node_get_attr(handle: u64, name: &str) -> Option<String> {
379    match name {
380        "id" => {
381            let entity = NodeRef(handle).entity()?;
382            dom_index_snapshot()
383                .record(entity)
384                .and_then(|r| r.id.clone())
385        }
386        "class" => {
387            let entity = NodeRef(handle).entity()?;
388            let classes = dom_index_snapshot()
389                .record(entity)
390                .map(|r| r.classes.clone())?;
391            if classes.is_empty() {
392                None
393            } else {
394                Some(classes.join(" "))
395            }
396        }
397        "text" => node_text(handle),
398        _ => dom_details_snapshot().detail(handle).and_then(|d| {
399            d.attributes
400                .iter()
401                .find(|(k, _)| k == name)
402                .map(|(_, v)| v.clone())
403        }),
404    }
405}
406
407/// The generic attribute map (attrs with no typed component) published for
408/// `handle`, ordered. Backs the introspection `attrs()` getter.
409pub fn attributes_of_handle(handle: u64) -> Vec<(String, String)> {
410    dom_details_snapshot().attributes_of(handle)
411}
412
413/// `node.id()`: the node's stable id, or `None`.
414pub fn node_id(handle: u64) -> Option<String> {
415    node_get_attr(handle, "id")
416}
417
418/// `node.class_list().contains(class)`.
419pub fn node_class_contains(handle: u64, class: &str) -> bool {
420    let Some(entity) = NodeRef(handle).entity() else {
421        return false;
422    };
423    dom_index_snapshot()
424        .record(entity)
425        .map(|r| r.classes.iter().any(|c| c == class))
426        .unwrap_or(false)
427}
428
429/// `node.style_get(prop)`: the inline-style override for `prop`, or `None`.
430pub fn node_style_get(handle: u64, property: &str) -> Option<String> {
431    dom_details_snapshot().detail(handle).and_then(|d| {
432        d.inline_style
433            .iter()
434            .find(|(k, _)| k == property)
435            .map(|(_, v)| v.clone())
436    })
437}
438
439/// `node.computed_style(prop)`: the value of `prop` after the full cascade
440/// (stylesheet + inherited + inline). Reflects the last committed tick;
441/// an inline write issued this tick is visible next tick (commands apply
442/// at drain). Returns `None` for an unmodeled property or an unknown node.
443pub fn node_computed_style(handle: u64, property: &str) -> Option<String> {
444    lumen_ir::css::computed_property(&resolved_attributes(handle), property)
445}
446
447/// The fully-cascaded [`Attributes`](lumen_ir::layout_ir::Attributes) for
448/// `handle`: the element resolved from scratch the way a restyle resolves
449/// it, from its tag, classes and id, its place in the live tree, and its own
450/// markup and inline-style declarations, which outrank every rule. Shared by
451/// `computed_style(prop)` and the full-map `computed_style()` introspection
452/// getter. Reflects the last committed tick.
453pub fn resolved_attributes(handle: u64) -> lumen_ir::layout_ir::Attributes {
454    let details = dom_details_snapshot();
455    // The element's own declarations: its markup's, then its inline
456    // style's, so the inline tier wins.
457    let declarations = |handle: u64| -> Vec<(String, String)> {
458        details
459            .detail(handle)
460            .map(|d| {
461                d.markup_style
462                    .iter()
463                    .chain(&d.inline_style)
464                    .cloned()
465                    .collect()
466            })
467            .unwrap_or_default()
468    };
469    let resolved = NodeRef(handle).entity().and_then(|entity| {
470        let sheet = details.sheet.as_ref()?;
471        let index = dom_index_snapshot();
472        let rec = index.record(entity)?;
473        let subject = subject_info(rec).with_declarations(declarations(handle));
474        // An ancestor's own declarations reach this element through what
475        // it inherits.
476        let mut ancestors: Vec<AncestorInfo> = index
477            .ancestors(entity)
478            .into_iter()
479            .filter_map(|e| {
480                let info = subject_info(index.record(e)?);
481                Some(info.with_declarations(declarations(NodeHandle::new(e).pack())))
482            })
483            .collect();
484        ancestors.reverse();
485        Some(lumen_ir::css::resolve_element(
486            &subject,
487            sheet,
488            &details.media,
489            &ancestors,
490        ))
491    });
492    // Without a stylesheet, or for a node not in the index, the element's
493    // own declarations are all there is.
494    resolved.unwrap_or_else(|| {
495        let mut attrs = lumen_ir::layout_ir::Attributes::default();
496        for (prop, value) in declarations(handle) {
497            let _ = lumen_ir::css::apply_inline_declaration(&prop, &value, &mut attrs);
498        }
499        attrs
500    })
501}
502
503// ---------------------------------------------------------------------------
504// Focus / hover mirror for `document.focused()` / `document.hovered()`.
505// The runtime publishes the current targets each tick; hosts read them as
506// packed handles.
507// ---------------------------------------------------------------------------
508
509static FOCUS_STATE: OnceLock<RwLock<(Option<u64>, Option<u64>)>> = OnceLock::new();
510
511fn focus_cell() -> &'static RwLock<(Option<u64>, Option<u64>)> {
512    FOCUS_STATE.get_or_init(|| RwLock::new((None, None)))
513}
514
515/// Publish the current focus / hover targets (packed handles). The runtime
516/// calls this each tick.
517pub fn publish_focus(focused: Option<u64>, hovered: Option<u64>) {
518    if let Ok(mut g) = focus_cell().write() {
519        *g = (focused, hovered);
520    }
521}
522
523/// `document.focused()`: the focused node handle, or `None`.
524pub fn focused_node() -> Option<u64> {
525    focus_cell().read().ok().and_then(|g| g.0)
526}
527
528/// `document.hovered()`: the hovered node handle, or `None`.
529pub fn hovered_node() -> Option<u64> {
530    focus_cell().read().ok().and_then(|g| g.1)
531}
532
533// ---------------------------------------------------------------------------
534// External DOM command bus. Fire-and-forget mutation commands issued from
535// outside a script tick (the C-ABI, the Rust SDK) queue here; the runtime
536// drains them into `ScriptCommandEvent` each tick so they flow through the
537// same applier as script-issued mutations. Script hosts push into their
538// own per-host sink and do not use this bus.
539// ---------------------------------------------------------------------------
540
541static DOM_CMD_QUEUE: OnceLock<Mutex<Vec<ScriptCommand>>> = OnceLock::new();
542
543fn dom_cmd_queue() -> &'static Mutex<Vec<ScriptCommand>> {
544    DOM_CMD_QUEUE.get_or_init(|| Mutex::new(Vec::new()))
545}
546
547/// Enqueue a mutation command from a non-script surface (C-ABI / SDK).
548/// Returns `false` only if the process-global lock is poisoned.
549pub fn push_external_dom_command(cmd: ScriptCommand) -> bool {
550    match dom_cmd_queue().lock() {
551        Ok(mut q) => {
552            q.push(cmd);
553            true
554        }
555        Err(_) => false,
556    }
557}
558
559/// Drain all queued external DOM commands (FIFO). Called by the runtime
560/// once per tick.
561pub fn drain_external_dom_commands() -> Vec<ScriptCommand> {
562    dom_cmd_queue()
563        .lock()
564        .map(|mut q| std::mem::take(&mut *q))
565        .unwrap_or_default()
566}
567
568// ---------------------------------------------------------------------------
569// Host-neutral mutation builders. Each mints any reserved token and returns
570// the ScriptCommand for the caller to push into its sink (script hosts) or
571// the external bus (C-ABI / SDK). The returned handle is what the fluent
572// chain keeps addressing.
573// ---------------------------------------------------------------------------
574
575/// `create(tag)`: mint a reserved token and the backing [`ScriptCommand`].
576/// Returns `(handle, command)`; the handle is valid for the whole tick.
577pub fn build_spawn(tag: &str) -> (u64, ScriptCommand) {
578    let reserved = lumen_core::node::reserve_node_token();
579    (
580        reserved,
581        ScriptCommand::Spawn {
582            tag: tag.to_string(),
583            reserved,
584        },
585    )
586}
587
588/// `source.clone_deep()`: mint a reserved token for the clone root and the
589/// backing [`ScriptCommand`].
590pub fn build_clone(source: u64) -> (u64, ScriptCommand) {
591    let reserved = lumen_core::node::reserve_node_token();
592    (reserved, ScriptCommand::CloneNode { source, reserved })
593}
594
595/// Instantiate the fragment `key`: mint a reserved token for the instance
596/// root and the backing [`ScriptCommand`]. `args` binds parameters,
597/// `children` fills slots with nodes that already exist.
598pub fn build_spawn_fragment(
599    key: &str,
600    args: Vec<(String, String)>,
601    children: Vec<(String, u64)>,
602) -> (u64, ScriptCommand) {
603    let reserved = lumen_core::node::reserve_node_token();
604    (
605        reserved,
606        ScriptCommand::SpawnFragment {
607            key: key.to_string(),
608            args,
609            children,
610            reserved,
611        },
612    )
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use bevy_ecs::world::World;
619    use lumen_core::node::{DomRecord, publish_dom_index};
620
621    fn rec(
622        entity: Entity,
623        tag: &str,
624        id: Option<&str>,
625        classes: &[&str],
626        parent: Option<Entity>,
627        children: &[Entity],
628    ) -> DomRecord {
629        DomRecord {
630            entity,
631            generation: entity.generation().to_bits(),
632            tag: tag.to_string(),
633            id: id.map(str::to_string),
634            classes: classes.iter().map(|s| s.to_string()).collect(),
635            parent,
636            children: children.to_vec(),
637            child_index: 0,
638            sibling_count: 0,
639            doc_order: 0,
640        }
641    }
642
643    fn fixture() -> (DomIndex, Entity, Entity, Entity, Entity) {
644        let mut w = World::new();
645        let root = w.spawn_empty().id();
646        let card = w.spawn_empty().id();
647        let save = w.spawn_empty().id();
648        let cancel = w.spawn_empty().id();
649        let idx = DomIndex::build(vec![
650            rec(root, "root", Some("app"), &["app"], None, &[card]),
651            rec(card, "div", None, &["card"], Some(root), &[save, cancel]),
652            rec(save, "button", Some("save"), &["row"], Some(card), &[]),
653            rec(cancel, "button", Some("cancel"), &["row"], Some(card), &[]),
654        ]);
655        (idx, root, card, save, cancel)
656    }
657
658    #[test]
659    fn query_by_id_class_and_descendant() {
660        let (idx, _root, _card, save, cancel) = fixture();
661        assert_eq!(query_entities(&idx, "#save").unwrap(), vec![save]);
662        assert_eq!(query_entities(&idx, ".row").unwrap(), vec![save, cancel]);
663        assert_eq!(
664            query_entities(&idx, ".card button").unwrap(),
665            vec![save, cancel]
666        );
667        assert_eq!(
668            query_entities(&idx, ".card > .row").unwrap(),
669            vec![save, cancel]
670        );
671        assert!(query_entities(&idx, ".nope").unwrap().is_empty());
672    }
673
674    #[test]
675    fn closest_walks_up() {
676        let (idx, root, card, save, _cancel) = fixture();
677        assert_eq!(closest_entity(&idx, save, ".card").unwrap(), Some(card));
678        assert_eq!(closest_entity(&idx, save, ".app").unwrap(), Some(root));
679        assert_eq!(closest_entity(&idx, save, ".row").unwrap(), Some(save));
680        assert_eq!(closest_entity(&idx, save, ".none").unwrap(), None);
681    }
682
683    #[test]
684    fn snapshot_entry_points_and_liveness() {
685        // The published snapshot is process-wide; the DOM event tests
686        // publish one of their own under the same lock.
687        let _guard = crate::event::TEST_GUARD
688            .lock()
689            .unwrap_or_else(|e| e.into_inner());
690        let (idx, root, card, save, cancel) = fixture();
691        publish_dom_index(idx);
692        let q = run_query(".row").unwrap();
693        assert_eq!(q.len(), 2);
694        assert!(q.single().is_err());
695        let one = run_query("#save").unwrap();
696        assert_eq!(one.single().unwrap(), NodeHandle::new(save).pack());
697        assert_eq!(
698            run_get_by_id("cancel"),
699            Some(NodeHandle::new(cancel).pack())
700        );
701        assert_eq!(run_document(), Some(NodeHandle::new(root).pack()));
702        let save_h = NodeHandle::new(save).pack();
703        assert_eq!(node_next(save_h), Some(NodeHandle::new(cancel).pack()));
704        assert_eq!(node_parent(save_h), Some(NodeHandle::new(card).pack()));
705        assert_eq!(node_children(NodeHandle::new(card).pack()).len(), 2);
706        assert_eq!(
707            node_closest(save_h, ".app").unwrap(),
708            Some(NodeHandle::new(root).pack())
709        );
710        assert!(node_valid(save_h));
711        assert!(!node_valid(0));
712    }
713}