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:
229
crates/improvise-core/src/format.rs
Normal file
229
crates/improvise-core/src/format.rs
Normal 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), "");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
222
crates/improvise-core/src/model/category.rs
Normal file
222
crates/improvise-core/src/model/category.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
650
crates/improvise-core/src/model/cell.rs
Normal file
650
crates/improvise-core/src/model/cell.rs
Normal 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
crates/improvise-core/src/model/mod.rs
Normal file
6
crates/improvise-core/src/model/mod.rs
Normal file
@ -0,0 +1,6 @@
|
||||
pub mod category;
|
||||
pub mod cell;
|
||||
pub mod symbol;
|
||||
pub mod types;
|
||||
|
||||
pub use types::Model;
|
||||
79
crates/improvise-core/src/model/symbol.rs
Normal file
79
crates/improvise-core/src/model/symbol.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
2062
crates/improvise-core/src/model/types.rs
Normal file
2062
crates/improvise-core/src/model/types.rs
Normal file
File diff suppressed because it is too large
Load Diff
21
crates/improvise-core/src/view/axis.rs
Normal file
21
crates/improvise-core/src/view/axis.rs
Normal 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 ∅"),
|
||||
}
|
||||
}
|
||||
}
|
||||
1140
crates/improvise-core/src/view/layout.rs
Normal file
1140
crates/improvise-core/src/view/layout.rs
Normal file
File diff suppressed because it is too large
Load Diff
7
crates/improvise-core/src/view/mod.rs
Normal file
7
crates/improvise-core/src/view/mod.rs
Normal 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;
|
||||
531
crates/improvise-core/src/view/types.rs
Normal file
531
crates/improvise-core/src/view/types.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
267
crates/improvise-core/src/workbook.rs
Normal file
267
crates/improvise-core/src/workbook.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user