Add view navigation history with back/forward stacks (bound to < and >). Introduce CategoryKind enum to distinguish regular categories from virtual ones (_Index, _Dim) that are synthesized at query time. Add DrillIntoCell command that creates a drill view showing raw data for an aggregated cell, expanding categories on Axis::None into Row and Column axes while filtering by the cell's fixed coordinates. Virtual categories default to Axis::None and are automatically added to all views when the model is initialized. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
477 lines
16 KiB
Rust
477 lines
16 KiB
Rust
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) {
|
|
// 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<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 → 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<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, 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<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), 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);
|
|
}
|
|
}
|
|
}
|