//! Integration tests for the cost estimator module. //! //! Tests the full workflow: initialize data → load rooms → manipulate store → compute totals. //! Also tests screen_actions logic paths, floor multiplier, export summary, and cross-module workflows. use nigig_build::construction_frame::pages::workspace::cost_estimator::data_raw::{ calculate_property_cost, format_cost, get_building_data, get_default_rooms, BuildingCategory, BuildingCostData, PropertyDefaultRooms, Room, RoomSizeBand, }; use nigig_build::construction_frame::pages::workspace::cost_estimator::room_controller::{ build_size_bands_lookup, get_rooms_by_category, lookup_rate, update_rates_in_place, }; use nigig_build::construction_frame::pages::workspace::cost_estimator::room_store::RoomStore; use nigig_build::construction_frame::pages::workspace::cost_estimator::screen_state::StoreyType; fn make_room(name: &str, length: f64, width: f64, rate: f64) -> Room { Room { room_type: name.into(), length, width, rate, count: Some(1), ..Room::default() } } fn make_room_with_count(name: &str, length: f64, width: f64, rate: f64, count: u32) -> Room { Room { room_type: name.into(), length, width, rate, count: Some(count), ..Room::default() } } // ── Full workflow tests ─────────────────────────────────────────────── #[test] fn full_workflow_initialize_load_store_totals() { let data = get_building_data(); let rooms = get_rooms_by_category( &data, &BuildingCategory::Residential, Some("Nairobi"), true, Some("Standard Bungalow"), ); assert!( !rooms.is_empty(), "Should load rooms for residential Nairobi bungalow" ); let mut store = RoomStore::new(); for room in rooms { store.add(room); } assert!(store.len() > 0); for (_, room) in store.iter() { assert!( room.rate > 0.0, "Room '{}' should have a positive rate", room.room_type ); } assert!(store.total_area() > 0.0, "Total area should be positive"); assert!(store.total_cost() > 0.0, "Total cost should be positive"); } #[test] fn workflow_add_remove_rooms() { let mut store = RoomStore::new(); let idx1 = store.add(make_room("Bedroom", 4.0, 3.0, 20_000.0)); let idx2 = store.add(make_room("Kitchen", 3.0, 3.0, 25_000.0)); let idx3 = store.add(make_room("Bathroom", 2.0, 2.0, 30_000.0)); assert_eq!(store.len(), 3); assert!(store.remove(idx2)); assert_eq!(store.len(), 2); assert_eq!(store.get(0).unwrap().room_type, "Bedroom"); assert_eq!(store.get(1).unwrap().room_type, "Bathroom"); assert_eq!(store.total_cost(), 360_000.0); } #[test] fn workflow_update_all_rates() { let mut store = RoomStore::new(); store.add(make_room("A", 3.0, 3.0, 0.0)); store.add(make_room("B", 4.0, 4.0, 0.0)); assert_eq!(store.total_cost(), 0.0); update_rates_in_place(&mut store, 10_000.0); assert_eq!(store.total_cost(), 250_000.0); } #[test] fn workflow_format_cost() { assert_eq!(format_cost(1_500_000.0), "1,500,000.00"); assert_eq!(format_cost(123_456.78), "123,456.78"); assert_eq!(format_cost(0.0), "0.00"); } #[test] fn workflow_lookup_rate() { let data = get_building_data(); let rate = lookup_rate( &data, &BuildingCategory::Residential, "Nairobi", "Standard Bungalow", ); let rate = rate.expect("Should find rate for residential Nairobi bungalow"); assert!(rate > 0.0); let area = 150.0; let expected_cost = rate * area; assert!(expected_cost > 0.0); } #[test] fn workflow_default_rooms() { let data = get_building_data(); let default = get_default_rooms(&data, "Standard Bungalow"); assert!( default.is_some(), "Should have default rooms for Standard Bungalow" ); let rooms = &default.unwrap().rooms; assert!(!rooms.is_empty()); } #[test] fn workflow_store_display_filtering() { let mut store = RoomStore::new(); store.add(make_room("Bedroom", 4.0, 3.0, 20_000.0)); store.add(make_room("Kitchen", 3.0, 3.0, 25_000.0)); store.add(make_room("Bedroom", 3.5, 3.5, 22_000.0)); store.add(make_room("Bathroom", 2.0, 2.0, 30_000.0)); store.rebuild_display_indices(); assert_eq!(store.displayed_indices().len(), 4); store.remove(1); store.rebuild_display_indices(); assert_eq!(store.displayed_indices().len(), 3); store.clear(); assert!(store.is_empty()); assert_eq!(store.total_cost(), 0.0); } // ── Floor multiplier logic tests ────────────────────────────────────── fn apply_floor_multiplier( area: Option, cost: Option, storey_type: &StoreyType, num_floors: u32, ) -> (Option, Option) { if *storey_type == StoreyType::NonStorey || num_floors <= 1 { return (area, cost); } let multiplier = num_floors as f64; (area.map(|a| a * multiplier), cost.map(|c| c * multiplier)) } #[test] fn floor_multiplier_non_storey_no_change() { let area = Some(100.0); let cost = Some(2_000_000.0); let (a, c) = apply_floor_multiplier(area, cost, &StoreyType::NonStorey, 1); assert_eq!(a, Some(100.0)); assert_eq!(c, Some(2_000_000.0)); } #[test] fn floor_multiplier_single_floor_no_change() { let area = Some(100.0); let cost = Some(2_000_000.0); let (a, c) = apply_floor_multiplier(area, cost, &StoreyType::LowRise, 1); assert_eq!(a, Some(100.0)); assert_eq!(c, Some(2_000_000.0)); } #[test] fn floor_multiplier_low_rise_3_floors() { let area = Some(100.0); let cost = Some(2_000_000.0); let (a, c) = apply_floor_multiplier(area, cost, &StoreyType::LowRise, 3); assert!((a.unwrap() - 300.0).abs() < f64::EPSILON); assert!((c.unwrap() - 6_000_000.0).abs() < f64::EPSILON); } #[test] fn floor_multiplier_high_rise_10_floors() { let area = Some(50.0); let cost = Some(1_000_000.0); let (a, c) = apply_floor_multiplier(area, cost, &StoreyType::HighRise, 10); assert!((a.unwrap() - 500.0).abs() < f64::EPSILON); assert!((c.unwrap() - 10_000_000.0).abs() < f64::EPSILON); } #[test] fn floor_multiplier_none_values_stay_none() { let (a, c) = apply_floor_multiplier(None, None, &StoreyType::HighRise, 5); assert!(a.is_none()); assert!(c.is_none()); } #[test] fn floor_multiplier_mixed_some_none() { let (a, c) = apply_floor_multiplier(Some(100.0), None, &StoreyType::LowRise, 2); assert!((a.unwrap() - 200.0).abs() < f64::EPSILON); assert!(c.is_none()); } // ── All building categories ─────────────────────────────────────────── #[test] fn all_categories_have_regions_and_rooms() { let data = get_building_data(); let categories = [ BuildingCategory::Residential, BuildingCategory::Commercial, BuildingCategory::Retail, BuildingCategory::Industrial, ]; for cat in &categories { let rooms = get_rooms_by_category(&data, cat, None, false, None); if !rooms.is_empty() { let mut store = RoomStore::new(); for room in rooms { store.add(room); } assert!(store.total_area() >= 0.0); assert!(store.total_cost() >= 0.0); } } } #[test] fn all_categories_lookup_rates() { let data = get_building_data(); let categories = [ BuildingCategory::Residential, BuildingCategory::Commercial, BuildingCategory::Retail, BuildingCategory::Industrial, ]; for cat in &categories { let residential_data = data.get(cat); if let Some(entries) = residential_data { for entry in entries { for region in &entry.regions { for bt in ®ion.building_types { for prop in &bt.properties { let rate = lookup_rate(&data, cat, ®ion.name, &prop.property_type); if let Some(r) = rate { assert!( r > 0.0, "Rate for {} should be positive", prop.property_type ); } } } } } } } } // ── Export summary format ───────────────────────────────────────────── fn export_summary( category: &BuildingCategory, property_type: &str, region: &str, storey_type: &StoreyType, num_floors: u32, area: Option, cost: Option, ) -> String { let floors_info = if storey_type.shows_floor_input() { format!( "\nStorey Type: {}\nFloors: {}", storey_type.as_label(), num_floors ) } else { format!("\nStorey Type: {}", storey_type.as_label()) }; format!( "Cost Estimate\nCategory: {}\nProperty: {}\nRegion: {}{}\nArea: {}\nCost: {}", category.as_label(), property_type, region, floors_info, area.map(|v| format!("{} m² / {} ft²", format_cost(v), format_cost(v * 10.763_91))) .unwrap_or_else(|| "--".to_string()), cost.map(|v| format!("Ksh {}", format_cost(v))) .unwrap_or_else(|| "Ksh --".to_string()), ) } #[test] fn export_summary_non_storey() { let summary = export_summary( &BuildingCategory::Residential, "Standard Bungalow", "Nairobi", &StoreyType::NonStorey, 1, Some(150.0), Some(3_000_000.0), ); assert!(summary.contains("Category: Residential")); assert!(summary.contains("Property: Standard Bungalow")); assert!(summary.contains("Region: Nairobi")); assert!(summary.contains("Storey Type: Non-Storey")); assert!(!summary.contains("Floors:")); assert!(summary.contains("150.00")); assert!(summary.contains("Ksh")); } #[test] fn export_summary_low_rise_with_floors() { let summary = export_summary( &BuildingCategory::Residential, "Maisonette", "Mombasa", &StoreyType::LowRise, 3, Some(200.0), Some(6_000_000.0), ); assert!(summary.contains("Storey Type: Low Rise")); assert!(summary.contains("Floors: 3")); assert!(summary.contains("200.00")); assert!(summary.contains("6,000,000.00")); } #[test] fn export_summary_no_data() { let summary = export_summary( &BuildingCategory::Industrial, "Warehouse", "Kisumu", &StoreyType::NonStorey, 1, None, None, ); assert!(summary.contains("Category: Industrial")); assert!(summary.contains("--")); assert!(summary.contains("Ksh --")); } // ── Room with count multiplier ──────────────────────────────────────── #[test] fn room_count_multiplies_area_and_cost() { let mut store = RoomStore::new(); store.add(make_room_with_count("Bedroom", 4.0, 3.0, 20_000.0, 3)); // area = 4*3*3 = 36 assert!((store.total_area() - 36.0).abs() < f64::EPSILON); // cost = 36 * 20000 = 720000 assert_eq!(store.total_cost(), 720_000.0); } #[test] fn mixed_count_rooms_totals() { let mut store = RoomStore::new(); store.add(make_room_with_count("Bedroom", 4.0, 3.0, 20_000.0, 2)); // 24 m² → 480000 store.add(make_room("Kitchen", 3.0, 3.0, 25_000.0)); // 9 m² → 225000 store.add(make_room_with_count("Bathroom", 2.0, 2.0, 30_000.0, 2)); // 8 m² → 480000 assert!((store.total_area() - 65.0).abs() < f64::EPSILON); assert_eq!(store.total_cost(), 1_185_000.0); } // ── Size bands and classification ───────────────────────────────────── #[test] fn size_bands_lookup_for_multiple_room_types() { let data = get_building_data(); let residential = data.get(&BuildingCategory::Residential).unwrap(); let first = &residential[0]; let lookup = build_size_bands_lookup(&first.room_sizes); assert!( !lookup.is_empty(), "Should have size bands for residential rooms" ); for (room_type, bands) in &lookup { assert!( !bands.is_empty(), "Room type '{}' should have bands", room_type ); for band in bands { assert!(!band.size_category.is_empty()); assert!(band.length > 0.0); assert!(band.width > 0.0); } } } // ── StoreyType classification integration ───────────────────────────── #[test] fn storey_classification_for_all_property_types() { let data = get_building_data(); let categories = [ BuildingCategory::Residential, BuildingCategory::Commercial, BuildingCategory::Retail, BuildingCategory::Industrial, ]; let mut all_property_types = Vec::new(); for cat in &categories { if let Some(entries) = data.get(cat) { for entry in entries { let rooms = get_rooms_by_category(&data, cat, None, false, None); for room in &rooms { let st = StoreyType::classify_property(&room.room_type); assert!(matches!( st, StoreyType::NonStorey | StoreyType::LowRise | StoreyType::HighRise )); } } } } } #[test] fn storey_type_from_all_indices() { assert_eq!(StoreyType::from_index(0), StoreyType::NonStorey); assert_eq!(StoreyType::from_index(1), StoreyType::LowRise); assert_eq!(StoreyType::from_index(2), StoreyType::HighRise); assert_eq!(StoreyType::from_index(3), StoreyType::NonStorey); assert_eq!(StoreyType::from_index(100), StoreyType::NonStorey); assert_eq!(StoreyType::from_index(usize::MAX), StoreyType::NonStorey); } // ── Edge cases ──────────────────────────────────────────────────────── #[test] fn empty_store_totals_are_zero() { let store = RoomStore::new(); assert_eq!(store.total_area(), 0.0); assert_eq!(store.total_cost(), 0.0); } #[test] fn store_remove_all_rooms_one_by_one() { let mut store = RoomStore::new(); store.add(make_room("A", 1.0, 1.0, 100.0)); store.add(make_room("B", 2.0, 2.0, 200.0)); store.add(make_room("C", 3.0, 3.0, 300.0)); assert!(store.remove(0)); assert!(store.remove(0)); assert!(store.remove(0)); assert!(store.is_empty()); assert_eq!(store.total_cost(), 0.0); } #[test] fn zero_rate_rooms_contribute_zero_cost() { let mut store = RoomStore::new(); store.add(make_room("A", 10.0, 10.0, 0.0)); store.add(make_room("B", 5.0, 5.0, 0.0)); assert_eq!(store.total_cost(), 0.0); assert_eq!(store.total_area(), 125.0); } #[test] fn format_cost_various_values() { assert_eq!(format_cost(0.0), "0.00"); assert_eq!(format_cost(1.0), "1.00"); assert_eq!(format_cost(999.0), "999.00"); assert_eq!(format_cost(1000.0), "1,000.00"); assert_eq!(format_cost(1234567.89), "1,234,567.89"); assert_eq!(format_cost(-1000.0), "-1,000.00"); } // ── Cross-module: data → controller → store ────────────────────────── #[test] fn cross_module_data_controller_store_workflow() { let data = get_building_data(); let categories = [BuildingCategory::Residential, BuildingCategory::Commercial]; for cat in &categories { let rooms = get_rooms_by_category(&data, cat, None, false, None); if rooms.is_empty() { continue; } let mut store = RoomStore::new(); for room in &rooms { store.add(room.clone()); } assert!(store.len() > 0); store.rebuild_display_indices(); assert_eq!(store.displayed_indices().len(), store.len()); if let Some(first) = store.get(0) { if first.rate > 0.0 { let cost = first.length * first.width * first.rate * first.count.unwrap_or(1) as f64; assert!(cost > 0.0); } } let total_area = store.total_area(); let total_cost = store.total_cost(); assert!(total_area >= 0.0); assert!(total_cost >= 0.0); } } #[test] fn calculate_property_cost_for_all_residential_types() { let data = get_building_data(); let residential = data.get(&BuildingCategory::Residential).unwrap(); for entry in residential { for region in &entry.regions { for bt in ®ion.building_types { for prop in &bt.properties { let (area, cost) = calculate_property_cost( entry, &prop.property_type, ®ion.name, &bt.category, ); if let Some(a) = area { assert!( a > 0.0, "Area for {} should be positive", prop.property_type ); } if let Some(c) = cost { assert!( c > 0.0, "Cost for {} should be positive", prop.property_type ); } } } } } } // ── Room rate update propagation ────────────────────────────────────── #[test] fn rate_update_changes_total_proportionally() { let mut store = RoomStore::new(); store.add(make_room("A", 5.0, 5.0, 10_000.0)); // 25 * 10000 = 250000 store.add(make_room("B", 3.0, 4.0, 20_000.0)); // 12 * 20000 = 240000 let cost_before = store.total_cost(); update_rates_in_place(&mut store, 50_000.0); let cost_after = store.total_cost(); // A: 25*50000=1,250,000 B: 12*50000=600,000 → total=1,850,000 assert_eq!(cost_after, 1_850_000.0); assert!(cost_after > cost_before); } #[test] fn rate_update_to_zero_resets_cost() { let mut store = RoomStore::new(); store.add(make_room("A", 5.0, 5.0, 10_000.0)); store.add(make_room("B", 3.0, 4.0, 20_000.0)); assert!(store.total_cost() > 0.0); update_rates_in_place(&mut store, 0.0); assert_eq!(store.total_cost(), 0.0); } // ── Estimate + Command pipeline integration tests ──────────────────── use nigig_build::construction_frame::pages::workspace::cost_estimator::estimate::{ Command, DomainEvent, Estimate, }; use nigig_build::construction_frame::pages::workspace::cost_estimator::estimate_store::EstimateStore; use nigig_build::construction_frame::pages::workspace::cost_estimator::lookup::LookupIndex; use nigig_build::construction_frame::pages::workspace::cost_estimator::services; use nigig_build::construction_frame::pages::workspace::cost_estimator::types::{ PropertyType, RegionName, }; #[test] fn estimate_dispatch_initialize_events() { let data = get_building_data(); let idx = LookupIndex::build(data); let mut e = Estimate::new(); let events = e.execute_command(Command::Initialize, data, &idx); assert!(events.contains(&DomainEvent::SelectionChanged)); assert!(events.contains(&DomainEvent::FloorsChanged)); assert!(events.contains(&DomainEvent::TotalsChanged)); assert!(events.contains(&DomainEvent::RegionListChanged)); assert!(events.contains(&DomainEvent::PropertyListChanged)); assert!(events.contains(&DomainEvent::RoomTypesChanged)); } #[test] fn estimate_dispatch_add_remove_room_roundtrip() { let data = get_building_data(); let idx = LookupIndex::build(data); let mut e = Estimate::new(); e.execute_command(Command::Initialize, data, &idx); let room = Room { room_type: "Bedroom".into(), length: 4.0, width: 3.0, rate: 20_000.0, count: Some(1), ..Room::default() }; let events = e.execute_command(Command::AddRoom(room), data, &idx); assert!(events .iter() .any(|ev| matches!(ev, DomainEvent::RoomAdded(0)))); assert_eq!(e.rooms.len(), 1); let events = e.execute_command(Command::RemoveRoom(0), data, &idx); assert!(events .iter() .any(|ev| matches!(ev, DomainEvent::RoomRemoved(0)))); assert_eq!(e.rooms.len(), 0); } #[test] fn estimate_selected_data_available_after_init() { let data = get_building_data(); let idx = LookupIndex::build(data); let mut e = Estimate::new(); e.execute_command(Command::Initialize, data, &idx); let regions = e.region_labels(&idx); if let Some(r) = regions.first() { e.execute_command(Command::SetRegion(r.clone().into()), data, &idx); let bd = e.selected_data(data); assert!(bd.is_some()); } } #[test] fn estimate_compute_totals_after_full_selection() { let data = get_building_data(); let idx = LookupIndex::build(data); let mut e = Estimate::new(); e.execute_command(Command::Initialize, data, &idx); let regions = e.region_labels(&idx); if let Some(r) = regions.first() { e.execute_command(Command::SetRegion(r.clone().into()), data, &idx); } let props = e.filtered_property_types(&idx); if let Some(p) = props.first() { e.execute_command(Command::SetProperty(p.clone().into()), data, &idx); } let totals = e.compute_totals(data); assert!(totals.property_area_m2.is_some()); assert!(totals.property_cost.is_some()); } #[test] fn estimate_export_summary_after_selection() { let data = get_building_data(); let idx = LookupIndex::build(data); let mut e = Estimate::new(); e.execute_command(Command::Initialize, data, &idx); let regions = e.region_labels(&idx); if let Some(r) = regions.first() { e.execute_command(Command::SetRegion(r.clone().into()), data, &idx); } let totals = e.compute_totals(data); let summary = e.export_summary(data, &totals); assert!(summary.contains("Cost Estimate")); assert!(summary.contains("Ksh")); } #[test] fn estimate_store_dispatch_matches_direct_execute() { let data = get_building_data(); let idx = LookupIndex::build(data); let mut est = Estimate::new(); let mut store = EstimateStore::new(); est.execute_command(Command::Initialize, data, &idx); let store_events = store.dispatch(Command::Initialize); assert_eq!(est.selected_category, store.estimate.selected_category); assert_eq!(est.num_floors, store.estimate.num_floors); assert_eq!(est.rooms.len(), store.estimate.rooms.len()); assert_eq!(store_events.len(), 6); } // ── Index-backed vs data-backed service cross-validation ───────────── #[test] fn index_vs_data_list_regions_match() { let data = get_building_data(); let idx = LookupIndex::build(data); let categories = [ BuildingCategory::Residential, BuildingCategory::Commercial, BuildingCategory::Retail, BuildingCategory::Industrial, ]; for cat in &categories { let data_regions = services::list_regions(data, cat); let index_regions = services::list_regions_via_index(&idx, cat); assert_eq!( data_regions.len(), index_regions.len(), "Region count mismatch for {:?}", cat ); for r in &data_regions { assert!( index_regions.contains(r), "Index missing region '{}' for {:?}", r, cat ); } } } #[test] fn index_vs_data_property_types_match() { let data = get_building_data(); let idx = LookupIndex::build(data); let categories = [ BuildingCategory::Residential, BuildingCategory::Commercial, BuildingCategory::Retail, BuildingCategory::Industrial, ]; for cat in &categories { let data_regions = services::list_regions(data, cat); for region in &data_regions { let data_props = services::property_types_for_region(data, cat, region); let index_props = services::property_types_via_index(&idx, cat, region); assert_eq!( data_props.len(), index_props.len(), "Property count mismatch for {:?}/{}", cat, region ); for p in &data_props { assert!( index_props.contains(p), "Index missing property '{}' for {:?}/{}", p, cat, region ); } } } } #[test] fn index_vs_data_compute_rate_matches() { let data = get_building_data(); let idx = LookupIndex::build(data); let categories = [BuildingCategory::Residential, BuildingCategory::Commercial]; for cat in &categories { if let Some(entries) = data.get(cat) { for entry in entries { for region in &entry.regions { for bt in ®ion.building_types { for prop in &bt.properties { let data_rate = services::compute_rate( entry, ®ion.name, &bt.category, &prop.property_type, ); let index_rate = services::compute_rate_via_index( &idx, &bt.category, ®ion.name, &prop.property_type, ); assert!( (data_rate - index_rate).abs() < f64::EPSILON, "Rate mismatch for {} in {}/{}: data={}, index={}", prop.property_type, bt.category, region.name, data_rate, index_rate ); } } } } } } } #[test] fn index_vs_data_ensure_valid_region_matches() { let data = get_building_data(); let idx = LookupIndex::build(data); let categories = [BuildingCategory::Residential, BuildingCategory::Commercial]; for cat in &categories { let data_result = services::ensure_valid_region(data, cat, "nonexistent_region"); let index_result = services::ensure_valid_region_via_index(&idx, cat, "nonexistent_region"); assert_eq!( data_result, index_result, "ensure_valid_region mismatch for {:?}", cat ); let data_result = services::ensure_valid_region(data, cat, &data_result); let index_result = services::ensure_valid_region_via_index(&idx, cat, &index_result); assert_eq!( data_result, index_result, "ensure_valid_region (valid) mismatch for {:?}", cat ); } } // ── Newtype integration in Command flow ────────────────────────────── #[test] fn region_name_newtype_in_command_roundtrip() { let data = get_building_data(); let idx = LookupIndex::build(data); let mut e = Estimate::new(); e.execute_command(Command::Initialize, data, &idx); let r: RegionName = "Nairobi".into(); e.execute_command(Command::SetRegion(r), data, &idx); assert_eq!(e.selected_region.as_str(), "Nairobi"); } #[test] fn property_type_newtype_in_command_roundtrip() { let data = get_building_data(); let idx = LookupIndex::build(data); let mut e = Estimate::new(); e.execute_command(Command::Initialize, data, &idx); let regions = e.region_labels(&idx); if let Some(r) = regions.first() { e.execute_command(Command::SetRegion(r.clone().into()), data, &idx); } let props = e.filtered_property_types(&idx); if let Some(p) = props.first() { e.execute_command(Command::SetProperty(p.clone().into()), data, &idx); assert_eq!(e.selected_property.as_str(), p.as_str()); } } // ── Export service integration tests ──────────────────────────────── use nigig_build::construction_frame::pages::workspace::cost_estimator::data_raw::Room; #[test] fn export_csv_full_workflow() { let data = get_building_data(); let idx = LookupIndex::build(data); let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); let regions = store.estimate.region_labels(store.index()); if let Some(r) = regions.first() { store.dispatch(Command::SetRegion(r.clone().into())); } let props = store.estimate.filtered_property_types(store.index()); if let Some(p) = props.first() { store.dispatch(Command::SetProperty(p.clone().into())); } store.dispatch(Command::AddRoom(Room { room_type: "Bedroom".into(), size_category: "Medium".into(), length: 4.0, width: 3.0, count: Some(2), rate: 20_000.0, ..Room::default() })); let totals = store.compute_totals(); let csv = store.export_csv(&totals); assert!(csv.contains("Header,Category,")); assert!(csv.contains("Room,Bedroom,Medium,")); assert!(csv.contains("Room,Type,Size,Length,Width,Count,Rate,Cost")); } #[test] fn export_json_full_workflow() { let data = get_building_data(); let idx = LookupIndex::build(data); let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); let regions = store.estimate.region_labels(store.index()); if let Some(r) = regions.first() { store.dispatch(Command::SetRegion(r.clone().into())); } let props = store.estimate.filtered_property_types(store.index()); if let Some(p) = props.first() { store.dispatch(Command::SetProperty(p.clone().into())); } store.dispatch(Command::AddRoom(Room { room_type: "Kitchen".into(), size_category: "Small".into(), length: 3.0, width: 3.0, count: Some(1), rate: 25_000.0, ..Room::default() })); let totals = store.compute_totals(); let json = store.export_json(&totals); assert!(json.contains("\"category\":")); assert!(json.contains("\"rooms\":")); assert!(json.contains("\"type\": \"Kitchen\"")); assert!(json.contains("\"cost\":")); } #[test] fn export_formats_consistent_totals() { let data = get_building_data(); let idx = LookupIndex::build(data); let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); let regions = store.estimate.region_labels(store.index()); if let Some(r) = regions.first() { store.dispatch(Command::SetRegion(r.clone().into())); } let props = store.estimate.filtered_property_types(store.index()); if let Some(p) = props.first() { store.dispatch(Command::SetProperty(p.clone().into())); } store.dispatch(Command::AddRoom(Room { room_type: "Bedroom".into(), size_category: "Medium".into(), length: 4.0, width: 3.0, count: Some(2), rate: 20_000.0, ..Room::default() })); let totals = store.compute_totals(); let summary = store.export_summary(&totals); let csv = store.export_csv(&totals); let json = store.export_json(&totals); // All three formats should contain the same cost value if let Some(cost) = totals.property_cost { let cost_str = format_cost(cost); assert!(summary.contains(&cost_str), "Summary should contain cost"); assert!(csv.contains(&cost_str), "CSV should contain cost"); assert!( json.contains(&format!("{:.2}", cost)), "JSON should contain cost" ); } } #[test] fn bench_lookup_index_build() { let data = get_building_data(); let start = std::time::Instant::now(); let idx = LookupIndex::build(data); let elapsed = start.elapsed(); eprintln!("LookupIndex::build: {:?}", elapsed); // Sanity: index should contain data let residential_regions = idx.regions_for_category(&BuildingCategory::Residential); assert!(!residential_regions.is_empty()); // Should complete in < 50ms. assert!( elapsed.as_millis() < 200, "LookupIndex::build took {:?} — expected < 200ms", elapsed ); } #[test] fn bench_estimate_store_dispatch_initialize() { let data = get_building_data(); let idx = LookupIndex::build(data); let mut e = Estimate::new(); let start = std::time::Instant::now(); for _ in 0..100 { e.execute_command(Command::Initialize, data, &idx); } let elapsed = start.elapsed(); eprintln!("100× Estimate::execute_command(Initialize): {:?}", elapsed); } #[test] fn bench_full_workflow_100_iterations() { let data = get_building_data(); let idx = LookupIndex::build(data); let start = std::time::Instant::now(); for _ in 0..100 { let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); let regions = store.estimate.region_labels(store.index()); if let Some(r) = regions.first() { store.dispatch(Command::SetRegion(r.clone().into())); } let props = store.estimate.filtered_property_types(store.index()); if let Some(p) = props.first() { store.dispatch(Command::SetProperty(p.clone().into())); } store.dispatch(Command::SetStorey( nigig_build::construction_frame::pages::workspace::cost_estimator::screen_state::StoreyType::LowRise, )); store.dispatch(Command::SetFloorCount(3)); store.dispatch(Command::AddRoom(make_room("Bedroom", 4.0, 3.0, 20_000.0))); store.dispatch(Command::AddRoom(make_room("Kitchen", 3.0, 3.0, 25_000.0))); store.dispatch(Command::AddRoom(make_room("Bathroom", 2.0, 2.0, 30_000.0))); let totals = store.compute_totals(); let _ = store.export_summary(&totals); } let elapsed = start.elapsed(); eprintln!( "100× full workflow (init→select→add rooms→totals→export): {:?}", elapsed ); } // ── UpdateRoomDimensions integration tests ────────────────────────────── #[test] fn update_room_dimensions_roundtrip() { let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); store.dispatch(Command::AddRoom(make_room("Bedroom", 4.0, 3.0, 20_000.0))); let events = store.dispatch(Command::UpdateRoomDimensions { index: 0, length: 6.0, width: 5.0, }); assert!(events .iter() .any(|e| matches!(e, DomainEvent::RoomUpdated(0)))); assert!(events .iter() .any(|e| matches!(e, DomainEvent::TotalsChanged))); let room = store.estimate.rooms.get(0).unwrap(); assert_eq!(room.length, 6.0); assert_eq!(room.width, 5.0); } #[test] fn update_room_dimensions_out_of_bounds_returns_no_events() { let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); let events = store.dispatch(Command::UpdateRoomDimensions { index: 0, length: 5.0, width: 4.0, }); assert!(events.is_empty()); let events = store.dispatch(Command::UpdateRoomDimensions { index: 999, length: 5.0, width: 4.0, }); assert!(events.is_empty()); } #[test] fn update_room_dimensions_zero_is_valid() { let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); store.dispatch(Command::AddRoom(make_room("Bedroom", 4.0, 3.0, 20_000.0))); store.dispatch(Command::UpdateRoomDimensions { index: 0, length: 0.0, width: 0.0, }); let room = store.estimate.rooms.get(0).unwrap(); assert_eq!(room.length, 0.0); assert_eq!(room.width, 0.0); } #[test] fn update_room_dimensions_negative_values_accepted() { let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); store.dispatch(Command::AddRoom(make_room("Bedroom", 4.0, 3.0, 20_000.0))); store.dispatch(Command::UpdateRoomDimensions { index: 0, length: -5.0, width: -3.0, }); let room = store.estimate.rooms.get(0).unwrap(); assert_eq!(room.length, -5.0); assert_eq!(room.width, -3.0); } #[test] fn update_room_dimensions_extreme_values() { let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); store.dispatch(Command::AddRoom(make_room("Bedroom", 4.0, 3.0, 20_000.0))); store.dispatch(Command::UpdateRoomDimensions { index: 0, length: f64::MAX, width: f64::MAX, }); let room = store.estimate.rooms.get(0).unwrap(); assert_eq!(room.length, f64::MAX); assert_eq!(room.width, f64::MAX); } #[test] fn update_room_dimensions_multiple_rooms_independently() { let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); store.dispatch(Command::AddRoom(make_room("Bedroom", 4.0, 3.0, 20_000.0))); store.dispatch(Command::AddRoom(make_room("Kitchen", 3.0, 3.0, 25_000.0))); store.dispatch(Command::AddRoom(make_room("Bathroom", 2.0, 2.0, 30_000.0))); store.dispatch(Command::UpdateRoomDimensions { index: 1, length: 5.0, width: 4.0, }); let bedroom = store.estimate.rooms.get(0).unwrap(); let kitchen = store.estimate.rooms.get(1).unwrap(); let bathroom = store.estimate.rooms.get(2).unwrap(); assert_eq!(bedroom.length, 4.0); assert_eq!(bedroom.width, 3.0); assert_eq!(kitchen.length, 5.0); assert_eq!(kitchen.width, 4.0); assert_eq!(bathroom.length, 2.0); assert_eq!(bathroom.width, 2.0); } // ── Undo/Redo integration tests ───────────────────────────────────────── #[test] fn undo_redo_update_room_dimensions() { let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); store.dispatch(Command::AddRoom(make_room("Bedroom", 4.0, 3.0, 20_000.0))); store.dispatch(Command::UpdateRoomDimensions { index: 0, length: 6.0, width: 5.0, }); assert_eq!(store.estimate.rooms.get(0).unwrap().length, 6.0); store.dispatch(Command::Undo); assert_eq!(store.estimate.rooms.get(0).unwrap().length, 4.0); assert_eq!(store.estimate.rooms.get(0).unwrap().width, 3.0); store.dispatch(Command::Redo); assert_eq!(store.estimate.rooms.get(0).unwrap().length, 6.0); assert_eq!(store.estimate.rooms.get(0).unwrap().width, 5.0); } #[test] fn undo_add_room_then_undo_update_dimensions() { let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); store.dispatch(Command::AddRoom(make_room("Bedroom", 4.0, 3.0, 20_000.0))); store.dispatch(Command::UpdateRoomDimensions { index: 0, length: 6.0, width: 5.0, }); store.dispatch(Command::Undo); assert_eq!(store.estimate.rooms.get(0).unwrap().length, 4.0); store.dispatch(Command::Undo); assert_eq!(store.estimate.rooms.len(), 0); store.dispatch(Command::Redo); assert_eq!(store.estimate.rooms.len(), 1); assert_eq!(store.estimate.rooms.get(0).unwrap().length, 4.0); store.dispatch(Command::Redo); assert_eq!(store.estimate.rooms.get(0).unwrap().length, 6.0); } #[test] fn new_mutation_after_undo_clears_redo() { let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); store.dispatch(Command::SetFloorCount(5)); store.dispatch(Command::Undo); assert!(store.can_redo()); store.dispatch(Command::SetFloorCount(10)); assert!(!store.can_redo()); assert_eq!(store.estimate.num_floors, 10); store.dispatch(Command::Undo); assert_eq!(store.estimate.num_floors, 1); } #[test] fn undo_empty_stack_returns_empty_events() { let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); let events = store.dispatch(Command::Undo); assert!(events.is_empty()); } #[test] fn redo_empty_stack_returns_empty_events() { let mut store = EstimateStore::new(); store.dispatch(Command::Initialize); let events = store.dispatch(Command::Redo); assert!(events.is_empty()); }