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, pub selected_path: String, pub records: Vec, pub proposals: Vec, pub model_name: String, /// Raw formula strings to add to the model (e.g., "Profit = Revenue - Cost"). pub formulas: Vec, } 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::>().join(", ")) .unwrap_or_default() ), Value::Object(map) => format!( "Object with {} top-level keys: {}", map.len(), map.keys().take(10).cloned().collect::>().join(", ") ), _ => "Unknown JSON structure".to_string(), } } /// Build a Model from the current proposals. Pure — no side effects. pub fn build_model(&self) -> Result { 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, /// 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 { 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 { 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 { 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 = (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 = (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)); } }