♻️ (html): Añade valores extra a Props

- Reordena campos, variantes y métodos para mayor consistencia interna.
- Añade `extra_or`, `extra_or_default` y `extra_or_else` a `Props`.
- Mejora la documentación y amplía los tests de integración.
This commit is contained in:
Manuel Cillero 2026-07-09 00:05:28 +02:00
parent a26aa4d30a
commit 25713a8a13
26 changed files with 578 additions and 227 deletions

View file

@ -23,35 +23,6 @@ async fn props_get_missing_key_returns_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]
@ -222,7 +193,7 @@ async fn props_splice_empty_string_emits_nothing() {
assert_eq!(html! { span ("") { "x" } }.into_string(), "<span>x</span>");
}
// **< is_attrs_empty / is_classes_empty / is_empty >***********************************************
// **< is_attrs_empty / is_empty >******************************************************************
#[pagetop::test]
async fn props_is_attrs_empty_on_default() {
@ -260,23 +231,7 @@ async fn props_is_empty_false_with_class() {
assert!(!Props::classes("btn").is_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());
}
// **< get_prop("id") / get_prop("class") >*********************************************************
// **< get_prop("id") >*****************************************************************************
#[pagetop::test]
async fn get_prop_id_returns_none_by_default() {
@ -295,26 +250,6 @@ async fn get_prop_id_matches_get_id() {
assert_eq!(p.get_prop("id"), p.get_id());
}
#[pagetop::test]
async fn get_prop_class_returns_none_by_default() {
assert_eq!(Props::default().get_prop("class"), None);
}
#[pagetop::test]
async fn get_prop_class_returns_joined_classes() {
let p = Props::classes("btn btn-primary").with_prop(PropsOp::add_classes("active"));
assert_eq!(
p.get_prop("class"),
Some("btn btn-primary active".to_string())
);
}
#[pagetop::test]
async fn get_prop_class_matches_get_classes() {
let p = Props::classes("btn active");
assert_eq!(p.get_prop("class"), p.get_classes());
}
// **< Regression & edge cases >********************************************************************
#[pagetop::test]

View file

@ -11,7 +11,7 @@ fn assert_classes(p: &Props, expected: Option<&str>) {
);
}
// **< Construction & invariants (new/get) >********************************************************
// **< Props::classes (construction) >**************************************************************
#[pagetop::test]
async fn classes_new_empty_and_whitespace_is_empty() {
@ -28,13 +28,7 @@ async fn classes_new_normalizes_and_dedups_and_preserves_first_occurrence_order(
assert!(p.has_class("btn-primary"));
}
#[pagetop::test]
async fn classes_get_returns_none_when_empty_some_when_not() {
assert_classes(&Props::classes(" "), None);
assert_classes(&Props::classes("a"), Some("a"));
}
// **< Basic operations (add/prepend/set) >*********************************************************
// **< PropsOp::add_classes >***********************************************************************
#[pagetop::test]
async fn classes_add_appends_unique_and_normalizes() {
@ -49,7 +43,7 @@ async fn classes_add_ignores_empty_input() {
}
#[pagetop::test]
async fn classes_add_same_tokens() {
async fn classes_add_same_tokens_is_noop() {
let p = Props::classes("a b").with_prop(PropsOp::add_classes("A B a b"));
assert_classes(&p, Some("a b"));
}
@ -60,6 +54,8 @@ async fn classes_add_rejects_non_ascii_is_noop() {
assert_classes(&p, Some("a b"));
}
// **< PropsOp::prepend_classes >*******************************************************************
#[pagetop::test]
async fn classes_prepend_inserts_at_front_preserving_new_order() {
let p = Props::classes("c d").with_prop(PropsOp::prepend_classes("A b"));
@ -78,6 +74,8 @@ async fn classes_prepend_ignores_empty_input() {
assert_classes(&p, Some("a b"));
}
// **< PropsOp::set / remove ("class") >************************************************************
#[pagetop::test]
async fn classes_reset_replaces_entire_list_and_dedups() {
let p = Props::classes("a b c").with_prop(PropsOp::set("class", "X y y Z"));
@ -96,7 +94,13 @@ async fn classes_reset_with_non_ascii_is_noop() {
assert_classes(&p, Some("a b"));
}
// **< Mutation operations (remove) >***************************************************************
#[pagetop::test]
async fn classes_remove_attr_clears_all_classes() {
let p = Props::classes("btn primary").with_prop(PropsOp::remove("class"));
assert_classes(&p, None);
}
// **< PropsOp::remove_classes >********************************************************************
#[pagetop::test]
async fn classes_remove_is_case_insensitive() {
@ -116,7 +120,7 @@ async fn classes_remove_with_extra_whitespace() {
assert_classes(&p, Some("a c"));
}
// **< Queries (contains) >*************************************************************************
// **< has_class / has_any_class >******************************************************************
#[pagetop::test]
async fn classes_contains_single() {
@ -129,11 +133,9 @@ async fn classes_contains_single() {
#[pagetop::test]
async fn classes_contains_all_and_any() {
let p = Props::classes("btn btn-primary active");
assert!(p.has_class("btn active"));
assert!(p.has_class("BTN BTN-PRIMARY"));
assert!(!p.has_class("btn missing"));
assert!(p.has_any_class("missing active"));
assert!(p.has_any_class("BTN-PRIMARY missing"));
assert!(!p.has_any_class("missing other"));
@ -155,7 +157,68 @@ async fn classes_contains_non_ascii_is_false() {
assert!(!p.has_any_class("a ñ"));
}
// **< Properties / regression (combined sequences, ordering) >*************************************
// **< is_classes_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());
}
// **< get_classes / get_prop("class") >************************************************************
#[pagetop::test]
async fn classes_get_returns_none_when_empty_some_when_not() {
assert_classes(&Props::classes(" "), None);
assert_classes(&Props::classes("a"), Some("a"));
}
#[pagetop::test]
async fn get_prop_class_returns_joined_classes() {
let p = Props::classes("btn btn-primary").with_prop(PropsOp::add_classes("active"));
assert_eq!(
p.get_prop("class"),
Some("btn btn-primary active".to_string())
);
}
#[pagetop::test]
async fn get_prop_class_matches_get_classes() {
let p = Props::classes("btn active");
assert_eq!(p.get_prop("class"), p.get_classes());
}
// **< HTML rendering >*****************************************************************************
#[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_can_be_extended_with_add_classes() {
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>"#
);
}
// **< Combined sequences >*************************************************************************
#[pagetop::test]
async fn classes_order_is_stable_for_existing_items() {

120
tests/html_props_extras.rs Normal file
View file

@ -0,0 +1,120 @@
use pagetop::prelude::*;
// **< PropsOp::set_extra / remove_extra >**********************************************************
#[pagetop::test]
async fn set_and_read_extra() {
let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true));
assert_eq!(*props.extra::<bool>("ext.flag").unwrap(), true);
}
#[pagetop::test]
async fn overwrite_extra() {
let props = Props::default()
.with_prop(PropsOp::set_extra("ext.count", 1_u32))
.with_prop(PropsOp::set_extra("ext.count", 2_u32));
assert_eq!(*props.extra::<u32>("ext.count").unwrap(), 2);
}
#[pagetop::test]
async fn remove_extra() {
let props = Props::default()
.with_prop(PropsOp::set_extra("ext.flag", true))
.with_prop(PropsOp::remove_extra("ext.flag"));
assert_eq!(
props.extra::<bool>("ext.flag"),
Err(PropsError::ExtraNotFound { key: "ext.flag" })
);
}
// **< Props::extra >*******************************************************************************
#[pagetop::test]
async fn extra_not_found() {
let props = Props::default();
assert_eq!(
props.extra::<bool>("ext.missing"),
Err(PropsError::ExtraNotFound { key: "ext.missing" })
);
}
#[pagetop::test]
async fn extra_type_mismatch() {
let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true));
assert!(matches!(
props.extra::<u32>("ext.flag"),
Err(PropsError::ExtraTypeMismatch { .. })
));
}
// **< Props::extra_or >****************************************************************************
#[pagetop::test]
async fn extra_or_returns_value_when_found() {
let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true));
assert_eq!(props.extra_or("ext.flag", false), true);
}
#[pagetop::test]
async fn extra_or_returns_default_on_missing() {
let props = Props::default();
assert_eq!(props.extra_or("ext.flag", false), false);
}
#[pagetop::test]
async fn extra_or_returns_default_on_type_mismatch() {
let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true));
assert_eq!(props.extra_or("ext.flag", 0_u32), 0_u32);
}
// **< Props::extra_or_default >********************************************************************
#[pagetop::test]
async fn extra_or_default_returns_type_default_on_missing() {
let props = Props::default();
assert_eq!(props.extra_or_default::<bool>("ext.flag"), false);
assert_eq!(props.extra_or_default::<i32>("ext.count"), 0);
}
// **< Props::extra_or_else >***********************************************************************
#[pagetop::test]
async fn extra_or_else_calls_closure_on_missing() {
let props = Props::default();
assert_eq!(
props.extra_or_else("ext.label", || "fallback".to_string()),
"fallback"
);
}
// **< Clone and mixed types >**********************************************************************
#[pagetop::test]
async fn clone_preserves_extras() {
let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true));
let cloned = props.clone();
assert_eq!(*cloned.extra::<bool>("ext.flag").unwrap(), true);
}
#[pagetop::test]
async fn multiple_extras_with_different_types() {
let props = Props::default()
.with_prop(PropsOp::set_extra("ext.flag", true))
.with_prop(PropsOp::set_extra("ext.count", 42_u32))
.with_prop(PropsOp::set_extra("ext.label", "hello".to_string()));
assert_eq!(*props.extra::<bool>("ext.flag").unwrap(), true);
assert_eq!(*props.extra::<u32>("ext.count").unwrap(), 42);
assert_eq!(props.extra::<String>("ext.label").unwrap(), "hello");
}
#[pagetop::test]
async fn extras_not_emitted_in_html() {
let props = Props::default()
.with_prop(PropsOp::set_extra("ext.flag", true))
.with_prop(PropsOp::add_classes("btn"));
assert_eq!(
html! { button (props) { "OK" } }.into_string(),
r#"<button class="btn">OK</button>"#
);
}