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"),