♻️ (html): Migra Classes/ClassesOp a Props/PropsOp

Introduce `Props`/`PropsOp` para gestionar pares `atributo="valor"` y
clases CSS para aplicar en componentes.

- Constructores `Props::new()`, `Props::classes()` y `Props::default()`.
- `Page.body_classes` reemplazado por `body_props` (permite atributos
  arbitrarios en `<body>`, no sólo clases).
- Tests nuevos para atributos y reescritos para clases.
This commit is contained in:
Manuel Cillero 2026-06-12 01:55:07 +02:00
parent 0121fad94a
commit f9e87058d8
8 changed files with 707 additions and 319 deletions

View file

@ -1,7 +1,7 @@
use pagetop::prelude::*;
fn assert_classes(c: &Classes, expected: Option<&str>) {
let got = c.get();
fn assert_classes(p: &Props, expected: Option<&str>) {
let got = p.get_classes();
assert_eq!(
got.as_deref(),
expected,
@ -15,176 +15,154 @@ fn assert_classes(c: &Classes, expected: Option<&str>) {
#[pagetop::test]
async fn classes_new_empty_and_whitespace_is_empty() {
assert_classes(&Classes::new(""), None);
assert_classes(&Classes::new(" "), None);
assert_classes(&Classes::new("\t\n\r "), None);
assert_classes(&Props::classes(""), None);
assert_classes(&Props::classes(" "), None);
assert_classes(&Props::classes("\t\n\r "), None);
}
#[pagetop::test]
async fn classes_new_normalizes_and_dedups_and_preserves_first_occurrence_order() {
let c = Classes::new("Btn btn BTN btn-primary BTN-PRIMARY");
assert_classes(&c, Some("btn btn-primary"));
assert!(c.contains("BTN"));
assert!(c.contains("btn-primary"));
let p = Props::classes("Btn btn BTN btn-primary BTN-PRIMARY");
assert_classes(&p, Some("btn btn-primary"));
assert!(p.has_class("BTN"));
assert!(p.has_class("btn-primary"));
}
#[pagetop::test]
async fn classes_get_returns_none_when_empty_some_when_not() {
assert_classes(&Classes::new(" "), None);
assert_classes(&Classes::new("a"), Some("a"));
assert_classes(&Props::classes(" "), None);
assert_classes(&Props::classes("a"), Some("a"));
}
// **< Basic operations (add/prepend/set) >*********************************************************
#[pagetop::test]
async fn classes_add_appends_unique_and_normalizes() {
let c = Classes::new("a b").with_classes(ClassesOp::Add, "C b D");
assert_classes(&c, Some("a b c d"));
let p = Props::classes("a b").with_prop(PropsOp::add_classes("C b D"));
assert_classes(&p, Some("a b c d"));
}
#[pagetop::test]
async fn classes_add_ignores_empty_input() {
let c = Classes::new("a b").with_classes(ClassesOp::Add, " \t");
assert_classes(&c, Some("a b"));
let p = Props::classes("a b").with_prop(PropsOp::add_classes(" \t"));
assert_classes(&p, Some("a b"));
}
#[pagetop::test]
async fn classes_add_same_tokens() {
let c = Classes::new("a b").with_classes(ClassesOp::Add, "A B a b");
assert_classes(&c, Some("a b"));
let p = Props::classes("a b").with_prop(PropsOp::add_classes("A B a b"));
assert_classes(&p, Some("a b"));
}
#[pagetop::test]
async fn classes_add_rejects_non_ascii_is_noop() {
let c = Classes::new("a b").with_classes(ClassesOp::Add, "c ñ d");
assert_classes(&c, Some("a b"));
let p = Props::classes("a b").with_prop(PropsOp::add_classes("c ñ d"));
assert_classes(&p, Some("a b"));
}
#[pagetop::test]
async fn classes_prepend_inserts_at_front_preserving_new_order() {
let c = Classes::new("c d").with_classes(ClassesOp::Prepend, "A b");
assert_classes(&c, Some("a b c d"));
let p = Props::classes("c d").with_prop(PropsOp::prepend_classes("A b"));
assert_classes(&p, Some("a b c d"));
}
#[pagetop::test]
async fn classes_prepend_inserts_new_tokens_skipping_duplicates() {
let c = Classes::new("b c").with_classes(ClassesOp::Prepend, "a b d");
assert_classes(&c, Some("a d b c"));
let p = Props::classes("b c").with_prop(PropsOp::prepend_classes("a b d"));
assert_classes(&p, Some("a d b c"));
}
#[pagetop::test]
async fn classes_prepend_ignores_empty_input() {
let c = Classes::new("a b").with_classes(ClassesOp::Prepend, "");
assert_classes(&c, Some("a b"));
let p = Props::classes("a b").with_prop(PropsOp::prepend_classes(""));
assert_classes(&p, Some("a b"));
}
#[pagetop::test]
async fn classes_reset_replaces_entire_list_and_dedups() {
let c = Classes::new("a b c").with_classes(ClassesOp::Reset, "X y y Z");
assert_classes(&c, Some("x y z"));
let p = Props::classes("a b c").with_prop(PropsOp::set("class", "X y y Z"));
assert_classes(&p, Some("x y z"));
}
#[pagetop::test]
async fn classes_reset_with_empty_input_clears() {
let base = Classes::new("a b");
let c = base.with_classes(ClassesOp::Reset, " \n ");
assert_classes(&c, None);
let p = Props::classes("a b").with_prop(PropsOp::set("class", " \n "));
assert_classes(&p, None);
}
// **< Mutation operations (remove/toggle) >********************************************************
#[pagetop::test]
async fn classes_reset_with_non_ascii_is_noop() {
let p = Props::classes("a b").with_prop(PropsOp::set("class", "ñ"));
assert_classes(&p, Some("a b"));
}
// **< Mutation operations (remove) >***************************************************************
#[pagetop::test]
async fn classes_remove_is_case_insensitive() {
let c = Classes::new("a b c d").with_classes(ClassesOp::Remove, "B D");
assert_classes(&c, Some("a c"));
let p = Props::classes("a b c d").with_prop(PropsOp::remove_classes("B D"));
assert_classes(&p, Some("a c"));
}
#[pagetop::test]
async fn classes_remove_non_existing_is_noop() {
let c = Classes::new("a b c").with_classes(ClassesOp::Remove, "x y z");
assert_classes(&c, Some("a b c"));
let p = Props::classes("a b c").with_prop(PropsOp::remove_classes("x y z"));
assert_classes(&p, Some("a b c"));
}
#[pagetop::test]
async fn classes_remove_with_extra_whitespace() {
let c = Classes::new("a b c d").with_classes(ClassesOp::Remove, " b\t\t \n d ");
assert_classes(&c, Some("a c"));
}
#[pagetop::test]
async fn classes_toggle_removes_if_present_case_insensitive() {
let c = Classes::new("a b c").with_classes(ClassesOp::Toggle, "B");
assert_classes(&c, Some("a c"));
}
#[pagetop::test]
async fn classes_toggle_adds_if_missing_and_normalizes() {
let c = Classes::new("a b").with_classes(ClassesOp::Toggle, "C");
assert_classes(&c, Some("a b c"));
}
#[pagetop::test]
async fn classes_toggle_multiple_tokens_is_sequential_and_order_dependent() {
let c = Classes::new("a b").with_classes(ClassesOp::Toggle, "C B A");
assert_classes(&c, Some("c"));
}
#[pagetop::test]
async fn classes_toggle_duplicate_tokens_are_applied_sequentially() {
let c = Classes::new("b").with_classes(ClassesOp::Toggle, "a a");
assert_classes(&c, Some("b"));
let c = Classes::new("a b").with_classes(ClassesOp::Toggle, "a a");
assert_classes(&c, Some("b a"));
let p = Props::classes("a b c d").with_prop(PropsOp::remove_classes(" b\t\t \n d "));
assert_classes(&p, Some("a c"));
}
// **< Queries (contains) >*************************************************************************
#[pagetop::test]
async fn classes_contains_single() {
let c = Classes::new("btn btn-primary");
assert!(c.contains("btn"));
assert!(c.contains("BTN"));
assert!(!c.contains("missing"));
let p = Props::classes("btn btn-primary");
assert!(p.has_class("btn"));
assert!(p.has_class("BTN"));
assert!(!p.has_class("missing"));
}
#[pagetop::test]
async fn classes_contains_all_and_any() {
let c = Classes::new("btn btn-primary active");
let p = Props::classes("btn btn-primary active");
assert!(c.contains("btn active"));
assert!(c.contains("BTN BTN-PRIMARY"));
assert!(!c.contains("btn missing"));
assert!(p.has_class("btn active"));
assert!(p.has_class("BTN BTN-PRIMARY"));
assert!(!p.has_class("btn missing"));
assert!(c.contains_any("missing active"));
assert!(c.contains_any("BTN-PRIMARY missing"));
assert!(!c.contains_any("missing other"));
assert!(p.has_any_class("missing active"));
assert!(p.has_any_class("BTN-PRIMARY missing"));
assert!(!p.has_any_class("missing other"));
}
#[pagetop::test]
async fn classes_contains_empty_and_whitespace_is_false() {
let c = Classes::new("a b");
assert!(!c.contains(""));
assert!(!c.contains(" \t"));
assert!(!c.contains_any(""));
assert!(!c.contains_any(" \n "));
let p = Props::classes("a b");
assert!(!p.has_class(""));
assert!(!p.has_class(" \t"));
assert!(!p.has_any_class(""));
assert!(!p.has_any_class(" \n "));
}
#[pagetop::test]
async fn classes_contains_non_ascii_is_false() {
let c = Classes::new("a b");
assert!(!c.contains("ñ"));
assert!(!c.contains_any("a ñ"));
let p = Props::classes("a b");
assert!(!p.has_class("ñ"));
assert!(!p.has_any_class("a ñ"));
}
// **< Properties / regression (combined sequences, ordering) >*************************************
#[pagetop::test]
async fn classes_order_is_stable_for_existing_items() {
let c = Classes::new("a b c")
.with_classes(ClassesOp::Add, "d") // a b c d
.with_classes(ClassesOp::Prepend, "x") // x a b c d
.with_classes(ClassesOp::Remove, "b") // x a c d
.with_classes(ClassesOp::Add, "b"); // x a c d b
assert_classes(&c, Some("x a c d b"));
let p = Props::classes("a b c")
.with_prop(PropsOp::add_classes("d")) // a b c d
.with_prop(PropsOp::prepend_classes("x")) // x a b c d
.with_prop(PropsOp::remove_classes("b")) // x a c d
.with_prop(PropsOp::add_classes("b")); // x a c d b
assert_classes(&p, Some("x a c d b"));
}

304
tests/html_props.rs Normal file
View file

@ -0,0 +1,304 @@
use pagetop::prelude::*;
// **< Construction & invariants >******************************************************************
#[pagetop::test]
async fn props_default_renders_nothing() {
assert_eq!(
html! { span (Props::default()) {} }.into_string(),
"<span></span>"
);
}
#[pagetop::test]
async fn props_new_creates_first_attr() {
let p = Props::new("hx-get", "/api");
assert_eq!(p.get_prop("hx-get"), Some("/api"));
}
#[pagetop::test]
async fn props_get_missing_key_returns_none() {
let p = Props::new("hx-get", "/api");
assert_eq!(p.get_prop("hx-post"), None);
assert_eq!(p.get_prop(""), None);
}
// **< Props::classes >*****************************************************************************
#[pagetop::test]
async fn props_classes_renders_class_attribute() {
let p = Props::classes("btn btn-primary");
assert_eq!(
html! { button (p) { "OK" } }.into_string(),
r#"<button class="btn btn-primary">OK</button>"#
);
}
#[pagetop::test]
async fn props_classes_empty_input_renders_no_class_attribute() {
let p = Props::classes(" ");
assert_eq!(
html! { button (p) { "OK" } }.into_string(),
"<button>OK</button>"
);
}
#[pagetop::test]
async fn props_classes_can_be_extended_with_with_prop() {
let p = Props::classes("btn").with_prop(PropsOp::add_classes("active"));
assert_eq!(
html! { button (p) { "OK" } }.into_string(),
r#"<button class="btn active">OK</button>"#
);
}
// **< PropsOp::set >*******************************************************************************
#[pagetop::test]
async fn props_set_adds_new_attrs() {
let p = Props::default()
.with_prop(PropsOp::set("hx-get", "/api"))
.with_prop(PropsOp::set("hx-swap", "outerHTML"));
assert_eq!(p.get_prop("hx-get"), Some("/api"));
assert_eq!(p.get_prop("hx-swap"), Some("outerHTML"));
}
#[pagetop::test]
async fn props_set_replaces_existing_value() {
let p = Props::new("hx-get", "/old").with_prop(PropsOp::set("hx-get", "/new"));
assert_eq!(p.get_prop("hx-get"), Some("/new"));
}
#[pagetop::test]
async fn props_set_does_not_create_duplicate_key() {
// Reasignar la misma clave debe reemplazar el valor, no añadir una entrada duplicada.
let p = Props::new("key", "v1").with_prop(PropsOp::set("key", "v2"));
assert_eq!(
html! { span (p) {} }.into_string(),
r#"<span key="v2"></span>"#
);
}
#[pagetop::test]
async fn props_set_preserves_insertion_order() {
let p = Props::new("a", "1")
.with_prop(PropsOp::set("b", "2"))
.with_prop(PropsOp::set("c", "3"));
assert_eq!(
html! { span (p) {} }.into_string(),
r#"<span a="1" b="2" c="3"></span>"#
);
}
// **< PropsOp::remove >****************************************************************************
#[pagetop::test]
async fn props_remove_existing_attr() {
let p = Props::new("a", "1")
.with_prop(PropsOp::set("b", "2"))
.with_prop(PropsOp::remove("a"));
assert_eq!(p.get_prop("a"), None);
assert_eq!(p.get_prop("b"), Some("2"));
}
#[pagetop::test]
async fn props_remove_nonexistent_key_is_noop() {
let p = Props::new("a", "1").with_prop(PropsOp::remove("missing"));
assert_eq!(p.get_prop("a"), Some("1"));
assert_eq!(p.get_prop("missing"), None);
}
#[pagetop::test]
async fn props_renders_nothing_after_removing_last_attr() {
let p = Props::new("only", "one").with_prop(PropsOp::remove("only"));
assert_eq!(html! { span (p) {} }.into_string(), "<span></span>");
}
// **< HTML Escaped >*******************************************************************************
#[pagetop::test]
async fn props_escapes_ampersand_and_angle_brackets_in_value() {
let p = Props::new("data-info", "a&b<c>d");
assert_eq!(
html! { span (p) {} }.into_string(),
r#"<span data-info="a&amp;b&lt;c&gt;d"></span>"#
);
}
#[pagetop::test]
async fn props_escapes_double_quotes_in_value() {
let p = Props::new("data-label", r#"say "hello""#);
assert_eq!(
html! { span (p) {} }.into_string(),
r#"<span data-label="say &quot;hello&quot;"></span>"#
);
}
// **< Integration with html! >*********************************************************************
#[pagetop::test]
async fn props_empty_in_html_macro_produces_no_attributes() {
// Una Props vacía no debe emitir ni siquiera un espacio en blanco extra.
let p = Props::default();
assert_eq!(
html! { button (p) { "x" } }.into_string(),
"<button>x</button>"
);
}
#[pagetop::test]
async fn props_single_attr_in_html_macro() {
let p = Props::new("hx-get", "/api");
assert_eq!(
html! { button (p) { "Load" } }.into_string(),
r#"<button hx-get="/api">Load</button>"#
);
}
#[pagetop::test]
async fn props_multiple_attrs_preserve_order_in_html_macro() {
let p = Props::new("hx-get", "/api")
.with_prop(PropsOp::set("hx-target", "#result"))
.with_prop(PropsOp::set("hx-swap", "outerHTML"));
assert_eq!(
html! { button (p) {} }.into_string(),
r##"<button hx-get="/api" hx-target="#result" hx-swap="outerHTML"></button>"##
);
}
#[pagetop::test]
async fn props_alongside_class_and_id_in_html_macro() {
// El splice siempre se emite después de class e id, independientemente del orden escrito.
let p = Props::new("hx-get", "/api");
assert_eq!(
html! { button #mybtn .btn (p) { "Go" } }.into_string(),
r#"<button class="btn" id="mybtn" hx-get="/api">Go</button>"#
);
}
#[pagetop::test]
async fn props_alongside_named_attr_renders_after_it() {
let p = Props::new("hx-get", "/api");
assert_eq!(
html! { button type="button" (p) {} }.into_string(),
r#"<button type="button" hx-get="/api"></button>"#
);
}
#[pagetop::test]
async fn props_multiple_splices_in_same_element() {
let p1 = Props::new("hx-get", "/api");
let p2 = Props::new("hx-swap", "outerHTML");
assert_eq!(
html! { button (p1) (p2) {} }.into_string(),
r#"<button hx-get="/api" hx-swap="outerHTML"></button>"#
);
}
#[pagetop::test]
async fn props_inline_construction_in_html_macro() {
assert_eq!(
html! { button (Props::new("hx-get", "/api")) { "Go" } }.into_string(),
r#"<button hx-get="/api">Go</button>"#
);
}
#[pagetop::test]
async fn props_conditional_expression_in_html_macro() {
for (active, expected) in [
(true, r#"<button hx-get="/api">x</button>"#),
(false, "<button>x</button>"),
] {
let markup = html! {
button (if active { Props::new("hx-get", "/api") } else { Props::default() }) { "x" }
};
assert_eq!(markup.into_string(), expected);
}
}
#[pagetop::test]
async fn props_splice_empty_string_emits_nothing() {
// Un splice vacío no emite ningún atributo ni espacio extra.
assert_eq!(html! { span ("") { "x" } }.into_string(), "<span>x</span>");
}
// **< is_props_empty / is_classes_empty >**********************************************************
#[pagetop::test]
async fn props_is_props_empty_on_default() {
assert!(Props::default().is_props_empty());
}
#[pagetop::test]
async fn props_is_props_empty_false_after_set() {
assert!(!Props::new("hx-get", "/api").is_props_empty());
}
#[pagetop::test]
async fn props_is_props_empty_true_after_removing_last_attr() {
let p = Props::new("only", "one").with_prop(PropsOp::remove("only"));
assert!(p.is_props_empty());
}
#[pagetop::test]
async fn props_is_classes_empty_on_default() {
assert!(Props::default().is_classes_empty());
}
#[pagetop::test]
async fn props_is_classes_empty_false_after_add_classes() {
assert!(!Props::classes("btn").is_classes_empty());
}
#[pagetop::test]
async fn props_is_classes_empty_true_after_remove_class() {
let p = Props::classes("btn").with_prop(PropsOp::remove("class"));
assert!(p.is_classes_empty());
}
// **< Regression & edge cases >********************************************************************
#[pagetop::test]
async fn props_hx_target_value_with_hash_renders_correctly() {
// Regresión: r#"..."# se cerraba prematuramente al encontrar `"#lista"`.
let p = Props::new("hx-target", "#list");
assert_eq!(
html! { button (p) {} }.into_string(),
r##"<button hx-target="#list"></button>"##
);
}
#[pagetop::test]
async fn props_with_empty_value_renders_attr_with_empty_value() {
let p = Props::new("data-expanded", "");
assert_eq!(
html! { span (p) {} }.into_string(),
r#"<span data-expanded=""></span>"#
);
}
#[pagetop::test]
async fn props_chained_set_and_remove_yields_expected_state() {
let p = Props::new("a", "1")
.with_prop(PropsOp::set("b", "2"))
.with_prop(PropsOp::set("c", "3"))
.with_prop(PropsOp::remove("b"))
.with_prop(PropsOp::set("a", "updated"));
assert_eq!(p.get_prop("a"), Some("updated"));
assert_eq!(p.get_prop("b"), None);
assert_eq!(p.get_prop("c"), Some("3"));
assert_eq!(
html! { span (p) {} }.into_string(),
r#"<span a="updated" c="3"></span>"#
);
}
#[pagetop::test]
async fn props_with_empty_attr_name_renders_without_validation() {
// Comportamiento documentado: los nombres no se validan; el HTML resultante no es estándar.
let p = Props::new("", "val");
assert_eq!(
html! { span (p) {} }.into_string(),
r#"<span ="val"></span>"#
);
}