Add unit tests for ImportWizard and ImportPipeline, covering: - Wizard step transitions - Proposal and formula editing - Date configuration - Edge cases for building the data model Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
1128 lines
40 KiB
Rust
1128 lines
40 KiB
Rust
use anyhow::{anyhow, Result};
|
|
use serde_json::Value;
|
|
|
|
use super::analyzer::{
|
|
analyze_records, extract_array_at_path, extract_date_component, find_array_paths,
|
|
DateComponent, FieldKind, FieldProposal,
|
|
};
|
|
use crate::formula::parse_formula;
|
|
use crate::model::cell::{CellKey, CellValue};
|
|
use crate::model::Model;
|
|
|
|
// ── Pipeline (no UI state) ────────────────────────────────────────────────────
|
|
|
|
/// Pure data + logic for turning a JSON value into a Model.
|
|
/// No cursor, no display messages — those live in [`ImportWizard`].
|
|
#[derive(Debug)]
|
|
pub struct ImportPipeline {
|
|
pub raw: Value,
|
|
pub array_paths: Vec<String>,
|
|
pub selected_path: String,
|
|
pub records: Vec<Value>,
|
|
pub proposals: Vec<FieldProposal>,
|
|
pub model_name: String,
|
|
/// Raw formula strings to add to the model (e.g., "Profit = Revenue - Cost").
|
|
pub formulas: Vec<String>,
|
|
}
|
|
|
|
impl ImportPipeline {
|
|
pub fn new(raw: Value) -> Self {
|
|
let array_paths = find_array_paths(&raw);
|
|
let mut pipeline = Self {
|
|
raw: raw.clone(),
|
|
array_paths,
|
|
selected_path: String::new(),
|
|
records: vec![],
|
|
proposals: vec![],
|
|
model_name: "Imported Model".to_string(),
|
|
formulas: vec![],
|
|
};
|
|
|
|
// Auto-select if root is an array or there is exactly one candidate path.
|
|
if raw.is_array() {
|
|
pipeline.select_path("");
|
|
} else if pipeline.array_paths.len() == 1 {
|
|
let path = pipeline.array_paths[0].clone();
|
|
pipeline.select_path(&path);
|
|
}
|
|
|
|
pipeline
|
|
}
|
|
|
|
pub fn select_path(&mut self, path: &str) {
|
|
self.selected_path = path.to_string();
|
|
if let Some(arr) = extract_array_at_path(&self.raw, path) {
|
|
self.records = arr.clone();
|
|
self.proposals = analyze_records(&self.records);
|
|
}
|
|
}
|
|
|
|
pub fn needs_path_selection(&self) -> bool {
|
|
self.records.is_empty() && self.raw.is_object() && self.array_paths.len() != 1
|
|
}
|
|
|
|
pub fn preview_summary(&self) -> String {
|
|
match &self.raw {
|
|
Value::Array(arr) => format!(
|
|
"Array of {} records. Sample keys: {}",
|
|
arr.len(),
|
|
arr.first()
|
|
.and_then(|r| r.as_object())
|
|
.map(|m| m.keys().take(5).cloned().collect::<Vec<_>>().join(", "))
|
|
.unwrap_or_default()
|
|
),
|
|
Value::Object(map) => format!(
|
|
"Object with {} top-level keys: {}",
|
|
map.len(),
|
|
map.keys().take(10).cloned().collect::<Vec<_>>().join(", ")
|
|
),
|
|
_ => "Unknown JSON structure".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Build a Model from the current proposals. Pure — no side effects.
|
|
pub fn build_model(&self) -> Result<Model> {
|
|
let categories: Vec<&FieldProposal> = self
|
|
.proposals
|
|
.iter()
|
|
.filter(|p| {
|
|
p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory)
|
|
})
|
|
.collect();
|
|
let measures: Vec<&FieldProposal> = self
|
|
.proposals
|
|
.iter()
|
|
.filter(|p| p.accepted && p.kind == FieldKind::Measure)
|
|
.collect();
|
|
let labels: Vec<&FieldProposal> = self
|
|
.proposals
|
|
.iter()
|
|
.filter(|p| p.accepted && p.kind == FieldKind::Label)
|
|
.collect();
|
|
|
|
if categories.is_empty() {
|
|
return Err(anyhow!("At least one category must be accepted"));
|
|
}
|
|
|
|
// Collect date component extractions: (field_name, format, component, derived_cat_name)
|
|
let date_extractions: Vec<(&str, &str, DateComponent, String)> = self
|
|
.proposals
|
|
.iter()
|
|
.filter(|p| {
|
|
p.accepted
|
|
&& p.kind == FieldKind::TimeCategory
|
|
&& p.date_format.is_some()
|
|
&& !p.date_components.is_empty()
|
|
})
|
|
.flat_map(|p| {
|
|
let fmt = p.date_format.as_deref().unwrap();
|
|
p.date_components.iter().map(move |comp| {
|
|
let suffix = match comp {
|
|
DateComponent::Year => "Year",
|
|
DateComponent::Month => "Month",
|
|
DateComponent::Quarter => "Quarter",
|
|
};
|
|
let derived_name = format!("{}_{}", p.field, suffix);
|
|
(p.field.as_str(), fmt, *comp, derived_name)
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let mut model = Model::new(&self.model_name);
|
|
|
|
for cat_proposal in &categories {
|
|
model.add_category(&cat_proposal.field)?;
|
|
if let Some(cat) = model.category_mut(&cat_proposal.field) {
|
|
for val in &cat_proposal.distinct_values {
|
|
cat.add_item(val);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create derived date-component categories
|
|
for (_, _, _, ref derived_name) in &date_extractions {
|
|
model.add_category(derived_name)?;
|
|
}
|
|
|
|
// Create label categories (stored but not pivoted by default)
|
|
for lab in &labels {
|
|
model.add_label_category(&lab.field)?;
|
|
}
|
|
|
|
if !measures.is_empty() {
|
|
model.add_category("Measure")?;
|
|
if let Some(cat) = model.category_mut("Measure") {
|
|
for m in &measures {
|
|
cat.add_item(&m.field);
|
|
}
|
|
}
|
|
}
|
|
|
|
for record in &self.records {
|
|
if let Value::Object(map) = record {
|
|
let mut coords: Vec<(String, String)> = vec![];
|
|
let mut valid = true;
|
|
|
|
for cat_proposal in &categories {
|
|
let val = map
|
|
.get(&cat_proposal.field)
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string())
|
|
.or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string()));
|
|
|
|
if let Some(v) = val {
|
|
if let Some(cat) = model.category_mut(&cat_proposal.field) {
|
|
cat.add_item(&v);
|
|
}
|
|
coords.push((cat_proposal.field.clone(), v.clone()));
|
|
|
|
// Extract date components from this field's value
|
|
for (field, fmt, comp, ref derived_name) in &date_extractions {
|
|
if *field == cat_proposal.field {
|
|
if let Some(derived_val) = extract_date_component(&v, fmt, *comp) {
|
|
if let Some(cat) = model.category_mut(derived_name) {
|
|
cat.add_item(&derived_val);
|
|
}
|
|
coords.push((derived_name.clone(), derived_val));
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
valid = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if !valid {
|
|
continue;
|
|
}
|
|
|
|
// Attach label values as coords (missing labels become "").
|
|
for lab in &labels {
|
|
let val = map
|
|
.get(&lab.field)
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string())
|
|
.or_else(|| {
|
|
map.get(&lab.field).and_then(|v| {
|
|
if v.is_null() {
|
|
None
|
|
} else {
|
|
Some(v.to_string())
|
|
}
|
|
})
|
|
})
|
|
.unwrap_or_default();
|
|
if let Some(cat) = model.category_mut(&lab.field) {
|
|
cat.add_item(&val);
|
|
}
|
|
coords.push((lab.field.clone(), val));
|
|
}
|
|
|
|
for measure in &measures {
|
|
if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) {
|
|
let mut cell_coords = coords.clone();
|
|
cell_coords.push(("Measure".to_string(), measure.field.clone()));
|
|
model.set_cell(CellKey::new(cell_coords), CellValue::Number(val));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse and add formulas
|
|
// Formulas target the "Measure" category by default.
|
|
let formula_cat: String = if model.category("Measure").is_some() {
|
|
"Measure".to_string()
|
|
} else {
|
|
model
|
|
.categories
|
|
.keys()
|
|
.next()
|
|
.cloned()
|
|
.unwrap_or_else(|| "Measure".to_string())
|
|
};
|
|
for raw in &self.formulas {
|
|
if let Ok(formula) = parse_formula(raw, &formula_cat) {
|
|
model.add_formula(formula);
|
|
}
|
|
}
|
|
|
|
Ok(model)
|
|
}
|
|
}
|
|
|
|
// ── Wizard (UI state wrapped around the pipeline) ─────────────────────────────
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum WizardStep {
|
|
Preview,
|
|
SelectArrayPath,
|
|
ReviewProposals,
|
|
ConfigureDates,
|
|
DefineFormulas,
|
|
NameModel,
|
|
Done,
|
|
}
|
|
|
|
/// Interactive state layered on top of [`ImportPipeline`] for the TUI wizard.
|
|
/// The pipeline holds all data; the wizard holds only what the UI needs to
|
|
/// drive the multi-step interaction (current step, list cursor, error message).
|
|
#[derive(Debug)]
|
|
pub struct ImportWizard {
|
|
pub pipeline: ImportPipeline,
|
|
pub step: WizardStep,
|
|
/// Cursor within the current list (array paths or proposals).
|
|
pub cursor: usize,
|
|
/// One-line message to display at the bottom of the wizard panel.
|
|
pub message: Option<String>,
|
|
/// Whether we're in formula text-input mode.
|
|
pub formula_editing: bool,
|
|
/// Buffer for the formula being typed.
|
|
pub formula_buffer: String,
|
|
}
|
|
|
|
impl ImportWizard {
|
|
pub fn new(raw: Value) -> Self {
|
|
let pipeline = ImportPipeline::new(raw);
|
|
|
|
let step = if pipeline.needs_path_selection() {
|
|
WizardStep::SelectArrayPath
|
|
} else if pipeline.records.is_empty() {
|
|
WizardStep::Preview
|
|
} else {
|
|
WizardStep::ReviewProposals
|
|
};
|
|
|
|
Self {
|
|
pipeline,
|
|
step,
|
|
cursor: 0,
|
|
message: None,
|
|
formula_editing: false,
|
|
formula_buffer: String::new(),
|
|
}
|
|
}
|
|
|
|
// ── Step transitions ──────────────────────────────────────────────────────
|
|
|
|
pub fn advance(&mut self) {
|
|
self.step = match self.step {
|
|
WizardStep::Preview => {
|
|
if self.pipeline.array_paths.len() > 1 && self.pipeline.needs_path_selection() {
|
|
WizardStep::SelectArrayPath
|
|
} else {
|
|
WizardStep::ReviewProposals
|
|
}
|
|
}
|
|
WizardStep::SelectArrayPath => WizardStep::ReviewProposals,
|
|
WizardStep::ReviewProposals => {
|
|
if self.has_time_categories() {
|
|
WizardStep::ConfigureDates
|
|
} else {
|
|
WizardStep::DefineFormulas
|
|
}
|
|
}
|
|
WizardStep::ConfigureDates => WizardStep::DefineFormulas,
|
|
WizardStep::DefineFormulas => WizardStep::NameModel,
|
|
WizardStep::NameModel => WizardStep::Done,
|
|
WizardStep::Done => WizardStep::Done,
|
|
};
|
|
self.cursor = 0;
|
|
self.message = None;
|
|
}
|
|
|
|
fn has_time_categories(&self) -> bool {
|
|
self.pipeline
|
|
.proposals
|
|
.iter()
|
|
.any(|p| p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some())
|
|
}
|
|
|
|
/// Get accepted TimeCategory proposals (for ConfigureDates step).
|
|
pub fn time_category_proposals(&self) -> Vec<&FieldProposal> {
|
|
self.pipeline
|
|
.proposals
|
|
.iter()
|
|
.filter(|p| p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some())
|
|
.collect()
|
|
}
|
|
|
|
pub fn confirm_path(&mut self) {
|
|
if self.cursor < self.pipeline.array_paths.len() {
|
|
let path = self.pipeline.array_paths[self.cursor].clone();
|
|
self.pipeline.select_path(&path);
|
|
self.advance();
|
|
}
|
|
}
|
|
|
|
// ── Cursor movement ───────────────────────────────────────────────────────
|
|
|
|
pub fn move_cursor(&mut self, delta: i32) {
|
|
let len = match self.step {
|
|
WizardStep::SelectArrayPath => self.pipeline.array_paths.len(),
|
|
WizardStep::ReviewProposals => self.pipeline.proposals.len(),
|
|
WizardStep::ConfigureDates => self.date_config_item_count(),
|
|
WizardStep::DefineFormulas => self.pipeline.formulas.len(),
|
|
_ => 0,
|
|
};
|
|
if len == 0 {
|
|
return;
|
|
}
|
|
if delta > 0 {
|
|
self.cursor = (self.cursor + 1).min(len - 1);
|
|
} else if self.cursor > 0 {
|
|
self.cursor -= 1;
|
|
}
|
|
}
|
|
|
|
// ── Proposal editing (delegates to pipeline) ──────────────────────────────
|
|
|
|
pub fn toggle_proposal(&mut self) {
|
|
if self.cursor < self.pipeline.proposals.len() {
|
|
self.pipeline.proposals[self.cursor].accepted =
|
|
!self.pipeline.proposals[self.cursor].accepted;
|
|
}
|
|
}
|
|
|
|
pub fn cycle_proposal_kind(&mut self) {
|
|
if self.cursor < self.pipeline.proposals.len() {
|
|
let p = &mut self.pipeline.proposals[self.cursor];
|
|
p.kind = match p.kind {
|
|
FieldKind::Category => FieldKind::Measure,
|
|
FieldKind::Measure => FieldKind::TimeCategory,
|
|
FieldKind::TimeCategory => FieldKind::Label,
|
|
FieldKind::Label => FieldKind::Category,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── Model name input ──────────────────────────────────────────────────────
|
|
|
|
pub fn push_name_char(&mut self, c: char) {
|
|
self.pipeline.model_name.push(c);
|
|
}
|
|
pub fn pop_name_char(&mut self) {
|
|
self.pipeline.model_name.pop();
|
|
}
|
|
|
|
// ── Date config ────────────────────────────────────────────────────────────
|
|
|
|
/// Total number of items in the ConfigureDates list.
|
|
/// Each TimeCategory field gets 3 rows (Year, Month, Quarter).
|
|
fn date_config_item_count(&self) -> usize {
|
|
self.time_category_proposals().len() * 3
|
|
}
|
|
|
|
/// Get the (field_index, component) for the current cursor position.
|
|
pub fn date_config_at_cursor(&self) -> Option<(usize, DateComponent)> {
|
|
let tc_indices = self.time_category_indices();
|
|
if tc_indices.is_empty() {
|
|
return None;
|
|
}
|
|
let field_idx = self.cursor / 3;
|
|
let comp_idx = self.cursor % 3;
|
|
let component = match comp_idx {
|
|
0 => DateComponent::Year,
|
|
1 => DateComponent::Month,
|
|
_ => DateComponent::Quarter,
|
|
};
|
|
tc_indices.get(field_idx).map(|&pi| (pi, component))
|
|
}
|
|
|
|
/// Indices into pipeline.proposals for accepted TimeCategory fields.
|
|
fn time_category_indices(&self) -> Vec<usize> {
|
|
self.pipeline
|
|
.proposals
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, p)| {
|
|
p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some()
|
|
})
|
|
.map(|(i, _)| i)
|
|
.collect()
|
|
}
|
|
|
|
/// Toggle a date component for the field at the current cursor.
|
|
pub fn toggle_date_component(&mut self) {
|
|
if let Some((pi, component)) = self.date_config_at_cursor() {
|
|
let proposal = &mut self.pipeline.proposals[pi];
|
|
if let Some(pos) = proposal
|
|
.date_components
|
|
.iter()
|
|
.position(|c| *c == component)
|
|
{
|
|
proposal.date_components.remove(pos);
|
|
} else {
|
|
proposal.date_components.push(component);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Formula editing ────────────────────────────────────────────────────────
|
|
|
|
/// Buffer for typing a new formula in the DefineFormulas step.
|
|
pub fn push_formula_char(&mut self, c: char) {
|
|
if !self.formula_editing {
|
|
self.formula_editing = true;
|
|
self.formula_buffer.clear();
|
|
}
|
|
self.formula_buffer.push(c);
|
|
}
|
|
|
|
pub fn pop_formula_char(&mut self) {
|
|
self.formula_buffer.pop();
|
|
}
|
|
|
|
/// Commit the current formula buffer to the pipeline's formula list.
|
|
pub fn confirm_formula(&mut self) {
|
|
let text = self.formula_buffer.trim().to_string();
|
|
if !text.is_empty() {
|
|
self.pipeline.formulas.push(text);
|
|
}
|
|
self.formula_buffer.clear();
|
|
self.formula_editing = false;
|
|
self.cursor = self.pipeline.formulas.len().saturating_sub(1);
|
|
}
|
|
|
|
/// Delete the formula at the current cursor position.
|
|
pub fn delete_formula(&mut self) {
|
|
if self.cursor < self.pipeline.formulas.len() {
|
|
self.pipeline.formulas.remove(self.cursor);
|
|
if self.cursor > 0 && self.cursor >= self.pipeline.formulas.len() {
|
|
self.cursor -= 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Start editing a new formula.
|
|
pub fn start_formula_edit(&mut self) {
|
|
self.formula_editing = true;
|
|
self.formula_buffer.clear();
|
|
}
|
|
|
|
/// Cancel formula editing.
|
|
pub fn cancel_formula_edit(&mut self) {
|
|
self.formula_editing = false;
|
|
self.formula_buffer.clear();
|
|
}
|
|
|
|
/// Generate sample formulas based on accepted measures.
|
|
pub fn sample_formulas(&self) -> Vec<String> {
|
|
let measures: Vec<&str> = self
|
|
.pipeline
|
|
.proposals
|
|
.iter()
|
|
.filter(|p| p.accepted && p.kind == FieldKind::Measure)
|
|
.map(|p| p.field.as_str())
|
|
.collect();
|
|
|
|
let mut samples = Vec::new();
|
|
if measures.len() >= 2 {
|
|
samples.push(format!("Diff = {} - {}", measures[0], measures[1]));
|
|
}
|
|
if !measures.is_empty() {
|
|
samples.push(format!("Total = SUM({})", measures[0]));
|
|
}
|
|
if measures.len() >= 2 {
|
|
samples.push(format!("Ratio = {} / {}", measures[0], measures[1]));
|
|
}
|
|
samples
|
|
}
|
|
|
|
// ── Delegate build to pipeline ────────────────────────────────────────────
|
|
|
|
pub fn build_model(&self) -> Result<Model> {
|
|
self.pipeline.build_model()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::ImportPipeline;
|
|
use crate::import::analyzer::FieldKind;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn flat_array_auto_selected() {
|
|
let raw = json!([
|
|
{"region": "East", "product": "Shirts", "revenue": 100.0},
|
|
{"region": "West", "product": "Shirts", "revenue": 200.0},
|
|
]);
|
|
let p = ImportPipeline::new(raw);
|
|
assert!(!p.records.is_empty());
|
|
assert!(!p.proposals.is_empty());
|
|
assert!(!p.needs_path_selection());
|
|
}
|
|
|
|
#[test]
|
|
fn numeric_field_proposed_as_measure() {
|
|
let raw = json!([{"region": "East", "revenue": 100.0, "cost": 50.0}]);
|
|
let p = ImportPipeline::new(raw);
|
|
let revenue = p.proposals.iter().find(|p| p.field == "revenue").unwrap();
|
|
assert_eq!(revenue.kind, FieldKind::Measure);
|
|
}
|
|
|
|
#[test]
|
|
fn low_cardinality_string_field_proposed_as_category() {
|
|
let raw = json!([
|
|
{"region": "East", "revenue": 100.0},
|
|
{"region": "West", "revenue": 200.0},
|
|
{"region": "East", "revenue": 150.0},
|
|
]);
|
|
let p = ImportPipeline::new(raw);
|
|
let region = p.proposals.iter().find(|p| p.field == "region").unwrap();
|
|
assert_eq!(region.kind, FieldKind::Category);
|
|
}
|
|
|
|
#[test]
|
|
fn nested_json_needs_path_selection_when_multiple_arrays() {
|
|
let raw = json!({
|
|
"orders": [{"id": 1}, {"id": 2}],
|
|
"products": [{"name": "A"}, {"name": "B"}],
|
|
});
|
|
let p = ImportPipeline::new(raw);
|
|
assert!(p.needs_path_selection());
|
|
}
|
|
|
|
#[test]
|
|
fn single_nested_array_auto_selected() {
|
|
let raw = json!({
|
|
"data": [
|
|
{"region": "East", "revenue": 100.0},
|
|
{"region": "West", "revenue": 200.0},
|
|
]
|
|
});
|
|
let p = ImportPipeline::new(raw);
|
|
assert!(!p.records.is_empty());
|
|
assert!(!p.needs_path_selection());
|
|
}
|
|
|
|
#[test]
|
|
fn select_path_populates_records_and_proposals() {
|
|
let raw = json!({
|
|
"a": [{"x": 1}, {"x": 2}],
|
|
"b": [{"y": "foo"}, {"y": "bar"}],
|
|
});
|
|
let mut p = ImportPipeline::new(raw);
|
|
p.select_path("b");
|
|
assert!(!p.records.is_empty());
|
|
assert!(!p.proposals.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn build_model_fails_with_no_accepted_categories() {
|
|
let raw = json!([{"revenue": 100.0, "cost": 50.0}]);
|
|
let mut p = ImportPipeline::new(raw);
|
|
for prop in &mut p.proposals {
|
|
prop.accepted = false;
|
|
}
|
|
assert!(p.build_model().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn build_model_creates_categories_and_measure_category() {
|
|
let raw = json!([
|
|
{"region": "East", "revenue": 100.0},
|
|
{"region": "West", "revenue": 200.0},
|
|
]);
|
|
let p = ImportPipeline::new(raw);
|
|
let model = p.build_model().unwrap();
|
|
assert!(model.category("region").is_some());
|
|
assert!(model.category("Measure").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn label_fields_imported_as_label_category_coords() {
|
|
use crate::model::category::CategoryKind;
|
|
// 25 unique descriptions → classified as Label (> CATEGORY_THRESHOLD=20)
|
|
let records: Vec<serde_json::Value> = (0..25)
|
|
.map(|i| json!({"region": "East", "desc": format!("row-{i}"), "revenue": i as f64}))
|
|
.collect();
|
|
let raw = serde_json::Value::Array(records);
|
|
let p = ImportPipeline::new(raw);
|
|
let desc = p.proposals.iter().find(|p| p.field == "desc").unwrap();
|
|
assert_eq!(desc.kind, FieldKind::Label);
|
|
assert!(desc.accepted, "labels should default to accepted");
|
|
|
|
let model = p.build_model().unwrap();
|
|
// Label field exists as a category with Label kind
|
|
let cat = model.category("desc").expect("desc category exists");
|
|
assert_eq!(cat.kind, CategoryKind::Label);
|
|
// Each record's cell key carries the desc label coord
|
|
use crate::model::cell::CellKey;
|
|
let k = CellKey::new(vec![
|
|
("Measure".to_string(), "revenue".to_string()),
|
|
("desc".to_string(), "row-7".to_string()),
|
|
("region".to_string(), "East".to_string()),
|
|
]);
|
|
assert_eq!(model.get_cell(&k).and_then(|v| v.as_f64()), Some(7.0));
|
|
}
|
|
|
|
#[test]
|
|
fn label_category_defaults_to_none_axis() {
|
|
use crate::view::Axis;
|
|
let records: Vec<serde_json::Value> = (0..25)
|
|
.map(|i| json!({"region": "East", "desc": format!("r{i}"), "n": 1.0}))
|
|
.collect();
|
|
let raw = serde_json::Value::Array(records);
|
|
let p = ImportPipeline::new(raw);
|
|
let model = p.build_model().unwrap();
|
|
let v = model.active_view();
|
|
assert_eq!(v.axis_of("desc"), Axis::None);
|
|
}
|
|
|
|
#[test]
|
|
fn build_model_cells_match_source_data() {
|
|
let raw = json!([
|
|
{"region": "East", "revenue": 100.0},
|
|
{"region": "West", "revenue": 200.0},
|
|
]);
|
|
let p = ImportPipeline::new(raw);
|
|
let model = p.build_model().unwrap();
|
|
use crate::model::cell::CellKey;
|
|
let k_east = CellKey::new(vec![
|
|
("Measure".to_string(), "revenue".to_string()),
|
|
("region".to_string(), "East".to_string()),
|
|
]);
|
|
let k_west = CellKey::new(vec![
|
|
("Measure".to_string(), "revenue".to_string()),
|
|
("region".to_string(), "West".to_string()),
|
|
]);
|
|
assert_eq!(
|
|
model.get_cell(&k_east).and_then(|v| v.as_f64()),
|
|
Some(100.0)
|
|
);
|
|
assert_eq!(
|
|
model.get_cell(&k_west).and_then(|v| v.as_f64()),
|
|
Some(200.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_name_defaults_to_imported_model() {
|
|
let raw = json!([{"x": 1.0}]);
|
|
let p = ImportPipeline::new(raw);
|
|
assert_eq!(p.model_name, "Imported Model");
|
|
}
|
|
|
|
#[test]
|
|
fn build_model_adds_formulas_from_pipeline() {
|
|
let raw = json!([
|
|
{"region": "East", "revenue": 100.0, "cost": 40.0},
|
|
{"region": "West", "revenue": 200.0, "cost": 80.0},
|
|
]);
|
|
let mut p = ImportPipeline::new(raw);
|
|
p.formulas.push("Profit = revenue - cost".to_string());
|
|
let model = p.build_model().unwrap();
|
|
// The formula should produce Profit = 60 for East (100-40)
|
|
use crate::model::cell::CellKey;
|
|
let key = CellKey::new(vec![
|
|
("Measure".to_string(), "Profit".to_string()),
|
|
("region".to_string(), "East".to_string()),
|
|
]);
|
|
let val = model.evaluate(&key).and_then(|v| v.as_f64());
|
|
assert_eq!(val, Some(60.0));
|
|
}
|
|
|
|
#[test]
|
|
fn build_model_extracts_date_month_component() {
|
|
use crate::import::analyzer::DateComponent;
|
|
|
|
let raw = json!([
|
|
{"Date": "01/15/2025", "Amount": 100.0},
|
|
{"Date": "01/20/2025", "Amount": 50.0},
|
|
{"Date": "02/05/2025", "Amount": 200.0},
|
|
]);
|
|
let mut p = ImportPipeline::new(raw);
|
|
// Enable Month extraction on the Date field
|
|
for prop in &mut p.proposals {
|
|
if prop.field == "Date" && prop.kind == FieldKind::TimeCategory {
|
|
prop.date_components.push(DateComponent::Month);
|
|
}
|
|
}
|
|
let model = p.build_model().unwrap();
|
|
assert!(model.category("Date_Month").is_some());
|
|
let cat = model.category("Date_Month").unwrap();
|
|
let items: Vec<&str> = cat.items.keys().map(|s| s.as_str()).collect();
|
|
assert!(items.contains(&"2025-01"));
|
|
assert!(items.contains(&"2025-02"));
|
|
}
|
|
|
|
// ── ImportWizard tests ────────────────────────────────────────────────
|
|
|
|
use super::ImportWizard;
|
|
use super::WizardStep;
|
|
use crate::import::analyzer::DateComponent;
|
|
|
|
fn sample_wizard() -> ImportWizard {
|
|
let raw = json!([
|
|
{"region": "East", "product": "Shirts", "revenue": 100.0, "cost": 40.0},
|
|
{"region": "West", "product": "Pants", "revenue": 200.0, "cost": 80.0},
|
|
]);
|
|
ImportWizard::new(raw)
|
|
}
|
|
|
|
#[test]
|
|
fn wizard_starts_at_review_proposals_for_flat_array() {
|
|
let w = sample_wizard();
|
|
assert_eq!(w.step, WizardStep::ReviewProposals);
|
|
}
|
|
|
|
#[test]
|
|
fn wizard_starts_at_select_array_path_for_multi_path_object() {
|
|
let raw = json!({
|
|
"orders": [{"id": 1}],
|
|
"products": [{"name": "A"}],
|
|
});
|
|
let w = ImportWizard::new(raw);
|
|
assert_eq!(w.step, WizardStep::SelectArrayPath);
|
|
}
|
|
|
|
#[test]
|
|
fn wizard_advance_from_review_proposals_skips_dates_when_none() {
|
|
let mut w = sample_wizard();
|
|
assert_eq!(w.step, WizardStep::ReviewProposals);
|
|
w.advance();
|
|
// No time categories → skip ConfigureDates → DefineFormulas
|
|
assert_eq!(w.step, WizardStep::DefineFormulas);
|
|
}
|
|
|
|
#[test]
|
|
fn wizard_advance_full_sequence() {
|
|
let mut w = sample_wizard();
|
|
// ReviewProposals → DefineFormulas → NameModel → Done
|
|
w.advance();
|
|
assert_eq!(w.step, WizardStep::DefineFormulas);
|
|
w.advance();
|
|
assert_eq!(w.step, WizardStep::NameModel);
|
|
w.advance();
|
|
assert_eq!(w.step, WizardStep::Done);
|
|
// Done stays Done
|
|
w.advance();
|
|
assert_eq!(w.step, WizardStep::Done);
|
|
}
|
|
|
|
#[test]
|
|
fn wizard_move_cursor_clamps() {
|
|
let mut w = sample_wizard();
|
|
// At ReviewProposals, cursor starts at 0
|
|
w.move_cursor(-1);
|
|
assert_eq!(w.cursor, 0); // can't go below 0
|
|
w.move_cursor(1);
|
|
assert_eq!(w.cursor, 1);
|
|
// Move way past end
|
|
for _ in 0..100 {
|
|
w.move_cursor(1);
|
|
}
|
|
assert!(w.cursor < w.pipeline.proposals.len());
|
|
}
|
|
|
|
#[test]
|
|
fn wizard_toggle_proposal() {
|
|
let mut w = sample_wizard();
|
|
let was_accepted = w.pipeline.proposals[0].accepted;
|
|
w.toggle_proposal();
|
|
assert_ne!(w.pipeline.proposals[0].accepted, was_accepted);
|
|
w.toggle_proposal();
|
|
assert_eq!(w.pipeline.proposals[0].accepted, was_accepted);
|
|
}
|
|
|
|
#[test]
|
|
fn wizard_cycle_proposal_kind() {
|
|
let mut w = sample_wizard();
|
|
let original = w.pipeline.proposals[0].kind.clone();
|
|
w.cycle_proposal_kind();
|
|
assert_ne!(w.pipeline.proposals[0].kind, original);
|
|
// Cycle through all 4 kinds back to original
|
|
w.cycle_proposal_kind();
|
|
w.cycle_proposal_kind();
|
|
w.cycle_proposal_kind();
|
|
assert_eq!(w.pipeline.proposals[0].kind, original);
|
|
}
|
|
|
|
#[test]
|
|
fn wizard_model_name_editing() {
|
|
let mut w = sample_wizard();
|
|
w.pipeline.model_name.clear();
|
|
w.push_name_char('H');
|
|
w.push_name_char('i');
|
|
assert_eq!(w.pipeline.model_name, "Hi");
|
|
w.pop_name_char();
|
|
assert_eq!(w.pipeline.model_name, "H");
|
|
}
|
|
|
|
#[test]
|
|
fn wizard_confirm_path() {
|
|
let raw = json!({
|
|
"orders": [{"id": 1, "region": "East", "amount": 10.0}],
|
|
"products": [{"name": "A"}],
|
|
});
|
|
let mut w = ImportWizard::new(raw);
|
|
assert_eq!(w.step, WizardStep::SelectArrayPath);
|
|
w.confirm_path(); // selects first path
|
|
// Should advance past SelectArrayPath
|
|
assert_ne!(w.step, WizardStep::SelectArrayPath);
|
|
assert!(!w.pipeline.records.is_empty());
|
|
}
|
|
|
|
// ── Formula editing in wizard ───────────────────────────────────────
|
|
|
|
#[test]
|
|
fn wizard_formula_lifecycle() {
|
|
let mut w = sample_wizard();
|
|
// Go to DefineFormulas
|
|
w.advance();
|
|
assert_eq!(w.step, WizardStep::DefineFormulas);
|
|
|
|
// Start editing
|
|
w.start_formula_edit();
|
|
assert!(w.formula_editing);
|
|
|
|
// Type formula
|
|
for c in "Profit = revenue - cost".chars() {
|
|
w.push_formula_char(c);
|
|
}
|
|
assert_eq!(w.formula_buffer, "Profit = revenue - cost");
|
|
|
|
// Pop a char
|
|
w.pop_formula_char();
|
|
assert_eq!(w.formula_buffer, "Profit = revenue - cos");
|
|
|
|
// Cancel
|
|
w.cancel_formula_edit();
|
|
assert!(!w.formula_editing);
|
|
assert!(w.formula_buffer.is_empty());
|
|
assert!(w.pipeline.formulas.is_empty()); // nothing committed
|
|
|
|
// Start again and confirm
|
|
w.start_formula_edit();
|
|
for c in "Profit = revenue - cost".chars() {
|
|
w.push_formula_char(c);
|
|
}
|
|
w.confirm_formula();
|
|
assert!(!w.formula_editing);
|
|
assert_eq!(w.pipeline.formulas.len(), 1);
|
|
assert_eq!(w.pipeline.formulas[0], "Profit = revenue - cost");
|
|
}
|
|
|
|
#[test]
|
|
fn wizard_delete_formula() {
|
|
let mut w = sample_wizard();
|
|
w.pipeline.formulas.push("A = B + C".to_string());
|
|
w.pipeline.formulas.push("D = E + F".to_string());
|
|
w.cursor = 1;
|
|
w.delete_formula();
|
|
assert_eq!(w.pipeline.formulas.len(), 1);
|
|
assert_eq!(w.pipeline.formulas[0], "A = B + C");
|
|
assert_eq!(w.cursor, 0); // adjusted down
|
|
}
|
|
|
|
#[test]
|
|
fn wizard_delete_formula_at_zero() {
|
|
let mut w = sample_wizard();
|
|
w.pipeline.formulas.push("A = B + C".to_string());
|
|
w.cursor = 0;
|
|
w.delete_formula();
|
|
assert!(w.pipeline.formulas.is_empty());
|
|
assert_eq!(w.cursor, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn wizard_confirm_empty_formula_is_noop() {
|
|
let mut w = sample_wizard();
|
|
w.start_formula_edit();
|
|
w.confirm_formula(); // empty buffer
|
|
assert!(w.pipeline.formulas.is_empty());
|
|
}
|
|
|
|
// ── Sample formulas ─────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn sample_formulas_with_two_measures() {
|
|
let w = sample_wizard();
|
|
let samples = w.sample_formulas();
|
|
// Should have Diff, Total, and Ratio suggestions
|
|
assert!(samples.len() >= 2);
|
|
assert!(samples.iter().any(|s| s.contains("Diff")));
|
|
assert!(samples.iter().any(|s| s.contains("Total")));
|
|
}
|
|
|
|
#[test]
|
|
fn sample_formulas_with_one_measure() {
|
|
let raw = json!([
|
|
{"region": "East", "revenue": 100.0},
|
|
]);
|
|
let w = ImportWizard::new(raw);
|
|
let samples = w.sample_formulas();
|
|
assert!(samples.iter().any(|s| s.contains("Total")));
|
|
// No Diff or Ratio with only one measure
|
|
assert!(!samples.iter().any(|s| s.contains("Diff")));
|
|
}
|
|
|
|
#[test]
|
|
fn sample_formulas_with_no_measures() {
|
|
let raw = json!([
|
|
{"region": "East", "product": "Shirts"},
|
|
]);
|
|
let w = ImportWizard::new(raw);
|
|
let samples = w.sample_formulas();
|
|
assert!(samples.is_empty());
|
|
}
|
|
|
|
// ── Preview summary ─────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn preview_summary_for_array() {
|
|
let raw = json!([
|
|
{"region": "East", "revenue": 100.0},
|
|
]);
|
|
let p = ImportPipeline::new(raw);
|
|
let s = p.preview_summary();
|
|
assert!(s.contains("1 records"));
|
|
assert!(s.contains("region"));
|
|
}
|
|
|
|
#[test]
|
|
fn preview_summary_for_object() {
|
|
let raw = json!({
|
|
"data": [{"x": 1}],
|
|
"meta": {"version": 1},
|
|
});
|
|
let p = ImportPipeline::new(raw);
|
|
let s = p.preview_summary();
|
|
assert!(s.contains("Object"));
|
|
assert!(s.contains("data"));
|
|
}
|
|
|
|
// ── Date config ─────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn wizard_date_config_toggle() {
|
|
let raw = json!([
|
|
{"Date": "01/15/2025", "Amount": 100.0},
|
|
{"Date": "02/15/2025", "Amount": 200.0},
|
|
]);
|
|
let mut w = ImportWizard::new(raw);
|
|
// Enable the Date field as a TimeCategory (should already be detected)
|
|
let has_time = w.has_time_categories();
|
|
if has_time {
|
|
// Advance to ConfigureDates
|
|
w.advance();
|
|
assert_eq!(w.step, WizardStep::ConfigureDates);
|
|
|
|
// Toggle Year component (cursor 0 = Year of first time field)
|
|
let had_year_before = {
|
|
let tc = w.time_category_proposals();
|
|
!tc.is_empty() && tc[0].date_components.iter().any(|c| *c == DateComponent::Year)
|
|
};
|
|
w.toggle_date_component();
|
|
let has_year_after = {
|
|
let tc = w.time_category_proposals();
|
|
!tc.is_empty() && tc[0].date_components.iter().any(|c| *c == DateComponent::Year)
|
|
};
|
|
assert_ne!(had_year_before, has_year_after);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn wizard_date_config_at_cursor_mapping() {
|
|
let raw = json!([
|
|
{"Date": "01/15/2025", "Amount": 100.0},
|
|
{"Date": "02/15/2025", "Amount": 200.0},
|
|
]);
|
|
let mut w = ImportWizard::new(raw);
|
|
if w.has_time_categories() {
|
|
w.advance();
|
|
// cursor 0 → Year, cursor 1 → Month, cursor 2 → Quarter
|
|
w.cursor = 0;
|
|
let (_, comp) = w.date_config_at_cursor().unwrap();
|
|
assert_eq!(comp, DateComponent::Year);
|
|
w.cursor = 1;
|
|
let (_, comp) = w.date_config_at_cursor().unwrap();
|
|
assert_eq!(comp, DateComponent::Month);
|
|
w.cursor = 2;
|
|
let (_, comp) = w.date_config_at_cursor().unwrap();
|
|
assert_eq!(comp, DateComponent::Quarter);
|
|
}
|
|
}
|
|
|
|
// ── Edge cases in build_model ───────────────────────────────────────
|
|
|
|
#[test]
|
|
fn build_model_record_with_missing_category_value_skipped() {
|
|
// If a record is missing a category field, it should be skipped
|
|
let raw = json!([
|
|
{"region": "East", "revenue": 100.0},
|
|
{"revenue": 200.0}, // missing "region"
|
|
]);
|
|
let p = ImportPipeline::new(raw);
|
|
let model = p.build_model().unwrap();
|
|
// Only one cell should exist (the East record)
|
|
use crate::model::cell::CellKey;
|
|
let k = CellKey::new(vec![
|
|
("Measure".to_string(), "revenue".to_string()),
|
|
("region".to_string(), "East".to_string()),
|
|
]);
|
|
assert!(model.get_cell(&k).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn build_model_with_integer_category_values() {
|
|
// Non-string JSON values used as categories should be stringified.
|
|
// Use many repeated string values so "id" gets classified as Category,
|
|
// plus a numeric field that triggers Measure.
|
|
let raw = json!([
|
|
{"id": "A", "type": "x", "value": 100.0},
|
|
{"id": "B", "type": "x", "value": 200.0},
|
|
{"id": "A", "type": "y", "value": 150.0},
|
|
]);
|
|
let p = ImportPipeline::new(raw);
|
|
let model = p.build_model().unwrap();
|
|
let cat = model.category("id").expect("id should be a category");
|
|
let items: Vec<&str> = cat.ordered_item_names().into_iter().collect();
|
|
assert!(items.contains(&"A"));
|
|
assert!(items.contains(&"B"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_model_formulas_without_measure_category() {
|
|
// NOTE: When there are no measures, formula_cat falls back to
|
|
// the first category key, which may include virtual categories.
|
|
// This mirrors the CommitFormula bug (improvise-79u).
|
|
let raw = json!([
|
|
{"region": "East", "product": "Shirts"},
|
|
{"region": "West", "product": "Pants"},
|
|
]);
|
|
let mut p = ImportPipeline::new(raw);
|
|
p.formulas.push("Test = A + B".to_string());
|
|
let model = p.build_model().unwrap();
|
|
// Formula should still be added (even if target category is suboptimal)
|
|
// The formula may fail to parse against a non-measure category, which is OK
|
|
// Just ensure build_model doesn't panic
|
|
assert!(model.category("region").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn build_model_date_components_appear_in_cell_keys() {
|
|
use crate::import::analyzer::DateComponent;
|
|
use crate::model::cell::CellKey;
|
|
|
|
let raw = json!([
|
|
{"Date": "03/31/2026", "Amount": 100.0},
|
|
]);
|
|
let mut p = ImportPipeline::new(raw);
|
|
for prop in &mut p.proposals {
|
|
if prop.field == "Date" {
|
|
prop.date_components.push(DateComponent::Month);
|
|
}
|
|
}
|
|
let model = p.build_model().unwrap();
|
|
let key = CellKey::new(vec![
|
|
("Date".to_string(), "03/31/2026".to_string()),
|
|
("Date_Month".to_string(), "2026-03".to_string()),
|
|
("Measure".to_string(), "Amount".to_string()),
|
|
]);
|
|
assert_eq!(model.get_cell(&key).and_then(|v| v.as_f64()), Some(100.0));
|
|
}
|
|
}
|