Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ffe0eec
Introduce the kata and document steps
vfarah-if May 3, 2025
6c8a63d
Iteration1: Create failing test
vfarah-if May 3, 2025
c11d67b
Iteration1: Make test pass for a single product
vfarah-if May 3, 2025
403e5c0
Iteration1: Make test pass per unit two products
vfarah-if May 3, 2025
c5a9f0d
Iteration1: Make test pass per unit for the rest
vfarah-if May 3, 2025
3f925c0
Iteration1: Failing test for the final price
vfarah-if May 3, 2025
f8b7522
Iteration1: Passing test for the final price with normal tax
vfarah-if May 3, 2025
c8a6f25
Iteration1: Passing test for the final price with first necessity tax
vfarah-if May 3, 2025
880e6ba
Iteration1: Full coverage
vfarah-if May 3, 2025
18b4da6
Iteration2: Create failing test for cart item
vfarah-if May 4, 2025
ebce475
Iteration2: Make test pass for single item
vfarah-if May 4, 2025
3046c36
Iteration2: Make test pass for multiple items
vfarah-if May 4, 2025
113132c
Iteration2: Minor refactor - Duplication removal
vfarah-if May 4, 2025
8ca7cbf
Iteration3: Failing test for creating a discount
vfarah-if May 4, 2025
6f2d4af
Iteration3: Make simple promo pass
vfarah-if May 4, 2025
d2edc15
Iteration3: Failing test for unknown promocode
vfarah-if May 4, 2025
f464827
Iteration3: Fix null discount for unknown discount code
vfarah-if May 4, 2025
294ab32
Iteration3: Failing test for applying a 10% discount
vfarah-if May 4, 2025
7579383
Iteration3: Fix the method to apply discounts rounding up
vfarah-if May 4, 2025
e1c72da
Iteration4: Creted failing test for adding items to the shopping cart
vfarah-if May 6, 2025
53176b5
Iteration4: Make test pass with the header, line items
vfarah-if May 6, 2025
6970df7
Iteration5: Add promotion fail test scenarios
vfarah-if May 6, 2025
7132291
Iteration5: Make tests pass
vfarah-if May 6, 2025
cfb23b4
Iteration5: Minor refactor to make sure printing order and simplicity…
vfarah-if May 6, 2025
b4d9d0d
Iteration6: Creat failing test for scenario
vfarah-if May 6, 2025
42275c2
Iteration6: Make test pass
vfarah-if May 6, 2025
ed72983
Iteration6: Refactored final passing tests with full story and skippe…
vfarah-if May 6, 2025
8f87fbc
Iteration6: Refactor magic strings and printer patterns
vfarah-if May 6, 2025
3132415
Add some future tasks to extend this and fix some typos
vfarah-if May 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
211 changes: 211 additions & 0 deletions test-pnpm/shopping-cart-kata/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
# What do we want to build?

We are building a shopping cart for an online grocery shop. The idea of this kata is to build the product in an iterative way.

### Technical requirements

- The price per unit is calculated based on the product cost and the percentage of revenue that the company wants for that product.
- The price has to be rounded up; so if a price per unit calculated is 1.7825, then the expected price per unit for that product is 1.79
- The final price of the product is then calculated as the **price per unit with the VAT rounded up**.
- Products are not allowed to have the same name.

### List of products

| **Name** | **Cost** | **% Revenue** | **Price per unit** | **Tax** | **Final price** |
| -------------- | -------- | ------------- | ------------------ | ----------------------- | --------------- |
| **Iceberg 🥬** | _1.55 €_ | _15 %_ | 1,79 € | _Normal (21%)_ | 2.17 € |
| **Tomato 🍅** | _0.52 €_ | _15 %_ | 0.60 € | _Normal (21%)_ | 0.73 € |
| **Chicken 🍗** | _1.34 €_ | _12 %_ | 1.51 € | _Normal (21%)_ | 1.83 € |
| **Bread 🍞** | _0.71 €_ | _12 %_ | 0.80 € | _First necessity (10%)_ | 0.89 € |
| **Corn 🌽** | _1.21 €_ | _12 %_ | 1.36 € | _First necessity (10%)_ | 1.50 € |

### List of discounts

| **Discounts code** | **Amount** |
| :----------------: | ---------- |
| **PROMO_5** | 5% |
| **PROMO_10** | 10% |

### Use cases

#### List the shopping cart

> ```
> As a customer
> I want to see my shopping cart
> ```

#### **Empty cart**

```
--------------------------------------------
| Product name | Price with VAT | Quantity |
| ----------- | -------------- | -------- |
|------------------------------------------|
| Promotion: |
--------------------------------------------
| Total products: 0 |
| Total price: 0.00 € |
--------------------------------------------
```

#### Add product to shopping cart

> ```
> As a customer
> I want to add Iceberg 🥬 to my shopping cart
> I want to add Tomato 🍅 to my shopping cart
> I want to add Chicken 🍗 to my shopping cart
> I want to add Bread 🍞 to my shopping cart
> I want to add Corn 🌽 to my shopping cart
> I want to see my shopping cart
> ```

```
--------------------------------------------
| Product name | Price with VAT | Quantity |
| ----------- | -------------- | -------- |
| Iceberg 🥬 | 2.17 € | 1 |
| Tomato 🍅 | 0.73 € | 1 |
| Chicken 🍗 | 1.83 € | 1 |
| Bread 🍞 | 0.88 € | 1 |
| Corn 🌽 | 1.50 € | 1 |
|------------------------------------------|
| Promotion: |
--------------------------------------------
| Total products: 5 |
| Total price: 7.11 € |
--------------------------------------------
```

#### Add product to shopping cart

> ```
> As a customer
> I want to add Iceberg 🥬 to my shopping cart
> I want to add Iceberg 🥬 to my shopping cart
> I want to add Iceberg 🥬 to my shopping cart
> I want to add Tomato 🍅 to my shopping cart
> I want to add Chicken 🍗 to my shopping cart
> I want to add Bread 🍞 to my shopping cart
> I want to add Bread 🍞 to my shopping cart
> I want to add Corn 🌽 to my shopping cart
> I want to see my shopping cart
> ```

```
--------------------------------------------
| Product name | Price with VAT | Quantity |
| ----------- | -------------- | -------- |
| Iceberg 🥬 | 2.17 € | 3 |
| Tomato 🍅 | 0.73 € | 1 |
| Chicken 🍗 | 1.83 € | 1 |
| Bread 🍞 | 0.88 € | 2 |
| Corn 🌽 | 1.50 € | 1 |
|------------------------------------------|
| Promotion: |
--------------------------------------------
| Total products: 8 |
| Total price: 12.33 € |
--------------------------------------------
```

#### Apply discount to the shopping cart

> ```
> As a customer
> I want to add Iceberg 🥬 to my shopping cart
> I want to add Iceberg 🥬 to my shopping cart
> I want to add Iceberg 🥬 to my shopping cart
> I want to add Tomato 🍅 to my shopping cart
> I want to add Chicken 🍗 to my shopping cart
> I want to add Bread 🍞 to my shopping cart
> I want to add Bread 🍞 to my shopping cart
> I want to add Corn 🌽 to my shopping cart
> I want to apply my coupon code PROMO_5
> I want to see my shopping cart
> ```

```
--------------------------------------------
| Product name | Price with VAT | Quantity |
| ----------- | -------------- | -------- |
| Iceberg 🥬 | 2.17 € | 3 |
| Tomato 🍅 | 0.73 € | 1 |
| Chicken 🍗 | 1.83 € | 1 |
| Bread 🍞 | 0.88 € | 2 |
| Corn 🌽 | 1.50 € | 1 |
|------------------------------------------|
| Promotion: 5% off with code PROMO_5 |
--------------------------------------------
| Total products: 8 |
| Total price: 11.71 € |
--------------------------------------------
```

### Possible API for the ShoppingCart

**Approach 1 passing objects as arguments could be DTO**

```javascript
export interface ShoppingCart {
addItem(cartItem: CartItem): void;
applyDiscount(discount: Discount): void;
printShoppingCart(): void;
// TODO
removeItem(name: string): void;
updateItemQuantity(name: string, quantity: number): void;
getItems(): ReadonlyArray<CartItem>;
}
```

### Summary of steps

**Inside-Out (classicist)** vs **Outside-In (London School / mockist)** has significant implications for how the system grows. Since I am aiming for **small, logical, test-driven steps**, I will start inside out.

##### Suggested Inside-Out Iterations (with TDD focus)

| Iteration | Focus | Why Start Here? |
| --------- | ---------------------------------------- | ------------------------------------------------------------ |
| 1 | `Product` - price per unit and VAT logic | Small, atomic, pure — perfect TDD start |
| 2 | `CartItem` - quantity & total price | Introduces aggregation, still isolated logic |
| 3 | `Discount` - percentage logic | Stateless, predictable, complements pricing |
| 4 | `ShoppingCart` - add items | Start using composition of domain elements |
| 5 | `ShoppingCart` - apply discount | Introduces first mutation to cart state |
| 6 | `ShoppingCart` - total price & print | Drives end-to-end expectation-based testing |
| 7 | `ProductCatalogue` or Registry | Encapsulate product lookup and validation |
| 8 | `ShoppingCart` - delete items | TODO - Drive removing Items from the cart |
| 9 | `ShoppingCart` - updateItemQuantity | TODO - Drive changing the quantity |
| 10 | `ShoppingCart` - getItems | TODO - Drive getting the items to be more inline with a cart |

### Evolution Flow Summary

#### Phase 1: Pure Domain Logic

- `Product` — validate markup/VAT/rounding rules
- `CartItem` — quantity x price logic
- `Discount` — test discount % and edge cases (e.g. invalid codes)

#### Phase 2: Composition

- `ShoppingCart` — manages cart items and uses the above types
- Discounts applied as decorators or cart state

#### Phase 3: Orchestration + I/O-style Outputs

- Implement `ShoppingCart.getShoppingCart()` as DTO for rendering
- `print()` or `toString()` as test-driving format rendering
- Possibly inject or access product catalogue here

### When to Consider _Outside-In_

You could start Outside-In **only** if:

- You’re driving everything from the end goal (like rendering the full cart view)
- You're willing to mock deeper domain types (`Product`, `CartItem`, etc.)

But in this kata, that’s harder to justify because the logic in the "leaf" types is richer and more test-worthy than in the orchestration layer.

### Summary

Thanks Emmanuael Valverde, for your cool take on this [kata](https://www.codurance.com/katas/shopping-cart-kata). There were a few sneaky catches with the pricing. I have done this kata before using simple types and I found myself producing more code this time. I also did outside in, before, mocking and faking bits until I had the final design. I think breaking this up into the seperate phases made it easier to get my head around each concept, so I am glad I did it this way. The thing didn't like is it forced me into a solution by virtue of what I simplified. It may be a good or bad thing, no idea.
14 changes: 14 additions & 0 deletions test-pnpm/shopping-cart-kata/cartItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Product } from './product';
import { roundUp } from './utility';

export class CartItem {
constructor(
public product: Product,
public quantity: number,
) {}

get LineTotal(): number {
const price = this.product.finalPrice * this.quantity;
return roundUp(price);
}
}
19 changes: 19 additions & 0 deletions test-pnpm/shopping-cart-kata/cartitem.should.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { CartItem } from './cartItem';
import { Product } from './product';
import { TaxRate } from './taxRates';

describe('Adding Cart items should', () => {
let product = new Product('Iceberg 🥬', 1.55, 0.15, TaxRate.Normal);

test('calcultate the line total for one item', () => {
const cartItem = new CartItem(product, 1);

expect(cartItem.LineTotal).toBe(2.17);
});

test('calcultate the line total for multiple items', () => {
const cartItem = new CartItem(product, 3);

expect(cartItem.LineTotal).toBe(6.51);
});
});
15 changes: 15 additions & 0 deletions test-pnpm/shopping-cart-kata/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@

export const TABLE_CONSTANTS = {
LINE_WIDTH: 44,
COLUMN_WIDTHS: {
NAME: 12,
PRICE: 14,
QUANTITY: 8,
},
PADDING_WIDTH: 40,
CURRENCY: '€',
SEPARATORS: {
HORIZONTAL: '-',
VERTICAL: '|',
},
};
21 changes: 21 additions & 0 deletions test-pnpm/shopping-cart-kata/discount.should.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Discount } from './discount';

describe('Discount should', () => {
it('be created from a valid discount code', () => {
const discount = Discount.fromCode('PROMO_5');

expect(discount.code).toBe('PROMO_5');
expect(discount.percentage).toBe(0.05);
});

it('return null for an unknown code', () => {
const discount = Discount.fromCode('INVALID');

expect(discount).toBeNull();
});

it('calculate a discount from a price', () => {
const discount = Discount.fromCode('PROMO_10')!;
expect(discount.applyTo(100)).toBe(90);
});
});
26 changes: 26 additions & 0 deletions test-pnpm/shopping-cart-kata/discount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { roundUp } from './utility';

export class Discount {
readonly code: string;
readonly percentage: number;

private constructor(code: string, percentage: number) {
this.code = code;
this.percentage = percentage;
}

public applyTo(price: number): number {
const discountAmount = price * this.percentage;
const discountedPrice = price - discountAmount;
return roundUp(discountedPrice);
}

static fromCode(code: string): Discount | null {
const map: Record<string, number> = {
PROMO_5: 0.05,
PROMO_10: 0.1,
};
const value = map[code];
return value ? new Discount(code, value) : null;
}
}
25 changes: 25 additions & 0 deletions test-pnpm/shopping-cart-kata/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export interface Logger {
log(message: string): void;
print(): string;
clear(): void;
}

export class InMemoryLogger implements Logger {
private messages: string[];

constructor() {
this.clear();
}

clear(): void {
this.messages = [];
}

log(message: string): void {
this.messages.push(message);
}

print(): string {
return this.messages.join('\n');
}
}
Loading