test(import): add unit tests for import wizard and pipeline
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)
This commit is contained in:
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user