refactor(core): move model, view, workbook, format into improvise-core

Relocate the four pure-data module trees into the improvise-core
sub-crate scaffolded in the previous commit. Phase A already made
these modules UI/IO-free; this commit is purely mechanical:

  git mv src/format.rs   -> crates/improvise-core/src/format.rs
  git mv src/workbook.rs -> crates/improvise-core/src/workbook.rs
  git mv src/model       -> crates/improvise-core/src/model
  git mv src/view        -> crates/improvise-core/src/view

The moved code contains no path edits: the `crate::formula::*`,
`crate::model::*`, `crate::view::*`, `crate::workbook::*`,
`crate::format::*` imports inside the four trees all continue to
resolve because the new crate mirrors the same module layout and
re-exports improvise_formula under `formula` via its lib.rs.

Main-crate `src/lib.rs` flips from declaring these as owned modules
(`pub mod model;` etc.) to re-exporting them from improvise-core
(`pub use improvise_core::model;` etc.). This keeps every
`crate::model::*`, `crate::view::*`, `crate::workbook::*`,
`crate::format::*` path inside the 26 consumer files in src/ (ui,
command, persistence, import, draw, main) resolving unchanged — no
downstream edits needed.

Verification:
- cargo check --workspace: clean
- cargo test --workspace: 612 passing (357 main + 190 core + 65 formula)
- cargo clippy --workspace --tests: clean
- cargo build -p improvise-core: standalone build succeeds, confirming
  zero UI/IO leakage into the core crate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-04-15 22:31:42 -07:00
parent 0d75c7bd0b
commit fc9d9cb52a
13 changed files with 11 additions and 5 deletions

View File

@ -0,0 +1,229 @@
use crate::model::cell::CellValue;
/// Format a CellValue for display with number formatting options.
pub fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
match v {
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),
Some(CellValue::Text(s)) => s.clone(),
Some(CellValue::Error(e)) => format!("ERR:{e}"),
None => String::new(),
}
}
/// Parse a number format string like ",.0" into (use_commas, decimal_places).
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
let comma = fmt.contains(',');
let decimals = fmt
.rfind('.')
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
.unwrap_or(0);
(comma, decimals)
}
/// Round half away from zero (the "normal" rounding people expect).
fn round_half_away(n: f64, decimals: u8) -> f64 {
let factor = 10_f64.powi(decimals as i32);
(n * factor + n.signum() * 0.5).trunc() / factor
}
/// Format an f64 with optional comma grouping and decimal places.
pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String {
let rounded = round_half_away(n, decimals);
let formatted = format!("{:.prec$}", rounded, prec = decimals as usize);
if !comma {
return formatted;
}
let (int_part, dec_part) = if let Some(dot) = formatted.find('.') {
(&formatted[..dot], Some(&formatted[dot..]))
} else {
(&formatted[..], None)
};
let is_neg = int_part.starts_with('-');
let digits = if is_neg { &int_part[1..] } else { int_part };
let mut result = String::new();
for (idx, c) in digits.chars().rev().enumerate() {
if idx > 0 && idx % 3 == 0 {
result.push(',');
}
result.push(c);
}
if is_neg {
result.push('-');
}
let mut out: String = result.chars().rev().collect();
if let Some(dec) = dec_part {
out.push_str(dec);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
// ── parse_number_format ────────────────────────────────────────────
#[test]
fn parse_comma_and_zero_decimals() {
assert_eq!(parse_number_format(",.0"), (true, 0));
}
#[test]
fn parse_comma_and_two_decimals() {
assert_eq!(parse_number_format(",.2"), (true, 2));
}
#[test]
fn parse_no_comma_two_decimals() {
assert_eq!(parse_number_format(".2"), (false, 2));
}
#[test]
fn parse_comma_only() {
assert_eq!(parse_number_format(","), (true, 0));
}
#[test]
fn parse_empty_string() {
assert_eq!(parse_number_format(""), (false, 0));
}
#[test]
fn parse_dot_no_digits_after() {
// "." has nothing after the dot — parse::<u8> fails → default 0
assert_eq!(parse_number_format("."), (false, 0));
}
#[test]
fn parse_multiple_dots_uses_last() {
// rfind picks the last dot
assert_eq!(parse_number_format(",.1.3"), (true, 3));
}
// ── format_f64 basic ───────────────────────────────────────────────
#[test]
fn format_no_comma_zero_decimals() {
assert_eq!(format_f64(1234.5, false, 0), "1235");
}
#[test]
fn format_no_comma_two_decimals() {
assert_eq!(format_f64(1234.5, false, 2), "1234.50");
}
#[test]
fn format_comma_zero_decimals() {
assert_eq!(format_f64(1234.0, true, 0), "1,234");
}
#[test]
fn format_comma_two_decimals() {
assert_eq!(format_f64(1234.56, true, 2), "1,234.56");
}
// ── comma placement boundaries ─────────────────────────────────────
#[test]
fn format_comma_exactly_three_digits() {
assert_eq!(format_f64(999.0, true, 0), "999");
}
#[test]
fn format_comma_four_digits() {
assert_eq!(format_f64(1000.0, true, 0), "1,000");
}
#[test]
fn format_comma_seven_digits() {
assert_eq!(format_f64(1234567.0, true, 0), "1,234,567");
}
#[test]
fn format_comma_millions_with_decimals() {
assert_eq!(format_f64(1234567.89, true, 2), "1,234,567.89");
}
// ── negative numbers ───────────────────────────────────────────────
#[test]
fn format_negative_with_comma() {
assert_eq!(format_f64(-1234.0, true, 0), "-1,234");
}
#[test]
fn format_negative_with_comma_and_decimals() {
assert_eq!(format_f64(-1234567.89, true, 2), "-1,234,567.89");
}
#[test]
fn format_negative_no_comma() {
assert_eq!(format_f64(-42.5, false, 1), "-42.5");
}
// ── edge values ────────────────────────────────────────────────────
#[test]
fn format_zero() {
assert_eq!(format_f64(0.0, true, 2), "0.00");
}
#[test]
fn format_small_fraction() {
assert_eq!(format_f64(0.123, true, 2), "0.12");
}
#[test]
fn format_negative_small_fraction() {
assert_eq!(format_f64(-0.5, true, 1), "-0.5");
}
// ── rounding: half-away-from-zero ─────────────────────────────────
#[test]
fn round_half_up_positive() {
// 2.5 → 3, not 2 (banker's would give 2)
assert_eq!(format_f64(2.5, false, 0), "3");
}
#[test]
fn round_half_down_negative() {
// -2.5 → -3, not -2 (away from zero)
assert_eq!(format_f64(-2.5, false, 0), "-3");
}
#[test]
fn round_half_at_one_decimal() {
// 1.25 → 1.3
assert_eq!(format_f64(1.25, false, 1), "1.3");
}
#[test]
fn round_below_half_truncates() {
assert_eq!(format_f64(1.24, false, 1), "1.2");
}
#[test]
fn round_above_half_rounds_up() {
assert_eq!(format_f64(1.26, false, 1), "1.3");
}
// ── format_value dispatch ──────────────────────────────────────────
#[test]
fn format_value_number() {
let v = CellValue::Number(1234.0);
assert_eq!(format_value(Some(&v), true, 0), "1,234");
}
#[test]
fn format_value_text() {
let v = CellValue::Text("hello".into());
assert_eq!(format_value(Some(&v), true, 2), "hello");
}
#[test]
fn format_value_none() {
assert_eq!(format_value(None, true, 2), "");
}
}

View File

@ -2,5 +2,11 @@
//! and number formatting. Depends on `improvise-formula` for AST types;
//! has no awareness of UI, I/O, or commands.
//!
//! Scaffolded empty in this commit; the modules land in the next commit.
//! Re-exports `improvise_formula` under `formula` so internal code can use
//! `crate::formula::*` paths, mirroring the main crate's convention.
pub use improvise_formula as formula;
pub mod format;
pub mod model;
pub mod view;
pub mod workbook;

View File

@ -0,0 +1,222 @@
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
}
}
/// What kind of category this is.
/// Regular categories store their items explicitly. Virtual categories
/// are synthesized at query time by the layout layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum CategoryKind {
#[default]
Regular,
/// Items are "0", "1", ... N where N = number of matching cells.
VirtualIndex,
/// Items are the names of all regular categories + "Value".
VirtualDim,
/// The measure dimension. Items come from two sources: numeric data
/// fields (listed in the file) and formula targets (added automatically
/// by add_formula). Virtual because formula-derived items are implied
/// by the formula definitions — listing them explicitly would be
/// redundant in the file format and confusing in the UI.
VirtualMeasure,
/// High-cardinality per-row field (description, id, note). Stored
/// alongside the data so it shows up in record/drill views, but
/// defaults to Axis::None and is excluded from pivot limits and the
/// auto Row/Column axis assignment.
Label,
}
impl CategoryKind {
/// True for user-managed pivot dimensions (what the category
/// count limit and auto axis assignment apply to).
pub fn is_regular(&self) -> bool {
matches!(self, CategoryKind::Regular)
}
}
#[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,
/// Whether this is a regular or virtual category
#[serde(default)]
pub kind: CategoryKind,
}
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,
kind: CategoryKind::Regular,
}
}
pub fn with_kind(mut self, kind: CategoryKind) -> Self {
self.kind = kind;
self
}
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 remove_item(&mut self, name: &str) {
self.items.shift_remove(name);
}
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);
}
}
/// Returns item names in order, grouped hierarchically
pub fn ordered_item_names(&self) -> Vec<&str> {
self.items.keys().map(|s| s.as_str()).collect()
}
}
#[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");
assert_eq!(
c.items.get("Jan").and_then(|i| i.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 item_index_reflects_insertion_order() {
let mut c = cat();
c.add_item("East");
c.add_item("West");
c.add_item("North");
assert_eq!(c.items.get_index_of("East"), Some(0));
assert_eq!(c.items.get_index_of("West"), Some(1));
assert_eq!(c.items.get_index_of("North"), Some(2));
}
}

View File

@ -0,0 +1,650 @@
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use super::symbol::{Symbol, SymbolTable};
/// A cell key is a sorted vector of (category_name, item_name) pairs.
/// Sorted by category name for canonical form.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CellKey(pub Vec<(String, String)>);
impl CellKey {
pub fn new(mut coords: Vec<(String, String)>) -> Self {
coords.sort_by(|a, b| a.0.cmp(&b.0));
Self(coords)
}
pub fn get(&self, category: &str) -> Option<&str> {
self.0
.iter()
.find(|(c, _)| c == category)
.map(|(_, v)| v.as_str())
}
pub fn with(mut self, category: impl Into<String>, item: impl Into<String>) -> Self {
let cat = category.into();
let itm = item.into();
if let Some(pos) = self.0.iter().position(|(c, _)| c == &cat) {
self.0[pos].1 = itm;
} else {
self.0.push((cat, itm));
self.0.sort_by(|a, b| a.0.cmp(&b.0));
}
self
}
pub fn without(&self, category: &str) -> Self {
Self(
self.0
.iter()
.filter(|(c, _)| c != category)
.cloned()
.collect(),
)
}
#[allow(dead_code)]
pub fn matches_partial(&self, partial: &[(String, String)]) -> bool {
partial
.iter()
.all(|(cat, item)| self.get(cat) == Some(item.as_str()))
}
}
impl std::fmt::Display for CellKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let parts: Vec<_> = self.0.iter().map(|(c, v)| format!("{c}={v}")).collect();
write!(f, "{{{}}}", parts.join(", "))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CellValue {
Number(f64),
Text(String),
/// Evaluation error (circular reference, depth overflow, etc.)
Error(String),
}
impl CellValue {
pub fn as_f64(&self) -> Option<f64> {
match self {
CellValue::Number(n) => Some(*n),
_ => None,
}
}
pub fn is_error(&self) -> bool {
matches!(self, CellValue::Error(_))
}
}
impl std::fmt::Display for CellValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CellValue::Number(n) => {
if n.fract() == 0.0 && n.abs() < 1e15 {
write!(f, "{}", *n as i64)
} else {
write!(f, "{n:.4}")
}
}
CellValue::Text(s) => write!(f, "{s}"),
CellValue::Error(msg) => write!(f, "ERR:{msg}"),
}
}
}
/// Interned representation of a CellKey — cheap to hash and compare.
/// Sorted by first element (category Symbol) for canonical form.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct InternedKey(pub Vec<(Symbol, Symbol)>);
/// Serialized as a list of (key, value) pairs so CellKey doesn't need
/// to implement the `Serialize`-as-string requirement for JSON object keys.
#[derive(Debug, Clone, Default)]
pub struct DataStore {
/// Primary storage — interned keys, insertion-ordered so records mode
/// can display rows in the order they were entered.
cells: IndexMap<InternedKey, CellValue>,
/// String interner — all category/item names are interned here.
pub symbols: SymbolTable,
/// Secondary index: interned (category, item) → set of interned keys.
index: HashMap<(Symbol, Symbol), HashSet<InternedKey>>,
}
impl Serialize for DataStore {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq;
let mut seq = s.serialize_seq(Some(self.cells.len()))?;
for (k, v) in &self.cells {
let cell_key = self.to_cell_key(k);
seq.serialize_element(&(cell_key, v))?;
}
seq.end()
}
}
impl<'de> Deserialize<'de> for DataStore {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let pairs: Vec<(CellKey, CellValue)> = Vec::deserialize(d)?;
let mut store = DataStore::default();
for (key, value) in pairs {
store.set(key, value);
}
Ok(store)
}
}
impl DataStore {
pub fn new() -> Self {
Self::default()
}
/// Intern a CellKey into an InternedKey.
pub fn intern_key(&mut self, key: &CellKey) -> InternedKey {
InternedKey(self.symbols.intern_coords(&key.0))
}
/// Convert an InternedKey back to a CellKey (string form).
pub fn to_cell_key(&self, ikey: &InternedKey) -> CellKey {
CellKey(
ikey.0
.iter()
.map(|(c, i)| {
(
self.symbols.resolve(*c).to_string(),
self.symbols.resolve(*i).to_string(),
)
})
.collect(),
)
}
/// Sort cells by their CellKey for deterministic display order.
/// Call once on entry into records mode so existing data is ordered;
/// subsequent inserts append at the end.
pub fn sort_by_key(&mut self) {
let symbols = &self.symbols;
self.cells.sort_by(|a, _, b, _| {
let resolve = |k: &InternedKey| -> Vec<(String, String)> {
k.0.iter()
.map(|(c, i)| {
(
symbols.resolve(*c).to_string(),
symbols.resolve(*i).to_string(),
)
})
.collect()
};
resolve(a).cmp(&resolve(b))
});
}
pub fn set(&mut self, key: CellKey, value: CellValue) {
let ikey = self.intern_key(&key);
// Update index for each coordinate pair
for pair in &ikey.0 {
self.index.entry(*pair).or_default().insert(ikey.clone());
}
self.cells.insert(ikey, value);
}
pub fn get(&self, key: &CellKey) -> Option<&CellValue> {
let ikey = self.lookup_key(key)?;
self.cells.get(&ikey)
}
/// Look up an InternedKey for a CellKey without interning new symbols.
fn lookup_key(&self, key: &CellKey) -> Option<InternedKey> {
let pairs: Option<Vec<(Symbol, Symbol)>> = key
.0
.iter()
.map(|(c, i)| Some((self.symbols.get(c)?, self.symbols.get(i)?)))
.collect();
pairs.map(InternedKey)
}
/// Iterate over all cells, yielding (CellKey, &CellValue) pairs.
pub fn iter_cells(&self) -> impl Iterator<Item = (CellKey, &CellValue)> {
self.cells.iter().map(|(k, v)| (self.to_cell_key(k), v))
}
pub fn remove(&mut self, key: &CellKey) {
let Some(ikey) = self.lookup_key(key) else {
return;
};
if self.cells.shift_remove(&ikey).is_some() {
for pair in &ikey.0 {
if let Some(set) = self.index.get_mut(pair) {
set.remove(&ikey);
}
}
}
}
/// Values of all cells where every coordinate in `partial` matches.
/// Hot path: avoids allocating CellKey for each result.
pub fn matching_values(&self, partial: &[(String, String)]) -> Vec<&CellValue> {
if partial.is_empty() {
return self.cells.values().collect();
}
// Intern the partial key (lookup only, no new symbols)
let interned_partial: Vec<(Symbol, Symbol)> = partial
.iter()
.filter_map(|(c, i)| Some((self.symbols.get(c)?, self.symbols.get(i)?)))
.collect();
if interned_partial.len() < partial.len() {
return vec![];
}
let mut sets: Vec<&HashSet<InternedKey>> = interned_partial
.iter()
.filter_map(|pair| self.index.get(pair))
.collect();
if sets.len() < interned_partial.len() {
return vec![];
}
sets.sort_by_key(|s| s.len());
let first = sets[0];
let rest = &sets[1..];
first
.iter()
.filter(|ikey| rest.iter().all(|s| s.contains(*ikey)))
.filter_map(|ikey| self.cells.get(ikey))
.collect()
}
/// All cells where every coordinate in `partial` matches.
/// Allocates CellKey strings for each match — use `matching_values`
/// if you only need values.
#[allow(dead_code)]
pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(CellKey, &CellValue)> {
if partial.is_empty() {
return self.iter_cells().collect();
}
let interned_partial: Vec<(Symbol, Symbol)> = partial
.iter()
.filter_map(|(c, i)| Some((self.symbols.get(c)?, self.symbols.get(i)?)))
.collect();
if interned_partial.len() < partial.len() {
return vec![];
}
let mut sets: Vec<&HashSet<InternedKey>> = interned_partial
.iter()
.filter_map(|pair| self.index.get(pair))
.collect();
if sets.len() < interned_partial.len() {
return vec![];
}
sets.sort_by_key(|s| s.len());
let first = sets[0];
let rest = &sets[1..];
first
.iter()
.filter(|ikey| rest.iter().all(|s| s.contains(*ikey)))
.filter_map(|ikey| {
let value = self.cells.get(ikey)?;
Some((self.to_cell_key(ikey), value))
})
.collect()
}
}
#[cfg(test)]
mod cell_key {
use super::CellKey;
fn key(pairs: &[(&str, &str)]) -> CellKey {
CellKey::new(
pairs
.iter()
.map(|(c, i)| (c.to_string(), i.to_string()))
.collect(),
)
}
#[test]
fn coords_are_sorted_by_category_name() {
let k = key(&[
("Region", "East"),
("Measure", "Revenue"),
("Product", "Shirts"),
]);
assert_eq!(k.0[0].0, "Measure");
assert_eq!(k.0[1].0, "Product");
assert_eq!(k.0[2].0, "Region");
}
#[test]
fn get_returns_item_for_known_category() {
let k = key(&[("Region", "East"), ("Product", "Shirts")]);
assert_eq!(k.get("Region"), Some("East"));
assert_eq!(k.get("Product"), Some("Shirts"));
}
#[test]
fn get_returns_none_for_unknown_category() {
let k = key(&[("Region", "East")]);
assert_eq!(k.get("Measure"), None);
}
#[test]
fn with_adds_new_coordinate_in_sorted_order() {
let k = key(&[("Region", "East")]).with("Measure", "Revenue");
assert_eq!(k.get("Measure"), Some("Revenue"));
assert_eq!(k.get("Region"), Some("East"));
assert_eq!(k.0[0].0, "Measure");
assert_eq!(k.0[1].0, "Region");
}
#[test]
fn with_replaces_existing_coordinate() {
let k = key(&[("Region", "East"), ("Product", "Shirts")]).with("Region", "West");
assert_eq!(k.get("Region"), Some("West"));
assert_eq!(k.0.len(), 2);
}
#[test]
fn without_removes_coordinate() {
let k = key(&[("Region", "East"), ("Product", "Shirts")]).without("Region");
assert_eq!(k.get("Region"), None);
assert_eq!(k.get("Product"), Some("Shirts"));
assert_eq!(k.0.len(), 1);
}
#[test]
fn without_missing_category_is_noop() {
let k = key(&[("Region", "East")]).without("Measure");
assert_eq!(k.0.len(), 1);
}
#[test]
fn matches_partial_full_match() {
let k = key(&[("Region", "East"), ("Product", "Shirts")]);
let partial = vec![("Region".to_string(), "East".to_string())];
assert!(k.matches_partial(&partial));
}
#[test]
fn matches_partial_empty_matches_all() {
let k = key(&[("Region", "East"), ("Product", "Shirts")]);
assert!(k.matches_partial(&[]));
}
#[test]
fn matches_partial_wrong_item_no_match() {
let k = key(&[("Region", "East"), ("Product", "Shirts")]);
let partial = vec![("Region".to_string(), "West".to_string())];
assert!(!k.matches_partial(&partial));
}
#[test]
fn matches_partial_missing_category_no_match() {
let k = key(&[("Region", "East")]);
let partial = vec![("Product".to_string(), "Shirts".to_string())];
assert!(!k.matches_partial(&partial));
}
#[test]
fn display_format() {
let k = key(&[("Region", "East")]);
assert_eq!(k.to_string(), "{Region=East}");
}
}
#[cfg(test)]
mod data_store {
use super::{CellKey, CellValue, DataStore};
fn key(pairs: &[(&str, &str)]) -> CellKey {
CellKey::new(
pairs
.iter()
.map(|(c, i)| (c.to_string(), i.to_string()))
.collect(),
)
}
#[test]
fn get_missing_returns_empty() {
let store = DataStore::new();
assert_eq!(store.get(&key(&[("Region", "East")])), None);
}
#[test]
fn set_and_get_roundtrip() {
let mut store = DataStore::new();
let k = key(&[("Region", "East"), ("Product", "Shirts")]);
store.set(k.clone(), CellValue::Number(42.0));
assert_eq!(store.get(&k), Some(&CellValue::Number(42.0)));
}
#[test]
fn overwrite_value() {
let mut store = DataStore::new();
let k = key(&[("Region", "East")]);
store.set(k.clone(), CellValue::Number(1.0));
store.set(k.clone(), CellValue::Number(99.0));
assert_eq!(store.get(&k), Some(&CellValue::Number(99.0)));
}
#[test]
fn remove_evicts_key() {
let mut store = DataStore::new();
let k = key(&[("Region", "East")]);
store.set(k.clone(), CellValue::Number(5.0));
store.remove(&k);
assert!(store.iter_cells().next().is_none());
}
#[test]
fn matching_cells_returns_correct_subset() {
let mut store = DataStore::new();
store.set(
key(&[("Measure", "Revenue"), ("Region", "East")]),
CellValue::Number(100.0),
);
store.set(
key(&[("Measure", "Revenue"), ("Region", "West")]),
CellValue::Number(200.0),
);
store.set(
key(&[("Measure", "Cost"), ("Region", "East")]),
CellValue::Number(50.0),
);
let partial = vec![("Measure".to_string(), "Revenue".to_string())];
let cells = store.matching_cells(&partial);
assert_eq!(cells.len(), 2);
let values: Vec<f64> = cells.iter().filter_map(|(_, v)| v.as_f64()).collect();
assert!(values.contains(&100.0));
assert!(values.contains(&200.0));
}
}
#[cfg(test)]
mod prop_tests {
use super::{CellKey, CellValue, DataStore};
use proptest::prelude::*;
/// Strategy: map of unique cat→item strings (HashMap guarantees unique keys).
fn pairs_map() -> impl Strategy<Value = Vec<(String, String)>> {
prop::collection::hash_map("[a-f]{1,5}", "[a-z]{1,5}", 1..6)
.prop_map(|m| m.into_iter().collect())
}
/// Strategy: finite f64 (no NaN, no infinity).
fn finite_f64() -> impl Strategy<Value = f64> {
prop::num::f64::NORMAL.prop_filter("finite", |f| f.is_finite())
}
proptest! {
// ── CellKey invariants ────────────────────────────────────────────────
/// Pairs are always in ascending category-name order after construction.
#[test]
fn cellkey_always_sorted(pairs in pairs_map()) {
let key = CellKey::new(pairs);
for w in key.0.windows(2) {
prop_assert!(w[0].0 <= w[1].0,
"out of order: {:?} then {:?}", w[0].0, w[1].0);
}
}
/// Reversing the input produces an identical key (order-independence).
#[test]
fn cellkey_order_independent(pairs in pairs_map()) {
let mut rev = pairs.clone();
rev.reverse();
prop_assert_eq!(CellKey::new(pairs), CellKey::new(rev));
}
/// get(cat) finds every pair that was passed to new().
#[test]
fn cellkey_get_retrieves_all_pairs(pairs in pairs_map()) {
let key = CellKey::new(pairs.clone());
for (cat, item) in &pairs {
prop_assert_eq!(key.get(cat), Some(item.as_str()),
"missing {}={}", cat, item);
}
}
/// with(cat, val) — if cat already exists, it is updated in-place.
#[test]
fn cellkey_with_overwrites_existing(
pairs in pairs_map(),
new_item in "[a-z]{1,5}",
) {
let key = CellKey::new(pairs.clone());
let cat = pairs[0].0.clone();
let key2 = key.with(cat.clone(), new_item.clone());
prop_assert_eq!(key2.get(&cat), Some(new_item.as_str()));
// length unchanged when cat already exists
prop_assert_eq!(key2.0.len(), pairs.len());
}
/// with(fresh_cat, val) — a brand-new category is inserted and the
/// result is still sorted.
#[test]
fn cellkey_with_adds_new_category(
pairs in pairs_map(),
// use g-z so it is unlikely to collide with a-f used in pairs_map
fresh_cat in "[g-z]{1,5}",
new_item in "[a-z]{1,5}",
) {
let key = CellKey::new(pairs.clone());
// only run if fresh_cat is truly absent
prop_assume!(!pairs.iter().any(|(c, _)| c == &fresh_cat));
let key2 = key.with(fresh_cat.clone(), new_item.clone());
prop_assert_eq!(key2.get(&fresh_cat), Some(new_item.as_str()));
prop_assert_eq!(key2.0.len(), pairs.len() + 1);
for w in key2.0.windows(2) {
prop_assert!(w[0].0 <= w[1].0, "not sorted after with()");
}
}
/// without(cat) — the removed category is absent; all others survive.
#[test]
fn cellkey_without_removes_and_preserves(pairs in pairs_map()) {
prop_assume!(pairs.len() >= 2);
let removed_cat = pairs[0].0.clone();
let key = CellKey::new(pairs.clone());
let key2 = key.without(&removed_cat);
prop_assert_eq!(key2.get(&removed_cat), None);
for (cat, item) in pairs.iter().skip(1) {
prop_assert_eq!(key2.get(cat), Some(item.as_str()));
}
}
// ── DataStore invariants ──────────────────────────────────────────────
/// Setting a value and immediately getting it back returns the same value.
#[test]
fn datastore_set_get_roundtrip(pairs in pairs_map(), val in finite_f64()) {
let key = CellKey::new(pairs);
let mut store = DataStore::default();
store.set(key.clone(), CellValue::Number(val));
prop_assert_eq!(store.get(&key), Some(&CellValue::Number(val)));
}
/// Removing after a real value: get returns None (key is evicted).
#[test]
fn datastore_empty_evicts_key(pairs in pairs_map(), val in finite_f64()) {
let key = CellKey::new(pairs);
let mut store = DataStore::default();
store.set(key.clone(), CellValue::Number(val));
store.remove(&key);
prop_assert_eq!(store.get(&key), None);
}
/// The last write to a key wins.
#[test]
fn datastore_last_write_wins(
pairs in pairs_map(),
v1 in finite_f64(),
v2 in finite_f64(),
) {
let key = CellKey::new(pairs);
let mut store = DataStore::default();
store.set(key.clone(), CellValue::Number(v1));
store.set(key.clone(), CellValue::Number(v2));
prop_assert_eq!(store.get(&key), Some(&CellValue::Number(v2)));
}
/// Two keys that differ by one coordinate are fully independent.
#[test]
fn datastore_distinct_keys_independent(
pairs in pairs_map(),
v1 in finite_f64(),
v2 in finite_f64(),
new_item in "[g-z]{1,5}",
) {
// key2 shares all categories with key1 but has a different item in
// the first category, so key1 ≠ key2.
let mut pairs2 = pairs.clone();
let changed_cat = pairs2[0].0.clone();
pairs2[0].1 = new_item.clone();
prop_assume!(pairs[0].1 != new_item); // ensure they truly differ
let key1 = CellKey::new(pairs);
let key2 = CellKey::new(pairs2);
let mut store = DataStore::default();
store.set(key1.clone(), CellValue::Number(v1));
store.set(key2.clone(), CellValue::Number(v2));
prop_assert_eq!(store.get(&key1), Some(&CellValue::Number(v1)),
"key1 corrupted after writing key2 (diff in {})", changed_cat);
prop_assert_eq!(store.get(&key2), Some(&CellValue::Number(v2)));
}
/// Every cell returned by matching_cells actually satisfies the partial key.
#[test]
fn datastore_matching_cells_all_match_partial(
pairs in pairs_map(),
val in finite_f64(),
) {
prop_assume!(pairs.len() >= 2);
let key = CellKey::new(pairs.clone());
let mut store = DataStore::default();
store.set(key, CellValue::Number(val));
// partial = first pair only
let partial = vec![pairs[0].clone()];
let results = store.matching_cells(&partial);
for (result_key, _) in &results {
prop_assert!(result_key.matches_partial(&partial),
"returned key {result_key} does not match partial {partial:?}");
}
}
}
}

View File

@ -0,0 +1,6 @@
pub mod category;
pub mod cell;
pub mod symbol;
pub mod types;
pub use types::Model;

View File

@ -0,0 +1,79 @@
use std::collections::HashMap;
/// An interned string identifier. Copy-cheap, O(1) hash and equality.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Symbol(u64);
/// Bidirectional string ↔ Symbol mapping.
#[derive(Debug, Clone, Default)]
pub struct SymbolTable {
to_id: HashMap<String, Symbol>,
to_str: Vec<String>,
}
impl SymbolTable {
#[allow(dead_code)]
pub fn new() -> Self {
Self::default()
}
/// Intern a string, returning its Symbol. Returns existing Symbol if
/// already interned.
pub fn intern(&mut self, s: &str) -> Symbol {
if let Some(&id) = self.to_id.get(s) {
return id;
}
let id = Symbol(self.to_str.len() as u64);
self.to_str.push(s.to_string());
self.to_id.insert(s.to_string(), id);
id
}
/// Look up the Symbol for a string without interning.
pub fn get(&self, s: &str) -> Option<Symbol> {
self.to_id.get(s).copied()
}
/// Resolve a Symbol back to its string.
pub fn resolve(&self, sym: Symbol) -> &str {
&self.to_str[sym.0 as usize]
}
/// Intern a (category, item) pair.
pub fn intern_pair(&mut self, cat: &str, item: &str) -> (Symbol, Symbol) {
(self.intern(cat), self.intern(item))
}
/// Intern a full coordinate list.
pub fn intern_coords(&mut self, coords: &[(String, String)]) -> Vec<(Symbol, Symbol)> {
coords.iter().map(|(c, i)| self.intern_pair(c, i)).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn intern_returns_same_id() {
let mut t = SymbolTable::new();
let a = t.intern("hello");
let b = t.intern("hello");
assert_eq!(a, b);
}
#[test]
fn different_strings_different_ids() {
let mut t = SymbolTable::new();
let a = t.intern("hello");
let b = t.intern("world");
assert_ne!(a, b);
}
#[test]
fn resolve_roundtrips() {
let mut t = SymbolTable::new();
let s = t.intern("test");
assert_eq!(t.resolve(s), "test");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Axis {
Row,
Column,
Page,
None,
}
impl std::fmt::Display for Axis {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Axis::Row => write!(f, "Row ↕"),
Axis::Column => write!(f, "Col ↔"),
Axis::Page => write!(f, "Page ☰"),
Axis::None => write!(f, "None ∅"),
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,531 @@
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use super::axis::Axis;
fn default_prune() -> bool {
true
}
#[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,
/// When true, empty rows/columns are pruned from the display.
#[serde(default = "default_prune")]
pub prune_empty: bool,
/// 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(),
prune_empty: false,
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/underscore categories default to Axis::None.
// Regular categories auto-assign: first → Row, second → Column, rest → Page.
// If a virtual currently holds Row or Column and a regular category needs
// the slot, bump the virtual to None.
let axis = if cat_name.starts_with('_') {
Axis::None
} else {
let regular_rows: Vec<String> = self
.categories_on(Axis::Row)
.into_iter()
.filter(|c| !c.starts_with('_'))
.map(String::from)
.collect();
let regular_cols: Vec<String> = self
.categories_on(Axis::Column)
.into_iter()
.filter(|c| !c.starts_with('_'))
.map(String::from)
.collect();
if regular_rows.is_empty() {
// Bump any virtual on Row to None
let bump: Vec<String> = self
.categories_on(Axis::Row)
.into_iter()
.filter(|c| c.starts_with('_'))
.map(String::from)
.collect();
for c in bump {
self.category_axes.insert(c, Axis::None);
}
Axis::Row
} else if regular_cols.is_empty() {
let bump: Vec<String> = self
.categories_on(Axis::Column)
.into_iter()
.filter(|c| c.starts_with('_'))
.map(String::from)
.collect();
for c in bump {
self.category_axes.insert(c, Axis::None);
}
Axis::Column
} else {
Axis::Page
}
};
self.category_axes.insert(cat_name.to_string(), axis);
}
}
pub fn on_category_removed(&mut self, cat_name: &str) {
self.category_axes.shift_remove(cat_name);
self.page_selections.remove(cat_name);
self.hidden_items.remove(cat_name);
}
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()
}
/// Owned-string variant of `categories_on(Axis::None)`. Used by callers
/// that need to pass the None-axis set to formula recomputation, which
/// takes `&[String]` so it can be stored without tying lifetimes to `View`.
pub fn none_cats(&self) -> Vec<String> {
self.categories_on(Axis::None)
.into_iter()
.map(String::from)
.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);
}
}
}

View File

@ -0,0 +1,267 @@
//! A [`Workbook`] wraps a pure-data [`Model`] with the set of named [`View`]s
//! that are rendered over it. Splitting the two breaks the former
//! `Model ↔ View` cycle: `Model` knows nothing about views, while `View`
//! depends on `Model` (one direction).
//!
//! Cross-slice operations — adding or removing a category, for example, must
//! update both the model's categories and every view's axis assignments
//! — live here rather than on `Model`, so `Model` stays pure data and
//! `improvise-core` can be extracted without pulling view code along.
use anyhow::{Result, anyhow};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::model::Model;
use crate::model::category::CategoryId;
use crate::view::{Axis, View};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workbook {
pub model: Model,
pub views: IndexMap<String, View>,
pub active_view: String,
}
impl Workbook {
/// Create a new workbook with a fresh `Model` and a single `Default` view.
/// Virtual categories (`_Index`, `_Dim`, `_Measure`) are registered on the
/// default view. All virtuals default to `Axis::None` via
/// `on_category_added` (see improvise-709f2df), then `_Measure` is bumped
/// to `Axis::Page` so aggregated pivot views show a single measure at a
/// time (see improvise-kos). Leaving `_Index`/`_Dim` on None keeps pivot
/// mode the default — records mode activates only when the user moves
/// both onto axes.
pub fn new(name: impl Into<String>) -> Self {
let model = Model::new(name);
let mut views = IndexMap::new();
views.insert("Default".to_string(), View::new("Default"));
let mut wb = Self {
model,
views,
active_view: "Default".to_string(),
};
for view in wb.views.values_mut() {
for cat_name in wb.model.categories.keys() {
view.on_category_added(cat_name);
}
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);
}
Ok(id)
}
/// Add a label category (excluded from 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);
view.set_axis(&name, Axis::None);
}
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);
}
}
// ── 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 ─────────────────────────────────────────────────────
/// Create a new view pre-populated with every existing category, and
/// return a mutable reference to it. 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_name in self.model.categories.keys() {
view.on_category_added(cat_name);
}
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(())
}
/// Reset all view scroll offsets to zero. Call after loading or replacing
/// a workbook so stale offsets don't render an empty grid.
pub fn normalize_view_state(&mut self) {
for view in self.views.values_mut() {
view.row_offset = 0;
view.col_offset = 0;
}
}
}
#[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();
// Virtual categories default to Axis::None; _Measure is bumped to Page
// so aggregated pivot views show a single measure by default
// (improvise-kos, improvise-709f2df).
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);
}
#[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 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");
}
}