chore: format
This commit is contained in:
@ -36,31 +36,61 @@ fn load_grammar() -> HashMap<String, (RuleType, Expr)> {
|
||||
// ── Word pools for realistic output ─────────────────────────────────────────
|
||||
|
||||
const BARE_WORDS: &[&str] = &[
|
||||
"Region", "Product", "Customer", "Channel", "Date",
|
||||
"North", "South", "East", "West",
|
||||
"Revenue", "Cost", "Profit", "Margin",
|
||||
"Widgets", "Gadgets", "Sprockets",
|
||||
"Q1", "Q2", "Q3", "Q4",
|
||||
"Jan", "Feb", "Mar", "Apr",
|
||||
"Acme", "Globex", "Initech", "Umbrella",
|
||||
"Region",
|
||||
"Product",
|
||||
"Customer",
|
||||
"Channel",
|
||||
"Date",
|
||||
"North",
|
||||
"South",
|
||||
"East",
|
||||
"West",
|
||||
"Revenue",
|
||||
"Cost",
|
||||
"Profit",
|
||||
"Margin",
|
||||
"Widgets",
|
||||
"Gadgets",
|
||||
"Sprockets",
|
||||
"Q1",
|
||||
"Q2",
|
||||
"Q3",
|
||||
"Q4",
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"Acme",
|
||||
"Globex",
|
||||
"Initech",
|
||||
"Umbrella",
|
||||
];
|
||||
|
||||
const QUOTED_WORDS: &[&str] = &[
|
||||
"Total Revenue", "Net Income", "Gross Margin",
|
||||
"2025-01", "2025-02", "2025-03",
|
||||
"East Coast", "West Coast",
|
||||
"Acme Corp", "Globex Inc",
|
||||
"Cost of Goods", "Operating Expense",
|
||||
"Total Revenue",
|
||||
"Net Income",
|
||||
"Gross Margin",
|
||||
"2025-01",
|
||||
"2025-02",
|
||||
"2025-03",
|
||||
"East Coast",
|
||||
"West Coast",
|
||||
"Acme Corp",
|
||||
"Globex Inc",
|
||||
"Cost of Goods",
|
||||
"Operating Expense",
|
||||
];
|
||||
|
||||
const MODEL_NAMES: &[&str] = &[
|
||||
"Sales Report", "Budget 2025", "Quarterly Review",
|
||||
"Inventory Model", "Revenue Analysis", "Demo Model",
|
||||
"Sales Report",
|
||||
"Budget 2025",
|
||||
"Quarterly Review",
|
||||
"Inventory Model",
|
||||
"Revenue Analysis",
|
||||
"Demo Model",
|
||||
];
|
||||
|
||||
const VIEW_NAMES: &[&str] = &[
|
||||
"Default", "Summary", "Detail", "By Region", "Monthly",
|
||||
];
|
||||
const VIEW_NAMES: &[&str] = &["Default", "Summary", "Detail", "By Region", "Monthly"];
|
||||
|
||||
const FORMULA_EXPRS: &[&str] = &[
|
||||
"Profit = Revenue - Cost",
|
||||
@ -70,9 +100,7 @@ const FORMULA_EXPRS: &[&str] = &[
|
||||
"Net = Revenue - Cost - Tax",
|
||||
];
|
||||
|
||||
const FORMAT_STRINGS: &[&str] = &[
|
||||
",.0", ",.2f", ",.1f", ".0%",
|
||||
];
|
||||
const FORMAT_STRINGS: &[&str] = &[",.0", ",.2f", ",.1f", ".0%"];
|
||||
|
||||
// ── PRNG ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ use crate::ui::app::AppMode;
|
||||
use crate::ui::effect::{self, Effect};
|
||||
|
||||
use super::core::{Cmd, CmdContext};
|
||||
use super::navigation::{viewport_effects, CursorState, EnterAdvance};
|
||||
use super::navigation::{CursorState, EnterAdvance, viewport_effects};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@ -3,8 +3,8 @@ use std::fmt::Debug;
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::ui::effect::{Effect, Panel};
|
||||
use crate::view::{Axis, GridLayout};
|
||||
|
||||
@ -2,7 +2,7 @@ use crate::model::cell::CellValue;
|
||||
use crate::ui::effect::{self, Effect};
|
||||
use crate::view::Axis;
|
||||
|
||||
use super::core::{require_args, Cmd, CmdContext};
|
||||
use super::core::{Cmd, CmdContext, require_args};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@ -110,7 +110,6 @@ macro_rules! effect_cmd {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
effect_cmd!(
|
||||
AddCategoryCmd,
|
||||
"add-category",
|
||||
@ -202,7 +201,10 @@ effect_cmd!(
|
||||
"add-formula",
|
||||
|args: &[String]| {
|
||||
if args.is_empty() || args.len() > 2 {
|
||||
return Err(format!("add-formula requires 1-2 argument(s), got {}", args.len()));
|
||||
return Err(format!(
|
||||
"add-formula requires 1-2 argument(s), got {}",
|
||||
args.len()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
@ -397,7 +399,10 @@ effect_cmd!(
|
||||
"help",
|
||||
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![effect::help_page_set(0), effect::change_mode(crate::ui::app::AppMode::Help)]
|
||||
vec![
|
||||
effect::help_page_set(0),
|
||||
effect::change_mode(crate::ui::app::AppMode::Help),
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
pub mod core;
|
||||
pub mod navigation;
|
||||
pub mod mode;
|
||||
pub mod cell;
|
||||
pub mod search;
|
||||
pub mod panel;
|
||||
pub mod grid;
|
||||
pub mod tile;
|
||||
pub mod text_buffer;
|
||||
pub mod commit;
|
||||
pub mod core;
|
||||
pub mod effect_cmds;
|
||||
pub mod grid;
|
||||
pub mod mode;
|
||||
pub mod navigation;
|
||||
pub mod panel;
|
||||
pub mod registry;
|
||||
pub mod search;
|
||||
pub mod text_buffer;
|
||||
pub mod tile;
|
||||
|
||||
// Re-export items used by external code
|
||||
pub use self::core::{Cmd, CmdContext, CmdRegistry};
|
||||
|
||||
@ -3,7 +3,7 @@ use crossterm::event::KeyCode;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::ui::effect::{self, Effect};
|
||||
|
||||
use super::core::{read_buffer, Cmd, CmdContext};
|
||||
use super::core::{Cmd, CmdContext, read_buffer};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@ -235,9 +235,8 @@ impl Keymap {
|
||||
|
||||
/// Look up the binding for a key, falling through to parent keymaps.
|
||||
pub fn lookup(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
|
||||
self.lookup_local(key, mods).or_else(|| {
|
||||
self.parent.as_ref().and_then(|p| p.lookup(key, mods))
|
||||
})
|
||||
self.lookup_local(key, mods)
|
||||
.or_else(|| self.parent.as_ref().and_then(|p| p.lookup(key, mods)))
|
||||
}
|
||||
|
||||
/// Dispatch a key: look up binding, resolve through registry, return effects.
|
||||
@ -729,50 +728,82 @@ impl KeymapSet {
|
||||
|
||||
// ── Editing mode ─────────────────────────────────────────────────
|
||||
let mut ed = Keymap::new();
|
||||
ed.bind_seq(KeyCode::Esc, none, vec![
|
||||
("clear-buffer", vec!["edit".into()]),
|
||||
("enter-mode", vec!["normal".into()]),
|
||||
]);
|
||||
ed.bind_seq(KeyCode::Enter, none, vec![
|
||||
("commit-cell-edit", vec![]),
|
||||
("clear-buffer", vec!["edit".into()]),
|
||||
]);
|
||||
ed.bind_seq(KeyCode::Tab, none, vec![
|
||||
("commit-and-advance-right", vec![]),
|
||||
("clear-buffer", vec!["edit".into()]),
|
||||
]);
|
||||
ed.bind_seq(
|
||||
KeyCode::Esc,
|
||||
none,
|
||||
vec![
|
||||
("clear-buffer", vec!["edit".into()]),
|
||||
("enter-mode", vec!["normal".into()]),
|
||||
],
|
||||
);
|
||||
ed.bind_seq(
|
||||
KeyCode::Enter,
|
||||
none,
|
||||
vec![
|
||||
("commit-cell-edit", vec![]),
|
||||
("clear-buffer", vec!["edit".into()]),
|
||||
],
|
||||
);
|
||||
ed.bind_seq(
|
||||
KeyCode::Tab,
|
||||
none,
|
||||
vec![
|
||||
("commit-and-advance-right", vec![]),
|
||||
("clear-buffer", vec!["edit".into()]),
|
||||
],
|
||||
);
|
||||
ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]);
|
||||
ed.bind_any_char("append-char", vec!["edit".into()]);
|
||||
set.insert(ModeKey::Editing, Arc::new(ed));
|
||||
|
||||
// ── Formula edit ─────────────────────────────────────────────────
|
||||
let mut fe = Keymap::new();
|
||||
fe.bind_seq(KeyCode::Esc, none, vec![
|
||||
("clear-buffer", vec!["formula".into()]),
|
||||
("enter-mode", vec!["formula-panel".into()]),
|
||||
]);
|
||||
fe.bind_seq(KeyCode::Enter, none, vec![
|
||||
("commit-formula", vec![]),
|
||||
("clear-buffer", vec!["formula".into()]),
|
||||
]);
|
||||
fe.bind_seq(
|
||||
KeyCode::Esc,
|
||||
none,
|
||||
vec![
|
||||
("clear-buffer", vec!["formula".into()]),
|
||||
("enter-mode", vec!["formula-panel".into()]),
|
||||
],
|
||||
);
|
||||
fe.bind_seq(
|
||||
KeyCode::Enter,
|
||||
none,
|
||||
vec![
|
||||
("commit-formula", vec![]),
|
||||
("clear-buffer", vec!["formula".into()]),
|
||||
],
|
||||
);
|
||||
fe.bind_args(KeyCode::Backspace, none, "pop-char", vec!["formula".into()]);
|
||||
fe.bind_any_char("append-char", vec!["formula".into()]);
|
||||
set.insert(ModeKey::FormulaEdit, Arc::new(fe));
|
||||
|
||||
// ── Category add ─────────────────────────────────────────────────
|
||||
let mut ca = Keymap::new();
|
||||
ca.bind_seq(KeyCode::Esc, none, vec![
|
||||
("clear-buffer", vec!["category".into()]),
|
||||
("enter-mode", vec!["category-panel".into()]),
|
||||
]);
|
||||
ca.bind_seq(KeyCode::Enter, none, vec![
|
||||
("commit-category-add", vec![]),
|
||||
("clear-buffer", vec!["category".into()]),
|
||||
]);
|
||||
ca.bind_seq(KeyCode::Tab, none, vec![
|
||||
("commit-category-add", vec![]),
|
||||
("clear-buffer", vec!["category".into()]),
|
||||
]);
|
||||
ca.bind_seq(
|
||||
KeyCode::Esc,
|
||||
none,
|
||||
vec![
|
||||
("clear-buffer", vec!["category".into()]),
|
||||
("enter-mode", vec!["category-panel".into()]),
|
||||
],
|
||||
);
|
||||
ca.bind_seq(
|
||||
KeyCode::Enter,
|
||||
none,
|
||||
vec![
|
||||
("commit-category-add", vec![]),
|
||||
("clear-buffer", vec!["category".into()]),
|
||||
],
|
||||
);
|
||||
ca.bind_seq(
|
||||
KeyCode::Tab,
|
||||
none,
|
||||
vec![
|
||||
("commit-category-add", vec![]),
|
||||
("clear-buffer", vec!["category".into()]),
|
||||
],
|
||||
);
|
||||
ca.bind_args(
|
||||
KeyCode::Backspace,
|
||||
none,
|
||||
@ -784,46 +815,74 @@ impl KeymapSet {
|
||||
|
||||
// ── Item add ─────────────────────────────────────────────────────
|
||||
let mut ia = Keymap::new();
|
||||
ia.bind_seq(KeyCode::Esc, none, vec![
|
||||
("clear-buffer", vec!["item".into()]),
|
||||
("enter-mode", vec!["category-panel".into()]),
|
||||
]);
|
||||
ia.bind_seq(KeyCode::Enter, none, vec![
|
||||
("commit-item-add", vec![]),
|
||||
("clear-buffer", vec!["item".into()]),
|
||||
]);
|
||||
ia.bind_seq(KeyCode::Tab, none, vec![
|
||||
("commit-item-add", vec![]),
|
||||
("clear-buffer", vec!["item".into()]),
|
||||
]);
|
||||
ia.bind_seq(
|
||||
KeyCode::Esc,
|
||||
none,
|
||||
vec![
|
||||
("clear-buffer", vec!["item".into()]),
|
||||
("enter-mode", vec!["category-panel".into()]),
|
||||
],
|
||||
);
|
||||
ia.bind_seq(
|
||||
KeyCode::Enter,
|
||||
none,
|
||||
vec![
|
||||
("commit-item-add", vec![]),
|
||||
("clear-buffer", vec!["item".into()]),
|
||||
],
|
||||
);
|
||||
ia.bind_seq(
|
||||
KeyCode::Tab,
|
||||
none,
|
||||
vec![
|
||||
("commit-item-add", vec![]),
|
||||
("clear-buffer", vec!["item".into()]),
|
||||
],
|
||||
);
|
||||
ia.bind_args(KeyCode::Backspace, none, "pop-char", vec!["item".into()]);
|
||||
ia.bind_any_char("append-char", vec!["item".into()]);
|
||||
set.insert(ModeKey::ItemAdd, Arc::new(ia));
|
||||
|
||||
// ── Export prompt ────────────────────────────────────────────────
|
||||
let mut ep = Keymap::new();
|
||||
ep.bind_seq(KeyCode::Esc, none, vec![
|
||||
("clear-buffer", vec!["export".into()]),
|
||||
("enter-mode", vec!["normal".into()]),
|
||||
]);
|
||||
ep.bind_seq(KeyCode::Enter, none, vec![
|
||||
("commit-export", vec![]),
|
||||
("clear-buffer", vec!["export".into()]),
|
||||
]);
|
||||
ep.bind_seq(
|
||||
KeyCode::Esc,
|
||||
none,
|
||||
vec![
|
||||
("clear-buffer", vec!["export".into()]),
|
||||
("enter-mode", vec!["normal".into()]),
|
||||
],
|
||||
);
|
||||
ep.bind_seq(
|
||||
KeyCode::Enter,
|
||||
none,
|
||||
vec![
|
||||
("commit-export", vec![]),
|
||||
("clear-buffer", vec!["export".into()]),
|
||||
],
|
||||
);
|
||||
ep.bind_args(KeyCode::Backspace, none, "pop-char", vec!["export".into()]);
|
||||
ep.bind_any_char("append-char", vec!["export".into()]);
|
||||
set.insert(ModeKey::ExportPrompt, Arc::new(ep));
|
||||
|
||||
// ── Command mode ─────────────────────────────────────────────────
|
||||
let mut cm = Keymap::new();
|
||||
cm.bind_seq(KeyCode::Esc, none, vec![
|
||||
("clear-buffer", vec!["command".into()]),
|
||||
("enter-mode", vec!["normal".into()]),
|
||||
]);
|
||||
cm.bind_seq(KeyCode::Enter, none, vec![
|
||||
("execute-command", vec![]),
|
||||
("clear-buffer", vec!["command".into()]),
|
||||
]);
|
||||
cm.bind_seq(
|
||||
KeyCode::Esc,
|
||||
none,
|
||||
vec![
|
||||
("clear-buffer", vec!["command".into()]),
|
||||
("enter-mode", vec!["normal".into()]),
|
||||
],
|
||||
);
|
||||
cm.bind_seq(
|
||||
KeyCode::Enter,
|
||||
none,
|
||||
vec![
|
||||
("execute-command", vec![]),
|
||||
("clear-buffer", vec!["command".into()]),
|
||||
],
|
||||
);
|
||||
cm.bind(KeyCode::Backspace, none, "command-mode-backspace");
|
||||
cm.bind_any_char("append-char", vec!["command".into()]);
|
||||
set.insert(ModeKey::CommandMode, Arc::new(cm));
|
||||
@ -1086,9 +1145,11 @@ mod tests {
|
||||
let ks = KeymapSet::default_keymaps();
|
||||
let editing = ks.mode_maps.get(&ModeKey::Editing).unwrap();
|
||||
// Should have AnyChar for text input
|
||||
assert!(editing
|
||||
.lookup(KeyCode::Char('z'), KeyModifiers::NONE)
|
||||
.is_some());
|
||||
assert!(
|
||||
editing
|
||||
.lookup(KeyCode::Char('z'), KeyModifiers::NONE)
|
||||
.is_some()
|
||||
);
|
||||
// Should have Esc to exit
|
||||
assert!(editing.lookup(KeyCode::Esc, KeyModifiers::NONE).is_some());
|
||||
}
|
||||
@ -1097,9 +1158,11 @@ mod tests {
|
||||
fn search_mode_has_any_char_and_esc() {
|
||||
let ks = KeymapSet::default_keymaps();
|
||||
let search = ks.mode_maps.get(&ModeKey::SearchMode).unwrap();
|
||||
assert!(search
|
||||
.lookup(KeyCode::Char('a'), KeyModifiers::NONE)
|
||||
.is_some());
|
||||
assert!(
|
||||
search
|
||||
.lookup(KeyCode::Char('a'), KeyModifiers::NONE)
|
||||
.is_some()
|
||||
);
|
||||
assert!(search.lookup(KeyCode::Esc, KeyModifiers::NONE).is_some());
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
//! Coordinate pairs use `/`: `Category/Item`
|
||||
//! Quoted strings supported: `"Profit = Revenue - Cost"`
|
||||
|
||||
use super::cmd::{default_registry, Cmd, CmdRegistry};
|
||||
use super::cmd::{Cmd, CmdRegistry, default_registry};
|
||||
|
||||
/// Parse a line into commands using the default registry.
|
||||
pub fn parse_line(line: &str) -> Result<Vec<Box<dyn Cmd>>, String> {
|
||||
|
||||
@ -6,14 +6,14 @@ use anyhow::Result;
|
||||
use crossterm::{
|
||||
event::{self, Event},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use ratatui::{
|
||||
Frame, Terminal,
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use crate::model::Model;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use super::ast::{AggFunc, BinOp, Expr, Filter, Formula};
|
||||
|
||||
@ -364,7 +364,7 @@ fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
||||
t => {
|
||||
return Err(anyhow!(
|
||||
"Expected category name, got {t:?}"
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
// expect =
|
||||
@ -537,11 +537,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_sum_with_top_level_where_works() {
|
||||
let f = parse_formula(
|
||||
"EastTotal = SUM(Revenue) WHERE Region = \"East\"",
|
||||
"Foo",
|
||||
)
|
||||
.unwrap();
|
||||
let f = parse_formula("EastTotal = SUM(Revenue) WHERE Region = \"East\"", "Foo").unwrap();
|
||||
assert!(matches!(f.expr, Expr::Agg(AggFunc::Sum, _, _)));
|
||||
let filter = f.filter.as_ref().unwrap();
|
||||
assert_eq!(filter.category, "Region");
|
||||
@ -552,11 +548,7 @@ mod tests {
|
||||
/// The tokenizer must not merge "Revenue WHERE" into a single identifier.
|
||||
#[test]
|
||||
fn parse_sum_with_inline_where_filter() {
|
||||
let f = parse_formula(
|
||||
"EastTotal = SUM(Revenue WHERE Region = \"East\")",
|
||||
"Foo",
|
||||
)
|
||||
.unwrap();
|
||||
let f = parse_formula("EastTotal = SUM(Revenue WHERE Region = \"East\")", "Foo").unwrap();
|
||||
if let Expr::Agg(AggFunc::Sum, inner, Some(filter)) = &f.expr {
|
||||
assert!(matches!(**inner, Expr::Ref(_)));
|
||||
assert_eq!(filter.category, "Region");
|
||||
@ -772,11 +764,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pipe_quoted_in_inline_where() {
|
||||
let f = parse_formula(
|
||||
"X = SUM(Revenue WHERE |Region Name| = |East Coast|)",
|
||||
"Foo",
|
||||
)
|
||||
.unwrap();
|
||||
let f =
|
||||
parse_formula("X = SUM(Revenue WHERE |Region Name| = |East Coast|)", "Foo").unwrap();
|
||||
if let Expr::Agg(AggFunc::Sum, _, Some(filter)) = &f.expr {
|
||||
assert_eq!(filter.category, "Region Name");
|
||||
assert_eq!(filter.item, "East Coast");
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{Result, anyhow};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::analyzer::{
|
||||
analyze_records, extract_array_at_path, extract_date_component, find_array_paths,
|
||||
DateComponent, FieldKind, FieldProposal,
|
||||
DateComponent, FieldKind, FieldProposal, analyze_records, extract_array_at_path,
|
||||
extract_date_component, find_array_paths,
|
||||
};
|
||||
use crate::formula::parse_formula;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
|
||||
// ── Pipeline (no UI state) ────────────────────────────────────────────────────
|
||||
|
||||
@ -850,7 +850,7 @@ mod tests {
|
||||
let mut w = ImportWizard::new(raw);
|
||||
assert_eq!(w.step, WizardStep::SelectArrayPath);
|
||||
w.confirm_path(); // selects first path
|
||||
// Should advance past SelectArrayPath
|
||||
// Should advance past SelectArrayPath
|
||||
assert_ne!(w.step, WizardStep::SelectArrayPath);
|
||||
assert!(!w.pipeline.records.is_empty());
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{Result, anyhow};
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@ -274,7 +274,12 @@ impl Model {
|
||||
pub fn measure_item_names(&self) -> Vec<String> {
|
||||
let mut names: Vec<String> = self
|
||||
.category("_Measure")
|
||||
.map(|c| c.ordered_item_names().iter().map(|s| s.to_string()).collect())
|
||||
.map(|c| {
|
||||
c.ordered_item_names()
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for f in &self.formulas {
|
||||
if f.target_category == "_Measure" && !names.iter().any(|n| n == &f.target) {
|
||||
@ -292,7 +297,12 @@ impl Model {
|
||||
self.measure_item_names()
|
||||
} else {
|
||||
self.category(cat_name)
|
||||
.map(|c| c.ordered_item_names().iter().map(|s| s.to_string()).collect())
|
||||
.map(|c| {
|
||||
c.ordered_item_names()
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@ -504,8 +514,8 @@ impl Model {
|
||||
match expr {
|
||||
Expr::Number(n) => Ok(*n),
|
||||
Expr::Ref(name) => {
|
||||
let cat = find_item_category(model, name)
|
||||
.ok_or_else(|| format!("ref:{name}"))?;
|
||||
let cat =
|
||||
find_item_category(model, name).ok_or_else(|| format!("ref:{name}"))?;
|
||||
let ref_key = context.clone().with(cat, name);
|
||||
// Check formula cache first, then aggregate raw data
|
||||
if let Some(cached) = model.formula_cache.get(&ref_key) {
|
||||
@ -529,12 +539,26 @@ impl Model {
|
||||
BinOp::Add => Ok(lv + rv),
|
||||
BinOp::Sub => Ok(lv - rv),
|
||||
BinOp::Mul => Ok(lv * rv),
|
||||
BinOp::Div => if rv == 0.0 { Err("div/0".into()) } else { Ok(lv / rv) },
|
||||
BinOp::Div => {
|
||||
if rv == 0.0 {
|
||||
Err("div/0".into())
|
||||
} else {
|
||||
Ok(lv / rv)
|
||||
}
|
||||
}
|
||||
BinOp::Pow => Ok(lv.powf(rv)),
|
||||
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => Err("type".into()),
|
||||
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => {
|
||||
Err("type".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
Expr::UnaryMinus(e) => Ok(-eval_expr_cached(e, context, model, target_category, none_cats)?),
|
||||
Expr::UnaryMinus(e) => Ok(-eval_expr_cached(
|
||||
e,
|
||||
context,
|
||||
model,
|
||||
target_category,
|
||||
none_cats,
|
||||
)?),
|
||||
Expr::Agg(func, inner, agg_filter) => {
|
||||
use crate::formula::AggFunc;
|
||||
let mut partial = context.without(target_category);
|
||||
@ -554,9 +578,23 @@ impl Model {
|
||||
.collect();
|
||||
match func {
|
||||
AggFunc::Sum => Ok(values.iter().sum()),
|
||||
AggFunc::Avg => if values.is_empty() { Err("empty".into()) } else { Ok(values.iter().sum::<f64>() / values.len() as f64) },
|
||||
AggFunc::Min => values.iter().cloned().reduce(f64::min).ok_or_else(|| "empty".into()),
|
||||
AggFunc::Max => values.iter().cloned().reduce(f64::max).ok_or_else(|| "empty".into()),
|
||||
AggFunc::Avg => {
|
||||
if values.is_empty() {
|
||||
Err("empty".into())
|
||||
} else {
|
||||
Ok(values.iter().sum::<f64>() / values.len() as f64)
|
||||
}
|
||||
}
|
||||
AggFunc::Min => values
|
||||
.iter()
|
||||
.cloned()
|
||||
.reduce(f64::min)
|
||||
.ok_or_else(|| "empty".into()),
|
||||
AggFunc::Max => values
|
||||
.iter()
|
||||
.cloned()
|
||||
.reduce(f64::max)
|
||||
.ok_or_else(|| "empty".into()),
|
||||
AggFunc::Count => Ok(values.len() as f64),
|
||||
}
|
||||
}
|
||||
@ -597,7 +635,13 @@ impl Model {
|
||||
}
|
||||
}
|
||||
|
||||
match eval_expr_cached(&formula.expr, context, self, &formula.target_category, none_cats) {
|
||||
match eval_expr_cached(
|
||||
&formula.expr,
|
||||
context,
|
||||
self,
|
||||
&formula.target_category,
|
||||
none_cats,
|
||||
) {
|
||||
Ok(n) => Some(CellValue::Number(n)),
|
||||
Err(e) => Some(CellValue::Error(e)),
|
||||
}
|
||||
@ -679,8 +723,8 @@ impl Model {
|
||||
match expr {
|
||||
Expr::Number(n) => Ok(*n),
|
||||
Expr::Ref(name) => {
|
||||
let cat = find_item_category(model, name)
|
||||
.ok_or_else(|| format!("ref:{name}"))?;
|
||||
let cat =
|
||||
find_item_category(model, name).ok_or_else(|| format!("ref:{name}"))?;
|
||||
let new_key = context.clone().with(cat, name);
|
||||
match model.evaluate_depth(&new_key, depth) {
|
||||
Some(CellValue::Number(n)) => Ok(n),
|
||||
@ -735,8 +779,16 @@ impl Model {
|
||||
Ok(values.iter().sum::<f64>() / values.len() as f64)
|
||||
}
|
||||
}
|
||||
AggFunc::Min => values.iter().cloned().reduce(f64::min).ok_or_else(|| "empty".into()),
|
||||
AggFunc::Max => values.iter().cloned().reduce(f64::max).ok_or_else(|| "empty".into()),
|
||||
AggFunc::Min => values
|
||||
.iter()
|
||||
.cloned()
|
||||
.reduce(f64::min)
|
||||
.ok_or_else(|| "empty".into()),
|
||||
AggFunc::Max => values
|
||||
.iter()
|
||||
.cloned()
|
||||
.reduce(f64::max)
|
||||
.ok_or_else(|| "empty".into()),
|
||||
AggFunc::Count => Ok(values.len() as f64),
|
||||
}
|
||||
}
|
||||
@ -779,7 +831,13 @@ impl Model {
|
||||
}
|
||||
}
|
||||
|
||||
match eval_expr(&formula.expr, context, self, &formula.target_category, depth) {
|
||||
match eval_expr(
|
||||
&formula.expr,
|
||||
context,
|
||||
self,
|
||||
&formula.target_category,
|
||||
depth,
|
||||
) {
|
||||
Ok(n) => Some(CellValue::Number(n)),
|
||||
Err(e) => Some(CellValue::Error(e)),
|
||||
}
|
||||
@ -1011,11 +1069,19 @@ mod model_tests {
|
||||
m.category_mut("_Measure").unwrap().add_item("Amount");
|
||||
|
||||
m.set_cell(
|
||||
coord(&[("Payee", "Acme"), ("Date", "Jan-01"), ("_Measure", "Amount")]),
|
||||
coord(&[
|
||||
("Payee", "Acme"),
|
||||
("Date", "Jan-01"),
|
||||
("_Measure", "Amount"),
|
||||
]),
|
||||
CellValue::Number(100.0),
|
||||
);
|
||||
m.set_cell(
|
||||
coord(&[("Payee", "Acme"), ("Date", "Jan-02"), ("_Measure", "Amount")]),
|
||||
coord(&[
|
||||
("Payee", "Acme"),
|
||||
("Date", "Jan-02"),
|
||||
("_Measure", "Amount"),
|
||||
]),
|
||||
CellValue::Number(50.0),
|
||||
);
|
||||
|
||||
@ -1780,8 +1846,10 @@ mod five_category {
|
||||
.evaluate(&coord(region, product, channel, time, "Margin"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or_else(|| panic!("Margin empty at {region}/{product}/{channel}/{time}"));
|
||||
assert!(approx(actual, expected),
|
||||
"Margin at {region}/{product}/{channel}/{time}: expected {expected:.4}, got {actual:.4}");
|
||||
assert!(
|
||||
approx(actual, expected),
|
||||
"Margin at {region}/{product}/{channel}/{time}: expected {expected:.4}, got {actual:.4}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
use anyhow::{Context, Result};
|
||||
use flate2::Compression;
|
||||
use flate2::read::GzDecoder;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use pest::Parser;
|
||||
use pest_derive::Parser;
|
||||
use std::io::{BufReader, BufWriter, Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::formula::parse_formula;
|
||||
use crate::model::Model;
|
||||
use crate::model::category::Group;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::view::{Axis, GridLayout};
|
||||
|
||||
#[derive(Parser)]
|
||||
@ -152,7 +152,6 @@ pub fn autosave_path(path: &Path) -> std::path::PathBuf {
|
||||
p
|
||||
}
|
||||
|
||||
|
||||
/// Serialize a model to the markdown `.improv` format.
|
||||
pub fn format_md(model: &Model) -> String {
|
||||
// writeln! to a String is infallible; this macro avoids .unwrap() noise.
|
||||
@ -188,11 +187,15 @@ pub fn format_md(model: &Model) -> String {
|
||||
if !view.number_format.is_empty() {
|
||||
w!(out, "format: {}", view.number_format);
|
||||
}
|
||||
for (prefix, map) in [("hidden", &view.hidden_items), ("collapsed", &view.collapsed_groups)]
|
||||
{
|
||||
for (prefix, map) in [
|
||||
("hidden", &view.hidden_items),
|
||||
("collapsed", &view.collapsed_groups),
|
||||
] {
|
||||
let mut pairs: Vec<_> = map
|
||||
.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();
|
||||
pairs.sort();
|
||||
for (cat, item) in pairs {
|
||||
@ -225,7 +228,10 @@ pub fn format_md(model: &Model) -> String {
|
||||
for cat in model.categories.values() {
|
||||
use crate::model::category::CategoryKind;
|
||||
// Skip _Index and _Dim — they are fully virtual, never persisted
|
||||
if matches!(cat.kind, CategoryKind::VirtualIndex | CategoryKind::VirtualDim) {
|
||||
if matches!(
|
||||
cat.kind,
|
||||
CategoryKind::VirtualIndex | CategoryKind::VirtualDim
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
w!(out, "\n## Category: {}", cat.name);
|
||||
@ -234,7 +240,9 @@ pub fn format_md(model: &Model) -> String {
|
||||
for item in cat.items.values() {
|
||||
// For _Measure, skip items that are formula targets
|
||||
// (they'll be recreated from the ## Formulas section)
|
||||
if cat.kind == CategoryKind::VirtualMeasure && formula_targets.contains(item.name.as_str()) {
|
||||
if cat.kind == CategoryKind::VirtualMeasure
|
||||
&& formula_targets.contains(item.name.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match &item.group {
|
||||
@ -272,7 +280,6 @@ pub fn format_md(model: &Model) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
/// Parse the `.improv` format into a Model using the pest grammar.
|
||||
///
|
||||
/// Sections may appear in any order; a two-pass approach registers categories
|
||||
@ -359,7 +366,10 @@ pub fn parse_md(text: &str) -> Result<Model> {
|
||||
}
|
||||
Rule::category_section => {
|
||||
let mut inner = pair.into_inner();
|
||||
let cname = next(&mut inner, "category_section")?.as_str().trim().to_string();
|
||||
let cname = next(&mut inner, "category_section")?
|
||||
.as_str()
|
||||
.trim()
|
||||
.to_string();
|
||||
let mut pc = PCategory {
|
||||
name: cname,
|
||||
items: Vec::new(),
|
||||
@ -440,7 +450,10 @@ pub fn parse_md(text: &str) -> Result<Model> {
|
||||
}
|
||||
Rule::view_section => {
|
||||
let mut inner = pair.into_inner();
|
||||
let vname = next(&mut inner, "view_section")?.as_str().trim().to_string();
|
||||
let vname = next(&mut inner, "view_section")?
|
||||
.as_str()
|
||||
.trim()
|
||||
.to_string();
|
||||
let mut pv = PView {
|
||||
name: vname,
|
||||
axes: Vec::new(),
|
||||
@ -615,9 +628,9 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
|
||||
mod tests {
|
||||
use super::{format_md, parse_md};
|
||||
use crate::formula::parse_formula;
|
||||
use crate::model::Model;
|
||||
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 {
|
||||
@ -656,7 +669,10 @@ mod tests {
|
||||
let text = format_md(&m);
|
||||
assert!(text.contains("## Category: Type"));
|
||||
// Bare items are now comma-separated on one line
|
||||
assert!(text.contains("- Food, Gas"), "expected comma-separated items:\n{text}");
|
||||
assert!(
|
||||
text.contains("- Food, Gas"),
|
||||
"expected comma-separated items:\n{text}"
|
||||
);
|
||||
assert!(text.contains("## Category: Month"));
|
||||
assert!(text.contains("Jan"));
|
||||
}
|
||||
@ -706,7 +722,10 @@ mod tests {
|
||||
"expected sorted order Feb < Jan:\n{text}"
|
||||
);
|
||||
assert!(text.contains("= 200"), "number not quoted:\n{text}");
|
||||
assert!(text.contains("= |N/A|"), "text should be pipe-quoted:\n{text}");
|
||||
assert!(
|
||||
text.contains("= |N/A|"),
|
||||
"text should be pipe-quoted:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -755,14 +774,16 @@ mod tests {
|
||||
fn parse_md_round_trips_categories_and_items() {
|
||||
let m = two_cat_model();
|
||||
let m2 = parse_md(&format_md(&m)).unwrap();
|
||||
assert!(m2
|
||||
.category("Type")
|
||||
.and_then(|c| c.items.get("Food"))
|
||||
.is_some());
|
||||
assert!(m2
|
||||
.category("Month")
|
||||
.and_then(|c| c.items.get("Feb"))
|
||||
.is_some());
|
||||
assert!(
|
||||
m2.category("Type")
|
||||
.and_then(|c| c.items.get("Food"))
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
m2.category("Month")
|
||||
.and_then(|c| c.items.get("Feb"))
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1450,8 +1471,8 @@ Type=Food = 42
|
||||
#[cfg(test)]
|
||||
mod parser_prop_tests {
|
||||
use super::{format_md, parse_md};
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||
@ -1501,31 +1522,29 @@ mod parser_prop_tests {
|
||||
let items2 = prop::collection::hash_set(safe_ident(), 1..=4);
|
||||
let values = prop::collection::vec(cell_value(), 1..=8);
|
||||
|
||||
(safe_ident(), items1, items2, values).prop_map(
|
||||
|(name, items1, items2, values)| {
|
||||
let mut m = Model::new(&name);
|
||||
m.add_category("CatA").unwrap();
|
||||
m.add_category("CatB").unwrap();
|
||||
(safe_ident(), items1, items2, values).prop_map(|(name, items1, items2, values)| {
|
||||
let mut m = Model::new(&name);
|
||||
m.add_category("CatA").unwrap();
|
||||
m.add_category("CatB").unwrap();
|
||||
|
||||
let items1: Vec<_> = items1.into_iter().collect();
|
||||
let items2: Vec<_> = items2.into_iter().collect();
|
||||
let items1: Vec<_> = items1.into_iter().collect();
|
||||
let items2: Vec<_> = items2.into_iter().collect();
|
||||
|
||||
for item in &items1 {
|
||||
m.category_mut("CatA").unwrap().add_item(item);
|
||||
}
|
||||
for item in &items2 {
|
||||
m.category_mut("CatB").unwrap().add_item(item);
|
||||
}
|
||||
for item in &items1 {
|
||||
m.category_mut("CatA").unwrap().add_item(item);
|
||||
}
|
||||
for item in &items2 {
|
||||
m.category_mut("CatB").unwrap().add_item(item);
|
||||
}
|
||||
|
||||
for (i, value) in values.into_iter().enumerate() {
|
||||
let a = &items1[i % items1.len()];
|
||||
let b = &items2[i % items2.len()];
|
||||
m.set_cell(coord(&[("CatA", a), ("CatB", b)]), value);
|
||||
}
|
||||
for (i, value) in values.into_iter().enumerate() {
|
||||
let a = &items1[i % items1.len()];
|
||||
let b = &items2[i % items2.len()];
|
||||
m.set_cell(coord(&[("CatA", a), ("CatB", b)]), value);
|
||||
}
|
||||
|
||||
m
|
||||
},
|
||||
)
|
||||
m
|
||||
})
|
||||
}
|
||||
|
||||
proptest! {
|
||||
@ -1720,9 +1739,9 @@ mod parser_prop_tests {
|
||||
#[cfg(test)]
|
||||
mod parser_edge_cases {
|
||||
use super::{format_md, parse_md};
|
||||
use crate::model::Model;
|
||||
use crate::model::category::Group;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
|
||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||
CellKey::new(
|
||||
@ -1865,14 +1884,14 @@ mod parser_edge_cases {
|
||||
let text = format_md(&m);
|
||||
let loaded = parse_md(&text).unwrap();
|
||||
let cat = loaded.category("Type").unwrap();
|
||||
let item = cat
|
||||
.items
|
||||
.values()
|
||||
.find(|i| i.name == "Data [raw]");
|
||||
let item = cat.items.values().find(|i| i.name == "Data [raw]");
|
||||
assert!(
|
||||
item.is_some(),
|
||||
"Item 'Data [raw]' with group not found.\nItems: {:?}\n{text}",
|
||||
cat.items.values().map(|i| (&i.name, &i.group)).collect::<Vec<_>>()
|
||||
cat.items
|
||||
.values()
|
||||
.map(|i| (&i.name, &i.group))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(item.unwrap().group.as_deref(), Some("Input"));
|
||||
}
|
||||
@ -1882,8 +1901,10 @@ mod parser_edge_cases {
|
||||
#[test]
|
||||
fn parse_empty_string() {
|
||||
let result = parse_md("");
|
||||
assert!(result.is_err() || result.unwrap().name.is_empty(),
|
||||
"Empty input should either error or produce empty model");
|
||||
assert!(
|
||||
result.is_err() || result.unwrap().name.is_empty(),
|
||||
"Empty input should either error or produce empty model"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -12,8 +12,8 @@ use ratatui::style::Color;
|
||||
use crate::command::cmd::CmdContext;
|
||||
use crate::command::keymap::{Keymap, KeymapSet};
|
||||
use crate::import::wizard::ImportWizard;
|
||||
use crate::model::cell::CellValue;
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::CellValue;
|
||||
use crate::persistence;
|
||||
use crate::ui::grid::{
|
||||
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
|
||||
@ -338,7 +338,9 @@ impl App {
|
||||
self.model.categories.values().all(|c| {
|
||||
matches!(
|
||||
c.kind,
|
||||
CategoryKind::VirtualIndex | CategoryKind::VirtualDim | CategoryKind::VirtualMeasure
|
||||
CategoryKind::VirtualIndex
|
||||
| CategoryKind::VirtualDim
|
||||
| CategoryKind::VirtualMeasure
|
||||
)
|
||||
})
|
||||
}
|
||||
@ -389,16 +391,26 @@ impl App {
|
||||
/// Hint text for the status bar (context-sensitive)
|
||||
pub fn hint_text(&self) -> &'static str {
|
||||
match &self.mode {
|
||||
AppMode::Normal => "hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd",
|
||||
AppMode::Normal => {
|
||||
"hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd"
|
||||
}
|
||||
AppMode::Editing { .. } => "Enter:commit Tab:commit+right Esc:cancel",
|
||||
AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back",
|
||||
AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
|
||||
AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back",
|
||||
AppMode::CategoryAdd { .. } => "Enter:add & continue Tab:same Esc:done — type a category name",
|
||||
AppMode::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name",
|
||||
AppMode::CategoryPanel => {
|
||||
"jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back"
|
||||
}
|
||||
AppMode::CategoryAdd { .. } => {
|
||||
"Enter:add & continue Tab:same Esc:done — type a category name"
|
||||
}
|
||||
AppMode::ItemAdd { .. } => {
|
||||
"Enter:add & continue Tab:same Esc:done — type an item name"
|
||||
}
|
||||
AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back",
|
||||
AppMode::TileSelect => "hl:select Enter:cycle r/c/p/n:set-axis Esc:back",
|
||||
AppMode::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :show-item :help",
|
||||
AppMode::CommandMode { .. } => {
|
||||
":q quit :w save :import :add-cat :formula :show-item :help"
|
||||
}
|
||||
AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel",
|
||||
AppMode::Help => "h/l:pages q/Esc:close",
|
||||
_ => "",
|
||||
|
||||
@ -6,7 +6,7 @@ use ratatui::{
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::ui::cat_tree::{build_cat_tree, CatTreeEntry};
|
||||
use crate::ui::cat_tree::{CatTreeEntry, build_cat_tree};
|
||||
use crate::ui::panel::PanelContent;
|
||||
use crate::view::Axis;
|
||||
|
||||
|
||||
@ -743,7 +743,7 @@ pub struct ImportJsonHeadless {
|
||||
impl Effect for ImportJsonHeadless {
|
||||
fn apply(&self, app: &mut App) {
|
||||
use crate::import::analyzer::{
|
||||
analyze_records, extract_array_at_path, find_array_paths, FieldKind,
|
||||
FieldKind, analyze_records, extract_array_at_path, find_array_paths,
|
||||
};
|
||||
use crate::import::wizard::ImportPipeline;
|
||||
|
||||
@ -952,8 +952,8 @@ pub fn help_page_set(page: usize) -> Box<dyn Effect> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
|
||||
fn test_app() -> App {
|
||||
let mut m = Model::new("Test");
|
||||
@ -1036,12 +1036,13 @@ mod tests {
|
||||
fn add_formula_adds_target_item_to_category() {
|
||||
let mut app = test_app();
|
||||
// "Margin" does not exist as an item in "Type" before adding the formula
|
||||
assert!(!app
|
||||
.model
|
||||
.category("Type")
|
||||
.unwrap()
|
||||
.ordered_item_names()
|
||||
.contains(&"Margin"));
|
||||
assert!(
|
||||
!app.model
|
||||
.category("Type")
|
||||
.unwrap()
|
||||
.ordered_item_names()
|
||||
.contains(&"Margin")
|
||||
);
|
||||
AddFormula {
|
||||
raw: "Margin = Food * 2".to_string(),
|
||||
target_category: "Type".to_string(),
|
||||
@ -1257,16 +1258,14 @@ mod tests {
|
||||
#[test]
|
||||
fn set_buffer_empty_clears() {
|
||||
let mut app = test_app();
|
||||
app.buffers.insert("formula".to_string(), "old text".to_string());
|
||||
app.buffers
|
||||
.insert("formula".to_string(), "old text".to_string());
|
||||
SetBuffer {
|
||||
name: "formula".to_string(),
|
||||
value: String::new(),
|
||||
}
|
||||
.apply(&mut app);
|
||||
assert_eq!(
|
||||
app.buffers.get("formula").map(|s| s.as_str()),
|
||||
Some(""),
|
||||
);
|
||||
assert_eq!(app.buffers.get("formula").map(|s| s.as_str()), Some(""),);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1597,19 +1596,21 @@ mod tests {
|
||||
group: "MyGroup".to_string(),
|
||||
}
|
||||
.apply(&mut app);
|
||||
assert!(app
|
||||
.model
|
||||
.active_view()
|
||||
.is_group_collapsed("Type", "MyGroup"));
|
||||
assert!(
|
||||
app.model
|
||||
.active_view()
|
||||
.is_group_collapsed("Type", "MyGroup")
|
||||
);
|
||||
ToggleGroup {
|
||||
category: "Type".to_string(),
|
||||
group: "MyGroup".to_string(),
|
||||
}
|
||||
.apply(&mut app);
|
||||
assert!(!app
|
||||
.model
|
||||
.active_view()
|
||||
.is_group_collapsed("Type", "MyGroup"));
|
||||
assert!(
|
||||
!app.model
|
||||
.active_view()
|
||||
.is_group_collapsed("Type", "MyGroup")
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cycle axis ──────────────────────────────────────────────────────
|
||||
|
||||
@ -674,8 +674,8 @@ mod tests {
|
||||
|
||||
use super::GridWidget;
|
||||
use crate::formula::parse_formula;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::view::GridLayout;
|
||||
|
||||
@ -914,8 +914,10 @@ mod tests {
|
||||
CellValue::Number(600.0),
|
||||
);
|
||||
m.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
|
||||
m.active_view_mut().set_axis("_Measure", crate::view::Axis::Row);
|
||||
m.active_view_mut().set_axis("Region", crate::view::Axis::Column);
|
||||
m.active_view_mut()
|
||||
.set_axis("_Measure", crate::view::Axis::Row);
|
||||
m.active_view_mut()
|
||||
.set_axis("Region", crate::view::Axis::Column);
|
||||
|
||||
let text = buf_text(&render(&m, 80, 24));
|
||||
assert!(text.contains("400"), "expected '400' (Profit) in:\n{text}");
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::view::{Axis, View};
|
||||
|
||||
/// Extract (record_index, dim_name) from a synthetic records-mode CellKey.
|
||||
@ -149,7 +149,7 @@ impl GridLayout {
|
||||
.collect()
|
||||
};
|
||||
// Sort for deterministic ordering
|
||||
records.sort_by(|a, b| a.0 .0.cmp(&b.0 .0));
|
||||
records.sort_by(|a, b| a.0.0.cmp(&b.0.0));
|
||||
|
||||
// Synthesize row items: one per record, labeled with its index
|
||||
let row_items: Vec<AxisEntry> = (0..records.len())
|
||||
@ -192,7 +192,7 @@ impl GridLayout {
|
||||
// col_item is a category name
|
||||
let found = record
|
||||
.0
|
||||
.0
|
||||
.0
|
||||
.iter()
|
||||
.find(|(c, _)| c == &col_item)
|
||||
.map(|(_, v)| v.clone());
|
||||
@ -516,7 +516,10 @@ fn expand_category(
|
||||
if view.is_hidden(cat_name, item_name) {
|
||||
continue;
|
||||
}
|
||||
let item_group = cat.items.get(item_name.as_str()).and_then(|i| i.group.as_deref());
|
||||
let item_group = cat
|
||||
.items
|
||||
.get(item_name.as_str())
|
||||
.and_then(|i| i.group.as_deref());
|
||||
|
||||
// Emit a group header at each group boundary.
|
||||
if item_group != last_group {
|
||||
@ -564,9 +567,9 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry>
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{synthetic_record_info, AxisEntry, GridLayout};
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use super::{AxisEntry, GridLayout, synthetic_record_info};
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::view::Axis;
|
||||
|
||||
fn records_model() -> Model {
|
||||
@ -867,14 +870,18 @@ mod tests {
|
||||
fn ungrouped_items_produce_no_headers() {
|
||||
let m = two_cat_model();
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
assert!(!layout
|
||||
.row_items
|
||||
.iter()
|
||||
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })));
|
||||
assert!(!layout
|
||||
.col_items
|
||||
.iter()
|
||||
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })));
|
||||
assert!(
|
||||
!layout
|
||||
.row_items
|
||||
.iter()
|
||||
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }))
|
||||
);
|
||||
assert!(
|
||||
!layout
|
||||
.col_items
|
||||
.iter()
|
||||
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -3,5 +3,5 @@ pub mod layout;
|
||||
pub mod types;
|
||||
|
||||
pub use axis::Axis;
|
||||
pub use layout::{synthetic_record_info, AxisEntry, GridLayout};
|
||||
pub use layout::{AxisEntry, GridLayout, synthetic_record_info};
|
||||
pub use types::View;
|
||||
|
||||
Reference in New Issue
Block a user