414 lines
14 KiB
Rust
414 lines
14 KiB
Rust
//! A [`Workbook`] is a [`Model`] plus its named [`View`]s, and the home of
|
|
//! every cross-slice operation. Why: context/code/core.md §10.
|
|
|
|
use anyhow::{Result, anyhow};
|
|
use indexmap::IndexMap;
|
|
|
|
use crate::model::Model;
|
|
use crate::model::category::{CategoryId, CategoryKind, ItemId};
|
|
use crate::view::{Axis, DEFAULT_VIEW, View};
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Workbook {
|
|
pub model: Model,
|
|
pub views: IndexMap<String, View>,
|
|
pub active_view: String,
|
|
}
|
|
|
|
impl Workbook {
|
|
/// A fresh `Model` and one `Default` view, with the three virtuals
|
|
/// registered. The axis choices are argued in context/code/core.md §10.
|
|
pub fn new(name: impl Into<String>) -> Self {
|
|
let model = Model::new(name);
|
|
let mut views = IndexMap::new();
|
|
views.insert(DEFAULT_VIEW.to_string(), View::new(DEFAULT_VIEW));
|
|
let mut wb = Self {
|
|
model,
|
|
views,
|
|
active_view: DEFAULT_VIEW.to_string(),
|
|
};
|
|
for view in wb.views.values_mut() {
|
|
for cat in wb.model.categories.values() {
|
|
view.on_category_added(&cat.name, cat.kind);
|
|
}
|
|
view.set_axis("_Measure", Axis::Page);
|
|
}
|
|
wb
|
|
}
|
|
|
|
// ── Cross-slice category management ─────────────────────────────────────
|
|
|
|
/// Add a regular pivot category and register it with every view.
|
|
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
|
|
let name = name.into();
|
|
let id = self.model.add_category(&name)?;
|
|
for view in self.views.values_mut() {
|
|
view.on_category_added(&name, CategoryKind::Regular);
|
|
}
|
|
Ok(id)
|
|
}
|
|
|
|
/// Add a label category (outside the pivot-count limit) and register it
|
|
/// with every view on `Axis::None`.
|
|
pub fn add_label_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
|
|
let name = name.into();
|
|
let id = self.model.add_label_category(&name)?;
|
|
for view in self.views.values_mut() {
|
|
view.on_category_added(&name, CategoryKind::Label);
|
|
}
|
|
Ok(id)
|
|
}
|
|
|
|
/// Remove a category from the model and from every view.
|
|
pub fn remove_category(&mut self, name: &str) {
|
|
self.model.remove_category(name);
|
|
for view in self.views.values_mut() {
|
|
view.on_category_removed(name);
|
|
}
|
|
}
|
|
|
|
// ── Item management ─────────────────────────────────────────────────────
|
|
|
|
/// Add an item to a category, erroring on an unknown one so callers need
|
|
/// no handling of their own (improvise-tvt).
|
|
pub fn add_item(&mut self, cat_name: &str, item: impl Into<String>) -> Result<ItemId> {
|
|
Ok(self.category_mut(cat_name)?.add_item(item))
|
|
}
|
|
|
|
/// Add an item to a category, filed under `group`.
|
|
pub fn add_item_in_group(
|
|
&mut self,
|
|
cat_name: &str,
|
|
item: impl Into<String>,
|
|
group: impl Into<String>,
|
|
) -> Result<ItemId> {
|
|
Ok(self.category_mut(cat_name)?.add_item_in_group(item, group))
|
|
}
|
|
|
|
/// Remove an item from a category. Unknown categories and items are
|
|
/// ignored, matching [`Model::remove_item`].
|
|
pub fn remove_item(&mut self, cat_name: &str, item_name: &str) {
|
|
self.model.remove_item(cat_name, item_name);
|
|
for view in self.views.values_mut() {
|
|
view.on_item_removed(cat_name, item_name);
|
|
}
|
|
}
|
|
|
|
fn category_mut(&mut self, cat_name: &str) -> Result<&mut crate::model::category::Category> {
|
|
self.model
|
|
.category_mut(cat_name)
|
|
.ok_or_else(|| anyhow!("Unknown category '{cat_name}'"))
|
|
}
|
|
|
|
// ── Active view access ──────────────────────────────────────────────────
|
|
|
|
pub fn active_view(&self) -> &View {
|
|
self.views
|
|
.get(&self.active_view)
|
|
.expect("active_view always names an existing view")
|
|
}
|
|
|
|
pub fn active_view_mut(&mut self) -> &mut View {
|
|
self.views
|
|
.get_mut(&self.active_view)
|
|
.expect("active_view always names an existing view")
|
|
}
|
|
|
|
// ── View management ─────────────────────────────────────────────────────
|
|
|
|
/// A new view pre-populated with every existing category. Does not change
|
|
/// the active view.
|
|
pub fn create_view(&mut self, name: impl Into<String>) -> &mut View {
|
|
let name = name.into();
|
|
let mut view = View::new(name.clone());
|
|
for cat in self.model.categories.values() {
|
|
view.on_category_added(&cat.name, cat.kind);
|
|
}
|
|
self.views.insert(name.clone(), view);
|
|
self.views.get_mut(&name).unwrap()
|
|
}
|
|
|
|
pub fn switch_view(&mut self, name: &str) -> Result<()> {
|
|
if self.views.contains_key(name) {
|
|
self.active_view = name.to_string();
|
|
Ok(())
|
|
} else {
|
|
Err(anyhow!("View '{name}' not found"))
|
|
}
|
|
}
|
|
|
|
pub fn delete_view(&mut self, name: &str) -> Result<()> {
|
|
if self.views.len() <= 1 {
|
|
return Err(anyhow!("Cannot delete the last view"));
|
|
}
|
|
self.views.shift_remove(name);
|
|
if self.active_view == name {
|
|
self.active_view = self.views.keys().next().unwrap().clone();
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::Workbook;
|
|
use crate::view::Axis;
|
|
|
|
#[test]
|
|
fn new_workbook_has_default_view_with_virtuals_seeded() {
|
|
let wb = Workbook::new("Test");
|
|
assert_eq!(wb.active_view, "Default");
|
|
let v = wb.active_view();
|
|
// improvise-kos (commit 709f2df): virtuals default to None, _Measure
|
|
// is bumped to Page. context/code/core.md §10.
|
|
assert_eq!(v.axis_of("_Index"), Axis::None);
|
|
assert_eq!(v.axis_of("_Dim"), Axis::None);
|
|
assert_eq!(v.axis_of("_Measure"), Axis::Page);
|
|
}
|
|
|
|
#[test]
|
|
fn add_category_notifies_all_views() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.create_view("Secondary");
|
|
wb.add_category("Region").unwrap();
|
|
// Both views should know about Region (axis_of panics on unknown).
|
|
let _ = wb.views.get("Default").unwrap().axis_of("Region");
|
|
let _ = wb.views.get("Secondary").unwrap().axis_of("Region");
|
|
}
|
|
|
|
#[test]
|
|
fn add_label_category_sets_none_axis_on_all_views() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.create_view("Other");
|
|
wb.add_label_category("Note").unwrap();
|
|
assert_eq!(wb.views.get("Default").unwrap().axis_of("Note"), Axis::None);
|
|
assert_eq!(wb.views.get("Other").unwrap().axis_of("Note"), Axis::None);
|
|
}
|
|
|
|
/// A view created *after* the label must seat it off the pivot axes too.
|
|
/// Evidence: `context/test-evidence/core.md`.
|
|
#[test]
|
|
fn a_view_created_after_a_label_still_keeps_it_off_the_axes() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_label_category("Description").unwrap();
|
|
assert_eq!(wb.active_view().axis_of("Description"), Axis::None);
|
|
assert_eq!(wb.create_view("Other").axis_of("Description"), Axis::None);
|
|
}
|
|
|
|
/// The control for the test above: a regular category added the same way
|
|
/// still takes the first free pivot slot, so `None` is not the blanket answer.
|
|
#[test]
|
|
fn a_view_created_after_a_regular_category_still_seats_it_on_row() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
assert_eq!(wb.create_view("Other").axis_of("Region"), Axis::Row);
|
|
}
|
|
|
|
/// A label must not consume the Row slot a later regular category wants.
|
|
/// Evidence: `context/test-evidence/core.md`.
|
|
#[test]
|
|
fn a_label_does_not_displace_a_regular_category_in_a_new_view() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_label_category("Description").unwrap();
|
|
wb.add_category("Region").unwrap();
|
|
|
|
let v = wb.create_view("Other");
|
|
assert_eq!(v.axis_of("Description"), Axis::None);
|
|
assert_eq!(
|
|
v.axis_of("Region"),
|
|
Axis::Row,
|
|
"Row was free — the label must not have taken it"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_category_removes_from_all_views() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
wb.create_view("Second");
|
|
wb.remove_category("Region");
|
|
// Region should no longer appear in either view's Row axis.
|
|
assert!(
|
|
wb.views
|
|
.get("Default")
|
|
.unwrap()
|
|
.categories_on(Axis::Row)
|
|
.iter()
|
|
.all(|c| *c != "Region")
|
|
);
|
|
assert!(
|
|
wb.views
|
|
.get("Second")
|
|
.unwrap()
|
|
.categories_on(Axis::Row)
|
|
.iter()
|
|
.all(|c| *c != "Region")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn add_item_puts_the_item_in_the_category() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
wb.add_item("Region", "East").unwrap();
|
|
assert!(
|
|
wb.model
|
|
.category("Region")
|
|
.unwrap()
|
|
.items
|
|
.contains_key("East")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn add_item_on_unknown_category_errors() {
|
|
let mut wb = Workbook::new("Test");
|
|
let err = wb.add_item("Nope", "East").unwrap_err();
|
|
assert_eq!(err.to_string(), "Unknown category 'Nope'");
|
|
}
|
|
|
|
#[test]
|
|
fn add_item_in_group_files_the_item_under_the_group() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
wb.add_item_in_group("Region", "East", "Coastal").unwrap();
|
|
let item = &wb.model.category("Region").unwrap().items["East"];
|
|
assert_eq!(item.group.as_deref(), Some("Coastal"));
|
|
}
|
|
|
|
#[test]
|
|
fn add_item_in_group_on_unknown_category_errors() {
|
|
let mut wb = Workbook::new("Test");
|
|
assert!(wb.add_item_in_group("Nope", "East", "Coastal").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn remove_item_takes_the_item_out_of_the_category() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
wb.add_item("Region", "East").unwrap();
|
|
wb.remove_item("Region", "East");
|
|
assert!(
|
|
!wb.model
|
|
.category("Region")
|
|
.unwrap()
|
|
.items
|
|
.contains_key("East")
|
|
);
|
|
}
|
|
|
|
/// Arm 1 of improvise-qx1z through the facade: `Model::remove_item` touches
|
|
/// no view, so the cross-view sweep is `Workbook`'s job (gotcha 6).
|
|
#[test]
|
|
fn remove_item_unhides_it_in_every_view() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
wb.add_item("Region", "East").unwrap();
|
|
wb.create_view("Other");
|
|
for name in ["Default", "Other"] {
|
|
wb.views.get_mut(name).unwrap().hide_item("Region", "East");
|
|
}
|
|
wb.remove_item("Region", "East");
|
|
wb.add_item("Region", "East").unwrap();
|
|
for name in ["Default", "Other"] {
|
|
assert!(
|
|
!wb.views[name].is_hidden("Region", "East"),
|
|
"{name} brought East back hidden"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// improvise-g3no through the facade both front ends actually call:
|
|
/// evidence in `context/test-evidence/core.md`.
|
|
#[test]
|
|
fn removing_a_measure_row_that_is_only_a_formula_target_removes_the_formula() {
|
|
let mut wb = Workbook::new("T");
|
|
wb.add_item("_Measure", "Revenue").unwrap();
|
|
wb.add_item("_Measure", "Cost").unwrap();
|
|
wb.model.add_formula(
|
|
crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
|
|
);
|
|
assert!(
|
|
wb.model
|
|
.effective_item_names("_Measure")
|
|
.contains(&"Profit".to_string())
|
|
);
|
|
|
|
wb.remove_item("_Measure", "Profit");
|
|
|
|
assert_eq!(
|
|
wb.model.effective_item_names("_Measure"),
|
|
vec!["Revenue".to_string(), "Cost".to_string()],
|
|
"the row came back, and both front ends said it was deleted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_item_on_unknown_category_is_a_no_op() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.remove_item("Nope", "East");
|
|
}
|
|
|
|
#[test]
|
|
fn switch_view_changes_active_view() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.create_view("Other");
|
|
wb.switch_view("Other").unwrap();
|
|
assert_eq!(wb.active_view, "Other");
|
|
}
|
|
|
|
#[test]
|
|
fn switch_view_unknown_returns_error() {
|
|
let mut wb = Workbook::new("Test");
|
|
assert!(wb.switch_view("NoSuchView").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn delete_view_removes_it() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.create_view("Extra");
|
|
wb.delete_view("Extra").unwrap();
|
|
assert!(!wb.views.contains_key("Extra"));
|
|
}
|
|
|
|
#[test]
|
|
fn delete_last_view_returns_error() {
|
|
let wb = Workbook::new("Test");
|
|
// Use wb without binding mut — delete_view would need &mut, so:
|
|
let mut wb = wb;
|
|
assert!(wb.delete_view("Default").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn delete_active_view_switches_to_another() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.create_view("Other");
|
|
wb.switch_view("Other").unwrap();
|
|
wb.delete_view("Other").unwrap();
|
|
assert_ne!(wb.active_view, "Other");
|
|
}
|
|
|
|
#[test]
|
|
fn first_category_goes_to_row_second_to_column_rest_to_page() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
wb.add_category("Product").unwrap();
|
|
wb.add_category("Time").unwrap();
|
|
let v = wb.active_view();
|
|
assert_eq!(v.axis_of("Region"), Axis::Row);
|
|
assert_eq!(v.axis_of("Product"), Axis::Column);
|
|
assert_eq!(v.axis_of("Time"), Axis::Page);
|
|
}
|
|
|
|
#[test]
|
|
fn create_view_copies_category_structure() {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
wb.add_category("Product").unwrap();
|
|
wb.create_view("Secondary");
|
|
let v = wb.views.get("Secondary").unwrap();
|
|
let _ = v.axis_of("Region");
|
|
let _ = v.axis_of("Product");
|
|
}
|
|
}
|