use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use super::axis::Axis; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct View { pub name: String, /// Axis assignment for each category pub category_axes: IndexMap, /// For page axis: selected item per category pub page_selections: HashMap, /// Hidden items per category pub hidden_items: HashMap>, /// Collapsed groups per category pub collapsed_groups: HashMap>, /// Number format string (e.g. ",.0f" for comma-separated integer) pub number_format: String, /// Scroll offset for grid pub row_offset: usize, pub col_offset: usize, /// Selected cell (row_idx, col_idx) pub selected: (usize, usize), } impl View { pub fn new(name: impl Into) -> Self { Self { name: name.into(), category_axes: IndexMap::new(), page_selections: HashMap::new(), hidden_items: HashMap::new(), collapsed_groups: HashMap::new(), number_format: ",.0".to_string(), row_offset: 0, col_offset: 0, selected: (0, 0), } } pub fn on_category_added(&mut self, cat_name: &str) { if !self.category_axes.contains_key(cat_name) { // Virtual categories (names starting with `_`) default to Axis::None. // Regular categories auto-assign: first → Row, second → Column, rest → Page. let axis = if cat_name.starts_with('_') { Axis::None } else { let rows = self.categories_on(Axis::Row).len(); let cols = self.categories_on(Axis::Column).len(); if rows == 0 { Axis::Row } else if cols == 0 { Axis::Column } else { Axis::Page } }; self.category_axes.insert(cat_name.to_string(), axis); } } pub fn set_axis(&mut self, cat_name: &str, axis: Axis) { if let Some(a) = self.category_axes.get_mut(cat_name) { *a = axis; } } pub fn axis_of(&self, cat_name: &str) -> Axis { *self .category_axes .get(cat_name) .expect("axis_of called for category not registered with this view") } pub fn categories_on(&self, axis: Axis) -> Vec<&str> { self.category_axes .iter() .filter(|(_, &a)| a == axis) .map(|(n, _)| n.as_str()) .collect() } pub fn set_page_selection(&mut self, cat_name: &str, item: &str) { self.page_selections .insert(cat_name.to_string(), item.to_string()); } pub fn page_selection(&self, cat_name: &str) -> Option<&str> { self.page_selections.get(cat_name).map(|s| s.as_str()) } pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) { let set = self .collapsed_groups .entry(cat_name.to_string()) .or_default(); if set.contains(group_name) { set.remove(group_name); } else { set.insert(group_name.to_string()); } } pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool { self.collapsed_groups .get(cat_name) .map(|s| s.contains(group_name)) .unwrap_or(false) } pub fn hide_item(&mut self, cat_name: &str, item_name: &str) { self.hidden_items .entry(cat_name.to_string()) .or_default() .insert(item_name.to_string()); } pub fn show_item(&mut self, cat_name: &str, item_name: &str) { if let Some(set) = self.hidden_items.get_mut(cat_name) { set.remove(item_name); } } pub fn is_hidden(&self, cat_name: &str, item_name: &str) -> bool { self.hidden_items .get(cat_name) .map(|s| s.contains(item_name)) .unwrap_or(false) } /// Swap all Row categories to Column and all Column categories to Row. /// Page categories are unaffected. pub fn transpose_axes(&mut self) { let rows: Vec = self .categories_on(Axis::Row) .iter() .map(|s| s.to_string()) .collect(); let cols: Vec = self .categories_on(Axis::Column) .iter() .map(|s| s.to_string()) .collect(); for cat in &rows { self.set_axis(cat, Axis::Column); } for cat in &cols { self.set_axis(cat, Axis::Row); } self.selected = (0, 0); self.row_offset = 0; self.col_offset = 0; } /// Cycle axis for a category: Row → Column → Page → None → Row pub fn cycle_axis(&mut self, cat_name: &str) { let next = match self.axis_of(cat_name) { Axis::Row => Axis::Column, Axis::Column => Axis::Page, Axis::Page => Axis::None, Axis::None => Axis::Row, }; self.set_axis(cat_name, next); self.selected = (0, 0); self.row_offset = 0; self.col_offset = 0; } } #[cfg(test)] mod tests { use super::View; use crate::view::Axis; fn view_with_cats(cats: &[&str]) -> View { let mut v = View::new("Test"); for &c in cats { v.on_category_added(c); } v } #[test] fn first_category_assigned_to_row() { let v = view_with_cats(&["Region"]); assert_eq!(v.axis_of("Region"), Axis::Row); } #[test] fn second_category_assigned_to_column() { let v = view_with_cats(&["Region", "Product"]); assert_eq!(v.axis_of("Product"), Axis::Column); } #[test] fn third_and_later_categories_assigned_to_page() { let v = view_with_cats(&["Region", "Product", "Time", "Scenario"]); assert_eq!(v.axis_of("Time"), Axis::Page); assert_eq!(v.axis_of("Scenario"), Axis::Page); } #[test] fn set_axis_changes_assignment() { let mut v = view_with_cats(&["Region", "Product"]); v.set_axis("Region", Axis::Column); assert_eq!(v.axis_of("Region"), Axis::Column); } #[test] fn categories_on_returns_correct_list() { let v = view_with_cats(&["Region", "Product", "Time"]); assert_eq!(v.categories_on(Axis::Row), vec!["Region"]); assert_eq!(v.categories_on(Axis::Column), vec!["Product"]); assert_eq!(v.categories_on(Axis::Page), vec!["Time"]); } #[test] fn transpose_axes_swaps_row_and_column() { let mut v = view_with_cats(&["Region", "Product"]); // Default: Region=Row, Product=Column assert_eq!(v.axis_of("Region"), Axis::Row); assert_eq!(v.axis_of("Product"), Axis::Column); v.transpose_axes(); assert_eq!(v.axis_of("Region"), Axis::Column); assert_eq!(v.axis_of("Product"), Axis::Row); } #[test] fn transpose_axes_leaves_page_categories_unchanged() { let mut v = view_with_cats(&["Region", "Product", "Time"]); // Default: Region=Row, Product=Column, Time=Page v.transpose_axes(); assert_eq!(v.axis_of("Time"), Axis::Page); } #[test] fn transpose_axes_is_its_own_inverse() { let mut v = view_with_cats(&["Region", "Product"]); v.transpose_axes(); v.transpose_axes(); assert_eq!(v.axis_of("Region"), Axis::Row); assert_eq!(v.axis_of("Product"), Axis::Column); } #[test] #[should_panic(expected = "axis_of called for category not registered")] fn axis_of_unknown_category_panics() { let v = View::new("Test"); v.axis_of("Ghost"); } #[test] fn page_selection_set_and_get() { let mut v = view_with_cats(&["Region", "Product", "Time"]); v.set_page_selection("Time", "Q1"); assert_eq!(v.page_selection("Time"), Some("Q1")); assert_eq!(v.page_selection("Region"), None); } #[test] fn toggle_group_collapse_toggles_twice() { let mut v = View::new("Test"); assert!(!v.is_group_collapsed("Time", "Q1")); v.toggle_group_collapse("Time", "Q1"); assert!(v.is_group_collapsed("Time", "Q1")); v.toggle_group_collapse("Time", "Q1"); assert!(!v.is_group_collapsed("Time", "Q1")); } #[test] fn is_group_collapsed_isolated_across_categories() { let mut v = View::new("Test"); v.toggle_group_collapse("Cat1", "G1"); assert!(!v.is_group_collapsed("Cat2", "G1")); } #[test] fn is_group_collapsed_isolated_across_groups() { let mut v = View::new("Test"); v.toggle_group_collapse("Cat1", "G1"); assert!(!v.is_group_collapsed("Cat1", "G2")); } #[test] fn hide_and_show_item() { let mut v = View::new("Test"); assert!(!v.is_hidden("Region", "East")); v.hide_item("Region", "East"); assert!(v.is_hidden("Region", "East")); v.show_item("Region", "East"); assert!(!v.is_hidden("Region", "East")); } #[test] fn cycle_axis_row_to_column() { let mut v = view_with_cats(&["Region", "Product"]); v.cycle_axis("Region"); assert_eq!(v.axis_of("Region"), Axis::Column); } #[test] fn cycle_axis_column_to_page() { let mut v = view_with_cats(&["Region", "Product"]); v.set_axis("Product", Axis::Column); v.cycle_axis("Product"); assert_eq!(v.axis_of("Product"), Axis::Page); } #[test] fn cycle_axis_page_to_none() { let mut v = view_with_cats(&["Region", "Product", "Time"]); v.cycle_axis("Time"); assert_eq!(v.axis_of("Time"), Axis::None); } #[test] fn cycle_axis_none_to_row() { let mut v = view_with_cats(&["Region", "Product", "Time"]); v.set_axis("Time", Axis::None); v.cycle_axis("Time"); assert_eq!(v.axis_of("Time"), Axis::Row); } #[test] fn cycle_axis_resets_scroll_and_selection() { let mut v = view_with_cats(&["Region"]); v.row_offset = 5; v.col_offset = 3; v.selected = (2, 2); v.cycle_axis("Region"); assert_eq!(v.row_offset, 0); assert_eq!(v.col_offset, 0); assert_eq!(v.selected, (0, 0)); } } #[cfg(test)] mod prop_tests { use super::View; use crate::view::Axis; use proptest::prelude::*; fn unique_cat_names() -> impl Strategy> { prop::collection::hash_set("[A-Za-z][a-z]{1,7}", 1usize..=8) .prop_map(|s| s.into_iter().collect::>()) } proptest! { /// axis_of and categories_on are consistent: cat is in categories_on(axis_of(cat)) #[test] fn axis_of_and_categories_on_consistent(cats in unique_cat_names()) { let mut v = View::new("T"); for c in &cats { v.on_category_added(c); } for c in &cats { let axis = v.axis_of(c); let on_axis = v.categories_on(axis); prop_assert!(on_axis.contains(&c.as_str()), "categories_on({:?}) should contain '{}'", axis, c); } } /// Each known category appears on exactly one axis #[test] fn each_category_on_exactly_one_axis(cats in unique_cat_names()) { let mut v = View::new("T"); for c in &cats { v.on_category_added(c); } let all_axes = [Axis::Row, Axis::Column, Axis::Page, Axis::None]; for c in &cats { let count = all_axes.iter() .filter(|&&ax| v.categories_on(ax).contains(&c.as_str())) .count(); prop_assert_eq!(count, 1, "category '{}' should be on exactly one axis, found {}", c, count); } } /// on_category_added is idempotent: adding same cat twice keeps original axis #[test] fn on_category_added_idempotent(cats in unique_cat_names()) { let mut v = View::new("T"); for c in &cats { v.on_category_added(c); } let axes_before: Vec = cats.iter().map(|c| v.axis_of(c)).collect(); for c in &cats { v.on_category_added(c); } let axes_after: Vec = cats.iter().map(|c| v.axis_of(c)).collect(); prop_assert_eq!(axes_before, axes_after); } /// set_axis updates axis_of for the target category #[test] fn set_axis_updates_axis_of( cats in unique_cat_names(), target_idx in 0usize..8, axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)], ) { let mut v = View::new("T"); for c in &cats { v.on_category_added(c); } let idx = target_idx % cats.len(); let cat = &cats[idx]; v.set_axis(cat, axis); prop_assert_eq!(v.axis_of(cat), axis); } /// After set_axis(cat, X), cat is NOT in categories_on(Y) for Y ≠ X #[test] fn set_axis_exclusive( cats in unique_cat_names(), target_idx in 0usize..8, axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)], ) { let mut v = View::new("T"); for c in &cats { v.on_category_added(c); } let idx = target_idx % cats.len(); let cat = &cats[idx]; v.set_axis(cat, axis); let other_axes = [Axis::Row, Axis::Column, Axis::Page] .into_iter() .filter(|&a| a != axis); for other in other_axes { prop_assert!(!v.categories_on(other).contains(&cat.as_str()), "after set_axis({:?}), '{}' should not be in categories_on({:?})", axis, cat, other); } } /// No two categories share the same axis entry (map guarantees uniqueness by key) /// — equivalently, total count across all axes equals number of known categories #[test] fn total_category_count_consistent(cats in unique_cat_names()) { let mut v = View::new("T"); for c in &cats { v.on_category_added(c); } let total: usize = [Axis::Row, Axis::Column, Axis::Page] .iter() .map(|&ax| v.categories_on(ax).len()) .sum(); prop_assert_eq!(total, cats.len()); } /// page_selection round-trips: set then get returns the same value #[test] fn page_selection_roundtrip( cat in "[A-Za-z][a-z]{1,7}", item in "[A-Za-z][a-z]{1,7}", ) { let mut v = View::new("T"); v.set_page_selection(&cat, &item); prop_assert_eq!(v.page_selection(&cat), Some(item.as_str())); } /// hide/show round-trip: hiding then showing leaves item visible #[test] fn hide_show_roundtrip( cat in "[A-Za-z][a-z]{1,7}", item in "[A-Za-z][a-z]{1,7}", ) { let mut v = View::new("T"); v.hide_item(&cat, &item); prop_assert!(v.is_hidden(&cat, &item)); v.show_item(&cat, &item); prop_assert!(!v.is_hidden(&cat, &item)); } /// toggle_group_collapse is its own inverse #[test] fn toggle_group_collapse_involutive( cat in "[A-Za-z][a-z]{1,7}", group in "[A-Za-z][a-z]{1,7}", ) { let mut v = View::new("T"); let initial = v.is_group_collapsed(&cat, &group); v.toggle_group_collapse(&cat, &group); v.toggle_group_collapse(&cat, &group); prop_assert_eq!(v.is_group_collapsed(&cat, &group), initial); } } }