Skip to main content

plushie_renderer_lib/
scripting.rs

1//! Protocol helpers for scripting messages.
2//!
3//! Both the daemon and headless modes handle Query/Interact/Reset/TreeHash
4//! messages from stdin. The logic is identical; only the surrounding event loop
5//! differs. This module contains the canonical implementations so the two modes
6//! stay in sync.
7//!
8//! Event construction functions (`parse_iced_key`, `parse_iced_modifiers`,
9//! `make_key_pressed`, `make_key_released`, `interaction_to_iced_events`) also
10//! live here. Both the daemon renderer and headless mode use them to translate
11//! scripting protocol interactions into iced events.
12
13use std::io;
14
15use iced::keyboard::{self, Key, Modifiers};
16use iced::mouse;
17use iced::{Event, Point};
18
19use smol_str::SmolStr;
20
21use serde_json::Value;
22
23use plushie_renderer_engine::Codec;
24use plushie_renderer_engine::Core;
25use plushie_widget_sdk::protocol::{
26    InteractResponse, OutgoingEvent, QueryResponse, ResetResponse, TreeHashResponse, TreeNode,
27};
28
29// ---------------------------------------------------------------------------
30// Selector (re-exported from plushie-core)
31// ---------------------------------------------------------------------------
32
33pub use plushie_core::{MAX_SELECTOR_SEARCH_DEPTH, Selector};
34
35/// Parse a selector from wire protocol JSON.
36///
37/// Delegates to [`Selector::from_wire`].
38pub fn parse_selector(selector: &Value) -> Option<Selector> {
39    Selector::from_wire(selector)
40}
41
42// ---------------------------------------------------------------------------
43// Key / modifier parsing (delegates to plushie-core)
44// ---------------------------------------------------------------------------
45
46/// Parse key and modifiers from an interact payload.
47///
48/// Supports three formats via [`plushie_core::key::KeyPress::from_wire`]:
49/// 1. Combined combo string: `{"combo": "Ctrl+s"}`
50/// 2. Explicit key + modifiers: `{"key": "s", "modifiers": {"ctrl": true}}`
51/// 3. Combined key field: `{"key": "ctrl+s"}`
52///
53/// All key names and modifiers are normalized (case-insensitive,
54/// underscores/hyphens stripped, aliases resolved).
55pub fn parse_key_and_modifiers(
56    payload: Option<&serde_json::Map<String, Value>>,
57) -> (String, Value) {
58    let payload_value = payload
59        .map(|m| Value::Object(m.clone()))
60        .unwrap_or(Value::Null);
61
62    // Try the shared KeyPress parser first.
63    if let Some(kp) = plushie_core::KeyPress::from_wire(&payload_value) {
64        return (kp.key.wire_name(), resolve_modifiers(&kp.modifiers));
65    }
66
67    // Fallback: treat malformed combo input as a literal key instead
68    // of silently emitting an empty key event.
69    let raw_key = payload_value
70        .get("combo")
71        .and_then(|v| v.as_str())
72        .or_else(|| payload_value.get("key").and_then(|v| v.as_str()))
73        .unwrap_or("");
74    match raw_key.parse::<plushie_core::KeyPress>() {
75        Ok(kp) => (kp.key.wire_name(), resolve_modifiers(&kp.modifiers)),
76        Err(err) => {
77            log::warn!("invalid scripted key combo {raw_key:?}: {err}");
78            let key = plushie_core::Key::from(raw_key);
79            (key.wire_name(), resolve_modifiers(&Default::default()))
80        }
81    }
82}
83
84// ---------------------------------------------------------------------------
85// Key string -> iced Key conversion
86// ---------------------------------------------------------------------------
87
88/// Convert a key name string to an iced `keyboard::Key`.
89///
90/// Uses plushie-core's [`Key`](plushie_core::Key) normalization for
91/// forgiving input, then maps to the iced Key type.
92pub fn parse_iced_key(name: &str) -> Key {
93    let core_key = plushie_core::Key::from(name);
94    core_key_to_iced(&core_key)
95}
96
97/// Resolve the platform-aware `command` modifier to the correct
98/// physical modifier for the current platform.
99///
100/// On macOS, `command: true` maps to `logo` (the Cmd key).
101/// On Linux/Windows, `command: true` maps to `ctrl`.
102fn resolve_modifiers(mods: &plushie_core::protocol::KeyModifiers) -> Value {
103    let is_macos = cfg!(target_os = "macos");
104    serde_json::json!({
105        "shift": mods.shift,
106        "ctrl": mods.ctrl || (!is_macos && mods.command),
107        "alt": mods.alt,
108        "logo": mods.logo || (is_macos && mods.command),
109    })
110}
111
112/// Convert a plushie-core Key to an iced Key.
113fn core_key_to_iced(key: &plushie_core::Key) -> Key {
114    use plushie_core::Key as CK;
115    match key {
116        CK::ArrowUp => Key::Named(keyboard::key::Named::ArrowUp),
117        CK::ArrowDown => Key::Named(keyboard::key::Named::ArrowDown),
118        CK::ArrowLeft => Key::Named(keyboard::key::Named::ArrowLeft),
119        CK::ArrowRight => Key::Named(keyboard::key::Named::ArrowRight),
120        CK::Home => Key::Named(keyboard::key::Named::Home),
121        CK::End => Key::Named(keyboard::key::Named::End),
122        CK::PageUp => Key::Named(keyboard::key::Named::PageUp),
123        CK::PageDown => Key::Named(keyboard::key::Named::PageDown),
124        CK::Enter => Key::Named(keyboard::key::Named::Enter),
125        CK::Tab => Key::Named(keyboard::key::Named::Tab),
126        CK::Space => Key::Named(keyboard::key::Named::Space),
127        CK::Backspace => Key::Named(keyboard::key::Named::Backspace),
128        CK::Delete => Key::Named(keyboard::key::Named::Delete),
129        CK::Insert => Key::Named(keyboard::key::Named::Insert),
130        CK::Escape => Key::Named(keyboard::key::Named::Escape),
131        CK::Shift => Key::Named(keyboard::key::Named::Shift),
132        CK::Control => Key::Named(keyboard::key::Named::Control),
133        CK::Alt => Key::Named(keyboard::key::Named::Alt),
134        CK::Super => Key::Named(keyboard::key::Named::Super),
135        CK::F1 => Key::Named(keyboard::key::Named::F1),
136        CK::F2 => Key::Named(keyboard::key::Named::F2),
137        CK::F3 => Key::Named(keyboard::key::Named::F3),
138        CK::F4 => Key::Named(keyboard::key::Named::F4),
139        CK::F5 => Key::Named(keyboard::key::Named::F5),
140        CK::F6 => Key::Named(keyboard::key::Named::F6),
141        CK::F7 => Key::Named(keyboard::key::Named::F7),
142        CK::F8 => Key::Named(keyboard::key::Named::F8),
143        CK::F9 => Key::Named(keyboard::key::Named::F9),
144        CK::F10 => Key::Named(keyboard::key::Named::F10),
145        CK::F11 => Key::Named(keyboard::key::Named::F11),
146        CK::F12 => Key::Named(keyboard::key::Named::F12),
147        CK::CapsLock => Key::Named(keyboard::key::Named::CapsLock),
148        CK::NumLock => Key::Named(keyboard::key::Named::NumLock),
149        CK::ScrollLock => Key::Named(keyboard::key::Named::ScrollLock),
150        CK::PrintScreen => Key::Named(keyboard::key::Named::PrintScreen),
151        CK::Pause => Key::Named(keyboard::key::Named::Pause),
152        CK::ContextMenu => Key::Named(keyboard::key::Named::ContextMenu),
153        CK::Copy => Key::Named(keyboard::key::Named::Copy),
154        CK::Cut => Key::Named(keyboard::key::Named::Cut),
155        CK::Paste => Key::Named(keyboard::key::Named::Paste),
156        CK::Undo => Key::Named(keyboard::key::Named::Undo),
157        CK::Redo => Key::Named(keyboard::key::Named::Redo),
158        CK::Char(c) => Key::Character(SmolStr::new(c.to_string())),
159        CK::Named(name) => {
160            // Try to match against iced's Named enum by the wire name.
161            // This handles rare keys (MediaPlay, BrowserBack, etc.).
162            Key::Character(SmolStr::new(name))
163        }
164    }
165}
166
167/// Build iced `Modifiers` from parsed scripting protocol modifiers JSON.
168pub fn parse_iced_modifiers(mods: &Value) -> Modifiers {
169    let mut m = Modifiers::empty();
170    if mods.get("shift").and_then(|v| v.as_bool()).unwrap_or(false) {
171        m |= Modifiers::SHIFT;
172    }
173    if mods.get("ctrl").and_then(|v| v.as_bool()).unwrap_or(false) {
174        m |= Modifiers::CTRL;
175    }
176    if mods.get("alt").and_then(|v| v.as_bool()).unwrap_or(false) {
177        m |= Modifiers::ALT;
178    }
179    if mods.get("logo").and_then(|v| v.as_bool()).unwrap_or(false) {
180        m |= Modifiers::LOGO;
181    }
182    m
183}
184
185/// Build a KeyPressed iced event.
186pub fn make_key_pressed(key: Key, modifiers: Modifiers, text: Option<SmolStr>) -> Event {
187    Event::Keyboard(keyboard::Event::KeyPressed {
188        key: key.clone(),
189        modified_key: key,
190        physical_key: keyboard::key::Physical::Unidentified(
191            keyboard::key::NativeCode::Unidentified,
192        ),
193        location: keyboard::Location::Standard,
194        modifiers,
195        text,
196        repeat: false,
197    })
198}
199
200/// Build a KeyReleased iced event.
201pub fn make_key_released(key: Key, modifiers: Modifiers) -> Event {
202    Event::Keyboard(keyboard::Event::KeyReleased {
203        key: key.clone(),
204        modified_key: key,
205        physical_key: keyboard::key::Physical::Unidentified(
206            keyboard::key::NativeCode::Unidentified,
207        ),
208        location: keyboard::Location::Standard,
209        modifiers,
210    })
211}
212
213// ---------------------------------------------------------------------------
214// Interaction -> iced events
215// ---------------------------------------------------------------------------
216
217/// Convert a scripting protocol interaction into a sequence of iced events.
218///
219/// Returns an empty vec for action types that don't map to iced events
220/// (synthetic-only actions like paste, sort, pane_focus_cycle, slide).
221pub fn interaction_to_iced_events(
222    action: &str,
223    _widget_id: Option<&str>,
224    payload: &Value,
225    cursor: mouse::Cursor,
226) -> Vec<Event> {
227    match action {
228        "click" | "toggle" | "select" => {
229            // Click at the current cursor position.
230            let pos = match cursor {
231                mouse::Cursor::Available(p) | mouse::Cursor::Levitating(p) => p,
232                mouse::Cursor::Unavailable => Point::new(0.0, 0.0),
233            };
234            vec![
235                Event::Mouse(mouse::Event::CursorMoved { position: pos }),
236                Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
237                Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
238            ]
239        }
240        "type_text" => {
241            let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or("");
242            text.chars()
243                .flat_map(|c| {
244                    let s = SmolStr::new(c.to_string());
245                    let key = Key::Character(s.clone());
246                    [
247                        make_key_pressed(key.clone(), Modifiers::empty(), Some(s)),
248                        make_key_released(key, Modifiers::empty()),
249                    ]
250                })
251                .collect()
252        }
253        "type_key" => {
254            let payload_map = payload.as_object();
255            let (key_str, mods_json) = parse_key_and_modifiers(payload_map);
256            let key = parse_iced_key(&key_str);
257            let modifiers = parse_iced_modifiers(&mods_json);
258            let text = match &key {
259                Key::Character(c) if modifiers.is_empty() => Some(c.clone()),
260                _ => None,
261            };
262            vec![
263                make_key_pressed(key.clone(), modifiers, text),
264                make_key_released(key, modifiers),
265            ]
266        }
267        "press" => {
268            let payload_map = payload.as_object();
269            let (key_str, mods_json) = parse_key_and_modifiers(payload_map);
270            let key = parse_iced_key(&key_str);
271            let modifiers = parse_iced_modifiers(&mods_json);
272            let text = match &key {
273                Key::Character(c) if modifiers.is_empty() => Some(c.clone()),
274                _ => None,
275            };
276            vec![make_key_pressed(key, modifiers, text)]
277        }
278        "release" => {
279            let payload_map = payload.as_object();
280            let (key_str, mods_json) = parse_key_and_modifiers(payload_map);
281            let key = parse_iced_key(&key_str);
282            let modifiers = parse_iced_modifiers(&mods_json);
283            vec![make_key_released(key, modifiers)]
284        }
285        "submit" => {
286            let key = Key::Named(keyboard::key::Named::Enter);
287            vec![
288                make_key_pressed(key.clone(), Modifiers::empty(), None),
289                make_key_released(key, Modifiers::empty()),
290            ]
291        }
292        "scroll" => {
293            let delta_x = payload
294                .get("delta_x")
295                .and_then(|v| v.as_f64())
296                .unwrap_or(0.0) as f32;
297            let delta_y = payload
298                .get("delta_y")
299                .and_then(|v| v.as_f64())
300                .unwrap_or(0.0) as f32;
301            vec![Event::Mouse(mouse::Event::WheelScrolled {
302                delta: mouse::ScrollDelta::Lines {
303                    x: delta_x,
304                    y: delta_y,
305                },
306            })]
307        }
308        "move_to" => {
309            let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
310            let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
311            vec![Event::Mouse(mouse::Event::CursorMoved {
312                position: Point::new(x, y),
313            })]
314        }
315        // Canvas actions: convert to real iced mouse events so the
316        // canvas widget's Program::update() runs and produces shape-
317        // level events (enter/leave/click/drag) in headless mode.
318        "canvas_press" => {
319            let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
320            let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
321            let button = match payload.get("button").and_then(|v| v.as_str()) {
322                Some("right") => mouse::Button::Right,
323                Some("middle") => mouse::Button::Middle,
324                _ => mouse::Button::Left,
325            };
326            vec![
327                Event::Mouse(mouse::Event::CursorMoved {
328                    position: Point::new(x, y),
329                }),
330                Event::Mouse(mouse::Event::ButtonPressed(button)),
331            ]
332        }
333        "canvas_release" => {
334            let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
335            let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
336            let button = match payload.get("button").and_then(|v| v.as_str()) {
337                Some("right") => mouse::Button::Right,
338                Some("middle") => mouse::Button::Middle,
339                _ => mouse::Button::Left,
340            };
341            vec![
342                Event::Mouse(mouse::Event::CursorMoved {
343                    position: Point::new(x, y),
344                }),
345                Event::Mouse(mouse::Event::ButtonReleased(button)),
346            ]
347        }
348        "canvas_move" => {
349            let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
350            let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
351            vec![Event::Mouse(mouse::Event::CursorMoved {
352                position: Point::new(x, y),
353            })]
354        }
355        // Canvas element actions: synthesize click/focus at element center.
356        // The SDK resolves element coordinates from the tree; here we
357        // just handle the coordinate-based injection.
358        "click_element" => {
359            // The SDK provides the element's center as x/y in the payload.
360            // If not provided, this is a no-op (the scripting layer doesn't
361            // have access to parsed interactive elements).
362            let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
363            let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
364            vec![
365                Event::Mouse(mouse::Event::CursorMoved {
366                    position: Point::new(x, y),
367                }),
368                Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
369                Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
370            ]
371        }
372        "focus_element" => {
373            // Focus the canvas (Tab into it). The SDK handles setting the
374            // specific element focus via the focus_element widget_op.
375            vec![Event::Keyboard(iced::keyboard::Event::KeyPressed {
376                key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab),
377                modified_key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab),
378                physical_key: iced::keyboard::key::Physical::Code(iced::keyboard::key::Code::Tab),
379                location: iced::keyboard::Location::Standard,
380                modifiers: iced::keyboard::Modifiers::default(),
381                text: None,
382                repeat: false,
383            })]
384        }
385        // Synthetic-only actions: no iced event injection needed.
386        "paste" | "sort" | "pane_focus_cycle" | "slide" => vec![],
387        _ => vec![],
388    }
389}
390
391// ---------------------------------------------------------------------------
392// Wire I/O
393// ---------------------------------------------------------------------------
394
395/// Write a serialized response using the given codec.
396pub fn emit_wire<T: serde::Serialize>(
397    emitter: &crate::emitter::EventEmitter,
398    codec: &Codec,
399    value: &T,
400) -> io::Result<()> {
401    let bytes = codec.encode(value).map_err(io::Error::other)?;
402    emitter.write_raw(&bytes)
403}
404
405// ---------------------------------------------------------------------------
406// Tree search helpers
407// ---------------------------------------------------------------------------
408
409/// Walk the tree depth-first, returning the first node matching the predicate.
410/// `extract` converts the matching node to the desired return type.
411#[cfg(test)]
412fn search_tree<R>(
413    node: &TreeNode,
414    depth: usize,
415    predicate: &dyn Fn(&TreeNode) -> bool,
416    extract: &dyn Fn(&TreeNode) -> R,
417) -> Option<R> {
418    if depth > MAX_SELECTOR_SEARCH_DEPTH {
419        return None;
420    }
421    if predicate(node) {
422        return Some(extract(node));
423    }
424    for child in &node.children {
425        if let Some(found) = search_tree(child, depth + 1, predicate, extract) {
426            return Some(found);
427        }
428    }
429    None
430}
431
432// -- Extractors -------------------------------------------------------------
433
434fn node_to_value(node: &TreeNode) -> Value {
435    serde_json::to_value(node).unwrap_or(Value::Null)
436}
437
438// -- Predicates (test-only, production code uses plushie_core::Selector) -------------------------------------------------------------
439
440/// Match by explicit `a11y.role`, falling back to `type_name` only when no
441/// `a11y` prop is present at all.
442#[cfg(test)]
443fn matches_role(node: &TreeNode, role: &str) -> bool {
444    if let Some(a11y) = node.props.get("a11y") {
445        a11y.as_object()
446            .and_then(|m| m.get("role"))
447            .and_then(|v| v.as_str())
448            == Some(role)
449    } else {
450        node.type_name == role
451    }
452}
453
454/// Match by explicit `a11y.label`, falling back to `label` and `content` props.
455#[cfg(test)]
456fn matches_label(node: &TreeNode, label: &str) -> bool {
457    if let Some(a11y) = node.props.get("a11y")
458        && let Some(node_label) = a11y
459            .as_object()
460            .and_then(|m| m.get("label"))
461            .and_then(|v| v.as_str())
462        && node_label == label
463    {
464        return true;
465    }
466    for key in &["label", "content"] {
467        if let Some(val) = node.props.get(key)
468            && val.as_str() == Some(label)
469        {
470            return true;
471        }
472    }
473    false
474}
475
476/// Match against text content in `content`, `label`, `value`, and `placeholder` props.
477#[cfg(test)]
478fn matches_text(node: &TreeNode, text: &str) -> bool {
479    for key in &["content", "label", "value", "placeholder"] {
480        if let Some(val) = node.props.get(key)
481            && val.as_str() == Some(text)
482        {
483            return true;
484        }
485    }
486    false
487}
488
489/// Match nodes with `props.focused == true` or `a11y.focused == true`.
490#[cfg(test)]
491fn is_focused(node: &TreeNode) -> bool {
492    if node.props.get("focused").and_then(|v| v.as_bool()) == Some(true) {
493        return true;
494    }
495    if let Some(a11y) = node.props.get("a11y")
496        && a11y
497            .as_object()
498            .and_then(|m| m.get("focused"))
499            .and_then(|v| v.as_bool())
500            == Some(true)
501    {
502        return true;
503    }
504    false
505}
506
507// -- Public API (delegates to Selector tree search) -------------------------
508
509pub fn find_node_by_id(core: &Core, widget_id: &str, window_id: Option<&str>) -> Value {
510    let selector = match window_id {
511        Some(win) => Selector::id_in_window(widget_id, win),
512        None => Selector::id(widget_id),
513    };
514    find_by_selector(core, &selector)
515}
516
517pub fn find_node_by_text(core: &Core, text: &str) -> Value {
518    find_by_selector(core, &Selector::text(text))
519}
520
521pub fn find_node_by_role(core: &Core, role: &str) -> Value {
522    find_by_selector(core, &Selector::role(role))
523}
524
525pub fn find_node_by_label(core: &Core, label: &str) -> Value {
526    find_by_selector(core, &Selector::label(label))
527}
528
529pub fn find_focused_node(core: &Core) -> Value {
530    find_by_selector(core, &Selector::focused())
531}
532
533/// Find a node in the core tree using any Selector.
534pub fn find_by_selector(core: &Core, selector: &Selector) -> Value {
535    core.tree
536        .root()
537        .and_then(|root| selector.find(root))
538        .map(node_to_value)
539        .unwrap_or(Value::Null)
540}
541
542// -- Public API (ID only, delegates to Selector) ----------------------------
543
544pub fn find_id_by_text(node: &TreeNode, text: &str, _depth: usize) -> Option<String> {
545    Selector::text(text).find(node).map(|n| n.id.clone())
546}
547
548pub fn find_id_by_role(node: &TreeNode, role: &str, _depth: usize) -> Option<String> {
549    Selector::role(role).find(node).map(|n| n.id.clone())
550}
551
552pub fn find_id_by_label(node: &TreeNode, label: &str, _depth: usize) -> Option<String> {
553    Selector::label(label).find(node).map(|n| n.id.clone())
554}
555
556pub fn find_id_focused(node: &TreeNode, _depth: usize) -> Option<String> {
557    Selector::focused().find(node).map(|n| n.id.clone())
558}
559
560// ---------------------------------------------------------------------------
561// Message handlers
562// ---------------------------------------------------------------------------
563
564/// Build a QueryResponse without writing it anywhere.
565pub fn build_query_response(
566    core: &Core,
567    id: String,
568    target: String,
569    selector: Value,
570) -> QueryResponse {
571    let data = match target.as_str() {
572        "tree" => match core.tree.root() {
573            Some(root) => serde_json::to_value(root).unwrap_or(Value::Null),
574            None => Value::Null,
575        },
576        "find" => match parse_selector(&selector) {
577            Some(Selector::Id {
578                widget_id,
579                window_id,
580            }) => find_node_by_id(core, &widget_id, window_id.as_deref()),
581            Some(Selector::Text(text)) => find_node_by_text(core, &text),
582            Some(Selector::Role(role)) => find_node_by_role(core, &role),
583            Some(Selector::Label(label)) => find_node_by_label(core, &label),
584            Some(Selector::Focused) => find_focused_node(core),
585            None => Value::Null,
586        },
587        _ => {
588            log::warn!("unknown query target: {target}");
589            Value::Null
590        }
591    };
592
593    QueryResponse::new(id, target, data)
594}
595
596/// Build and emit a QueryResponse to stdout.
597pub fn handle_query(
598    emitter: &crate::emitter::EventEmitter,
599    codec: &Codec,
600    core: &Core,
601    id: String,
602    target: String,
603    selector: Value,
604) -> io::Result<()> {
605    emit_wire(
606        emitter,
607        codec,
608        &build_query_response(core, id, target, selector),
609    )
610}
611
612/// Resolve a selector to a widget ID without emitting anything.
613pub fn resolve_widget_id(core: &Core, selector: &Value) -> Option<String> {
614    let sel = Selector::from_wire(selector)?;
615    let root = core.tree.root()?;
616    sel.find(root).map(|node| node.id.clone())
617}
618
619/// Build an InteractResponse without writing it anywhere.
620pub fn build_interact_response(
621    core: &Core,
622    id: String,
623    action: String,
624    selector: Value,
625    payload: Value,
626) -> InteractResponse {
627    let widget_target = resolve_widget_target(core, &selector);
628
629    let events: Vec<OutgoingEvent> = match (action.as_str(), widget_target) {
630        ("click", Some((_window_id, wid))) => {
631            vec![OutgoingEvent::click(wid)]
632        }
633        ("type_text", Some((_window_id, wid))) => {
634            let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or("");
635            vec![OutgoingEvent::input(wid, text.to_string())]
636        }
637        ("submit", Some((_window_id, wid))) => {
638            let value = payload.get("value").and_then(|v| v.as_str()).unwrap_or("");
639            vec![OutgoingEvent::submit(wid, value.to_string())]
640        }
641        ("toggle", Some((_window_id, wid))) => {
642            let value = payload
643                .get("value")
644                .and_then(|v| v.as_bool())
645                .unwrap_or(false);
646            vec![OutgoingEvent::toggle(wid, value)]
647        }
648        ("select", Some((_window_id, wid))) => {
649            let value = payload.get("value").and_then(|v| v.as_str()).unwrap_or("");
650            vec![OutgoingEvent::select(wid, value.to_string())]
651        }
652        ("slide", Some((_window_id, wid))) => {
653            let value = payload.get("value").and_then(|v| v.as_f64()).unwrap_or(0.0);
654            vec![OutgoingEvent::slide(wid, value)]
655        }
656        ("press", _) => {
657            let payload_map = payload.as_object();
658            let (key, modifiers) = parse_key_and_modifiers(payload_map);
659            vec![OutgoingEvent::scripting_key_press(key, modifiers)]
660        }
661        ("release", _) => {
662            let payload_map = payload.as_object();
663            let (key, modifiers) = parse_key_and_modifiers(payload_map);
664            vec![OutgoingEvent::scripting_key_release(key, modifiers)]
665        }
666        ("move_to", _) => {
667            let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
668            let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
669            vec![OutgoingEvent::scripting_cursor_moved(x, y)]
670        }
671        ("type_key", _) => {
672            let payload_map = payload.as_object();
673            let (key, modifiers) = parse_key_and_modifiers(payload_map);
674            vec![
675                OutgoingEvent::scripting_key_press(key.clone(), modifiers.clone()),
676                OutgoingEvent::scripting_key_release(key, modifiers),
677            ]
678        }
679        ("paste", Some((_window_id, wid))) => {
680            let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or("");
681            vec![OutgoingEvent::paste(wid, text.to_string())]
682        }
683        // Widget-targeted scroll: scroll a specific scrollable widget.
684        ("scroll", Some((_window_id, wid))) => {
685            let delta_x = payload
686                .get("delta_x")
687                .and_then(|v| v.as_f64())
688                .unwrap_or(0.0);
689            let delta_y = payload
690                .get("delta_y")
691                .and_then(|v| v.as_f64())
692                .unwrap_or(0.0);
693            vec![OutgoingEvent::generic(
694                "scroll",
695                wid,
696                Some(serde_json::json!({"delta_x": delta_x, "delta_y": delta_y})),
697            )]
698        }
699        // Input simulation: wheel event at current cursor position.
700        ("scroll", None) => {
701            let delta_x = payload
702                .get("delta_x")
703                .and_then(|v| v.as_f64())
704                .unwrap_or(0.0) as f32;
705            let delta_y = payload
706                .get("delta_y")
707                .and_then(|v| v.as_f64())
708                .unwrap_or(0.0) as f32;
709            vec![OutgoingEvent::scripting_scroll(delta_x, delta_y)]
710        }
711        ("sort", Some((_window_id, wid))) => {
712            let column = payload.get("column").and_then(|v| v.as_str()).unwrap_or("");
713            vec![OutgoingEvent::generic(
714                "sort",
715                wid,
716                Some(serde_json::json!({"column": column})),
717            )]
718        }
719        ("pane_focus_cycle", Some((_window_id, wid))) => {
720            vec![OutgoingEvent::generic("pane_focus_cycle", wid, None)]
721        }
722        ("canvas_press", Some((window_id, wid))) => {
723            let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
724            let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
725            let button = payload
726                .get("button")
727                .and_then(|v| v.as_str())
728                .unwrap_or("left")
729                .to_string();
730            let mods = plushie_widget_sdk::protocol::KeyModifiers::default();
731
732            // Hit test against the canvas tree to determine if an
733            // interactive element was clicked. Coordinates are canvas-
734            // relative, matching the canvas widget's coordinate space.
735            if let Some(node) = core.tree.root().and_then(|root| {
736                find_tree_node_by_id_with_window(root, &wid, Some(&window_id), None, 0)
737            }) {
738                if let Some(element_id) =
739                    plushie_widget_sdk::runtime::canvas::canvas_hit_test(node, x, y)
740                {
741                    vec![OutgoingEvent::generic(
742                        "click",
743                        element_id,
744                        Some(serde_json::json!({
745                            "x": x, "y": y, "button": button,
746                        })),
747                    )]
748                } else if plushie_widget_sdk::runtime::canvas::canvas_has_on_press(node) {
749                    vec![OutgoingEvent::pointer_press(
750                        wid, x, y, &button, "mouse", None, mods,
751                    )]
752                } else {
753                    vec![]
754                }
755            } else {
756                vec![]
757            }
758        }
759        ("canvas_release", Some((window_id, wid))) => {
760            let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
761            let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
762            let button = payload
763                .get("button")
764                .and_then(|v| v.as_str())
765                .unwrap_or("left")
766                .to_string();
767            let mods = plushie_widget_sdk::protocol::KeyModifiers::default();
768
769            if let Some(node) = core.tree.root().and_then(|root| {
770                find_tree_node_by_id_with_window(root, &wid, Some(&window_id), None, 0)
771            }) {
772                if plushie_widget_sdk::runtime::canvas::canvas_has_on_press(node) {
773                    vec![OutgoingEvent::pointer_release(
774                        wid, x, y, &button, "mouse", None, mods,
775                    )]
776                } else {
777                    vec![]
778                }
779            } else {
780                vec![]
781            }
782        }
783        ("canvas_move", Some((window_id, wid))) => {
784            let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
785            let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
786            let mods = plushie_widget_sdk::protocol::KeyModifiers::default();
787
788            if let Some(node) = core.tree.root().and_then(|root| {
789                find_tree_node_by_id_with_window(root, &wid, Some(&window_id), None, 0)
790            }) {
791                // Check for element enter/leave + raw move event
792                let mut events = Vec::new();
793                if let Some(element_id) =
794                    plushie_widget_sdk::runtime::canvas::canvas_hit_test(node, x, y)
795                {
796                    events.push(OutgoingEvent::generic(
797                        "enter",
798                        element_id,
799                        Some(serde_json::json!({"x": x, "y": y})),
800                    ));
801                }
802                events.push(OutgoingEvent::pointer_move(wid, x, y, "mouse", None, mods));
803                events
804            } else {
805                vec![]
806            }
807        }
808        // Canvas element click via scoped ID (e.g. "my-canvas/save-button").
809        // The full scoped ID doesn't match any tree node, so widget_target
810        // is None. Walk prefixes to find the canvas node, verify the element
811        // exists, and emit a canvas_element_click with the full wire ID.
812        ("click", None) => {
813            let raw_id = parse_selector(&selector).and_then(|sel| match sel {
814                Selector::Id { widget_id, .. } => Some(widget_id),
815                _ => None,
816            });
817
818            if let Some(scoped_id) = raw_id.filter(|id| id.contains('/')) {
819                // Walk prefixes to find the canvas node in the tree.
820                let mut found_canvas = None;
821                let mut remaining = scoped_id.as_str();
822                while let Some(slash) = remaining.rfind('/') {
823                    let prefix = &scoped_id[..slash];
824                    let element_local = &scoped_id[slash + 1..];
825                    if let Some(window_id) = core
826                        .tree
827                        .root()
828                        .and_then(|root| find_window_id_for_node(root, prefix, None))
829                        && let Some(node) = core.tree.root().and_then(|root| {
830                            find_tree_node_by_id_with_window(
831                                root,
832                                prefix,
833                                Some(&window_id),
834                                None,
835                                0,
836                            )
837                        })
838                    {
839                        found_canvas = Some((node, window_id, prefix, element_local));
840                        break;
841                    }
842                    remaining = &scoped_id[..slash];
843                }
844
845                if let Some((node, _window_id, canvas_id, element_local)) = found_canvas {
846                    if plushie_widget_sdk::runtime::canvas::canvas_find_element_by_id(
847                        node,
848                        element_local,
849                    ) {
850                        vec![OutgoingEvent::generic(
851                            "click",
852                            scoped_id,
853                            Some(serde_json::json!({
854                                "x": 0.0, "y": 0.0, "button": "left",
855                            })),
856                        )]
857                    } else {
858                        log::warn!(
859                            "canvas element '{element_local}' not found in canvas '{canvas_id}'"
860                        );
861                        vec![]
862                    }
863                } else {
864                    log::warn!("click action: no canvas node found for '{scoped_id}'");
865                    vec![]
866                }
867            } else {
868                log::warn!("click action: widget not found");
869                vec![]
870            }
871        }
872        // Unknown action (check before selector_not_found so the
873        // diagnostic correctly identifies the root cause).
874        (action, _) if plushie_core::InteractAction::from_wire(action).is_none() => {
875            log::warn!("interact: unknown action '{action}'");
876            vec![OutgoingEvent::diagnostic(
877                String::new(),
878                None,
879                "warning",
880                "unknown_action",
881                &format!("unknown interact action: {action}"),
882            )]
883        }
884        // Known action but selector didn't resolve to a widget.
885        (action, None) => {
886            log::warn!("interact: widget not found for action '{action}'");
887            vec![OutgoingEvent::diagnostic(
888                String::new(),
889                None,
890                "warning",
891                "selector_not_found",
892                &format!("interact {action}: no widget matched the selector"),
893            )]
894        }
895        // Known action, selector resolved, but not handled above
896        // (shouldn't happen if all InteractAction variants are covered).
897        (action, _) => {
898            log::warn!("interact: unhandled action '{action}'");
899            vec![]
900        }
901    };
902
903    InteractResponse::new(id, events)
904}
905
906fn resolve_widget_target(core: &Core, selector: &Value) -> Option<(String, String)> {
907    let parsed = parse_selector(selector)?;
908    let widget_id = resolve_widget_id(core, selector)?;
909    let requested_window = match parsed {
910        Selector::Id { window_id, .. } => window_id,
911        _ => None,
912    };
913    let window_id = find_window_id_for_node(core.tree.root()?, &widget_id, None)?;
914    if let Some(expected) = requested_window
915        && expected != window_id
916    {
917        return None;
918    }
919    Some((window_id, widget_id))
920}
921
922fn find_tree_node_by_id_with_window<'a>(
923    node: &'a plushie_widget_sdk::protocol::TreeNode,
924    target_id: &str,
925    target_window_id: Option<&str>,
926    current_window_id: Option<&'a str>,
927    depth: usize,
928) -> Option<&'a plushie_widget_sdk::protocol::TreeNode> {
929    if depth > MAX_SELECTOR_SEARCH_DEPTH {
930        return None;
931    }
932
933    let current_window_id = if node.type_name == "window" {
934        Some(node.id.as_str())
935    } else {
936        current_window_id
937    };
938
939    if node.id == target_id
940        && target_window_id.is_none_or(|window_id| current_window_id == Some(window_id))
941    {
942        return Some(node);
943    }
944
945    node.children.iter().find_map(|child| {
946        find_tree_node_by_id_with_window(
947            child,
948            target_id,
949            target_window_id,
950            current_window_id,
951            depth + 1,
952        )
953    })
954}
955
956fn find_window_id_for_node(
957    node: &plushie_widget_sdk::protocol::TreeNode,
958    target_id: &str,
959    current_window_id: Option<&str>,
960) -> Option<String> {
961    let current_window_id = if node.type_name == "window" {
962        Some(node.id.as_str())
963    } else {
964        current_window_id
965    };
966
967    if node.id == target_id {
968        return current_window_id.map(str::to_string);
969    }
970
971    node.children
972        .iter()
973        .find_map(|child| find_window_id_for_node(child, target_id, current_window_id))
974}
975
976/// Build and emit an InteractResponse to stdout.
977pub fn handle_interact(
978    emitter: &crate::emitter::EventEmitter,
979    codec: &Codec,
980    core: &Core,
981    id: String,
982    action: String,
983    selector: Value,
984    payload: Value,
985) -> io::Result<()> {
986    emit_wire(
987        emitter,
988        codec,
989        &build_interact_response(core, id, action, selector, payload),
990    )
991}
992
993/// Reset core to a blank state and return the response.
994pub fn build_reset_response(core: &mut Core, id: String) -> ResetResponse {
995    *core = Core::new();
996    ResetResponse::ok(id)
997}
998
999/// Reset core and emit the response to stdout.
1000pub fn handle_reset(
1001    emitter: &crate::emitter::EventEmitter,
1002    codec: &Codec,
1003    core: &mut Core,
1004    id: String,
1005) -> io::Result<()> {
1006    emit_wire(emitter, codec, &build_reset_response(core, id))
1007}
1008
1009/// Build a TreeHashResponse without writing it anywhere.
1010pub fn build_tree_hash_response(core: &Core, id: String, name: String) -> TreeHashResponse {
1011    let hash = match plushie_widget_sdk::protocol::canonical_tree_hash(core.tree.root()) {
1012        Ok(hash) => hash,
1013        Err(e) => {
1014            log::error!("tree_hash: serialization failed: {e}");
1015            "SERIALIZATION_ERROR".to_string()
1016        }
1017    };
1018
1019    TreeHashResponse::new(id, name, hash)
1020}
1021
1022/// Build and emit a TreeHashResponse to stdout.
1023pub fn handle_tree_hash(
1024    emitter: &crate::emitter::EventEmitter,
1025    codec: &Codec,
1026    core: &Core,
1027    id: String,
1028    name: String,
1029) -> io::Result<()> {
1030    emit_wire(emitter, codec, &build_tree_hash_response(core, id, name))
1031}
1032
1033// ---------------------------------------------------------------------------
1034// Tests
1035// ---------------------------------------------------------------------------
1036
1037#[cfg(test)]
1038#[allow(unused_must_use)]
1039mod tests {
1040    use super::*;
1041    use plushie_widget_sdk::testing::{node as make_node, node_with_props};
1042    use serde_json::json;
1043
1044    /// No-op emitter for tests that verify logic, not wire output.
1045    fn test_emitter() -> crate::emitter::EventEmitter {
1046        use std::sync::Arc;
1047
1048        use parking_lot::Mutex;
1049        struct NullSink;
1050        impl crate::emitters::EventSink for NullSink {
1051            fn emit_event(
1052                &mut self,
1053                _: plushie_widget_sdk::protocol::OutgoingEvent,
1054            ) -> std::io::Result<()> {
1055                Ok(())
1056            }
1057            fn emit_effect_response(
1058                &mut self,
1059                _: plushie_widget_sdk::protocol::EffectResponse,
1060            ) -> std::io::Result<()> {
1061                Ok(())
1062            }
1063            fn emit_query_response(
1064                &mut self,
1065                _: &str,
1066                _: &str,
1067                _: &serde_json::Value,
1068            ) -> std::io::Result<()> {
1069                Ok(())
1070            }
1071            fn emit_screenshot_response(
1072                &mut self,
1073                _: &str,
1074                _: &str,
1075                _: &str,
1076                _: u32,
1077                _: u32,
1078                _: &[u8],
1079            ) -> std::io::Result<()> {
1080                Ok(())
1081            }
1082            fn emit_hello(
1083                &mut self,
1084                _: &str,
1085                _: &str,
1086                _: &[&str],
1087                _: &[&str],
1088                _: &str,
1089            ) -> std::io::Result<()> {
1090                Ok(())
1091            }
1092            fn emit_diagnostic(
1093                &mut self,
1094                _: plushie_widget_sdk::protocol::DiagnosticMessage,
1095            ) -> std::io::Result<()> {
1096                Ok(())
1097            }
1098            fn write_raw(&mut self, _: &[u8]) -> std::io::Result<()> {
1099                Ok(())
1100            }
1101        }
1102        let sink: Arc<Mutex<Box<dyn crate::emitters::EventSink>>> =
1103            Arc::new(Mutex::new(Box::new(NullSink)));
1104        crate::emitter::EventEmitter::new(sink)
1105    }
1106
1107    fn make_text_node(id: &str, content: &str) -> plushie_widget_sdk::protocol::TreeNode {
1108        node_with_props(id, "text", json!({"content": content}))
1109    }
1110
1111    fn text_node_at_depth(depth: usize, content: &str) -> plushie_widget_sdk::protocol::TreeNode {
1112        let mut target = make_text_node("target", content);
1113
1114        for level in (0..depth).rev() {
1115            target = plushie_widget_sdk::testing::node_with_children(
1116                &format!("level-{level}"),
1117                "column",
1118                vec![target],
1119            );
1120        }
1121
1122        target
1123    }
1124
1125    // -- parse_selector --
1126
1127    #[test]
1128    fn parse_selector_by_id() {
1129        let sel = json!({"by": "id", "value": "btn-1"});
1130        match parse_selector(&sel) {
1131            Some(Selector::Id {
1132                widget_id,
1133                window_id,
1134            }) => {
1135                assert_eq!(widget_id, "btn-1");
1136                assert_eq!(window_id, None);
1137            }
1138            other => panic!("expected Id, got {other:?}"),
1139        }
1140    }
1141
1142    #[test]
1143    fn parse_selector_by_id_with_window() {
1144        let sel = json!({"by": "id", "value": "form/save", "window_id": "prefs"});
1145        match parse_selector(&sel) {
1146            Some(Selector::Id {
1147                widget_id,
1148                window_id,
1149            }) => {
1150                assert_eq!(widget_id, "form/save");
1151                assert_eq!(window_id.as_deref(), Some("prefs"));
1152            }
1153            other => panic!("expected Id, got {other:?}"),
1154        }
1155    }
1156
1157    #[test]
1158    fn parse_selector_by_text() {
1159        let sel = json!({"by": "text", "value": "Click me"});
1160        match parse_selector(&sel) {
1161            Some(Selector::Text(t)) => assert_eq!(t, "Click me"),
1162            other => panic!("expected Text, got {other:?}"),
1163        }
1164    }
1165
1166    #[test]
1167    fn parse_selector_unknown_by() {
1168        let sel = json!({"by": "css", "value": ".foo"});
1169        assert!(parse_selector(&sel).is_none());
1170    }
1171
1172    #[test]
1173    fn parse_selector_missing_fields() {
1174        assert!(parse_selector(&json!({})).is_none());
1175        assert!(parse_selector(&json!({"by": "id"})).is_none());
1176        assert!(parse_selector(&Value::Null).is_none());
1177    }
1178
1179    #[test]
1180    fn search_tree_uses_core_selector_depth_limit() {
1181        let at_limit = text_node_at_depth(MAX_SELECTOR_SEARCH_DEPTH, "needle");
1182        assert!(
1183            search_tree(&at_limit, 0, &|n| matches_text(n, "needle"), &node_to_value).is_some()
1184        );
1185        assert!(
1186            find_tree_node_by_id_with_window(&at_limit, "target", None, None, 0).is_some(),
1187            "production ID lookup should include nodes at the shared depth limit"
1188        );
1189
1190        let past_limit = text_node_at_depth(MAX_SELECTOR_SEARCH_DEPTH + 1, "needle");
1191        assert!(
1192            search_tree(
1193                &past_limit,
1194                0,
1195                &|n| matches_text(n, "needle"),
1196                &node_to_value
1197            )
1198            .is_none()
1199        );
1200        assert!(
1201            find_tree_node_by_id_with_window(&past_limit, "target", None, None, 0).is_none(),
1202            "production ID lookup should stop after the shared depth limit"
1203        );
1204        assert!(Selector::text("needle").find(&past_limit).is_none());
1205    }
1206
1207    // -- parse_key_and_modifiers --
1208
1209    #[test]
1210    fn parse_key_plain() {
1211        let (key, mods) = parse_key_and_modifiers(None);
1212        assert_eq!(key, "");
1213        assert_eq!(mods["ctrl"], false);
1214    }
1215
1216    #[test]
1217    fn parse_key_combined_string() {
1218        let map: serde_json::Map<String, Value> =
1219            serde_json::from_value(json!({"key": "ctrl+shift+s"})).unwrap();
1220        let (key, mods) = parse_key_and_modifiers(Some(&map));
1221        assert_eq!(key, "s");
1222        assert_eq!(mods["ctrl"], true);
1223        assert_eq!(mods["shift"], true);
1224        assert_eq!(mods["alt"], false);
1225    }
1226
1227    #[test]
1228    fn parse_key_unknown_modifier_falls_back_to_literal_key() {
1229        let map: serde_json::Map<String, Value> =
1230            serde_json::from_value(json!({"key": "crtl+s"})).unwrap();
1231        let (key, mods) = parse_key_and_modifiers(Some(&map));
1232        assert_eq!(key, "crtl+s");
1233        assert_eq!(mods["ctrl"], false);
1234        assert_eq!(mods["shift"], false);
1235    }
1236
1237    #[test]
1238    fn parse_key_unknown_combo_modifier_falls_back_to_literal_key() {
1239        let map: serde_json::Map<String, Value> =
1240            serde_json::from_value(json!({"combo": "crtl+s"})).unwrap();
1241        let (key, mods) = parse_key_and_modifiers(Some(&map));
1242        assert_eq!(key, "crtl+s");
1243        assert_eq!(mods["ctrl"], false);
1244        assert_eq!(mods["shift"], false);
1245    }
1246
1247    #[test]
1248    fn parse_key_explicit_modifiers() {
1249        let map: serde_json::Map<String, Value> =
1250            serde_json::from_value(json!({"key": "a", "modifiers": {"alt": true}})).unwrap();
1251        let (key, mods) = parse_key_and_modifiers(Some(&map));
1252        assert_eq!(key, "a");
1253        assert_eq!(mods["alt"], true);
1254        assert_eq!(mods["ctrl"], false);
1255    }
1256
1257    #[test]
1258    fn parse_key_logo_aliases() {
1259        for alias in &["logo", "super", "meta"] {
1260            let combo = format!("{alias}+x");
1261            let map: serde_json::Map<String, Value> =
1262                serde_json::from_value(json!({"key": combo})).unwrap();
1263            let (key, mods) = parse_key_and_modifiers(Some(&map));
1264            assert_eq!(key, "x");
1265            assert_eq!(mods["logo"], true, "alias '{alias}' should set logo=true");
1266        }
1267    }
1268
1269    // -- tree search --
1270
1271    #[test]
1272    fn search_by_id_finds_root() {
1273        let root = make_node("root", "column");
1274        let result = search_tree(&root, 0, &|n| n.id == "root", &node_to_value);
1275        assert!(result.is_some());
1276        assert_eq!(result.unwrap()["id"], "root");
1277    }
1278
1279    #[test]
1280    fn search_by_id_finds_child() {
1281        let mut root = make_node("root", "column");
1282        root.children.push(make_node("child", "button"));
1283        let result = search_tree(&root, 0, &|n| n.id == "child", &node_to_value);
1284        assert!(result.is_some());
1285        assert_eq!(result.unwrap()["id"], "child");
1286    }
1287
1288    #[test]
1289    fn search_by_id_not_found() {
1290        let root = make_node("root", "column");
1291        assert!(search_tree(&root, 0, &|n| n.id == "missing", &node_to_value).is_none());
1292    }
1293
1294    #[test]
1295    fn search_by_text_finds_node() {
1296        let mut root = make_node("root", "column");
1297        root.children.push(make_text_node("lbl", "Hello World"));
1298        let result = search_tree(
1299            &root,
1300            0,
1301            &|n| matches_text(n, "Hello World"),
1302            &node_to_value,
1303        );
1304        assert!(result.is_some());
1305        assert_eq!(result.unwrap()["id"], "lbl");
1306    }
1307
1308    #[test]
1309    fn search_by_text_not_found() {
1310        let root = make_text_node("lbl", "Hello");
1311        assert!(search_tree(&root, 0, &|n| matches_text(n, "Goodbye"), &node_to_value).is_none());
1312    }
1313
1314    #[test]
1315    fn find_id_by_text_returns_id() {
1316        let mut root = make_node("root", "column");
1317        root.children.push(make_text_node("btn", "Submit"));
1318        assert_eq!(find_id_by_text(&root, "Submit", 0), Some("btn".to_string()));
1319    }
1320
1321    #[test]
1322    fn find_id_by_text_not_found() {
1323        let root = make_node("root", "column");
1324        assert_eq!(find_id_by_text(&root, "nope", 0), None);
1325    }
1326
1327    // -- find_node_by_* with Core --
1328
1329    #[test]
1330    fn find_node_by_id_empty_tree() {
1331        let core: Core = Core::new();
1332        assert_eq!(find_node_by_id(&core, "anything", None), Value::Null);
1333    }
1334
1335    #[test]
1336    fn find_node_by_text_empty_tree() {
1337        let core: Core = Core::new();
1338        assert_eq!(find_node_by_text(&core, "anything"), Value::Null);
1339    }
1340
1341    // -- handle_interact smoke tests --
1342    //
1343    // These verify handle_interact doesn't panic for various actions.
1344    // The output goes to stdout via emit_wire which we can't capture in
1345    // a unit test, but the test proves the code path doesn't crash.
1346    // We build a Core with a tree so selector resolution has something
1347    // to work with.
1348
1349    fn core_with_tree() -> Core {
1350        let mut core: Core = Core::new();
1351        let mut root = make_node("root", "column");
1352        root.children.push(make_text_node("btn1", "Click me"));
1353        root.children.push({
1354            let mut n = make_node("input1", "text_input");
1355            n.props = plushie_widget_sdk::protocol::Props::from_json(
1356                json!({"placeholder": "Type here", "value": ""}),
1357            );
1358            n
1359        });
1360        root.children.push({
1361            let mut n = make_node("toggle1", "toggler");
1362            n.props = plushie_widget_sdk::protocol::Props::from_json(json!({"is_toggled": false}));
1363            n
1364        });
1365        root.children.push({
1366            let mut n = make_node("slider1", "slider");
1367            n.props = plushie_widget_sdk::protocol::Props::from_json(
1368                json!({"min": 0.0, "max": 100.0, "value": 50.0}),
1369            );
1370            n
1371        });
1372        core.apply(plushie_widget_sdk::protocol::IncomingMessage::Snapshot { tree: root });
1373        core
1374    }
1375
1376    #[test]
1377    fn build_tree_hash_response_matches_core_tree_hash() {
1378        let core = core_with_tree();
1379        let response = build_tree_hash_response(&core, "q1".to_string(), "main".to_string());
1380        assert_eq!(response.hash, core.tree_hash());
1381    }
1382
1383    #[test]
1384    fn build_tree_hash_response_empty_tree_uses_empty_string() {
1385        let core = Core::new();
1386        let response = build_tree_hash_response(&core, "q1".to_string(), "main".to_string());
1387        assert_eq!(response.hash, "");
1388    }
1389
1390    #[test]
1391    fn handle_interact_click_does_not_panic() {
1392        let core = core_with_tree();
1393        handle_interact(
1394            &test_emitter(),
1395            &Codec::MsgPack,
1396            &core,
1397            "i1".to_string(),
1398            "click".to_string(),
1399            json!({"by": "id", "value": "btn1"}),
1400            json!({}),
1401        );
1402    }
1403
1404    #[test]
1405    fn handle_interact_type_text_does_not_panic() {
1406        let core = core_with_tree();
1407        handle_interact(
1408            &test_emitter(),
1409            &Codec::MsgPack,
1410            &core,
1411            "i2".to_string(),
1412            "type_text".to_string(),
1413            json!({"by": "id", "value": "input1"}),
1414            json!({"text": "hello"}),
1415        );
1416    }
1417
1418    #[test]
1419    fn handle_interact_submit_does_not_panic() {
1420        let core = core_with_tree();
1421        handle_interact(
1422            &test_emitter(),
1423            &Codec::MsgPack,
1424            &core,
1425            "i3".to_string(),
1426            "submit".to_string(),
1427            json!({"by": "id", "value": "input1"}),
1428            json!({"value": "submitted"}),
1429        );
1430    }
1431
1432    #[test]
1433    fn handle_interact_toggle_does_not_panic() {
1434        let core = core_with_tree();
1435        handle_interact(
1436            &test_emitter(),
1437            &Codec::MsgPack,
1438            &core,
1439            "i4".to_string(),
1440            "toggle".to_string(),
1441            json!({"by": "id", "value": "toggle1"}),
1442            json!({"value": true}),
1443        );
1444    }
1445
1446    #[test]
1447    fn handle_interact_select_does_not_panic() {
1448        let core = core_with_tree();
1449        handle_interact(
1450            &test_emitter(),
1451            &Codec::MsgPack,
1452            &core,
1453            "i5".to_string(),
1454            "select".to_string(),
1455            json!({"by": "id", "value": "btn1"}),
1456            json!({"value": "option_a"}),
1457        );
1458    }
1459
1460    #[test]
1461    fn handle_interact_slide_does_not_panic() {
1462        let core = core_with_tree();
1463        handle_interact(
1464            &test_emitter(),
1465            &Codec::MsgPack,
1466            &core,
1467            "i6".to_string(),
1468            "slide".to_string(),
1469            json!({"by": "id", "value": "slider1"}),
1470            json!({"value": 75.0}),
1471        );
1472    }
1473
1474    #[test]
1475    fn handle_interact_press_does_not_panic() {
1476        let core = core_with_tree();
1477        handle_interact(
1478            &test_emitter(),
1479            &Codec::MsgPack,
1480            &core,
1481            "i7".to_string(),
1482            "press".to_string(),
1483            json!({}),
1484            json!({"key": "ctrl+s"}),
1485        );
1486    }
1487
1488    #[test]
1489    fn handle_interact_release_does_not_panic() {
1490        let core = core_with_tree();
1491        handle_interact(
1492            &test_emitter(),
1493            &Codec::MsgPack,
1494            &core,
1495            "i8".to_string(),
1496            "release".to_string(),
1497            json!({}),
1498            json!({"key": "a"}),
1499        );
1500    }
1501
1502    #[test]
1503    fn handle_interact_move_to_does_not_panic() {
1504        let core = core_with_tree();
1505        handle_interact(
1506            &test_emitter(),
1507            &Codec::MsgPack,
1508            &core,
1509            "i9".to_string(),
1510            "move_to".to_string(),
1511            json!({}),
1512            json!({"x": 100.0, "y": 200.0}),
1513        );
1514    }
1515
1516    #[test]
1517    fn handle_interact_type_key_does_not_panic() {
1518        let core = core_with_tree();
1519        handle_interact(
1520            &test_emitter(),
1521            &Codec::MsgPack,
1522            &core,
1523            "i10".to_string(),
1524            "type_key".to_string(),
1525            json!({}),
1526            json!({"key": "enter"}),
1527        );
1528    }
1529
1530    #[test]
1531    fn handle_interact_unknown_action_does_not_panic() {
1532        let core = core_with_tree();
1533        handle_interact(
1534            &test_emitter(),
1535            &Codec::MsgPack,
1536            &core,
1537            "i11".to_string(),
1538            "nonexistent_action".to_string(),
1539            json!({"by": "id", "value": "btn1"}),
1540            json!({}),
1541        );
1542    }
1543
1544    #[test]
1545    fn handle_interact_selector_not_found_does_not_panic() {
1546        let core = core_with_tree();
1547        handle_interact(
1548            &test_emitter(),
1549            &Codec::MsgPack,
1550            &core,
1551            "i12".to_string(),
1552            "click".to_string(),
1553            json!({"by": "id", "value": "no_such_widget"}),
1554            json!({}),
1555        );
1556    }
1557
1558    #[test]
1559    fn handle_interact_by_text_selector() {
1560        let core = core_with_tree();
1561        handle_interact(
1562            &test_emitter(),
1563            &Codec::MsgPack,
1564            &core,
1565            "i13".to_string(),
1566            "click".to_string(),
1567            json!({"by": "text", "value": "Click me"}),
1568            json!({}),
1569        );
1570    }
1571
1572    // -- parse_selector: new variants --
1573
1574    #[test]
1575    fn parse_selector_by_role() {
1576        let sel = json!({"by": "role", "value": "button"});
1577        match parse_selector(&sel) {
1578            Some(Selector::Role(r)) => assert_eq!(r, "button"),
1579            other => panic!("expected Role, got {other:?}"),
1580        }
1581    }
1582
1583    #[test]
1584    fn parse_selector_by_label() {
1585        let sel = json!({"by": "label", "value": "Submit"});
1586        match parse_selector(&sel) {
1587            Some(Selector::Label(l)) => assert_eq!(l, "Submit"),
1588            other => panic!("expected Label, got {other:?}"),
1589        }
1590    }
1591
1592    #[test]
1593    fn parse_selector_focused() {
1594        let sel = json!({"by": "focused"});
1595        match parse_selector(&sel) {
1596            Some(Selector::Focused) => {}
1597            other => panic!("expected Focused, got {other:?}"),
1598        }
1599    }
1600
1601    #[test]
1602    fn parse_selector_focused_ignores_value() {
1603        // "focused" should work even if a value field is present
1604        let sel = json!({"by": "focused", "value": "ignored"});
1605        match parse_selector(&sel) {
1606            Some(Selector::Focused) => {}
1607            other => panic!("expected Focused, got {other:?}"),
1608        }
1609    }
1610
1611    // -- search_by_role --
1612
1613    fn make_a11y_node(id: &str, type_name: &str, a11y: Value) -> TreeNode {
1614        TreeNode {
1615            id: id.to_string(),
1616            type_name: type_name.to_string(),
1617            props: plushie_widget_sdk::protocol::Props::from_json(json!({"a11y": a11y})),
1618            children: vec![],
1619        }
1620    }
1621
1622    #[test]
1623    fn search_by_role_matches_a11y_prop() {
1624        let node = make_a11y_node("btn", "container", json!({"role": "button"}));
1625        let result = search_tree(&node, 0, &|n| matches_role(n, "button"), &node_to_value);
1626        assert!(result.is_some());
1627        assert_eq!(result.unwrap()["id"], "btn");
1628    }
1629
1630    #[test]
1631    fn search_by_role_matches_type_name() {
1632        let node = make_node("btn", "button");
1633        let result = search_tree(&node, 0, &|n| matches_role(n, "button"), &node_to_value);
1634        assert!(result.is_some());
1635        assert_eq!(result.unwrap()["id"], "btn");
1636    }
1637
1638    #[test]
1639    fn search_by_role_prefers_a11y_over_type() {
1640        // a11y role "heading" on a "container" type: should match "heading", not "container"
1641        let node = make_a11y_node("h1", "container", json!({"role": "heading"}));
1642        assert!(search_tree(&node, 0, &|n| matches_role(n, "heading"), &node_to_value).is_some());
1643        assert!(search_tree(&node, 0, &|n| matches_role(n, "container"), &node_to_value).is_none());
1644    }
1645
1646    #[test]
1647    fn search_by_role_finds_in_children() {
1648        let mut root = make_node("root", "column");
1649        root.children.push(make_a11y_node(
1650            "slider",
1651            "slider",
1652            json!({"role": "slider"}),
1653        ));
1654        let result = search_tree(&root, 0, &|n| matches_role(n, "slider"), &node_to_value);
1655        assert!(result.is_some());
1656        assert_eq!(result.unwrap()["id"], "slider");
1657    }
1658
1659    #[test]
1660    fn search_by_role_not_found() {
1661        let node = make_node("root", "column");
1662        assert!(search_tree(&node, 0, &|n| matches_role(n, "button"), &node_to_value).is_none());
1663    }
1664
1665    // -- search_by_label --
1666
1667    #[test]
1668    fn search_by_label_matches_a11y_label() {
1669        let node = make_a11y_node("btn", "button", json!({"label": "Submit"}));
1670        let result = search_tree(&node, 0, &|n| matches_label(n, "Submit"), &node_to_value);
1671        assert!(result.is_some());
1672        assert_eq!(result.unwrap()["id"], "btn");
1673    }
1674
1675    #[test]
1676    fn search_by_label_matches_label_prop() {
1677        let mut node = make_node("chk", "checkbox");
1678        node.props =
1679            plushie_widget_sdk::protocol::Props::from_json(json!({"label": "Accept terms"}));
1680        let result = search_tree(
1681            &node,
1682            0,
1683            &|n| matches_label(n, "Accept terms"),
1684            &node_to_value,
1685        );
1686        assert!(result.is_some());
1687        assert_eq!(result.unwrap()["id"], "chk");
1688    }
1689
1690    #[test]
1691    fn search_by_label_matches_content_prop() {
1692        let node = make_text_node("txt", "Hello World");
1693        let result = search_tree(
1694            &node,
1695            0,
1696            &|n| matches_label(n, "Hello World"),
1697            &node_to_value,
1698        );
1699        assert!(result.is_some());
1700        assert_eq!(result.unwrap()["id"], "txt");
1701    }
1702
1703    #[test]
1704    fn search_by_label_not_found() {
1705        let node = make_node("root", "column");
1706        assert!(search_tree(&node, 0, &|n| matches_label(n, "Missing"), &node_to_value).is_none());
1707    }
1708
1709    // -- search_focused --
1710
1711    #[test]
1712    fn search_focused_matches_focused_prop() {
1713        let mut node = make_node("inp", "text_input");
1714        node.props = plushie_widget_sdk::protocol::Props::from_json(json!({"focused": true}));
1715        let result = search_tree(&node, 0, &is_focused, &node_to_value);
1716        assert!(result.is_some());
1717        assert_eq!(result.unwrap()["id"], "inp");
1718    }
1719
1720    #[test]
1721    fn search_focused_matches_a11y_focused() {
1722        let node = make_a11y_node("inp", "text_input", json!({"focused": true}));
1723        let result = search_tree(&node, 0, &is_focused, &node_to_value);
1724        assert!(result.is_some());
1725        assert_eq!(result.unwrap()["id"], "inp");
1726    }
1727
1728    #[test]
1729    fn search_focused_skips_unfocused() {
1730        let mut node = make_node("inp", "text_input");
1731        node.props = plushie_widget_sdk::protocol::Props::from_json(json!({"focused": false}));
1732        assert!(search_tree(&node, 0, &is_focused, &node_to_value).is_none());
1733    }
1734
1735    #[test]
1736    fn search_focused_finds_in_children() {
1737        let mut root = make_node("root", "column");
1738        let mut child = make_node("inp", "text_input");
1739        child.props = plushie_widget_sdk::protocol::Props::from_json(json!({"focused": true}));
1740        root.children.push(child);
1741        let result = search_tree(&root, 0, &is_focused, &node_to_value);
1742        assert!(result.is_some());
1743        assert_eq!(result.unwrap()["id"], "inp");
1744    }
1745
1746    #[test]
1747    fn search_focused_not_found() {
1748        let root = make_node("root", "column");
1749        assert!(search_tree(&root, 0, &is_focused, &node_to_value).is_none());
1750    }
1751
1752    // -- find_id_by_role / find_id_by_label / find_id_focused --
1753
1754    #[test]
1755    fn find_id_by_role_returns_id() {
1756        let mut root = make_node("root", "column");
1757        root.children.push(make_node("btn", "button"));
1758        assert_eq!(find_id_by_role(&root, "button", 0), Some("btn".to_string()));
1759    }
1760
1761    #[test]
1762    fn find_id_by_label_returns_id() {
1763        let mut root = make_node("root", "column");
1764        root.children
1765            .push(make_a11y_node("btn", "button", json!({"label": "Submit"})));
1766        assert_eq!(
1767            find_id_by_label(&root, "Submit", 0),
1768            Some("btn".to_string())
1769        );
1770    }
1771
1772    #[test]
1773    fn find_id_focused_returns_id() {
1774        let mut root = make_node("root", "column");
1775        let mut child = make_node("inp", "text_input");
1776        child.props = plushie_widget_sdk::protocol::Props::from_json(json!({"focused": true}));
1777        root.children.push(child);
1778        assert_eq!(find_id_focused(&root, 0), Some("inp".to_string()));
1779    }
1780
1781    // -- handle_interact with new selectors --
1782
1783    #[test]
1784    fn handle_interact_by_role_does_not_panic() {
1785        let core = core_with_tree();
1786        handle_interact(
1787            &test_emitter(),
1788            &Codec::MsgPack,
1789            &core,
1790            "i14".to_string(),
1791            "click".to_string(),
1792            json!({"by": "role", "value": "text_input"}),
1793            json!({}),
1794        );
1795    }
1796
1797    #[test]
1798    fn handle_interact_by_label_does_not_panic() {
1799        let core = core_with_tree();
1800        handle_interact(
1801            &test_emitter(),
1802            &Codec::MsgPack,
1803            &core,
1804            "i15".to_string(),
1805            "click".to_string(),
1806            json!({"by": "label", "value": "Click me"}),
1807            json!({}),
1808        );
1809    }
1810
1811    #[test]
1812    fn handle_interact_paste_does_not_panic() {
1813        let core = core_with_tree();
1814        handle_interact(
1815            &test_emitter(),
1816            &Codec::MsgPack,
1817            &core,
1818            "i17".to_string(),
1819            "paste".to_string(),
1820            json!({"by": "id", "value": "input1"}),
1821            json!({"text": "pasted text"}),
1822        );
1823    }
1824
1825    #[test]
1826    fn handle_interact_scroll_does_not_panic() {
1827        let core = core_with_tree();
1828        handle_interact(
1829            &test_emitter(),
1830            &Codec::MsgPack,
1831            &core,
1832            "i18".to_string(),
1833            "scroll".to_string(),
1834            json!({}),
1835            json!({"delta_x": 0.0, "delta_y": -10.0}),
1836        );
1837    }
1838
1839    #[test]
1840    fn handle_interact_sort_does_not_panic() {
1841        let core = core_with_tree();
1842        handle_interact(
1843            &test_emitter(),
1844            &Codec::MsgPack,
1845            &core,
1846            "i19".to_string(),
1847            "sort".to_string(),
1848            json!({"by": "id", "value": "btn1"}),
1849            json!({"column": "name"}),
1850        );
1851    }
1852
1853    #[test]
1854    fn handle_interact_pane_focus_cycle_does_not_panic() {
1855        let core = core_with_tree();
1856        handle_interact(
1857            &test_emitter(),
1858            &Codec::MsgPack,
1859            &core,
1860            "i20".to_string(),
1861            "pane_focus_cycle".to_string(),
1862            json!({"by": "id", "value": "btn1"}),
1863            json!({}),
1864        );
1865    }
1866
1867    #[test]
1868    fn handle_interact_canvas_press_does_not_panic() {
1869        let core = core_with_tree();
1870        handle_interact(
1871            &test_emitter(),
1872            &Codec::MsgPack,
1873            &core,
1874            "i21".to_string(),
1875            "canvas_press".to_string(),
1876            json!({"by": "id", "value": "btn1"}),
1877            json!({"x": 50.0, "y": 75.0}),
1878        );
1879    }
1880
1881    #[test]
1882    fn handle_interact_canvas_release_does_not_panic() {
1883        let core = core_with_tree();
1884        handle_interact(
1885            &test_emitter(),
1886            &Codec::MsgPack,
1887            &core,
1888            "i22".to_string(),
1889            "canvas_release".to_string(),
1890            json!({"by": "id", "value": "btn1"}),
1891            json!({"x": 50.0, "y": 75.0}),
1892        );
1893    }
1894
1895    #[test]
1896    fn handle_interact_canvas_move_does_not_panic() {
1897        let core = core_with_tree();
1898        handle_interact(
1899            &test_emitter(),
1900            &Codec::MsgPack,
1901            &core,
1902            "i23".to_string(),
1903            "canvas_move".to_string(),
1904            json!({"by": "id", "value": "btn1"}),
1905            json!({"x": 60.0, "y": 80.0}),
1906        );
1907    }
1908
1909    #[test]
1910    fn handle_interact_focused_does_not_panic() {
1911        let core = core_with_tree();
1912        handle_interact(
1913            &test_emitter(),
1914            &Codec::MsgPack,
1915            &core,
1916            "i16".to_string(),
1917            "click".to_string(),
1918            json!({"by": "focused"}),
1919            json!({}),
1920        );
1921    }
1922
1923    // -- parse_iced_key / parse_iced_modifiers / interaction_to_iced_events --
1924
1925    #[test]
1926    fn parse_iced_key_named_enter() {
1927        assert_eq!(
1928            parse_iced_key("Enter"),
1929            Key::Named(keyboard::key::Named::Enter)
1930        );
1931        assert_eq!(
1932            parse_iced_key("enter"),
1933            Key::Named(keyboard::key::Named::Enter)
1934        );
1935    }
1936
1937    #[test]
1938    fn parse_iced_key_named_tab() {
1939        assert_eq!(parse_iced_key("Tab"), Key::Named(keyboard::key::Named::Tab));
1940    }
1941
1942    #[test]
1943    fn parse_iced_key_named_arrows() {
1944        assert_eq!(
1945            parse_iced_key("ArrowUp"),
1946            Key::Named(keyboard::key::Named::ArrowUp)
1947        );
1948        assert_eq!(
1949            parse_iced_key("Up"),
1950            Key::Named(keyboard::key::Named::ArrowUp)
1951        );
1952        assert_eq!(
1953            parse_iced_key("ArrowDown"),
1954            Key::Named(keyboard::key::Named::ArrowDown)
1955        );
1956    }
1957
1958    #[test]
1959    fn parse_iced_key_single_char() {
1960        assert_eq!(parse_iced_key("a"), Key::Character(SmolStr::new("a")));
1961        assert_eq!(parse_iced_key("Z"), Key::Character(SmolStr::new("Z")));
1962    }
1963
1964    #[test]
1965    fn parse_iced_key_function_keys() {
1966        assert_eq!(parse_iced_key("F1"), Key::Named(keyboard::key::Named::F1));
1967        assert_eq!(parse_iced_key("F12"), Key::Named(keyboard::key::Named::F12));
1968    }
1969
1970    #[test]
1971    fn parse_iced_modifiers_from_json() {
1972        let mods = json!({"shift": true, "ctrl": true, "alt": false, "logo": false});
1973        let result = parse_iced_modifiers(&mods);
1974        assert!(result.shift());
1975        assert!(result.control());
1976        assert!(!result.alt());
1977        assert!(!result.logo());
1978    }
1979
1980    #[test]
1981    fn parse_iced_modifiers_empty() {
1982        let mods = json!({});
1983        let result = parse_iced_modifiers(&mods);
1984        assert!(result.is_empty());
1985    }
1986
1987    #[test]
1988    fn interaction_to_iced_events_click() {
1989        let events = interaction_to_iced_events(
1990            "click",
1991            Some("btn1"),
1992            &json!({}),
1993            mouse::Cursor::Available(Point::new(100.0, 50.0)),
1994        );
1995        assert_eq!(events.len(), 3); // CursorMoved + ButtonPressed + ButtonReleased
1996    }
1997
1998    #[test]
1999    fn interaction_to_iced_events_type_text() {
2000        let events = interaction_to_iced_events(
2001            "type_text",
2002            Some("inp1"),
2003            &json!({"text": "hi"}),
2004            mouse::Cursor::Unavailable,
2005        );
2006        // 2 chars * 2 events each (press + release)
2007        assert_eq!(events.len(), 4);
2008    }
2009
2010    #[test]
2011    fn interaction_to_iced_events_scroll() {
2012        let events = interaction_to_iced_events(
2013            "scroll",
2014            None,
2015            &json!({"delta_x": 0.0, "delta_y": -10.0}),
2016            mouse::Cursor::Unavailable,
2017        );
2018        assert_eq!(events.len(), 1);
2019        match &events[0] {
2020            Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
2021                assert_eq!(*delta, mouse::ScrollDelta::Lines { x: 0.0, y: -10.0 });
2022            }
2023            _ => panic!("expected WheelScrolled"),
2024        }
2025    }
2026
2027    #[test]
2028    fn interaction_to_iced_events_move_to() {
2029        let events = interaction_to_iced_events(
2030            "move_to",
2031            None,
2032            &json!({"x": 42.0, "y": 84.0}),
2033            mouse::Cursor::Unavailable,
2034        );
2035        assert_eq!(events.len(), 1);
2036        match &events[0] {
2037            Event::Mouse(mouse::Event::CursorMoved { position }) => {
2038                assert_eq!(*position, Point::new(42.0, 84.0));
2039            }
2040            _ => panic!("expected CursorMoved"),
2041        }
2042    }
2043
2044    #[test]
2045    fn interaction_to_iced_events_synthetic_only() {
2046        // These actions should produce no iced events (synthetic-only).
2047        for action in &["paste", "sort", "pane_focus_cycle", "slide"] {
2048            let events = interaction_to_iced_events(
2049                action,
2050                Some("w1"),
2051                &json!({}),
2052                mouse::Cursor::Unavailable,
2053            );
2054            assert!(
2055                events.is_empty(),
2056                "action '{action}' should produce no iced events"
2057            );
2058        }
2059    }
2060
2061    #[test]
2062    fn interaction_to_iced_events_submit() {
2063        let events = interaction_to_iced_events(
2064            "submit",
2065            Some("inp1"),
2066            &json!({}),
2067            mouse::Cursor::Unavailable,
2068        );
2069        assert_eq!(events.len(), 2); // KeyPressed(Enter) + KeyReleased(Enter)
2070    }
2071
2072    #[test]
2073    fn interaction_to_iced_events_type_key() {
2074        let events = interaction_to_iced_events(
2075            "type_key",
2076            None,
2077            &json!({"key": "ctrl+s"}),
2078            mouse::Cursor::Unavailable,
2079        );
2080        assert_eq!(events.len(), 2); // KeyPressed + KeyReleased
2081    }
2082
2083    #[test]
2084    fn interaction_to_iced_events_press_release() {
2085        let press = interaction_to_iced_events(
2086            "press",
2087            None,
2088            &json!({"key": "a"}),
2089            mouse::Cursor::Unavailable,
2090        );
2091        assert_eq!(press.len(), 1);
2092
2093        let release = interaction_to_iced_events(
2094            "release",
2095            None,
2096            &json!({"key": "a"}),
2097            mouse::Cursor::Unavailable,
2098        );
2099        assert_eq!(release.len(), 1);
2100    }
2101
2102    // -- malformed payload tests --
2103
2104    #[test]
2105    fn handle_interact_slide_string_value_does_not_panic() {
2106        let core = core_with_tree();
2107        handle_interact(
2108            &test_emitter(),
2109            &Codec::MsgPack,
2110            &core,
2111            "bad1".to_string(),
2112            "slide".to_string(),
2113            json!({"by": "id", "value": "slider1"}),
2114            json!({"value": "not_a_number"}),
2115        );
2116    }
2117
2118    #[test]
2119    fn parse_key_and_modifiers_null_key() {
2120        let map: serde_json::Map<String, Value> =
2121            serde_json::from_value(json!({"key": null})).unwrap();
2122        let (key, mods) = parse_key_and_modifiers(Some(&map));
2123        assert_eq!(key, "");
2124        assert_eq!(mods["ctrl"], false);
2125    }
2126
2127    #[test]
2128    fn parse_selector_null_value() {
2129        let sel = json!({"by": "id", "value": null});
2130        assert!(parse_selector(&sel).is_none());
2131    }
2132}