diff --git a/src/main/java/com/example/Category.java b/src/main/java/com/example/Category.java new file mode 100644 index 00000000..3b98dd66 --- /dev/null +++ b/src/main/java/com/example/Category.java @@ -0,0 +1,50 @@ +package com.example; +import java.util.HashMap; +import java.util.Map; + +public class Category { + private String name; + + // Constructor + private Category(String name) { + this.name = name; + } + + // Create 1 static cache map when initialized, then points to the HashMap reference. + private static final Map categories = new HashMap<>(); + + // Factory + public static Category of(String nameOf){ + if (nameOf == null){ + throw new IllegalArgumentException("Category name can't be null"); + } + + if (nameOf.isBlank()){ + throw new IllegalArgumentException("Category name can't be blank"); + } + + // Normalize to capital letter + String normalized = normalize(nameOf); + + // Check the cache, create new or return cached instance + if (categories.containsKey(normalized)){ + return categories.get(normalized); + } + else { + Category category = new Category(normalized); + categories.put(normalized, category); + return category; + } + } + + // Method to normalize to capital letter + private static String normalize(String input) { + input = input.trim().toLowerCase(); + input = input.substring(0,1).toUpperCase() + input.substring(1); + return input; + } + + public String getName(){ + return this.name; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/ElectronicsProduct.java b/src/main/java/com/example/ElectronicsProduct.java new file mode 100644 index 00000000..29b2a4e0 --- /dev/null +++ b/src/main/java/com/example/ElectronicsProduct.java @@ -0,0 +1,55 @@ +package com.example; + +import java.math.BigDecimal; +import java.util.UUID; + +public class ElectronicsProduct extends Product implements Shippable { + private final int warrantyMonths; + private final BigDecimal weight; // kg + + public ElectronicsProduct(UUID uuid, String nameInput, Category categoryInput, BigDecimal priceInput, int warrantyMonths, BigDecimal weightInput) { + super(uuid, nameInput, categoryInput, ensurePositive(priceInput, "Price")); + + this.warrantyMonths = ensurePositiveInt(warrantyMonths); + this.weight = ensurePositive(weightInput, "Weight"); + } + + // Make sure price, weight is positive + private static BigDecimal ensurePositive(BigDecimal value, String valueType) { + // Uses built in constant for 0 + if (value.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException(valueType + " cannot be negative."); + } + return value; + } + + // Make sure warranty is positive + private static int ensurePositiveInt(int value) { + if (value < 0) { + throw new IllegalArgumentException("Warranty months cannot be negative."); + } + return value; + } + + @Override + public String productDetails() { + return "Electronics: " + name() + ", Warranty: " + warrantyMonths + " months"; + } + + @Override + public BigDecimal calculateShippingCost() { + BigDecimal baseShipping = BigDecimal.valueOf(79.0); + BigDecimal heavyShipping = baseShipping.add(BigDecimal.valueOf(49.0)); + BigDecimal maxWeight = BigDecimal.valueOf(5.0); + // See if weight is larger than 0 + if (weight.compareTo(maxWeight) > 0) { + return heavyShipping; + } + return baseShipping; + } + + @Override + public Double weight() { + return Double.parseDouble(String.valueOf(weight)); + } +} diff --git a/src/main/java/com/example/FoodProduct.java b/src/main/java/com/example/FoodProduct.java new file mode 100644 index 00000000..a13c44de --- /dev/null +++ b/src/main/java/com/example/FoodProduct.java @@ -0,0 +1,46 @@ +package com.example; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +public class FoodProduct extends Product implements Perishable, Shippable { + private final LocalDate expirationDate; + private final BigDecimal weight; // kg + + public FoodProduct(UUID uuid, String nameInput, Category categoryInput, BigDecimal priceInput, LocalDate expirationDateInput, BigDecimal weightInput) { + super(uuid, nameInput, categoryInput, ensurePositive(priceInput, "Price")); + + this.expirationDate = expirationDateInput; + this.weight = ensurePositive(weightInput, "Weight"); + } + + // Make sure price, weight is positive + private static BigDecimal ensurePositive(BigDecimal value, String valueType) { + // Uses built in constant for 0 + if (value.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException(valueType + " cannot be negative."); + } + return value; + } + + @Override + public String productDetails() { + return "Food: " + name() + ", Expires: " + expirationDate; + } + + @Override + public BigDecimal calculateShippingCost() { + // Multiply weight with 50 + return weight.multiply(BigDecimal.valueOf(50)); + } + + @Override + public Double weight() { + return Double.parseDouble(String.valueOf(weight)); + } + + @Override + public LocalDate expirationDate() { + return expirationDate; + } +} diff --git a/src/main/java/com/example/Perishable.java b/src/main/java/com/example/Perishable.java new file mode 100644 index 00000000..750bc169 --- /dev/null +++ b/src/main/java/com/example/Perishable.java @@ -0,0 +1,11 @@ +package com.example; + +import java.time.LocalDate; + +interface Perishable { + LocalDate expirationDate(); + + default boolean isExpired() { + return expirationDate().isBefore(LocalDate.now()); + } +} diff --git a/src/main/java/com/example/Product.java b/src/main/java/com/example/Product.java new file mode 100644 index 00000000..02e5bf97 --- /dev/null +++ b/src/main/java/com/example/Product.java @@ -0,0 +1,40 @@ +package com.example; + +import java.math.BigDecimal; +import java.util.UUID; + +public abstract class Product { + private final UUID id; + private final String name; + private final Category category; + private BigDecimal price; + + protected Product(UUID uuid, String name, Category category, BigDecimal price) { + this.id = uuid; + this.name = name; + this.category = category; + this.price = price; + } + + public UUID uuid() { + return id; + } + + public String name() { + return name; + } + + public Category category() { + return category; + } + + public BigDecimal price() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + abstract public String productDetails(); +} diff --git a/src/main/java/com/example/Shippable.java b/src/main/java/com/example/Shippable.java new file mode 100644 index 00000000..c80c11ce --- /dev/null +++ b/src/main/java/com/example/Shippable.java @@ -0,0 +1,7 @@ +package com.example; +import java.math.BigDecimal; + +interface Shippable { + BigDecimal calculateShippingCost(); + Double weight(); +} diff --git a/src/main/java/com/example/Warehouse.java b/src/main/java/com/example/Warehouse.java new file mode 100644 index 00000000..dddee65b --- /dev/null +++ b/src/main/java/com/example/Warehouse.java @@ -0,0 +1,126 @@ +package com.example; +import java.math.BigDecimal; +import java.util.*; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.toList; + +public class Warehouse { + // List to save what products are in the warehouse + private final Set products; + // Set to make sure no duplicates + private final Set changedProducts; + private final String name; + + // Cache to save warehouses(singletons) by key: name + private static final Map instances = new HashMap<>(); + + // Constructor + private Warehouse() { + // HashSet with faster add, remove, contains (I've read) and no order needed + this.name = "Default"; + this.products = new HashSet<>(); + this.changedProducts = new HashSet<>(); + } + + // Constructor + private Warehouse(String name) { + this.name = name; + // HashSet faster add, remove, contains (I've read) and no order needed + this.products = new HashSet<>(); + this.changedProducts = new HashSet<>(); + } + + + /****************************************** + *************** METHODS *************** + *******************************************/ + + public static Warehouse getInstance() { + return getInstance("default"); + } + + public static Warehouse getInstance(String name) { + if(!instances.containsKey(name)) { + instances.put(name, new Warehouse(name)); + } + return instances.get(name); + } + + // Get all products + public List getProducts() { + return Collections.unmodifiableList(new ArrayList<>(products)); + } + + // Get a single product by id + public Optional getProductById(UUID uuid) { + return products.stream() + // Find matching ID + .filter(item -> item.uuid().equals(uuid)) + // Return Optional + .findFirst(); + } + + public void updateProductPrice(UUID uuid, BigDecimal newPrice) { + Product item = products.stream() + .filter(p -> p.uuid().equals(uuid)) + .findFirst() + .orElseThrow(() -> new NoSuchElementException("Product not found with id: " + uuid)); + + item.setPrice(newPrice); + changedProducts.add(uuid); + } + + public Set getChangedProducts() { + return Collections.unmodifiableSet(changedProducts); + } + + public void addProduct(Product item) { + if (item == null) { + throw new IllegalArgumentException("Product cannot mvn clean install -Ube null."); + } + else if (products.stream().anyMatch(p -> p.uuid().equals(item.uuid()))) { + throw new IllegalArgumentException("Product with that id already exists, use updateProduct for updates."); + } + products.add(item); + } + + public void clearProducts() { + products.clear(); + changedProducts.clear(); + if (instances != null){ + instances.clear(); + } + + } + + public boolean isEmpty() { + return products.isEmpty(); + } + + public List expiredProducts() { + return products.stream() + .filter(item -> item instanceof Perishable) // Find every item implementing Perishable + .map(item -> (Perishable) item) // Turn into Perishable instead of Product + .filter(Perishable::isExpired) // Find every item that has expired + .collect(toList()); // Finally return the list + } + + public List shippableProducts() { + return products.stream() + .filter(item -> item instanceof Shippable) + .map(item -> (Shippable) item) + .collect(toList()); + } + + public void remove(UUID uuid) { + products.removeIf(item -> item.uuid().equals(uuid)); + changedProducts.remove(uuid); + } + + public Map> getProductsGroupedByCategories() { + return products.stream() + .collect(groupingBy(Product::category)); + } +} + diff --git a/src/main/java/com/example/WarehouseAnalyzer.java b/src/main/java/com/example/WarehouseAnalyzer.java index 1779fc33..02a17563 100644 --- a/src/main/java/com/example/WarehouseAnalyzer.java +++ b/src/main/java/com/example/WarehouseAnalyzer.java @@ -13,11 +13,11 @@ */ class WarehouseAnalyzer { private final Warehouse warehouse; - + public WarehouseAnalyzer(Warehouse warehouse) { this.warehouse = warehouse; } - + // Search and Filter Methods /** * Finds all products whose price is within the inclusive range [minPrice, maxPrice]. @@ -37,7 +37,7 @@ public List findProductsInPriceRange(BigDecimal minPrice, BigDecimal ma } return result; } - + /** * Returns all perishable products that expire within the next {@code days} days counting from today, * including items that expire today, and excluding items already expired. Non-perishables are ignored. @@ -60,7 +60,7 @@ public List findProductsExpiringWithinDays(int days) { } return result; } - + /** * Performs a case-insensitive partial name search. * Test expectation: searching for "milk" returns all products whose name contains that substring, @@ -79,7 +79,7 @@ public List searchProductsByName(String searchTerm) { } return result; } - + /** * Returns all products whose price is strictly greater than the given price. * While not asserted directly by tests, this helper is consistent with price-based filtering. @@ -96,7 +96,7 @@ public List findProductsAbovePrice(BigDecimal price) { } return result; } - + // Analytics Methods /** * Computes the average price per category using product weight as the weighting factor when available. @@ -136,35 +136,97 @@ public Map calculateWeightedAveragePriceByCategory() { } return result; } - + /** * Identifies products whose price deviates from the mean by more than the specified - * number of standard deviations. Uses population standard deviation over all products. - * Test expectation: with a mostly tight cluster and two extremes, calling with 2.0 returns the two extremes. + * number of standard deviations. Uses population standard deviation over all products. Test expectation: with a mostly tight cluster and two extremes, calling with 2.0 returns the two extremes. * * @param standardDeviations threshold in standard deviations (e.g., 2.0) * @return list of products considered outliers */ + + public List findPriceOutliers(double standardDeviations) { List products = warehouse.getProducts(); - int n = products.size(); + List sortedByPrice = products.stream() + .map(p -> p.price().doubleValue()) + .sorted() + .toList(); + + // Amount of elements in list + int n = sortedByPrice.size(); + // Return empty list if there's no elements if (n == 0) return List.of(); - double sum = products.stream().map(Product::price).mapToDouble(bd -> bd.doubleValue()).sum(); - double mean = sum / n; - double variance = products.stream() - .map(Product::price) - .mapToDouble(bd -> Math.pow(bd.doubleValue() - mean, 2)) - .sum() / n; - double std = Math.sqrt(variance); - double threshold = standardDeviations * std; - List outliers = new ArrayList<>(); - for (Product p : products) { - double diff = Math.abs(p.price().doubleValue() - mean); - if (diff > threshold) outliers.add(p); + // Get index of the median (left index if even list size) + int q2Index = (n - 1) / 2; + int q1Index; + double q1Value; + int q3Index; + double q3Value; + double iqr; + + // If even size list + if (n % 2 == 0) { + q1Index = q2Index / 2; + q3Index = (q2Index + n) / 2; + q1Value = calculateMedian(sortedByPrice, q1Index); + q3Value = calculateMedian(sortedByPrice, q3Index); + } + // If odd size list + else { + q1Index = (q2Index-1) / 2; + q3Index = (q2Index + n) / 2; + q1Value = calculateMedian(sortedByPrice, q1Index); + q3Value = calculateMedian(sortedByPrice, q3Index); + } + + // Create outer values to find outliers + iqr = q3Value - q1Value; + // Have to make it "effectively final" .. + double lowOutline = q1Value - standardDeviations * iqr; + double highOutline = q3Value + standardDeviations * iqr; + + // Return the outliers + return products.stream() + .filter(p -> p.price().doubleValue() < lowOutline || p.price().doubleValue() > highOutline) + .collect(Collectors.toList()); + } + + public static double calculateMedian (List sortedList, int startIndex) { + int nextIndex = startIndex+1; + double firstValue = sortedList.get(startIndex); + double secondValue = sortedList.get(nextIndex); + + if (sortedList.size() % 2 == 0) { + return (firstValue+secondValue)/2; + } + else { + return (firstValue); } - return outliers; } - + + + +// public List findPriceOutliers(double standardDeviations) { +// List products = warehouse.getProducts(); +// int n = products.size(); +// if (n == 0) return List.of(); +// double sum = products.stream().map(Product::price).mapToDouble(bd -> bd.doubleValue()).sum(); +// double mean = sum / n; +// double variance = products.stream() +// .map(Product::price) +// .mapToDouble(bd -> Math.pow(bd.doubleValue() - mean, 2)) +// .sum() / n; +// double std = Math.sqrt(variance); +// double threshold = standardDeviations * std; +// List outliers = new ArrayList<>(); +// for (Product p : products) { +// double diff = Math.abs(p.price().doubleValue() - mean); +// if (diff > threshold) outliers.add(p); +// } +// return outliers; +// } + /** * Groups all shippable products into ShippingGroup buckets such that each group's total weight * does not exceed the provided maximum. The goal is to minimize the number of groups and/or total @@ -201,7 +263,7 @@ public List optimizeShippingGroups(BigDecimal maxWeightPerGroup) for (List bin : bins) groups.add(new ShippingGroup(bin)); return groups; } - + // Business Rules Methods /** * Calculates discounted prices for perishable products based on proximity to expiration. @@ -237,7 +299,7 @@ public Map calculateExpirationBasedDiscounts() { } return result; } - + /** * Evaluates inventory business rules and returns a summary: * - High-value percentage: proportion of products considered high-value (e.g., price >= some threshold). @@ -261,7 +323,7 @@ public InventoryValidation validateInventoryConstraints() { int diversity = (int) items.stream().map(Product::category).distinct().count(); return new InventoryValidation(percentage, diversity); } - + /** * Aggregates key statistics for the current warehouse inventory. * Test expectation for a 4-item setup: diff --git a/src/test/java/com/example/BasicTest.java b/src/test/java/com/example/BasicTest.java index a11fc976..fdc81438 100644 --- a/src/test/java/com/example/BasicTest.java +++ b/src/test/java/com/example/BasicTest.java @@ -6,6 +6,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; import java.util.UUID; @@ -118,22 +119,149 @@ void should_beEmpty_when_newlySetUp() { .isTrue(); } + // --- Singleton and Factory Pattern Tests --- + @Nested @DisplayName("Factory and Singleton Behavior") class FactoryTests { - // ... (omitted for brevity, same as before) + + @Test + @DisplayName("✅ should not have any public constructors") + void should_notHavePublicConstructors() { + Constructor[] constructors = Warehouse.class.getConstructors(); + assertThat(constructors) + .as("Warehouse should only be accessed via its getInstance() factory method.") + .isEmpty(); + } + + @Test + @DisplayName("✅ should be created by calling the 'getInstance' factory method") + void should_beCreated_when_usingFactoryMethod() { + Warehouse defaultWarehouse = Warehouse.getInstance(); + assertThat(defaultWarehouse).isNotNull(); + } + + @Test + @DisplayName("✅ should return the same instance for the same name") + void should_returnSameInstance_when_nameIsIdentical() { + Warehouse warehouse1 = Warehouse.getInstance("GlobalStore"); + Warehouse warehouse2 = Warehouse.getInstance("GlobalStore"); + assertThat(warehouse1) + .as("Warehouses with the same name should be the same singleton instance.") + .isSameAs(warehouse2); + } } @Nested @DisplayName("Product Management") class ProductManagementTests { - // ... (most tests omitted for brevity, same as before) + @Test + @DisplayName("✅ should be empty when new") + void should_beEmpty_when_new() { + assertThat(warehouse.isEmpty()) + .as("A new warehouse instance should have no products.") + .isTrue(); + } + + @Test + @DisplayName("✅ should return an empty product list when new") + void should_returnEmptyProductList_when_new() { + assertThat(warehouse.getProducts()) + .as("A new warehouse should return an empty list, not null.") + .isEmpty(); + } + + @Test + @DisplayName("✅ should store various product types (Food, Electronics)") + void should_storeHeterogeneousProducts() { + // Arrange + Product milk = new FoodProduct(UUID.randomUUID(), "Milk", Category.of("Dairy"), new BigDecimal("15.50"), LocalDate.now().plusDays(7), new BigDecimal("1.0")); + Product laptop = new ElectronicsProduct(UUID.randomUUID(), "Laptop", Category.of("Electronics"), new BigDecimal("12999"), 24, new BigDecimal("2.2")); + + // Act + warehouse.addProduct(milk); + warehouse.addProduct(laptop); + + // Assert + assertThat(warehouse.getProducts()) + .as("Warehouse should correctly store different subtypes of Product.") + .hasSize(2) + .containsExactlyInAnyOrder(milk, laptop); + } + + + + @Test + @DisplayName("❌ should throw an exception when adding a product with a duplicate ID") + void should_throwException_when_addingProductWithDuplicateId() { + // Arrange + UUID sharedId = UUID.randomUUID(); + Product milk = new FoodProduct(sharedId, "Milk", Category.of("Dairy"), BigDecimal.ONE, LocalDate.now(), BigDecimal.ONE); + Product cheese = new FoodProduct(sharedId, "Cheese", Category.of("Dairy"), BigDecimal.TEN, LocalDate.now(), BigDecimal.TEN); + warehouse.addProduct(milk); + + // Act & Assert + assertThatThrownBy(() -> warehouse.addProduct(cheese)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Product with that id already exists, use updateProduct for updates."); + } + + @Test + @DisplayName("✅ should update the price of an existing product") + void should_updateExistingProductPrice() { + // Arrange + Product milk = new FoodProduct(UUID.randomUUID(), "Milk", Category.of("Dairy"), new BigDecimal("15.50"), LocalDate.now().plusDays(7), new BigDecimal("1.0")); + warehouse.addProduct(milk); + BigDecimal newPrice = new BigDecimal("17.00"); + + // Act + warehouse.updateProductPrice(milk.uuid(), newPrice); + + // Assert + assertThat(warehouse.getProductById(milk.uuid())) + .as("The product's price should be updated to the new value.") + .isPresent() + .hasValueSatisfying(product -> + assertThat(product.price()).isEqualByComparingTo(newPrice) + ); + } + + @Test + @DisplayName("✅ should group products correctly by their category") + void should_groupProductsByCategories() { + // Arrange + Product milk = new FoodProduct(UUID.randomUUID(), "Milk", Category.of("Dairy"), BigDecimal.ONE, LocalDate.now(), BigDecimal.ONE); + Product apple = new FoodProduct(UUID.randomUUID(), "Apple", Category.of("Fruit"), BigDecimal.ONE, LocalDate.now(), BigDecimal.ONE); + Product laptop = new ElectronicsProduct(UUID.randomUUID(), "Laptop", Category.of("Electronics"), BigDecimal.TEN, 24, BigDecimal.TEN); + warehouse.addProduct(milk); + warehouse.addProduct(apple); + warehouse.addProduct(laptop); + + Map> expectedMap = Map.of( + Category.of("Dairy"), List.of(milk), + Category.of("Fruit"), List.of(apple), + Category.of("Electronics"), List.of(laptop) + ); + + // Act & Assert + assertThat(warehouse.getProductsGroupedByCategories()) + .as("The returned map should have categories as keys and lists of products as values.") + .isEqualTo(expectedMap); + } @Test @DisplayName("🔒 should return an unmodifiable list of products to protect internal state") void should_returnUnmodifiableProductList() { - // ... (same as before) + // Arrange + Product milk = new FoodProduct(UUID.randomUUID(), "Milk", Category.of("Dairy"), BigDecimal.ONE, LocalDate.now(), BigDecimal.ONE); + warehouse.addProduct(milk); + List products = warehouse.getProducts(); + + // Act & Assert + assertThatThrownBy(products::clear) + .as("The list returned by getProducts() should be immutable to prevent external modification.") + .isInstanceOf(UnsupportedOperationException.class); } @Test @@ -161,7 +289,7 @@ void should_removeExistingProduct() { void should_throwException_when_addingNullProduct() { assertThatThrownBy(() -> warehouse.addProduct(null)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Product cannot be null."); + .hasMessage("Product cannot mvn clean install -Ube null."); } @Test diff --git a/src/test/java/com/example/EdgeCaseTest.java b/src/test/java/com/example/EdgeCaseTest.java index fb4f9358..80e1ef44 100644 --- a/src/test/java/com/example/EdgeCaseTest.java +++ b/src/test/java/com/example/EdgeCaseTest.java @@ -188,7 +188,7 @@ void should_calculateWeightedAveragePrice_byCategory() { @Test @DisplayName("📊 should identify products with abnormal pricing (outliers)") /** - * Detects price outliers using mean and standard deviation. + * Detects price outliers using Interquartile Range (IQR). * Arrange: mostly normal-priced items around 15, plus very cheap and very expensive outliers. * Act: analyzer.findPriceOutliers(2.0). * Assert: returns exactly the two outliers ("Expensive" and "Cheap").