diff --git a/src/main/java/com/example/Category.java b/src/main/java/com/example/Category.java new file mode 100644 index 00000000..2acac214 --- /dev/null +++ b/src/main/java/com/example/Category.java @@ -0,0 +1,47 @@ +package com.example; + +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public final class Category { + private static final Map CACHE = new ConcurrentHashMap<>(); + + private final String name; + + private Category(String name) { + this.name = name; + } + + public static Category of(String name) { + if (name == null) { + throw new IllegalArgumentException("Category name can't be null"); + } + String trimmedName = name.trim(); + if (trimmedName.isBlank()) { + throw new IllegalArgumentException("Category name can't be blank"); + } + + String normalizedName = trimmedName.substring(0, 1).toUpperCase(Locale.ROOT) + + trimmedName.substring(1).toLowerCase(Locale.ROOT); + + return CACHE.computeIfAbsent(normalizedName, Category::new); + } + + public String getName() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Category category = (Category) o; + return name.equals(category.name); + } + + @Override + public int hashCode() { + return name.hashCode(); + } +} \ 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..e3661bf2 --- /dev/null +++ b/src/main/java/com/example/ElectronicsProduct.java @@ -0,0 +1,41 @@ +package com.example; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.UUID; + +public class ElectronicsProduct extends Product implements Shippable { + + private final int warrantyMonths; + private final BigDecimal weight; + + 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."); + } + this.warrantyMonths = warrantyMonths; + this.weight = weight; + } + @Override + public BigDecimal calculateShippingCost() { + BigDecimal baseCost = new BigDecimal("79"); + BigDecimal heavyWeightSurcharge = BigDecimal.ZERO; + + if (weight.compareTo(new BigDecimal("5.0")) > 0) { + heavyWeightSurcharge = new BigDecimal("49"); + } + return baseCost.add(heavyWeightSurcharge).setScale(2, RoundingMode.HALF_UP); + + } + @Override + public double weight() { + return weight.doubleValue(); + } + @Override + public String productDetails() { + return String.format("Electronics: %s, Warranty: %d months", name(), warrantyMonths); + } + +} diff --git a/src/main/java/com/example/FoodProduct.java b/src/main/java/com/example/FoodProduct.java new file mode 100644 index 00000000..3818532d --- /dev/null +++ b/src/main/java/com/example/FoodProduct.java @@ -0,0 +1,42 @@ +package com.example; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.UUID; + +public class FoodProduct extends Product implements Perishable, Shippable { + + private final LocalDate expirationDate; + private final BigDecimal weight; + + public FoodProduct(UUID id, String name, Category category, BigDecimal price, LocalDate expirationDate, BigDecimal weight) { + super(id, name, category, price); + + if (weight.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Weight cannot be negative."); + } + + this.expirationDate = expirationDate; + this.weight = weight; + } + @Override + public LocalDate expirationDate() { + return expirationDate; + } + + @Override + public BigDecimal calculateShippingCost() { + return weight.multiply(new BigDecimal("50")).setScale(2, RoundingMode.HALF_UP); + } + @Override + public double weight() { + return weight.doubleValue(); + } + @Override + public String productDetails() { + return String.format("Food: %s, Expires: %s", name(), 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..9372f5e4 --- /dev/null +++ b/src/main/java/com/example/Perishable.java @@ -0,0 +1,11 @@ +package com.example; +import java.time.LocalDate; + +public 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..3a8b5467 --- /dev/null +++ b/src/main/java/com/example/Product.java @@ -0,0 +1,44 @@ +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 id, String name, Category category, BigDecimal price) { + this.id = id; + this.name = name; + this.category = category; + setPrice(price); + } + + public UUID uuid() { + return id; + } + public String name() { + return name; + } + public Category category() { + return category; + } + public BigDecimal price() { + return price; + } + public void price(BigDecimal newPrice) { + setPrice(newPrice); + } + + protected void setPrice(BigDecimal newPrice) { + if (newPrice != null && newPrice.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Price cannot be negative."); + } + this.price = newPrice; + } + + public abstract 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..8c526e2c --- /dev/null +++ b/src/main/java/com/example/Shippable.java @@ -0,0 +1,11 @@ +package com.example; + +import java.math.BigDecimal; + +public 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..7cb87fb0 --- /dev/null +++ b/src/main/java/com/example/Warehouse.java @@ -0,0 +1,82 @@ +package com.example; + +import java.math.BigDecimal; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +public class Warehouse { + + private static final Map INSTANCES = new ConcurrentHashMap<>(); + private final Map products; + private final Set changedProducts; + + private Warehouse(String name) { + this.products = new LinkedHashMap<>(); + this.changedProducts = new HashSet<>(); + } + + public static Warehouse getInstance(String name) { + return INSTANCES.computeIfAbsent(name, Warehouse::new); + } + + public static Warehouse getInstance() { + return getInstance("DefaultWarehouse"); + } + + 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); + } + public List getProducts() { + return Collections.unmodifiableList(new ArrayList<>(products.values())); + } + public Optional getProductById(UUID id) { + return Optional.ofNullable(products.get(id)); + } + public void remove(UUID id) { + products.remove(id); + } + public void updateProductPrice(UUID id, BigDecimal newPrice) { + Product product = products.get(id); + if (product == null) { + throw new NoSuchElementException("Product not found with id: " + id); + } + product.price(newPrice); + changedProducts.add(product); + } + public List getChangedProducts() { + return List.copyOf(changedProducts); + } + + public List expiredProducts() { + return products.values().stream() + .filter(p -> p instanceof Perishable) + .map(p -> (Perishable) p) + .filter(Perishable::isExpired) + .collect(Collectors.toList()); + } + public List shippableProducts() { + return products.values().stream() + .filter(p -> p instanceof Shippable) + .map(p -> (Shippable) p) + .collect(Collectors.toList()); + } + public void clearProducts() { + products.clear(); + changedProducts.clear(); + } + public boolean isEmpty() { + return products.isEmpty(); + } + public Map> getProductsGroupedByCategories() { + return products.values().stream() + .collect(Collectors.groupingBy(Product::category)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/WarehouseAnalyzer.java b/src/main/java/com/example/WarehouseAnalyzer.java index 1779fc33..b11f82b2 100644 --- a/src/main/java/com/example/WarehouseAnalyzer.java +++ b/src/main/java/com/example/WarehouseAnalyzer.java @@ -4,6 +4,7 @@ import java.math.MathContext; import java.math.RoundingMode; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.*; import java.util.stream.Collectors; @@ -142,27 +143,67 @@ public Map calculateWeightedAveragePriceByCategory() { * 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) { + private BigDecimal getQuartile(List sortedPrices, double position) { + if (sortedPrices.isEmpty()) { + return BigDecimal.ZERO; + } + int n = sortedPrices.size(); + double index = (n - 1) * position; + int lowerIndex = (int) Math.floor(index); + double fraction = index - lowerIndex; + + if (fraction == 0) { + return sortedPrices.get(lowerIndex); + } + + if (lowerIndex >= n - 1) { + return sortedPrices.get(n - 1); + } + + BigDecimal lowerValue = sortedPrices.get(lowerIndex); + BigDecimal upperValue = sortedPrices.get(lowerIndex + 1); + + BigDecimal diff = upperValue.subtract(lowerValue); + BigDecimal interpolatedValue = lowerValue.add(diff.multiply(BigDecimal.valueOf(fraction))); + + return interpolatedValue.setScale(2, RoundingMode.HALF_UP); + } + + /** + * Identifies products whose price is considered a statistical outlier using the + * Interquartile Range (IQR) method (Tukey's Fences). + * * An outlier is defined as any price that falls outside the range: + * [Q1 - (IQR * multiplier), Q3 + (IQR * multiplier)]. + * This method is robust against extreme values that can skew mean-based calculations. + * Test expectation: In the provided test scenario, using a multiplier of 1.5 + * correctly identifies both the extremely high and extremely low price points + * as outliers. + * * @param iqrMultiplier The multiplier used to define the fences (e.g., 1.5 for standard outlier detection). + * @return A list of products whose prices lie outside the calculated fences. + */ + + public List findPriceOutliers(double iqrMultiplier) { 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); + if (products.isEmpty()) { + return Collections.emptyList(); } - return outliers; + List prices = products.stream() + .map(Product::price) + .sorted() + .collect(Collectors.toList()); + BigDecimal q1 = getQuartile(prices, 0.25); + BigDecimal q3 = getQuartile(prices, 0.75); + BigDecimal iqr = q3.subtract(q1); + BigDecimal multiplier = BigDecimal.valueOf(iqrMultiplier); + BigDecimal step = iqr.multiply(multiplier); + BigDecimal lowerFence = q1.subtract(step); + BigDecimal upperFence = q3.add(step); + return products.stream() + .filter(p -> p.price().compareTo(lowerFence) < 0 || p.price().compareTo(upperFence) > 0) + .collect(Collectors.toList()); } /** @@ -221,7 +262,7 @@ public Map calculateExpirationBasedDiscounts() { BigDecimal discounted = p.price(); if (p instanceof Perishable per) { LocalDate exp = per.expirationDate(); - long daysBetween = java.time.temporal.ChronoUnit.DAYS.between(today, exp); + long daysBetween = ChronoUnit.DAYS.between(today, exp); if (daysBetween == 0) { discounted = p.price().multiply(new BigDecimal("0.50")); } else if (daysBetween == 1) { @@ -368,4 +409,5 @@ public InventoryStatistics(int totalProducts, BigDecimal totalValue, BigDecimal public int getCategoryCount() { return categoryCount; } public Product getMostExpensiveProduct() { return mostExpensiveProduct; } public Product getCheapestProduct() { return cheapestProduct; } + } \ No newline at end of file diff --git a/src/test/java/com/example/BasicTest.java b/src/test/java/com/example/BasicTest.java index a11fc976..8a45877e 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