tdd-workshop/shopping_cart/lib/shopping_cart_solution.dart
fiatcode c3355063f2 Initial commit: Complete TDD workshop materials
- Workshop documentation (WORKSHOP_PLAN, FACILITATOR_GUIDE, etc.)
- FizzBuzz kata with demo script (git history to be recreated)
- Password Validator kata with demo script and solution
- Shopping Cart kata with demo script and solution
- Setup guide and TDD reference card for participants
2026-03-10 15:32:21 +07:00

144 lines
5.2 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// REFERENCE SOLUTION — For facilitator use only.
// Do not share with participants before the session.
//
// This solution shows the evolution of design through TDD:
// - Steps 1-2: Basic implementation with List
// - Step 3: Refactored to Map when removeItem() test drove the change
// - Step 5: Value Object pattern for CartItem validation
// STEP 5: Value Object pattern emerged from test "rejects item with zero price"
// See: test/shopping_cart_test.dart:139
//
// WHY VALIDATE IN CONSTRUCTOR?
// - CartItem can't exist in an invalid state
// - No need to check validity every time we use it
// - Validation happens once, at creation time
// - This is the Value Object pattern from Domain-Driven Design
class CartItem {
final String name;
final double price;
final int quantity;
CartItem({required this.name, required this.price, required this.quantity}) {
// STEP 5: From test "rejects item with zero/negative price"
// See: test/shopping_cart_test.dart:139-152
// Validation belongs HERE, not in ShoppingCart.addItem()
// If validation was in addItem(), what happens when CartItem is created elsewhere?
if (price <= 0) {
throw ArgumentError('Price must be greater than zero, got: $price');
}
// STEP 5: From test "rejects item with zero quantity"
// See: test/shopping_cart_test.dart:155-161
if (quantity <= 0) {
throw ArgumentError('Quantity must be greater than zero, got: $quantity');
}
}
}
class ShoppingCart {
// STEP 3 REFACTOR: Originally a List<CartItem>
// Changed to Map when removeItem(name) test made List lookup inefficient
//
// WHY MAP instead of LIST?
// - removeItem() needs to find item by name
// - With List: O(n) lookup with .removeWhere()
// - With Map: O(1) lookup by key
// - The test for "remove by name" drove this design decision
//
// From test "removing an item reduces the subtotal"
// See: test/shopping_cart_test.dart:71
final Map<String, CartItem> _items = {};
// STEP 2: From test "subtotal reflects a single item added"
// See: test/shopping_cart_test.dart:39
// Simple add - using name as key means duplicate names replace (design choice)
void addItem(CartItem item) {
_items[item.name] = item;
// DESIGN CHOICE: This replaces items with duplicate names
// Could alternatively accumulate: _items[item.name].quantity += item.quantity
// Both are valid! TDD forces us to decide and document via tests
// See bonus test: test/shopping_cart_test.dart:168
}
// STEP 3: From test "removing an item reduces the subtotal"
// See: test/shopping_cart_test.dart:71
// Map.remove() is simple and efficient - this test drove the Map refactoring
void removeItem(String name) {
_items.remove(name);
// Note: remove() on non-existent key does nothing (which is what we want)
// Test "removing non-existent item does nothing" confirmed this behavior
// See: test/shopping_cart_test.dart:87
}
// STEP 2: From test "subtotal reflects a single item added"
// See: test/shopping_cart_test.dart:39
// Classic fold operation: accumulate (price × quantity) for each item
double subtotal() {
return _items.values
.fold(0.0, (sum, item) => sum + (item.price * item.quantity));
}
// STEP 2: From test "item count reflects quantity of single item"
// See: test/shopping_cart_test.dart:45
// Similar to subtotal, but sum quantities instead of prices
int itemCount() {
return _items.values.fold(0, (sum, item) => sum + item.quantity);
}
// STEP 4: From test "10% discount reduces subtotal correctly"
// See: test/shopping_cart_test.dart:109
// Straightforward percentage calculation
// discountPercent is 0-100, so divide by 100 to get decimal
double totalAfterDiscount(double discountPercent) {
final discount = discountPercent / 100;
return subtotal() * (1 - discount);
}
}
// EVOLUTION NOTES:
//
// Initial Implementation (Steps 1-2):
// Started with List<CartItem> because that's the obvious choice:
//
// final List<CartItem> _items = [];
//
// void addItem(CartItem item) {
// _items.add(item);
// }
//
// This worked for add, subtotal, and itemCount tests.
//
// REFACTORING TRIGGER (Step 3):
// The removeItem(String name) test exposed inefficiency:
//
// void removeItem(String name) {
// _items.removeWhere((item) => item.name == name); // O(n) search!
// }
//
// This works, but felt wrong. The test made us ask:
// "Why are we searching by name every time?"
//
// Refactoring to Map made the code simpler AND more efficient:
// _items.remove(name); // O(1) lookup!
//
// VALUE OBJECT INSIGHT (Step 5):
// Initially, participants might put validation in addItem():
//
// void addItem(CartItem item) {
// if (item.price <= 0) throw ArgumentError('Invalid price');
// _items[item.name] = item;
// }
//
// But this has a problem: what if CartItem is created elsewhere?
// Moving validation to CartItem's constructor makes invalid state impossible.
//
// This is the Value Object pattern:
// - Immutable data
// - Validation at construction
// - Can't exist in invalid state
// - Can be passed around safely without rechecking validity
//
// All these design insights were DRIVEN BY TESTS, not planned upfront!