- model/cell.rs: CellKey (sorting, get, with, without, matches_partial, display) and DataStore (set/get, overwrite, empty-removes-key, sum_matching, matching_cells, text exclusion) - model/category.rs: item ids, deduplication, group assignment, top_level_groups, item_index insertion order - formula/parser.rs: subtraction, WHERE clause, SUM/AVG, IF, numeric literal, chained arithmetic, error on missing = - view/view.rs: auto-axis assignment, set_axis, categories_on, page_selection, group collapse toggle, hide/show, cycle_axis (all transitions + scroll/selection reset) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
195 lines
5.1 KiB
Rust
195 lines
5.1 KiB
Rust
use indexmap::IndexMap;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
pub type CategoryId = usize;
|
|
pub type ItemId = usize;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Item {
|
|
pub id: ItemId,
|
|
pub name: String,
|
|
/// Parent group name, if any
|
|
pub group: Option<String>,
|
|
}
|
|
|
|
impl Item {
|
|
pub fn new(id: ItemId, name: impl Into<String>) -> Self {
|
|
Self { id, name: name.into(), group: None }
|
|
}
|
|
|
|
pub fn with_group(mut self, group: impl Into<String>) -> Self {
|
|
self.group = Some(group.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Group {
|
|
pub name: String,
|
|
/// Parent group name for nested hierarchies
|
|
pub parent: Option<String>,
|
|
}
|
|
|
|
impl Group {
|
|
pub fn new(name: impl Into<String>) -> Self {
|
|
Self { name: name.into(), parent: None }
|
|
}
|
|
|
|
pub fn with_parent(mut self, parent: impl Into<String>) -> Self {
|
|
self.parent = Some(parent.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Category {
|
|
pub id: CategoryId,
|
|
pub name: String,
|
|
/// Items in insertion order
|
|
pub items: IndexMap<String, Item>,
|
|
/// Named groups (hierarchy nodes)
|
|
pub groups: Vec<Group>,
|
|
/// Next item id counter
|
|
next_item_id: ItemId,
|
|
}
|
|
|
|
impl Category {
|
|
pub fn new(id: CategoryId, name: impl Into<String>) -> Self {
|
|
Self {
|
|
id,
|
|
name: name.into(),
|
|
items: IndexMap::new(),
|
|
groups: Vec::new(),
|
|
next_item_id: 0,
|
|
}
|
|
}
|
|
|
|
pub fn add_item(&mut self, name: impl Into<String>) -> ItemId {
|
|
let name = name.into();
|
|
if let Some(item) = self.items.get(&name) {
|
|
return item.id;
|
|
}
|
|
let id = self.next_item_id;
|
|
self.next_item_id += 1;
|
|
self.items.insert(name.clone(), Item::new(id, name));
|
|
id
|
|
}
|
|
|
|
pub fn add_item_in_group(&mut self, name: impl Into<String>, group: impl Into<String>) -> ItemId {
|
|
let name = name.into();
|
|
let group = group.into();
|
|
if let Some(item) = self.items.get(&name) {
|
|
return item.id;
|
|
}
|
|
let id = self.next_item_id;
|
|
self.next_item_id += 1;
|
|
self.items.insert(name.clone(), Item::new(id, name).with_group(group));
|
|
id
|
|
}
|
|
|
|
pub fn add_group(&mut self, group: Group) {
|
|
if !self.groups.iter().any(|g| g.name == group.name) {
|
|
self.groups.push(group);
|
|
}
|
|
}
|
|
|
|
pub fn item_by_name(&self, name: &str) -> Option<&Item> {
|
|
self.items.get(name)
|
|
}
|
|
|
|
pub fn item_index(&self, name: &str) -> Option<usize> {
|
|
self.items.get_index_of(name)
|
|
}
|
|
|
|
/// Returns item names in order, grouped hierarchically
|
|
pub fn ordered_item_names(&self) -> Vec<&str> {
|
|
self.items.keys().map(|s| s.as_str()).collect()
|
|
}
|
|
|
|
/// Returns unique group names at the top level
|
|
pub fn top_level_groups(&self) -> Vec<&str> {
|
|
let mut seen = Vec::new();
|
|
for item in self.items.values() {
|
|
if let Some(g) = &item.group {
|
|
if !seen.contains(&g.as_str()) {
|
|
seen.push(g.as_str());
|
|
}
|
|
}
|
|
}
|
|
seen
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{Category, Group};
|
|
|
|
fn cat() -> Category {
|
|
Category::new(0, "Region")
|
|
}
|
|
|
|
#[test]
|
|
fn add_item_returns_sequential_ids() {
|
|
let mut c = cat();
|
|
let id0 = c.add_item("East");
|
|
let id1 = c.add_item("West");
|
|
assert_eq!(id0, 0);
|
|
assert_eq!(id1, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn add_item_duplicate_returns_same_id() {
|
|
let mut c = cat();
|
|
let id1 = c.add_item("East");
|
|
let id2 = c.add_item("East");
|
|
assert_eq!(id1, id2);
|
|
assert_eq!(c.items.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn add_item_in_group_sets_group() {
|
|
let mut c = cat();
|
|
c.add_item_in_group("Jan", "Q1");
|
|
let item = c.item_by_name("Jan").unwrap();
|
|
assert_eq!(item.group.as_deref(), Some("Q1"));
|
|
}
|
|
|
|
#[test]
|
|
fn add_item_in_group_duplicate_returns_same_id() {
|
|
let mut c = cat();
|
|
let id1 = c.add_item_in_group("Jan", "Q1");
|
|
let id2 = c.add_item_in_group("Jan", "Q1");
|
|
assert_eq!(id1, id2);
|
|
assert_eq!(c.items.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn add_group_deduplicates() {
|
|
let mut c = cat();
|
|
c.add_group(Group::new("Q1"));
|
|
c.add_group(Group::new("Q1"));
|
|
assert_eq!(c.groups.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn top_level_groups_returns_unique_groups_in_insertion_order() {
|
|
let mut c = cat();
|
|
c.add_item_in_group("Jan", "Q1");
|
|
c.add_item_in_group("Feb", "Q1");
|
|
c.add_item_in_group("Apr", "Q2");
|
|
let groups = c.top_level_groups();
|
|
assert_eq!(groups, vec!["Q1", "Q2"]);
|
|
}
|
|
|
|
#[test]
|
|
fn item_index_reflects_insertion_order() {
|
|
let mut c = cat();
|
|
c.add_item("East");
|
|
c.add_item("West");
|
|
c.add_item("North");
|
|
assert_eq!(c.item_index("East"), Some(0));
|
|
assert_eq!(c.item_index("West"), Some(1));
|
|
assert_eq!(c.item_index("North"), Some(2));
|
|
}
|
|
}
|