1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3
4use serde::de::DeserializeOwned;
5use serde_json::Value;
6
7#[cfg(feature = "async")]
8use std::future::Future;
9#[cfg(feature = "async")]
10use std::pin::Pin;
11
12use crate::context::GlobalContext;
13use crate::error::{Result, SdkError};
14use crate::state::{State, StateContainer};
15
16#[cfg(feature = "async")]
18pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
19
20type SyncLifecycleFn<S> = Box<dyn Fn(&S, Option<&GlobalContext>) + Send + Sync>;
26
27type SyncActionFn<S> = Box<dyn Fn(&mut S, Option<&Value>, Option<&GlobalContext>) + Send + Sync>;
30
31#[cfg(feature = "async")]
33type AsyncLifecycleFn<S> = Box<dyn Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync>;
34
35#[cfg(feature = "async")]
38type AsyncActionFn<S> =
39 Box<dyn Fn(S, Option<Value>, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync>;
40
41type ErrorHandler = Box<dyn Fn(&ErrorContext) -> ErrorResult + Send + Sync>;
43
44fn deserialize_action_payload<A: DeserializeOwned>(
59 action_name: &str,
60 raw: Option<&Value>,
61) -> Option<A> {
62 match raw {
63 Some(v) => match serde_json::from_value::<A>(v.clone()) {
64 Ok(a) => Some(a),
65 Err(primary_err) => match serde_json::from_value::<A>(Value::Null) {
66 Ok(a) => Some(a),
67 Err(_) => {
68 eprintln!(
69 "[hypen-server] action {:?} payload did not deserialize into declared type: {} (payload was {})",
70 action_name,
71 primary_err,
72 v,
73 );
74 None
75 }
76 },
77 },
78 None => serde_json::from_value::<A>(Value::Null).ok(),
79 }
80}
81
82pub(crate) enum LifecycleHandler<S> {
88 Sync(SyncLifecycleFn<S>),
89 #[cfg(feature = "async")]
90 Async(AsyncLifecycleFn<S>),
91}
92
93pub(crate) enum ActionHandler<S> {
94 Sync(SyncActionFn<S>),
95 #[cfg(feature = "async")]
96 Async(AsyncActionFn<S>),
97}
98
99pub struct ErrorContext {
101 pub error: SdkError,
102 pub action_name: Option<String>,
103 pub lifecycle: Option<String>,
104}
105
106pub struct ErrorResult {
108 pub handled: bool,
110}
111
112pub use crate::remote::SessionInfo;
114
115type DisconnectFn<S> = Box<dyn Fn(&S, &SessionInfo) + Send + Sync>;
117type ReconnectFn<S> = Box<dyn Fn(&mut S, &SessionInfo, &serde_json::Value) + Send + Sync>;
123type ExpireFn = Box<dyn Fn(&SessionInfo) + Send + Sync>;
125
126pub struct ModuleDefinition<S: State> {
135 pub(crate) name: String,
136 pub(crate) initial_state: S,
137 pub(crate) ui_source: Option<String>,
138 pub(crate) ui_file: Option<String>,
139 pub(crate) action_handlers: HashMap<String, ActionHandler<S>>,
140 pub(crate) on_created: Option<LifecycleHandler<S>>,
141 pub(crate) on_activated: Option<LifecycleHandler<S>>,
142 pub(crate) on_deactivated: Option<LifecycleHandler<S>>,
143 pub(crate) on_destroyed: Option<LifecycleHandler<S>>,
144 #[allow(dead_code)]
145 pub(crate) on_error: Option<ErrorHandler>,
146 pub(crate) on_disconnect: Option<DisconnectFn<S>>,
147 pub(crate) on_reconnect: Option<ReconnectFn<S>>,
148 pub(crate) on_expire: Option<ExpireFn>,
149 pub(crate) persist: bool,
150 pub(crate) resource_map: indexmap::IndexMap<String, String>,
151}
152
153impl<S: State> ModuleDefinition<S> {
154 pub fn name(&self) -> &str {
155 &self.name
156 }
157
158 pub fn action_names(&self) -> Vec<String> {
159 self.action_handlers.keys().cloned().collect()
160 }
161
162 pub fn ui_source(&self) -> Option<&str> {
163 self.ui_source.as_deref()
164 }
165
166 pub fn is_persistent(&self) -> bool {
167 self.persist
168 }
169}
170
171pub struct ModuleBuilder<S: State> {
201 name: String,
202 initial_state: Option<S>,
203 ui_source: Option<String>,
204 ui_file: Option<String>,
205 action_handlers: HashMap<String, ActionHandler<S>>,
206 on_created: Option<LifecycleHandler<S>>,
207 on_activated: Option<LifecycleHandler<S>>,
208 on_deactivated: Option<LifecycleHandler<S>>,
209 on_destroyed: Option<LifecycleHandler<S>>,
210 on_error: Option<ErrorHandler>,
211 on_disconnect: Option<DisconnectFn<S>>,
212 on_reconnect: Option<ReconnectFn<S>>,
213 on_expire: Option<ExpireFn>,
214 persist: bool,
215 resource_map: indexmap::IndexMap<String, String>,
216}
217
218impl<S: State> ModuleBuilder<S> {
219 pub fn new(name: impl Into<String>) -> Self {
220 Self {
221 name: name.into(),
222 initial_state: None,
223 ui_source: None,
224 ui_file: None,
225 action_handlers: HashMap::new(),
226 on_created: None,
227 on_activated: None,
228 on_deactivated: None,
229 on_destroyed: None,
230 on_error: None,
231 on_disconnect: None,
232 on_reconnect: None,
233 on_expire: None,
234 persist: false,
235 resource_map: indexmap::IndexMap::new(),
236 }
237 }
238
239 pub fn state(mut self, initial: S) -> Self {
241 self.initial_state = Some(initial);
242 self
243 }
244
245 pub fn ui(mut self, source: impl Into<String>) -> Self {
256 self.ui_source = Some(source.into());
257 self
258 }
259
260 pub fn ui_file(mut self, path: impl Into<String>) -> Self {
264 self.ui_file = Some(path.into());
265 self
266 }
267
268 pub fn on_action<A>(
295 mut self,
296 name: impl Into<String>,
297 handler: impl Fn(&mut S, A, Option<&GlobalContext>) + Send + Sync + 'static,
298 ) -> Self
299 where
300 A: DeserializeOwned + 'static,
301 {
302 let name = name.into();
303 let action_name = name.clone();
304 let wrapped: SyncActionFn<S> = Box::new(move |state, raw, ctx| {
305 let action = deserialize_action_payload::<A>(&action_name, raw);
306 if let Some(action) = action {
307 handler(state, action, ctx);
308 }
309 });
310 self.action_handlers
311 .insert(name, ActionHandler::Sync(wrapped));
312 self
313 }
314
315 pub fn on_created<F>(mut self, handler: F) -> Self
317 where
318 F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
319 {
320 self.on_created = Some(LifecycleHandler::Sync(Box::new(handler)));
321 self
322 }
323
324 pub fn on_destroyed<F>(mut self, handler: F) -> Self
326 where
327 F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
328 {
329 self.on_destroyed = Some(LifecycleHandler::Sync(Box::new(handler)));
330 self
331 }
332
333 pub fn on_activated<F>(mut self, handler: F) -> Self
341 where
342 F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
343 {
344 self.on_activated = Some(LifecycleHandler::Sync(Box::new(handler)));
345 self
346 }
347
348 pub fn on_deactivated<F>(mut self, handler: F) -> Self
352 where
353 F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
354 {
355 self.on_deactivated = Some(LifecycleHandler::Sync(Box::new(handler)));
356 self
357 }
358
359 pub fn on_error<F>(mut self, handler: F) -> Self
361 where
362 F: Fn(&ErrorContext) -> ErrorResult + Send + Sync + 'static,
363 {
364 self.on_error = Some(Box::new(handler));
365 self
366 }
367
368 pub fn on_disconnect<F>(mut self, handler: F) -> Self
372 where
373 F: Fn(&S, &SessionInfo) + Send + Sync + 'static,
374 {
375 self.on_disconnect = Some(Box::new(handler));
376 self
377 }
378
379 pub fn on_reconnect<F>(mut self, handler: F) -> Self
386 where
387 F: Fn(&mut S, &SessionInfo, &serde_json::Value) + Send + Sync + 'static,
388 {
389 self.on_reconnect = Some(Box::new(handler));
390 self
391 }
392
393 pub fn on_expire<F>(mut self, handler: F) -> Self
396 where
397 F: Fn(&SessionInfo) + Send + Sync + 'static,
398 {
399 self.on_expire = Some(Box::new(handler));
400 self
401 }
402
403 pub fn resource(mut self, name: impl Into<String>, svg: impl Into<String>) -> Self {
409 self.resource_map.insert(name.into(), svg.into());
410 self
411 }
412
413 pub fn resources(mut self, map: indexmap::IndexMap<String, String>) -> Self {
415 self.resource_map.extend(map);
416 self
417 }
418
419 pub fn resources_dir(mut self, path: impl AsRef<std::path::Path>) -> Self {
424 if let Ok(entries) = std::fs::read_dir(path.as_ref()) {
425 for entry in entries.flatten() {
426 let p = entry.path();
427 if p.extension().and_then(|e| e.to_str()) == Some("svg") {
428 let name = p
429 .file_stem()
430 .and_then(|s| s.to_str())
431 .unwrap_or("")
432 .to_string();
433 if let Ok(svg) = std::fs::read_to_string(&p) {
434 self.resource_map.insert(name, svg);
435 }
436 }
437 }
438 } else {
439 eprintln!(
440 "Warning: could not read resources dir: {}",
441 path.as_ref().display()
442 );
443 }
444 self
445 }
446
447 pub fn resources_file(mut self, path: impl AsRef<std::path::Path>) -> Self {
453 match std::fs::read_to_string(path.as_ref()) {
454 Ok(json) => {
455 if let Ok(map) = serde_json::from_str::<indexmap::IndexMap<String, String>>(&json) {
456 self.resource_map.extend(map);
457 } else {
458 eprintln!(
459 "Warning: could not parse resources file {}: expected {{name: svg}} map",
460 path.as_ref().display()
461 );
462 }
463 }
464 Err(e) => eprintln!(
465 "Warning: could not read resources file {}: {}",
466 path.as_ref().display(),
467 e
468 ),
469 }
470 self
471 }
472
473 pub fn persist(mut self) -> Self {
474 self.persist = true;
475 self
476 }
477
478 #[cfg(feature = "async")]
495 pub fn on_action_async<A>(
496 mut self,
497 name: impl Into<String>,
498 handler: impl Fn(S, A, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
499 ) -> Self
500 where
501 A: DeserializeOwned + Send + 'static,
502 {
503 let name = name.into();
504 let action_name = name.clone();
505 let wrapped: AsyncActionFn<S> = Box::new(move |state, raw, ctx| {
506 let action = deserialize_action_payload::<A>(&action_name, raw.as_ref());
507 if let Some(action) = action {
508 handler(state, action, ctx)
509 } else {
510 Box::pin(async move { state })
511 }
512 });
513 self.action_handlers
514 .insert(name, ActionHandler::Async(wrapped));
515 self
516 }
517
518 #[cfg(feature = "async")]
531 pub fn on_created_async(
532 mut self,
533 handler: impl Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
534 ) -> Self {
535 self.on_created = Some(LifecycleHandler::Async(Box::new(handler)));
536 self
537 }
538
539 #[cfg(feature = "async")]
552 pub fn on_destroyed_async(
553 mut self,
554 handler: impl Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
555 ) -> Self {
556 self.on_destroyed = Some(LifecycleHandler::Async(Box::new(handler)));
557 self
558 }
559
560 pub fn build(self) -> ModuleDefinition<S> {
562 let initial_state = self
563 .initial_state
564 .expect("ModuleBuilder::state() must be called before build()");
565
566 ModuleDefinition {
567 name: self.name,
568 initial_state,
569 ui_source: self.ui_source,
570 ui_file: self.ui_file,
571 action_handlers: self.action_handlers,
572 on_created: self.on_created,
573 on_activated: self.on_activated,
574 on_deactivated: self.on_deactivated,
575 on_destroyed: self.on_destroyed,
576 on_error: self.on_error,
577 on_disconnect: self.on_disconnect,
578 on_reconnect: self.on_reconnect,
579 on_expire: self.on_expire,
580 persist: self.persist,
581 resource_map: self.resource_map,
582 }
583 }
584}
585
586pub struct ModuleInstance<S: State> {
595 definition: Arc<ModuleDefinition<S>>,
596 state: Arc<Mutex<StateContainer<S>>>,
600 engine: Mutex<hypen_engine::Engine>,
601 mounted: Mutex<bool>,
602 pending_ir: Mutex<Option<hypen_engine::ir::IRNode>>,
608 global_context: Option<Arc<GlobalContext>>,
609}
610
611impl<S: State> ModuleInstance<S> {
612 pub fn new(
614 definition: Arc<ModuleDefinition<S>>,
615 global_context: Option<Arc<GlobalContext>>,
616 ) -> Result<Self> {
617 let state_container = StateContainer::new(definition.initial_state.clone())?;
618 let mut engine = hypen_engine::Engine::new();
619
620 let module_meta = hypen_engine::lifecycle::Module::new(&definition.name)
622 .with_actions(definition.action_names())
623 .with_persist(definition.persist);
624
625 let initial_json = state_container.to_json()?;
626 let engine_module = hypen_engine::ModuleInstance::new(module_meta, initial_json);
627 engine.set_module(engine_module);
628
629 for (name, svg) in &definition.resource_map {
631 engine.register_resource(name, svg);
632 }
633
634 let pending_ir = if let Some(ref source) = definition.ui_source {
638 Some(Self::parse_ui_source(source)?)
639 } else if let Some(ref path) = definition.ui_file {
640 let source = Self::read_ui_file(path)?;
641 Some(Self::parse_ui_source(&source)?)
642 } else {
643 None
644 };
645
646 let state = Arc::new(Mutex::new(state_container));
647 Self::register_action_handlers_with_engine(
648 &mut engine,
649 Arc::clone(&definition),
650 Arc::clone(&state),
651 global_context.clone(),
652 );
653
654 Ok(Self {
655 definition,
656 state,
657 engine: Mutex::new(engine),
658 mounted: Mutex::new(false),
659 pending_ir: Mutex::new(pending_ir),
660 global_context,
661 })
662 }
663
664 pub fn new_with_components(
684 definition: Arc<ModuleDefinition<S>>,
685 global_context: Option<Arc<GlobalContext>>,
686 components: &crate::discovery::ComponentRegistry,
687 ) -> Result<Self> {
688 let state_container = StateContainer::new(definition.initial_state.clone())?;
689 let mut engine = hypen_engine::Engine::new();
690
691 let module_meta = hypen_engine::lifecycle::Module::new(&definition.name)
693 .with_actions(definition.action_names())
694 .with_persist(definition.persist);
695
696 let initial_json = state_container.to_json()?;
697 let engine_module = hypen_engine::ModuleInstance::new(module_meta, initial_json);
698 engine.set_module(engine_module);
699
700 for (name, svg) in &definition.resource_map {
705 engine.register_resource(name, svg);
706 }
707
708 let entries: Vec<(String, String, String)> = components
710 .all()
711 .iter()
712 .map(|e| {
713 (
714 e.name.clone(),
715 e.source.clone(),
716 e.path
717 .as_ref()
718 .map(|p| p.to_string_lossy().to_string())
719 .unwrap_or_default(),
720 )
721 })
722 .collect();
723
724 engine.set_component_resolver(move |name, _ctx_path| {
725 entries
726 .iter()
727 .find(|(n, _, _)| n == name)
728 .map(|(_, source, path)| hypen_engine::ir::ResolvedComponent {
729 source: source.clone(),
730 path: path.clone(),
731 passthrough: false,
732 lazy: false,
733 })
734 });
735
736 let pending_ir = if let Some(ref source) = definition.ui_source {
739 Some(Self::parse_ui_source(source)?)
740 } else if let Some(ref path) = definition.ui_file {
741 let source = Self::read_ui_file(path)?;
742 Some(Self::parse_ui_source(&source)?)
743 } else {
744 None
745 };
746
747 let state = Arc::new(Mutex::new(state_container));
748 Self::register_action_handlers_with_engine(
749 &mut engine,
750 Arc::clone(&definition),
751 Arc::clone(&state),
752 global_context.clone(),
753 );
754
755 Ok(Self {
756 definition,
757 state,
758 engine: Mutex::new(engine),
759 mounted: Mutex::new(false),
760 pending_ir: Mutex::new(pending_ir),
761 global_context,
762 })
763 }
764
765 fn register_action_handlers_with_engine(
771 engine: &mut hypen_engine::Engine,
772 definition: Arc<ModuleDefinition<S>>,
773 state: Arc<Mutex<StateContainer<S>>>,
774 global_context: Option<Arc<GlobalContext>>,
775 ) {
776 for (action_name, handler) in definition.action_handlers.iter() {
777 #[cfg(feature = "async")]
781 if matches!(handler, ActionHandler::Async(_)) {
782 continue;
783 }
784 let definition = Arc::clone(&definition);
790 let state = Arc::clone(&state);
791 let global_context = global_context.clone();
792 let action_name_owned = action_name.clone();
793 engine.on_action(action_name.clone(), move |action| {
794 if let Some(ActionHandler::Sync(handler)) =
795 definition.action_handlers.get(&action_name_owned)
796 {
797 let ctx = global_context.as_deref();
798 let mut state_guard = state.lock().unwrap();
799 handler(state_guard.get_mut(), action.payload.as_ref(), ctx);
800 }
801 });
802 let _ = handler;
805 }
806 }
807
808 fn parse_ui_source(source: &str) -> Result<hypen_engine::ir::IRNode> {
813 let doc = hypen_parser::parse_document(source).map_err(|e| {
814 SdkError::Engine(hypen_engine::EngineError::ParseError {
815 source: source.chars().take(80).collect(),
816 message: format!("{e:?}"),
817 })
818 })?;
819 let component = doc
820 .components
821 .first()
822 .ok_or_else(|| SdkError::Component("No component found in UI source".to_string()))?;
823 Ok(hypen_engine::ast_to_ir_node(component))
824 }
825
826 fn read_ui_file(path: &str) -> Result<String> {
828 std::fs::read_to_string(path)
829 .map_err(|e| SdkError::Component(format!("Failed to read UI file '{path}': {e}")))
830 }
831
832 fn flush_initial_render(&self) {
836 let ir = {
837 let mut slot = self.pending_ir.lock().unwrap();
838 slot.take()
839 };
840 if let Some(ir) = ir {
841 let mut engine = self.engine.lock().unwrap();
842 engine.render_ir_node(&ir);
843 }
844 }
845
846 pub fn mount(&self) {
857 let mut mounted = self.mounted.lock().unwrap();
858 if !*mounted {
859 *mounted = true;
860 self.flush_initial_render();
861 if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_created {
862 let state = self.state.lock().unwrap();
863 let ctx = self.global_context.as_deref();
864 handler(state.get(), ctx);
865 }
866 }
867 }
868
869 pub fn activate(&self) {
874 if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_activated {
875 let state = self.state.lock().unwrap();
876 let ctx = self.global_context.as_deref();
877 handler(state.get(), ctx);
878 }
879 }
880
881 pub fn deactivate(&self) {
885 if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_deactivated {
886 let state = self.state.lock().unwrap();
887 let ctx = self.global_context.as_deref();
888 handler(state.get(), ctx);
889 }
890 }
891
892 pub fn unmount(&self) {
897 let mut mounted = self.mounted.lock().unwrap();
898 if *mounted {
899 if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_destroyed {
900 let state = self.state.lock().unwrap();
901 let ctx = self.global_context.as_deref();
902 handler(state.get(), ctx);
903 }
904 *mounted = false;
905 }
906 }
907
908 pub fn dispatch_action(&self, name: impl Into<String>, payload: Option<Value>) -> Result<()> {
921 let name = name.into();
922
923 if name == "__hypen_bind" {
928 return self.handle_bind_action(payload);
929 }
930
931 #[cfg(feature = "async")]
938 if matches!(
939 self.definition.action_handlers.get(&name),
940 Some(ActionHandler::Async(_))
941 ) {
942 return Err(SdkError::Engine(hypen_engine::EngineError::ActionNotFound(
943 name,
944 )));
945 }
946
947 {
951 let mut state = self.state.lock().unwrap();
952 state.take_snapshot()?;
953 }
954
955 let mut action = hypen_engine::dispatch::Action::new(name.clone());
960 if let Some(p) = payload {
961 action = action.with_payload(p);
962 }
963 {
964 let mut engine = self.engine.lock().unwrap();
965 engine.dispatch_action(action).map_err(SdkError::Engine)?;
966 }
967
968 self.sync_state_to_engine()?;
970
971 Ok(())
972 }
973
974 pub fn get_state(&self) -> S {
976 self.state.lock().unwrap().get().clone()
977 }
978
979 pub fn get_state_json(&self) -> Result<Value> {
981 self.state.lock().unwrap().to_json()
982 }
983
984 pub fn on_patches<F>(&self, callback: F)
986 where
987 F: Fn(&[hypen_engine::Patch]) + Send + Sync + 'static,
988 {
989 let mut engine = self.engine.lock().unwrap();
990 engine.set_render_callback(callback);
991 }
992
993 pub fn is_mounted(&self) -> bool {
995 *self.mounted.lock().unwrap()
996 }
997
998 pub fn name(&self) -> &str {
1000 &self.definition.name
1001 }
1002
1003 #[cfg(feature = "async")]
1006 pub async fn mount_async(&self) {
1007 {
1008 let mut mounted = self.mounted.lock().unwrap();
1009 if *mounted {
1010 return;
1011 }
1012 *mounted = true;
1013 }
1014
1015 self.flush_initial_render();
1019
1020 match &self.definition.on_created {
1021 Some(LifecycleHandler::Async(handler)) => {
1022 let current_state = self.state.lock().unwrap().get().clone();
1023 let ctx = self.global_context.clone();
1024 let new_state = handler(current_state, ctx).await;
1025 *self.state.lock().unwrap().get_mut() = new_state;
1026 }
1027 Some(LifecycleHandler::Sync(handler)) => {
1028 let state = self.state.lock().unwrap();
1029 let ctx = self.global_context.as_deref();
1030 handler(state.get(), ctx);
1031 }
1032 None => {}
1033 }
1034 }
1035
1036 #[cfg(feature = "async")]
1039 pub async fn unmount_async(&self) {
1040 {
1041 let mounted = self.mounted.lock().unwrap();
1042 if !*mounted {
1043 return;
1044 }
1045 }
1046
1047 match &self.definition.on_destroyed {
1048 Some(LifecycleHandler::Async(handler)) => {
1049 let current_state = self.state.lock().unwrap().get().clone();
1050 let ctx = self.global_context.clone();
1051 let new_state = handler(current_state, ctx).await;
1052 *self.state.lock().unwrap().get_mut() = new_state;
1053 }
1054 Some(LifecycleHandler::Sync(handler)) => {
1055 let state = self.state.lock().unwrap();
1056 let ctx = self.global_context.as_deref();
1057 handler(state.get(), ctx);
1058 }
1059 None => {}
1060 }
1061
1062 *self.mounted.lock().unwrap() = false;
1063 }
1064
1065 #[cfg(feature = "async")]
1068 pub async fn dispatch_action_async(
1069 &self,
1070 name: impl Into<String>,
1071 payload: Option<Value>,
1072 ) -> Result<()> {
1073 let name = name.into();
1074
1075 if name == "__hypen_bind" {
1078 return self.handle_bind_action(payload);
1079 }
1080
1081 {
1083 let mut state = self.state.lock().unwrap();
1084 state.take_snapshot()?;
1085 }
1086
1087 match self.definition.action_handlers.get(&name) {
1088 Some(ActionHandler::Async(handler)) => {
1089 let current_state = self.state.lock().unwrap().get().clone();
1090 let ctx = self.global_context.clone();
1091 let new_state = handler(current_state, payload, ctx).await;
1092 *self.state.lock().unwrap().get_mut() = new_state;
1093 }
1094 Some(ActionHandler::Sync(handler)) => {
1095 let ctx = self.global_context.as_deref();
1096 let mut state = self.state.lock().unwrap();
1097 handler(state.get_mut(), payload.as_ref(), ctx);
1098 }
1099 None => {
1100 return Err(SdkError::Engine(hypen_engine::EngineError::ActionNotFound(
1101 name,
1102 )));
1103 }
1104 }
1105
1106 self.sync_state_to_engine()?;
1107 Ok(())
1108 }
1109
1110 fn handle_bind_action(&self, payload: Option<Value>) -> Result<()> {
1118 let payload = payload.ok_or_else(|| SdkError::ActionPayload {
1119 action: "__hypen_bind".into(),
1120 message: "missing payload".into(),
1121 })?;
1122 let obj = payload.as_object().ok_or_else(|| SdkError::ActionPayload {
1123 action: "__hypen_bind".into(),
1124 message: "payload must be an object".into(),
1125 })?;
1126 let path = obj
1127 .get("path")
1128 .and_then(|p| p.as_str())
1129 .ok_or_else(|| SdkError::ActionPayload {
1130 action: "__hypen_bind".into(),
1131 message: "missing 'path' string field".into(),
1132 })?
1133 .to_string();
1134 let value = obj.get("value").cloned().unwrap_or(Value::Null);
1135
1136 {
1137 let mut state = self.state.lock().unwrap();
1138 state.take_snapshot()?;
1139 let new_typed: S = crate::state::apply_bind(state.get(), &path, value)?;
1140 *state.get_mut() = new_typed;
1141 }
1142
1143 self.sync_state_to_engine()
1144 }
1145
1146 fn sync_state_to_engine(&self) -> Result<()> {
1159 let state = self.state.lock().unwrap();
1160 let paths = state.changed_paths()?;
1161
1162 if !paths.is_empty() {
1163 let patch = state.diff_patch()?;
1164 drop(state); let mut engine = self.engine.lock().unwrap();
1167 engine.update_state(None, patch);
1168 }
1169
1170 Ok(())
1171 }
1172}
1173
1174pub fn create_nested_instance<S: State>(
1196 definition: Arc<ModuleDefinition<S>>,
1197 context: Arc<GlobalContext>,
1198) -> Result<ModuleInstance<S>> {
1199 let instance = ModuleInstance::new(definition, Some(context.clone()))?;
1200 let name = instance.name().to_lowercase();
1201 let state_json = instance.get_state_json()?;
1202 context.register_module_state(&name, state_json);
1203 instance.mount();
1204 Ok(instance)
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209 use super::*;
1210 use serde::{Deserialize, Serialize};
1211 use std::sync::atomic::{AtomicI32, Ordering};
1212
1213 #[derive(Clone, Default, Serialize, Deserialize, Debug)]
1214 struct TestState {
1215 count: i32,
1216 name: String,
1217 }
1218
1219 #[test]
1220 fn test_module_builder_action() {
1221 let def = ModuleBuilder::<TestState>::new("Test")
1222 .state(TestState {
1223 count: 0,
1224 name: "Alice".into(),
1225 })
1226 .on_action::<()>("increment", |state, _, _ctx| {
1227 state.count += 1;
1228 })
1229 .build();
1230
1231 assert_eq!(def.name(), "Test");
1232 assert!(def.action_names().contains(&"increment".to_string()));
1233 }
1234
1235 #[test]
1236 fn test_module_builder_with_ui() {
1237 let def = ModuleBuilder::<TestState>::new("Test")
1238 .state(TestState::default())
1239 .ui(r#"Column { Text("Hello") }"#)
1240 .build();
1241
1242 assert_eq!(def.ui_source(), Some(r#"Column { Text("Hello") }"#));
1243 }
1244
1245 #[test]
1246 fn test_module_instance_dispatch() {
1247 let def = ModuleBuilder::<TestState>::new("Test")
1248 .state(TestState {
1249 count: 0,
1250 name: "Alice".into(),
1251 })
1252 .on_action::<()>("increment", |state, _, _ctx| {
1253 state.count += 1;
1254 })
1255 .on_action::<String>("set_name", |state, name, _ctx| {
1256 state.name = name;
1257 })
1258 .build();
1259
1260 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1261 instance.mount();
1262
1263 instance.dispatch_action("increment", None).unwrap();
1264 assert_eq!(instance.get_state().count, 1);
1265
1266 instance.dispatch_action("increment", None).unwrap();
1267 assert_eq!(instance.get_state().count, 2);
1268
1269 instance
1270 .dispatch_action("set_name", Some(serde_json::json!("Bob")))
1271 .unwrap();
1272 assert_eq!(instance.get_state().name, "Bob");
1273 }
1274
1275 #[test]
1276 fn test_module_lifecycle() {
1277 let created = Arc::new(AtomicI32::new(0));
1278 let destroyed = Arc::new(AtomicI32::new(0));
1279
1280 let created_clone = created.clone();
1281 let destroyed_clone = destroyed.clone();
1282
1283 let def = ModuleBuilder::<TestState>::new("Test")
1284 .state(TestState::default())
1285 .on_created(move |_state, _ctx| {
1286 created_clone.fetch_add(1, Ordering::SeqCst);
1287 })
1288 .on_destroyed(move |_state, _ctx| {
1289 destroyed_clone.fetch_add(1, Ordering::SeqCst);
1290 })
1291 .build();
1292
1293 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1294
1295 assert_eq!(created.load(Ordering::SeqCst), 0);
1296 instance.mount();
1297 assert_eq!(created.load(Ordering::SeqCst), 1);
1298
1299 instance.mount();
1301 assert_eq!(created.load(Ordering::SeqCst), 1);
1302
1303 instance.unmount();
1304 assert_eq!(destroyed.load(Ordering::SeqCst), 1);
1305
1306 instance.unmount();
1308 assert_eq!(destroyed.load(Ordering::SeqCst), 1);
1309 }
1310
1311 #[test]
1312 fn test_module_unknown_action() {
1313 let def = ModuleBuilder::<TestState>::new("Test")
1314 .state(TestState::default())
1315 .build();
1316
1317 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1318 let result = instance.dispatch_action("nonexistent", None);
1319 assert!(result.is_err());
1320 }
1321
1322 #[test]
1323 fn test_module_persist_flag() {
1324 let def = ModuleBuilder::<TestState>::new("Test")
1325 .state(TestState::default())
1326 .persist()
1327 .build();
1328
1329 assert!(def.is_persistent());
1330 }
1331
1332 #[test]
1333 fn test_module_typed_payload() {
1334 #[derive(Deserialize)]
1335 struct AddPayload {
1336 amount: i32,
1337 }
1338
1339 let def = ModuleBuilder::<TestState>::new("TypedTest")
1340 .state(TestState {
1341 count: 10,
1342 name: "test".into(),
1343 })
1344 .on_action::<AddPayload>("add", |state, payload, _ctx| {
1345 state.count += payload.amount;
1346 })
1347 .on_action::<()>("reset", |state, _, _ctx| {
1348 state.count = 0;
1349 })
1350 .build();
1351
1352 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1353 instance.mount();
1354
1355 instance
1356 .dispatch_action("add", Some(serde_json::json!({"amount": 5})))
1357 .unwrap();
1358 assert_eq!(instance.get_state().count, 15);
1359
1360 instance.dispatch_action("reset", None).unwrap();
1361 assert_eq!(instance.get_state().count, 0);
1362 }
1363
1364 #[test]
1365 fn test_module_multiple_typed_actions() {
1366 #[derive(Deserialize)]
1367 struct AddPayload {
1368 amount: i32,
1369 }
1370
1371 #[derive(Deserialize)]
1372 struct MultiplyPayload {
1373 factor: i32,
1374 }
1375
1376 let def = ModuleBuilder::<TestState>::new("Mixed")
1377 .state(TestState {
1378 count: 10,
1379 name: "test".into(),
1380 })
1381 .on_action::<()>("reset", |state, _, _ctx| {
1382 state.count = 0;
1383 })
1384 .on_action::<AddPayload>("add", |state, payload, _ctx| {
1385 state.count += payload.amount;
1386 })
1387 .on_action::<MultiplyPayload>("multiply", |state, payload, _ctx| {
1388 state.count *= payload.factor;
1389 })
1390 .build();
1391
1392 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1393 instance.mount();
1394
1395 instance.dispatch_action("reset", None).unwrap();
1396 assert_eq!(instance.get_state().count, 0);
1397
1398 instance
1399 .dispatch_action("add", Some(serde_json::json!({"amount": 5})))
1400 .unwrap();
1401 assert_eq!(instance.get_state().count, 5);
1402
1403 instance
1404 .dispatch_action("multiply", Some(serde_json::json!({"factor": 3})))
1405 .unwrap();
1406 assert_eq!(instance.get_state().count, 15);
1407 }
1408
1409 #[test]
1410 #[should_panic(expected = "ModuleBuilder::state() must be called before build()")]
1411 fn test_module_builder_panics_without_state() {
1412 let _def = ModuleBuilder::<TestState>::new("Test").build();
1413 }
1414
1415 #[test]
1416 fn test_module_invalid_ui_source() {
1417 let def = ModuleBuilder::<TestState>::new("Test")
1418 .state(TestState::default())
1419 .ui("this is not valid {{{{ hypen")
1420 .build();
1421
1422 let result = ModuleInstance::new(Arc::new(def), None);
1423 assert!(result.is_err());
1424 }
1425
1426 #[test]
1427 fn test_module_payload_type_mismatch_is_noop() {
1428 #[derive(Deserialize)]
1429 struct Expected {
1430 #[allow(dead_code)]
1431 value: i32,
1432 }
1433
1434 let def = ModuleBuilder::<TestState>::new("Test")
1435 .state(TestState {
1436 count: 42,
1437 name: "test".into(),
1438 })
1439 .on_action::<Expected>("set", |state, payload, _ctx| {
1440 state.count = payload.value;
1441 })
1442 .build();
1443
1444 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1445 instance.mount();
1446
1447 instance
1449 .dispatch_action("set", Some(serde_json::json!("wrong type")))
1450 .unwrap();
1451 assert_eq!(instance.get_state().count, 42); }
1453
1454 #[test]
1455 fn test_module_duplicate_action_last_wins() {
1456 let def = ModuleBuilder::<TestState>::new("Test")
1457 .state(TestState {
1458 count: 0,
1459 name: "test".into(),
1460 })
1461 .on_action::<()>("act", |state, _, _ctx| {
1462 state.count += 1;
1463 })
1464 .on_action::<()>("act", |state, _, _ctx| {
1465 state.count += 100;
1466 })
1467 .build();
1468
1469 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1470 instance.dispatch_action("act", None).unwrap();
1471 assert_eq!(instance.get_state().count, 100); }
1473
1474 #[test]
1475 fn test_module_ui_file() {
1476 let dir = std::env::temp_dir().join("hypen_test_ui_file");
1477 let _ = std::fs::remove_dir_all(&dir);
1478 std::fs::create_dir_all(&dir).unwrap();
1479
1480 let path = dir.join("counter.hypen");
1481 std::fs::write(&path, r#"Column { Text("Hello") }"#).unwrap();
1482
1483 let def = ModuleBuilder::<TestState>::new("Test")
1484 .state(TestState::default())
1485 .ui_file(path.to_str().unwrap())
1486 .build();
1487
1488 let instance = ModuleInstance::new(Arc::new(def), None);
1489 assert!(instance.is_ok());
1490
1491 let _ = std::fs::remove_dir_all(&dir);
1492 }
1493
1494 #[test]
1495 fn test_module_ui_file_not_found() {
1496 let def = ModuleBuilder::<TestState>::new("Test")
1497 .state(TestState::default())
1498 .ui_file("/tmp/hypen_no_such_file.hypen")
1499 .build();
1500
1501 let result = ModuleInstance::new(Arc::new(def), None);
1502 assert!(result.is_err());
1503 }
1504
1505 #[test]
1506 fn test_module_dispatch_without_mount() {
1507 let def = ModuleBuilder::<TestState>::new("Test")
1508 .state(TestState {
1509 count: 0,
1510 name: "test".into(),
1511 })
1512 .on_action::<()>("inc", |state, _, _ctx| {
1513 state.count += 1;
1514 })
1515 .build();
1516
1517 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1518 instance.dispatch_action("inc", None).unwrap();
1520 assert_eq!(instance.get_state().count, 1);
1521 }
1522
1523 #[test]
1524 fn test_module_raw_json_action() {
1525 let def = ModuleBuilder::<TestState>::new("RawTest")
1526 .state(TestState {
1527 count: 0,
1528 name: "test".into(),
1529 })
1530 .on_action::<Value>("set_count", |state, payload, _ctx| {
1531 if let Some(n) = payload.as_i64() {
1532 state.count = n as i32;
1533 }
1534 })
1535 .build();
1536
1537 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1538 instance.mount();
1539
1540 instance
1541 .dispatch_action("set_count", Some(serde_json::json!(42)))
1542 .unwrap();
1543 assert_eq!(instance.get_state().count, 42);
1544 }
1545
1546 #[test]
1547 fn test_nested_module_registers_in_context() {
1548 let ctx = Arc::new(GlobalContext::new());
1549
1550 let def = Arc::new(
1551 ModuleBuilder::<TestState>::new("Feed")
1552 .state(TestState {
1553 count: 0,
1554 name: "feed".into(),
1555 })
1556 .build(),
1557 );
1558
1559 let instance = create_nested_instance(def, ctx.clone()).unwrap();
1560
1561 assert!(ctx.has_module("feed"));
1563 let state = ctx.get_module_state("feed").unwrap();
1564 assert_eq!(state["name"], "feed");
1565
1566 instance.unmount();
1567 }
1568
1569 #[test]
1570 fn test_nested_module_actions_work() {
1571 let ctx = Arc::new(GlobalContext::new());
1572
1573 let def = Arc::new(
1574 ModuleBuilder::<TestState>::new("Counter")
1575 .state(TestState {
1576 count: 0,
1577 name: String::new(),
1578 })
1579 .on_action::<()>("increment", |state, _, _| {
1580 state.count += 1;
1581 })
1582 .build(),
1583 );
1584
1585 let instance = create_nested_instance(def, ctx.clone()).unwrap();
1586 instance.dispatch_action("increment", None).unwrap();
1587 assert_eq!(instance.get_state().count, 1);
1588
1589 instance.unmount();
1590 }
1591
1592 #[test]
1593 fn test_multiple_nested_modules() {
1594 let ctx = Arc::new(GlobalContext::new());
1595
1596 let feed_def = Arc::new(
1597 ModuleBuilder::<TestState>::new("Feed")
1598 .state(TestState {
1599 count: 0,
1600 name: "feed".into(),
1601 })
1602 .build(),
1603 );
1604 let cart_def = Arc::new(
1605 ModuleBuilder::<TestState>::new("Cart")
1606 .state(TestState {
1607 count: 5,
1608 name: "cart".into(),
1609 })
1610 .build(),
1611 );
1612
1613 let _feed = create_nested_instance(feed_def, ctx.clone()).unwrap();
1614 let _cart = create_nested_instance(cart_def, ctx.clone()).unwrap();
1615
1616 assert!(ctx.has_module("feed"));
1617 assert!(ctx.has_module("cart"));
1618 assert_eq!(ctx.module_names().len(), 2);
1619
1620 let global = ctx.global_state();
1621 assert_eq!(global["feed"]["name"], "feed");
1622 assert_eq!(global["cart"]["count"], 5);
1623 }
1624
1625 #[test]
1626 fn test_new_with_components_resolves_child() {
1627 use crate::discovery::ComponentRegistry;
1628
1629 let mut registry = ComponentRegistry::new();
1630 registry.register("Card", r#"Column { Text("Card content") }"#, None);
1631
1632 let def = ModuleBuilder::<TestState>::new("Parent")
1633 .state(TestState {
1634 count: 0,
1635 name: "parent".into(),
1636 })
1637 .ui(r#"Column { Card {} }"#)
1639 .build();
1640
1641 let instance = ModuleInstance::new_with_components(Arc::new(def), None, ®istry).unwrap();
1643 instance.mount();
1644 assert_eq!(instance.get_state().name, "parent");
1645 }
1646
1647 #[test]
1648 fn test_new_with_components_empty_registry() {
1649 use crate::discovery::ComponentRegistry;
1650
1651 let registry = ComponentRegistry::new();
1652
1653 let def = ModuleBuilder::<TestState>::new("Simple")
1654 .state(TestState::default())
1655 .ui(r#"Column { Text("Hello") }"#)
1656 .build();
1657
1658 let instance = ModuleInstance::new_with_components(Arc::new(def), None, ®istry).unwrap();
1659 instance.mount();
1660 assert!(instance.is_mounted());
1661 }
1662
1663 #[test]
1668 fn test_new_with_components_registers_resources() {
1669 use crate::discovery::ComponentRegistry;
1670
1671 let registry = ComponentRegistry::new();
1672 let heart_svg = r#"<svg viewBox="0 0 24 24"><path d="M12 21s-7-4.5-7-11a5 5 0 0 1 9-3 5 5 0 0 1 9 3c0 6.5-7 11-7 11z" stroke="currentColor"/></svg>"#;
1673
1674 let def = ModuleBuilder::<TestState>::new("WithIcons")
1675 .state(TestState::default())
1676 .ui(r#"Icon(@resources.heart)"#)
1677 .resource("heart", heart_svg)
1678 .build();
1679
1680 let instance = ModuleInstance::new_with_components(Arc::new(def), None, ®istry).unwrap();
1681
1682 let engine = instance.engine.lock().unwrap();
1687 let resolved = engine.resource_registry().resolve("heart");
1688 assert!(
1689 resolved.is_some(),
1690 "heart resource was not registered with the engine in new_with_components — \
1691 Icon(@resources.heart) would render as a raw reference string"
1692 );
1693 let data = resolved.unwrap();
1694 assert!(
1695 !data.paths.is_empty(),
1696 "resolved heart icon has no parsed paths"
1697 );
1698 assert!(
1699 data.paths[0].d.starts_with("M12 21"),
1700 "resolved heart path d did not round-trip: {:?}",
1701 data.paths[0].d
1702 );
1703 }
1704
1705 #[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
1715 struct BindState {
1716 name: String,
1717 count: i32,
1718 nested: Nested,
1719 }
1720
1721 #[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
1722 struct Nested {
1723 flag: bool,
1724 }
1725
1726 #[test]
1727 fn test_hypen_bind_writes_value_at_path() {
1728 let def = ModuleBuilder::<BindState>::new("BindTest")
1729 .state(BindState::default())
1730 .build();
1731 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1732
1733 instance
1734 .dispatch_action(
1735 "__hypen_bind",
1736 Some(serde_json::json!({"path": "name", "value": "Alice"})),
1737 )
1738 .unwrap();
1739
1740 assert_eq!(instance.get_state().name, "Alice");
1741 }
1742
1743 #[test]
1744 fn test_hypen_bind_writes_typed_number() {
1745 let def = ModuleBuilder::<BindState>::new("BindTest")
1746 .state(BindState::default())
1747 .build();
1748 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1749
1750 instance
1751 .dispatch_action(
1752 "__hypen_bind",
1753 Some(serde_json::json!({"path": "count", "value": 42})),
1754 )
1755 .unwrap();
1756
1757 assert_eq!(instance.get_state().count, 42);
1758 }
1759
1760 #[test]
1761 fn test_hypen_bind_writes_nested_path() {
1762 let def = ModuleBuilder::<BindState>::new("BindTest")
1763 .state(BindState::default())
1764 .build();
1765 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1766
1767 instance
1768 .dispatch_action(
1769 "__hypen_bind",
1770 Some(serde_json::json!({"path": "nested.flag", "value": true})),
1771 )
1772 .unwrap();
1773
1774 assert!(instance.get_state().nested.flag);
1775 }
1776
1777 #[test]
1778 fn test_hypen_bind_invalid_path_returns_error() {
1779 let def = ModuleBuilder::<BindState>::new("BindTest")
1782 .state(BindState {
1783 name: "before".into(),
1784 count: 0,
1785 nested: Nested::default(),
1786 })
1787 .build();
1788 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1789
1790 let result = instance.dispatch_action(
1791 "__hypen_bind",
1792 Some(serde_json::json!({"path": "name", "value": 42})), );
1794 assert!(result.is_err(), "type-mismatched bind should fail");
1795 assert_eq!(instance.get_state().name, "before");
1797 }
1798
1799 #[test]
1800 fn test_hypen_bind_missing_path_returns_error() {
1801 let def = ModuleBuilder::<BindState>::new("BindTest")
1802 .state(BindState::default())
1803 .build();
1804 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1805
1806 let result = instance.dispatch_action(
1807 "__hypen_bind",
1808 Some(serde_json::json!({"value": "missing path"})),
1809 );
1810 assert!(matches!(result, Err(SdkError::ActionPayload { .. })));
1811 }
1812
1813 #[test]
1814 fn test_hypen_bind_missing_payload_returns_error() {
1815 let def = ModuleBuilder::<BindState>::new("BindTest")
1816 .state(BindState::default())
1817 .build();
1818 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1819
1820 let result = instance.dispatch_action("__hypen_bind", None);
1821 assert!(matches!(result, Err(SdkError::ActionPayload { .. })));
1822 }
1823
1824 #[cfg(feature = "async")]
1829 mod async_tests {
1830 use super::*;
1831
1832 #[derive(Clone, Default, Serialize, Deserialize, Debug)]
1833 struct AsyncState {
1834 count: i32,
1835 name: String,
1836 }
1837
1838 #[tokio::test]
1839 async fn test_async_action_handler() {
1840 let def = ModuleBuilder::<AsyncState>::new("AsyncTest")
1841 .state(AsyncState {
1842 count: 0,
1843 name: "test".into(),
1844 })
1845 .on_action_async::<()>("increment", |mut state, _, _ctx| {
1846 Box::pin(async move {
1847 state.count += 1;
1848 state
1849 })
1850 })
1851 .build();
1852
1853 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1854 instance.mount();
1855
1856 instance
1857 .dispatch_action_async("increment", None)
1858 .await
1859 .unwrap();
1860 assert_eq!(instance.get_state().count, 1);
1861
1862 instance
1863 .dispatch_action_async("increment", None)
1864 .await
1865 .unwrap();
1866 assert_eq!(instance.get_state().count, 2);
1867 }
1868
1869 #[tokio::test]
1870 async fn test_async_typed_payload() {
1871 #[derive(Deserialize)]
1872 struct AddPayload {
1873 amount: i32,
1874 }
1875
1876 let def = ModuleBuilder::<AsyncState>::new("AsyncTyped")
1877 .state(AsyncState {
1878 count: 10,
1879 name: "test".into(),
1880 })
1881 .on_action_async::<AddPayload>("add", |mut state, payload, _ctx| {
1882 Box::pin(async move {
1883 state.count += payload.amount;
1884 state
1885 })
1886 })
1887 .build();
1888
1889 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1890 instance.mount();
1891
1892 instance
1893 .dispatch_action_async("add", Some(serde_json::json!({"amount": 5})))
1894 .await
1895 .unwrap();
1896 assert_eq!(instance.get_state().count, 15);
1897 }
1898
1899 #[tokio::test]
1900 async fn test_async_falls_back_to_sync() {
1901 let def = ModuleBuilder::<AsyncState>::new("Fallback")
1902 .state(AsyncState {
1903 count: 0,
1904 name: "test".into(),
1905 })
1906 .on_action::<()>("sync_inc", |state, _, _ctx| {
1907 state.count += 1;
1908 })
1909 .build();
1910
1911 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1912
1913 instance
1915 .dispatch_action_async("sync_inc", None)
1916 .await
1917 .unwrap();
1918 assert_eq!(instance.get_state().count, 1);
1919 }
1920
1921 #[tokio::test]
1922 async fn test_async_on_created() {
1923 let def = ModuleBuilder::<AsyncState>::new("AsyncCreated")
1924 .state(AsyncState {
1925 count: 0,
1926 name: "test".into(),
1927 })
1928 .on_created_async(|mut state, _ctx| {
1929 Box::pin(async move {
1930 state.count = 42;
1931 state.name = "initialized".into();
1932 state
1933 })
1934 })
1935 .build();
1936
1937 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1938 instance.mount_async().await;
1939
1940 assert_eq!(instance.get_state().count, 42);
1941 assert_eq!(instance.get_state().name, "initialized");
1942 }
1943
1944 #[tokio::test]
1945 async fn test_async_on_destroyed() {
1946 let destroyed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1947 let destroyed_clone = destroyed.clone();
1948
1949 let def = ModuleBuilder::<AsyncState>::new("AsyncDestroyed")
1950 .state(AsyncState {
1951 count: 0,
1952 name: "test".into(),
1953 })
1954 .on_destroyed_async(move |state, _ctx| {
1955 let flag = destroyed_clone.clone();
1956 Box::pin(async move {
1957 flag.store(true, std::sync::atomic::Ordering::SeqCst);
1958 state
1959 })
1960 })
1961 .build();
1962
1963 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1964 instance.mount();
1965 assert!(!destroyed.load(std::sync::atomic::Ordering::SeqCst));
1966
1967 instance.unmount_async().await;
1968 assert!(destroyed.load(std::sync::atomic::Ordering::SeqCst));
1969 assert!(!instance.is_mounted());
1970 }
1971
1972 #[tokio::test]
1973 async fn test_async_mount_idempotent() {
1974 let call_count = Arc::new(std::sync::atomic::AtomicI32::new(0));
1975 let cc = call_count.clone();
1976
1977 let def = ModuleBuilder::<AsyncState>::new("Idempotent")
1978 .state(AsyncState::default())
1979 .on_created_async(move |state, _ctx| {
1980 let count = cc.clone();
1981 Box::pin(async move {
1982 count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1983 state
1984 })
1985 })
1986 .build();
1987
1988 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1989 instance.mount_async().await;
1990 instance.mount_async().await; assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
1993 }
1994
1995 #[tokio::test]
1996 async fn test_async_dispatch_unknown_action() {
1997 let def = ModuleBuilder::<AsyncState>::new("Unknown")
1998 .state(AsyncState::default())
1999 .build();
2000
2001 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
2002 let result = instance.dispatch_action_async("nonexistent", None).await;
2003 assert!(result.is_err());
2004 }
2005
2006 #[tokio::test]
2007 async fn test_async_mixed_sync_and_async_actions() {
2008 #[derive(Deserialize)]
2009 struct SetName {
2010 name: String,
2011 }
2012
2013 let def = ModuleBuilder::<AsyncState>::new("Mixed")
2014 .state(AsyncState {
2015 count: 0,
2016 name: "init".into(),
2017 })
2018 .on_action::<()>("sync_inc", |state, _, _ctx| {
2019 state.count += 1;
2020 })
2021 .on_action_async::<SetName>("async_set_name", |mut state, payload, _ctx| {
2022 Box::pin(async move {
2023 state.name = payload.name;
2024 state
2025 })
2026 })
2027 .build();
2028
2029 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
2030 instance.mount();
2031
2032 instance
2034 .dispatch_action_async("sync_inc", None)
2035 .await
2036 .unwrap();
2037 assert_eq!(instance.get_state().count, 1);
2038
2039 instance
2040 .dispatch_action_async("async_set_name", Some(serde_json::json!({"name": "Alice"})))
2041 .await
2042 .unwrap();
2043 assert_eq!(instance.get_state().name, "Alice");
2044 }
2045 }
2046}