use hypen_engine::ir::{ast_to_ir_node, Element, IRNode, Props, RouterRoute, Value};
use hypen_engine::reactive::{Binding, DependencyGraph};
use hypen_engine::reconcile::{reconcile_ir, InstanceTree, Patch};
use hypen_parser::parse_component;
use serde_json::json;
#[test]
fn test_router_ast_to_ir_basic() {
let source = r#"
Router {
Route(path: "/") { Text("Home") }
Route(path: "/about") { Text("About") }
}
"#;
let ast = parse_component(source).expect("parse Router");
let ir = ast_to_ir_node(&ast);
match ir {
IRNode::Router {
routes, fallback, ..
} => {
assert_eq!(routes.len(), 2);
assert_eq!(routes[0].path, "/");
assert_eq!(routes[1].path, "/about");
assert!(fallback.is_none());
}
other => panic!("Expected IRNode::Router, got {:?}", other),
}
}
#[test]
fn test_router_ast_to_ir_default_location_binding() {
let source = r#"
Router {
Route(path: "/") { Text("Home") }
}
"#;
let ir = ast_to_ir_node(&parse_component(source).unwrap());
match ir {
IRNode::Router { location, .. } => match location {
Value::Binding(b) => {
assert!(b.is_state());
assert_eq!(b.full_path(), "location");
}
other => panic!("Expected state.location binding, got {:?}", other),
},
other => panic!("Expected IRNode::Router, got {:?}", other),
}
}
#[test]
fn test_router_ast_to_ir_with_else_fallback() {
let source = r#"
Router {
Route(path: "/") { Text("Home") }
Else { Text("Not Found") }
}
"#;
let ir = ast_to_ir_node(&parse_component(source).unwrap());
match ir {
IRNode::Router {
routes, fallback, ..
} => {
assert_eq!(routes.len(), 1);
assert!(fallback.is_some(), "Else should produce fallback");
assert_eq!(fallback.unwrap().len(), 1);
}
other => panic!("Expected IRNode::Router, got {:?}", other),
}
}
#[test]
fn test_router_reconciles_to_matching_route() {
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let state = json!({ "location": "/search" });
let ir = IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![
RouterRoute::new("/", vec![IRNode::Element(Element::new("HomeView"))]),
RouterRoute::new("/search", vec![IRNode::Element(Element::new("SearchView"))]),
RouterRoute::new(
"/profile",
vec![IRNode::Element(Element::new("ProfileView"))],
),
],
fallback: None,
module_scope: None,
};
let patches = reconcile_ir(&mut tree, &ir, None, &state, &mut deps);
let created_types: Vec<&str> = patches
.iter()
.filter_map(|p| match p {
Patch::Create { element_type, .. } => Some(element_type.as_str()),
_ => None,
})
.collect();
assert!(
created_types.contains(&"SearchView"),
"expected SearchView in {:?}",
created_types
);
assert!(
!created_types.contains(&"HomeView"),
"HomeView should not be created"
);
assert!(
!created_types.contains(&"ProfileView"),
"ProfileView should not be created"
);
for patch in &patches {
if let Patch::Create { element_type, .. } = patch {
assert_ne!(
element_type, "__Router",
"__Router container must not appear in patches"
);
}
}
}
#[test]
fn test_router_falls_back_when_no_route_matches() {
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let state = json!({ "location": "/missing" });
let ir = IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![RouterRoute::new(
"/",
vec![IRNode::Element(Element::new("HomeView"))],
)],
fallback: Some(vec![IRNode::Element(Element::new("NotFound"))]),
module_scope: None,
};
let patches = reconcile_ir(&mut tree, &ir, None, &state, &mut deps);
let created_types: Vec<&str> = patches
.iter()
.filter_map(|p| match p {
Patch::Create { element_type, .. } => Some(element_type.as_str()),
_ => None,
})
.collect();
assert!(
created_types.contains(&"NotFound"),
"expected NotFound in {:?}",
created_types
);
assert!(!created_types.contains(&"HomeView"));
}
#[test]
fn test_router_renders_nothing_when_no_match_and_no_fallback() {
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let state = json!({ "location": "/missing" });
let ir = IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![RouterRoute::new(
"/",
vec![IRNode::Element(Element::new("HomeView"))],
)],
fallback: None,
module_scope: None,
};
let patches = reconcile_ir(&mut tree, &ir, None, &state, &mut deps);
let created_view: Vec<_> = patches
.iter()
.filter(|p| {
matches!(
p,
Patch::Create { element_type, .. }
if element_type != "__Router"
)
})
.collect();
assert!(
created_view.is_empty(),
"no view patches expected, got {:?}",
created_view
);
}
#[test]
fn test_router_swaps_view_on_location_change() {
use hypen_engine::reconcile::reconcile_ir as reconcile;
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let ir = IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![
RouterRoute::new("/", vec![IRNode::Element(Element::new("HomeView"))]),
RouterRoute::new("/search", vec![IRNode::Element(Element::new("SearchView"))]),
],
fallback: None,
module_scope: None,
};
let initial_state = json!({ "location": "/" });
let initial_patches = reconcile(&mut tree, &ir, None, &initial_state, &mut deps);
let initial_types: Vec<&str> = initial_patches
.iter()
.filter_map(|p| match p {
Patch::Create { element_type, .. } => Some(element_type.as_str()),
_ => None,
})
.collect();
assert!(initial_types.contains(&"HomeView"));
assert!(!initial_types.contains(&"SearchView"));
let next_state = json!({ "location": "/search" });
let next_patches = reconcile(&mut tree, &ir, None, &next_state, &mut deps);
let creates: Vec<&str> = next_patches
.iter()
.filter_map(|p| match p {
Patch::Create { element_type, .. } => Some(element_type.as_str()),
_ => None,
})
.collect();
let detaches_count = next_patches
.iter()
.filter(|p| matches!(p, Patch::Detach { .. }))
.count();
let removes_count = next_patches
.iter()
.filter(|p| matches!(p, Patch::Remove { .. }))
.count();
assert!(
creates.contains(&"SearchView"),
"expected SearchView in {:?}",
creates
);
assert!(
!creates.contains(&"HomeView"),
"HomeView should not be re-created"
);
assert!(
detaches_count >= 1,
"expected at least one Detach patch for the old view, got {} detaches and {} removes",
detaches_count,
removes_count,
);
assert_eq!(
removes_count, 0,
"expected no Remove patches on navigation-away (should Detach instead)",
);
}
#[test]
fn test_router_ir_matches_param_routes() {
use hypen_engine::reconcile::reconcile_ir as reconcile;
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let ir = IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![RouterRoute::new(
"/profile/:id",
vec![IRNode::Element(Element::new("ProfileView"))],
)],
fallback: Some(vec![IRNode::Element(Element::new("NotFound"))]),
module_scope: None,
};
let patches = reconcile(
&mut tree,
&ir,
None,
&json!({"location": "/profile/42"}),
&mut deps,
);
let creates: Vec<&str> = patches
.iter()
.filter_map(|p| match p {
Patch::Create { element_type, .. } => Some(element_type.as_str()),
_ => None,
})
.collect();
assert!(
creates.contains(&"ProfileView"),
"expected ProfileView matched by /profile/:id against /profile/42; got {:?}",
creates
);
assert!(
!creates.contains(&"NotFound"),
"fallback should not render when a param route matched"
);
}
#[test]
fn test_router_ir_matches_trailing_wildcard() {
use hypen_engine::reconcile::reconcile_ir as reconcile;
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let ir = IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![RouterRoute::new(
"/api/*",
vec![IRNode::Element(Element::new("ApiView"))],
)],
fallback: None,
module_scope: None,
};
let patches = reconcile(
&mut tree,
&ir,
None,
&json!({"location": "/api/users/42"}),
&mut deps,
);
let types: Vec<&str> = patches
.iter()
.filter_map(|p| match p {
Patch::Create { element_type, .. } => Some(element_type.as_str()),
_ => None,
})
.collect();
assert!(types.contains(&"ApiView"));
}
#[test]
fn test_router_param_changes_hit_same_cache_bucket() {
use hypen_engine::reconcile::reconcile_ir as reconcile;
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let ir = IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![RouterRoute::new(
"/profile/:id",
vec![IRNode::Element(Element::new("ProfileView"))],
)],
fallback: None,
module_scope: None,
};
let _ = reconcile(
&mut tree,
&ir,
None,
&json!({"location": "/profile/42"}),
&mut deps,
);
let patches = reconcile(
&mut tree,
&ir,
None,
&json!({"location": "/profile/99"}),
&mut deps,
);
let detach_count = patches
.iter()
.filter(|p| matches!(p, Patch::Detach { .. }))
.count();
let attach_count = patches
.iter()
.filter(|p| matches!(p, Patch::Attach { .. }))
.count();
let create_count = patches
.iter()
.filter(
|p| matches!(p, Patch::Create { element_type, .. } if element_type == "ProfileView"),
)
.count();
assert_eq!(detach_count, 0, "same pattern = no detach: {:?}", patches);
assert_eq!(attach_count, 0, "same pattern = no attach: {:?}", patches);
assert_eq!(
create_count, 0,
"same pattern = no re-create: {:?}",
patches
);
}
#[test]
fn test_router_reuses_cached_subtree_on_navigate_back() {
use hypen_engine::reconcile::reconcile_ir as reconcile;
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let ir = IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![
RouterRoute::new("/", vec![IRNode::Element(Element::new("HomeView"))]),
RouterRoute::new("/search", vec![IRNode::Element(Element::new("SearchView"))]),
],
fallback: None,
module_scope: None,
};
let _ = reconcile(&mut tree, &ir, None, &json!({"location": "/"}), &mut deps);
let _ = reconcile(
&mut tree,
&ir,
None,
&json!({"location": "/search"}),
&mut deps,
);
let back_patches = reconcile(&mut tree, &ir, None, &json!({"location": "/"}), &mut deps);
let creates: Vec<&str> = back_patches
.iter()
.filter_map(|p| match p {
Patch::Create { element_type, .. } => Some(element_type.as_str()),
_ => None,
})
.collect();
let attach_count = back_patches
.iter()
.filter(|p| matches!(p, Patch::Attach { .. }))
.count();
assert!(
!creates.contains(&"HomeView"),
"HomeView should be reattached from cache, not re-created. creates: {:?}",
creates
);
assert!(
attach_count >= 1,
"expected at least one Attach patch on navigate-back, got {}",
attach_count
);
}
#[test]
fn test_router_evicts_least_recently_used_cache_entry() {
use hypen_engine::reconcile::reconcile_ir as reconcile;
use hypen_engine::reconcile::ControlFlowKind;
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let ir = IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![
RouterRoute::new("/a", vec![IRNode::Element(Element::new("A"))]),
RouterRoute::new("/b", vec![IRNode::Element(Element::new("B"))]),
RouterRoute::new("/c", vec![IRNode::Element(Element::new("C"))]),
],
fallback: None,
module_scope: None,
};
let _ = reconcile(&mut tree, &ir, None, &json!({"location": "/a"}), &mut deps);
let _ = reconcile(&mut tree, &ir, None, &json!({"location": "/b"}), &mut deps);
let _ = reconcile(&mut tree, &ir, None, &json!({"location": "/c"}), &mut deps);
let router_id = tree.root().expect("router is the root instance node");
let router = tree.get(router_id).expect("router node must exist");
match router.control_flow.as_ref() {
Some(ControlFlowKind::Router {
cache,
current_route_key,
..
}) => {
assert_eq!(current_route_key.as_deref(), Some("/c"));
let cached_keys: Vec<&str> = cache.keys().map(|s| s.as_str()).collect();
assert_eq!(
cached_keys,
vec!["/a", "/b"],
"expected /a and /b cached in insertion order"
);
}
other => panic!("expected Router control flow, got {:?}", other),
}
}
#[test]
fn test_cached_subtree_stays_reactive_while_detached() {
use hypen_engine::reconcile::reconcile_ir as reconcile;
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let home_text = Element::new("Text").with_prop(
"0",
Value::Binding(Binding::state(vec!["greeting".to_string()])),
);
let ir = IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![
RouterRoute::new("/", vec![IRNode::Element(home_text)]),
RouterRoute::new("/other", vec![IRNode::Element(Element::new("Other"))]),
],
fallback: None,
module_scope: None,
};
let _ = reconcile(
&mut tree,
&ir,
None,
&json!({"location": "/", "greeting": "hello"}),
&mut deps,
);
let _ = reconcile(
&mut tree,
&ir,
None,
&json!({"location": "/other", "greeting": "hello"}),
&mut deps,
);
let back_patches = reconcile(
&mut tree,
&ir,
None,
&json!({"location": "/", "greeting": "world"}),
&mut deps,
);
let text_creates = back_patches
.iter()
.filter(|p| matches!(p, Patch::Create { element_type, .. } if element_type == "Text"))
.count();
assert_eq!(
text_creates, 0,
"cached Text should be reattached, not re-created"
);
let attach_count = back_patches
.iter()
.filter(|p| matches!(p, Patch::Attach { .. }))
.count();
assert!(attach_count >= 1, "expected Attach on navigate-back");
}
#[test]
fn test_router_dependency_is_registered_on_location() {
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let state = json!({ "location": "/" });
let ir = IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![RouterRoute::new(
"/",
vec![IRNode::Element(Element::new("HomeView"))],
)],
fallback: None,
module_scope: None,
};
let _ = reconcile_ir(&mut tree, &ir, None, &state, &mut deps);
let affected = deps.get_affected_nodes("location");
assert!(
!affected.is_empty(),
"expected at least one node bound to `location`"
);
}
#[test]
fn test_router_at_root_inserts_children_under_root_not_router_node() {
use hypen_engine::reconcile::reconcile_ir as reconcile;
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let state = json!({ "location": "/" });
let ir = IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![
RouterRoute::new("/", vec![IRNode::Element(Element::new("HomeView"))]),
RouterRoute::new("/search", vec![IRNode::Element(Element::new("SearchView"))]),
],
fallback: None,
module_scope: None,
};
let patches = reconcile(&mut tree, &ir, None, &state, &mut deps);
let home_insert = patches.iter().find_map(|p| match p {
Patch::Insert { parent_id, id, .. } => {
let is_home = patches.iter().any(|q| {
matches!(q, Patch::Create { id: cid, element_type, .. }
if cid == id && element_type == "HomeView")
});
if is_home {
Some(parent_id.clone())
} else {
None
}
}
_ => None,
});
let parent = home_insert.expect("expected an Insert patch for HomeView");
assert_eq!(
parent, "root",
"Router-at-root: HomeView must Insert under \"root\", not under \
the __Router control-flow NodeId. Got parent = {:?}. \
Full patches: {:#?}",
parent, patches,
);
}
#[test]
fn test_router_at_root_nav_attaches_children_under_root_not_router_node() {
use hypen_engine::reconcile::reconcile_ir as reconcile;
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let ir = IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![
RouterRoute::new("/", vec![IRNode::Element(Element::new("HomeView"))]),
RouterRoute::new("/search", vec![IRNode::Element(Element::new("SearchView"))]),
],
fallback: None,
module_scope: None,
};
let _ = reconcile(&mut tree, &ir, None, &json!({"location": "/"}), &mut deps);
let nav_patches = reconcile(
&mut tree,
&ir,
None,
&json!({"location": "/search"}),
&mut deps,
);
let search_insert_parent = nav_patches.iter().find_map(|p| match p {
Patch::Insert { parent_id, id, .. } => {
let is_search = nav_patches.iter().any(|q| {
matches!(q, Patch::Create { id: cid, element_type, .. }
if cid == id && element_type == "SearchView")
});
if is_search {
Some(parent_id.clone())
} else {
None
}
}
_ => None,
});
let parent = search_insert_parent.expect("expected an Insert patch for SearchView");
assert_eq!(
parent, "root",
"Router-at-root nav: SearchView Insert must target \"root\", not \
the __Router NodeId. Got parent = {:?}. Full patches: {:#?}",
parent, nav_patches,
);
let back_patches = reconcile(&mut tree, &ir, None, &json!({"location": "/"}), &mut deps);
let attach_parents: Vec<String> = back_patches
.iter()
.filter_map(|p| match p {
Patch::Attach { parent_id, .. } => Some(parent_id.clone()),
_ => None,
})
.collect();
assert!(
!attach_parents.is_empty(),
"expected at least one Attach patch on nav-back to cached route"
);
for p in &attach_parents {
assert_eq!(
p, "root",
"Router-at-root nav-back: cached Attach must target \"root\". \
Got parent = {:?}. Full patches: {:#?}",
p, back_patches,
);
}
}
#[test]
fn test_nested_router_inserts_children_under_wrapping_element_not_root() {
use hypen_engine::reconcile::reconcile_ir as reconcile;
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let state = json!({ "location": "/" });
let mut column = Element::new("Column");
column.ir_children.push(IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![
RouterRoute::new("/", vec![IRNode::Element(Element::new("HomeView"))]),
RouterRoute::new("/search", vec![IRNode::Element(Element::new("SearchView"))]),
],
fallback: None,
module_scope: None,
});
let ir = IRNode::Element(column);
let patches = reconcile(&mut tree, &ir, None, &state, &mut deps);
let column_id = patches
.iter()
.find_map(|p| match p {
Patch::Create {
id, element_type, ..
} if element_type == "Column" => Some(id.clone()),
_ => None,
})
.expect("expected Column Create");
let column_insert_parent = patches
.iter()
.find_map(|p| match p {
Patch::Insert { parent_id, id, .. } if *id == column_id => Some(parent_id.clone()),
_ => None,
})
.expect("expected Column Insert");
assert_eq!(
column_insert_parent, "root",
"Column is the IR root → inserts under \"root\""
);
let home_insert_parent = patches
.iter()
.find_map(|p| match p {
Patch::Insert { parent_id, id, .. } => {
let is_home = patches.iter().any(|q| {
matches!(q, Patch::Create { id: cid, element_type, .. }
if cid == id && element_type == "HomeView")
});
if is_home {
Some(parent_id.clone())
} else {
None
}
}
_ => None,
})
.expect("expected HomeView Insert");
assert_eq!(
home_insert_parent, column_id,
"Nested Router: HomeView must Insert under the wrapping Column, \
not \"root\" and not the __Router NodeId. Got parent = {:?}. \
Column id = {:?}. Full patches: {:#?}",
home_insert_parent, column_id, patches,
);
}
#[test]
fn test_nested_router_nav_routes_children_under_wrapping_element() {
use hypen_engine::reconcile::reconcile_ir as reconcile;
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let mut column = Element::new("Column");
column.ir_children.push(IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![
RouterRoute::new("/", vec![IRNode::Element(Element::new("HomeView"))]),
RouterRoute::new("/search", vec![IRNode::Element(Element::new("SearchView"))]),
],
fallback: None,
module_scope: None,
});
let ir = IRNode::Element(column);
let initial = reconcile(&mut tree, &ir, None, &json!({"location": "/"}), &mut deps);
let column_id = initial
.iter()
.find_map(|p| match p {
Patch::Create {
id, element_type, ..
} if element_type == "Column" => Some(id.clone()),
_ => None,
})
.expect("Column created");
let nav = reconcile(
&mut tree,
&ir,
None,
&json!({"location": "/search"}),
&mut deps,
);
let search_parent = nav
.iter()
.find_map(|p| match p {
Patch::Insert { parent_id, id, .. } => {
let is_search = nav.iter().any(|q| {
matches!(q, Patch::Create { id: cid, element_type, .. }
if cid == id && element_type == "SearchView")
});
if is_search {
Some(parent_id.clone())
} else {
None
}
}
_ => None,
})
.expect("SearchView Insert");
assert_eq!(
search_parent, column_id,
"Nested Router nav: SearchView must Insert under Column, not \"root\". \
Got parent = {:?}. Patches: {:#?}",
search_parent, nav,
);
let back = reconcile(&mut tree, &ir, None, &json!({"location": "/"}), &mut deps);
let attach_parents: Vec<String> = back
.iter()
.filter_map(|p| match p {
Patch::Attach { parent_id, .. } => Some(parent_id.clone()),
_ => None,
})
.collect();
assert!(!attach_parents.is_empty(), "expected Attach on nav-back");
for p in &attach_parents {
assert_eq!(
*p, column_id,
"Nested Router nav-back: cached Attach must target Column, \
not \"root\". Got parent = {:?}. Patches: {:#?}",
p, back,
);
}
}
#[test]
fn test_router_route_with_list_element_emits_items_on_nav() {
use hypen_engine::reconcile::reconcile_ir as reconcile;
let mut tree = InstanceTree::new();
let mut deps = DependencyGraph::new();
let foreach = IRNode::ForEach {
source: Binding::state(vec!["items".to_string()]),
item_name: "item".to_string(),
key_path: Some("id".to_string()),
template: vec![IRNode::Element(Element::new("Row"))],
props: Props::new(),
module_scope: None,
};
let mut list_el = Element::new("List");
list_el.ir_children.push(foreach);
let mut column_el = Element::new("Column");
column_el.ir_children.push(IRNode::Element(list_el));
let ir = IRNode::Router {
location: Value::Binding(Binding::state(vec!["location".to_string()])),
routes: vec![
RouterRoute::new("/", vec![IRNode::Element(Element::new("HomeView"))]),
RouterRoute::new("/list", vec![IRNode::Element(column_el)]),
],
fallback: None,
module_scope: None,
};
let state = json!({
"location": "/",
"items": [{"id": "a"}, {"id": "b"}, {"id": "c"}],
});
let _ = reconcile(&mut tree, &ir, None, &state, &mut deps);
let nav_state = json!({
"location": "/list",
"items": [{"id": "a"}, {"id": "b"}, {"id": "c"}],
});
let nav = reconcile(&mut tree, &ir, None, &nav_state, &mut deps);
let row_creates = nav
.iter()
.filter(|p| matches!(p, Patch::Create { element_type, .. } if element_type == "Row"))
.count();
assert_eq!(
row_creates, 3,
"expected 3 Row creates (one per state.items entry) after nav to /list; \
got {}. Full patches: {:#?}",
row_creates, nav,
);
}