diff --git a/crates/ui-egui/tests/it/cf_rules_manager.rs b/crates/ui-egui/tests/it/cf_rules_manager.rs index d6f19a715..013a0b28f 100644 --- a/crates/ui-egui/tests/it/cf_rules_manager.rs +++ b/crates/ui-egui/tests/it/cf_rules_manager.rs @@ -2,7 +2,7 @@ //! inside the dialog, and only the visible rules are laid out, instead of one row per rule //! making the window as tall as the whole list and the application window go blank. -use egui::vec2; +use egui::{Event, Modifiers, MouseWheelSource, MouseWheelUnit, PointerButton, Pos2, Rect, TouchPhase, Vec2, pos2, vec2}; use egui_kittest::kittest::Queryable; use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; @@ -14,10 +14,15 @@ const RULES: usize = 3000; const SCREEN_H: f32 = 800.0; fn harness_with_rules(n: usize) -> Harness { + let ranges: Vec = (0..n).map(|i| format!("A{}", i + 1)).collect(); + harness_with_ranges(&ranges) +} + +/// One rule per entry of `ranges`, added in order (so the last one is listed first). +fn harness_with_ranges(ranges: &[String]) -> Harness { let mut session = Session::new(); session.new_workbook(); - for i in 0..n { - let range = format!("A{}", i + 1); + for range in ranges { session .execute( "home.conditionalFormat", @@ -38,6 +43,67 @@ fn harness_with_rules(n: usize) -> Harness { h } +fn dialog(h: &Harness) -> Rect { + h.get_by_label("Conditional Formatting Rules Manager").rect() +} + +/// The rows of the list shown inside the dialog: (rule number, row rectangle). +fn visible_rows(h: &Harness) -> Vec<(usize, Rect)> { + let window = dialog(h); + let mut rows: Vec<(usize, Rect)> = h + .query_all_by_label_contains("applies to") + .filter(|n| window.contains(n.rect().left_center())) + .filter_map(|n| Some((n.value()?.split('.').next()?.parse().ok()?, n.rect()))) + .collect(); + rows.sort_by(|a, b| a.1.top().total_cmp(&b.1.top())); + rows +} + +/// The row of rule `n`, wherever it is laid out (its label may reach past the dialog's edges). +fn row_of(h: &Harness, n: usize) -> Rect { + let prefix = format!("{n}. "); + h.query_all_by_label_contains("applies to") + .find(|r| r.value().is_some_and(|l| l.starts_with(&prefix))) + .expect("the rule's row is laid out") + .rect() +} + +/// The `label` button on the row at `row`, if it is inside the dialog. +fn row_button(h: &Harness, row: Rect, label: &str) -> Option { + let window = dialog(h); + h.query_all_by_label(label).map(|n| n.rect()).find(|r| window.contains_rect(*r) && (r.center().y - row.center().y).abs() < row.height() / 2.0) +} + +fn wheel(h: &mut Harness, at: Pos2, delta: Vec2) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(2); + h.input_mut().events.push(Event::MouseWheel { + unit: MouseWheelUnit::Point, + delta, + phase: TouchPhase::Move, + source: MouseWheelSource::Unknown, + modifiers: Modifiers::NONE, + }); + h.run_steps(30); +} + +fn click(h: &mut Harness, r: Rect) { + let pos = r.center(); + for pressed in [true, false] { + h.input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.step(); + } + h.run_steps(4); +} + +/// The ranges of every rule on the sheet, in list order. +fn rule_ranges(h: &Harness) -> Vec { + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + sh.cond_formats.iter().map(|cf| cf.ranges.iter().map(|r| r.a1()).collect::>().join(",")).collect() +} + #[test] fn many_rules_scroll_inside_a_dialog_that_fits_the_window() { let mut h = harness_with_rules(RULES); @@ -66,3 +132,48 @@ fn an_empty_sheet_says_so() { let h = harness_with_rules(0); h.get_by_label("There are no conditional formatting rules on this sheet."); } + +#[test] +fn a_rule_scrolled_into_view_is_moved_and_deleted() { + let mut h = harness_with_rules(RULES); + let before = visible_rows(&h); + let last_shown = before.iter().map(|r| r.0).max().expect("the first rules are listed"); + // Scroll the list down, then act on a row in the middle of what is shown now. + let at = pos2(dialog(&h).left() + 40.0, before[before.len() / 2].1.center().y); + wheel(&mut h, at, vec2(0.0, -2000.0)); + let rows = visible_rows(&h); + let (n, row) = rows[rows.len() / 2]; + assert!(n > last_shown, "rule {n} was not on screen before scrolling (rules 1 to {last_shown} were)"); + // Rule n is the n-th listed; new rules go first, so it applies to A{RULES + 1 - n}. + let target = format!("A{}", RULES + 1 - n); + assert_eq!(rule_ranges(&h)[n - 1], target); + let down = row_button(&h, row, "▼").expect("the row's move-down button is in the dialog"); + click(&mut h, down); + let ranges = rule_ranges(&h); + assert_eq!(ranges[n], target, "rule {n} moved down one place"); + assert_eq!(ranges[n - 1], format!("A{}", RULES - n), "the rule below it moved up"); + // The moved rule is now rule n + 1, on the next row. + let delete = row_button(&h, row_of(&h, n + 1), "Delete").expect("the row's Delete button is in the dialog"); + click(&mut h, delete); + let ranges = rule_ranges(&h); + assert_eq!(ranges.len(), RULES - 1); + assert!(!ranges.contains(&target), "the rule scrolled to was deleted"); +} + +#[test] +fn a_rule_with_a_long_label_is_reached_by_scrolling_sideways() { + let long: Vec = (0..200).map(|i| format!("C{}", 2 * i + 1)).collect(); + let long = long.join(","); + let mut h = harness_with_ranges(&["A1".into(), long.clone(), "B1".into()]); + assert_eq!(rule_ranges(&h), ["B1", long.as_str(), "A1"]); + let row = row_of(&h, 2); + assert!(row_button(&h, row, "▼").is_none(), "the long rule's buttons start out of view"); + let window = dialog(&h); + wheel(&mut h, pos2(window.left() + 40.0, row.center().y), vec2(-20000.0, 0.0)); + let down = row_button(&h, row_of(&h, 2), "▼").expect("scrolling sideways brings the move-down button into the dialog"); + click(&mut h, down); + assert_eq!(rule_ranges(&h), ["B1", "A1", long.as_str()], "the long rule moved down"); + let delete = row_button(&h, row_of(&h, 3), "Delete").expect("the long rule's Delete button is in the dialog on its new row"); + click(&mut h, delete); + assert_eq!(rule_ranges(&h), ["B1", "A1"], "the long rule was deleted"); +}