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