From e9565bf70b1a917030543a3b62e926375ac4e0bf Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 28 Dec 2025 18:00:26 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Mejora=20rendimiento?= =?UTF-8?q?=20de=20b=C3=BAsquedas=20de=20clases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/html/classes.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/html/classes.rs b/src/html/classes.rs index 2fd2fbf4..0dec5fd8 100644 --- a/src/html/classes.rs +++ b/src/html/classes.rs @@ -133,9 +133,37 @@ impl Classes { } } - /// Devuelve `true` si la clase está presente. + /// Devuelve `true` si **una única clase** está presente. + /// + /// Si necesitas comprobar varias clases separadas por espacios, usa [`contains_all`] o + /// [`contains_any`]. pub fn contains(&self, class: impl AsRef) -> bool { - let class = class.as_ref().to_ascii_lowercase(); - self.0.iter().any(|c| c == &class) + self.contains_class(class.as_ref()) + } + + /// Devuelve `true` si **todas** las clases indicadas están presentes. + pub fn contains_all(&self, classes: impl AsRef) -> bool { + classes + .as_ref() + .split_ascii_whitespace() + .all(|class| self.contains_class(class)) + } + + /// Devuelve `true` si **alguna** de las clases indicadas está presente. + pub fn contains_any(&self, classes: impl AsRef) -> bool { + classes + .as_ref() + .split_ascii_whitespace() + .any(|class| self.contains_class(class)) + } + + #[inline] + fn contains_class(&self, class: &str) -> bool { + if class.bytes().any(|b| b.is_ascii_uppercase()) { + let class = class.to_ascii_lowercase(); + self.0.iter().any(|c| c == &class) + } else { + self.0.iter().any(|c| c == class) + } } } From 00d4de840ba19b4c1585ec972866960a89d420ed Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Wed, 31 Dec 2025 08:48:25 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Mejora=20operaciones?= =?UTF-8?q?=20con=20clases=20y=20documentaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/html/classes.rs | 119 ++++++++++++++++++++++++++------------------ 1 file changed, 70 insertions(+), 49 deletions(-) diff --git a/src/html/classes.rs b/src/html/classes.rs index 0dec5fd8..0123d383 100644 --- a/src/html/classes.rs +++ b/src/html/classes.rs @@ -3,33 +3,44 @@ use crate::{builder_fn, AutoDefault}; use std::borrow::Cow; /// Operaciones disponibles sobre la lista de clases en [`Classes`]. +/// +/// Cada variante opera sobre **una o más clases** proporcionadas como una cadena separada por +/// espacios (p. ej. `"btn active"`), que se normalizan internamente a minúsculas en +/// [`Classes::with_classes()`]. pub enum ClassesOp { - /// Añade al final (si no existe). + /// Añade las clases que no existan al final. Add, - /// Añade al principio. + /// Añade las clases que no existan al principio. Prepend, - /// Elimina la(s) clase(s) indicada(s). + /// Elimina las clases indicadas que existan. Remove, - /// Sustituye una o varias clases por otras nuevas (`Replace("old other".into())`). + /// Sustituye una o varias clases existentes (indicadas en la variante) por las clases + /// proporcionadas. Replace(Cow<'static, str>), /// Alterna presencia/ausencia de una o más clases. + /// + /// Si en una misma llamada se repite una clase (p. ej. `"a a"`) que ya existe, el resultado + /// mantiene la pertenencia pero puede cambiar el orden (primero se elimina y luego se añade al + /// final). Toggle, - /// Sustituye toda la lista. + /// Sustituye la lista completa por las clases indicadas. Set, } -/// Cadena de clases CSS normalizadas para el atributo `class` de HTML. +/// Lista de clases CSS normalizadas para el atributo `class` de HTML. /// /// Permite construir y modificar dinámicamente con [`ClassesOp`] una lista de clases CSS /// normalizadas. /// /// # Normalización /// -/// - El [orden de las clases no es relevante](https://stackoverflow.com/a/1321712) en CSS, pero -/// [`ClassesOp`] ofrece operaciones para controlar su orden de aparición. -/// - Las clases se convierten a minúsculas. -/// - No se permiten clases duplicadas. +/// - Aunque el orden de las clases en el atributo `class` no afecta al resultado en CSS, +/// [`ClassesOp`] ofrece operaciones para controlar su orden de aparición por legibilidad. +/// - Las clases se almacenan en minúsculas. +/// - No se permiten clases duplicadas tras la normalización (por ejemplo, `Btn` y `btn` se +/// consideran la misma clase). /// - Las clases vacías se ignoran. +/// - Sin clases, [`get()`](Self::get) devuelve `None` (no `Some("")`). /// /// # Ejemplo /// @@ -59,53 +70,62 @@ impl Classes { /// lista de clases actual. #[builder_fn] pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef) -> Self { - let classes = classes.as_ref().to_ascii_lowercase(); - let classes: Vec<&str> = classes.split_ascii_whitespace().collect(); - - if classes.is_empty() { - return self; - } - + let classes = classes.as_ref(); match op { ClassesOp::Add => { - self.add(&classes, self.0.len()); + self.add(classes, self.0.len()); } ClassesOp::Prepend => { - self.add(&classes, 0); + self.add(classes, 0); } ClassesOp::Remove => { - for class in classes { - self.0.retain(|c| c != class); + let mut classes_to_remove = classes.split_ascii_whitespace(); + + // 0 clases: no se hace nada. + let Some(first) = classes_to_remove.next() else { + return self; + }; + + // 1 clase: un único *retain*, cero reservas extra. + let first = first.to_ascii_lowercase(); + let Some(second) = classes_to_remove.next() else { + self.0.retain(|c| c != &first); + return self; + }; + + // 2+ clases: se construye lista para borrar y un único *retain*. + let mut to_remove = Vec::new(); + to_remove.push(first); + to_remove.push(second.to_ascii_lowercase()); + for class in classes_to_remove { + to_remove.push(class.to_ascii_lowercase()); } + self.0.retain(|c| !to_remove.iter().any(|r| r == c)); } ClassesOp::Replace(classes_to_replace) => { let mut pos = self.0.len(); - let replace = classes_to_replace.to_ascii_lowercase(); - let replace: Vec<&str> = replace.split_ascii_whitespace().collect(); - for class in replace { - if let Some(replace_pos) = self.0.iter().position(|c| c == class) { + for class in classes_to_replace.split_ascii_whitespace() { + let class = class.to_ascii_lowercase(); + if let Some(replace_pos) = self.0.iter().position(|c| c == &class) { self.0.remove(replace_pos); - if pos > replace_pos { - pos = replace_pos; - } + pos = pos.min(replace_pos); } } - self.add(&classes, pos); + self.add(classes, pos); } ClassesOp::Toggle => { - for class in classes { - if !class.is_empty() { - if let Some(pos) = self.0.iter().position(|c| c.eq(class)) { - self.0.remove(pos); - } else { - self.0.push(class.to_string()); - } + for class in classes.split_ascii_whitespace() { + let class = class.to_ascii_lowercase(); + if let Some(pos) = self.0.iter().position(|c| c == &class) { + self.0.remove(pos); + } else { + self.0.push(class); } } } ClassesOp::Set => { self.0.clear(); - self.add(&classes, 0); + self.add(classes, 0); } } @@ -113,10 +133,16 @@ impl Classes { } #[inline] - fn add(&mut self, classes: &[&str], mut pos: usize) { - for &class in classes { - if !class.is_empty() && !self.0.iter().any(|c| c == class) { - self.0.insert(pos, class.to_string()); + fn add(&mut self, classes: &str, mut pos: usize) { + for class in classes.split_ascii_whitespace() { + let class = class.to_ascii_lowercase(); + // Inserción segura descartando duplicados. + if !self.0.iter().any(|c| c == &class) { + if pos >= self.0.len() { + self.0.push(class); + } else { + self.0.insert(pos, class); + } pos += 1; } } @@ -135,8 +161,8 @@ impl Classes { /// Devuelve `true` si **una única clase** está presente. /// - /// Si necesitas comprobar varias clases separadas por espacios, usa [`contains_all`] o - /// [`contains_any`]. + /// Si necesitas comprobar varias clases, usa [`contains_all()`](Self::contains_all) o + /// [`contains_any()`](Self::contains_any). pub fn contains(&self, class: impl AsRef) -> bool { self.contains_class(class.as_ref()) } @@ -159,11 +185,6 @@ impl Classes { #[inline] fn contains_class(&self, class: &str) -> bool { - if class.bytes().any(|b| b.is_ascii_uppercase()) { - let class = class.to_ascii_lowercase(); - self.0.iter().any(|c| c == &class) - } else { - self.0.iter().any(|c| c == class) - } + self.0.iter().any(|c| c.eq_ignore_ascii_case(class)) } } From 41d509134882c1d081e342e32ba4c6c8a47111cc Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Wed, 31 Dec 2025 08:48:50 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=85=20A=C3=B1ade=20pruebas=20para=20o?= =?UTF-8?q?peraciones=20con=20clases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/html_classes.rs | 199 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 tests/html_classes.rs diff --git a/tests/html_classes.rs b/tests/html_classes.rs new file mode 100644 index 00000000..00de96c5 --- /dev/null +++ b/tests/html_classes.rs @@ -0,0 +1,199 @@ +use pagetop::prelude::*; + +fn assert_classes(c: &Classes, expected: Option<&str>) { + let got = c.get(); + assert_eq!(got.as_deref(), expected, "Expected {:?}, got {:?}", expected, got); +} + +// **< Construction & invariants (new/get) >******************************************************** + +#[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); +} + +#[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")); +} + +#[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")); +} + +// **< 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")); +} + +#[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")); +} + +#[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")); +} + +#[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")); +} + +#[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")); +} + +#[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")); +} + +#[pagetop::test] +async fn classes_set_replaces_entire_list_and_dedups() { + let c = Classes::new("a b c").with_classes(ClassesOp::Set, "X y y Z"); + assert_classes(&c, Some("x y z")); +} + +#[pagetop::test] +async fn classes_set_with_empty_input_clears() { + let base = Classes::new("a b"); + let c = base.with_classes(ClassesOp::Set, " \n "); + assert_classes(&c, None); +} + +// **< Mutation operations (remove/toggle/replace) >************************************************ + +#[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")); +} + +#[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")); +} + +#[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")); +} + +#[pagetop::test] +async fn classes_replace_removes_targets_and_inserts_new_at_min_position() { + let c = Classes::new("a b c d").with_classes(ClassesOp::Replace("c a".into()), "x y"); + assert_classes(&c, Some("x y b d")); +} + +#[pagetop::test] +async fn classes_replace_when_none_found_appends_at_end() { + let c = Classes::new("a b").with_classes(ClassesOp::Replace("x y".into()), "c d"); + assert_classes(&c, Some("a b c d")); +} + +#[pagetop::test] +async fn classes_replace_is_case_insensitive_on_targets_and_new_values_are_normalized() { + let c = Classes::new("btn btn-primary active") + .with_classes(ClassesOp::Replace("BTN-PRIMARY".into()), "Btn-Secondary"); + assert_classes(&c, Some("btn btn-secondary active")); +} + +#[pagetop::test] +async fn classes_replace_with_empty_new_removes_only() { + let c = Classes::new("a b c").with_classes(ClassesOp::Replace("b".into()), " "); + assert_classes(&c, Some("a c")); +} + +#[pagetop::test] +async fn classes_replace_dedups_against_existing_items() { + let c = Classes::new("a b c").with_classes(ClassesOp::Replace("b".into()), "c d"); + assert_classes(&c, Some("a d c")); +} + +#[pagetop::test] +async fn classes_replace_ignores_target_whitespace_and_repetition() { + let c = Classes::new("a b c").with_classes(ClassesOp::Replace(" b b ".into()), "x y"); + assert_classes(&c, Some("a x y 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")); +} + +#[pagetop::test] +async fn classes_contains_all_and_any() { + let c = Classes::new("btn btn-primary active"); + + assert!(c.contains_all("btn active")); + assert!(c.contains_all("BTN BTN-PRIMARY")); + assert!(!c.contains_all("btn missing")); + + assert!(c.contains_any("missing active")); + assert!(c.contains_any("BTN-PRIMARY missing")); + assert!(!c.contains_any("missing other")); +} + +// **< 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")); +}