diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 4052e29..f3cd25e 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -803,4 +803,265 @@ mod tests { let m2: Model = serde_json::from_str(&json).unwrap(); assert_eq!(m2.name, "Budget"); } + + // ── save/load roundtrip via file ──────────────────────────────────── + + #[test] + fn save_and_load_roundtrip_plain() { + let mut m = two_cat_model(); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Number(42.0), + ); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.improv"); + super::save(&m, &path).unwrap(); + let loaded = super::load(&path).unwrap(); + assert_eq!(loaded.name, "Budget"); + assert_eq!( + loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + Some(&CellValue::Number(42.0)) + ); + } + + #[test] + fn save_and_load_roundtrip_gzip() { + let mut m = two_cat_model(); + m.set_cell( + coord(&[("Type", "Gas"), ("Month", "Feb")]), + CellValue::Number(99.0), + ); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.improv.gz"); + super::save(&m, &path).unwrap(); + let loaded = super::load(&path).unwrap(); + assert_eq!(loaded.name, "Budget"); + assert_eq!( + loaded.get_cell(&coord(&[("Type", "Gas"), ("Month", "Feb")])), + Some(&CellValue::Number(99.0)) + ); + } + + // ── autosave_path ─────────────────────────────────────────────────── + + #[test] + fn autosave_path_inserts_dot_prefix() { + let p = std::path::Path::new("/home/user/data/budget.improv"); + let auto = super::autosave_path(p); + assert_eq!(auto.file_name().unwrap().to_str().unwrap(), ".budget.improv.autosave"); + } + + // ── format_md: collapsed groups ───────────────────────────────────── + + #[test] + fn format_md_collapsed_group() { + let mut m = two_cat_model(); + m.active_view_mut() + .toggle_group_collapse("Type", "MyGroup"); + let text = format_md(&m); + assert!(text.contains("collapsed: Type/MyGroup")); + } + + // ── format_md: page axis without selection ────────────────────────── + + #[test] + fn format_md_page_without_selection() { + let mut m = two_cat_model(); + m.active_view_mut().set_axis("Month", Axis::Page); + // Don't set a page selection + let text = format_md(&m); + assert!(text.contains("Month: page")); + // Should NOT have a comma after "page" (no selection) + let line = text.lines().find(|l| l.starts_with("Month:")).unwrap(); + assert!(!line.contains(','), "Expected no selection, got: {line}"); + } + + // ── format_md: none axis ──────────────────────────────────────────── + + #[test] + fn format_md_none_axis() { + let mut m = two_cat_model(); + m.active_view_mut().set_axis("Month", Axis::None); + let text = format_md(&m); + assert!(text.contains("Month: none")); + } + + // ── format_md: number format ──────────────────────────────────────── + + #[test] + fn format_md_includes_number_format() { + let mut m = two_cat_model(); + m.active_view_mut().number_format = ",.2f".to_string(); + let text = format_md(&m); + assert!(text.contains("format: ,.2f")); + } + + // ── parse_md: comments and blank lines ────────────────────────────── + + #[test] + fn parse_md_ignores_blank_and_comment_lines() { + let text = r#"# Test Model + +## Category: Type +- Food +- Gas + +## Data +Type=Food = 42 +"#; + let m = parse_md(text).unwrap(); + assert_eq!(m.name, "Test Model"); + assert!(m.category("Type").is_some()); + } + + // ── parse_md: text values ─────────────────────────────────────────── + + #[test] + fn parse_md_round_trips_text_cell_values() { + let mut m = two_cat_model(); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Text("pending".to_string()), + ); + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!( + loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + Some(&CellValue::Text("pending".to_string())) + ); + } + + // ── parse_md: collapsed groups roundtrip ──────────────────────────── + + #[test] + fn parse_md_round_trips_collapsed_group() { + let mut m = two_cat_model(); + m.active_view_mut() + .toggle_group_collapse("Type", "MyGroup"); + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert!(loaded.active_view().is_group_collapsed("Type", "MyGroup")); + } + + // ── parse_md: number format roundtrip ─────────────────────────────── + + #[test] + fn parse_md_round_trips_number_format() { + let mut m = two_cat_model(); + m.active_view_mut().number_format = ",.2f".to_string(); + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!(loaded.active_view().number_format, ",.2f"); + } + + // ── parse_md: none axis roundtrip ─────────────────────────────────── + + #[test] + fn parse_md_round_trips_none_axis() { + let mut m = two_cat_model(); + m.active_view_mut().set_axis("Month", Axis::None); + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert_eq!(loaded.active_view().axis_of("Month"), Axis::None); + } + + // ── parse_md: multiple views ──────────────────────────────────────── + + #[test] + fn parse_md_round_trips_multiple_views() { + let mut m = two_cat_model(); + m.create_view("Alternate"); + { + let v = m.views.get_mut("Alternate").unwrap(); + v.set_axis("Type", Axis::Column); + v.set_axis("Month", Axis::Row); + } + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + assert!(loaded.views.contains_key("Default")); + assert!(loaded.views.contains_key("Alternate")); + let alt = loaded.views.get("Alternate").unwrap(); + assert_eq!(alt.axis_of("Type"), Axis::Column); + assert_eq!(alt.axis_of("Month"), Axis::Row); + } + + // ── export_csv ────────────────────────────────────────────────────── + + #[test] + fn export_csv_produces_valid_output() { + let mut m = two_cat_model(); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Number(100.0), + ); + m.set_cell( + coord(&[("Type", "Gas"), ("Month", "Feb")]), + CellValue::Number(50.0), + ); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("export.csv"); + super::export_csv(&m, "Default", &path).unwrap(); + let content = std::fs::read_to_string(&path).unwrap(); + // Should have a header and data rows + let lines: Vec<&str> = content.lines().collect(); + assert!(lines.len() >= 2, "Expected header + data, got: {content}"); + // Header should contain column labels + assert!(lines[0].contains(','), "Expected CSV header, got: {}", lines[0]); + } + + #[test] + fn export_csv_unknown_view_returns_error() { + let m = two_cat_model(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("export.csv"); + assert!(super::export_csv(&m, "Nonexistent", &path).is_err()); + } + + // ── Full save/load/format roundtrip with all features ─────────────── + + #[test] + fn full_roundtrip_preserves_all_features() { + let mut m = two_cat_model(); + // Add data + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Number(100.0), + ); + m.set_cell( + coord(&[("Type", "Gas"), ("Month", "Feb")]), + CellValue::Text("pending".to_string()), + ); + // Add formula + let f = parse_formula("Gas = Food * 2", "Type").unwrap(); + m.add_formula(f); + // Configure view + m.active_view_mut().set_axis("Month", Axis::Page); + m.active_view_mut() + .set_page_selection("Month", "Jan"); + m.active_view_mut().hide_item("Type", "Gas"); + m.active_view_mut() + .toggle_group_collapse("Type", "G1"); + m.active_view_mut().number_format = ",.0".to_string(); + + let text = format_md(&m); + let loaded = parse_md(&text).unwrap(); + + // Verify everything roundtripped + assert_eq!(loaded.name, "Budget"); + assert_eq!( + loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + Some(&CellValue::Number(100.0)) + ); + assert_eq!( + loaded.get_cell(&coord(&[("Type", "Gas"), ("Month", "Feb")])), + Some(&CellValue::Text("pending".to_string())) + ); + assert!(!loaded.formulas().is_empty()); + let v = loaded.active_view(); + assert_eq!(v.axis_of("Month"), Axis::Page); + assert_eq!(v.page_selection("Month"), Some("Jan")); + assert!(v.is_hidden("Type", "Gas")); + assert!(v.is_group_collapsed("Type", "G1")); + assert_eq!(v.number_format, ",.0"); + } }