Nuevo método `oob()` para incluir fragmentos adicionales marcados con `hx-swap-oob` en el cuerpo de la respuesta, sin necesidad de construir el envoltorio a mano ni depender del `hx-target` de la petición. De paso, `set_header()` usa ya las constantes de `hx::response` en vez de literales repetidos, y se revisan comentarios.
277 lines
8.9 KiB
Rust
277 lines
8.9 KiB
Rust
use pagetop::prelude::*;
|
|
use pagetop_htmx::prelude::*;
|
|
|
|
// Forces an effective language different from the default negotiated one (en-US, with no `?lang` in
|
|
// the request), so that `Context::route()` decides to propagate `?lang=...` in local routes.
|
|
fn cx_with_lang(lang: &str) -> Context {
|
|
Context::new(None).with_langid(&Locale::resolve(lang))
|
|
}
|
|
|
|
fn header<'a>(response: &'a web::Response, name: &str) -> Option<&'a str> {
|
|
response.headers().get(name)?.to_str().ok()
|
|
}
|
|
|
|
// **< HtmxResponse::new() / empty() / oob() >******************************************************
|
|
|
|
#[pagetop::test]
|
|
async fn new_renders_the_given_markup_with_an_html_content_type() {
|
|
let response = HtmxResponse::new(html! { li #item-42 { "New item" } }).into_response();
|
|
|
|
assert_eq!(
|
|
header(&response, "content-type"),
|
|
Some("text/html; charset=utf-8")
|
|
);
|
|
|
|
let body = web::test::read_body_text(response).await;
|
|
assert_eq!(body, r#"<li id="item-42">New item</li>"#);
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn empty_has_no_body_but_keeps_the_html_content_type() {
|
|
let response = HtmxResponse::empty().into_response();
|
|
|
|
assert_eq!(
|
|
header(&response, "content-type"),
|
|
Some("text/html; charset=utf-8")
|
|
);
|
|
|
|
let body = web::test::read_body_text(response).await;
|
|
assert_eq!(body, "");
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn oob_appends_markup_after_the_main_body() {
|
|
let response = HtmxResponse::new(html! { li #item-42 { "New item" } })
|
|
.oob(html! { span #item-count hx-swap-oob="true" { "1" } })
|
|
.into_response();
|
|
|
|
let body = web::test::read_body_text(response).await;
|
|
assert_eq!(
|
|
body,
|
|
concat!(
|
|
r#"<li id="item-42">New item</li>"#,
|
|
r#"<span id="item-count" hx-swap-oob="true">1</span>"#,
|
|
)
|
|
);
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn oob_can_be_called_several_times_to_accumulate_fragments() {
|
|
let response = HtmxResponse::new(html! { p { "Main" } })
|
|
.oob(html! { span #a hx-swap-oob="true" { "A" } })
|
|
.oob(html! { span #b hx-swap-oob="true" { "B" } })
|
|
.into_response();
|
|
|
|
let body = web::test::read_body_text(response).await;
|
|
assert_eq!(
|
|
body,
|
|
concat!(
|
|
"<p>Main</p>",
|
|
r#"<span id="a" hx-swap-oob="true">A</span>"#,
|
|
r#"<span id="b" hx-swap-oob="true">B</span>"#,
|
|
)
|
|
);
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn oob_works_from_an_empty_response() {
|
|
let response = HtmxResponse::empty()
|
|
.oob(html! { span #item-count hx-swap-oob="true" { "0" } })
|
|
.into_response();
|
|
|
|
let body = web::test::read_body_text(response).await;
|
|
assert_eq!(body, r#"<span id="item-count" hx-swap-oob="true">0</span>"#);
|
|
}
|
|
|
|
// **< location() / location_json() >***************************************************************
|
|
|
|
#[pagetop::test]
|
|
async fn location_sets_hx_location_from_a_route_path() {
|
|
let response = HtmxResponse::empty().location("/items").into_response();
|
|
|
|
assert_eq!(header(&response, "hx-location"), Some("/items"));
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn location_preserves_lang_when_built_from_context_route() {
|
|
let cx = cx_with_lang("es-ES");
|
|
|
|
let response = HtmxResponse::empty()
|
|
.location(cx.route("/items"))
|
|
.into_response();
|
|
|
|
assert_eq!(header(&response, "hx-location"), Some("/items?lang=es-ES"));
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn location_json_sets_hx_location_when_the_json_is_syntactically_valid() {
|
|
let json = r##"{"path": "/items", "target": "#content"}"##;
|
|
|
|
let response = HtmxResponse::empty().location_json(json).into_response();
|
|
|
|
assert_eq!(header(&response, "hx-location"), Some(json));
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn location_json_discards_the_header_when_the_json_is_malformed() {
|
|
// Missing closing brace: invalid JSON. The header must be silently dropped rather than sending
|
|
// a broken payload to the client.
|
|
let response = HtmxResponse::empty()
|
|
.location_json(r##"{"path": "/items""##)
|
|
.into_response();
|
|
|
|
assert_eq!(header(&response, "hx-location"), None);
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn location_json_only_validates_syntax_not_the_expected_keys() {
|
|
// A key HTMX does not recognize (`"tagret"` instead of `"target"`) is still valid JSON, so it
|
|
// passes this check; the mistake would only surface client-side. This documents that limit.
|
|
let json = r##"{"path": "/items", "tagret": "#content"}"##;
|
|
|
|
let response = HtmxResponse::empty().location_json(json).into_response();
|
|
|
|
assert_eq!(header(&response, "hx-location"), Some(json));
|
|
}
|
|
|
|
// **< push_url() / replace_url() / redirect() >****************************************************
|
|
|
|
#[pagetop::test]
|
|
async fn push_url_sets_hx_push_url_from_a_route_path() {
|
|
let cx = cx_with_lang("es-ES");
|
|
|
|
let response = HtmxResponse::empty()
|
|
.push_url(cx.route("/items"))
|
|
.into_response();
|
|
|
|
assert_eq!(header(&response, "hx-push-url"), Some("/items?lang=es-ES"));
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn push_url_accepts_the_false_sentinel_to_disable_pushing() {
|
|
let response = HtmxResponse::empty().push_url("false").into_response();
|
|
|
|
assert_eq!(header(&response, "hx-push-url"), Some("false"));
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn replace_url_sets_hx_replace_url_from_a_route_path() {
|
|
let response = HtmxResponse::empty()
|
|
.replace_url("/items/42")
|
|
.into_response();
|
|
|
|
assert_eq!(header(&response, "hx-replace-url"), Some("/items/42"));
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn redirect_sets_hx_redirect_from_a_route_path() {
|
|
let cx = cx_with_lang("es-ES");
|
|
|
|
let response = HtmxResponse::empty()
|
|
.redirect(cx.route("/items"))
|
|
.into_response();
|
|
|
|
assert_eq!(header(&response, "hx-redirect"), Some("/items?lang=es-ES"));
|
|
}
|
|
|
|
// **< refresh() / retarget() / reswap() / reselect() >*********************************************
|
|
|
|
#[pagetop::test]
|
|
async fn refresh_sets_hx_refresh_to_true() {
|
|
let response = HtmxResponse::empty().refresh().into_response();
|
|
|
|
assert_eq!(header(&response, "hx-refresh"), Some("true"));
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn retarget_reswap_and_reselect_set_the_expected_headers() {
|
|
let response = HtmxResponse::empty()
|
|
.retarget("#message")
|
|
.reswap(hx::swap::BEFORE_END)
|
|
.reselect("#fragment")
|
|
.into_response();
|
|
|
|
assert_eq!(header(&response, "hx-retarget"), Some("#message"));
|
|
assert_eq!(header(&response, "hx-reswap"), Some("beforeend"));
|
|
assert_eq!(header(&response, "hx-reselect"), Some("#fragment"));
|
|
}
|
|
|
|
// **< trigger() / trigger_after_settle() / trigger_after_swap() >**********************************
|
|
|
|
#[pagetop::test]
|
|
async fn trigger_accepts_a_single_event_name() {
|
|
let response = HtmxResponse::empty().trigger("itemAdded").into_response();
|
|
|
|
assert_eq!(header(&response, "hx-trigger"), Some("itemAdded"));
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn trigger_accepts_multiple_comma_separated_events() {
|
|
let response = HtmxResponse::empty()
|
|
.trigger("itemAdded, listUpdated")
|
|
.into_response();
|
|
|
|
assert_eq!(
|
|
header(&response, "hx-trigger"),
|
|
Some("itemAdded, listUpdated")
|
|
);
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn trigger_accepts_a_json_payload_with_event_data() {
|
|
let json = r#"{"itemAdded": {"id": 42, "name": "Example"}}"#;
|
|
|
|
let response = HtmxResponse::empty().trigger(json).into_response();
|
|
|
|
assert_eq!(header(&response, "hx-trigger"), Some(json));
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn trigger_after_settle_and_trigger_after_swap_use_their_own_headers() {
|
|
let response = HtmxResponse::empty()
|
|
.trigger_after_settle("settled")
|
|
.trigger_after_swap("swapped")
|
|
.into_response();
|
|
|
|
assert_eq!(
|
|
header(&response, "hx-trigger-after-settle"),
|
|
Some("settled")
|
|
);
|
|
assert_eq!(header(&response, "hx-trigger-after-swap"), Some("swapped"));
|
|
}
|
|
|
|
// **< Builder chaining behavior >******************************************************************
|
|
|
|
#[pagetop::test]
|
|
async fn chaining_several_methods_sets_all_their_headers_at_once() {
|
|
let response = HtmxResponse::new(html! { ul { li { "Item 1" } li { "Item 2" } } })
|
|
.retarget("#list")
|
|
.reswap(hx::swap::BEFORE_END)
|
|
.push_url("/items")
|
|
.trigger("itemAdded")
|
|
.into_response();
|
|
|
|
assert_eq!(header(&response, "hx-retarget"), Some("#list"));
|
|
assert_eq!(header(&response, "hx-reswap"), Some("beforeend"));
|
|
assert_eq!(header(&response, "hx-push-url"), Some("/items"));
|
|
assert_eq!(header(&response, "hx-trigger"), Some("itemAdded"));
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn calling_the_same_method_twice_the_last_call_wins() {
|
|
let response = HtmxResponse::empty()
|
|
.trigger("first")
|
|
.trigger("second")
|
|
.into_response();
|
|
|
|
assert_eq!(header(&response, "hx-trigger"), Some("second"));
|
|
}
|
|
|
|
#[pagetop::test]
|
|
async fn a_header_value_with_control_characters_is_silently_discarded() {
|
|
// `\n` is forbidden in an HTTP header value; `set_header()` must drop it rather than panicking
|
|
// or producing a malformed response.
|
|
let response = HtmxResponse::empty().retarget("foo\nbar").into_response();
|
|
|
|
assert_eq!(header(&response, "hx-retarget"), None);
|
|
}
|