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
45pub(crate) enum LifecycleHandler<S> {
51 Sync(SyncLifecycleFn<S>),
52 #[cfg(feature = "async")]
53 Async(AsyncLifecycleFn<S>),
54}
55
56pub(crate) enum ActionHandler<S> {
57 Sync(SyncActionFn<S>),
58 #[cfg(feature = "async")]
59 Async(AsyncActionFn<S>),
60}
61
62pub struct ErrorContext {
64 pub error: SdkError,
65 pub action_name: Option<String>,
66 pub lifecycle: Option<String>,
67}
68
69pub struct ErrorResult {
71 pub handled: bool,
73}
74
75pub use crate::remote::SessionInfo;
77
78type DisconnectFn<S> = Box<dyn Fn(&S, &SessionInfo) + Send + Sync>;
80type ReconnectFn<S> = Box<dyn Fn(&mut S, &SessionInfo, &serde_json::Value) + Send + Sync>;
86type ExpireFn = Box<dyn Fn(&SessionInfo) + Send + Sync>;
88
89pub struct ModuleDefinition<S: State> {
98 pub(crate) name: String,
99 pub(crate) initial_state: S,
100 pub(crate) ui_source: Option<String>,
101 pub(crate) ui_file: Option<String>,
102 pub(crate) action_handlers: HashMap<String, ActionHandler<S>>,
103 pub(crate) on_created: Option<LifecycleHandler<S>>,
104 pub(crate) on_activated: Option<LifecycleHandler<S>>,
105 pub(crate) on_deactivated: Option<LifecycleHandler<S>>,
106 pub(crate) on_destroyed: Option<LifecycleHandler<S>>,
107 #[allow(dead_code)]
108 pub(crate) on_error: Option<ErrorHandler>,
109 pub(crate) on_disconnect: Option<DisconnectFn<S>>,
110 pub(crate) on_reconnect: Option<ReconnectFn<S>>,
111 pub(crate) on_expire: Option<ExpireFn>,
112 pub(crate) persist: bool,
113 pub(crate) resource_map: indexmap::IndexMap<String, String>,
114}
115
116impl<S: State> ModuleDefinition<S> {
117 pub fn name(&self) -> &str {
118 &self.name
119 }
120
121 pub fn action_names(&self) -> Vec<String> {
122 self.action_handlers.keys().cloned().collect()
123 }
124
125 pub fn ui_source(&self) -> Option<&str> {
126 self.ui_source.as_deref()
127 }
128
129 pub fn is_persistent(&self) -> bool {
130 self.persist
131 }
132}
133
134pub struct ModuleBuilder<S: State> {
164 name: String,
165 initial_state: Option<S>,
166 ui_source: Option<String>,
167 ui_file: Option<String>,
168 action_handlers: HashMap<String, ActionHandler<S>>,
169 on_created: Option<LifecycleHandler<S>>,
170 on_activated: Option<LifecycleHandler<S>>,
171 on_deactivated: Option<LifecycleHandler<S>>,
172 on_destroyed: Option<LifecycleHandler<S>>,
173 on_error: Option<ErrorHandler>,
174 on_disconnect: Option<DisconnectFn<S>>,
175 on_reconnect: Option<ReconnectFn<S>>,
176 on_expire: Option<ExpireFn>,
177 persist: bool,
178 resource_map: indexmap::IndexMap<String, String>,
179}
180
181impl<S: State> ModuleBuilder<S> {
182 pub fn new(name: impl Into<String>) -> Self {
183 Self {
184 name: name.into(),
185 initial_state: None,
186 ui_source: None,
187 ui_file: None,
188 action_handlers: HashMap::new(),
189 on_created: None,
190 on_activated: None,
191 on_deactivated: None,
192 on_destroyed: None,
193 on_error: None,
194 on_disconnect: None,
195 on_reconnect: None,
196 on_expire: None,
197 persist: false,
198 resource_map: indexmap::IndexMap::new(),
199 }
200 }
201
202 pub fn state(mut self, initial: S) -> Self {
204 self.initial_state = Some(initial);
205 self
206 }
207
208 pub fn ui(mut self, source: impl Into<String>) -> Self {
219 self.ui_source = Some(source.into());
220 self
221 }
222
223 pub fn ui_file(mut self, path: impl Into<String>) -> Self {
227 self.ui_file = Some(path.into());
228 self
229 }
230
231 pub fn on_action<A>(
258 mut self,
259 name: impl Into<String>,
260 handler: impl Fn(&mut S, A, Option<&GlobalContext>) + Send + Sync + 'static,
261 ) -> Self
262 where
263 A: DeserializeOwned + 'static,
264 {
265 let wrapped: SyncActionFn<S> = Box::new(move |state, raw, ctx| {
266 let action = match raw {
267 Some(v) => serde_json::from_value::<A>(v.clone()).ok(),
268 None => serde_json::from_value::<A>(Value::Null).ok(),
269 };
270 if let Some(action) = action {
271 handler(state, action, ctx);
272 }
273 });
274 self.action_handlers
275 .insert(name.into(), ActionHandler::Sync(wrapped));
276 self
277 }
278
279 pub fn on_created<F>(mut self, handler: F) -> Self
281 where
282 F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
283 {
284 self.on_created = Some(LifecycleHandler::Sync(Box::new(handler)));
285 self
286 }
287
288 pub fn on_destroyed<F>(mut self, handler: F) -> Self
290 where
291 F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
292 {
293 self.on_destroyed = Some(LifecycleHandler::Sync(Box::new(handler)));
294 self
295 }
296
297 pub fn on_activated<F>(mut self, handler: F) -> Self
305 where
306 F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
307 {
308 self.on_activated = Some(LifecycleHandler::Sync(Box::new(handler)));
309 self
310 }
311
312 pub fn on_deactivated<F>(mut self, handler: F) -> Self
316 where
317 F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
318 {
319 self.on_deactivated = Some(LifecycleHandler::Sync(Box::new(handler)));
320 self
321 }
322
323 pub fn on_error<F>(mut self, handler: F) -> Self
325 where
326 F: Fn(&ErrorContext) -> ErrorResult + Send + Sync + 'static,
327 {
328 self.on_error = Some(Box::new(handler));
329 self
330 }
331
332 pub fn on_disconnect<F>(mut self, handler: F) -> Self
336 where
337 F: Fn(&S, &SessionInfo) + Send + Sync + 'static,
338 {
339 self.on_disconnect = Some(Box::new(handler));
340 self
341 }
342
343 pub fn on_reconnect<F>(mut self, handler: F) -> Self
350 where
351 F: Fn(&mut S, &SessionInfo, &serde_json::Value) + Send + Sync + 'static,
352 {
353 self.on_reconnect = Some(Box::new(handler));
354 self
355 }
356
357 pub fn on_expire<F>(mut self, handler: F) -> Self
360 where
361 F: Fn(&SessionInfo) + Send + Sync + 'static,
362 {
363 self.on_expire = Some(Box::new(handler));
364 self
365 }
366
367 pub fn resource(mut self, name: impl Into<String>, svg: impl Into<String>) -> Self {
373 self.resource_map.insert(name.into(), svg.into());
374 self
375 }
376
377 pub fn resources(mut self, map: indexmap::IndexMap<String, String>) -> Self {
379 self.resource_map.extend(map);
380 self
381 }
382
383 pub fn resources_dir(mut self, path: impl AsRef<std::path::Path>) -> Self {
388 if let Ok(entries) = std::fs::read_dir(path.as_ref()) {
389 for entry in entries.flatten() {
390 let p = entry.path();
391 if p.extension().and_then(|e| e.to_str()) == Some("svg") {
392 let name = p
393 .file_stem()
394 .and_then(|s| s.to_str())
395 .unwrap_or("")
396 .to_string();
397 if let Ok(svg) = std::fs::read_to_string(&p) {
398 self.resource_map.insert(name, svg);
399 }
400 }
401 }
402 } else {
403 eprintln!(
404 "Warning: could not read resources dir: {}",
405 path.as_ref().display()
406 );
407 }
408 self
409 }
410
411
412 pub fn resources_file(mut self, path: impl AsRef<std::path::Path>) -> Self {
418 match std::fs::read_to_string(path.as_ref()) {
419 Ok(json) => {
420 if let Ok(map) = serde_json::from_str::<indexmap::IndexMap<String, String>>(&json) {
421 self.resource_map.extend(map);
422 } else {
423 eprintln!(
424 "Warning: could not parse resources file {}: expected {{name: svg}} map",
425 path.as_ref().display()
426 );
427 }
428 }
429 Err(e) => eprintln!(
430 "Warning: could not read resources file {}: {}",
431 path.as_ref().display(),
432 e
433 ),
434 }
435 self
436 }
437
438 pub fn persist(mut self) -> Self {
439 self.persist = true;
440 self
441 }
442
443 #[cfg(feature = "async")]
460 pub fn on_action_async<A>(
461 mut self,
462 name: impl Into<String>,
463 handler: impl Fn(S, A, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
464 ) -> Self
465 where
466 A: DeserializeOwned + Send + 'static,
467 {
468 let wrapped: AsyncActionFn<S> = Box::new(move |state, raw, ctx| {
469 let action = match raw {
470 Some(v) => serde_json::from_value::<A>(v).ok(),
471 None => serde_json::from_value::<A>(Value::Null).ok(),
472 };
473 if let Some(action) = action {
474 handler(state, action, ctx)
475 } else {
476 Box::pin(async move { state })
477 }
478 });
479 self.action_handlers
480 .insert(name.into(), ActionHandler::Async(wrapped));
481 self
482 }
483
484 #[cfg(feature = "async")]
497 pub fn on_created_async(
498 mut self,
499 handler: impl Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
500 ) -> Self {
501 self.on_created = Some(LifecycleHandler::Async(Box::new(handler)));
502 self
503 }
504
505 #[cfg(feature = "async")]
518 pub fn on_destroyed_async(
519 mut self,
520 handler: impl Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
521 ) -> Self {
522 self.on_destroyed = Some(LifecycleHandler::Async(Box::new(handler)));
523 self
524 }
525
526 pub fn build(self) -> ModuleDefinition<S> {
528 let initial_state = self
529 .initial_state
530 .expect("ModuleBuilder::state() must be called before build()");
531
532 ModuleDefinition {
533 name: self.name,
534 initial_state,
535 ui_source: self.ui_source,
536 ui_file: self.ui_file,
537 action_handlers: self.action_handlers,
538 on_created: self.on_created,
539 on_activated: self.on_activated,
540 on_deactivated: self.on_deactivated,
541 on_destroyed: self.on_destroyed,
542 on_error: self.on_error,
543 on_disconnect: self.on_disconnect,
544 on_reconnect: self.on_reconnect,
545 on_expire: self.on_expire,
546 persist: self.persist,
547 resource_map: self.resource_map,
548 }
549 }
550}
551
552pub struct ModuleInstance<S: State> {
561 definition: Arc<ModuleDefinition<S>>,
562 state: Arc<Mutex<StateContainer<S>>>,
566 engine: Mutex<hypen_engine::Engine>,
567 mounted: Mutex<bool>,
568 global_context: Option<Arc<GlobalContext>>,
569}
570
571impl<S: State> ModuleInstance<S> {
572 pub fn new(
574 definition: Arc<ModuleDefinition<S>>,
575 global_context: Option<Arc<GlobalContext>>,
576 ) -> Result<Self> {
577 let state_container = StateContainer::new(definition.initial_state.clone())?;
578 let mut engine = hypen_engine::Engine::new();
579
580 let module_meta = hypen_engine::lifecycle::Module::new(&definition.name)
582 .with_actions(definition.action_names())
583 .with_persist(definition.persist);
584
585 let initial_json = state_container.to_json()?;
586 let engine_module = hypen_engine::ModuleInstance::new(module_meta, initial_json);
587 engine.set_module(engine_module);
588
589 for (name, svg) in &definition.resource_map {
591 engine.register_resource(name, svg);
592 }
593
594 if let Some(ref source) = definition.ui_source {
596 Self::load_ui_source(&mut engine, source)?;
597 } else if let Some(ref path) = definition.ui_file {
598 let source = std::fs::read_to_string(path).map_err(|e| {
599 SdkError::Component(format!("Failed to read UI file '{path}': {e}"))
600 })?;
601 Self::load_ui_source(&mut engine, &source)?;
602 }
603
604 let state = Arc::new(Mutex::new(state_container));
605 Self::register_action_handlers_with_engine(
606 &mut engine,
607 Arc::clone(&definition),
608 Arc::clone(&state),
609 global_context.clone(),
610 );
611
612 Ok(Self {
613 definition,
614 state,
615 engine: Mutex::new(engine),
616 mounted: Mutex::new(false),
617 global_context,
618 })
619 }
620
621 pub fn new_with_components(
641 definition: Arc<ModuleDefinition<S>>,
642 global_context: Option<Arc<GlobalContext>>,
643 components: &crate::discovery::ComponentRegistry,
644 ) -> Result<Self> {
645 let state_container = StateContainer::new(definition.initial_state.clone())?;
646 let mut engine = hypen_engine::Engine::new();
647
648 let module_meta = hypen_engine::lifecycle::Module::new(&definition.name)
650 .with_actions(definition.action_names())
651 .with_persist(definition.persist);
652
653 let initial_json = state_container.to_json()?;
654 let engine_module = hypen_engine::ModuleInstance::new(module_meta, initial_json);
655 engine.set_module(engine_module);
656
657 for (name, svg) in &definition.resource_map {
662 engine.register_resource(name, svg);
663 }
664
665 let entries: Vec<(String, String, String)> = components
667 .all()
668 .iter()
669 .map(|e| {
670 (
671 e.name.clone(),
672 e.source.clone(),
673 e.path
674 .as_ref()
675 .map(|p| p.to_string_lossy().to_string())
676 .unwrap_or_default(),
677 )
678 })
679 .collect();
680
681 engine.set_component_resolver(move |name, _ctx_path| {
682 entries.iter().find(|(n, _, _)| n == name).map(
683 |(_, source, path)| hypen_engine::ir::ResolvedComponent {
684 source: source.clone(),
685 path: path.clone(),
686 passthrough: false,
687 lazy: false,
688 },
689 )
690 });
691
692 if let Some(ref source) = definition.ui_source {
694 Self::load_ui_source(&mut engine, source)?;
695 } else if let Some(ref path) = definition.ui_file {
696 let source = std::fs::read_to_string(path).map_err(|e| {
697 SdkError::Component(format!("Failed to read UI file '{path}': {e}"))
698 })?;
699 Self::load_ui_source(&mut engine, &source)?;
700 }
701
702 let state = Arc::new(Mutex::new(state_container));
703 Self::register_action_handlers_with_engine(
704 &mut engine,
705 Arc::clone(&definition),
706 Arc::clone(&state),
707 global_context.clone(),
708 );
709
710 Ok(Self {
711 definition,
712 state,
713 engine: Mutex::new(engine),
714 mounted: Mutex::new(false),
715 global_context,
716 })
717 }
718
719 fn register_action_handlers_with_engine(
725 engine: &mut hypen_engine::Engine,
726 definition: Arc<ModuleDefinition<S>>,
727 state: Arc<Mutex<StateContainer<S>>>,
728 global_context: Option<Arc<GlobalContext>>,
729 ) {
730 for (action_name, handler) in definition.action_handlers.iter() {
731 #[cfg(feature = "async")]
735 if matches!(handler, ActionHandler::Async(_)) {
736 continue;
737 }
738 let definition = Arc::clone(&definition);
744 let state = Arc::clone(&state);
745 let global_context = global_context.clone();
746 let action_name_owned = action_name.clone();
747 engine.on_action(action_name.clone(), move |action| {
748 if let Some(ActionHandler::Sync(handler)) =
749 definition.action_handlers.get(&action_name_owned)
750 {
751 let ctx = global_context.as_deref();
752 let mut state_guard = state.lock().unwrap();
753 handler(state_guard.get_mut(), action.payload.as_ref(), ctx);
754 }
755 });
756 let _ = handler;
759 }
760 }
761
762 fn load_ui_source(engine: &mut hypen_engine::Engine, source: &str) -> Result<()> {
763 let doc = hypen_parser::parse_document(source).map_err(|e| {
764 SdkError::Engine(hypen_engine::EngineError::ParseError {
765 source: source.chars().take(80).collect(),
766 message: format!("{e:?}"),
767 })
768 })?;
769 let component = doc
770 .components
771 .first()
772 .ok_or_else(|| SdkError::Component("No component found in UI source".to_string()))?;
773 let ir_node = hypen_engine::ast_to_ir_node(component);
774 engine.render_ir_node(&ir_node);
775 Ok(())
776 }
777
778 pub fn mount(&self) {
783 let mut mounted = self.mounted.lock().unwrap();
784 if !*mounted {
785 *mounted = true;
786 if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_created {
787 let state = self.state.lock().unwrap();
788 let ctx = self.global_context.as_deref();
789 handler(state.get(), ctx);
790 }
791 }
792 }
793
794 pub fn activate(&self) {
799 if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_activated {
800 let state = self.state.lock().unwrap();
801 let ctx = self.global_context.as_deref();
802 handler(state.get(), ctx);
803 }
804 }
805
806 pub fn deactivate(&self) {
810 if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_deactivated {
811 let state = self.state.lock().unwrap();
812 let ctx = self.global_context.as_deref();
813 handler(state.get(), ctx);
814 }
815 }
816
817 pub fn unmount(&self) {
822 let mut mounted = self.mounted.lock().unwrap();
823 if *mounted {
824 if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_destroyed {
825 let state = self.state.lock().unwrap();
826 let ctx = self.global_context.as_deref();
827 handler(state.get(), ctx);
828 }
829 *mounted = false;
830 }
831 }
832
833 pub fn dispatch_action(&self, name: impl Into<String>, payload: Option<Value>) -> Result<()> {
846 let name = name.into();
847
848 if name == "__hypen_bind" {
853 return self.handle_bind_action(payload);
854 }
855
856 #[cfg(feature = "async")]
863 if matches!(
864 self.definition.action_handlers.get(&name),
865 Some(ActionHandler::Async(_))
866 ) {
867 return Err(SdkError::Engine(hypen_engine::EngineError::ActionNotFound(
868 name,
869 )));
870 }
871
872 {
876 let mut state = self.state.lock().unwrap();
877 state.take_snapshot()?;
878 }
879
880 let mut action = hypen_engine::dispatch::Action::new(name.clone());
885 if let Some(p) = payload {
886 action = action.with_payload(p);
887 }
888 {
889 let mut engine = self.engine.lock().unwrap();
890 engine
891 .dispatch_action(action)
892 .map_err(SdkError::Engine)?;
893 }
894
895 self.sync_state_to_engine()?;
897
898 Ok(())
899 }
900
901 pub fn get_state(&self) -> S {
903 self.state.lock().unwrap().get().clone()
904 }
905
906 pub fn get_state_json(&self) -> Result<Value> {
908 self.state.lock().unwrap().to_json()
909 }
910
911 pub fn on_patches<F>(&self, callback: F)
913 where
914 F: Fn(&[hypen_engine::Patch]) + Send + Sync + 'static,
915 {
916 let mut engine = self.engine.lock().unwrap();
917 engine.set_render_callback(callback);
918 }
919
920 pub fn is_mounted(&self) -> bool {
922 *self.mounted.lock().unwrap()
923 }
924
925 pub fn name(&self) -> &str {
927 &self.definition.name
928 }
929
930 #[cfg(feature = "async")]
933 pub async fn mount_async(&self) {
934 {
935 let mut mounted = self.mounted.lock().unwrap();
936 if *mounted {
937 return;
938 }
939 *mounted = true;
940 }
941
942 match &self.definition.on_created {
943 Some(LifecycleHandler::Async(handler)) => {
944 let current_state = self.state.lock().unwrap().get().clone();
945 let ctx = self.global_context.clone();
946 let new_state = handler(current_state, ctx).await;
947 *self.state.lock().unwrap().get_mut() = new_state;
948 }
949 Some(LifecycleHandler::Sync(handler)) => {
950 let state = self.state.lock().unwrap();
951 let ctx = self.global_context.as_deref();
952 handler(state.get(), ctx);
953 }
954 None => {}
955 }
956 }
957
958 #[cfg(feature = "async")]
961 pub async fn unmount_async(&self) {
962 {
963 let mounted = self.mounted.lock().unwrap();
964 if !*mounted {
965 return;
966 }
967 }
968
969 match &self.definition.on_destroyed {
970 Some(LifecycleHandler::Async(handler)) => {
971 let current_state = self.state.lock().unwrap().get().clone();
972 let ctx = self.global_context.clone();
973 let new_state = handler(current_state, ctx).await;
974 *self.state.lock().unwrap().get_mut() = new_state;
975 }
976 Some(LifecycleHandler::Sync(handler)) => {
977 let state = self.state.lock().unwrap();
978 let ctx = self.global_context.as_deref();
979 handler(state.get(), ctx);
980 }
981 None => {}
982 }
983
984 *self.mounted.lock().unwrap() = false;
985 }
986
987 #[cfg(feature = "async")]
990 pub async fn dispatch_action_async(
991 &self,
992 name: impl Into<String>,
993 payload: Option<Value>,
994 ) -> Result<()> {
995 let name = name.into();
996
997 if name == "__hypen_bind" {
1000 return self.handle_bind_action(payload);
1001 }
1002
1003 {
1005 let mut state = self.state.lock().unwrap();
1006 state.take_snapshot()?;
1007 }
1008
1009 match self.definition.action_handlers.get(&name) {
1010 Some(ActionHandler::Async(handler)) => {
1011 let current_state = self.state.lock().unwrap().get().clone();
1012 let ctx = self.global_context.clone();
1013 let new_state = handler(current_state, payload, ctx).await;
1014 *self.state.lock().unwrap().get_mut() = new_state;
1015 }
1016 Some(ActionHandler::Sync(handler)) => {
1017 let ctx = self.global_context.as_deref();
1018 let mut state = self.state.lock().unwrap();
1019 handler(state.get_mut(), payload.as_ref(), ctx);
1020 }
1021 None => {
1022 return Err(SdkError::Engine(hypen_engine::EngineError::ActionNotFound(
1023 name,
1024 )));
1025 }
1026 }
1027
1028 self.sync_state_to_engine()?;
1029 Ok(())
1030 }
1031
1032 fn handle_bind_action(&self, payload: Option<Value>) -> Result<()> {
1040 let payload = payload.ok_or_else(|| SdkError::ActionPayload {
1041 action: "__hypen_bind".into(),
1042 message: "missing payload".into(),
1043 })?;
1044 let obj = payload.as_object().ok_or_else(|| SdkError::ActionPayload {
1045 action: "__hypen_bind".into(),
1046 message: "payload must be an object".into(),
1047 })?;
1048 let path = obj
1049 .get("path")
1050 .and_then(|p| p.as_str())
1051 .ok_or_else(|| SdkError::ActionPayload {
1052 action: "__hypen_bind".into(),
1053 message: "missing 'path' string field".into(),
1054 })?
1055 .to_string();
1056 let value = obj.get("value").cloned().unwrap_or(Value::Null);
1057
1058 {
1059 let mut state = self.state.lock().unwrap();
1060 state.take_snapshot()?;
1061 let new_typed: S = crate::state::apply_bind(state.get(), &path, value)?;
1062 *state.get_mut() = new_typed;
1063 }
1064
1065 self.sync_state_to_engine()
1066 }
1067
1068 fn sync_state_to_engine(&self) -> Result<()> {
1081 let state = self.state.lock().unwrap();
1082 let paths = state.changed_paths()?;
1083
1084 if !paths.is_empty() {
1085 let patch = state.diff_patch()?;
1086 drop(state); let mut engine = self.engine.lock().unwrap();
1089 engine.update_state(None, patch);
1090 }
1091
1092 Ok(())
1093 }
1094}
1095
1096pub fn create_nested_instance<S: State>(
1118 definition: Arc<ModuleDefinition<S>>,
1119 context: Arc<GlobalContext>,
1120) -> Result<ModuleInstance<S>> {
1121 let instance = ModuleInstance::new(definition, Some(context.clone()))?;
1122 let name = instance.name().to_lowercase();
1123 let state_json = instance.get_state_json()?;
1124 context.register_module_state(&name, state_json);
1125 instance.mount();
1126 Ok(instance)
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131 use super::*;
1132 use serde::{Deserialize, Serialize};
1133 use std::sync::atomic::{AtomicI32, Ordering};
1134
1135 #[derive(Clone, Default, Serialize, Deserialize, Debug)]
1136 struct TestState {
1137 count: i32,
1138 name: String,
1139 }
1140
1141 #[test]
1142 fn test_module_builder_action() {
1143 let def = ModuleBuilder::<TestState>::new("Test")
1144 .state(TestState {
1145 count: 0,
1146 name: "Alice".into(),
1147 })
1148 .on_action::<()>("increment", |state, _, _ctx| {
1149 state.count += 1;
1150 })
1151 .build();
1152
1153 assert_eq!(def.name(), "Test");
1154 assert!(def.action_names().contains(&"increment".to_string()));
1155 }
1156
1157 #[test]
1158 fn test_module_builder_with_ui() {
1159 let def = ModuleBuilder::<TestState>::new("Test")
1160 .state(TestState::default())
1161 .ui(r#"Column { Text("Hello") }"#)
1162 .build();
1163
1164 assert_eq!(def.ui_source(), Some(r#"Column { Text("Hello") }"#));
1165 }
1166
1167 #[test]
1168 fn test_module_instance_dispatch() {
1169 let def = ModuleBuilder::<TestState>::new("Test")
1170 .state(TestState {
1171 count: 0,
1172 name: "Alice".into(),
1173 })
1174 .on_action::<()>("increment", |state, _, _ctx| {
1175 state.count += 1;
1176 })
1177 .on_action::<String>("set_name", |state, name, _ctx| {
1178 state.name = name;
1179 })
1180 .build();
1181
1182 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1183 instance.mount();
1184
1185 instance.dispatch_action("increment", None).unwrap();
1186 assert_eq!(instance.get_state().count, 1);
1187
1188 instance.dispatch_action("increment", None).unwrap();
1189 assert_eq!(instance.get_state().count, 2);
1190
1191 instance
1192 .dispatch_action("set_name", Some(serde_json::json!("Bob")))
1193 .unwrap();
1194 assert_eq!(instance.get_state().name, "Bob");
1195 }
1196
1197 #[test]
1198 fn test_module_lifecycle() {
1199 let created = Arc::new(AtomicI32::new(0));
1200 let destroyed = Arc::new(AtomicI32::new(0));
1201
1202 let created_clone = created.clone();
1203 let destroyed_clone = destroyed.clone();
1204
1205 let def = ModuleBuilder::<TestState>::new("Test")
1206 .state(TestState::default())
1207 .on_created(move |_state, _ctx| {
1208 created_clone.fetch_add(1, Ordering::SeqCst);
1209 })
1210 .on_destroyed(move |_state, _ctx| {
1211 destroyed_clone.fetch_add(1, Ordering::SeqCst);
1212 })
1213 .build();
1214
1215 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1216
1217 assert_eq!(created.load(Ordering::SeqCst), 0);
1218 instance.mount();
1219 assert_eq!(created.load(Ordering::SeqCst), 1);
1220
1221 instance.mount();
1223 assert_eq!(created.load(Ordering::SeqCst), 1);
1224
1225 instance.unmount();
1226 assert_eq!(destroyed.load(Ordering::SeqCst), 1);
1227
1228 instance.unmount();
1230 assert_eq!(destroyed.load(Ordering::SeqCst), 1);
1231 }
1232
1233 #[test]
1234 fn test_module_unknown_action() {
1235 let def = ModuleBuilder::<TestState>::new("Test")
1236 .state(TestState::default())
1237 .build();
1238
1239 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1240 let result = instance.dispatch_action("nonexistent", None);
1241 assert!(result.is_err());
1242 }
1243
1244 #[test]
1245 fn test_module_persist_flag() {
1246 let def = ModuleBuilder::<TestState>::new("Test")
1247 .state(TestState::default())
1248 .persist()
1249 .build();
1250
1251 assert!(def.is_persistent());
1252 }
1253
1254 #[test]
1255 fn test_module_typed_payload() {
1256 #[derive(Deserialize)]
1257 struct AddPayload {
1258 amount: i32,
1259 }
1260
1261 let def = ModuleBuilder::<TestState>::new("TypedTest")
1262 .state(TestState {
1263 count: 10,
1264 name: "test".into(),
1265 })
1266 .on_action::<AddPayload>("add", |state, payload, _ctx| {
1267 state.count += payload.amount;
1268 })
1269 .on_action::<()>("reset", |state, _, _ctx| {
1270 state.count = 0;
1271 })
1272 .build();
1273
1274 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1275 instance.mount();
1276
1277 instance
1278 .dispatch_action("add", Some(serde_json::json!({"amount": 5})))
1279 .unwrap();
1280 assert_eq!(instance.get_state().count, 15);
1281
1282 instance.dispatch_action("reset", None).unwrap();
1283 assert_eq!(instance.get_state().count, 0);
1284 }
1285
1286 #[test]
1287 fn test_module_multiple_typed_actions() {
1288 #[derive(Deserialize)]
1289 struct AddPayload {
1290 amount: i32,
1291 }
1292
1293 #[derive(Deserialize)]
1294 struct MultiplyPayload {
1295 factor: i32,
1296 }
1297
1298 let def = ModuleBuilder::<TestState>::new("Mixed")
1299 .state(TestState {
1300 count: 10,
1301 name: "test".into(),
1302 })
1303 .on_action::<()>("reset", |state, _, _ctx| {
1304 state.count = 0;
1305 })
1306 .on_action::<AddPayload>("add", |state, payload, _ctx| {
1307 state.count += payload.amount;
1308 })
1309 .on_action::<MultiplyPayload>("multiply", |state, payload, _ctx| {
1310 state.count *= payload.factor;
1311 })
1312 .build();
1313
1314 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1315 instance.mount();
1316
1317 instance.dispatch_action("reset", None).unwrap();
1318 assert_eq!(instance.get_state().count, 0);
1319
1320 instance
1321 .dispatch_action("add", Some(serde_json::json!({"amount": 5})))
1322 .unwrap();
1323 assert_eq!(instance.get_state().count, 5);
1324
1325 instance
1326 .dispatch_action("multiply", Some(serde_json::json!({"factor": 3})))
1327 .unwrap();
1328 assert_eq!(instance.get_state().count, 15);
1329 }
1330
1331 #[test]
1332 #[should_panic(expected = "ModuleBuilder::state() must be called before build()")]
1333 fn test_module_builder_panics_without_state() {
1334 let _def = ModuleBuilder::<TestState>::new("Test").build();
1335 }
1336
1337 #[test]
1338 fn test_module_invalid_ui_source() {
1339 let def = ModuleBuilder::<TestState>::new("Test")
1340 .state(TestState::default())
1341 .ui("this is not valid {{{{ hypen")
1342 .build();
1343
1344 let result = ModuleInstance::new(Arc::new(def), None);
1345 assert!(result.is_err());
1346 }
1347
1348 #[test]
1349 fn test_module_payload_type_mismatch_is_noop() {
1350 #[derive(Deserialize)]
1351 struct Expected {
1352 #[allow(dead_code)]
1353 value: i32,
1354 }
1355
1356 let def = ModuleBuilder::<TestState>::new("Test")
1357 .state(TestState {
1358 count: 42,
1359 name: "test".into(),
1360 })
1361 .on_action::<Expected>("set", |state, payload, _ctx| {
1362 state.count = payload.value;
1363 })
1364 .build();
1365
1366 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1367 instance.mount();
1368
1369 instance
1371 .dispatch_action("set", Some(serde_json::json!("wrong type")))
1372 .unwrap();
1373 assert_eq!(instance.get_state().count, 42); }
1375
1376 #[test]
1377 fn test_module_duplicate_action_last_wins() {
1378 let def = ModuleBuilder::<TestState>::new("Test")
1379 .state(TestState {
1380 count: 0,
1381 name: "test".into(),
1382 })
1383 .on_action::<()>("act", |state, _, _ctx| {
1384 state.count += 1;
1385 })
1386 .on_action::<()>("act", |state, _, _ctx| {
1387 state.count += 100;
1388 })
1389 .build();
1390
1391 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1392 instance.dispatch_action("act", None).unwrap();
1393 assert_eq!(instance.get_state().count, 100); }
1395
1396 #[test]
1397 fn test_module_ui_file() {
1398 let dir = std::env::temp_dir().join("hypen_test_ui_file");
1399 let _ = std::fs::remove_dir_all(&dir);
1400 std::fs::create_dir_all(&dir).unwrap();
1401
1402 let path = dir.join("counter.hypen");
1403 std::fs::write(&path, r#"Column { Text("Hello") }"#).unwrap();
1404
1405 let def = ModuleBuilder::<TestState>::new("Test")
1406 .state(TestState::default())
1407 .ui_file(path.to_str().unwrap())
1408 .build();
1409
1410 let instance = ModuleInstance::new(Arc::new(def), None);
1411 assert!(instance.is_ok());
1412
1413 let _ = std::fs::remove_dir_all(&dir);
1414 }
1415
1416 #[test]
1417 fn test_module_ui_file_not_found() {
1418 let def = ModuleBuilder::<TestState>::new("Test")
1419 .state(TestState::default())
1420 .ui_file("/tmp/hypen_no_such_file.hypen")
1421 .build();
1422
1423 let result = ModuleInstance::new(Arc::new(def), None);
1424 assert!(result.is_err());
1425 }
1426
1427 #[test]
1428 fn test_module_dispatch_without_mount() {
1429 let def = ModuleBuilder::<TestState>::new("Test")
1430 .state(TestState {
1431 count: 0,
1432 name: "test".into(),
1433 })
1434 .on_action::<()>("inc", |state, _, _ctx| {
1435 state.count += 1;
1436 })
1437 .build();
1438
1439 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1440 instance.dispatch_action("inc", None).unwrap();
1442 assert_eq!(instance.get_state().count, 1);
1443 }
1444
1445 #[test]
1446 fn test_module_raw_json_action() {
1447 let def = ModuleBuilder::<TestState>::new("RawTest")
1448 .state(TestState {
1449 count: 0,
1450 name: "test".into(),
1451 })
1452 .on_action::<Value>("set_count", |state, payload, _ctx| {
1453 if let Some(n) = payload.as_i64() {
1454 state.count = n as i32;
1455 }
1456 })
1457 .build();
1458
1459 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1460 instance.mount();
1461
1462 instance
1463 .dispatch_action("set_count", Some(serde_json::json!(42)))
1464 .unwrap();
1465 assert_eq!(instance.get_state().count, 42);
1466 }
1467
1468 #[test]
1469 fn test_nested_module_registers_in_context() {
1470 let ctx = Arc::new(GlobalContext::new());
1471
1472 let def = Arc::new(
1473 ModuleBuilder::<TestState>::new("Feed")
1474 .state(TestState {
1475 count: 0,
1476 name: "feed".into(),
1477 })
1478 .build(),
1479 );
1480
1481 let instance = create_nested_instance(def, ctx.clone()).unwrap();
1482
1483 assert!(ctx.has_module("feed"));
1485 let state = ctx.get_module_state("feed").unwrap();
1486 assert_eq!(state["name"], "feed");
1487
1488 instance.unmount();
1489 }
1490
1491 #[test]
1492 fn test_nested_module_actions_work() {
1493 let ctx = Arc::new(GlobalContext::new());
1494
1495 let def = Arc::new(
1496 ModuleBuilder::<TestState>::new("Counter")
1497 .state(TestState {
1498 count: 0,
1499 name: String::new(),
1500 })
1501 .on_action::<()>("increment", |state, _, _| {
1502 state.count += 1;
1503 })
1504 .build(),
1505 );
1506
1507 let instance = create_nested_instance(def, ctx.clone()).unwrap();
1508 instance.dispatch_action("increment", None).unwrap();
1509 assert_eq!(instance.get_state().count, 1);
1510
1511 instance.unmount();
1512 }
1513
1514 #[test]
1515 fn test_multiple_nested_modules() {
1516 let ctx = Arc::new(GlobalContext::new());
1517
1518 let feed_def = Arc::new(
1519 ModuleBuilder::<TestState>::new("Feed")
1520 .state(TestState {
1521 count: 0,
1522 name: "feed".into(),
1523 })
1524 .build(),
1525 );
1526 let cart_def = Arc::new(
1527 ModuleBuilder::<TestState>::new("Cart")
1528 .state(TestState {
1529 count: 5,
1530 name: "cart".into(),
1531 })
1532 .build(),
1533 );
1534
1535 let _feed = create_nested_instance(feed_def, ctx.clone()).unwrap();
1536 let _cart = create_nested_instance(cart_def, ctx.clone()).unwrap();
1537
1538 assert!(ctx.has_module("feed"));
1539 assert!(ctx.has_module("cart"));
1540 assert_eq!(ctx.module_names().len(), 2);
1541
1542 let global = ctx.global_state();
1543 assert_eq!(global["feed"]["name"], "feed");
1544 assert_eq!(global["cart"]["count"], 5);
1545 }
1546
1547 #[test]
1548 fn test_new_with_components_resolves_child() {
1549 use crate::discovery::ComponentRegistry;
1550
1551 let mut registry = ComponentRegistry::new();
1552 registry.register("Card", r#"Column { Text("Card content") }"#, None);
1553
1554 let def = ModuleBuilder::<TestState>::new("Parent")
1555 .state(TestState {
1556 count: 0,
1557 name: "parent".into(),
1558 })
1559 .ui(r#"Column { Card {} }"#)
1561 .build();
1562
1563 let instance =
1565 ModuleInstance::new_with_components(Arc::new(def), None, ®istry).unwrap();
1566 instance.mount();
1567 assert_eq!(instance.get_state().name, "parent");
1568 }
1569
1570 #[test]
1571 fn test_new_with_components_empty_registry() {
1572 use crate::discovery::ComponentRegistry;
1573
1574 let registry = ComponentRegistry::new();
1575
1576 let def = ModuleBuilder::<TestState>::new("Simple")
1577 .state(TestState::default())
1578 .ui(r#"Column { Text("Hello") }"#)
1579 .build();
1580
1581 let instance =
1582 ModuleInstance::new_with_components(Arc::new(def), None, ®istry).unwrap();
1583 instance.mount();
1584 assert!(instance.is_mounted());
1585 }
1586
1587 #[test]
1592 fn test_new_with_components_registers_resources() {
1593 use crate::discovery::ComponentRegistry;
1594
1595 let registry = ComponentRegistry::new();
1596 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>"#;
1597
1598 let def = ModuleBuilder::<TestState>::new("WithIcons")
1599 .state(TestState::default())
1600 .ui(r#"Icon(@resources.heart)"#)
1601 .resource("heart", heart_svg)
1602 .build();
1603
1604 let instance =
1605 ModuleInstance::new_with_components(Arc::new(def), None, ®istry).unwrap();
1606
1607 let engine = instance.engine.lock().unwrap();
1612 let resolved = engine.resource_registry().resolve("heart");
1613 assert!(
1614 resolved.is_some(),
1615 "heart resource was not registered with the engine in new_with_components — \
1616 Icon(@resources.heart) would render as a raw reference string"
1617 );
1618 let data = resolved.unwrap();
1619 assert!(
1620 !data.paths.is_empty(),
1621 "resolved heart icon has no parsed paths"
1622 );
1623 assert!(
1624 data.paths[0].d.starts_with("M12 21"),
1625 "resolved heart path d did not round-trip: {:?}",
1626 data.paths[0].d
1627 );
1628 }
1629
1630 #[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
1640 struct BindState {
1641 name: String,
1642 count: i32,
1643 nested: Nested,
1644 }
1645
1646 #[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
1647 struct Nested {
1648 flag: bool,
1649 }
1650
1651 #[test]
1652 fn test_hypen_bind_writes_value_at_path() {
1653 let def = ModuleBuilder::<BindState>::new("BindTest")
1654 .state(BindState::default())
1655 .build();
1656 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1657
1658 instance
1659 .dispatch_action(
1660 "__hypen_bind",
1661 Some(serde_json::json!({"path": "name", "value": "Alice"})),
1662 )
1663 .unwrap();
1664
1665 assert_eq!(instance.get_state().name, "Alice");
1666 }
1667
1668 #[test]
1669 fn test_hypen_bind_writes_typed_number() {
1670 let def = ModuleBuilder::<BindState>::new("BindTest")
1671 .state(BindState::default())
1672 .build();
1673 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1674
1675 instance
1676 .dispatch_action(
1677 "__hypen_bind",
1678 Some(serde_json::json!({"path": "count", "value": 42})),
1679 )
1680 .unwrap();
1681
1682 assert_eq!(instance.get_state().count, 42);
1683 }
1684
1685 #[test]
1686 fn test_hypen_bind_writes_nested_path() {
1687 let def = ModuleBuilder::<BindState>::new("BindTest")
1688 .state(BindState::default())
1689 .build();
1690 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1691
1692 instance
1693 .dispatch_action(
1694 "__hypen_bind",
1695 Some(serde_json::json!({"path": "nested.flag", "value": true})),
1696 )
1697 .unwrap();
1698
1699 assert!(instance.get_state().nested.flag);
1700 }
1701
1702 #[test]
1703 fn test_hypen_bind_invalid_path_returns_error() {
1704 let def = ModuleBuilder::<BindState>::new("BindTest")
1707 .state(BindState {
1708 name: "before".into(),
1709 count: 0,
1710 nested: Nested::default(),
1711 })
1712 .build();
1713 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1714
1715 let result = instance.dispatch_action(
1716 "__hypen_bind",
1717 Some(serde_json::json!({"path": "name", "value": 42})), );
1719 assert!(result.is_err(), "type-mismatched bind should fail");
1720 assert_eq!(instance.get_state().name, "before");
1722 }
1723
1724 #[test]
1725 fn test_hypen_bind_missing_path_returns_error() {
1726 let def = ModuleBuilder::<BindState>::new("BindTest")
1727 .state(BindState::default())
1728 .build();
1729 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1730
1731 let result = instance.dispatch_action(
1732 "__hypen_bind",
1733 Some(serde_json::json!({"value": "missing path"})),
1734 );
1735 assert!(matches!(result, Err(SdkError::ActionPayload { .. })));
1736 }
1737
1738 #[test]
1739 fn test_hypen_bind_missing_payload_returns_error() {
1740 let def = ModuleBuilder::<BindState>::new("BindTest")
1741 .state(BindState::default())
1742 .build();
1743 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1744
1745 let result = instance.dispatch_action("__hypen_bind", None);
1746 assert!(matches!(result, Err(SdkError::ActionPayload { .. })));
1747 }
1748
1749 #[cfg(feature = "async")]
1754 mod async_tests {
1755 use super::*;
1756
1757 #[derive(Clone, Default, Serialize, Deserialize, Debug)]
1758 struct AsyncState {
1759 count: i32,
1760 name: String,
1761 }
1762
1763 #[tokio::test]
1764 async fn test_async_action_handler() {
1765 let def = ModuleBuilder::<AsyncState>::new("AsyncTest")
1766 .state(AsyncState {
1767 count: 0,
1768 name: "test".into(),
1769 })
1770 .on_action_async::<()>("increment", |mut state, _, _ctx| {
1771 Box::pin(async move {
1772 state.count += 1;
1773 state
1774 })
1775 })
1776 .build();
1777
1778 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1779 instance.mount();
1780
1781 instance
1782 .dispatch_action_async("increment", None)
1783 .await
1784 .unwrap();
1785 assert_eq!(instance.get_state().count, 1);
1786
1787 instance
1788 .dispatch_action_async("increment", None)
1789 .await
1790 .unwrap();
1791 assert_eq!(instance.get_state().count, 2);
1792 }
1793
1794 #[tokio::test]
1795 async fn test_async_typed_payload() {
1796 #[derive(Deserialize)]
1797 struct AddPayload {
1798 amount: i32,
1799 }
1800
1801 let def = ModuleBuilder::<AsyncState>::new("AsyncTyped")
1802 .state(AsyncState {
1803 count: 10,
1804 name: "test".into(),
1805 })
1806 .on_action_async::<AddPayload>("add", |mut state, payload, _ctx| {
1807 Box::pin(async move {
1808 state.count += payload.amount;
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("add", Some(serde_json::json!({"amount": 5})))
1819 .await
1820 .unwrap();
1821 assert_eq!(instance.get_state().count, 15);
1822 }
1823
1824 #[tokio::test]
1825 async fn test_async_falls_back_to_sync() {
1826 let def = ModuleBuilder::<AsyncState>::new("Fallback")
1827 .state(AsyncState {
1828 count: 0,
1829 name: "test".into(),
1830 })
1831 .on_action::<()>("sync_inc", |state, _, _ctx| {
1832 state.count += 1;
1833 })
1834 .build();
1835
1836 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1837
1838 instance
1840 .dispatch_action_async("sync_inc", None)
1841 .await
1842 .unwrap();
1843 assert_eq!(instance.get_state().count, 1);
1844 }
1845
1846 #[tokio::test]
1847 async fn test_async_on_created() {
1848 let def = ModuleBuilder::<AsyncState>::new("AsyncCreated")
1849 .state(AsyncState {
1850 count: 0,
1851 name: "test".into(),
1852 })
1853 .on_created_async(|mut state, _ctx| {
1854 Box::pin(async move {
1855 state.count = 42;
1856 state.name = "initialized".into();
1857 state
1858 })
1859 })
1860 .build();
1861
1862 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1863 instance.mount_async().await;
1864
1865 assert_eq!(instance.get_state().count, 42);
1866 assert_eq!(instance.get_state().name, "initialized");
1867 }
1868
1869 #[tokio::test]
1870 async fn test_async_on_destroyed() {
1871 let destroyed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1872 let destroyed_clone = destroyed.clone();
1873
1874 let def = ModuleBuilder::<AsyncState>::new("AsyncDestroyed")
1875 .state(AsyncState {
1876 count: 0,
1877 name: "test".into(),
1878 })
1879 .on_destroyed_async(move |state, _ctx| {
1880 let flag = destroyed_clone.clone();
1881 Box::pin(async move {
1882 flag.store(true, std::sync::atomic::Ordering::SeqCst);
1883 state
1884 })
1885 })
1886 .build();
1887
1888 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1889 instance.mount();
1890 assert!(!destroyed.load(std::sync::atomic::Ordering::SeqCst));
1891
1892 instance.unmount_async().await;
1893 assert!(destroyed.load(std::sync::atomic::Ordering::SeqCst));
1894 assert!(!instance.is_mounted());
1895 }
1896
1897 #[tokio::test]
1898 async fn test_async_mount_idempotent() {
1899 let call_count = Arc::new(std::sync::atomic::AtomicI32::new(0));
1900 let cc = call_count.clone();
1901
1902 let def = ModuleBuilder::<AsyncState>::new("Idempotent")
1903 .state(AsyncState::default())
1904 .on_created_async(move |state, _ctx| {
1905 let count = cc.clone();
1906 Box::pin(async move {
1907 count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1908 state
1909 })
1910 })
1911 .build();
1912
1913 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1914 instance.mount_async().await;
1915 instance.mount_async().await; assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
1918 }
1919
1920 #[tokio::test]
1921 async fn test_async_dispatch_unknown_action() {
1922 let def = ModuleBuilder::<AsyncState>::new("Unknown")
1923 .state(AsyncState::default())
1924 .build();
1925
1926 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1927 let result = instance.dispatch_action_async("nonexistent", None).await;
1928 assert!(result.is_err());
1929 }
1930
1931 #[tokio::test]
1932 async fn test_async_mixed_sync_and_async_actions() {
1933 #[derive(Deserialize)]
1934 struct SetName {
1935 name: String,
1936 }
1937
1938 let def = ModuleBuilder::<AsyncState>::new("Mixed")
1939 .state(AsyncState {
1940 count: 0,
1941 name: "init".into(),
1942 })
1943 .on_action::<()>("sync_inc", |state, _, _ctx| {
1944 state.count += 1;
1945 })
1946 .on_action_async::<SetName>("async_set_name", |mut state, payload, _ctx| {
1947 Box::pin(async move {
1948 state.name = payload.name;
1949 state
1950 })
1951 })
1952 .build();
1953
1954 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1955 instance.mount();
1956
1957 instance
1959 .dispatch_action_async("sync_inc", None)
1960 .await
1961 .unwrap();
1962 assert_eq!(instance.get_state().count, 1);
1963
1964 instance
1965 .dispatch_action_async("async_set_name", Some(serde_json::json!({"name": "Alice"})))
1966 .await
1967 .unwrap();
1968 assert_eq!(instance.get_state().name, "Alice");
1969 }
1970 }
1971}