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 LifecycleHandler<S> = Box<dyn Fn(&S, Option<&GlobalContext>) + Send + Sync>;
26
27type ActionHandlerFn<S> = Box<dyn Fn(&mut S, Option<&Value>, Option<&GlobalContext>) + Send + Sync>;
30
31type ErrorHandler = Box<dyn Fn(&ErrorContext) -> ErrorResult + Send + Sync>;
33
34#[cfg(feature = "async")]
38type AsyncLifecycleHandler<S> =
39 Box<dyn Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync>;
40
41#[cfg(feature = "async")]
44type AsyncActionHandlerFn<S> =
45 Box<dyn Fn(S, Option<Value>, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync>;
46
47pub struct ErrorContext {
49 pub error: SdkError,
50 pub action_name: Option<String>,
51 pub lifecycle: Option<String>,
52}
53
54pub struct ErrorResult {
56 pub handled: bool,
58}
59
60pub struct ModuleDefinition<S: State> {
69 pub(crate) name: String,
70 pub(crate) initial_state: S,
71 pub(crate) ui_source: Option<String>,
72 #[allow(dead_code)]
73 pub(crate) ui_file: Option<String>,
74 pub(crate) action_handlers: HashMap<String, ActionHandlerFn<S>>,
75 pub(crate) on_created: Option<LifecycleHandler<S>>,
76 pub(crate) on_destroyed: Option<LifecycleHandler<S>>,
77 #[allow(dead_code)]
78 pub(crate) on_error: Option<ErrorHandler>,
79 pub(crate) persist: bool,
80 #[cfg(feature = "async")]
81 pub(crate) async_action_handlers: HashMap<String, AsyncActionHandlerFn<S>>,
82 #[cfg(feature = "async")]
83 pub(crate) on_created_async: Option<AsyncLifecycleHandler<S>>,
84 #[cfg(feature = "async")]
85 pub(crate) on_destroyed_async: Option<AsyncLifecycleHandler<S>>,
86}
87
88impl<S: State> ModuleDefinition<S> {
89 pub fn name(&self) -> &str {
90 &self.name
91 }
92
93 pub fn action_names(&self) -> Vec<String> {
94 self.action_handlers.keys().cloned().collect()
95 }
96
97 pub fn ui_source(&self) -> Option<&str> {
98 self.ui_source.as_deref()
99 }
100
101 pub fn is_persistent(&self) -> bool {
102 self.persist
103 }
104}
105
106pub struct ModuleBuilder<S: State> {
136 name: String,
137 initial_state: Option<S>,
138 ui_source: Option<String>,
139 ui_file: Option<String>,
140 action_handlers: HashMap<String, ActionHandlerFn<S>>,
141 on_created: Option<LifecycleHandler<S>>,
142 on_destroyed: Option<LifecycleHandler<S>>,
143 on_error: Option<ErrorHandler>,
144 persist: bool,
145 #[cfg(feature = "async")]
146 async_action_handlers: HashMap<String, AsyncActionHandlerFn<S>>,
147 #[cfg(feature = "async")]
148 on_created_async: Option<AsyncLifecycleHandler<S>>,
149 #[cfg(feature = "async")]
150 on_destroyed_async: Option<AsyncLifecycleHandler<S>>,
151}
152
153impl<S: State> ModuleBuilder<S> {
154 pub fn new(name: impl Into<String>) -> Self {
155 Self {
156 name: name.into(),
157 initial_state: None,
158 ui_source: None,
159 ui_file: None,
160 action_handlers: HashMap::new(),
161 on_created: None,
162 on_destroyed: None,
163 on_error: None,
164 persist: false,
165 #[cfg(feature = "async")]
166 async_action_handlers: HashMap::new(),
167 #[cfg(feature = "async")]
168 on_created_async: None,
169 #[cfg(feature = "async")]
170 on_destroyed_async: None,
171 }
172 }
173
174 pub fn state(mut self, initial: S) -> Self {
176 self.initial_state = Some(initial);
177 self
178 }
179
180 pub fn ui(mut self, source: impl Into<String>) -> Self {
191 self.ui_source = Some(source.into());
192 self
193 }
194
195 pub fn ui_file(mut self, path: impl Into<String>) -> Self {
199 self.ui_file = Some(path.into());
200 self
201 }
202
203 pub fn on_action<A>(
230 mut self,
231 name: impl Into<String>,
232 handler: impl Fn(&mut S, A, Option<&GlobalContext>) + Send + Sync + 'static,
233 ) -> Self
234 where
235 A: DeserializeOwned + 'static,
236 {
237 let wrapped: ActionHandlerFn<S> = Box::new(move |state, raw, ctx| {
238 let action = match raw {
239 Some(v) => serde_json::from_value::<A>(v.clone()).ok(),
240 None => serde_json::from_value::<A>(Value::Null).ok(),
241 };
242 if let Some(action) = action {
243 handler(state, action, ctx);
244 }
245 });
246 self.action_handlers.insert(name.into(), wrapped);
247 self
248 }
249
250 pub fn on_created<F>(mut self, handler: F) -> Self
252 where
253 F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
254 {
255 self.on_created = Some(Box::new(handler));
256 self
257 }
258
259 pub fn on_destroyed<F>(mut self, handler: F) -> Self
261 where
262 F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
263 {
264 self.on_destroyed = Some(Box::new(handler));
265 self
266 }
267
268 pub fn on_error<F>(mut self, handler: F) -> Self
270 where
271 F: Fn(&ErrorContext) -> ErrorResult + Send + Sync + 'static,
272 {
273 self.on_error = Some(Box::new(handler));
274 self
275 }
276
277 pub fn persist(mut self) -> Self {
279 self.persist = true;
280 self
281 }
282
283 #[cfg(feature = "async")]
300 pub fn on_action_async<A>(
301 mut self,
302 name: impl Into<String>,
303 handler: impl Fn(S, A, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
304 ) -> Self
305 where
306 A: DeserializeOwned + Send + 'static,
307 {
308 let wrapped: AsyncActionHandlerFn<S> = Box::new(move |state, raw, ctx| {
309 let action = match raw {
310 Some(v) => serde_json::from_value::<A>(v).ok(),
311 None => serde_json::from_value::<A>(Value::Null).ok(),
312 };
313 if let Some(action) = action {
314 handler(state, action, ctx)
315 } else {
316 Box::pin(async move { state })
317 }
318 });
319 self.async_action_handlers.insert(name.into(), wrapped);
320 self
321 }
322
323 #[cfg(feature = "async")]
336 pub fn on_created_async(
337 mut self,
338 handler: impl Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
339 ) -> Self {
340 self.on_created_async = Some(Box::new(handler));
341 self
342 }
343
344 #[cfg(feature = "async")]
357 pub fn on_destroyed_async(
358 mut self,
359 handler: impl Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
360 ) -> Self {
361 self.on_destroyed_async = Some(Box::new(handler));
362 self
363 }
364
365 pub fn build(self) -> ModuleDefinition<S> {
367 let initial_state = self
368 .initial_state
369 .expect("ModuleBuilder::state() must be called before build()");
370
371 ModuleDefinition {
372 name: self.name,
373 initial_state,
374 ui_source: self.ui_source,
375 ui_file: self.ui_file,
376 action_handlers: self.action_handlers,
377 on_created: self.on_created,
378 on_destroyed: self.on_destroyed,
379 on_error: self.on_error,
380 persist: self.persist,
381 #[cfg(feature = "async")]
382 async_action_handlers: self.async_action_handlers,
383 #[cfg(feature = "async")]
384 on_created_async: self.on_created_async,
385 #[cfg(feature = "async")]
386 on_destroyed_async: self.on_destroyed_async,
387 }
388 }
389}
390
391pub struct ModuleInstance<S: State> {
400 definition: Arc<ModuleDefinition<S>>,
401 state: Mutex<StateContainer<S>>,
402 engine: Mutex<hypen_engine::Engine>,
403 mounted: Mutex<bool>,
404 global_context: Option<Arc<GlobalContext>>,
405}
406
407impl<S: State> ModuleInstance<S> {
408 pub fn new(
410 definition: Arc<ModuleDefinition<S>>,
411 global_context: Option<Arc<GlobalContext>>,
412 ) -> Result<Self> {
413 let state_container = StateContainer::new(definition.initial_state.clone())?;
414 let mut engine = hypen_engine::Engine::new();
415
416 let module_meta = hypen_engine::lifecycle::Module::new(&definition.name)
418 .with_actions(definition.action_names())
419 .with_persist(definition.persist);
420
421 let initial_json = state_container.to_json()?;
422 let engine_module = hypen_engine::ModuleInstance::new(module_meta, initial_json);
423 engine.set_module(engine_module);
424
425 if let Some(ref source) = definition.ui_source {
427 Self::load_ui_source(&mut engine, source)?;
428 } else if let Some(ref path) = definition.ui_file {
429 let source = std::fs::read_to_string(path).map_err(|e| {
430 SdkError::Component(format!("Failed to read UI file '{path}': {e}"))
431 })?;
432 Self::load_ui_source(&mut engine, &source)?;
433 }
434
435 Ok(Self {
436 definition,
437 state: Mutex::new(state_container),
438 engine: Mutex::new(engine),
439 mounted: Mutex::new(false),
440 global_context,
441 })
442 }
443
444 fn load_ui_source(engine: &mut hypen_engine::Engine, source: &str) -> Result<()> {
445 let doc = hypen_parser::parse_document(source).map_err(|e| {
446 SdkError::Engine(hypen_engine::EngineError::ParseError {
447 source: source.chars().take(80).collect(),
448 message: format!("{e:?}"),
449 })
450 })?;
451 let component = doc
452 .components
453 .first()
454 .ok_or_else(|| SdkError::Component("No component found in UI source".to_string()))?;
455 let ir_node = hypen_engine::ast_to_ir_node(component);
456 engine.render_ir_node(&ir_node);
457 Ok(())
458 }
459
460 pub fn mount(&self) {
462 let mut mounted = self.mounted.lock().unwrap();
463 if !*mounted {
464 *mounted = true;
465 if let Some(ref handler) = self.definition.on_created {
466 let state = self.state.lock().unwrap();
467 let ctx = self.global_context.as_deref();
468 handler(state.get(), ctx);
469 }
470 }
471 }
472
473 pub fn unmount(&self) {
475 let mut mounted = self.mounted.lock().unwrap();
476 if *mounted {
477 if let Some(ref handler) = self.definition.on_destroyed {
478 let state = self.state.lock().unwrap();
479 let ctx = self.global_context.as_deref();
480 handler(state.get(), ctx);
481 }
482 *mounted = false;
483 }
484 }
485
486 pub fn dispatch_action(&self, name: impl Into<String>, payload: Option<Value>) -> Result<()> {
488 let name = name.into();
489
490 {
492 let mut state = self.state.lock().unwrap();
493 state.take_snapshot()?;
494 }
495
496 let ctx = self.global_context.as_deref();
497
498 if let Some(handler) = self.definition.action_handlers.get(&name) {
499 let mut state = self.state.lock().unwrap();
500 handler(state.get_mut(), payload.as_ref(), ctx);
501 } else {
502 return Err(SdkError::Engine(hypen_engine::EngineError::ActionNotFound(
503 name,
504 )));
505 }
506
507 self.sync_state_to_engine()?;
509
510 Ok(())
511 }
512
513 pub fn get_state(&self) -> S {
515 self.state.lock().unwrap().get().clone()
516 }
517
518 pub fn get_state_json(&self) -> Result<Value> {
520 self.state.lock().unwrap().to_json()
521 }
522
523 pub fn on_patches<F>(&self, callback: F)
525 where
526 F: Fn(&[hypen_engine::Patch]) + Send + Sync + 'static,
527 {
528 let mut engine = self.engine.lock().unwrap();
529 engine.set_render_callback(callback);
530 }
531
532 pub fn is_mounted(&self) -> bool {
534 *self.mounted.lock().unwrap()
535 }
536
537 pub fn name(&self) -> &str {
539 &self.definition.name
540 }
541
542 #[cfg(feature = "async")]
545 pub async fn mount_async(&self) {
546 {
547 let mut mounted = self.mounted.lock().unwrap();
548 if *mounted {
549 return;
550 }
551 *mounted = true;
552 }
553
554 if let Some(ref handler) = self.definition.on_created_async {
555 let current_state = self.state.lock().unwrap().get().clone();
556 let ctx = self.global_context.clone();
557 let new_state = handler(current_state, ctx).await;
558 *self.state.lock().unwrap().get_mut() = new_state;
559 } else if let Some(ref handler) = self.definition.on_created {
560 let state = self.state.lock().unwrap();
561 let ctx = self.global_context.as_deref();
562 handler(state.get(), ctx);
563 }
564 }
565
566 #[cfg(feature = "async")]
569 pub async fn unmount_async(&self) {
570 {
571 let mounted = self.mounted.lock().unwrap();
572 if !*mounted {
573 return;
574 }
575 }
576
577 if let Some(ref handler) = self.definition.on_destroyed_async {
578 let current_state = self.state.lock().unwrap().get().clone();
579 let ctx = self.global_context.clone();
580 let new_state = handler(current_state, ctx).await;
581 *self.state.lock().unwrap().get_mut() = new_state;
582 } else if let Some(ref handler) = self.definition.on_destroyed {
583 let state = self.state.lock().unwrap();
584 let ctx = self.global_context.as_deref();
585 handler(state.get(), ctx);
586 }
587
588 *self.mounted.lock().unwrap() = false;
589 }
590
591 #[cfg(feature = "async")]
595 pub async fn dispatch_action_async(
596 &self,
597 name: impl Into<String>,
598 payload: Option<Value>,
599 ) -> Result<()> {
600 let name = name.into();
601
602 {
604 let mut state = self.state.lock().unwrap();
605 state.take_snapshot()?;
606 }
607
608 if let Some(handler) = self.definition.async_action_handlers.get(&name) {
610 let current_state = self.state.lock().unwrap().get().clone();
611 let ctx = self.global_context.clone();
612 let new_state = handler(current_state, payload, ctx).await;
613 *self.state.lock().unwrap().get_mut() = new_state;
614 self.sync_state_to_engine()?;
615 return Ok(());
616 }
617
618 let ctx = self.global_context.as_deref();
620 if let Some(handler) = self.definition.action_handlers.get(&name) {
621 let mut state = self.state.lock().unwrap();
622 handler(state.get_mut(), payload.as_ref(), ctx);
623 } else {
624 return Err(SdkError::Engine(hypen_engine::EngineError::ActionNotFound(
625 name,
626 )));
627 }
628
629 self.sync_state_to_engine()?;
630 Ok(())
631 }
632
633 fn sync_state_to_engine(&self) -> Result<()> {
636 let state = self.state.lock().unwrap();
637 let paths = state.changed_paths()?;
638
639 if !paths.is_empty() {
640 let patch = state.diff_patch()?;
641 drop(state); let mut engine = self.engine.lock().unwrap();
644 engine.update_state(patch);
645 }
646
647 Ok(())
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654 use serde::{Deserialize, Serialize};
655 use std::sync::atomic::{AtomicI32, Ordering};
656
657 #[derive(Clone, Default, Serialize, Deserialize, Debug)]
658 struct TestState {
659 count: i32,
660 name: String,
661 }
662
663 #[test]
664 fn test_module_builder_action() {
665 let def = ModuleBuilder::<TestState>::new("Test")
666 .state(TestState {
667 count: 0,
668 name: "Alice".into(),
669 })
670 .on_action::<()>("increment", |state, _, _ctx| {
671 state.count += 1;
672 })
673 .build();
674
675 assert_eq!(def.name(), "Test");
676 assert!(def.action_names().contains(&"increment".to_string()));
677 }
678
679 #[test]
680 fn test_module_builder_with_ui() {
681 let def = ModuleBuilder::<TestState>::new("Test")
682 .state(TestState::default())
683 .ui(r#"Column { Text("Hello") }"#)
684 .build();
685
686 assert_eq!(def.ui_source(), Some(r#"Column { Text("Hello") }"#));
687 }
688
689 #[test]
690 fn test_module_instance_dispatch() {
691 let def = ModuleBuilder::<TestState>::new("Test")
692 .state(TestState {
693 count: 0,
694 name: "Alice".into(),
695 })
696 .on_action::<()>("increment", |state, _, _ctx| {
697 state.count += 1;
698 })
699 .on_action::<String>("set_name", |state, name, _ctx| {
700 state.name = name;
701 })
702 .build();
703
704 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
705 instance.mount();
706
707 instance.dispatch_action("increment", None).unwrap();
708 assert_eq!(instance.get_state().count, 1);
709
710 instance.dispatch_action("increment", None).unwrap();
711 assert_eq!(instance.get_state().count, 2);
712
713 instance
714 .dispatch_action("set_name", Some(serde_json::json!("Bob")))
715 .unwrap();
716 assert_eq!(instance.get_state().name, "Bob");
717 }
718
719 #[test]
720 fn test_module_lifecycle() {
721 let created = Arc::new(AtomicI32::new(0));
722 let destroyed = Arc::new(AtomicI32::new(0));
723
724 let created_clone = created.clone();
725 let destroyed_clone = destroyed.clone();
726
727 let def = ModuleBuilder::<TestState>::new("Test")
728 .state(TestState::default())
729 .on_created(move |_state, _ctx| {
730 created_clone.fetch_add(1, Ordering::SeqCst);
731 })
732 .on_destroyed(move |_state, _ctx| {
733 destroyed_clone.fetch_add(1, Ordering::SeqCst);
734 })
735 .build();
736
737 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
738
739 assert_eq!(created.load(Ordering::SeqCst), 0);
740 instance.mount();
741 assert_eq!(created.load(Ordering::SeqCst), 1);
742
743 instance.mount();
745 assert_eq!(created.load(Ordering::SeqCst), 1);
746
747 instance.unmount();
748 assert_eq!(destroyed.load(Ordering::SeqCst), 1);
749
750 instance.unmount();
752 assert_eq!(destroyed.load(Ordering::SeqCst), 1);
753 }
754
755 #[test]
756 fn test_module_unknown_action() {
757 let def = ModuleBuilder::<TestState>::new("Test")
758 .state(TestState::default())
759 .build();
760
761 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
762 let result = instance.dispatch_action("nonexistent", None);
763 assert!(result.is_err());
764 }
765
766 #[test]
767 fn test_module_persist_flag() {
768 let def = ModuleBuilder::<TestState>::new("Test")
769 .state(TestState::default())
770 .persist()
771 .build();
772
773 assert!(def.is_persistent());
774 }
775
776 #[test]
777 fn test_module_typed_payload() {
778 #[derive(Deserialize)]
779 struct AddPayload {
780 amount: i32,
781 }
782
783 let def = ModuleBuilder::<TestState>::new("TypedTest")
784 .state(TestState {
785 count: 10,
786 name: "test".into(),
787 })
788 .on_action::<AddPayload>("add", |state, payload, _ctx| {
789 state.count += payload.amount;
790 })
791 .on_action::<()>("reset", |state, _, _ctx| {
792 state.count = 0;
793 })
794 .build();
795
796 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
797 instance.mount();
798
799 instance
800 .dispatch_action("add", Some(serde_json::json!({"amount": 5})))
801 .unwrap();
802 assert_eq!(instance.get_state().count, 15);
803
804 instance.dispatch_action("reset", None).unwrap();
805 assert_eq!(instance.get_state().count, 0);
806 }
807
808 #[test]
809 fn test_module_multiple_typed_actions() {
810 #[derive(Deserialize)]
811 struct AddPayload {
812 amount: i32,
813 }
814
815 #[derive(Deserialize)]
816 struct MultiplyPayload {
817 factor: i32,
818 }
819
820 let def = ModuleBuilder::<TestState>::new("Mixed")
821 .state(TestState {
822 count: 10,
823 name: "test".into(),
824 })
825 .on_action::<()>("reset", |state, _, _ctx| {
826 state.count = 0;
827 })
828 .on_action::<AddPayload>("add", |state, payload, _ctx| {
829 state.count += payload.amount;
830 })
831 .on_action::<MultiplyPayload>("multiply", |state, payload, _ctx| {
832 state.count *= payload.factor;
833 })
834 .build();
835
836 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
837 instance.mount();
838
839 instance.dispatch_action("reset", None).unwrap();
840 assert_eq!(instance.get_state().count, 0);
841
842 instance
843 .dispatch_action("add", Some(serde_json::json!({"amount": 5})))
844 .unwrap();
845 assert_eq!(instance.get_state().count, 5);
846
847 instance
848 .dispatch_action("multiply", Some(serde_json::json!({"factor": 3})))
849 .unwrap();
850 assert_eq!(instance.get_state().count, 15);
851 }
852
853 #[test]
854 #[should_panic(expected = "ModuleBuilder::state() must be called before build()")]
855 fn test_module_builder_panics_without_state() {
856 let _def = ModuleBuilder::<TestState>::new("Test").build();
857 }
858
859 #[test]
860 fn test_module_invalid_ui_source() {
861 let def = ModuleBuilder::<TestState>::new("Test")
862 .state(TestState::default())
863 .ui("this is not valid {{{{ hypen")
864 .build();
865
866 let result = ModuleInstance::new(Arc::new(def), None);
867 assert!(result.is_err());
868 }
869
870 #[test]
871 fn test_module_payload_type_mismatch_is_noop() {
872 #[derive(Deserialize)]
873 struct Expected {
874 #[allow(dead_code)]
875 value: i32,
876 }
877
878 let def = ModuleBuilder::<TestState>::new("Test")
879 .state(TestState {
880 count: 42,
881 name: "test".into(),
882 })
883 .on_action::<Expected>("set", |state, payload, _ctx| {
884 state.count = payload.value;
885 })
886 .build();
887
888 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
889 instance.mount();
890
891 instance
893 .dispatch_action("set", Some(serde_json::json!("wrong type")))
894 .unwrap();
895 assert_eq!(instance.get_state().count, 42); }
897
898 #[test]
899 fn test_module_duplicate_action_last_wins() {
900 let def = ModuleBuilder::<TestState>::new("Test")
901 .state(TestState {
902 count: 0,
903 name: "test".into(),
904 })
905 .on_action::<()>("act", |state, _, _ctx| {
906 state.count += 1;
907 })
908 .on_action::<()>("act", |state, _, _ctx| {
909 state.count += 100;
910 })
911 .build();
912
913 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
914 instance.dispatch_action("act", None).unwrap();
915 assert_eq!(instance.get_state().count, 100); }
917
918 #[test]
919 fn test_module_ui_file() {
920 let dir = std::env::temp_dir().join("hypen_test_ui_file");
921 let _ = std::fs::remove_dir_all(&dir);
922 std::fs::create_dir_all(&dir).unwrap();
923
924 let path = dir.join("counter.hypen");
925 std::fs::write(&path, r#"Column { Text("Hello") }"#).unwrap();
926
927 let def = ModuleBuilder::<TestState>::new("Test")
928 .state(TestState::default())
929 .ui_file(path.to_str().unwrap())
930 .build();
931
932 let instance = ModuleInstance::new(Arc::new(def), None);
933 assert!(instance.is_ok());
934
935 let _ = std::fs::remove_dir_all(&dir);
936 }
937
938 #[test]
939 fn test_module_ui_file_not_found() {
940 let def = ModuleBuilder::<TestState>::new("Test")
941 .state(TestState::default())
942 .ui_file("/tmp/hypen_no_such_file.hypen")
943 .build();
944
945 let result = ModuleInstance::new(Arc::new(def), None);
946 assert!(result.is_err());
947 }
948
949 #[test]
950 fn test_module_dispatch_without_mount() {
951 let def = ModuleBuilder::<TestState>::new("Test")
952 .state(TestState {
953 count: 0,
954 name: "test".into(),
955 })
956 .on_action::<()>("inc", |state, _, _ctx| {
957 state.count += 1;
958 })
959 .build();
960
961 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
962 instance.dispatch_action("inc", None).unwrap();
964 assert_eq!(instance.get_state().count, 1);
965 }
966
967 #[test]
968 fn test_module_raw_json_action() {
969 let def = ModuleBuilder::<TestState>::new("RawTest")
970 .state(TestState {
971 count: 0,
972 name: "test".into(),
973 })
974 .on_action::<Value>("set_count", |state, payload, _ctx| {
975 if let Some(n) = payload.as_i64() {
976 state.count = n as i32;
977 }
978 })
979 .build();
980
981 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
982 instance.mount();
983
984 instance
985 .dispatch_action("set_count", Some(serde_json::json!(42)))
986 .unwrap();
987 assert_eq!(instance.get_state().count, 42);
988 }
989
990 #[cfg(feature = "async")]
995 mod async_tests {
996 use super::*;
997
998 #[derive(Clone, Default, Serialize, Deserialize, Debug)]
999 struct AsyncState {
1000 count: i32,
1001 name: String,
1002 }
1003
1004 #[tokio::test]
1005 async fn test_async_action_handler() {
1006 let def = ModuleBuilder::<AsyncState>::new("AsyncTest")
1007 .state(AsyncState {
1008 count: 0,
1009 name: "test".into(),
1010 })
1011 .on_action_async::<()>("increment", |mut state, _, _ctx| {
1012 Box::pin(async move {
1013 state.count += 1;
1014 state
1015 })
1016 })
1017 .build();
1018
1019 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1020 instance.mount();
1021
1022 instance
1023 .dispatch_action_async("increment", None)
1024 .await
1025 .unwrap();
1026 assert_eq!(instance.get_state().count, 1);
1027
1028 instance
1029 .dispatch_action_async("increment", None)
1030 .await
1031 .unwrap();
1032 assert_eq!(instance.get_state().count, 2);
1033 }
1034
1035 #[tokio::test]
1036 async fn test_async_typed_payload() {
1037 #[derive(Deserialize)]
1038 struct AddPayload {
1039 amount: i32,
1040 }
1041
1042 let def = ModuleBuilder::<AsyncState>::new("AsyncTyped")
1043 .state(AsyncState {
1044 count: 10,
1045 name: "test".into(),
1046 })
1047 .on_action_async::<AddPayload>("add", |mut state, payload, _ctx| {
1048 Box::pin(async move {
1049 state.count += payload.amount;
1050 state
1051 })
1052 })
1053 .build();
1054
1055 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1056 instance.mount();
1057
1058 instance
1059 .dispatch_action_async("add", Some(serde_json::json!({"amount": 5})))
1060 .await
1061 .unwrap();
1062 assert_eq!(instance.get_state().count, 15);
1063 }
1064
1065 #[tokio::test]
1066 async fn test_async_falls_back_to_sync() {
1067 let def = ModuleBuilder::<AsyncState>::new("Fallback")
1068 .state(AsyncState {
1069 count: 0,
1070 name: "test".into(),
1071 })
1072 .on_action::<()>("sync_inc", |state, _, _ctx| {
1073 state.count += 1;
1074 })
1075 .build();
1076
1077 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1078
1079 instance
1081 .dispatch_action_async("sync_inc", None)
1082 .await
1083 .unwrap();
1084 assert_eq!(instance.get_state().count, 1);
1085 }
1086
1087 #[tokio::test]
1088 async fn test_async_on_created() {
1089 let def = ModuleBuilder::<AsyncState>::new("AsyncCreated")
1090 .state(AsyncState {
1091 count: 0,
1092 name: "test".into(),
1093 })
1094 .on_created_async(|mut state, _ctx| {
1095 Box::pin(async move {
1096 state.count = 42;
1097 state.name = "initialized".into();
1098 state
1099 })
1100 })
1101 .build();
1102
1103 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1104 instance.mount_async().await;
1105
1106 assert_eq!(instance.get_state().count, 42);
1107 assert_eq!(instance.get_state().name, "initialized");
1108 }
1109
1110 #[tokio::test]
1111 async fn test_async_on_destroyed() {
1112 let destroyed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1113 let destroyed_clone = destroyed.clone();
1114
1115 let def = ModuleBuilder::<AsyncState>::new("AsyncDestroyed")
1116 .state(AsyncState {
1117 count: 0,
1118 name: "test".into(),
1119 })
1120 .on_destroyed_async(move |state, _ctx| {
1121 let flag = destroyed_clone.clone();
1122 Box::pin(async move {
1123 flag.store(true, std::sync::atomic::Ordering::SeqCst);
1124 state
1125 })
1126 })
1127 .build();
1128
1129 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1130 instance.mount();
1131 assert!(!destroyed.load(std::sync::atomic::Ordering::SeqCst));
1132
1133 instance.unmount_async().await;
1134 assert!(destroyed.load(std::sync::atomic::Ordering::SeqCst));
1135 assert!(!instance.is_mounted());
1136 }
1137
1138 #[tokio::test]
1139 async fn test_async_mount_idempotent() {
1140 let call_count = Arc::new(std::sync::atomic::AtomicI32::new(0));
1141 let cc = call_count.clone();
1142
1143 let def = ModuleBuilder::<AsyncState>::new("Idempotent")
1144 .state(AsyncState::default())
1145 .on_created_async(move |state, _ctx| {
1146 let count = cc.clone();
1147 Box::pin(async move {
1148 count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1149 state
1150 })
1151 })
1152 .build();
1153
1154 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1155 instance.mount_async().await;
1156 instance.mount_async().await; assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
1159 }
1160
1161 #[tokio::test]
1162 async fn test_async_dispatch_unknown_action() {
1163 let def = ModuleBuilder::<AsyncState>::new("Unknown")
1164 .state(AsyncState::default())
1165 .build();
1166
1167 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1168 let result = instance.dispatch_action_async("nonexistent", None).await;
1169 assert!(result.is_err());
1170 }
1171
1172 #[tokio::test]
1173 async fn test_async_mixed_sync_and_async_actions() {
1174 #[derive(Deserialize)]
1175 struct SetName {
1176 name: String,
1177 }
1178
1179 let def = ModuleBuilder::<AsyncState>::new("Mixed")
1180 .state(AsyncState {
1181 count: 0,
1182 name: "init".into(),
1183 })
1184 .on_action::<()>("sync_inc", |state, _, _ctx| {
1185 state.count += 1;
1186 })
1187 .on_action_async::<SetName>("async_set_name", |mut state, payload, _ctx| {
1188 Box::pin(async move {
1189 state.name = payload.name;
1190 state
1191 })
1192 })
1193 .build();
1194
1195 let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1196 instance.mount();
1197
1198 instance
1200 .dispatch_action_async("sync_inc", None)
1201 .await
1202 .unwrap();
1203 assert_eq!(instance.get_state().count, 1);
1204
1205 instance
1206 .dispatch_action_async("async_set_name", Some(serde_json::json!({"name": "Alice"})))
1207 .await
1208 .unwrap();
1209 assert_eq!(instance.get_state().name, "Alice");
1210 }
1211 }
1212}