From c8654a5742ec4c9a09657f936d19d513f312e3ed Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 24 Aug 2026 19:54:58 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(core):=20A=C3=B1ade=20TypedOp?= =?UTF-8?q?=20para=20restringir=20Children?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mismo repertorio de operaciones que `ChildOp`, pero cada variante exige un componente de tipo `C`, evitando que un componente ajeno a ese tipo acabe en una lista pensada para un único tipo de elemento. --- src/core/component.rs | 2 +- src/core/component/children.rs | 73 ++++++++++++++++++++++++ tests/component_children.rs | 100 +++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) diff --git a/src/core/component.rs b/src/core/component.rs index 07a00e30..fe26edb4 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -14,7 +14,7 @@ pub use definition::{Component, ComponentClone, ComponentRender}; mod children; pub use children::Children; -pub use children::{Child, ChildOp, Embed}; +pub use children::{Child, ChildOp, Embed, TypedOp}; mod message; pub use message::{MessageLevel, StatusMessage}; diff --git a/src/core/component/children.rs b/src/core/component/children.rs index 154b4945..6a716c72 100644 --- a/src/core/component/children.rs +++ b/src/core/component/children.rs @@ -258,6 +258,79 @@ pub enum ChildOp { Reset, } +/// Mismo repertorio de operaciones de [`ChildOp`] restringido a un tipo de componente. +/// +/// Conserva toda la funcionalidad de [`ChildOp`] (inserción relativa, reemplazo o eliminación por +/// `id`, etc.) sin permitir que un componente ajeno al tipo `C` acabe en una lista pensada para un +/// único tipo de elemento (p. ej., los elementos de un menú [`Nav`](crate::base::component::Nav)). +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// +/// let nav = nav::Nav::new() +/// // Un componente `nav::Item` se convierte implícitamente en `TypedOp::Add`. +/// .with_item(nav::Item::link(Lc::n("Home"), "/")) +/// // Para el resto de operaciones se construye la variante explícita. +/// .with_item(TypedOp::AddMany(vec![ +/// nav::Item::link(Lc::n("About"), "/about"), +/// nav::Item::link(Lc::n("Contact"), "/contact"), +/// ])); +/// ``` +pub enum TypedOp { + /// Añade un componente al final de la lista. + Add(C), + /// Añade un componente sólo si la lista está vacía. + AddIfEmpty(C), + /// Añade varios componentes al final de la lista, en el orden recibido. + AddMany(Vec), + /// Inserta un componente justo después del que tiene el `id` dado, o al final si no existe. + InsertAfterId(&'static str, C), + /// Inserta un componente justo antes del que tiene el `id` dado, o al principio si no existe. + InsertBeforeId(&'static str, C), + /// Inserta un componente al principio de la lista. + Prepend(C), + /// Inserta varios componentes al principio de la lista, manteniendo el orden recibido. + PrependMany(Vec), + /// Elimina el primer componente con el `id` dado. + RemoveById(&'static str), + /// Sustituye el primer componente con el `id` dado por otro. + ReplaceById(&'static str, C), + /// Vacía la lista eliminando todos los componentes. + Reset, +} + +impl From for TypedOp { + /// Convierte un componente de tipo `C` en [`TypedOp::Add`], permitiendo pasarlo directamente a + /// métodos como `with_item()` sin envolverlo explícitamente. + #[inline] + fn from(component: C) -> Self { + TypedOp::Add(component) + } +} + +impl From> for ChildOp { + /// Traduce cada variante de [`TypedOp`] a su equivalente en [`ChildOp`], envolviendo cada + /// componente `C` en un [`Child`]. + fn from(op: TypedOp) -> Self { + match op { + TypedOp::Add(c) => ChildOp::Add(Child::with(c)), + TypedOp::AddIfEmpty(c) => ChildOp::AddIfEmpty(Child::with(c)), + TypedOp::AddMany(cs) => ChildOp::AddMany(cs.into_iter().map(Child::with).collect()), + TypedOp::InsertAfterId(id, c) => ChildOp::InsertAfterId(id, Child::with(c)), + TypedOp::InsertBeforeId(id, c) => ChildOp::InsertBeforeId(id, Child::with(c)), + TypedOp::Prepend(c) => ChildOp::Prepend(Child::with(c)), + TypedOp::PrependMany(cs) => { + ChildOp::PrependMany(cs.into_iter().map(Child::with).collect()) + } + TypedOp::RemoveById(id) => ChildOp::RemoveById(id), + TypedOp::ReplaceById(id, c) => ChildOp::ReplaceById(id, Child::with(c)), + TypedOp::Reset => ChildOp::Reset, + } + } +} + /// Lista ordenada de componentes hijo ([`Child`]) mantenida por un componente padre. /// /// Permite añadir, modificar, renderizar y consultar componentes hijo en orden de inserción, con diff --git a/tests/component_children.rs b/tests/component_children.rs index bce7d3da..36472a6a 100644 --- a/tests/component_children.rs +++ b/tests/component_children.rs @@ -269,6 +269,106 @@ async fn children_render_concatenates_all_outputs_in_order() { ); } +// **< TypedOp >************************************************************************************ +// +// `TypedOp` just translates to the equivalent `ChildOp` variant (see the `From` impl in +// `children.rs`), so these tests only check that each variant reaches the right `Children` +// operation (the operations themselves are already covered above under `ChildOp`). + +#[pagetop::test] +async fn typed_op_add_appends_component() { + let c = Children::new().with_child(TypedOp::Add(TestComp::text("a"))); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "a"); +} + +#[pagetop::test] +async fn typed_op_add_if_empty_only_adds_when_list_is_empty() { + let c = Children::new() + .with_child(TypedOp::AddIfEmpty(TestComp::text("first"))) + .with_child(TypedOp::AddIfEmpty(TestComp::text("second"))); + assert_eq!( + c.render(&mut Context::default()).await.into_string(), + "first" + ); +} + +#[pagetop::test] +async fn typed_op_add_many_appends_all_in_order() { + let c = Children::new().with_child(TypedOp::AddMany(vec![ + TestComp::text("x"), + TestComp::text("y"), + TestComp::text("z"), + ])); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "xyz"); +} + +#[pagetop::test] +async fn typed_op_insert_after_id_inserts_after_matching_element() { + let c = Children::new() + .with_child(TestComp::tagged("first", "a")) + .with_child(TestComp::text("c")) + .with_child(TypedOp::InsertAfterId("first", TestComp::text("b"))); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "abc"); +} + +#[pagetop::test] +async fn typed_op_insert_before_id_inserts_before_matching_element() { + let c = Children::new() + .with_child(TestComp::text("a")) + .with_child(TestComp::tagged("last", "c")) + .with_child(TypedOp::InsertBeforeId("last", TestComp::text("b"))); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "abc"); +} + +#[pagetop::test] +async fn typed_op_prepend_inserts_at_start() { + let c = Children::new() + .with_child(TestComp::text("b")) + .with_child(TypedOp::Prepend(TestComp::text("a"))); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "ab"); +} + +#[pagetop::test] +async fn typed_op_prepend_many_inserts_all_at_start() { + let c = Children::new() + .with_child(TestComp::text("c")) + .with_child(TypedOp::PrependMany(vec![ + TestComp::text("a"), + TestComp::text("b"), + ])); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "abc"); +} + +#[pagetop::test] +async fn typed_op_remove_by_id_removes_matching_element() { + let c = Children::new() + .with_child(TestComp::tagged("keep", "a")) + .with_child(TestComp::tagged("drop", "b")) + .with_child(TypedOp::::RemoveById("drop")); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "a"); +} + +#[pagetop::test] +async fn typed_op_replace_by_id_replaces_matching_element() { + let c = Children::new() + .with_child(TestComp::tagged("target", "old")) + .with_child(TestComp::text("b")) + .with_child(TypedOp::ReplaceById("target", TestComp::text("new"))); + assert_eq!( + c.render(&mut Context::default()).await.into_string(), + "newb" + ); +} + +#[pagetop::test] +async fn typed_op_reset_clears_all_elements() { + let c = Children::new() + .with_child(TestComp::text("a")) + .with_child(TestComp::text("b")) + .with_child(TypedOp::::Reset); + assert!(c.is_empty()); +} + // **< Embed >************************************************************************************** #[pagetop::test]