//! El **encabezado fijo** y los **handles de resize** de `detail-table`, //! medidos sobre el árbol montado. //! //! Las dos cosas se rompen en la junta, no en la aritmética: un encabezado //! «sticky» que en realidad viaja con el cuerpo, y un agarre de resize que //! además dispara el orden de la columna. Eso sólo se ve montando. //! //! Sin GPU (CLAUDE.md §8). use llimphi_test::query::{Sel, Ui}; use llimphi_ui::Role; use llimphi_ui::View; use llimphi_widget_detail_table::{ detail_table_view, detail_table_view_ext, Column, DetailOpciones, DetailPalette, DetailRow, DetailScroll, DetailSpec, }; #[derive(Clone, Debug, PartialEq)] enum Msg { Ordenar(usize), Fila(usize), Scroll(i32), Ancho(usize, i32), } const ALTO_FILA: f32 = 22.0; /// Muchas más filas de las que entran: sin eso no hay nada que scrollear y el /// test no probaría nada. const FILAS: usize = 60; fn columnas() -> Vec { vec![ Column::flex("Nombre", 1.0), Column::fixed("Tamaño", 90.0).right(), Column::fixed("Tipo", 80.0), ] } fn spec<'a>(cols: &'a [Column]) -> DetailSpec<'a, Msg> { DetailSpec { columns: cols, rows: (0..FILAS) .map(|i| DetailRow { cells: vec![format!("archivo-{i}.txt"), "1 KB".into(), "texto".into()], selected: false, accent: None, icon: None, on_click: Msg::Fila(i), }) .collect(), sort: None, row_height: ALTO_FILA, caption: None, palette: DetailPalette::default(), } } fn con_scroll(cols: &[Column], offset: f32, viewport_h: f32) -> Ui { let v: View = detail_table_view_ext( spec(cols), Msg::Ordenar, DetailOpciones { scroll: Some(DetailScroll { offset, viewport_h, on_scroll: std::sync::Arc::new(|d: f32| Msg::Scroll(d as i32)), }), ..Default::default() }, ); Ui::new(v, 500.0, 300.0) } /// Y del encabezado (la fila que lleva los `ColumnHeader`). fn y_del_encabezado(ui: &Ui) -> f32 { ui.get(Sel::new().role(Role::ColumnHeader).label("Nombre")).rect().y } /// Y de la primera fila de datos, buscada **por su contenido**. /// /// No sirve tomar el mínimo de los `Role::Row`: el encabezado también es una /// `Row`, así que con el cuerpo desplazado hacia arriba el mínimo pasa a ser el /// encabezado y la medición se vuelve la de otra cosa (probado: daba 80 en vez /// de 100). fn y_de_la_primera_fila(ui: &Ui) -> f32 { ui.find_all(Sel::new().label("archivo-0.txt")) .first() .expect("la primera fila tiene que existir") .rect() .y } #[test] fn el_encabezado_no_se_mueve_cuando_el_cuerpo_scrollea() { let cols = columnas(); let quieto = con_scroll(&cols, 0.0, 200.0); let corrido = con_scroll(&cols, 100.0, 200.0); // El encabezado está en el mismo Y con y sin desplazamiento: es lo único // que «sticky» significa acá — no viaja porque está fuera del cuerpo. assert_eq!( y_del_encabezado(&quieto), y_del_encabezado(&corrido), "el encabezado se movió con el scroll:\n{}", corrido.dump() ); // Y el cuerpo sí se movió: si no, el test de arriba pasaría por no estar // scrolleando nada. let dy = y_de_la_primera_fila(&quieto) - y_de_la_primera_fila(&corrido); assert!((dy - 100.0).abs() < 0.5, "el cuerpo tendría que haberse corrido 100 px, se corrió {dy}"); } #[test] fn sin_alto_medido_todavia_el_cuerpo_arranca_desde_arriba() { // Primer cuadro (`viewport_h = 0`) con un offset guardado: se pinta desde // arriba en vez de saltar a un lugar que aún no se puede validar. let cols = columnas(); let primera = con_scroll(&cols, 0.0, 200.0); let sin_medir = con_scroll(&cols, 100.0, 0.0); assert_eq!(y_de_la_primera_fila(&primera), y_de_la_primera_fila(&sin_medir)); } #[test] fn el_handle_de_resize_arrastra_sin_ordenar_la_columna() { let cols = columnas(); let v: View = detail_table_view_ext( spec(&cols), Msg::Ordenar, DetailOpciones { on_resize: Some(std::sync::Arc::new(|c: usize, d: f32| Msg::Ancho(c, d as i32))), ..Default::default() }, ); let ui = Ui::new(v, 500.0, 300.0); // Un handle por junta entre columnas: la última no lleva (arrastrar el // borde de la tabla no redimensiona nada). let handles = ui.find_all(Role::Splitter); assert_eq!(handles.len(), cols.len() - 1, "{}", ui.dump()); assert_eq!(handles[0].name(), Some("Ancho de la columna 1")); // El handle NO es clickeable: el hit-test elige el nodo más interno con // handler, y como el suyo es un drag, agarrarlo no dispara el `on_sort` del // encabezado que tiene debajo. Ese es el bug clásico de esta UI: querés // ensanchar una columna y la tabla se te reordena. assert_eq!(handles[0].click(), None); // Y el encabezado sigue ordenando por su cuenta. assert_eq!( ui.get(Sel::new().role(Role::ColumnHeader).label("Nombre")).click(), Some(Msg::Ordenar(0)) ); } #[test] fn sin_opciones_la_tabla_es_la_de_siempre() { // Ni handles, ni cuerpo aparte: el `Default` no cambia el árbol. let cols = columnas(); let ui = Ui::new(detail_table_view(spec(&cols), Msg::Ordenar), 500.0, 300.0); assert!(ui.find(Role::Splitter).is_none(), "{}", ui.dump()); assert_eq!( ui.get(Sel::new().role(Role::ColumnHeader).label("Tipo")).click(), Some(Msg::Ordenar(2)) ); } /// **Una celda que el host pinta** reemplaza al texto de esa columna, y sólo a /// esa. /// /// Es lo que hace falta para una columna que no es texto —un ícono de estado, /// un botón con el gesto de la fila— y lo que se tiene que poder afirmar es que /// el texto que esa celda tapa **no se pinta igual debajo**: dos textos en el /// mismo rect salen uno encima del otro y el árbol queda correcto, así que /// ningún test estructural lo ve. #[test] fn una_celda_pintada_por_el_host_reemplaza_su_texto() { let cols = columnas(); let v: View = detail_table_view_ext( spec(&cols), Msg::Ordenar, DetailOpciones { // Sólo la columna 2 («Tipo»), y sólo en la fila 0. celda_vista: Some(std::sync::Arc::new(|fila: usize, col: usize| { (fila == 0 && col == 2).then(|| { View::new(Default::default()) .role(Role::Button) .aria_label("forjar".to_string()) .on_click(Msg::Fila(1000)) }) })), ..Default::default() }, ); let ui = Ui::new(v, 500.0, 300.0); // La celda está, y es la que contesta el click (gana al `on_click` de la // fila, que es el nodo de afuera). let boton = ui.get(Sel::new().role(Role::Button).label("forjar")); assert_eq!(boton.click(), Some(Msg::Fila(1000)), "{}", ui.dump()); // El texto «texto» de esa fila NO quedó debajo. El brazo de control son las // OTRAS 59 filas, que lo siguen teniendo: si el conteo diera 0, lo que // estaría roto es el instrumento. let con_texto = ui.find_all(Sel::new().label("texto")).len(); assert_eq!( con_texto, FILAS - 1, "la celda pintada por el host tiene que REEMPLAZAR el texto, no taparlo:\n{}", ui.dump() ); }