chore: reformat
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -8,5 +8,5 @@
|
||||
pub mod dispatch;
|
||||
pub mod types;
|
||||
|
||||
pub use types::{Command, CommandResult};
|
||||
pub use dispatch::dispatch;
|
||||
pub use types::{Command, CommandResult};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
pub mod parser;
|
||||
pub mod ast;
|
||||
pub mod parser;
|
||||
|
||||
pub use ast::{AggFunc, BinOp, Expr, Formula};
|
||||
pub use parser::parse_formula;
|
||||
|
||||
@ -8,7 +8,9 @@ pub fn parse_formula(raw: &str, target_category: &str) -> Result<Formula> {
|
||||
let raw = raw.trim();
|
||||
|
||||
// Split on first `=` to get target = expression
|
||||
let eq_pos = raw.find('=').ok_or_else(|| anyhow!("Formula must contain '=': {raw}"))?;
|
||||
let eq_pos = raw
|
||||
.find('=')
|
||||
.ok_or_else(|| anyhow!("Formula must contain '=': {raw}"))?;
|
||||
let target = raw[..eq_pos].trim().to_string();
|
||||
let rest = raw[eq_pos + 1..].trim();
|
||||
|
||||
@ -54,7 +56,9 @@ fn split_where(s: &str) -> (&str, Option<&str>) {
|
||||
|
||||
fn parse_where(s: &str) -> Result<Filter> {
|
||||
// Format: Category = "Item" or Category = Item
|
||||
let eq_pos = s.find('=').ok_or_else(|| anyhow!("WHERE clause must contain '=': {s}"))?;
|
||||
let eq_pos = s
|
||||
.find('=')
|
||||
.ok_or_else(|| anyhow!("WHERE clause must contain '=': {s}"))?;
|
||||
let category = s[..eq_pos].trim().to_string();
|
||||
let item_raw = s[eq_pos + 1..].trim();
|
||||
let item = item_raw.trim_matches('"').to_string();
|
||||
@ -67,7 +71,10 @@ pub fn parse_expr(s: &str) -> Result<Expr> {
|
||||
let mut pos = 0;
|
||||
let expr = parse_add_sub(&tokens, &mut pos)?;
|
||||
if pos < tokens.len() {
|
||||
return Err(anyhow!("Unexpected token at position {pos}: {:?}", tokens[pos]));
|
||||
return Err(anyhow!(
|
||||
"Unexpected token at position {pos}: {:?}",
|
||||
tokens[pos]
|
||||
));
|
||||
}
|
||||
Ok(expr)
|
||||
}
|
||||
@ -101,20 +108,62 @@ fn tokenize(s: &str) -> Result<Vec<Token>> {
|
||||
while i < chars.len() {
|
||||
match chars[i] {
|
||||
' ' | '\t' | '\n' => i += 1,
|
||||
'+' => { tokens.push(Token::Plus); i += 1; }
|
||||
'-' => { tokens.push(Token::Minus); i += 1; }
|
||||
'*' => { tokens.push(Token::Star); i += 1; }
|
||||
'/' => { tokens.push(Token::Slash); i += 1; }
|
||||
'^' => { tokens.push(Token::Caret); i += 1; }
|
||||
'(' => { tokens.push(Token::LParen); i += 1; }
|
||||
')' => { tokens.push(Token::RParen); i += 1; }
|
||||
',' => { tokens.push(Token::Comma); i += 1; }
|
||||
'!' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Ne); i += 2; }
|
||||
'<' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Le); i += 2; }
|
||||
'>' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Ge); i += 2; }
|
||||
'<' => { tokens.push(Token::Lt); i += 1; }
|
||||
'>' => { tokens.push(Token::Gt); i += 1; }
|
||||
'=' => { tokens.push(Token::Eq); i += 1; }
|
||||
'+' => {
|
||||
tokens.push(Token::Plus);
|
||||
i += 1;
|
||||
}
|
||||
'-' => {
|
||||
tokens.push(Token::Minus);
|
||||
i += 1;
|
||||
}
|
||||
'*' => {
|
||||
tokens.push(Token::Star);
|
||||
i += 1;
|
||||
}
|
||||
'/' => {
|
||||
tokens.push(Token::Slash);
|
||||
i += 1;
|
||||
}
|
||||
'^' => {
|
||||
tokens.push(Token::Caret);
|
||||
i += 1;
|
||||
}
|
||||
'(' => {
|
||||
tokens.push(Token::LParen);
|
||||
i += 1;
|
||||
}
|
||||
')' => {
|
||||
tokens.push(Token::RParen);
|
||||
i += 1;
|
||||
}
|
||||
',' => {
|
||||
tokens.push(Token::Comma);
|
||||
i += 1;
|
||||
}
|
||||
'!' if chars.get(i + 1) == Some(&'=') => {
|
||||
tokens.push(Token::Ne);
|
||||
i += 2;
|
||||
}
|
||||
'<' if chars.get(i + 1) == Some(&'=') => {
|
||||
tokens.push(Token::Le);
|
||||
i += 2;
|
||||
}
|
||||
'>' if chars.get(i + 1) == Some(&'=') => {
|
||||
tokens.push(Token::Ge);
|
||||
i += 2;
|
||||
}
|
||||
'<' => {
|
||||
tokens.push(Token::Lt);
|
||||
i += 1;
|
||||
}
|
||||
'>' => {
|
||||
tokens.push(Token::Gt);
|
||||
i += 1;
|
||||
}
|
||||
'=' => {
|
||||
tokens.push(Token::Eq);
|
||||
i += 1;
|
||||
}
|
||||
'"' => {
|
||||
i += 1;
|
||||
let mut s = String::new();
|
||||
@ -122,7 +171,9 @@ fn tokenize(s: &str) -> Result<Vec<Token>> {
|
||||
s.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
if i < chars.len() { i += 1; }
|
||||
if i < chars.len() {
|
||||
i += 1;
|
||||
}
|
||||
tokens.push(Token::Str(s));
|
||||
}
|
||||
c if c.is_ascii_digit() || c == '.' => {
|
||||
@ -135,13 +186,25 @@ fn tokenize(s: &str) -> Result<Vec<Token>> {
|
||||
}
|
||||
c if c.is_alphabetic() || c == '_' => {
|
||||
let mut ident = String::new();
|
||||
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == ' ') {
|
||||
while i < chars.len()
|
||||
&& (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == ' ')
|
||||
{
|
||||
// Don't consume trailing spaces if next non-space is operator
|
||||
if chars[i] == ' ' {
|
||||
// Peek ahead
|
||||
let j = i + 1;
|
||||
let next_nonspace = chars[j..].iter().find(|&&c| c != ' ');
|
||||
if matches!(next_nonspace, Some('+') | Some('-') | Some('*') | Some('/') | Some('^') | Some(')') | Some(',') | None) {
|
||||
if matches!(
|
||||
next_nonspace,
|
||||
Some('+')
|
||||
| Some('-')
|
||||
| Some('*')
|
||||
| Some('/')
|
||||
| Some('^')
|
||||
| Some(')')
|
||||
| Some(',')
|
||||
| None
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -161,7 +224,7 @@ fn parse_add_sub(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
||||
let mut left = parse_mul_div(tokens, pos)?;
|
||||
while *pos < tokens.len() {
|
||||
let op = match &tokens[*pos] {
|
||||
Token::Plus => BinOp::Add,
|
||||
Token::Plus => BinOp::Add,
|
||||
Token::Minus => BinOp::Sub,
|
||||
_ => break,
|
||||
};
|
||||
@ -176,7 +239,7 @@ fn parse_mul_div(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
||||
let mut left = parse_pow(tokens, pos)?;
|
||||
while *pos < tokens.len() {
|
||||
let op = match &tokens[*pos] {
|
||||
Token::Star => BinOp::Mul,
|
||||
Token::Star => BinOp::Mul,
|
||||
Token::Slash => BinOp::Div,
|
||||
_ => break,
|
||||
};
|
||||
@ -239,19 +302,42 @@ fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
||||
if kw.to_ascii_uppercase() == "WHERE" {
|
||||
*pos += 1;
|
||||
let cat = match &tokens[*pos] {
|
||||
Token::Ident(s) => { let s = s.clone(); *pos += 1; s }
|
||||
t => return Err(anyhow!("Expected category name, got {t:?}")),
|
||||
Token::Ident(s) => {
|
||||
let s = s.clone();
|
||||
*pos += 1;
|
||||
s
|
||||
}
|
||||
t => {
|
||||
return Err(anyhow!(
|
||||
"Expected category name, got {t:?}"
|
||||
))
|
||||
}
|
||||
};
|
||||
// expect =
|
||||
if *pos < tokens.len() && tokens[*pos] == Token::Eq { *pos += 1; }
|
||||
if *pos < tokens.len() && tokens[*pos] == Token::Eq {
|
||||
*pos += 1;
|
||||
}
|
||||
let item = match &tokens[*pos] {
|
||||
Token::Str(s) | Token::Ident(s) => { let s = s.clone(); *pos += 1; s }
|
||||
Token::Str(s) | Token::Ident(s) => {
|
||||
let s = s.clone();
|
||||
*pos += 1;
|
||||
s
|
||||
}
|
||||
t => return Err(anyhow!("Expected item name, got {t:?}")),
|
||||
};
|
||||
Some(Filter { category: cat, item })
|
||||
} else { None }
|
||||
} else { None }
|
||||
} else { None };
|
||||
Some(Filter {
|
||||
category: cat,
|
||||
item,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// expect )
|
||||
if *pos < tokens.len() && tokens[*pos] == Token::RParen {
|
||||
*pos += 1;
|
||||
@ -266,9 +352,13 @@ fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
||||
if *pos < tokens.len() && tokens[*pos] == Token::LParen {
|
||||
*pos += 1;
|
||||
let cond = parse_comparison(tokens, pos)?;
|
||||
if *pos < tokens.len() && tokens[*pos] == Token::Comma { *pos += 1; }
|
||||
if *pos < tokens.len() && tokens[*pos] == Token::Comma {
|
||||
*pos += 1;
|
||||
}
|
||||
let then = parse_add_sub(tokens, pos)?;
|
||||
if *pos < tokens.len() && tokens[*pos] == Token::Comma { *pos += 1; }
|
||||
if *pos < tokens.len() && tokens[*pos] == Token::Comma {
|
||||
*pos += 1;
|
||||
}
|
||||
let else_ = parse_add_sub(tokens, pos)?;
|
||||
if *pos < tokens.len() && tokens[*pos] == Token::RParen {
|
||||
*pos += 1;
|
||||
@ -296,7 +386,9 @@ fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
||||
|
||||
fn parse_comparison(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
||||
let left = parse_add_sub(tokens, pos)?;
|
||||
if *pos >= tokens.len() { return Ok(left); }
|
||||
if *pos >= tokens.len() {
|
||||
return Ok(left);
|
||||
}
|
||||
let op = match &tokens[*pos] {
|
||||
Token::Eq => BinOp::Eq,
|
||||
Token::Ne => BinOp::Ne,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum FieldKind {
|
||||
@ -51,73 +51,76 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
|
||||
}
|
||||
}
|
||||
|
||||
fields.into_iter().map(|field| {
|
||||
let values: Vec<&Value> = records.iter()
|
||||
.filter_map(|r| r.get(&field))
|
||||
.collect();
|
||||
fields
|
||||
.into_iter()
|
||||
.map(|field| {
|
||||
let values: Vec<&Value> = records.iter().filter_map(|r| r.get(&field)).collect();
|
||||
|
||||
let all_numeric = values.iter().all(|v| v.is_number());
|
||||
let all_string = values.iter().all(|v| v.is_string());
|
||||
let all_numeric = values.iter().all(|v| v.is_number());
|
||||
let all_string = values.iter().all(|v| v.is_string());
|
||||
|
||||
if all_numeric {
|
||||
return FieldProposal {
|
||||
field,
|
||||
kind: FieldKind::Measure,
|
||||
distinct_values: vec![],
|
||||
accepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
if all_string {
|
||||
let distinct: HashSet<&str> = values.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect();
|
||||
let distinct_vec: Vec<String> = distinct.into_iter().map(String::from).collect();
|
||||
let n = distinct_vec.len();
|
||||
let _total = values.len();
|
||||
|
||||
// Check if looks like date
|
||||
let looks_like_date = distinct_vec.iter().any(|s| {
|
||||
s.contains('-') && s.len() >= 8
|
||||
|| s.starts_with("Q") && s.len() == 2
|
||||
|| ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
|
||||
.iter().any(|m| s.starts_with(m))
|
||||
});
|
||||
|
||||
if looks_like_date {
|
||||
if all_numeric {
|
||||
return FieldProposal {
|
||||
field,
|
||||
kind: FieldKind::TimeCategory,
|
||||
distinct_values: distinct_vec,
|
||||
kind: FieldKind::Measure,
|
||||
distinct_values: vec![],
|
||||
accepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
if n <= CATEGORY_THRESHOLD {
|
||||
if all_string {
|
||||
let distinct: HashSet<&str> = values.iter().filter_map(|v| v.as_str()).collect();
|
||||
let distinct_vec: Vec<String> = distinct.into_iter().map(String::from).collect();
|
||||
let n = distinct_vec.len();
|
||||
let _total = values.len();
|
||||
|
||||
// Check if looks like date
|
||||
let looks_like_date = distinct_vec.iter().any(|s| {
|
||||
s.contains('-') && s.len() >= 8
|
||||
|| s.starts_with("Q") && s.len() == 2
|
||||
|| [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct",
|
||||
"Nov", "Dec",
|
||||
]
|
||||
.iter()
|
||||
.any(|m| s.starts_with(m))
|
||||
});
|
||||
|
||||
if looks_like_date {
|
||||
return FieldProposal {
|
||||
field,
|
||||
kind: FieldKind::TimeCategory,
|
||||
distinct_values: distinct_vec,
|
||||
accepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
if n <= CATEGORY_THRESHOLD {
|
||||
return FieldProposal {
|
||||
field,
|
||||
kind: FieldKind::Category,
|
||||
distinct_values: distinct_vec,
|
||||
accepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
return FieldProposal {
|
||||
field,
|
||||
kind: FieldKind::Category,
|
||||
kind: FieldKind::Label,
|
||||
distinct_values: distinct_vec,
|
||||
accepted: true,
|
||||
accepted: false,
|
||||
};
|
||||
}
|
||||
|
||||
return FieldProposal {
|
||||
// Mixed or other: treat as label
|
||||
FieldProposal {
|
||||
field,
|
||||
kind: FieldKind::Label,
|
||||
distinct_values: distinct_vec,
|
||||
distinct_values: vec![],
|
||||
accepted: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Mixed or other: treat as label
|
||||
FieldProposal {
|
||||
field,
|
||||
kind: FieldKind::Label,
|
||||
distinct_values: vec![],
|
||||
accepted: false,
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract nested array from JSON by dot-path
|
||||
|
||||
@ -1,3 +1,2 @@
|
||||
pub mod wizard;
|
||||
pub mod analyzer;
|
||||
|
||||
pub mod wizard;
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
use serde_json::Value;
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::analyzer::{FieldKind, FieldProposal, analyze_records, extract_array_at_path, find_array_paths};
|
||||
use crate::model::Model;
|
||||
use super::analyzer::{
|
||||
analyze_records, extract_array_at_path, find_array_paths, FieldKind, FieldProposal,
|
||||
};
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
|
||||
// ── Pipeline (no UI state) ────────────────────────────────────────────────────
|
||||
|
||||
@ -75,10 +77,16 @@ impl ImportPipeline {
|
||||
|
||||
/// Build a Model from the current proposals. Pure — no side effects.
|
||||
pub fn build_model(&self) -> Result<Model> {
|
||||
let categories: Vec<&FieldProposal> = self.proposals.iter()
|
||||
.filter(|p| p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory))
|
||||
let categories: Vec<&FieldProposal> = self
|
||||
.proposals
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory)
|
||||
})
|
||||
.collect();
|
||||
let measures: Vec<&FieldProposal> = self.proposals.iter()
|
||||
let measures: Vec<&FieldProposal> = self
|
||||
.proposals
|
||||
.iter()
|
||||
.filter(|p| p.accepted && p.kind == FieldKind::Measure)
|
||||
.collect();
|
||||
|
||||
@ -112,7 +120,8 @@ impl ImportPipeline {
|
||||
let mut valid = true;
|
||||
|
||||
for cat_proposal in &categories {
|
||||
let val = map.get(&cat_proposal.field)
|
||||
let val = map
|
||||
.get(&cat_proposal.field)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string()));
|
||||
@ -128,7 +137,9 @@ impl ImportPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
if !valid { continue; }
|
||||
if !valid {
|
||||
continue;
|
||||
}
|
||||
|
||||
for measure in &measures {
|
||||
if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) {
|
||||
@ -180,7 +191,12 @@ impl ImportWizard {
|
||||
WizardStep::ReviewProposals
|
||||
};
|
||||
|
||||
Self { pipeline, step, cursor: 0, message: None }
|
||||
Self {
|
||||
pipeline,
|
||||
step,
|
||||
cursor: 0,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step transitions ──────────────────────────────────────────────────────
|
||||
@ -219,7 +235,9 @@ impl ImportWizard {
|
||||
WizardStep::ReviewProposals => self.pipeline.proposals.len(),
|
||||
_ => 0,
|
||||
};
|
||||
if len == 0 { return; }
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
if delta > 0 {
|
||||
self.cursor = (self.cursor + 1).min(len - 1);
|
||||
} else if self.cursor > 0 {
|
||||
@ -240,18 +258,22 @@ impl ImportWizard {
|
||||
if self.cursor < self.pipeline.proposals.len() {
|
||||
let p = &mut self.pipeline.proposals[self.cursor];
|
||||
p.kind = match p.kind {
|
||||
FieldKind::Category => FieldKind::Measure,
|
||||
FieldKind::Measure => FieldKind::TimeCategory,
|
||||
FieldKind::Category => FieldKind::Measure,
|
||||
FieldKind::Measure => FieldKind::TimeCategory,
|
||||
FieldKind::TimeCategory => FieldKind::Label,
|
||||
FieldKind::Label => FieldKind::Category,
|
||||
FieldKind::Label => FieldKind::Category,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model name input ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn push_name_char(&mut self, c: char) { self.pipeline.model_name.push(c); }
|
||||
pub fn pop_name_char(&mut self) { self.pipeline.model_name.pop(); }
|
||||
pub fn push_name_char(&mut self, c: char) {
|
||||
self.pipeline.model_name.push(c);
|
||||
}
|
||||
pub fn pop_name_char(&mut self) {
|
||||
self.pipeline.model_name.pop();
|
||||
}
|
||||
|
||||
// ── Delegate build to pipeline ────────────────────────────────────────────
|
||||
|
||||
@ -262,9 +284,9 @@ impl ImportWizard {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use super::ImportPipeline;
|
||||
use crate::import::analyzer::FieldKind;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn flat_array_auto_selected() {
|
||||
@ -337,7 +359,9 @@ mod tests {
|
||||
fn build_model_fails_with_no_accepted_categories() {
|
||||
let raw = json!([{"revenue": 100.0, "cost": 50.0}]);
|
||||
let mut p = ImportPipeline::new(raw);
|
||||
for prop in &mut p.proposals { prop.accepted = false; }
|
||||
for prop in &mut p.proposals {
|
||||
prop.accepted = false;
|
||||
}
|
||||
assert!(p.build_model().is_err());
|
||||
}
|
||||
|
||||
@ -364,14 +388,20 @@ mod tests {
|
||||
use crate::model::cell::CellKey;
|
||||
let k_east = CellKey::new(vec![
|
||||
("Measure".to_string(), "revenue".to_string()),
|
||||
("region".to_string(), "East".to_string()),
|
||||
("region".to_string(), "East".to_string()),
|
||||
]);
|
||||
let k_west = CellKey::new(vec![
|
||||
("Measure".to_string(), "revenue".to_string()),
|
||||
("region".to_string(), "West".to_string()),
|
||||
("region".to_string(), "West".to_string()),
|
||||
]);
|
||||
assert_eq!(model.get_cell(&k_east).and_then(|v| v.as_f64()), Some(100.0));
|
||||
assert_eq!(model.get_cell(&k_west).and_then(|v| v.as_f64()), Some(200.0));
|
||||
assert_eq!(
|
||||
model.get_cell(&k_east).and_then(|v| v.as_f64()),
|
||||
Some(100.0)
|
||||
);
|
||||
assert_eq!(
|
||||
model.get_cell(&k_west).and_then(|v| v.as_f64()),
|
||||
Some(200.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -14,7 +14,11 @@ pub struct Item {
|
||||
|
||||
impl Item {
|
||||
pub fn new(id: ItemId, name: impl Into<String>) -> Self {
|
||||
Self { id, name: name.into(), group: None }
|
||||
Self {
|
||||
id,
|
||||
name: name.into(),
|
||||
group: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_group(mut self, group: impl Into<String>) -> Self {
|
||||
@ -32,7 +36,10 @@ pub struct Group {
|
||||
|
||||
impl Group {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self { name: name.into(), parent: None }
|
||||
Self {
|
||||
name: name.into(),
|
||||
parent: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_parent(mut self, parent: impl Into<String>) -> Self {
|
||||
@ -75,7 +82,11 @@ impl Category {
|
||||
id
|
||||
}
|
||||
|
||||
pub fn add_item_in_group(&mut self, name: impl Into<String>, group: impl Into<String>) -> ItemId {
|
||||
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) {
|
||||
@ -83,7 +94,8 @@ impl Category {
|
||||
}
|
||||
let id = self.next_item_id;
|
||||
self.next_item_id += 1;
|
||||
self.items.insert(name.clone(), Item::new(id, name).with_group(group));
|
||||
self.items
|
||||
.insert(name.clone(), Item::new(id, name).with_group(group));
|
||||
id
|
||||
}
|
||||
|
||||
@ -106,18 +118,18 @@ impl Category {
|
||||
self.items.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
|
||||
// /// Returns unique group names at the top level
|
||||
// pub fn top_level_groups(&self) -> Vec<&str> {
|
||||
// let mut seen = Vec::new();
|
||||
// for item in self.items.values() {
|
||||
// if let Some(g) = &item.group {
|
||||
// if !seen.contains(&g.as_str()) {
|
||||
// seen.push(g.as_str());
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// seen
|
||||
// }
|
||||
/// Returns unique group names in insertion order, derived from item.group fields.
|
||||
pub fn top_level_groups(&self) -> Vec<&str> {
|
||||
let mut seen = Vec::new();
|
||||
for item in self.items.values() {
|
||||
if let Some(g) = &item.group {
|
||||
if !seen.contains(&g.as_str()) {
|
||||
seen.push(g.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -150,7 +162,10 @@ mod tests {
|
||||
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"));
|
||||
assert_eq!(
|
||||
c.items.get("Jan").and_then(|i| i.group.as_deref()),
|
||||
Some("Q1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -170,15 +185,29 @@ mod tests {
|
||||
assert_eq!(c.groups.len(), 1);
|
||||
}
|
||||
|
||||
// #[test]
|
||||
// fn top_level_groups_returns_unique_groups_in_insertion_order() {
|
||||
// let mut c = cat();
|
||||
// c.add_item_in_group("Jan", "Q1");
|
||||
// c.add_item_in_group("Feb", "Q1");
|
||||
// c.add_item_in_group("Apr", "Q2");
|
||||
// let groups = c.top_level_groups();
|
||||
// assert_eq!(groups, vec!["Q1", "Q2"]);
|
||||
// }
|
||||
#[test]
|
||||
fn top_level_groups_returns_unique_groups_in_insertion_order() {
|
||||
let mut c = cat();
|
||||
c.add_item_in_group("Jan", "Q1");
|
||||
c.add_item_in_group("Feb", "Q1");
|
||||
c.add_item_in_group("Apr", "Q2");
|
||||
assert_eq!(c.top_level_groups(), vec!["Q1", "Q2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_groups_empty_for_ungrouped_category() {
|
||||
let mut c = cat();
|
||||
c.add_item("East");
|
||||
c.add_item("West");
|
||||
assert!(c.top_level_groups().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_groups_only_reflects_item_group_fields_not_groups_vec() {
|
||||
let mut c = cat();
|
||||
c.add_group(Group::new("Orphan"));
|
||||
assert!(c.top_level_groups().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_index_reflects_insertion_order() {
|
||||
@ -186,8 +215,8 @@ mod tests {
|
||||
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("East"), Some(0));
|
||||
assert_eq!(c.items.get_index_of("West"), Some(1));
|
||||
assert_eq!(c.items.get_index_of("North"), Some(2));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A cell key is a sorted vector of (category_name, item_name) pairs.
|
||||
/// Sorted by category name for canonical form.
|
||||
@ -13,7 +13,10 @@ impl CellKey {
|
||||
}
|
||||
|
||||
pub fn get(&self, category: &str) -> Option<&str> {
|
||||
self.0.iter().find(|(c, _)| c == category).map(|(_, v)| v.as_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 {
|
||||
@ -29,11 +32,19 @@ impl CellKey {
|
||||
}
|
||||
|
||||
pub fn without(&self, category: &str) -> Self {
|
||||
Self(self.0.iter().filter(|(c, _)| c != category).cloned().collect())
|
||||
Self(
|
||||
self.0
|
||||
.iter()
|
||||
.filter(|(c, _)| c != category)
|
||||
.cloned()
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn matches_partial(&self, partial: &[(String, String)]) -> bool {
|
||||
partial.iter().all(|(cat, item)| self.get(cat) == Some(item.as_str()))
|
||||
partial
|
||||
.iter()
|
||||
.all(|(cat, item)| self.get(cat) == Some(item.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,7 +134,8 @@ impl DataStore {
|
||||
|
||||
/// All cells where partial coords match
|
||||
pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(&CellKey, &CellValue)> {
|
||||
self.cells.iter()
|
||||
self.cells
|
||||
.iter()
|
||||
.filter(|(key, _)| key.matches_partial(partial))
|
||||
.collect()
|
||||
}
|
||||
@ -134,12 +146,21 @@ 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())
|
||||
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")]);
|
||||
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");
|
||||
@ -227,7 +248,12 @@ 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())
|
||||
CellKey::new(
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(c, i)| (c.to_string(), i.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -265,9 +291,18 @@ mod data_store {
|
||||
#[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));
|
||||
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);
|
||||
@ -275,13 +310,12 @@ mod data_store {
|
||||
assert!(values.contains(&100.0));
|
||||
assert!(values.contains(&200.0));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prop_tests {
|
||||
use proptest::prelude::*;
|
||||
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)>> {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
use super::category::{Category, CategoryId};
|
||||
use super::cell::{CellKey, CellValue, DataStore};
|
||||
@ -47,7 +47,8 @@ impl Model {
|
||||
}
|
||||
let id = self.next_category_id;
|
||||
self.next_category_id += 1;
|
||||
self.categories.insert(name.clone(), Category::new(id, name.clone()));
|
||||
self.categories
|
||||
.insert(name.clone(), Category::new(id, name.clone()));
|
||||
// Add to all views
|
||||
for view in self.views.values_mut() {
|
||||
view.on_category_added(&name);
|
||||
@ -87,7 +88,8 @@ impl Model {
|
||||
}
|
||||
|
||||
pub fn remove_formula(&mut self, target: &str, target_category: &str) {
|
||||
self.formulas.retain(|f| !(f.target == target && f.target_category == target_category));
|
||||
self.formulas
|
||||
.retain(|f| !(f.target == target && f.target_category == target_category));
|
||||
}
|
||||
|
||||
pub fn formulas(&self) -> &[Formula] {
|
||||
@ -95,12 +97,14 @@ impl Model {
|
||||
}
|
||||
|
||||
pub fn active_view(&self) -> &View {
|
||||
self.views.get(&self.active_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)
|
||||
self.views
|
||||
.get_mut(&self.active_view)
|
||||
.expect("active_view always names an existing view")
|
||||
}
|
||||
|
||||
@ -169,11 +173,12 @@ impl Model {
|
||||
}
|
||||
|
||||
fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option<CellValue> {
|
||||
use crate::formula::{Expr, AggFunc};
|
||||
use crate::formula::{AggFunc, Expr};
|
||||
|
||||
// Check WHERE filter first
|
||||
if let Some(filter) = &formula.filter {
|
||||
let matches = context.get(&filter.category)
|
||||
let matches = context
|
||||
.get(&filter.category)
|
||||
.map(|v| v == filter.item.as_str())
|
||||
.unwrap_or(false);
|
||||
if !matches {
|
||||
@ -211,12 +216,18 @@ impl Model {
|
||||
BinOp::Add => lv + rv,
|
||||
BinOp::Sub => lv - rv,
|
||||
BinOp::Mul => lv * rv,
|
||||
BinOp::Div => { if rv == 0.0 { return None; } lv / rv }
|
||||
BinOp::Div => {
|
||||
if rv == 0.0 {
|
||||
return None;
|
||||
}
|
||||
lv / rv
|
||||
}
|
||||
BinOp::Pow => lv.powf(rv),
|
||||
// Comparison operators are handled by eval_bool; reaching
|
||||
// here means a comparison was used where a number is expected.
|
||||
BinOp::Eq | BinOp::Ne | BinOp::Lt |
|
||||
BinOp::Gt | BinOp::Le | BinOp::Ge => return None,
|
||||
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => {
|
||||
return None
|
||||
}
|
||||
})
|
||||
}
|
||||
Expr::UnaryMinus(e) => Some(-eval_expr(e, context, model, target_category)?),
|
||||
@ -230,15 +241,20 @@ impl Model {
|
||||
if let Some(f) = agg_filter {
|
||||
partial = partial.with(&f.category, &f.item);
|
||||
}
|
||||
let values: Vec<f64> = model.data.matching_cells(&partial.0)
|
||||
let values: Vec<f64> = model
|
||||
.data
|
||||
.matching_cells(&partial.0)
|
||||
.into_iter()
|
||||
.filter_map(|(_, v)| v.as_f64())
|
||||
.collect();
|
||||
match func {
|
||||
AggFunc::Sum => Some(values.iter().sum()),
|
||||
AggFunc::Avg => {
|
||||
if values.is_empty() { None }
|
||||
else { Some(values.iter().sum::<f64>() / values.len() as f64) }
|
||||
if values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(values.iter().sum::<f64>() / values.len() as f64)
|
||||
}
|
||||
}
|
||||
AggFunc::Min => values.iter().cloned().reduce(f64::min),
|
||||
AggFunc::Max => values.iter().cloned().reduce(f64::max),
|
||||
@ -275,16 +291,16 @@ impl Model {
|
||||
BinOp::Le => lv <= rv,
|
||||
BinOp::Ge => lv >= rv,
|
||||
// Arithmetic operators are not comparisons
|
||||
BinOp::Add | BinOp::Sub | BinOp::Mul |
|
||||
BinOp::Div | BinOp::Pow => return None,
|
||||
BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Pow => {
|
||||
return None
|
||||
}
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
eval_expr(&formula.expr, context, self, &formula.target_category)
|
||||
.map(CellValue::Number)
|
||||
eval_expr(&formula.expr, context, self, &formula.target_category).map(CellValue::Number)
|
||||
}
|
||||
}
|
||||
|
||||
@ -295,7 +311,12 @@ mod model_tests {
|
||||
use crate::view::Axis;
|
||||
|
||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||
CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect())
|
||||
CellKey::new(
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(c, i)| (c.to_string(), i.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -324,7 +345,9 @@ mod model_tests {
|
||||
#[test]
|
||||
fn add_category_max_limit() {
|
||||
let mut m = Model::new("Test");
|
||||
for i in 0..12 { m.add_category(format!("Cat{i}")).unwrap(); }
|
||||
for i in 0..12 {
|
||||
m.add_category(format!("Cat{i}")).unwrap();
|
||||
}
|
||||
assert!(m.add_category("TooMany").is_err());
|
||||
}
|
||||
|
||||
@ -368,10 +391,26 @@ mod model_tests {
|
||||
m.add_category("Region").unwrap();
|
||||
m.add_category("Product").unwrap();
|
||||
m.add_category("Measure").unwrap();
|
||||
let k1 = coord(&[("Region", "East"), ("Product", "Shirts"), ("Measure", "Revenue")]);
|
||||
let k2 = coord(&[("Region", "West"), ("Product", "Shirts"), ("Measure", "Revenue")]);
|
||||
let k3 = coord(&[("Region", "East"), ("Product", "Pants"), ("Measure", "Revenue")]);
|
||||
let k4 = coord(&[("Region", "East"), ("Product", "Shirts"), ("Measure", "Cost")]);
|
||||
let k1 = coord(&[
|
||||
("Region", "East"),
|
||||
("Product", "Shirts"),
|
||||
("Measure", "Revenue"),
|
||||
]);
|
||||
let k2 = coord(&[
|
||||
("Region", "West"),
|
||||
("Product", "Shirts"),
|
||||
("Measure", "Revenue"),
|
||||
]);
|
||||
let k3 = coord(&[
|
||||
("Region", "East"),
|
||||
("Product", "Pants"),
|
||||
("Measure", "Revenue"),
|
||||
]);
|
||||
let k4 = coord(&[
|
||||
("Region", "East"),
|
||||
("Product", "Shirts"),
|
||||
("Measure", "Cost"),
|
||||
]);
|
||||
m.set_cell(k1.clone(), CellValue::Number(100.0));
|
||||
m.set_cell(k2.clone(), CellValue::Number(200.0));
|
||||
m.set_cell(k3.clone(), CellValue::Number(300.0));
|
||||
@ -438,9 +477,9 @@ mod model_tests {
|
||||
m.add_category("Product").unwrap();
|
||||
m.add_category("Time").unwrap();
|
||||
let v = m.active_view();
|
||||
assert_eq!(v.axis_of("Region"), Axis::Row);
|
||||
assert_eq!(v.axis_of("Region"), Axis::Row);
|
||||
assert_eq!(v.axis_of("Product"), Axis::Column);
|
||||
assert_eq!(v.axis_of("Time"), Axis::Page);
|
||||
assert_eq!(v.axis_of("Time"), Axis::Page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -456,14 +495,21 @@ mod model_tests {
|
||||
#[cfg(test)]
|
||||
mod formula_tests {
|
||||
use super::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::formula::parse_formula;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
|
||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||
CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect())
|
||||
CellKey::new(
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(c, i)| (c.to_string(), i.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn approx_eq(a: f64, b: f64) -> bool { (a - b).abs() < 1e-9 }
|
||||
fn approx_eq(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
fn revenue_cost_model() -> Model {
|
||||
let mut m = Model::new("Test");
|
||||
@ -478,10 +524,22 @@ mod formula_tests {
|
||||
cat.add_item("East");
|
||||
cat.add_item("West");
|
||||
}
|
||||
m.set_cell(coord(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(1000.0));
|
||||
m.set_cell(coord(&[("Measure", "Cost"), ("Region", "East")]), CellValue::Number(600.0));
|
||||
m.set_cell(coord(&[("Measure", "Revenue"), ("Region", "West")]), CellValue::Number(800.0));
|
||||
m.set_cell(coord(&[("Measure", "Cost"), ("Region", "West")]), CellValue::Number(500.0));
|
||||
m.set_cell(
|
||||
coord(&[("Measure", "Revenue"), ("Region", "East")]),
|
||||
CellValue::Number(1000.0),
|
||||
);
|
||||
m.set_cell(
|
||||
coord(&[("Measure", "Cost"), ("Region", "East")]),
|
||||
CellValue::Number(600.0),
|
||||
);
|
||||
m.set_cell(
|
||||
coord(&[("Measure", "Revenue"), ("Region", "West")]),
|
||||
CellValue::Number(800.0),
|
||||
);
|
||||
m.set_cell(
|
||||
coord(&[("Measure", "Cost"), ("Region", "West")]),
|
||||
CellValue::Number(500.0),
|
||||
);
|
||||
m
|
||||
}
|
||||
|
||||
@ -507,8 +565,13 @@ mod formula_tests {
|
||||
fn formula_multiplication() {
|
||||
let mut m = revenue_cost_model();
|
||||
m.add_formula(parse_formula("Tax = Revenue * 0.1", "Measure").unwrap());
|
||||
if let Some(cat) = m.category_mut("Measure") { cat.add_item("Tax"); }
|
||||
let val = m.evaluate(&coord(&[("Measure", "Tax"), ("Region", "East")])).and_then(|v| v.as_f64()).unwrap();
|
||||
if let Some(cat) = m.category_mut("Measure") {
|
||||
cat.add_item("Tax");
|
||||
}
|
||||
let val = m
|
||||
.evaluate(&coord(&[("Measure", "Tax"), ("Region", "East")]))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap();
|
||||
assert!(approx_eq(val, 100.0));
|
||||
}
|
||||
|
||||
@ -521,7 +584,10 @@ mod formula_tests {
|
||||
cat.add_item("Profit");
|
||||
cat.add_item("Margin");
|
||||
}
|
||||
let val = m.evaluate(&coord(&[("Measure", "Margin"), ("Region", "East")])).and_then(|v| v.as_f64()).unwrap();
|
||||
let val = m
|
||||
.evaluate(&coord(&[("Measure", "Margin"), ("Region", "East")]))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap();
|
||||
assert!(approx_eq(val, 0.4));
|
||||
}
|
||||
|
||||
@ -535,18 +601,29 @@ mod formula_tests {
|
||||
cat.add_item("Zero");
|
||||
cat.add_item("Result");
|
||||
}
|
||||
m.set_cell(coord(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(100.0));
|
||||
m.set_cell(coord(&[("Measure", "Zero"), ("Region", "East")]), CellValue::Number(0.0));
|
||||
m.set_cell(
|
||||
coord(&[("Measure", "Revenue"), ("Region", "East")]),
|
||||
CellValue::Number(100.0),
|
||||
);
|
||||
m.set_cell(
|
||||
coord(&[("Measure", "Zero"), ("Region", "East")]),
|
||||
CellValue::Number(0.0),
|
||||
);
|
||||
m.add_formula(parse_formula("Result = Revenue / Zero", "Measure").unwrap());
|
||||
// Division by zero must yield Empty, not 0, so the user sees a blank not a misleading zero.
|
||||
assert_eq!(m.evaluate(&coord(&[("Measure", "Result"), ("Region", "East")])), None);
|
||||
assert_eq!(
|
||||
m.evaluate(&coord(&[("Measure", "Result"), ("Region", "East")])),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unary_minus() {
|
||||
let mut m = revenue_cost_model();
|
||||
m.add_formula(parse_formula("NegRevenue = -Revenue", "Measure").unwrap());
|
||||
if let Some(cat) = m.category_mut("Measure") { cat.add_item("NegRevenue"); }
|
||||
if let Some(cat) = m.category_mut("Measure") {
|
||||
cat.add_item("NegRevenue");
|
||||
}
|
||||
let k = coord(&[("Measure", "NegRevenue"), ("Region", "East")]);
|
||||
assert_eq!(m.evaluate(&k), Some(CellValue::Number(-1000.0)));
|
||||
}
|
||||
@ -561,14 +638,19 @@ mod formula_tests {
|
||||
}
|
||||
m.set_cell(coord(&[("Measure", "Base")]), CellValue::Number(4.0));
|
||||
m.add_formula(parse_formula("Squared = Base ^ 2", "Measure").unwrap());
|
||||
assert_eq!(m.evaluate(&coord(&[("Measure", "Squared")])), Some(CellValue::Number(16.0)));
|
||||
assert_eq!(
|
||||
m.evaluate(&coord(&[("Measure", "Squared")])),
|
||||
Some(CellValue::Number(16.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formula_with_missing_ref_returns_empty() {
|
||||
let mut m = revenue_cost_model();
|
||||
m.add_formula(parse_formula("Ghost = NoSuchField - Cost", "Measure").unwrap());
|
||||
if let Some(cat) = m.category_mut("Measure") { cat.add_item("Ghost"); }
|
||||
if let Some(cat) = m.category_mut("Measure") {
|
||||
cat.add_item("Ghost");
|
||||
}
|
||||
let k = coord(&[("Measure", "Ghost"), ("Region", "East")]);
|
||||
assert_eq!(m.evaluate(&k), None);
|
||||
}
|
||||
@ -576,18 +658,32 @@ mod formula_tests {
|
||||
#[test]
|
||||
fn formula_where_applied_to_matching_region() {
|
||||
let mut m = revenue_cost_model();
|
||||
m.add_formula(parse_formula("EastOnly = Revenue WHERE Region = \"East\"", "Measure").unwrap());
|
||||
if let Some(cat) = m.category_mut("Measure") { cat.add_item("EastOnly"); }
|
||||
let val = m.evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "East")])).and_then(|v| v.as_f64()).unwrap();
|
||||
m.add_formula(
|
||||
parse_formula("EastOnly = Revenue WHERE Region = \"East\"", "Measure").unwrap(),
|
||||
);
|
||||
if let Some(cat) = m.category_mut("Measure") {
|
||||
cat.add_item("EastOnly");
|
||||
}
|
||||
let val = m
|
||||
.evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "East")]))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap();
|
||||
assert!(approx_eq(val, 1000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formula_where_not_applied_to_non_matching_region() {
|
||||
let mut m = revenue_cost_model();
|
||||
m.add_formula(parse_formula("EastOnly = Revenue WHERE Region = \"East\"", "Measure").unwrap());
|
||||
if let Some(cat) = m.category_mut("Measure") { cat.add_item("EastOnly"); }
|
||||
assert_eq!(m.evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "West")])), None);
|
||||
m.add_formula(
|
||||
parse_formula("EastOnly = Revenue WHERE Region = \"East\"", "Measure").unwrap(),
|
||||
);
|
||||
if let Some(cat) = m.category_mut("Measure") {
|
||||
cat.add_item("EastOnly");
|
||||
}
|
||||
assert_eq!(
|
||||
m.evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "West")])),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -614,8 +710,13 @@ mod formula_tests {
|
||||
fn sum_aggregation_across_region() {
|
||||
let mut m = revenue_cost_model();
|
||||
m.add_formula(parse_formula("Total = SUM(Revenue)", "Measure").unwrap());
|
||||
if let Some(cat) = m.category_mut("Measure") { cat.add_item("Total"); }
|
||||
let val = m.evaluate(&coord(&[("Measure", "Total"), ("Region", "East")])).and_then(|v| v.as_f64()).unwrap();
|
||||
if let Some(cat) = m.category_mut("Measure") {
|
||||
cat.add_item("Total");
|
||||
}
|
||||
let val = m
|
||||
.evaluate(&coord(&[("Measure", "Total"), ("Region", "East")]))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap();
|
||||
// Revenue(East)=1000 only — Cost must not be included
|
||||
assert_eq!(val, 1000.0);
|
||||
}
|
||||
@ -630,10 +731,16 @@ mod formula_tests {
|
||||
cat.add_item("Count");
|
||||
}
|
||||
for region in ["East", "West", "North"] {
|
||||
m.set_cell(coord(&[("Measure", "Sales"), ("Region", region)]), CellValue::Number(100.0));
|
||||
m.set_cell(
|
||||
coord(&[("Measure", "Sales"), ("Region", region)]),
|
||||
CellValue::Number(100.0),
|
||||
);
|
||||
}
|
||||
m.add_formula(parse_formula("Count = COUNT(Sales)", "Measure").unwrap());
|
||||
let val = m.evaluate(&coord(&[("Measure", "Count"), ("Region", "East")])).and_then(|v| v.as_f64()).unwrap();
|
||||
let val = m
|
||||
.evaluate(&coord(&[("Measure", "Count"), ("Region", "East")]))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap();
|
||||
assert!(val >= 1.0);
|
||||
}
|
||||
|
||||
@ -647,7 +754,10 @@ mod formula_tests {
|
||||
}
|
||||
m.set_cell(coord(&[("Measure", "X")]), CellValue::Number(10.0));
|
||||
m.add_formula(parse_formula("Result = IF(X > 5, 1, 0)", "Measure").unwrap());
|
||||
assert_eq!(m.evaluate(&coord(&[("Measure", "Result")])), Some(CellValue::Number(1.0)));
|
||||
assert_eq!(
|
||||
m.evaluate(&coord(&[("Measure", "Result")])),
|
||||
Some(CellValue::Number(1.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -660,7 +770,10 @@ mod formula_tests {
|
||||
}
|
||||
m.set_cell(coord(&[("Measure", "X")]), CellValue::Number(3.0));
|
||||
m.add_formula(parse_formula("Result = IF(X > 5, 1, 0)", "Measure").unwrap());
|
||||
assert_eq!(m.evaluate(&coord(&[("Measure", "Result")])), Some(CellValue::Number(0.0)));
|
||||
assert_eq!(
|
||||
m.evaluate(&coord(&[("Measure", "Result")])),
|
||||
Some(CellValue::Number(0.0))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bug regression tests ─────────────────────────────────────────────────
|
||||
@ -710,8 +823,14 @@ mod formula_tests {
|
||||
if let Some(cat) = m.category_mut("Region") {
|
||||
cat.add_item("East");
|
||||
}
|
||||
m.set_cell(coord(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(100.0));
|
||||
m.set_cell(coord(&[("Measure", "Cost"), ("Region", "East")]), CellValue::Number(50.0));
|
||||
m.set_cell(
|
||||
coord(&[("Measure", "Revenue"), ("Region", "East")]),
|
||||
CellValue::Number(100.0),
|
||||
);
|
||||
m.set_cell(
|
||||
coord(&[("Measure", "Cost"), ("Region", "East")]),
|
||||
CellValue::Number(50.0),
|
||||
);
|
||||
m.add_formula(parse_formula("Total = SUM(Revenue)", "Measure").unwrap());
|
||||
// Expected: 100 (SUM constrainted to Revenue only)
|
||||
// Bug: returns 150 — inner Ref("Revenue") is ignored, Cost is also summed
|
||||
@ -730,8 +849,12 @@ mod formula_tests {
|
||||
let mut m = Model::new("Test");
|
||||
m.add_category("Measure").unwrap();
|
||||
m.add_category("KPI").unwrap();
|
||||
if let Some(c) = m.category_mut("Measure") { c.add_item("Profit"); }
|
||||
if let Some(c) = m.category_mut("KPI") { c.add_item("Profit"); }
|
||||
if let Some(c) = m.category_mut("Measure") {
|
||||
c.add_item("Profit");
|
||||
}
|
||||
if let Some(c) = m.category_mut("KPI") {
|
||||
c.add_item("Profit");
|
||||
}
|
||||
|
||||
m.add_formula(parse_formula("Profit = 1", "Measure").unwrap());
|
||||
m.add_formula(parse_formula("Profit = 2", "KPI").unwrap());
|
||||
@ -748,16 +871,26 @@ mod formula_tests {
|
||||
let mut m = Model::new("Test");
|
||||
m.add_category("Measure").unwrap();
|
||||
m.add_category("KPI").unwrap();
|
||||
if let Some(c) = m.category_mut("Measure") { c.add_item("Profit"); }
|
||||
if let Some(c) = m.category_mut("KPI") { c.add_item("Profit"); }
|
||||
if let Some(c) = m.category_mut("Measure") {
|
||||
c.add_item("Profit");
|
||||
}
|
||||
if let Some(c) = m.category_mut("KPI") {
|
||||
c.add_item("Profit");
|
||||
}
|
||||
|
||||
m.add_formula(parse_formula("Profit = 1", "Measure").unwrap());
|
||||
m.add_formula(parse_formula("Profit = 2", "KPI").unwrap());
|
||||
|
||||
// Measure formula → 1, KPI formula → 2
|
||||
// Bug: first formula was replaced; {Measure=Profit} evaluates to Empty.
|
||||
assert_eq!(m.evaluate(&coord(&[("Measure", "Profit")])), Some(CellValue::Number(1.0)));
|
||||
assert_eq!(m.evaluate(&coord(&[("KPI", "Profit")])), Some(CellValue::Number(2.0)));
|
||||
assert_eq!(
|
||||
m.evaluate(&coord(&[("Measure", "Profit")])),
|
||||
Some(CellValue::Number(1.0))
|
||||
);
|
||||
assert_eq!(
|
||||
m.evaluate(&coord(&[("KPI", "Profit")])),
|
||||
Some(CellValue::Number(2.0))
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug: remove_formula matches by target name alone, so removing "Profit"
|
||||
@ -768,8 +901,12 @@ mod formula_tests {
|
||||
let mut m = Model::new("Test");
|
||||
m.add_category("Measure").unwrap();
|
||||
m.add_category("KPI").unwrap();
|
||||
if let Some(c) = m.category_mut("Measure") { c.add_item("Profit"); }
|
||||
if let Some(c) = m.category_mut("KPI") { c.add_item("Profit"); }
|
||||
if let Some(c) = m.category_mut("Measure") {
|
||||
c.add_item("Profit");
|
||||
}
|
||||
if let Some(c) = m.category_mut("KPI") {
|
||||
c.add_item("Profit");
|
||||
}
|
||||
|
||||
m.add_formula(parse_formula("Profit = 1", "Measure").unwrap());
|
||||
m.add_formula(parse_formula("Profit = 2", "KPI").unwrap());
|
||||
@ -780,34 +917,37 @@ mod formula_tests {
|
||||
// KPI formula must survive
|
||||
// Bug: remove_formula("Profit") wipes both; formulas.len() == 0
|
||||
assert_eq!(m.formulas.len(), 1);
|
||||
assert_eq!(m.evaluate(&coord(&[("KPI", "Profit")])), Some(CellValue::Number(2.0)));
|
||||
assert_eq!(
|
||||
m.evaluate(&coord(&[("KPI", "Profit")])),
|
||||
Some(CellValue::Number(2.0))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod five_category {
|
||||
use super::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::formula::parse_formula;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::view::Axis;
|
||||
|
||||
const DATA: &[(&str, &str, &str, &str, f64, f64)] = &[
|
||||
("East", "Shirts", "Online", "Q1", 1_000.0, 600.0),
|
||||
("East", "Shirts", "Online", "Q2", 1_200.0, 700.0),
|
||||
("East", "Shirts", "Retail", "Q1", 800.0, 500.0),
|
||||
("East", "Shirts", "Retail", "Q2", 900.0, 540.0),
|
||||
("East", "Pants", "Online", "Q1", 500.0, 300.0),
|
||||
("East", "Pants", "Online", "Q2", 600.0, 360.0),
|
||||
("East", "Pants", "Retail", "Q1", 400.0, 240.0),
|
||||
("East", "Pants", "Retail", "Q2", 450.0, 270.0),
|
||||
("West", "Shirts", "Online", "Q1", 700.0, 420.0),
|
||||
("West", "Shirts", "Online", "Q2", 750.0, 450.0),
|
||||
("West", "Shirts", "Retail", "Q1", 600.0, 360.0),
|
||||
("West", "Shirts", "Retail", "Q2", 650.0, 390.0),
|
||||
("West", "Pants", "Online", "Q1", 300.0, 180.0),
|
||||
("West", "Pants", "Online", "Q2", 350.0, 210.0),
|
||||
("West", "Pants", "Retail", "Q1", 250.0, 150.0),
|
||||
("West", "Pants", "Retail", "Q2", 280.0, 168.0),
|
||||
("East", "Shirts", "Online", "Q1", 1_000.0, 600.0),
|
||||
("East", "Shirts", "Online", "Q2", 1_200.0, 700.0),
|
||||
("East", "Shirts", "Retail", "Q1", 800.0, 500.0),
|
||||
("East", "Shirts", "Retail", "Q2", 900.0, 540.0),
|
||||
("East", "Pants", "Online", "Q1", 500.0, 300.0),
|
||||
("East", "Pants", "Online", "Q2", 600.0, 360.0),
|
||||
("East", "Pants", "Retail", "Q1", 400.0, 240.0),
|
||||
("East", "Pants", "Retail", "Q2", 450.0, 270.0),
|
||||
("West", "Shirts", "Online", "Q1", 700.0, 420.0),
|
||||
("West", "Shirts", "Online", "Q2", 750.0, 450.0),
|
||||
("West", "Shirts", "Retail", "Q1", 600.0, 360.0),
|
||||
("West", "Shirts", "Retail", "Q2", 650.0, 390.0),
|
||||
("West", "Pants", "Online", "Q1", 300.0, 180.0),
|
||||
("West", "Pants", "Online", "Q2", 350.0, 210.0),
|
||||
("West", "Pants", "Retail", "Q1", 250.0, 150.0),
|
||||
("West", "Pants", "Retail", "Q2", 280.0, 168.0),
|
||||
];
|
||||
|
||||
fn coord(region: &str, product: &str, channel: &str, time: &str, measure: &str) -> CellKey {
|
||||
@ -815,8 +955,8 @@ mod five_category {
|
||||
("Channel".to_string(), channel.to_string()),
|
||||
("Measure".to_string(), measure.to_string()),
|
||||
("Product".to_string(), product.to_string()),
|
||||
("Region".to_string(), region.to_string()),
|
||||
("Time".to_string(), time.to_string()),
|
||||
("Region".to_string(), region.to_string()),
|
||||
("Time".to_string(), time.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
@ -827,14 +967,16 @@ mod five_category {
|
||||
}
|
||||
for cat in ["Region", "Product", "Channel", "Time"] {
|
||||
let items: &[&str] = match cat {
|
||||
"Region" => &["East", "West"],
|
||||
"Region" => &["East", "West"],
|
||||
"Product" => &["Shirts", "Pants"],
|
||||
"Channel" => &["Online", "Retail"],
|
||||
"Time" => &["Q1", "Q2"],
|
||||
_ => &[],
|
||||
"Time" => &["Q1", "Q2"],
|
||||
_ => &[],
|
||||
};
|
||||
if let Some(c) = m.category_mut(cat) {
|
||||
for &item in items { c.add_item(item); }
|
||||
for &item in items {
|
||||
c.add_item(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(c) = m.category_mut("Measure") {
|
||||
@ -843,21 +985,30 @@ mod five_category {
|
||||
}
|
||||
}
|
||||
for &(region, product, channel, time, rev, cost) in DATA {
|
||||
m.set_cell(coord(region, product, channel, time, "Revenue"), CellValue::Number(rev));
|
||||
m.set_cell(coord(region, product, channel, time, "Cost"), CellValue::Number(cost));
|
||||
m.set_cell(
|
||||
coord(region, product, channel, time, "Revenue"),
|
||||
CellValue::Number(rev),
|
||||
);
|
||||
m.set_cell(
|
||||
coord(region, product, channel, time, "Cost"),
|
||||
CellValue::Number(cost),
|
||||
);
|
||||
}
|
||||
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
|
||||
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
|
||||
m.add_formula(parse_formula("Margin = Profit / Revenue", "Measure").unwrap());
|
||||
m.add_formula(parse_formula("Total = SUM(Revenue)", "Measure").unwrap());
|
||||
m.add_formula(parse_formula("Total = SUM(Revenue)", "Measure").unwrap());
|
||||
m
|
||||
}
|
||||
|
||||
fn approx(a: f64, b: f64) -> bool { (a - b).abs() < 1e-9 }
|
||||
fn approx(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_sixteen_revenue_cells_stored() {
|
||||
let m = build_model();
|
||||
let count = DATA.iter()
|
||||
let count = DATA
|
||||
.iter()
|
||||
.filter(|&&(r, p, c, t, _, _)| !m.get_cell(&coord(r, p, c, t, "Revenue")).is_none())
|
||||
.count();
|
||||
assert_eq!(count, 16);
|
||||
@ -866,7 +1017,8 @@ mod five_category {
|
||||
#[test]
|
||||
fn all_sixteen_cost_cells_stored() {
|
||||
let m = build_model();
|
||||
let count = DATA.iter()
|
||||
let count = DATA
|
||||
.iter()
|
||||
.filter(|&&(r, p, c, t, _, _)| !m.get_cell(&coord(r, p, c, t, "Cost")).is_none())
|
||||
.count();
|
||||
assert_eq!(count, 16);
|
||||
@ -875,15 +1027,25 @@ mod five_category {
|
||||
#[test]
|
||||
fn spot_check_raw_revenue() {
|
||||
let m = build_model();
|
||||
assert_eq!(m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")), Some(&CellValue::Number(1_000.0)));
|
||||
assert_eq!(m.get_cell(&coord("West", "Pants", "Retail", "Q2", "Revenue")), Some(&CellValue::Number(280.0)));
|
||||
assert_eq!(
|
||||
m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")),
|
||||
Some(&CellValue::Number(1_000.0))
|
||||
);
|
||||
assert_eq!(
|
||||
m.get_cell(&coord("West", "Pants", "Retail", "Q2", "Revenue")),
|
||||
Some(&CellValue::Number(280.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_cells_do_not_alias() {
|
||||
let m = build_model();
|
||||
let a = m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")).clone();
|
||||
let b = m.get_cell(&coord("West", "Pants", "Retail", "Q2", "Revenue")).clone();
|
||||
let a = m
|
||||
.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue"))
|
||||
.clone();
|
||||
let b = m
|
||||
.get_cell(&coord("West", "Pants", "Retail", "Q2", "Revenue"))
|
||||
.clone();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
@ -892,11 +1054,14 @@ mod five_category {
|
||||
let m = build_model();
|
||||
for &(region, product, channel, time, rev, cost) in DATA {
|
||||
let expected = rev - cost;
|
||||
let actual = m.evaluate(&coord(region, product, channel, time, "Profit"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or_else(|| panic!("Profit empty at {region}/{product}/{channel}/{time}"));
|
||||
assert!(approx(actual, expected),
|
||||
"Profit at {region}/{product}/{channel}/{time}: expected {expected}, got {actual}");
|
||||
let actual = m
|
||||
.evaluate(&coord(region, product, channel, time, "Profit"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or_else(|| panic!("Profit empty at {region}/{product}/{channel}/{time}"));
|
||||
assert!(
|
||||
approx(actual, expected),
|
||||
"Profit at {region}/{product}/{channel}/{time}: expected {expected}, got {actual}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -905,9 +1070,10 @@ mod five_category {
|
||||
let m = build_model();
|
||||
for &(region, product, channel, time, rev, cost) in DATA {
|
||||
let expected = (rev - cost) / rev;
|
||||
let actual = m.evaluate(&coord(region, product, channel, time, "Margin"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or_else(|| panic!("Margin empty at {region}/{product}/{channel}/{time}"));
|
||||
let actual = m
|
||||
.evaluate(&coord(region, product, channel, time, "Margin"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or_else(|| panic!("Margin empty at {region}/{product}/{channel}/{time}"));
|
||||
assert!(approx(actual, expected),
|
||||
"Margin at {region}/{product}/{channel}/{time}: expected {expected:.4}, got {actual:.4}");
|
||||
}
|
||||
@ -916,17 +1082,29 @@ mod five_category {
|
||||
#[test]
|
||||
fn chained_formula_profit_feeds_margin() {
|
||||
let m = build_model();
|
||||
let margin = m.evaluate(&coord("East", "Shirts", "Online", "Q1", "Margin")).and_then(|v| v.as_f64()).unwrap();
|
||||
let margin = m
|
||||
.evaluate(&coord("East", "Shirts", "Online", "Q1", "Margin"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap();
|
||||
assert!(approx(margin, 0.4), "expected 0.4, got {margin}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_revenue_updates_profit_and_margin() {
|
||||
let mut m = build_model();
|
||||
m.set_cell(coord("East", "Shirts", "Online", "Q1", "Revenue"), CellValue::Number(1_500.0));
|
||||
let profit = m.evaluate(&coord("East", "Shirts", "Online", "Q1", "Profit")).and_then(|v| v.as_f64()).unwrap();
|
||||
m.set_cell(
|
||||
coord("East", "Shirts", "Online", "Q1", "Revenue"),
|
||||
CellValue::Number(1_500.0),
|
||||
);
|
||||
let profit = m
|
||||
.evaluate(&coord("East", "Shirts", "Online", "Q1", "Profit"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap();
|
||||
assert!(approx(profit, 900.0), "expected 900, got {profit}");
|
||||
let margin = m.evaluate(&coord("East", "Shirts", "Online", "Q1", "Margin")).and_then(|v| v.as_f64()).unwrap();
|
||||
let margin = m
|
||||
.evaluate(&coord("East", "Shirts", "Online", "Q1", "Margin"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap();
|
||||
assert!(approx(margin, 0.6), "expected 0.6, got {margin}");
|
||||
}
|
||||
|
||||
@ -935,10 +1113,14 @@ mod five_category {
|
||||
let m = build_model();
|
||||
let key = CellKey::new(vec![
|
||||
("Measure".to_string(), "Total".to_string()),
|
||||
("Region".to_string(), "East".to_string()),
|
||||
("Region".to_string(), "East".to_string()),
|
||||
]);
|
||||
let total = m.evaluate(&key).and_then(|v| v.as_f64()).unwrap();
|
||||
let expected: f64 = DATA.iter().filter(|&&(r, _, _, _, _, _)| r == "East").map(|&(_, _, _, _, rev, _)| rev).sum();
|
||||
let expected: f64 = DATA
|
||||
.iter()
|
||||
.filter(|&&(r, _, _, _, _, _)| r == "East")
|
||||
.map(|&(_, _, _, _, rev, _)| rev)
|
||||
.sum();
|
||||
assert!(approx(total, expected), "expected {expected}, got {total}");
|
||||
}
|
||||
|
||||
@ -950,7 +1132,11 @@ mod five_category {
|
||||
("Measure".to_string(), "Total".to_string()),
|
||||
]);
|
||||
let total = m.evaluate(&key).and_then(|v| v.as_f64()).unwrap();
|
||||
let expected: f64 = DATA.iter().filter(|&&(_, _, ch, _, _, _)| ch == "Online").map(|&(_, _, _, _, rev, _)| rev).sum();
|
||||
let expected: f64 = DATA
|
||||
.iter()
|
||||
.filter(|&&(_, _, ch, _, _, _)| ch == "Online")
|
||||
.map(|&(_, _, _, _, rev, _)| rev)
|
||||
.sum();
|
||||
assert!(approx(total, expected), "expected {expected}, got {total}");
|
||||
}
|
||||
|
||||
@ -960,19 +1146,21 @@ mod five_category {
|
||||
let key = CellKey::new(vec![
|
||||
("Measure".to_string(), "Total".to_string()),
|
||||
("Product".to_string(), "Shirts".to_string()),
|
||||
("Time".to_string(), "Q1".to_string()),
|
||||
("Time".to_string(), "Q1".to_string()),
|
||||
]);
|
||||
let total = m.evaluate(&key).and_then(|v| v.as_f64()).unwrap();
|
||||
let expected: f64 = DATA.iter().filter(|&&(_, p, _, t, _, _)| p == "Shirts" && t == "Q1").map(|&(_, _, _, _, rev, _)| rev).sum();
|
||||
let expected: f64 = DATA
|
||||
.iter()
|
||||
.filter(|&&(_, p, _, t, _, _)| p == "Shirts" && t == "Q1")
|
||||
.map(|&(_, _, _, _, rev, _)| rev)
|
||||
.sum();
|
||||
assert!(approx(total, expected), "expected {expected}, got {total}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_all_revenue_equals_grand_total() {
|
||||
let m = build_model();
|
||||
let key = CellKey::new(vec![
|
||||
("Measure".to_string(), "Total".to_string()),
|
||||
]);
|
||||
let key = CellKey::new(vec![("Measure".to_string(), "Total".to_string())]);
|
||||
let total = m.evaluate(&key).and_then(|v| v.as_f64()).unwrap();
|
||||
let expected: f64 = DATA.iter().map(|&(_, _, _, _, rev, _)| rev).sum();
|
||||
assert!(approx(total, expected), "expected {expected}, got {total}");
|
||||
@ -982,10 +1170,10 @@ mod five_category {
|
||||
fn default_view_first_two_on_axes_rest_on_page() {
|
||||
let m = build_model();
|
||||
let v = m.active_view();
|
||||
assert_eq!(v.axis_of("Region"), Axis::Row);
|
||||
assert_eq!(v.axis_of("Region"), Axis::Row);
|
||||
assert_eq!(v.axis_of("Product"), Axis::Column);
|
||||
assert_eq!(v.axis_of("Channel"), Axis::Page);
|
||||
assert_eq!(v.axis_of("Time"), Axis::Page);
|
||||
assert_eq!(v.axis_of("Time"), Axis::Page);
|
||||
assert_eq!(v.axis_of("Measure"), Axis::Page);
|
||||
}
|
||||
|
||||
@ -994,13 +1182,16 @@ mod five_category {
|
||||
let mut m = build_model();
|
||||
{
|
||||
let v = m.active_view_mut();
|
||||
v.set_axis("Region", Axis::Page);
|
||||
v.set_axis("Region", Axis::Page);
|
||||
v.set_axis("Product", Axis::Page);
|
||||
v.set_axis("Channel", Axis::Row);
|
||||
v.set_axis("Time", Axis::Column);
|
||||
v.set_axis("Time", Axis::Column);
|
||||
v.set_axis("Measure", Axis::Page);
|
||||
}
|
||||
assert_eq!(m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")), Some(&CellValue::Number(1_000.0)));
|
||||
assert_eq!(
|
||||
m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")),
|
||||
Some(&CellValue::Number(1_000.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1009,15 +1200,18 @@ mod five_category {
|
||||
m.create_view("Pivot");
|
||||
{
|
||||
let v = m.views.get_mut("Pivot").unwrap();
|
||||
v.set_axis("Time", Axis::Row);
|
||||
v.set_axis("Time", Axis::Row);
|
||||
v.set_axis("Channel", Axis::Column);
|
||||
v.set_axis("Region", Axis::Page);
|
||||
v.set_axis("Region", Axis::Page);
|
||||
v.set_axis("Product", Axis::Page);
|
||||
v.set_axis("Measure", Axis::Page);
|
||||
}
|
||||
assert_eq!(m.views.get("Default").unwrap().axis_of("Region"), Axis::Row);
|
||||
assert_eq!(m.views.get("Pivot").unwrap().axis_of("Time"), Axis::Row);
|
||||
assert_eq!(m.views.get("Pivot").unwrap().axis_of("Channel"), Axis::Column);
|
||||
assert_eq!(m.views.get("Pivot").unwrap().axis_of("Time"), Axis::Row);
|
||||
assert_eq!(
|
||||
m.views.get("Pivot").unwrap().axis_of("Channel"),
|
||||
Axis::Column
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1027,8 +1221,14 @@ mod five_category {
|
||||
if let Some(v) = m.views.get_mut("West only") {
|
||||
v.set_page_selection("Region", "West");
|
||||
}
|
||||
assert_eq!(m.views.get("Default").unwrap().page_selection("Region"), None);
|
||||
assert_eq!(m.views.get("West only").unwrap().page_selection("Region"), Some("West"));
|
||||
assert_eq!(
|
||||
m.views.get("Default").unwrap().page_selection("Region"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
m.views.get("West only").unwrap().page_selection("Region"),
|
||||
Some("West")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1036,7 +1236,9 @@ mod five_category {
|
||||
let m = build_model();
|
||||
assert_eq!(m.categories.len(), 5);
|
||||
let mut m2 = build_model();
|
||||
for i in 0..7 { m2.add_category(format!("Extra{i}")).unwrap(); }
|
||||
for i in 0..7 {
|
||||
m2.add_category(format!("Extra{i}")).unwrap();
|
||||
}
|
||||
assert_eq!(m2.categories.len(), 12);
|
||||
assert!(m2.add_category("OneMore").is_err());
|
||||
}
|
||||
@ -1044,10 +1246,10 @@ mod five_category {
|
||||
|
||||
#[cfg(test)]
|
||||
mod prop_tests {
|
||||
use proptest::prelude::*;
|
||||
use super::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::formula::parse_formula;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn finite_f64() -> impl Strategy<Value = f64> {
|
||||
prop::num::f64::NORMAL.prop_filter("finite", |f| f.is_finite())
|
||||
|
||||
@ -1,16 +1,15 @@
|
||||
use std::io::{Read, Write, BufReader, BufWriter};
|
||||
use std::path::Path;
|
||||
use anyhow::{Context, Result};
|
||||
use flate2::read::GzDecoder;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use std::io::{BufReader, BufWriter, Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::category::Group;
|
||||
use crate::view::{Axis, GridLayout};
|
||||
use crate::formula::parse_formula;
|
||||
|
||||
use crate::model::category::Group;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::view::{Axis, GridLayout};
|
||||
|
||||
pub fn save(model: &Model, path: &Path) -> Result<()> {
|
||||
let text = format_md(model);
|
||||
@ -22,15 +21,14 @@ pub fn save(model: &Model, path: &Path) -> Result<()> {
|
||||
encoder.write_all(text.as_bytes())?;
|
||||
encoder.finish()?;
|
||||
} else {
|
||||
std::fs::write(path, &text)
|
||||
.with_context(|| format!("Cannot write {}", path.display()))?;
|
||||
std::fs::write(path, &text).with_context(|| format!("Cannot write {}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Model> {
|
||||
let file = std::fs::File::open(path)
|
||||
.with_context(|| format!("Cannot open {}", path.display()))?;
|
||||
let file =
|
||||
std::fs::File::open(path).with_context(|| format!("Cannot open {}", path.display()))?;
|
||||
|
||||
let text = if path.to_str().map(|s| s.ends_with(".gz")).unwrap_or(false) {
|
||||
let mut decoder = GzDecoder::new(BufReader::new(file));
|
||||
@ -70,7 +68,7 @@ pub fn format_md(model: &Model) -> String {
|
||||
for item in cat.items.values() {
|
||||
match &item.group {
|
||||
Some(g) => writeln!(out, "- {} [{}]", item.name, g).unwrap(),
|
||||
None => writeln!(out, "- {}", item.name).unwrap(),
|
||||
None => writeln!(out, "- {}", item.name).unwrap(),
|
||||
}
|
||||
}
|
||||
// Group hierarchy: lines starting with `>` for groups that have a parent
|
||||
@ -97,7 +95,7 @@ pub fn format_md(model: &Model) -> String {
|
||||
for (key, value) in cells {
|
||||
let val_str = match value {
|
||||
CellValue::Number(_) => value.to_string(),
|
||||
CellValue::Text(s) => format!("\"{}\"", s),
|
||||
CellValue::Text(s) => format!("\"{}\"", s),
|
||||
};
|
||||
writeln!(out, "{} = {}", coord_str(key), val_str).unwrap();
|
||||
}
|
||||
@ -105,25 +103,29 @@ pub fn format_md(model: &Model) -> String {
|
||||
|
||||
// Views
|
||||
for (view_name, view) in &model.views {
|
||||
let active = if view_name == &model.active_view { " (active)" } else { "" };
|
||||
let active = if view_name == &model.active_view {
|
||||
" (active)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
writeln!(out, "\n## View: {}{}", view.name, active).unwrap();
|
||||
for (cat, axis) in &view.category_axes {
|
||||
match axis {
|
||||
Axis::Row => writeln!(out, "{}: row", cat).unwrap(),
|
||||
Axis::Row => writeln!(out, "{}: row", cat).unwrap(),
|
||||
Axis::Column => writeln!(out, "{}: column", cat).unwrap(),
|
||||
Axis::Page => {
|
||||
match view.page_selections.get(cat) {
|
||||
Some(sel) => writeln!(out, "{}: page, {}", cat, sel).unwrap(),
|
||||
None => writeln!(out, "{}: page", cat).unwrap(),
|
||||
}
|
||||
}
|
||||
Axis::Page => match view.page_selections.get(cat) {
|
||||
Some(sel) => writeln!(out, "{}: page, {}", cat, sel).unwrap(),
|
||||
None => writeln!(out, "{}: page", cat).unwrap(),
|
||||
},
|
||||
}
|
||||
}
|
||||
if !view.number_format.is_empty() {
|
||||
writeln!(out, "format: {}", view.number_format).unwrap();
|
||||
}
|
||||
// Hidden items (sorted for deterministic diffs)
|
||||
let mut hidden: Vec<(&str, &str)> = view.hidden_items.iter()
|
||||
let mut hidden: Vec<(&str, &str)> = view
|
||||
.hidden_items
|
||||
.iter()
|
||||
.flat_map(|(cat, items)| items.iter().map(move |item| (cat.as_str(), item.as_str())))
|
||||
.collect();
|
||||
hidden.sort();
|
||||
@ -131,7 +133,9 @@ pub fn format_md(model: &Model) -> String {
|
||||
writeln!(out, "hidden: {}/{}", cat, item).unwrap();
|
||||
}
|
||||
// Collapsed groups (sorted for deterministic diffs)
|
||||
let mut collapsed: Vec<(&str, &str)> = view.collapsed_groups.iter()
|
||||
let mut collapsed: Vec<(&str, &str)> = view
|
||||
.collapsed_groups
|
||||
.iter()
|
||||
.flat_map(|(cat, gs)| gs.iter().map(move |g| (cat.as_str(), g.as_str())))
|
||||
.collect();
|
||||
collapsed.sort();
|
||||
@ -153,8 +157,8 @@ pub fn parse_md(text: &str) -> Result<Model> {
|
||||
|
||||
struct PCategory {
|
||||
name: String,
|
||||
items: Vec<(String, Option<String>)>, // (name, group)
|
||||
group_parents: Vec<(String, String)>, // (group, parent)
|
||||
items: Vec<(String, Option<String>)>, // (name, group)
|
||||
group_parents: Vec<(String, String)>, // (group, parent)
|
||||
}
|
||||
|
||||
struct PView {
|
||||
@ -170,50 +174,78 @@ pub fn parse_md(text: &str) -> Result<Model> {
|
||||
// ── Pass 1: collect ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum Section { None, Category, Formulas, Data, View }
|
||||
enum Section {
|
||||
None,
|
||||
Category,
|
||||
Formulas,
|
||||
Data,
|
||||
View,
|
||||
}
|
||||
|
||||
let mut model_name: Option<String> = None;
|
||||
let mut categories: Vec<PCategory> = Vec::new();
|
||||
let mut formulas: Vec<(String, String)> = Vec::new(); // (raw, category)
|
||||
let mut data: Vec<(CellKey, CellValue)> = Vec::new();
|
||||
let mut views: Vec<PView> = Vec::new();
|
||||
let mut categories: Vec<PCategory> = Vec::new();
|
||||
let mut formulas: Vec<(String, String)> = Vec::new(); // (raw, category)
|
||||
let mut data: Vec<(CellKey, CellValue)> = Vec::new();
|
||||
let mut views: Vec<PView> = Vec::new();
|
||||
let mut section = Section::None;
|
||||
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() { continue; }
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("# ") && !trimmed.starts_with("## ") {
|
||||
model_name = Some(trimmed[2..].trim().to_string());
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("## Category: ") {
|
||||
categories.push(PCategory { name: rest.trim().to_string(),
|
||||
items: Vec::new(), group_parents: Vec::new() });
|
||||
categories.push(PCategory {
|
||||
name: rest.trim().to_string(),
|
||||
items: Vec::new(),
|
||||
group_parents: Vec::new(),
|
||||
});
|
||||
section = Section::Category;
|
||||
continue;
|
||||
}
|
||||
if trimmed == "## Formulas" { section = Section::Formulas; continue; }
|
||||
if trimmed == "## Data" { section = Section::Data; continue; }
|
||||
if trimmed == "## Formulas" {
|
||||
section = Section::Formulas;
|
||||
continue;
|
||||
}
|
||||
if trimmed == "## Data" {
|
||||
section = Section::Data;
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("## View: ") {
|
||||
let (name, is_active) = match rest.trim().strip_suffix(" (active)") {
|
||||
Some(n) => (n.trim().to_string(), true),
|
||||
None => (rest.trim().to_string(), false),
|
||||
None => (rest.trim().to_string(), false),
|
||||
};
|
||||
views.push(PView { name, is_active, axes: Vec::new(),
|
||||
page_selections: Vec::new(), format: String::new(),
|
||||
hidden: Vec::new(), collapsed: Vec::new() });
|
||||
views.push(PView {
|
||||
name,
|
||||
is_active,
|
||||
axes: Vec::new(),
|
||||
page_selections: Vec::new(),
|
||||
format: String::new(),
|
||||
hidden: Vec::new(),
|
||||
collapsed: Vec::new(),
|
||||
});
|
||||
section = Section::View;
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("## ") { continue; }
|
||||
if trimmed.starts_with("## ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
match section {
|
||||
Section::Category => {
|
||||
let Some(cat) = categories.last_mut() else { continue };
|
||||
let Some(cat) = categories.last_mut() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(rest) = trimmed.strip_prefix("- ") {
|
||||
let (name, group) = parse_bracketed(rest);
|
||||
cat.items.push((name.to_string(), group.map(str::to_string)));
|
||||
cat.items
|
||||
.push((name.to_string(), group.map(str::to_string)));
|
||||
} else if let Some(rest) = trimmed.strip_prefix("> ") {
|
||||
let (group, parent) = parse_bracketed(rest);
|
||||
if let Some(p) = parent {
|
||||
@ -230,14 +262,22 @@ pub fn parse_md(text: &str) -> Result<Model> {
|
||||
}
|
||||
}
|
||||
Section::Data => {
|
||||
let Some(sep) = trimmed.find(" = ") else { continue };
|
||||
let coords: Vec<(String, String)> = trimmed[..sep].split(", ")
|
||||
.filter_map(|p| { let (c, i) = p.split_once('=')?;
|
||||
Some((c.trim().to_string(), i.trim().to_string())) })
|
||||
let Some(sep) = trimmed.find(" = ") else {
|
||||
continue;
|
||||
};
|
||||
let coords: Vec<(String, String)> = trimmed[..sep]
|
||||
.split(", ")
|
||||
.filter_map(|p| {
|
||||
let (c, i) = p.split_once('=')?;
|
||||
Some((c.trim().to_string(), i.trim().to_string()))
|
||||
})
|
||||
.collect();
|
||||
if coords.is_empty() { continue; }
|
||||
if coords.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let vs = trimmed[sep + 3..].trim();
|
||||
let value = if let Some(s) = vs.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
|
||||
let value = if let Some(s) = vs.strip_prefix('"').and_then(|s| s.strip_suffix('"'))
|
||||
{
|
||||
CellValue::Text(s.to_string())
|
||||
} else if let Ok(n) = vs.parse::<f64>() {
|
||||
CellValue::Number(n)
|
||||
@ -247,28 +287,36 @@ pub fn parse_md(text: &str) -> Result<Model> {
|
||||
data.push((CellKey::new(coords), value));
|
||||
}
|
||||
Section::View => {
|
||||
let Some(view) = views.last_mut() else { continue };
|
||||
let Some(view) = views.last_mut() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(fmt) = trimmed.strip_prefix("format: ") {
|
||||
view.format = fmt.trim().to_string();
|
||||
} else if let Some(rest) = trimmed.strip_prefix("hidden: ") {
|
||||
if let Some((c, i)) = rest.trim().split_once('/') {
|
||||
view.hidden.push((c.trim().to_string(), i.trim().to_string()));
|
||||
view.hidden
|
||||
.push((c.trim().to_string(), i.trim().to_string()));
|
||||
}
|
||||
} else if let Some(rest) = trimmed.strip_prefix("collapsed: ") {
|
||||
if let Some((c, g)) = rest.trim().split_once('/') {
|
||||
view.collapsed.push((c.trim().to_string(), g.trim().to_string()));
|
||||
view.collapsed
|
||||
.push((c.trim().to_string(), g.trim().to_string()));
|
||||
}
|
||||
} else if let Some(colon) = trimmed.find(": ") {
|
||||
let cat = trimmed[..colon].trim();
|
||||
let cat = trimmed[..colon].trim();
|
||||
let rest = trimmed[colon + 2..].trim();
|
||||
if let Some(sel_rest) = rest.strip_prefix("page") {
|
||||
view.axes.push((cat.to_string(), Axis::Page));
|
||||
if let Some(sel) = sel_rest.strip_prefix(", ") {
|
||||
view.page_selections.push((cat.to_string(), sel.trim().to_string()));
|
||||
view.page_selections
|
||||
.push((cat.to_string(), sel.trim().to_string()));
|
||||
}
|
||||
} else {
|
||||
let axis = match rest { "row" => Axis::Row, "column" => Axis::Column,
|
||||
_ => continue };
|
||||
let axis = match rest {
|
||||
"row" => Axis::Row,
|
||||
"column" => Axis::Column,
|
||||
_ => continue,
|
||||
};
|
||||
view.axes.push((cat.to_string(), axis));
|
||||
}
|
||||
}
|
||||
@ -294,13 +342,15 @@ pub fn parse_md(text: &str) -> Result<Model> {
|
||||
cat.add_group(Group::new(g));
|
||||
}
|
||||
}
|
||||
None => { cat.add_item(item_name); }
|
||||
None => {
|
||||
cat.add_item(item_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (group_name, parent) in &pc.group_parents {
|
||||
match cat.groups.iter_mut().find(|g| &g.name == group_name) {
|
||||
Some(g) => g.parent = Some(parent.clone()),
|
||||
None => cat.add_group(Group::new(group_name).with_parent(parent)),
|
||||
None => cat.add_group(Group::new(group_name).with_parent(parent)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -308,14 +358,28 @@ pub fn parse_md(text: &str) -> Result<Model> {
|
||||
// Views — all categories are now registered, so set_axis works correctly
|
||||
let mut active_view = String::new();
|
||||
for pv in &views {
|
||||
if pv.is_active { active_view = pv.name.clone(); }
|
||||
if !m.views.contains_key(&pv.name) { m.create_view(&pv.name); }
|
||||
if pv.is_active {
|
||||
active_view = pv.name.clone();
|
||||
}
|
||||
if !m.views.contains_key(&pv.name) {
|
||||
m.create_view(&pv.name);
|
||||
}
|
||||
let view = m.views.get_mut(&pv.name).unwrap();
|
||||
for (cat, axis) in &pv.axes { view.set_axis(cat, *axis); }
|
||||
for (cat, sel) in &pv.page_selections { view.set_page_selection(cat, sel); }
|
||||
if !pv.format.is_empty() { view.number_format = pv.format.clone(); }
|
||||
for (cat, item) in &pv.hidden { view.hide_item(cat, item); }
|
||||
for (cat, grp) in &pv.collapsed { view.toggle_group_collapse(cat, grp); }
|
||||
for (cat, axis) in &pv.axes {
|
||||
view.set_axis(cat, *axis);
|
||||
}
|
||||
for (cat, sel) in &pv.page_selections {
|
||||
view.set_page_selection(cat, sel);
|
||||
}
|
||||
if !pv.format.is_empty() {
|
||||
view.number_format = pv.format.clone();
|
||||
}
|
||||
for (cat, item) in &pv.hidden {
|
||||
view.hide_item(cat, item);
|
||||
}
|
||||
for (cat, grp) in &pv.collapsed {
|
||||
view.toggle_group_collapse(cat, grp);
|
||||
}
|
||||
}
|
||||
if !active_view.is_empty() && m.views.contains_key(&active_view) {
|
||||
m.active_view = active_view;
|
||||
@ -323,8 +387,7 @@ pub fn parse_md(text: &str) -> Result<Model> {
|
||||
|
||||
// Formulas and data can go in any order relative to each other
|
||||
for (raw, cat_name) in &formulas {
|
||||
m.add_formula(parse_formula(raw, cat_name)
|
||||
.with_context(|| format!("Formula: {raw}"))?);
|
||||
m.add_formula(parse_formula(raw, cat_name).with_context(|| format!("Formula: {raw}"))?);
|
||||
}
|
||||
for (key, value) in data {
|
||||
m.set_cell(key, value);
|
||||
@ -346,11 +409,17 @@ fn parse_bracketed(s: &str) -> (&str, Option<&str>) {
|
||||
}
|
||||
|
||||
fn coord_str(key: &CellKey) -> String {
|
||||
key.0.iter().map(|(c, i)| format!("{}={}", c, i)).collect::<Vec<_>>().join(", ")
|
||||
key.0
|
||||
.iter()
|
||||
.map(|(c, i)| format!("{}={}", c, i))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
|
||||
let view = model.views.get(view_name)
|
||||
let view = model
|
||||
.views
|
||||
.get(view_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("View '{view_name}' not found"))?;
|
||||
|
||||
let layout = GridLayout::new(model, view);
|
||||
@ -359,16 +428,23 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
|
||||
|
||||
// Header row
|
||||
let row_header = layout.row_cats.join("/");
|
||||
let page_label: Vec<String> = layout.page_coords.iter()
|
||||
.map(|(c, v)| format!("{c}={v}")).collect();
|
||||
let header_prefix = if page_label.is_empty() { row_header } else {
|
||||
let page_label: Vec<String> = layout
|
||||
.page_coords
|
||||
.iter()
|
||||
.map(|(c, v)| format!("{c}={v}"))
|
||||
.collect();
|
||||
let header_prefix = if page_label.is_empty() {
|
||||
row_header
|
||||
} else {
|
||||
format!("{} ({})", row_header, page_label.join(", "))
|
||||
};
|
||||
if !header_prefix.is_empty() {
|
||||
out.push_str(&header_prefix);
|
||||
out.push(',');
|
||||
}
|
||||
let col_labels: Vec<String> = (0..layout.col_count()).map(|ci| layout.col_label(ci)).collect();
|
||||
let col_labels: Vec<String> = (0..layout.col_count())
|
||||
.map(|ci| layout.col_label(ci))
|
||||
.collect();
|
||||
out.push_str(&col_labels.join(","));
|
||||
out.push('\n');
|
||||
|
||||
@ -380,10 +456,13 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
|
||||
out.push(',');
|
||||
}
|
||||
let row_values: Vec<String> = (0..layout.col_count())
|
||||
.map(|ci| layout.cell_key(ri, ci)
|
||||
.and_then(|key| model.evaluate(&key))
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default())
|
||||
.map(|ci| {
|
||||
layout
|
||||
.cell_key(ri, ci)
|
||||
.and_then(|key| model.evaluate(&key))
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
out.push_str(&row_values.join(","));
|
||||
out.push('\n');
|
||||
@ -396,22 +475,31 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_md, parse_md};
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::category::Group;
|
||||
use crate::view::Axis;
|
||||
use crate::formula::parse_formula;
|
||||
use crate::model::category::Group;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::view::Axis;
|
||||
|
||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||
CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect())
|
||||
CellKey::new(
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(c, i)| (c.to_string(), i.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn two_cat_model() -> Model {
|
||||
let mut m = Model::new("Budget");
|
||||
m.add_category("Type").unwrap();
|
||||
m.add_category("Month").unwrap();
|
||||
for item in ["Food", "Gas"] { m.category_mut("Type").unwrap().add_item(item); }
|
||||
for item in ["Jan", "Feb"] { m.category_mut("Month").unwrap().add_item(item); }
|
||||
for item in ["Food", "Gas"] {
|
||||
m.category_mut("Type").unwrap().add_item(item);
|
||||
}
|
||||
for item in ["Jan", "Feb"] {
|
||||
m.category_mut("Month").unwrap().add_item(item);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
@ -438,7 +526,9 @@ mod tests {
|
||||
fn format_md_item_with_group_uses_brackets() {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Month").unwrap();
|
||||
m.category_mut("Month").unwrap().add_item_in_group("Jan", "Q1");
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Jan", "Q1");
|
||||
let text = format_md(&m);
|
||||
assert!(text.contains("- Jan [Q1]"), "got:\n{text}");
|
||||
}
|
||||
@ -447,8 +537,12 @@ mod tests {
|
||||
fn format_md_group_hierarchy_uses_angle_prefix() {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Month").unwrap();
|
||||
m.category_mut("Month").unwrap().add_item_in_group("Jan", "Q1");
|
||||
m.category_mut("Month").unwrap().add_group(Group::new("Q1").with_parent("2025"));
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Jan", "Q1");
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_group(Group::new("Q1").with_parent("2025"));
|
||||
let text = format_md(&m);
|
||||
assert!(text.contains("> Q1 [2025]"), "got:\n{text}");
|
||||
}
|
||||
@ -456,14 +550,22 @@ mod tests {
|
||||
#[test]
|
||||
fn format_md_data_is_sorted_and_quoted() {
|
||||
let mut m = two_cat_model();
|
||||
m.set_cell(coord(&[("Month", "Feb"), ("Type", "Food")]), CellValue::Number(200.0));
|
||||
m.set_cell(coord(&[("Month", "Jan"), ("Type", "Gas")]), CellValue::Text("N/A".into()));
|
||||
m.set_cell(
|
||||
coord(&[("Month", "Feb"), ("Type", "Food")]),
|
||||
CellValue::Number(200.0),
|
||||
);
|
||||
m.set_cell(
|
||||
coord(&[("Month", "Jan"), ("Type", "Gas")]),
|
||||
CellValue::Text("N/A".into()),
|
||||
);
|
||||
let text = format_md(&m);
|
||||
let data_pos = text.find("## Data").unwrap();
|
||||
let feb_pos = text.find("Month=Feb").unwrap();
|
||||
let jan_pos = text.find("Month=Jan").unwrap();
|
||||
assert!(data_pos < feb_pos && feb_pos < jan_pos,
|
||||
"expected sorted order Feb < Jan:\n{text}");
|
||||
let data_pos = text.find("## Data").unwrap();
|
||||
let feb_pos = text.find("Month=Feb").unwrap();
|
||||
let jan_pos = text.find("Month=Jan").unwrap();
|
||||
assert!(
|
||||
data_pos < feb_pos && feb_pos < jan_pos,
|
||||
"expected sorted order Feb < Jan:\n{text}"
|
||||
);
|
||||
assert!(text.contains("= 200"), "number not quoted:\n{text}");
|
||||
assert!(text.contains("= \"N/A\""), "text should be quoted:\n{text}");
|
||||
}
|
||||
@ -485,7 +587,9 @@ mod tests {
|
||||
m.add_category("Region").unwrap();
|
||||
m.category_mut("Type").unwrap().add_item("Food");
|
||||
m.category_mut("Month").unwrap().add_item("Jan");
|
||||
for r in ["East", "West"] { m.category_mut("Region").unwrap().add_item(r); }
|
||||
for r in ["East", "West"] {
|
||||
m.category_mut("Region").unwrap().add_item(r);
|
||||
}
|
||||
m.active_view_mut().set_page_selection("Region", "West");
|
||||
let text = format_md(&m);
|
||||
assert!(text.contains("Region: page, West"), "got:\n{text}");
|
||||
@ -512,18 +616,28 @@ mod tests {
|
||||
fn parse_md_round_trips_categories_and_items() {
|
||||
let m = two_cat_model();
|
||||
let m2 = parse_md(&format_md(&m)).unwrap();
|
||||
assert!(m2.category("Type").and_then(|c| c.items.get("Food")).is_some());
|
||||
assert!(m2.category("Month").and_then(|c| c.items.get("Feb")).is_some());
|
||||
assert!(m2
|
||||
.category("Type")
|
||||
.and_then(|c| c.items.get("Food"))
|
||||
.is_some());
|
||||
assert!(m2
|
||||
.category("Month")
|
||||
.and_then(|c| c.items.get("Feb"))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_md_round_trips_item_group() {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Month").unwrap();
|
||||
m.category_mut("Month").unwrap().add_item_in_group("Jan", "Q1");
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Jan", "Q1");
|
||||
let m2 = parse_md(&format_md(&m)).unwrap();
|
||||
assert_eq!(
|
||||
m2.category("Month").and_then(|c| c.items.get("Jan")).and_then(|i| i.group.as_deref()),
|
||||
m2.category("Month")
|
||||
.and_then(|c| c.items.get("Jan"))
|
||||
.and_then(|i| i.group.as_deref()),
|
||||
Some("Q1")
|
||||
);
|
||||
}
|
||||
@ -532,8 +646,12 @@ mod tests {
|
||||
fn parse_md_round_trips_group_hierarchy() {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Month").unwrap();
|
||||
m.category_mut("Month").unwrap().add_item_in_group("Jan", "Q1");
|
||||
m.category_mut("Month").unwrap().add_group(Group::new("Q1").with_parent("2025"));
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Jan", "Q1");
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_group(Group::new("Q1").with_parent("2025"));
|
||||
let m2 = parse_md(&format_md(&m)).unwrap();
|
||||
let groups = &m2.category("Month").unwrap().groups;
|
||||
let q1 = groups.iter().find(|g| g.name == "Q1").unwrap();
|
||||
@ -543,13 +661,23 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_md_round_trips_data_cells() {
|
||||
let mut m = two_cat_model();
|
||||
m.set_cell(coord(&[("Month", "Jan"), ("Type", "Food")]), CellValue::Number(100.0));
|
||||
m.set_cell(coord(&[("Month", "Feb"), ("Type", "Gas")]), CellValue::Text("N/A".into()));
|
||||
m.set_cell(
|
||||
coord(&[("Month", "Jan"), ("Type", "Food")]),
|
||||
CellValue::Number(100.0),
|
||||
);
|
||||
m.set_cell(
|
||||
coord(&[("Month", "Feb"), ("Type", "Gas")]),
|
||||
CellValue::Text("N/A".into()),
|
||||
);
|
||||
let m2 = parse_md(&format_md(&m)).unwrap();
|
||||
assert_eq!(m2.get_cell(&coord(&[("Month", "Jan"), ("Type", "Food")])),
|
||||
Some(&CellValue::Number(100.0)));
|
||||
assert_eq!(m2.get_cell(&coord(&[("Month", "Feb"), ("Type", "Gas")])),
|
||||
Some(&CellValue::Text("N/A".into())));
|
||||
assert_eq!(
|
||||
m2.get_cell(&coord(&[("Month", "Jan"), ("Type", "Food")])),
|
||||
Some(&CellValue::Number(100.0))
|
||||
);
|
||||
assert_eq!(
|
||||
m2.get_cell(&coord(&[("Month", "Feb"), ("Type", "Gas")])),
|
||||
Some(&CellValue::Text("N/A".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -557,7 +685,7 @@ mod tests {
|
||||
let m = two_cat_model();
|
||||
let m2 = parse_md(&format_md(&m)).unwrap();
|
||||
let v = m2.active_view();
|
||||
assert_eq!(v.axis_of("Type"), Axis::Row);
|
||||
assert_eq!(v.axis_of("Type"), Axis::Row);
|
||||
assert_eq!(v.axis_of("Month"), Axis::Column);
|
||||
}
|
||||
|
||||
@ -569,7 +697,9 @@ mod tests {
|
||||
m.add_category("Region").unwrap();
|
||||
m.category_mut("Type").unwrap().add_item("Food");
|
||||
m.category_mut("Month").unwrap().add_item("Jan");
|
||||
for r in ["East", "West"] { m.category_mut("Region").unwrap().add_item(r); }
|
||||
for r in ["East", "West"] {
|
||||
m.category_mut("Region").unwrap().add_item(r);
|
||||
}
|
||||
m.active_view_mut().set_page_selection("Region", "West");
|
||||
let m2 = parse_md(&format_md(&m)).unwrap();
|
||||
assert_eq!(m2.active_view().page_selection("Region"), Some("West"));
|
||||
@ -621,7 +751,7 @@ mod tests {
|
||||
## Category: Month\n\
|
||||
- Jan\n";
|
||||
let m = parse_md(text).unwrap();
|
||||
assert_eq!(m.active_view().axis_of("Type"), Axis::Row);
|
||||
assert_eq!(m.active_view().axis_of("Type"), Axis::Row);
|
||||
assert_eq!(m.active_view().axis_of("Month"), Axis::Column);
|
||||
}
|
||||
|
||||
@ -641,10 +771,10 @@ mod tests {
|
||||
- Jan\n";
|
||||
let m = parse_md(text).unwrap();
|
||||
let transposed = m.views.get("Transposed").unwrap();
|
||||
assert_eq!(transposed.axis_of("Type"), Axis::Column);
|
||||
assert_eq!(transposed.axis_of("Type"), Axis::Column);
|
||||
assert_eq!(transposed.axis_of("Month"), Axis::Row);
|
||||
let default = m.views.get("Default").unwrap();
|
||||
assert_eq!(default.axis_of("Type"), Axis::Row);
|
||||
assert_eq!(default.axis_of("Type"), Axis::Row);
|
||||
assert_eq!(default.axis_of("Month"), Axis::Column);
|
||||
}
|
||||
|
||||
|
||||
@ -6,14 +6,14 @@ use ratatui::{
|
||||
};
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::view::Axis;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::view::Axis;
|
||||
|
||||
fn axis_display(axis: Axis) -> (&'static str, Color) {
|
||||
match axis {
|
||||
Axis::Row => ("Row ↕", Color::Green),
|
||||
Axis::Row => ("Row ↕", Color::Green),
|
||||
Axis::Column => ("Col ↔", Color::Blue),
|
||||
Axis::Page => ("Page ☰", Color::Magenta),
|
||||
Axis::Page => ("Page ☰", Color::Magenta),
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,20 +25,30 @@ pub struct CategoryPanel<'a> {
|
||||
|
||||
impl<'a> CategoryPanel<'a> {
|
||||
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
|
||||
Self { model, mode, cursor }
|
||||
Self {
|
||||
model,
|
||||
mode,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for CategoryPanel<'a> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let is_item_add = matches!(self.mode, AppMode::ItemAdd { .. });
|
||||
let is_cat_add = matches!(self.mode, AppMode::CategoryAdd { .. });
|
||||
let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add;
|
||||
let is_cat_add = matches!(self.mode, AppMode::CategoryAdd { .. });
|
||||
let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add;
|
||||
|
||||
let (border_color, title) = if is_cat_add {
|
||||
(Color::Yellow, " Categories — New category (Enter:add Esc:done) ")
|
||||
(
|
||||
Color::Yellow,
|
||||
" Categories — New category (Enter:add Esc:done) ",
|
||||
)
|
||||
} else if is_item_add {
|
||||
(Color::Green, " Categories — Adding items (Enter:add Esc:done) ")
|
||||
(
|
||||
Color::Green,
|
||||
" Categories — Adding items (Enter:add Esc:done) ",
|
||||
)
|
||||
} else if is_active {
|
||||
(Color::Cyan, " Categories n:new a:add-items Space:axis ")
|
||||
} else {
|
||||
@ -56,9 +66,12 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
|
||||
let cat_names: Vec<&str> = self.model.category_names();
|
||||
if cat_names.is_empty() {
|
||||
buf.set_string(inner.x, inner.y,
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
inner.y,
|
||||
"(no categories — use :add-cat <name>)",
|
||||
Style::default().fg(Color::DarkGray));
|
||||
Style::default().fg(Color::DarkGray),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -67,24 +80,35 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
let list_height = inner.height.saturating_sub(prompt_rows);
|
||||
|
||||
for (i, cat_name) in cat_names.iter().enumerate() {
|
||||
if i as u16 >= list_height { break; }
|
||||
if i as u16 >= list_height {
|
||||
break;
|
||||
}
|
||||
let y = inner.y + i as u16;
|
||||
|
||||
let (axis_str, axis_color) = axis_display(view.axis_of(cat_name));
|
||||
|
||||
let item_count = self.model.category(cat_name).map(|c| c.items.len()).unwrap_or(0);
|
||||
let item_count = self
|
||||
.model
|
||||
.category(cat_name)
|
||||
.map(|c| c.items.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Highlight the selected category both in CategoryPanel and ItemAdd modes
|
||||
let is_selected_cat = if is_item_add {
|
||||
if let AppMode::ItemAdd { category, .. } = self.mode {
|
||||
*cat_name == category.as_str()
|
||||
} else { false }
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
i == self.cursor && is_active
|
||||
};
|
||||
|
||||
let base_style = if is_selected_cat {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
@ -99,19 +123,23 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
|
||||
buf.set_string(inner.x, y, &name_part, base_style);
|
||||
if name_part.len() + axis_part.len() < inner.width as usize {
|
||||
buf.set_string(inner.x + name_part.len() as u16, y, &axis_part,
|
||||
if is_selected_cat { base_style } else { Style::default().fg(axis_color) });
|
||||
buf.set_string(
|
||||
inner.x + name_part.len() as u16,
|
||||
y,
|
||||
&axis_part,
|
||||
if is_selected_cat {
|
||||
base_style
|
||||
} else {
|
||||
Style::default().fg(axis_color)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline prompt at the bottom for CategoryAdd or ItemAdd
|
||||
let (prompt_color, prompt_text) = match self.mode {
|
||||
AppMode::CategoryAdd { buffer } => {
|
||||
(Color::Yellow, format!(" + category: {buffer}▌"))
|
||||
}
|
||||
AppMode::ItemAdd { buffer, .. } => {
|
||||
(Color::Green, format!(" + item: {buffer}▌"))
|
||||
}
|
||||
AppMode::CategoryAdd { buffer } => (Color::Yellow, format!(" + category: {buffer}▌")),
|
||||
AppMode::ItemAdd { buffer, .. } => (Color::Green, format!(" + item: {buffer}▌")),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
@ -122,8 +150,14 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
buf.set_string(inner.x, sep_y, &sep, Style::default().fg(prompt_color));
|
||||
}
|
||||
if prompt_y < inner.y + inner.height {
|
||||
buf.set_string(inner.x, prompt_y, &prompt_text,
|
||||
Style::default().fg(prompt_color).add_modifier(Modifier::BOLD));
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
prompt_y,
|
||||
&prompt_text,
|
||||
Style::default()
|
||||
.fg(prompt_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,13 +16,20 @@ pub struct FormulaPanel<'a> {
|
||||
|
||||
impl<'a> FormulaPanel<'a> {
|
||||
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
|
||||
Self { model, mode, cursor }
|
||||
Self {
|
||||
model,
|
||||
mode,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for FormulaPanel<'a> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let is_active = matches!(self.mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. });
|
||||
let is_active = matches!(
|
||||
self.mode,
|
||||
AppMode::FormulaPanel | AppMode::FormulaEdit { .. }
|
||||
);
|
||||
let border_style = if is_active {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
@ -39,17 +46,25 @@ impl<'a> Widget for FormulaPanel<'a> {
|
||||
let formulas = self.model.formulas();
|
||||
|
||||
if formulas.is_empty() {
|
||||
buf.set_string(inner.x, inner.y,
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
inner.y,
|
||||
"(no formulas — press 'n' to add)",
|
||||
Style::default().fg(Color::DarkGray));
|
||||
Style::default().fg(Color::DarkGray),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (i, formula) in formulas.iter().enumerate() {
|
||||
if inner.y + i as u16 >= inner.y + inner.height { break; }
|
||||
if inner.y + i as u16 >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let is_selected = i == self.cursor && is_active;
|
||||
let style = if is_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Green)
|
||||
};
|
||||
@ -65,9 +80,12 @@ impl<'a> Widget for FormulaPanel<'a> {
|
||||
// Formula edit mode
|
||||
if let AppMode::FormulaEdit { buffer } = self.mode {
|
||||
let y = inner.y + inner.height.saturating_sub(2);
|
||||
buf.set_string(inner.x, y,
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
y,
|
||||
"┄ Enter formula (Name = expr): ",
|
||||
Style::default().fg(Color::Yellow));
|
||||
Style::default().fg(Color::Yellow),
|
||||
);
|
||||
let y = y + 1;
|
||||
let prompt = format!("> {buffer}█");
|
||||
buf.set_string(inner.x, y, &prompt, Style::default().fg(Color::Green));
|
||||
|
||||
@ -5,8 +5,8 @@ use ratatui::{
|
||||
widgets::{Block, Borders, Clear, Widget},
|
||||
};
|
||||
|
||||
use crate::import::wizard::{ImportWizard, WizardStep};
|
||||
use crate::import::analyzer::FieldKind;
|
||||
use crate::import::wizard::{ImportWizard, WizardStep};
|
||||
|
||||
pub struct ImportWizardWidget<'a> {
|
||||
pub wizard: &'a ImportWizard,
|
||||
@ -52,20 +52,31 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
||||
let summary = self.wizard.pipeline.preview_summary();
|
||||
buf.set_string(x, y, truncate(&summary, w), Style::default());
|
||||
y += 2;
|
||||
buf.set_string(x, y,
|
||||
buf.set_string(
|
||||
x,
|
||||
y,
|
||||
"Press Enter to continue\u{2026}",
|
||||
Style::default().fg(Color::Yellow));
|
||||
Style::default().fg(Color::Yellow),
|
||||
);
|
||||
}
|
||||
WizardStep::SelectArrayPath => {
|
||||
buf.set_string(x, y,
|
||||
buf.set_string(
|
||||
x,
|
||||
y,
|
||||
"Select the path containing records:",
|
||||
Style::default().fg(Color::Yellow));
|
||||
Style::default().fg(Color::Yellow),
|
||||
);
|
||||
y += 1;
|
||||
for (i, path) in self.wizard.pipeline.array_paths.iter().enumerate() {
|
||||
if y >= inner.y + inner.height { break; }
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let is_sel = i == self.wizard.cursor;
|
||||
let style = if is_sel {
|
||||
Style::default().fg(Color::Black).bg(Color::Magenta).add_modifier(Modifier::BOLD)
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
@ -74,19 +85,36 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
||||
y += 1;
|
||||
}
|
||||
y += 1;
|
||||
buf.set_string(x, y, "\u{2191}\u{2193} select Enter confirm", Style::default().fg(Color::DarkGray));
|
||||
buf.set_string(
|
||||
x,
|
||||
y,
|
||||
"\u{2191}\u{2193} select Enter confirm",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
);
|
||||
}
|
||||
WizardStep::ReviewProposals => {
|
||||
buf.set_string(x, y,
|
||||
buf.set_string(
|
||||
x,
|
||||
y,
|
||||
"Review field proposals (Space toggle, c cycle kind):",
|
||||
Style::default().fg(Color::Yellow));
|
||||
Style::default().fg(Color::Yellow),
|
||||
);
|
||||
y += 1;
|
||||
let header = format!(" {:<20} {:<22} {:<6}", "Field", "Kind", "Accept");
|
||||
buf.set_string(x, y, truncate(&header, w), Style::default().fg(Color::Gray).add_modifier(Modifier::UNDERLINED));
|
||||
buf.set_string(
|
||||
x,
|
||||
y,
|
||||
truncate(&header, w),
|
||||
Style::default()
|
||||
.fg(Color::Gray)
|
||||
.add_modifier(Modifier::UNDERLINED),
|
||||
);
|
||||
y += 1;
|
||||
|
||||
for (i, proposal) in self.wizard.pipeline.proposals.iter().enumerate() {
|
||||
if y >= inner.y + inner.height - 2 { break; }
|
||||
if y >= inner.y + inner.height - 2 {
|
||||
break;
|
||||
}
|
||||
let is_sel = i == self.wizard.cursor;
|
||||
|
||||
let kind_color = match proposal.kind {
|
||||
@ -96,14 +124,23 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
||||
FieldKind::Label => Color::DarkGray,
|
||||
};
|
||||
|
||||
let accept_str = if proposal.accepted { "[\u{2713}]" } else { "[ ]" };
|
||||
let row = format!(" {:<20} {:<22} {}",
|
||||
let accept_str = if proposal.accepted {
|
||||
"[\u{2713}]"
|
||||
} else {
|
||||
"[ ]"
|
||||
};
|
||||
let row = format!(
|
||||
" {:<20} {:<22} {}",
|
||||
truncate(&proposal.field, 20),
|
||||
truncate(proposal.kind_label(), 22),
|
||||
accept_str);
|
||||
accept_str
|
||||
);
|
||||
|
||||
let style = if is_sel {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if proposal.accepted {
|
||||
Style::default().fg(kind_color)
|
||||
} else {
|
||||
@ -114,23 +151,34 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
||||
y += 1;
|
||||
}
|
||||
let hint_y = inner.y + inner.height - 1;
|
||||
buf.set_string(x, hint_y, "Enter: next Space: toggle c: cycle kind Esc: cancel",
|
||||
Style::default().fg(Color::DarkGray));
|
||||
buf.set_string(
|
||||
x,
|
||||
hint_y,
|
||||
"Enter: next Space: toggle c: cycle kind Esc: cancel",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
);
|
||||
}
|
||||
WizardStep::NameModel => {
|
||||
buf.set_string(x, y, "Model name:", Style::default().fg(Color::Yellow));
|
||||
y += 1;
|
||||
let name_str = format!("> {}\u{2588}", self.wizard.pipeline.model_name);
|
||||
buf.set_string(x, y, truncate(&name_str, w),
|
||||
Style::default().fg(Color::Green));
|
||||
buf.set_string(
|
||||
x,
|
||||
y,
|
||||
truncate(&name_str, w),
|
||||
Style::default().fg(Color::Green),
|
||||
);
|
||||
y += 2;
|
||||
buf.set_string(x, y, "Enter to import, Esc to cancel",
|
||||
Style::default().fg(Color::DarkGray));
|
||||
buf.set_string(
|
||||
x,
|
||||
y,
|
||||
"Enter to import, Esc to cancel",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
);
|
||||
|
||||
if let Some(msg) = &self.wizard.message {
|
||||
let msg_y = inner.y + inner.height - 1;
|
||||
buf.set_string(x, msg_y, truncate(msg, w),
|
||||
Style::default().fg(Color::Red));
|
||||
buf.set_string(x, msg_y, truncate(msg, w), Style::default().fg(Color::Red));
|
||||
}
|
||||
}
|
||||
WizardStep::Done => {
|
||||
@ -141,7 +189,11 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max { s.to_string() }
|
||||
else if max > 1 { format!("{}\u{2026}", &s[..max-1]) }
|
||||
else { s[..max].to_string() }
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else if max > 1 {
|
||||
format!("{}\u{2026}", &s[..max - 1])
|
||||
} else {
|
||||
s[..max].to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
pub mod app;
|
||||
pub mod grid;
|
||||
pub mod formula_panel;
|
||||
pub mod category_panel;
|
||||
pub mod view_panel;
|
||||
pub mod tile_bar;
|
||||
pub mod import_wizard_ui;
|
||||
pub mod formula_panel;
|
||||
pub mod grid;
|
||||
pub mod help;
|
||||
|
||||
pub mod import_wizard_ui;
|
||||
pub mod tile_bar;
|
||||
pub mod view_panel;
|
||||
|
||||
@ -6,14 +6,14 @@ use ratatui::{
|
||||
};
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::view::Axis;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::view::Axis;
|
||||
|
||||
fn axis_display(axis: Axis) -> (&'static str, Color) {
|
||||
match axis {
|
||||
Axis::Row => ("↕", Color::Green),
|
||||
Axis::Row => ("↕", Color::Green),
|
||||
Axis::Column => ("↔", Color::Blue),
|
||||
Axis::Page => ("☰", Color::Magenta),
|
||||
Axis::Page => ("☰", Color::Magenta),
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,12 +49,17 @@ impl<'a> Widget for TileBar<'a> {
|
||||
let is_selected = selected_cat_idx == Some(i);
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(axis_color)
|
||||
};
|
||||
|
||||
if x + label.len() as u16 > area.x + area.width { break; }
|
||||
if x + label.len() as u16 > area.x + area.width {
|
||||
break;
|
||||
}
|
||||
buf.set_string(x, area.y, &label, style);
|
||||
x += label.len() as u16;
|
||||
}
|
||||
|
||||
@ -16,7 +16,11 @@ pub struct ViewPanel<'a> {
|
||||
|
||||
impl<'a> ViewPanel<'a> {
|
||||
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
|
||||
Self { model, mode, cursor }
|
||||
Self {
|
||||
model,
|
||||
mode,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,23 +44,33 @@ impl<'a> Widget for ViewPanel<'a> {
|
||||
let active = &self.model.active_view;
|
||||
|
||||
for (i, view_name) in view_names.iter().enumerate() {
|
||||
if inner.y + i as u16 >= inner.y + inner.height { break; }
|
||||
if inner.y + i as u16 >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let is_selected = i == self.cursor && is_active;
|
||||
let is_active_view = *view_name == active.as_str();
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Blue).add_modifier(Modifier::BOLD)
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Blue)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_active_view {
|
||||
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)
|
||||
Style::default()
|
||||
.fg(Color::Blue)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let prefix = if is_active_view { "▶ " } else { " " };
|
||||
buf.set_string(inner.x, inner.y + i as u16,
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
inner.y + i as u16,
|
||||
format!("{prefix}{view_name}"),
|
||||
style);
|
||||
style,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,9 +11,9 @@ pub enum Axis {
|
||||
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::Row => write!(f, "Row ↕"),
|
||||
Axis::Column => write!(f, "Col ↔"),
|
||||
Axis::Page => write!(f, "Page ☰"),
|
||||
Axis::Page => write!(f, "Page ☰"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user