Files
hemx/hemx-test/src/lib.rs
T
2026-09-01 09:21:23 +02:00

512 lines
18 KiB
Rust

#![doc = include_str!("../README.md")]
#[cfg(feature = "axum")]
pub mod axum;
mod html;
mod process;
pub use html::{HtmlElement, HtmlInspectionError, HtmlInspector, HtmlSelection};
pub use process::{ProcessError, ProcessFailure, TestProcess, TestProcessBuilder};
use hemx_core::{
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, IntoEffect, KeyedSlot,
ResourceId, ResourceRef, ScopeKey, Slot,
};
use std::future::Future;
/// Run a synchronous handler with typed input and inspect its effects.
pub fn run<I, F, R>(handler: F, input: I) -> EffectInspector
where
F: FnOnce(I) -> R,
R: IntoEffect,
{
inspect(handler(input))
}
/// Run an asynchronous handler with typed input and inspect its effects.
pub async fn run_async<I, F, HandlerFuture, R>(handler: F, input: I) -> EffectInspector
where
F: FnOnce(I) -> HandlerFuture,
HandlerFuture: Future<Output = R>,
R: IntoEffect,
{
inspect(handler(input).await)
}
/// Run a fallible synchronous handler without hiding its concrete error.
///
/// Only the successful value is converted into effects. `IntoEffect`
/// conversion is infallible in the current public contract, so an error from
/// the handler is returned unchanged rather than becoming an empty or
/// success-looking batch.
pub fn run_result<I, F, R, E>(handler: F, input: I) -> Result<EffectInspector, E>
where
F: FnOnce(I) -> Result<R, E>,
R: IntoEffect,
{
handler(input).map(inspect)
}
/// Run a fallible asynchronous handler without hiding its concrete error.
///
/// Only the successful value is converted into effects; the handler's original
/// error type and value are preserved.
pub async fn run_async_result<I, F, HandlerFuture, R, E>(
handler: F,
input: I,
) -> Result<EffectInspector, E>
where
F: FnOnce(I) -> HandlerFuture,
HandlerFuture: Future<Output = Result<R, E>>,
R: IntoEffect,
{
handler(input).await.map(inspect)
}
fn inspection_fingerprint() -> BuildFingerprint {
BuildFingerprint(0)
}
pub fn inspect(effect: impl IntoEffect) -> EffectInspector {
inspect_batch(effect.into_batch(inspection_fingerprint()))
}
/// Parse and structurally inspect a complete server-rendered HTML document.
pub fn inspect_html_document(html: impl Into<String>) -> HtmlInspector {
HtmlInspector::document(html.into(), String::from("HTML document"))
}
/// Parse and structurally inspect a server-rendered HTML fragment.
pub fn inspect_html_fragment(html: impl Into<String>) -> HtmlInspector {
HtmlInspector::fragment(html.into(), String::from("HTML fragment"))
}
/// Inspect an already-dispatched batch without matching raw effect variants in tests.
pub fn inspect_batch(batch: EffectBatch) -> EffectInspector {
EffectInspector { batch }
}
/// Decode and inspect an effect wire response without exposing `EffectBatch` in tests.
pub fn inspect_wire(bytes: &[u8]) -> EffectInspector {
try_inspect_wire(bytes)
.unwrap_or_else(|error| panic!("invalid hemx effect wire response: {error:?}"))
}
/// Try to decode and inspect an effect wire response.
pub fn try_inspect_wire(bytes: &[u8]) -> Result<EffectInspector, hemx_core::WireError> {
EffectBatch::from_wire(bytes).map(inspect_batch)
}
/// Return the resource id behind a generated target for low-level test assertions.
pub fn target_resource(target: impl GeneratedTarget) -> ResourceId {
target.__hemx_resource_id()
}
/// Return the unscoped resource reference behind a generated target for low-level test assertions.
pub fn target_ref(target: impl GeneratedTarget) -> ResourceRef {
ResourceRef::unscoped(target_resource(target))
}
/// Build an interaction request body from a generated handle and form fields.
pub fn handle_form_body<I>(handle: hemx_core::Handle<I>, fields: &[(&str, &str)]) -> String {
let mut body = form_pair("__h", &handle.to_string());
for (name, value) in fields {
body.push('&');
body.push_str(&form_pair(name, value));
}
body
}
/// Build a request body for invalid-handle tests without exposing the wire field name.
pub fn unknown_handle_form_body(id: u32) -> String {
form_pair("__h", &id.to_string())
}
fn form_pair(name: &str, value: &str) -> String {
format!("{}={}", form_encode(name), form_encode(value))
}
fn form_encode(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char)
}
b' ' => encoded.push('+'),
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}
#[derive(Clone, Debug)]
pub struct EffectInspector {
batch: EffectBatch,
}
impl EffectInspector {
pub fn batch(&self) -> &EffectBatch {
&self.batch
}
pub fn ops(&self) -> &[Effect] {
&self.batch.ops
}
pub fn contains(&self, op: &Effect) -> bool {
self.batch.ops.contains(op)
}
pub fn op_count(&self) -> usize {
self.batch.ops.len()
}
pub fn is_empty(&self) -> bool {
self.batch.ops.is_empty()
}
pub fn has_resource(&self, resource: ResourceId) -> bool {
self.batch
.ops
.iter()
.any(|op| op_targets_resource(op, resource))
}
/// Assert against the same generated target object application handlers use.
pub fn has_target(&self, target: impl GeneratedTarget) -> bool {
self.has_resource(target.__hemx_resource_id())
}
/// Check that a generated target receives a text update, without matching raw effects.
pub fn updates_text(&self, target: impl GeneratedTarget) -> bool {
self.has_text_update_containing(target.__hemx_resource_id(), "")
}
/// Check that a generated target receives a text update containing a fragment.
///
/// This associates the payload condition with the intended target, unlike a separate global
/// [`Self::payload_contains`] check that can accidentally match another operation.
pub fn updates_text_containing(&self, target: impl GeneratedTarget, needle: &str) -> bool {
self.has_text_update_containing(target.__hemx_resource_id(), needle)
}
/// Assert that a generated target receives a text update containing a fragment.
#[track_caller]
pub fn assert_updates_text_containing(&self, target: impl GeneratedTarget, needle: &str) {
let resource = target.__hemx_resource_id();
assert!(
self.has_text_update_containing(resource, needle),
"expected a text update for {resource:?} containing {needle:?}; actual effects: {:#?}",
self.batch.ops
);
}
fn has_text_update_containing(&self, resource: ResourceId, needle: &str) -> bool {
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Patch { target, html, .. }
if target.resource == resource && html.as_str().contains(needle)
)
})
}
/// Assert that a generated target receives an HTML update, without matching raw effects.
pub fn updates_html(&self, target: impl GeneratedTarget) -> bool {
let resource = target.__hemx_resource_id();
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Patch { target, .. } | Effect::Insert { target, .. }
if target.resource == resource
)
})
}
/// Check that a generated target receives an HTML update containing text.
pub fn updates_html_containing(&self, target: impl GeneratedTarget, needle: &str) -> bool {
self.has_html_update_containing(target.__hemx_resource_id(), needle)
}
/// Assert that a generated target receives an HTML update containing text.
///
/// Unlike wrapping [`Self::updates_html_containing`] in `assert!`, failures include the
/// expected resource and payload fragment together with every actual effect operation.
#[track_caller]
pub fn assert_updates_html_containing(&self, target: impl GeneratedTarget, needle: &str) {
let resource = target.__hemx_resource_id();
assert!(
self.has_html_update_containing(resource, needle),
"expected an HTML update for {resource:?} containing {needle:?}; actual effects: {:#?}",
self.batch.ops
);
}
fn has_html_update_containing(&self, resource: ResourceId, needle: &str) -> bool {
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Patch { target, html, .. } | Effect::Insert { target, html, .. }
if target.resource == resource && html.as_str().contains(needle)
)
})
}
/// Parse the single HTML effect for a generated target as a complete document.
///
/// Returns an error when no HTML operation targets the resource or when more
/// than one operation would make the selected payload ambiguous.
pub fn target_html_document(
&self,
target: impl GeneratedTarget,
) -> Result<HtmlInspector, HtmlInspectionError> {
let resource = target.__hemx_resource_id();
let (html, operation) = self.single_target_html(resource)?;
Ok(HtmlInspector::document(
html.to_owned(),
format!("{operation} HTML effect for generated target {resource:?}"),
))
}
/// Parse the single HTML effect for a generated target as a fragment.
///
/// `Put`, `Insert`, and `Prepend` HTML payloads are supported. The parser is
/// kept internal; the returned inspector and all selected elements own their
/// observable data.
pub fn target_html_fragment(
&self,
target: impl GeneratedTarget,
) -> Result<HtmlInspector, HtmlInspectionError> {
let resource = target.__hemx_resource_id();
let (html, operation) = self.single_target_html(resource)?;
Ok(HtmlInspector::fragment(
html.to_owned(),
format!("{operation} HTML effect for generated target {resource:?}"),
))
}
fn single_target_html(
&self,
resource: ResourceId,
) -> Result<(&str, &'static str), HtmlInspectionError> {
let target_effects = self
.batch
.ops
.iter()
.filter(|effect| op_targets_resource(effect, resource))
.collect::<Vec<_>>();
let html_effects = target_effects
.iter()
.filter_map(|effect| match effect {
Effect::Patch { html, .. } => Some((html.as_str(), "Patch")),
Effect::Insert { html, .. } => Some((html.as_str(), "Insert")),
_ => None,
})
.collect::<Vec<_>>();
match html_effects.as_slice() {
[only] => Ok(*only),
[] => {
let actual = if target_effects.is_empty() {
format!("all effects: {:#?}", self.batch.ops)
} else {
format!("effects for target: {target_effects:#?}")
};
Err(HtmlInspectionError::new(format!(
"expected one HTML effect for generated target {resource:?}, but found none; {actual}"
)))
}
many => Err(HtmlInspectionError::new(format!(
"expected one HTML effect for generated target {resource:?}, but found {} and cannot choose a document or fragment payload; effects for target: {target_effects:#?}",
many.len()
))),
}
}
/// Assert that a keyed generated target is replaced with HTML containing text.
pub fn replaces_keyed_html_containing(
&self,
target: impl GeneratedTarget,
key: impl ToString,
needle: &str,
) -> bool {
let resource = target.__hemx_resource_id();
let scope = Some(ScopeKey::KeyValue(key.to_string()));
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Patch { target, html, .. }
if target.resource == resource && target.scope == scope && html.as_str().contains(needle)
)
})
}
/// Assert that a keyed generated target appends HTML containing text.
pub fn inserts_html_containing(
&self,
target: impl GeneratedTarget,
key: impl ToString,
needle: &str,
) -> bool {
let resource = target.__hemx_resource_id();
let key_marker = format!("data-hemx-key=\"{}\"", key.to_string());
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Insert { target, html, .. }
if target.resource == resource
&& html.as_str().contains(&key_marker)
&& html.as_str().contains(needle)
)
})
}
/// Assert that a keyed generated target removes a key.
pub fn removes_key(&self, target: impl GeneratedTarget, key: impl ToString) -> bool {
let resource = target.__hemx_resource_id();
let key = key.to_string();
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Remove { target }
if target.resource == resource && target.scope == Some(ScopeKey::KeyValue(key.clone()))
)
})
}
/// Assert that the batch requests a push navigation to a URL.
pub fn pushes_to(&self, url: &str) -> bool {
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Visit { url: actual_url, .. } if actual_url == url
)
})
}
/// Assert that any payload or URL contains text, without matching raw effects.
pub fn payload_contains(&self, needle: &str) -> bool {
self.batch
.ops
.iter()
.any(|op| effect_payload_contains(op, needle))
}
/// Assert that no payload or URL contains text, without matching raw effects.
pub fn payload_excludes(&self, needle: &str) -> bool {
self.batch
.ops
.iter()
.all(|op| !effect_payload_contains(op, needle))
}
/// Assert that generated keyed-row metadata for a key is absent from payloads.
pub fn payload_excludes_key(&self, key: impl ToString) -> bool {
self.payload_excludes(&format!("data-hemx-key=\"{}\"", key.to_string()))
}
/// Return HTML for a generated target containing text, without exposing raw payloads.
pub fn target_html_containing(
&self,
target: impl GeneratedTarget,
needle: &str,
) -> Option<&str> {
let resource = target.__hemx_resource_id();
self.batch.ops.iter().find_map(|op| match op {
Effect::Patch { target, html, .. }
if target.resource == resource && html.as_str().contains(needle) =>
{
Some(html.as_str())
}
Effect::Insert { target, html, .. }
if target.resource == resource && html.as_str().contains(needle) =>
{
Some(html.as_str())
}
_ => None,
})
}
/// Assert that a named generated event is emitted with the exact payload.
pub fn emits(&self, name: &str, payload: &str) -> bool {
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Dispatch { event: actual_name, payload: actual_payload }
if actual_name == name && actual_payload.as_slice() == payload.as_bytes()
)
})
}
/// Assert that a named generated event payload contains text.
pub fn emits_containing(&self, name: &str, needle: &str) -> bool {
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Dispatch { event: actual_name, payload }
if actual_name == name && String::from_utf8_lossy(payload).contains(needle)
)
})
}
pub fn has_ref(&self, target: &ResourceRef) -> bool {
self.batch.ops.iter().any(|op| op_targets_ref(op, target))
}
pub fn has_slot<T>(&self, slot: Slot<T>) -> bool {
self.has_resource(slot.id())
}
pub fn has_keyed_slot<K, T>(&self, slot: KeyedSlot<K, T>) -> bool
where
K: ToString,
{
self.has_resource(slot.id())
}
pub fn has_atom<T>(&self, atom: Atom<T>) -> bool {
self.has_resource(atom.id())
}
pub fn has_form<T>(&self, form: Form<T>) -> bool {
self.has_resource(form.id())
}
}
fn effect_payload_contains(op: &Effect, needle: &str) -> bool {
match op {
Effect::Patch { html, .. } | Effect::Insert { html, .. } => html.as_str().contains(needle),
Effect::Dispatch { payload, .. } => String::from_utf8_lossy(payload).contains(needle),
Effect::Visit { url, .. } => url.contains(needle),
Effect::Remove { .. }
| Effect::Move { .. }
| Effect::Focus { .. }
| Effect::Scroll { .. } => false,
}
}
fn op_targets_resource(op: &Effect, resource: ResourceId) -> bool {
match op {
Effect::Patch { target, .. }
| Effect::Insert { target, .. }
| Effect::Remove { target }
| Effect::Move { target, .. }
| Effect::Focus { target, .. }
| Effect::Scroll { target, .. } => target.resource == resource,
Effect::Visit { .. } | Effect::Dispatch { .. } => false,
}
}
fn op_targets_ref(op: &Effect, wanted: &ResourceRef) -> bool {
match op {
Effect::Patch { target, .. }
| Effect::Insert { target, .. }
| Effect::Remove { target }
| Effect::Move { target, .. }
| Effect::Focus { target, .. }
| Effect::Scroll { target, .. } => target == wanted,
Effect::Visit { .. } | Effect::Dispatch { .. } => false,
}
}