Implement closed typed effect path

This commit is contained in:
tmk241
2026-09-01 00:58:29 +02:00
parent 353174604e
commit 31a0f02211
58 changed files with 4247 additions and 5933 deletions
+57 -255
View File
@@ -1,8 +1,18 @@
use hemx_core::{
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, KeyedSlot, NavigateMode,
Payload, ResourceId, ResourceKind, ResourceRef, ScopeKey, Slot,
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, HistoryMode,
InsertPosition, KeyedSlot, PatchMode, ResourceId, ResourceKind, ResourceRef, SafeHtml,
ScopeKey, Slot,
};
#[derive(Clone, Copy)]
struct TestTarget(ResourceKind, u32);
impl GeneratedTarget for TestTarget {
fn __hemx_resource_id(self) -> ResourceId {
ResourceId::new(self.0, self.1)
}
}
fn panic_text<T>(result: std::thread::Result<T>) -> String {
let panic = match result {
Ok(_) => panic!("operation must panic"),
@@ -26,24 +36,26 @@ fn inspects_tuple_effects() {
assert!(inspected.has_slot(count));
assert!(inspected.has_atom(user));
assert!(inspected.contains(&Effect::Put {
assert!(inspected.contains(&Effect::Patch {
target: ResourceRef::unscoped(count.id()),
payload: Payload::text(42),
mode: PatchMode::Morph,
html: SafeHtml::trusted("42"),
}));
}
#[test]
fn text_update_condition_is_bound_to_the_expected_target() {
// req: test/018
let expected_target = TestTarget(ResourceKind::Slot, 42);
let inspected = hemx_test::inspect(vec![
Effect::Put {
Effect::Patch {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 42)),
payload: Payload::Text(String::from("wrong payload")),
mode: PatchMode::Morph,
html: SafeHtml::trusted("wrong payload"),
},
Effect::Put {
Effect::Patch {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 7)),
payload: Payload::Text(String::from("expected fragment")),
mode: PatchMode::Morph,
html: SafeHtml::trusted("expected fragment"),
},
]);
@@ -68,11 +80,11 @@ fn text_update_condition_is_bound_to_the_expected_target() {
#[test]
fn html_update_assertion_reports_expectation_and_actual_effects() {
// req: test/018
let target = TestTarget(ResourceKind::Slot, 42);
let inspected = hemx_test::inspect(Effect::Put {
let inspected = hemx_test::inspect(Effect::Patch {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 7)),
payload: Payload::Html(String::from("<p>actual</p>")),
mode: PatchMode::Morph,
html: SafeHtml::trusted("<p>actual</p>"),
});
let panic = std::panic::catch_unwind(|| {
@@ -101,65 +113,64 @@ fn finds_keyed_slot_targets() {
#[test]
fn inspector_predicates_bind_operation_target_scope_kind_and_payload() {
// test req: test/008 req: test/009 req: test/018
let expected = TestTarget(ResourceKind::Slot, 42);
let other = TestTarget(ResourceKind::Slot, 7);
let resource = ResourceId::new(ResourceKind::Slot, 42);
let keyed_ref = ResourceRef::scoped(resource, ScopeKey::KeyValue("row-1".into()));
let form = Form::<()>::new(11);
let expected_emit = Effect::Emit {
name: "saved".into(),
payload: "card 42 saved".into(),
let expected_emit = Effect::Dispatch {
event: "saved".into(),
payload: b"card 42 saved".to_vec(),
};
let inspected = hemx_test::inspect(vec![
Effect::Put {
Effect::Patch {
target: ResourceRef::unscoped(other.__hemx_resource_id()),
payload: Payload::Text("decoy needle".into()),
mode: PatchMode::Morph,
html: SafeHtml::trusted("decoy needle"),
},
Effect::Put {
Effect::Patch {
target: ResourceRef::unscoped(resource),
payload: Payload::Text("expected text".into()),
mode: PatchMode::Morph,
html: SafeHtml::trusted("expected text"),
},
Effect::Put {
Effect::Patch {
target: keyed_ref.clone(),
payload: Payload::Html("<li data-key=\"row-1\">replacement</li>".into()),
mode: PatchMode::Replace,
html: SafeHtml::trusted("<li data-key=\"row-1\">replacement</li>"),
},
Effect::Insert {
target: ResourceRef::unscoped(resource),
key: "row-2".into(),
payload: Payload::Html("<li>inserted</li>".into()),
position: InsertPosition::After(ResourceRef::scoped(
resource,
ScopeKey::KeyValue("row-1".into()),
)),
html: SafeHtml::trusted("<li data-hemx-key=\"row-2\">inserted</li>"),
},
Effect::Prepend {
Effect::Insert {
target: ResourceRef::unscoped(resource),
key: "row-0".into(),
payload: Payload::Html("<li>prepended</li>".into()),
position: InsertPosition::First,
html: SafeHtml::trusted("<li data-hemx-key=\"row-0\">prepended</li>"),
},
Effect::Remove {
target: ResourceRef::unscoped(resource),
key: Some("row-old".into()),
target: ResourceRef::scoped(resource, ScopeKey::KeyValue("row-old".into())),
},
Effect::Focus {
target: ResourceRef::unscoped(form.id()),
prevent_scroll: false,
},
Effect::Navigate {
Effect::Visit {
url: "/cards/42".into(),
mode: NavigateMode::Push,
scroll: hemx_core::ScrollBehavior::Preserve,
title: None,
history: HistoryMode::Push,
},
expected_emit.clone(),
Effect::Emit {
name: "hemx:form-reset".into(),
payload: form.id().id.to_string(),
},
]);
assert!(!inspected.is_empty());
assert_eq!(inspected.op_count(), 10);
assert_eq!(inspected.op_count(), 9);
assert!(inspected.contains(&expected_emit));
assert!(!inspected.contains(&Effect::Emit {
name: "saved".into(),
payload: "wrong".into(),
assert!(!inspected.contains(&Effect::Dispatch {
event: "saved".into(),
payload: b"wrong".to_vec(),
}));
assert!(inspected.has_resource(resource));
assert!(inspected.has_resource(other.__hemx_resource_id()));
@@ -195,11 +206,11 @@ fn inspector_predicates_bind_operation_target_scope_kind_and_payload() {
);
assert_eq!(
inspected.target_html_containing(expected, "inserted"),
Some("<li>inserted</li>")
Some("<li data-hemx-key=\"row-2\">inserted</li>")
);
assert_eq!(
inspected.target_html_containing(expected, "prepended"),
Some("<li>prepended</li>")
Some("<li data-hemx-key=\"row-0\">prepended</li>")
);
assert_eq!(inspected.target_html_containing(expected, "missing"), None);
assert!(inspected.emits("saved", "card 42 saved"));
@@ -219,120 +230,26 @@ fn inspector_predicates_bind_operation_target_scope_kind_and_payload() {
assert!(!inspected.has_atom(Atom::<()>::new(42)));
assert!(inspected.has_form(form));
assert!(!inspected.has_form(Form::<()>::new(12)));
assert!(inspected.resets_form(form));
assert!(!inspected.resets_form(Form::<()>::new(12)));
let empty = hemx_test::inspect(Vec::<Effect>::new());
assert!(empty.is_empty());
assert_eq!(empty.op_count(), 0);
}
#[test]
fn selector_helpers_validate_parts_and_cover_unscoped_variants() {
// test req: test/017
assert_eq!(hemx_test::heading_selector("", 2), "h2");
assert_eq!(hemx_test::list_item_selector(""), "li");
assert_eq!(hemx_test::prose_selector(""), "p");
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Slot, 1)),
"[data-sid=\"1\"]"
);
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Atom, 2)),
"[data-aid=\"2\"]"
);
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Handle, 3)),
"[data-hid=\"3\"]"
);
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Form, 4)),
"[data-fid=\"4\"]"
);
assert_eq!(
hemx_test::nav_link_selector("/path\\\"quoted"),
"a[href=\"/path\\\\\\\"quoted\"]"
);
hemx_test::assert_rendered_target(
TestTarget(ResourceKind::Slot, 1),
"slot",
"<p data-sid=\"1\"></p>",
);
hemx_test::assert_rendered_handle(
hemx_core::Handle::<()>::new(3),
"handle",
"<button data-hid=\"3\"></button>",
);
assert!(std::panic::catch_unwind(|| {
hemx_test::assert_rendered_target(TestTarget(ResourceKind::Slot, 1), "slot", "<p></p>");
})
.is_err());
assert!(std::panic::catch_unwind(|| {
hemx_test::assert_rendered_handle(
hemx_core::Handle::<()>::new(3),
"handle",
"<button data-other=\"3\"></button>",
);
})
.is_err());
for invalid in ["", "two parts", ".class", "#id", "a>b", "a[b]"] {
assert!(panic_text(std::panic::catch_unwind(|| {
hemx_test::class_selector(invalid)
}))
.contains("class selector part"));
}
for (call, label) in [
(
std::panic::catch_unwind(|| hemx_test::element_class_selector("bad tag", "ok")),
"element selector part",
),
(
std::panic::catch_unwind(|| hemx_test::element_class_selector("span", "bad class")),
"class selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_child_selector("bad parent", "li", "row")),
"parent class selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "bad tag", "row")),
"element selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "li", "bad class")),
"class selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_descendant_selector("bad parent", "i")),
"parent class selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_descendant_selector("note", "bad tag")),
"element selector part",
),
] {
assert!(panic_text(call).contains(label));
}
}
#[test]
fn inspect_wire_reports_the_decode_failure_and_accepts_canonical_batches() {
let batch = EffectBatch {
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
fingerprint: BuildFingerprint(9),
ops: vec![Effect::Emit {
name: "saved".into(),
payload: "ok".into(),
ops: vec![Effect::Dispatch {
event: "saved".into(),
payload: b"ok".to_vec(),
}],
};
assert!(hemx_test::inspect_wire(&batch.to_wire()).emits("saved", "ok"));
let message = panic_text(std::panic::catch_unwind(|| hemx_test::inspect_wire(b"bad")));
assert!(message.contains("invalid hemx effect wire response: Truncated"));
// req: test/001 test req: wire/009 test
}
#[test]
@@ -348,118 +265,3 @@ fn builds_generated_handle_form_bodies() {
);
assert_eq!(hemx_test::unknown_handle_form_body(99), "__h=99");
}
#[test]
fn builds_authoring_boundary_selectors() {
assert_eq!(
hemx_test::root_selector("techdemo"),
r#"[data-hemx-root="techdemo"]"#
);
assert_eq!(
hemx_test::island_selector("orbit"),
r#"[data-hemx-island="orbit"]"#
);
assert_eq!(hemx_test::island_attribute_name(), "data-hemx-island");
assert_eq!(hemx_test::island_event_name("orbit"), "hemx:island-orbit");
assert_eq!(
hemx_test::sse_endpoint_marker("/events"),
r#"data-hemx-sse="/events""#
);
assert_eq!(hemx_test::any_root_selector(), "[data-hemx-root]");
assert_eq!(
hemx_test::root_element_selector("main", "docs"),
r#"main[data-hemx-root="docs"]"#
);
assert_eq!(hemx_test::document_body_selector(), "body");
assert_eq!(hemx_test::document_title_selector(), "title");
assert_eq!(
hemx_test::runtime_script_selector(),
r#"script[src^="/hemx."][src$=".js"]"#
);
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Slot, 42)),
r#"[data-sid="42"]"#
);
hemx_test::assert_rendered_target(
TestTarget(ResourceKind::Slot, 42),
"gallery::search_result",
r#"<li data-sid="42">row</li>"#,
);
assert_eq!(
hemx_test::handle_button_selector(hemx_core::Handle::<()>::new(7)),
r#"button[data-hid="7"]"#
);
hemx_test::assert_rendered_handle(
hemx_core::Handle::<()>::new(7),
"gallery::search",
r#"<button data-hid="7">Search</button>"#,
);
assert_eq!(hemx_test::article_selector(), "article");
assert_eq!(hemx_test::strong_text_selector(), "strong");
assert_eq!(hemx_test::small_text_selector(), "small");
assert_eq!(hemx_test::escaped_markup_selector("b"), "b");
assert_eq!(hemx_test::heading_selector("article", 1), "article h1");
assert_eq!(hemx_test::list_item_selector("ul"), "ul li");
assert_eq!(hemx_test::prose_selector("article"), "article p");
assert_eq!(hemx_test::form_selector("header"), "header form");
assert_eq!(
hemx_test::select_options_selector("column"),
r#"select[name="column"] > option"#
);
assert_eq!(hemx_test::class_selector("lane"), ".lane");
assert_eq!(
hemx_test::element_class_selector("span", "presence"),
"span.presence"
);
assert_eq!(
hemx_test::class_child_selector("columns", "section", "column"),
".columns > section.column"
);
assert_eq!(
hemx_test::class_descendant_selector("impact", "i"),
".impact i"
);
assert_eq!(hemx_test::disabled_button_selector(), "button[disabled]");
assert_eq!(
hemx_test::nav_link_selector("/architecture"),
r#"a[href="/architecture"]"#
);
assert_eq!(
hemx_test::page_nav_link_selector("/docs"),
r#"a[href="/docs"][data-hemx-nav]:not([data-hemx-handle])"#
);
assert_eq!(hemx_test::island_snapshot_marker(), "data-island-snapshot=");
assert_eq!(
hemx_test::island_readout_selector(),
"[data-island-readout]"
);
assert_eq!(
hemx_test::scoped_island_readout_selector("#probe-island"),
"#probe-island [data-island-readout]"
);
assert_eq!(
hemx_test::keyed_selector(".work-card", 4),
r#".work-card[data-key="4"]"#
);
assert_eq!(hemx_test::keyed_items_selector("li"), "li[data-key]");
let probe = hemx_test::island_probe_script(
"probe-\"island",
"orbit\\bridge",
"line1\nline2\rline3\tend",
"detail \"quoted\"",
);
assert!(probe.contains(r#"island.id = "probe-\"island";"#));
assert!(probe.contains(r#"const islandName = "orbit\\bridge";"#));
assert!(probe.contains(r#""line1\nline2\rline3\tend""#));
assert!(probe.contains(r#"detail: "detail \"quoted\"""#));
}
#[derive(Clone, Copy)]
struct TestTarget(ResourceKind, u32);
impl GeneratedTarget for TestTarget {
fn __hemx_resource_id(self) -> ResourceId {
ResourceId::new(self.0, self.1)
}
}