nigig-org/crates/apps/nigig-build/tests/cost_estimator.rs
andodeki a739acb85e
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
phase 4: eliminate wasteful .clone() calls via Copy enums and targeted field construction
2026-07-28 20:20:32 +03:00

1277 lines
41 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<f64>,
cost: Option<f64>,
storey_type: &StoreyType,
num_floors: u32,
) -> (Option<f64>, Option<f64>) {
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 &region.building_types {
for prop in &bt.properties {
let rate = lookup_rate(&data, cat, &region.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<f64>,
cost: Option<f64>,
) -> 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 &region.building_types {
for prop in &bt.properties {
let (area, cost) = calculate_property_cost(
entry,
&prop.property_type,
&region.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 &region.building_types {
for prop in &bt.properties {
let data_rate = services::compute_rate(
entry,
&region.name,
&bt.category,
&prop.property_type,
);
let index_rate = services::compute_rate_via_index(
&idx,
&bt.category,
&region.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());
}