605 lines
18 KiB
Rust
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(®ion.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 ®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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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 ®ion.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 ®ion.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);
|
|
}
|