chore: move csv_path_p, restructure modules
This commit is contained in:
462
src/view/types.rs
Normal file
462
src/view/types.rs
Normal file
@ -0,0 +1,462 @@
|
||||
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<String, Axis>,
|
||||
/// For page axis: selected item per category
|
||||
pub page_selections: HashMap<String, String>,
|
||||
/// Hidden items per category
|
||||
pub hidden_items: HashMap<String, HashSet<String>>,
|
||||
/// Collapsed groups per category
|
||||
pub collapsed_groups: HashMap<String, HashSet<String>>,
|
||||
/// 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<String>) -> 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) {
|
||||
// Auto-assign: first → Row, second → Column, rest → Page
|
||||
let rows = self.categories_on(Axis::Row).len();
|
||||
let cols = self.categories_on(Axis::Column).len();
|
||||
let axis = 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<String> = self
|
||||
.categories_on(Axis::Row)
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let cols: Vec<String> = 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 → 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::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_row() {
|
||||
let mut v = view_with_cats(&["Region", "Product", "Time"]);
|
||||
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<Value = Vec<String>> {
|
||||
prop::collection::hash_set("[A-Za-z][a-z]{1,7}", 1usize..=8)
|
||||
.prop_map(|s| s.into_iter().collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
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];
|
||||
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<Axis> = cats.iter().map(|c| v.axis_of(c)).collect();
|
||||
for c in &cats { v.on_category_added(c); }
|
||||
let axes_after: Vec<Axis> = 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)],
|
||||
) {
|
||||
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)],
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user