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