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
This commit is contained in:
fiatcode 2026-03-10 15:32:21 +07:00
commit c3355063f2
26 changed files with 4725 additions and 0 deletions

View file

@ -0,0 +1,477 @@
# Shopping Cart — Demo Script for Facilitators
**Purpose:** This script helps you demonstrate the Shopping Cart kata if participants get stuck, or for debriefing discussions. Unlike the FizzBuzz live demo, this is NOT meant to be performed in real-time. Use it as a reference guide.
**Target time:** 45 minutes (participant hands-on)
**Your role:** Circulate, answer questions, refer to this script when helping stuck participants
---
## Overview of the Kata
**Features to implement (in order):**
1. Empty cart has subtotal 0 and count 0
2. Add items — subtotal = price × quantity
3. Remove item by name
4. Apply % discount to subtotal
5. CartItem rejects zero/negative price & quantity (Value Object)
6. **Bonus:** Duplicate name handling / float precision
**Key learning goals:**
- Practice RED-GREEN-REFACTOR rhythm
- Let tests drive design decisions (List → Map refactoring)
- Discover Value Object pattern naturally (Step 5)
- Experience design evolution through failing tests
---
## Suggested Test Order & Implementation
### STEP 1: Empty Cart (The Baseline)
**First test to uncomment (line 23):**
```dart
test('subtotal is zero when cart is empty', () {
expect(cart.subtotal(), equals(0.0));
});
```
**Expected failure:**
- "The method 'subtotal' isn't defined for the class 'ShoppingCart'"
**Minimum code to make it GREEN:**
```dart
class ShoppingCart {
double subtotal() {
return 0.0;
}
}
```
**Next test (line 28):**
```dart
test('item count is zero when cart is empty', () {
expect(cart.itemCount(), equals(0));
});
```
**Code to add:**
```dart
int itemCount() {
return 0;
}
```
**Teaching moment:** "Always start with the simplest case. Empty state is your baseline."
---
### STEP 2: Adding Items
**Test to uncomment (line 39):**
```dart
test('subtotal reflects a single item added', () {
cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 3));
expect(cart.subtotal(), equals(4.50));
});
```
**Expected failure:**
- "The class 'CartItem' isn't defined"
- "The method 'addItem' isn't defined"
**Minimum code to make it GREEN:**
```dart
class CartItem {
final String name;
final double price;
final int quantity;
CartItem({required this.name, required this.price, required this.quantity});
}
class ShoppingCart {
final List<CartItem> _items = [];
void addItem(CartItem item) {
_items.add(item);
}
double subtotal() {
if (_items.isEmpty) return 0.0;
return _items.first.price * _items.first.quantity;
}
int itemCount() {
if (_items.isEmpty) return 0;
return _items.first.quantity;
}
}
```
**Teaching moment:** "This is intentionally naive! We only handle the first item. The next tests will force us to generalize."
**Next test (line 51):**
```dart
test('subtotal accumulates across multiple different items', () {
cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 2)); // 3.00
cart.addItem(CartItem(name: 'Banana', price: 0.75, quantity: 4)); // 3.00
expect(cart.subtotal(), equals(6.00));
});
```
**Expected failure:**
- Test fails because `subtotal()` only looks at first item
**Code to fix:**
```dart
double subtotal() {
return _items.fold(0.0, (sum, item) => sum + (item.price * item.quantity));
}
int itemCount() {
return _items.fold(0, (sum, item) => sum + item.quantity);
}
```
**Teaching moment:** "The test forced us to handle multiple items. We went from naive to general."
---
### STEP 3: Removing Items (The Design Pivot)
**Test to uncomment (line 71):**
```dart
test('removing an item reduces the subtotal', () {
cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 2));
cart.addItem(CartItem(name: 'Banana', price: 0.75, quantity: 4));
cart.removeItem('Apple');
expect(cart.subtotal(), equals(3.00));
});
```
**First attempt (works but feels wrong):**
```dart
void removeItem(String name) {
_items.removeWhere((item) => item.name == name);
}
```
**This passes the tests! But... do we like it?**
**Teaching moment:** "This works, but we're doing an O(n) search every time. The test is telling us something: we're removing *by name*, not by position. What data structure is optimized for name lookups?"
**Refactoring opportunity:**
```dart
class ShoppingCart {
// Changed from List to Map!
final Map<String, CartItem> _items = {};
void addItem(CartItem item) {
_items[item.name] = item;
}
void removeItem(String name) {
_items.remove(name); // Much simpler!
}
double subtotal() {
return _items.values.fold(0.0, (sum, item) => sum + (item.price * item.quantity));
}
int itemCount() {
return _items.values.fold(0, (sum, item) => sum + item.quantity);
}
}
```
**Run tests → Still GREEN! ✅**
**Teaching moment:** "This is TDD at work:
1. The tests gave us confidence to refactor
2. The failing test (removeItem) revealed the better design
3. Map makes the code simpler AND more efficient
4. We discovered this through tests, not upfront planning"
**Next test (line 87):**
```dart
test('removing a non-existent item does nothing', () {
cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 2));
cart.removeItem('Orange'); // not in cart
expect(cart.subtotal(), equals(3.00));
});
```
**This already passes!** Map.remove() silently handles missing keys.
**Teaching moment:** "Our refactoring gave us this behavior for free. Good design often does."
---
### STEP 4: Discount Calculation
**Test to uncomment (line 109):**
```dart
test('10% discount reduces subtotal correctly', () {
cart.addItem(CartItem(name: 'Apple', price: 10.00, quantity: 5)); // 50.00
expect(cart.totalAfterDiscount(10), equals(45.00));
});
```
**Expected failure:**
- "The method 'totalAfterDiscount' isn't defined"
**Minimum code to make it GREEN:**
```dart
double totalAfterDiscount(double discountPercent) {
final discount = discountPercent / 100;
return subtotal() * (1 - discount);
}
```
**Next tests (lines 115, 121, 127):**
- "0% discount returns full subtotal" → passes!
- "100% discount results in zero" → passes!
- "discount on empty cart returns zero" → passes!
**Teaching moment:** "Notice how we got edge cases for free by implementing the general formula correctly."
---
### STEP 5: Value Object Validation (The Design Pattern)
**Test to uncomment (line 139):**
```dart
test('rejects an item with a zero price', () {
expect(
() => CartItem(name: 'Free thing', price: 0, quantity: 1),
throwsArgumentError,
);
});
```
**First attempt (common mistake):**
Many participants will add validation to `addItem()`:
```dart
void addItem(CartItem item) {
if (item.price <= 0) {
throw ArgumentError('Price must be greater than zero');
}
_items[item.name] = item;
}
```
**This makes the test pass, BUT...**
**Ask the participant:** "What if CartItem is created somewhere else in the code? Can it exist with a zero price?"
**The answer:** Yes! And that's a problem.
**Better approach - move validation to CartItem constructor:**
```dart
class CartItem {
final String name;
final double price;
final int quantity;
CartItem({required this.name, required this.price, required this.quantity}) {
if (price <= 0) {
throw ArgumentError('Price must be greater than zero, got: $price');
}
if (quantity <= 0) {
throw ArgumentError('Quantity must be greater than zero, got: $quantity');
}
}
}
```
**Now remove validation from addItem():**
```dart
void addItem(CartItem item) {
_items[item.name] = item; // Simple again!
}
```
**Run tests → All GREEN! ✅**
**Teaching moment:** "This is the Value Object pattern:
- **Immutable data** (final fields)
- **Validation at construction** (in constructor)
- **Invalid state is impossible** (can't create bad CartItem)
- **No need to revalidate** (if it exists, it's valid)
Where does validation belong? As close to the data as possible. Not in the cart, but in the item itself."
---
### BONUS: Edge Cases (If Time Permits)
**Duplicate names test (line 168):**
This test is commented with a design choice - participants must decide:
**Option A: Replace** (current implementation)
```dart
cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 2));
cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 5));
expect(cart.itemCount(), equals(5)); // replaced, not added
```
Using `_items[item.name] = item` naturally replaces duplicates.
**Option B: Accumulate**
```dart
cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 2));
cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 5));
expect(cart.itemCount(), equals(7)); // quantities stacked
```
Requires changing `addItem()`:
```dart
void addItem(CartItem item) {
if (_items.containsKey(item.name)) {
final existing = _items[item.name]!;
_items[item.name] = CartItem(
name: item.name,
price: item.price,
quantity: existing.quantity + item.quantity,
);
} else {
_items[item.name] = item;
}
}
```
**Teaching moment:** "Both are valid! TDD doesn't prescribe the design—you do. The test just documents your decision."
**Float precision test (line 181):**
```dart
test('subtotal handles floating point prices accurately', () {
cart.addItem(CartItem(name: 'Item', price: 0.1, quantity: 3));
expect(cart.subtotal(), closeTo(0.30, 0.001)); // use closeTo!
});
```
This demonstrates floating-point arithmetic issues (0.1 + 0.1 + 0.1 ≠ 0.3 exactly).
**Teaching moment:** "Always use `closeTo()` for float comparisons in tests. This is a fundamental programming lesson!"
---
## Common Participant Questions
### "Why use a Map instead of a List?"
**Answer:** The `removeItem(name)` test revealed this. With a List, you have to search by name every time (O(n)). With a Map, removal is O(1). The test drove us toward the better data structure.
### "Should I validate in CartItem or in ShoppingCart?"
**Answer:** CartItem. Ask yourself: "Can an invalid CartItem exist?" If yes, you can't trust it anywhere in your code. Value Objects enforce validity at creation time.
### "What if I want to accumulate quantities for duplicate names?"
**Answer:** That's a valid design choice! Write a test that documents that behavior, then implement it. TDD lets you decide—just make the decision explicit through tests.
### "Can I add a method to list all items?"
**Answer:** Do you have a failing test for it? If not, you're writing production code without a test (YAGNI - You Aren't Gonna Need It). Write the test first!
---
## Debrief Discussion Points (5-10 min after kata)
Ask participants:
1. **"When did you realize List wasn't the right choice?"**
- Most realize during Step 3 (removeItem)
- Some continue with List and never refactor (ask them why!)
2. **"What made you switch from List to Map?"**
- Listen for: "removeItem by name," "felt inefficient," "Map is cleaner"
- Reinforce: The test drove the design decision
3. **"Where did you put the validation initially?"**
- Many start in `addItem()`, then move to `CartItem`
- Ask: "What changed your mind?"
4. **"What's the benefit of validating in the constructor?"**
- Invalid state is impossible
- No need to check validity elsewhere
- Confidence that any CartItem is valid
5. **"How did the tests help during refactoring?"**
- Confidence to change from List to Map
- Proof that behavior didn't break
- Freedom to experiment
6. **"What design patterns emerged?"**
- Value Object (CartItem validation)
- Collection wrapper (ShoppingCart wrapping Map)
- Functional operations (fold, map, values)
---
## Timing Guidance
**For 45-minute practice session:**
- 0-5 min: Participants read README, set up files
- 5-15 min: Steps 1-2 (Empty cart, Adding items)
- 15-25 min: Step 3 (Removing items, List → Map refactor)
- 25-30 min: Step 4 (Discount calculation)
- 30-40 min: Step 5 (Value Object validation)
- 40-45 min: Bonus edge cases (if time)
**If participants finish early:**
- Direct them to bonus tests
- Challenge: "Add a `clear()` method using TDD"
- Challenge: "Add a `getItems()` method that returns a read-only view"
---
## Troubleshooting
### Participant stuck on first test
- Check: Did they create `subtotal()` method?
- Hint: "What's the simplest value you can return?"
### Still using List for removeItem
- Ask: "How does removeWhere work internally?"
- Ask: "What if you had 10,000 items?"
- Hint: "What data structure is optimized for name lookups?"
### Validation in addItem instead of CartItem
- Ask: "Can a CartItem be created outside of addItem?"
- Ask: "If yes, can it have a negative price?"
- Hint: "Where can you guarantee the item is ALWAYS valid?"
### Tests not running
- Check: Did they run `dart pub get`?
- Check: Are they in the `shopping_cart` directory?
- Check: Did they uncomment the test code?
### Float precision issues
- Show: `0.1 + 0.1 + 0.1` in Dart REPL
- Explain: Floating point representation
- Solution: Use `closeTo(expected, delta)` in tests
---
## Summary
This kata demonstrates:
- ✅ Tests driving design decisions (List → Map)
- ✅ Value Object pattern emerging from validation needs
- ✅ Safe refactoring with test coverage
- ✅ Design evolution through incremental testing
- ✅ The power of "make it work, then make it right"
**Key facilitator role:**
- Encourage the List → Map refactoring (but don't mandate it)
- Guide participants toward constructor validation
- Let them discover design patterns through pain points
- Celebrate when they refactor successfully!
The best learning happens when participants feel the design tension, then resolve it through refactoring.

62
shopping_cart/README.md Normal file
View file

@ -0,0 +1,62 @@
# Feature B — Shopping Cart
## Your goal
Implement `ShoppingCart` using strict TDD. **Do not write any production code before you have a failing test.**
## Domain rules to implement (in order)
| Step | Feature | Notes |
|------|---------|-------|
| 1 | Empty cart has subtotal 0 and count 0 | The baseline |
| 2 | Add items — subtotal = price × quantity | Multiple items accumulate |
| 3 | Remove item by name | Removing unknown name does nothing |
| 4 | Apply % discount to subtotal | 10% off $50 → $45 |
| 5 | CartItem rejects zero/negative price & quantity | Value Object validation |
| 6 | Bonus: duplicate name / float precision | Design decision — your call |
## The rhythm — repeat for every rule
```
1. Uncomment the next test → run → watch it FAIL (Red)
2. Write the minimum code to make it pass → run → GREEN
3. Refactor if needed → run → still GREEN
4. Move to the next test
```
## Running the tests
```bash
dart pub get
dart test
```
## Files
- `lib/shopping_cart.dart` — your implementation goes here
- `test/shopping_cart_test.dart` — uncomment tests one at a time
## Design hints (don't peek until you're stuck!)
<details>
<summary>Hint for Step 3 — Remove by name</summary>
When you need to remove by name, a `List<CartItem>` forces you to search by name.
A `Map<String, CartItem>` makes lookup O(1) and removal trivial.
Let the test drive you toward the right data structure.
</details>
<details>
<summary>Hint for Step 5 — Value Object</summary>
Validation belongs in `CartItem`'s constructor, not in `ShoppingCart.addItem()`.
If the item can't be created, the cart never sees it.
This is the Value Object pattern: invalid state is unrepresentable.
</details>
<details>
<summary>Hint for the float precision bonus test</summary>
Use `closeTo(expected, delta)` instead of `equals()` for floating point comparisons.
Example: `expect(result, closeTo(0.30, 0.001))`
</details>

View file

@ -0,0 +1,46 @@
// TODO: Implement ShoppingCart using TDD.
//
// Domain rules (implement one at a time, test first!):
// 1. Add an item (name, price, quantity) to the cart
// 2. Get the subtotal (sum of price × quantity for all items)
// 3. Remove an item by name
// 4. Get total item count (sum of all quantities)
// 5. Apply a percentage discount to the subtotal (e.g. 10% off)
// 6. Items with zero or negative price are rejected
//
// CartItem is a Value Object immutable, validated at construction.
// ShoppingCart is stateful it accumulates items across calls.
class CartItem {
final String name;
final double price;
final int quantity;
CartItem({required this.name, required this.price, required this.quantity}) {
// TODO: validate price > 0 and quantity > 0
}
}
class ShoppingCart {
// TODO: implement
void addItem(CartItem item) {
throw UnimplementedError();
}
void removeItem(String name) {
throw UnimplementedError();
}
double subtotal() {
throw UnimplementedError();
}
int itemCount() {
throw UnimplementedError();
}
double totalAfterDiscount(double discountPercent) {
throw UnimplementedError();
}
}

View file

@ -0,0 +1,144 @@
// 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!

View file

@ -0,0 +1,8 @@
name: shopping_cart
description: TDD Workshop - Feature B
environment:
sdk: ^3.0.0
dev_dependencies:
test: ^1.25.0

View file

@ -0,0 +1,187 @@
import 'package:test/test.dart';
import '../lib/shopping_cart.dart';
// ============================================================
// SHOPPING CART Workshop Starter
// Follow Red Green Refactor strictly.
// Uncomment one test at a time. Make it pass. Then the next.
// ============================================================
void main() {
late ShoppingCart cart;
setUp(() {
cart = ShoppingCart();
});
//
// STEP 1 Empty cart
// Before adding anything, the cart should be empty.
// Trivial, but sets the baseline. Always start here.
//
group('Empty cart', () {
test('subtotal is zero when cart is empty', () {
// TODO: uncomment when ready
// expect(cart.subtotal(), equals(0.0));
});
test('item count is zero when cart is empty', () {
// TODO: uncomment when ready
// expect(cart.itemCount(), equals(0));
});
});
//
// STEP 2 Adding items
// A single item: subtotal = price × quantity.
//
group('Adding items', () {
test('subtotal reflects a single item added', () {
// TODO: uncomment when ready
// cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 3));
// expect(cart.subtotal(), equals(4.50));
});
test('item count reflects quantity of a single item', () {
// TODO: uncomment when ready
// cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 3));
// expect(cart.itemCount(), equals(3));
});
test('subtotal accumulates across multiple different items', () {
// TODO: uncomment when ready
// cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 2)); // 3.00
// cart.addItem(CartItem(name: 'Banana', price: 0.75, quantity: 4)); // 3.00
// expect(cart.subtotal(), equals(6.00));
});
test('item count sums quantities across multiple items', () {
// TODO: uncomment when ready
// cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 2));
// cart.addItem(CartItem(name: 'Banana', price: 0.75, quantity: 4));
// expect(cart.itemCount(), equals(6));
});
});
//
// STEP 3 Removing items
// Removing an item by name should drop it from the subtotal.
//
group('Removing items', () {
test('removing an item reduces the subtotal', () {
// TODO: uncomment when ready
// cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 2));
// cart.addItem(CartItem(name: 'Banana', price: 0.75, quantity: 4));
// cart.removeItem('Apple');
// expect(cart.subtotal(), equals(3.00));
});
test('removing an item reduces the item count', () {
// TODO: uncomment when ready
// cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 2));
// cart.addItem(CartItem(name: 'Banana', price: 0.75, quantity: 4));
// cart.removeItem('Apple');
// expect(cart.itemCount(), equals(4));
});
test('removing a non-existent item does nothing', () {
// TODO: uncomment when ready
// cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 2));
// cart.removeItem('Orange'); // not in cart
// expect(cart.subtotal(), equals(3.00));
});
test('cart is empty after removing the only item', () {
// TODO: uncomment when ready
// cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 2));
// cart.removeItem('Apple');
// expect(cart.subtotal(), equals(0.0));
// expect(cart.itemCount(), equals(0));
});
});
//
// STEP 4 Discount
// A percentage discount reduces the subtotal proportionally.
// Think: what is 10% off $50.00? $45.00
//
group('Applying a percentage discount', () {
test('10% discount reduces subtotal correctly', () {
// TODO: uncomment when ready
// cart.addItem(CartItem(name: 'Apple', price: 10.00, quantity: 5)); // 50.00
// expect(cart.totalAfterDiscount(10), equals(45.00));
});
test('0% discount returns the full subtotal', () {
// TODO: uncomment when ready
// cart.addItem(CartItem(name: 'Apple', price: 10.00, quantity: 5));
// expect(cart.totalAfterDiscount(0), equals(50.00));
});
test('100% discount results in zero total', () {
// TODO: uncomment when ready
// cart.addItem(CartItem(name: 'Apple', price: 10.00, quantity: 5));
// expect(cart.totalAfterDiscount(100), equals(0.0));
});
test('discount on empty cart returns zero', () {
// TODO: uncomment when ready
// expect(cart.totalAfterDiscount(20), equals(0.0));
});
});
//
// STEP 5 CartItem validation (Value Object)
// Invalid items should be rejected at construction time.
// This is the "Value Object" pattern: impossible to create bad state.
//
group('CartItem validation', () {
test('rejects an item with a zero price', () {
// TODO: uncomment when ready
// expect(
// () => CartItem(name: 'Free thing', price: 0, quantity: 1),
// throwsArgumentError,
// );
});
test('rejects an item with a negative price', () {
// TODO: uncomment when ready
// expect(
// () => CartItem(name: 'Broken item', price: -5.00, quantity: 1),
// throwsArgumentError,
// );
});
test('rejects an item with zero quantity', () {
// TODO: uncomment when ready
// expect(
// () => CartItem(name: 'Apple', price: 1.50, quantity: 0),
// throwsArgumentError,
// );
});
});
//
// BONUS Edge cases (if you finish early)
//
group('Edge cases', () {
test('adding the same item name twice replaces it (or accumulates — you decide!)', () {
// TODO: uncomment and decide the behavior, then implement it
// Hint: this is a design decision. TDD lets the test define the rule.
//
// Option A replace:
// cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 2));
// cart.addItem(CartItem(name: 'Apple', price: 1.50, quantity: 5));
// expect(cart.itemCount(), equals(5)); // replaced
//
// Option B accumulate:
// expect(cart.itemCount(), equals(7)); // stacked
});
test('subtotal handles floating point prices accurately', () {
// TODO: uncomment when ready
// cart.addItem(CartItem(name: 'Item', price: 0.1, quantity: 3)); // 0.1 + 0.1 + 0.1
// expect(cart.subtotal(), closeTo(0.30, 0.001)); // use closeTo for floats!
});
});
}