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_destroyed: Option<LifecycleHandler<S>>,
105 #[allow(dead_code)]
106 pub(crate) on_error: Option<ErrorHandler>,
107 pub(crate) on_disconnect: Option<DisconnectFn<S>>,
108 pub(crate) on_reconnect: Option<ReconnectFn<S>>,
109 pub(crate) on_expire: Option<ExpireFn>,
110 pub(crate) persist: bool,
111 pub(crate) resource_map: indexmap::IndexMap<String, String>,
112}
113
114impl<S: State> ModuleDefinition<S> {
115 pub fn name(&self) -> &str {
116 &self.name
117 }
118
119 pub fn action_names(&self) -> Vec<String> {
120 self.action_handlers.keys().cloned().collect()
121 }
122
123 pub fn ui_source(&self) -> Option<&str> {
124 self.ui_source.as_deref()
125 }
126
127 pub fn is_persistent(&self) -> bool {
128 self.persist
129 }
130}
131
132pub struct ModuleBuilder<S: State> {
162 name: String,
163 initial_state: Option<S>,
164 ui_source: Option<String>,
165 ui_file: Option<String>,
166 action_handlers: HashMap<String, ActionHandler<S>>,
167 on_created: Option<LifecycleHandler<S>>,
168 on_destroyed: Option<LifecycleHandler<S>>,
169 on_error: Option<ErrorHandler>,
170 on_disconnect: Option<DisconnectFn<S>>,
171 on_reconnect: Option<ReconnectFn<S>>,
172 on_expire: Option<ExpireFn>,
173 persist: bool,
174 resource_map: indexmap::IndexMap<String, String>,
175}
176
177impl<S: State> ModuleBuilder<S> {
178 pub fn new(name: impl Into<String>) -> Self {
179 Self {
180 name: name.into(),
181 initial_state: None,
182 ui_source: None,
183 ui_file: None,
184 action_handlers: HashMap::new(),
185 on_created: None,
186 on_destroyed: None,
187 on_error: None,
188 on_disconnect: None,
189 on_reconnect: None,
190 on_expire: None,
191 persist: false,
192 resource_map: indexmap::IndexMap::new(),
193 }
194 }
195
196 pub fn state(mut self, initial: S) -> Self {
198 self.initial_state = Some(initial);
199 self
200 }
201
202 pub fn ui(mut self, source: impl Into<String>) -> Self {
213 self.ui_source = Some(source.into());
214 self
215 }
216
217 pub fn ui_file(mut self, path: impl Into<String>) -> Self {
221 self.ui_file = Some(path.into());
222 self
223 }
224
225 pub fn on_action<A>(
252 mut self,
253 name: impl Into<String>,
254 handler: impl Fn(&mut S, A, Option<&GlobalContext>) + Send + Sync + 'static,
255 ) -> Self
256 where
257 A: DeserializeOwned + 'static,
258 {
259 let wrapped: SyncActionFn<S> = Box::new(move |state, raw, ctx| {
260 let action = match raw {
261 Some(v) => serde_json::from_value::<A>(v.clone()).ok(),
262 None => serde_json::from_value::<A>(Value::Null).ok(),
263 };
264 if let Some(action) = action {
265 handler(state, action, ctx);
266 }
267 });
268 self.action_handlers
269 .insert(name.into(), ActionHandler::Sync(wrapped));
270 self
271 }
272
273 pub fn on_created<F>(mut self, handler: F) -> Self
275 where
276 F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
277 {
278 self.on_created = Some(LifecycleHandler::Sync(Box::new(handler)));
279 self
280 }
281
282 pub fn on_destroyed<F>(mut self, handler: F) -> Self
284 where
285 F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
286 {
287 self.on_destroyed = Some(LifecycleHandler::Sync(Box::new(handler)));
288 self
289 }
290
291 pub fn on_error<F>(mut self, handler: F) -> Self
293 where
294 F: Fn(&ErrorContext) -> ErrorResult + Send + Sync + 'static,
295 {
296 self.on_error = Some(Box::new(handler));
297 self
298 }
299
300 pub fn on_disconnect<F>(mut self, handler: F) -> Self
304 where
305 F: Fn(&S, &SessionInfo) + Send + Sync + 'static,
306 {
307 self.on_disconnect = Some(Box::new(handler));
308 self
309 }
310
311 pub fn on_reconnect<F>(mut self, handler: F) -> Self
318 where
319 F: Fn(&mut S, &SessionInfo, &serde_json::Value) + Send + Sync + 'static,
320 {
321 self.on_reconnect = Some(Box::new(handler));
322 self
323 }
324
325 pub fn on_expire<F>(mut self, handler: F) -> Self
328 where
329 F: Fn(&SessionInfo) + Send + Sync + 'static,
330 {
331 self.on_expire = Some(Box::new(handler));
332 self
333 }
334
335 pub fn resource(mut self, name: impl Into<String>, svg: impl Into<String>) -> Self {
341 self.resource_map.insert(name.into(), svg.into());
342 self
343 }
344
345 pub fn resources(mut self, map: indexmap::IndexMap<String, String>) -> Self {
347 self.resource_map.extend(map);
348 self
349 }
350
351 pub fn resources_dir(mut self, path: impl AsRef<std::path::Path>) -> Self {
356 if let Ok(entries) = std::fs::read_dir(path.as_ref()) {
357 for entry in entries.flatten() {
358 let p = entry.path();
359 if p.extension().and_then(|e| e.to_str()) == Some("svg") {
360 let name = p
361 .file_stem()
362 .and_then(|s| s.to_str())
363 .unwrap_or("")
364 .to_string();
365 if let Ok(svg) = std::fs::read_to_string(&p) {
366 self.resource_map.insert(name, svg);
367 }
368 }
369 }
370 } else {
371 eprintln!(
372 "Warning: could not read resources dir: {}",
373 path.as_ref().display()
374 );
375 }
376 self
377 }
378
379
380 pub fn resources_file(mut self, path: impl AsRef<std::path::Path>) -> Self {
386 match std::fs::read_to_string(path.as_ref()) {
387 Ok(json) => {
388 if let Ok(map) = serde_json::from_str::<indexmap::IndexMap<String, String>>(&json) {
389 self.resource_map.extend(map);
390 } else {
391 eprintln!(
392 "Warning: could not parse resources file {}: expected {{name: svg}} map",
393 path.as_ref().display()
394 );
395 }
396 }
397 Err(e) => eprintln!(
398 "Warning: could not read resources file {}: {}",
399 path.as_ref().display(),
400 e
401 ),
402 }
403 self
404 }
405
406 pub fn persist(mut self) -> Self {
407 self.persist = true;
408 self
409 }
410
411 #[cfg(feature = "async")]
428 pub fn on_action_async<A>(
429 mut self,
430 name: impl Into<String>,
431 handler: impl Fn(S, A, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
432 ) -> Self
433 where
434 A: DeserializeOwned + Send + 'static,
435 {
436 let wrapped: AsyncActionFn<S> = Box::new(move |state, raw, ctx| {
437 let action = match raw {
438 Some(v) => serde_json::from_value::<A>(v).ok(),
439 None => serde_json::from_value::<A>(Value::Null).ok(),
440 };
441 if let Some(action) = action {
442 handler(state, action, ctx)
443 } else {
444 Box::pin(async move { state })
445 }
446 });
447 self.action_handlers
448 .insert(name.into(), ActionHandler::Async(wrapped));
449 self
450 }
451
452 #[cfg(feature = "async")]
465 pub fn on_created_async(
466 mut self,
467 handler: impl Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
468 ) -> Self {
469 self.on_created = Some(LifecycleHandler::Async(Box::new(handler)));
470 self
471 }
472
473 #[cfg(feature = "async")]
486 pub fn on_destroyed_async(
487 mut self,
488 handler: impl Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
489 ) -> Self {
490 self.on_destroyed = Some(LifecycleHandler::Async(Box::new(handler)));
491 self
492 }
493
494 pub fn build(self) -> ModuleDefinition<S> {
496 let initial_state = self
497 .initial_state
498 .expect("ModuleBuilder::state() must be called before build()");
499
500 ModuleDefinition {
501 name: self.name,
502 initial_state,
503 ui_source: self.ui_source,
504 ui_file: self.ui_file,
505 action_handlers: self.action_handlers,
506 on_created: self.on_created,
507 on_destroyed: self.on_destroyed,
508 on_error: self.on_error,
509 on_disconnect: self.on_disconnect,
510 on_reconnect: self.on_reconnect,
511 on_expire: self.on_expire,
512 persist: self.persist,
513 resource_map: self.resource_map,
514 }
515 }
516}
517
518pub struct ModuleInstance<S: State> {
527 definition: Arc<ModuleDefinition<S>>,
528 state: Arc<Mutex<StateContainer<S>>>,
532 engine: Mutex<hypen_engine::Engine>,
533 mounted: Mutex<bool>,
534 global_context: Option<Arc<GlobalContext>>,
535}
536
537impl<S: State> ModuleInstance<S> {
538 pub fn new(
540 definition: Arc<ModuleDefinition<S>>,
541 global_context: Option<Arc<GlobalContext>>,
542 ) -> Result<Self> {
543 let state_container = StateContainer::new(definition.initial_state.clone())?;
544 let mut engine = hypen_engine::Engine::new();
545
546 let module_meta = hypen_engine::lifecycle::Module::new(&definition.name)
548 .with_actions(definition.action_names())
549 .with_persist(definition.persist);
550
551 let initial_json = state_container.to_json()?;
552 let engine_module = hypen_engine::ModuleInstance::new(module_meta, initial_json);
553 engine.set_module(engine_module);
554
555 for (name, svg) in &definition.resource_map {
557 engine.register_resource(name, svg);
558 }
559
560 if let Some(ref source) = definition.ui_source {
562 Self::load_ui_source(&mut engine, source)?;
563 } else if let Some(ref path) = definition.ui_file {
564 let source = std::fs::read_to_string(path).map_err(|e| {
565 SdkError::Component(format!("Failed to read UI file '{path}': {e}"))
566 })?;
567 Self::load_ui_source(&mut engine, &source)?;
568 }
569
570 let state = Arc::new(Mutex::new(state_container));
571 Self::register_action_handlers_with_engine(
572 &mut engine,
573 Arc::clone(&definition),
574 Arc::clone(&state),
575 global_context.clone(),
576 );
577
578 Ok(Self {
579 definition,
580 state,
581 engine: Mutex::new(engine),
582 mounted: Mutex::new(false),
583 global_context,
584 })
585 }
586
587 pub fn new_with_components(
607 definition: Arc<ModuleDefinition<S>>,
608 global_context: Option<Arc<GlobalContext>>,
609 components: &crate::discovery::ComponentRegistry,
610 ) -> Result<Self> {
611 let state_container = StateContainer::new(definition.initial_state.clone())?;
612 let mut engine = hypen_engine::Engine::new();
613
614 let module_meta = hypen_engine::lifecycle::Module::new(&definition.name)
616 .with_actions(definition.action_names())
617 .with_persist(definition.persist);
618
619 let initial_json = state_container.to_json()?;
620 let engine_module = hypen_engine::ModuleInstance::new(module_meta, initial_json);
621 engine.set_module(engine_module);
622
623 for (name, svg) in &definition.resource_map {
628 engine.register_resource(name, svg);
629 }
630
631 let entries: Vec<(String, String, String)> = components
633 .all()
634 .iter()
635 .map(|e| {
636 (
637 e.name.clone(),
638 e.source.clone(),
639 e.path
640 .as_ref()
641 .map(|p| p.to_string_lossy().to_string())
642 .unwrap_or_default(),
643 )
644 })
645 .collect();
646
647 engine.set_component_resolver(move |name, _ctx_path| {
648 entries.iter().find(|(n, _, _)| n == name).map(
649 |(_, source, path)| hypen_engine::ir::ResolvedComponent {
650 source: source.clone(),
651 path: path.clone(),
652 passthrough: false,
653 lazy: false,
654 },
655 )
656 });
657
658 if let Some(ref source) = definition.ui_source {
660 Self::load_ui_source(&mut engine, source)?;
661 } else if let Some(ref path) = definition.ui_file {
662 let source = std::fs::read_to_string(path).map_err(|e| {
663 SdkError::Component(format!("Failed to read UI file '{path}': {e}"))
664 })?;
665 Self::load_ui_source(&mut engine, &source)?;
666 }
667
668 let state = Arc::new(Mutex::new(state_container));
669 Self::register_action_handlers_with_engine(
670 &mut engine,
671 Arc::clone(&definition),
672 Arc::clone(&state),
673 global_context.clone(),
674 );
675
676 Ok(Self {
677 definition,
678 state,
679 engine: Mutex::new(engine),
680 mounted: Mutex::new(false),
681 global_context,
682 })
683 }
684
685 fn register_action_handlers_with_engine(
691 engine: &mut hypen_engine::Engine,
692 definition: Arc<ModuleDefinition<S>>,
693 state: Arc<Mutex<StateContainer<S>>>,
694 global_context: Option<Arc<GlobalContext>>,
695 ) {
696 for (action_name, handler) in definition.action_handlers.iter() {
697 #[cfg(feature = "async")]
701 if matches!(handler, ActionHandler::Async(_)) {
702 continue;
703 }
704 let definition = Arc::clone(&definition);
710 let state = Arc::clone(&state);
711 let global_context = global_context.clone();
712 let action_name_owned = action_name.clone();
713 engine.on_action(action_name.clone(), move |action| {
714 if let Some(ActionHandler::Sync(handler)) =
715 definition.action_handlers.get(&action_name_owned)
716 {
717 let ctx = global_context.as_deref();
718 let mut state_guard = state.lock().unwrap();
719 handler(state_guard.get_mut(), action.payload.as_ref(), ctx);
720 }
721 });
722 let _ = handler;
725 }
726 }
727
728 fn load_ui_source(engine: &mut hypen_engine::Engine, source: &str) -> Result<()> {
729 let doc = hypen_parser::parse_document(source).map_err(|e| {
730 SdkError::Engine(hypen_engine::EngineError::ParseError {
731 source: source.chars().take(80).collect(),
732 message: format!("{e:?}"),
733 })
734 })?;
735 let component = doc
736 .components
737 .first()
738 .ok_or_else(|| SdkError::Component("No component found in UI source".to_string()))?;
739 let ir_node = hypen_engine::ast_to_ir_node(component);
740 engine.render_ir_node(&ir_node);
741 Ok(())
742 }
743
744 pub fn mount(&self) {
749 let mut mounted = self.mounted.lock().unwrap();
750 if !*mounted {
751 *mounted = true;
752 if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_created {
753 let state = self.state.lock().unwrap();
754 let ctx = self.global_context.as_deref();
755 handler(state.get(), ctx);
756 }
757 }
758 }
759
760 pub fn unmount(&self) {
765 let mut mounted = self.mounted.lock().unwrap();
766 if *mounted {
767 if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_destroyed {
768 let state = self.state.lock().unwrap();
769 let ctx = self.global_context.as_deref();
770 handler(state.get(), ctx);
771 }
772 *mounted = false;
773 }
774 }
775
776 pub fn dispatch_action(&self, name: impl Into<String>, payload: Option<Value>) -> Result<()> {
789 let name = name.into();
790
791 if name == "__hypen_bind" {
796 return self.handle_bind_action(payload);
797 }
798
799 #[cfg(feature = "async")]
806 if matches!(
807 self.definition.action_handlers.get(&name),
808 Some(ActionHandler::Async(_))
809 ) {
810 return Err(SdkError::Engine(hypen_engine::EngineError::ActionNotFound(
811 name,
812 )));
813 }
814
815 {
819 let mut state = self.state.lock().unwrap();
820 state.take_snapshot()?;
821 }
822
823 let mut action = hypen_engine::dispatch::Action::new(name.clone());
828 if let Some(p) = payload {
829 action = action.with_payload(p);
830 }
831 {
832 let mut engine = self.engine.lock().unwrap();
833 engine
834 .dispatch_action(action)
835 .map_err(SdkError::Engine)?;
836 }
837
838 self.sync_state_to_engine()?;
840
841 Ok(())
842 }
843
844 pub fn get_state(&self) -> S {
846 self.state.lock().unwrap().get().clone()
847 }
848
849 pub fn get_state_json(&self) -> Result<Value> {
851 self.state.lock().unwrap().to_json()
852 }
853
854 pub fn on_patches<F>(&self, callback: F)
856 where
857 F: Fn(&[hypen_engine::Patch]) + Send + Sync + 'static,
858 {
859 let mut engine = self.engine.lock().unwrap();
860 engine.set_render_callback(callback);
861 }
862
863 pub fn is_mounted(&self) -> bool {
865 *self.mounted.lock().unwrap()
866 }
867
868 pub fn name(&self) -> &str {
870 &self.definition.name
871 }
872
873 #[cfg(feature = "async")]
876 pub async fn mount_async(&self) {
877 {
878 let mut mounted = self.mounted.lock().unwrap();
879 if *mounted {
880 return;
881 }
882 *mounted = true;
883 }
884
885 match &self.definition.on_created {
886 Some(LifecycleHandler::Async(handler)) => {
887 let current_state = self.state.lock().unwrap().get().clone();
888 let ctx = self.global_context.clone();
889 let new_state = handler(current_state, ctx).await;
890 *self.state.lock().unwrap().get_mut() = new_state;
891 }
892 Some(LifecycleHandler::Sync(handler)) => {
893 let state = self.state.lock().unwrap();
894 let ctx = self.global_context.as_deref();
895 handler(state.get(), ctx);
896 }
897 None => {}
898 }
899 }
900
901 #[cfg(feature = "async")]
904 pub async fn unmount_async(&self) {
905 {
906 let mounted = self.mounted.lock().unwrap();
907 if !*mounted {
908 return;
909 }
910 }
911
912 match &self.definition.on_destroyed {
913 Some(LifecycleHandler::Async(handler)) => {
914 let current_state = self.state.lock().unwrap().get().clone();
915 let ctx = self.global_context.clone();
916 let new_state = handler(current_state, ctx).await;
917 *self.state.lock().unwrap().get_mut() = new_state;
918 }
919 Some(LifecycleHandler::Sync(handler)) => {
920 let state = self.state.lock().unwrap();
921 let ctx = self.global_context.as_deref();
922 handler(state.get(), ctx);
923 }
924 None => {}
925 }
926
927 *self.mounted.lock().unwrap() = false;
928 }
929
930 #[cfg(feature = "async")]
933 pub async fn dispatch_action_async(
934 &self,
935 name: impl Into<String>,
936 payload: Option<Value>,
937 ) -> Result<()> {
938 let name = name.into();
939
940 if name == "__hypen_bind" {
943 return self.handle_bind_action(payload);
944 }
945
946 {
948 let mut state = self.state.lock().unwrap();
949 state.take_snapshot()?;
950 }
951
952 match self.definition.action_handlers.get(&name) {
953 Some(ActionHandler::Async(handler)) => {
954 let current_state = self.state.lock().unwrap().get().clone();
955 let ctx = self.global_context.clone();
956 let new_state = handler(current_state, payload, ctx).await;
957 *self.state.lock().unwrap().get_mut() = new_state;
958 }
959 Some(ActionHandler::Sync(handler)) => {
960 let ctx = self.global_context.as_deref();
961 let mut state = self.state.lock().unwrap();
962 handler(state.get_mut(), payload.as_ref(), ctx);
963 }
964 None => {
965 return Err(SdkError::Engine(hypen_engine::EngineError::ActionNotFound(
966 name,
967 )));
968 }
969 }
970
971 self.sync_state_to_engine()?;
972 Ok(())
973 }
974
975 fn handle_bind_action(&self, payload: Option<Value>) -> Result<()> {
983 let payload = payload.ok_or_else(|| SdkError::ActionPayload {
984 action: "__hypen_bind".into(),
985 message: "missing payload".into(),
986 })?;
987 let obj = payload.as_object().ok_or_else(|| SdkError::ActionPayload {
988 action: "__hypen_bind".into(),
989 message: "payload must be an object".into(),
990 })?;
991 let path = obj
992 .get("path")
993 .and_then(|p| p.as_str())
994 .ok_or_else(|| SdkError::ActionPayload {
995 action: "__hypen_bind".into(),
996 message: "missing 'path' string field".into(),
997 })?
998 .to_string();
999 let value = obj.get("value").cloned().unwrap_or(Value::Null);
1000
1001 {
1002 let mut state = self.state.lock().unwrap();
1003 state.take_snapshot()?;
1004 let new_typed: S = crate::state::apply_bind(state.get(), &path, value)?;
1005 *state.get_mut() = new_typed;
1006 }
1007
1008 self.sync_state_to_engine()
1009 }
1010
1011 fn sync_state_to_engine(&self) -> Result<()> {
1024 let state = self.state.lock().unwrap();
1025 let paths = state.changed_paths()?;
1026
1027 if !paths.is_empty() {
1028 let patch = state.diff_patch()?;
1029 drop(state); let mut engine = self.engine.lock().unwrap();
1032 engine.update_state(None, patch);
1033 }
1034
1035 Ok(())
1036 }
1037}
1038
1039pub fn create_nested_instance<S: State>(
1061 definition: Arc<ModuleDefinition<S>>,
1062 context: Arc<GlobalContext>,
1063) -> Result<ModuleInstance<S>> {
1064 let instance = ModuleInstance::new(definition, Some(context.clone()))?;
1065 let name = instance.name().to_lowercase();
1066 let state_json = instance.get_state_json()?;
1067 context.register_module_state(&name, state_json);
1068 instance.mount();
1069 Ok(instance)
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075 use serde::{Deserialize, Serialize};
1076 use std::sync::atomic::{AtomicI32, Ordering};
1077
1078 #[derive(Clone, Default, Serialize, Deserialize, Debug)]
1079 struct TestState {
1080 count: i32,
1081 name: String,
1082 }
1083
1084 #[test]
1085 fn test_module_builder_action() {
1086 let def = ModuleBuilder::<TestState>::new("Test")
1087 .state(TestState {
1088 count: 0,
1089 name: "Alice".into(),
1090 })
1091 .on_action::<()>("increment", |state, _, _ctx| {
1092 state.count += 1;
1093 })
1094 .build();
1095
1096 assert_eq!(def.name(), "Test");
1097 assert!(def.action_names().contains(&"increment".to_string()));
1098 }
1099
1100 #[test]
1101 fn test_module_builder_with_ui() {
1102 let def = ModuleBuilder::<TestState>::new("Test")
1103 .state(TestState::default())
1104 .ui(r#"Column { Text("Hello") }"#)
1105 .build();
1106
1107 assert_eq!(def.ui_source(), Some(r#"Column { Text("Hello") }"#));
1108 }
1109
1110 #[test]
1111 fn test_module_instance_dispatch() {
1112 let def = ModuleBuilder::<TestState>::new("Test")
1113 .state(TestState {
1114 count: 0,
1115 name: "Alice".into(),
1116 })
1117 .on_action::<()>("increment", |state, _, _ctx| {
1118 state.count += 1;
1119 })
1120 .on_action::<String>("set_name", |state, name, _ctx| {
1121 state.name = name;
1122 })
1123 .build();
1124
1125 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1126 instance.mount();
1127
1128 instance.dispatch_action("increment", None).unwrap();
1129 assert_eq!(instance.get_state().count, 1);
1130
1131 instance.dispatch_action("increment", None).unwrap();
1132 assert_eq!(instance.get_state().count, 2);
1133
1134 instance
1135 .dispatch_action("set_name", Some(serde_json::json!("Bob")))
1136 .unwrap();
1137 assert_eq!(instance.get_state().name, "Bob");
1138 }
1139
1140 #[test]
1141 fn test_module_lifecycle() {
1142 let created = Arc::new(AtomicI32::new(0));
1143 let destroyed = Arc::new(AtomicI32::new(0));
1144
1145 let created_clone = created.clone();
1146 let destroyed_clone = destroyed.clone();
1147
1148 let def = ModuleBuilder::<TestState>::new("Test")
1149 .state(TestState::default())
1150 .on_created(move |_state, _ctx| {
1151 created_clone.fetch_add(1, Ordering::SeqCst);
1152 })
1153 .on_destroyed(move |_state, _ctx| {
1154 destroyed_clone.fetch_add(1, Ordering::SeqCst);
1155 })
1156 .build();
1157
1158 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1159
1160 assert_eq!(created.load(Ordering::SeqCst), 0);
1161 instance.mount();
1162 assert_eq!(created.load(Ordering::SeqCst), 1);
1163
1164 instance.mount();
1166 assert_eq!(created.load(Ordering::SeqCst), 1);
1167
1168 instance.unmount();
1169 assert_eq!(destroyed.load(Ordering::SeqCst), 1);
1170
1171 instance.unmount();
1173 assert_eq!(destroyed.load(Ordering::SeqCst), 1);
1174 }
1175
1176 #[test]
1177 fn test_module_unknown_action() {
1178 let def = ModuleBuilder::<TestState>::new("Test")
1179 .state(TestState::default())
1180 .build();
1181
1182 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1183 let result = instance.dispatch_action("nonexistent", None);
1184 assert!(result.is_err());
1185 }
1186
1187 #[test]
1188 fn test_module_persist_flag() {
1189 let def = ModuleBuilder::<TestState>::new("Test")
1190 .state(TestState::default())
1191 .persist()
1192 .build();
1193
1194 assert!(def.is_persistent());
1195 }
1196
1197 #[test]
1198 fn test_module_typed_payload() {
1199 #[derive(Deserialize)]
1200 struct AddPayload {
1201 amount: i32,
1202 }
1203
1204 let def = ModuleBuilder::<TestState>::new("TypedTest")
1205 .state(TestState {
1206 count: 10,
1207 name: "test".into(),
1208 })
1209 .on_action::<AddPayload>("add", |state, payload, _ctx| {
1210 state.count += payload.amount;
1211 })
1212 .on_action::<()>("reset", |state, _, _ctx| {
1213 state.count = 0;
1214 })
1215 .build();
1216
1217 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1218 instance.mount();
1219
1220 instance
1221 .dispatch_action("add", Some(serde_json::json!({"amount": 5})))
1222 .unwrap();
1223 assert_eq!(instance.get_state().count, 15);
1224
1225 instance.dispatch_action("reset", None).unwrap();
1226 assert_eq!(instance.get_state().count, 0);
1227 }
1228
1229 #[test]
1230 fn test_module_multiple_typed_actions() {
1231 #[derive(Deserialize)]
1232 struct AddPayload {
1233 amount: i32,
1234 }
1235
1236 #[derive(Deserialize)]
1237 struct MultiplyPayload {
1238 factor: i32,
1239 }
1240
1241 let def = ModuleBuilder::<TestState>::new("Mixed")
1242 .state(TestState {
1243 count: 10,
1244 name: "test".into(),
1245 })
1246 .on_action::<()>("reset", |state, _, _ctx| {
1247 state.count = 0;
1248 })
1249 .on_action::<AddPayload>("add", |state, payload, _ctx| {
1250 state.count += payload.amount;
1251 })
1252 .on_action::<MultiplyPayload>("multiply", |state, payload, _ctx| {
1253 state.count *= payload.factor;
1254 })
1255 .build();
1256
1257 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1258 instance.mount();
1259
1260 instance.dispatch_action("reset", None).unwrap();
1261 assert_eq!(instance.get_state().count, 0);
1262
1263 instance
1264 .dispatch_action("add", Some(serde_json::json!({"amount": 5})))
1265 .unwrap();
1266 assert_eq!(instance.get_state().count, 5);
1267
1268 instance
1269 .dispatch_action("multiply", Some(serde_json::json!({"factor": 3})))
1270 .unwrap();
1271 assert_eq!(instance.get_state().count, 15);
1272 }
1273
1274 #[test]
1275 #[should_panic(expected = "ModuleBuilder::state() must be called before build()")]
1276 fn test_module_builder_panics_without_state() {
1277 let _def = ModuleBuilder::<TestState>::new("Test").build();
1278 }
1279
1280 #[test]
1281 fn test_module_invalid_ui_source() {
1282 let def = ModuleBuilder::<TestState>::new("Test")
1283 .state(TestState::default())
1284 .ui("this is not valid {{{{ hypen")
1285 .build();
1286
1287 let result = ModuleInstance::new(Arc::new(def), None);
1288 assert!(result.is_err());
1289 }
1290
1291 #[test]
1292 fn test_module_payload_type_mismatch_is_noop() {
1293 #[derive(Deserialize)]
1294 struct Expected {
1295 #[allow(dead_code)]
1296 value: i32,
1297 }
1298
1299 let def = ModuleBuilder::<TestState>::new("Test")
1300 .state(TestState {
1301 count: 42,
1302 name: "test".into(),
1303 })
1304 .on_action::<Expected>("set", |state, payload, _ctx| {
1305 state.count = payload.value;
1306 })
1307 .build();
1308
1309 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1310 instance.mount();
1311
1312 instance
1314 .dispatch_action("set", Some(serde_json::json!("wrong type")))
1315 .unwrap();
1316 assert_eq!(instance.get_state().count, 42); }
1318
1319 #[test]
1320 fn test_module_duplicate_action_last_wins() {
1321 let def = ModuleBuilder::<TestState>::new("Test")
1322 .state(TestState {
1323 count: 0,
1324 name: "test".into(),
1325 })
1326 .on_action::<()>("act", |state, _, _ctx| {
1327 state.count += 1;
1328 })
1329 .on_action::<()>("act", |state, _, _ctx| {
1330 state.count += 100;
1331 })
1332 .build();
1333
1334 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1335 instance.dispatch_action("act", None).unwrap();
1336 assert_eq!(instance.get_state().count, 100); }
1338
1339 #[test]
1340 fn test_module_ui_file() {
1341 let dir = std::env::temp_dir().join("hypen_test_ui_file");
1342 let _ = std::fs::remove_dir_all(&dir);
1343 std::fs::create_dir_all(&dir).unwrap();
1344
1345 let path = dir.join("counter.hypen");
1346 std::fs::write(&path, r#"Column { Text("Hello") }"#).unwrap();
1347
1348 let def = ModuleBuilder::<TestState>::new("Test")
1349 .state(TestState::default())
1350 .ui_file(path.to_str().unwrap())
1351 .build();
1352
1353 let instance = ModuleInstance::new(Arc::new(def), None);
1354 assert!(instance.is_ok());
1355
1356 let _ = std::fs::remove_dir_all(&dir);
1357 }
1358
1359 #[test]
1360 fn test_module_ui_file_not_found() {
1361 let def = ModuleBuilder::<TestState>::new("Test")
1362 .state(TestState::default())
1363 .ui_file("/tmp/hypen_no_such_file.hypen")
1364 .build();
1365
1366 let result = ModuleInstance::new(Arc::new(def), None);
1367 assert!(result.is_err());
1368 }
1369
1370 #[test]
1371 fn test_module_dispatch_without_mount() {
1372 let def = ModuleBuilder::<TestState>::new("Test")
1373 .state(TestState {
1374 count: 0,
1375 name: "test".into(),
1376 })
1377 .on_action::<()>("inc", |state, _, _ctx| {
1378 state.count += 1;
1379 })
1380 .build();
1381
1382 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1383 instance.dispatch_action("inc", None).unwrap();
1385 assert_eq!(instance.get_state().count, 1);
1386 }
1387
1388 #[test]
1389 fn test_module_raw_json_action() {
1390 let def = ModuleBuilder::<TestState>::new("RawTest")
1391 .state(TestState {
1392 count: 0,
1393 name: "test".into(),
1394 })
1395 .on_action::<Value>("set_count", |state, payload, _ctx| {
1396 if let Some(n) = payload.as_i64() {
1397 state.count = n as i32;
1398 }
1399 })
1400 .build();
1401
1402 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1403 instance.mount();
1404
1405 instance
1406 .dispatch_action("set_count", Some(serde_json::json!(42)))
1407 .unwrap();
1408 assert_eq!(instance.get_state().count, 42);
1409 }
1410
1411 #[test]
1412 fn test_nested_module_registers_in_context() {
1413 let ctx = Arc::new(GlobalContext::new());
1414
1415 let def = Arc::new(
1416 ModuleBuilder::<TestState>::new("Feed")
1417 .state(TestState {
1418 count: 0,
1419 name: "feed".into(),
1420 })
1421 .build(),
1422 );
1423
1424 let instance = create_nested_instance(def, ctx.clone()).unwrap();
1425
1426 assert!(ctx.has_module("feed"));
1428 let state = ctx.get_module_state("feed").unwrap();
1429 assert_eq!(state["name"], "feed");
1430
1431 instance.unmount();
1432 }
1433
1434 #[test]
1435 fn test_nested_module_actions_work() {
1436 let ctx = Arc::new(GlobalContext::new());
1437
1438 let def = Arc::new(
1439 ModuleBuilder::<TestState>::new("Counter")
1440 .state(TestState {
1441 count: 0,
1442 name: String::new(),
1443 })
1444 .on_action::<()>("increment", |state, _, _| {
1445 state.count += 1;
1446 })
1447 .build(),
1448 );
1449
1450 let instance = create_nested_instance(def, ctx.clone()).unwrap();
1451 instance.dispatch_action("increment", None).unwrap();
1452 assert_eq!(instance.get_state().count, 1);
1453
1454 instance.unmount();
1455 }
1456
1457 #[test]
1458 fn test_multiple_nested_modules() {
1459 let ctx = Arc::new(GlobalContext::new());
1460
1461 let feed_def = Arc::new(
1462 ModuleBuilder::<TestState>::new("Feed")
1463 .state(TestState {
1464 count: 0,
1465 name: "feed".into(),
1466 })
1467 .build(),
1468 );
1469 let cart_def = Arc::new(
1470 ModuleBuilder::<TestState>::new("Cart")
1471 .state(TestState {
1472 count: 5,
1473 name: "cart".into(),
1474 })
1475 .build(),
1476 );
1477
1478 let _feed = create_nested_instance(feed_def, ctx.clone()).unwrap();
1479 let _cart = create_nested_instance(cart_def, ctx.clone()).unwrap();
1480
1481 assert!(ctx.has_module("feed"));
1482 assert!(ctx.has_module("cart"));
1483 assert_eq!(ctx.module_names().len(), 2);
1484
1485 let global = ctx.global_state();
1486 assert_eq!(global["feed"]["name"], "feed");
1487 assert_eq!(global["cart"]["count"], 5);
1488 }
1489
1490 #[test]
1491 fn test_new_with_components_resolves_child() {
1492 use crate::discovery::ComponentRegistry;
1493
1494 let mut registry = ComponentRegistry::new();
1495 registry.register("Card", r#"Column { Text("Card content") }"#, None);
1496
1497 let def = ModuleBuilder::<TestState>::new("Parent")
1498 .state(TestState {
1499 count: 0,
1500 name: "parent".into(),
1501 })
1502 .ui(r#"Column { Card {} }"#)
1504 .build();
1505
1506 let instance =
1508 ModuleInstance::new_with_components(Arc::new(def), None, ®istry).unwrap();
1509 instance.mount();
1510 assert_eq!(instance.get_state().name, "parent");
1511 }
1512
1513 #[test]
1514 fn test_new_with_components_empty_registry() {
1515 use crate::discovery::ComponentRegistry;
1516
1517 let registry = ComponentRegistry::new();
1518
1519 let def = ModuleBuilder::<TestState>::new("Simple")
1520 .state(TestState::default())
1521 .ui(r#"Column { Text("Hello") }"#)
1522 .build();
1523
1524 let instance =
1525 ModuleInstance::new_with_components(Arc::new(def), None, ®istry).unwrap();
1526 instance.mount();
1527 assert!(instance.is_mounted());
1528 }
1529
1530 #[test]
1535 fn test_new_with_components_registers_resources() {
1536 use crate::discovery::ComponentRegistry;
1537
1538 let registry = ComponentRegistry::new();
1539 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>"#;
1540
1541 let def = ModuleBuilder::<TestState>::new("WithIcons")
1542 .state(TestState::default())
1543 .ui(r#"Icon(@resources.heart)"#)
1544 .resource("heart", heart_svg)
1545 .build();
1546
1547 let instance =
1548 ModuleInstance::new_with_components(Arc::new(def), None, ®istry).unwrap();
1549
1550 let engine = instance.engine.lock().unwrap();
1555 let resolved = engine.resource_registry().resolve("heart");
1556 assert!(
1557 resolved.is_some(),
1558 "heart resource was not registered with the engine in new_with_components — \
1559 Icon(@resources.heart) would render as a raw reference string"
1560 );
1561 let data = resolved.unwrap();
1562 assert!(
1563 !data.paths.is_empty(),
1564 "resolved heart icon has no parsed paths"
1565 );
1566 assert!(
1567 data.paths[0].d.starts_with("M12 21"),
1568 "resolved heart path d did not round-trip: {:?}",
1569 data.paths[0].d
1570 );
1571 }
1572
1573 #[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
1583 struct BindState {
1584 name: String,
1585 count: i32,
1586 nested: Nested,
1587 }
1588
1589 #[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
1590 struct Nested {
1591 flag: bool,
1592 }
1593
1594 #[test]
1595 fn test_hypen_bind_writes_value_at_path() {
1596 let def = ModuleBuilder::<BindState>::new("BindTest")
1597 .state(BindState::default())
1598 .build();
1599 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1600
1601 instance
1602 .dispatch_action(
1603 "__hypen_bind",
1604 Some(serde_json::json!({"path": "name", "value": "Alice"})),
1605 )
1606 .unwrap();
1607
1608 assert_eq!(instance.get_state().name, "Alice");
1609 }
1610
1611 #[test]
1612 fn test_hypen_bind_writes_typed_number() {
1613 let def = ModuleBuilder::<BindState>::new("BindTest")
1614 .state(BindState::default())
1615 .build();
1616 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1617
1618 instance
1619 .dispatch_action(
1620 "__hypen_bind",
1621 Some(serde_json::json!({"path": "count", "value": 42})),
1622 )
1623 .unwrap();
1624
1625 assert_eq!(instance.get_state().count, 42);
1626 }
1627
1628 #[test]
1629 fn test_hypen_bind_writes_nested_path() {
1630 let def = ModuleBuilder::<BindState>::new("BindTest")
1631 .state(BindState::default())
1632 .build();
1633 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1634
1635 instance
1636 .dispatch_action(
1637 "__hypen_bind",
1638 Some(serde_json::json!({"path": "nested.flag", "value": true})),
1639 )
1640 .unwrap();
1641
1642 assert!(instance.get_state().nested.flag);
1643 }
1644
1645 #[test]
1646 fn test_hypen_bind_invalid_path_returns_error() {
1647 let def = ModuleBuilder::<BindState>::new("BindTest")
1650 .state(BindState {
1651 name: "before".into(),
1652 count: 0,
1653 nested: Nested::default(),
1654 })
1655 .build();
1656 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1657
1658 let result = instance.dispatch_action(
1659 "__hypen_bind",
1660 Some(serde_json::json!({"path": "name", "value": 42})), );
1662 assert!(result.is_err(), "type-mismatched bind should fail");
1663 assert_eq!(instance.get_state().name, "before");
1665 }
1666
1667 #[test]
1668 fn test_hypen_bind_missing_path_returns_error() {
1669 let def = ModuleBuilder::<BindState>::new("BindTest")
1670 .state(BindState::default())
1671 .build();
1672 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1673
1674 let result = instance.dispatch_action(
1675 "__hypen_bind",
1676 Some(serde_json::json!({"value": "missing path"})),
1677 );
1678 assert!(matches!(result, Err(SdkError::ActionPayload { .. })));
1679 }
1680
1681 #[test]
1682 fn test_hypen_bind_missing_payload_returns_error() {
1683 let def = ModuleBuilder::<BindState>::new("BindTest")
1684 .state(BindState::default())
1685 .build();
1686 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1687
1688 let result = instance.dispatch_action("__hypen_bind", None);
1689 assert!(matches!(result, Err(SdkError::ActionPayload { .. })));
1690 }
1691
1692 #[cfg(feature = "async")]
1697 mod async_tests {
1698 use super::*;
1699
1700 #[derive(Clone, Default, Serialize, Deserialize, Debug)]
1701 struct AsyncState {
1702 count: i32,
1703 name: String,
1704 }
1705
1706 #[tokio::test]
1707 async fn test_async_action_handler() {
1708 let def = ModuleBuilder::<AsyncState>::new("AsyncTest")
1709 .state(AsyncState {
1710 count: 0,
1711 name: "test".into(),
1712 })
1713 .on_action_async::<()>("increment", |mut state, _, _ctx| {
1714 Box::pin(async move {
1715 state.count += 1;
1716 state
1717 })
1718 })
1719 .build();
1720
1721 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1722 instance.mount();
1723
1724 instance
1725 .dispatch_action_async("increment", None)
1726 .await
1727 .unwrap();
1728 assert_eq!(instance.get_state().count, 1);
1729
1730 instance
1731 .dispatch_action_async("increment", None)
1732 .await
1733 .unwrap();
1734 assert_eq!(instance.get_state().count, 2);
1735 }
1736
1737 #[tokio::test]
1738 async fn test_async_typed_payload() {
1739 #[derive(Deserialize)]
1740 struct AddPayload {
1741 amount: i32,
1742 }
1743
1744 let def = ModuleBuilder::<AsyncState>::new("AsyncTyped")
1745 .state(AsyncState {
1746 count: 10,
1747 name: "test".into(),
1748 })
1749 .on_action_async::<AddPayload>("add", |mut state, payload, _ctx| {
1750 Box::pin(async move {
1751 state.count += payload.amount;
1752 state
1753 })
1754 })
1755 .build();
1756
1757 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1758 instance.mount();
1759
1760 instance
1761 .dispatch_action_async("add", Some(serde_json::json!({"amount": 5})))
1762 .await
1763 .unwrap();
1764 assert_eq!(instance.get_state().count, 15);
1765 }
1766
1767 #[tokio::test]
1768 async fn test_async_falls_back_to_sync() {
1769 let def = ModuleBuilder::<AsyncState>::new("Fallback")
1770 .state(AsyncState {
1771 count: 0,
1772 name: "test".into(),
1773 })
1774 .on_action::<()>("sync_inc", |state, _, _ctx| {
1775 state.count += 1;
1776 })
1777 .build();
1778
1779 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1780
1781 instance
1783 .dispatch_action_async("sync_inc", None)
1784 .await
1785 .unwrap();
1786 assert_eq!(instance.get_state().count, 1);
1787 }
1788
1789 #[tokio::test]
1790 async fn test_async_on_created() {
1791 let def = ModuleBuilder::<AsyncState>::new("AsyncCreated")
1792 .state(AsyncState {
1793 count: 0,
1794 name: "test".into(),
1795 })
1796 .on_created_async(|mut state, _ctx| {
1797 Box::pin(async move {
1798 state.count = 42;
1799 state.name = "initialized".into();
1800 state
1801 })
1802 })
1803 .build();
1804
1805 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1806 instance.mount_async().await;
1807
1808 assert_eq!(instance.get_state().count, 42);
1809 assert_eq!(instance.get_state().name, "initialized");
1810 }
1811
1812 #[tokio::test]
1813 async fn test_async_on_destroyed() {
1814 let destroyed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1815 let destroyed_clone = destroyed.clone();
1816
1817 let def = ModuleBuilder::<AsyncState>::new("AsyncDestroyed")
1818 .state(AsyncState {
1819 count: 0,
1820 name: "test".into(),
1821 })
1822 .on_destroyed_async(move |state, _ctx| {
1823 let flag = destroyed_clone.clone();
1824 Box::pin(async move {
1825 flag.store(true, std::sync::atomic::Ordering::SeqCst);
1826 state
1827 })
1828 })
1829 .build();
1830
1831 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1832 instance.mount();
1833 assert!(!destroyed.load(std::sync::atomic::Ordering::SeqCst));
1834
1835 instance.unmount_async().await;
1836 assert!(destroyed.load(std::sync::atomic::Ordering::SeqCst));
1837 assert!(!instance.is_mounted());
1838 }
1839
1840 #[tokio::test]
1841 async fn test_async_mount_idempotent() {
1842 let call_count = Arc::new(std::sync::atomic::AtomicI32::new(0));
1843 let cc = call_count.clone();
1844
1845 let def = ModuleBuilder::<AsyncState>::new("Idempotent")
1846 .state(AsyncState::default())
1847 .on_created_async(move |state, _ctx| {
1848 let count = cc.clone();
1849 Box::pin(async move {
1850 count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1851 state
1852 })
1853 })
1854 .build();
1855
1856 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1857 instance.mount_async().await;
1858 instance.mount_async().await; assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
1861 }
1862
1863 #[tokio::test]
1864 async fn test_async_dispatch_unknown_action() {
1865 let def = ModuleBuilder::<AsyncState>::new("Unknown")
1866 .state(AsyncState::default())
1867 .build();
1868
1869 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1870 let result = instance.dispatch_action_async("nonexistent", None).await;
1871 assert!(result.is_err());
1872 }
1873
1874 #[tokio::test]
1875 async fn test_async_mixed_sync_and_async_actions() {
1876 #[derive(Deserialize)]
1877 struct SetName {
1878 name: String,
1879 }
1880
1881 let def = ModuleBuilder::<AsyncState>::new("Mixed")
1882 .state(AsyncState {
1883 count: 0,
1884 name: "init".into(),
1885 })
1886 .on_action::<()>("sync_inc", |state, _, _ctx| {
1887 state.count += 1;
1888 })
1889 .on_action_async::<SetName>("async_set_name", |mut state, payload, _ctx| {
1890 Box::pin(async move {
1891 state.name = payload.name;
1892 state
1893 })
1894 })
1895 .build();
1896
1897 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1898 instance.mount();
1899
1900 instance
1902 .dispatch_action_async("sync_inc", None)
1903 .await
1904 .unwrap();
1905 assert_eq!(instance.get_state().count, 1);
1906
1907 instance
1908 .dispatch_action_async("async_set_name", Some(serde_json::json!({"name": "Alice"})))
1909 .await
1910 .unwrap();
1911 assert_eq!(instance.get_state().name, "Alice");
1912 }
1913 }
1914}