diff --git a/src/import/wizard.rs b/src/import/wizard.rs index 24e8f3d..de7c86b 100644 --- a/src/import/wizard.rs +++ b/src/import/wizard.rs @@ -748,6 +748,360 @@ mod tests { 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;