8f96169b03
Support Vec<T: IntoEffect> and [T; N] batches so dynamic reusable partial updates compose inside the existing effect model without a broad IntoIterator blanket impl. req: canonical_authoring/003
226 lines
6.8 KiB
Rust
226 lines
6.8 KiB
Rust
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::<u32>::new(1);
|
|
let todos = KeyedSlot::<u64, String>::new(2);
|
|
let user = Atom::<String>::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::<u32>::new(1);
|
|
let batch = (Some(count.text(2)), Option::<Effect>::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::<u64, String>::new(2);
|
|
let summary = Slot::<u32>::new(3);
|
|
let notice = Slot::<String>::new(4);
|
|
|
|
let dynamic_rows = [7, 8]
|
|
.into_iter()
|
|
.map(|id| rows.replace_text(id, format!("todo {id}")))
|
|
.collect::<Vec<_>>();
|
|
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::<u64, String>::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::<u8>::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::<String>::new(10);
|
|
let Effect::Put { payload, .. } = content.html(SafeHtml::trusted("<strong>ok</strong>")) else {
|
|
panic!("expected Put");
|
|
};
|
|
|
|
assert_eq!(payload, Payload::Html(String::from("<strong>ok</strong>")));
|
|
}
|
|
|
|
#[test]
|
|
fn safe_html_joins_only_explicit_safe_fragments() {
|
|
// req: html_safety/001 req: html_safety/002
|
|
let html = SafeHtml::join([
|
|
SafeHtml::trusted("<main>"),
|
|
SafeHtml::trusted("<strong>ok</strong>"),
|
|
SafeHtml::trusted("</main>"),
|
|
]);
|
|
|
|
assert_eq!(html.as_str(), "<main><strong>ok</strong></main>");
|
|
}
|
|
|
|
#[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::<String>::new(10).to_string(), "10");
|
|
assert_eq!(KeyedSlot::<u64, String>::new(11).to_string(), "11");
|
|
assert_eq!(Atom::<String>::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);
|
|
}
|