nigig-org/crates/apps/nigig-build/tests/cost_estimator_ui.rs
2026-07-26 19:38:26 +03:00

605 lines
18 KiB
Rust

//! UI integration tests for the cost estimator widget.
//!
//! Full widget rendering tests require the makepad test harness (window + event loop).
//! See the placeholder comment block in cad_ui.rs for framework details.
//!
//! These tests exercise data logic that backs the widget without requiring
//! a makepad App context. They validate StoreyType classification, room list
//! population, dropdown option generation, modal data, and cost formatting.
use nigig_build::construction_frame::pages::workspace::cost_estimator::data_raw::{
format_cost, get_building_data, BuildingCategory, Room,
};
use nigig_build::construction_frame::pages::workspace::cost_estimator::room_controller::{
build_size_bands_lookup, get_rooms_by_category, lookup_rate,
};
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),
..Default::default()
}
}
// ── Dropdown option generation ────────────────────────────────────────
#[test]
fn dropdown_options_for_residential() {
let data = get_building_data();
let regions: Vec<String> = data
.values()
.flat_map(|list| list.iter())
.flat_map(|bd| bd.regions.iter())
.map(|r| r.name.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
assert!(!regions.is_empty(), "Should have at least one region");
let categories = [
BuildingCategory::Residential,
BuildingCategory::Commercial,
BuildingCategory::Industrial,
];
for cat in &categories {
let rooms =
get_rooms_by_category(&data, cat, regions.first().map(|s| s.as_str()), false, None);
let _ = rooms;
}
}
#[test]
fn all_categories_have_at_least_one_region() {
let data = get_building_data();
let categories = [
BuildingCategory::Residential,
BuildingCategory::Commercial,
BuildingCategory::Retail,
BuildingCategory::Industrial,
];
for cat in &categories {
if let Some(entries) = data.get(cat) {
let regions: Vec<String> = entries
.iter()
.flat_map(|e| e.regions.iter())
.map(|r| r.name.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
if !regions.is_empty() {
assert!(
regions.len() >= 1,
"Category {:?} should have at least 1 region",
cat
);
}
}
}
}
#[test]
fn dropdown_options_for_all_categories_with_regions() {
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 property_types: Vec<String> = rooms
.iter()
.map(|r| r.room_type.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
assert!(
property_types.len() >= 1,
"Category {:?} should have at least 1 room type",
cat
);
}
}
}
// ── StoreyType UI labels match enum ───────────────────────────────────
#[test]
fn storey_type_ui_labels_match_enum() {
let options = ["Non-Storey", "Low Rise", "High Rise"];
for (i, expected) in options.iter().enumerate() {
assert_eq!(StoreyType::from_index(i).as_label(), *expected);
}
}
#[test]
fn storey_type_all_variants_have_labels() {
let variants = [
StoreyType::NonStorey,
StoreyType::LowRise,
StoreyType::HighRise,
];
for v in &variants {
let label = v.as_label();
assert!(!label.is_empty(), "StoreyType label should not be empty");
}
}
#[test]
fn storey_type_default_floors_consistent() {
assert!(StoreyType::NonStorey.default_floors() >= 1);
assert!(StoreyType::LowRise.default_floors() >= 2);
assert!(StoreyType::HighRise.default_floors() >= 5);
}
#[test]
fn storey_type_shows_floor_input_consistent() {
assert!(!StoreyType::NonStorey.shows_floor_input());
assert!(StoreyType::LowRise.shows_floor_input());
assert!(StoreyType::HighRise.shows_floor_input());
}
// ── Room list population ──────────────────────────────────────────────
#[test]
fn room_list_populated_from_data() {
let data = get_building_data();
let rooms = get_rooms_by_category(
&data,
&BuildingCategory::Residential,
Some("Nairobi"),
true,
Some("Standard Bungalow"),
);
let mut store = RoomStore::new();
for room in rooms {
store.add(room);
}
assert!(store.len() > 0);
store.rebuild_display_indices();
assert_eq!(store.displayed_indices().len(), store.len());
}
#[test]
fn room_list_rooms_have_positive_dimensions() {
let data = get_building_data();
let rooms = get_rooms_by_category(
&data,
&BuildingCategory::Residential,
Some("Nairobi"),
false,
Some("Standard Bungalow"),
);
for room in &rooms {
assert!(
room.length >= 0.0,
"Room '{}' length should be non-negative",
room.room_type
);
assert!(
room.width >= 0.0,
"Room '{}' width should be non-negative",
room.room_type
);
}
}
#[test]
fn room_list_for_commercial_category() {
let data = get_building_data();
let rooms = get_rooms_by_category(
&data,
&BuildingCategory::Commercial,
None,
false,
None,
);
if !rooms.is_empty() {
let mut store = RoomStore::new();
for room in rooms {
store.add(room);
}
assert!(store.len() > 0);
assert!(store.total_area() >= 0.0);
}
}
// ── Cost preview formatting ───────────────────────────────────────────
#[test]
fn cost_preview_formats_correctly() {
let preview = format_cost(2_500_000.0);
assert_eq!(preview, "2,500,000.00");
let preview = format_cost(99.9);
assert_eq!(preview, "99.90");
}
#[test]
fn cost_preview_format_zero() {
assert_eq!(format_cost(0.0), "0.00");
}
#[test]
fn cost_preview_format_small() {
assert_eq!(format_cost(42.5), "42.50");
}
#[test]
fn cost_preview_format_large() {
assert_eq!(format_cost(99_999_999.99), "99,999,999.99");
}
#[test]
fn cost_preview_format_negative() {
assert_eq!(format_cost(-1234.56), "-1,234.56");
}
#[test]
fn cost_preview_format_with_commas() {
assert_eq!(format_cost(1_234_567.89), "1,234,567.89");
}
// ── Modal room type list ──────────────────────────────────────────────
#[test]
fn modal_room_type_list_matches_data() {
use std::collections::HashSet;
let data = get_building_data();
let room_types: HashSet<String> = data
.values()
.flat_map(|list| list.iter())
.flat_map(|bd| bd.default_rooms.iter())
.flat_map(|dr| dr.rooms.iter())
.map(|r| r.room_type.clone())
.collect();
assert!(room_types.contains("Bedroom"));
assert!(
room_types.len() >= 3,
"Should have several distinct room types"
);
}
#[test]
fn modal_room_types_have_default_dimensions() {
let data = get_building_data();
let residential = data.get(&BuildingCategory::Residential).unwrap();
for entry in residential {
for pdr in &entry.default_rooms {
for room in &pdr.rooms {
if room.length > 0.0 && room.width > 0.0 {
assert!(
room.length > 0.0 && room.width > 0.0,
"Room '{}' should have positive dimensions if populated",
room.room_type
);
}
}
}
}
}
#[test]
fn modal_has_size_bands_for_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);
let mut room_types_with_bands = 0;
for (room_type, bands) in &lookup {
if !bands.is_empty() {
room_types_with_bands += 1;
assert!(
bands.len() >= 2,
"Room '{}' should have at least 2 size bands",
room_type
);
}
}
assert!(
room_types_with_bands >= 1,
"At least 1 room type should have size bands"
);
}
// ── Storey classification for property types ──────────────────────────
#[test]
fn classify_property_standard_variants() {
let high_rise_inputs = [
"Apartment Block",
"Office Tower",
"High Rise",
"Skyscraper",
"Multi-Storey",
];
for input in &high_rise_inputs {
assert_eq!(
StoreyType::classify_property(input),
StoreyType::HighRise,
"'{}' should classify as HighRise",
input
);
}
let low_rise_inputs = [
"Maisonette",
"Townhouse",
"Duplex",
"Two Storey",
"Terrace House",
];
for input in &low_rise_inputs {
assert_eq!(
StoreyType::classify_property(input),
StoreyType::LowRise,
"'{}' should classify as LowRise",
input
);
}
let non_storey_inputs = ["Standard Bungalow", "Cottage", "Villa", "Studio"];
for input in &non_storey_inputs {
assert_eq!(
StoreyType::classify_property(input),
StoreyType::NonStorey,
"'{}' should classify as NonStorey",
input
);
}
}
#[test]
fn classify_property_case_insensitive() {
assert_eq!(
StoreyType::classify_property("APARTMENT BLOCK"),
StoreyType::HighRise
);
assert_eq!(
StoreyType::classify_property("maisonette"),
StoreyType::LowRise
);
assert_eq!(
StoreyType::classify_property("BOULANGERIE"),
StoreyType::NonStorey
);
}
#[test]
fn classify_property_empty_string() {
assert_eq!(
StoreyType::classify_property(""),
StoreyType::NonStorey
);
}
// ── Region + property type filtering ──────────────────────────────────
#[test]
fn property_types_by_region_consistency() {
let data = get_building_data();
let categories = [
BuildingCategory::Residential,
BuildingCategory::Commercial,
BuildingCategory::Retail,
BuildingCategory::Industrial,
];
for cat in &categories {
if let Some(entries) = data.get(cat) {
for entry in entries {
for region in &entry.regions {
let rooms = get_rooms_by_category(
&data,
cat,
Some(&region.name),
false,
None,
);
if !rooms.is_empty() {
let mut store = RoomStore::new();
for room in rooms {
store.add(room);
}
assert!(store.total_area() >= 0.0);
}
}
}
}
}
}
// ── Rate lookup edge cases ────────────────────────────────────────────
#[test]
fn lookup_rate_nonexistent_region() {
let data = get_building_data();
let rate = lookup_rate(
&data,
&BuildingCategory::Residential,
"NonexistentRegion",
"Standard Bungalow",
);
assert!(rate.is_none());
}
#[test]
fn lookup_rate_nonexistent_property() {
let data = get_building_data();
let rate = lookup_rate(
&data,
&BuildingCategory::Residential,
"Nairobi",
"NonexistentProperty",
);
assert!(rate.is_none());
}
#[test]
fn lookup_rate_all_categories() {
let data = get_building_data();
let categories = [
BuildingCategory::Residential,
BuildingCategory::Commercial,
BuildingCategory::Retail,
BuildingCategory::Industrial,
];
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 rate = lookup_rate(
&data,
cat,
&region.name,
&prop.property_type,
);
if let Some(r) = rate {
assert!(r > 0.0);
}
}
}
}
}
}
}
}
// ── Room store + classification integration ───────────────────────────
#[test]
fn room_store_with_classified_rooms() {
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("Bathroom", 2.0, 2.0, 30_000.0));
let total_area = store.total_area();
let total_cost = store.total_cost();
assert!((total_area - 25.0).abs() < f64::EPSILON);
assert_eq!(total_cost, 240_000.0 + 225_000.0 + 120_000.0);
}
#[test]
fn store_rebuild_display_indices_after_multiple_operations() {
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));
store.add(make_room("D", 4.0, 4.0, 400.0));
store.remove(1); // remove B
store.remove(1); // remove C (was at index 2, now at 1)
store.rebuild_display_indices();
assert_eq!(store.displayed_indices().len(), 2);
assert_eq!(store.get(0).unwrap().room_type, "A");
assert_eq!(store.get(1).unwrap().room_type, "D");
}
// ── Data completeness checks ──────────────────────────────────────────
#[test]
fn building_data_json_completeness() {
let data = get_building_data();
let mut total_entries = 0;
let mut total_regions = 0;
for (cat, entries) in &data {
total_entries += entries.len();
for entry in entries {
total_regions += entry.regions.len();
assert!(
!entry.room_sizes.is_empty(),
"Entry for {:?} should have room_sizes",
cat
);
assert!(
!entry.default_rooms.is_empty(),
"Entry for {:?} should have default_rooms",
cat
);
assert!(
!entry.meta.currency.is_empty(),
"Entry for {:?} should have currency",
cat
);
}
}
assert!(total_entries > 0, "Should have building data entries");
assert!(total_regions > 0, "Should have regions");
}
#[test]
fn all_regions_have_building_types() {
let data = get_building_data();
for (_cat, entries) in &data {
for entry in entries {
for region in &entry.regions {
assert!(
!region.building_types.is_empty(),
"Region '{}' should have building types",
region.name
);
for bt in &region.building_types {
assert!(
!bt.properties.is_empty(),
"Building type '{}' in region '{}' should have properties",
bt.category,
region.name
);
}
}
}
}
}
#[test]
fn property_costs_are_consistent() {
let data = get_building_data();
for (_cat, entries) in &data {
for entry in entries {
for region in &entry.regions {
for bt in &region.building_types {
for prop in &bt.properties {
if let Some(m2_cost) = prop.cost_per_square_metre {
assert!(m2_cost > 0, "cost_per_square_metre for '{}' should be > 0", prop.property_type);
}
if let Some(ft2_cost) = prop.cost_per_square_foot {
assert!(ft2_cost > 0, "cost_per_square_foot for '{}' should be > 0", prop.property_type);
}
}
}
}
}
}
}
// ── StoreyType default clone ──────────────────────────────────────────
#[test]
fn storey_type_clone_and_default() {
let st = StoreyType::default();
assert_eq!(st, StoreyType::NonStorey);
let cloned = StoreyType::HighRise;
assert_eq!(cloned, StoreyType::HighRise);
}