Content
<aside> 💭
Practice exercises are optional and do not need to be submitted
</aside>
In this exercise, you will build a small book-tracking utility that groups books by genre. You will practice generics, ArrayList, HashMap, loops, and generic methods.
Pair<K, V>.key of type K and value of type V.getKey() and getValue().toString() method so printed pairs are readable.In a main method, create an ArrayList<Pair<String, String>> to hold book-title and genre pairs. Add at least six books across at least three genres. For example:
Then complete the tracker:
HashMap<String, Integer> to count how many books belong to each genre.Expected output for the sample data above:
Genre statistics:
Fantasy: 1 book(s)
Sci-Fi: 3 book(s)
Dystopian: 2 book(s)
<aside> 💡
HashMap has a method called getOrDefault(key, defaultValue). It is useful when you want to read the current count for a genre, but the genre might not be in the map yet.
</aside>
<aside> ⚠️
Use .equals() to compare String values, not ==. This is a common source of bugs when working with strings and collection keys.
</aside>
Using @Override , implement the toString() method for your Pair<K,V> class. Then try printing the whole list in the console IO.println(books) or System.out.println(books)
Build a smart shopping cart that calculates totals and applies a discount based on item categories. This exercise combines enums, collections, the Stream API, records, and BigDecimal.
<aside> 💭
double can produce rounding errors, and money values need precision. In Java, common choices for money calculations are BigDecimal or a dedicated money library such as Joda-Money.
BigDecimal is more verbose because you use methods instead of arithmetic symbols, but it gives you stable calculations.
| Operator | BigDecimal equivalent |
|---|---|
+ |
add(BigDecimal) |
- |
subtract(BigDecimal) |
* |
multiply(BigDecimal) |
/ |
divide(BigDecimal) |
| </aside> |
Use this GroceryItem record:
public record GroceryItem(
String name,
GroceryType type,
Integer quantity,
BigDecimal individualPrice
) {
private static final int PERCENTAGE_DIVISOR_HUNDRED = 100;
public static final int MONEY_SCALE = 2;
public GroceryItem(String name, GroceryType type, Integer quantity, String individualPrice) {
this(name, type, quantity, new BigDecimal(individualPrice));
}
/**
* Safely calculates the new cost after applying the discount percentage.
* BigDecimal operations are used for 100% accuracy.
* @param percentage should be between 0 and 100, e.g. 20 for 20% discount
* @return The BigDecimal instance adjusted with discount
*/
public BigDecimal discountedIndividualPrice(int percentage) {
// Extract the percentage for the "new price". Eg: Discount 20%? Get 80%
int priceAfterDiscountPercentage = PERCENTAGE_DIVISOR_HUNDRED - percentage;
// priceAfterDiscountPercentage 80 divided by 100 = 0.8 multiplied with individual price
return this.individualPrice()
.multiply(BigDecimal.valueOf(priceAfterDiscountPercentage ))
.divide(BigDecimal.valueOf(PERCENTAGE_DIVISOR_HUNDRED), MONEY_SCALE, RoundingMode.HALF_UP);
}
}
GroceryType enumCreate an enum called GroceryType in net.hackyourfuture.models with below values:
FRUIT, VEGETABLE, DAIRY, MEAT, BAKERY, OTHERShoppingCart classCreate a ShoppingCart class in net.hackyourfuture. It should extend ArrayList<GroceryItem> and implement below methods:
package net.hackyourfuture;
public class ShoppingCart extends ArrayList<GroceryItem> {
public List<GroceryItem> getItemsByType(GroceryType type) {
throw new UnsupportedOperationException("Implement getItemsByType");
}
public Set<String> uniqueItemNames() {
throw new UnsupportedOperationException("Implement uniqueItemNames");
}
public Integer totalItemsCount() {
throw new UnsupportedOperationException("Implement totalItemsCount");
}
public BigDecimal calculateTotalPrice() {
throw new UnsupportedOperationException("Implement calculateTotalPrice");
}
public String totalPriceFormatted() {
return calculateTotalPrice()
.setScale(GroceryItem.MONEY_SCALE, RoundingMode.HALF_UP)
.toPlainString();
}
}
Your methods should behave like this:
| Method | Required behavior |
|---|---|
getItemsByType(GroceryType type) |
Return a List<GroceryItem> containing only items with the provided GroceryType. Use the Stream API. |
uniqueItemNames() |
Return a Set<String> containing each grocery item name once. Use the Stream API. |
totalItemsCount() |
Return the sum of all item quantities in the cart. |
calculateTotalPrice() |
Calculate quantity * individualPrice for each item, apply a 20% discount to fruit items, and return the grand total with scale 2. |
totalPriceFormatted() |
Return the total price as a plain string with two decimal places. This method is already provided above. |
<aside> 💡
You can implement calculateTotalPrice() with a loop first. After that, try rewriting it with streams. Using reduce() is a good bonus challenge, but it is more advanced than the core Stream API operations from this week.
</aside>
Running the code below should produce the expected output shown after the code block:
import net.hackyourfuture.ShoppingCart;
import net.hackyourfuture.models.GroceryItem;
import net.hackyourfuture.models.GroceryType;
void main() {
ShoppingCart cart = new ShoppingCart();
cart.add(new GroceryItem("Bananas", GroceryType.FRUIT, 4, "1.25"));
cart.add(new GroceryItem("Whole Milk", GroceryType.DAIRY, 1, "2.80"));
cart.add(new GroceryItem("Sourdough Bread", GroceryType.BAKERY, 1, "3.50"));
IO.println("Total: €" + cart.totalPriceFormatted());
IO.println("Total Items in Cart: " + cart.totalItemsCount());
IO.println("Fruit groceries: " + cart.getItemsByType(GroceryType.FRUIT));
IO.println("Grocery item names: " + cart.uniqueItemNames());
}
/*=================================================================================================*/
// Expected output:
// Total: €10.30
// Total Items in Cart: 6
// Fruit groceries: [GroceryItem[name=Bananas, type=FRUIT, quantity=4, individualPrice=1.25]]
// Grocery item names: [Bananas, Whole Milk, Sourdough Bread] <- // In any order
/*=================================================================================================*/
ShoppingCartTestNow that you have built ShoppingCart, prove that it works correctly using JUnit 5 tests and the Arrange-Act-Assert pattern from this week.
Create a test class called ShoppingCartTest (place it in src/test/java/net/hackyourfuture/ so JUnit can find it).
Copy the below to your class and complete the test methods:
package net.hackyourfuture;
import static org.junit.jupiter.api.Assertions.fail;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class ShoppingCartTest {
private ShoppingCart cart;
@BeforeEach
void setUp() {
cart = new ShoppingCart();
}
@Test
void calculateTotalPrice_applies20PercentDiscountOnFruits() {
fail("Arrange items, call calculateTotalPrice(), and assert the BigDecimal total.");
}
@Test
void totalItemsCount_returnsSumOfAllQuantities() {
fail("Arrange items, call totalItemsCount(), and assert the total quantity.");
}
@Test
void getItemsByType_returnsOnlyMatchingItems() {
fail("Arrange mixed items, call getItemsByType(), and assert only matching items are returned.");
}
@Test
void uniqueItemNames_returnsAllUniqueNames() {
fail("Arrange items with duplicate names, call uniqueItemNames(), and assert each name appears once.");
}
@Test
void emptyCart_returnsZeroForTotalAndCount() {
fail("Call calculateTotalPrice() and totalItemsCount() on an empty cart, then assert zero values.");
}
}
@Test for each test method.@BeforeEach to create a fresh ShoppingCart before every test.assertEquals(expected, actual) for totals, counts, lists, and sets.assertTrue() or assertFalse() when checking whether a result contains a specific item.<aside> 💡
For BigDecimal, prefer expected values such as new BigDecimal("10.30"). Creating a BigDecimal from a String avoids the precision issues that happen with double.
</aside>
<aside> 🎉
If your tests pass, you have combined most of Week 3: collections, generics, enums, streams, unit testing, and debugging. That is exactly the toolkit you will use when you start building REST APIs in Week 4. Congratulations!
</aside>