use hemx_core::{ event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint, ComponentRef, CssClass, CssClasses, Effect, EffectBatch, Form, Handle, IntoEffect, KeyedSlot, NavigateMode, ParamName, Payload, ResourceKind, SafeHtml, ScopeKey, Slot, }; #[test] fn effect_batch_wire_round_trips() { let count = Slot::::new(1); let todos = KeyedSlot::::new(2); let user = Atom::::new(3); let batch = ( count.text(2), todos.append_text(7, String::from("Buy milk")), todos.replace_text(7, String::from("Buy oat milk")), user.set("Ada"), navigate("/todos"), event("toast", "Saved"), ) .into_batch(BuildFingerprint(42)); let bytes = batch.to_wire(); assert_eq!(&bytes[..4], b"HEMX"); let decoded = EffectBatch::from_wire(&bytes).unwrap(); assert_eq!(decoded, batch); assert!(decoded.is_compatible()); } #[test] fn optional_effects_compose_into_batches() { // req: component/005 req: public_api/003 let count = Slot::::new(1); let batch = (Some(count.text(2)), Option::::None).into_batch(BuildFingerprint(42)); assert_eq!(batch.ops.len(), 1); assert_eq!(batch.ops[0], count.text(2)); } #[test] fn effect_collections_compose_into_batches() { // req: canonical_authoring/003 let rows = KeyedSlot::::new(2); let summary = Slot::::new(3); let notice = Slot::::new(4); let dynamic_rows = [7, 8] .into_iter() .map(|id| rows.replace_text(id, format!("todo {id}"))) .collect::>(); let fixed_notices = [notice.text("Saved"), notice.text("Synced")]; let batch = (dynamic_rows, Some(summary.text(2)), fixed_notices).into_batch(BuildFingerprint(42)); assert_eq!(batch.ops.len(), 5); assert_eq!(batch.ops[0], rows.replace_text(7, String::from("todo 7"))); assert_eq!(batch.ops[1], rows.replace_text(8, String::from("todo 8"))); assert_eq!(batch.ops[2], summary.text(2)); assert_eq!(batch.ops[3], notice.text("Saved")); assert_eq!(batch.ops[4], notice.text("Synced")); } #[test] fn keyed_slot_replace_uses_scoped_resource_ref() { let todos = KeyedSlot::::new(9); // req: codegen/002 let effect = todos.replace_text(12, String::from("done")); let Effect::Put { target, payload } = effect else { panic!("expected Put"); }; assert_eq!(target.resource.kind, ResourceKind::Slot); assert_eq!(target.resource.id, 9); assert_eq!(target.scope, Some(ScopeKey::KeyValue(String::from("12")))); assert_eq!(payload, Payload::Text(String::from("done"))); } #[test] fn tuple_composition_supports_arity_twelve() { let slot = Slot::::new(1); let batch = ( slot.text(1), slot.text(2), slot.text(3), slot.text(4), slot.text(5), slot.text(6), slot.text(7), slot.text(8), slot.text(9), slot.text(10), slot.text(11), slot.text(12), ) .into_batch(BuildFingerprint(1)); assert_eq!(batch.ops.len(), 12); } #[test] fn generated_form_helpers_target_form_fields() { let signup = Form::<()>::new(4); let Effect::Emit { name, payload } = signup.error("email", "Use your work email") else { panic!("expected Emit"); }; assert_eq!(name, "hemx:form-error"); assert_eq!(payload, "4\u{1f}email\u{1f}Use your work email"); let Effect::Focus { target } = signup.focus("email") else { panic!("expected Focus"); }; assert_eq!(target.scope, Some(ScopeKey::Field(String::from("email")))); } #[test] fn slot_html_requires_explicit_safe_html() { let content = Slot::::new(10); let Effect::Put { payload, .. } = content.html(SafeHtml::trusted("ok")) else { panic!("expected Put"); }; assert_eq!(payload, Payload::Html(String::from("ok"))); } #[test] fn safe_html_joins_only_explicit_safe_fragments() { // req: html_safety/001 req: html_safety/002 let html = SafeHtml::join([ SafeHtml::trusted("
"), SafeHtml::trusted("ok"), SafeHtml::trusted("
"), ]); assert_eq!(html.as_str(), "
ok
"); } #[test] fn param_names_format_generated_param_names() { // req: codegen/003 let param = ParamName::new("todo_id"); assert_eq!(param.as_str(), "todo_id"); assert_eq!(param.to_string(), "todo_id"); } #[test] fn component_refs_format_generated_component_names() { // req: component/003 let component = ComponentRef::new("todo_list"); assert_eq!(component.as_str(), "todo_list"); assert_eq!(component.to_string(), "todo_list"); } #[test] fn generated_resources_format_for_hemplate_dynamic_attrs() { // req: public_api/001 req: public_api/002 assert_eq!(Slot::::new(10).to_string(), "10"); assert_eq!(KeyedSlot::::new(11).to_string(), "11"); assert_eq!(Atom::::new(12).to_string(), "12"); assert_eq!(Handle::<()>::new(42).to_string(), "42"); assert_eq!(Form::<()>::new(13).to_string(), "13"); } #[test] fn generated_css_classes_join_for_hemplate_dynamic_class_attrs() { // req: style/003 const CARD: CssClass = CssClass::new("work-card"); const SELECTED: CssClass = CssClass::new("is-selected"); let classes = CssClasses::from([CARD, SELECTED]); assert_eq!(classes.as_str(), "work-card is-selected"); assert_eq!(classes.to_string(), "work-card is-selected"); assert_eq!(CARD.with(SELECTED).as_str(), "work-card is-selected"); assert_eq!( CARD.with_if(true, SELECTED).as_str(), "work-card is-selected" ); assert_eq!(CARD.with_if(false, SELECTED).as_str(), "work-card"); } #[test] fn atom_state_bootstrap_is_postcard_round_trippable() { let state = AtomState { atoms: vec![AtomSnapshot { id: 7, bytes: vec![1, 2, 3], }], }; let bytes = state.to_postcard().unwrap(); assert_eq!(AtomState::from_postcard(&bytes).unwrap(), state); } #[test] fn navigation_helpers_choose_explicit_modes() { let Effect::Navigate { mode, .. } = navigate("/docs") else { panic!("expected Navigate"); }; assert_eq!(mode, NavigateMode::Push); let Effect::Navigate { mode, .. } = replace("/docs") else { panic!("expected Navigate"); }; assert_eq!(mode, NavigateMode::Replace); let Effect::Navigate { mode, .. } = redirect("/login") else { panic!("expected Navigate"); }; assert_eq!(mode, NavigateMode::Redirect); } #[test] fn build_fingerprint_is_deterministic_from_abi_parts() { let a = BuildFingerprint::from_parts(&[1, 2, 3, 4]); let b = BuildFingerprint::from_parts(&[1, 2, 3, 4]); let c = BuildFingerprint::from_parts(&[1, 2, 3, 5]); assert_eq!(a, b); assert_ne!(a, c); }