1use 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#[derive(Debug)]
33pub enum CoreEffect {
34 Emit(Emit),
36 Dispatch(Dispatch),
38 StateChange(StateChange),
40}
41
42#[derive(Debug)]
46pub enum Emit {
47 Event(OutgoingEvent),
49 EffectResponse(plushie_core::protocol::EffectResponse),
51 StubAck(plushie_core::protocol::EffectStubAck),
53}
54
55#[derive(Debug)]
59pub enum Dispatch {
60 Effect {
79 request_id: String,
80 kind: String,
81 payload: Value,
82 },
83
84 WidgetOp { op: String, payload: Value },
89
90 Window(plushie_core::ops::WindowOp),
92
93 WindowQuery(plushie_core::ops::WindowQuery),
95
96 System(plushie_core::ops::SystemOp),
98
99 SystemQuery(plushie_core::ops::SystemQuery),
101
102 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#[derive(Debug)]
121pub enum StateChange {
122 SyncWindows,
128
129 ThemeChanged(iced::Theme, runtime::ThemeChrome),
134
135 ThemeFollowsSystem,
138
139 ExitNodes(Vec<(String, usize, plushie_core::protocol::TreeNode)>),
142
143 WidgetConfig(Value),
148}
149
150#[derive(Debug, Clone)]
153pub struct SubscriptionEntry {
154 pub tag: String,
155 pub window_id: Option<String>,
157 pub max_rate: Option<u32>,
158}
159
160pub struct Core {
166 pub tree: Tree,
168 pub caches: SharedState,
170 pub active_subscriptions: HashMap<String, Vec<SubscriptionEntry>>,
174 pub default_event_rate: Option<u32>,
177 pub default_text_size: Option<f32>,
179 pub default_font: Option<Font>,
181 pub cached_theme: Option<iced::Theme>,
184 pub cached_theme_chrome: runtime::ThemeChrome,
185 cached_theme_hash: Option<u64>,
189 settings_applied: bool,
192 pub effect_stubs: HashMap<String, Value>,
197 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 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 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 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 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 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 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 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 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 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 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 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 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 validate_wire_settings(&settings);
547
548 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 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" => {
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 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 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 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
744fn 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#[derive(Debug, serde::Deserialize)]
795#[serde(deny_unknown_fields)]
796#[allow(dead_code)] struct 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
824fn 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 #[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 #[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 assert!(core.tree.root().is_some());
941 assert_eq!(core.tree.root().unwrap().id, "root");
942 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 #[test]
1063 fn patch_with_no_ops_returns_sync_windows() {
1064 let mut core: Core = Core::new();
1065 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 #[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 let main_entries = core.matching_entries("on_key_press", Some("main"));
1620 assert_eq!(main_entries.len(), 2);
1621 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 #[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 #[test]
1742 fn unhandled_message_returns_empty_effects() {
1743 let mut core: Core = Core::new();
1744 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 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 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 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 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 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 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 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 #[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 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}