use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::de::DeserializeOwned;
use serde_json::Value;
#[cfg(feature = "async")]
use std::future::Future;
#[cfg(feature = "async")]
use std::pin::Pin;
use crate::context::GlobalContext;
use crate::error::{Result, SdkError};
use crate::state::{State, StateContainer};
#[cfg(feature = "async")]
pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
type LifecycleHandler<S> = Box<dyn Fn(&S, Option<&GlobalContext>) + Send + Sync>;
type ActionHandlerFn<S> = Box<dyn Fn(&mut S, Option<&Value>, Option<&GlobalContext>) + Send + Sync>;
type ErrorHandler = Box<dyn Fn(&ErrorContext) -> ErrorResult + Send + Sync>;
#[cfg(feature = "async")]
type AsyncLifecycleHandler<S> =
Box<dyn Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync>;
#[cfg(feature = "async")]
type AsyncActionHandlerFn<S> =
Box<dyn Fn(S, Option<Value>, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync>;
pub struct ErrorContext {
pub error: SdkError,
pub action_name: Option<String>,
pub lifecycle: Option<String>,
}
pub struct ErrorResult {
pub handled: bool,
}
pub struct ModuleDefinition<S: State> {
pub(crate) name: String,
pub(crate) initial_state: S,
pub(crate) ui_source: Option<String>,
#[allow(dead_code)]
pub(crate) ui_file: Option<String>,
pub(crate) action_handlers: HashMap<String, ActionHandlerFn<S>>,
pub(crate) on_created: Option<LifecycleHandler<S>>,
pub(crate) on_destroyed: Option<LifecycleHandler<S>>,
#[allow(dead_code)]
pub(crate) on_error: Option<ErrorHandler>,
pub(crate) persist: bool,
#[cfg(feature = "async")]
pub(crate) async_action_handlers: HashMap<String, AsyncActionHandlerFn<S>>,
#[cfg(feature = "async")]
pub(crate) on_created_async: Option<AsyncLifecycleHandler<S>>,
#[cfg(feature = "async")]
pub(crate) on_destroyed_async: Option<AsyncLifecycleHandler<S>>,
}
impl<S: State> ModuleDefinition<S> {
pub fn name(&self) -> &str {
&self.name
}
pub fn action_names(&self) -> Vec<String> {
self.action_handlers.keys().cloned().collect()
}
pub fn ui_source(&self) -> Option<&str> {
self.ui_source.as_deref()
}
pub fn is_persistent(&self) -> bool {
self.persist
}
}
pub struct ModuleBuilder<S: State> {
name: String,
initial_state: Option<S>,
ui_source: Option<String>,
ui_file: Option<String>,
action_handlers: HashMap<String, ActionHandlerFn<S>>,
on_created: Option<LifecycleHandler<S>>,
on_destroyed: Option<LifecycleHandler<S>>,
on_error: Option<ErrorHandler>,
persist: bool,
#[cfg(feature = "async")]
async_action_handlers: HashMap<String, AsyncActionHandlerFn<S>>,
#[cfg(feature = "async")]
on_created_async: Option<AsyncLifecycleHandler<S>>,
#[cfg(feature = "async")]
on_destroyed_async: Option<AsyncLifecycleHandler<S>>,
}
impl<S: State> ModuleBuilder<S> {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
initial_state: None,
ui_source: None,
ui_file: None,
action_handlers: HashMap::new(),
on_created: None,
on_destroyed: None,
on_error: None,
persist: false,
#[cfg(feature = "async")]
async_action_handlers: HashMap::new(),
#[cfg(feature = "async")]
on_created_async: None,
#[cfg(feature = "async")]
on_destroyed_async: None,
}
}
pub fn state(mut self, initial: S) -> Self {
self.initial_state = Some(initial);
self
}
pub fn ui(mut self, source: impl Into<String>) -> Self {
self.ui_source = Some(source.into());
self
}
pub fn ui_file(mut self, path: impl Into<String>) -> Self {
self.ui_file = Some(path.into());
self
}
pub fn on_action<A>(
mut self,
name: impl Into<String>,
handler: impl Fn(&mut S, A, Option<&GlobalContext>) + Send + Sync + 'static,
) -> Self
where
A: DeserializeOwned + 'static,
{
let wrapped: ActionHandlerFn<S> = Box::new(move |state, raw, ctx| {
let action = match raw {
Some(v) => serde_json::from_value::<A>(v.clone()).ok(),
None => serde_json::from_value::<A>(Value::Null).ok(),
};
if let Some(action) = action {
handler(state, action, ctx);
}
});
self.action_handlers.insert(name.into(), wrapped);
self
}
pub fn on_created<F>(mut self, handler: F) -> Self
where
F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
{
self.on_created = Some(Box::new(handler));
self
}
pub fn on_destroyed<F>(mut self, handler: F) -> Self
where
F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
{
self.on_destroyed = Some(Box::new(handler));
self
}
pub fn on_error<F>(mut self, handler: F) -> Self
where
F: Fn(&ErrorContext) -> ErrorResult + Send + Sync + 'static,
{
self.on_error = Some(Box::new(handler));
self
}
pub fn persist(mut self) -> Self {
self.persist = true;
self
}
#[cfg(feature = "async")]
pub fn on_action_async<A>(
mut self,
name: impl Into<String>,
handler: impl Fn(S, A, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
) -> Self
where
A: DeserializeOwned + Send + 'static,
{
let wrapped: AsyncActionHandlerFn<S> = Box::new(move |state, raw, ctx| {
let action = match raw {
Some(v) => serde_json::from_value::<A>(v).ok(),
None => serde_json::from_value::<A>(Value::Null).ok(),
};
if let Some(action) = action {
handler(state, action, ctx)
} else {
Box::pin(async move { state })
}
});
self.async_action_handlers.insert(name.into(), wrapped);
self
}
#[cfg(feature = "async")]
pub fn on_created_async(
mut self,
handler: impl Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
) -> Self {
self.on_created_async = Some(Box::new(handler));
self
}
#[cfg(feature = "async")]
pub fn on_destroyed_async(
mut self,
handler: impl Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
) -> Self {
self.on_destroyed_async = Some(Box::new(handler));
self
}
pub fn build(self) -> ModuleDefinition<S> {
let initial_state = self
.initial_state
.expect("ModuleBuilder::state() must be called before build()");
ModuleDefinition {
name: self.name,
initial_state,
ui_source: self.ui_source,
ui_file: self.ui_file,
action_handlers: self.action_handlers,
on_created: self.on_created,
on_destroyed: self.on_destroyed,
on_error: self.on_error,
persist: self.persist,
#[cfg(feature = "async")]
async_action_handlers: self.async_action_handlers,
#[cfg(feature = "async")]
on_created_async: self.on_created_async,
#[cfg(feature = "async")]
on_destroyed_async: self.on_destroyed_async,
}
}
}
pub struct ModuleInstance<S: State> {
definition: Arc<ModuleDefinition<S>>,
state: Mutex<StateContainer<S>>,
engine: Mutex<hypen_engine::Engine>,
mounted: Mutex<bool>,
global_context: Option<Arc<GlobalContext>>,
}
impl<S: State> ModuleInstance<S> {
pub fn new(
definition: Arc<ModuleDefinition<S>>,
global_context: Option<Arc<GlobalContext>>,
) -> Result<Self> {
let state_container = StateContainer::new(definition.initial_state.clone())?;
let mut engine = hypen_engine::Engine::new();
let module_meta = hypen_engine::lifecycle::Module::new(&definition.name)
.with_actions(definition.action_names())
.with_persist(definition.persist);
let initial_json = state_container.to_json()?;
let engine_module = hypen_engine::ModuleInstance::new(module_meta, initial_json);
engine.set_module(engine_module);
if let Some(ref source) = definition.ui_source {
Self::load_ui_source(&mut engine, source)?;
} else if let Some(ref path) = definition.ui_file {
let source = std::fs::read_to_string(path).map_err(|e| {
SdkError::Component(format!("Failed to read UI file '{path}': {e}"))
})?;
Self::load_ui_source(&mut engine, &source)?;
}
Ok(Self {
definition,
state: Mutex::new(state_container),
engine: Mutex::new(engine),
mounted: Mutex::new(false),
global_context,
})
}
fn load_ui_source(engine: &mut hypen_engine::Engine, source: &str) -> Result<()> {
let doc = hypen_parser::parse_document(source).map_err(|e| {
SdkError::Engine(hypen_engine::EngineError::ParseError {
source: source.chars().take(80).collect(),
message: format!("{e:?}"),
})
})?;
let component = doc
.components
.first()
.ok_or_else(|| SdkError::Component("No component found in UI source".to_string()))?;
let ir_node = hypen_engine::ast_to_ir_node(component);
engine.render_ir_node(&ir_node);
Ok(())
}
pub fn mount(&self) {
let mut mounted = self.mounted.lock().unwrap();
if !*mounted {
*mounted = true;
if let Some(ref handler) = self.definition.on_created {
let state = self.state.lock().unwrap();
let ctx = self.global_context.as_deref();
handler(state.get(), ctx);
}
}
}
pub fn unmount(&self) {
let mut mounted = self.mounted.lock().unwrap();
if *mounted {
if let Some(ref handler) = self.definition.on_destroyed {
let state = self.state.lock().unwrap();
let ctx = self.global_context.as_deref();
handler(state.get(), ctx);
}
*mounted = false;
}
}
pub fn dispatch_action(&self, name: impl Into<String>, payload: Option<Value>) -> Result<()> {
let name = name.into();
{
let mut state = self.state.lock().unwrap();
state.take_snapshot()?;
}
let ctx = self.global_context.as_deref();
if let Some(handler) = self.definition.action_handlers.get(&name) {
let mut state = self.state.lock().unwrap();
handler(state.get_mut(), payload.as_ref(), ctx);
} else {
return Err(SdkError::Engine(hypen_engine::EngineError::ActionNotFound(
name,
)));
}
self.sync_state_to_engine()?;
Ok(())
}
pub fn get_state(&self) -> S {
self.state.lock().unwrap().get().clone()
}
pub fn get_state_json(&self) -> Result<Value> {
self.state.lock().unwrap().to_json()
}
pub fn on_patches<F>(&self, callback: F)
where
F: Fn(&[hypen_engine::Patch]) + Send + Sync + 'static,
{
let mut engine = self.engine.lock().unwrap();
engine.set_render_callback(callback);
}
pub fn is_mounted(&self) -> bool {
*self.mounted.lock().unwrap()
}
pub fn name(&self) -> &str {
&self.definition.name
}
#[cfg(feature = "async")]
pub async fn mount_async(&self) {
{
let mut mounted = self.mounted.lock().unwrap();
if *mounted {
return;
}
*mounted = true;
}
if let Some(ref handler) = self.definition.on_created_async {
let current_state = self.state.lock().unwrap().get().clone();
let ctx = self.global_context.clone();
let new_state = handler(current_state, ctx).await;
*self.state.lock().unwrap().get_mut() = new_state;
} else if let Some(ref handler) = self.definition.on_created {
let state = self.state.lock().unwrap();
let ctx = self.global_context.as_deref();
handler(state.get(), ctx);
}
}
#[cfg(feature = "async")]
pub async fn unmount_async(&self) {
{
let mounted = self.mounted.lock().unwrap();
if !*mounted {
return;
}
}
if let Some(ref handler) = self.definition.on_destroyed_async {
let current_state = self.state.lock().unwrap().get().clone();
let ctx = self.global_context.clone();
let new_state = handler(current_state, ctx).await;
*self.state.lock().unwrap().get_mut() = new_state;
} else if let Some(ref handler) = self.definition.on_destroyed {
let state = self.state.lock().unwrap();
let ctx = self.global_context.as_deref();
handler(state.get(), ctx);
}
*self.mounted.lock().unwrap() = false;
}
#[cfg(feature = "async")]
pub async fn dispatch_action_async(
&self,
name: impl Into<String>,
payload: Option<Value>,
) -> Result<()> {
let name = name.into();
{
let mut state = self.state.lock().unwrap();
state.take_snapshot()?;
}
if let Some(handler) = self.definition.async_action_handlers.get(&name) {
let current_state = self.state.lock().unwrap().get().clone();
let ctx = self.global_context.clone();
let new_state = handler(current_state, payload, ctx).await;
*self.state.lock().unwrap().get_mut() = new_state;
self.sync_state_to_engine()?;
return Ok(());
}
let ctx = self.global_context.as_deref();
if let Some(handler) = self.definition.action_handlers.get(&name) {
let mut state = self.state.lock().unwrap();
handler(state.get_mut(), payload.as_ref(), ctx);
} else {
return Err(SdkError::Engine(hypen_engine::EngineError::ActionNotFound(
name,
)));
}
self.sync_state_to_engine()?;
Ok(())
}
fn sync_state_to_engine(&self) -> Result<()> {
let state = self.state.lock().unwrap();
let paths = state.changed_paths()?;
if !paths.is_empty() {
let patch = state.diff_patch()?;
drop(state);
let mut engine = self.engine.lock().unwrap();
engine.update_state(patch);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicI32, Ordering};
#[derive(Clone, Default, Serialize, Deserialize, Debug)]
struct TestState {
count: i32,
name: String,
}
#[test]
fn test_module_builder_action() {
let def = ModuleBuilder::<TestState>::new("Test")
.state(TestState {
count: 0,
name: "Alice".into(),
})
.on_action::<()>("increment", |state, _, _ctx| {
state.count += 1;
})
.build();
assert_eq!(def.name(), "Test");
assert!(def.action_names().contains(&"increment".to_string()));
}
#[test]
fn test_module_builder_with_ui() {
let def = ModuleBuilder::<TestState>::new("Test")
.state(TestState::default())
.ui(r#"Column { Text("Hello") }"#)
.build();
assert_eq!(def.ui_source(), Some(r#"Column { Text("Hello") }"#));
}
#[test]
fn test_module_instance_dispatch() {
let def = ModuleBuilder::<TestState>::new("Test")
.state(TestState {
count: 0,
name: "Alice".into(),
})
.on_action::<()>("increment", |state, _, _ctx| {
state.count += 1;
})
.on_action::<String>("set_name", |state, name, _ctx| {
state.name = name;
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.mount();
instance.dispatch_action("increment", None).unwrap();
assert_eq!(instance.get_state().count, 1);
instance.dispatch_action("increment", None).unwrap();
assert_eq!(instance.get_state().count, 2);
instance
.dispatch_action("set_name", Some(serde_json::json!("Bob")))
.unwrap();
assert_eq!(instance.get_state().name, "Bob");
}
#[test]
fn test_module_lifecycle() {
let created = Arc::new(AtomicI32::new(0));
let destroyed = Arc::new(AtomicI32::new(0));
let created_clone = created.clone();
let destroyed_clone = destroyed.clone();
let def = ModuleBuilder::<TestState>::new("Test")
.state(TestState::default())
.on_created(move |_state, _ctx| {
created_clone.fetch_add(1, Ordering::SeqCst);
})
.on_destroyed(move |_state, _ctx| {
destroyed_clone.fetch_add(1, Ordering::SeqCst);
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
assert_eq!(created.load(Ordering::SeqCst), 0);
instance.mount();
assert_eq!(created.load(Ordering::SeqCst), 1);
instance.mount();
assert_eq!(created.load(Ordering::SeqCst), 1);
instance.unmount();
assert_eq!(destroyed.load(Ordering::SeqCst), 1);
instance.unmount();
assert_eq!(destroyed.load(Ordering::SeqCst), 1);
}
#[test]
fn test_module_unknown_action() {
let def = ModuleBuilder::<TestState>::new("Test")
.state(TestState::default())
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
let result = instance.dispatch_action("nonexistent", None);
assert!(result.is_err());
}
#[test]
fn test_module_persist_flag() {
let def = ModuleBuilder::<TestState>::new("Test")
.state(TestState::default())
.persist()
.build();
assert!(def.is_persistent());
}
#[test]
fn test_module_typed_payload() {
#[derive(Deserialize)]
struct AddPayload {
amount: i32,
}
let def = ModuleBuilder::<TestState>::new("TypedTest")
.state(TestState {
count: 10,
name: "test".into(),
})
.on_action::<AddPayload>("add", |state, payload, _ctx| {
state.count += payload.amount;
})
.on_action::<()>("reset", |state, _, _ctx| {
state.count = 0;
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.mount();
instance
.dispatch_action("add", Some(serde_json::json!({"amount": 5})))
.unwrap();
assert_eq!(instance.get_state().count, 15);
instance.dispatch_action("reset", None).unwrap();
assert_eq!(instance.get_state().count, 0);
}
#[test]
fn test_module_multiple_typed_actions() {
#[derive(Deserialize)]
struct AddPayload {
amount: i32,
}
#[derive(Deserialize)]
struct MultiplyPayload {
factor: i32,
}
let def = ModuleBuilder::<TestState>::new("Mixed")
.state(TestState {
count: 10,
name: "test".into(),
})
.on_action::<()>("reset", |state, _, _ctx| {
state.count = 0;
})
.on_action::<AddPayload>("add", |state, payload, _ctx| {
state.count += payload.amount;
})
.on_action::<MultiplyPayload>("multiply", |state, payload, _ctx| {
state.count *= payload.factor;
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.mount();
instance.dispatch_action("reset", None).unwrap();
assert_eq!(instance.get_state().count, 0);
instance
.dispatch_action("add", Some(serde_json::json!({"amount": 5})))
.unwrap();
assert_eq!(instance.get_state().count, 5);
instance
.dispatch_action("multiply", Some(serde_json::json!({"factor": 3})))
.unwrap();
assert_eq!(instance.get_state().count, 15);
}
#[test]
#[should_panic(expected = "ModuleBuilder::state() must be called before build()")]
fn test_module_builder_panics_without_state() {
let _def = ModuleBuilder::<TestState>::new("Test").build();
}
#[test]
fn test_module_invalid_ui_source() {
let def = ModuleBuilder::<TestState>::new("Test")
.state(TestState::default())
.ui("this is not valid {{{{ hypen")
.build();
let result = ModuleInstance::new(Arc::new(def), None);
assert!(result.is_err());
}
#[test]
fn test_module_payload_type_mismatch_is_noop() {
#[derive(Deserialize)]
struct Expected {
#[allow(dead_code)]
value: i32,
}
let def = ModuleBuilder::<TestState>::new("Test")
.state(TestState {
count: 42,
name: "test".into(),
})
.on_action::<Expected>("set", |state, payload, _ctx| {
state.count = payload.value;
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.mount();
instance
.dispatch_action("set", Some(serde_json::json!("wrong type")))
.unwrap();
assert_eq!(instance.get_state().count, 42); }
#[test]
fn test_module_duplicate_action_last_wins() {
let def = ModuleBuilder::<TestState>::new("Test")
.state(TestState {
count: 0,
name: "test".into(),
})
.on_action::<()>("act", |state, _, _ctx| {
state.count += 1;
})
.on_action::<()>("act", |state, _, _ctx| {
state.count += 100;
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.dispatch_action("act", None).unwrap();
assert_eq!(instance.get_state().count, 100); }
#[test]
fn test_module_ui_file() {
let dir = std::env::temp_dir().join("hypen_test_ui_file");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("counter.hypen");
std::fs::write(&path, r#"Column { Text("Hello") }"#).unwrap();
let def = ModuleBuilder::<TestState>::new("Test")
.state(TestState::default())
.ui_file(path.to_str().unwrap())
.build();
let instance = ModuleInstance::new(Arc::new(def), None);
assert!(instance.is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_module_ui_file_not_found() {
let def = ModuleBuilder::<TestState>::new("Test")
.state(TestState::default())
.ui_file("/tmp/hypen_no_such_file.hypen")
.build();
let result = ModuleInstance::new(Arc::new(def), None);
assert!(result.is_err());
}
#[test]
fn test_module_dispatch_without_mount() {
let def = ModuleBuilder::<TestState>::new("Test")
.state(TestState {
count: 0,
name: "test".into(),
})
.on_action::<()>("inc", |state, _, _ctx| {
state.count += 1;
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.dispatch_action("inc", None).unwrap();
assert_eq!(instance.get_state().count, 1);
}
#[test]
fn test_module_raw_json_action() {
let def = ModuleBuilder::<TestState>::new("RawTest")
.state(TestState {
count: 0,
name: "test".into(),
})
.on_action::<Value>("set_count", |state, payload, _ctx| {
if let Some(n) = payload.as_i64() {
state.count = n as i32;
}
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.mount();
instance
.dispatch_action("set_count", Some(serde_json::json!(42)))
.unwrap();
assert_eq!(instance.get_state().count, 42);
}
#[cfg(feature = "async")]
mod async_tests {
use super::*;
#[derive(Clone, Default, Serialize, Deserialize, Debug)]
struct AsyncState {
count: i32,
name: String,
}
#[tokio::test]
async fn test_async_action_handler() {
let def = ModuleBuilder::<AsyncState>::new("AsyncTest")
.state(AsyncState {
count: 0,
name: "test".into(),
})
.on_action_async::<()>("increment", |mut state, _, _ctx| {
Box::pin(async move {
state.count += 1;
state
})
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.mount();
instance
.dispatch_action_async("increment", None)
.await
.unwrap();
assert_eq!(instance.get_state().count, 1);
instance
.dispatch_action_async("increment", None)
.await
.unwrap();
assert_eq!(instance.get_state().count, 2);
}
#[tokio::test]
async fn test_async_typed_payload() {
#[derive(Deserialize)]
struct AddPayload {
amount: i32,
}
let def = ModuleBuilder::<AsyncState>::new("AsyncTyped")
.state(AsyncState {
count: 10,
name: "test".into(),
})
.on_action_async::<AddPayload>("add", |mut state, payload, _ctx| {
Box::pin(async move {
state.count += payload.amount;
state
})
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.mount();
instance
.dispatch_action_async("add", Some(serde_json::json!({"amount": 5})))
.await
.unwrap();
assert_eq!(instance.get_state().count, 15);
}
#[tokio::test]
async fn test_async_falls_back_to_sync() {
let def = ModuleBuilder::<AsyncState>::new("Fallback")
.state(AsyncState {
count: 0,
name: "test".into(),
})
.on_action::<()>("sync_inc", |state, _, _ctx| {
state.count += 1;
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance
.dispatch_action_async("sync_inc", None)
.await
.unwrap();
assert_eq!(instance.get_state().count, 1);
}
#[tokio::test]
async fn test_async_on_created() {
let def = ModuleBuilder::<AsyncState>::new("AsyncCreated")
.state(AsyncState {
count: 0,
name: "test".into(),
})
.on_created_async(|mut state, _ctx| {
Box::pin(async move {
state.count = 42;
state.name = "initialized".into();
state
})
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.mount_async().await;
assert_eq!(instance.get_state().count, 42);
assert_eq!(instance.get_state().name, "initialized");
}
#[tokio::test]
async fn test_async_on_destroyed() {
let destroyed = Arc::new(std::sync::atomic::AtomicBool::new(false));
let destroyed_clone = destroyed.clone();
let def = ModuleBuilder::<AsyncState>::new("AsyncDestroyed")
.state(AsyncState {
count: 0,
name: "test".into(),
})
.on_destroyed_async(move |state, _ctx| {
let flag = destroyed_clone.clone();
Box::pin(async move {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
state
})
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.mount();
assert!(!destroyed.load(std::sync::atomic::Ordering::SeqCst));
instance.unmount_async().await;
assert!(destroyed.load(std::sync::atomic::Ordering::SeqCst));
assert!(!instance.is_mounted());
}
#[tokio::test]
async fn test_async_mount_idempotent() {
let call_count = Arc::new(std::sync::atomic::AtomicI32::new(0));
let cc = call_count.clone();
let def = ModuleBuilder::<AsyncState>::new("Idempotent")
.state(AsyncState::default())
.on_created_async(move |state, _ctx| {
let count = cc.clone();
Box::pin(async move {
count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
state
})
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.mount_async().await;
instance.mount_async().await;
assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_async_dispatch_unknown_action() {
let def = ModuleBuilder::<AsyncState>::new("Unknown")
.state(AsyncState::default())
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
let result = instance.dispatch_action_async("nonexistent", None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_async_mixed_sync_and_async_actions() {
#[derive(Deserialize)]
struct SetName {
name: String,
}
let def = ModuleBuilder::<AsyncState>::new("Mixed")
.state(AsyncState {
count: 0,
name: "init".into(),
})
.on_action::<()>("sync_inc", |state, _, _ctx| {
state.count += 1;
})
.on_action_async::<SetName>("async_set_name", |mut state, payload, _ctx| {
Box::pin(async move {
state.name = payload.name;
state
})
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.mount();
instance
.dispatch_action_async("sync_inc", None)
.await
.unwrap();
assert_eq!(instance.get_state().count, 1);
instance
.dispatch_action_async("async_set_name", Some(serde_json::json!({"name": "Alice"})))
.await
.unwrap();
assert_eq!(instance.get_state().name, "Alice");
}
}
}