Skip to main content

plushie_renderer_engine/
engine.rs

1//! Pure state engine, decoupled from the iced runtime.
2//!
3//! [`Core`] owns the UI tree, widget caches, and subscription state.
4//! It processes [`IncomingMessage`]s and returns [`CoreEffect`]s that
5//! the host (the iced `App` or the headless runner) must execute.
6//! Core never touches iced directly; it's pure state management.
7
8use std::collections::HashMap;
9
10use iced::Font;
11use serde_json::Value;
12
13use plushie_core::protocol::{IncomingMessage, OutgoingEvent};
14use plushie_widget_sdk::runtime::{self as runtime, SharedState};
15
16use crate::tree::Tree;
17
18/// Side effects produced by [`Core::apply`] that the host must handle.
19///
20/// Core is zero-I/O: it never writes to stdout, opens windows, or runs
21/// platform operations. Instead it returns these effects as commands for
22/// the host (the iced daemon or headless runner) to execute. This keeps
23/// Core testable and mode-agnostic.
24///
25/// Effects are returned in a `Vec` and should be processed in order.
26/// Some variants (e.g. `StateChange::SyncWindows`) may depend on prior
27/// tree mutations from the same `apply` call.
28///
29/// Variants are grouped by conceptual category so hosts can dispatch
30/// on the outer variant first (emit vs dispatch vs state change) and
31/// then on the inner typed sub-variant.
32#[derive(Debug)]
33pub enum CoreEffect {
34    /// Write something to the outgoing wire stream.
35    Emit(Emit),
36    /// Run a platform or widget operation against the renderer.
37    Dispatch(Dispatch),
38    /// Update host-owned state that lives outside Core.
39    StateChange(StateChange),
40}
41
42/// Outgoing wire payloads produced by Core. Every variant is a
43/// fully-formed message the host can encode and write without any
44/// further parsing.
45#[derive(Debug)]
46pub enum Emit {
47    /// Widget or subscription event.
48    Event(OutgoingEvent),
49    /// Response to an effect request (stub or synthetic).
50    EffectResponse(plushie_core::protocol::EffectResponse),
51    /// Acknowledgement that an effect stub registration changed.
52    StubAck(plushie_core::protocol::EffectStubAck),
53}
54
55/// Platform or widget operations the host must execute on Core's
56/// behalf. Core doesn't touch iced, stdout, or the filesystem; it
57/// produces these typed commands and the host dispatches them.
58#[derive(Debug)]
59pub enum Dispatch {
60    /// Handle a platform effect (file dialog, clipboard, notification).
61    ///
62    /// Core does not execute effects; it passes the raw request through
63    /// for the host to dispatch. The host decides whether to run the
64    /// effect synchronously, asynchronously (via Task::perform), or
65    /// return unsupported (e.g. in headless mode where file dialogs
66    /// are unavailable).
67    ///
68    /// # Known effect kinds
69    ///
70    /// **Async (file dialogs):** `file_open`, `file_open_multiple`,
71    /// `file_save`, `directory_select`, `directory_select_multiple`
72    ///
73    /// **Sync (clipboard):** `clipboard_read`, `clipboard_write`,
74    /// `clipboard_read_html`, `clipboard_write_html`, `clipboard_clear`,
75    /// `clipboard_read_primary`, `clipboard_write_primary`
76    ///
77    /// **Sync (notification):** `notification`
78    Effect {
79        request_id: String,
80        kind: String,
81        payload: Value,
82    },
83
84    /// Renderer-internal widget-targeted operation by op string.
85    ///
86    /// Covers focus, scroll, cursor, pane-grid ops, tree_hash queries,
87    /// list_images, load_font, announce, exit, find_focused.
88    WidgetOp { op: String, payload: Value },
89
90    /// Typed window operation (open, close, resize, move, ...).
91    Window(plushie_core::ops::WindowOp),
92
93    /// Typed window query (get_size, get_position, ...).
94    WindowQuery(plushie_core::ops::WindowQuery),
95
96    /// Typed system-wide operation.
97    System(plushie_core::ops::SystemOp),
98
99    /// Typed system-wide query.
100    SystemQuery(plushie_core::ops::SystemQuery),
101
102    /// Image registry operation that targets a specific handle.
103    ///
104    /// # Known ops
105    ///
106    /// `create_image`, `update_image`, `delete_image`. Registry-level
107    /// ops without per-image fields (`list`, `clear`) re-emit as
108    /// `WidgetOp` and share the existing handlers.
109    Image {
110        op: String,
111        handle: String,
112        data: Option<Vec<u8>>,
113        pixels: Option<Vec<u8>>,
114        width: Option<u32>,
115        height: Option<u32>,
116    },
117}
118
119/// Changes to host-owned state that lives outside Core.
120#[derive(Debug)]
121pub enum StateChange {
122    /// The window set may have changed; re-sync with renderer.
123    ///
124    /// Produced after every Snapshot and Patch that succeeds. The host
125    /// should compare `tree.window_ids()` against its open window set
126    /// and open/close as needed.
127    SyncWindows,
128
129    /// The global/root theme changed to an explicit value.
130    ///
131    /// The host should update its cached theme and set
132    /// `theme_follows_system = false`.
133    ThemeChanged(iced::Theme, runtime::ThemeChrome),
134
135    /// The root theme was set to `"system"`: the app-level theme
136    /// should follow the OS preference.
137    ThemeFollowsSystem,
138
139    /// Nodes removed during a patch that had "exit" props.
140    /// The host should promote these to ghost nodes for exit animations.
141    ExitNodes(Vec<(String, usize, plushie_core::protocol::TreeNode)>),
142
143    /// Widget configuration received from the host's Settings message.
144    ///
145    /// The host should call `dispatcher.init_all(&config, &theme, ...)`
146    /// to pass configuration and context to registered widgets.
147    WidgetConfig(Value),
148}
149
150/// A single subscription entry within a kind. Multiple entries per kind
151/// allow window-scoped subscriptions alongside global ones.
152#[derive(Debug, Clone)]
153pub struct SubscriptionEntry {
154    pub tag: String,
155    /// When set, only events from this window match. None = all windows.
156    pub window_id: Option<String>,
157    pub max_rate: Option<u32>,
158}
159
160/// Pure state core, decoupled from the iced runtime.
161///
162/// Owns the retained UI tree, widget caches, active subscriptions, and
163/// global rendering defaults. The host calls [`apply`](Self::apply) with
164/// each incoming message and executes the returned [`CoreEffect`]s.
165pub struct Core {
166    /// The retained UI tree (snapshots replace it, patches update it).
167    pub tree: Tree,
168    /// Caches for stateful widgets (text_editor content, markdown items, etc.).
169    pub caches: SharedState,
170    /// Active event subscriptions: kind -> list of entries.
171    /// Each kind can have multiple entries with different tags and
172    /// optional window scoping.
173    pub active_subscriptions: HashMap<String, Vec<SubscriptionEntry>>,
174    /// Global default event rate from Settings (events per second).
175    /// None = no limit (full speed).
176    pub default_event_rate: Option<u32>,
177    /// Global default text size from Settings.
178    pub default_text_size: Option<f32>,
179    /// Global default font from Settings.
180    pub default_font: Option<Font>,
181    /// Cached resolved theme from the root node's `theme` prop.
182    /// Only re-resolved when the raw JSON value changes.
183    pub cached_theme: Option<iced::Theme>,
184    pub cached_theme_chrome: runtime::ThemeChrome,
185    /// Content hash of the last resolved theme prop, used for change
186    /// detection. Replaces the previous `to_string()` approach which
187    /// allocated and compared a full JSON string on every check.
188    cached_theme_hash: Option<u64>,
189    /// True after the first Settings message has been applied. Used to
190    /// suppress warnings about startup-only fields on the initial Settings.
191    settings_applied: bool,
192    /// Registered effect stubs: kind -> response value. When an effect
193    /// request matches a stub, the renderer returns the stubbed response
194    /// immediately without executing the real effect. Used for testing
195    /// and scripting.
196    pub effect_stubs: HashMap<String, Value>,
197    /// Per-session prop-validation override.
198    ///
199    /// `Some(true)` enables validation for this session.
200    /// `None` falls back to the process-wide
201    /// [`is_validate_props_enabled`](runtime::is_validate_props_enabled)
202    /// check (which itself defaults to `cfg(debug_assertions)` when
203    /// no global value has been set). `validate_props: false` in
204    /// Settings does not disable validation; it leaves the fallback in
205    /// control so hosts cannot turn off debug/default validation for a
206    /// session.
207    pub validate_props: Option<bool>,
208}
209
210impl Default for Core {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216impl Core {
217    pub fn new() -> Self {
218        Self {
219            tree: Tree::new(),
220            caches: SharedState::new(),
221            active_subscriptions: HashMap::new(),
222            default_event_rate: None,
223            default_text_size: None,
224            default_font: None,
225            cached_theme: None,
226            cached_theme_chrome: runtime::ThemeChrome::default(),
227            cached_theme_hash: None,
228            settings_applied: false,
229            effect_stubs: HashMap::new(),
230            validate_props: None,
231        }
232    }
233
234    /// Resolve whether prop validation should run for this session.
235    ///
236    /// A per-session `true` forces validation on. Otherwise the
237    /// process-wide flag decides.
238    pub fn is_validate_props_enabled(&self) -> bool {
239        match self.validate_props {
240            Some(true) => true,
241            None => runtime::is_validate_props_enabled(),
242            Some(false) => runtime::is_validate_props_enabled(),
243        }
244    }
245
246    /// Check whether at least one entry is registered for the given kind.
247    pub fn has_subscription(&self, kind: &str) -> bool {
248        self.active_subscriptions
249            .get(kind)
250            .is_some_and(|entries| !entries.is_empty())
251    }
252
253    /// Return all entries matching a kind, filtered by window_id.
254    /// An entry matches if its window_id is None (global) or equals
255    /// the event's window_id.
256    pub fn matching_entries(&self, kind: &str, window_id: Option<&str>) -> Vec<&SubscriptionEntry> {
257        match self.active_subscriptions.get(kind) {
258            Some(entries) => entries
259                .iter()
260                .filter(|e| match (&e.window_id, window_id) {
261                    (None, _) => true,
262                    (Some(sub_wid), Some(evt_wid)) => sub_wid == evt_wid,
263                    (Some(_), None) => false,
264                })
265                .collect(),
266            None => Vec::new(),
267        }
268    }
269
270    /// Return all entries matching a specific kind plus the catch-all
271    /// SUB_EVENT kind, filtered by window_id. Useful for event emission
272    /// where both specific and catch-all subscriptions should fire.
273    pub fn matching_entries_with_catchall(
274        &self,
275        kind: &str,
276        catchall_kind: &str,
277        window_id: Option<&str>,
278    ) -> Vec<&SubscriptionEntry> {
279        let mut entries = self.matching_entries(kind, window_id);
280        if kind != catchall_kind {
281            entries.extend(self.matching_entries(catchall_kind, window_id));
282        }
283        entries
284    }
285
286    /// Collect all max_rate values from subscription entries, keyed by tag.
287    /// Returns (tag, max_rate) pairs for entries that have a max_rate set.
288    /// The tag includes window scope when present, so rate limiting is
289    /// isolated per subscription entry.
290    pub fn subscription_rates(&self) -> impl Iterator<Item = (&str, u32)> {
291        self.active_subscriptions.values().flat_map(|entries| {
292            entries
293                .iter()
294                .filter_map(|e| e.max_rate.map(|r| (e.tag.as_str(), r)))
295        })
296    }
297
298    /// Collect all tags that have a max_rate set.
299    pub fn subscription_rate_tags(&self) -> impl Iterator<Item = &str> {
300        self.active_subscriptions.values().flat_map(|entries| {
301            entries
302                .iter()
303                .filter(|e| e.max_rate.is_some())
304                .map(|e| e.tag.as_str())
305        })
306    }
307
308    /// Compute the canonical SHA-256 hash of the current tree.
309    /// Returns the hex-encoded hash string, or an empty string if no tree.
310    pub fn tree_hash(&self) -> String {
311        match plushie_core::protocol::canonical_tree_hash(self.tree.root()) {
312            Ok(hash) => hash,
313            Err(e) => {
314                log::error!("tree_hash: serialization failed: {e}");
315                "SERIALIZATION_ERROR".to_string()
316            }
317        }
318    }
319
320    /// Resolve and cache a theme from a JSON prop value. Only re-resolves
321    /// when the serialized JSON differs from the cached version.
322    fn resolve_and_cache_theme(
323        &mut self,
324        theme_val: &serde_json::Value,
325        effects: &mut Vec<CoreEffect>,
326    ) {
327        use std::collections::hash_map::DefaultHasher;
328        use std::hash::Hasher;
329
330        let mut hasher = DefaultHasher::new();
331        plushie_widget_sdk::shared_state::hash_json_value(theme_val, &mut hasher);
332        let hash = hasher.finish();
333
334        if self.cached_theme_hash == Some(hash) {
335            // Theme prop unchanged, skip resolution.
336            return;
337        }
338        match runtime::resolve_theme_resolution(theme_val) {
339            runtime::ThemeResolution::Theme(theme, chrome) => {
340                self.cached_theme_hash = Some(hash);
341                self.cached_theme = Some(theme.clone());
342                self.cached_theme_chrome = chrome;
343                effects.push(CoreEffect::StateChange(StateChange::ThemeChanged(
344                    theme, chrome,
345                )));
346            }
347            runtime::ThemeResolution::System => {
348                self.cached_theme_hash = Some(hash);
349                self.cached_theme = None;
350                self.cached_theme_chrome = runtime::ThemeChrome::default();
351                effects.push(CoreEffect::StateChange(StateChange::ThemeFollowsSystem));
352            }
353            runtime::ThemeResolution::Invalid => self.clear_cached_theme(effects),
354        }
355    }
356
357    fn clear_cached_theme(&mut self, effects: &mut Vec<CoreEffect>) {
358        if self.cached_theme_hash.is_none() {
359            return;
360        }
361
362        self.cached_theme = None;
363        self.cached_theme_chrome = runtime::ThemeChrome::default();
364        self.cached_theme_hash = None;
365        effects.push(CoreEffect::StateChange(StateChange::ThemeFollowsSystem));
366    }
367
368    /// Process an incoming message, mutate state, return effects.
369    pub fn apply(&mut self, message: IncomingMessage) -> Vec<CoreEffect> {
370        let mut effects = Vec::new();
371
372        match message {
373            IncomingMessage::Snapshot { tree } => {
374                log::debug!("snapshot received (root id={})", tree.id);
375                if let Some(theme_val) = tree.props.get_value("theme") {
376                    self.resolve_and_cache_theme(&theme_val, &mut effects);
377                } else {
378                    self.clear_cached_theme(&mut effects);
379                }
380                if let Err(duplicates) = self.tree.snapshot(tree) {
381                    let dup_list = duplicates.join(", ");
382                    log::error!("snapshot contains duplicate node IDs: {dup_list}");
383                    effects.push(CoreEffect::Emit(Emit::Event(OutgoingEvent::generic(
384                        "error".to_string(),
385                        "duplicate_node_ids".to_string(),
386                        Some(serde_json::json!({
387                            "error": "snapshot contains duplicate node IDs",
388                            "duplicates": duplicates,
389                        })),
390                    ))));
391                }
392                self.caches.clear();
393                if let Some(root) = self.tree.root()
394                    && self.is_validate_props_enabled()
395                {
396                    Self::emit_prop_validation_warnings(root, &mut effects);
397                }
398                effects.push(CoreEffect::StateChange(StateChange::SyncWindows));
399            }
400            IncomingMessage::Patch { ops } => {
401                log::debug!("patch received ({} ops)", ops.len());
402                if let Err(error) = Tree::validate_patch_order(&ops) {
403                    log::error!("invalid patch order: {error}");
404                    effects.push(CoreEffect::Emit(Emit::Event(OutgoingEvent::generic(
405                        "error",
406                        "patch_order",
407                        Some(serde_json::json!({
408                            "error": error,
409                        })),
410                    ))));
411                    return effects;
412                }
413                let exit_nodes = self.tree.apply_patch(ops);
414                if !exit_nodes.is_empty() {
415                    effects.push(CoreEffect::StateChange(StateChange::ExitNodes(exit_nodes)));
416                }
417                // Re-check root theme prop in case a patch changed it.
418                if let Some(root) = self.tree.root() {
419                    if let Some(theme_val) = root.props.get_value("theme") {
420                        self.resolve_and_cache_theme(&theme_val, &mut effects);
421                    } else {
422                        self.clear_cached_theme(&mut effects);
423                    }
424                }
425                if let Some(root) = self.tree.root()
426                    && self.is_validate_props_enabled()
427                {
428                    Self::emit_prop_validation_warnings(root, &mut effects);
429                }
430                effects.push(CoreEffect::StateChange(StateChange::SyncWindows));
431            }
432            IncomingMessage::Effect { id, kind, payload } => {
433                log::debug!("effect request: {kind} ({id})");
434                if id.is_empty() {
435                    log::warn!("effect request missing response id: {kind}");
436                    effects.push(CoreEffect::Emit(Emit::Event(OutgoingEvent::generic(
437                        "error",
438                        "effect",
439                        Some(serde_json::json!({
440                            "error": "effect request missing response id",
441                            "kind": kind,
442                        })),
443                    ))));
444                } else if let Err(err) =
445                    plushie_core::ops::validate_effect_request_from_wire(&kind, &payload)
446                {
447                    log::warn!("invalid effect request: {err}");
448                    effects.push(CoreEffect::Emit(Emit::EffectResponse(
449                        plushie_core::protocol::EffectResponse::error(id, err.to_string()),
450                    )));
451                } else if let Some(stub_response) = self.effect_stubs.get(&kind) {
452                    log::debug!("effect stub hit: {kind} ({id})");
453                    effects.push(CoreEffect::Emit(Emit::EffectResponse(
454                        plushie_core::protocol::EffectResponse::ok(id, stub_response.clone()),
455                    )));
456                } else {
457                    effects.push(CoreEffect::Dispatch(Dispatch::Effect {
458                        request_id: id,
459                        kind,
460                        payload,
461                    }));
462                }
463            }
464            IncomingMessage::WidgetOp { op, payload } => {
465                log::debug!("widget_op: {op}");
466                effects.push(CoreEffect::Dispatch(Dispatch::WidgetOp { op, payload }));
467            }
468            IncomingMessage::Subscribe {
469                kind,
470                tag,
471                window_id,
472                max_rate,
473            } => {
474                log::debug!("subscription register: {kind} -> {tag} (window: {window_id:?})");
475                let entries = self.active_subscriptions.entry(kind.clone()).or_default();
476                // Update existing entry with same tag, or add a new one.
477                if let Some(existing) = entries.iter_mut().find(|e| e.tag == tag) {
478                    existing.window_id = window_id;
479                    existing.max_rate = max_rate;
480                } else {
481                    entries.push(SubscriptionEntry {
482                        tag,
483                        window_id,
484                        max_rate,
485                    });
486                }
487            }
488            IncomingMessage::Unsubscribe { kind, tag } => {
489                if let Some(tag) = tag {
490                    log::debug!("subscription unregister: {kind} tag={tag}");
491                    if let Some(entries) = self.active_subscriptions.get_mut(&kind) {
492                        entries.retain(|e| e.tag != tag);
493                        if entries.is_empty() {
494                            self.active_subscriptions.remove(&kind);
495                        }
496                    }
497                } else {
498                    log::debug!("subscription unregister: {kind} (all)");
499                    self.active_subscriptions.remove(&kind);
500                }
501            }
502            IncomingMessage::WindowOp {
503                op,
504                window_id,
505                payload,
506            } => {
507                log::debug!("window_op: {op} ({window_id})");
508                if let Some(typed) =
509                    plushie_core::ops::WindowOp::from_wire(&op, &window_id, &payload)
510                {
511                    effects.push(CoreEffect::Dispatch(Dispatch::Window(typed)));
512                } else if let Some(typed) =
513                    plushie_core::ops::WindowQuery::from_wire(&op, &window_id, &payload)
514                {
515                    effects.push(CoreEffect::Dispatch(Dispatch::WindowQuery(typed)));
516                } else {
517                    log::warn!("unknown window_op: {op}");
518                }
519            }
520            IncomingMessage::SystemOp { op, payload } => {
521                log::debug!("system_op: {op}");
522                if let Some(typed) = plushie_core::ops::SystemOp::from_wire(&op, &payload) {
523                    effects.push(CoreEffect::Dispatch(Dispatch::System(typed)));
524                } else {
525                    log::warn!("unknown system_op: {op}");
526                }
527            }
528            IncomingMessage::SystemQuery { op, payload } => {
529                log::debug!("system_query: {op}");
530                if let Some(typed) = plushie_core::ops::SystemQuery::from_wire(&op, &payload) {
531                    effects.push(CoreEffect::Dispatch(Dispatch::SystemQuery(typed)));
532                } else {
533                    log::warn!("unknown system_query: {op}");
534                }
535            }
536            IncomingMessage::Settings { settings } => {
537                log::debug!("settings received");
538
539                // Protocol version was already validated by
540                // renderer::startup::perform_handshake before we got
541                // here; no second check needed.
542
543                // Typed deny_unknown_fields pass: logs per-field
544                // diagnostics for unknown keys and type mismatches
545                // without failing the whole parse.
546                validate_wire_settings(&settings);
547
548                // Startup-only fields are extracted by run.rs before the
549                // daemon starts. Subsequent Settings messages can't change
550                // them, so warn if the host sends them again.
551                if self.settings_applied {
552                    for field in &["antialiasing", "vsync", "fonts", "scale_factor"] {
553                        if settings.get(*field).is_some() {
554                            log::warn!(
555                                "Settings field `{field}` is startup-only; \
556                                 ignored after the daemon has started"
557                            );
558                        }
559                    }
560                }
561                self.settings_applied = true;
562
563                self.default_event_rate = settings
564                    .get("default_event_rate")
565                    .and_then(|v| v.as_u64())
566                    .map(|v| v as u32);
567                self.default_text_size = settings
568                    .get("default_text_size")
569                    .and_then(|v| v.as_f64())
570                    .map(plushie_widget_sdk::prop_helpers::f64_to_f32);
571                self.default_font = settings.get("default_font").map(resolve_font_with_fallback);
572                // Per-session validate_props override. Only `true`
573                // forces validation on for this session. `false`
574                // leaves the process/debug default in control.
575                if settings
576                    .get("validate_props")
577                    .and_then(|v| v.as_bool())
578                    .unwrap_or(false)
579                {
580                    self.validate_props = Some(true);
581                }
582                let ext_config = settings
583                    .get("widget_config")
584                    .cloned()
585                    .unwrap_or(Value::Null);
586                effects.push(CoreEffect::StateChange(StateChange::WidgetConfig(
587                    ext_config,
588                )));
589            }
590            IncomingMessage::ImageOp { op, payload } => {
591                log::debug!("image_op: {op} ({handle})", handle = payload.handle);
592                match op.as_str() {
593                    // `list` and `clear` are registry-level ops with no
594                    // per-image fields. Re-emit through the existing
595                    // widget-op handlers so the shared logic stays in
596                    // one place; the typed wire shape replaces the old
597                    // `widget_op` envelope on the wire.
598                    "list" => {
599                        let payload_value = match payload.tag {
600                            Some(tag) => serde_json::json!({"tag": tag}),
601                            None => Value::Null,
602                        };
603                        effects.push(CoreEffect::Dispatch(Dispatch::WidgetOp {
604                            op: "list_images".to_string(),
605                            payload: payload_value,
606                        }));
607                    }
608                    "clear" => {
609                        effects.push(CoreEffect::Dispatch(Dispatch::WidgetOp {
610                            op: "clear_images".to_string(),
611                            payload: Value::Null,
612                        }));
613                    }
614                    _ => {
615                        effects.push(CoreEffect::Dispatch(Dispatch::Image {
616                            op,
617                            handle: payload.handle,
618                            data: payload.data,
619                            pixels: payload.pixels,
620                            width: payload.width,
621                            height: payload.height,
622                        }));
623                    }
624                }
625            }
626            IncomingMessage::LoadFont { payload } => {
627                log::debug!("load_font: family={}", payload.family);
628                // Re-emit as the existing WidgetOp dispatch path so the
629                // shared "load_font" handler (renderer-lib widget_ops
630                // and headless's `load_font_from_payload`) keeps a
631                // single applied site. The typed message's win is a
632                // clean wire shape and native msgpack binary; the
633                // internal dispatch shape is unchanged.
634                let data_json = match payload.data {
635                    Some(bytes) => {
636                        use base64::Engine;
637                        Value::String(base64::engine::general_purpose::STANDARD.encode(&bytes))
638                    }
639                    None => Value::Null,
640                };
641                let payload_value = serde_json::json!({
642                    "family": payload.family,
643                    "data": data_json,
644                });
645                effects.push(CoreEffect::Dispatch(Dispatch::WidgetOp {
646                    op: "load_font".to_string(),
647                    payload: payload_value,
648                }));
649            }
650            // Scripting messages handled by the renderer binary (daemon /
651            // headless), not by Core. Listed explicitly so adding a new
652            // IncomingMessage variant produces a compile error here instead
653            // of silently falling through a catch-all `_` arm.
654            IncomingMessage::Query { .. } => {
655                log::debug!("Query message ignored by Core (handled by scripting layer)");
656            }
657            IncomingMessage::Interact { .. } => {
658                log::debug!("Interact message ignored by Core (handled by scripting layer)");
659            }
660            IncomingMessage::TreeHash { .. } => {
661                log::debug!("TreeHash message ignored by Core (handled by scripting layer)");
662            }
663            IncomingMessage::Screenshot { .. } => {
664                log::debug!("Screenshot message ignored by Core (handled by scripting layer)");
665            }
666            IncomingMessage::Reset { .. } => {
667                log::debug!("Reset message ignored by Core (handled by scripting layer)");
668            }
669            IncomingMessage::Command { .. } => {
670                log::debug!("Command message ignored by Core (handled by renderer App)");
671            }
672            IncomingMessage::Commands { .. } => {
673                log::debug!("Commands message ignored by Core (handled by renderer App)");
674            }
675            IncomingMessage::AdvanceFrame { .. } => {
676                log::warn!(
677                    "AdvanceFrame is only supported in headless/test mode; ignored in daemon mode"
678                );
679            }
680            IncomingMessage::RegisterEffectStub { kind, response } => {
681                if plushie_core::ops::is_known_effect_kind(&kind) {
682                    log::info!("effect stub registered: {kind}");
683                    self.effect_stubs.insert(kind.clone(), response);
684                    effects.push(CoreEffect::Emit(Emit::StubAck(
685                        plushie_core::protocol::EffectStubAck::registered(kind),
686                    )));
687                } else {
688                    log::warn!("unknown effect stub kind: {kind}");
689                    effects.push(CoreEffect::Emit(Emit::StubAck(
690                        plushie_core::protocol::EffectStubAck::register_error(kind),
691                    )));
692                }
693            }
694            IncomingMessage::UnregisterEffectStub { kind } => {
695                if plushie_core::ops::is_known_effect_kind(&kind) {
696                    log::info!("effect stub unregistered: {kind}");
697                    self.effect_stubs.remove(&kind);
698                    effects.push(CoreEffect::Emit(Emit::StubAck(
699                        plushie_core::protocol::EffectStubAck::unregistered(kind),
700                    )));
701                } else {
702                    log::warn!("unknown effect stub kind: {kind}");
703                    effects.push(CoreEffect::Emit(Emit::StubAck(
704                        plushie_core::protocol::EffectStubAck::unregister_error(kind),
705                    )));
706                }
707            }
708        }
709
710        effects
711    }
712
713    /// Walk the tree and emit prop validation warnings as wire events.
714    /// Called after Snapshot and Patch when validate_props is enabled.
715    fn emit_prop_validation_warnings(
716        root: &plushie_core::protocol::TreeNode,
717        effects: &mut Vec<CoreEffect>,
718    ) {
719        Self::validate_node_recursive(root, effects);
720    }
721
722    fn validate_node_recursive(
723        node: &plushie_core::protocol::TreeNode,
724        effects: &mut Vec<CoreEffect>,
725    ) {
726        let warnings = runtime::collect_prop_warnings(node);
727        if !warnings.is_empty() {
728            effects.push(CoreEffect::Emit(Emit::Event(OutgoingEvent::generic(
729                "prop_validation",
730                node.id.clone(),
731                Some(serde_json::json!({
732                    "node_id": node.id,
733                    "node_type": node.type_name,
734                    "warnings": warnings,
735                })),
736            ))));
737        }
738        for child in &node.children {
739            Self::validate_node_recursive(child, effects);
740        }
741    }
742}
743
744/// Resolve a font family from a `default_font` settings entry,
745/// walking the optional fallback chain. Emits a
746/// `font_family_not_found` diagnostic on each unresolved family.
747///
748/// Resolution order per name:
749/// 1. Built-in shortcut: `monospace` -> `Font::MONOSPACE`.
750/// 2. Runtime-loaded family via [`plushie_widget_sdk::fonts::is_loaded`] (populated
751///    by `Command::load_font` at execution time).
752/// 3. Fall through to the next name in the chain and emit a
753///    `font_family_not_found` diagnostic.
754///
755/// If every name falls through, returns `Font::DEFAULT`.
756fn resolve_font_with_fallback(v: &Value) -> Font {
757    let primary = v.get("family").and_then(|f| f.as_str());
758    let fallback_iter = v.get("fallback").and_then(|a| a.as_array());
759    let mut chain: Vec<&str> = Vec::new();
760    if let Some(p) = primary {
761        chain.push(p);
762    }
763    if let Some(arr) = fallback_iter {
764        for entry in arr {
765            if let Some(s) = entry.as_str() {
766                chain.push(s);
767            }
768        }
769    }
770    for name in &chain {
771        if matches!(*name, "monospace") {
772            return Font::MONOSPACE;
773        }
774        if plushie_widget_sdk::fonts::is_loaded(name)
775            && let Some(interned) = runtime::intern_font_family_public(name)
776        {
777            return Font {
778                family: iced::font::Family::Name(interned),
779                ..Font::DEFAULT
780            };
781        }
782        plushie_core::diagnostics::warn(plushie_core::Diagnostic::FontFamilyNotFound {
783            family: (*name).to_string(),
784        });
785    }
786    Font::DEFAULT
787}
788
789/// Typed shape of the Settings payload, for `deny_unknown_fields`
790/// validation. Field-level decode failures emit diagnostics but do
791/// not fail the whole parse; the caller continues extracting fields
792/// via the existing `get`-and-coerce pattern so partial settings
793/// still take effect.
794#[derive(Debug, serde::Deserialize)]
795#[serde(deny_unknown_fields)]
796#[allow(dead_code)] // fields observed via Debug only; real extraction is field-by-field
797struct WireSettings {
798    #[serde(default)]
799    protocol_version: Option<u64>,
800    #[serde(default)]
801    default_event_rate: Option<u64>,
802    #[serde(default)]
803    default_text_size: Option<f64>,
804    #[serde(default)]
805    default_font: Option<serde_json::Value>,
806    #[serde(default)]
807    antialiasing: Option<bool>,
808    #[serde(default)]
809    vsync: Option<bool>,
810    #[serde(default)]
811    fonts: Option<Vec<String>>,
812    #[serde(default)]
813    scale_factor: Option<f64>,
814    #[serde(default)]
815    theme: Option<serde_json::Value>,
816    #[serde(default)]
817    widget_config: Option<serde_json::Value>,
818    #[serde(default)]
819    validate_props: Option<bool>,
820    #[serde(default)]
821    log_level: Option<String>,
822}
823
824/// Run the typed `deny_unknown_fields` validation. Unknown keys
825/// and type mismatches produce an error diagnostic but do not fail
826/// the parse: the caller proceeds with per-field extraction.
827fn validate_wire_settings(settings: &Value) {
828    match serde_json::from_value::<WireSettings>(settings.clone()) {
829        Ok(_) => {}
830        Err(e) => {
831            plushie_core::diagnostics::error(plushie_core::Diagnostic::InvalidSettings {
832                detail: e.to_string(),
833            });
834        }
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841    use plushie_core::protocol::{IncomingMessage, PatchOp, TreeNode};
842    use plushie_widget_sdk::testing::{
843        node as make_node, node_with_children as make_node_with_children,
844        node_with_props as make_node_with_props,
845    };
846
847    fn make_patch_op(op: &str, path: Vec<usize>, rest: serde_json::Value) -> PatchOp {
848        let mut obj = serde_json::Map::new();
849        obj.insert("op".to_string(), serde_json::json!(op));
850        obj.insert("path".to_string(), serde_json::json!(path));
851        if let Some(map) = rest.as_object() {
852            for (key, value) in map {
853                obj.insert(key.clone(), value.clone());
854            }
855        }
856        serde_json::from_value(serde_json::Value::Object(obj)).unwrap()
857    }
858
859    fn child_ids(core: &Core) -> Vec<String> {
860        core.tree
861            .root()
862            .unwrap()
863            .children
864            .iter()
865            .map(|child| child.id.clone())
866            .collect()
867    }
868
869    fn has_sync_windows(effects: &[CoreEffect]) -> bool {
870        effects
871            .iter()
872            .any(|effect| matches!(effect, CoreEffect::StateChange(StateChange::SyncWindows)))
873    }
874
875    fn has_patch_order_error(effects: &[CoreEffect]) -> bool {
876        effects.iter().any(|effect| {
877            matches!(
878                effect,
879                CoreEffect::Emit(Emit::Event(event))
880                    if event.family == "error" && event.id == "patch_order"
881            )
882        })
883    }
884
885    fn has_theme_follows_system(effects: &[CoreEffect]) -> bool {
886        effects.iter().any(|effect| {
887            matches!(
888                effect,
889                CoreEffect::StateChange(StateChange::ThemeFollowsSystem)
890            )
891        })
892    }
893
894    fn has_prop_validation(effects: &[CoreEffect], node_id: &str) -> bool {
895        effects.iter().any(|effect| {
896            matches!(
897                effect,
898                CoreEffect::Emit(Emit::Event(event))
899                    if event.family == "prop_validation" && event.id == node_id
900            )
901        })
902    }
903
904    // -- Core::new() --
905
906    #[test]
907    fn new_returns_empty_tree() {
908        let core: Core = Core::new();
909        assert!(core.tree.root().is_none());
910    }
911
912    #[test]
913    fn new_has_empty_active_subscriptions() {
914        let core: Core = Core::new();
915        assert!(core.active_subscriptions.is_empty());
916    }
917
918    #[test]
919    fn new_has_no_default_text_size() {
920        let core: Core = Core::new();
921        assert!(core.default_text_size.is_none());
922    }
923
924    #[test]
925    fn new_has_no_default_font() {
926        let core: Core = Core::new();
927        assert!(core.default_font.is_none());
928    }
929
930    // -- Snapshot --
931
932    #[test]
933    fn snapshot_sets_tree_and_returns_sync_windows() {
934        let mut core: Core = Core::new();
935        let msg = IncomingMessage::Snapshot {
936            tree: make_node("root", "column"),
937        };
938        let effects = core.apply(msg);
939        // Tree should be populated
940        assert!(core.tree.root().is_some());
941        assert_eq!(core.tree.root().unwrap().id, "root");
942        // Must include SyncWindows
943        let has_sync = effects
944            .iter()
945            .any(|e| matches!(e, CoreEffect::StateChange(StateChange::SyncWindows)));
946        assert!(has_sync);
947    }
948
949    #[test]
950    fn snapshot_with_theme_prop_returns_theme_changed() {
951        let mut core: Core = Core::new();
952        let msg = IncomingMessage::Snapshot {
953            tree: make_node_with_props("root", "column", serde_json::json!({"theme": "dark"})),
954        };
955        let effects = core.apply(msg);
956        let has_theme = effects
957            .iter()
958            .any(|e| matches!(e, CoreEffect::StateChange(StateChange::ThemeChanged(_, _))));
959        assert!(has_theme);
960    }
961
962    #[test]
963    fn snapshot_with_unknown_theme_does_not_apply_dark_or_system() {
964        let mut core: Core = Core::new();
965        let effects = core.apply(IncomingMessage::Snapshot {
966            tree: make_node_with_props("root", "column", serde_json::json!({"theme": "neon_pink"})),
967        });
968
969        assert!(
970            !effects
971                .iter()
972                .any(|e| matches!(e, CoreEffect::StateChange(StateChange::ThemeChanged(_, _))))
973        );
974        assert!(!has_theme_follows_system(&effects));
975        assert!(core.cached_theme.is_none());
976    }
977
978    #[test]
979    fn unknown_theme_clears_previous_resolved_theme() {
980        let mut core: Core = Core::new();
981        core.apply(IncomingMessage::Snapshot {
982            tree: make_node_with_props("root", "column", serde_json::json!({"theme": "nord"})),
983        });
984        assert!(matches!(
985            core.cached_theme.as_ref(),
986            Some(iced::Theme::Nord)
987        ));
988
989        let effects = core.apply(IncomingMessage::Snapshot {
990            tree: make_node_with_props("root", "column", serde_json::json!({"theme": "neon_pink"})),
991        });
992
993        assert!(
994            !effects
995                .iter()
996                .any(|e| matches!(e, CoreEffect::StateChange(StateChange::ThemeChanged(_, _))))
997        );
998        assert!(has_theme_follows_system(&effects));
999        assert!(core.cached_theme.is_none());
1000    }
1001
1002    #[test]
1003    fn removing_unknown_theme_after_clear_does_not_emit_again() {
1004        let mut core: Core = Core::new();
1005        core.apply(IncomingMessage::Snapshot {
1006            tree: make_node_with_props("root", "column", serde_json::json!({"theme": "nord"})),
1007        });
1008        let effects = core.apply(IncomingMessage::Snapshot {
1009            tree: make_node_with_props("root", "column", serde_json::json!({"theme": "neon_pink"})),
1010        });
1011        assert!(has_theme_follows_system(&effects));
1012
1013        let effects = core.apply(IncomingMessage::Snapshot {
1014            tree: make_node("root", "column"),
1015        });
1016
1017        assert!(!has_theme_follows_system(&effects));
1018        assert!(core.cached_theme_hash.is_none());
1019    }
1020
1021    #[test]
1022    fn snapshot_without_theme_prop_has_no_theme_changed() {
1023        let mut core: Core = Core::new();
1024        let msg = IncomingMessage::Snapshot {
1025            tree: make_node("root", "column"),
1026        };
1027        let effects = core.apply(msg);
1028        let has_theme = effects
1029            .iter()
1030            .any(|e| matches!(e, CoreEffect::StateChange(StateChange::ThemeChanged(_, _))));
1031        assert!(!has_theme);
1032    }
1033
1034    #[test]
1035    fn snapshot_without_theme_prop_clears_previous_theme_chrome() {
1036        let mut core: Core = Core::new();
1037        core.apply(IncomingMessage::Snapshot {
1038            tree: make_node_with_props(
1039                "root",
1040                "column",
1041                serde_json::json!({
1042                    "theme": {
1043                        "name": "chrome",
1044                        "scrollbar_color": "#112233"
1045                    }
1046                }),
1047            ),
1048        });
1049        assert!(core.cached_theme_chrome.scrollbar_color.is_some());
1050
1051        let effects = core.apply(IncomingMessage::Snapshot {
1052            tree: make_node("root", "column"),
1053        });
1054
1055        assert!(has_theme_follows_system(&effects));
1056        assert!(core.cached_theme.is_none());
1057        assert!(core.cached_theme_chrome.is_empty());
1058    }
1059
1060    // -- Patch --
1061
1062    #[test]
1063    fn patch_with_no_ops_returns_sync_windows() {
1064        let mut core: Core = Core::new();
1065        // First put a tree in place so patch has something to work with
1066        let snapshot_msg = IncomingMessage::Snapshot {
1067            tree: make_node("root", "column"),
1068        };
1069        core.apply(snapshot_msg);
1070
1071        let patch_msg = IncomingMessage::Patch { ops: vec![] };
1072        let effects = core.apply(patch_msg);
1073        assert!(has_sync_windows(&effects));
1074    }
1075
1076    #[test]
1077    fn patch_removing_root_theme_clears_previous_theme_chrome() {
1078        let mut core: Core = Core::new();
1079        core.apply(IncomingMessage::Snapshot {
1080            tree: make_node_with_props(
1081                "root",
1082                "column",
1083                serde_json::json!({
1084                    "theme": {
1085                        "name": "chrome",
1086                        "cursor_color": "#112233",
1087                        "scrollbar_color": "#445566",
1088                        "scroller_color": "#778899"
1089                    }
1090                }),
1091            ),
1092        });
1093        assert!(!core.cached_theme_chrome.is_empty());
1094
1095        let effects = core.apply(IncomingMessage::Patch {
1096            ops: vec![make_patch_op(
1097                "update_props",
1098                vec![],
1099                serde_json::json!({
1100                    "props": {"theme": null}
1101                }),
1102            )],
1103        });
1104
1105        assert!(has_theme_follows_system(&effects));
1106        assert!(core.cached_theme.is_none());
1107        assert!(core.cached_theme_chrome.is_empty());
1108    }
1109
1110    #[test]
1111    fn patch_rejects_insert_before_remove_without_mutating_tree() {
1112        let mut core: Core = Core::new();
1113        core.apply(IncomingMessage::Snapshot {
1114            tree: make_node_with_children(
1115                "root",
1116                "column",
1117                vec![
1118                    make_node("a", "text"),
1119                    make_node("b", "text"),
1120                    make_node("c", "text"),
1121                ],
1122            ),
1123        });
1124
1125        let effects = core.apply(IncomingMessage::Patch {
1126            ops: vec![
1127                make_patch_op(
1128                    "insert_child",
1129                    vec![],
1130                    serde_json::json!({
1131                        "index": 3,
1132                        "node": {"id": "d", "type": "text", "props": {}, "children": []}
1133                    }),
1134                ),
1135                make_patch_op("remove_child", vec![], serde_json::json!({"index": 0})),
1136            ],
1137        });
1138
1139        assert_eq!(child_ids(&core), vec!["a", "b", "c"]);
1140        assert!(has_patch_order_error(&effects));
1141        assert!(!has_sync_windows(&effects));
1142    }
1143
1144    #[test]
1145    fn patch_rejects_remove_same_parent_ascending_without_mutating_tree() {
1146        let mut core: Core = Core::new();
1147        core.apply(IncomingMessage::Snapshot {
1148            tree: make_node_with_children(
1149                "root",
1150                "column",
1151                vec![
1152                    make_node("a", "text"),
1153                    make_node("b", "text"),
1154                    make_node("c", "text"),
1155                ],
1156            ),
1157        });
1158
1159        let effects = core.apply(IncomingMessage::Patch {
1160            ops: vec![
1161                make_patch_op("remove_child", vec![], serde_json::json!({"index": 0})),
1162                make_patch_op("remove_child", vec![], serde_json::json!({"index": 1})),
1163            ],
1164        });
1165
1166        assert_eq!(child_ids(&core), vec!["a", "b", "c"]);
1167        assert!(has_patch_order_error(&effects));
1168        assert!(!has_sync_windows(&effects));
1169    }
1170
1171    #[test]
1172    fn patch_rejects_insert_same_parent_descending_without_mutating_tree() {
1173        let mut core: Core = Core::new();
1174        core.apply(IncomingMessage::Snapshot {
1175            tree: make_node_with_children("root", "column", vec![make_node("a", "text")]),
1176        });
1177
1178        let effects = core.apply(IncomingMessage::Patch {
1179            ops: vec![
1180                make_patch_op(
1181                    "insert_child",
1182                    vec![],
1183                    serde_json::json!({
1184                        "index": 1,
1185                        "node": {"id": "b", "type": "text", "props": {}, "children": []}
1186                    }),
1187                ),
1188                make_patch_op(
1189                    "insert_child",
1190                    vec![],
1191                    serde_json::json!({
1192                        "index": 0,
1193                        "node": {"id": "c", "type": "text", "props": {}, "children": []}
1194                    }),
1195                ),
1196            ],
1197        });
1198
1199        assert_eq!(child_ids(&core), vec!["a"]);
1200        assert!(has_patch_order_error(&effects));
1201        assert!(!has_sync_windows(&effects));
1202    }
1203
1204    #[test]
1205    fn patch_valid_remove_update_insert_sequence_applies() {
1206        let mut core: Core = Core::new();
1207        core.apply(IncomingMessage::Snapshot {
1208            tree: make_node_with_children(
1209                "root",
1210                "column",
1211                vec![
1212                    make_node_with_props("a", "text", serde_json::json!({"content": "old"})),
1213                    make_node("b", "text"),
1214                    make_node("c", "text"),
1215                ],
1216            ),
1217        });
1218
1219        let effects = core.apply(IncomingMessage::Patch {
1220            ops: vec![
1221                make_patch_op("remove_child", vec![], serde_json::json!({"index": 2})),
1222                make_patch_op(
1223                    "update_props",
1224                    vec![0],
1225                    serde_json::json!({"props": {"content": "new"}}),
1226                ),
1227                make_patch_op(
1228                    "insert_child",
1229                    vec![],
1230                    serde_json::json!({
1231                        "index": 1,
1232                        "node": {"id": "d", "type": "text", "props": {}, "children": []}
1233                    }),
1234                ),
1235            ],
1236        });
1237
1238        assert_eq!(child_ids(&core), vec!["a", "d", "b"]);
1239        assert_eq!(
1240            core.tree.root().unwrap().children[0].props.to_value()["content"],
1241            "new"
1242        );
1243        assert!(!has_patch_order_error(&effects));
1244        assert!(has_sync_windows(&effects));
1245    }
1246
1247    #[test]
1248    fn patch_allows_parent_update_before_child_remove() {
1249        let mut core: Core = Core::new();
1250        core.apply(IncomingMessage::Snapshot {
1251            tree: make_node_with_children(
1252                "root",
1253                "column",
1254                vec![make_node("a", "text"), make_node("b", "text")],
1255            ),
1256        });
1257
1258        let effects = core.apply(IncomingMessage::Patch {
1259            ops: vec![
1260                make_patch_op(
1261                    "update_props",
1262                    vec![],
1263                    serde_json::json!({"props": {"spacing": 8}}),
1264                ),
1265                make_patch_op("remove_child", vec![], serde_json::json!({"index": 1})),
1266            ],
1267        });
1268
1269        assert_eq!(child_ids(&core), vec!["a"]);
1270        assert_eq!(core.tree.root().unwrap().props.to_value()["spacing"], 8);
1271        assert!(!has_patch_order_error(&effects));
1272        assert!(has_sync_windows(&effects));
1273    }
1274
1275    #[test]
1276    fn patch_allows_insert_in_one_subtree_before_update_in_another() {
1277        let mut core: Core = Core::new();
1278        core.apply(IncomingMessage::Snapshot {
1279            tree: make_node_with_children(
1280                "root",
1281                "column",
1282                vec![
1283                    make_node_with_children("left", "column", vec![]),
1284                    make_node_with_props("right", "text", serde_json::json!({"content": "old"})),
1285                ],
1286            ),
1287        });
1288
1289        let effects = core.apply(IncomingMessage::Patch {
1290            ops: vec![
1291                make_patch_op(
1292                    "insert_child",
1293                    vec![0],
1294                    serde_json::json!({
1295                        "index": 0,
1296                        "node": {"id": "left-child", "type": "text", "props": {}, "children": []}
1297                    }),
1298                ),
1299                make_patch_op(
1300                    "update_props",
1301                    vec![1],
1302                    serde_json::json!({"props": {"content": "new"}}),
1303                ),
1304            ],
1305        });
1306
1307        let root = core.tree.root().unwrap();
1308        assert_eq!(root.children[0].children[0].id, "left-child");
1309        assert_eq!(root.children[1].props.to_value()["content"], "new");
1310        assert!(!has_patch_order_error(&effects));
1311        assert!(has_sync_windows(&effects));
1312    }
1313
1314    #[test]
1315    fn malformed_insert_still_uses_existing_per_op_error_handling() {
1316        let mut core: Core = Core::new();
1317        core.apply(IncomingMessage::Snapshot {
1318            tree: make_node_with_children(
1319                "root",
1320                "column",
1321                vec![make_node_with_props(
1322                    "a",
1323                    "text",
1324                    serde_json::json!({"content": "old"}),
1325                )],
1326            ),
1327        });
1328
1329        let effects = core.apply(IncomingMessage::Patch {
1330            ops: vec![
1331                make_patch_op("insert_child", vec![], serde_json::json!({"index": 0})),
1332                make_patch_op(
1333                    "update_props",
1334                    vec![0],
1335                    serde_json::json!({"props": {"content": "new"}}),
1336                ),
1337            ],
1338        });
1339
1340        assert_eq!(child_ids(&core), vec!["a"]);
1341        assert_eq!(
1342            core.tree.root().unwrap().children[0].props.to_value()["content"],
1343            "new"
1344        );
1345        assert!(!has_patch_order_error(&effects));
1346        assert!(has_sync_windows(&effects));
1347    }
1348
1349    #[test]
1350    fn invalid_insert_node_still_uses_existing_per_op_error_handling() {
1351        let mut core: Core = Core::new();
1352        core.apply(IncomingMessage::Snapshot {
1353            tree: make_node_with_children(
1354                "root",
1355                "column",
1356                vec![make_node_with_props(
1357                    "a",
1358                    "text",
1359                    serde_json::json!({"content": "old"}),
1360                )],
1361            ),
1362        });
1363
1364        let effects = core.apply(IncomingMessage::Patch {
1365            ops: vec![
1366                make_patch_op(
1367                    "insert_child",
1368                    vec![],
1369                    serde_json::json!({"index": 0, "node": {"garbage": true}}),
1370                ),
1371                make_patch_op(
1372                    "update_props",
1373                    vec![0],
1374                    serde_json::json!({"props": {"content": "new"}}),
1375                ),
1376            ],
1377        });
1378
1379        assert_eq!(child_ids(&core), vec!["a"]);
1380        assert_eq!(
1381            core.tree.root().unwrap().children[0].props.to_value()["content"],
1382            "new"
1383        );
1384        assert!(!has_patch_order_error(&effects));
1385        assert!(has_sync_windows(&effects));
1386    }
1387
1388    #[test]
1389    fn non_object_update_props_still_uses_existing_per_op_error_handling() {
1390        let mut core: Core = Core::new();
1391        core.apply(IncomingMessage::Snapshot {
1392            tree: make_node_with_children(
1393                "root",
1394                "column",
1395                vec![make_node_with_props(
1396                    "a",
1397                    "text",
1398                    serde_json::json!({"content": "old"}),
1399                )],
1400            ),
1401        });
1402
1403        let effects = core.apply(IncomingMessage::Patch {
1404            ops: vec![
1405                make_patch_op(
1406                    "insert_child",
1407                    vec![],
1408                    serde_json::json!({
1409                        "index": 1,
1410                        "node": {"id": "b", "type": "text", "props": {}, "children": []}
1411                    }),
1412                ),
1413                make_patch_op("update_props", vec![0], serde_json::json!({"props": false})),
1414            ],
1415        });
1416
1417        assert_eq!(child_ids(&core), vec!["a", "b"]);
1418        assert_eq!(
1419            core.tree.root().unwrap().children[0].props.to_value()["content"],
1420            "old"
1421        );
1422        assert!(!has_patch_order_error(&effects));
1423        assert!(has_sync_windows(&effects));
1424    }
1425
1426    // -- Settings --
1427
1428    #[test]
1429    fn settings_sets_default_text_size() {
1430        let mut core: Core = Core::new();
1431        let msg = IncomingMessage::Settings {
1432            settings: serde_json::json!({"default_text_size": 18.0}),
1433        };
1434        core.apply(msg);
1435        assert_eq!(core.default_text_size, Some(18.0_f32));
1436    }
1437
1438    #[test]
1439    fn settings_sets_default_font_monospace() {
1440        let mut core: Core = Core::new();
1441        let msg = IncomingMessage::Settings {
1442            settings: serde_json::json!({"default_font": {"family": "monospace"}}),
1443        };
1444        core.apply(msg);
1445        assert_eq!(core.default_font, Some(iced::Font::MONOSPACE));
1446    }
1447
1448    #[test]
1449    fn settings_sets_default_font_default_for_unknown_family() {
1450        let mut core: Core = Core::new();
1451        let msg = IncomingMessage::Settings {
1452            settings: serde_json::json!({"default_font": {"family": "sans_serif"}}),
1453        };
1454        core.apply(msg);
1455        assert_eq!(core.default_font, Some(iced::Font::DEFAULT));
1456    }
1457
1458    #[test]
1459    fn settings_sets_default_event_rate() {
1460        let mut core: Core = Core::new();
1461        let msg = IncomingMessage::Settings {
1462            settings: serde_json::json!({"default_event_rate": 60}),
1463        };
1464        core.apply(msg);
1465        assert_eq!(core.default_event_rate, Some(60));
1466    }
1467
1468    #[test]
1469    fn settings_validate_props_false_does_not_store_local_override() {
1470        let mut core: Core = Core::new();
1471        let msg = IncomingMessage::Settings {
1472            settings: serde_json::json!({"validate_props": false}),
1473        };
1474        core.apply(msg);
1475        assert_eq!(core.validate_props, None);
1476        assert_eq!(
1477            core.is_validate_props_enabled(),
1478            runtime::is_validate_props_enabled()
1479        );
1480        if cfg!(debug_assertions) {
1481            assert!(core.is_validate_props_enabled());
1482            let effects = core.apply(IncomingMessage::Snapshot {
1483                tree: make_node_with_props("bad", "text", serde_json::json!({"content": 42})),
1484            });
1485            assert!(
1486                has_prop_validation(&effects, "bad"),
1487                "validate_props false must not suppress debug/default validation"
1488            );
1489        }
1490    }
1491
1492    #[test]
1493    fn settings_validate_props_true_stores_local_override() {
1494        let mut core: Core = Core::new();
1495        let msg = IncomingMessage::Settings {
1496            settings: serde_json::json!({"validate_props": true}),
1497        };
1498        core.apply(msg);
1499        assert_eq!(core.validate_props, Some(true));
1500        assert!(core.is_validate_props_enabled());
1501        let effects = core.apply(IncomingMessage::Snapshot {
1502            tree: make_node_with_props("bad", "text", serde_json::json!({"content": 42})),
1503        });
1504        assert!(
1505            has_prop_validation(&effects, "bad"),
1506            "validate_props true should enable validation for the session"
1507        );
1508    }
1509
1510    #[test]
1511    fn settings_without_default_event_rate_leaves_none() {
1512        let mut core: Core = Core::new();
1513        let msg = IncomingMessage::Settings {
1514            settings: serde_json::json!({"default_text_size": 14.0}),
1515        };
1516        core.apply(msg);
1517        assert_eq!(core.default_event_rate, None);
1518    }
1519
1520    #[test]
1521    fn subscribe_with_max_rate_stores_rate_in_entry() {
1522        let mut core: Core = Core::new();
1523        let msg = IncomingMessage::Subscribe {
1524            kind: "on_pointer_move".to_string(),
1525            tag: "mouse".to_string(),
1526            window_id: None,
1527            max_rate: Some(30),
1528        };
1529        core.apply(msg);
1530        let entries = &core.active_subscriptions["on_pointer_move"];
1531        assert_eq!(entries.len(), 1);
1532        assert_eq!(entries[0].max_rate, Some(30));
1533    }
1534
1535    #[test]
1536    fn subscribe_without_max_rate_has_none_rate() {
1537        let mut core: Core = Core::new();
1538        let msg = IncomingMessage::Subscribe {
1539            kind: "on_key_press".to_string(),
1540            tag: "keys".to_string(),
1541            window_id: None,
1542            max_rate: None,
1543        };
1544        core.apply(msg);
1545        let entries = &core.active_subscriptions["on_key_press"];
1546        assert_eq!(entries[0].max_rate, None);
1547    }
1548
1549    #[test]
1550    fn unsubscribe_removes_all_entries_for_kind() {
1551        let mut core: Core = Core::new();
1552        core.apply(IncomingMessage::Subscribe {
1553            kind: "on_pointer_move".to_string(),
1554            tag: "mouse".to_string(),
1555            window_id: None,
1556            max_rate: Some(30),
1557        });
1558        core.apply(IncomingMessage::Unsubscribe {
1559            kind: "on_pointer_move".to_string(),
1560            tag: None,
1561        });
1562        assert!(!core.active_subscriptions.contains_key("on_pointer_move"));
1563    }
1564
1565    #[test]
1566    fn unsubscribe_by_tag_removes_specific_entry() {
1567        let mut core: Core = Core::new();
1568        core.apply(IncomingMessage::Subscribe {
1569            kind: "on_key_press".to_string(),
1570            tag: "global".to_string(),
1571            window_id: None,
1572            max_rate: None,
1573        });
1574        core.apply(IncomingMessage::Subscribe {
1575            kind: "on_key_press".to_string(),
1576            tag: "main_keys".to_string(),
1577            window_id: Some("main".to_string()),
1578            max_rate: None,
1579        });
1580        assert_eq!(core.active_subscriptions["on_key_press"].len(), 2);
1581        core.apply(IncomingMessage::Unsubscribe {
1582            kind: "on_key_press".to_string(),
1583            tag: Some("main_keys".to_string()),
1584        });
1585        let entries = &core.active_subscriptions["on_key_press"];
1586        assert_eq!(entries.len(), 1);
1587        assert_eq!(entries[0].tag, "global");
1588    }
1589
1590    #[test]
1591    fn subscribe_with_window_id_stores_scope() {
1592        let mut core: Core = Core::new();
1593        core.apply(IncomingMessage::Subscribe {
1594            kind: "on_key_press".to_string(),
1595            tag: "main_keys".to_string(),
1596            window_id: Some("main".to_string()),
1597            max_rate: None,
1598        });
1599        let entries = &core.active_subscriptions["on_key_press"];
1600        assert_eq!(entries[0].window_id, Some("main".to_string()));
1601    }
1602
1603    #[test]
1604    fn matching_entries_filters_by_window_id() {
1605        let mut core: Core = Core::new();
1606        core.apply(IncomingMessage::Subscribe {
1607            kind: "on_key_press".to_string(),
1608            tag: "global".to_string(),
1609            window_id: None,
1610            max_rate: None,
1611        });
1612        core.apply(IncomingMessage::Subscribe {
1613            kind: "on_key_press".to_string(),
1614            tag: "main_keys".to_string(),
1615            window_id: Some("main".to_string()),
1616            max_rate: None,
1617        });
1618        // Event from "main" window matches both global and main-scoped
1619        let main_entries = core.matching_entries("on_key_press", Some("main"));
1620        assert_eq!(main_entries.len(), 2);
1621        // Event from "popup" window matches only global
1622        let popup_entries = core.matching_entries("on_key_press", Some("popup"));
1623        assert_eq!(popup_entries.len(), 1);
1624        assert_eq!(popup_entries[0].tag, "global");
1625    }
1626
1627    #[test]
1628    fn settings_without_widget_config_emits_null_config() {
1629        let mut core: Core = Core::new();
1630        let msg = IncomingMessage::Settings {
1631            settings: serde_json::json!({"default_text_size": 14.0}),
1632        };
1633        let effects = core.apply(msg);
1634        assert_eq!(effects.len(), 1);
1635        assert!(matches!(
1636            effects[0],
1637            CoreEffect::StateChange(StateChange::WidgetConfig(serde_json::Value::Null))
1638        ));
1639    }
1640
1641    #[test]
1642    fn settings_with_widget_config_emits_effect() {
1643        let mut core: Core = Core::new();
1644        let msg = IncomingMessage::Settings {
1645            settings: serde_json::json!({
1646                "default_text_size": 14.0,
1647                "widget_config": {
1648                    "terminal": {"shell": "/bin/bash"}
1649                }
1650            }),
1651        };
1652        let effects = core.apply(msg);
1653        let has_ext_config = effects
1654            .iter()
1655            .any(|e| matches!(e, CoreEffect::StateChange(StateChange::WidgetConfig(_))));
1656        assert!(has_ext_config);
1657    }
1658
1659    #[test]
1660    fn settings_with_widget_config_contains_correct_value() {
1661        let mut core: Core = Core::new();
1662        let config_val = serde_json::json!({"terminal": {"shell": "/bin/zsh"}});
1663        let msg = IncomingMessage::Settings {
1664            settings: serde_json::json!({
1665                "widget_config": config_val,
1666            }),
1667        };
1668        let effects = core.apply(msg);
1669        let ext_config = effects.iter().find_map(|e| match e {
1670            CoreEffect::StateChange(StateChange::WidgetConfig(v)) => Some(v),
1671            _ => None,
1672        });
1673        assert_eq!(
1674            ext_config.unwrap(),
1675            &serde_json::json!({"terminal": {"shell": "/bin/zsh"}})
1676        );
1677    }
1678
1679    // -- Subscribe / Unsubscribe --
1680
1681    #[test]
1682    fn subscription_register_adds_to_active_subscriptions() {
1683        let mut core: Core = Core::new();
1684        let msg = IncomingMessage::Subscribe {
1685            kind: "time".to_string(),
1686            tag: "tick".to_string(),
1687            window_id: None,
1688            max_rate: None,
1689        };
1690        core.apply(msg);
1691        let entries = &core.active_subscriptions["time"];
1692        assert_eq!(entries.len(), 1);
1693        assert_eq!(entries[0].tag, "tick");
1694    }
1695
1696    #[test]
1697    fn subscription_register_returns_no_effects() {
1698        let mut core: Core = Core::new();
1699        let msg = IncomingMessage::Subscribe {
1700            kind: "keyboard".to_string(),
1701            tag: "key".to_string(),
1702            window_id: None,
1703            max_rate: None,
1704        };
1705        let effects = core.apply(msg);
1706        assert!(effects.is_empty());
1707    }
1708
1709    #[test]
1710    fn subscription_unregister_removes_from_active_subscriptions() {
1711        let mut core: Core = Core::new();
1712        core.active_subscriptions
1713            .entry("time".to_string())
1714            .or_default()
1715            .push(SubscriptionEntry {
1716                tag: "tick".to_string(),
1717                window_id: None,
1718                max_rate: None,
1719            });
1720        let msg = IncomingMessage::Unsubscribe {
1721            kind: "time".to_string(),
1722            tag: None,
1723        };
1724        core.apply(msg);
1725        assert!(!core.active_subscriptions.contains_key("time"));
1726    }
1727
1728    #[test]
1729    fn subscription_unregister_returns_no_effects() {
1730        let mut core: Core = Core::new();
1731        let msg = IncomingMessage::Unsubscribe {
1732            kind: "time".to_string(),
1733            tag: None,
1734        };
1735        let effects = core.apply(msg);
1736        assert!(effects.is_empty());
1737    }
1738
1739    // -- Unhandled message types --
1740
1741    #[test]
1742    fn unhandled_message_returns_empty_effects() {
1743        let mut core: Core = Core::new();
1744        // Query is handled by the scripting layer, not Core; hits the catch-all
1745        let msg = IncomingMessage::Query {
1746            id: "q1".to_string(),
1747            target: "tree".to_string(),
1748            selector: Value::Null,
1749        };
1750        let effects = core.apply(msg);
1751        assert!(effects.is_empty());
1752    }
1753
1754    #[test]
1755    fn snapshot_clears_shared_state() {
1756        let mut core: Core = Core::new();
1757
1758        // Test that shared state is cleared on snapshot.
1759        // Manually insert a value and verify it's cleared.
1760        core.caches
1761            .interpolated_props
1762            .insert("w1".into(), serde_json::Map::new());
1763        core.apply(IncomingMessage::Snapshot {
1764            tree: make_node("root", "column"),
1765        });
1766        assert!(core.caches.interpolated_props.is_empty());
1767    }
1768
1769    // -- Multi-window sequence -----------------------------------------------
1770
1771    fn make_window_node(id: &str) -> TreeNode {
1772        TreeNode {
1773            id: id.to_string(),
1774            type_name: "window".to_string(),
1775            props: plushie_core::protocol::Props::default(),
1776            children: vec![],
1777        }
1778    }
1779
1780    #[test]
1781    fn multi_window_snapshot_two_windows_produces_sync_windows() {
1782        let mut core: Core = Core::new();
1783        let mut root = make_node("root", "column");
1784        root.children.push(make_window_node("win-a"));
1785        root.children.push(make_window_node("win-b"));
1786
1787        let effects = core.apply(IncomingMessage::Snapshot { tree: root });
1788
1789        let has_sync = effects
1790            .iter()
1791            .any(|e| matches!(e, CoreEffect::StateChange(StateChange::SyncWindows)));
1792        assert!(has_sync, "Snapshot with windows should produce SyncWindows");
1793
1794        // Verify the tree has both windows.
1795        let ids = core.tree.window_ids();
1796        assert_eq!(ids.len(), 2);
1797        assert!(ids.contains(&"win-a".to_string()));
1798        assert!(ids.contains(&"win-b".to_string()));
1799    }
1800
1801    #[test]
1802    fn multi_window_second_snapshot_removes_window() {
1803        let mut core: Core = Core::new();
1804
1805        // First snapshot: two windows.
1806        let mut root1 = make_node("root", "column");
1807        root1.children.push(make_window_node("win-a"));
1808        root1.children.push(make_window_node("win-b"));
1809        core.apply(IncomingMessage::Snapshot { tree: root1 });
1810        assert_eq!(core.tree.window_ids().len(), 2);
1811
1812        // Second snapshot: only one window.
1813        let mut root2 = make_node("root", "column");
1814        root2.children.push(make_window_node("win-a"));
1815        let effects = core.apply(IncomingMessage::Snapshot { tree: root2 });
1816
1817        let has_sync = effects
1818            .iter()
1819            .any(|e| matches!(e, CoreEffect::StateChange(StateChange::SyncWindows)));
1820        assert!(has_sync, "Second Snapshot should produce SyncWindows");
1821
1822        let ids = core.tree.window_ids();
1823        assert_eq!(ids.len(), 1);
1824        assert_eq!(ids[0], "win-a");
1825    }
1826
1827    #[test]
1828    fn multi_window_snapshot_then_add_window_via_second_snapshot() {
1829        let mut core: Core = Core::new();
1830
1831        // First: one window.
1832        let mut root1 = make_node("root", "column");
1833        root1.children.push(make_window_node("win-a"));
1834        core.apply(IncomingMessage::Snapshot { tree: root1 });
1835        assert_eq!(core.tree.window_ids().len(), 1);
1836
1837        // Second: three windows.
1838        let mut root2 = make_node("root", "column");
1839        root2.children.push(make_window_node("win-a"));
1840        root2.children.push(make_window_node("win-b"));
1841        root2.children.push(make_window_node("win-c"));
1842        let effects = core.apply(IncomingMessage::Snapshot { tree: root2 });
1843
1844        let has_sync = effects
1845            .iter()
1846            .any(|e| matches!(e, CoreEffect::StateChange(StateChange::SyncWindows)));
1847        assert!(has_sync);
1848
1849        let ids = core.tree.window_ids();
1850        assert_eq!(ids.len(), 3);
1851    }
1852
1853    // -- Duplicate node ID detection --
1854
1855    #[test]
1856    fn snapshot_with_duplicate_ids_emits_error_event() {
1857        let mut core: Core = Core::new();
1858        let mut root = make_node("root", "column");
1859        root.children.push(make_node("dupe", "text"));
1860        root.children.push(make_node("dupe", "button"));
1861
1862        let effects = core.apply(IncomingMessage::Snapshot { tree: root });
1863        let has_error = effects.iter().any(|e| match e {
1864            CoreEffect::Emit(Emit::Event(ev)) => ev.family == "error",
1865            _ => false,
1866        });
1867        assert!(has_error, "duplicate IDs should produce an error event");
1868        // Tree should still be accepted
1869        assert!(core.tree.root().is_some());
1870    }
1871
1872    #[test]
1873    fn snapshot_without_duplicates_has_no_error_event() {
1874        let mut core: Core = Core::new();
1875        let mut root = make_node("root", "column");
1876        root.children.push(make_node("a", "text"));
1877        root.children.push(make_node("b", "button"));
1878
1879        let effects = core.apply(IncomingMessage::Snapshot { tree: root });
1880        let has_error = effects.iter().any(|e| match e {
1881            CoreEffect::Emit(Emit::Event(ev)) => ev.family == "error",
1882            _ => false,
1883        });
1884        assert!(!has_error, "unique IDs should not produce an error event");
1885    }
1886
1887    #[test]
1888    fn invalid_effect_payload_returns_error_without_dispatch() {
1889        let mut core = Core::new();
1890
1891        let effects = core.apply(IncomingMessage::Effect {
1892            id: "req-1".to_string(),
1893            kind: "clipboard_write".to_string(),
1894            payload: serde_json::json!({}),
1895        });
1896
1897        assert!(!effects.iter().any(|effect| {
1898            matches!(
1899                effect,
1900                CoreEffect::Dispatch(Dispatch::Effect {
1901                    request_id,
1902                    kind,
1903                    ..
1904                }) if request_id == "req-1" && kind == "clipboard_write"
1905            )
1906        }));
1907        let response = effects.iter().find_map(|effect| match effect {
1908            CoreEffect::Emit(Emit::EffectResponse(response)) => Some(response),
1909            _ => None,
1910        });
1911        assert!(matches!(
1912            response,
1913            Some(response)
1914                if response.id == "req-1"
1915                    && response.status == "error"
1916                    && response.error.as_deref()
1917                        == Some("missing required field for clipboard_write: text")
1918        ));
1919    }
1920
1921    #[test]
1922    fn unknown_effect_kind_returns_error_without_dispatch() {
1923        let mut core = Core::new();
1924
1925        let effects = core.apply(IncomingMessage::Effect {
1926            id: "req-1".to_string(),
1927            kind: "not_real".to_string(),
1928            payload: serde_json::json!({}),
1929        });
1930
1931        assert!(
1932            !effects
1933                .iter()
1934                .any(|effect| matches!(effect, CoreEffect::Dispatch(Dispatch::Effect { .. })))
1935        );
1936        let response = effects.iter().find_map(|effect| match effect {
1937            CoreEffect::Emit(Emit::EffectResponse(response)) => Some(response),
1938            _ => None,
1939        });
1940        assert!(matches!(
1941            response,
1942            Some(response)
1943                if response.id == "req-1"
1944                    && response.status == "error"
1945                    && response.error.as_deref() == Some("unknown effect kind: not_real")
1946        ));
1947    }
1948
1949    #[test]
1950    fn effect_with_empty_id_emits_error_event_without_dispatch() {
1951        let mut core = Core::new();
1952
1953        let effects = core.apply(IncomingMessage::Effect {
1954            id: String::new(),
1955            kind: "clipboard_write".to_string(),
1956            payload: serde_json::json!({"text": "hello"}),
1957        });
1958
1959        assert!(
1960            !effects
1961                .iter()
1962                .any(|effect| matches!(effect, CoreEffect::Dispatch(Dispatch::Effect { .. })))
1963        );
1964        assert!(effects.iter().any(|effect| {
1965            matches!(
1966                effect,
1967                CoreEffect::Emit(Emit::Event(event))
1968                    if event.family == "error" && event.id == "effect"
1969            )
1970        }));
1971    }
1972
1973    #[test]
1974    fn unknown_effect_stub_kind_is_rejected_without_inserting() {
1975        let mut core = Core::new();
1976
1977        let effects = core.apply(IncomingMessage::RegisterEffectStub {
1978            kind: "not_real".to_string(),
1979            response: serde_json::json!({"ok": true}),
1980        });
1981
1982        assert!(!core.effect_stubs.contains_key("not_real"));
1983        assert!(effects.iter().any(|effect| {
1984            matches!(
1985                effect,
1986                CoreEffect::Emit(Emit::StubAck(ack))
1987                    if ack.kind == "not_real" && ack.status == "error"
1988            )
1989        }));
1990    }
1991
1992    #[test]
1993    fn valid_effect_stub_registration_still_works() {
1994        let mut core = Core::new();
1995
1996        let effects = core.apply(IncomingMessage::RegisterEffectStub {
1997            kind: "clipboard_read".to_string(),
1998            response: serde_json::json!({"text": "stubbed"}),
1999        });
2000
2001        assert_eq!(
2002            core.effect_stubs.get("clipboard_read"),
2003            Some(&serde_json::json!({"text": "stubbed"}))
2004        );
2005        assert!(effects.iter().any(|effect| {
2006            matches!(
2007                effect,
2008                CoreEffect::Emit(Emit::StubAck(ack))
2009                    if ack.kind == "clipboard_read" && ack.status == "registered"
2010            )
2011        }));
2012    }
2013
2014    #[test]
2015    fn valid_effect_stub_intercepts_valid_effect_request() {
2016        let mut core = Core::new();
2017        core.apply(IncomingMessage::RegisterEffectStub {
2018            kind: "clipboard_write".to_string(),
2019            response: serde_json::json!({"stubbed": true}),
2020        });
2021
2022        let effects = core.apply(IncomingMessage::Effect {
2023            id: "req-1".to_string(),
2024            kind: "clipboard_write".to_string(),
2025            payload: serde_json::json!({"text": "hello"}),
2026        });
2027
2028        assert!(
2029            !effects
2030                .iter()
2031                .any(|effect| matches!(effect, CoreEffect::Dispatch(Dispatch::Effect { .. })))
2032        );
2033        let response = effects.iter().find_map(|effect| match effect {
2034            CoreEffect::Emit(Emit::EffectResponse(response)) => Some(response),
2035            _ => None,
2036        });
2037        assert!(matches!(
2038            response,
2039            Some(response)
2040                if response.id == "req-1"
2041                    && response.status == "ok"
2042                    && response.result.as_ref() == Some(&serde_json::json!({"stubbed": true}))
2043        ));
2044    }
2045
2046    #[test]
2047    fn unknown_effect_stub_unregister_is_rejected_without_mutating_stubs() {
2048        let mut core = Core::new();
2049        core.effect_stubs.insert(
2050            "clipboard_read".to_string(),
2051            serde_json::json!({"text": "stubbed"}),
2052        );
2053
2054        let effects = core.apply(IncomingMessage::UnregisterEffectStub {
2055            kind: "not_real".to_string(),
2056        });
2057
2058        assert!(core.effect_stubs.contains_key("clipboard_read"));
2059        assert!(effects.iter().any(|effect| {
2060            matches!(
2061                effect,
2062                CoreEffect::Emit(Emit::StubAck(ack))
2063                    if ack.kind == "not_real" && ack.status == "error"
2064            )
2065        }));
2066    }
2067}