use std::sync::{Arc, Mutex};
use hypen_server::prelude::*;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
struct CounterState {
count: i32,
}
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
struct TodoState {
items: Vec<TodoItem>,
filter: String,
}
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
struct TodoItem {
id: String,
text: String,
done: bool,
}
#[test]
fn test_counter_full_lifecycle() {
let def = ModuleBuilder::<CounterState>::new("Counter")
.state(CounterState { count: 0 })
.ui(r#"Column { Text("Count: @{state.count}") }"#)
.on_action::<()>("increment", |state, _, _| {
state.count += 1;
})
.on_action::<()>("decrement", |state, _, _| {
state.count -= 1;
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.mount();
assert!(instance.is_mounted());
for _ in 0..3 {
instance.dispatch_action("increment", None).unwrap();
}
assert_eq!(instance.get_state().count, 3);
instance.dispatch_action("decrement", None).unwrap();
assert_eq!(instance.get_state().count, 2);
instance.unmount();
assert!(!instance.is_mounted());
}
#[test]
fn test_todo_app_flow() {
#[derive(Deserialize)]
struct AddItem {
id: String,
text: String,
}
#[derive(Deserialize)]
struct ToggleItem {
id: String,
}
#[derive(Deserialize)]
struct SetFilter {
filter: String,
}
let def = ModuleBuilder::<TodoState>::new("TodoApp")
.state(TodoState {
items: vec![],
filter: "all".into(),
})
.on_action::<AddItem>("add", |state, payload, _| {
state.items.push(TodoItem {
id: payload.id,
text: payload.text,
done: false,
});
})
.on_action::<ToggleItem>("toggle", |state, payload, _| {
if let Some(item) = state.items.iter_mut().find(|i| i.id == payload.id) {
item.done = !item.done;
}
})
.on_action::<SetFilter>("setFilter", |state, payload, _| {
state.filter = payload.filter;
})
.on_action::<()>("clearCompleted", |state, _, _| {
state.items.retain(|i| !i.done);
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.mount();
instance
.dispatch_action("add", Some(json!({"id": "1", "text": "Buy milk"})))
.unwrap();
instance
.dispatch_action("add", Some(json!({"id": "2", "text": "Write tests"})))
.unwrap();
assert_eq!(instance.get_state().items.len(), 2);
instance
.dispatch_action("toggle", Some(json!({"id": "1"})))
.unwrap();
assert!(instance.get_state().items[0].done);
assert!(!instance.get_state().items[1].done);
instance
.dispatch_action("setFilter", Some(json!({"filter": "active"})))
.unwrap();
assert_eq!(instance.get_state().filter, "active");
instance.dispatch_action("clearCompleted", None).unwrap();
assert_eq!(instance.get_state().items.len(), 1);
assert_eq!(instance.get_state().items[0].id, "2");
}
#[test]
fn test_patches_emitted_on_state_change() {
let patches: Arc<Mutex<Vec<Vec<Patch>>>> = Arc::new(Mutex::new(vec![]));
let patches_clone = patches.clone();
let def = ModuleBuilder::<CounterState>::new("PatchTest")
.state(CounterState { count: 0 })
.ui(r#"Text("@{state.count}")"#)
.on_action::<()>("increment", |state, _, _| {
state.count += 1;
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.on_patches(move |p| {
patches_clone.lock().unwrap().push(p.to_vec());
});
instance.mount();
instance.dispatch_action("increment", None).unwrap();
let after_dispatch = patches.lock().unwrap().len();
assert!(after_dispatch > 0, "State change should produce patches");
}
#[test]
fn test_global_context_shared_between_modules() {
let ctx = Arc::new(GlobalContext::new());
ctx.register_module_state("theme_config", json!({"theme": "dark"}));
let def1 = ModuleBuilder::<CounterState>::new("ModuleA")
.state(CounterState { count: 0 })
.on_created(|_state, ctx| {
if let Some(ctx) = ctx {
let state = ctx.get_module_state("theme_config").unwrap();
assert_eq!(state["theme"], "dark");
}
})
.build();
let def2 = ModuleBuilder::<CounterState>::new("ModuleB")
.state(CounterState { count: 0 })
.on_created(|_state, ctx| {
if let Some(ctx) = ctx {
assert!(ctx.has_module("theme_config"));
}
})
.build();
let inst1 = ModuleInstance::new(Arc::new(def1), Some(ctx.clone())).unwrap();
let inst2 = ModuleInstance::new(Arc::new(def2), Some(ctx.clone())).unwrap();
inst1.mount();
inst2.mount();
}
#[test]
fn test_lifecycle_ordering() {
let events: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
let e1 = events.clone();
let e2 = events.clone();
let e3 = events.clone();
let def = ModuleBuilder::<CounterState>::new("LifecycleOrder")
.state(CounterState { count: 0 })
.on_created(move |_, _| {
e1.lock().unwrap().push("created".into());
})
.on_action::<()>("act", move |state, _, _| {
e2.lock().unwrap().push("action".into());
state.count += 1;
})
.on_destroyed(move |_, _| {
e3.lock().unwrap().push("destroyed".into());
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
instance.mount();
instance.dispatch_action("act", None).unwrap();
instance.unmount();
let log = events.lock().unwrap();
assert_eq!(*log, vec!["created", "action", "destroyed"]);
}
#[test]
fn test_state_json_roundtrip() {
let def = ModuleBuilder::<TodoState>::new("JsonRT")
.state(TodoState {
items: vec![TodoItem {
id: "1".into(),
text: "Test".into(),
done: false,
}],
filter: "all".into(),
})
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
let json = instance.get_state_json().unwrap();
assert_eq!(json["filter"], "all");
assert_eq!(json["items"][0]["text"], "Test");
assert_eq!(json["items"][0]["done"], false);
}
#[test]
fn test_dispatch_unknown_action_returns_error() {
let def = ModuleBuilder::<CounterState>::new("ErrTest")
.state(CounterState { count: 0 })
.build();
let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
let result = instance.dispatch_action("nope", None);
assert!(result.is_err());
}
#[test]
fn test_router_pattern_matching() {
let router = HypenRouter::new();
router.push("/counter");
assert_eq!(router.current_path(), "/counter");
let m = router.match_path("/counter", "/counter");
assert!(m.is_some());
let m = router.match_path("/profile/:id", "/profile/42").unwrap();
assert_eq!(m.params["id"], "42");
assert!(router.match_path("/api/*", "/api/users/list").is_some());
assert!(router.match_path("/api/*", "/other").is_none());
}
#[test]
fn test_component_discovery() {
let dir = std::env::temp_dir().join("hypen_integration_test_discovery");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Header.hypen"), r#"Text("Header")"#).unwrap();
std::fs::write(dir.join("Footer.hypen"), r#"Text("Footer")"#).unwrap();
let mut registry = ComponentRegistry::new();
let loaded = registry.load_dir(dir.to_str().unwrap()).unwrap();
assert_eq!(loaded.len(), 2);
assert!(registry.get("Header").is_some());
assert!(registry.get("Footer").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_hypen_app_builder() {
let counter_def = ModuleBuilder::<CounterState>::new("Counter")
.state(CounterState { count: 0 })
.on_action::<()>("increment", |state, _, _| {
state.count += 1;
})
.build();
let app = HypenApp::builder().route("/", counter_def).build();
assert!(app.match_route("/").is_some());
}
#[test]
fn test_app_instantiate_and_dispatch() {
let app = HypenApp::default();
let def = ModuleBuilder::<CounterState>::new("Counter")
.state(CounterState { count: 0 })
.on_action::<()>("increment", |state, _, _| {
state.count += 1;
})
.build();
let instance = app.instantiate(Arc::new(def)).unwrap();
instance.mount();
instance.dispatch_action("increment", None).unwrap();
instance.dispatch_action("increment", None).unwrap();
assert_eq!(instance.get_state().count, 2);
}
#[test]
fn test_multi_module_via_global_context() {
let ctx = Arc::new(GlobalContext::new());
let def_a = ModuleBuilder::<CounterState>::new("A")
.state(CounterState { count: 0 })
.on_action::<()>("increment_and_sync", |state, _, ctx| {
state.count += 1;
if let Some(ctx) = ctx {
ctx.register_module_state("A", json!({"count": state.count}));
}
})
.build();
let def_b = ModuleBuilder::<CounterState>::new("B")
.state(CounterState { count: 0 })
.on_action::<()>("read_global", |state, _, ctx| {
if let Some(ctx) = ctx {
if let Some(val) = ctx.get_module_state("A") {
state.count = val["count"].as_i64().unwrap_or(0) as i32;
}
}
})
.build();
let inst_a = ModuleInstance::new(Arc::new(def_a), Some(ctx.clone())).unwrap();
let inst_b = ModuleInstance::new(Arc::new(def_b), Some(ctx.clone())).unwrap();
inst_a.mount();
inst_b.mount();
for _ in 0..5 {
inst_a.dispatch_action("increment_and_sync", None).unwrap();
}
inst_b.dispatch_action("read_global", None).unwrap();
assert_eq!(inst_b.get_state().count, 5);
}
#[test]
fn test_ui_file_integration() {
let dir = std::env::temp_dir().join("hypen_integration_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("Count: @{state.count}") }"#).unwrap();
let def = ModuleBuilder::<CounterState>::new("FileModule")
.state(CounterState { count: 0 })
.ui_file(path.to_str().unwrap())
.on_action::<()>("increment", |state, _, _| {
state.count += 1;
})
.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);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_persist_flag_propagates() {
let def = ModuleBuilder::<CounterState>::new("Persistent")
.state(CounterState { count: 0 })
.persist()
.build();
assert!(def.is_persistent());
let non_persist = ModuleBuilder::<CounterState>::new("Ephemeral")
.state(CounterState { count: 0 })
.build();
assert!(!non_persist.is_persistent());
}
#[test]
fn test_event_emitter_cross_module() {
let emitter = EventEmitter::new();
let received = Arc::new(Mutex::new(vec![]));
let r = received.clone();
emitter.on("user:login", move |payload| {
r.lock().unwrap().push(payload.clone());
});
emitter.emit("user:login", &json!({"user": "alice"}));
emitter.emit("user:login", &json!({"user": "bob"}));
let msgs = received.lock().unwrap();
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0]["user"], "alice");
assert_eq!(msgs[1]["user"], "bob");
}
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
struct AppState {
current_view: String,
}
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
struct SearchState {
search_query: String,
explore_posts: Vec<ExplorePost>,
}
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
struct ExplorePost {
id: String,
image_url: String,
}
#[test]
fn test_nested_module_grid_renders_items() {
use hypen_server::remote::{ModuleSessionConfig, RemoteSession};
use hypen_server::discovery::ComponentRegistry;
let app_module = Arc::new(
HypenApp::module::<AppState>("App")
.state(AppState {
current_view: "search".to_string(),
})
.ui(r#"module App {
Column {
If(condition: "@{state.currentView == 'search'}") {
Search()
}
}
}"#)
.on_action::<()>("navigateToFeed", |state, _, _| {
state.current_view = "feed".to_string();
})
.build(),
);
let search_module = Arc::new(
HypenApp::module::<SearchState>("Search")
.state(SearchState {
search_query: String::new(),
explore_posts: vec![
ExplorePost { id: "p1".into(), image_url: "https://img1.jpg".into() },
ExplorePost { id: "p2".into(), image_url: "https://img2.jpg".into() },
ExplorePost { id: "p3".into(), image_url: "https://img3.jpg".into() },
],
})
.build(),
);
let mut components = ComponentRegistry::new();
components.register("Search", r#"module Search {
Column {
Input(placeholder: "Search")
Grid(@state.explorePosts, key: "id") {
Image(src: "@{item.imageUrl}")
}
}
}"#, None);
let session = RemoteSession::from_definition_with_state(
app_module,
components,
AppState { current_view: "search".to_string() },
vec![ModuleSessionConfig::from_definition(search_module)],
);
let responses = session.handle_hello(None);
let initial_tree: serde_json::Value = responses
.iter()
.find_map(|r| {
let v: serde_json::Value = serde_json::from_str(r).ok()?;
if v["type"] == "initialTree" { Some(v) } else { None }
})
.expect("Should receive an initialTree response");
let patches = initial_tree["patches"].as_array().expect("patches should be an array");
let creates: Vec<&str> = patches
.iter()
.filter(|p| p["type"] == "create")
.filter_map(|p| p["elementType"].as_str())
.collect();
assert!(
creates.contains(&"Grid"),
"Initial render should create a Grid element. Got: {:?}",
creates
);
let image_count = creates.iter().filter(|&&t| t == "Image").count();
assert_eq!(
image_count, 3,
"Should create 3 Image elements. Got creates: {:?}",
creates
);
}
#[test]
fn test_remote_session_dispatches_nested_module_action() {
use hypen_server::discovery::ComponentRegistry;
use hypen_server::remote::{ModuleSessionConfig, RemoteSession};
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
struct ShellState {}
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
struct CounterModState {
count: i32,
}
let shell = Arc::new(
HypenApp::module::<ShellState>("Shell")
.state(ShellState {})
.ui(r#"module Shell {
Column {
Counter()
}
}"#)
.build(),
);
let counter = Arc::new(
HypenApp::module::<CounterModState>("Counter")
.state(CounterModState { count: 0 })
.on_action::<()>("bump", |state, _, _| {
state.count += 1;
})
.build(),
);
let mut components = ComponentRegistry::new();
components.register(
"Counter",
r#"module Counter {
Text("Count: @{state.count}")
}"#,
None,
);
let session = RemoteSession::from_definition_with_state(
shell,
components,
ShellState {},
vec![ModuleSessionConfig::from_definition(counter)],
);
let _ = session.handle_hello(None);
let action_json = r#"{"type":"dispatchAction","module":"Counter","action":"bump"}"#;
let responses = session.handle_message(action_json);
assert_eq!(
session.revision(),
1,
"revision should advance after a successful nested-module dispatch"
);
let has_patch_msg = responses.iter().any(|r| r.contains("\"type\":\"patch\""));
assert!(
has_patch_msg,
"expected a patch message from nested-module dispatch, got: {:?}",
responses
);
for _ in 0..2 {
let _ = session.handle_message(action_json);
}
assert_eq!(session.revision(), 3, "revision should advance per dispatch");
}