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:
Edward Langley
2026-04-08 23:12:31 -07:00
parent 96a4cda368
commit 687cf80698

View File

@ -748,6 +748,360 @@ mod tests {
assert!(items.contains(&"2025-02")); 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] #[test]
fn build_model_date_components_appear_in_cell_keys() { fn build_model_date_components_appear_in_cell_keys() {
use crate::import::analyzer::DateComponent; use crate::import::analyzer::DateComponent;