chore: move csv_path_p, restructure modules

This commit is contained in:
Edward Langley
2026-04-02 09:58:08 -07:00
parent 368b303eac
commit 2c9d9c7de7
6 changed files with 12 additions and 10 deletions

462
src/view/types.rs Normal file
View 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);
}
}
}