- 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
144 lines
5.2 KiB
Dart
144 lines
5.2 KiB
Dart
// 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!
|