Skip to main content

hypen_server/
module.rs

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/// A boxed, pinned, Send future — the return type of async handlers.
17#[cfg(feature = "async")]
18pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
19
20// ---------------------------------------------------------------------------
21// Callback types
22// ---------------------------------------------------------------------------
23
24/// Lifecycle callback: receives immutable state and optional global context.
25type LifecycleHandler<S> = Box<dyn Fn(&S, Option<&GlobalContext>) + Send + Sync>;
26
27/// Type-erased action handler: receives mutable state, optional raw JSON
28/// payload, and optional global context.
29type ActionHandlerFn<S> = Box<dyn Fn(&mut S, Option<&Value>, Option<&GlobalContext>) + Send + Sync>;
30
31/// Error handler: receives the error context and returns whether the error was handled.
32type ErrorHandler = Box<dyn Fn(&ErrorContext) -> ErrorResult + Send + Sync>;
33
34// Async handler types (behind "async" feature)
35
36/// Async lifecycle handler: takes owned state, returns owned state after `.await`.
37#[cfg(feature = "async")]
38type AsyncLifecycleHandler<S> =
39    Box<dyn Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync>;
40
41/// Async type-erased action handler: takes owned state, optional raw JSON
42/// payload, and optional global context. Returns owned state after `.await`.
43#[cfg(feature = "async")]
44type AsyncActionHandlerFn<S> =
45    Box<dyn Fn(S, Option<Value>, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync>;
46
47/// Context provided to error handlers.
48pub struct ErrorContext {
49    pub error: SdkError,
50    pub action_name: Option<String>,
51    pub lifecycle: Option<String>,
52}
53
54/// Result from an error handler.
55pub struct ErrorResult {
56    /// If true, the error is considered handled and won't propagate.
57    pub handled: bool,
58}
59
60// ---------------------------------------------------------------------------
61// ModuleDefinition
62// ---------------------------------------------------------------------------
63
64/// An immutable, built module definition.
65///
66/// Created via `ModuleBuilder::build()`. Contains all state, handlers, and
67/// UI configuration needed to instantiate a running module.
68pub 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
106// ---------------------------------------------------------------------------
107// ModuleBuilder (fluent API)
108// ---------------------------------------------------------------------------
109
110/// Fluent builder for constructing a `ModuleDefinition`.
111///
112/// # Example
113///
114/// ```rust,ignore
115/// use hypen_server::prelude::*;
116/// use serde::{Deserialize, Serialize};
117///
118/// #[derive(Clone, Default, Serialize, Deserialize)]
119/// struct Counter { count: i32 }
120///
121/// #[derive(Deserialize)]
122/// struct AddPayload { amount: i32 }
123///
124/// let module = ModuleBuilder::new("Counter")
125///     .state(Counter { count: 0 })
126///     .ui(r#"Column { Text("Count: ${state.count}") }"#)
127///     .on_action::<()>("increment", |state, _, _ctx| {
128///         state.count += 1;
129///     })
130///     .on_action::<AddPayload>("add", |state, payload, _ctx| {
131///         state.count += payload.amount;
132///     })
133///     .build();
134/// ```
135pub 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    /// Set the initial state for this module.
175    pub fn state(mut self, initial: S) -> Self {
176        self.initial_state = Some(initial);
177        self
178    }
179
180    /// Set the Hypen DSL template as an inline string.
181    ///
182    /// ```rust,ignore
183    /// .ui(r#"
184    ///     Column {
185    ///         Text("Count: ${state.count}")
186    ///         Button("@actions.increment") { Text("+") }
187    ///     }
188    /// "#)
189    /// ```
190    pub fn ui(mut self, source: impl Into<String>) -> Self {
191        self.ui_source = Some(source.into());
192        self
193    }
194
195    /// Load the Hypen DSL template from a file path.
196    ///
197    /// The file will be read when the module is instantiated.
198    pub fn ui_file(mut self, path: impl Into<String>) -> Self {
199        self.ui_file = Some(path.into());
200        self
201    }
202
203    /// Register an action handler with a typed payload.
204    ///
205    /// The type parameter `A` determines how the action payload is
206    /// deserialized. Use `()` for actions that don't carry a payload.
207    ///
208    /// # Examples
209    ///
210    /// ```rust,ignore
211    /// // No payload — use ()
212    /// .on_action::<()>("increment", |state, _, _ctx| {
213    ///     state.count += 1;
214    /// })
215    ///
216    /// // Typed payload — any Deserialize type
217    /// #[derive(Deserialize)]
218    /// struct SetValue { value: i32 }
219    ///
220    /// .on_action::<SetValue>("set_value", |state, payload, _ctx| {
221    ///     state.count = payload.value;
222    /// })
223    ///
224    /// // Raw JSON access
225    /// .on_action::<serde_json::Value>("raw", |state, raw, _ctx| {
226    ///     if let Some(n) = raw.as_i64() { state.count = n as i32; }
227    /// })
228    /// ```
229    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    /// Called when the module is first mounted.
251    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    /// Called when the module is destroyed/unmounted.
260    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    /// Register an error handler for this module.
269    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    /// Mark this module as persistent (survives route changes).
278    pub fn persist(mut self) -> Self {
279        self.persist = true;
280        self
281    }
282
283    /// Register an async action handler with a typed payload.
284    ///
285    /// The handler takes **owned** state, performs async work, and returns
286    /// the (possibly mutated) state. This avoids holding `&mut` across
287    /// `.await` points.
288    ///
289    /// # Example
290    ///
291    /// ```rust,ignore
292    /// .on_action_async::<AddPayload>("add", |mut state, payload, _ctx| {
293    ///     Box::pin(async move {
294    ///         state.count += payload.amount;
295    ///         state
296    ///     })
297    /// })
298    /// ```
299    #[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    /// Register an async `on_created` lifecycle handler.
324    ///
325    /// Takes owned state and returns it after async work.
326    ///
327    /// ```rust,ignore
328    /// .on_created_async(|mut state, _ctx| {
329    ///     Box::pin(async move {
330    ///         // fetch data, initialize, etc.
331    ///         state
332    ///     })
333    /// })
334    /// ```
335    #[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    /// Register an async `on_destroyed` lifecycle handler.
345    ///
346    /// Takes owned state and returns it after async cleanup.
347    ///
348    /// ```rust,ignore
349    /// .on_destroyed_async(|state, _ctx| {
350    ///     Box::pin(async move {
351    ///         // cleanup, flush logs, etc.
352    ///         state
353    ///     })
354    /// })
355    /// ```
356    #[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    /// Consume the builder and produce an immutable `ModuleDefinition`.
366    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
391// ---------------------------------------------------------------------------
392// ModuleInstance (running module)
393// ---------------------------------------------------------------------------
394
395/// A running module instance with live state.
396///
397/// Created from a `ModuleDefinition` when the module is mounted.
398/// Wraps a `hypen_engine::Engine` and manages state synchronization.
399pub 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    /// Create a new module instance from a definition.
409    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        // Set up the engine module metadata
417        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        // Parse and load UI if provided
426        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    /// Mount the module (triggers `on_created`).
461    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    /// Unmount the module (triggers `on_destroyed`).
474    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    /// Dispatch an action by name with an optional payload.
487    pub fn dispatch_action(&self, name: impl Into<String>, payload: Option<Value>) -> Result<()> {
488        let name = name.into();
489
490        // Snapshot state before handler runs
491        {
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        // Diff state and notify engine of changes
508        self.sync_state_to_engine()?;
509
510        Ok(())
511    }
512
513    /// Get a clone of the current state.
514    pub fn get_state(&self) -> S {
515        self.state.lock().unwrap().get().clone()
516    }
517
518    /// Get the current state as JSON.
519    pub fn get_state_json(&self) -> Result<Value> {
520        self.state.lock().unwrap().to_json()
521    }
522
523    /// Set the render callback for receiving patches.
524    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    /// Check if the module is currently mounted.
533    pub fn is_mounted(&self) -> bool {
534        *self.mounted.lock().unwrap()
535    }
536
537    /// Name of this module.
538    pub fn name(&self) -> &str {
539        &self.definition.name
540    }
541
542    /// Mount the module asynchronously (triggers async `on_created` if set,
543    /// otherwise falls back to the sync handler).
544    #[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    /// Unmount the module asynchronously (triggers async `on_destroyed` if set,
567    /// otherwise falls back to the sync handler).
568    #[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    /// Dispatch an action asynchronously.
592    ///
593    /// Checks async handlers first, then falls back to sync handlers.
594    #[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        // Snapshot state before handler runs
603        {
604            let mut state = self.state.lock().unwrap();
605            state.take_snapshot()?;
606        }
607
608        // Check async handlers first
609        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        // Fall back to sync handler
619        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    /// Internal: compare state against pre-handler snapshot and push changes
634    /// to the engine.
635    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); // release lock before engine lock
642
643            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        // Mounting again should be idempotent
744        instance.mount();
745        assert_eq!(created.load(Ordering::SeqCst), 1);
746
747        instance.unmount();
748        assert_eq!(destroyed.load(Ordering::SeqCst), 1);
749
750        // Unmounting again should be idempotent
751        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        // Send wrong payload shape — handler should silently skip
892        instance
893            .dispatch_action("set", Some(serde_json::json!("wrong type")))
894            .unwrap();
895        assert_eq!(instance.get_state().count, 42); // unchanged
896    }
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); // second handler wins
916    }
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        // Don't mount — dispatch should still work
963        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    // -----------------------------------------------------------------------
991    // Async handler tests (behind "async" feature)
992    // -----------------------------------------------------------------------
993
994    #[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            // dispatch_action_async should fall back to the sync handler
1080            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; // second call is noop
1157
1158            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            // Use async dispatch for both sync and async handlers
1199            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}