feat: group-aware grid rendering and hide/show item

Builds out two half-finished view features:

Group collapse:
- AxisEntry enum distinguishes GroupHeader from DataItem on grid axes
- expand_category() emits group headers and filters collapsed items
- Grid renders inline group header rows with ▼/▶ indicator
- `z` keybinding toggles collapse of nearest group above cursor

Hide/show item:
- Restore show_item() (was commented out alongside hide_item)
- Add HideItem / ShowItem commands and dispatch
- `H` keybinding hides the current row item
- `:show-item <cat> <item>` command to restore hidden items
- Restore silenced test assertions for hide/show round-trip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-03-31 00:07:11 -07:00
parent 3cf64b40a3
commit 37584670eb
8 changed files with 1735 additions and 555 deletions

View File

@ -1,62 +1,156 @@
use crate::model::Model;
use crate::model::cell::CellKey;
use crate::model::Model;
use crate::view::{Axis, View};
/// One entry on a grid axis: either a visual group header or a data-item tuple.
///
/// `GroupHeader` entries are always visible so the user can see the group label
/// and toggle its collapse state. `DataItem` entries are absent when their
/// group is collapsed.
#[derive(Debug, Clone, PartialEq)]
pub enum AxisEntry {
GroupHeader {
cat_name: String,
group_name: String,
},
DataItem(Vec<String>),
}
/// The resolved 2-D layout of a view: which item tuples appear on each axis,
/// what page filter is active, and how to map (row, col) → CellKey.
///
/// This is the single authoritative place that converts the multi-dimensional
/// model into the flat grid consumed by both the terminal renderer and CSV exporter.
pub struct GridLayout {
pub row_cats: Vec<String>,
pub col_cats: Vec<String>,
pub row_cats: Vec<String>,
pub col_cats: Vec<String>,
pub page_coords: Vec<(String, String)>,
pub row_items: Vec<Vec<String>>,
pub col_items: Vec<Vec<String>>,
pub row_items: Vec<AxisEntry>,
pub col_items: Vec<AxisEntry>,
}
impl GridLayout {
pub fn new(model: &Model, view: &View) -> Self {
let row_cats: Vec<String> = view.categories_on(Axis::Row)
.into_iter().map(String::from).collect();
let col_cats: Vec<String> = view.categories_on(Axis::Column)
.into_iter().map(String::from).collect();
let page_cats: Vec<String> = view.categories_on(Axis::Page)
.into_iter().map(String::from).collect();
let row_cats: Vec<String> = view
.categories_on(Axis::Row)
.into_iter()
.map(String::from)
.collect();
let col_cats: Vec<String> = view
.categories_on(Axis::Column)
.into_iter()
.map(String::from)
.collect();
let page_cats: Vec<String> = view
.categories_on(Axis::Page)
.into_iter()
.map(String::from)
.collect();
let page_coords = page_cats.iter().map(|cat| {
let items: Vec<String> = model.category(cat)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default();
let sel = view.page_selection(cat)
.map(String::from)
.or_else(|| items.first().cloned())
.unwrap_or_default();
(cat.clone(), sel)
}).collect();
let page_coords = page_cats
.iter()
.map(|cat| {
let items: Vec<String> = model
.category(cat)
.map(|c| {
c.ordered_item_names()
.into_iter()
.map(String::from)
.collect()
})
.unwrap_or_default();
let sel = view
.page_selection(cat)
.map(String::from)
.or_else(|| items.first().cloned())
.unwrap_or_default();
(cat.clone(), sel)
})
.collect();
let row_items = cross_product(model, view, &row_cats);
let col_items = cross_product(model, view, &col_cats);
Self { row_cats, col_cats, page_coords, row_items, col_items }
Self {
row_cats,
col_cats,
page_coords,
row_items,
col_items,
}
}
pub fn row_count(&self) -> usize { self.row_items.len() }
pub fn col_count(&self) -> usize { self.col_items.len() }
/// Number of data rows (group headers excluded).
pub fn row_count(&self) -> usize {
self.row_items
.iter()
.filter(|e| matches!(e, AxisEntry::DataItem(_)))
.count()
}
/// Number of data columns (group headers excluded).
pub fn col_count(&self) -> usize {
self.col_items
.iter()
.filter(|e| matches!(e, AxisEntry::DataItem(_)))
.count()
}
pub fn row_label(&self, row: usize) -> String {
self.row_items.get(row).map(|r| r.join("/")).unwrap_or_default()
self.row_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
}
})
.nth(row)
.map(|r| r.join("/"))
.unwrap_or_default()
}
pub fn col_label(&self, col: usize) -> String {
self.col_items.get(col).map(|c| c.join("/")).unwrap_or_default()
self.col_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
}
})
.nth(col)
.map(|c| c.join("/"))
.unwrap_or_default()
}
/// Build the CellKey for the data cell at (row, col), including the active
/// page-axis filter. Returns None if row or col is out of bounds.
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
let row_item = self.row_items.get(row)?;
let col_item = self.col_items.get(col)?;
let row_item = self
.row_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
}
})
.nth(row)?;
let col_item = self
.col_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
}
})
.nth(col)?;
let mut coords = self.page_coords.clone();
for (cat, item) in self.row_cats.iter().zip(row_item.iter()) {
coords.push((cat.clone(), item.clone()));
@ -66,48 +160,126 @@ impl GridLayout {
}
Some(CellKey::new(coords))
}
/// Visual index of the nth data row (skipping group headers).
pub fn data_row_to_visual(&self, data_row: usize) -> Option<usize> {
let mut count = 0;
for (vi, entry) in self.row_items.iter().enumerate() {
if let AxisEntry::DataItem(_) = entry {
if count == data_row {
return Some(vi);
}
count += 1;
}
}
None
}
/// Visual index of the nth data column (skipping group headers).
pub fn data_col_to_visual(&self, data_col: usize) -> Option<usize> {
let mut count = 0;
for (vi, entry) in self.col_items.iter().enumerate() {
if let AxisEntry::DataItem(_) = entry {
if count == data_col {
return Some(vi);
}
count += 1;
}
}
None
}
}
/// Expand a single category into `AxisEntry` values, given a coordinate prefix.
/// Emits a `GroupHeader` at each group boundary, then `DataItem` entries for
/// visible, non-collapsed items.
fn expand_category(
model: &Model,
view: &View,
cat_name: &str,
prefix: Vec<String>,
) -> Vec<AxisEntry> {
let Some(cat) = model.category(cat_name) else {
return vec![];
};
let mut result = Vec::new();
let mut last_group: Option<&str> = None;
for item_name in cat.ordered_item_names() {
if view.is_hidden(cat_name, item_name) {
continue;
}
let item_group = cat.items.get(item_name).and_then(|i| i.group.as_deref());
// Emit a group header at each group boundary.
if item_group != last_group {
if let Some(g) = item_group {
result.push(AxisEntry::GroupHeader {
cat_name: cat_name.to_string(),
group_name: g.to_string(),
});
}
last_group = item_group;
}
// Skip the data item if its group is collapsed.
if item_group.map_or(false, |g| view.is_group_collapsed(cat_name, g)) {
continue;
}
let mut row = prefix.clone();
row.push(item_name.to_string());
result.push(AxisEntry::DataItem(row));
}
result
}
/// Cartesian product of visible items across `cats`, in category order.
/// Hidden items are excluded. Returns `vec![vec![]]` when `cats` is empty.
fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<Vec<String>> {
/// Hidden items and items in collapsed groups are excluded from `DataItem`
/// entries; group headers are always emitted.
/// Returns `vec![DataItem(vec![])]` when `cats` is empty.
fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry> {
if cats.is_empty() {
return vec![vec![]];
return vec![AxisEntry::DataItem(vec![])];
}
let mut result: Vec<Vec<String>> = vec![vec![]];
let mut result: Vec<AxisEntry> = vec![AxisEntry::DataItem(vec![])];
for cat_name in cats {
let items: Vec<String> = model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter()
.filter(|item| !view.is_hidden(cat_name, item))
.map(String::from).collect())
.unwrap_or_default();
result = result.into_iter().flat_map(|prefix| {
items.iter().map(move |item| {
let mut row = prefix.clone();
row.push(item.clone());
row
result = result
.into_iter()
.flat_map(|entry| match entry {
AxisEntry::DataItem(prefix) => expand_category(model, view, cat_name, prefix),
header @ AxisEntry::GroupHeader { .. } => vec![header],
})
}).collect();
.collect();
}
result
}
#[cfg(test)]
mod tests {
use super::GridLayout;
use crate::model::Model;
use super::{AxisEntry, GridLayout};
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
fn coord(pairs: &[(&str, &str)]) -> CellKey {
CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect())
CellKey::new(
pairs
.iter()
.map(|(c, i)| (c.to_string(), i.to_string()))
.collect(),
)
}
fn two_cat_model() -> Model {
let mut m = Model::new("T");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
for item in ["Food", "Clothing"] { m.category_mut("Type").unwrap().add_item(item); }
for item in ["Jan", "Feb"] { m.category_mut("Month").unwrap().add_item(item); }
for item in ["Food", "Clothing"] {
m.category_mut("Type").unwrap().add_item(item);
}
for item in ["Jan", "Feb"] {
m.category_mut("Month").unwrap().add_item(item);
}
m
}
@ -174,8 +346,141 @@ mod tests {
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month").unwrap().add_item("Jan");
m.category_mut("Year").unwrap().add_item("2025");
m.active_view_mut().set_axis("Year", crate::view::Axis::Column);
m.active_view_mut()
.set_axis("Year", crate::view::Axis::Column);
let layout = GridLayout::new(&m, m.active_view());
assert_eq!(layout.col_label(0), "Jan/2025");
}
#[test]
fn row_count_excludes_group_headers() {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Type").unwrap();
m.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Feb", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
m.category_mut("Type").unwrap().add_item("Food");
let layout = GridLayout::new(&m, m.active_view());
assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count
}
#[test]
fn group_header_emitted_at_group_boundary() {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Type").unwrap();
m.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
m.category_mut("Type").unwrap().add_item("Food");
let layout = GridLayout::new(&m, m.active_view());
let headers: Vec<_> = layout
.row_items
.iter()
.filter(|e| matches!(e, AxisEntry::GroupHeader { .. }))
.collect();
assert_eq!(headers.len(), 2);
assert!(
matches!(&headers[0], AxisEntry::GroupHeader { group_name, .. } if group_name == "Q1")
);
assert!(
matches!(&headers[1], AxisEntry::GroupHeader { group_name, .. } if group_name == "Q2")
);
}
#[test]
fn collapsed_group_has_header_but_no_data_items() {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Type").unwrap();
m.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Feb", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
m.category_mut("Type").unwrap().add_item("Food");
m.active_view_mut().toggle_group_collapse("Month", "Q1");
let layout = GridLayout::new(&m, m.active_view());
// Q1 collapsed: header present, Jan and Feb absent; Q2 intact
assert_eq!(layout.row_count(), 1); // only Apr
let q1_header = layout
.row_items
.iter()
.find(|e| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "Q1"));
assert!(q1_header.is_some());
let jan = layout
.row_items
.iter()
.find(|e| matches!(e, AxisEntry::DataItem(v) if v.contains(&"Jan".to_string())));
assert!(jan.is_none());
}
#[test]
fn ungrouped_items_produce_no_headers() {
let m = two_cat_model();
let layout = GridLayout::new(&m, m.active_view());
assert!(!layout
.row_items
.iter()
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })));
assert!(!layout
.col_items
.iter()
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })));
}
#[test]
fn cell_key_correct_with_grouped_items() {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Type").unwrap();
m.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
m.category_mut("Type").unwrap().add_item("Food");
m.set_cell(
coord(&[("Month", "Apr"), ("Type", "Food")]),
CellValue::Number(99.0),
);
let layout = GridLayout::new(&m, m.active_view());
// data row 0 = Jan, data row 1 = Apr
let key = layout.cell_key(1, 0).unwrap();
assert_eq!(m.evaluate(&key), Some(CellValue::Number(99.0)));
}
#[test]
fn data_row_to_visual_skips_headers() {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Type").unwrap();
m.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
m.category_mut("Type").unwrap().add_item("Food");
let layout = GridLayout::new(&m, m.active_view());
// visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
assert_eq!(layout.data_row_to_visual(0), Some(1)); // Jan is at visual index 1
assert_eq!(layout.data_row_to_visual(1), Some(3)); // Apr is at visual index 3
assert_eq!(layout.data_row_to_visual(2), None);
}
}

View File

@ -1,7 +1,7 @@
pub mod view;
pub mod axis;
pub mod layout;
pub mod view;
pub use view::View;
pub use axis::Axis;
pub use layout::GridLayout;
pub use layout::{AxisEntry, GridLayout};
pub use view::View;

View File

@ -1,6 +1,6 @@
use std::collections::{HashMap, HashSet};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use super::axis::Axis;
@ -62,20 +62,23 @@ impl View {
}
pub fn axis_of(&self, cat_name: &str) -> Axis {
*self.category_axes.get(cat_name)
*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()
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());
self.page_selections
.insert(cat_name.to_string(), item.to_string());
}
pub fn page_selection(&self, cat_name: &str) -> Option<&str> {
@ -83,7 +86,10 @@ impl View {
}
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();
let set = self
.collapsed_groups
.entry(cat_name.to_string())
.or_default();
if set.contains(group_name) {
set.remove(group_name);
} else {
@ -91,34 +97,52 @@ impl View {
}
}
// 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 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 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 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)
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); }
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;
@ -127,9 +151,9 @@ impl View {
/// 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::Row => Axis::Column,
Axis::Column => Axis::Page,
Axis::Page => Axis::Row,
Axis::Page => Axis::Row,
};
self.set_axis(cat_name, next);
self.selected = (0, 0);
@ -145,7 +169,9 @@ mod tests {
fn view_with_cats(cats: &[&str]) -> View {
let mut v = View::new("Test");
for &c in cats { v.on_category_added(c); }
for &c in cats {
v.on_category_added(c);
}
v
}
@ -164,7 +190,7 @@ mod tests {
#[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("Time"), Axis::Page);
assert_eq!(v.axis_of("Scenario"), Axis::Page);
}
@ -178,19 +204,19 @@ mod tests {
#[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::Row), vec!["Region"]);
assert_eq!(v.categories_on(Axis::Column), vec!["Product"]);
assert_eq!(v.categories_on(Axis::Page), vec!["Time"]);
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("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("Region"), Axis::Column);
assert_eq!(v.axis_of("Product"), Axis::Row);
}
@ -207,7 +233,7 @@ mod tests {
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("Region"), Axis::Row);
assert_eq!(v.axis_of("Product"), Axis::Column);
}
@ -222,21 +248,32 @@ mod tests {
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("Time"), Some("Q1"));
assert_eq!(v.page_selection("Region"), None);
}
#[test]
fn toggle_group_collapse_toggles_twice() {
let collapsed = |v: &View, cat: &str, group: &str| {
v.collapsed_groups.get(cat).map(|s| s.contains(group)).unwrap_or(false)
};
let mut v = View::new("Test");
assert!(!collapsed(&v, "Time", "Q1"));
assert!(!v.is_group_collapsed("Time", "Q1"));
v.toggle_group_collapse("Time", "Q1");
assert!(collapsed(&v, "Time", "Q1"));
assert!(v.is_group_collapsed("Time", "Q1"));
v.toggle_group_collapse("Time", "Q1");
assert!(!collapsed(&v, "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]
@ -245,8 +282,8 @@ mod tests {
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"));
v.show_item("Region", "East");
assert!(!v.is_hidden("Region", "East"));
}
#[test]
@ -280,7 +317,7 @@ mod tests {
v.cycle_axis("Region");
assert_eq!(v.row_offset, 0);
assert_eq!(v.col_offset, 0);
assert_eq!(v.selected, (0, 0));
assert_eq!(v.selected, (0, 0));
}
}
@ -290,7 +327,6 @@ mod prop_tests {
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<_>>())
@ -406,8 +442,8 @@ mod prop_tests {
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));
v.show_item(&cat, &item);
prop_assert!(!v.is_hidden(&cat, &item));
}
/// toggle_group_collapse is its own inverse
@ -417,11 +453,10 @@ mod prop_tests {
group in "[A-Za-z][a-z]{1,7}",
) {
let mut v = View::new("T");
let collapsed = |v: &View| v.collapsed_groups.get(&cat).map(|s| s.contains(&group as &str)).unwrap_or(false);
let initial = collapsed(&v);
let initial = v.is_group_collapsed(&cat, &group);
v.toggle_group_collapse(&cat, &group);
v.toggle_group_collapse(&cat, &group);
prop_assert_eq!(collapsed(&v), initial);
prop_assert_eq!(v.is_group_collapsed(&cat, &group), initial);
}
}
}