diff --git a/pom.xml b/pom.xml
index ff79ddbb..8949bea2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -60,6 +60,15 @@
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ 25
+ 25
+ --enable-preview
+
+
diff --git a/src/main/java/com/example/WarehouseAnalyzer.java b/src/main/java/com/example/WarehouseAnalyzer.java
index 1779fc33..fe14c2a9 100644
--- a/src/main/java/com/example/WarehouseAnalyzer.java
+++ b/src/main/java/com/example/WarehouseAnalyzer.java
@@ -1,7 +1,8 @@
package com.example;
+import com.example.warehouse.*;
+
import java.math.BigDecimal;
-import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.*;
@@ -138,33 +139,85 @@ public Map calculateWeightedAveragePriceByCategory() {
}
/**
- * 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.
+ * Identifies products in Warehouse whose price deviates from the median by using the
+ * InterQuartile Range(IQR) method,a robust method that is used to identify outliners when the data is distorted.
+ * Outliers are defined as any price outside the range: [Q1 - 1.5 * IQR, Q3 + 1.5 * IQR].
+ * Test expectation: with a mostly tight cluster and two extremes, calling with 1.5 returns the two extremes.
*
- * @param standardDeviations threshold in standard deviations (e.g., 2.0)
+ * @param thresholdFactor threshold factor (e.g., 1.5). The value is used as
+ * the multiplier in the IQR boundary calculation. (NOTE: 1.5 is the standard factor for the IQR method.)
* @return list of products considered outliers
*/
- public List findPriceOutliers(double standardDeviations) {
+ public List findPriceOutliers(double thresholdFactor) {
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;
+ final int n = products.size();
+ // Edge case: Cannot calculate quartiles reliably with fewer than two items.
+ if (n < 2) return List.of();
+
+ //Retrieve prices(BigDecimal), convert to doubles and sort them.
+ List sortedPrices = products.stream()
+ .map(Product::price).mapToDouble(BigDecimal::doubleValue)
+ .boxed().sorted().toList();
+
+ //Find the median of Q1 and Q3
+ // L = (n - 1) * p method is used here (0.25 for Q1, 0.75 for Q3).
+ double q1Index = (n - 1) * 0.25;
+ double q3Index = (n - 1) * 0.75;
+
+ //Quantile value retrieval, using the helper method for linear interpolation.
+ double q1IndexValue = calculateQuantileValue(sortedPrices, q1Index);
+ double q3IndexValue = calculateQuantileValue(sortedPrices, q3Index);
+
+ //Determine the IQR for final result.
+ double iqr = q3IndexValue - q1IndexValue;
+ double lowerOutlier = q1IndexValue - thresholdFactor * iqr;
+ double upperOutlier = q3IndexValue + thresholdFactor * iqr;
+
+ //Use streams to filter the original product-list based on the calculations
+ return products.stream()
+ .filter(p -> {
+ double price = p.price().doubleValue();
+ // Price is an outlier if it is outside the calculated fences.
+ return (price < lowerOutlier || price > upperOutlier);
+ })
+ .collect(Collectors.toList());
}
-
+
+ /**
+ * Help-method for the method findPriceOutliers -
+ * Calculates the quantile value (Q1, Q2, or Q3) by using linear interpolation.
+ * This is based on my calculated floating-point index (L), using the standard formula L = (n-1) * p.
+ * @param sortedPrices Sorted list with prices.
+ * @param qIndex The calculated floating-point index for the quantile (L). Shows where the quantile should be.
+ * @return The interpolated quantile value.
+ */
+ private static double calculateQuantileValue(List sortedPrices, double qIndex) {
+ final int n = sortedPrices.size();
+
+ // Calculate the 0-based integer index.
+
+ int lowerIndex = (int) Math.floor(qIndex);
+
+ // Check 1: If the calculated index falls before the start of the list (lowerIndex < 0),
+ // we return the lowest price.This handles small data sets where the quantile
+ // mathematically lands before the first element.
+ if (lowerIndex < 0) return sortedPrices.getFirst();
+
+ // Check 2: If the index falls at or after the end of the list (n-1),
+ // we return the highest price. This is necessary to prevent an IndexOutOfBoundsException
+ // when we try to fetch 'lowerIndex + 1'. The value is assumed to be the last element's value.
+ if (lowerIndex >= n - 1) return sortedPrices.getLast();
+
+ //Linear interpolation
+ // qDecimal represents the weight/fraction of the distance between the two prices.
+ double qDecimal = qIndex - Math.floor(qIndex);
+ double lowerPrice = sortedPrices.get(lowerIndex);
+ double upperPrice = sortedPrices.get(lowerIndex + 1);
+
+ // Formula for linear interpolation: Lower Price + (Weight * Distance between Prices)
+ return lowerPrice + (qDecimal * (upperPrice - lowerPrice));
+ }
+
/**
* 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
@@ -245,7 +298,6 @@ public Map calculateExpirationBasedDiscounts() {
* when percentage exceeds 70%.
* - Category diversity: count of distinct categories in the inventory. The tests expect at least 2.
* - Convenience booleans: highValueWarning (percentage > 70%) and minimumDiversity (category count >= 2).
- *
* Note: The exact high-value threshold is implementation-defined, but the provided tests create a clear
* separation using very expensive electronics (e.g., 2000) vs. low-priced food items (e.g., 10),
* allowing percentage computation regardless of the chosen cutoff as long as it matches the scenario.
@@ -330,7 +382,6 @@ public InventoryValidation(double highValuePercentage, int categoryDiversity) {
this.highValueWarning = highValuePercentage > 70.0;
this.minimumDiversity = categoryDiversity >= 2;
}
-
public double getHighValuePercentage() { return highValuePercentage; }
public int getCategoryDiversity() { return categoryDiversity; }
public boolean isHighValueWarning() { return highValueWarning; }
diff --git a/src/main/java/com/example/warehouse/Category.java b/src/main/java/com/example/warehouse/Category.java
new file mode 100644
index 00000000..2bde3630
--- /dev/null
+++ b/src/main/java/com/example/warehouse/Category.java
@@ -0,0 +1,72 @@
+package com.example.warehouse;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Represents a product category. This class implements the Flyweight design pattern,
+ * ensuring that only one instance exists for each unique category name
+ * after normalization (e.g., "food", "Food", and "FOOD" all map to "Food").
+ */
+public class Category {
+
+ private final String name;
+ //Map to store and retrieve Category instances.
+ private static final Map categories = new HashMap<>();
+
+ /**
+ * Private constructor for the Flyweight pattern. Instances can only be created
+ * internally via the Code.of factory method.
+ *
+ * @param name The name of the category.
+ */
+ private Category(String name) {
+ this.name = name;
+ }
+
+ /**
+ * Factory method to retrieve a Category instance based on the input name.
+ * The input name is validated and then normalized (first letter capitalized, rest lowercased)
+ * before checking the cache.
+ *
+ * @param name The desired category name (case-insensitive for lookup).
+ * @return A unique, cached instance of the Category.
+ * @throws IllegalArgumentException if the provided name is null or blank.
+ */
+ public static Category of(String name) {
+
+ if (name == null) throw new IllegalArgumentException("Category name can't be null");
+ if (name.isBlank()) throw new IllegalArgumentException("Category name can't be blank");
+
+ String normalized = name.trim().substring(0, 1).toUpperCase() + name.trim().substring(1).toLowerCase();
+
+ return categories.computeIfAbsent(normalized, Category::new);
+ }
+
+ /**
+ * Retrieves the name of the category.
+ *
+ * @return The normalized name of the category (e.g., "Food").
+ */
+
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof Category category)) return false;
+ return Objects.equals(name, category.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(name);
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+}
diff --git a/src/main/java/com/example/warehouse/ElectronicsProduct.java b/src/main/java/com/example/warehouse/ElectronicsProduct.java
new file mode 100644
index 00000000..1c40df01
--- /dev/null
+++ b/src/main/java/com/example/warehouse/ElectronicsProduct.java
@@ -0,0 +1,74 @@
+package com.example.warehouse;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.UUID;
+
+/**
+ * Represents an electronic item in the warehouse.
+ * An ElectronicsProduct implements Shippable (it has a weight and calculated shipping cost)
+ * and includes a specific warranty period.
+ */
+public class ElectronicsProduct extends Product implements Shippable {
+
+ private final int warrantyMonths;
+ private final BigDecimal weight;
+
+ /**
+ * Constructs a new ElectronicsProduct.
+ * * @param id The unique identifier for the product.
+ *
+ * @param name The name of the electronic product.
+ * @param category The product's category.
+ * @param price The initial price of the product.
+ * @param warrantyMonths The length of the product warranty in months.
+ * @param weight The weight of the product.
+ * @throws IllegalArgumentException if the warranty months or weight is negative.
+ */
+ public ElectronicsProduct(UUID id, String name, Category category, BigDecimal price, int warrantyMonths, BigDecimal weight) {
+ super(id, name, category, price);
+ if (warrantyMonths < 0) throw new IllegalArgumentException("Warranty months cannot be negative.");
+ if (weight.compareTo(BigDecimal.ZERO) < 0) throw new IllegalArgumentException("Weight cannot be negative.");
+ this.warrantyMonths = warrantyMonths;
+ this.weight = weight;
+ }
+
+ /**
+ * Provides a summary of the electronics product.
+ * * @return A String containing the product name and its warranty period.
+ */
+ @Override
+ public String productDetails() {
+ return String.format("Electronics: %s, Warranty: %s months", name(), warrantyMonths);
+ }
+
+ // --- Implementation of Shippable Interface ---
+
+ /**
+ * Calculates the shipping cost for electronics, applying a surcharge for heavy items.
+ * Base cost is 79. An extra of 49 is added if the weight is over 5 units(kg).
+ * * @return The calculated shipping cost, rounded up to two decimals.
+ */
+ @Override
+ public BigDecimal calculateShippingCost() {
+
+ BigDecimal baseShippingCost = BigDecimal.valueOf(79);
+ BigDecimal extraShippingCost = BigDecimal.valueOf(49);
+
+ if (this.weight.compareTo(BigDecimal.valueOf(5)) > 0) {
+ baseShippingCost = baseShippingCost.add(extraShippingCost);
+ }
+ return baseShippingCost.setScale(2, RoundingMode.HALF_UP);
+ }
+
+ /**
+ * Retrieves the weight of the product as a double.
+ *
+ * @return The product's weight.
+ */
+ @Override
+ public Double weight() {
+ // Converts the BigDecimal weight to a double for the interface contract.
+ return this.weight.doubleValue();
+ }
+}
diff --git a/src/main/java/com/example/warehouse/FoodProduct.java b/src/main/java/com/example/warehouse/FoodProduct.java
new file mode 100644
index 00000000..18aeb1be
--- /dev/null
+++ b/src/main/java/com/example/warehouse/FoodProduct.java
@@ -0,0 +1,80 @@
+package com.example.warehouse;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.util.UUID;
+
+/**
+ * Represents a food product in the warehouse.
+ * A FoodProduct implements both Perishable (has an expiration date) and
+ * Shippable (has a weight and a defined shipping cost calculation).
+ */
+public class FoodProduct extends Product implements Perishable, Shippable {
+
+ private final LocalDate expirationDate;
+ private final BigDecimal weight;
+
+ /**
+ * Constructs a new FoodProduct.
+ *
+ * @param id The unique identifier for the product
+ * @param name The name of the food product.
+ * @param category The product's category.
+ * @param price The initial price of the product.
+ * @param expirationDate The date on which the product expires.
+ * @param weight The weight of the product.
+ * @throws IllegalArgumentException if the provided weight is negative.
+ */
+ public FoodProduct(UUID id, String name, Category category, BigDecimal price, LocalDate expirationDate, BigDecimal weight) {
+ if (weight.compareTo(BigDecimal.ZERO) < 0) throw new IllegalArgumentException("Weight cannot be negative.");
+ // Calls the abstract Product constructor.
+ super(id, name, category, price);
+
+ this.expirationDate = expirationDate;
+ this.weight = weight;
+ }
+
+ /**
+ * Provides a summary of the food product, forced from the superclass.
+ * * @return A formatted String containing the product name and its expiration date.
+ */
+ @Override
+ public String productDetails() {
+ return String.format("Food: %s, Expires: %s", name(), expirationDate);
+ }
+
+ // ----- Implementation of the interface Shippable ----
+
+ /**
+ * Calculates the shipping cost based on the product's weight.
+ * Uses weight * 50, rounded to two decimals.
+ * * @return The calculated shipping cost.
+ */
+ @Override
+ public BigDecimal calculateShippingCost() {
+
+ return weight.multiply(new BigDecimal(50)).setScale(2, RoundingMode.HALF_UP);
+ }
+
+ /**
+ * Retrieves the weight of the product as a double.
+ * * @return The product's weight.
+ */
+ @Override
+ public Double weight() {
+
+ return this.weight.doubleValue();
+ }
+ // ----Implementation of the Interface Perishable ----
+
+ /**
+ * Retrieves the specific expiration date for this food product.
+ * * @return The product's expiration date.
+ */
+ @Override
+ public LocalDate expirationDate() {
+
+ return this.expirationDate;
+ }
+}
diff --git a/src/main/java/com/example/warehouse/Perishable.java b/src/main/java/com/example/warehouse/Perishable.java
new file mode 100644
index 00000000..4531cd2d
--- /dev/null
+++ b/src/main/java/com/example/warehouse/Perishable.java
@@ -0,0 +1,27 @@
+package com.example.warehouse;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+/**
+ * Defines a contract for products that have an expiration date, such as food or certain chemicals.
+ * Items implementing this interface can be checked for expiration.
+ */
+public interface Perishable {
+
+ LocalDate expirationDate();
+
+ /**
+ * Checks if the item has expired based on the current date.
+ *
+ * @return true if the expiration date has passed and false if not.
+ */
+ default boolean isExpired() {
+
+ LocalDate idag = LocalDate.now();
+ // Checks if the recorded expiration date is before today's date.
+ return expirationDate().isBefore(idag);
+ }
+
+
+}
diff --git a/src/main/java/com/example/warehouse/Product.java b/src/main/java/com/example/warehouse/Product.java
new file mode 100644
index 00000000..b41dbb08
--- /dev/null
+++ b/src/main/java/com/example/warehouse/Product.java
@@ -0,0 +1,102 @@
+package com.example.warehouse;
+
+import java.math.BigDecimal;
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * An abstract base class for all products managed by the warehouse.
+ * It provides core properties (ID, name, category, price) and enforces
+ * rules, such as non-negative pricing.
+ */
+public abstract class Product {
+
+ private final UUID id;
+ private final String name;
+ private final Category category;
+ private BigDecimal price; // Price is not final (can be changed later)
+
+ /**
+ * protected Constructor - Constructs a new Product instance. A unique UUID is generated automatically.
+ * * @param name The product's name.
+ *
+ * @param category The product's category.
+ * @param price The initial price of the product.
+ * @throws IllegalArgumentException if the provided price is negative.
+ */
+ protected Product(UUID id, String name, Category category, BigDecimal price) {
+
+ if (price.compareTo(BigDecimal.ZERO) < 0) throw new IllegalArgumentException("Price cannot be negative.");
+ this.id = id; // Gives a random id-number (128bit)
+ this.name = name;
+ this.category = category;
+ this.price = price;
+ }
+
+ /**
+ * Retrieves the unique identifier (UUID) for this product.
+ *
+ * @return The UUID of the product.
+ */
+ public UUID uuid() {
+ return id;
+ }
+
+ /**
+ * Retrieves the name of the product.
+ *
+ * @return The product's name.
+ */
+ public String name() {
+ return name;
+ }
+
+ /**
+ * Retrieves the category of the product.
+ *
+ * @return The product's Category.
+ */
+ public Category category() {
+ return category;
+ }
+
+ /**
+ * Retrieves the current price of the product.
+ *
+ * @return The price as a BigDecimal.
+ */
+ public BigDecimal price() {
+ return price;
+ }
+
+ /**
+ * Updates the product's price.
+ *
+ * @param newPrice The new price to set.
+ * @throws IllegalArgumentException if the provided new price is negative.
+ */
+ public void price(BigDecimal newPrice) {
+ this.price = newPrice;
+ if (newPrice.compareTo(BigDecimal.ZERO) < 0) throw new IllegalArgumentException("Price cannot be negative.");
+
+ }
+
+ /**
+ * Defines the abstract method for returning specific details about the product.
+ * Subclasses must implement this to provide their own unique description.
+ *
+ * @return A String description of the product.
+ */
+ public abstract String productDetails();
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof Product product)) return false;
+ return Objects.equals(id, product.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, name, category, price);
+ }
+}
diff --git a/src/main/java/com/example/warehouse/Shippable.java b/src/main/java/com/example/warehouse/Shippable.java
new file mode 100644
index 00000000..c581430b
--- /dev/null
+++ b/src/main/java/com/example/warehouse/Shippable.java
@@ -0,0 +1,22 @@
+package com.example.warehouse;
+
+import java.math.BigDecimal;
+/**
+ * Defines a contract for any product or item that can be shipped.
+ * Items implementing this interface must provide methods to calculate shipping cost
+ * and retrieve the item's weight.
+ */
+public interface Shippable {
+ /**
+ * Calculates the total shipping cost for the item.
+ * May vary due to 'extra weight' implementations.
+ * @return The calculated shipping cost as a BigDecimal.
+ */
+ BigDecimal calculateShippingCost();
+
+ /**
+ * Retrieves the weight of the item to be shipped.
+ * @return The weight of the item in a standard unit (e.g., kilograms or pounds) as a Double.
+ */
+ Double weight();
+}
diff --git a/src/main/java/com/example/warehouse/Warehouse.java b/src/main/java/com/example/warehouse/Warehouse.java
new file mode 100644
index 00000000..9cf24881
--- /dev/null
+++ b/src/main/java/com/example/warehouse/Warehouse.java
@@ -0,0 +1,195 @@
+package com.example.warehouse;
+
+import java.math.BigDecimal;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * Represents a specific warehouse instance, acting as a registry for products.
+ * This class implements a Singleton pattern, ensuring only one instance exists per name.
+ */
+public class Warehouse {
+
+
+ private final String name;
+ // Map used to implement the Singleton pattern (stores instances by name).
+ private static final Map warehouses = new HashMap<>();
+ // Stores all products currently in the warehouse, keyed by UUID for fast lookup.
+ private final Map products = new HashMap<>();
+ // Tracks products whose price has been updated since being added/last check.
+ private final Set changedProducts = new HashSet<>();
+
+ /**
+ * Private constructor to enforce the Multiton pattern.
+ *
+ * @param name The unique name of the warehouse.
+ */
+ private Warehouse(String name) {
+ this.name = name;
+ }
+
+ public static Warehouse getInstance() {
+ return getInstance("default");
+ }
+
+ /**
+ * Retrieves the instance of the Warehouse with the specified name.
+ * If an instance with that name does not yet exist, a new one is created and cached.
+ *
+ * @param name The unique name of the warehouse to get or create.
+ * @return The existing or newly created Warehouse instance.
+ */
+ public static Warehouse getInstance(String name) {
+
+ return warehouses.computeIfAbsent(name, Warehouse::new);
+ }
+
+ /**
+ * Adds a product to the warehouse. Throws an exception if the product is null.
+ *
+ * @param product The product to add.
+ * @throws IllegalArgumentException if the product is null.
+ */
+ public void addProduct(Product product) {
+ if (product == null) throw new IllegalArgumentException("Product cannot be null.");
+ if (products.containsKey(product.uuid())) throw new IllegalArgumentException("Product with that id already exists, use updateProduct for updates.");
+ products.put(product.uuid(), product);
+
+ }
+
+ /**
+ * Retrieves an unmodifiable copy of all products currently in the warehouse.
+ * The returned list is immutable, preventing external modification of the warehouse contents.
+ *
+ * @return An unmodifiable List of all products.
+ */
+ public List getProducts() {
+
+ return List.copyOf(products.values());
+ }
+
+ /**
+ * Retrieves a product by its unique ID.
+ * Uses Optional to clearly indicate whether a product was found or not, avoiding null checks.
+ *
+ * @param productID The unique identifier of the product.
+ * @return An Optional containing the product if found, or an empty Optional otherwise.
+ */
+ public Optional getProductById(UUID productID) {
+
+ return Optional.ofNullable(products.get(productID));
+ }
+
+ /**
+ * Updates the price of an existing product and marks it as 'changed'.
+ *
+ * @param productID The ID of the product to update.
+ * @param newPrice The new price to set.
+ * @throws NoSuchElementException if no product with the given ID is found.
+ */
+ public void updateProductPrice(UUID productID, BigDecimal newPrice) {
+ Product product = products.get(productID);
+ if (product == null) throw new NoSuchElementException("Product not found with id: " + productID);
+
+ product.price(newPrice);
+ changedProducts.add(product);
+ }
+
+ /**
+ * Retrieves an unmodifiable list of products whose prices have been updated
+ * since they were added or since the last clear of the changed set.
+ *
+ * @return An unmodifiable List of products that have been modified.
+ */
+ public List getChangedProducts() {
+
+ //Takes a copy of the data
+ return List.copyOf(changedProducts);
+ }
+
+ /**
+ * Retrieves a list of all products in the warehouse that implement the Perishable interface
+ * AND are currently expired.
+ *
+ * @return A List of expired Perishable products.
+ */
+ public List expiredProducts() {
+ // Stream filters for Perishable interface, casts, and then filters for the expired status.
+ return products.values().stream().filter(product -> product instanceof Perishable)
+ .map(product -> (Perishable) product).filter(Perishable::isExpired).collect(Collectors.toList());
+ }
+
+ /**
+ * Retrieves a list of all products in the warehouse that implement the Shippable interface.
+ *
+ * @return A List of Shippable products.
+ */
+ public List shippableProducts() {
+ // Stream filters for Shippable interface and safely casts them.
+ return products.values().stream().filter(product -> product instanceof Shippable)
+ .map(product -> (Shippable) product).collect(Collectors.toList());
+ }
+
+ /**
+ * Removes a product by its ID from the main collection and the changed products set.
+ * If product is found - also deletes it in changed products
+ * @param productID The ID of the product to remove.
+ */
+ public void remove(UUID productID) {
+ Product product = products.remove(productID);
+ if (product != null) {
+ changedProducts.remove(product);
+ }
+ }
+
+ /**
+ * Clears all products from the warehouse and resets the changed products tracker.
+ */
+ public void clearProducts() {
+ products.clear();
+ changedProducts.clear();
+ }
+
+ /**
+ * Checks if the warehouse currently contains any products.
+ *
+ * @return true if the products map is empty, false otherwise.
+ */
+ public boolean isEmpty() {
+ return products.isEmpty();
+ }
+
+ /**
+ * Groups all products in the warehouse by their category.
+ *
+ * @return A Map where the Key is the Category and the Value is a List of Products belonging to that category.
+ */
+ public Map> getProductsGroupedByCategories() {
+
+ // Uses the functional groupingBy collector to efficiently create the map.
+ return products.values().stream()
+ .collect(Collectors.groupingBy(Product::category));
+
+ }
+
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof Warehouse warehouse)) return false;
+ return Objects.equals(name, warehouse.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, products, changedProducts);
+ }
+
+ @Override
+ public String toString() {
+ return "Warehouse{" +
+ "changedProducts=" + changedProducts +
+ ", name='" + name + '\'' +
+ ", products=" + products +
+ '}';
+ }
+}
diff --git a/src/test/java/com/example/BasicTest.java b/src/test/java/com/example/BasicTest.java
index a11fc976..cd4391db 100644
--- a/src/test/java/com/example/BasicTest.java
+++ b/src/test/java/com/example/BasicTest.java
@@ -1,11 +1,14 @@
package com.example;
+
+import com.example.warehouse.*;
import org.junit.jupiter.api.*;
import java.lang.reflect.Constructor;
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 +121,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
diff --git a/src/test/java/com/example/EdgeCaseTest.java b/src/test/java/com/example/EdgeCaseTest.java
index fb4f9358..2d8b1515 100644
--- a/src/test/java/com/example/EdgeCaseTest.java
+++ b/src/test/java/com/example/EdgeCaseTest.java
@@ -1,5 +1,7 @@
package com.example;
+
+import com.example.warehouse.*;
import org.junit.jupiter.api.*;
import java.math.BigDecimal;
@@ -35,16 +37,16 @@ void setUp() {
@DisplayName("Advanced Search and Filtering")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class AdvancedSearchTests {
-
- @Test
- @Order(1)
- @DisplayName("🔍 should handle complex price range queries with boundary conditions")
/**
* Verifies price range filtering is inclusive of boundaries.
* Arrange: add products priced below min, at min, in range, at max, and above max.
* Act: call analyzer.findProductsInPriceRange(10.00, 100.00).
* Assert: only names MinPrice, InRange, and MaxPrice are returned (3 items).
*/
+ @Test
+ @Order(1)
+ @DisplayName("🔍 should handle complex price range queries with boundary conditions")
+
void should_filterByPriceRange_withBoundaryConditions() {
// Arrange - Products at exact boundaries
Product exactMin = new FoodProduct(UUID.randomUUID(), "MinPrice", Category.of("Test"),
@@ -77,15 +79,15 @@ void should_filterByPriceRange_withBoundaryConditions() {
.extracting(Product::name)
.containsExactlyInAnyOrder("MinPrice", "MaxPrice", "InRange");
}
-
- @Test
- @Order(2)
- @DisplayName("🔍 should find products expiring within N days using date arithmetic")
/**
* Finds perishable products that expire within the given number of days starting today,
* excluding items already expired and non-perishables. Uses LocalDate arithmetic.
* Expect: Today, Tomorrow, In3Days (3 results), based on analyzer.findProductsExpiringWithinDays(3).
*/
+ @Test
+ @Order(2)
+ @DisplayName("🔍 should find products expiring within N days using date arithmetic")
+
void should_findProductsExpiringWithinDays() {
// Arrange - Various expiration scenarios
LocalDate today = LocalDate.now();
@@ -119,16 +121,16 @@ void should_findProductsExpiringWithinDays() {
.extracting(p -> ((Product) p).name())
.containsExactlyInAnyOrder("Today", "Tomorrow", "In3Days");
}
-
- @Test
- @Order(3)
- @DisplayName("🔍 should perform case-insensitive partial name search with special characters")
/**
* Performs a case-insensitive substring search over product names, handling spaces and symbols.
* Arrange: mix of names containing "milk" in various cases plus a non-matching electronics item.
* Act: analyzer.searchProductsByName("milk").
* Assert: returns 4 matching products regardless of case or extra characters.
*/
+ @Test
+ @Order(3)
+ @DisplayName("🔍 should perform case-insensitive partial name search with special characters")
+
void should_searchByPartialName_caseInsensitive() {
// Arrange
warehouse.addProduct(new FoodProduct(UUID.randomUUID(), "Organic Milk 2%", Category.of("Dairy"),
@@ -157,15 +159,15 @@ void should_searchByPartialName_caseInsensitive() {
@Nested
@DisplayName("Advanced Analytics and Calculations")
class AdvancedAnalyticsTests {
-
- @Test
- @DisplayName("📊 should calculate weighted average price by category")
/**
* Calculates a weighted average price per category where weight is the product weight.
* Arrange: three dairy items with different prices and weights.
* Act: analyzer.calculateWeightedAveragePriceByCategory().
* Assert: Dairy category has weighted average 11.43.
*/
+ @Test
+ @DisplayName("📊 should calculate weighted average price by category")
+
void should_calculateWeightedAveragePrice_byCategory() {
// Arrange - Products with different weights in same category
Category dairy = Category.of("Dairy");
@@ -184,15 +186,15 @@ void should_calculateWeightedAveragePrice_byCategory() {
.as("Weighted average should consider product weights: (10*2 + 30*0.5 + 5*1) / 3.5 = 11.43")
.isEqualByComparingTo(new BigDecimal("11.43"));
}
-
- @Test
- @DisplayName("📊 should identify products with abnormal pricing (outliers)")
/**
- * Detects price outliers using mean and standard deviation.
+ * Detects price outliers with extreme prices using the InterQuartile Range method.
* Arrange: mostly normal-priced items around 15, plus very cheap and very expensive outliers.
- * Act: analyzer.findPriceOutliers(2.0).
+ * Act: analyzer.findPriceOutliers(1.5).
* Assert: returns exactly the two outliers ("Expensive" and "Cheap").
*/
+ @Test
+ @DisplayName("📊 should identify products with abnormal pricing (outliers)")
+
void should_identifyPriceOutliers_usingStatistics() {
// Arrange - Most products around 10-20, with outliers
IntStream.rangeClosed(1, 10).forEach(i ->
@@ -207,8 +209,8 @@ void should_identifyPriceOutliers_usingStatistics() {
warehouse.addProduct(outlierHigh);
warehouse.addProduct(outlierLow);
- // Act - Find outliers (products with price > 2 standard deviations from mean)
- List outliers = analyzer.findPriceOutliers(2.0); // 2 standard deviations
+ // Act - Find outliers, using the IQR method with threshold factor of 1.5
+ List outliers = analyzer.findPriceOutliers(1.5); // threshold factor
// Assert
assertThat(outliers)
@@ -217,15 +219,15 @@ void should_identifyPriceOutliers_usingStatistics() {
.extracting(Product::name)
.containsExactlyInAnyOrder("Expensive", "Cheap");
}
-
- @Test
- @DisplayName("💰 should optimize shipping by grouping products efficiently")
/**
* Groups shippable products into bins not exceeding a maximum total weight to optimize shipping.
* Arrange: mix of light and heavy items across categories.
* Act: analyzer.optimizeShippingGroups(10.0).
* Assert: each group total weight <= 10.0 and all 5 items are included across groups.
*/
+ @Test
+ @DisplayName("💰 should optimize shipping by grouping products efficiently")
+
void should_optimizeShipping_byGroupingProducts() {
// Arrange - Products that could be grouped for shipping optimization
warehouse.addProduct(new FoodProduct(UUID.randomUUID(), "Light1", Category.of("Food"),
@@ -265,15 +267,15 @@ void should_optimizeShipping_byGroupingProducts() {
@Nested
@DisplayName("Complex Business Rules")
class BusinessRulesTests {
-
- @Test
- @DisplayName("📋 should apply discount rules based on expiration proximity")
/**
* Applies tiered discounts based on how soon a perishable item expires.
* Arrange: items expiring today, tomorrow, in 3 days, and in 7 days.
* Act: analyzer.calculateExpirationBasedDiscounts().
* Assert: prices become 50%, 70%, 85%, and 100% of original respectively.
*/
+ @Test
+ @DisplayName("📋 should apply discount rules based on expiration proximity")
+
void should_applyDiscounts_basedOnExpiration() {
// Arrange
LocalDate today = LocalDate.now();
@@ -308,15 +310,15 @@ void should_applyDiscounts_basedOnExpiration() {
.as("Product expiring in 7 days should have no discount")
.isEqualByComparingTo(new BigDecimal("100.00"));
}
-
- @Test
- @DisplayName("📦 should validate inventory constraints and business rules")
/**
* Validates high-value item percentage and category diversity business rules across inventory.
* Arrange: 15 expensive electronics and 5 food items (total 20).
* Act: analyzer.validateInventoryConstraints().
* Assert: ~75% high-value, warning for >70%, diversity count 2, and minimum diversity satisfied.
*/
+ @Test
+ @DisplayName("📦 should validate inventory constraints and business rules")
+
void should_validateInventoryConstraints() {
// Arrange - Setup products that might violate business rules
Category electronics = Category.of("Electronics");
@@ -354,9 +356,6 @@ food, new BigDecimal("10"), LocalDate.now().plusDays(1), BigDecimal.ONE))
.as("Should have minimum category diversity (at least 2)")
.isTrue();
}
-
- @Test
- @DisplayName("📊 should generate comprehensive inventory statistics")
/**
* Produces aggregate inventory metrics including counts, sums, averages, and extremes.
* Arrange: 4 diverse products with one expired.
@@ -364,6 +363,9 @@ food, new BigDecimal("10"), LocalDate.now().plusDays(1), BigDecimal.ONE))
* Assert: totalProducts=4, totalValue=1590.50, averagePrice=397.63, expiredCount=1,
* categoryCount=2, most expensive is "Laptop" and cheapest is "Milk".
*/
+ @Test
+ @DisplayName("📊 should generate comprehensive inventory statistics")
+
void should_generateInventoryStatistics() {
// Arrange - Diverse product mix
warehouse.addProduct(new FoodProduct(UUID.randomUUID(), "Milk", Category.of("Dairy"),