Initial commit: TDD Workshop exercises for participants

- Password Validator kata with starter code and tests
- Shopping Cart kata with starter code and tests
- FizzBuzz reference code (from live demo)
- Setup guide and TDD reference card
- No solutions included (participants implement themselves)
This commit is contained in:
fiatcode 2026-03-10 15:37:58 +07:00
commit 3d94c96ed2
13 changed files with 1469 additions and 0 deletions

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,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!
});
});
}